@pylonsync/functions 0.3.247 → 0.3.248
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/ssr-client-bundler.ts +24 -0
- package/src/ssr-runtime.test.ts +120 -1
- package/src/ssr-runtime.ts +267 -75
package/package.json
CHANGED
|
@@ -130,6 +130,22 @@ function discoverRoutes(
|
|
|
130
130
|
layouts: nextLayouts,
|
|
131
131
|
});
|
|
132
132
|
}
|
|
133
|
+
// Boundary modules (not-found.tsx / error.tsx) are hydrated like pages
|
|
134
|
+
// (#279) so onClick/useState/reset() work — that means each needs its own
|
|
135
|
+
// client entry + manifest key, keyed by component path exactly like a page.
|
|
136
|
+
// They wrap in the layouts ABOVE them (nextLayouts), same as a page here.
|
|
137
|
+
for (const base of ["not-found", "error"]) {
|
|
138
|
+
const bHere = [`${base}.tsx`, `${base}.ts`, `${base}.jsx`, `${base}.js`]
|
|
139
|
+
.map((n: string) => path.join(dir, n))
|
|
140
|
+
.find((p: string) => fs.existsSync(p));
|
|
141
|
+
if (bHere) {
|
|
142
|
+
pages.push({
|
|
143
|
+
segments: [...segments],
|
|
144
|
+
component: path.relative(cwd, bHere).replace(/\.(tsx?|jsx?)$/, ""),
|
|
145
|
+
layouts: nextLayouts,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
133
149
|
for (const e of entries) {
|
|
134
150
|
if (!e.isDirectory()) continue;
|
|
135
151
|
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
@@ -271,10 +287,18 @@ function makeNoopResponse() {
|
|
|
271
287
|
|
|
272
288
|
// Rehydrate the live, server-only props (serverData + response) that were
|
|
273
289
|
// stripped before serialization, so the client tree matches the server's.
|
|
290
|
+
// For a hydrated error boundary (#279), synthesize the reset() the server
|
|
291
|
+
// rendered as a no-op: re-fetch + re-render the current URL (a transient
|
|
292
|
+
// error clears to the page; a deterministic one re-shows the boundary).
|
|
274
293
|
function withClientProps(data) {
|
|
275
294
|
const props = { ...(data.props || {}) };
|
|
276
295
|
props.serverData = makeClientServerData(data.ssrData);
|
|
277
296
|
props.response = makeNoopResponse();
|
|
297
|
+
if (data.kind === "error") {
|
|
298
|
+
props.reset = function () {
|
|
299
|
+
navigate(location.pathname + location.search, { replace: true });
|
|
300
|
+
};
|
|
301
|
+
}
|
|
278
302
|
return props;
|
|
279
303
|
}
|
|
280
304
|
|
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -4,7 +4,22 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
applyAutoIcons,
|
|
9
|
+
applyAutoSocialImages,
|
|
10
|
+
renderMetadata,
|
|
11
|
+
buildHydrationTail,
|
|
12
|
+
errorDigest,
|
|
13
|
+
} from "./ssr-runtime";
|
|
14
|
+
|
|
15
|
+
// Pull the JSON out of the `__PYLON_DATA__` <script> a hydration tail emits.
|
|
16
|
+
function extractPylonData(tail: string): any {
|
|
17
|
+
const m = tail.match(
|
|
18
|
+
/<script id="__PYLON_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
|
|
19
|
+
);
|
|
20
|
+
if (!m) throw new Error("no __PYLON_DATA__ in tail");
|
|
21
|
+
return JSON.parse(m[1]); // JSON.parse natively decodes the < escaping
|
|
22
|
+
}
|
|
8
23
|
|
|
9
24
|
// `react` isn't a dependency of @pylonsync/functions — the SSR runtime
|
|
10
25
|
// imports it dynamically from the host project at render time. For unit
|
|
@@ -226,3 +241,107 @@ describe("renderMetadata head-tag marking (client-nav sync)", () => {
|
|
|
226
241
|
expect(renderMetadata(fakeReact, {})).toBeNull();
|
|
227
242
|
});
|
|
228
243
|
});
|
|
244
|
+
|
|
245
|
+
describe("buildHydrationTail — boundary hydration (#279) + strip (#270)", () => {
|
|
246
|
+
const manifestRoute = { file: "app__error-x.js", imports: [], css: [] };
|
|
247
|
+
|
|
248
|
+
test("error boundary serializes {message,digest}; raw error/stack/cookies NEVER cross the wire", () => {
|
|
249
|
+
const tail = buildHydrationTail({
|
|
250
|
+
component: "app/error",
|
|
251
|
+
layouts: ["app/layout"],
|
|
252
|
+
props: {
|
|
253
|
+
url: "/boom",
|
|
254
|
+
auth: { user_id: "u1", is_admin: false, tenant_id: null, roles: [] },
|
|
255
|
+
// live, non-serializable + sensitive handles that MUST be stripped:
|
|
256
|
+
error: new Error("DB exploded at secretHost:5432"),
|
|
257
|
+
serverData: { get() {} },
|
|
258
|
+
response: { setStatus() {} },
|
|
259
|
+
reset: () => {},
|
|
260
|
+
headers: { cookie: "pylon_session=SUPERSECRET" },
|
|
261
|
+
cookies: { pylon_session: "SUPERSECRET" },
|
|
262
|
+
},
|
|
263
|
+
ssrData: {},
|
|
264
|
+
manifestRoute,
|
|
265
|
+
publicPrefix: "/_pylon/build/",
|
|
266
|
+
manifestErr: null,
|
|
267
|
+
kind: "error",
|
|
268
|
+
errorForClient: { message: "Something went wrong", digest: "deadbeef" },
|
|
269
|
+
});
|
|
270
|
+
const data = extractPylonData(tail);
|
|
271
|
+
expect(data.kind).toBe("error");
|
|
272
|
+
expect(data.component).toBe("app/error");
|
|
273
|
+
// The client error.tsx gets ONLY the safe projection.
|
|
274
|
+
expect(data.props.error).toEqual({
|
|
275
|
+
message: "Something went wrong",
|
|
276
|
+
digest: "deadbeef",
|
|
277
|
+
});
|
|
278
|
+
// Live handles stripped; headers/cookies emptied (shape preserved).
|
|
279
|
+
expect(data.props.serverData).toBeUndefined();
|
|
280
|
+
expect(data.props.response).toBeUndefined();
|
|
281
|
+
expect(data.props.reset).toBeUndefined();
|
|
282
|
+
expect(data.props.headers).toEqual({});
|
|
283
|
+
expect(data.props.cookies).toEqual({});
|
|
284
|
+
// The session cookie + the raw error message/stack are nowhere in the blob.
|
|
285
|
+
expect(tail).not.toContain("SUPERSECRET");
|
|
286
|
+
expect(tail).not.toContain("secretHost");
|
|
287
|
+
expect(tail).not.toContain("stack");
|
|
288
|
+
// The per-boundary entry script is appended.
|
|
289
|
+
expect(tail).toContain('src="/_pylon/build/app__error-x.js"');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("not-found boundary carries kind but no error/reset", () => {
|
|
293
|
+
const tail = buildHydrationTail({
|
|
294
|
+
component: "app/not-found",
|
|
295
|
+
layouts: [],
|
|
296
|
+
props: { url: "/missing", auth: {}, response: {}, serverData: {} },
|
|
297
|
+
ssrData: {},
|
|
298
|
+
manifestRoute,
|
|
299
|
+
publicPrefix: "/_pylon/build/",
|
|
300
|
+
manifestErr: null,
|
|
301
|
+
kind: "not-found",
|
|
302
|
+
});
|
|
303
|
+
const data = extractPylonData(tail);
|
|
304
|
+
expect(data.kind).toBe("not-found");
|
|
305
|
+
expect(data.props.error).toBeUndefined();
|
|
306
|
+
expect(data.props.reset).toBeUndefined();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("a page (no kind) hydrates without a kind field", () => {
|
|
310
|
+
const tail = buildHydrationTail({
|
|
311
|
+
component: "app/page",
|
|
312
|
+
layouts: ["app/layout"],
|
|
313
|
+
props: { url: "/", auth: {}, response: {}, serverData: {} },
|
|
314
|
+
ssrData: { "list:Note": [] },
|
|
315
|
+
manifestRoute,
|
|
316
|
+
publicPrefix: "/_pylon/build/",
|
|
317
|
+
manifestErr: null,
|
|
318
|
+
});
|
|
319
|
+
const data = extractPylonData(tail);
|
|
320
|
+
expect(data.kind).toBeUndefined();
|
|
321
|
+
expect(data.ssrData).toEqual({ "list:Note": [] });
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("no manifest entry → hydration-disabled warning, not an entry script", () => {
|
|
325
|
+
const tail = buildHydrationTail({
|
|
326
|
+
component: "app/page",
|
|
327
|
+
layouts: [],
|
|
328
|
+
props: { url: "/" },
|
|
329
|
+
ssrData: {},
|
|
330
|
+
manifestRoute: null,
|
|
331
|
+
publicPrefix: "/_pylon/build/",
|
|
332
|
+
manifestErr: "manifest crashed",
|
|
333
|
+
});
|
|
334
|
+
expect(tail).toContain("hydration disabled");
|
|
335
|
+
expect(tail).not.toContain('type="module" src=');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("errorDigest is deterministic, stack-free, 8 hex chars", () => {
|
|
339
|
+
const e = new Error("boom");
|
|
340
|
+
const d1 = errorDigest(e);
|
|
341
|
+
const d2 = errorDigest(e);
|
|
342
|
+
expect(d1).toBe(d2);
|
|
343
|
+
expect(d1).toMatch(/^[0-9a-f]{8}$/);
|
|
344
|
+
// A different error yields a different digest.
|
|
345
|
+
expect(errorDigest(new Error("other"))).not.toBe(d1);
|
|
346
|
+
});
|
|
347
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -792,11 +792,116 @@ async function collectBoundaryHeadBlob(): Promise<string> {
|
|
|
792
792
|
}
|
|
793
793
|
|
|
794
794
|
/**
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
795
|
+
* Build the hydration tail appended after React's stream EOFs: the
|
|
796
|
+
* `__PYLON_DATA__` JSON blob (props + ssrData) + the per-route entry
|
|
797
|
+
* `<script>` that hydrates it, + (dev) the live-reload snippet. Shared by the
|
|
798
|
+
* page render AND the now-hydrated boundary render (#279) so a boundary
|
|
799
|
+
* hydrates through the EXACT same path as a page.
|
|
800
|
+
*
|
|
801
|
+
* `kind` marks an error/not-found boundary so the client knows whether to
|
|
802
|
+
* synthesize a `reset()`. For an error boundary, `errorForClient` is the SAFE
|
|
803
|
+
* projection ({message, digest}) — the raw `Error` (and its stack) is NEVER
|
|
804
|
+
* serialized (the dev overlay owns dev stacks; preserves the #270 posture).
|
|
805
|
+
*/
|
|
806
|
+
export function buildHydrationTail(args: {
|
|
807
|
+
component: string;
|
|
808
|
+
layouts: string[];
|
|
809
|
+
props: any;
|
|
810
|
+
ssrData: Record<string, any>;
|
|
811
|
+
manifestRoute: { file: string; imports: string[]; css: string[] } | null;
|
|
812
|
+
publicPrefix: string;
|
|
813
|
+
manifestErr: string | null;
|
|
814
|
+
kind?: "error" | "not-found";
|
|
815
|
+
errorForClient?: { message: string; digest?: string };
|
|
816
|
+
}): string {
|
|
817
|
+
// Strip live, non-serializable handles (serverData / response / reset) + the
|
|
818
|
+
// request headers/cookies (SECURITY: never expose the session cookie to
|
|
819
|
+
// client JS — see #270). The raw `error` Error is dropped too; an error
|
|
820
|
+
// boundary's client-visible error rides in `errorForClient` instead.
|
|
821
|
+
const {
|
|
822
|
+
serverData: _sd,
|
|
823
|
+
response: _resp,
|
|
824
|
+
reset: _reset,
|
|
825
|
+
headers: _h,
|
|
826
|
+
cookies: _c,
|
|
827
|
+
error: _err,
|
|
828
|
+
...restProps
|
|
829
|
+
} = args.props ?? {};
|
|
830
|
+
const serializableProps: any = { ...restProps, headers: {}, cookies: {} };
|
|
831
|
+
if (args.errorForClient) serializableProps.error = args.errorForClient;
|
|
832
|
+
const hydrationPayload: any = {
|
|
833
|
+
component: args.component,
|
|
834
|
+
layouts: args.layouts ?? [],
|
|
835
|
+
props: serializableProps,
|
|
836
|
+
ssrData: args.ssrData,
|
|
837
|
+
};
|
|
838
|
+
if (args.kind) hydrationPayload.kind = args.kind;
|
|
839
|
+
// Escape `<` (closes a </script> breakout) + U+2028/U+2029 (JSON-valid but
|
|
840
|
+
// JS statement terminators). Regex form keeps the separators visible in
|
|
841
|
+
// source rather than as invisible literals.
|
|
842
|
+
const json = JSON.stringify(hydrationPayload)
|
|
843
|
+
.replace(/</g, "\\u003c")
|
|
844
|
+
.replace(/\u2028/g, "\\u2028")
|
|
845
|
+
.replace(/\u2029/g, "\\u2029");
|
|
846
|
+
let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
|
|
847
|
+
if (args.manifestRoute) {
|
|
848
|
+
tail += `<script type="module" src="${args.publicPrefix}${args.manifestRoute.file}"></script>`;
|
|
849
|
+
} else {
|
|
850
|
+
tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${args.manifestErr}`)})</script>`;
|
|
851
|
+
}
|
|
852
|
+
if (process.env.PYLON_DEV_MODE) tail += DEV_LIVE_RELOAD_SNIPPET;
|
|
853
|
+
return tail;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* The layout chain for a component, walked top-down from `app/` to the
|
|
858
|
+
* component's own directory — IDENTICAL to the bundler's `discoverRoutes`
|
|
859
|
+
* accumulation (and the SDK's `discoverAppRoutes`). A boundary's hydration
|
|
860
|
+
* needs the SERVER tree wrapped in the SAME layouts the bundler baked into
|
|
861
|
+
* its client entry; the catch path otherwise has only the failing PAGE's
|
|
862
|
+
* layouts, which would mismatch a root boundary covering a nested page.
|
|
863
|
+
*/
|
|
864
|
+
function resolveLayoutChain(componentRelPath: string, cwd: string): string[] {
|
|
865
|
+
const fs = require("node:fs");
|
|
866
|
+
const path = require("node:path");
|
|
867
|
+
const rel = componentRelPath.replace(/\\/g, "/");
|
|
868
|
+
const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
|
|
869
|
+
const parts = dir.split("/").filter(Boolean);
|
|
870
|
+
const layouts: string[] = [];
|
|
871
|
+
let acc = "";
|
|
872
|
+
for (const part of parts) {
|
|
873
|
+
acc = acc ? `${acc}/${part}` : part;
|
|
874
|
+
for (const ext of MODULE_EXTS) {
|
|
875
|
+
if (fs.existsSync(path.join(cwd, acc, `layout${ext}`))) {
|
|
876
|
+
layouts.push(`${acc}/layout`);
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return layouts;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* A short, non-reversible correlation id for an error — surfaced to the
|
|
886
|
+
* client error boundary as `error.digest` (matching server logs) WITHOUT
|
|
887
|
+
* carrying any stack content. FNV-1a over message+stack, 8 hex chars.
|
|
888
|
+
*/
|
|
889
|
+
export function errorDigest(err: any): string {
|
|
890
|
+
const s = `${err?.message ?? ""}\n${err?.stack ?? ""}`;
|
|
891
|
+
let h = 0x811c9dc5;
|
|
892
|
+
for (let i = 0; i < s.length; i++) {
|
|
893
|
+
h ^= s.charCodeAt(i);
|
|
894
|
+
h = Math.imul(h, 0x01000193);
|
|
895
|
+
}
|
|
896
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Render a boundary (not-found/error) tree, stream it at `status`, and —
|
|
901
|
+
* when the boundary has a client bundle entry (#279) — append the hydration
|
|
902
|
+
* tail so onClick/useState/`reset()` work. With no manifest entry the
|
|
903
|
+
* boundary still renders (server-only, styled) — CSS/hydration must never
|
|
904
|
+
* block the error path.
|
|
800
905
|
*/
|
|
801
906
|
async function renderBoundaryToClient(
|
|
802
907
|
React: any,
|
|
@@ -806,6 +911,14 @@ async function renderBoundaryToClient(
|
|
|
806
911
|
callId: string,
|
|
807
912
|
status: number,
|
|
808
913
|
headers: Record<string, string>,
|
|
914
|
+
tail?: {
|
|
915
|
+
component: string;
|
|
916
|
+
layouts: string[];
|
|
917
|
+
props: any;
|
|
918
|
+
ssrData: Record<string, any>;
|
|
919
|
+
kind: "error" | "not-found";
|
|
920
|
+
errorForClient?: { message: string; digest?: string };
|
|
921
|
+
},
|
|
809
922
|
): Promise<void> {
|
|
810
923
|
const stream: ReadableStream<Uint8Array> = await renderToReadableStream(tree, {
|
|
811
924
|
onError(e: unknown) {
|
|
@@ -813,7 +926,35 @@ async function renderBoundaryToClient(
|
|
|
813
926
|
console.error("[ssr] boundary render error:", e);
|
|
814
927
|
},
|
|
815
928
|
});
|
|
816
|
-
|
|
929
|
+
// Resolve the boundary's own client entry (keyed by its component path) so
|
|
930
|
+
// the head gets ITS css/modulepreloads and the body-tail loads ITS script.
|
|
931
|
+
let manifestRoute:
|
|
932
|
+
| { file: string; imports: string[]; css: string[] }
|
|
933
|
+
| null = null;
|
|
934
|
+
let publicPrefix = "/_pylon/build/";
|
|
935
|
+
let headBlob = "";
|
|
936
|
+
if (tail) {
|
|
937
|
+
try {
|
|
938
|
+
const { getManifest } = await import("./ssr-client-bundler");
|
|
939
|
+
const manifest = await getManifest();
|
|
940
|
+
publicPrefix = manifest.public_prefix || publicPrefix;
|
|
941
|
+
manifestRoute = manifest.routes[tail.component] ?? null;
|
|
942
|
+
} catch {
|
|
943
|
+
manifestRoute = null;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
if (manifestRoute) {
|
|
947
|
+
for (const css of manifestRoute.css) {
|
|
948
|
+
headBlob += `<link rel="stylesheet" href="${publicPrefix}${css}">`;
|
|
949
|
+
}
|
|
950
|
+
for (const chunk of manifestRoute.imports) {
|
|
951
|
+
headBlob += `<link rel="modulepreload" href="${publicPrefix}${chunk}">`;
|
|
952
|
+
}
|
|
953
|
+
} else {
|
|
954
|
+
// No per-boundary entry → fall back to the global stylesheet union so the
|
|
955
|
+
// page is at least styled (static).
|
|
956
|
+
headBlob = await collectBoundaryHeadBlob();
|
|
957
|
+
}
|
|
817
958
|
// renderToReadableStream resolved without throwing → safe to commit the
|
|
818
959
|
// head now, then drain the (already-rendered) shell, injecting CSS.
|
|
819
960
|
send({ type: "response_start", call_id: callId, status, headers });
|
|
@@ -826,6 +967,20 @@ async function renderBoundaryToClient(
|
|
|
826
967
|
});
|
|
827
968
|
};
|
|
828
969
|
await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
|
|
970
|
+
if (tail && manifestRoute) {
|
|
971
|
+
const tailHtml = buildHydrationTail({
|
|
972
|
+
component: tail.component,
|
|
973
|
+
layouts: tail.layouts,
|
|
974
|
+
props: tail.props,
|
|
975
|
+
ssrData: tail.ssrData,
|
|
976
|
+
manifestRoute,
|
|
977
|
+
publicPrefix,
|
|
978
|
+
manifestErr: null,
|
|
979
|
+
kind: tail.kind,
|
|
980
|
+
errorForClient: tail.errorForClient,
|
|
981
|
+
});
|
|
982
|
+
sendChunk(tailHtml);
|
|
983
|
+
}
|
|
829
984
|
send({ type: "render_done", call_id: callId });
|
|
830
985
|
}
|
|
831
986
|
|
|
@@ -848,7 +1003,7 @@ async function tryRenderBoundary(
|
|
|
848
1003
|
headers: Record<string, string>;
|
|
849
1004
|
},
|
|
850
1005
|
): Promise<boolean> {
|
|
851
|
-
const { React, renderToReadableStream, cwd, componentPath, fileName,
|
|
1006
|
+
const { React, renderToReadableStream, cwd, componentPath, fileName, props, send, callId, status, headers } =
|
|
852
1007
|
opts;
|
|
853
1008
|
if (!React || !renderToReadableStream || !props) return false;
|
|
854
1009
|
const rel = findBoundary(componentPath, fileName);
|
|
@@ -857,9 +1012,45 @@ async function tryRenderBoundary(
|
|
|
857
1012
|
const mod = await importModule(cwd, rel);
|
|
858
1013
|
const Comp = mod.default ?? mod.Component ?? mod.NotFound ?? mod.Error;
|
|
859
1014
|
if (typeof Comp !== "function") return false;
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1015
|
+
// The boundary hydrates through its OWN layout chain (walked from app/ to
|
|
1016
|
+
// the boundary's dir) — NOT the failing page's chain — so the server tree
|
|
1017
|
+
// matches the client entry the bundler baked for this boundary (#279).
|
|
1018
|
+
const boundaryLayouts = resolveLayoutChain(rel, cwd);
|
|
1019
|
+
// For an error boundary, project the thrown Error to the SAFE client shape
|
|
1020
|
+
// ({message, digest}) and give BOTH server + client the SAME value (zero
|
|
1021
|
+
// hydration mismatch) + a no-op reset() server-side. The raw Error/stack
|
|
1022
|
+
// never reaches the client (the dev overlay owns dev stacks; #270).
|
|
1023
|
+
let errorForClient: { message: string; digest?: string } | undefined;
|
|
1024
|
+
let compProps = props;
|
|
1025
|
+
if (fileName === "error") {
|
|
1026
|
+
const rawErr = props.error;
|
|
1027
|
+
errorForClient = {
|
|
1028
|
+
message: rawErr?.message ?? String(rawErr ?? "Error"),
|
|
1029
|
+
digest: errorDigest(rawErr),
|
|
1030
|
+
};
|
|
1031
|
+
compProps = { ...props, error: errorForClient, reset: () => {} };
|
|
1032
|
+
}
|
|
1033
|
+
let tree = React.createElement(Comp, compProps);
|
|
1034
|
+
tree = await buildLayoutTree(cwd, tree, boundaryLayouts, compProps, React);
|
|
1035
|
+
await renderBoundaryToClient(
|
|
1036
|
+
React,
|
|
1037
|
+
renderToReadableStream,
|
|
1038
|
+
tree,
|
|
1039
|
+
send,
|
|
1040
|
+
callId,
|
|
1041
|
+
status,
|
|
1042
|
+
headers,
|
|
1043
|
+
{
|
|
1044
|
+
component: rel,
|
|
1045
|
+
layouts: boundaryLayouts,
|
|
1046
|
+
props: compProps,
|
|
1047
|
+
// Catch-path boundaries don't set up serverData/use() (the by-name
|
|
1048
|
+
// not-found dispatch through handleRenderRoute does); empty ssrData.
|
|
1049
|
+
ssrData: {},
|
|
1050
|
+
kind: fileName === "error" ? "error" : "not-found",
|
|
1051
|
+
errorForClient,
|
|
1052
|
+
},
|
|
1053
|
+
);
|
|
863
1054
|
return true;
|
|
864
1055
|
} catch (e) {
|
|
865
1056
|
// Boundary render itself failed — no tertiary fallback; let the caller
|
|
@@ -1059,6 +1250,41 @@ export async function handleRenderRoute(
|
|
|
1059
1250
|
metadata = applyAutoIcons(msg.component, metadata);
|
|
1060
1251
|
const metaFragment = renderMetadata(React, metadata);
|
|
1061
1252
|
|
|
1253
|
+
// loading.tsx (#278): the nearest `loading` module — walked up from the
|
|
1254
|
+
// page dir, like not-found/error — becomes ONE route-level Suspense
|
|
1255
|
+
// fallback wrapping the page. When present, the shell (layouts) + this
|
|
1256
|
+
// skeleton flush immediately and React reveals the real page content when
|
|
1257
|
+
// the page's top-level `use()` resolves, instead of buffering the whole
|
|
1258
|
+
// document (see the `allReady` gate below). A page with no loading.tsx
|
|
1259
|
+
// keeps the byte-identical buffered single-flush path.
|
|
1260
|
+
//
|
|
1261
|
+
// The skeleton is SERVER-ONLY and must not read `serverData` (a read would
|
|
1262
|
+
// suspend the FALLBACK itself, delaying the shell). It gets the page props
|
|
1263
|
+
// for url/params/searchParams/auth.
|
|
1264
|
+
const loadingRel = findBoundary(msg.component, "loading");
|
|
1265
|
+
let Loading: any = null;
|
|
1266
|
+
if (loadingRel) {
|
|
1267
|
+
try {
|
|
1268
|
+
const lMod = await importModule(cwd, loadingRel);
|
|
1269
|
+
const L = lMod.default ?? lMod.Loading ?? lMod.loading;
|
|
1270
|
+
if (typeof L === "function") Loading = L;
|
|
1271
|
+
} catch {
|
|
1272
|
+
// A broken loading.tsx must never block the page — fall back to the
|
|
1273
|
+
// buffered path.
|
|
1274
|
+
Loading = null;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// The page leaf, optionally wrapped in the single Suspense boundary.
|
|
1279
|
+
let pageLeaf: any = React.createElement(Component, props);
|
|
1280
|
+
if (Loading) {
|
|
1281
|
+
pageLeaf = React.createElement(
|
|
1282
|
+
React.Suspense,
|
|
1283
|
+
{ fallback: React.createElement(Loading, props) },
|
|
1284
|
+
pageLeaf,
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1062
1288
|
// Resolve the layout chain. Each layout module exports a default
|
|
1063
1289
|
// function that accepts the same props + `children`. Walk leaf →
|
|
1064
1290
|
// root: start with the page component as `tree`, then for each
|
|
@@ -1067,13 +1293,8 @@ export async function handleRenderRoute(
|
|
|
1067
1293
|
// the page. The metadata fragment is the FIRST child so React hoists
|
|
1068
1294
|
// its <title>/<meta> into the <head> a layout renders.
|
|
1069
1295
|
let tree: any = metaFragment
|
|
1070
|
-
? React.createElement(
|
|
1071
|
-
|
|
1072
|
-
null,
|
|
1073
|
-
metaFragment,
|
|
1074
|
-
React.createElement(Component, props),
|
|
1075
|
-
)
|
|
1076
|
-
: React.createElement(Component, props);
|
|
1296
|
+
? React.createElement(React.Fragment, null, metaFragment, pageLeaf)
|
|
1297
|
+
: pageLeaf;
|
|
1077
1298
|
tree = await buildLayoutTree(cwd, tree, msg.layouts, props, React);
|
|
1078
1299
|
const element = tree;
|
|
1079
1300
|
const stream: ReadableStream<Uint8Array> = await renderToReadableStream(
|
|
@@ -1099,7 +1320,16 @@ export async function handleRenderRoute(
|
|
|
1099
1320
|
// whole-document hydration (which leaves the boundary stuck on its
|
|
1100
1321
|
// fallback). Pages with no async data have no boundaries, so `allReady`
|
|
1101
1322
|
// resolves immediately — zero cost for the common case.
|
|
1102
|
-
|
|
1323
|
+
//
|
|
1324
|
+
// EXCEPTION (#278): when a loading.tsx wraps the page in a Suspense
|
|
1325
|
+
// boundary, we DELIBERATELY skip the buffer and stream — the shell +
|
|
1326
|
+
// skeleton flush first, then React reveals the real content + its reveal
|
|
1327
|
+
// script as the page's `use()` resolves. Hydration stays clean because
|
|
1328
|
+
// there's exactly ONE route-level boundary and the tail `__PYLON_DATA__`
|
|
1329
|
+
// (emitted below, after the stream drains to EOF) still carries a fully
|
|
1330
|
+
// resolved `ssrData` map — so the client's `use()` reads a fulfilled
|
|
1331
|
+
// value and never re-suspends.
|
|
1332
|
+
if (!Loading && (stream as any).allReady) {
|
|
1103
1333
|
await (stream as any).allReady;
|
|
1104
1334
|
}
|
|
1105
1335
|
|
|
@@ -1196,67 +1426,29 @@ export async function handleRenderRoute(
|
|
|
1196
1426
|
// and `getManifest` parses with mtime-keyed caching. Falls back
|
|
1197
1427
|
// to a no-hydration warning if the manifest can't be loaded
|
|
1198
1428
|
// (rare — usually means the bundler crashed).
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
// token), `Authorization`, and client IP to any client-side JS —
|
|
1209
|
-
// defeating HttpOnly and handing a same-page XSS an exfil target. The
|
|
1210
|
-
// client gets empty maps (shape preserved so `props.headers`/`.cookies`
|
|
1211
|
-
// aren't `undefined`); a page that must surface a request value to the
|
|
1212
|
-
// browser should pass it explicitly via a prop or `serverData`.
|
|
1213
|
-
const {
|
|
1214
|
-
serverData: _sd,
|
|
1215
|
-
response: _resp,
|
|
1216
|
-
headers: _h,
|
|
1217
|
-
cookies: _c,
|
|
1218
|
-
...restProps
|
|
1219
|
-
} = props;
|
|
1220
|
-
const serializableProps = { ...restProps, headers: {}, cookies: {} };
|
|
1221
|
-
const hydrationPayload = {
|
|
1429
|
+
// Pages always hydrate. A boundary dispatched BY NAME here (the host
|
|
1430
|
+
// rendering `app/not-found` at 404) now hydrates too (#279) when it has a
|
|
1431
|
+
// client entry - only stay server-only (no tail) when there's no entry to
|
|
1432
|
+
// load. `buildHydrationTail` does the props strip (serverData/response +
|
|
1433
|
+
// the security headers/cookies strip) + the </script> + U+2028/2029
|
|
1434
|
+
// escaping. The CSS/modulepreload links were already injected into <head>.
|
|
1435
|
+
const wantsHydration = !isBoundaryComponent || !!preloadManifestRoute;
|
|
1436
|
+
if (wantsHydration) {
|
|
1437
|
+
const tail = buildHydrationTail({
|
|
1222
1438
|
component: msg.component,
|
|
1223
1439
|
layouts: msg.layouts ?? [],
|
|
1224
|
-
props
|
|
1440
|
+
props,
|
|
1225
1441
|
ssrData: ssrValueCache,
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
.replaceAll("
", "\\u2029");
|
|
1235
|
-
|
|
1236
|
-
let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
|
|
1237
|
-
if (preloadManifestRoute) {
|
|
1238
|
-
// Per-route entry script comes last — it needs the inline
|
|
1239
|
-
// `__PYLON_DATA__` above to have been parsed before it runs.
|
|
1240
|
-
// CSS + modulepreload links were already injected into `<head>`
|
|
1241
|
-
// above so they could start fetching as early as possible.
|
|
1242
|
-
tail += `<script type="module" src="${preloadPublicPrefix}${preloadManifestRoute.file}"></script>`;
|
|
1243
|
-
} else {
|
|
1244
|
-
tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${preloadManifestErr}`)})</script>`;
|
|
1245
|
-
}
|
|
1246
|
-
// Dev-only browser live-reload. `pylon dev` (PYLON_DEV_MODE=1) re-execs
|
|
1247
|
-
// the whole process on every file edit; each process serves a fresh
|
|
1248
|
-
// boot id from /_pylon/dev/live. This client subscribes via
|
|
1249
|
-
// EventSource and reloads the tab when the boot id changes — so saving
|
|
1250
|
-
// a page, a component, or app/globals.css refreshes the browser with
|
|
1251
|
-
// no manual F5. Stripped entirely in production builds.
|
|
1252
|
-
if (process.env.PYLON_DEV_MODE) {
|
|
1253
|
-
tail += DEV_LIVE_RELOAD_SNIPPET;
|
|
1254
|
-
}
|
|
1255
|
-
send({
|
|
1256
|
-
type: "render_chunk",
|
|
1257
|
-
call_id: msg.call_id,
|
|
1258
|
-
data: Buffer.from(tail, "utf8").toString("base64"),
|
|
1442
|
+
manifestRoute: preloadManifestRoute,
|
|
1443
|
+
publicPrefix: preloadPublicPrefix,
|
|
1444
|
+
manifestErr: preloadManifestErr,
|
|
1445
|
+
kind: isBoundaryComponent
|
|
1446
|
+
? /(^|\/)error$/.test(msg.component)
|
|
1447
|
+
? "error"
|
|
1448
|
+
: "not-found"
|
|
1449
|
+
|
|
1259
1450
|
});
|
|
1451
|
+
sendChunk(tail);
|
|
1260
1452
|
}
|
|
1261
1453
|
|
|
1262
1454
|
send({ type: "render_done", call_id: msg.call_id });
|