Skip to content

feat(router)!: group-stable segment for clientUrls() — the optimistic instance survives the commit - #852

Merged
ivogt merged 1 commit into
mainfrom
feat/client-urls-group-stable-segment
Sep 13, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/client-urls-group-stable-segment

Conversation

@ivogt

@ivogt ivogt commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

0.13.0 renders a clientUrls() destination optimistically, but each projected route is its own server segment keyed by id-params, so the canonical commit mounted the destination as a new segment and unmounted the optimistic branch. The instance the user was already interacting with was replaced: text typed into a field during the wait was lost, and effects ran twice. The design doc recorded this as the phase-one limitation and named the fix.

Before / after

// Same group, same code. Click A -> B behind a slow middleware, type into B.
<input data-testid="search" />
Before (0.13.0) After
Typed value when the response commits lost (B remounted) kept (same instance)
B's mount effect runs twice runs once
Same-route param nav inside a group (/items/1 -> /items/2) remounts, skeleton reconciles, previous content held until data lands

End-to-end example

// src/urls/portal.client.tsx
"use client";
import { Suspense, useState } from "react";
import { clientUrls, Link, useLoader, useParams } from "@rangojs/router/client";
import { SiteLoader } from "./portal.loader.js";

function Site() {
  const { siteId } = useParams<{ siteId: string }>();
  const [filter, setFilter] = useState("");
  return (
    <article>
      <input value={filter} onChange={(e) => setFilter(e.target.value)} />
      <Suspense fallback={<h1>{siteId}</h1>}>
        <SiteName />
      </Suspense>
      <Link to="/portal/sites/beta">Beta</Link>
    </article>
  );
}
function SiteName() {
  const { data } = useLoader(SiteLoader);
  return <h1>{data.name}</h1>;
}

export default clientUrls(({ path, loader }) => [
  path("/sites/:siteId", Site, { name: "site" }, () => [loader(SiteLoader)]),
]);

Clicking Beta from Acme keeps Site mounted and filter intact; the heading holds "Acme" until Beta's loader lands, then swaps. No transition() needed for the hold.

Semantics

Case Before After
Cross-route in-group nav, canonical commit destination segment mounts fresh, optimistic instance discarded destination segment reconciles into the same position; instance, state, and effects survive
Same-route param nav in a group param-bearing key, remount with fresh skeleton (transition() opts into the hold) group key is param-agnostic by construction, so it holds; transition() in a group is the animation opt-in only
Same-route search nav in a group held unchanged
Loader-thrown redirect / notFound / error fallback in a group caught by a per-route boundary instance same boundary instance survives; it now resets on resetKey (id-params) so a marker caught for one route never leaks into the next
Group route with loading() LoaderBoundary + RouteContentWrapper from the segment system Suspense inside ClientUrlsRoot (same fallback, same reads); no server-side change, loading() still masks loaders for PPR capture and drives SSR
Group route without loading() and without loaders bare OutletProvider OutletProvider + StreamedLoaderErrorBoundary (uniform shape; the boundary only catches marked loader errors)
Server routes (outside groups) unchanged unchanged
Hard loads unchanged unchanged

Code changes

  • src/client-urls/server-projection.ts — every materialized route gets clientGroup = the include's URL prefix ("/" at root).
  • src/urls/pattern-types.ts (PathOptions.clientGroup, internal), src/urls/path-helper.ts, src/server/context.ts (EntryData), src/router/segment-resolution/fresh.ts + revalidation.ts, src/types/segments.ts (ResolvedSegment), src/cache/segment-codec.ts — carry the key from the DSL to the browser and through the segment cache.
  • src/segment-system.tsx — group routes: React key cg:<group>; one wrapper shape (no LoaderBoundary/RouteContentWrapper, always StreamedLoaderErrorBoundary); the id-params identity is computed once and passed as the boundary's resetKey.
    const idParamsKey = paramStr ? `${id}-${paramStr}` : id;
    const key = clientGroup ? `cg:${clientGroup}` : idParamsKey;
  • src/route-content-wrapper.tsx — StreamedLoaderErrorBoundary clears its caught marker in getDerivedStateFromProps when resetKey changes. Found by the e2e: a surviving boundary kept rendering LoaderRedirect for /legacy after the redirect had landed on /state, so the target never rendered (also the vite-rsc-demo "moved product redirects" flakes).
  • src/client-urls/client-root.tsx — the wrapper chain (OutletProvider + OptimisticLocationContext.Provider, Suspense when the route declares loading()) is identical in the optimistic and canonical states; only prop values change.
  • src/browser/partial-update.ts — a navigation that presented optimistically commits in the transition lane, so a read that still suspends holds the presented content instead of flashing a fallback.
  • Docs: docs/design/client-urls-optimistic-destination.md (mechanics 4 rewritten, phase two marked shipped, the boundary-reset scar recorded), docs/client-urls.md (navigation authority, same-route section), skills/client-urls/SKILL.md (transition() row).
  • E2e: both slow-middleware suites type into B during the window and assert the value after the commit; client-urls.test.ts "same-route param navs hold previous content in a group, with or without transition()" replaces the plain-twin re-stream expectation.

Why the shape is uniform client-side rather than by emitting loading() for every group route server-side: loading() masks loaders during PPR shell capture (entryLoadingMasksLoaders), so a synthetic server-side loading() would silently change capture for group routes without one. Keeping the segment's loading value intact and only changing what renderSegments builds leaves every server contract as it was.

Test plan

  • Unit: segment-system.test.tsx "keys clientUrls() group routes by the group and gives them one wrapper shape"; client-root.test.tsx "keeps the optimistic instance across the canonical commit" (typed state survives, mount effect runs once); new streamed-loader-error-boundary.test.tsx pins the reset and the no-reset case; existing view-transition and client-urls pins green.
  • E2e dev + production, test-app and cloudflare-basic: slow-middleware suites (state persists across the commit; presentation timings unchanged), client-urls (redirect-with-state probe, same-route hold with and without transition()), loader-behavior (tree-structure rule), conditional-transition, prefetch-transition, client-reverse, location-state.
  • E2e vite-rsc-demo client-shop-* (the redirect flakes from the first run disappeared with the boundary reset; one remaining flake was a dev-server setup timeout).
  • pnpm run typecheck, pnpm run test:unit:all, pnpm run lint, pnpm run format, check:e2e-bucketing, check:e2e-parity --strict, check:docs-api.

Notes

  • Release: minor bump. Same-route param navigations inside a group now hold by default; transition() in a group no longer changes the hold, only the animation.
  • /simplify applied: the id-params identity is computed once for the key and the reset key; the shapeLoading indirection became direct group gates; type comments point at the segment-system branch instead of repeating it. Three of four reviewers stalled; their angles were covered by hand. Skipped: deriving the group from mountPath (a server route mounted at the same prefix would wrongly take the group key and shape), adding clientGroup to segment-structure-assert (static per segment id).
  • Local runs during this PR hit a heavily loaded machine: full cloudflare-basic runs took up to an hour and timed out the 5 s-middleware cases in dev; the same cases pass in about 20 s when run alone. No timeout was changed.

… instance survives the commit

Every route of one clientUrls() group mount now carries a `clientGroup` key
(PathOptions internal -> EntryData -> ResolvedSegment -> segment cache codec).
renderSegments keys those segments by the group instead of route id + params
and gives them one wrapper shape (no LoaderBoundary/RouteContentWrapper —
ClientUrlsRoot renders loading() itself), and ClientUrlsRoot renders one
wrapper chain in both the optimistic and the canonical state. The canonical
commit therefore reconciles the destination into the position the origin held:
the optimistically rendered component keeps its instance, local state typed
during the wait survives, effects run once. Optimistic navigations commit in
the transition lane so a read that still suspends holds the presented content.

Consequence: same-route param navigations inside a group hold previous content
by default (the group key is param-agnostic by construction); transition() in a
group is the view-transition animation opt-in, not the hold. Server semantics
are untouched — loading() still masks loaders for PPR capture and drives SSR.

StreamedLoaderErrorBoundary gains a resetKey (id-params): as a surviving
instance it kept rendering LoaderRedirect for /legacy after the redirect had
landed on /state, so the target never rendered. It now clears the caught
marker when the route or params change, the cadence the per-route remount
used to provide.

Design: docs/design/client-urls-optimistic-destination.md (mechanics 4, phase two).
@pkg-pr-new

pkg-pr-new Bot commented Sep 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: af0836d

@ivogt
ivogt merged commit 3e4e749 into main Sep 13, 2026
43 checks passed
@ivogt
ivogt deleted the feat/client-urls-group-stable-segment branch September 13, 2026 05:25
ivogt added a commit that referenced this pull request Sep 13, 2026
Group-stable segment for clientUrls() (#852): the optimistically rendered instance survives the canonical commit, same-route param navs inside a group hold previous content by default, and the streamed loader error boundary resets per route. Minor bump for the same-route behavior change.
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