diff --git a/.changeset/cool-ideas-deny.md b/.changeset/cool-ideas-deny.md new file mode 100644 index 00000000000..0220b7662c4 --- /dev/null +++ b/.changeset/cool-ideas-deny.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +load-client can cache settles abandonned loader work without preload authority diff --git a/packages/router-core/INTERNALS.md b/packages/router-core/INTERNALS.md index 4983f360150..2fd9472cec2 100644 --- a/packages/router-core/INTERNALS.md +++ b/packages/router-core/INTERNALS.md @@ -45,6 +45,10 @@ match a private lane -> publish only if the navigation still owns the result ``` +An ordinary foreground or preload loader success can gain an independent cache +owner while its lane is still private. This is per-match data reuse, not lane +publication; unresolved loader work remains owned only by its consumers. + Preloads run the same work without publishing a lane. Server loading uses a request signal instead of a client navigation owner. Hydration reconstructs the accepted server prefix, then hands any remaining client work to the initial @@ -251,7 +255,10 @@ chain. On the client, eligible loaders and normal component chunks start concurrently. On the server, loaders reduce before normal render chunks are consumed. Reduction turns their outcomes into one terminal semantic lane and one cutoff. -No task publishes while reduction is in progress. +No task publishes semantic or presentation state while reduction is in +progress. An eligible successful client loader may independently enter the +off-screen cache at settlement; that admission neither accepts the lane nor +selects its terminal outcome. ### Projected @@ -321,9 +328,12 @@ is also removed from the transaction's private ownership before publication. Every asynchronous navigation or presentation publication checks `_tx` immediately before the write. Background publication additionally checks the -exact committed base array from which it was derived. Preload cache admission -and private redirect continuation use their own identity and controller checks -instead. +exact committed base array from which it was derived. Per-match cache admission +is not lane publication: it requires the producing lane's controller to remain +live and the cache entry captured during planning to remain the exact current +entry. Navigation lanes use their transaction controller, while preloads use +their independently owned controller. Private preload redirect continuation +uses the active preload entry as its additional authority. ## `beforeLoad`: execution and hydration @@ -390,6 +400,23 @@ lane uses that data or requires a loader generation. A discoverable same-ID flight may satisfy that requirement, including when `shouldReload` returns `true`. +Every eligible successful blocking loader task from an ordinary foreground +navigation or preload attempts cache admission immediately after loader +settlement. It does not wait for component readiness, whole-lane reduction, +projection, commit, or render acknowledgement. The cache receives a +non-terminal copy with merged context removed and an additional flight lease; +the private lane keeps its own match and lease until it commits or is discarded. +If a navigation is superseded, a settled cached generation therefore survives +the lane's lease release, while an unresolved loader has no cache owner and is +aborted when its last consumer releases it. Navigation-produced entries retain +normal navigation staleness and GC semantics rather than becoming synthetic +preloads. + +Background loader candidates remain private until their exact transaction and +committed base authorize background publication. Development refresh foreground +generations also skip early cache admission because their candidate publication +may roll back. + It is valid for cached loader data to have been produced under context from an older `beforeLoad` generation. Loaders are the cache boundary; guards are not. Likewise, a shared in-flight invocation may have started with another lane's @@ -401,15 +428,13 @@ its generation identity, but it can never satisfy freshness and must reload. Failed, canceled, loaderless, and expired generations do not become reusable loader-cache entries. -A terminal preload lane can still contain independently successful loader -generations when its error or not-found came from `beforeLoad`, validation, or -another route. Each preload loader success attempts cache admission immediately, -before whole-lane reduction. The cache receives a non-terminal copy and an -additional flight lease; the speculative lane keeps its own lease and terminal -meaning until it is discarded. This works even below the eventual render -boundary and does not preserve the speculative parent chain: merged -context and `beforeLoad` output are cleared. Same-ID `_ctx`, loader identity, -and successful loader data remain reusable by design. +A private client lane can contain independently successful loader generations +when its eventual terminal outcome comes from `beforeLoad`, validation, another +route, or when a descendant is still loading as the navigation is superseded. +Settlement-time cache admission works even below the eventual render boundary +and does not preserve the private parent chain: merged context and `beforeLoad` +output are cleared. Same-ID `_ctx`, loader identity, and successful loader data +remain reusable by design. Hydration retry is the transported-work exception because it did not run those client loader tasks. There, `loaderData` membership together with @@ -424,12 +449,13 @@ transport. Reconstruction preserves the absence and does not treat an omitted value as reusable loader success during hydration retry. The cache may deliberately contain a successful generation with the same match -ID as a committed match. For example, a speculative lane can produce reusable -ancestor loader data before failing below it while the older committed -generation remains visible. Cache-first matching lets the next lane use that -newer loader generation. Its merged context and `beforeLoad` contribution have -been removed; the merged chain is rebuilt from current parents and same-ID -route-local context before `beforeLoad` reruns. +ID as a committed match. For example, a navigation can finish an ancestor +loader and then be superseded while a descendant is still pending, or a private +lane can produce reusable ancestor data before failing below it while an older +committed generation remains visible. Cache-first matching lets the next lane +use that newer loader generation. Its merged context and `beforeLoad` +contribution have been removed; the merged chain is rebuilt from current parents +and same-ID route-local context before `beforeLoad` reruns. Commit removes a cache entry when the accepted render prefix contains that ID, or when a successful match anywhere in the committed lane contains it. A @@ -922,9 +948,11 @@ leases and discovery entries are detached does it abort the collected flight and preload-lane controllers. A public loader signal can synchronously reenter from its abort listener; that reentrant load must observe the cleared authorities and every removed lease as already detached. Unselected concurrent preloads keep -shared flights discoverable, and every later cache publication must still have a -live preload signal and pass the per-match cache-entry identity check captured -during planning. +shared flights discoverable, and every later cache admission must still have a +live producing-lane signal and pass the per-match cache-entry identity check +captured during planning. Cache clearing does not cancel the current transaction, +so an active navigation loader that settles afterward may establish a new cache +generation. Invalidation is the API that replaces active semantic generations. Development refresh is deliberately aggressive about reuse. It removes all loader flights from discovery, discards active preloads and cache entries, and @@ -932,6 +960,13 @@ rematches with committed/cache reuse disabled so obsolete params, context, loader data, or projected assets cannot seed the refreshed lane. Selected cache and preload resources are detached before their controllers are aborted. +Unlike an ordinary foreground navigation, a development refresh does not admit +its loader successes to the cache while its candidate lane is private. The +refresh publication checkpoint is created only after lane execution; admitting +the new generation earlier would capture it as rollback cache and allow a failed +or superseded refresh to remain reusable. Acknowledged refresh data becomes +committed normally and may be cached later when displaced. + Refresh does not immediately discard the accepted committed lane or abort the loader signals it still owns. The previous semantic lane, presentation, and their resources remain available to the refresh transaction until the new @@ -969,13 +1004,13 @@ match array while any eligible successful loader generations can still enter the cache. Cancellation or control flow that does not yield a reusable lane can resolve `undefined`. -Each preload loader task compares the current cache entry for its match ID with -the entry captured when that task was planned. It cannot overwrite a cache -generation installed since that plan. Admission happens at loader settlement, -without waiting for whole-lane success. A distinct successful generation may -coexist with an older committed same-ID generation; this changes future -planning precedence, not current presentation. A duplicate sharing the already -accepted flight is discarded. +Each preload loader task uses the shared settlement-time cache admission rule: +it compares the current cache entry for its match ID with the entry captured when +that task was planned. It cannot overwrite a cache generation installed since +that plan. Admission does not wait for whole-lane success. A distinct successful +generation may coexist with an older committed same-ID generation; this changes +future planning precedence, not current presentation. A duplicate sharing the +already accepted flight is discarded. `preloadRoute` also works on a server router. It runs the same speculative protocol with `preload: true`, can return matches and populate that router's diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index f0024720b8c..08084cc70de 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -750,7 +750,7 @@ function createLoaderTask( const match = lane[1 /* matches */][index]! const route = getRoute(router, match) const preload = !!options[4 /* preload */] - const plannedCacheMatch = preload ? router._cache.get(match.id) : undefined + const plannedCacheMatch = router._cache.get(match.id) let configured let reload = false let reloadFailure: LoaderOutcome | undefined @@ -878,10 +878,16 @@ function createLoaderTask( if (blocking) { settleInto(match, result, preload) if (result[0 /* kind */] === SUCCESS) { + // A settled generation can outlive its lane without keeping unresolved + // navigation work alive. Refresh generations remain private for rollback. if ( - preload && routeLoader && - !options[0 /* controller */].signal.aborted + !options[0 /* controller */].signal.aborted && + !( + process.env.NODE_ENV !== 'production' && + !preload && + router._tx?.[6 /* refresh */] + ) ) { cacheLoaderMatch(router, match, plannedCacheMatch) } diff --git a/packages/router-core/tests/navigation-settled-cache.test.ts b/packages/router-core/tests/navigation-settled-cache.test.ts new file mode 100644 index 00000000000..b45a8143495 --- /dev/null +++ b/packages/router-core/tests/navigation-settled-cache.test.ts @@ -0,0 +1,74 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +test('a settled loader result survives when its navigation is superseded', async () => { + const aLoader = vi.fn(() => 'a data') + let bSignal: AbortSignal | undefined + const bLoader = vi.fn( + ({ abortController }: { abortController: AbortController }) => { + bSignal = abortController.signal + return new Promise((_resolve, reject) => { + abortController.signal.addEventListener( + 'abort', + () => reject(abortController.signal), + { once: true }, + ) + }) + }, + ) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const aRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/a', + loader: aLoader, + staleTime: Infinity, + gcTime: Infinity, + }) + const bRoute = new BaseRoute({ + getParentRoute: () => aRoute, + path: '/b', + loader: bLoader, + }) + const cRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/c', + }) + const dRoute = new BaseRoute({ + getParentRoute: () => cRoute, + path: '/d', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + aRoute.addChildren([bRoute]), + cRoute.addChildren([dRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const abandoned = router.navigate({ to: '/a/b' }) + await vi.waitFor(() => { + expect(aLoader).toHaveBeenCalledOnce() + expect(bLoader).toHaveBeenCalledOnce() + }) + + await router.navigate({ to: '/c/d' }) + await abandoned + expect(bSignal?.aborted).toBe(true) + + await router.navigate({ to: '/a' }) + + expect(aLoader).toHaveBeenCalledOnce() + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: aRoute.id, + loaderData: 'a data', + }) +})