@reckona/mreact-router 0.0.138 → 0.0.139

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 (76) hide show
  1. package/README.md +1 -1
  2. package/dist/actions.d.ts +1 -0
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +32 -12
  5. package/dist/actions.js.map +1 -1
  6. package/dist/adapters/static.js +10 -4
  7. package/dist/adapters/static.js.map +1 -1
  8. package/dist/build.d.ts.map +1 -1
  9. package/dist/build.js +28 -21
  10. package/dist/build.js.map +1 -1
  11. package/dist/cache.d.ts +2 -0
  12. package/dist/cache.d.ts.map +1 -1
  13. package/dist/cache.js +23 -1
  14. package/dist/cache.js.map +1 -1
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +49 -12
  17. package/dist/client.js.map +1 -1
  18. package/dist/dev-server.d.ts +1 -0
  19. package/dist/dev-server.d.ts.map +1 -1
  20. package/dist/dev-server.js +6 -4
  21. package/dist/dev-server.js.map +1 -1
  22. package/dist/link.js +17 -3
  23. package/dist/link.js.map +1 -1
  24. package/dist/middleware.d.ts.map +1 -1
  25. package/dist/middleware.js +10 -7
  26. package/dist/middleware.js.map +1 -1
  27. package/dist/multipart.d.ts +5 -0
  28. package/dist/multipart.d.ts.map +1 -1
  29. package/dist/multipart.js +24 -1
  30. package/dist/multipart.js.map +1 -1
  31. package/dist/native-route-matcher.d.ts +9 -0
  32. package/dist/native-route-matcher.d.ts.map +1 -1
  33. package/dist/native-route-matcher.js +23 -11
  34. package/dist/native-route-matcher.js.map +1 -1
  35. package/dist/navigation.d.ts.map +1 -1
  36. package/dist/navigation.js +8 -1
  37. package/dist/navigation.js.map +1 -1
  38. package/dist/render.d.ts +5 -1
  39. package/dist/render.d.ts.map +1 -1
  40. package/dist/render.js +239 -95
  41. package/dist/render.js.map +1 -1
  42. package/dist/route-source.d.ts +3 -1
  43. package/dist/route-source.d.ts.map +1 -1
  44. package/dist/route-source.js +13 -11
  45. package/dist/route-source.js.map +1 -1
  46. package/dist/route-styles.d.ts +4 -0
  47. package/dist/route-styles.d.ts.map +1 -1
  48. package/dist/route-styles.js +15 -3
  49. package/dist/route-styles.js.map +1 -1
  50. package/dist/serve.d.ts.map +1 -1
  51. package/dist/serve.js +39 -9
  52. package/dist/serve.js.map +1 -1
  53. package/dist/server-action-inference.d.ts.map +1 -1
  54. package/dist/server-action-inference.js +57 -0
  55. package/dist/server-action-inference.js.map +1 -1
  56. package/dist/vite.d.ts.map +1 -1
  57. package/dist/vite.js +27 -11
  58. package/dist/vite.js.map +1 -1
  59. package/package.json +11 -11
  60. package/src/actions.ts +44 -10
  61. package/src/adapters/static.ts +11 -4
  62. package/src/build.ts +28 -17
  63. package/src/cache.ts +33 -1
  64. package/src/client.ts +49 -12
  65. package/src/dev-server.ts +11 -4
  66. package/src/link.ts +24 -3
  67. package/src/middleware.ts +12 -7
  68. package/src/multipart.ts +32 -1
  69. package/src/native-route-matcher.ts +34 -13
  70. package/src/navigation.ts +11 -2
  71. package/src/render.ts +321 -113
  72. package/src/route-source.ts +14 -10
  73. package/src/route-styles.ts +28 -3
  74. package/src/serve.ts +68 -15
  75. package/src/server-action-inference.ts +86 -0
  76. package/src/vite.ts +39 -12
package/src/render.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  createQueryClient,
13
13
  dehydrate,
14
14
  __MREACT_QUERY_STATE_SCRIPT_ID,
15
+ installQueryAsyncStorage,
15
16
  runWithQueryClient,
16
17
  type DehydratedQueryClient,
17
18
  type QueryClient,
@@ -40,7 +41,7 @@ import {
40
41
  import { assetPath } from "./assets.js";
41
42
  import { escapeHtmlAttribute } from "@reckona/mreact-shared/html-escape";
42
43
  import { matchRoute, scanAppRoutes } from "./routes.js";
43
- import type { AppRoute, RouteMatcher } from "./routes.js";
44
+ import type { AppRoute, MatchedRoute, RouteMatcher } from "./routes.js";
44
45
  import { appFileConventionContentType } from "./file-conventions.js";
45
46
  import {
46
47
  type AppRouterServerActionOptions,
@@ -144,6 +145,8 @@ const nativeEscapeTransform = {
144
145
  const authRuntimeStateKey = "__mreactAuthRuntimeState";
145
146
  const authSessionScriptId = "__mreact_auth_session";
146
147
 
148
+ installQueryAsyncStorage(new AsyncLocalStorage<QueryClient>());
149
+
147
150
  interface AuthRuntimeRequestState {
148
151
  claims?: unknown;
149
152
  }
@@ -159,6 +162,7 @@ interface RenderTiming {
159
162
  export interface RenderAppRequestOptions {
160
163
  appDir: string;
161
164
  assetBaseUrl?: string | undefined;
165
+ clientRouteInferenceCache?: ClientRouteInferenceCache | undefined;
162
166
  clientScripts?: ReadonlyMap<string, string>;
163
167
  clientStylesByFile?: ReadonlyMap<string, readonly string[]>;
164
168
  clientStyles?: ReadonlyMap<string, readonly string[]>;
@@ -171,6 +175,8 @@ export interface RenderAppRequestOptions {
171
175
  onResponse?: AppRouterResponseHook | undefined;
172
176
  queryClient?: QueryClient | undefined;
173
177
  request: Request;
178
+ requestUrl?: URL | undefined;
179
+ matchedRoute?: MatchedRoute | undefined;
174
180
  routeCache?: AppRouterCache | undefined;
175
181
  routeMatcher?: RouteMatcher | undefined;
176
182
  routes?: readonly AppRoute[] | undefined;
@@ -595,7 +601,7 @@ export async function renderAppRequest(options: RenderAppRequestOptions): Promis
595
601
  }
596
602
 
597
603
  const trace = traceContextFromRequest(options.request);
598
- const url = new URL(options.request.url);
604
+ const url = options.requestUrl ?? new URL(options.request.url);
599
605
  const requestEvent = {
600
606
  method: options.request.method,
601
607
  path: url.pathname,
@@ -603,7 +609,7 @@ export async function renderAppRequest(options: RenderAppRequestOptions): Promis
603
609
  ...(trace === undefined ? {} : { trace }),
604
610
  };
605
611
  invokeRouterInstrumentation(options.instrumentation?.onRequestStart, requestEvent);
606
- const response = await renderAppRequestInternal(options);
612
+ const response = await renderAppRequestInternal({ ...options, requestUrl: url });
607
613
  invokeRouterInstrumentation(options.instrumentation?.onRequestEnd, {
608
614
  ...requestEvent,
609
615
  status: response.status,
@@ -738,13 +744,15 @@ export async function resolveAppRouterMiddleware(options: {
738
744
 
739
745
  async function renderAppRequestInternal(options: RenderAppRequestOptions): Promise<Response> {
740
746
  const timing = createRenderTiming(options.logger);
741
- const clientRouteInferenceCache = createClientRouteInferenceCache();
747
+ const clientRouteInferenceCache =
748
+ options.clientRouteInferenceCache ?? createClientRouteInferenceCache();
742
749
  let phaseStartedAt = renderTimingPhaseStartedAt(timing);
743
750
  const routes = options.routes ?? (await scanAppRoutes({ appDir: options.appDir }));
744
751
  finishRenderTimingPhase(timing, phaseStartedAt, "routeScanMs");
745
- const url = new URL(options.request.url);
752
+ const url = options.requestUrl ?? new URL(options.request.url);
746
753
  phaseStartedAt = renderTimingPhaseStartedAt(timing);
747
- const matched = options.routeMatcher?.match(url.pathname) ?? matchRoute(routes, url.pathname);
754
+ const matched =
755
+ options.matchedRoute ?? options.routeMatcher?.match(url.pathname) ?? matchRoute(routes, url.pathname);
748
756
  finishRenderTimingPhase(timing, phaseStartedAt, "routeMatchMs");
749
757
  const hasMiddleware =
750
758
  options.skipMiddleware === true
@@ -790,7 +798,9 @@ async function renderAppRequestInternal(options: RenderAppRequestOptions): Promi
790
798
  if (middlewareResult.request !== options.request) {
791
799
  return renderAppRequestInternal({
792
800
  ...options,
801
+ matchedRoute: undefined,
793
802
  request: middlewareResult.request,
803
+ requestUrl: new URL(middlewareResult.request.url),
794
804
  skipMiddleware: true,
795
805
  });
796
806
  }
@@ -943,6 +953,7 @@ async function renderAppRequestInternal(options: RenderAppRequestOptions): Promi
943
953
  : await cachedRouteResponse({
944
954
  cache: options.routeCache,
945
955
  key: cacheKey,
956
+ request: options.request,
946
957
  });
947
958
  finishRenderTimingPhase(timing, phaseStartedAt, "routeCacheMs");
948
959
 
@@ -1146,6 +1157,7 @@ async function renderAppRequestInternal(options: RenderAppRequestOptions): Promi
1146
1157
  serverModules: options.serverModules,
1147
1158
  serverModuleCacheVersion: options.serverModuleCacheVersion,
1148
1159
  serverSourceFiles: options.serverSourceFiles,
1160
+ define: options.define,
1149
1161
  vitePlugins: options.vitePlugins,
1150
1162
  });
1151
1163
  html = injectHeadMetadata(html, metadata);
@@ -1454,6 +1466,7 @@ async function renderAppRequestInternal(options: RenderAppRequestOptions): Promi
1454
1466
  cache: options.routeCache,
1455
1467
  path: matched.route.path,
1456
1468
  policy: effectiveCachePolicy,
1469
+ request: options.request,
1457
1470
  response,
1458
1471
  });
1459
1472
  finishRenderTimingPhase(timing, phaseStartedAt, "responseBuildMs");
@@ -1518,7 +1531,7 @@ async function renderAppRequestInternal(options: RenderAppRequestOptions): Promi
1518
1531
  vitePlugins: options.vitePlugins,
1519
1532
  navigation: recoveryRoute,
1520
1533
  status: 500,
1521
- textFallback: error instanceof Error ? error.message : String(error),
1534
+ textFallback: "Internal Server Error",
1522
1535
  });
1523
1536
  emitRenderTiming(options, timing, response.status);
1524
1537
  return response;
@@ -2633,10 +2646,9 @@ function routeSourceFilesForAnalysis(options: {
2633
2646
  code: string;
2634
2647
  filename: string;
2635
2648
  serverSourceFiles?: ReadonlyMap<string, string> | undefined;
2636
- }): Record<string, string> {
2637
- return options.serverSourceFiles === undefined
2638
- ? { [options.filename]: options.code }
2639
- : { ...Object.fromEntries(options.serverSourceFiles), [options.filename]: options.code };
2649
+ }): (file: string) => string | undefined {
2650
+ return (file) =>
2651
+ file === options.filename ? options.code : options.serverSourceFiles?.get(file);
2640
2652
  }
2641
2653
 
2642
2654
  async function runServerModule(
@@ -3445,9 +3457,8 @@ async function applyLayouts(options: {
3445
3457
  let html = options.html;
3446
3458
  let shellHasOutOfOrderBoundary = false;
3447
3459
  const slotContext = createSlotRenderContext(options.slots);
3448
-
3449
- for (const shell of layoutFiles.reverse()) {
3450
- const rendered = await renderShellPrefixSuffix(
3460
+ const renderShell = (shell: ShellFile) =>
3461
+ renderShellPrefixSuffix(
3451
3462
  options.appDir,
3452
3463
  shell,
3453
3464
  options.props,
@@ -3461,6 +3472,18 @@ async function applyLayouts(options: {
3461
3472
  options.vitePlugins,
3462
3473
  options.importPolicy,
3463
3474
  );
3475
+ const renderedShells =
3476
+ Object.keys(slotContext.namedSlots).length === 0
3477
+ ? await Promise.all(layoutFiles.map(renderShell))
3478
+ : undefined;
3479
+
3480
+ for (let index = layoutFiles.length - 1; index >= 0; index -= 1) {
3481
+ const shell = layoutFiles[index];
3482
+ if (shell === undefined) {
3483
+ continue;
3484
+ }
3485
+
3486
+ const rendered = renderedShells?.[index] ?? (await renderShell(shell));
3464
3487
  shellHasOutOfOrderBoundary ||= rendered.hasOutOfOrderBoundary;
3465
3488
  html = `${rendered.prefix}${html}${rendered.suffix}`;
3466
3489
  }
@@ -3497,12 +3520,9 @@ async function layoutShellsForPage(
3497
3520
  importPolicy?: AppRouterImportPolicy | undefined,
3498
3521
  ): Promise<RenderedShell[]> {
3499
3522
  const layoutFiles = await shellFilesForPage(appDir, pageFile, serverModuleCacheVersion);
3500
- const shells: RenderedShell[] = [];
3501
3523
  const slotContext = createSlotRenderContext(slots);
3502
-
3503
- for (const shell of layoutFiles) {
3504
- shells.push(
3505
- await renderShellPrefixSuffix(
3524
+ const renderShell = (shell: ShellFile) =>
3525
+ renderShellPrefixSuffix(
3506
3526
  appDir,
3507
3527
  shell,
3508
3528
  props,
@@ -3515,8 +3535,16 @@ async function layoutShellsForPage(
3515
3535
  define,
3516
3536
  vitePlugins,
3517
3537
  importPolicy,
3518
- ),
3519
- );
3538
+ );
3539
+ const shells =
3540
+ Object.keys(slotContext.namedSlots).length === 0
3541
+ ? await Promise.all(layoutFiles.map(renderShell))
3542
+ : [];
3543
+
3544
+ if (shells.length === 0 && layoutFiles.length > 0) {
3545
+ for (const shell of layoutFiles) {
3546
+ shells.push(await renderShell(shell));
3547
+ }
3520
3548
  }
3521
3549
 
3522
3550
  warnUnconsumedRouteSlots({
@@ -3559,18 +3587,94 @@ async function renderShellPrefixSuffix(
3559
3587
  }
3560
3588
  }
3561
3589
 
3590
+ const staticEntry =
3591
+ cacheKey === undefined ? undefined : shellStaticRenderCache.get(cacheKey);
3592
+ const loadedStaticEntry =
3593
+ staticEntry ??
3594
+ (await loadShellStaticRenderEntry({
3595
+ cacheKey,
3596
+ clientRouteInferenceCache,
3597
+ define,
3598
+ importPolicy,
3599
+ serverModuleCacheVersion,
3600
+ serverModules,
3601
+ serverSourceFiles,
3602
+ shell,
3603
+ timing,
3604
+ vitePlugins,
3605
+ }));
3606
+
3562
3607
  let phaseStartedAt = renderTimingPhaseStartedAt(timing);
3563
- const code = await readServerSourceFile(shell.file, serverModuleCacheVersion, serverSourceFiles);
3564
- addRenderTimingPhaseDuration(timing, phaseStartedAt, "layoutSourceReadMs");
3608
+ const layoutHtml = loadedStaticEntry.shellUsesAwait
3609
+ ? await renderShellStreamComponent(loadedStaticEntry.component as StreamComponent, props)
3610
+ : await (loadedStaticEntry.component as ServerComponent)(props);
3611
+ addRenderTimingPhaseDuration(timing, phaseStartedAt, "layoutComponentRenderMs");
3565
3612
  phaseStartedAt = renderTimingPhaseStartedAt(timing);
3613
+ const rendered = {
3614
+ ...splitLayoutSlot(markShellBoundary(layoutHtml, shell), slotContext),
3615
+ hasOutOfOrderBoundary: loadedStaticEntry.hasOutOfOrderBoundary,
3616
+ };
3617
+ addRenderTimingPhaseDuration(timing, phaseStartedAt, "layoutSlotSplitMs");
3618
+ const shellCacheKey = loadedStaticEntry.shellUsesAwait ? undefined : cacheKey;
3619
+ const cached =
3620
+ shellCacheKey !== undefined
3621
+ ? readRouterRuntimeCacheEntry(renderedShellCache, shellCacheKey, renderedShellCacheCounters)
3622
+ : undefined;
3623
+
3624
+ // Detect purity: a zero-arg component cannot depend on props. The
3625
+ // markShellBoundary + splitLayoutSlot output is then constant for
3626
+ // the (appDir, shellFile, version) tuple. We only set the cache
3627
+ // entry on the first request that observes the function arity; on
3628
+ // an "impure" tag we never overwrite it.
3629
+ if (shellCacheKey !== undefined && cached !== "impure") {
3630
+ if (loadedStaticEntry.component.length === 0) {
3631
+ if (renderedShellCache.size >= MAX_RENDERED_SHELL_CACHE_ENTRIES) {
3632
+ const oldestKey = renderedShellCache.keys().next().value;
3633
+ if (oldestKey !== undefined) {
3634
+ renderedShellCache.delete(oldestKey);
3635
+ renderedShellCacheCounters.evictions += 1;
3636
+ }
3637
+ }
3638
+ renderedShellCache.set(shellCacheKey, rendered);
3639
+ } else {
3640
+ // Impure — stamp the cache so subsequent lookups short-circuit
3641
+ // without re-checking arity. We still run the per-request
3642
+ // render path above so the props are honoured.
3643
+ renderedShellCache.set(shellCacheKey, "impure");
3644
+ }
3645
+ }
3646
+
3647
+ return rendered;
3648
+ }
3649
+
3650
+ async function loadShellStaticRenderEntry(options: {
3651
+ cacheKey: string | undefined;
3652
+ clientRouteInferenceCache?: ClientRouteInferenceCache | undefined;
3653
+ define?: UserConfig["define"] | undefined;
3654
+ importPolicy?: AppRouterImportPolicy | undefined;
3655
+ serverModuleCacheVersion: string | undefined;
3656
+ serverModules: ReadonlyMap<string, BuiltServerModuleArtifact> | undefined;
3657
+ serverSourceFiles: ReadonlyMap<string, string> | undefined;
3658
+ shell: ShellFile;
3659
+ timing?: RenderTiming | undefined;
3660
+ vitePlugins?: readonly PluginOption[] | undefined;
3661
+ }): Promise<ShellStaticRenderEntry> {
3662
+ let phaseStartedAt = renderTimingPhaseStartedAt(options.timing);
3663
+ const code = await readServerSourceFile(
3664
+ options.shell.file,
3665
+ options.serverModuleCacheVersion,
3666
+ options.serverSourceFiles,
3667
+ );
3668
+ addRenderTimingPhaseDuration(options.timing, phaseStartedAt, "layoutSourceReadMs");
3669
+ phaseStartedAt = renderTimingPhaseStartedAt(options.timing);
3566
3670
  const shellUsesAwait = await mayRenderOutOfOrderBoundaryDeep({
3567
3671
  code,
3568
- filename: shell.file,
3569
- serverModuleCacheVersion,
3570
- serverSourceFiles,
3672
+ filename: options.shell.file,
3673
+ serverModuleCacheVersion: options.serverModuleCacheVersion,
3674
+ serverSourceFiles: options.serverSourceFiles,
3571
3675
  });
3572
3676
  const serverOutput: ServerOutputMode = shellUsesAwait ? "stream" : "string";
3573
- const artifact = serverModules?.get(shell.file)?.[serverOutput];
3677
+ const artifact = options.serverModules?.get(options.shell.file)?.[serverOutput];
3574
3678
  const clientInference =
3575
3679
  artifact !== undefined && artifact.sourceHash === memoizedHashText(code)
3576
3680
  ? {
@@ -3580,10 +3684,10 @@ async function renderShellPrefixSuffix(
3580
3684
  diagnostics: [],
3581
3685
  }
3582
3686
  : await inferClientRouteModule({
3583
- cache: clientRouteInferenceCache,
3584
- code: stripRouteClientOnlyExports(code, shell.file),
3585
- filename: shell.file,
3586
- vitePlugins,
3687
+ cache: options.clientRouteInferenceCache,
3688
+ code: stripRouteClientOnlyExports(code, options.shell.file),
3689
+ filename: options.shell.file,
3690
+ vitePlugins: options.vitePlugins,
3587
3691
  });
3588
3692
  for (const diagnostic of clientInference.diagnostics) {
3589
3693
  console.warn(formatClientRouteInferenceDiagnostic(diagnostic));
@@ -3592,81 +3696,57 @@ async function renderShellPrefixSuffix(
3592
3696
  code,
3593
3697
  clientBoundaryImports: clientInference.clientBoundaryImports,
3594
3698
  clientBoundaryFallbackImports: clientInference.clientBoundaryFallbackImports,
3595
- filename: shell.file,
3596
- serverModules,
3699
+ filename: options.shell.file,
3700
+ serverModules: options.serverModules,
3597
3701
  serverOutput,
3598
3702
  });
3599
- addRenderTimingPhaseDuration(timing, phaseStartedAt, "layoutTransformMs");
3703
+ addRenderTimingPhaseDuration(options.timing, phaseStartedAt, "layoutTransformMs");
3600
3704
  const fatalDiagnostics = fatalServerDiagnostics(output.diagnostics);
3601
3705
 
3602
3706
  if (fatalDiagnostics.length > 0) {
3603
- throw new Error(formatServerDiagnostics(shell.file, fatalDiagnostics));
3707
+ throw new Error(formatServerDiagnostics(options.shell.file, fatalDiagnostics));
3604
3708
  }
3605
3709
 
3606
- phaseStartedAt = renderTimingPhaseStartedAt(timing);
3710
+ phaseStartedAt = renderTimingPhaseStartedAt(options.timing);
3607
3711
  const component = shellUsesAwait
3608
3712
  ? selectStreamComponent(
3609
3713
  await loadServerStreamModule(
3610
3714
  output.code,
3611
- shell.file,
3612
- serverModules,
3613
- serverModuleCacheVersion,
3614
- define,
3615
- vitePlugins,
3616
- importPolicy,
3715
+ options.shell.file,
3716
+ options.serverModules,
3717
+ options.serverModuleCacheVersion,
3718
+ options.define,
3719
+ options.vitePlugins,
3720
+ options.importPolicy,
3617
3721
  ),
3618
3722
  )
3619
3723
  : await loadServerComponent(
3620
3724
  output.code,
3621
- shell.file,
3622
- serverModules,
3623
- serverModuleCacheVersion,
3624
- define,
3625
- vitePlugins,
3626
- importPolicy,
3725
+ options.shell.file,
3726
+ options.serverModules,
3727
+ options.serverModuleCacheVersion,
3728
+ options.define,
3729
+ options.vitePlugins,
3730
+ options.importPolicy,
3627
3731
  );
3628
- addRenderTimingPhaseDuration(timing, phaseStartedAt, "layoutModuleLoadMs");
3629
- phaseStartedAt = renderTimingPhaseStartedAt(timing);
3630
- const layoutHtml = shellUsesAwait
3631
- ? await renderShellStreamComponent(component as StreamComponent, props)
3632
- : await (component as ServerComponent)(props);
3633
- addRenderTimingPhaseDuration(timing, phaseStartedAt, "layoutComponentRenderMs");
3634
- phaseStartedAt = renderTimingPhaseStartedAt(timing);
3635
- const rendered = {
3636
- ...splitLayoutSlot(markShellBoundary(layoutHtml, shell), slotContext),
3732
+ addRenderTimingPhaseDuration(options.timing, phaseStartedAt, "layoutModuleLoadMs");
3733
+ const entry = {
3734
+ component,
3637
3735
  hasOutOfOrderBoundary: hasOutOfOrderBoundary(output.code),
3736
+ shellUsesAwait,
3638
3737
  };
3639
- addRenderTimingPhaseDuration(timing, phaseStartedAt, "layoutSlotSplitMs");
3640
- const shellCacheKey = shellUsesAwait ? undefined : cacheKey;
3641
- const cached =
3642
- shellCacheKey !== undefined
3643
- ? readRouterRuntimeCacheEntry(renderedShellCache, shellCacheKey, renderedShellCacheCounters)
3644
- : undefined;
3645
3738
 
3646
- // Detect purity: a zero-arg component cannot depend on props. The
3647
- // markShellBoundary + splitLayoutSlot output is then constant for
3648
- // the (appDir, shellFile, version) tuple. We only set the cache
3649
- // entry on the first request that observes the function arity; on
3650
- // an "impure" tag we never overwrite it.
3651
- if (shellCacheKey !== undefined && cached !== "impure") {
3652
- if (component.length === 0) {
3653
- if (renderedShellCache.size >= MAX_RENDERED_SHELL_CACHE_ENTRIES) {
3654
- const oldestKey = renderedShellCache.keys().next().value;
3655
- if (oldestKey !== undefined) {
3656
- renderedShellCache.delete(oldestKey);
3657
- renderedShellCacheCounters.evictions += 1;
3658
- }
3739
+ if (options.cacheKey !== undefined) {
3740
+ if (shellStaticRenderCache.size >= MAX_SHELL_STATIC_RENDER_CACHE_ENTRIES) {
3741
+ const oldestKey = shellStaticRenderCache.keys().next().value;
3742
+ if (oldestKey !== undefined) {
3743
+ shellStaticRenderCache.delete(oldestKey);
3659
3744
  }
3660
- renderedShellCache.set(shellCacheKey, rendered);
3661
- } else {
3662
- // Impure — stamp the cache so subsequent lookups short-circuit
3663
- // without re-checking arity. We still run the per-request
3664
- // render path above so the props are honoured.
3665
- renderedShellCache.set(shellCacheKey, "impure");
3666
3745
  }
3746
+ shellStaticRenderCache.set(options.cacheKey, entry);
3667
3747
  }
3668
3748
 
3669
- return rendered;
3749
+ return entry;
3670
3750
  }
3671
3751
 
3672
3752
  async function renderShellStreamComponent(
@@ -3691,14 +3771,31 @@ async function renderShellStreamComponent(
3691
3771
  // next request.
3692
3772
  const shellFilesCache = new Map<string, ShellFile[]>();
3693
3773
  const MAX_SHELL_FILES_CACHE_ENTRIES = 1024;
3774
+ const devShellFilesCache = new Map<string, { directoryStats: readonly FileStat[]; files: ShellFile[] }>();
3775
+ const shellStaticRenderCache = new Map<string, ShellStaticRenderEntry>();
3776
+ const MAX_SHELL_STATIC_RENDER_CACHE_ENTRIES = 1024;
3694
3777
  const routeMiddlewareControlCache = new Map<string, Promise<RouteMiddlewareControl | undefined>>();
3695
3778
  const MAX_ROUTE_MIDDLEWARE_CONTROL_CACHE_ENTRIES = 1024;
3696
3779
  const appMiddlewareFileCache = new Map<string, Promise<boolean>>();
3697
3780
  const MAX_APP_MIDDLEWARE_FILE_CACHE_ENTRIES = 1024;
3781
+ const devAppMiddlewareFileCache = new Map<string, { directoryStat: FileStat; hasMiddleware: boolean }>();
3698
3782
  const routeMiddlewareControlSourceCache = new Map<
3699
3783
  string,
3700
3784
  { control: RouteMiddlewareControl | undefined; mtimeMs: number }
3701
3785
  >();
3786
+ const devServerSourceFileCache = new Map<string, { mtimeMs: number; size: number; source: string }>();
3787
+ const MAX_DEV_SERVER_SOURCE_FILE_CACHE_ENTRIES = 2048;
3788
+
3789
+ interface FileStat {
3790
+ mtimeMs: number;
3791
+ size: number;
3792
+ }
3793
+
3794
+ interface ShellStaticRenderEntry {
3795
+ component: ServerComponent | StreamComponent;
3796
+ hasOutOfOrderBoundary: boolean;
3797
+ shellUsesAwait: boolean;
3798
+ }
3702
3799
 
3703
3800
  async function shellFilesForPage(
3704
3801
  appDir: string,
@@ -3716,6 +3813,36 @@ async function shellFilesForPage(
3716
3813
  }
3717
3814
  }
3718
3815
 
3816
+ const devCacheKey = cacheKey === undefined ? `${appDir}\0${pageFile}` : undefined;
3817
+ if (devCacheKey !== undefined) {
3818
+ const directoryStats = await routeShellDirectoryStats(appDir, pageFile);
3819
+ const cached = devShellFilesCache.get(devCacheKey);
3820
+ if (
3821
+ cached !== undefined &&
3822
+ sameFileStats(cached.directoryStats, directoryStats)
3823
+ ) {
3824
+ return cached.files;
3825
+ }
3826
+
3827
+ const files = await shellFilesForPageUncached(appDir, pageFile);
3828
+ devShellFilesCache.set(devCacheKey, { directoryStats, files });
3829
+ return files;
3830
+ }
3831
+
3832
+ const files = await shellFilesForPageUncached(appDir, pageFile);
3833
+ if (cacheKey !== undefined) {
3834
+ if (shellFilesCache.size >= MAX_SHELL_FILES_CACHE_ENTRIES) {
3835
+ const oldestKey = shellFilesCache.keys().next().value;
3836
+ if (oldestKey !== undefined) {
3837
+ shellFilesCache.delete(oldestKey);
3838
+ }
3839
+ }
3840
+ shellFilesCache.set(cacheKey, files);
3841
+ }
3842
+ return files;
3843
+ }
3844
+
3845
+ async function shellFilesForPageUncached(appDir: string, pageFile: string): Promise<ShellFile[]> {
3719
3846
  const shells = await existingRouteShellCandidates(appDir, pageFile, async (file) => {
3720
3847
  try {
3721
3848
  await access(file);
@@ -3730,15 +3857,6 @@ async function shellFilesForPage(
3730
3857
  kind: shell.kind,
3731
3858
  }));
3732
3859
 
3733
- if (cacheKey !== undefined) {
3734
- if (shellFilesCache.size >= MAX_SHELL_FILES_CACHE_ENTRIES) {
3735
- const oldestKey = shellFilesCache.keys().next().value;
3736
- if (oldestKey !== undefined) {
3737
- shellFilesCache.delete(oldestKey);
3738
- }
3739
- }
3740
- shellFilesCache.set(cacheKey, files);
3741
- }
3742
3860
  return files;
3743
3861
  }
3744
3862
 
@@ -3779,22 +3897,33 @@ async function hasAppMiddleware(options: {
3779
3897
  }
3780
3898
  }
3781
3899
 
3782
- const loaded = hasAppMiddlewareUncached(middlewareFiles).catch((error) => {
3783
- if (cacheKey !== undefined) {
3784
- appMiddlewareFileCache.delete(cacheKey);
3900
+ if (cacheKey === undefined) {
3901
+ const directoryStat = await fileStat(options.appDir);
3902
+ const cached = devAppMiddlewareFileCache.get(options.appDir);
3903
+ if (
3904
+ cached !== undefined &&
3905
+ sameFileStat(cached.directoryStat, directoryStat)
3906
+ ) {
3907
+ return cached.hasMiddleware;
3785
3908
  }
3909
+
3910
+ const hasMiddleware = await hasAppMiddlewareUncached(middlewareFiles);
3911
+ devAppMiddlewareFileCache.set(options.appDir, { directoryStat, hasMiddleware });
3912
+ return hasMiddleware;
3913
+ }
3914
+
3915
+ const loaded = hasAppMiddlewareUncached(middlewareFiles).catch((error) => {
3916
+ appMiddlewareFileCache.delete(cacheKey);
3786
3917
  throw error;
3787
3918
  });
3788
3919
 
3789
- if (cacheKey !== undefined) {
3790
- if (appMiddlewareFileCache.size >= MAX_APP_MIDDLEWARE_FILE_CACHE_ENTRIES) {
3791
- const oldestKey = appMiddlewareFileCache.keys().next().value;
3792
- if (oldestKey !== undefined) {
3793
- appMiddlewareFileCache.delete(oldestKey);
3794
- }
3920
+ if (appMiddlewareFileCache.size >= MAX_APP_MIDDLEWARE_FILE_CACHE_ENTRIES) {
3921
+ const oldestKey = appMiddlewareFileCache.keys().next().value;
3922
+ if (oldestKey !== undefined) {
3923
+ appMiddlewareFileCache.delete(oldestKey);
3795
3924
  }
3796
- appMiddlewareFileCache.set(cacheKey, loaded);
3797
3925
  }
3926
+ appMiddlewareFileCache.set(cacheKey, loaded);
3798
3927
 
3799
3928
  return loaded;
3800
3929
  }
@@ -3812,6 +3941,57 @@ async function hasAppMiddlewareUncached(files: readonly string[]): Promise<boole
3812
3941
  return false;
3813
3942
  }
3814
3943
 
3944
+ async function routeShellDirectoryStats(appDir: string, pageFile: string): Promise<FileStat[]> {
3945
+ const directories: string[] = [];
3946
+ let current = dirname(pageFile);
3947
+ const resolvedAppDir = resolve(appDir);
3948
+
3949
+ while (true) {
3950
+ directories.push(current);
3951
+ if (resolve(current) === resolvedAppDir) {
3952
+ break;
3953
+ }
3954
+
3955
+ const parent = dirname(current);
3956
+ if (parent === current) {
3957
+ break;
3958
+ }
3959
+ current = parent;
3960
+ }
3961
+
3962
+ return await Promise.all(directories.map((directory) => fileStatOrMissing(directory)));
3963
+ }
3964
+
3965
+ async function fileStat(file: string): Promise<FileStat> {
3966
+ const stats = await stat(file);
3967
+ return { mtimeMs: stats.mtimeMs, size: stats.size };
3968
+ }
3969
+
3970
+ async function fileStatOrMissing(file: string): Promise<FileStat> {
3971
+ try {
3972
+ return await fileStat(file);
3973
+ } catch (error) {
3974
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
3975
+ return { mtimeMs: -1, size: -1 };
3976
+ }
3977
+ throw error;
3978
+ }
3979
+ }
3980
+
3981
+ function sameFileStats(left: readonly FileStat[], right: readonly FileStat[]): boolean {
3982
+ return (
3983
+ left.length === right.length &&
3984
+ left.every((value, index) => {
3985
+ const other = right[index];
3986
+ return other !== undefined && sameFileStat(value, other);
3987
+ })
3988
+ );
3989
+ }
3990
+
3991
+ function sameFileStat(left: FileStat, right: FileStat): boolean {
3992
+ return left.mtimeMs === right.mtimeMs && left.size === right.size;
3993
+ }
3994
+
3815
3995
  async function loadRouteMiddlewareControl(options: {
3816
3996
  appDir: string;
3817
3997
  pageFile: string;
@@ -4335,10 +4515,9 @@ async function loadComposedRouteMetadataUncached(options: {
4335
4515
  );
4336
4516
  const metadata: RouteMetadata[] = [];
4337
4517
  let dynamic = hasGenerateMetadataExport(options.code);
4338
-
4339
- for (const shell of layoutFiles) {
4518
+ const layoutMetadataTasks = layoutFiles.map(async (shell) => {
4340
4519
  if (shell.kind !== "layout") {
4341
- continue;
4520
+ return { code: undefined, metadata: undefined };
4342
4521
  }
4343
4522
 
4344
4523
  const code = await readServerSourceFile(
@@ -4346,7 +4525,6 @@ async function loadComposedRouteMetadataUncached(options: {
4346
4525
  options.serverModuleCacheVersion,
4347
4526
  options.serverSourceFiles,
4348
4527
  );
4349
- dynamic ||= hasGenerateMetadataExport(code);
4350
4528
  const shellMetadata = await loadRouteMetadata({
4351
4529
  appDir: options.appDir,
4352
4530
  code,
@@ -4359,12 +4537,9 @@ async function loadComposedRouteMetadataUncached(options: {
4359
4537
  vitePlugins: options.vitePlugins,
4360
4538
  });
4361
4539
 
4362
- if (shellMetadata !== undefined) {
4363
- metadata.push(shellMetadata);
4364
- }
4365
- }
4366
-
4367
- const pageMetadata = await loadRouteMetadata({
4540
+ return { code, metadata: shellMetadata };
4541
+ });
4542
+ const pageMetadataTask = loadRouteMetadata({
4368
4543
  appDir: options.appDir,
4369
4544
  code: options.code,
4370
4545
  context: options.context,
@@ -4376,6 +4551,17 @@ async function loadComposedRouteMetadataUncached(options: {
4376
4551
  vitePlugins: options.vitePlugins,
4377
4552
  });
4378
4553
 
4554
+ for (const task of layoutMetadataTasks) {
4555
+ const shell = await task;
4556
+ if (shell.code !== undefined) {
4557
+ dynamic ||= hasGenerateMetadataExport(shell.code);
4558
+ }
4559
+ if (shell.metadata !== undefined) {
4560
+ metadata.push(shell.metadata);
4561
+ }
4562
+ }
4563
+
4564
+ const pageMetadata = await pageMetadataTask;
4379
4565
  if (pageMetadata !== undefined) {
4380
4566
  metadata.push(pageMetadata);
4381
4567
  }
@@ -4632,7 +4818,7 @@ function readServerSourceFile(
4632
4818
  }
4633
4819
 
4634
4820
  if (serverModuleCacheVersion === undefined) {
4635
- return readFile(file, "utf8");
4821
+ return readDevServerSourceFile(file);
4636
4822
  }
4637
4823
 
4638
4824
  const key = `${serverModuleCacheVersion}:${file}`;
@@ -4661,6 +4847,28 @@ function readServerSourceFile(
4661
4847
  return loaded;
4662
4848
  }
4663
4849
 
4850
+ async function readDevServerSourceFile(file: string): Promise<string> {
4851
+ const stats = await fileStat(file);
4852
+ const cached = devServerSourceFileCache.get(file);
4853
+ if (
4854
+ cached !== undefined &&
4855
+ cached.mtimeMs === stats.mtimeMs &&
4856
+ cached.size === stats.size
4857
+ ) {
4858
+ return cached.source;
4859
+ }
4860
+
4861
+ const source = await readFile(file, "utf8");
4862
+ if (devServerSourceFileCache.size >= MAX_DEV_SERVER_SOURCE_FILE_CACHE_ENTRIES) {
4863
+ const oldestKey = devServerSourceFileCache.keys().next().value;
4864
+ if (oldestKey !== undefined) {
4865
+ devServerSourceFileCache.delete(oldestKey);
4866
+ }
4867
+ }
4868
+ devServerSourceFileCache.set(file, { ...stats, source });
4869
+ return source;
4870
+ }
4871
+
4664
4872
  function setBoundedCacheEntry<K, V>(
4665
4873
  cache: Map<K, V>,
4666
4874
  key: K,