Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 95 additions & 1 deletion packages/docs/src/pages/ApiComponentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function ApiComponentsPage() {
<div className="page docs-page api-page">
<PageHead
title="Components API"
description="API reference for FUNSTACK Router components, including Router and Outlet."
description="API reference for FUNSTACK Router components, including Router, RouteDisplay, and Outlet."
/>
<h1>Components</h1>
<p className="page-intro">Core components for building routing in your React application.</p>
Expand Down Expand Up @@ -138,6 +138,100 @@ export function ApiComponentsPage() {
</div>
</article>

<article className="api-item">
<h3>
<code>{"<RouteDisplay>"}</code>
</h3>
<p>
Renders the routes matching the current URL, without client-side routing. Unlike{" "}
<code>{"<Router>"}</code>, 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.
</p>
<CodeBlock language="tsx">{`import { RouteDisplay } from "@funstack/router";

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

// Server: matches routes against the given path
<RouteDisplay routes={routes} ssr={{ path: "/about", runLoaders: true }} />`}</CodeBlock>
<p>
Hooks that read the location (<code>useLocation</code>, <code>useSearchParams</code>,{" "}
<code>useRouteParams</code>, <code>useRouteData</code>, etc.) work as usual. It follows
the current location the same way as <code>{"<Router>"}</code>: 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 <code>null</code> / <code>undefined</code>.
</p>
<p>
Setters that navigate (such as the one from <code>useSearchParams</code>, or a route's{" "}
<code>setState</code>) perform a full page navigation. With the Navigation API, the new
entry keeps the given state; without it, the state is lost. <code>setStateSync</code>{" "}
updates the current entry's state in place without navigating (Navigation API only).
Features that depend on intercepting navigations are inert: <code>useBlocker</code> never
blocks, and route actions are not run (forms submit to the server).
</p>
<h4>Props</h4>
<div className="table-scroll">
<table className="props-table">
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>routes</code>
</td>
<td>
<code>RouteDefinition[]</code>
</td>
<td>Array of route definitions</td>
</tr>
<tr>
<td>
<code>ssr</code>
</td>
<td>
<code>SSRConfig</code>
</td>
<td>
Same as <code>{"<Router>"}</code>'s <code>ssr</code> prop. Used only during SSR
and hydration; afterwards, the page URL from <code>window.location</code> takes
over.
</td>
</tr>
<tr>
<td>
<code>trailingSlash</code>
</td>
<td>
<code>{'"ignore" | "strict"'}</code>
</td>
<td>
Same as <code>{"<Router>"}</code>'s <code>trailingSlash</code> prop.
</td>
</tr>
<tr>
<td>
<code>features</code>
</td>
<td>
<code>RouterFeatures</code>
</td>
<td>
Same as <code>{"<Router>"}</code>'s <code>features</code> prop.
</td>
</tr>
</tbody>
</table>
</div>
</article>

<article className="api-item">
<h3>
<code>{"<Outlet>"}</code>
Expand Down
8 changes: 8 additions & 0 deletions packages/docs/src/pages/LearnSsrBasicPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ function AppShell() {
// Static fallback: renders routes using window.location
// when Navigation API is unavailable
<Router routes={routes} fallback="static" />`}</CodeBlock>
<p>
If you are building a multi-page application and never want client-side navigation, use{" "}
<a href="/api/components">
<code>{"<RouteDisplay>"}</code>
</a>{" "}
instead. It never intercepts navigations, whether or not the Navigation API is available,
and performs navigations requested by hooks as full page loads.
</p>
</section>

<section>
Expand Down
2 changes: 1 addition & 1 deletion packages/router/skills/funstack-router-knowledge/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
189 changes: 189 additions & 0 deletions packages/router/src/RouteDisplay/index.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>());

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 (
<BlockerContext.Provider value={blockerContextValue}>
<RouterContext.Provider value={routerContextValue}>
{matchedRoutesWithData ? (
<RouteRenderer matchedRoutes={matchedRoutesWithData} index={0} />
) : null}
</RouterContext.Provider>
</BlockerContext.Provider>
);
}, [routerContextValue, matchedRoutesWithData, blockerRegistry]);
}
Loading
Loading