@mandujs/core 0.54.17 → 0.54.18

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.
Files changed (35) hide show
  1. package/package.json +3 -1
  2. package/src/agent/__tests__/context.test.ts +54 -16
  3. package/src/agent/context.ts +17 -0
  4. package/src/agent/types.ts +32 -12
  5. package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
  6. package/src/bundler/__tests__/build-runner.ts +130 -17
  7. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
  8. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
  9. package/src/bundler/build.test.ts +440 -8
  10. package/src/bundler/build.ts +455 -132
  11. package/src/bundler/client-boundary-transform.ts +977 -0
  12. package/src/bundler/dev.ts +39 -112
  13. package/src/bundler/fast-refresh-preamble.ts +47 -0
  14. package/src/bundler/index.ts +3 -2
  15. package/src/bundler/manifest-schema.ts +10 -0
  16. package/src/bundler/types.ts +20 -2
  17. package/src/diagnose/__tests__/checks.test.ts +117 -17
  18. package/src/diagnose/checks.ts +184 -3
  19. package/src/diagnose/run.ts +10 -8
  20. package/src/generator/templates.test.ts +48 -5
  21. package/src/generator/templates.ts +10 -1
  22. package/src/internal/client-boundary.ts +266 -0
  23. package/src/internal/index.ts +2 -1
  24. package/src/router/client-entry.test.ts +43 -6
  25. package/src/router/client-entry.ts +33 -12
  26. package/src/router/fs-routes.test.ts +388 -1
  27. package/src/router/fs-routes.ts +16 -3
  28. package/src/router/fs-scanner.ts +166 -18
  29. package/src/router/fs-types.ts +4 -1
  30. package/src/runtime/__tests__/page-render-response.test.ts +212 -0
  31. package/src/runtime/handlers.ts +50 -26
  32. package/src/runtime/page-render-response.ts +1 -0
  33. package/src/runtime/ssr.ts +16 -5
  34. package/src/runtime/streaming-ssr.ts +119 -76
  35. package/src/spec/schema.ts +31 -5
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { stat } from "fs/promises";
10
- import { join, relative, basename, extname } from "path";
10
+ import { dirname, join, relative, basename, extname } from "path";
11
11
  import type {
12
12
  ScannedFile,
13
13
  FSScannerConfig,
@@ -33,8 +33,17 @@ import { METADATA_ROUTES } from "../routes/types";
33
33
  import type { HydrationConfig } from "../spec/schema";
34
34
  import {
35
35
  hasUseClientDirective,
36
+ resolveClientImportModulePath,
36
37
  resolveRouteLevelClientEntry,
37
38
  } from "./client-entry";
39
+ import {
40
+ assertNoClientBoundaryDiagnostics,
41
+ collectStaticImportSpecifiers,
42
+ isClientBoundaryModuleSpecifier,
43
+ transformClientBoundaries,
44
+ validateClientBoundaryExport,
45
+ validateClientBoundaryServerOnlyImports,
46
+ } from "../bundler/client-boundary-transform";
38
47
 
39
48
  const HYDRATION_STRATEGIES = new Set(["none", "island", "full", "progressive"]);
40
49
  const HYDRATION_PRIORITIES = new Set(["immediate", "visible", "idle", "interaction"]);
@@ -406,8 +415,10 @@ export class FSScanner {
406
415
  // clientModule 결정: island 파일 또는 "use client"가 있는 page 자체
407
416
  let clientModule: string | undefined;
408
417
  let clientExportName: string | undefined;
418
+ let boundaries: NonNullable<FSRouteConfig["boundaries"]> = [];
409
419
  let hydration: HydrationConfig | undefined;
410
420
  let pageFileContent: string | null = null;
421
+ let pageHasUseClient = false;
411
422
 
412
423
  if (file.type === "page") {
413
424
  try {
@@ -417,6 +428,23 @@ export class FSScanner {
417
428
  }
418
429
  if (pageFileContent) {
419
430
  hydration = parsePageHydrationConfig(pageFileContent);
431
+ pageHasUseClient = hasUseClientDirective(pageFileContent);
432
+ if (!pageHasUseClient) {
433
+ boundaries = await this.resolveClientBoundaries(
434
+ rootDir,
435
+ modulePath,
436
+ routeId,
437
+ pageFileContent,
438
+ hydration?.priority ?? "visible",
439
+ );
440
+ }
441
+ if (boundaries.length > 0 && !hydration) {
442
+ hydration = {
443
+ strategy: "island",
444
+ priority: "visible",
445
+ preload: false,
446
+ };
447
+ }
420
448
  }
421
449
  }
422
450
 
@@ -436,12 +464,11 @@ export class FSScanner {
436
464
  conflictsWith: islands[0].absolutePath,
437
465
  });
438
466
  }
439
- } else if (file.type === "page" && pageFileContent) {
440
- // page 파일 자체에서 "use client" 확인
441
- const hasUseClient = hasUseClientDirective(pageFileContent);
442
- if (hasUseClient) {
467
+ } else if (file.type === "page" && pageFileContent) {
468
+ // page 파일 자체에서 "use client" 확인
469
+ if (pageHasUseClient) {
443
470
  clientModule = modulePath;
444
- } else {
471
+ } else if (boundaries.length === 0) {
445
472
  const routeClientEntry = await resolveRouteLevelClientEntry(rootDir, modulePath, pageFileContent);
446
473
  clientModule = routeClientEntry?.modulePath;
447
474
  clientExportName = routeClientEntry?.exportName;
@@ -465,6 +492,7 @@ export class FSScanner {
465
492
  componentModule: file.type === "page" ? modulePath : undefined,
466
493
  clientModule,
467
494
  clientExportName,
495
+ boundaries: boundaries && boundaries.length > 0 ? boundaries : undefined,
468
496
  hydration,
469
497
  layoutChain,
470
498
  loadingModule,
@@ -486,7 +514,7 @@ export class FSScanner {
486
514
  return { routes, routeErrors };
487
515
  }
488
516
 
489
- private hasHydrationShellMismatchRisk(pageContent: string, _islandRelativePath: string): boolean {
517
+ private hasHydrationShellMismatchRisk(pageContent: string, _islandRelativePath: string): boolean {
490
518
  // import문에서 island 모듈의 변수명을 직접 파싱
491
519
  const importMatch = pageContent.match(
492
520
  /import\s+([A-Za-z_$][A-Za-z0-9_$]*)\s+from\s+["'][^"']*\.island(?:\.(?:tsx?|jsx?))?["']/
@@ -503,10 +531,116 @@ export class FSScanner {
503
531
  return new RegExp(
504
532
  `typeof\\s+${islandVarName}\\s*!==\\s*["']undefined["']\\s*&&\\s*null`
505
533
  ).test(pageContent);
506
- }
507
-
508
- /**
509
- * 레이아웃 체인 해결
534
+ }
535
+
536
+ private async resolveClientBoundaries(
537
+ rootDir: string,
538
+ routeModule: string,
539
+ routeId: string,
540
+ source: string,
541
+ hydrate: NonNullable<HydrationConfig["priority"]>,
542
+ ): Promise<NonNullable<FSRouteConfig["boundaries"]>> {
543
+ const modules = await this.collectRouteBoundaryModules(rootDir, routeModule, source);
544
+ const boundaries: NonNullable<FSRouteConfig["boundaries"]> = [];
545
+
546
+ for (const moduleInfo of modules) {
547
+ const result = transformClientBoundaries(moduleInfo.source, {
548
+ routeId,
549
+ fileName: moduleInfo.modulePath,
550
+ hydrate,
551
+ ordinalOffset: boundaries.length,
552
+ });
553
+ if (result.boundaries.length === 0) continue;
554
+
555
+ const diagnostics = [...result.diagnostics];
556
+ const resolved = await Promise.all(result.boundaries.map(async (boundary) => {
557
+ const modulePath = await resolveClientImportModulePath(rootDir, boundary.source.file, boundary.module);
558
+ const resolvedBoundary = {
559
+ ...boundary,
560
+ module: modulePath ?? boundary.module,
561
+ };
562
+ if (modulePath) {
563
+ const clientSource = await Bun.file(join(rootDir, modulePath)).text();
564
+ diagnostics.push(...validateClientBoundaryServerOnlyImports(clientSource, resolvedBoundary, modulePath));
565
+ const validation = validateClientBoundaryExport(clientSource, resolvedBoundary, modulePath);
566
+ if (validation.diagnostic) diagnostics.push(validation.diagnostic);
567
+ }
568
+ return resolvedBoundary;
569
+ }));
570
+ assertNoClientBoundaryDiagnostics(diagnostics);
571
+ boundaries.push(...resolved);
572
+ }
573
+
574
+ return boundaries;
575
+ }
576
+
577
+ private async collectRouteBoundaryModules(
578
+ rootDir: string,
579
+ routeModule: string,
580
+ routeSource: string,
581
+ ): Promise<Array<{ modulePath: string; source: string }>> {
582
+ const visited = new Set<string>();
583
+ const modules: Array<{ modulePath: string; source: string }> = [];
584
+
585
+ const visit = async (modulePath: string, source: string): Promise<void> => {
586
+ const normalizedModulePath = modulePath.replace(/\\/g, "/").replace(/^\.\//, "");
587
+ if (visited.has(normalizedModulePath)) return;
588
+ visited.add(normalizedModulePath);
589
+ modules.push({ modulePath: normalizedModulePath, source });
590
+
591
+ for (const specifier of collectStaticImportSpecifiers(source, normalizedModulePath)) {
592
+ if (isClientBoundaryModuleSpecifier(specifier)) continue;
593
+ const resolved = await this.resolveRouteOwnedImportModulePath(rootDir, normalizedModulePath, specifier);
594
+ if (!resolved || visited.has(resolved)) continue;
595
+
596
+ let childSource: string;
597
+ try {
598
+ childSource = await Bun.file(join(rootDir, resolved)).text();
599
+ } catch {
600
+ continue;
601
+ }
602
+ if (hasUseClientDirective(childSource)) continue;
603
+
604
+ await visit(resolved, childSource);
605
+ }
606
+ };
607
+
608
+ await visit(routeModule, routeSource);
609
+ return modules;
610
+ }
611
+
612
+ private async resolveRouteOwnedImportModulePath(
613
+ rootDir: string,
614
+ importerModule: string,
615
+ specifier: string,
616
+ ): Promise<string | null> {
617
+ const normalized = specifier.replace(/\\/g, "/");
618
+ let basePath: string | null = null;
619
+
620
+ if (normalized.startsWith("@/") || normalized.startsWith("~/")) {
621
+ basePath = join(rootDir, "src", normalized.slice(2));
622
+ } else if (normalized.startsWith("./") || normalized.startsWith("../")) {
623
+ basePath = join(rootDir, dirname(importerModule), normalized);
624
+ }
625
+
626
+ if (!basePath) return null;
627
+
628
+ for (const candidate of expandRouteImportCandidates(basePath)) {
629
+ try {
630
+ const entry = await stat(candidate);
631
+ if (entry.isFile()) {
632
+ return relative(rootDir, candidate).replace(/\\/g, "/");
633
+ }
634
+ } catch {
635
+ // Try the next candidate.
636
+ }
637
+ }
638
+
639
+ return null;
640
+ }
641
+
642
+ /**
643
+ * 레이아웃 체인 해결
510
644
  */
511
645
  private resolveLayoutChain(
512
646
  segments: ScannedFile["segments"],
@@ -755,7 +889,7 @@ export function synthesizeLocaleRoutes(
755
889
  return out;
756
890
  }
757
891
 
758
- function prefixRouteWithLocale(route: FSRouteConfig, locale: string): FSRouteConfig {
892
+ function prefixRouteWithLocale(route: FSRouteConfig, locale: string): FSRouteConfig {
759
893
  const prefixedPattern = route.pattern === "/"
760
894
  ? `/${locale}`
761
895
  : `/${locale}${route.pattern.startsWith("/") ? route.pattern : `/${route.pattern}`}`;
@@ -771,12 +905,26 @@ function prefixRouteWithLocale(route: FSRouteConfig, locale: string): FSRouteCon
771
905
  { raw: locale, type: "static" },
772
906
  ...route.segments,
773
907
  ],
774
- };
775
- }
776
-
777
- // ═══════════════════════════════════════════════════════════════════════════
778
- // Factory Function
779
- // ═══════════════════════════════════════════════════════════════════════════
908
+ };
909
+ }
910
+
911
+ function expandRouteImportCandidates(basePath: string): string[] {
912
+ if (/\.[cm]?[jt]sx?$/.test(basePath)) return [basePath];
913
+ return [
914
+ `${basePath}.tsx`,
915
+ `${basePath}.ts`,
916
+ `${basePath}.jsx`,
917
+ `${basePath}.js`,
918
+ join(basePath, "index.tsx"),
919
+ join(basePath, "index.ts"),
920
+ join(basePath, "index.jsx"),
921
+ join(basePath, "index.js"),
922
+ ];
923
+ }
924
+
925
+ // ═══════════════════════════════════════════════════════════════════════════
926
+ // Factory Function
927
+ // ═══════════════════════════════════════════════════════════════════════════
780
928
 
781
929
  /**
782
930
  * 스캐너 생성 팩토리 함수
@@ -6,7 +6,7 @@
6
6
  * @module router/fs-types
7
7
  */
8
8
 
9
- import type { RouteKind, HydrationConfig, SpecHttpMethod } from "../spec/schema";
9
+ import type { RouteKind, HydrationConfig, RouteClientBoundary, SpecHttpMethod } from "../spec/schema";
10
10
 
11
11
  // ═══════════════════════════════════════════════════════════════════════════
12
12
  // Segment Types
@@ -123,6 +123,9 @@ export interface FSRouteConfig {
123
123
  /** Named export used by the route-level client module, if not default. */
124
124
  clientExportName?: string;
125
125
 
126
+ /** Compiler-discovered client boundaries rendered by this route. */
127
+ boundaries?: RouteClientBoundary[];
128
+
126
129
  /** 적용할 레이아웃 체인 */
127
130
  layoutChain: string[];
128
131
 
@@ -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"),
@@ -188,6 +347,59 @@ describe("runtime page render response orchestration", () => {
188
347
  expect(html).not.toContain('data-mandu-island="candidates-$id"');
189
348
  });
190
349
 
350
+ it("serializes inline client props through a sync server wrapper fallback", async () => {
351
+ function ClientWidget({ label }: { label: string }) {
352
+ return React.createElement("button", null, label);
353
+ }
354
+
355
+ function ServerWrapper({ label }: { label: string }) {
356
+ return React.createElement("section", null, React.createElement(ClientWidget, { label }));
357
+ }
358
+
359
+ function WrappedPage() {
360
+ return React.createElement("main", null, React.createElement(ServerWrapper, { label: "wrapped" }));
361
+ }
362
+
363
+ const response = await renderPageResponse({
364
+ app: React.createElement(WrappedPage),
365
+ useStreaming: false,
366
+ title: "Wrapped",
367
+ headTags: "",
368
+ isDev: false,
369
+ routeId: "wrapped-fallback",
370
+ routePattern: "/wrapped",
371
+ hydration: { strategy: "island", priority: "visible", preload: false },
372
+ bundleManifest: {
373
+ ...HYDRATED_MANIFEST,
374
+ bundles: {
375
+ "wrapped-fallback": {
376
+ js: "/.mandu/client/wrapped-fallback.island.js",
377
+ dependencies: ["_runtime", "_react"],
378
+ priority: "visible",
379
+ },
380
+ },
381
+ },
382
+ loaderData: undefined,
383
+ transitions: false,
384
+ prefetch: false,
385
+ spa: false,
386
+ devtools: false,
387
+ inlineClientHydration: {
388
+ routeId: "wrapped-fallback",
389
+ src: "/.mandu/client/wrapped-fallback.island.js",
390
+ priority: "visible",
391
+ component: ClientWidget,
392
+ },
393
+ });
394
+
395
+ const html = await response.text();
396
+ expect(html).toContain('data-mandu-island="wrapped-fallback--0"');
397
+ expect(html).toContain('data-mandu-src="/.mandu/client/wrapped-fallback.island.js"');
398
+ expect(html).toContain('data-mandu-props="wrapped-fallback--0"');
399
+ expect(html).toContain('"label":"wrapped"');
400
+ expect(html).not.toContain('data-mandu-island="wrapped-fallback"');
401
+ });
402
+
191
403
  it("does not invoke sync function components while looking for inline client targets", async () => {
192
404
  function ClientWidget({ label }: { label: string }) {
193
405
  return React.createElement("button", null, label);
@@ -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: (modulePath: string, opts?: { changedFile?: string }) => Promise<unknown>;
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 importOpts: { changedFile?: string } | undefined =
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, importOpts);
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, importOpts)) as RouteModule;
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, importOpts);
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, importOpts)) as Record<string, unknown>;
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, importOpts)) as Parameters<typeof registerPageLoader>[1]);
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, importOpts);
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: (modulePath: string, opts?: { changedFile?: string }) => Promise<unknown>,
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",
@@ -214,6 +214,7 @@ async function renderStreamingPageResponse(
214
214
  criticalData: options.loaderData as Record<string, unknown> | undefined,
215
215
  enableClientRouter: true,
216
216
  cssPath: options.cssPath,
217
+ islandPreWrapped: !!options.islandPreWrapped,
217
218
  transitions: options.transitions,
218
219
  prefetch: options.prefetch,
219
220
  spa: options.spa,