Repository navigation
feat(router): intercept() loaders are loader segments of their slot: prefetch: false, per-loader revalidate() and handles reach modals - #1132
Merged
Conversation
commit: |
…solved by the shared funnel and folded back in the browser
An intercept's loaders were packed into its slot segment as one aggregate
(loaderDataPromise + loaderIds) and resolved outside the partial lane's
funnel, so a prefetch that resolved an intercept could defer nothing, a
held loader's data or handle push could be lost, and revalidation was per
slot, not per loader.
Server. Each intercept loader is now a loader segment of its slot,
`${parent}.${slot}D${i}.${$$id}`, resolved by resolveOwnerLoaders
(segment-resolution/revalidation.ts) through a LoaderOwner descriptor:
deferral, fill skipping, the ctx.use() delivery, per-loader revalidation
and handle attribution come from it as for a route's loaders. Route
callers pass ownerOf(entry); resolveLoadersWithRevalidation is gone. Both
intercept paths (resolveInterceptEntry, resolveInterceptLoadersOnly on a
cache or prerender hit) call it; a hit strips the aggregate a record
written before this change carries on its slot.
partialDeferralMode no longer excludes intercept requests: their plan's
scope is the intercept's own flagged loaders (resolveInterceptDeferralScope).
A fill with the slot held skips its handler and layout, runs only the
missing loaders and skips every target-chain entry the client does not
hold (interceptFillSkips); the intercept's middleware runs when the slot
resolves live, not on a cache() hit or a Prerender() target, as before.
An intercept's `matched` is the client's ids without the slot's, then the
slot's in declaration order. The prerender-store path keeps a held stored
intercept slot like any other held segment: its loaders' data rides their
own segments.
Browser. renderSegments folds an intercept slot's loader segments back
onto the slot before the tree walk (foldInterceptLoaders): one aggregate,
memoized on the loaders' data, read by the slot's LoaderBoundary as
before, on a new slot object. Walked as loaders they would attach to the
slot's parent and remount the page under the modal. The aggregate settles
before the commit when nothing in it is deferred and the slot has no
loading() or the lane settles loaders. Back/forward restores an intercept
entry unsplit, so the fold runs on the main segments too. The fill of an
adopted intercept prefetch is sent from the source with the intercept
source header: from the target itself nothing intercepts.
Every loader the server skipped is now a cached segment in `matched`, so
the reconciler's aggregate merge (mergeSegmentLoaders, needsLoaderMerge)
and settledValue are removed. A navigation with the modal open no longer
loses a held intercept loader's data, and an action that re-runs the
slot's handler keeps a held loader's handle pushes. An intercept loader's
error on a navigation reaches onError with isPartial: true, as a route
loader's does.
serveShellRequest gains partial.interceptSource
(X-RSC-Router-Intercept-Source).
… a server-run revalidate() collectIntercepts keeps a loader's prefetch: false, and ClientUrlProjectionIntercept carries deferrableLoaderIndices and loaderRevalidate (the hoisted predicate keys), which materialization puts on the intercept's loader stubs, as on a route's. An intercept loader's revalidate() was checked by the build and never applied. The Vitest projection installer registers intercept predicates too.
…duction prefetch-false fixtures gain an intercept source page (`ix`) with three modals (a flagged loader with loading(), one with no loading(), one with no flag); the shared `intercept():` bodies cover the prefetch and its fill, the page under the modal kept mounted, the empty slot with no loading(), back/forward, a newer navigation during the fill, a hard load, and parity with a plain click at a 300 ms server. intercept-held gains a link between two modals reading a held loader and a held loader's handle push across an action. clientUrls() intercepts in both apps: revalidate() with the action result, and an in-group prefetch deferring the flagged loader. Semantic matrix row I3. cloudflare-basic dogfoods the intercept prefetch and fill through serveShellRequest.
…nd revalidate() in intercepts prefetch-false.md (intercept path table, R8/R9 reworded, "Intercepts"), execution-model.md flow 4, feature-map and feature-file-map, client-urls docs and skill, intercept, loader and serveShellRequest skills, the docs site's clientUrls() guide, and the Unreleased CHANGELOG entries. Intercept middleware runs in a fill only when the slot resolves live: a runtime cache() hit or a Prerender() target runs none (a known gap, unchanged here), and execution-model.md and prefetch-false.md say so.
ivogt
force-pushed
the
feat/intercept-loader-segments
branch
from
October 12, 2026 21:49
be90842 to
5515957
Compare
This was referenced Oct 13, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
devcorner: dc-t6
Summary
An
intercept()'s loaders now behave like a route's loaders.loader(Def, { prefetch: false })insideintercept()keeps that loader out of a prefetch from the source page. Each intercept loader runs its ownrevalidate(), inurls()and inclientUrls(). The modal keeps the data and handle pushes of every loader the server skipped. Under the hood, intercept loaders are now loader segments of their slot, resolved by the same loader funnel as route loaders. The browser folds them back onto the slot, so the page under the modal never remounts. Ships in 0.25.0.Problem
From a consumer's seat, an intercept's loaders were second-class in four ways:
prefetch: falsedid nothing in a modal.loader(CommentsLoader, { prefetch: false })insideintercept()was accepted and ignored. A hover or viewport prefetch of the target from the source page ran every loader of the modal, the expensive one included. On a route, the same flag keeps that loader for the click.revalidate()on aclientUrls()intercept loader never ran. The build hoisted and checked the predicate, then nothing called it: an action from the modal re-ran every loader of the modal, whatever the predicate said.revalidate()allowed it. The browser then dropped the data of the others, and the new modal'suseLoader()threwdata not found in context.revalidate()answeredfalsekept its data, but its handle pushes (a breadcrumb, a title) were counted as the handler's and disappeared when the handler re-ran.Before and after
/photos/1from/photosPhotoLoaderandCommentsLoaderPhotoLoader;CommentsLoaderis deferredloading()at the click; one fill request runsCommentsLoadersaveNotefrom the modal returns{ ok: false }NotesLoaderre-runs (predicate never called)NotesLoaderis skipped; the modal keeps its dataNothing changes in the consumer's code. The flag and the predicate start doing what they say.
End-to-end example
A photo gallery whose modal defers its comments in prefetches, plus a
clientUrls()group whose modal re-runs its notes only when the save succeeded.Semantics
prefetch: falseignoredloading()at the click; one fill, sent with the intercept source, runs only the flagged loaders. The slot's handler and layout do not run again, the intercept's middleware runs, and target-chain layouts outside the intercept'smatchedare skipped. The page under the modal is not re-rendered or remountedloading()ctx.use()before its firstawaitrevalidate()skipped are dropped by the browser:useLoader()throwsurls()revalidate()decides; a skipped loader's handle pushes are droppedrevalidate()decides; a skipped loader keeps its data and its handle pushesclientUrls()revalidate()is never calledrevalidate()runs on the server,actionResultincludedcache()hit on the targetPrerender()targetloading(fallback, { prefetch: false })insideintercept()onErrorgetsisPartialunsetonErrorgetsisPartial: true, as a route loader on a navigationCode changes
Router source vs main, tests excluded: +663 / −669.
Server: one loader funnel.
resolveLoadersWithRevalidationbecomesresolveOwnerLoaders(owner: LoaderOwner, ...)inrouter/segment-resolution/revalidation.ts. ALoaderOwneris a route entry (ownerOf(entry)) or an intercept:The intercept gets deferral, fill skipping,
ctx.use()delivery (deliverStartedLoaders), per-loaderrevalidate()and handle attribution without code of its own.router/intercept-resolution.tsdrops its two hand-written loader loops (+148 / −265 in that file). The slot carries no aggregate any more: its loaders follow it as${parent}.${slot}D${i}.${$$id}segments. Other server changes:prefetch-deferral.ts:resolveInterceptDeferralScopebuilds the scope from the intercept alone, and a prefetch that resolves an intercept is no longer excluded (partialDeferralMode).interceptFillSkipsholds the fill rule "send only what the client lacks of the intercept" in one predicate, used at the cache-lookup and revalidation sites.match-result.ts: an intercept'smatchedhas one order (client ids minus the slot's, theninterceptSegmentIds), so a fill's identity check holds (R6).match-middleware/intercept-resolution.ts: a cache or prerender hit yields the loader segments after the stored slot. It also strips the aggregate a pre-0.25.0 record carries on the slot. That record is reachable only under aversionpinned across the upgrade (createRouter's or the cache store's); build versions hash the router's code.Browser: fold, don't walk.
foldInterceptLoaders(segment-system.tsx) puts the loader segments back on the slot as one memoized aggregate (Suspense contract rules 1, 2 and 6). Walked as plain loaders, they would attach to the slot's parent and remount the page under the modal. A list with nothing to fold comes back synchronously, sorenderSegmentsawaits only when there is something to fold. A slot with noloading()settles before the commit, so a modal without a skeleton commits whole, as before.merge-segment-loaders.tsloses its loader merge (−85): no reconcile actor merges loader data now, because a skipped loader is its own cached segment inmatched.partial-update.tssends an intercept's fill with its source (previousUrlandinterceptSourceUrl).clientUrls(). The DSL recordsprefetch: falseinsideintercept(). The projection carriesdeferrableLoaderIndicesandloaderRevalidateon the intercept (ClientUrlProjectionIntercept). Materialization builds route and intercept loaders through onematerializeLoadershelper (client-urls/server-projection.ts).Testing primitive.
serveShellRequest({ partial: { interceptSource } })sendsX-RSC-Router-Intercept-Source, the header the browser sends from an open modal and with an intercept's fill. Without it, a test cannot serve an intercept's fill.Why fold in the browser and not render loader segments natively in
segmentTreeWalk? The walk's slot-loader branch is gated onp.loading. Lifting that gate for intercept slots touches every slot's Suspense path. That is filed as dc-t34.Test plan
pnpm run typecheckpnpm run test:unit:all: router 8725 unit + 840 RSC (2 expected fails, unchanged); cloudflare-basic 85 + 27; mini 25 + 3; vite-rsc-demo 27 + 1; shared-e2e 20. Router unit + RSC also pass on eachfeatcommit alone.pnpm run lint,pnpm run formatcheck:e2e-bucketing,check-e2e-parity --strict,check:suspense-contract,check:docs-api,check:skills,check:skills-catalog,check:doc-links,check:no-external-store,check:e2e-console-baselinenode tools/check-bundle-guards.mjs: cloudflare-basic router chunk 49,402 B gzip (max 49,536 B; main is 49,527 B, so 125 B smaller)[I3]: "an action inside a modal re-runs only the intercept loaders whose revalidate() says so; the modal keeps the rest" (execution-model.mdupdated)pnpm --filter @rangojs/router test:hmr-routes-localand thehmr-interceptandhmr-client-urlsprojects: 21, 2 and 3 passedRed before green. The three bugs were run against
origin/mainin the router test-app, dev and production, with 3 retries each. All three fail there and pass on this branch:origin/mainintercept-held: "a link in the open modal to another intercepted route keeps the data of a loader both modals read and neither re-runs"getByTestId('ih-modal')not found; the dev server logsuseLoader: Loader ...#InterceptHeldLoader data not found in contextclient-urls: "a clientUrls() intercept loader's revalidate() sees the action result"ci-modal-result-runs: expected "2", received "3"intercept-held: "a held intercept loader's handle push stays when an action re-runs the slot's handler"held-loader:1crumb: expected 1, received 0Userland, through
@rangojs/router/testing.src/testing/__tests__/prefetch-false-intercept.rsc-test.tsx(serveShellRequest) pins four things:ctx.use()of the flagged loader is one run;tests/cloudflare-basic/test/prefetch-false-intercept.rsc-test.tsxruns the same prefetch-then-fill contract against the consumer app's own fixture.prefetch-false-unflagged.rsc-test.tsxadds an intercept to the R9 pin: no flag means no deferred segment and no fill.prefetch-false-client-urls.rsc-test.tsxadds aclientUrls()intercept with a flagged loader.E2e, dev and production, both apps. New cases:
intercept():intests/shared-e2e/src/prefetch-false.ts, run by both apps:loading(), the slot is empty until the fill;clientUrls()cases ine2e/client-urls.test.tsin both apps.intercept-heldscenario.Runs on this branch:
Suites in the router test-app run:
suspense-cases,held-boundary,intercept-held,prefetch-false(326 per mode) andsemantic-matrix(31 dev, 34 production);client-urls*file;intercept-when-shape,prefetch-intercept-sourceandprerender-intercept;loading-fn,loading-redirect,navigation-loadingandprerender-loading;parallel-*files;stream-mode,streamed-boundary-adoption,streaming-actions,suspense-streamanduse-cache-streaming.In cloudflare-basic:
suspense-cases,held-boundary,intercept-held,prefetch-false, everyclient-urls*file,intercept-when-shape,parallel-new-slot-reval,prefetch-intercept-source,stream-idle,stream-suspense,streamed-boundary-adoptionandstreamed-handler-boundary.The one failure is in cloudflare-basic dev, while another session's e2e suite shared the machine: "a click that adopts a prefetch is never worse than a plain click: hub to vt-inner, loaders 300 ms slower, the server 500 ms away". The plain navigation of pair 1 committed in two steps and ran 3 view transitions to the adopted click's 2 (
view transitions that change the page: the plain navigation 3, the adopted click 2); pair 2 was on time. The route has no intercept. Rerun alone with--repeat-each=3, it passed 3 of 3.Notes
onErrorchange. A live intercept loader's error on a navigation now reportsisPartial: true, as a route loader's does; it was unset. It comes from the shared funnel's error context. The CHANGELOG says so.versionis pinned across it; the cache-hit path strips the old aggregate for that case.cache()hit or aPrerender()target. Only the live path callsexecuteInterceptMiddleware. This is pre-existing, the same onorigin/main, andexecution-model.mdstates it.segmentTreeWalkand dropfoldInterceptLoaders.matchedIdsfrom the funnel instead of re-deriving them withinterceptSegmentIds.interceptFillSkipschecks.## Unreleased.