@mandujs/core 0.54.18 → 0.54.20

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.
@@ -0,0 +1,521 @@
1
+ import * as ts from "typescript";
2
+
3
+ import type { HydrationConfig } from "../spec/schema";
4
+
5
+ const HYDRATION_STRATEGIES = new Set(["none", "island", "full", "progressive"]);
6
+ const HYDRATION_PRIORITIES = new Set(["immediate", "visible", "idle", "interaction"]);
7
+
8
+ export interface RouteSourceAnalysis {
9
+ directives: {
10
+ useClient: boolean;
11
+ useServer: boolean;
12
+ };
13
+ hydrationConfig?: HydrationConfig;
14
+ hydrationSourceRange?: { start: number; end: number };
15
+ imports: RouteSourceImportRecord[];
16
+ exports: RouteSourceExportRecord[];
17
+ defaultExport: RouteSourceDefaultExport;
18
+ diagnostics: RouteSourceDiagnostic[];
19
+ }
20
+
21
+ export interface RouteSourceImportRecord {
22
+ source: string;
23
+ defaultName?: string;
24
+ namespaceName?: string;
25
+ named: Array<{ imported: string; local: string; isTypeOnly?: boolean }>;
26
+ isTypeOnly?: boolean;
27
+ isSideEffectOnly?: boolean;
28
+ }
29
+
30
+ export interface RouteSourceExportRecord {
31
+ name: string;
32
+ kind: "default" | "named";
33
+ localName?: string;
34
+ source?: string;
35
+ }
36
+
37
+ export interface RouteSourceDefaultExport {
38
+ kind: "function" | "identifier" | "call" | "unknown";
39
+ localName?: string;
40
+ source?: string;
41
+ referencesJsx: boolean;
42
+ renderedJsxNames: string[];
43
+ }
44
+
45
+ export interface RouteSourceDiagnostic {
46
+ code:
47
+ | "MANDU_ROUTE_HYDRATION_UNSUPPORTED_INITIALIZER"
48
+ | "MANDU_ROUTE_HYDRATION_INVALID_VALUE"
49
+ | "MANDU_ROUTE_DEFAULT_EXPORT_WRAPPER";
50
+ severity: "warning";
51
+ message: string;
52
+ start?: number;
53
+ end?: number;
54
+ }
55
+
56
+ interface AnalyzerContext {
57
+ sourceFile: ts.SourceFile;
58
+ declarations: Map<string, ts.Node>;
59
+ variableInitializers: Map<string, ts.Expression>;
60
+ diagnostics: RouteSourceDiagnostic[];
61
+ }
62
+
63
+ export function analyzeRouteSource(source: string, fileName = "route.tsx"): RouteSourceAnalysis {
64
+ const sourceFile = ts.createSourceFile(
65
+ fileName,
66
+ source,
67
+ ts.ScriptTarget.Latest,
68
+ true,
69
+ ts.ScriptKind.TSX,
70
+ );
71
+ const diagnostics: RouteSourceDiagnostic[] = [];
72
+ const declarations = new Map<string, ts.Node>();
73
+ const variableInitializers = new Map<string, ts.Expression>();
74
+ const context: AnalyzerContext = { sourceFile, declarations, variableInitializers, diagnostics };
75
+
76
+ const imports: RouteSourceImportRecord[] = [];
77
+ const exports: RouteSourceExportRecord[] = [];
78
+ let hydrationConfig: HydrationConfig | undefined;
79
+ let hydrationSourceRange: { start: number; end: number } | undefined;
80
+ let defaultExport: RouteSourceDefaultExport = {
81
+ kind: "unknown",
82
+ referencesJsx: false,
83
+ renderedJsxNames: [],
84
+ };
85
+
86
+ for (const statement of sourceFile.statements) {
87
+ collectDeclaration(statement, context);
88
+
89
+ if (ts.isImportDeclaration(statement)) {
90
+ const record = getImportRecord(statement);
91
+ if (record) imports.push(record);
92
+ continue;
93
+ }
94
+
95
+ if (ts.isVariableStatement(statement)) {
96
+ const hydration = readHydrationExport(statement, context);
97
+ if (hydration) {
98
+ hydrationConfig = hydration.config;
99
+ hydrationSourceRange = hydration.sourceRange;
100
+ }
101
+ exports.push(...getVariableExports(statement));
102
+ continue;
103
+ }
104
+
105
+ if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) {
106
+ exports.push(...getDeclarationExports(statement));
107
+ if (hasModifier(statement, ts.SyntaxKind.ExportKeyword) && hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
108
+ defaultExport = createDefaultExportFromNode("function", statement, context, statement.name?.text);
109
+ }
110
+ continue;
111
+ }
112
+
113
+ if (ts.isExportAssignment(statement)) {
114
+ defaultExport = createDefaultExportFromExpression(statement.expression, context);
115
+ exports.push({ name: "default", kind: "default", localName: defaultExport.localName });
116
+ continue;
117
+ }
118
+
119
+ if (ts.isExportDeclaration(statement)) {
120
+ const records = getExportDeclarationRecords(statement);
121
+ exports.push(...records);
122
+ const defaultRecord = records.find((record) => record.name === "default");
123
+ if (defaultRecord) {
124
+ defaultExport = {
125
+ kind: "identifier",
126
+ localName: defaultRecord.localName,
127
+ source: defaultRecord.source,
128
+ referencesJsx: false,
129
+ renderedJsxNames: [],
130
+ };
131
+ }
132
+ }
133
+ }
134
+
135
+ return {
136
+ directives: getDirectives(sourceFile),
137
+ hydrationConfig,
138
+ hydrationSourceRange,
139
+ imports,
140
+ exports,
141
+ defaultExport,
142
+ diagnostics,
143
+ };
144
+ }
145
+
146
+ function getDirectives(sourceFile: ts.SourceFile): RouteSourceAnalysis["directives"] {
147
+ let useClient = false;
148
+ let useServer = false;
149
+
150
+ for (const statement of sourceFile.statements) {
151
+ if (!ts.isExpressionStatement(statement) || !ts.isStringLiteral(statement.expression)) break;
152
+ if (statement.expression.text === "use client") useClient = true;
153
+ if (statement.expression.text === "use server") useServer = true;
154
+ }
155
+
156
+ return { useClient, useServer };
157
+ }
158
+
159
+ function collectDeclaration(statement: ts.Statement, context: AnalyzerContext): void {
160
+ if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name) {
161
+ context.declarations.set(statement.name.text, statement);
162
+ return;
163
+ }
164
+
165
+ if (!ts.isVariableStatement(statement)) return;
166
+ for (const declaration of statement.declarationList.declarations) {
167
+ if (!ts.isIdentifier(declaration.name)) continue;
168
+ context.declarations.set(declaration.name.text, declaration);
169
+ if (declaration.initializer) {
170
+ context.variableInitializers.set(declaration.name.text, declaration.initializer);
171
+ }
172
+ }
173
+ }
174
+
175
+ function getImportRecord(statement: ts.ImportDeclaration): RouteSourceImportRecord | null {
176
+ const source = getStringModuleSpecifier(statement.moduleSpecifier);
177
+ if (!source) return null;
178
+ const importClause = statement.importClause;
179
+ if (!importClause) {
180
+ return { source, named: [], isSideEffectOnly: true };
181
+ }
182
+
183
+ const record: RouteSourceImportRecord = {
184
+ source,
185
+ named: [],
186
+ isTypeOnly: importClause.isTypeOnly || undefined,
187
+ };
188
+
189
+ if (importClause.name && !importClause.isTypeOnly) {
190
+ record.defaultName = importClause.name.text;
191
+ }
192
+
193
+ const namedBindings = importClause.namedBindings;
194
+ if (namedBindings && ts.isNamespaceImport(namedBindings) && !importClause.isTypeOnly) {
195
+ record.namespaceName = namedBindings.name.text;
196
+ }
197
+
198
+ if (namedBindings && ts.isNamedImports(namedBindings)) {
199
+ for (const element of namedBindings.elements) {
200
+ record.named.push({
201
+ imported: element.propertyName?.text ?? element.name.text,
202
+ local: element.name.text,
203
+ isTypeOnly: importClause.isTypeOnly || element.isTypeOnly || undefined,
204
+ });
205
+ }
206
+ }
207
+
208
+ return record;
209
+ }
210
+
211
+ function readHydrationExport(
212
+ statement: ts.VariableStatement,
213
+ context: AnalyzerContext,
214
+ ): { config?: HydrationConfig; sourceRange: { start: number; end: number } } | null {
215
+ if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) return null;
216
+
217
+ for (const declaration of statement.declarationList.declarations) {
218
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "hydration") continue;
219
+ const sourceRange = {
220
+ start: declaration.getStart(context.sourceFile),
221
+ end: declaration.getEnd(),
222
+ };
223
+ if (!declaration.initializer) {
224
+ pushDiagnostic(
225
+ context,
226
+ "MANDU_ROUTE_HYDRATION_UNSUPPORTED_INITIALIZER",
227
+ "Route hydration export must have an initializer.",
228
+ declaration,
229
+ );
230
+ return { sourceRange };
231
+ }
232
+ return {
233
+ config: readHydrationInitializer(declaration.initializer, context),
234
+ sourceRange,
235
+ };
236
+ }
237
+
238
+ return null;
239
+ }
240
+
241
+ function readHydrationInitializer(
242
+ initializer: ts.Expression,
243
+ context: AnalyzerContext,
244
+ ): HydrationConfig | undefined {
245
+ if (ts.isStringLiteral(initializer)) {
246
+ const strategy = initializer.text;
247
+ if (!HYDRATION_STRATEGIES.has(strategy)) {
248
+ pushHydrationValueDiagnostic(context, initializer, "strategy", strategy);
249
+ return undefined;
250
+ }
251
+ return {
252
+ strategy: strategy as HydrationConfig["strategy"],
253
+ priority: "visible",
254
+ preload: false,
255
+ };
256
+ }
257
+
258
+ if (!ts.isObjectLiteralExpression(initializer)) {
259
+ pushDiagnostic(
260
+ context,
261
+ "MANDU_ROUTE_HYDRATION_UNSUPPORTED_INITIALIZER",
262
+ "Route hydration export must be an object literal or a supported strategy string literal.",
263
+ initializer,
264
+ );
265
+ return undefined;
266
+ }
267
+
268
+ const strategy = readStringProperty(initializer, "strategy");
269
+ if (!strategy || !HYDRATION_STRATEGIES.has(strategy.value)) {
270
+ pushHydrationValueDiagnostic(context, strategy?.node ?? initializer, "strategy", strategy?.value);
271
+ return undefined;
272
+ }
273
+
274
+ const priority = readStringProperty(initializer, "priority");
275
+ if (priority && !HYDRATION_PRIORITIES.has(priority.value)) {
276
+ pushHydrationValueDiagnostic(context, priority.node, "priority", priority.value);
277
+ }
278
+
279
+ return {
280
+ strategy: strategy.value as HydrationConfig["strategy"],
281
+ priority: HYDRATION_PRIORITIES.has(priority?.value ?? "")
282
+ ? (priority?.value as HydrationConfig["priority"])
283
+ : "visible",
284
+ preload: readBooleanProperty(initializer, "preload") ?? false,
285
+ };
286
+ }
287
+
288
+ function readStringProperty(
289
+ object: ts.ObjectLiteralExpression,
290
+ key: string,
291
+ ): { value: string; node: ts.Node } | null {
292
+ for (const property of object.properties) {
293
+ if (!ts.isPropertyAssignment(property)) continue;
294
+ if (getPropertyNameText(property.name) !== key) continue;
295
+ if (!ts.isStringLiteral(property.initializer)) return null;
296
+ return { value: property.initializer.text, node: property.initializer };
297
+ }
298
+ return null;
299
+ }
300
+
301
+ function readBooleanProperty(object: ts.ObjectLiteralExpression, key: string): boolean | undefined {
302
+ for (const property of object.properties) {
303
+ if (!ts.isPropertyAssignment(property)) continue;
304
+ if (getPropertyNameText(property.name) !== key) continue;
305
+ if (property.initializer.kind === ts.SyntaxKind.TrueKeyword) return true;
306
+ if (property.initializer.kind === ts.SyntaxKind.FalseKeyword) return false;
307
+ return undefined;
308
+ }
309
+ return undefined;
310
+ }
311
+
312
+ function getVariableExports(statement: ts.VariableStatement): RouteSourceExportRecord[] {
313
+ if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) return [];
314
+ return statement.declarationList.declarations
315
+ .filter((declaration): declaration is ts.VariableDeclaration & { name: ts.Identifier } => ts.isIdentifier(declaration.name))
316
+ .map((declaration) => ({
317
+ name: declaration.name.text,
318
+ kind: "named" as const,
319
+ localName: declaration.name.text,
320
+ }));
321
+ }
322
+
323
+ function getDeclarationExports(statement: ts.FunctionDeclaration | ts.ClassDeclaration): RouteSourceExportRecord[] {
324
+ if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) return [];
325
+ const localName = statement.name?.text;
326
+ if (hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
327
+ return [{ name: "default", kind: "default", localName }];
328
+ }
329
+ return localName ? [{ name: localName, kind: "named", localName }] : [];
330
+ }
331
+
332
+ function getExportDeclarationRecords(statement: ts.ExportDeclaration): RouteSourceExportRecord[] {
333
+ if (!statement.exportClause || !ts.isNamedExports(statement.exportClause)) return [];
334
+ const source = getStringModuleSpecifier(statement.moduleSpecifier);
335
+ return statement.exportClause.elements.map((element) => {
336
+ const exportedName = element.name.text;
337
+ const localName = element.propertyName?.text ?? element.name.text;
338
+ return {
339
+ name: exportedName,
340
+ kind: exportedName === "default" ? "default" as const : "named" as const,
341
+ localName,
342
+ source: source ?? undefined,
343
+ };
344
+ });
345
+ }
346
+
347
+ function createDefaultExportFromExpression(
348
+ expression: ts.Expression,
349
+ context: AnalyzerContext,
350
+ ): RouteSourceDefaultExport {
351
+ if (ts.isIdentifier(expression)) {
352
+ return createDefaultExportFromIdentifier(expression.text, context);
353
+ }
354
+
355
+ if (ts.isFunctionExpression(expression) || ts.isArrowFunction(expression)) {
356
+ return createDefaultExportFromNode("function", expression, context);
357
+ }
358
+
359
+ if (ts.isCallExpression(expression)) {
360
+ pushDiagnostic(
361
+ context,
362
+ "MANDU_ROUTE_DEFAULT_EXPORT_WRAPPER",
363
+ "Route default export is a call expression. Mandu treats this as a conservative wrapper and does not infer route-level client entries from it.",
364
+ expression,
365
+ );
366
+ return {
367
+ kind: "call",
368
+ localName: getExpressionName(expression.expression, context.sourceFile),
369
+ referencesJsx: hasJsxReference(expression),
370
+ renderedJsxNames: collectJsxElementNames(expression),
371
+ };
372
+ }
373
+
374
+ return {
375
+ kind: "unknown",
376
+ referencesJsx: hasJsxReference(expression),
377
+ renderedJsxNames: collectJsxElementNames(expression),
378
+ };
379
+ }
380
+
381
+ function createDefaultExportFromIdentifier(
382
+ localName: string,
383
+ context: AnalyzerContext,
384
+ seen = new Set<string>(),
385
+ ): RouteSourceDefaultExport {
386
+ if (seen.has(localName)) {
387
+ return { kind: "identifier", localName, referencesJsx: false, renderedJsxNames: [] };
388
+ }
389
+ seen.add(localName);
390
+
391
+ const initializer = context.variableInitializers.get(localName);
392
+ if (initializer) {
393
+ if (ts.isIdentifier(initializer)) {
394
+ return createDefaultExportFromIdentifier(initializer.text, context, seen);
395
+ }
396
+ return {
397
+ kind: "identifier",
398
+ localName,
399
+ referencesJsx: hasJsxReference(initializer),
400
+ renderedJsxNames: collectJsxElementNames(initializer),
401
+ };
402
+ }
403
+
404
+ const declaration = context.declarations.get(localName);
405
+ if (declaration) {
406
+ return {
407
+ kind: "identifier",
408
+ localName,
409
+ referencesJsx: hasJsxReference(declaration),
410
+ renderedJsxNames: collectJsxElementNames(declaration),
411
+ };
412
+ }
413
+
414
+ return { kind: "identifier", localName, referencesJsx: false, renderedJsxNames: [] };
415
+ }
416
+
417
+ function createDefaultExportFromNode(
418
+ kind: RouteSourceDefaultExport["kind"],
419
+ node: ts.Node,
420
+ context: AnalyzerContext,
421
+ localName?: string,
422
+ ): RouteSourceDefaultExport {
423
+ return {
424
+ kind,
425
+ localName,
426
+ referencesJsx: hasJsxReference(node),
427
+ renderedJsxNames: collectJsxElementNames(node),
428
+ };
429
+ }
430
+
431
+ function hasJsxReference(node: ts.Node): boolean {
432
+ let found = false;
433
+ const visit = (candidate: ts.Node): void => {
434
+ if (
435
+ ts.isJsxElement(candidate) ||
436
+ ts.isJsxSelfClosingElement(candidate) ||
437
+ ts.isJsxFragment(candidate)
438
+ ) {
439
+ found = true;
440
+ return;
441
+ }
442
+ if (!found) ts.forEachChild(candidate, visit);
443
+ };
444
+ visit(node);
445
+ return found;
446
+ }
447
+
448
+ function collectJsxElementNames(node: ts.Node): string[] {
449
+ const names = new Set<string>();
450
+ const visit = (candidate: ts.Node): void => {
451
+ if (ts.isJsxSelfClosingElement(candidate)) {
452
+ addJsxTagNames(candidate.tagName, names);
453
+ } else if (ts.isJsxElement(candidate)) {
454
+ addJsxTagNames(candidate.openingElement.tagName, names);
455
+ }
456
+ ts.forEachChild(candidate, visit);
457
+ };
458
+ visit(node);
459
+ return Array.from(names);
460
+ }
461
+
462
+ function addJsxTagNames(tagName: ts.JsxTagNameExpression, names: Set<string>): void {
463
+ const text = tagName.getText();
464
+ if (!text || text[0] === text[0]?.toLowerCase()) return;
465
+ names.add(text);
466
+ }
467
+
468
+ function pushHydrationValueDiagnostic(
469
+ context: AnalyzerContext,
470
+ node: ts.Node,
471
+ key: string,
472
+ value: string | undefined,
473
+ ): void {
474
+ pushDiagnostic(
475
+ context,
476
+ "MANDU_ROUTE_HYDRATION_INVALID_VALUE",
477
+ `Route hydration.${key} has unsupported value ${JSON.stringify(value)}.`,
478
+ node,
479
+ );
480
+ }
481
+
482
+ function pushDiagnostic(
483
+ context: AnalyzerContext,
484
+ code: RouteSourceDiagnostic["code"],
485
+ message: string,
486
+ node: ts.Node,
487
+ ): void {
488
+ context.diagnostics.push({
489
+ code,
490
+ severity: "warning",
491
+ message,
492
+ start: node.getStart(context.sourceFile),
493
+ end: node.getEnd(),
494
+ });
495
+ }
496
+
497
+ function hasModifier(
498
+ node: ts.Node,
499
+ kind: ts.SyntaxKind.ExportKeyword | ts.SyntaxKind.DefaultKeyword,
500
+ ): boolean {
501
+ return !!ts.canHaveModifiers(node) && !!ts.getModifiers(node)?.some((modifier) => modifier.kind === kind);
502
+ }
503
+
504
+ function getStringModuleSpecifier(moduleSpecifier: ts.Expression | undefined): string | null {
505
+ return moduleSpecifier && ts.isStringLiteral(moduleSpecifier)
506
+ ? moduleSpecifier.text
507
+ : null;
508
+ }
509
+
510
+ function getPropertyNameText(name: ts.PropertyName): string | null {
511
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
512
+ return name.text;
513
+ }
514
+ return null;
515
+ }
516
+
517
+ function getExpressionName(expression: ts.Expression, sourceFile: ts.SourceFile): string | undefined {
518
+ return ts.isIdentifier(expression) || ts.isPropertyAccessExpression(expression)
519
+ ? expression.getText(sourceFile)
520
+ : undefined;
521
+ }
@@ -43,7 +43,7 @@ afterEach(async () => {
43
43
  });
44
44
 
45
45
  describe("startServer inline client hydration", () => {
46
- it("captures component props through sync server pages and inferred named client exports", async () => {
46
+ it("captures component props through sync server pages only under the legacy runtime scan flag", async () => {
47
47
  await resetTestRoot();
48
48
 
49
49
  const routeId = "pledges-$id";
@@ -104,7 +104,14 @@ export function CommentsSection({ pledgeId, initialComments }) {
104
104
  };
105
105
 
106
106
  let server: ManduServer | undefined;
107
+ const originalFlag = process.env.MANDU_LEGACY_RUNTIME_PARTIAL_SCAN;
108
+ const originalWarn = console.warn;
109
+ const warnings: string[] = [];
107
110
  try {
111
+ process.env.MANDU_LEGACY_RUNTIME_PARTIAL_SCAN = "1";
112
+ console.warn = (...args: unknown[]) => {
113
+ warnings.push(args.map((arg) => String(arg)).join(" "));
114
+ };
108
115
  server = startServer(manifest, {
109
116
  port: 0,
110
117
  registry,
@@ -127,6 +134,102 @@ export function CommentsSection({ pledgeId, initialComments }) {
127
134
  expect(html).toContain('"initialComments"');
128
135
  expect(html).toContain("serialized comment");
129
136
  expect(html).not.toContain('data-mandu-island="pledges-$id"');
137
+ expect(warnings.some((warning) => warning.includes("MANDU_LEGACY_RUNTIME_PARTIAL_SCAN"))).toBe(true);
138
+ expect(warnings.some((warning) => warning.includes(`route="${routeId}"`))).toBe(true);
139
+ expect(warnings.some((warning) => warning.includes('file="app/pledges/[id]/page.tsx"'))).toBe(true);
140
+ } finally {
141
+ server?.stop();
142
+ console.warn = originalWarn;
143
+ if (originalFlag === undefined) {
144
+ delete process.env.MANDU_LEGACY_RUNTIME_PARTIAL_SCAN;
145
+ } else {
146
+ process.env.MANDU_LEGACY_RUNTIME_PARTIAL_SCAN = originalFlag;
147
+ }
148
+ }
149
+ });
150
+
151
+ it("uses route-level hydration by default instead of runtime-scanning sync server wrappers", async () => {
152
+ await resetTestRoot();
153
+
154
+ const routeId = "pledges-$id";
155
+ const clientModule = "src/client/widgets/comments-section/CommentsSection.client.tsx";
156
+ const clientPath = path.join(TEST_ROOT, clientModule);
157
+ await mkdir(path.dirname(clientPath), { recursive: true });
158
+ await writeFile(
159
+ clientPath,
160
+ `
161
+ import React from "react";
162
+
163
+ export function CommentsSection({ pledgeId, initialComments }) {
164
+ return React.createElement(
165
+ "section",
166
+ { "data-pledge-id": pledgeId },
167
+ initialComments.map((comment) =>
168
+ React.createElement("p", { key: comment.id }, comment.body)
169
+ )
170
+ );
171
+ }
172
+ `,
173
+ "utf-8",
174
+ );
175
+
176
+ const imported = await import(`${pathToFileURL(clientPath).href}?t=${Date.now()}`);
177
+ const CommentsSection = imported.CommentsSection as React.ComponentType<{
178
+ pledgeId: string;
179
+ initialComments: Array<{ id: string; body: string }>;
180
+ }>;
181
+
182
+ function PledgePage(): React.ReactElement {
183
+ return React.createElement(
184
+ "main",
185
+ null,
186
+ React.createElement(CommentsSection, {
187
+ pledgeId: "pledge-1",
188
+ initialComments: [{ id: "c1", body: "serialized comment" }],
189
+ }),
190
+ );
191
+ }
192
+
193
+ const registry = createServerRegistry();
194
+ registry.registerRouteComponent(routeId, PledgePage);
195
+
196
+ const manifest: RoutesManifest = {
197
+ version: 1,
198
+ routes: [
199
+ {
200
+ id: routeId,
201
+ kind: "page",
202
+ pattern: "/pledges/:id",
203
+ module: "app/pledges/[id]/page.tsx",
204
+ componentModule: "app/pledges/[id]/page.tsx",
205
+ clientModule,
206
+ hydration: { strategy: "island", priority: "visible", preload: false },
207
+ },
208
+ ],
209
+ };
210
+
211
+ let server: ManduServer | undefined;
212
+ try {
213
+ server = startServer(manifest, {
214
+ port: 0,
215
+ registry,
216
+ rootDir: TEST_ROOT,
217
+ bundleManifest: hydratedManifest(routeId),
218
+ transitions: false,
219
+ prefetch: false,
220
+ spa: false,
221
+ devtools: false,
222
+ silent: true,
223
+ });
224
+
225
+ const response = await fetch(`http://127.0.0.1:${server.server.port}/pledges/pledge-1`);
226
+ const html = await response.text();
227
+
228
+ expect(response.status).toBe(200);
229
+ expect(html).toContain('data-mandu-island="pledges-$id"');
230
+ expect(html).not.toContain('data-mandu-island="pledges-$id--0"');
231
+ expect(html).not.toContain('type="application/json" data-mandu-props="pledges-$id--0"');
232
+ expect(html).toContain("serialized comment");
130
233
  } finally {
131
234
  server?.stop();
132
235
  }
@@ -334,6 +334,8 @@ describe("runtime page render response orchestration", () => {
334
334
  src: "/.mandu/client/candidates-$id.island.js",
335
335
  priority: "immediate",
336
336
  component: PledgeAccordion,
337
+ legacyRuntimeScan: true,
338
+ sourceFile: "app/candidates/[id]/page.tsx",
337
339
  },
338
340
  });
339
341
 
@@ -389,6 +391,8 @@ describe("runtime page render response orchestration", () => {
389
391
  src: "/.mandu/client/wrapped-fallback.island.js",
390
392
  priority: "visible",
391
393
  component: ClientWidget,
394
+ legacyRuntimeScan: true,
395
+ sourceFile: "app/wrapped/page.tsx",
392
396
  },
393
397
  });
394
398
 
@@ -499,6 +503,8 @@ describe("runtime page render response orchestration", () => {
499
503
  src: "/.mandu/client/ordered.island.js",
500
504
  priority: "visible",
501
505
  component: ClientWidget,
506
+ legacyRuntimeScan: true,
507
+ sourceFile: "app/ordered/page.tsx",
502
508
  },
503
509
  });
504
510