diff --git a/packages/docs/src/pages/ApiComponentsPage.tsx b/packages/docs/src/pages/ApiComponentsPage.tsx index 9d1ec18..121a622 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,100 @@ export function ApiComponentsPage() {
+
+

+ {""} +

+

+ 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 { RouteDisplay } from "@funstack/router"; + +// 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. 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{" "} + 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, and route actions are not run (forms submit to the server). +

+

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..8207423 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 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 85d4948..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`, `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/RouteDisplay/index.tsx b/packages/router/src/RouteDisplay/index.tsx new file mode 100644 index 0000000..95751f5 --- /dev/null +++ b/packages/router/src/RouteDisplay/index.tsx @@ -0,0 +1,189 @@ +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 { useLocationEntry } from "../Router/useLocationEntry.js"; + +export type RouteDisplayProps = { + 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; +}; + +/** + * Renders the routes matching the current URL, without client-side routing. + * + * 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 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, + ssr, + trailingSlash, + features, +}: RouteDisplayProps): 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], + ); + + // 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()); + + const { locationEntry, actualLocationEntry, isPending } = useLocationEntry(adapter); + + 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 when a location entry is + * available; during SSR (and hydration) only when ssr.runLoaders is true + * and ssr.path is given. + */ + 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) { + 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, + locationKey, + createLoaderRequest(urlObject), + adapter.getIdleAbortSignal(), + undefined, + realLocationKey === null ? instanceLoaderCache : undefined, + ); + }, [ + 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, + locationInfo, + url: urlObject, + entryId, + entryKey, + isPending, + navigateAsync: (to, options) => adapter.navigateAsync(to, options), + updateCurrentEntryState: (state) => adapter.updateCurrentEntryState(state), + features: resolvedFeatures, + }), + [ + locationState, + locationInfo, + urlObject, + entryId, + entryKey, + isPending, + adapter, + resolvedFeatures, + ], + ); + + return useMemo(() => { + const blockerContextValue = { registry: blockerRegistry }; + + return ( + + + {matchedRoutesWithData ? ( + + ) : null} + + + ); + }, [routerContextValue, matchedRoutesWithData, blockerRegistry]); +} 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 new file mode 100644 index 0000000..869b796 --- /dev/null +++ b/packages/router/src/__tests__/RouteDisplay.test.tsx @@ -0,0 +1,427 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { renderToString } from "react-dom/server"; +import { hydrateRoot } from "react-dom/client"; +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, routeState, type RouteComponentProps, type RouteDefinition } from "../route.js"; +import { setupNavigationMock, cleanupNavigationMock } from "./setup.js"; +import { clearLoaderCache } from "../core/loaderCache.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, + assign: vi.fn(), + replace: vi.fn(), + }, + writable: true, + configurable: true, + }); +} + +type CountState = { count: number }; +type CountProps = RouteComponentProps, CountState>; + +describe("RouteDisplay", () => { + beforeEach(() => { + delete (globalThis as Record).navigation; + clearLoaderCache(); + }); + + 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("allows useBlocker inside routes", () => { + setLocation("http://localhost/"); + function Page() { + useBlocker({ shouldBlock: () => true }); + return
Page
; + } + + render(); + expect(screen.getByText("Page")).toBeInTheDocument(); + }); + + it("performs full page navigations from setters without Navigation API", () => { + setLocation("http://localhost/?a=1"); + let setSearchParams: ReturnType[1] | undefined; + function Page({ setState }: CountProps) { + setSearchParams = useSearchParams()[1]; + 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"); + + fireEvent.click(screen.getByText("Set")); + expect(window.location.replace).toHaveBeenCalledWith("http://localhost/?a=1"); + }); + + 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); + }); + + 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("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; + 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[] = [ + { + 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("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"); + 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); + // The server renders in a separate process + clearLoaderCache(); + 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/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/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(); 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 }); +} diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 86a65c9..553b90b 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 { RouteDisplay, type RouteDisplayProps } from "./RouteDisplay/index.js"; export { Outlet } from "./Outlet.js"; // Hooks