Skip to content

feat(router): intercept() loaders are loader segments of their slot: prefetch: false, per-loader revalidate() and handles reach modals - #1132

Merged
ivogt merged 4 commits into
mainfrom
feat/intercept-loader-segments
Oct 12, 2026
Merged

ivogt merged 4 commits into
mainfrom
feat/intercept-loader-segments

Conversation

@ivogt

@ivogt ivogt commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

devcorner: dc-t6

Summary

An intercept()'s loaders now behave like a route's loaders. loader(Def, { prefetch: false }) inside intercept() keeps that loader out of a prefetch from the source page. Each intercept loader runs its own revalidate(), in urls() and in clientUrls(). 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:

  1. prefetch: false did nothing in a modal. loader(CommentsLoader, { prefetch: false }) inside intercept() 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.
  2. revalidate() on a clientUrls() 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.
  3. A link in an open modal lost data. A link in a modal to another intercepted route whose modal reads the same loader (or back/forward between two such modals) re-ran only the loaders whose revalidate() allowed it. The browser then dropped the data of the others, and the new modal's useLoader() threw data not found in context.
  4. An action from a modal dropped handle pushes. A loader whose revalidate() answered false kept 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

// urls()
intercept("@modal", ".photo", PhotoModal, () => [
  loader(PhotoLoader),
  loader(CommentsLoader, { prefetch: false }),
  loading(<ModalSkeleton />),
]);
Before (0.24.1) After
Prefetch of /photos/1 from /photos runs PhotoLoader and CommentsLoader runs PhotoLoader; CommentsLoader is deferred
Click that adopts that prefetch modal from the prefetch modal and its loading() at the click; one fill request runs CommentsLoader
// clientUrls()
intercept("@modal", ".detail", DetailModal, () => [
  loader(NotesLoader, () => [
    revalidate(({ isAction, actionResult }) =>
      isAction(saveNote) ? actionResult?.ok === true : undefined,
    ),
  ]),
]);
Before (0.24.1) After
saveNote from the modal returns { ok: false } NotesLoader re-runs (predicate never called) NotesLoader is skipped; the modal keeps its data

Nothing 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.

// src/photos/urls.tsx
import { urls } from "@rangojs/router";
import { Outlet, ParallelOutlet } from "@rangojs/router/client";
import { PhotoLoader, CommentsLoader } from "./loaders";
import { Gallery, PhotoPage, PhotoModal, ModalSkeleton } from "./components";

function GalleryLayout() {
  return (
    <main>
      <Outlet />
      <ParallelOutlet name="@modal" />
    </main>
  );
}

export const photoPatterns = urls(({ path, layout, intercept, loader, loading }) => [
  layout(<GalleryLayout />, () => [
    path("/photos", Gallery, { name: "photos" }),
    path("/photos/:id", PhotoPage, { name: "photo" }, () => [
      loader(PhotoLoader),
      loader(CommentsLoader),
    ]),
    // Opens over /photos on a soft navigation; a hard load renders PhotoPage.
    intercept("@modal", ".photo", PhotoModal, () => [
      loader(PhotoLoader),
      // A hover or viewport prefetch from /photos skips it; the click that
      // adopts the prefetch opens the modal at once and one fill runs it.
      loader(CommentsLoader, { prefetch: false }),
      loading(<ModalSkeleton />),
    ]),
  ]),
]);
// src/items/items.client-urls.tsx
"use client";
import { clientUrls, useLoader, useOutlet } from "@rangojs/router/client";
import { saveNote } from "./actions"; // "use server": returns { ok: boolean }
import { ItemLoader, NotesLoader, RelatedLoader } from "./loaders";

function ItemsLayout() {
  return <section>{useOutlet().content}</section>;
}

function DetailModal() {
  const { data: item } = useLoader(ItemLoader);
  const { data: notes } = useLoader(NotesLoader);
  const { data: related } = useLoader(RelatedLoader);
  return (
    <dialog open>
      <h2>{item.title}</h2>
      <Notes notes={notes} onSave={(text) => saveNote(item.id, text)} />
      <Related items={related} />
    </dialog>
  );
}

export default clientUrls(({ layout, path, intercept, loader, loading, revalidate }) => [
  layout(ItemsLayout, () => [
    path("/", ItemsIndex, { name: "index" }),
    path("/detail/:id", ItemDetail, { name: "detail" }, () => [loader(ItemLoader)]),
    intercept("@modal", ".detail", DetailModal, () => [
      loader(ItemLoader),
      // The build moves this predicate to the server; it now runs there with
      // the action's result.
      loader(NotesLoader, () => [
        revalidate(({ isAction, actionResult }) =>
          isAction(saveNote) ? actionResult?.ok === true : undefined,
        ),
      ]),
      // Deferred by an in-group prefetch of /detail/:id, filled at the click.
      loader(RelatedLoader, { prefetch: false }),
      loading(<DetailModalSkeleton />),
    ]),
  ]),
]);

Semantics

Request Before (0.24.1) After
Prefetch of an intercept target from its source every intercept loader runs; prefetch: false ignored flagged loaders are deferred, unflagged ones and the handler run; nothing of the target chain is deferred
Click that adopts that prefetch the prefetched modal commits the modal and its loading() 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's matched are skipped. The page under the modal is not re-rendered or remounted
Same, a modal with a flagged loader and no loading() the prefetched modal commits the slot stays empty until the fill answers; the URL moves at the click
Plain click, nothing prefetched one request, every loader unchanged
A handler that reads a flagged loader with ctx.use() before its first await n/a the loader runs in the prefetch, once, and has no fill
Navigation with the modal open (a link to another intercepted route, back/forward between two modals) loaders whose revalidate() skipped are dropped by the browser: useLoader() throws the modal keeps every skipped loader's data
Action from the modal, urls() each loader's revalidate() decides; a skipped loader's handle pushes are dropped each loader's revalidate() decides; a skipped loader keeps its data and its handle pushes
Action from the modal, clientUrls() every intercept loader re-runs; revalidate() is never called each loader's hoisted revalidate() runs on the server, actionResult included
Back/forward closing or reopening a modal from the history cache restored unchanged; the restored entry is folded like a fresh one
Hard load of the target full page, no intercept unchanged
Runtime cache() hit on the target stored slot, loaders run fresh as an aggregate on it; intercept middleware does not run stored slot, loaders run fresh as loader segments; intercept middleware still does not run (dc-t30, pre-existing)
Prerender() target baked slot, loaders run at request; intercept middleware does not run same; intercept middleware still does not run (dc-t30, pre-existing)
loading(fallback, { prefetch: false }) inside intercept() ignored ignored (unchanged)
An intercept with no flag, prefetched no deferral no deferred segment and no fill (R9 now covers intercepts); its loaders travel as loader segments
A live intercept loader that throws on a navigation onError gets isPartial unset onError gets isPartial: true, as a route loader on a navigation

Code changes

Router source vs main, tests excluded: +663 / −669.

Server: one loader funnel. resolveLoadersWithRevalidation becomes resolveOwnerLoaders(owner: LoaderOwner, ...) in router/segment-resolution/revalidation.ts. A LoaderOwner is a route entry (ownerOf(entry)) or an intercept:

// router/intercept-resolution.ts
return resolveOwnerLoaders(
  {
    loaders: interceptEntry.loader,
    namespace: `intercept:${interceptEntry.routeName}`,
    segmentPrefix: interceptSlotId(parentEntry, interceptEntry),
    boundary: parentEntry,
    bakeLane: false,
    traceSource: "intercept-loader",
  },
  context, belongsToRoute, rc.clientSegmentIds, /* ... */
);

The intercept gets deferral, fill skipping, ctx.use() delivery (deliverStartedLoaders), per-loader revalidate() and handle attribution without code of its own. router/intercept-resolution.ts drops 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: resolveInterceptDeferralScope builds the scope from the intercept alone, and a prefetch that resolves an intercept is no longer excluded (partialDeferralMode).
  • interceptFillSkips holds 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's matched has one order (client ids minus the slot's, then interceptSegmentIds), 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 a version pinned 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, so renderSegments awaits only when there is something to fold. A slot with no loading() settles before the commit, so a modal without a skeleton commits whole, as before. merge-segment-loaders.ts loses its loader merge (−85): no reconcile actor merges loader data now, because a skipped loader is its own cached segment in matched. partial-update.ts sends an intercept's fill with its source (previousUrl and interceptSourceUrl).

clientUrls(). The DSL records prefetch: false inside intercept(). The projection carries deferrableLoaderIndices and loaderRevalidate on the intercept (ClientUrlProjectionIntercept). Materialization builds route and intercept loaders through one materializeLoaders helper (client-urls/server-projection.ts).

Testing primitive. serveShellRequest({ partial: { interceptSource } }) sends X-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 on p.loading. Lifting that gate for intercept slots touches every slot's Suspense path. That is filed as dc-t34.

Test plan

  • pnpm run typecheck
  • pnpm 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 each feat commit alone.
  • pnpm run lint, pnpm run format
  • check: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-baseline
  • node 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)
  • Semantic matrix in the router test-app, with the new row [I3]: "an action inside a modal re-runs only the intercept loaders whose revalidate() says so; the modal keeps the rest" (execution-model.md updated)
  • pnpm --filter @rangojs/router test:hmr-routes-local and the hmr-intercept and hmr-client-urls projects: 21, 2 and 3 passed

Red before green. The three bugs were run against origin/main in the router test-app, dev and production, with 3 retries each. All three fail there and pass on this branch:

Test On origin/main
intercept-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 logs useLoader: Loader ...#InterceptHeldLoader data not found in context
client-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" the held-loader:1 crumb: expected 1, received 0

Userland, through @rangojs/router/testing.

  • src/testing/__tests__/prefetch-false-intercept.rsc-test.tsx (serveShellRequest) pins four things:
    • a prefetch from the source defers the flagged intercept loader and runs the unflagged one and the handler;
    • its fill, with the intercept source, runs only the flagged loader;
    • with the modal open, nothing is deferred, and a handler's ctx.use() of the flagged loader is one run;
    • a navigation inside the modal keeps a skipped loader's data, and an action keeps a skipped loader's handle pushes.
  • tests/cloudflare-basic/test/prefetch-false-intercept.rsc-test.tsx runs the same prefetch-then-fill contract against the consumer app's own fixture.
  • prefetch-false-unflagged.rsc-test.tsx adds an intercept to the R9 pin: no flag means no deferred segment and no fill. prefetch-false-client-urls.rsc-test.tsx adds a clientUrls() intercept with a flagged loader.

E2e, dev and production, both apps. New cases:

  • intercept(): in tests/shared-e2e/src/prefetch-false.ts, run by both apps:
    • a prefetch skips the flagged loader and the click fills it;
    • the adopted click shows the modal at the click, never later than a plain click, at a 300 ms server;
    • the page under the modal is not remounted, prefetched or not, flagged or not;
    • with no loading(), the slot is empty until the fill;
    • back and forward keep the filled modal;
    • a newer navigation during the fill wins;
    • a hard load runs every loader.
  • Two clientUrls() cases in e2e/client-urls.test.ts in both apps.
  • The intercept-held scenario.

Runs on this branch:

App Mode Tests Result
router test-app dev 531 all passed
router test-app production 538 all passed
cloudflare-basic dev 420 419 passed; 1 failed once under load (below)
cloudflare-basic production 420 all passed

Suites in the router test-app run:

  • suspense-cases, held-boundary, intercept-held, prefetch-false (326 per mode) and semantic-matrix (31 dev, 34 production);
  • every client-urls* file;
  • intercept-when-shape, prefetch-intercept-source and prerender-intercept;
  • loading-fn, loading-redirect, navigation-loading and prerender-loading;
  • the parallel-* files;
  • stream-mode, streamed-boundary-adoption, streaming-actions, suspense-stream and use-cache-streaming.

In cloudflare-basic: suspense-cases, held-boundary, intercept-held, prefetch-false, every client-urls* file, intercept-when-shape, parallel-new-slot-reval, prefetch-intercept-source, stream-idle, stream-suspense, streamed-boundary-adoption and streamed-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

  • onError change. A live intercept loader's error on a navigation now reports isPartial: true, as a route loader's does; it was unset. It comes from the shared funnel's error context. The CHANGELOG says so.
  • Wire shape. Every intercept response changes shape, flagged or not. Intercept loaders travel as loader segments rather than an aggregate on the slot. A pre-0.25.0 segment-cache record is unreachable after the upgrade unless a version is pinned across it; the cache-hit path strips the old aggregate for that case.
  • Known gap, not changed here (dc-t30). Intercept middleware never runs on a runtime cache() hit or a Prerender() target. Only the live path calls executeInterceptMiddleware. This is pre-existing, the same on origin/main, and execution-model.md states it.
  • Follow-ups, filed under dc-t6:
    • dc-t34: render intercept loader segments natively in segmentTreeWalk and drop foldInterceptLoaders.
    • dc-t35: thread matchedIds from the funnel instead of re-deriving them with interceptSegmentIds.
    • dc-t36: one output filter for an intercept's fill instead of the per-source interceptFillSkips checks.
  • Release. Ships in 0.25.0. The CHANGELOG entries are under ## Unreleased.

@pkg-pr-new

pkg-pr-new Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@1132

commit: 5515957

ivogt added 4 commits October 13, 2026 00:46
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant