// SSR client-runtime not-found / error boundary.
//
// Extracted from `CLIENT_RUNTIME_SOURCE` in ssr-client-bundler.ts so its
// RENDER behavior is testable with a real React renderer — the inline copy
// was a string literal, so nothing could render it, and two real regressions
// (a client-thrown notFound() blanking the page, then rendering with the
// wrong props and redirecting to /login) shipped because unit/build tests
// couldn't reach render-time behavior.
//
// The bundler writes this module's SOURCE next to the generated client
// runtime (as `.pylon/client-boundary.ts`) and the runtime imports
// `createPylonBoundary` from "./client-boundary", wiring its own internals
// (loadManifest / loadRouteEntry / navigate / buildTree / the active page
// props) in as deps. ssr-client-boundary.test.ts imports the same factory and
// drives it directly. ONE source of truth, no inline drift.
//
// Browser-safe by construction: imports ONLY "react" and touches NO DOM
// globals — every browser capability (the manifest, navigation, the current
// href) arrives through injected deps. So it bundles cleanly into the client
// chunk AND typechecks without a DOM lib.
import { Component, createElement, useEffect, useState } from "react";
/** A per-directory module resolved by walking up from a route: the two error
* boundaries, plus `loading` (the pending-navigation skeleton). */
export type BoundaryFile = "not-found" | "error" | "loading";
/**
* Resolve the nearest `
/` module for a route by walking its
* component path up to the app root, returning the first key that exists.
* Nearest ancestor wins — the same model the server's `findBoundary` uses, but
* driven off client-side keys so the runtime needs no extra server round-trip.
*
* `component` is a cwd-relative path with "/" separators and no extension
* (e.g. "web/app/dashboard/orgs/[slug]/page"). `keys` is the set to resolve
* against: `Object.keys(manifest.routes)` for not-found/error, which ship as
* route entries, or the loading registry's keys for `loading`, which ships in
* the shared chunk instead.
*/
export function nearestBoundaryComponent(
component: string,
fileName: BoundaryFile,
routeKeys: Iterable,
): string | null {
const target = boundaryScope(component);
let best: { key: string; depth: number } | null = null;
for (const key of routeKeys) {
if (!key.endsWith(`/${fileName}`)) continue;
const scope = boundaryScope(key);
// Same URL scope, or an ancestor of it.
if (scope !== target && !target.startsWith(`${scope}/`)) continue;
const depth = scope === "" ? 0 : scope.split("/").length;
// Nearest ancestor wins; ties (a directory's own file vs one in a group
// beside it) resolve by key order so the answer is deterministic.
if (!best || depth > best.depth || (depth === best.depth && key < best.key)) {
best = { key, depth };
}
}
return best ? best.key : null;
}
/**
* The URL-space directory a module sits in: its parent path with `(group)`
* segments removed, since a group contributes no URL segment.
*
* `web/app/(marketing)/pricing/page` → `web/app/pricing`, and
* `web/app/(marketing)/not-found` → `web/app`. That is what makes a group's
* boundary resolve for `/` — matching the server, which walks the real
* directories but treats group dirs as transparent the same way.
*/
export function boundaryScope(componentPath: string): string {
const parts = String(componentPath).replace(/\\/g, "/").split("/");
parts.pop(); // the module's own basename
return parts
.filter((p) => !(p.startsWith("(") && p.endsWith(")")))
.join("/");
}
/** Runtime internals the boundary needs, injected so the module stays
* browser-safe AND unit-testable. */
export interface BoundaryDeps {
/** Resolve the client build manifest (`{ routes: {...} }`). */
loadManifest: () => Promise<{ routes?: Record } | null>;
/** Dynamically load a route entry → its Page + Layout chain. */
loadRouteEntry: (component: string) => Promise<{ Page: any; Layouts: any[] }>;
/** Client-side navigation — used by an error boundary's reset(). */
navigate: (href: string, opts?: { replace?: boolean }) => void;
/** Wrap a Page in its Layout chain with props (the runtime's buildTree). */
buildTree: (Page: any, Layouts: any[], props: any) => any;
/** The props the active page was rendered with (auth, serverData, …). */
getPageProps: () => any;
/** Current same-origin href, for an error boundary's reset() re-navigation. */
getResetHref: () => string;
}
/**
* Build the client error / not-found boundary against the runtime's internals.
* Returns `withBoundary(tree, component, navEpoch)` — the transparent root
* wrapper the runtime puts around every page — plus `PylonBoundary` for tests.
*
* Behavior: a `notFound()` (digest "PYLON_NOT_FOUND") or any error thrown
* during a descendant's render is caught; the nearest not-found.tsx / error.tsx
* for the active route is resolved from the manifest, loaded, and rendered in
* its own layout chain WITH the page's props (so an auth-guarding layout sees
* the real auth and doesn't redirect away). Resets on navigation via a changing
* `navEpoch` prop — not a key — so layouts keep their state across nav.
*/
export function createPylonBoundary(deps: BoundaryDeps) {
const {
loadManifest,
loadRouteEntry,
navigate,
buildTree,
getPageProps,
getResetHref,
} = deps;
async function resolveBoundaryComponent(
component: string,
fileName: "not-found" | "error",
): Promise {
const manifest = await loadManifest();
if (!manifest || !manifest.routes || !component) return null;
return nearestBoundaryComponent(
component,
fileName,
Object.keys(manifest.routes),
);
}
// Last-resort body when the app ships no not-found.tsx / error.tsx anywhere.
// Renders a full document so it can replace the document root cleanly
// (the normal path renders the app's boundary wrapped in the root layout,
// which also owns ).
function DefaultBoundary(props: any) {
const isNF = props.kind === "not-found";
return createElement(
"html",
{ lang: "en" },
createElement(
"head",
null,
createElement("meta", { charSet: "utf-8" }),
createElement(
"title",
null,
isNF ? "404 — Not found" : "Something went wrong",
),
),
createElement(
"body",
{
style: {
margin: 0,
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, -apple-system, sans-serif",
},
},
createElement(
"div",
{ style: { textAlign: "center", padding: "2rem" } },
createElement(
"h1",
{ style: { fontSize: "1.375rem", margin: "0 0 0.5rem" } },
isNF ? "404 — Not found" : "Something went wrong",
),
createElement(
"p",
{ style: { color: "#666", margin: "0 0 1.25rem" } },
isNF
? "This page doesn't exist or was moved."
: "An unexpected error occurred.",
),
createElement(
"a",
{ href: "/", style: { color: "#0969da", textDecoration: "none" } },
"← Go home",
),
),
),
);
}
// Lazily resolves + renders the boundary component in its own layout chain.
// Renders nothing for the brief moment before the (usually already-cached)
// entry loads, then the styled boundary — far better than a permanent blank.
function BoundaryView(props: any) {
const { component, kind, error, reset } = props;
const [resolved, setResolved] = useState(null);
useEffect(() => {
let cancelled = false;
const fileName = kind === "not-found" ? "not-found" : "error";
(async () => {
const comp = await resolveBoundaryComponent(component, fileName);
if (cancelled) return;
if (!comp) {
setResolved({ missing: true });
return;
}
try {
const entry = await loadRouteEntry(comp);
if (!cancelled) setResolved({ entry });
} catch {
if (!cancelled) setResolved({ missing: true });
}
})();
return () => {
cancelled = true;
};
}, [component, kind]);
if (!resolved) return null;
if (resolved.missing) return createElement(DefaultBoundary, { kind });
// Reuse the page's props (auth, serverData, params, url, …) so the
// boundary's layout chain renders with the SAME context the page had — an
// auth-guarding layout that reads props.auth must not see undefined and
// redirect away. error.tsx gets the SAFE error projection (message + digest
// only, never a raw Error/stack — matches the server boundary's #270).
const bProps: any = { ...getPageProps(), reset };
if (kind === "error" && error) {
bProps.error = {
message: String((error && error.message) || error),
digest: error && error.digest,
};
}
return buildTree(resolved.entry.Page, resolved.entry.Layouts, bProps);
}
class PylonBoundary extends Component {
constructor(p: any) {
super(p);
this.state = { err: null };
}
static getDerivedStateFromError(err: any) {
return { err };
}
componentDidUpdate(prev: any) {
// A navigation bumps navEpoch — clear a prior error so the freshly
// rendered children (the new page) show instead of the stale boundary.
if (prev.navEpoch !== this.props.navEpoch && this.state.err) {
this.setState({ err: null });
}
}
componentDidCatch(err: any) {
// notFound() is expected control flow, not a crash — keep it quiet.
// Genuine errors stay loud.
if (!(err && err.digest === "PYLON_NOT_FOUND")) {
// eslint-disable-next-line no-console
console.error("[pylon ssr] render error caught by boundary:", err);
}
}
render() {
const err = this.state.err;
if (!err) return this.props.children;
const kind = err.digest === "PYLON_NOT_FOUND" ? "not-found" : "error";
const self = this;
return createElement(BoundaryView, {
component: this.props.component,
kind,
error: err,
reset() {
self.setState({ err: null });
navigate(getResetHref(), { replace: true });
},
});
}
}
// Wrap a page tree in the root boundary. navEpoch (a prop, not a key) lets the
// boundary clear its error on navigation without remounting the layouts.
function withBoundary(tree: any, component: string, navEpoch: number) {
return createElement(PylonBoundary, { navEpoch, component }, tree);
}
return { withBoundary, PylonBoundary };
}