Skip to content

feat: add RouteDisplay component for route rendering without navigation interception - #270

Merged
uhyo merged 4 commits into
masterfrom
claude/gallant-gauss-cb1d2b
Oct 21, 2026
Merged

uhyo merged 4 commits into
masterfrom
claude/gallant-gauss-cb1d2b

Conversation

@uhyo

@uhyo uhyo commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds <RouteDisplay>, a component that only decides what to render from the current URL. It does not intercept navigations. It is meant for multi-page applications (MPAs) where each page is a separate document but still uses FUNSTACK Router route definitions to decide what to show, including SSR.

// Client: matches routes against the current page URL
<RouteDisplay routes={routes} />

// Server: matches routes against the given path
<RouteDisplay routes={routes} ssr={{ path: "/about", runLoaders: true }} />

Behavior

  • Props: routes, ssr, trailingSlash, features, all working the same as on <Router>.
  • Same location source as Router: Router's location tracking (subscribing to the adapter, the hydration handling, and the transitions on entry changes) is moved into a shared internal hook, useLocationEntry. RouteDisplay uses that hook with the same adapters and simply never calls setupInterception:
    • with the Navigation API: NavigationAPIAdapter, so the entry id, key and state are exposed, and same-document entry changes (e.g. hash navigations, setStateSync) render in a transition;
    • without it: StaticAdapter with a new fullPageNavigation option.
  • Loader caching: same keys as Router (the entry's key, cleanup when an entry is disposed, results carried over across same-URL replaces). When there is no entry (SSR), the cache is scoped to the instance.
  • Setters that navigate (the useSearchParams setter, route setState / resetState) do a full page navigation:
    • with the Navigation API: navigation.navigate() with no interception, and state is carried over to the new entry;
    • without it: location.assign() / location.replace().
  • Inert features: useBlocker never blocks, and route actions are not run (forms submit to the server).
  • Router unchanged: its behavior and its fallback="static" warning stay the same. The hook extraction moves code without changing what it does.

Tree shaking

  • An app importing only Router drops RouteDisplay.
  • An app importing only RouteDisplay drops NullAdapter, but currently keeps NavigationAPIAdapter's interception and form-action methods, because bundlers can't remove individual class methods (≈12.2 KB vs ≈12.9 KB minified for Router, React excluded). Step 1 of the follow-up plan below fixes this.
  • core/addTransitionType.ts now resolves React's export through a /* @__PURE__ */ call.

Docs

  • Added a <RouteDisplay> section to the Components API page.
  • Added a pointer to it from the fallback="static" section of the SSR guide.
  • Mentioned it in the bundled knowledge skill.

Follow-up refactoring plan (separate PR)

Neither step changes the public API, so both can land after this PR without user-visible changes. They are independent of each other and can be one PR or two.

1. Split interception out of NavigationAPIAdapter (tree shaking)

Goal: a RouteDisplay-only bundle no longer contains the interception code, bringing it back to about 7–8 KB minified, roughly what it was before this PR adopted the adapters.

  • What stays in NavigationAPIAdapter: observing the location: getSnapshot, subscribe (including the WebKit Private Browsing fallback), entry dispose cache cleanup, carrying cache over across same-URL replaces, navigate / navigateAsync, updateCurrentEntryState, getIdleAbortSignal.
  • What moves out: the navigate event handler (currently setupInterception, plus findActionRoute and the form-action / revalidation flow) moves to a new module, e.g. core/navigationInterception.ts, as setupNavigationInterception(adapter, getRoutes, onNavigate, checkBlockers, getMatchOptions). Only Router imports it.
  • How the two talk: interception currently writes the adapter's private fields #currentNavigationInfo, #lastInterceptedNavigationType, #reloadCounts, #pendingFormNavigation and #committedDestination, and invalidates #cachedSnapshot. These become a small internal API on the adapter that interception calls, for example:
    • recordInterceptedNavigation({ info, navigationType })
    • bumpReloadCount(key)
    • beginFormNavigation() / endFormNavigation(handle)
    • setCommittedDestination(entryId, href)
  • Interface cleanup: remove setupInterception from the RouterAdapter interface, and with it the no-op implementations in StaticAdapter and NullAdapter. Router calls setupNavigationInterception only when its adapter is a NavigationAPIAdapter.

2. Share the rendering core between Router and RouteDisplay (deduplication)

Goal: route matching, loader and SSR behavior live in one place. Today about 100 lines are duplicated between Router/index.tsx and RouteDisplay/index.tsx.

  • New internal component, e.g. RouteTree. It takes the resolved location (locationEntry, actualLocationEntry, isPending), the adapter (for navigateAsync, updateCurrentEntryState, getIdleAbortSignal), routes, ssr, trailingSlash, features and the blocker registry. It owns:
    • resolving feature flags and the browser() check
    • building the URL from ssr.path
    • deciding whether loaders run
    • loader cache keying (the entry key, or the per-instance cache when there is no entry)
    • the matching and loader memo
    • the RouterContext value and the provider tree around RouteRenderer
  • After the refactor:
    • Router = createAdapter(fallback) + useLocationEntry(adapter, transitionTypes) + interception + RouteTree
    • RouteDisplay = createDisplayAdapter() + useLocationEntry(adapter) + RouteTree

Verification for the follow-up

  • Existing test suites (Router, SSR loader-cache isolation, action revalidation, hydration, WebKit Private Browsing, RouteDisplay) pass without changes.
  • A manual bundle check (rolldown and esbuild against dist/, one entry importing only RouteDisplay and one only Router) confirms the interception code is gone from the RouteDisplay-only bundle.

Test plan

  • New RouteDisplay.test.tsx: rendering, nested routes, location and search params, loaders, full-page navigation from setters (with and without the Navigation API), entry id/key/state, setStateSync, a hash navigation rendered in a transition, loader results kept across a same-URL setState replace, no navigate listener, SSR with and without ssr / runLoaders, and hydration (including Navigation API state and reuse of loader results)
  • Existing Router tests pass unchanged after the useLocationEntry extraction
  • pnpm test:run: 371 tests pass
  • pnpm typecheck, pnpm lint, pnpm format:check, pnpm build

🤖 Generated with Claude Code

https://claude.ai/code/session_01AoUA6HQioyuW5Y6iX8wvZZ

…on interception

StaticRouter matches routes against the current URL and renders them,
but never subscribes to or intercepts navigations. This makes it usable
for MPAs where each page uses route definitions to decide what to show,
with SSR support via the same `ssr` prop as Router.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoUA6HQioyuW5Y6iX8wvZZ
…ads, expose entry details

- Rename StaticRouter to RouteDisplay.
- Navigations requested by hooks (useSearchParams setter, route setState)
  are performed as regular full page navigations instead of warning:
  navigation.navigate() without interception (state carried over to the
  new entry), or location.assign/replace without Navigation API.
- When Navigation API is available, expose the current entry's id, key
  and state, and follow currententrychange so setStateSync works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoUA6HQioyuW5Y6iX8wvZZ
The top-level React export lookup was retained by bundlers even when
Router was unused (e.g. apps that only use RouteDisplay), since property
access can't be proven side-effect-free. Resolve it in a pure-annotated
call instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoUA6HQioyuW5Y6iX8wvZZ
Extract Router's location tracking (adapter subscription, hydration
handling, entry-change transitions) into a shared useLocationEntry hook
and use it in RouteDisplay together with the same adapters Router uses,
without setting up interception.

- RouteDisplay uses NavigationAPIAdapter when available, otherwise a
  StaticAdapter with the new fullPageNavigation option.
- Loader cache keying matches Router (entry keys, dispose cleanup,
  carry-over across same-URL replaces).
- Same-document entry changes render in a transition, so useIsPending
  reflects them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AoUA6HQioyuW5Y6iX8wvZZ
@uhyo
uhyo merged commit a7fa549 into master Oct 21, 2026
1 check passed
@uhyo
uhyo deleted the claude/gallant-gauss-cb1d2b branch October 21, 2026 15:32
uhyo pushed a commit that referenced this pull request Oct 22, 2026
Follow-up to #270, with no behavior change.

Extract the rendering logic duplicated between Router and RouteDisplay
(feature flags, SSR URL, loader execution and cache keying, RouterContext
and providers) into Router/RouteTree.tsx. Router and RouteDisplay now
only differ in how they create the adapter and whether they set up
interception.

Also document that RouteDisplay assumes nothing else on the page
intercepts navigations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vNbiZGrzNhyfyctwm4NbJ
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.

2 participants