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
4 changes: 2 additions & 2 deletions packages/example-pathless-ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"dependencies": {
"@funstack/router": "workspace:*",
"@funstack/static": "^1.1.2",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react": "19.3.0-canary-1b45e243-20260402",
"react-dom": "19.3.0-canary-1b45e243-20260402"
},
"devDependencies": {
"@types/react": "^19.2.14",
Expand Down
4 changes: 3 additions & 1 deletion packages/example-pathless-ssr/src/ClientApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ import "./styles.css";
export function ClientApp({ routes }: { routes: RouteDefinition[] }) {
// No ssr prop — during SSR only pathless routes match, rendering the app shell.
// Path-based content fills in on client hydration.
return <Router routes={routes} fallback="static" />;
// experimentalPostpone uses React's unstable_postpone API to avoid hydration
// mismatches by deferring unmatched outlet content to client rendering.
return <Router routes={routes} fallback="static" experimentalPostpone />;
}
6 changes: 6 additions & 0 deletions packages/example-pathless-ssr/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,10 @@ export default defineConfig({
react(),
],
base: "/",
resolve: {
// Ensure all react imports resolve to the same instance.
// Without this, the workspace router package may resolve to a different
// React version than the one used by this example (React Canary).
dedupe: ["react", "react-dom"],
},
});
1 change: 1 addition & 0 deletions packages/router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@types/react": "^19.2.14",
"jsdom": "^29.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tsdown": "^0.21.7",
"typescript": "^6.0.2",
"urlpattern-polyfill": "^10.1.0",
Expand Down
24 changes: 24 additions & 0 deletions packages/router/src/Outlet.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,40 @@
import { type ReactNode, useContext } from "react";
import * as React from "react";
import { RouteContext } from "./context/RouteContext.js";
import { RouterContext } from "./context/RouterContext.js";

/**
* Renders the matched child route.
* Used in layout components to specify where child routes should render.
*
* When `experimentalPostpone` is enabled on the Router and no child route
* matches during pathless SSR, calls `React.unstable_postpone()` instead of
* rendering `null`, deferring the content to client-side rendering.
*/
export function Outlet(): ReactNode {
const routeContext = useContext(RouteContext);
const routerContext = useContext(RouterContext);

if (!routeContext) {
return null;
}

if (
routeContext.outlet === null &&
routerContext !== null &&
routerContext.experimentalPostpone &&
routerContext.url === null
) {
// During pathless SSR, child routes cannot match because there is no URL.
// Use React's experimental postpone API to signal that this content
// should be rendered on the client instead.
const postpone = (React as Record<string, unknown>)["unstable_postpone"];
if (typeof postpone === "function") {
(postpone as (reason: string) => never)(
"Route not matched during pathless SSR",
);
}
}

return routeContext.outlet;
}
16 changes: 16 additions & 0 deletions packages/router/src/Router/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,27 @@ export type RouterProps = {
* ```
*/
ssr?: SSRConfig;
/**
* Enable React's experimental `unstable_postpone` API for pathless SSR.
*
* When enabled, `<Outlet />` calls `React.unstable_postpone()` instead of
* rendering `null` when no child route can be matched during pathless SSR.
* This avoids hydration mismatches by telling React to defer rendering of
* the outlet content to the client.
*
* **Requires React Canary or experimental builds.**
*
* @default false
*/
experimentalPostpone?: boolean;
};

export function Router({
routes: inputRoutes,
onNavigate,
fallback = "none",
ssr,
experimentalPostpone = false,
}: RouterProps): ReactNode {
const routes = internalRoutes(inputRoutes);

Expand Down Expand Up @@ -285,6 +299,7 @@ export function Router({
isPending,
navigateAsync,
updateCurrentEntryState,
experimentalPostpone,
}),
[
locationState,
Expand All @@ -295,6 +310,7 @@ export function Router({
isPending,
navigateAsync,
updateCurrentEntryState,
experimentalPostpone,
],
);

Expand Down
5 changes: 5 additions & 0 deletions packages/router/src/context/RouterContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export type RouterContextValue = {
navigateAsync: (to: string, options?: NavigateOptions) => Promise<void>;
/** Update current entry's state without navigation */
updateCurrentEntryState: (state: unknown) => void;
/**
* Whether to use React's experimental `unstable_postpone` API for
* content that cannot be rendered during pathless SSR.
*/
experimentalPostpone: boolean;
};

export const RouterContext = createContext<RouterContextValue | null>(null);
66 changes: 61 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.