@mandujs/core 0.54.13 → 0.54.15
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/bundler/__tests__/build-runner.ts +31 -12
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -17
- package/src/bundler/__tests__/hdr.test.ts +10 -3
- package/src/bundler/__tests__/hmr-client.test.ts +16 -11
- package/src/bundler/build.test.ts +101 -49
- package/src/bundler/build.ts +244 -115
- package/src/client/router.ts +47 -40
- package/src/router/client-entry.test.ts +15 -8
- package/src/router/client-entry.ts +22 -15
- package/src/router/fs-routes.test.ts +90 -0
- package/src/router/fs-routes.ts +13 -7
- package/src/router/fs-scanner.ts +60 -16
- package/src/runtime/__tests__/page-render-response.test.ts +194 -0
- package/src/runtime/page-render-response.ts +106 -4
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -12
- package/src/runtime/server.ts +121 -58
- package/src/runtime/streaming-ssr.ts +26 -18
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { generateManifest } from "./fs-routes";
|
|
5
|
+
|
|
6
|
+
const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
|
|
7
|
+
|
|
8
|
+
async function mkRepoTempDir(prefix: string): Promise<string> {
|
|
9
|
+
await mkdir(repoTempRoot, { recursive: true });
|
|
10
|
+
return mkdtemp(path.join(repoTempRoot, prefix));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("generateManifest hydration config", () => {
|
|
14
|
+
it("does not preserve stale island hydration after the client entry disappears", async () => {
|
|
15
|
+
const rootDir = await mkRepoTempDir("routes-hydration-stale-");
|
|
16
|
+
try {
|
|
17
|
+
await mkdir(path.join(rootDir, "app", "candidates", "[id]"), { recursive: true });
|
|
18
|
+
await mkdir(path.join(rootDir, "src", "client", "widgets"), { recursive: true });
|
|
19
|
+
await writeFile(
|
|
20
|
+
path.join(rootDir, "app", "candidates", "[id]", "page.tsx"),
|
|
21
|
+
`
|
|
22
|
+
import { PledgeAccordion } from "@/client/widgets/PledgeAccordion.client";
|
|
23
|
+
export default function Page() {
|
|
24
|
+
return <main><PledgeAccordion pledges={[]} /></main>;
|
|
25
|
+
}
|
|
26
|
+
`,
|
|
27
|
+
"utf-8",
|
|
28
|
+
);
|
|
29
|
+
await writeFile(
|
|
30
|
+
path.join(rootDir, "src", "client", "widgets", "PledgeAccordion.client.tsx"),
|
|
31
|
+
`
|
|
32
|
+
"use client";
|
|
33
|
+
export function PledgeAccordion() {
|
|
34
|
+
return <div />;
|
|
35
|
+
}
|
|
36
|
+
`,
|
|
37
|
+
"utf-8",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const first = await generateManifest(rootDir);
|
|
41
|
+
const firstRoute = first.manifest.routes.find((route) => route.id === "candidates-$id");
|
|
42
|
+
expect(firstRoute?.clientModule).toContain("PledgeAccordion.client.tsx");
|
|
43
|
+
expect(firstRoute?.hydration?.strategy).toBe("island");
|
|
44
|
+
|
|
45
|
+
await writeFile(
|
|
46
|
+
path.join(rootDir, "app", "candidates", "[id]", "page.tsx"),
|
|
47
|
+
`
|
|
48
|
+
export default function Page() {
|
|
49
|
+
return <main><details><summary>server only</summary></details></main>;
|
|
50
|
+
}
|
|
51
|
+
`,
|
|
52
|
+
"utf-8",
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const second = await generateManifest(rootDir);
|
|
56
|
+
const secondRoute = second.manifest.routes.find((route) => route.id === "candidates-$id");
|
|
57
|
+
expect(secondRoute?.clientModule).toBeUndefined();
|
|
58
|
+
expect(secondRoute?.hydration?.strategy).not.toBe("island");
|
|
59
|
+
} finally {
|
|
60
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("reads page-level hydration exports from the current page source", async () => {
|
|
65
|
+
const rootDir = await mkRepoTempDir("routes-hydration-export-");
|
|
66
|
+
try {
|
|
67
|
+
await mkdir(path.join(rootDir, "app", "about"), { recursive: true });
|
|
68
|
+
await writeFile(
|
|
69
|
+
path.join(rootDir, "app", "about", "page.tsx"),
|
|
70
|
+
`
|
|
71
|
+
export const hydration = { strategy: "none", priority: "idle", preload: true };
|
|
72
|
+
export default function Page() {
|
|
73
|
+
return <main>About</main>;
|
|
74
|
+
}
|
|
75
|
+
`,
|
|
76
|
+
"utf-8",
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const result = await generateManifest(rootDir);
|
|
80
|
+
const route = result.manifest.routes.find((entry) => entry.id === "about");
|
|
81
|
+
expect(route?.hydration).toEqual({
|
|
82
|
+
strategy: "none",
|
|
83
|
+
priority: "idle",
|
|
84
|
+
preload: true,
|
|
85
|
+
});
|
|
86
|
+
} finally {
|
|
87
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
package/src/router/fs-routes.ts
CHANGED
|
@@ -94,10 +94,12 @@ export function fsRouteToRouteSpec(fsRoute: FSRouteConfig): RouteSpec {
|
|
|
94
94
|
hydration: fsRoute.hydration ?? {
|
|
95
95
|
strategy: "island" as const,
|
|
96
96
|
priority: "immediate" as const,
|
|
97
|
-
preload: false,
|
|
98
|
-
},
|
|
99
|
-
}
|
|
100
|
-
:
|
|
97
|
+
preload: false,
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
: fsRoute.hydration
|
|
101
|
+
? { hydration: fsRoute.hydration }
|
|
102
|
+
: {}),
|
|
101
103
|
...(fsRoute.layoutChain && fsRoute.layoutChain.length > 0
|
|
102
104
|
? { layoutChain: fsRoute.layoutChain.map(normalizePath) }
|
|
103
105
|
: {}),
|
|
@@ -322,9 +324,13 @@ export async function generateManifest(
|
|
|
322
324
|
route.clientModule = prev.clientModule;
|
|
323
325
|
route.clientExportName = prev.clientExportName;
|
|
324
326
|
}
|
|
325
|
-
if (prev.hydration && !route.hydration) {
|
|
326
|
-
|
|
327
|
-
|
|
327
|
+
if (prev.hydration && !route.hydration) {
|
|
328
|
+
const canPreserveHydration =
|
|
329
|
+
!!route.clientModule || prev.hydration.strategy === "none";
|
|
330
|
+
if (canPreserveHydration) {
|
|
331
|
+
route.hydration = prev.hydration;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
328
334
|
// Issue #214 — preserve prerender-time fields (`dynamicParams`,
|
|
329
335
|
// `staticParams`) across manifest rescans. `mandu build`'s prerender
|
|
330
336
|
// phase stamps these onto the manifest; a subsequent `mandu dev`
|
package/src/router/fs-scanner.ts
CHANGED
|
@@ -28,16 +28,55 @@ import {
|
|
|
28
28
|
sortRoutesByPriority,
|
|
29
29
|
getPatternShape,
|
|
30
30
|
} from "./fs-patterns";
|
|
31
|
-
import { mark, measure } from "../perf";
|
|
32
|
-
import { METADATA_ROUTES } from "../routes/types";
|
|
31
|
+
import { mark, measure } from "../perf";
|
|
32
|
+
import { METADATA_ROUTES } from "../routes/types";
|
|
33
|
+
import type { HydrationConfig } from "../spec/schema";
|
|
33
34
|
import {
|
|
34
35
|
hasUseClientDirective,
|
|
35
36
|
resolveRouteLevelClientEntry,
|
|
36
37
|
} from "./client-entry";
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
|
|
39
|
+
const HYDRATION_STRATEGIES = new Set(["none", "island", "full", "progressive"]);
|
|
40
|
+
const HYDRATION_PRIORITIES = new Set(["immediate", "visible", "idle", "interaction"]);
|
|
41
|
+
|
|
42
|
+
function parsePageHydrationConfig(source: string): HydrationConfig | undefined {
|
|
43
|
+
const stringMatch = source.match(
|
|
44
|
+
/export\s+const\s+hydration\s*(?::[^=]+)?=\s*["'](none|island|full|progressive)["']/m,
|
|
45
|
+
);
|
|
46
|
+
if (stringMatch?.[1]) {
|
|
47
|
+
return {
|
|
48
|
+
strategy: stringMatch[1] as HydrationConfig["strategy"],
|
|
49
|
+
priority: "visible",
|
|
50
|
+
preload: false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const objectMatch = source.match(
|
|
55
|
+
/export\s+const\s+hydration\s*(?::[^=]+)?=\s*\{([\s\S]*?)\}\s*;?/m,
|
|
56
|
+
);
|
|
57
|
+
const body = objectMatch?.[1];
|
|
58
|
+
if (!body) return undefined;
|
|
59
|
+
|
|
60
|
+
const strategyMatch = body.match(/\bstrategy\s*:\s*["']([^"']+)["']/);
|
|
61
|
+
const strategy = strategyMatch?.[1];
|
|
62
|
+
if (!strategy || !HYDRATION_STRATEGIES.has(strategy)) return undefined;
|
|
63
|
+
|
|
64
|
+
const priorityMatch = body.match(/\bpriority\s*:\s*["']([^"']+)["']/);
|
|
65
|
+
const priority = priorityMatch?.[1];
|
|
66
|
+
const preloadMatch = body.match(/\bpreload\s*:\s*(true|false)\b/);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
strategy: strategy as HydrationConfig["strategy"],
|
|
70
|
+
priority: HYDRATION_PRIORITIES.has(priority ?? "")
|
|
71
|
+
? (priority as HydrationConfig["priority"])
|
|
72
|
+
: "visible",
|
|
73
|
+
preload: preloadMatch?.[1] === "true",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
78
|
+
// Scanner Class
|
|
79
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
41
80
|
|
|
42
81
|
/**
|
|
43
82
|
* FS Routes 스캐너
|
|
@@ -367,15 +406,19 @@ export class FSScanner {
|
|
|
367
406
|
// clientModule 결정: island 파일 또는 "use client"가 있는 page 자체
|
|
368
407
|
let clientModule: string | undefined;
|
|
369
408
|
let clientExportName: string | undefined;
|
|
409
|
+
let hydration: HydrationConfig | undefined;
|
|
370
410
|
let pageFileContent: string | null = null;
|
|
371
|
-
|
|
372
|
-
if (file.type === "page") {
|
|
373
|
-
try {
|
|
374
|
-
pageFileContent = await Bun.file(file.absolutePath).text();
|
|
375
|
-
} catch {
|
|
376
|
-
pageFileContent = null;
|
|
377
|
-
}
|
|
378
|
-
|
|
411
|
+
|
|
412
|
+
if (file.type === "page") {
|
|
413
|
+
try {
|
|
414
|
+
pageFileContent = await Bun.file(file.absolutePath).text();
|
|
415
|
+
} catch {
|
|
416
|
+
pageFileContent = null;
|
|
417
|
+
}
|
|
418
|
+
if (pageFileContent) {
|
|
419
|
+
hydration = parsePageHydrationConfig(pageFileContent);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
379
422
|
|
|
380
423
|
if (islands?.[0]) {
|
|
381
424
|
// 우선순위: 명시적 island 파일
|
|
@@ -422,9 +465,10 @@ export class FSScanner {
|
|
|
422
465
|
componentModule: file.type === "page" ? modulePath : undefined,
|
|
423
466
|
clientModule,
|
|
424
467
|
clientExportName,
|
|
468
|
+
hydration,
|
|
425
469
|
layoutChain,
|
|
426
|
-
loadingModule,
|
|
427
|
-
errorModule,
|
|
470
|
+
loadingModule,
|
|
471
|
+
errorModule,
|
|
428
472
|
notFoundModule,
|
|
429
473
|
sourceFile: file.absolutePath,
|
|
430
474
|
};
|
|
@@ -78,6 +78,30 @@ describe("runtime page render response orchestration", () => {
|
|
|
78
78
|
expect(html).toContain("Stream Page");
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
it("emits canonical data-hydrate attributes on streaming island wrappers", async () => {
|
|
82
|
+
const response = await renderPageResponse({
|
|
83
|
+
app: React.createElement("main", null, "stream-hydrated-page"),
|
|
84
|
+
useStreaming: true,
|
|
85
|
+
title: "Stream Hydrated Page",
|
|
86
|
+
headTags: "",
|
|
87
|
+
isDev: false,
|
|
88
|
+
routeId: "home",
|
|
89
|
+
routePattern: "/",
|
|
90
|
+
loaderData: { ok: true },
|
|
91
|
+
hydration: { strategy: "island", priority: "interaction", preload: false },
|
|
92
|
+
bundleManifest: HYDRATED_MANIFEST,
|
|
93
|
+
transitions: false,
|
|
94
|
+
prefetch: false,
|
|
95
|
+
spa: false,
|
|
96
|
+
devtools: false,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const html = await response.text();
|
|
100
|
+
expect(html).toContain('data-mandu-island="home"');
|
|
101
|
+
expect(html).toContain('data-mandu-priority="interaction"');
|
|
102
|
+
expect(html).toContain('data-hydrate="interaction"');
|
|
103
|
+
});
|
|
104
|
+
|
|
81
105
|
it("serializes non-streaming loaderData as the route server data exactly once", async () => {
|
|
82
106
|
const response = await renderPageResponse({
|
|
83
107
|
app: React.createElement("main", null, "hydrated-page"),
|
|
@@ -100,4 +124,174 @@ describe("runtime page render response orchestration", () => {
|
|
|
100
124
|
expect(html).toContain('"home":{"serverData":{"items":["a","b"]}');
|
|
101
125
|
expect(html).not.toContain('"serverData":{"home"');
|
|
102
126
|
});
|
|
127
|
+
|
|
128
|
+
it("serializes props for inline client components rendered by async server pages", async () => {
|
|
129
|
+
function PledgeAccordion({ pledges }: { pledges: Array<{ id: string; title: string }> }) {
|
|
130
|
+
return React.createElement(
|
|
131
|
+
"ul",
|
|
132
|
+
null,
|
|
133
|
+
pledges.map((pledge) => React.createElement("li", { key: pledge.id }, pledge.title)),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function CandidatePage() {
|
|
138
|
+
const pledges = [
|
|
139
|
+
{ id: "p1", title: "Public transit" },
|
|
140
|
+
{ id: "p2", title: "Housing" },
|
|
141
|
+
];
|
|
142
|
+
return React.createElement(
|
|
143
|
+
"main",
|
|
144
|
+
null,
|
|
145
|
+
React.createElement(PledgeAccordion, { pledges }),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const response = await renderPageResponse({
|
|
150
|
+
app: React.createElement(CandidatePage),
|
|
151
|
+
useStreaming: false,
|
|
152
|
+
title: "Candidate",
|
|
153
|
+
headTags: "",
|
|
154
|
+
isDev: false,
|
|
155
|
+
routeId: "candidates-$id",
|
|
156
|
+
routePattern: "/candidates/:id",
|
|
157
|
+
hydration: { strategy: "island", priority: "immediate", preload: false },
|
|
158
|
+
bundleManifest: {
|
|
159
|
+
...HYDRATED_MANIFEST,
|
|
160
|
+
bundles: {
|
|
161
|
+
"candidates-$id": {
|
|
162
|
+
js: "/.mandu/client/candidates-$id.island.js",
|
|
163
|
+
dependencies: ["_runtime", "_react"],
|
|
164
|
+
priority: "immediate",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
loaderData: undefined,
|
|
169
|
+
transitions: false,
|
|
170
|
+
prefetch: false,
|
|
171
|
+
spa: false,
|
|
172
|
+
devtools: false,
|
|
173
|
+
inlineClientHydration: {
|
|
174
|
+
routeId: "candidates-$id",
|
|
175
|
+
src: "/.mandu/client/candidates-$id.island.js",
|
|
176
|
+
priority: "immediate",
|
|
177
|
+
component: PledgeAccordion,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const html = await response.text();
|
|
182
|
+
expect(html).toContain('data-mandu-island="candidates-$id--0"');
|
|
183
|
+
expect(html).toContain('data-mandu-src="/.mandu/client/candidates-$id.island.js"');
|
|
184
|
+
expect(html).toContain(""pledges"");
|
|
185
|
+
expect(html).toContain("Public transit");
|
|
186
|
+
expect(html).not.toContain('data-mandu-island="candidates-$id"');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("does not invoke sync function components while looking for inline client targets", async () => {
|
|
190
|
+
function ClientWidget({ label }: { label: string }) {
|
|
191
|
+
return React.createElement("button", null, label);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function HookPage() {
|
|
195
|
+
const id = React.useId();
|
|
196
|
+
return React.createElement("main", { id }, React.createElement(ClientWidget, { label: "Click" }));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const response = await renderPageResponse({
|
|
200
|
+
app: React.createElement(HookPage),
|
|
201
|
+
useStreaming: false,
|
|
202
|
+
title: "Hook Page",
|
|
203
|
+
headTags: "",
|
|
204
|
+
isDev: false,
|
|
205
|
+
routeId: "hook-page",
|
|
206
|
+
routePattern: "/hook",
|
|
207
|
+
hydration: { strategy: "island", priority: "visible", preload: false },
|
|
208
|
+
bundleManifest: {
|
|
209
|
+
...HYDRATED_MANIFEST,
|
|
210
|
+
bundles: {
|
|
211
|
+
"hook-page": {
|
|
212
|
+
js: "/.mandu/client/hook-page.island.js",
|
|
213
|
+
dependencies: ["_runtime", "_react"],
|
|
214
|
+
priority: "visible",
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
loaderData: undefined,
|
|
219
|
+
transitions: false,
|
|
220
|
+
prefetch: false,
|
|
221
|
+
spa: false,
|
|
222
|
+
devtools: false,
|
|
223
|
+
inlineClientHydration: {
|
|
224
|
+
routeId: "hook-page",
|
|
225
|
+
src: "/.mandu/client/hook-page.island.js",
|
|
226
|
+
priority: "visible",
|
|
227
|
+
component: ClientWidget,
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const html = await response.text();
|
|
232
|
+
expect(html).toContain("Hook Page");
|
|
233
|
+
expect(html).toContain("Click");
|
|
234
|
+
expect(html).toContain('data-mandu-island="hook-page"');
|
|
235
|
+
expect(html).not.toContain('data-mandu-island="hook-page--0"');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("assigns inline client island IDs in document order", async () => {
|
|
239
|
+
function ClientWidget({ label }: { label: string }) {
|
|
240
|
+
return React.createElement("button", null, label);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function SlowSection() {
|
|
244
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
245
|
+
return React.createElement(ClientWidget, { label: "first" });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function FastSection() {
|
|
249
|
+
return React.createElement(ClientWidget, { label: "second" });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function OrderedPage() {
|
|
253
|
+
return [
|
|
254
|
+
React.createElement(SlowSection, { key: "slow" }),
|
|
255
|
+
React.createElement(FastSection, { key: "fast" }),
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const response = await renderPageResponse({
|
|
260
|
+
app: React.createElement(OrderedPage),
|
|
261
|
+
useStreaming: false,
|
|
262
|
+
title: "Ordered",
|
|
263
|
+
headTags: "",
|
|
264
|
+
isDev: false,
|
|
265
|
+
routeId: "ordered",
|
|
266
|
+
routePattern: "/ordered",
|
|
267
|
+
hydration: { strategy: "island", priority: "visible", preload: false },
|
|
268
|
+
bundleManifest: {
|
|
269
|
+
...HYDRATED_MANIFEST,
|
|
270
|
+
bundles: {
|
|
271
|
+
ordered: {
|
|
272
|
+
js: "/.mandu/client/ordered.island.js",
|
|
273
|
+
dependencies: ["_runtime", "_react"],
|
|
274
|
+
priority: "visible",
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
loaderData: undefined,
|
|
279
|
+
transitions: false,
|
|
280
|
+
prefetch: false,
|
|
281
|
+
spa: false,
|
|
282
|
+
devtools: false,
|
|
283
|
+
inlineClientHydration: {
|
|
284
|
+
routeId: "ordered",
|
|
285
|
+
src: "/.mandu/client/ordered.island.js",
|
|
286
|
+
priority: "visible",
|
|
287
|
+
component: ClientWidget,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const html = await response.text();
|
|
292
|
+
expect(html.indexOf('data-mandu-island="ordered--0"')).toBeLessThan(
|
|
293
|
+
html.indexOf('data-mandu-island="ordered--1"'),
|
|
294
|
+
);
|
|
295
|
+
expect(html.indexOf("first")).toBeLessThan(html.indexOf("second"));
|
|
296
|
+
});
|
|
103
297
|
});
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import
|
|
1
|
+
import React from "react";
|
|
2
2
|
import type { BundleManifest } from "../bundler/types";
|
|
3
3
|
import type { HydrationConfig } from "../spec/schema";
|
|
4
4
|
import type { CookieManager } from "../filling/context";
|
|
5
5
|
import { renderSSR, renderStreamingResponse, resolveAsyncElement } from "./ssr";
|
|
6
|
+
import { serializeProps } from "../client/serialize";
|
|
7
|
+
|
|
8
|
+
export interface InlineClientHydrationTarget {
|
|
9
|
+
routeId: string;
|
|
10
|
+
src: string;
|
|
11
|
+
priority: NonNullable<HydrationConfig["priority"]>;
|
|
12
|
+
component: unknown;
|
|
13
|
+
}
|
|
6
14
|
|
|
7
15
|
export interface PageRenderResponseOptions {
|
|
8
16
|
app: React.ReactElement;
|
|
@@ -23,6 +31,7 @@ export interface PageRenderResponseOptions {
|
|
|
23
31
|
spa?: boolean;
|
|
24
32
|
devtools?: boolean;
|
|
25
33
|
islandPreWrapped?: boolean;
|
|
34
|
+
inlineClientHydration?: InlineClientHydrationTarget;
|
|
26
35
|
cookies?: CookieManager;
|
|
27
36
|
}
|
|
28
37
|
|
|
@@ -30,18 +39,111 @@ export async function renderPageResponse(
|
|
|
30
39
|
options: PageRenderResponseOptions
|
|
31
40
|
): Promise<Response> {
|
|
32
41
|
let app = options.app;
|
|
42
|
+
let islandPreWrapped = !!options.islandPreWrapped;
|
|
33
43
|
|
|
34
44
|
if (!options.useStreaming) {
|
|
35
|
-
|
|
45
|
+
if (options.inlineClientHydration) {
|
|
46
|
+
const resolved = await resolveAndWrapInlineClientHydration(
|
|
47
|
+
app,
|
|
48
|
+
options.inlineClientHydration,
|
|
49
|
+
);
|
|
50
|
+
app = resolved.node as React.ReactElement;
|
|
51
|
+
islandPreWrapped = islandPreWrapped || resolved.didWrap;
|
|
52
|
+
} else {
|
|
53
|
+
app = (await resolveAsyncElement(app)) as React.ReactElement;
|
|
54
|
+
}
|
|
36
55
|
}
|
|
37
56
|
|
|
57
|
+
const effectiveOptions = islandPreWrapped === !!options.islandPreWrapped
|
|
58
|
+
? options
|
|
59
|
+
: { ...options, islandPreWrapped };
|
|
60
|
+
|
|
38
61
|
const response = options.useStreaming
|
|
39
|
-
? await renderStreamingPageResponse(app,
|
|
40
|
-
: renderNonStreamingPageResponse(app,
|
|
62
|
+
? await renderStreamingPageResponse(app, effectiveOptions)
|
|
63
|
+
: renderNonStreamingPageResponse(app, effectiveOptions);
|
|
41
64
|
|
|
42
65
|
return options.cookies ? options.cookies.applyToResponse(response) : response;
|
|
43
66
|
}
|
|
44
67
|
|
|
68
|
+
async function resolveAndWrapInlineClientHydration(
|
|
69
|
+
node: React.ReactNode,
|
|
70
|
+
target: InlineClientHydrationTarget,
|
|
71
|
+
counter = { value: 0 },
|
|
72
|
+
): Promise<{ node: React.ReactNode; didWrap: boolean }> {
|
|
73
|
+
if (node == null || typeof node !== "object") {
|
|
74
|
+
return { node, didWrap: false };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (Array.isArray(node)) {
|
|
78
|
+
let didWrap = false;
|
|
79
|
+
const children: React.ReactNode[] = [];
|
|
80
|
+
for (const child of node) {
|
|
81
|
+
const result = await resolveAndWrapInlineClientHydration(child, target, counter);
|
|
82
|
+
didWrap = didWrap || result.didWrap;
|
|
83
|
+
children.push(result.node);
|
|
84
|
+
}
|
|
85
|
+
return { node: children, didWrap };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!React.isValidElement(node)) {
|
|
89
|
+
return { node, didWrap: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const element = node as React.ReactElement<Record<string, unknown>>;
|
|
93
|
+
const type = element.type;
|
|
94
|
+
|
|
95
|
+
if (type === target.component) {
|
|
96
|
+
const id = `${target.routeId}--${counter.value++}`;
|
|
97
|
+
return {
|
|
98
|
+
node: React.createElement(
|
|
99
|
+
"div",
|
|
100
|
+
{
|
|
101
|
+
"data-mandu-island": id,
|
|
102
|
+
"data-mandu-src": target.src,
|
|
103
|
+
"data-mandu-priority": target.priority,
|
|
104
|
+
"data-hydrate": priorityToHydrate(target.priority),
|
|
105
|
+
"data-props": serializeProps(element.props ?? {}),
|
|
106
|
+
style: { display: "contents" },
|
|
107
|
+
},
|
|
108
|
+
element,
|
|
109
|
+
),
|
|
110
|
+
didWrap: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (typeof type === "function" && isAsyncFunctionComponent(type)) {
|
|
115
|
+
const rendered = await (type as (props: Record<string, unknown>) => React.ReactNode | Promise<React.ReactNode>)(
|
|
116
|
+
element.props ?? {},
|
|
117
|
+
);
|
|
118
|
+
return resolveAndWrapInlineClientHydration(rendered, target, counter);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const props = element.props;
|
|
122
|
+
const rawChildren = props?.children as React.ReactNode | undefined;
|
|
123
|
+
if (rawChildren === undefined) {
|
|
124
|
+
return { node: element, didWrap: false };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const resolvedChildren = await resolveAndWrapInlineClientHydration(rawChildren, target, counter);
|
|
128
|
+
if (!resolvedChildren.didWrap && resolvedChildren.node === rawChildren) {
|
|
129
|
+
return { node: element, didWrap: false };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const cloned = Array.isArray(resolvedChildren.node)
|
|
133
|
+
? React.cloneElement(element, undefined, ...resolvedChildren.node)
|
|
134
|
+
: React.cloneElement(element, undefined, resolvedChildren.node);
|
|
135
|
+
return { node: cloned, didWrap: resolvedChildren.didWrap };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isAsyncFunctionComponent(type: Function): boolean {
|
|
139
|
+
return !type.prototype?.isReactComponent &&
|
|
140
|
+
(type as { constructor?: { name?: string } }).constructor?.name === "AsyncFunction";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function priorityToHydrate(priority: InlineClientHydrationTarget["priority"]): string {
|
|
144
|
+
return priority === "immediate" ? "load" : priority;
|
|
145
|
+
}
|
|
146
|
+
|
|
45
147
|
async function renderStreamingPageResponse(
|
|
46
148
|
app: React.ReactElement,
|
|
47
149
|
options: PageRenderResponseOptions
|
|
@@ -157,10 +157,10 @@ describe("Wildcard Matching", () => {
|
|
|
157
157
|
|
|
158
158
|
const result = router.match("/docs");
|
|
159
159
|
|
|
160
|
-
expect(result).not.toBeNull();
|
|
161
|
-
expect(result!.route.id).toBe("docs");
|
|
162
|
-
expect(result!.params).toEqual({});
|
|
163
|
-
});
|
|
160
|
+
expect(result).not.toBeNull();
|
|
161
|
+
expect(result!.route.id).toBe("docs");
|
|
162
|
+
expect(result!.params).toEqual({ path: "" });
|
|
163
|
+
});
|
|
164
164
|
|
|
165
165
|
test("optional wildcard matches with remaining path", () => {
|
|
166
166
|
const router = createRouter([
|
package/src/runtime/router.ts
CHANGED
|
@@ -415,12 +415,10 @@ export class Router {
|
|
|
415
415
|
route,
|
|
416
416
|
};
|
|
417
417
|
|
|
418
|
-
// Optional wildcard
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
418
|
+
// Optional wildcard base paths are matched from wildcardConfig so
|
|
419
|
+
// the named wildcard param is still materialized as an empty string.
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
424
422
|
|
|
425
423
|
// Regular parameter: :param
|
|
426
424
|
const paramName = seg.slice(1);
|
|
@@ -528,12 +526,12 @@ export class Router {
|
|
|
528
526
|
if (node.wildcardConfig.optional) {
|
|
529
527
|
if (this.debug) {
|
|
530
528
|
console.log(`[Router] Optional wildcard match: ${node.wildcardConfig.route.id} with empty path`);
|
|
531
|
-
}
|
|
532
|
-
return {
|
|
533
|
-
route: node.wildcardConfig.route,
|
|
534
|
-
params,
|
|
535
|
-
};
|
|
536
|
-
}
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
route: node.wildcardConfig.route,
|
|
532
|
+
params: { ...params, [node.wildcardConfig.name]: "" },
|
|
533
|
+
};
|
|
534
|
+
}
|
|
537
535
|
// Non-optional wildcard: /files/:path* does NOT match /files
|
|
538
536
|
if (this.debug) {
|
|
539
537
|
console.log(`[Router] Wildcard policy: ${pathname} does not match non-optional wildcard`);
|