import { createLRUCache } from './lru-cache' import { arraysEqual, functionalUpdate } from './utils' import type { AnyRoute } from './route' import type { RouterState } from './router' import type { FullSearchSchema } from './routeInfo' import type { ParsedLocation } from './location' import type { AnyRedirect } from './redirect' import type { AnyRouteMatch } from './Matches' export interface RouterReadableStore { get: () => TValue } export interface RouterWritableStore< TValue, > extends RouterReadableStore { set: ((updater: (prev: TValue) => TValue) => void) & ((value: TValue) => void) } export type RouterBatchFn = (fn: () => void) => void export type MutableStoreFactory = ( initialValue: TValue, ) => RouterWritableStore export type ReadonlyStoreFactory = ( read: () => TValue, ) => RouterReadableStore export type GetStoreConfig = (opts: { isServer?: boolean }) => StoreConfig export type StoreConfig = { createMutableStore: MutableStoreFactory createReadonlyStore: ReadonlyStoreFactory batch: RouterBatchFn init?: (stores: RouterStores) => void } type MatchStore = RouterWritableStore & { routeId?: string } type ReadableStore = RouterReadableStore /** SSR non-reactive createMutableStore */ export function createNonReactiveMutableStore( initialValue: TValue, ): RouterWritableStore { let value = initialValue return { get() { return value }, set(nextOrUpdater: TValue | ((prev: TValue) => TValue)) { value = functionalUpdate(nextOrUpdater, value) }, } } /** SSR non-reactive createReadonlyStore */ export function createNonReactiveReadonlyStore( read: () => TValue, ): RouterReadableStore { return { get() { return read() }, } } export interface RouterStores { status: RouterWritableStore['status']> loadedAt: RouterWritableStore isLoading: RouterWritableStore isTransitioning: RouterWritableStore location: RouterWritableStore>> resolvedLocation: RouterWritableStore< ParsedLocation> | undefined > statusCode: RouterWritableStore redirect: RouterWritableStore matchesId: RouterWritableStore> pendingIds: RouterWritableStore> /** @internal */ cachedIds: RouterWritableStore> matches: ReadableStore> pendingMatches: ReadableStore> cachedMatches: ReadableStore> firstId: ReadableStore hasPending: ReadableStore matchRouteDeps: ReadableStore<{ locationHref: string resolvedLocationHref: string | undefined status: RouterState['status'] }> __store: RouterReadableStore> matchStores: Map pendingMatchStores: Map cachedMatchStores: Map /** * Get a computed store that resolves a routeId to its current match state. * Returns the same cached store instance for repeated calls with the same key. * The computed depends on matchesId + the individual match store, so * subscribers are only notified when the resolved match state changes. */ getRouteMatchStore: ( routeId: string, ) => RouterReadableStore setMatches: (nextMatches: Array) => void setPending: (nextMatches: Array) => void setCached: (nextMatches: Array) => void } export function createRouterStores( initialState: RouterState, config: StoreConfig, ): RouterStores { const { createMutableStore, createReadonlyStore, batch, init } = config // non reactive utilities const matchStores = new Map() const pendingMatchStores = new Map() const cachedMatchStores = new Map() // atoms const status = createMutableStore(initialState.status) const loadedAt = createMutableStore(initialState.loadedAt) const isLoading = createMutableStore(initialState.isLoading) const isTransitioning = createMutableStore(initialState.isTransitioning) const location = createMutableStore(initialState.location) const resolvedLocation = createMutableStore(initialState.resolvedLocation) const statusCode = createMutableStore(initialState.statusCode) const redirect = createMutableStore(initialState.redirect) const matchesId = createMutableStore>([]) const pendingIds = createMutableStore>([]) const cachedIds = createMutableStore>([]) // 1st order derived stores const matches = createReadonlyStore(() => readPoolMatches(matchStores, matchesId.get()), ) const pendingMatches = createReadonlyStore(() => readPoolMatches(pendingMatchStores, pendingIds.get()), ) const cachedMatches = createReadonlyStore(() => readPoolMatches(cachedMatchStores, cachedIds.get()), ) const firstId = createReadonlyStore(() => matchesId.get()[0]) const hasPending = createReadonlyStore(() => matchesId.get().some((matchId) => { const store = matchStores.get(matchId) return store?.get().status === 'pending' }), ) const matchRouteDeps = createReadonlyStore(() => ({ locationHref: location.get().href, resolvedLocationHref: resolvedLocation.get()?.href, status: status.get(), })) // compatibility "big" state store const __store = createReadonlyStore(() => ({ status: status.get(), loadedAt: loadedAt.get(), isLoading: isLoading.get(), isTransitioning: isTransitioning.get(), matches: matches.get(), location: location.get(), resolvedLocation: resolvedLocation.get(), statusCode: statusCode.get(), redirect: redirect.get(), })) // Per-routeId computed store cache. // Each entry resolves routeId → match state through the signal graph, // giving consumers a single store to subscribe to instead of the // two-level byRouteId → matchStore pattern. // // 64 max size is arbitrary, this is only for active matches anyway so // it should be plenty. And we already have a 32 limit due to route // matching bitmask anyway. const matchStoreByRouteIdCache = createLRUCache< string, RouterReadableStore >(64) function getRouteMatchStore( routeId: string, ): RouterReadableStore { let cached = matchStoreByRouteIdCache.get(routeId) if (!cached) { cached = createReadonlyStore(() => { // Reading matchesId.get() tracks it as a dependency. // When matchesId changes (navigation), this computed re-evaluates. const ids = matchesId.get() for (const id of ids) { const matchStore = matchStores.get(id) if (matchStore && matchStore.routeId === routeId) { // Reading matchStore.get() tracks it as a dependency. // When the match store's state changes, this re-evaluates. return matchStore.get() } } return undefined }) matchStoreByRouteIdCache.set(routeId, cached) } return cached } const store = { // atoms status, loadedAt, isLoading, isTransitioning, location, resolvedLocation, statusCode, redirect, matchesId, pendingIds, cachedIds, // derived matches, pendingMatches, cachedMatches, firstId, hasPending, matchRouteDeps, // non-reactive state matchStores, pendingMatchStores, cachedMatchStores, // compatibility "big" state __store, // per-key computed stores getRouteMatchStore, // methods setMatches, setPending, setCached, } // initialize the active matches setMatches(initialState.matches as Array) init?.(store) // setters to update non-reactive utilities in sync with the reactive stores function setMatches(nextMatches: Array) { reconcileMatchPool( nextMatches, matchStores, matchesId, createMutableStore, batch, ) } function setPending(nextMatches: Array) { reconcileMatchPool( nextMatches, pendingMatchStores, pendingIds, createMutableStore, batch, ) } function setCached(nextMatches: Array) { reconcileMatchPool( nextMatches, cachedMatchStores, cachedIds, createMutableStore, batch, ) } return store } function readPoolMatches( pool: Map, ids: Array, ): Array { const matches: Array = [] for (const id of ids) { const matchStore = pool.get(id) if (matchStore) { matches.push(matchStore.get()) } } return matches } function reconcileMatchPool( nextMatches: Array, pool: Map, idStore: RouterWritableStore>, createMutableStore: MutableStoreFactory, batch: RouterBatchFn, ): void { const nextIds = nextMatches.map((d) => d.id) const nextIdSet = new Set(nextIds) batch(() => { for (const id of pool.keys()) { if (!nextIdSet.has(id)) { pool.delete(id) } } for (const nextMatch of nextMatches) { const existing = pool.get(nextMatch.id) if (!existing) { const matchStore = createMutableStore(nextMatch) as MatchStore matchStore.routeId = nextMatch.routeId pool.set(nextMatch.id, matchStore) continue } existing.routeId = nextMatch.routeId if (existing.get() !== nextMatch) { existing.set(nextMatch) } } if (!arraysEqual(idStore.get(), nextIds)) { idStore.set(nextIds) } }) }