Recenter router around Suspense transitions#10
Open
KidkArolis wants to merge 62 commits into
Open
Conversation
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>
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>
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>
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.
Summary
This is the 1.0 rewrite of React Space Router around React transitions and Suspense.
The router now owns route state and commits navigations inside
startTransition, so the current page stays visible and interactive while the destination loads. Route code and data start loading as soon as a navigation begins, and pending state is exposed consistently for links, programmatic navigation, and browser traversal.What changed
usePending(),usePendingRoute(), per-linkdata-pending, and<DelayedSuspense>.<Router routes={routes}>;<Routes />is now only the render location for the matched route tree.resolver: () => import(...).prepare(ctx), with handles pinned for the committed route and released on superseded or completed navigations.queriesplus a small<Router data>adapter, so one route declaration drives both navigation preparation and speculative prefetching.<Link prefetch>,<Router prefetchLinks>, route-levelprefetchable, andusePrefetch().useNavigate(),<Navigate>, the public Space Router instance, and prefetching.transformQueryapplies app-owned query policy before href generation;transformRouteremains the post-match, pre-commit route transform.Why
The 0.6 API delegated route state and navigation lifecycle work to application callbacks. That made Suspense coordination, pending UI, code preloading, and data-cache lifetimes application concerns—and meant the route commit itself could happen outside React's transition.
In 1.0, a navigation is a React transition. The router owns the state update; route declarations own what must be loaded; Suspense boundaries own where loading UI appears.
Breaking changes
<Routes routes={routes}>to<Router routes={routes}>.<Router useRoute onNavigating onNavigated>is removed. UseuseRoute()for reads, effects for post-commit observation, and routeresolver/prepare/queriesfor loading.<Link>props (className,style, andextraProps) are removed. Usearia-current="page",data-pending, oruseLinkState(to).useInternalRouterInstance()is renamed touseSpaceRouter().See MIGRATION.md for the full migration path.
Loading model
The route chunk and query start together. The previous route remains committed while the destination suspends;
<DelayedSuspense>boundaries can switch to skeletons after the configured delay.Demo
npm run demoopens four loading-mode examples over simulated code and data latency, including immediate skeletons, hold-then-swap, delayed fallbacks, and detail-surface fades.Verification
npm testnpm run demo:buildnpm run docs:build