UNPKG

@tanstack/router-core

Version:

Modern and scalable routing for React applications

343 lines (302 loc) 10.1 kB
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<TValue> { get: () => TValue } export interface RouterWritableStore< TValue, > extends RouterReadableStore<TValue> { set: ((updater: (prev: TValue) => TValue) => void) & ((value: TValue) => void) } export type RouterBatchFn = (fn: () => void) => void export type MutableStoreFactory = <TValue>( initialValue: TValue, ) => RouterWritableStore<TValue> export type ReadonlyStoreFactory = <TValue>( read: () => TValue, ) => RouterReadableStore<TValue> export type GetStoreConfig = (opts: { isServer?: boolean }) => StoreConfig export type StoreConfig = { createMutableStore: MutableStoreFactory createReadonlyStore: ReadonlyStoreFactory batch: RouterBatchFn init?: (stores: RouterStores<AnyRoute>) => void } type MatchStore = RouterWritableStore<AnyRouteMatch> & { routeId?: string } type ReadableStore<TValue> = RouterReadableStore<TValue> /** SSR non-reactive createMutableStore */ export function createNonReactiveMutableStore<TValue>( initialValue: TValue, ): RouterWritableStore<TValue> { let value = initialValue return { get() { return value }, set(nextOrUpdater: TValue | ((prev: TValue) => TValue)) { value = functionalUpdate(nextOrUpdater, value) }, } } /** SSR non-reactive createReadonlyStore */ export function createNonReactiveReadonlyStore<TValue>( read: () => TValue, ): RouterReadableStore<TValue> { return { get() { return read() }, } } export interface RouterStores<in out TRouteTree extends AnyRoute> { status: RouterWritableStore<RouterState<TRouteTree>['status']> loadedAt: RouterWritableStore<number> isLoading: RouterWritableStore<boolean> isTransitioning: RouterWritableStore<boolean> location: RouterWritableStore<ParsedLocation<FullSearchSchema<TRouteTree>>> resolvedLocation: RouterWritableStore< ParsedLocation<FullSearchSchema<TRouteTree>> | undefined > statusCode: RouterWritableStore<number> redirect: RouterWritableStore<AnyRedirect | undefined> matchesId: RouterWritableStore<Array<string>> pendingIds: RouterWritableStore<Array<string>> /** @internal */ cachedIds: RouterWritableStore<Array<string>> matches: ReadableStore<Array<AnyRouteMatch>> pendingMatches: ReadableStore<Array<AnyRouteMatch>> cachedMatches: ReadableStore<Array<AnyRouteMatch>> firstId: ReadableStore<string | undefined> hasPending: ReadableStore<boolean> matchRouteDeps: ReadableStore<{ locationHref: string resolvedLocationHref: string | undefined status: RouterState<TRouteTree>['status'] }> __store: RouterReadableStore<RouterState<TRouteTree>> matchStores: Map<string, MatchStore> pendingMatchStores: Map<string, MatchStore> cachedMatchStores: Map<string, MatchStore> /** * 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<AnyRouteMatch | undefined> setMatches: (nextMatches: Array<AnyRouteMatch>) => void setPending: (nextMatches: Array<AnyRouteMatch>) => void setCached: (nextMatches: Array<AnyRouteMatch>) => void } export function createRouterStores<TRouteTree extends AnyRoute>( initialState: RouterState<TRouteTree>, config: StoreConfig, ): RouterStores<TRouteTree> { const { createMutableStore, createReadonlyStore, batch, init } = config // non reactive utilities const matchStores = new Map<string, MatchStore>() const pendingMatchStores = new Map<string, MatchStore>() const cachedMatchStores = new Map<string, MatchStore>() // 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<Array<string>>([]) const pendingIds = createMutableStore<Array<string>>([]) const cachedIds = createMutableStore<Array<string>>([]) // 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<AnyRouteMatch | undefined> >(64) function getRouteMatchStore( routeId: string, ): RouterReadableStore<AnyRouteMatch | undefined> { 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<AnyRouteMatch>) init?.(store) // setters to update non-reactive utilities in sync with the reactive stores function setMatches(nextMatches: Array<AnyRouteMatch>) { reconcileMatchPool( nextMatches, matchStores, matchesId, createMutableStore, batch, ) } function setPending(nextMatches: Array<AnyRouteMatch>) { reconcileMatchPool( nextMatches, pendingMatchStores, pendingIds, createMutableStore, batch, ) } function setCached(nextMatches: Array<AnyRouteMatch>) { reconcileMatchPool( nextMatches, cachedMatchStores, cachedIds, createMutableStore, batch, ) } return store } function readPoolMatches( pool: Map<string, MatchStore>, ids: Array<string>, ): Array<AnyRouteMatch> { const matches: Array<AnyRouteMatch> = [] for (const id of ids) { const matchStore = pool.get(id) if (matchStore) { matches.push(matchStore.get()) } } return matches } function reconcileMatchPool( nextMatches: Array<AnyRouteMatch>, pool: Map<string, MatchStore>, idStore: RouterWritableStore<Array<string>>, 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) } }) }