@pylonsync/functions 0.4.8 → 0.4.10
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/dist/ssr-client-boundary.d.ts +12 -8
- package/dist/ssr-client-bundler.d.ts +24 -0
- package/dist/ssr-route-match.d.ts +24 -1
- package/dist/ssr-runtime.d.ts +22 -0
- package/package.json +1 -1
- package/src/ssr-client-boundary.ts +13 -8
- package/src/ssr-client-bundler.test.ts +162 -0
- package/src/ssr-client-bundler.ts +197 -21
- package/src/ssr-route-match.test.ts +96 -1
- package/src/ssr-route-match.ts +47 -2
- package/src/ssr-runtime.ts +27 -2
- package/src/ssr-utf8.test.ts +110 -0
|
@@ -1,15 +1,19 @@
|
|
|
1
|
+
/** A per-directory module resolved by walking up from a route: the two error
|
|
2
|
+
* boundaries, plus `loading` (the pending-navigation skeleton). */
|
|
3
|
+
export type BoundaryFile = "not-found" | "error" | "loading";
|
|
1
4
|
/**
|
|
2
|
-
* Resolve the nearest
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* route keys so the client runtime needs no extra server round-trip.
|
|
5
|
+
* Resolve the nearest `<dir>/<fileName>` module for a route by walking its
|
|
6
|
+
* component path up to the app root, returning the first key that exists.
|
|
7
|
+
* Nearest ancestor wins — the same model the server's `findBoundary` uses, but
|
|
8
|
+
* driven off client-side keys so the runtime needs no extra server round-trip.
|
|
7
9
|
*
|
|
8
10
|
* `component` is a cwd-relative path with "/" separators and no extension
|
|
9
|
-
* (e.g. "web/app/dashboard/orgs/[slug]/page")
|
|
10
|
-
* `Object.keys(manifest.routes)
|
|
11
|
+
* (e.g. "web/app/dashboard/orgs/[slug]/page"). `keys` is the set to resolve
|
|
12
|
+
* against: `Object.keys(manifest.routes)` for not-found/error, which ship as
|
|
13
|
+
* route entries, or the loading registry's keys for `loading`, which ships in
|
|
14
|
+
* the shared chunk instead.
|
|
11
15
|
*/
|
|
12
|
-
export declare function nearestBoundaryComponent(component: string, fileName:
|
|
16
|
+
export declare function nearestBoundaryComponent(component: string, fileName: BoundaryFile, routeKeys: Iterable<string>): string | null;
|
|
13
17
|
/** Runtime internals the boundary needs, injected so the module stays
|
|
14
18
|
* browser-safe AND unit-testable. */
|
|
15
19
|
export interface BoundaryDeps {
|
|
@@ -24,6 +24,30 @@ interface BundleClientMessage {
|
|
|
24
24
|
* import into a loud build failure that names the offending importer.
|
|
25
25
|
*/
|
|
26
26
|
export declare function assertNotServerOnly(specifier: string, importer: string): void;
|
|
27
|
+
/**
|
|
28
|
+
* Every route-level `loading` module under the app dir, as project-relative
|
|
29
|
+
* component paths (`app/dashboard/loading`), sorted for a stable build.
|
|
30
|
+
*
|
|
31
|
+
* Unlike not-found / error, these get NO client entry of their own — see
|
|
32
|
+
* `generateLoadingRegistry` for why.
|
|
33
|
+
*/
|
|
34
|
+
export declare function discoverLoadingModules(fs: any, path: any, cwd: string, appDirRel: string): string[];
|
|
35
|
+
/**
|
|
36
|
+
* The module the client runtime imports as `./loading-registry`: every
|
|
37
|
+
* loading.tsx in the app, keyed by component path so the runtime can walk up
|
|
38
|
+
* from a destination route to its nearest one — the same nearest-ancestor rule
|
|
39
|
+
* the server's findBoundary applies.
|
|
40
|
+
*
|
|
41
|
+
* Statically imported, so these land in the SHARED chunk rather than shipping
|
|
42
|
+
* as route entries the way not-found / error do. A loading state exists to
|
|
43
|
+
* acknowledge a click that is already waiting on the network; fetching a chunk
|
|
44
|
+
* to render it would put it behind the very delay it covers.
|
|
45
|
+
*
|
|
46
|
+
* Route groups need no special handling: a page under `app/(dash)/settings`
|
|
47
|
+
* resolves against `app/(dash)/loading` by path, exactly as it does on the
|
|
48
|
+
* server.
|
|
49
|
+
*/
|
|
50
|
+
export declare function generateLoadingRegistry(components: string[]): string;
|
|
27
51
|
/**
|
|
28
52
|
* Manifest schema. One entry per route, indexed by the same
|
|
29
53
|
* project-relative component path the SSR side passes through.
|
|
@@ -4,10 +4,14 @@ export interface RouteMatch {
|
|
|
4
4
|
/** Decoded dynamic params captured from the path (e.g. `{ slug: "shoe-x" }`). */
|
|
5
5
|
params: Record<string, string>;
|
|
6
6
|
}
|
|
7
|
-
/** The slice of the build manifest this
|
|
7
|
+
/** The slice of the build manifest this module needs. */
|
|
8
8
|
export interface MatchableManifest {
|
|
9
9
|
routes: Record<string, {
|
|
10
10
|
path?: string;
|
|
11
|
+
/** The route's own entry chunk (outdir-relative). */
|
|
12
|
+
file?: string;
|
|
13
|
+
/** Shared chunks the browser needs before that entry runs. */
|
|
14
|
+
imports?: string[];
|
|
11
15
|
}>;
|
|
12
16
|
}
|
|
13
17
|
/**
|
|
@@ -18,3 +22,22 @@ export interface MatchableManifest {
|
|
|
18
22
|
* so `/orders/new` beats `/orders/[id]` beats `/[...all]`.
|
|
19
23
|
*/
|
|
20
24
|
export declare function matchRoute(manifest: MatchableManifest | null | undefined, pathname: string): RouteMatch | null;
|
|
25
|
+
/** What `<Link prefetch>` should warm for a destination href. */
|
|
26
|
+
export interface PrefetchTargets {
|
|
27
|
+
/** The destination route's own entry chunk, or "" when no page route matches. */
|
|
28
|
+
file: string;
|
|
29
|
+
/** Shared chunks to warm alongside it. */
|
|
30
|
+
imports: string[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The chunks a click on `pathname` will need before anything can render: the
|
|
34
|
+
* destination route's entry, plus the shared chunks (React, the client
|
|
35
|
+
* runtime, common layouts) that every route pulls in.
|
|
36
|
+
*
|
|
37
|
+
* Warming the page payload alone leaves the entry chunk to be fetched after
|
|
38
|
+
* the click, and the route cannot render until it lands — so the prefetch
|
|
39
|
+
* covers only the half that was already fast. An href matching no page route
|
|
40
|
+
* (an API path, a route this build doesn't serve) still yields the shared set;
|
|
41
|
+
* `file` is "" and the caller skips it.
|
|
42
|
+
*/
|
|
43
|
+
export declare function prefetchTargets(manifest: MatchableManifest | null | undefined, pathname: string): PrefetchTargets;
|
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -339,6 +339,28 @@ export declare function isSafeRedirect(url: string, opts: {
|
|
|
339
339
|
* into a page's metadata. Explicit `metadata.icons.*` wins. */
|
|
340
340
|
export declare function applyAutoIcons(component: string, metadata: SsrMetadata | undefined): SsrMetadata | undefined;
|
|
341
341
|
export declare function applyAutoSocialImages(component: string, headers: Record<string, string> | undefined, metadata: SsrMetadata | undefined, requestUrl?: string): SsrMetadata | undefined;
|
|
342
|
+
/**
|
|
343
|
+
* Drain a `renderToReadableStream` reader, injecting `headBlob` immediately
|
|
344
|
+
* before the first `</head>` (or, if the document has none, the blob is
|
|
345
|
+
* never emitted — fragment renders have no head). `</head>` can straddle a
|
|
346
|
+
* chunk boundary, so a small carry buffer (len("</head>") − 1 bytes) is
|
|
347
|
+
* withheld at each chunk's tail until the next read confirms the match.
|
|
348
|
+
* Each emitted slice is handed to `sendChunk` as utf-8 text.
|
|
349
|
+
*
|
|
350
|
+
* Shared by the page render and the boundary render so head injection has
|
|
351
|
+
* exactly one implementation.
|
|
352
|
+
*
|
|
353
|
+
* Decoding uses ONE `TextDecoder` with `{stream: true}` for the whole
|
|
354
|
+
* reader, never a per-chunk decode. React splits the byte stream at
|
|
355
|
+
* arbitrary offsets, so a multi-byte character can land across two chunks;
|
|
356
|
+
* decoding each chunk independently turns the orphaned bytes into one
|
|
357
|
+
* U+FFFD apiece — `…` (e2 80 a6) arrives as three replacement characters.
|
|
358
|
+
* The failure is silent and position-dependent: markup added anywhere
|
|
359
|
+
* earlier shifts the boundary, so a page can render correctly for months
|
|
360
|
+
* and corrupt on an unrelated CSS change. A streaming decoder holds the
|
|
361
|
+
* partial sequence back until the bytes that complete it arrive.
|
|
362
|
+
*/
|
|
363
|
+
export declare function streamWithHeadInjection(reader: ReadableStreamDefaultReader<Uint8Array>, headBlob: string, sendChunk: (text: string) => void): Promise<void>;
|
|
342
364
|
/**
|
|
343
365
|
* Dev-only tail chunk: the `__PYLON_DEV__` info blob (cache verdict, render
|
|
344
366
|
* mode/timing, route) + the HUD bootstrap. Embedded after the page tail so the
|
package/package.json
CHANGED
|
@@ -21,20 +21,25 @@
|
|
|
21
21
|
|
|
22
22
|
import { Component, createElement, useEffect, useState } from "react";
|
|
23
23
|
|
|
24
|
+
/** A per-directory module resolved by walking up from a route: the two error
|
|
25
|
+
* boundaries, plus `loading` (the pending-navigation skeleton). */
|
|
26
|
+
export type BoundaryFile = "not-found" | "error" | "loading";
|
|
27
|
+
|
|
24
28
|
/**
|
|
25
|
-
* Resolve the nearest
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* route keys so the client runtime needs no extra server round-trip.
|
|
29
|
+
* Resolve the nearest `<dir>/<fileName>` module for a route by walking its
|
|
30
|
+
* component path up to the app root, returning the first key that exists.
|
|
31
|
+
* Nearest ancestor wins — the same model the server's `findBoundary` uses, but
|
|
32
|
+
* driven off client-side keys so the runtime needs no extra server round-trip.
|
|
30
33
|
*
|
|
31
34
|
* `component` is a cwd-relative path with "/" separators and no extension
|
|
32
|
-
* (e.g. "web/app/dashboard/orgs/[slug]/page")
|
|
33
|
-
* `Object.keys(manifest.routes)
|
|
35
|
+
* (e.g. "web/app/dashboard/orgs/[slug]/page"). `keys` is the set to resolve
|
|
36
|
+
* against: `Object.keys(manifest.routes)` for not-found/error, which ship as
|
|
37
|
+
* route entries, or the loading registry's keys for `loading`, which ships in
|
|
38
|
+
* the shared chunk instead.
|
|
34
39
|
*/
|
|
35
40
|
export function nearestBoundaryComponent(
|
|
36
41
|
component: string,
|
|
37
|
-
fileName:
|
|
42
|
+
fileName: BoundaryFile,
|
|
38
43
|
routeKeys: Iterable<string>,
|
|
39
44
|
): string | null {
|
|
40
45
|
const keys = routeKeys instanceof Set ? routeKeys : new Set(routeKeys);
|
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
buildClientBundle,
|
|
26
26
|
buildTailwind,
|
|
27
27
|
assertNotServerOnly,
|
|
28
|
+
discoverLoadingModules,
|
|
29
|
+
generateLoadingRegistry,
|
|
28
30
|
type PylonBundleManifest,
|
|
29
31
|
} from "./ssr-client-bundler";
|
|
30
32
|
import { nearestBoundaryComponent } from "./ssr-client-boundary";
|
|
@@ -488,3 +490,163 @@ describe("server-only guard (secrets can't leak into the client bundle)", () =>
|
|
|
488
490
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
489
491
|
});
|
|
490
492
|
});
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// loading.tsx during client-side navigation.
|
|
496
|
+
//
|
|
497
|
+
// Regression: a route-level loading.tsx was honored ONLY by the server's
|
|
498
|
+
// streaming render. The client bundler emitted entries for not-found and error
|
|
499
|
+
// but not loading, so no loading module existed on the client at all and a
|
|
500
|
+
// pending navigation left the previous page fully painted until the
|
|
501
|
+
// destination was ready. Polling the DOM every 15ms across a sidebar click
|
|
502
|
+
// never saw the skeleton; the same route hard-loaded streamed it fine.
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
const LOADING_BODY = `
|
|
506
|
+
import React from "react";
|
|
507
|
+
export default function Loading() {
|
|
508
|
+
return <div aria-busy="true" data-skeleton="pylon-pending">Loading…</div>;
|
|
509
|
+
}
|
|
510
|
+
`;
|
|
511
|
+
|
|
512
|
+
describe("discoverLoadingModules", () => {
|
|
513
|
+
test("finds loading modules at every depth, sorted", () => {
|
|
514
|
+
tempDir = makeFixture(
|
|
515
|
+
{
|
|
516
|
+
"page.tsx": PAGE_BODY("Home"),
|
|
517
|
+
"loading.tsx": LOADING_BODY,
|
|
518
|
+
"dashboard/page.tsx": PAGE_BODY("Dash"),
|
|
519
|
+
"dashboard/events/[id]/page.tsx": PAGE_BODY("Event"),
|
|
520
|
+
"dashboard/events/[id]/loading.tsx": LOADING_BODY,
|
|
521
|
+
},
|
|
522
|
+
{ "layout.tsx": LAYOUT_BODY },
|
|
523
|
+
);
|
|
524
|
+
expect(discoverLoadingModules(fs, path, tempDir, "app")).toEqual([
|
|
525
|
+
"app/dashboard/events/[id]/loading",
|
|
526
|
+
"app/loading",
|
|
527
|
+
]);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("finds one inside a route group", () => {
|
|
531
|
+
// Chrome commonly lives in a (group) layout; its loading sibling has to be
|
|
532
|
+
// discoverable by the same path walk the server uses.
|
|
533
|
+
tempDir = makeFixture(
|
|
534
|
+
{
|
|
535
|
+
"(dash)/settings/page.tsx": PAGE_BODY("Settings"),
|
|
536
|
+
"(dash)/loading.tsx": LOADING_BODY,
|
|
537
|
+
},
|
|
538
|
+
{ "layout.tsx": LAYOUT_BODY },
|
|
539
|
+
);
|
|
540
|
+
expect(discoverLoadingModules(fs, path, tempDir, "app")).toEqual([
|
|
541
|
+
"app/(dash)/loading",
|
|
542
|
+
]);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("an app with no loading.tsx yields an empty list, not an error", () => {
|
|
546
|
+
tempDir = makeFixture(
|
|
547
|
+
{ "page.tsx": PAGE_BODY("Home") },
|
|
548
|
+
{ "layout.tsx": LAYOUT_BODY },
|
|
549
|
+
);
|
|
550
|
+
expect(discoverLoadingModules(fs, path, tempDir, "app")).toEqual([]);
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
describe("generateLoadingRegistry", () => {
|
|
555
|
+
test("keys each module by component path so the nearest-ancestor walk works", () => {
|
|
556
|
+
const src = generateLoadingRegistry([
|
|
557
|
+
"app/loading",
|
|
558
|
+
"app/dashboard/loading",
|
|
559
|
+
]);
|
|
560
|
+
expect(src).toContain(`import M0 from "../app/loading";`);
|
|
561
|
+
expect(src).toContain(`import M1 from "../app/dashboard/loading";`);
|
|
562
|
+
expect(src).toContain(`"app/loading": M0,`);
|
|
563
|
+
expect(src).toContain(`"app/dashboard/loading": M1,`);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("emits a valid empty registry when the app has none", () => {
|
|
567
|
+
// The runtime imports LOADING_MODULES unconditionally — an app without a
|
|
568
|
+
// loading.tsx must still produce a module that parses and exports it.
|
|
569
|
+
const src = generateLoadingRegistry([]);
|
|
570
|
+
expect(src).toContain("export const LOADING_MODULES = {");
|
|
571
|
+
expect(src).not.toContain("import M0");
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
describe("nearestBoundaryComponent resolves loading.tsx", () => {
|
|
576
|
+
const loadingKeys = new Set([
|
|
577
|
+
"web/app/loading",
|
|
578
|
+
"web/app/dashboard/events/[id]/loading",
|
|
579
|
+
]);
|
|
580
|
+
|
|
581
|
+
test("a sibling route picks up the nearest ancestor's skeleton", () => {
|
|
582
|
+
// Every tab under events/[id] shares that one loading.tsx.
|
|
583
|
+
expect(
|
|
584
|
+
nearestBoundaryComponent(
|
|
585
|
+
"web/app/dashboard/events/[id]/speakers/page",
|
|
586
|
+
"loading",
|
|
587
|
+
loadingKeys,
|
|
588
|
+
),
|
|
589
|
+
).toBe("web/app/dashboard/events/[id]/loading");
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("falls back to the root skeleton outside that subtree", () => {
|
|
593
|
+
expect(
|
|
594
|
+
nearestBoundaryComponent("web/app/settings/page", "loading", loadingKeys),
|
|
595
|
+
).toBe("web/app/loading");
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("returns null when the app ships none", () => {
|
|
599
|
+
expect(
|
|
600
|
+
nearestBoundaryComponent("web/app/page", "loading", new Set()),
|
|
601
|
+
).toBeNull();
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
describe("loading.tsx is wired into the client build", () => {
|
|
606
|
+
test("ships in the SHARED chunk — a skeleton can't wait on its own fetch", async () => {
|
|
607
|
+
tempDir = makeFixture(
|
|
608
|
+
{
|
|
609
|
+
"page.tsx": PAGE_BODY("Home"),
|
|
610
|
+
"dashboard/page.tsx": PAGE_BODY("Dash"),
|
|
611
|
+
"dashboard/loading.tsx": LOADING_BODY,
|
|
612
|
+
},
|
|
613
|
+
{ "layout.tsx": LAYOUT_BODY },
|
|
614
|
+
);
|
|
615
|
+
originalCwd = process.cwd();
|
|
616
|
+
process.chdir(tempDir);
|
|
617
|
+
|
|
618
|
+
const { manifestPath, outdir } = await buildClientBundle();
|
|
619
|
+
const manifest = JSON.parse(
|
|
620
|
+
fs.readFileSync(manifestPath, "utf8"),
|
|
621
|
+
) as PylonBundleManifest;
|
|
622
|
+
|
|
623
|
+
// Not a route entry: it has no URL of its own, and a loading state that
|
|
624
|
+
// costs a chunk fetch sits behind the very delay it exists to cover.
|
|
625
|
+
expect(manifest.routes["app/dashboard/loading"]).toBeUndefined();
|
|
626
|
+
|
|
627
|
+
const sharedChunks = fs
|
|
628
|
+
.readdirSync(path.join(outdir, "chunks"))
|
|
629
|
+
.map((n) => fs.readFileSync(path.join(outdir, "chunks", n), "utf8"))
|
|
630
|
+
.join("\n");
|
|
631
|
+
// The skeleton's own markup, and the registry key the runtime walks to.
|
|
632
|
+
expect(sharedChunks).toContain("pylon-pending");
|
|
633
|
+
expect(sharedChunks).toContain("app/dashboard/loading");
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("an app with no loading.tsx still builds and navigates", async () => {
|
|
637
|
+
// The runtime's import of ./loading-registry is unconditional, so the
|
|
638
|
+
// staged module must exist even with nothing to put in it.
|
|
639
|
+
tempDir = makeFixture(
|
|
640
|
+
{ "page.tsx": PAGE_BODY("Home"), "about/page.tsx": PAGE_BODY("About") },
|
|
641
|
+
{ "layout.tsx": LAYOUT_BODY },
|
|
642
|
+
);
|
|
643
|
+
originalCwd = process.cwd();
|
|
644
|
+
process.chdir(tempDir);
|
|
645
|
+
|
|
646
|
+
const { manifestPath } = await buildClientBundle();
|
|
647
|
+
expect(fs.existsSync(manifestPath)).toBe(true);
|
|
648
|
+
expect(
|
|
649
|
+
fs.existsSync(path.join(tempDir, ".pylon", "loading-registry.ts")),
|
|
650
|
+
).toBe(true);
|
|
651
|
+
});
|
|
652
|
+
});
|
|
@@ -230,6 +230,77 @@ function discoverRoutes(
|
|
|
230
230
|
}));
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Every route-level `loading` module under the app dir, as project-relative
|
|
235
|
+
* component paths (`app/dashboard/loading`), sorted for a stable build.
|
|
236
|
+
*
|
|
237
|
+
* Unlike not-found / error, these get NO client entry of their own — see
|
|
238
|
+
* `generateLoadingRegistry` for why.
|
|
239
|
+
*/
|
|
240
|
+
export function discoverLoadingModules(
|
|
241
|
+
fs: any,
|
|
242
|
+
path: any,
|
|
243
|
+
cwd: string,
|
|
244
|
+
appDirRel: string,
|
|
245
|
+
): string[] {
|
|
246
|
+
const found: string[] = [];
|
|
247
|
+
function walk(dir: string) {
|
|
248
|
+
let entries: any[];
|
|
249
|
+
try {
|
|
250
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
251
|
+
} catch {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const here = ["loading.tsx", "loading.ts", "loading.jsx", "loading.js"]
|
|
255
|
+
.map((n: string) => path.join(dir, n))
|
|
256
|
+
.find((p: string) => fs.existsSync(p));
|
|
257
|
+
if (here) {
|
|
258
|
+
found.push(
|
|
259
|
+
path.relative(cwd, here).replace(/\.(tsx?|jsx?)$/, "").replace(/\\/g, "/"),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
for (const e of entries) {
|
|
263
|
+
if (!e.isDirectory()) continue;
|
|
264
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
265
|
+
walk(path.join(dir, e.name));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
walk(path.join(cwd, appDirRel));
|
|
269
|
+
found.sort();
|
|
270
|
+
return found;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The module the client runtime imports as `./loading-registry`: every
|
|
275
|
+
* loading.tsx in the app, keyed by component path so the runtime can walk up
|
|
276
|
+
* from a destination route to its nearest one — the same nearest-ancestor rule
|
|
277
|
+
* the server's findBoundary applies.
|
|
278
|
+
*
|
|
279
|
+
* Statically imported, so these land in the SHARED chunk rather than shipping
|
|
280
|
+
* as route entries the way not-found / error do. A loading state exists to
|
|
281
|
+
* acknowledge a click that is already waiting on the network; fetching a chunk
|
|
282
|
+
* to render it would put it behind the very delay it covers.
|
|
283
|
+
*
|
|
284
|
+
* Route groups need no special handling: a page under `app/(dash)/settings`
|
|
285
|
+
* resolves against `app/(dash)/loading` by path, exactly as it does on the
|
|
286
|
+
* server.
|
|
287
|
+
*/
|
|
288
|
+
export function generateLoadingRegistry(components: string[]): string {
|
|
289
|
+
const imports = components
|
|
290
|
+
.map((c, i) => `import M${i} from "${cwd_to_import(c)}";`)
|
|
291
|
+
.join("\n");
|
|
292
|
+
const entries = components
|
|
293
|
+
.map((c, i) => ` ${JSON.stringify(c)}: M${i},`)
|
|
294
|
+
.join("\n");
|
|
295
|
+
return `// Generated by Pylon SSR (route-level loading.tsx registry).
|
|
296
|
+
// DO NOT EDIT — overwritten on every pylon dev / build.
|
|
297
|
+
${imports ? "\n" + imports + "\n" : ""}
|
|
298
|
+
export const LOADING_MODULES = {
|
|
299
|
+
${entries}
|
|
300
|
+
};
|
|
301
|
+
`;
|
|
302
|
+
}
|
|
303
|
+
|
|
233
304
|
/**
|
|
234
305
|
* The shared hydration dispatcher + router. ONE module, imported
|
|
235
306
|
* by every per-route entry. Bun's splitter sees N entries reach
|
|
@@ -259,11 +330,15 @@ const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime
|
|
|
259
330
|
|
|
260
331
|
import { createElement } from "react";
|
|
261
332
|
import { hydrateRoot } from "react-dom/client";
|
|
262
|
-
import { createPylonBoundary } from "./client-boundary";
|
|
263
|
-
import {
|
|
333
|
+
import { createPylonBoundary, nearestBoundaryComponent } from "./client-boundary";
|
|
334
|
+
import { LOADING_MODULES } from "./loading-registry";
|
|
335
|
+
import { matchRoute, prefetchTargets } from "./route-match";
|
|
264
336
|
|
|
265
337
|
const routeCache = Object.create(null);
|
|
266
338
|
let activeRoot = null;
|
|
339
|
+
// Component path of the route currently mounted (the manifest key). Its cached
|
|
340
|
+
// layout chain is what a pending-navigation skeleton renders inside.
|
|
341
|
+
let currentComponent = null;
|
|
267
342
|
// Destination of an in-flight client navigation. Read by hydrateRoot's
|
|
268
343
|
// onUncaughtError so a re-render that throws mid-nav degrades to a full page
|
|
269
344
|
// load instead of a white screen. Null when no nav is in flight.
|
|
@@ -509,6 +584,19 @@ function preloadChunks(routeInfo) {
|
|
|
509
584
|
}
|
|
510
585
|
}
|
|
511
586
|
|
|
587
|
+
// Run fn once the page itself has finished loading. modulepreload fetches at
|
|
588
|
+
// high priority, and a sidebar of links warms on first paint (Link's
|
|
589
|
+
// IntersectionObserver), so warming a dozen route chunks inline would compete
|
|
590
|
+
// with the current page's own scripts and data — buying a faster second
|
|
591
|
+
// navigation with a slower first render.
|
|
592
|
+
function whenLoaded(fn) {
|
|
593
|
+
if (document.readyState === "complete") {
|
|
594
|
+
fn();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
window.addEventListener("load", fn, { once: true });
|
|
598
|
+
}
|
|
599
|
+
|
|
512
600
|
async function prefetch(href) {
|
|
513
601
|
// HTML prefetch — primes the SSR response cache.
|
|
514
602
|
const url = new URL(href, location.href);
|
|
@@ -520,21 +608,19 @@ async function prefetch(href) {
|
|
|
520
608
|
html.href = url.pathname + url.search;
|
|
521
609
|
document.head.appendChild(html);
|
|
522
610
|
}
|
|
523
|
-
// Chunk prefetch — peek at the manifest, but we don't know the
|
|
524
|
-
// component path from the href without server help. v1: rely on
|
|
525
|
-
// the shared chunk already being cached + the SSR head emitting
|
|
526
|
-
// the right preload tags after the user lands. So all prefetch
|
|
527
|
-
// does today is HTML — chunk dedup happens via the manifest.
|
|
528
611
|
const manifest = await loadManifest();
|
|
529
|
-
if (manifest)
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
612
|
+
if (!manifest) return;
|
|
613
|
+
// Chunk prefetch. The page payload above is the half that was already
|
|
614
|
+
// cheap: nothing can render until the destination's own entry chunk lands,
|
|
615
|
+
// so warming only the HTML leaves a full round-trip on the click.
|
|
616
|
+
const targets = prefetchTargets(manifest, url.pathname);
|
|
617
|
+
whenLoaded(() =>
|
|
618
|
+
preloadChunks({
|
|
619
|
+
public_prefix: manifest.public_prefix,
|
|
620
|
+
file: targets.file,
|
|
621
|
+
imports: targets.imports,
|
|
622
|
+
}),
|
|
623
|
+
);
|
|
538
624
|
}
|
|
539
625
|
|
|
540
626
|
async function loadRouteEntry(component) {
|
|
@@ -570,6 +656,7 @@ export function hydrate(component, Page, Layouts) {
|
|
|
570
656
|
}
|
|
571
657
|
setNavParams(data);
|
|
572
658
|
currentPageProps = withClientProps(data);
|
|
659
|
+
currentComponent = data.component;
|
|
573
660
|
const tree = withBoundary(
|
|
574
661
|
buildTree(Page, Layouts, currentPageProps),
|
|
575
662
|
data.component,
|
|
@@ -628,6 +715,48 @@ function syncHeadMeta(doc) {
|
|
|
628
715
|
}
|
|
629
716
|
}
|
|
630
717
|
|
|
718
|
+
// How long a navigation may run before the destination's loading.tsx takes
|
|
719
|
+
// over the page area. Below this, the transition finishes on its own and
|
|
720
|
+
// swapping to a skeleton would read as a flicker rather than as feedback.
|
|
721
|
+
const PENDING_PLACEHOLDER_MS = 100;
|
|
722
|
+
|
|
723
|
+
// Paint the destination's nearest loading.tsx in place of the current page,
|
|
724
|
+
// so a click that can't complete immediately is still acknowledged. Returns
|
|
725
|
+
// whether anything was rendered — apps without a loading.tsx keep the old
|
|
726
|
+
// behavior (previous page stays put until the new one is ready).
|
|
727
|
+
//
|
|
728
|
+
// Rendered inside the CURRENTLY MOUNTED layout chain, not the destination's:
|
|
729
|
+
// the destination's layouts live in the chunk this skeleton exists to cover,
|
|
730
|
+
// so waiting for them would defeat the point. Sibling routes (the common case
|
|
731
|
+
// for a nav that's slow enough to reach here) share that chain anyway, and
|
|
732
|
+
// React keeps those layouts mounted across the swap — only the page area
|
|
733
|
+
// changes.
|
|
734
|
+
//
|
|
735
|
+
// Props are the current page's, verbatim. Layouts re-render with the data they
|
|
736
|
+
// already had, so one that read serverData or params can't suspend on a
|
|
737
|
+
// never-resolving handle or crash on a cache miss halfway through a
|
|
738
|
+
// transition. A skeleton has no data of its own to show.
|
|
739
|
+
function renderPendingPlaceholder(pathname, manifest, myEpoch) {
|
|
740
|
+
if (!activeRoot || !currentComponent) return false;
|
|
741
|
+
const matched = matchRoute(manifest, pathname);
|
|
742
|
+
if (!matched) return false;
|
|
743
|
+
const key = nearestBoundaryComponent(
|
|
744
|
+
matched.component,
|
|
745
|
+
"loading",
|
|
746
|
+
Object.keys(LOADING_MODULES),
|
|
747
|
+
);
|
|
748
|
+
const Loading = key ? LOADING_MODULES[key] : null;
|
|
749
|
+
if (!Loading) return false;
|
|
750
|
+
const current = routeCache[currentComponent];
|
|
751
|
+
const tree = withBoundary(
|
|
752
|
+
buildTree(Loading, (current && current.Layouts) || [], currentPageProps),
|
|
753
|
+
currentComponent,
|
|
754
|
+
myEpoch,
|
|
755
|
+
);
|
|
756
|
+
activeRoot.render(tree);
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
|
|
631
760
|
async function navigate(href, opts) {
|
|
632
761
|
const push = !opts || opts.push !== false;
|
|
633
762
|
const url = new URL(href, location.href);
|
|
@@ -643,6 +772,14 @@ async function navigate(href, opts) {
|
|
|
643
772
|
const myEpoch = ++navEpoch;
|
|
644
773
|
currentSeed = opts && opts.seed != null ? opts.seed : null;
|
|
645
774
|
|
|
775
|
+
// Flipped the moment this nav commits or gives up, so a pending-placeholder
|
|
776
|
+
// timer that fires late can never paint a skeleton over real content.
|
|
777
|
+
const navState = { settled: false };
|
|
778
|
+
const fullLoad = () => {
|
|
779
|
+
navState.settled = true;
|
|
780
|
+
window.location.href = href;
|
|
781
|
+
};
|
|
782
|
+
|
|
646
783
|
// ---- Optimistic first paint --------------------------------------------
|
|
647
784
|
// With a seed AND a client-resolvable route, render the destination NOW —
|
|
648
785
|
// before the SSR fetch — with a pending serverData, so the page shows its
|
|
@@ -673,6 +810,7 @@ async function navigate(href, opts) {
|
|
|
673
810
|
myEpoch,
|
|
674
811
|
);
|
|
675
812
|
pendingNav = target;
|
|
813
|
+
currentComponent = matched.component;
|
|
676
814
|
activeRoot.render(tree);
|
|
677
815
|
if (opts && opts.replace) {
|
|
678
816
|
history.replaceState({ component: matched.component }, "", target);
|
|
@@ -688,6 +826,28 @@ async function navigate(href, opts) {
|
|
|
688
826
|
}
|
|
689
827
|
}
|
|
690
828
|
|
|
829
|
+
// ---- Pending-state placeholder -----------------------------------------
|
|
830
|
+
// Nothing below paints until the SSR payload AND the destination's chunk are
|
|
831
|
+
// both in hand, which on a cold route is long enough for the click to look
|
|
832
|
+
// ignored. Hand the page area to the destination's loading.tsx once the nav
|
|
833
|
+
// outlives the flicker threshold. Skipped when a seed already painted the
|
|
834
|
+
// real destination — that's strictly better than a skeleton.
|
|
835
|
+
let placeholderTimer = null;
|
|
836
|
+
if (!didOptimistic && activeRoot) {
|
|
837
|
+
placeholderTimer = setTimeout(() => {
|
|
838
|
+
placeholderTimer = null;
|
|
839
|
+
// loadManifest resolves instantly once warm (Link prefetch primes it on
|
|
840
|
+
// first paint); the settled check covers the cold first navigation,
|
|
841
|
+
// where the fetch can win the race with it.
|
|
842
|
+
loadManifest().then((manifest) => {
|
|
843
|
+
if (navState.settled || myEpoch !== navEpoch) return;
|
|
844
|
+
if (renderPendingPlaceholder(url.pathname, manifest, myEpoch)) {
|
|
845
|
+
window.scrollTo(0, 0);
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
}, PENDING_PLACEHOLDER_MS);
|
|
849
|
+
}
|
|
850
|
+
|
|
691
851
|
// ---- Real fetch + render -----------------------------------------------
|
|
692
852
|
let html;
|
|
693
853
|
try {
|
|
@@ -696,26 +856,26 @@ async function navigate(href, opts) {
|
|
|
696
856
|
headers: { Accept: "text/html" },
|
|
697
857
|
});
|
|
698
858
|
if (!res.ok) {
|
|
699
|
-
|
|
859
|
+
fullLoad();
|
|
700
860
|
return;
|
|
701
861
|
}
|
|
702
862
|
html = await res.text();
|
|
703
863
|
} catch {
|
|
704
|
-
|
|
864
|
+
fullLoad();
|
|
705
865
|
return;
|
|
706
866
|
}
|
|
707
867
|
if (myEpoch !== navEpoch) return;
|
|
708
868
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
709
869
|
const dataEl = doc.getElementById("__PYLON_DATA__");
|
|
710
870
|
if (!dataEl) {
|
|
711
|
-
|
|
871
|
+
fullLoad();
|
|
712
872
|
return;
|
|
713
873
|
}
|
|
714
874
|
let data;
|
|
715
875
|
try {
|
|
716
876
|
data = JSON.parse(dataEl.textContent || "{}");
|
|
717
877
|
} catch {
|
|
718
|
-
|
|
878
|
+
fullLoad();
|
|
719
879
|
return;
|
|
720
880
|
}
|
|
721
881
|
let route;
|
|
@@ -723,10 +883,12 @@ async function navigate(href, opts) {
|
|
|
723
883
|
route = await loadRouteEntry(data.component);
|
|
724
884
|
} catch (e) {
|
|
725
885
|
console.warn("[pylon ssr] nav fallback (entry load failed):", e);
|
|
726
|
-
|
|
886
|
+
fullLoad();
|
|
727
887
|
return;
|
|
728
888
|
}
|
|
729
889
|
if (myEpoch !== navEpoch) return;
|
|
890
|
+
navState.settled = true;
|
|
891
|
+
if (placeholderTimer) clearTimeout(placeholderTimer);
|
|
730
892
|
document.title = doc.title || document.title;
|
|
731
893
|
syncHeadMeta(doc);
|
|
732
894
|
setNavParams(data);
|
|
@@ -749,6 +911,7 @@ async function navigate(href, opts) {
|
|
|
749
911
|
// (instead of leaving the URL changed but the page unswapped). Cleared on the
|
|
750
912
|
// next macrotask once the commit has settled with no error.
|
|
751
913
|
pendingNav = target;
|
|
914
|
+
currentComponent = data.component;
|
|
752
915
|
activeRoot.render(tree);
|
|
753
916
|
setTimeout(() => {
|
|
754
917
|
if (pendingNav === target) pendingNav = null;
|
|
@@ -1244,6 +1407,19 @@ async function _doBuildInner(
|
|
|
1244
1407
|
"utf8",
|
|
1245
1408
|
);
|
|
1246
1409
|
|
|
1410
|
+
// The app's loading.tsx modules, gathered into one module the runtime
|
|
1411
|
+
// imports statically (`./loading-registry`) so a pending navigation can
|
|
1412
|
+
// paint a skeleton without fetching anything. Always written — an app with
|
|
1413
|
+
// no loading.tsx gets an empty registry, since the runtime's import of it
|
|
1414
|
+
// is unconditional.
|
|
1415
|
+
fs.writeFileSync(
|
|
1416
|
+
path.join(stageDir, "loading-registry.ts"),
|
|
1417
|
+
generateLoadingRegistry(
|
|
1418
|
+
discoverLoadingModules(fs, path, cwd, appDirRel),
|
|
1419
|
+
),
|
|
1420
|
+
"utf8",
|
|
1421
|
+
);
|
|
1422
|
+
|
|
1247
1423
|
const entryPaths: string[] = [];
|
|
1248
1424
|
// entryPath (absolute) → component path (for manifest lookup).
|
|
1249
1425
|
const entryToComponent = new Map<string, string>();
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
matchRoute,
|
|
4
|
+
prefetchTargets,
|
|
5
|
+
type MatchableManifest,
|
|
6
|
+
} from "./ssr-route-match";
|
|
3
7
|
|
|
4
8
|
const manifest: MatchableManifest = {
|
|
5
9
|
routes: {
|
|
@@ -81,3 +85,94 @@ describe("matchRoute", () => {
|
|
|
81
85
|
expect(matchRoute({ routes: {} }, "/")).toBeNull();
|
|
82
86
|
});
|
|
83
87
|
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// prefetchTargets — what <Link prefetch> warms.
|
|
91
|
+
//
|
|
92
|
+
// Regression: prefetch emitted the page payload and the shared chunks but
|
|
93
|
+
// never the destination's OWN entry chunk, so every click paid a full
|
|
94
|
+
// round-trip (126-281ms measured across a 12-link sidebar) before the route
|
|
95
|
+
// could render. The payload it did prefetch was the half that was already fast.
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
const chunked: MatchableManifest = {
|
|
99
|
+
routes: {
|
|
100
|
+
"app/page": {
|
|
101
|
+
path: "/",
|
|
102
|
+
file: "client-entry-app__page-a1.js",
|
|
103
|
+
imports: ["chunks/shared-1.js"],
|
|
104
|
+
},
|
|
105
|
+
"app/speakers/page": {
|
|
106
|
+
path: "/speakers",
|
|
107
|
+
file: "client-entry-app__speakers__page-b2.js",
|
|
108
|
+
imports: ["chunks/shared-1.js", "chunks/editor-2.js"],
|
|
109
|
+
},
|
|
110
|
+
"app/events/[id]/page": {
|
|
111
|
+
path: "/events/[id]",
|
|
112
|
+
file: "client-entry-app__events____id____page-c3.js",
|
|
113
|
+
imports: ["chunks/shared-1.js"],
|
|
114
|
+
},
|
|
115
|
+
// A boundary module: no path, so it never matches a href.
|
|
116
|
+
"app/not-found": { file: "client-entry-app__not_found-d4.js", imports: [] },
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
describe("prefetchTargets", () => {
|
|
121
|
+
it("names the destination's OWN entry chunk", () => {
|
|
122
|
+
// The whole point: without `file`, the click still blocks on this fetch.
|
|
123
|
+
expect(prefetchTargets(chunked, "/speakers").file).toBe(
|
|
124
|
+
"client-entry-app__speakers__page-b2.js",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("includes chunks belonging to the destination alone", () => {
|
|
129
|
+
// editor-2 is imported only by /speakers. It has to be in the warm set
|
|
130
|
+
// whether it's reached as the destination's own import or via the
|
|
131
|
+
// all-routes union.
|
|
132
|
+
expect(prefetchTargets(chunked, "/speakers").imports).toContain(
|
|
133
|
+
"chunks/editor-2.js",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("warms the shared chunks every route needs", () => {
|
|
138
|
+
expect(prefetchTargets(chunked, "/").imports).toContain("chunks/shared-1.js");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("resolves a dynamic route to its entry", () => {
|
|
142
|
+
expect(prefetchTargets(chunked, "/events/42").file).toBe(
|
|
143
|
+
"client-entry-app__events____id____page-c3.js",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("yields no entry for an href that matches no page route", () => {
|
|
148
|
+
// e.g. an API path or a route this build doesn't serve. Shared chunks are
|
|
149
|
+
// still worth warming; the caller skips an empty `file`.
|
|
150
|
+
const t = prefetchTargets(chunked, "/api/webhooks/stripe");
|
|
151
|
+
expect(t.file).toBe("");
|
|
152
|
+
expect(t.imports).toContain("chunks/shared-1.js");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("never resolves to a boundary module's entry", () => {
|
|
156
|
+
expect(prefetchTargets(chunked, "/not-found").file).toBe("");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("deduplicates chunks shared across routes", () => {
|
|
160
|
+
const imports = prefetchTargets(chunked, "/speakers").imports;
|
|
161
|
+
expect(imports.filter((i) => i === "chunks/shared-1.js")).toHaveLength(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("is null-safe on an absent or empty manifest", () => {
|
|
165
|
+
expect(prefetchTargets(null, "/")).toEqual({ file: "", imports: [] });
|
|
166
|
+
expect(prefetchTargets({ routes: {} }, "/")).toEqual({
|
|
167
|
+
file: "",
|
|
168
|
+
imports: [],
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("tolerates a manifest whose routes carry no chunk fields", () => {
|
|
173
|
+
// Older build output — must degrade, not throw.
|
|
174
|
+
expect(prefetchTargets({ routes: { "app/page": { path: "/" } } }, "/")).toEqual(
|
|
175
|
+
{ file: "", imports: [] },
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
});
|
package/src/ssr-route-match.ts
CHANGED
|
@@ -18,9 +18,18 @@ export interface RouteMatch {
|
|
|
18
18
|
params: Record<string, string>;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/** The slice of the build manifest this
|
|
21
|
+
/** The slice of the build manifest this module needs. */
|
|
22
22
|
export interface MatchableManifest {
|
|
23
|
-
routes: Record<
|
|
23
|
+
routes: Record<
|
|
24
|
+
string,
|
|
25
|
+
{
|
|
26
|
+
path?: string;
|
|
27
|
+
/** The route's own entry chunk (outdir-relative). */
|
|
28
|
+
file?: string;
|
|
29
|
+
/** Shared chunks the browser needs before that entry runs. */
|
|
30
|
+
imports?: string[];
|
|
31
|
+
}
|
|
32
|
+
>;
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
function splitPath(p: string): string[] {
|
|
@@ -102,3 +111,39 @@ export function matchRoute(
|
|
|
102
111
|
}
|
|
103
112
|
return best ? best.match : null;
|
|
104
113
|
}
|
|
114
|
+
|
|
115
|
+
/** What `<Link prefetch>` should warm for a destination href. */
|
|
116
|
+
export interface PrefetchTargets {
|
|
117
|
+
/** The destination route's own entry chunk, or "" when no page route matches. */
|
|
118
|
+
file: string;
|
|
119
|
+
/** Shared chunks to warm alongside it. */
|
|
120
|
+
imports: string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The chunks a click on `pathname` will need before anything can render: the
|
|
125
|
+
* destination route's entry, plus the shared chunks (React, the client
|
|
126
|
+
* runtime, common layouts) that every route pulls in.
|
|
127
|
+
*
|
|
128
|
+
* Warming the page payload alone leaves the entry chunk to be fetched after
|
|
129
|
+
* the click, and the route cannot render until it lands — so the prefetch
|
|
130
|
+
* covers only the half that was already fast. An href matching no page route
|
|
131
|
+
* (an API path, a route this build doesn't serve) still yields the shared set;
|
|
132
|
+
* `file` is "" and the caller skips it.
|
|
133
|
+
*/
|
|
134
|
+
export function prefetchTargets(
|
|
135
|
+
manifest: MatchableManifest | null | undefined,
|
|
136
|
+
pathname: string,
|
|
137
|
+
): PrefetchTargets {
|
|
138
|
+
const imports = new Set<string>();
|
|
139
|
+
if (!manifest || !manifest.routes) return { file: "", imports: [] };
|
|
140
|
+
for (const r of Object.values(manifest.routes)) {
|
|
141
|
+
for (const i of r?.imports || []) imports.add(i);
|
|
142
|
+
}
|
|
143
|
+
const matched = matchRoute(manifest, pathname);
|
|
144
|
+
const route = matched ? manifest.routes[matched.component] : null;
|
|
145
|
+
if (route) {
|
|
146
|
+
for (const i of route.imports || []) imports.add(i);
|
|
147
|
+
}
|
|
148
|
+
return { file: route?.file || "", imports: Array.from(imports) };
|
|
149
|
+
}
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -1310,8 +1310,18 @@ export function applyAutoSocialImages(
|
|
|
1310
1310
|
*
|
|
1311
1311
|
* Shared by the page render and the boundary render so head injection has
|
|
1312
1312
|
* exactly one implementation.
|
|
1313
|
+
*
|
|
1314
|
+
* Decoding uses ONE `TextDecoder` with `{stream: true}` for the whole
|
|
1315
|
+
* reader, never a per-chunk decode. React splits the byte stream at
|
|
1316
|
+
* arbitrary offsets, so a multi-byte character can land across two chunks;
|
|
1317
|
+
* decoding each chunk independently turns the orphaned bytes into one
|
|
1318
|
+
* U+FFFD apiece — `…` (e2 80 a6) arrives as three replacement characters.
|
|
1319
|
+
* The failure is silent and position-dependent: markup added anywhere
|
|
1320
|
+
* earlier shifts the boundary, so a page can render correctly for months
|
|
1321
|
+
* and corrupt on an unrelated CSS change. A streaming decoder holds the
|
|
1322
|
+
* partial sequence back until the bytes that complete it arrive.
|
|
1313
1323
|
*/
|
|
1314
|
-
async function streamWithHeadInjection(
|
|
1324
|
+
export async function streamWithHeadInjection(
|
|
1315
1325
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
1316
1326
|
headBlob: string,
|
|
1317
1327
|
sendChunk: (text: string) => void,
|
|
@@ -1319,11 +1329,15 @@ async function streamWithHeadInjection(
|
|
|
1319
1329
|
let headInjected = headBlob.length === 0;
|
|
1320
1330
|
let carry = "";
|
|
1321
1331
|
const HEAD_CLOSE = "</head>";
|
|
1332
|
+
const decoder = new TextDecoder("utf-8");
|
|
1322
1333
|
for (;;) {
|
|
1323
1334
|
const { value, done } = await reader.read();
|
|
1324
1335
|
if (done) break;
|
|
1325
1336
|
if (!value || value.byteLength === 0) continue;
|
|
1326
|
-
const text =
|
|
1337
|
+
const text = decoder.decode(value, { stream: true });
|
|
1338
|
+
// A chunk that ended mid-character decodes to "" — the bytes are held
|
|
1339
|
+
// in the decoder until the rest arrives. Nothing to emit yet.
|
|
1340
|
+
if (!text) continue;
|
|
1327
1341
|
if (!headInjected) {
|
|
1328
1342
|
const combined = carry + text;
|
|
1329
1343
|
const idx = combined.indexOf(HEAD_CLOSE);
|
|
@@ -1348,6 +1362,17 @@ async function streamWithHeadInjection(
|
|
|
1348
1362
|
sendChunk(text);
|
|
1349
1363
|
}
|
|
1350
1364
|
}
|
|
1365
|
+
// Flush the decoder. A stream that ends mid-character is genuinely
|
|
1366
|
+
// truncated input, and this is where it becomes a replacement character
|
|
1367
|
+
// rather than silently vanishing.
|
|
1368
|
+
const tail = decoder.decode();
|
|
1369
|
+
if (tail) {
|
|
1370
|
+
if (!headInjected) {
|
|
1371
|
+
carry += tail;
|
|
1372
|
+
} else {
|
|
1373
|
+
sendChunk(tail);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1351
1376
|
if (carry) sendChunk(carry);
|
|
1352
1377
|
}
|
|
1353
1378
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR stream decoding — multi-byte UTF-8 across chunk boundaries.
|
|
3
|
+
*
|
|
4
|
+
* React's `renderToReadableStream` splits the byte stream at arbitrary
|
|
5
|
+
* offsets, so any non-ASCII character can land with its bytes divided
|
|
6
|
+
* between two chunks. Decoding each chunk independently turns the orphaned
|
|
7
|
+
* bytes into one U+FFFD apiece: `…` (e2 80 a6) arrives as three replacement
|
|
8
|
+
* characters.
|
|
9
|
+
*
|
|
10
|
+
* The failure is silent and position-dependent — markup added anywhere
|
|
11
|
+
* earlier in the document shifts the boundary — so a page renders correctly
|
|
12
|
+
* for months and corrupts on an unrelated CSS change. React only catches it
|
|
13
|
+
* when the string is hydrated and compared against the client render;
|
|
14
|
+
* server-only text corrupts with no warning at all.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from "bun:test";
|
|
17
|
+
import { streamWithHeadInjection } from "./ssr-runtime";
|
|
18
|
+
|
|
19
|
+
/** A reader over pre-split byte chunks — the shape React hands us. */
|
|
20
|
+
function readerOf(chunks: Uint8Array[]): ReadableStreamDefaultReader<Uint8Array> {
|
|
21
|
+
return new ReadableStream<Uint8Array>({
|
|
22
|
+
start(controller) {
|
|
23
|
+
for (const c of chunks) controller.enqueue(c);
|
|
24
|
+
controller.close();
|
|
25
|
+
},
|
|
26
|
+
}).getReader();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Split `text`'s utf-8 bytes at `at`, mid-character on purpose. */
|
|
30
|
+
function splitAt(text: string, at: number): Uint8Array[] {
|
|
31
|
+
const bytes = new TextEncoder().encode(text);
|
|
32
|
+
return [bytes.slice(0, at), bytes.slice(at)];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function collect(chunks: Uint8Array[], headBlob = ""): Promise<string> {
|
|
36
|
+
const out: string[] = [];
|
|
37
|
+
await streamWithHeadInjection(readerOf(chunks), headBlob, (t) => out.push(t));
|
|
38
|
+
return out.join("");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("streamWithHeadInjection — UTF-8 across chunk boundaries", () => {
|
|
42
|
+
test("an ellipsis split across chunks survives", async () => {
|
|
43
|
+
const html = "<p>Search speakers by name…</p>";
|
|
44
|
+
const bytes = new TextEncoder().encode(html);
|
|
45
|
+
// "…" is e2 80 a6; cut between its first and second byte.
|
|
46
|
+
const cut = bytes.indexOf(0xe2) + 1;
|
|
47
|
+
expect(await collect(splitAt(html, cut))).toBe(html);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("every split point of a 3-byte character round-trips", async () => {
|
|
51
|
+
const html = "<p>a…b</p>";
|
|
52
|
+
const bytes = new TextEncoder().encode(html);
|
|
53
|
+
for (let at = 1; at < bytes.length; at++) {
|
|
54
|
+
expect(await collect(splitAt(html, at))).toBe(html);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("4-byte characters (emoji) survive every split", async () => {
|
|
59
|
+
const html = "<p>ship it 🚀 now</p>";
|
|
60
|
+
const bytes = new TextEncoder().encode(html);
|
|
61
|
+
for (let at = 1; at < bytes.length; at++) {
|
|
62
|
+
expect(await collect(splitAt(html, at))).toBe(html);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("accented names and CJK survive every split", async () => {
|
|
67
|
+
// Ordinary content, not edge cases: a speaker called José, a Japanese
|
|
68
|
+
// session title, a middot in a byline.
|
|
69
|
+
const html = "<p>José · 日本語のセッション</p>";
|
|
70
|
+
const bytes = new TextEncoder().encode(html);
|
|
71
|
+
for (let at = 1; at < bytes.length; at++) {
|
|
72
|
+
expect(await collect(splitAt(html, at))).toBe(html);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a character split across THREE chunks survives", async () => {
|
|
77
|
+
// One byte per chunk — the decoder must hold state across two reads
|
|
78
|
+
// that each produce nothing.
|
|
79
|
+
const enc = new TextEncoder().encode("…");
|
|
80
|
+
const chunks = [enc.slice(0, 1), enc.slice(1, 2), enc.slice(2, 3)];
|
|
81
|
+
expect(await collect(chunks)).toBe("…");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("head injection still lands, with a split character before it", async () => {
|
|
85
|
+
const html = "<html><head><title>é…</title></head><body>x</body></html>";
|
|
86
|
+
const bytes = new TextEncoder().encode(html);
|
|
87
|
+
const cut = bytes.indexOf(0xe2) + 1; // mid-ellipsis, before </head>
|
|
88
|
+
const out = await collect(splitAt(html, cut), "<meta name=x>");
|
|
89
|
+
expect(out).toBe(
|
|
90
|
+
"<html><head><title>é…</title><meta name=x></head><body>x</body></html>",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a stream truncated mid-character does not lose the tail silently", async () => {
|
|
95
|
+
// Genuinely broken input. One replacement character is the honest
|
|
96
|
+
// answer; dropping the bytes without a trace is not.
|
|
97
|
+
const enc = new TextEncoder().encode("ok…");
|
|
98
|
+
const out = await collect([enc.slice(0, enc.length - 1)]);
|
|
99
|
+
expect(out.startsWith("ok")).toBe(true);
|
|
100
|
+
expect(out).toContain("�");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("pure ASCII is unaffected", async () => {
|
|
104
|
+
const html = "<p>plain ascii only</p>";
|
|
105
|
+
const bytes = new TextEncoder().encode(html);
|
|
106
|
+
for (let at = 1; at < bytes.length; at++) {
|
|
107
|
+
expect(await collect(splitAt(html, at))).toBe(html);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|