From ccd9ae85f5ea4f550dc3bceb0680b40c2734570c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 13:50:31 +0000 Subject: [PATCH 1/4] feat: add StaticRouter component for route rendering without navigation 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 Claude-Session: https://claude.ai/code/session_01AoUA6HQioyuW5Y6iX8wvZZ --- packages/docs/src/pages/ApiComponentsPage.tsx | 88 +++++- packages/docs/src/pages/LearnSsrBasicPage.tsx | 8 + .../skills/funstack-router-knowledge/SKILL.md | 2 +- packages/router/src/StaticRouter/index.tsx | 186 ++++++++++++ .../src/__tests__/StaticRouter.test.tsx | 277 ++++++++++++++++++ packages/router/src/index.ts | 1 + 6 files changed, 560 insertions(+), 2 deletions(-) create mode 100644 packages/router/src/StaticRouter/index.tsx create mode 100644 packages/router/src/__tests__/StaticRouter.test.tsx diff --git a/packages/docs/src/pages/ApiComponentsPage.tsx b/packages/docs/src/pages/ApiComponentsPage.tsx index 9d1ec18..4f25631 100644 --- a/packages/docs/src/pages/ApiComponentsPage.tsx +++ b/packages/docs/src/pages/ApiComponentsPage.tsx @@ -6,7 +6,7 @@ export function ApiComponentsPage() {

Components

Core components for building routing in your React application.

@@ -138,6 +138,92 @@ export function ApiComponentsPage() {
+
+

+ {""} +

+

+ A router that only renders the routes matching the current URL. Unlike{" "} + {""}, it does not subscribe to or intercept navigations: links and + form submissions cause regular full page loads. Use it for multi-page applications (MPAs) + where each page is a separate document but still uses route definitions to decide what to + render. It does not depend on the Navigation API, so it works in all browsers. +

+ {`import { StaticRouter } from "@funstack/router"; + +// Client: matches routes against window.location + + +// Server: matches routes against the given path +`} +

+ Hooks that read the location (useLocation, useSearchParams,{" "} + useRouteParams, useRouteData, etc.) work as usual. Features that + rely on client-side navigation are inert: useBlocker never blocks, route + state is always undefined, useIsPending is always{" "} + false, and route actions are not intercepted. Setters that would navigate + (such as the one from useSearchParams) log a warning and do nothing. +

+

Props

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropTypeDescription
+ routes + + RouteDefinition[] + Array of route definitions
+ ssr + + SSRConfig + + Same as {""}'s ssr prop. Used only during SSR + and hydration; afterwards, the page URL from window.location takes + over. +
+ trailingSlash + + {'"ignore" | "strict"'} + + Same as {""}'s trailingSlash prop. +
+ features + + RouterFeatures + + Same as {""}'s features prop. +
+
+
+

{""} diff --git a/packages/docs/src/pages/LearnSsrBasicPage.tsx b/packages/docs/src/pages/LearnSsrBasicPage.tsx index e5817c4..969a674 100644 --- a/packages/docs/src/pages/LearnSsrBasicPage.tsx +++ b/packages/docs/src/pages/LearnSsrBasicPage.tsx @@ -188,6 +188,14 @@ function AppShell() { // Static fallback: renders routes using window.location // when Navigation API is unavailable `} +

+ If you are building a multi-page application and never want client-side navigation, use{" "} + + {""} + {" "} + instead. It always behaves like static fallback mode, whether or not the Navigation API is + available. +

diff --git a/packages/router/skills/funstack-router-knowledge/SKILL.md b/packages/router/skills/funstack-router-knowledge/SKILL.md index 85d4948..c780dc3 100644 --- a/packages/router/skills/funstack-router-knowledge/SKILL.md +++ b/packages/router/skills/funstack-router-knowledge/SKILL.md @@ -9,7 +9,7 @@ description: Use this skill when you need information about `@funstack/router` ( ## Entrypoints -- `@funstack/router` — Main entrypoint. Provides `Router`, `Outlet`, hooks (`useLocation`, `useRouteParams`, etc.), and route definition utilities (`route()`, `routeState()`). +- `@funstack/router` — Main entrypoint. Provides `Router`, `StaticRouter` (route rendering without navigation interception, for MPAs), `Outlet`, hooks (`useLocation`, `useRouteParams`, etc.), and route definition utilities (`route()`, `routeState()`). - `@funstack/router/server` — Entrypoint for Server context when you are using React Server Components. Provides `route()` and `routeState()` utilities for defining routes in server modules. ## FUNSTACK Router Docs diff --git a/packages/router/src/StaticRouter/index.tsx b/packages/router/src/StaticRouter/index.tsx new file mode 100644 index 0000000..4a4f1ec --- /dev/null +++ b/packages/router/src/StaticRouter/index.tsx @@ -0,0 +1,186 @@ +import { type ReactNode, useCallback, useMemo, useState, useSyncExternalStore } from "react"; +import { RouterContext, type RouterContextValue } from "../context/RouterContext.js"; +import { BlockerContext, createBlockerRegistry } from "../context/BlockerContext.js"; +import { type NavigateOptions, type TrailingSlashMode, internalRoutes } from "../types.js"; +import { getBrowserFn } from "../core/browserBailout.js"; +import { matchRoutes } from "../core/matchRoutes.js"; +import { executeLoaders, createLoaderRequest } from "../core/loaderCache.js"; +import type { RouteDefinition } from "../route.js"; +import type { RouterFeatures, SSRConfig } from "../Router/index.js"; +import { noopSubscribe } from "../Router/ServerLocationSnapshot.js"; +import { RouteRenderer } from "../Router/RouteRenderer.js"; + +export type StaticRouterProps = { + routes: RouteDefinition[]; + /** + * SSR configuration for the router. + * + * Works the same as the `ssr` prop of `Router`: by default (no `ssr` prop), + * during SSR only pathless routes match. When provided, the router uses + * the given pathname to match path-based routes during SSR as well. + * + * This prop is only used during SSR and hydration. Once the client + * hydrates, the real URL of the page takes over. + */ + ssr?: SSRConfig; + /** + * How trailing slashes in the URL pathname are treated during route matching. + * Same as the `trailingSlash` prop of `Router`. + * + * @default "ignore" + */ + trailingSlash?: TrailingSlashMode; + /** + * Opt-in feature flags. See {@link RouterFeatures}. + */ + features?: RouterFeatures; +}; + +/** + * Loader cache key used for every render of a StaticRouter. The URL never + * changes during the lifetime of a StaticRouter, and the cache is scoped to + * the instance, so a single key suffices. Using the same key for the + * hydration render and the post-hydration render lets loader results from + * hydration be reused instead of re-executed. + */ +const staticLoaderCacheKey = "__static__"; + +const getServerSnapshot = () => null; + +/** + * Router that only renders the routes matching the current URL. + * + * Unlike `Router`, it does not subscribe to or intercept navigations: + * links and form submissions cause regular full page loads. This makes it + * suitable for multi-page applications (MPAs), where each page is a separate + * document but still uses route definitions to decide what to render. + * + * It does not depend on the Navigation API, so it works in all browsers. + */ +export function StaticRouter({ + routes: inputRoutes, + ssr, + trailingSlash, + features, +}: StaticRouterProps): ReactNode { + const routes = internalRoutes(inputRoutes); + + const pathlessSSROutletDeferral = features?.pathlessSSROutletDeferral ?? false; + + if (pathlessSSROutletDeferral) { + // Fail fast (on both server and client) when the host React build + // doesn't support the browser() API. + getBrowserFn(); + } + + const resolvedFeatures = useMemo( + () => ({ pathlessSSROutletDeferral }), + [pathlessSSROutletDeferral], + ); + + // The page URL never changes without a full page load, so it is read once + // and cached for this instance. + const [getSnapshot] = useState(() => { + let cachedUrl: string | null = null; + return () => (cachedUrl ??= window.location.href); + }); + // null during SSR/hydration; React re-renders with the real URL after hydration. + const clientUrl = useSyncExternalStore(noopSubscribe, getSnapshot, getServerSnapshot); + + // useBlocker requires a registry. Blockers never fire because no + // navigation is intercepted. + const [blockerRegistry] = useState(() => createBlockerRegistry()); + + // Loaders are never aborted: there is no subsequent navigation. + const [idleSignal] = useState(() => new AbortController().signal); + + // Loader results are scoped to this instance so that multiple renders in + // the same process (SSR requests, SSG pages) don't share them. + const [instanceLoaderCache] = useState(() => new Map()); + + const navigateAsync = useCallback(async (_to: string, _options?: NavigateOptions) => { + console.warn( + "FUNSTACK Router: navigation was requested inside a StaticRouter. " + + "StaticRouter does not perform client-side navigation; " + + "use regular links or a Router instead.", + ); + }, []); + + const updateCurrentEntryState = useCallback((_state: unknown) => { + // No-op: StaticRouter does not manage navigation entry state + }, []); + + const url = useMemo(() => { + if (clientUrl !== null) { + return clientUrl; + } + if (ssr) { + const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost"; + return new URL(ssr.path, origin).toString(); + } + return null; + }, [clientUrl, ssr]); + const urlObject = useMemo(() => (url ? new URL(url) : null), [url]); + + /** + * Whether to run loaders. Loaders always run on the client; during SSR + * (and hydration) only when ssr.runLoaders is true and ssr.path is given. + */ + const runLoaders = clientUrl !== null || (!!ssr?.runLoaders && urlObject !== null); + + const matchedRoutesWithData = useMemo(() => { + if (!runLoaders) { + const matched = matchRoutes(routes, urlObject?.pathname ?? null, { + skipLoaders: true, + trailingSlash, + }); + if (!matched) return null; + return matched.map((m) => ({ ...m, data: undefined })); + } + + if (urlObject === null) { + throw new Error("Invariant failure: loaders cannot run without URL."); + } + + const matched = matchRoutes(routes, urlObject.pathname, { trailingSlash }); + if (!matched) return null; + + return executeLoaders( + matched, + staticLoaderCacheKey, + createLoaderRequest(urlObject), + idleSignal, + undefined, + instanceLoaderCache, + ); + }, [routes, urlObject, runLoaders, trailingSlash, idleSignal, instanceLoaderCache]); + + const routerContextValue: RouterContextValue = useMemo( + () => ({ + locationState: undefined, + locationInfo: undefined, + url: urlObject, + entryId: null, + entryKey: null, + isPending: false, + navigateAsync, + updateCurrentEntryState, + features: resolvedFeatures, + }), + [urlObject, navigateAsync, updateCurrentEntryState, resolvedFeatures], + ); + + return useMemo(() => { + const blockerContextValue = { registry: blockerRegistry }; + + return ( + + + {matchedRoutesWithData ? ( + + ) : null} + + + ); + }, [routerContextValue, matchedRoutesWithData, blockerRegistry]); +} diff --git a/packages/router/src/__tests__/StaticRouter.test.tsx b/packages/router/src/__tests__/StaticRouter.test.tsx new file mode 100644 index 0000000..de9dcdc --- /dev/null +++ b/packages/router/src/__tests__/StaticRouter.test.tsx @@ -0,0 +1,277 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { act, render, screen } from "@testing-library/react"; +import { renderToString } from "react-dom/server"; +import { hydrateRoot } from "react-dom/client"; +import { StaticRouter } from "../StaticRouter/index.js"; +import { Outlet } from "../Outlet.js"; +import { useLocation } from "../hooks/useLocation.js"; +import { useSearchParams } from "../hooks/useSearchParams.js"; +import { useBlocker } from "../hooks/useBlocker.js"; +import { route, type RouteDefinition } from "../route.js"; +import { setupNavigationMock, cleanupNavigationMock } from "./setup.js"; + +const originalLocation = window.location; + +function setLocation(url: string) { + const urlObj = new URL(url); + Object.defineProperty(window, "location", { + value: { + href: url, + pathname: urlObj.pathname, + search: urlObj.search, + hash: urlObj.hash, + origin: urlObj.origin, + }, + writable: true, + configurable: true, + }); +} + +describe("StaticRouter", () => { + beforeEach(() => { + delete (globalThis as Record).navigation; + }); + + afterEach(() => { + cleanupNavigationMock(); + Object.defineProperty(window, "location", { + value: originalLocation, + writable: true, + configurable: true, + }); + }); + + it("renders the route matching the current URL without Navigation API", () => { + setLocation("http://localhost/about"); + const routes: RouteDefinition[] = [ + { path: "/", component: () =>
Home
}, + { path: "/about", component: () =>
About
}, + ]; + + render(); + expect(screen.getByText("About")).toBeInTheDocument(); + expect(screen.queryByText("Home")).toBeNull(); + }); + + it("renders nested routes with params", () => { + setLocation("http://localhost/users/42"); + function Layout() { + return ( +
+ Layout +
+ ); + } + const routes: RouteDefinition[] = [ + route({ + path: "/users", + component: Layout, + children: [ + route({ + path: ":id", + component: ({ params }) => User {params.id}, + }), + ], + }), + ]; + + render(); + expect(screen.getByText("User 42")).toBeInTheDocument(); + }); + + it("provides location and search params", () => { + setLocation("http://localhost/search?q=hello#top"); + function Page() { + const location = useLocation(); + const [searchParams] = useSearchParams(); + return ( +
+ {location.pathname}|{location.hash}|{searchParams.get("q")} +
+ ); + } + + render(); + expect(screen.getByText("/search|#top|hello")).toBeInTheDocument(); + }); + + it("does not intercept navigations even when Navigation API is available", () => { + const mockNavigation = setupNavigationMock("http://localhost/"); + setLocation("http://localhost/"); + + render(
Home
}]} />); + expect(screen.getByText("Home")).toBeInTheDocument(); + expect(mockNavigation.addEventListener).not.toHaveBeenCalled(); + }); + + it("allows useBlocker inside routes", () => { + setLocation("http://localhost/"); + function Page() { + useBlocker({ shouldBlock: () => true }); + return
Page
; + } + + render(); + expect(screen.getByText("Page")).toBeInTheDocument(); + }); + + it("warns instead of navigating when navigation is requested", () => { + setLocation("http://localhost/"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let setSearchParams: ReturnType[1] | undefined; + function Page() { + setSearchParams = useSearchParams()[1]; + return
Page
; + } + + render(); + setSearchParams!({ q: "x" }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("StaticRouter")); + warn.mockRestore(); + }); + + it("runs loaders and passes data to components", () => { + setLocation("http://localhost/items/7"); + const loader = vi.fn(({ params }: { params: { id: string } }) => `item-${params.id}`); + const routes = [ + route({ + path: "/items/:id", + loader, + component: ({ data }: { data: string }) =>
{data}
, + }), + ]; + + render(); + expect(screen.getByText("item-7")).toBeInTheDocument(); + expect(loader).toHaveBeenCalledTimes(1); + }); + + it("does not share loader results between instances", () => { + setLocation("http://localhost/a"); + const loader = vi.fn(() => "data"); + const routes = [ + route({ + path: "/a", + loader, + component: ({ data }: { data: string }) =>
{data}
, + }), + ]; + + render().unmount(); + render().unmount(); + expect(loader).toHaveBeenCalledTimes(2); + }); + + describe("SSR", () => { + it("renders only pathless routes without ssr prop", () => { + const routes: RouteDefinition[] = [ + { + component: () => ( +
+ Shell +
+ ), + children: [{ path: "/about", component: () => About }], + }, + ]; + + const html = renderToString(); + expect(html).toContain("Shell"); + expect(html).not.toContain("About"); + }); + + it("matches path-based routes with ssr.path", () => { + const routes: RouteDefinition[] = [ + { path: "/", component: () =>
Home
}, + { path: "/about", component: () =>
About
}, + ]; + + const html = renderToString(); + expect(html).toContain("About"); + expect(html).not.toContain("Home"); + }); + + it("skips routes with loaders unless runLoaders is set", () => { + const routes = [ + route({ + path: "/data", + loader: () => "loaded", + component: ({ data }: { data: string }) =>
{data}
, + }), + ]; + + expect( + renderToString(), + ).not.toContain("loaded"); + expect( + renderToString(), + ).toContain("loaded"); + }); + + it("hydrates server output and then switches to the real URL", async () => { + setLocation("http://localhost/about"); + const routes: RouteDefinition[] = [ + { + component: () => ( +
+ Shell +
+ ), + children: [{ path: "/about", component: () => About }], + }, + ]; + + const container = document.createElement("div"); + container.innerHTML = renderToString(); + document.body.appendChild(container); + expect(container.textContent).not.toContain("About"); + + const onRecoverableError = vi.fn(); + let root: ReturnType | undefined; + await act(async () => { + root = hydrateRoot(container, , { + onRecoverableError, + }); + }); + + expect(onRecoverableError).not.toHaveBeenCalled(); + expect(container.textContent).toContain("About"); + + act(() => root!.unmount()); + container.remove(); + }); + + it("reuses loader results from hydration after switching to the real URL", async () => { + setLocation("http://localhost/data"); + const loader = vi.fn(() => "loaded"); + const routes = [ + route({ + path: "/data", + loader, + component: ({ data }: { data: string }) =>
{data}
, + }), + ]; + const ssr = { path: "/data", runLoaders: true }; + + const container = document.createElement("div"); + container.innerHTML = renderToString(); + document.body.appendChild(container); + loader.mockClear(); + + const onRecoverableError = vi.fn(); + let root: ReturnType | undefined; + await act(async () => { + root = hydrateRoot(container, , { + onRecoverableError, + }); + }); + + expect(onRecoverableError).not.toHaveBeenCalled(); + expect(container.textContent).toBe("loaded"); + expect(loader).toHaveBeenCalledTimes(1); + + act(() => root!.unmount()); + container.remove(); + }); + }); +}); diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 86a65c9..f94e6ce 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -4,6 +4,7 @@ // Components export { Router, type RouterProps, type RouterFeatures, type SSRConfig } from "./Router/index.js"; +export { StaticRouter, type StaticRouterProps } from "./StaticRouter/index.js"; export { Outlet } from "./Outlet.js"; // Hooks From 6362642faea96e0cda689a627d5acd43c521b1f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:04:20 +0000 Subject: [PATCH 2/4] feat: rename StaticRouter to RouteDisplay, navigate with full page loads, 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 Claude-Session: https://claude.ai/code/session_01AoUA6HQioyuW5Y6iX8wvZZ --- packages/docs/src/pages/ApiComponentsPage.tsx | 39 ++-- packages/docs/src/pages/LearnSsrBasicPage.tsx | 6 +- .../skills/funstack-router-knowledge/SKILL.md | 2 +- .../{StaticRouter => RouteDisplay}/index.tsx | 107 +++++----- .../router/src/RouteDisplay/locationSource.ts | 140 ++++++++++++ ...cRouter.test.tsx => RouteDisplay.test.tsx} | 199 ++++++++++++++---- packages/router/src/index.ts | 2 +- 7 files changed, 381 insertions(+), 114 deletions(-) rename packages/router/src/{StaticRouter => RouteDisplay}/index.tsx (58%) create mode 100644 packages/router/src/RouteDisplay/locationSource.ts rename packages/router/src/__tests__/{StaticRouter.test.tsx => RouteDisplay.test.tsx} (51%) diff --git a/packages/docs/src/pages/ApiComponentsPage.tsx b/packages/docs/src/pages/ApiComponentsPage.tsx index 4f25631..e4a90a8 100644 --- a/packages/docs/src/pages/ApiComponentsPage.tsx +++ b/packages/docs/src/pages/ApiComponentsPage.tsx @@ -6,7 +6,7 @@ export function ApiComponentsPage() {

Components

Core components for building routing in your React application.

@@ -140,29 +140,36 @@ export function ApiComponentsPage() {

- {""} + {""}

- A router that only renders the routes matching the current URL. Unlike{" "} - {""}, it does not subscribe to or intercept navigations: links and - form submissions cause regular full page loads. Use it for multi-page applications (MPAs) - where each page is a separate document but still uses route definitions to decide what to - render. It does not depend on the Navigation API, so it works in all browsers. + Renders the routes matching the current URL, without client-side routing. Unlike{" "} + {""}, it does not intercept navigations: links and form submissions + cause regular full page loads. Use it for multi-page applications (MPAs) where each page + is a separate document but still uses route definitions to decide what to render. It works + in all browsers, with or without the Navigation API.

- {`import { StaticRouter } from "@funstack/router"; + {`import { RouteDisplay } from "@funstack/router"; -// Client: matches routes against window.location - +// Client: matches routes against the current page URL + // Server: matches routes against the given path -`} +`}

Hooks that read the location (useLocation, useSearchParams,{" "} - useRouteParams, useRouteData, etc.) work as usual. Features that - rely on client-side navigation are inert: useBlocker never blocks, route - state is always undefined, useIsPending is always{" "} - false, and route actions are not intercepted. Setters that would navigate - (such as the one from useSearchParams) log a warning and do nothing. + useRouteParams, useRouteData, etc.) work as usual. When the + Navigation API is available, the current entry's id, key and state are exposed, so route + state works too; without it, they are null / undefined. +

+

+ Setters that navigate (such as the one from useSearchParams, or a route's{" "} + setState) perform a full page navigation. With the Navigation API, the new + entry keeps the given state; without it, the state is lost. setStateSync{" "} + updates the current entry's state in place without navigating (Navigation API only). + Features that depend on intercepting navigations are inert: useBlocker never + blocks, useIsPending is always false, and route actions are not + run (forms submit to the server).

Props

diff --git a/packages/docs/src/pages/LearnSsrBasicPage.tsx b/packages/docs/src/pages/LearnSsrBasicPage.tsx index 969a674..8207423 100644 --- a/packages/docs/src/pages/LearnSsrBasicPage.tsx +++ b/packages/docs/src/pages/LearnSsrBasicPage.tsx @@ -191,10 +191,10 @@ function AppShell() {

If you are building a multi-page application and never want client-side navigation, use{" "} - {""} + {""} {" "} - instead. It always behaves like static fallback mode, whether or not the Navigation API is - available. + instead. It never intercepts navigations, whether or not the Navigation API is available, + and performs navigations requested by hooks as full page loads.

diff --git a/packages/router/skills/funstack-router-knowledge/SKILL.md b/packages/router/skills/funstack-router-knowledge/SKILL.md index c780dc3..a2dd9a4 100644 --- a/packages/router/skills/funstack-router-knowledge/SKILL.md +++ b/packages/router/skills/funstack-router-knowledge/SKILL.md @@ -9,7 +9,7 @@ description: Use this skill when you need information about `@funstack/router` ( ## Entrypoints -- `@funstack/router` — Main entrypoint. Provides `Router`, `StaticRouter` (route rendering without navigation interception, for MPAs), `Outlet`, hooks (`useLocation`, `useRouteParams`, etc.), and route definition utilities (`route()`, `routeState()`). +- `@funstack/router` — Main entrypoint. Provides `Router`, `RouteDisplay` (renders matched routes without client-side routing, for MPAs), `Outlet`, hooks (`useLocation`, `useRouteParams`, etc.), and route definition utilities (`route()`, `routeState()`). - `@funstack/router/server` — Entrypoint for Server context when you are using React Server Components. Provides `route()` and `routeState()` utilities for defining routes in server modules. ## FUNSTACK Router Docs diff --git a/packages/router/src/StaticRouter/index.tsx b/packages/router/src/RouteDisplay/index.tsx similarity index 58% rename from packages/router/src/StaticRouter/index.tsx rename to packages/router/src/RouteDisplay/index.tsx index 4a4f1ec..ba8eeea 100644 --- a/packages/router/src/StaticRouter/index.tsx +++ b/packages/router/src/RouteDisplay/index.tsx @@ -1,16 +1,16 @@ -import { type ReactNode, useCallback, useMemo, useState, useSyncExternalStore } from "react"; +import { type ReactNode, useMemo, useState, useSyncExternalStore } from "react"; import { RouterContext, type RouterContextValue } from "../context/RouterContext.js"; import { BlockerContext, createBlockerRegistry } from "../context/BlockerContext.js"; -import { type NavigateOptions, type TrailingSlashMode, internalRoutes } from "../types.js"; +import { type TrailingSlashMode, internalRoutes } from "../types.js"; import { getBrowserFn } from "../core/browserBailout.js"; import { matchRoutes } from "../core/matchRoutes.js"; import { executeLoaders, createLoaderRequest } from "../core/loaderCache.js"; import type { RouteDefinition } from "../route.js"; import type { RouterFeatures, SSRConfig } from "../Router/index.js"; -import { noopSubscribe } from "../Router/ServerLocationSnapshot.js"; import { RouteRenderer } from "../Router/RouteRenderer.js"; +import { createDisplayLocationSource } from "./locationSource.js"; -export type StaticRouterProps = { +export type RouteDisplayProps = { routes: RouteDefinition[]; /** * SSR configuration for the router. @@ -37,32 +37,39 @@ export type StaticRouterProps = { }; /** - * Loader cache key used for every render of a StaticRouter. The URL never - * changes during the lifetime of a StaticRouter, and the cache is scoped to - * the instance, so a single key suffices. Using the same key for the - * hydration render and the post-hydration render lets loader results from - * hydration be reused instead of re-executed. + * Loader cache key used for every render of a RouteDisplay. The document + * (and thus the pathname, barring same-document URL changes made outside the + * router) doesn't change during the lifetime of a RouteDisplay, and the cache + * is scoped to the instance and keyed by params as well, so a single key + * suffices. Using the same key for the hydration render and the + * post-hydration render lets loader results from hydration be reused instead + * of re-executed, and fragment navigations and entry state updates don't + * re-run loaders. */ -const staticLoaderCacheKey = "__static__"; +const displayLoaderCacheKey = "__display__"; const getServerSnapshot = () => null; /** - * Router that only renders the routes matching the current URL. + * Renders the routes matching the current URL, without client-side routing. * - * Unlike `Router`, it does not subscribe to or intercept navigations: - * links and form submissions cause regular full page loads. This makes it - * suitable for multi-page applications (MPAs), where each page is a separate - * document but still uses route definitions to decide what to render. + * Unlike `Router`, it does not intercept navigations: links and form + * submissions cause regular full page loads, and navigations requested by + * hooks (e.g. the setter of `useSearchParams`) are performed as full page + * navigations. This makes it suitable for multi-page applications (MPAs), + * where each page is a separate document but still uses route definitions + * to decide what to render. * - * It does not depend on the Navigation API, so it works in all browsers. + * When the Navigation API is available, the current entry's id, key and + * state are exposed (so route state works); otherwise, it falls back to + * `window.location` and works in all browsers. */ -export function StaticRouter({ +export function RouteDisplay({ routes: inputRoutes, ssr, trailingSlash, features, -}: StaticRouterProps): ReactNode { +}: RouteDisplayProps): ReactNode { const routes = internalRoutes(inputRoutes); const pathlessSSROutletDeferral = features?.pathlessSSROutletDeferral ?? false; @@ -78,14 +85,13 @@ export function StaticRouter({ [pathlessSSROutletDeferral], ); - // The page URL never changes without a full page load, so it is read once - // and cached for this instance. - const [getSnapshot] = useState(() => { - let cachedUrl: string | null = null; - return () => (cachedUrl ??= window.location.href); - }); - // null during SSR/hydration; React re-renders with the real URL after hydration. - const clientUrl = useSyncExternalStore(noopSubscribe, getSnapshot, getServerSnapshot); + const [locationSource] = useState(createDisplayLocationSource); + // null during SSR/hydration; React re-renders with the real location after hydration. + const clientLocation = useSyncExternalStore( + locationSource.subscribe, + locationSource.getSnapshot, + getServerSnapshot, + ); // useBlocker requires a registry. Blockers never fire because no // navigation is intercepted. @@ -98,29 +104,18 @@ export function StaticRouter({ // the same process (SSR requests, SSG pages) don't share them. const [instanceLoaderCache] = useState(() => new Map()); - const navigateAsync = useCallback(async (_to: string, _options?: NavigateOptions) => { - console.warn( - "FUNSTACK Router: navigation was requested inside a StaticRouter. " + - "StaticRouter does not perform client-side navigation; " + - "use regular links or a Router instead.", - ); - }, []); - - const updateCurrentEntryState = useCallback((_state: unknown) => { - // No-op: StaticRouter does not manage navigation entry state - }, []); - - const url = useMemo(() => { - if (clientUrl !== null) { - return clientUrl; - } - if (ssr) { - const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost"; - return new URL(ssr.path, origin).toString(); - } - return null; - }, [clientUrl, ssr]); - const urlObject = useMemo(() => (url ? new URL(url) : null), [url]); + // Keyed by href so that state-only entry updates don't re-run matching. + const clientHref = clientLocation?.url.href ?? null; + const clientUrl = useMemo(() => (clientHref === null ? null : new URL(clientHref)), [clientHref]); + // Keyed by the path string so that an inline `ssr` object doesn't create a + // new URL (and re-run matching) on every render. + const ssrPath = ssr?.path; + const ssrUrl = useMemo(() => { + if (ssrPath === undefined) return null; + const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost"; + return new URL(ssrPath, origin); + }, [ssrPath]); + const urlObject = clientUrl ?? ssrUrl; /** * Whether to run loaders. Loaders always run on the client; during SSR @@ -147,7 +142,7 @@ export function StaticRouter({ return executeLoaders( matched, - staticLoaderCacheKey, + displayLoaderCacheKey, createLoaderRequest(urlObject), idleSignal, undefined, @@ -157,17 +152,17 @@ export function StaticRouter({ const routerContextValue: RouterContextValue = useMemo( () => ({ - locationState: undefined, + locationState: clientLocation?.state, locationInfo: undefined, url: urlObject, - entryId: null, - entryKey: null, + entryId: clientLocation?.entryId ?? null, + entryKey: clientLocation?.entryKey ?? null, isPending: false, - navigateAsync, - updateCurrentEntryState, + navigateAsync: locationSource.navigateAsync, + updateCurrentEntryState: locationSource.updateCurrentEntryState, features: resolvedFeatures, }), - [urlObject, navigateAsync, updateCurrentEntryState, resolvedFeatures], + [clientLocation, urlObject, locationSource, resolvedFeatures], ); return useMemo(() => { diff --git a/packages/router/src/RouteDisplay/locationSource.ts b/packages/router/src/RouteDisplay/locationSource.ts new file mode 100644 index 0000000..0794acd --- /dev/null +++ b/packages/router/src/RouteDisplay/locationSource.ts @@ -0,0 +1,140 @@ +import type { NavigateOptions } from "../types.js"; + +/** + * The location a RouteDisplay renders. + */ +export type DisplayLocation = { + url: URL; + /** NavigationHistoryEntry.id, or null when Navigation API is unavailable */ + entryId: string | null; + /** NavigationHistoryEntry.key, or null when Navigation API is unavailable */ + entryKey: string | null; + /** State of the current entry, or undefined when Navigation API is unavailable */ + state: unknown; +}; + +/** + * Source of the current location for RouteDisplay. + * + * Unlike RouterAdapter, it never intercepts navigations: navigations + * requested through it are regular (usually cross-document) navigations. + * Methods are bound so they can be passed around directly. + */ +export type DisplayLocationSource = { + /** Returns the current location, or null when unavailable (SSR). */ + getSnapshot: () => DisplayLocation | null; + /** Subscribes to changes of the current entry (e.g. state updates, fragment navigations). */ + subscribe: (callback: () => void) => () => void; + /** Performs a full page navigation. */ + navigateAsync: (to: string, options?: NavigateOptions) => Promise; + /** Updates the state of the current entry without navigation. */ + updateCurrentEntryState: (state: unknown) => void; +}; + +/** + * Create a location source based on the environment: + * - Navigation API available: reads entries from `navigation.currentEntry` + * - Browser without Navigation API: reads the URL from `window.location` + * - No window (SSR): no location + */ +export function createDisplayLocationSource(): DisplayLocationSource { + if (typeof window === "undefined") { + return createServerSource(); + } + if ("navigation" in window) { + return createNavigationAPISource(); + } + return createWindowLocationSource(); +} + +function createNavigationAPISource(): DisplayLocationSource { + const nav = navigation; + let cachedSnapshot: DisplayLocation | null = null; + + const getSnapshot = () => { + const entry = nav.currentEntry; + if (!entry?.url) { + return null; + } + // getState() returns a fresh copy on each call, so the snapshot is cached + // until currententrychange fires. Entry id and URL are checked as well in + // case the event is missed. + if ( + cachedSnapshot === null || + cachedSnapshot.entryId !== entry.id || + cachedSnapshot.url.href !== entry.url + ) { + cachedSnapshot = { + url: new URL(entry.url), + entryId: entry.id, + entryKey: entry.key, + state: entry.getState(), + }; + } + return cachedSnapshot; + }; + + return { + getSnapshot, + subscribe: (callback) => { + const listener = () => { + cachedSnapshot = null; + callback(); + }; + nav.addEventListener("currententrychange", listener); + return () => { + nav.removeEventListener("currententrychange", listener); + }; + }, + navigateAsync: async (to, options) => { + // Nothing intercepts this navigation, so the browser performs a regular + // navigation (a full page load unless only the fragment changes). + // `state` is carried over to the new entry; `info` is not, for + // cross-document navigations. + const result = nav.navigate(to, { + history: options?.replace ? "replace" : "push", + state: options?.state, + info: options?.info, + }); + await result.finished; + }, + updateCurrentEntryState: (state) => { + nav.updateCurrentEntry({ state }); + }, + }; +} + +function createWindowLocationSource(): DisplayLocationSource { + let cachedSnapshot: DisplayLocation | null = null; + + return { + getSnapshot: () => + (cachedSnapshot ??= { + url: new URL(window.location.href), + entryId: null, + entryKey: null, + state: undefined, + }), + subscribe: () => () => {}, + navigateAsync: async (to, options) => { + // Without Navigation API, `state` and `info` cannot be carried over. + if (options?.replace) { + window.location.replace(to); + } else { + window.location.assign(to); + } + }, + updateCurrentEntryState: () => { + // No-op: entry state requires Navigation API + }, + }; +} + +function createServerSource(): DisplayLocationSource { + return { + getSnapshot: () => null, + subscribe: () => () => {}, + navigateAsync: async () => {}, + updateCurrentEntryState: () => {}, + }; +} diff --git a/packages/router/src/__tests__/StaticRouter.test.tsx b/packages/router/src/__tests__/RouteDisplay.test.tsx similarity index 51% rename from packages/router/src/__tests__/StaticRouter.test.tsx rename to packages/router/src/__tests__/RouteDisplay.test.tsx index de9dcdc..8595c8f 100644 --- a/packages/router/src/__tests__/StaticRouter.test.tsx +++ b/packages/router/src/__tests__/RouteDisplay.test.tsx @@ -1,13 +1,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { act, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { renderToString } from "react-dom/server"; import { hydrateRoot } from "react-dom/client"; -import { StaticRouter } from "../StaticRouter/index.js"; +import { RouteDisplay } from "../RouteDisplay/index.js"; import { Outlet } from "../Outlet.js"; import { useLocation } from "../hooks/useLocation.js"; import { useSearchParams } from "../hooks/useSearchParams.js"; import { useBlocker } from "../hooks/useBlocker.js"; -import { route, type RouteDefinition } from "../route.js"; +import { route, routeState, type RouteComponentProps, type RouteDefinition } from "../route.js"; import { setupNavigationMock, cleanupNavigationMock } from "./setup.js"; const originalLocation = window.location; @@ -21,13 +21,18 @@ function setLocation(url: string) { search: urlObj.search, hash: urlObj.hash, origin: urlObj.origin, + assign: vi.fn(), + replace: vi.fn(), }, writable: true, configurable: true, }); } -describe("StaticRouter", () => { +type CountState = { count: number }; +type CountProps = RouteComponentProps, CountState>; + +describe("RouteDisplay", () => { beforeEach(() => { delete (globalThis as Record).navigation; }); @@ -48,7 +53,7 @@ describe("StaticRouter", () => { { path: "/about", component: () =>
About
}, ]; - render(); + render(); expect(screen.getByText("About")).toBeInTheDocument(); expect(screen.queryByText("Home")).toBeNull(); }); @@ -75,7 +80,7 @@ describe("StaticRouter", () => { }), ]; - render(); + render(); expect(screen.getByText("User 42")).toBeInTheDocument(); }); @@ -91,19 +96,10 @@ describe("StaticRouter", () => { ); } - render(); + render(); expect(screen.getByText("/search|#top|hello")).toBeInTheDocument(); }); - it("does not intercept navigations even when Navigation API is available", () => { - const mockNavigation = setupNavigationMock("http://localhost/"); - setLocation("http://localhost/"); - - render(
Home
}]} />); - expect(screen.getByText("Home")).toBeInTheDocument(); - expect(mockNavigation.addEventListener).not.toHaveBeenCalled(); - }); - it("allows useBlocker inside routes", () => { setLocation("http://localhost/"); function Page() { @@ -111,23 +107,29 @@ describe("StaticRouter", () => { return
Page
; } - render(); + render(); expect(screen.getByText("Page")).toBeInTheDocument(); }); - it("warns instead of navigating when navigation is requested", () => { - setLocation("http://localhost/"); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + it("performs full page navigations from setters without Navigation API", () => { + setLocation("http://localhost/?a=1"); let setSearchParams: ReturnType[1] | undefined; - function Page() { + function Page({ setState }: CountProps) { setSearchParams = useSearchParams()[1]; - return
Page
; + return ; } + const routes = [routeState()({ path: "/", component: Page })]; + + render(); + + setSearchParams!({ q: "x" }, { replace: false }); + expect(window.location.assign).toHaveBeenCalledWith("/?q=x"); + + setSearchParams!({ q: "y" }); + expect(window.location.replace).toHaveBeenCalledWith("/?q=y"); - render(); - setSearchParams!({ q: "x" }); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("StaticRouter")); - warn.mockRestore(); + fireEvent.click(screen.getByText("Set")); + expect(window.location.replace).toHaveBeenCalledWith("http://localhost/?a=1"); }); it("runs loaders and passes data to components", () => { @@ -141,7 +143,7 @@ describe("StaticRouter", () => { }), ]; - render(); + render(); expect(screen.getByText("item-7")).toBeInTheDocument(); expect(loader).toHaveBeenCalledTimes(1); }); @@ -157,11 +159,103 @@ describe("StaticRouter", () => { }), ]; - render().unmount(); - render().unmount(); + render().unmount(); + render().unmount(); expect(loader).toHaveBeenCalledTimes(2); }); + describe("with Navigation API", () => { + it("does not intercept navigations", () => { + const mockNavigation = setupNavigationMock("http://localhost/"); + + render(
Home
}]} />); + expect(screen.getByText("Home")).toBeInTheDocument(); + expect(mockNavigation.addEventListener).not.toHaveBeenCalledWith( + "navigate", + expect.anything(), + ); + }); + + it("exposes the entry id and key", () => { + const mockNavigation = setupNavigationMock("http://localhost/"); + function Page() { + const location = useLocation(); + return ( +
+ {location.entryId}|{location.entryKey} +
+ ); + } + + render(); + const entry = mockNavigation.currentEntry; + expect(screen.getByText(`${entry.id}|${entry.key}`)).toBeInTheDocument(); + }); + + it("exposes route state of the current entry", () => { + const mockNavigation = setupNavigationMock("http://localhost/"); + mockNavigation.currentEntry.__updateState({ + __routeStates: [{ count: 3 }], + }); + function Page({ state }: CountProps) { + return
Count: {state?.count ?? "none"}
; + } + const routes = [routeState()({ path: "/", component: Page })]; + + render(); + expect(screen.getByText("Count: 3")).toBeInTheDocument(); + }); + + it("updates route state in place with setStateSync", () => { + const mockNavigation = setupNavigationMock("http://localhost/"); + function Page({ state, setStateSync }: CountProps) { + return ( + + ); + } + const routes = [routeState()({ path: "/", component: Page })]; + + render(); + fireEvent.click(screen.getByText("Count: 0")); + expect(mockNavigation.updateCurrentEntry).toHaveBeenCalledWith({ + state: { __routeStates: [{ count: 1 }] }, + }); + expect(screen.getByText("Count: 1")).toBeInTheDocument(); + }); + + it("navigates via navigation.navigate from setters", async () => { + const mockNavigation = setupNavigationMock("http://localhost/"); + let setSearchParams: ReturnType[1] | undefined; + function Page({ setState }: CountProps) { + setSearchParams = useSearchParams()[1]; + return ; + } + const routes = [routeState()({ path: "/", component: Page })]; + + render(); + + await act(async () => { + fireEvent.click(screen.getByText("Set")); + }); + expect(mockNavigation.navigate).toHaveBeenLastCalledWith("http://localhost/", { + history: "replace", + state: { __routeStates: [{ count: 1 }] }, + info: undefined, + }); + + await act(async () => { + setSearchParams!({ q: "x" }, { replace: false }); + }); + expect(mockNavigation.navigate).toHaveBeenLastCalledWith("/?q=x", { + history: "push", + state: undefined, + info: undefined, + }); + }); + }); + describe("SSR", () => { it("renders only pathless routes without ssr prop", () => { const routes: RouteDefinition[] = [ @@ -175,7 +269,7 @@ describe("StaticRouter", () => { }, ]; - const html = renderToString(); + const html = renderToString(); expect(html).toContain("Shell"); expect(html).not.toContain("About"); }); @@ -186,7 +280,7 @@ describe("StaticRouter", () => { { path: "/about", component: () =>
About
}, ]; - const html = renderToString(); + const html = renderToString(); expect(html).toContain("About"); expect(html).not.toContain("Home"); }); @@ -201,10 +295,10 @@ describe("StaticRouter", () => { ]; expect( - renderToString(), + renderToString(), ).not.toContain("loaded"); expect( - renderToString(), + renderToString(), ).toContain("loaded"); }); @@ -222,14 +316,14 @@ describe("StaticRouter", () => { ]; const container = document.createElement("div"); - container.innerHTML = renderToString(); + container.innerHTML = renderToString(); document.body.appendChild(container); expect(container.textContent).not.toContain("About"); const onRecoverableError = vi.fn(); let root: ReturnType | undefined; await act(async () => { - root = hydrateRoot(container, , { + root = hydrateRoot(container, , { onRecoverableError, }); }); @@ -241,6 +335,37 @@ describe("StaticRouter", () => { container.remove(); }); + it("hydrates with Navigation API and then exposes entry state", async () => { + const mockNavigation = setupNavigationMock("http://localhost/"); + mockNavigation.currentEntry.__updateState({ + __routeStates: [{ count: 3 }], + }); + function Page({ state }: CountProps) { + return
Count: {state?.count ?? "none"}
; + } + const routes = [routeState()({ path: "/", component: Page })]; + const ssr = { path: "/" }; + + const container = document.createElement("div"); + container.innerHTML = renderToString(); + document.body.appendChild(container); + expect(container.textContent).toBe("Count: none"); + + const onRecoverableError = vi.fn(); + let root: ReturnType | undefined; + await act(async () => { + root = hydrateRoot(container, , { + onRecoverableError, + }); + }); + + expect(onRecoverableError).not.toHaveBeenCalled(); + expect(container.textContent).toBe("Count: 3"); + + act(() => root!.unmount()); + container.remove(); + }); + it("reuses loader results from hydration after switching to the real URL", async () => { setLocation("http://localhost/data"); const loader = vi.fn(() => "loaded"); @@ -254,14 +379,14 @@ describe("StaticRouter", () => { const ssr = { path: "/data", runLoaders: true }; const container = document.createElement("div"); - container.innerHTML = renderToString(); + container.innerHTML = renderToString(); document.body.appendChild(container); loader.mockClear(); const onRecoverableError = vi.fn(); let root: ReturnType | undefined; await act(async () => { - root = hydrateRoot(container, , { + root = hydrateRoot(container, , { onRecoverableError, }); }); diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index f94e6ce..553b90b 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -4,7 +4,7 @@ // Components export { Router, type RouterProps, type RouterFeatures, type SSRConfig } from "./Router/index.js"; -export { StaticRouter, type StaticRouterProps } from "./StaticRouter/index.js"; +export { RouteDisplay, type RouteDisplayProps } from "./RouteDisplay/index.js"; export { Outlet } from "./Outlet.js"; // Hooks From 668beedaae8b353cc8f748263b1d17d04711ff00 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:25:02 +0000 Subject: [PATCH 3/4] perf: make addTransitionType lookup tree-shakeable 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 Claude-Session: https://claude.ai/code/session_01AoUA6HQioyuW5Y6iX8wvZZ --- packages/router/src/core/addTransitionType.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/router/src/core/addTransitionType.ts b/packages/router/src/core/addTransitionType.ts index aeac5aa..ff7d885 100644 --- a/packages/router/src/core/addTransitionType.ts +++ b/packages/router/src/core/addTransitionType.ts @@ -2,14 +2,19 @@ import * as React from "react"; type AddTransitionType = (type: string) => void; -const reactExports = React as unknown as Record; -const candidate = reactExports.addTransitionType ?? reactExports.unstable_addTransitionType; +function resolveAddTransitionType(): AddTransitionType { + const reactExports = React as unknown as Record; + const candidate = reactExports.addTransitionType ?? reactExports.unstable_addTransitionType; + return typeof candidate === "function" ? (candidate as AddTransitionType) : () => {}; +} /** * Wrapper around React's `addTransitionType` that degrades to a no-op when * the host React build doesn't expose the API (e.g. React 19.2). * `addTransitionType` is available in React starting with 19.3; older Canary * builds export it as `unstable_addTransitionType`. + * + * Resolved via a pure-annotated call so bundlers can drop the lookup when + * `Router` is not used (e.g. apps that only use `RouteDisplay`). */ -export const addTransitionType: AddTransitionType = - typeof candidate === "function" ? (candidate as AddTransitionType) : () => {}; +export const addTransitionType: AddTransitionType = /* @__PURE__ */ resolveAddTransitionType(); From b2ce30fda5e28e6415156d8165a7e23f1e1a9ab7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:55:13 +0000 Subject: [PATCH 4/4] feat: make RouteDisplay follow the location the same way as Router 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 Claude-Session: https://claude.ai/code/session_01AoUA6HQioyuW5Y6iX8wvZZ --- packages/docs/src/pages/ApiComponentsPage.tsx | 11 +- packages/router/src/RouteDisplay/index.tsx | 128 +++++++------- .../router/src/RouteDisplay/locationSource.ts | 140 ---------------- packages/router/src/Router/index.tsx | 141 +--------------- .../router/src/Router/useLocationEntry.ts | 156 ++++++++++++++++++ .../src/__tests__/RouteDisplay.test.tsx | 57 +++++-- packages/router/src/core/StaticAdapter.ts | 26 ++- packages/router/src/core/createAdapter.ts | 12 ++ 8 files changed, 315 insertions(+), 356 deletions(-) delete mode 100644 packages/router/src/RouteDisplay/locationSource.ts create mode 100644 packages/router/src/Router/useLocationEntry.ts diff --git a/packages/docs/src/pages/ApiComponentsPage.tsx b/packages/docs/src/pages/ApiComponentsPage.tsx index e4a90a8..121a622 100644 --- a/packages/docs/src/pages/ApiComponentsPage.tsx +++ b/packages/docs/src/pages/ApiComponentsPage.tsx @@ -158,9 +158,11 @@ export function ApiComponentsPage() { `}

Hooks that read the location (useLocation, useSearchParams,{" "} - useRouteParams, useRouteData, etc.) work as usual. When the - Navigation API is available, the current entry's id, key and state are exposed, so route - state works too; without it, they are null / undefined. + useRouteParams, useRouteData, etc.) work as usual. It follows + the current location the same way as {""}: when the Navigation API is + available, the current entry's id, key and state are exposed (so route state works too), + and same-document entry changes such as fragment navigations are rendered in a transition. + Without the Navigation API, these values are null / undefined.

Setters that navigate (such as the one from useSearchParams, or a route's{" "} @@ -168,8 +170,7 @@ export function ApiComponentsPage() { entry keeps the given state; without it, the state is lost. setStateSync{" "} updates the current entry's state in place without navigating (Navigation API only). Features that depend on intercepting navigations are inert: useBlocker never - blocks, useIsPending is always false, and route actions are not - run (forms submit to the server). + blocks, and route actions are not run (forms submit to the server).

Props

diff --git a/packages/router/src/RouteDisplay/index.tsx b/packages/router/src/RouteDisplay/index.tsx index ba8eeea..95751f5 100644 --- a/packages/router/src/RouteDisplay/index.tsx +++ b/packages/router/src/RouteDisplay/index.tsx @@ -1,14 +1,15 @@ -import { type ReactNode, useMemo, useState, useSyncExternalStore } from "react"; +import { type ReactNode, useMemo, useState } from "react"; import { RouterContext, type RouterContextValue } from "../context/RouterContext.js"; import { BlockerContext, createBlockerRegistry } from "../context/BlockerContext.js"; import { type TrailingSlashMode, internalRoutes } from "../types.js"; import { getBrowserFn } from "../core/browserBailout.js"; import { matchRoutes } from "../core/matchRoutes.js"; +import { createDisplayAdapter } from "../core/createAdapter.js"; import { executeLoaders, createLoaderRequest } from "../core/loaderCache.js"; import type { RouteDefinition } from "../route.js"; import type { RouterFeatures, SSRConfig } from "../Router/index.js"; import { RouteRenderer } from "../Router/RouteRenderer.js"; -import { createDisplayLocationSource } from "./locationSource.js"; +import { useLocationEntry } from "../Router/useLocationEntry.js"; export type RouteDisplayProps = { routes: RouteDefinition[]; @@ -36,20 +37,6 @@ export type RouteDisplayProps = { features?: RouterFeatures; }; -/** - * Loader cache key used for every render of a RouteDisplay. The document - * (and thus the pathname, barring same-document URL changes made outside the - * router) doesn't change during the lifetime of a RouteDisplay, and the cache - * is scoped to the instance and keyed by params as well, so a single key - * suffices. Using the same key for the hydration render and the - * post-hydration render lets loader results from hydration be reused instead - * of re-executed, and fragment navigations and entry state updates don't - * re-run loaders. - */ -const displayLoaderCacheKey = "__display__"; - -const getServerSnapshot = () => null; - /** * Renders the routes matching the current URL, without client-side routing. * @@ -60,9 +47,11 @@ const getServerSnapshot = () => null; * where each page is a separate document but still uses route definitions * to decide what to render. * - * When the Navigation API is available, the current entry's id, key and - * state are exposed (so route state works); otherwise, it falls back to - * `window.location` and works in all browsers. + * It follows the current location the same way as `Router`: when the + * Navigation API is available, it reacts to current entry changes (e.g. + * entry state updates and fragment navigations) and exposes the entry's id, + * key and state; otherwise, it reads `window.location` and works in all + * browsers. */ export function RouteDisplay({ routes: inputRoutes, @@ -85,43 +74,40 @@ export function RouteDisplay({ [pathlessSSROutletDeferral], ); - const [locationSource] = useState(createDisplayLocationSource); - // null during SSR/hydration; React re-renders with the real location after hydration. - const clientLocation = useSyncExternalStore( - locationSource.subscribe, - locationSource.getSnapshot, - getServerSnapshot, - ); + // Same adapters as Router, but interception is never set up + const [adapter] = useState(createDisplayAdapter); // useBlocker requires a registry. Blockers never fire because no // navigation is intercepted. const [blockerRegistry] = useState(() => createBlockerRegistry()); - // Loaders are never aborted: there is no subsequent navigation. - const [idleSignal] = useState(() => new AbortController().signal); - - // Loader results are scoped to this instance so that multiple renders in - // the same process (SSR requests, SSG pages) don't share them. - const [instanceLoaderCache] = useState(() => new Map()); + const { locationEntry, actualLocationEntry, isPending } = useLocationEntry(adapter); - // Keyed by href so that state-only entry updates don't re-run matching. - const clientHref = clientLocation?.url.href ?? null; - const clientUrl = useMemo(() => (clientHref === null ? null : new URL(clientHref)), [clientHref]); - // Keyed by the path string so that an inline `ssr` object doesn't create a - // new URL (and re-run matching) on every render. - const ssrPath = ssr?.path; - const ssrUrl = useMemo(() => { - if (ssrPath === undefined) return null; - const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost"; - return new URL(ssrPath, origin); - }, [ssrPath]); - const urlObject = clientUrl ?? ssrUrl; + const url = useMemo(() => { + if (locationEntry) { + return locationEntry.url.toString(); + } + if (ssr) { + const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost"; + return new URL(ssr.path, origin).toString(); + } + return null; + }, [locationEntry, ssr]); + const urlObject = useMemo(() => (url ? new URL(url) : null), [url]); /** - * Whether to run loaders. Loaders always run on the client; during SSR - * (and hydration) only when ssr.runLoaders is true and ssr.path is given. + * Whether to run loaders. Loaders always run when a location entry is + * available; during SSR (and hydration) only when ssr.runLoaders is true + * and ssr.path is given. */ - const runLoaders = clientUrl !== null || (!!ssr?.runLoaders && urlObject !== null); + const runLoaders = locationEntry !== null || (!!ssr?.runLoaders && urlObject !== null); + + // Loader cache keying is the same as Router's: the adapter's entry key, + // or an instance-scoped cache when no entry is available (SSR), so that + // renders in the same process don't share loader results. + const realLocationKey = actualLocationEntry?.key ?? null; + const locationKey = realLocationKey ?? "ssr"; + const [instanceLoaderCache] = useState(() => new Map()); const matchedRoutesWithData = useMemo(() => { if (!runLoaders) { @@ -142,27 +128,49 @@ export function RouteDisplay({ return executeLoaders( matched, - displayLoaderCacheKey, + locationKey, createLoaderRequest(urlObject), - idleSignal, + adapter.getIdleAbortSignal(), undefined, - instanceLoaderCache, + realLocationKey === null ? instanceLoaderCache : undefined, ); - }, [routes, urlObject, runLoaders, trailingSlash, idleSignal, instanceLoaderCache]); - + }, [ + routes, + adapter, + urlObject, + runLoaders, + trailingSlash, + locationKey, + realLocationKey, + instanceLoaderCache, + ]); + + const locationState = locationEntry?.state; + const locationInfo = locationEntry?.info; + const entryId = actualLocationEntry?.entryId ?? null; + const entryKey = actualLocationEntry?.entryKey ?? null; const routerContextValue: RouterContextValue = useMemo( () => ({ - locationState: clientLocation?.state, - locationInfo: undefined, + locationState, + locationInfo, url: urlObject, - entryId: clientLocation?.entryId ?? null, - entryKey: clientLocation?.entryKey ?? null, - isPending: false, - navigateAsync: locationSource.navigateAsync, - updateCurrentEntryState: locationSource.updateCurrentEntryState, + entryId, + entryKey, + isPending, + navigateAsync: (to, options) => adapter.navigateAsync(to, options), + updateCurrentEntryState: (state) => adapter.updateCurrentEntryState(state), features: resolvedFeatures, }), - [clientLocation, urlObject, locationSource, resolvedFeatures], + [ + locationState, + locationInfo, + urlObject, + entryId, + entryKey, + isPending, + adapter, + resolvedFeatures, + ], ); return useMemo(() => { diff --git a/packages/router/src/RouteDisplay/locationSource.ts b/packages/router/src/RouteDisplay/locationSource.ts deleted file mode 100644 index 0794acd..0000000 --- a/packages/router/src/RouteDisplay/locationSource.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { NavigateOptions } from "../types.js"; - -/** - * The location a RouteDisplay renders. - */ -export type DisplayLocation = { - url: URL; - /** NavigationHistoryEntry.id, or null when Navigation API is unavailable */ - entryId: string | null; - /** NavigationHistoryEntry.key, or null when Navigation API is unavailable */ - entryKey: string | null; - /** State of the current entry, or undefined when Navigation API is unavailable */ - state: unknown; -}; - -/** - * Source of the current location for RouteDisplay. - * - * Unlike RouterAdapter, it never intercepts navigations: navigations - * requested through it are regular (usually cross-document) navigations. - * Methods are bound so they can be passed around directly. - */ -export type DisplayLocationSource = { - /** Returns the current location, or null when unavailable (SSR). */ - getSnapshot: () => DisplayLocation | null; - /** Subscribes to changes of the current entry (e.g. state updates, fragment navigations). */ - subscribe: (callback: () => void) => () => void; - /** Performs a full page navigation. */ - navigateAsync: (to: string, options?: NavigateOptions) => Promise; - /** Updates the state of the current entry without navigation. */ - updateCurrentEntryState: (state: unknown) => void; -}; - -/** - * Create a location source based on the environment: - * - Navigation API available: reads entries from `navigation.currentEntry` - * - Browser without Navigation API: reads the URL from `window.location` - * - No window (SSR): no location - */ -export function createDisplayLocationSource(): DisplayLocationSource { - if (typeof window === "undefined") { - return createServerSource(); - } - if ("navigation" in window) { - return createNavigationAPISource(); - } - return createWindowLocationSource(); -} - -function createNavigationAPISource(): DisplayLocationSource { - const nav = navigation; - let cachedSnapshot: DisplayLocation | null = null; - - const getSnapshot = () => { - const entry = nav.currentEntry; - if (!entry?.url) { - return null; - } - // getState() returns a fresh copy on each call, so the snapshot is cached - // until currententrychange fires. Entry id and URL are checked as well in - // case the event is missed. - if ( - cachedSnapshot === null || - cachedSnapshot.entryId !== entry.id || - cachedSnapshot.url.href !== entry.url - ) { - cachedSnapshot = { - url: new URL(entry.url), - entryId: entry.id, - entryKey: entry.key, - state: entry.getState(), - }; - } - return cachedSnapshot; - }; - - return { - getSnapshot, - subscribe: (callback) => { - const listener = () => { - cachedSnapshot = null; - callback(); - }; - nav.addEventListener("currententrychange", listener); - return () => { - nav.removeEventListener("currententrychange", listener); - }; - }, - navigateAsync: async (to, options) => { - // Nothing intercepts this navigation, so the browser performs a regular - // navigation (a full page load unless only the fragment changes). - // `state` is carried over to the new entry; `info` is not, for - // cross-document navigations. - const result = nav.navigate(to, { - history: options?.replace ? "replace" : "push", - state: options?.state, - info: options?.info, - }); - await result.finished; - }, - updateCurrentEntryState: (state) => { - nav.updateCurrentEntry({ state }); - }, - }; -} - -function createWindowLocationSource(): DisplayLocationSource { - let cachedSnapshot: DisplayLocation | null = null; - - return { - getSnapshot: () => - (cachedSnapshot ??= { - url: new URL(window.location.href), - entryId: null, - entryKey: null, - state: undefined, - }), - subscribe: () => () => {}, - navigateAsync: async (to, options) => { - // Without Navigation API, `state` and `info` cannot be carried over. - if (options?.replace) { - window.location.replace(to); - } else { - window.location.assign(to); - } - }, - updateCurrentEntryState: () => { - // No-op: entry state requires Navigation API - }, - }; -} - -function createServerSource(): DisplayLocationSource { - return { - getSnapshot: () => null, - subscribe: () => () => {}, - navigateAsync: async () => {}, - updateCurrentEntryState: () => {}, - }; -} diff --git a/packages/router/src/Router/index.tsx b/packages/router/src/Router/index.tsx index 9d09c86..1766429 100644 --- a/packages/router/src/Router/index.tsx +++ b/packages/router/src/Router/index.tsx @@ -1,15 +1,4 @@ -import { - type ReactNode, - useCallback, - useEffect, - useEffectEvent, - useMemo, - useRef, - useState, - useSyncExternalStore, - useTransition, -} from "react"; -import type { LocationEntry } from "../core/RouterAdapter.js"; +import { type ReactNode, useCallback, useEffect, useEffectEvent, useMemo, useState } from "react"; import { RouterContext, type RouterContextValue } from "../context/RouterContext.js"; import { BlockerContext, createBlockerRegistry } from "../context/BlockerContext.js"; import { @@ -18,21 +7,15 @@ import { type OnNavigateCallback, type FallbackMode, type TrailingSlashMode, - type TransitionTypeContext, internalRoutes, } from "../types.js"; -import { addTransitionType } from "../core/addTransitionType.js"; import { getBrowserFn } from "../core/browserBailout.js"; import { matchRoutes } from "../core/matchRoutes.js"; import { createAdapter } from "../core/createAdapter.js"; import { executeLoaders, createLoaderRequest } from "../core/loaderCache.js"; import type { RouteDefinition } from "../route.js"; -import { - ServerLocationSnapshot, - isServerSnapshot, - noopSubscribe, -} from "./ServerLocationSnapshot.js"; import { RouteRenderer } from "./RouteRenderer.js"; +import { useLocationEntry } from "./useLocationEntry.js"; /** * SSR configuration for the router. @@ -198,104 +181,11 @@ export function Router({ // Create blocker registry once const [blockerRegistry] = useState(() => createBlockerRegistry()); - // Hydration-aware initial value: null during SSR/hydration, real on client-only mount - const getSnapshot = useCallback(() => adapter.getSnapshot(), [adapter]); - const serverSnapshotCacheRef = useRef(null); - const getServerSnapshot = useCallback(() => { - // During SSR/hydration, return special server snapshot object - // that captures the real snapshot at the time of first render. - // This allows us to detect hydration and sync to real snapshot on client. - return (serverSnapshotCacheRef.current ??= new ServerLocationSnapshot(adapter)); - }, [adapter]); - const initialEntry = useSyncExternalStore( - noopSubscribe, - getSnapshot, - getServerSnapshot, + const { locationEntry, actualLocationEntry, isPending } = useLocationEntry( + adapter, + transitionTypes ?? experimentalTransitionTypes, ); - const [isPending, startTransition] = useTransition(); - const [locationEntryInternal, setLocationEntry] = useState< - LocationEntry | null | ServerLocationSnapshot - >(initialEntry); - const locationEntry = isServerSnapshot(locationEntryInternal) ? null : locationEntryInternal; - - if (isServerSnapshot(locationEntryInternal) && !isServerSnapshot(initialEntry)) { - // On second hydrated render on client, sync state with real snapshot - // Rendering flow on hydration: - // 1. Hydrated Render 1: initialEntry = serverSnapshotSymbol, locationEntryInternal = serverSnapshotSymbol - // 2. Render 1 is committed - // 3. Hydrated Render 2: initialEntry = client snapshot, locationEntryInternal = serverSnapshotSymbol - // 4. This `if` block runs, updating state to client snapshot - // 5. Hydrated Render 2 (retry): initialEntry = client snapshot, locationEntryInternal = client snapshot - // 6. Render 2 is committed - setLocationEntry(initialEntry); - } - - // Resolve transition types for the current navigation. Wrapped in - // useEffectEvent so the subscription effect doesn't re-run when the - // `transitionTypes` prop identity changes. - const getTransitionTypes = useEffectEvent((context: TransitionTypeContext): readonly string[] => { - const getTypes = transitionTypes ?? experimentalTransitionTypes; - return getTypes ? getTypes(context) : ["navigation"]; - }); - - // Subscribe to navigation changes (conditionally wrapped in transition) - useEffect(() => { - return adapter.subscribe((change) => { - if (change.kind === "navigation") { - if (change.wait) { - // Intercepted form submission: the entry has committed but the - // action hasn't run yet. Rendering now would execute loaders - // without the action result, so wait (inside an async transition, - // keeping the old UI and isPending up) until the adapter has - // dispatched loaders with the action result. - const { wait, navigationType } = change; - // The entry is already committed, so the snapshot has the final - // URL. Read it now so transition types can be added synchronously - // inside the transition — addTransitionType is not supported - // after an await. - const committedSnapshot = adapter.getSnapshot(); - startTransition(async () => { - if (committedSnapshot) { - const types = getTransitionTypes({ - url: committedSnapshot.url, - navigationType, - }); - for (const type of types) { - addTransitionType(type); - } - } - await wait; - // Updates after the first await are not part of the transition - // unless wrapped in a nested startTransition call. - startTransition(() => { - // Re-read the snapshot: a superseding navigation may have - // resolved the wait, in which case this renders the newest entry. - setLocationEntry(adapter.getSnapshot()); - }); - }); - return; - } - const newSnapshot = adapter.getSnapshot(); - startTransition(() => { - if (newSnapshot) { - const types = getTransitionTypes({ - url: newSnapshot.url, - navigationType: change.navigationType, - }); - for (const type of types) { - addTransitionType(type); - } - } - setLocationEntry(newSnapshot); - }); - } else { - // State-only update: apply synchronously, no transition - setLocationEntry(adapter.getSnapshot()); - } - }); - }, [adapter, startTransition]); - // Wrap in useEffectEvent so interception doesn't re-setup when routes, // onNavigate, or trailingSlash change const getRoutes = useEffectEvent(() => routes); @@ -357,12 +247,7 @@ export function Router({ * Key of location provided by the adapter, or null when no location entry * is available (SSR, or Navigation API unavailable with fallback="none"). */ - const realLocationKey = - locationEntry?.key ?? - (isServerSnapshot(locationEntryInternal) - ? locationEntryInternal.actualLocationEntry?.key - : null) ?? - null; + const realLocationKey = actualLocationEntry?.key ?? null; /** * Key of location. This is used as the cache key for loader data saved in navigation entry. */ @@ -424,18 +309,8 @@ export function Router({ const locationState = locationEntry?.state; const locationInfo = locationEntry?.info; - const entryId = - locationEntry?.entryId ?? - (isServerSnapshot(locationEntryInternal) - ? locationEntryInternal.actualLocationEntry?.entryId - : null) ?? - null; - const entryKey = - locationEntry?.entryKey ?? - (isServerSnapshot(locationEntryInternal) - ? locationEntryInternal.actualLocationEntry?.entryKey - : null) ?? - null; + const entryId = actualLocationEntry?.entryId ?? null; + const entryKey = actualLocationEntry?.entryKey ?? null; const routerContextValue: RouterContextValue = useMemo( () => ({ locationState, diff --git a/packages/router/src/Router/useLocationEntry.ts b/packages/router/src/Router/useLocationEntry.ts new file mode 100644 index 0000000..bd4a308 --- /dev/null +++ b/packages/router/src/Router/useLocationEntry.ts @@ -0,0 +1,156 @@ +import { + useCallback, + useEffect, + useEffectEvent, + useRef, + useState, + useSyncExternalStore, + useTransition, +} from "react"; +import type { LocationEntry, RouterAdapter } from "../core/RouterAdapter.js"; +import type { GetTransitionTypes, TransitionTypeContext } from "../types.js"; +import { addTransitionType } from "../core/addTransitionType.js"; +import { + ServerLocationSnapshot, + isServerSnapshot, + noopSubscribe, +} from "./ServerLocationSnapshot.js"; + +export type UseLocationEntryResult = { + /** + * The location entry to render. Null during SSR and hydration, or when + * the adapter has no location (Navigation API unavailable with + * fallback="none"). + */ + locationEntry: LocationEntry | null; + /** + * The adapter's real location entry. Same as `locationEntry`, except + * during hydration, where it is available while `locationEntry` is still + * null. Used for identity (loader cache key, entry id/key) so that it stays + * stable between the hydration render and the post-hydration render. + */ + actualLocationEntry: LocationEntry | null; + /** Whether an entry-change transition is pending */ + isPending: boolean; +}; + +/** + * Tracks the adapter's current location entry. + * + * - Hydration-aware: renders without a location during SSR and hydration, + * then switches to the real entry. + * - Entry changes from navigations are applied in a transition (with + * transition types from `getTransitionTypes`); state-only updates are + * applied synchronously. + * + * This hook only observes entry changes; it does not intercept navigations. + */ +export function useLocationEntry( + adapter: RouterAdapter, + getTransitionTypes?: GetTransitionTypes, +): UseLocationEntryResult { + // Hydration-aware initial value: null during SSR/hydration, real on client-only mount + const getSnapshot = useCallback(() => adapter.getSnapshot(), [adapter]); + const serverSnapshotCacheRef = useRef(null); + const getServerSnapshot = useCallback(() => { + // During SSR/hydration, return special server snapshot object + // that captures the real snapshot at the time of first render. + // This allows us to detect hydration and sync to real snapshot on client. + return (serverSnapshotCacheRef.current ??= new ServerLocationSnapshot(adapter)); + }, [adapter]); + const initialEntry = useSyncExternalStore( + noopSubscribe, + getSnapshot, + getServerSnapshot, + ); + + const [isPending, startTransition] = useTransition(); + const [locationEntryInternal, setLocationEntry] = useState< + LocationEntry | null | ServerLocationSnapshot + >(initialEntry); + const locationEntry = isServerSnapshot(locationEntryInternal) ? null : locationEntryInternal; + + if (isServerSnapshot(locationEntryInternal) && !isServerSnapshot(initialEntry)) { + // On second hydrated render on client, sync state with real snapshot + // Rendering flow on hydration: + // 1. Hydrated Render 1: initialEntry = serverSnapshotSymbol, locationEntryInternal = serverSnapshotSymbol + // 2. Render 1 is committed + // 3. Hydrated Render 2: initialEntry = client snapshot, locationEntryInternal = serverSnapshotSymbol + // 4. This `if` block runs, updating state to client snapshot + // 5. Hydrated Render 2 (retry): initialEntry = client snapshot, locationEntryInternal = client snapshot + // 6. Render 2 is committed + setLocationEntry(initialEntry); + } + + // Resolve transition types for the current navigation. Wrapped in + // useEffectEvent so the subscription effect doesn't re-run when the + // `getTransitionTypes` identity changes. + const resolveTransitionTypes = useEffectEvent( + (context: TransitionTypeContext): readonly string[] => { + return getTransitionTypes ? getTransitionTypes(context) : ["navigation"]; + }, + ); + + // Subscribe to navigation changes (conditionally wrapped in transition) + useEffect(() => { + return adapter.subscribe((change) => { + if (change.kind === "navigation") { + if (change.wait) { + // Intercepted form submission: the entry has committed but the + // action hasn't run yet. Rendering now would execute loaders + // without the action result, so wait (inside an async transition, + // keeping the old UI and isPending up) until the adapter has + // dispatched loaders with the action result. + const { wait, navigationType } = change; + // The entry is already committed, so the snapshot has the final + // URL. Read it now so transition types can be added synchronously + // inside the transition — addTransitionType is not supported + // after an await. + const committedSnapshot = adapter.getSnapshot(); + startTransition(async () => { + if (committedSnapshot) { + const types = resolveTransitionTypes({ + url: committedSnapshot.url, + navigationType, + }); + for (const type of types) { + addTransitionType(type); + } + } + await wait; + // Updates after the first await are not part of the transition + // unless wrapped in a nested startTransition call. + startTransition(() => { + // Re-read the snapshot: a superseding navigation may have + // resolved the wait, in which case this renders the newest entry. + setLocationEntry(adapter.getSnapshot()); + }); + }); + return; + } + const newSnapshot = adapter.getSnapshot(); + startTransition(() => { + if (newSnapshot) { + const types = resolveTransitionTypes({ + url: newSnapshot.url, + navigationType: change.navigationType, + }); + for (const type of types) { + addTransitionType(type); + } + } + setLocationEntry(newSnapshot); + }); + } else { + // State-only update: apply synchronously, no transition + setLocationEntry(adapter.getSnapshot()); + } + }); + }, [adapter, startTransition]); + + const actualLocationEntry = + locationEntry ?? + (isServerSnapshot(locationEntryInternal) ? locationEntryInternal.actualLocationEntry : null); + + return { locationEntry, actualLocationEntry, isPending }; +} diff --git a/packages/router/src/__tests__/RouteDisplay.test.tsx b/packages/router/src/__tests__/RouteDisplay.test.tsx index 8595c8f..869b796 100644 --- a/packages/router/src/__tests__/RouteDisplay.test.tsx +++ b/packages/router/src/__tests__/RouteDisplay.test.tsx @@ -9,6 +9,7 @@ import { useSearchParams } from "../hooks/useSearchParams.js"; import { useBlocker } from "../hooks/useBlocker.js"; import { route, routeState, type RouteComponentProps, type RouteDefinition } from "../route.js"; import { setupNavigationMock, cleanupNavigationMock } from "./setup.js"; +import { clearLoaderCache } from "../core/loaderCache.js"; const originalLocation = window.location; @@ -35,6 +36,7 @@ type CountProps = RouteComponentProps, CountState>; describe("RouteDisplay", () => { beforeEach(() => { delete (globalThis as Record).navigation; + clearLoaderCache(); }); afterEach(() => { @@ -148,22 +150,6 @@ describe("RouteDisplay", () => { expect(loader).toHaveBeenCalledTimes(1); }); - it("does not share loader results between instances", () => { - setLocation("http://localhost/a"); - const loader = vi.fn(() => "data"); - const routes = [ - route({ - path: "/a", - loader, - component: ({ data }: { data: string }) =>
{data}
, - }), - ]; - - render().unmount(); - render().unmount(); - expect(loader).toHaveBeenCalledTimes(2); - }); - describe("with Navigation API", () => { it("does not intercept navigations", () => { const mockNavigation = setupNavigationMock("http://localhost/"); @@ -225,6 +211,43 @@ describe("RouteDisplay", () => { expect(screen.getByText("Count: 1")).toBeInTheDocument(); }); + it("follows same-document entry changes in a transition", async () => { + const mockNavigation = setupNavigationMock("http://localhost/"); + function Page() { + return
Hash: {useLocation().hash}
; + } + + render(); + expect(screen.getByText("Hash:")).toBeInTheDocument(); + + await act(async () => { + mockNavigation.navigate("http://localhost/#section"); + }); + expect(screen.getByText("Hash: #section")).toBeInTheDocument(); + }); + + it("keeps loader results across a same-URL replace from setState", async () => { + setupNavigationMock("http://localhost/"); + const loader = vi.fn(() => "loaded"); + function Page({ data, state, setState }: CountProps & { data: string }) { + return ( + + ); + } + const routes = [routeState()({ path: "/", loader, component: Page })]; + + render(); + expect(screen.getByText("loaded 0")).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByText("loaded 0")); + }); + expect(screen.getByText("loaded 1")).toBeInTheDocument(); + expect(loader).toHaveBeenCalledTimes(1); + }); + it("navigates via navigation.navigate from setters", async () => { const mockNavigation = setupNavigationMock("http://localhost/"); let setSearchParams: ReturnType[1] | undefined; @@ -381,6 +404,8 @@ describe("RouteDisplay", () => { const container = document.createElement("div"); container.innerHTML = renderToString(); document.body.appendChild(container); + // The server renders in a separate process + clearLoaderCache(); loader.mockClear(); const onRecoverableError = vi.fn(); diff --git a/packages/router/src/core/StaticAdapter.ts b/packages/router/src/core/StaticAdapter.ts index 268b3a7..3199fd3 100644 --- a/packages/router/src/core/StaticAdapter.ts +++ b/packages/router/src/core/StaticAdapter.ts @@ -1,14 +1,28 @@ import type { RouterAdapter, LocationEntry, EntryChange } from "./RouterAdapter.js"; import type { InternalRouteDefinition, NavigateOptions, OnNavigateCallback } from "../types.js"; +export type StaticAdapterOptions = { + /** + * When true, programmatic navigations are performed as full page loads + * via `location.assign()` / `location.replace()`. State and info cannot be + * carried over. When false (default), they are ignored with a warning. + */ + fullPageNavigation?: boolean; +}; + /** - * Static adapter for fallback mode when Navigation API is unavailable. + * Static adapter for when Navigation API is unavailable. * Provides read-only location access with no SPA navigation capabilities. * Links will cause full page loads (MPA behavior). */ export class StaticAdapter implements RouterAdapter { #cachedSnapshot: LocationEntry | null = null; #idleController: AbortController | null = null; + #fullPageNavigation: boolean; + + constructor(options?: StaticAdapterOptions) { + this.#fullPageNavigation = options?.fullPageNavigation ?? false; + } getSnapshot(): LocationEntry | null { if (typeof window === "undefined") { @@ -34,7 +48,15 @@ export class StaticAdapter implements RouterAdapter { return () => {}; } - navigate(_to: string, _options?: NavigateOptions): void { + navigate(to: string, options?: NavigateOptions): void { + if (this.#fullPageNavigation) { + if (options?.replace) { + window.location.replace(to); + } else { + window.location.assign(to); + } + return; + } console.warn( "FUNSTACK Router: navigate() called in static fallback mode. " + "Navigation API is not available in this browser. " + diff --git a/packages/router/src/core/createAdapter.ts b/packages/router/src/core/createAdapter.ts index db3fc94..a2006de 100644 --- a/packages/router/src/core/createAdapter.ts +++ b/packages/router/src/core/createAdapter.ts @@ -32,3 +32,15 @@ export function createAdapter(fallback: FallbackMode): RouterAdapter { // No adapter available (fallback="none" or default) return new NullAdapter(); } + +/** + * Create the adapter for RouteDisplay: the Navigation API adapter when + * available (without interception), otherwise a static adapter that performs + * full page navigations. + */ +export function createDisplayAdapter(): RouterAdapter { + if (hasNavigation()) { + return new NavigationAPIAdapter(); + } + return new StaticAdapter({ fullPageNavigation: true }); +}