Skip to content

Recenter router around Suspense transitions#10

Open
KidkArolis wants to merge 38 commits into
masterfrom
use-query
Open

Recenter router around Suspense transitions#10
KidkArolis wants to merge 38 commits into
masterfrom
use-query

Conversation

@KidkArolis

@KidkArolis KidkArolis commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Goals

0.6 was callback-driven: apps hooked onNavigating to preload code and data, tracked their own navigating flags, and wired route state through external stores. This rewrite recenters the router on React's own machinery — a navigation is a useTransition:

  1. Navigations are transitions. The previous page stays on screen and interactive while the destination loads. Pending state falls out of the transition for free — usePending() for "is a navigation happening", usePendingRoute() for "where to", and a per-link data-pending attribute for "was it this link" — for clicks, programmatic navigation, and browser back/forward alike.
  2. Fetch as you render. Route resolver chunks and prepare(ctx) data fetches start the moment navigation begins (including cold direct loads), so code download and data loading overlap instead of waterfalling. Prepared cache entries stay pinned for exactly as long as the route is committed.
  3. Loading UX is declarative and per-surface. Suspense boundaries decide what loading UI appears where. <DelayedSuspense> adds the browser-style "hold the old page briefly, then degrade to a skeleton" behavior with a single pendingDelayMs knob.

What it unlocks

const routes = [
  { path: '/', component: Home },
  {
    path: '/issues/:id',
    // chunk preloaded at navigation time, rendered via React.lazy
    resolver: () => import('./pages/IssueDetail'),
    // data fetch starts immediately, in parallel with the chunk
    prepare: ({ params }) => [issueStore.prepare({ id: Number(params.id) })],
  },
]

function App() {
  return (
    <Router pendingDelayMs={1000}>
      <Suspense fallback={null}>
        <Routes routes={routes} />
      </Suspense>
    </Router>
  )
}

// `id` arrives as a regular prop, injected from the `:id` path segment
export default function IssueDetail({ id }) {
  const issue = issueStore.read(id) // suspends only if prepare hasn't finished
  return (
    <>
      <h1>{issue.title}</h1>
      <DelayedSuspense fallback={<CommentsSkeleton />}>
        <Comments issueId={id} />
      </DelayedSuspense>
    </>
  )
}
/* active and pending link styling is pure CSS — no render-prop plumbing */
.nav-link[aria-current='page'] { font-weight: 600; }
.nav-link[data-pending] { opacity: 0.6; }

Clicking a link to /issues/123 keeps the current page up while the chunk and data load in parallel; the clicked link dims via data-pending. If loading outlasts pendingDelayMs, <DelayedSuspense> boundaries degrade to skeletons. usePendingRoute() covers destination-aware pending UI — e.g. highlighting the requested item in a list before it commits (see Mode D in the demo).

Breaking changes

  • Route state moved inside <Router>; the onNavigating / onNavigated / useRoute props are gone. Use resolver/prepare for preloading and useRoute() + effects for observing navigations.
  • Function-form <Link> props (className, style, extraProps) are gone. Style via aria-current / data-pending in CSS, or useLinkState(to) when state must affect rendered output.

See MIGRATION.md for the step-by-step guide and the docs for the full API reference.

Demo

npm run demo — an interactive tour of four loading-mode recipes (inner skeletons, hold-then-swap, delayed skeletons, detail swap fade) over simulated chunk and data latencies.

Verification

  • npm test — 50 tests, 100% statement coverage
  • npm run demo:build

🤖 Generated with Claude Code

KidkArolis and others added 27 commits July 7, 2026 19:47
Why: the 0.6.x escape hatches (onNavigating, onNavigated, useRoute
injection prop) predate React's useTransition and force route state
out of the router, which breaks Suspense's transition contract — the
commit needs to be inside startTransition for the previous route to
stay on screen while the next one prepares.

Router: drops onNavigating/onNavigated/useRoute props. State is now
internal (useState + useTransition). Adds:

- prepare(ctx) per route — returns PreparedHandle[] for figbird-style
  fetch-as-you-render data loading. Router pins handles for the
  lifetime of the committed nav and releases them on the next commit
  or on Routes unmount.
- resolver: () => import('./Page') — preloaded at navigate time and
  rendered via React.lazy.
- transformRoute() — synchronous pre-commit URL rewrite hook,
  replaces the one legitimate use case for onNavigating
  (e.g. persisted-query restoration with history.replaceState).
- usePending() — exposes useTransition's isPending for top-bar
  progress and "click did something" affordances.
- DelayedSuspense + Router pendingDelayMs — encapsulates the "hold
  previous route for N ms then degrade to skeleton" pattern.
  Internally uses a never-resolving-promise fallback during the
  hold window so suspension propagates to the outer transition,
  then swaps to the real fallback once the threshold elapses or
  the boundary post-commits with reads still pending.
- defineRoute / defineRoutes — typed identity helpers.

Tests: rewritten for the new API; new coverage for transformRoute,
usePending, and the prepare/release lifecycle.

Docs: README, docs/content/_index.md fully updated. New MIGRATION.md
walks 0.6.x → 1.0 with recipes for the removed surfaces.

Demo: examples/loading-modes/ — Vite app showcasing the three
loading-mode patterns (immediate+skeletons, wait-for-ready, timed
fallback) against simulated chunk + data latencies, so the API
choices can be felt against real timings rather than argued in
the abstract.

dist/ rebuilt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Plain object routes were always supposed to be the canonical shape;
the `defineRoute` / `defineRoutes` helpers in 1.0-WIP were leaning
toward a typed-routes future that doesn't fit how this library is
actually used (humaans has 100+ stable, simple-string param routes;
the wrapper-per-route ceremony wasn't earning its keep against
component-level typing).

Two changes that together replace the helpers:

- The `<Routes>` renderer now spreads each matched path param onto the
  leaf segment's component as own props. Each segment receives only
  the params declared in its own `path` — wrapping layouts that
  didn't declare those params get nothing extra. Static `props` from
  the route definition still spread alongside and win on key
  collision so consumers can override intentionally.

- `defineRoute` and `defineRoutes` are removed. Routes are plain
  objects in plain arrays. Components type the params they expect
  via their own function signature; the runtime injection meets them
  at that boundary. `prepare(ctx)` keeps `ctx.params` typed as
  `Record<string, string>` — typo-resistance via TypeScript wasn't
  worth the per-route wrapper or the mapped-tuple helper alternative.

Net result: humaans-style routes stay as `{ path, resolver, prepare }`
plain objects, and a page like:

  export default function Workflow({ workflowId }: { workflowId: string }) { ... }

receives `workflowId` for free from a `path: '/workflows/:workflowId'`
route — no `useRoute()` dance, no helper wrappers, no codegen.

Demo's ModeD migrated to declare `{ id?: string }` directly instead
of reaching for `useRoute()`. Tests, MIGRATION, and docs updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add usePendingRoute() exposing the transformed route an in-flight
  navigation is heading to. Pending state is now owned entirely by
  commit(), so clicks, programmatic navigation, and browser back/forward
  all register the same way; navigate()'s eager href/match dance and the
  redirect-aware clearing logic are gone. Mode (d) in the demo derives
  its fade from the router instead of intercepting clicks.
- Prepare the initial route during the first render so cold direct loads
  suspend on prepared data instead of crashing on an unseeded cache, and
  chunk download overlaps data loading on direct loads too.
- Commit back/forward navigations outside the popstate task. React 19
  flushes popstate-scheduled updates synchronously, which showed Suspense
  fallbacks instead of holding the previous route and never painted
  pending state. Verified native scroll restoration survives the deferral.
- Type the internal contract as Route<RouteData>, deleting the scattered
  casts; declare props on RouteData; drop the unread navigation option
  and PreparedHandle priority/key fields from the 1.0 API; fix the
  conditional hook in useRoute; split Router/Routes plumbing out of the
  public RouterContext.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
useLinkProps compared raw hrefs against pending route urls, so
hash-prefixed hrefs (e.g. #/users) never reported isPending in hash
mode. Normalize the href once and use it for both isCurrent and
isPending.

Also drop the unreachable re-prepare fallback in the initial-commit
effect — the render phase always prepares the exact route before the
effect can run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Narrow To to string | NavigateTarget; new LinkTo type carries the
  link-only current override for useLinkProps/Link
- Funnel both navigation entry points through a single beginNavigation
  callback in Routes
- Split router.test.tsx into router/prepare/link/pending test files with
  shared helpers; flush jsdom's async fragment navigation inside act in
  the same-page hash test so stray router updates don't leak

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Commit 80409c7 removed the initial-prepare fallback as dead code, crashing
apps under StrictMode: the remounted adoption effect ran with a nulled
initialPrepared ref after the simulated unmount had released the adopted
handles.

The branch looked dead because the StrictMode tests never exercised the
remount cycle: React 19 only runs the mount->unmount->remount effect
simulation when <StrictMode> wraps from the root render call, and the
tests nested it inside an App component, where effects are not
double-invoked at all.

Restore the fallback (re-prepare when the adopted handles are gone), wrap
both StrictMode tests from the root, and add an async-mode StrictMode test
that genuinely takes the re-prepare path — deleting the branch again now
fails a test instead of an app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
transformRoute's URL sync called window.history.replaceState directly,
which is mode-blind: in hash mode it rewrote the page path instead of the
fragment, and in memory mode it mutated real browser history. space-router
1.3's replaceUrl owns the per-mode encoding and is silent by contract, so
it cannot re-trigger the listener loop.

Writing the hash-mode test exposed a redundant second sync on cold loads
(adoption effect + initial emit both synced), so URL syncing now lives
solely in commit() and syncRouteUrl is gone from the internals plumbing.

Adds hash-mode and memory-mode regression tests for the transform sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
useLinkProps now returns only spreadable anchor props — the non-enumerable
isCurrent/isPending trick is gone. Pending links carry a data-pending
attribute for CSS styling, and useLinkState(to) serves programmatic reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KidkArolis and others added 2 commits July 7, 2026 21:56
Pin hugo extended for asdf (the docs compile SCSS), add docs:watch and
docs:build scripts, remove the long-gone google_analytics_async internal
template, disable unused taxonomy pages, and gitignore .hugo_build.lock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same escape hatch, same return value — the underlying space-router
instance — with a name that says which layer you're dropping into.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KidkArolis and others added 2 commits July 7, 2026 21:57
Lead with the Suspense-native pitch, add a Loading UI section showing
the three boundary-placement modes, drop the migration guide from the
docs site (MIGRATION.md in the repo covers it), and restructure the
0.7.0 changelog into explicit breaking changes plus key capabilities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Warm a route's chunk and data before the user commits to navigating.
Three roles split cleanly:

- WHAT: route declares data once via `queries` ([def, args] pairs),
  run through a `<Router data>` adapter — as prepare on navigation and
  prefetch on hover. The adapter is a minimal { prepare, prefetch }
  contract co-designed with figbird's kit (same spirit as PreparedHandle),
  so the router stays data-layer-agnostic. Low-level prepare/prefetch
  route fields remain for divergent routes or adapter-less layers.
- WHEN: <Link prefetch> (true/hover/visible) and <Router prefetchLinks>
  trigger warming; usePrefetch() is the primitive for custom triggers
  (form submit, viewport logic). Prefetch never touches pending state.
- WHETHER: route prefetchable:false vetoes speculation (chunk + data)
  while still preparing on real navigation; beats an explicit Link prefetch.

Demo Mode D migrated to queries + adapter, with prefetch-on-hover on the
item list so a hovered detail swaps instantly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KidkArolis and others added 7 commits July 7, 2026 23:30
usePrefetch and useLinkTarget each normalized string targets, built the
href, and stripped the hash-mode leading # independently. Hoist that into
normalizeLinkTarget/resolveLinkHref so the #-stripping rule has one home.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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