@mandujs/core 0.54.17 → 0.54.19
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 +3 -1
- package/src/agent/__tests__/context.test.ts +94 -25
- package/src/agent/context.ts +17 -0
- package/src/agent/types.ts +32 -12
- package/src/agent/verify.ts +55 -24
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
- package/src/bundler/__tests__/build-runner.ts +130 -17
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
- package/src/bundler/build.test.ts +478 -9
- package/src/bundler/build.ts +424 -746
- package/src/bundler/client-boundary-transform.ts +977 -0
- package/src/bundler/dev.ts +39 -112
- package/src/bundler/fast-refresh-preamble.ts +47 -0
- package/src/bundler/index.ts +3 -2
- package/src/bundler/manifest-schema.ts +10 -0
- package/src/bundler/types.ts +20 -2
- package/src/client/__tests__/props-serialization.test.ts +37 -0
- package/src/client/hydrate.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/props-serialization.ts +233 -0
- package/src/client/runtime-entry.ts +567 -0
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -404
- package/src/diagnose/__tests__/checks.test.ts +132 -17
- package/src/diagnose/checks.ts +184 -3
- package/src/diagnose/run.ts +10 -8
- package/src/generator/templates.test.ts +48 -5
- package/src/generator/templates.ts +10 -1
- package/src/internal/client-boundary.ts +266 -0
- package/src/internal/index.ts +2 -1
- package/src/router/client-entry.test.ts +154 -29
- package/src/router/client-entry.ts +111 -313
- package/src/router/fs-routes.test.ts +443 -1
- package/src/router/fs-routes.ts +16 -3
- package/src/router/fs-scanner.ts +176 -57
- package/src/router/fs-types.ts +11 -2
- package/src/router/route-source-analyzer.ts +521 -0
- package/src/runtime/__tests__/inline-client-hydration.test.ts +104 -1
- package/src/runtime/__tests__/page-render-response.test.ts +218 -0
- package/src/runtime/handlers.ts +50 -26
- package/src/runtime/page-render-response.ts +24 -1
- package/src/runtime/server.ts +14 -0
- package/src/runtime/ssr.ts +16 -5
- package/src/runtime/streaming-ssr.ts +119 -76
- package/src/spec/schema.ts +31 -5
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
|
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { renderPageResponse } from "../page-render-response";
|
|
4
4
|
import type { BundleManifest } from "../../bundler/types";
|
|
5
|
+
import { __ManduClientBoundary } from "../../internal/client-boundary";
|
|
5
6
|
|
|
6
7
|
const HYDRATED_MANIFEST: BundleManifest = {
|
|
7
8
|
version: 1,
|
|
@@ -102,6 +103,164 @@ describe("runtime page render response orchestration", () => {
|
|
|
102
103
|
expect(html).toContain('data-hydrate="interaction"');
|
|
103
104
|
});
|
|
104
105
|
|
|
106
|
+
it("does not duplicate pre-wrapped streaming islands and preloads split island chunks", async () => {
|
|
107
|
+
const response = await renderPageResponse({
|
|
108
|
+
app: React.createElement(
|
|
109
|
+
"div",
|
|
110
|
+
{
|
|
111
|
+
"data-mandu-island": "home",
|
|
112
|
+
"data-mandu-src": "/.mandu/client/home.island.js?t=prewrapped",
|
|
113
|
+
"data-mandu-priority": "visible",
|
|
114
|
+
"data-hydrate": "visible",
|
|
115
|
+
style: { display: "contents" },
|
|
116
|
+
},
|
|
117
|
+
React.createElement("main", null, "prewrapped-stream-page"),
|
|
118
|
+
),
|
|
119
|
+
useStreaming: true,
|
|
120
|
+
title: "Prewrapped Stream",
|
|
121
|
+
headTags: "",
|
|
122
|
+
isDev: false,
|
|
123
|
+
routeId: "home",
|
|
124
|
+
routePattern: "/",
|
|
125
|
+
loaderData: undefined,
|
|
126
|
+
hydration: { strategy: "island", priority: "visible", preload: false },
|
|
127
|
+
bundleManifest: {
|
|
128
|
+
...HYDRATED_MANIFEST,
|
|
129
|
+
islands: {
|
|
130
|
+
"home-widget": {
|
|
131
|
+
route: "home",
|
|
132
|
+
js: "/.mandu/client/home-widget.island.js",
|
|
133
|
+
priority: "visible",
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
islandPreWrapped: true,
|
|
138
|
+
transitions: false,
|
|
139
|
+
prefetch: false,
|
|
140
|
+
spa: false,
|
|
141
|
+
devtools: false,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const html = await response.text();
|
|
145
|
+
expect(html.match(/data-mandu-island="home"/g)?.length).toBe(1);
|
|
146
|
+
expect(html).toContain("prewrapped-stream-page");
|
|
147
|
+
expect(html).toContain('<link rel="modulepreload" href="/.mandu/client/home-widget.island.js?v=');
|
|
148
|
+
expect(html).not.toContain('<link rel="modulepreload" href="/.mandu/client/home.island.js?v=');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("serializes compiler-owned client boundaries on the streaming path", async () => {
|
|
152
|
+
const routeId = "stream-boundary";
|
|
153
|
+
const manifest: BundleManifest = {
|
|
154
|
+
...HYDRATED_MANIFEST,
|
|
155
|
+
bundles: {},
|
|
156
|
+
boundaries: {
|
|
157
|
+
"stream-boundary--0": {
|
|
158
|
+
route: routeId,
|
|
159
|
+
js: "/.mandu/client/stream-boundary--0.boundary.js",
|
|
160
|
+
module: "src/client/Counter.client.tsx",
|
|
161
|
+
exportName: "Counter",
|
|
162
|
+
priority: "visible",
|
|
163
|
+
hydrate: "visible",
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const response = await renderPageResponse({
|
|
169
|
+
app: React.createElement(
|
|
170
|
+
"main",
|
|
171
|
+
null,
|
|
172
|
+
React.createElement(__ManduClientBoundary, {
|
|
173
|
+
routeId,
|
|
174
|
+
boundaryId: "stream-boundary--0",
|
|
175
|
+
module: "src/client/Counter.client.tsx",
|
|
176
|
+
exportName: "Counter",
|
|
177
|
+
hydrate: "visible",
|
|
178
|
+
props: { count: 7 },
|
|
179
|
+
}),
|
|
180
|
+
),
|
|
181
|
+
useStreaming: true,
|
|
182
|
+
title: "Stream Boundary",
|
|
183
|
+
headTags: "",
|
|
184
|
+
isDev: false,
|
|
185
|
+
routeId,
|
|
186
|
+
routePattern: "/stream-boundary",
|
|
187
|
+
loaderData: undefined,
|
|
188
|
+
hydration: { strategy: "island", priority: "visible", preload: false },
|
|
189
|
+
bundleManifest: manifest,
|
|
190
|
+
transitions: false,
|
|
191
|
+
prefetch: false,
|
|
192
|
+
spa: false,
|
|
193
|
+
devtools: false,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const html = await response.text();
|
|
197
|
+
expect(html).toContain('data-mandu-island="stream-boundary--0"');
|
|
198
|
+
expect(html).toContain('data-mandu-boundary-id="stream-boundary--0"');
|
|
199
|
+
expect(html).toContain('data-mandu-client-export="Counter"');
|
|
200
|
+
expect(html).toContain('data-mandu-src="/.mandu/client/stream-boundary--0.boundary.js?t=');
|
|
201
|
+
expect(html).toContain('type="application/json" data-mandu-props="stream-boundary--0"');
|
|
202
|
+
expect(html).toContain('"count":7');
|
|
203
|
+
expect(html).toContain('<link rel="modulepreload" href="/.mandu/client/stream-boundary--0.boundary.js?v=');
|
|
204
|
+
expect(html).not.toContain('data-mandu-island="stream-boundary" data-mandu-src=');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("keeps boundary context across async streaming server components", async () => {
|
|
208
|
+
const routeId = "async-stream-boundary";
|
|
209
|
+
const manifest: BundleManifest = {
|
|
210
|
+
...HYDRATED_MANIFEST,
|
|
211
|
+
bundles: {},
|
|
212
|
+
boundaries: {
|
|
213
|
+
"async-stream-boundary--0": {
|
|
214
|
+
route: routeId,
|
|
215
|
+
js: "/.mandu/client/async-stream-boundary--0.boundary.js",
|
|
216
|
+
module: "src/client/AsyncCounter.client.tsx",
|
|
217
|
+
exportName: "AsyncCounter",
|
|
218
|
+
priority: "visible",
|
|
219
|
+
hydrate: "visible",
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
async function AsyncPage() {
|
|
225
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
226
|
+
return React.createElement(
|
|
227
|
+
"main",
|
|
228
|
+
null,
|
|
229
|
+
React.createElement(__ManduClientBoundary, {
|
|
230
|
+
routeId,
|
|
231
|
+
boundaryId: "async-stream-boundary--0",
|
|
232
|
+
module: "src/client/AsyncCounter.client.tsx",
|
|
233
|
+
exportName: "AsyncCounter",
|
|
234
|
+
hydrate: "visible",
|
|
235
|
+
props: { count: 11 },
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const response = await renderPageResponse({
|
|
241
|
+
app: React.createElement(AsyncPage),
|
|
242
|
+
useStreaming: true,
|
|
243
|
+
title: "Async Stream Boundary",
|
|
244
|
+
headTags: "",
|
|
245
|
+
isDev: false,
|
|
246
|
+
routeId,
|
|
247
|
+
routePattern: "/async-stream-boundary",
|
|
248
|
+
loaderData: undefined,
|
|
249
|
+
hydration: { strategy: "island", priority: "visible", preload: false },
|
|
250
|
+
bundleManifest: manifest,
|
|
251
|
+
transitions: false,
|
|
252
|
+
prefetch: false,
|
|
253
|
+
spa: false,
|
|
254
|
+
devtools: false,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const html = await response.text();
|
|
258
|
+
expect(html).toContain('data-mandu-island="async-stream-boundary--0"');
|
|
259
|
+
expect(html).toContain('data-mandu-src="/.mandu/client/async-stream-boundary--0.boundary.js?t=');
|
|
260
|
+
expect(html).toContain('data-mandu-props="async-stream-boundary--0"');
|
|
261
|
+
expect(html).toContain('"count":11');
|
|
262
|
+
});
|
|
263
|
+
|
|
105
264
|
it("serializes non-streaming loaderData as the route server data exactly once", async () => {
|
|
106
265
|
const response = await renderPageResponse({
|
|
107
266
|
app: React.createElement("main", null, "hydrated-page"),
|
|
@@ -175,6 +334,8 @@ describe("runtime page render response orchestration", () => {
|
|
|
175
334
|
src: "/.mandu/client/candidates-$id.island.js",
|
|
176
335
|
priority: "immediate",
|
|
177
336
|
component: PledgeAccordion,
|
|
337
|
+
legacyRuntimeScan: true,
|
|
338
|
+
sourceFile: "app/candidates/[id]/page.tsx",
|
|
178
339
|
},
|
|
179
340
|
});
|
|
180
341
|
|
|
@@ -188,6 +349,61 @@ describe("runtime page render response orchestration", () => {
|
|
|
188
349
|
expect(html).not.toContain('data-mandu-island="candidates-$id"');
|
|
189
350
|
});
|
|
190
351
|
|
|
352
|
+
it("serializes inline client props through a sync server wrapper fallback", async () => {
|
|
353
|
+
function ClientWidget({ label }: { label: string }) {
|
|
354
|
+
return React.createElement("button", null, label);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function ServerWrapper({ label }: { label: string }) {
|
|
358
|
+
return React.createElement("section", null, React.createElement(ClientWidget, { label }));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function WrappedPage() {
|
|
362
|
+
return React.createElement("main", null, React.createElement(ServerWrapper, { label: "wrapped" }));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const response = await renderPageResponse({
|
|
366
|
+
app: React.createElement(WrappedPage),
|
|
367
|
+
useStreaming: false,
|
|
368
|
+
title: "Wrapped",
|
|
369
|
+
headTags: "",
|
|
370
|
+
isDev: false,
|
|
371
|
+
routeId: "wrapped-fallback",
|
|
372
|
+
routePattern: "/wrapped",
|
|
373
|
+
hydration: { strategy: "island", priority: "visible", preload: false },
|
|
374
|
+
bundleManifest: {
|
|
375
|
+
...HYDRATED_MANIFEST,
|
|
376
|
+
bundles: {
|
|
377
|
+
"wrapped-fallback": {
|
|
378
|
+
js: "/.mandu/client/wrapped-fallback.island.js",
|
|
379
|
+
dependencies: ["_runtime", "_react"],
|
|
380
|
+
priority: "visible",
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
loaderData: undefined,
|
|
385
|
+
transitions: false,
|
|
386
|
+
prefetch: false,
|
|
387
|
+
spa: false,
|
|
388
|
+
devtools: false,
|
|
389
|
+
inlineClientHydration: {
|
|
390
|
+
routeId: "wrapped-fallback",
|
|
391
|
+
src: "/.mandu/client/wrapped-fallback.island.js",
|
|
392
|
+
priority: "visible",
|
|
393
|
+
component: ClientWidget,
|
|
394
|
+
legacyRuntimeScan: true,
|
|
395
|
+
sourceFile: "app/wrapped/page.tsx",
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const html = await response.text();
|
|
400
|
+
expect(html).toContain('data-mandu-island="wrapped-fallback--0"');
|
|
401
|
+
expect(html).toContain('data-mandu-src="/.mandu/client/wrapped-fallback.island.js"');
|
|
402
|
+
expect(html).toContain('data-mandu-props="wrapped-fallback--0"');
|
|
403
|
+
expect(html).toContain('"label":"wrapped"');
|
|
404
|
+
expect(html).not.toContain('data-mandu-island="wrapped-fallback"');
|
|
405
|
+
});
|
|
406
|
+
|
|
191
407
|
it("does not invoke sync function components while looking for inline client targets", async () => {
|
|
192
408
|
function ClientWidget({ label }: { label: string }) {
|
|
193
409
|
return React.createElement("button", null, label);
|
|
@@ -287,6 +503,8 @@ describe("runtime page render response orchestration", () => {
|
|
|
287
503
|
src: "/.mandu/client/ordered.island.js",
|
|
288
504
|
priority: "visible",
|
|
289
505
|
component: ClientWidget,
|
|
506
|
+
legacyRuntimeScan: true,
|
|
507
|
+
sourceFile: "app/ordered/page.tsx",
|
|
290
508
|
},
|
|
291
509
|
});
|
|
292
510
|
|
package/src/runtime/handlers.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
type PageRegistration,
|
|
24
24
|
} from "./server";
|
|
25
25
|
import { registerManifest } from "./registry";
|
|
26
|
-
import { needsHydration, type RoutesManifest } from "../spec/schema";
|
|
26
|
+
import { needsHydration, type RouteClientBoundary, type RoutesManifest } from "../spec/schema";
|
|
27
27
|
|
|
28
28
|
type RouteModule = Record<string, unknown>;
|
|
29
29
|
|
|
@@ -84,7 +84,7 @@ function createMethodDispatcher(module: RouteModule, routeId: string) {
|
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export interface RegisterHandlersOptions {
|
|
87
|
+
export interface RegisterHandlersOptions {
|
|
88
88
|
/**
|
|
89
89
|
* Module import function (dev: importFresh, start: standard import).
|
|
90
90
|
* The optional `opts.changedFile` is forwarded into Phase 7.0 B5's
|
|
@@ -92,7 +92,17 @@ export interface RegisterHandlersOptions {
|
|
|
92
92
|
* module's import graph, `importFn` returns the cached bundle in ~0.1 ms
|
|
93
93
|
* instead of re-running Bun.build.
|
|
94
94
|
*/
|
|
95
|
-
importFn: (
|
|
95
|
+
importFn: (
|
|
96
|
+
modulePath: string,
|
|
97
|
+
opts?: {
|
|
98
|
+
changedFile?: string;
|
|
99
|
+
clientBoundaryTransform?: {
|
|
100
|
+
routeId: string;
|
|
101
|
+
hydrate?: string;
|
|
102
|
+
boundaries?: RouteClientBoundary[];
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
) => Promise<unknown>;
|
|
96
106
|
/** Set for tracking already registered layout paths */
|
|
97
107
|
registeredLayouts: Set<string>;
|
|
98
108
|
/** Clear layout cache on reload */
|
|
@@ -115,9 +125,23 @@ export async function registerManifestHandlers(
|
|
|
115
125
|
rootDir: string,
|
|
116
126
|
options: RegisterHandlersOptions
|
|
117
127
|
): Promise<void> {
|
|
118
|
-
const { importFn, registeredLayouts, isReload = false, changedFile } = options;
|
|
119
|
-
const
|
|
120
|
-
changedFile !== undefined ? { changedFile } : undefined;
|
|
128
|
+
const { importFn, registeredLayouts, isReload = false, changedFile } = options;
|
|
129
|
+
const baseImportOpts: { changedFile?: string } | undefined =
|
|
130
|
+
changedFile !== undefined ? { changedFile } : undefined;
|
|
131
|
+
const importOptsForRoute = (route?: RoutesManifest["routes"][number]) => {
|
|
132
|
+
const boundaryTransform = route?.kind === "page" && route.boundaries?.length
|
|
133
|
+
? {
|
|
134
|
+
routeId: route.id,
|
|
135
|
+
hydrate: route.hydration?.priority ?? "visible",
|
|
136
|
+
boundaries: route.boundaries,
|
|
137
|
+
}
|
|
138
|
+
: undefined;
|
|
139
|
+
if (!baseImportOpts && !boundaryTransform) return undefined;
|
|
140
|
+
return {
|
|
141
|
+
...baseImportOpts,
|
|
142
|
+
...(boundaryTransform ? { clientBoundaryTransform: boundaryTransform } : {}),
|
|
143
|
+
};
|
|
144
|
+
};
|
|
121
145
|
|
|
122
146
|
if (isReload) {
|
|
123
147
|
registeredLayouts.clear();
|
|
@@ -134,19 +158,19 @@ export async function registerManifestHandlers(
|
|
|
134
158
|
// runtime dispatcher invokes the default export on each request
|
|
135
159
|
// so HMR reloads pick up edits automatically (same pattern as
|
|
136
160
|
// API routes below).
|
|
137
|
-
if (route.kind === "metadata") {
|
|
138
|
-
const modulePath = path.resolve(rootDir, route.module);
|
|
139
|
-
registerMetadataHandler(route.id, async () => {
|
|
140
|
-
return importFn(modulePath,
|
|
141
|
-
});
|
|
142
|
-
console.log(` 🗺️ Metadata: ${route.pattern} -> ${route.id}`);
|
|
143
|
-
continue;
|
|
161
|
+
if (route.kind === "metadata") {
|
|
162
|
+
const modulePath = path.resolve(rootDir, route.module);
|
|
163
|
+
registerMetadataHandler(route.id, async () => {
|
|
164
|
+
return importFn(modulePath, importOptsForRoute(route));
|
|
165
|
+
});
|
|
166
|
+
console.log(` 🗺️ Metadata: ${route.pattern} -> ${route.id}`);
|
|
167
|
+
continue;
|
|
144
168
|
}
|
|
145
169
|
|
|
146
|
-
if (route.kind === "api") {
|
|
147
|
-
const modulePath = path.resolve(rootDir, route.module);
|
|
148
|
-
try {
|
|
149
|
-
const module = (await importFn(modulePath,
|
|
170
|
+
if (route.kind === "api") {
|
|
171
|
+
const modulePath = path.resolve(rootDir, route.module);
|
|
172
|
+
try {
|
|
173
|
+
const module = (await importFn(modulePath, importOptsForRoute(route))) as RouteModule;
|
|
150
174
|
let handler: unknown = module.default ?? module.handler ?? module;
|
|
151
175
|
|
|
152
176
|
// 1) ManduFilling instance
|
|
@@ -193,7 +217,7 @@ export async function registerManifestHandlers(
|
|
|
193
217
|
// Layout modules must export a default component. Runtime
|
|
194
218
|
// validation in `renderToHTML` / page-loader asserts this —
|
|
195
219
|
// so casting the unknown `importFn` result is safe here.
|
|
196
|
-
return importFn(absLayoutPath,
|
|
220
|
+
return importFn(absLayoutPath, baseImportOpts);
|
|
197
221
|
}) as Parameters<typeof registerLayoutLoader>[1]);
|
|
198
222
|
registeredLayouts.add(layoutPath);
|
|
199
223
|
console.log(` 🎨 Layout: ${layoutPath}`);
|
|
@@ -204,7 +228,7 @@ export async function registerManifestHandlers(
|
|
|
204
228
|
// Use PageHandler if slotModule exists (filling.loader support)
|
|
205
229
|
if (route.slotModule) {
|
|
206
230
|
registerPageHandler(route.id, async () => {
|
|
207
|
-
const mod = (await importFn(componentPath,
|
|
231
|
+
const mod = (await importFn(componentPath, importOptsForRoute(route))) as Record<string, unknown>;
|
|
208
232
|
// Normalize the page module shape. Users write pages in two styles:
|
|
209
233
|
// (a) `export default function Page() {…}` + `export const filling = …`
|
|
210
234
|
// (b) `export default { component: …, filling: … }`
|
|
@@ -242,7 +266,7 @@ export async function registerManifestHandlers(
|
|
|
242
266
|
` 📄 Page: ${route.pattern} -> ${route.id} (with loader)${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`
|
|
243
267
|
);
|
|
244
268
|
} else {
|
|
245
|
-
registerPageLoader(route.id, (() => importFn(componentPath,
|
|
269
|
+
registerPageLoader(route.id, (() => importFn(componentPath, importOptsForRoute(route))) as Parameters<typeof registerPageLoader>[1]);
|
|
246
270
|
console.log(
|
|
247
271
|
` 📄 Page: ${route.pattern} -> ${route.id}${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`
|
|
248
272
|
);
|
|
@@ -252,7 +276,7 @@ export async function registerManifestHandlers(
|
|
|
252
276
|
|
|
253
277
|
// Phase 6.3: register `app/not-found.tsx` if it exists. Global, one per
|
|
254
278
|
// app — the server falls through to the built-in 404 if unregistered.
|
|
255
|
-
await registerAppNotFound(rootDir, importFn,
|
|
279
|
+
await registerAppNotFound(rootDir, importFn, baseImportOpts);
|
|
256
280
|
}
|
|
257
281
|
|
|
258
282
|
/**
|
|
@@ -260,11 +284,11 @@ export async function registerManifestHandlers(
|
|
|
260
284
|
* project root and register it as the app-level 404 handler. Silent
|
|
261
285
|
* no-op if no file exists — the server's built-in 404 covers that case.
|
|
262
286
|
*/
|
|
263
|
-
async function registerAppNotFound(
|
|
264
|
-
rootDir: string,
|
|
265
|
-
importFn:
|
|
266
|
-
importOpts?: { changedFile?: string },
|
|
267
|
-
): Promise<void> {
|
|
287
|
+
async function registerAppNotFound(
|
|
288
|
+
rootDir: string,
|
|
289
|
+
importFn: RegisterHandlersOptions["importFn"],
|
|
290
|
+
importOpts?: { changedFile?: string },
|
|
291
|
+
): Promise<void> {
|
|
268
292
|
const candidates = [
|
|
269
293
|
"app/not-found.tsx",
|
|
270
294
|
"app/not-found.ts",
|
|
@@ -11,6 +11,13 @@ export interface InlineClientHydrationTarget {
|
|
|
11
11
|
src: string;
|
|
12
12
|
priority: NonNullable<HydrationConfig["priority"]>;
|
|
13
13
|
component: unknown;
|
|
14
|
+
/**
|
|
15
|
+
* Compatibility path for pre-F42 route-level client modules where the
|
|
16
|
+
* client component is hidden behind a server wrapper. Disabled by default
|
|
17
|
+
* because it invokes user function components outside React's renderer.
|
|
18
|
+
*/
|
|
19
|
+
legacyRuntimeScan?: boolean;
|
|
20
|
+
sourceFile?: string;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
export interface PageRenderResponseOptions {
|
|
@@ -125,7 +132,8 @@ async function resolveAndWrapInlineClientHydration(
|
|
|
125
132
|
};
|
|
126
133
|
}
|
|
127
134
|
|
|
128
|
-
if (typeof type === "function" && !isClassComponent(type)) {
|
|
135
|
+
if (target.legacyRuntimeScan && typeof type === "function" && !isClassComponent(type)) {
|
|
136
|
+
warnLegacyRuntimePartialScan(target);
|
|
129
137
|
const rendered = await renderFunctionComponentForInlineHydration(
|
|
130
138
|
type,
|
|
131
139
|
element.props ?? {},
|
|
@@ -152,6 +160,20 @@ async function resolveAndWrapInlineClientHydration(
|
|
|
152
160
|
return { node: cloned, didWrap: resolvedChildren.didWrap };
|
|
153
161
|
}
|
|
154
162
|
|
|
163
|
+
const warnedLegacyRuntimePartialScanRoutes = new Set<string>();
|
|
164
|
+
|
|
165
|
+
function warnLegacyRuntimePartialScan(target: InlineClientHydrationTarget): void {
|
|
166
|
+
if (warnedLegacyRuntimePartialScanRoutes.has(target.routeId)) return;
|
|
167
|
+
warnedLegacyRuntimePartialScanRoutes.add(target.routeId);
|
|
168
|
+
|
|
169
|
+
const file = target.sourceFile ? ` file="${target.sourceFile}"` : "";
|
|
170
|
+
console.warn(
|
|
171
|
+
`[MANDU_LEGACY_RUNTIME_PARTIAL_SCAN] route="${target.routeId}"${file}: ` +
|
|
172
|
+
"runtime client component discovery is running under the compatibility flag. " +
|
|
173
|
+
"Migrate this route to compiler-owned client boundaries so SSR does not invoke user components during discovery.",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
155
177
|
function isAsyncFunctionComponent(type: Function): boolean {
|
|
156
178
|
return !type.prototype?.isReactComponent &&
|
|
157
179
|
(type as { constructor?: { name?: string } }).constructor?.name === "AsyncFunction";
|
|
@@ -214,6 +236,7 @@ async function renderStreamingPageResponse(
|
|
|
214
236
|
criticalData: options.loaderData as Record<string, unknown> | undefined,
|
|
215
237
|
enableClientRouter: true,
|
|
216
238
|
cssPath: options.cssPath,
|
|
239
|
+
islandPreWrapped: !!options.islandPreWrapped,
|
|
217
240
|
transitions: options.transitions,
|
|
218
241
|
prefetch: options.prefetch,
|
|
219
242
|
spa: options.spa,
|
package/src/runtime/server.ts
CHANGED
|
@@ -1117,9 +1117,11 @@ function createDefaultAppFactory(registry: ServerRegistry) {
|
|
|
1117
1117
|
async function resolveInlineClientHydrationTarget(
|
|
1118
1118
|
route: {
|
|
1119
1119
|
id: string;
|
|
1120
|
+
componentModule?: string;
|
|
1120
1121
|
clientModule?: string;
|
|
1121
1122
|
clientExportName?: string;
|
|
1122
1123
|
hydration?: HydrationConfig;
|
|
1124
|
+
boundaries?: unknown[];
|
|
1123
1125
|
},
|
|
1124
1126
|
rootDir: string,
|
|
1125
1127
|
src: string,
|
|
@@ -1127,6 +1129,14 @@ async function resolveInlineClientHydrationTarget(
|
|
|
1127
1129
|
if (!route.clientModule || !src) {
|
|
1128
1130
|
return undefined;
|
|
1129
1131
|
}
|
|
1132
|
+
if (route.boundaries && route.boundaries.length > 0) {
|
|
1133
|
+
return undefined;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const legacyRuntimeScan = process.env.MANDU_LEGACY_RUNTIME_PARTIAL_SCAN === "1";
|
|
1137
|
+
if (!legacyRuntimeScan) {
|
|
1138
|
+
return undefined;
|
|
1139
|
+
}
|
|
1130
1140
|
|
|
1131
1141
|
try {
|
|
1132
1142
|
const module = await import(path.join(rootDir, route.clientModule));
|
|
@@ -1139,6 +1149,8 @@ async function resolveInlineClientHydrationTarget(
|
|
|
1139
1149
|
src,
|
|
1140
1150
|
priority: route.hydration?.priority ?? "visible",
|
|
1141
1151
|
component,
|
|
1152
|
+
legacyRuntimeScan,
|
|
1153
|
+
sourceFile: route.componentModule ?? route.clientModule,
|
|
1142
1154
|
};
|
|
1143
1155
|
} catch (error) {
|
|
1144
1156
|
console.warn(
|
|
@@ -2112,8 +2124,10 @@ async function renderPageSSR(
|
|
|
2112
2124
|
errorModule?: string;
|
|
2113
2125
|
loadingModule?: string;
|
|
2114
2126
|
notFoundModule?: string;
|
|
2127
|
+
componentModule?: string;
|
|
2115
2128
|
clientModule?: string;
|
|
2116
2129
|
clientExportName?: string;
|
|
2130
|
+
boundaries?: unknown[];
|
|
2117
2131
|
},
|
|
2118
2132
|
params: Record<string, string>,
|
|
2119
2133
|
loaderData: unknown,
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -8,10 +8,11 @@ import type { HydrationConfig, HydrationPriority } from "../spec/schema";
|
|
|
8
8
|
import { PORTS, TIMEOUTS } from "../constants";
|
|
9
9
|
import { decodeHtmlText, escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
|
|
10
10
|
import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
|
|
11
|
-
import { generateFastRefreshPreamble } from "../bundler/
|
|
11
|
+
import { generateFastRefreshPreamble } from "../bundler/fast-refresh-preamble";
|
|
12
12
|
import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
|
|
13
13
|
import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
|
|
14
|
-
import { maybeInjectDevOverlay } from "../dev-error-overlay";
|
|
14
|
+
import { maybeInjectDevOverlay } from "../dev-error-overlay";
|
|
15
|
+
import { renderWithManduClientBoundaryManifest } from "../internal/client-boundary";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Issue #192 — `@view-transition` at-rule block.
|
|
@@ -275,8 +276,16 @@ function generateHydrationScripts(
|
|
|
275
276
|
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
276
277
|
}
|
|
277
278
|
}
|
|
278
|
-
|
|
279
|
-
|
|
279
|
+
|
|
280
|
+
if (manifest.boundaries) {
|
|
281
|
+
for (const boundary of Object.values(manifest.boundaries)) {
|
|
282
|
+
if (boundary.route !== routeId) continue;
|
|
283
|
+
const cacheBust = `${boundary.js}${boundary.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
284
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
|
|
280
289
|
if (manifest.shared.runtime) {
|
|
281
290
|
scripts.push(`<script type="module" src="${escapeHtmlAttr(manifest.shared.runtime)}"></script>`);
|
|
282
291
|
}
|
|
@@ -703,7 +712,9 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
703
712
|
} catch { /* client 모듈 로드 실패 시 무시 */ }
|
|
704
713
|
|
|
705
714
|
const renderToString = getRenderToString();
|
|
706
|
-
let content =
|
|
715
|
+
let content = renderWithManduClientBoundaryManifest(routeId, bundleManifest, () =>
|
|
716
|
+
renderToString(element),
|
|
717
|
+
);
|
|
707
718
|
|
|
708
719
|
// 렌더링 중 수집된 head 태그
|
|
709
720
|
collectedHeadTags = headGet?.() ?? "";
|