@reckona/mreact-router 0.0.93 → 0.0.94

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/src/client.ts CHANGED
@@ -17,6 +17,8 @@ import type { ClientReferenceMetadata } from "@reckona/mreact-shared/compiler-co
17
17
  import {
18
18
  collectClientRouteModuleAnalysisFromContext,
19
19
  createCompilerModuleContext,
20
+ readTopLevelBooleanExport,
21
+ readTopLevelBooleanExportFromContext,
20
22
  stripTypeScriptWithOxc,
21
23
  transformCompilerModuleContext,
22
24
  type CompilerModuleContext,
@@ -122,8 +124,10 @@ interface ClientRouteModuleInferenceResult extends ClientRouteInferenceResult {
122
124
  clientBoundaryModule: boolean;
123
125
  nestedClientExportNames: string[];
124
126
  clientReferenceSourceFiles: string[];
127
+ navigationLinkExportNames: string[];
125
128
  serverOnly: boolean;
126
129
  serverOnlyClientRuntime: boolean;
130
+ usesNavigationLink: boolean;
127
131
  }
128
132
 
129
133
  export interface ClientReferenceImport {
@@ -135,6 +139,7 @@ export interface ClientReferenceImport {
135
139
  export interface ClientRouteReferenceResult extends ClientRouteInferenceResult {
136
140
  clientReferenceImports: ClientReferenceImport[];
137
141
  clientReferenceManifest: ClientReferenceMetadata[];
142
+ usesNavigationLink: boolean;
138
143
  }
139
144
 
140
145
  export interface ClientRouteInferenceDiagnostic {
@@ -498,9 +503,49 @@ export async function collectClientRouteReferences(options: {
498
503
  clientReferenceImports,
499
504
  clientReferenceManifest,
500
505
  diagnostics: sources.flatMap((source) => source.inference.diagnostics),
506
+ usesNavigationLink:
507
+ routeInference.usesNavigationLink ||
508
+ sources.some(
509
+ (source) => source.filename !== options.filename && source.inference.usesNavigationLink,
510
+ ),
501
511
  };
502
512
  }
503
513
 
514
+ export async function resolveNavigationRuntime(options: {
515
+ appDir?: string | undefined;
516
+ cache?: ClientRouteInferenceCache | undefined;
517
+ code: string;
518
+ filename: string;
519
+ references?: ClientRouteReferenceResult | undefined;
520
+ vitePlugins?: readonly PluginOption[] | undefined;
521
+ }): Promise<boolean> {
522
+ const cache = options.cache ?? createClientRouteInferenceCache();
523
+ // Read the override from the cached module context so the dev server does not
524
+ // re-parse every page route on each request.
525
+ const moduleContext = await compilerModuleContextForSource({
526
+ cache,
527
+ code: options.code,
528
+ filename: options.filename,
529
+ });
530
+ const override = readTopLevelBooleanExportFromContext(moduleContext, "navigationRuntime");
531
+
532
+ if (override !== undefined) {
533
+ return override;
534
+ }
535
+
536
+ const references =
537
+ options.references ??
538
+ (await collectClientRouteReferences({
539
+ appDir: options.appDir,
540
+ cache,
541
+ code: options.code,
542
+ filename: options.filename,
543
+ vitePlugins: options.vitePlugins,
544
+ }));
545
+
546
+ return references.usesNavigationLink;
547
+ }
548
+
504
549
  async function inferClientRouteShellModules(options: {
505
550
  appDir: string;
506
551
  cache: ClientRouteInferenceCache;
@@ -595,11 +640,14 @@ async function inferClientRouteModuleSource(options: {
595
640
  sourceTransform?: ClientRouteSourceTransform | undefined;
596
641
  }): Promise<ClientRouteModuleInferenceResult> {
597
642
  const analysis = await clientRouteModuleAnalysisForSource(options);
643
+ const usesNavigationLinkLocal = detectLinkComponentUsage(analysis);
598
644
 
599
645
  if (isServerOnlyClientRouteSource(analysis)) {
600
646
  return emptyClientRouteModuleInferenceResult({
647
+ navigationLinkExportNames: detectLinkComponentExportNames(analysis),
601
648
  serverOnly: true,
602
649
  serverOnlyClientRuntime: analysis.clientRuntime,
650
+ usesNavigationLink: usesNavigationLinkLocal,
603
651
  });
604
652
  }
605
653
 
@@ -625,6 +673,8 @@ async function inferClientRouteModuleSource(options: {
625
673
  let boundaryGraphFallbackRequired = false;
626
674
  let clientProxy = false;
627
675
  let nestedClient = false;
676
+ let usesNavigationLink = usesNavigationLinkLocal;
677
+ const navigationLinkExportNames = new Set<string>(detectLinkComponentExportNames(analysis));
628
678
  const exportInfo = analysis.topLevelExportRenderInfo;
629
679
  const implicitModuleClient = exportInfo.length === 0 && analysis.clientRuntime;
630
680
  for (const info of exportInfo) {
@@ -643,7 +693,14 @@ async function inferClientRouteModuleSource(options: {
643
693
  }
644
694
  const jsxComponentRoots = new Set(analysis.jsxComponentRoots);
645
695
  const componentCallRoots = new Set(analysis.componentCallRoots);
646
- const renderedComponentRoots = unionSets(jsxComponentRoots, componentCallRoots);
696
+ // A bare-identifier default export (`export default Page`) is the route's
697
+ // rendered component even though the module body has no JSX for it; treat it
698
+ // as a rendered root so a re-exported imported component (and any Link it
699
+ // renders) is followed.
700
+ const renderedComponentRoots =
701
+ analysis.defaultExportIdentifier === undefined
702
+ ? unionSets(jsxComponentRoots, componentCallRoots)
703
+ : new Set([...jsxComponentRoots, ...componentCallRoots, analysis.defaultExportIdentifier]);
647
704
  const identifierReferences = new Set(analysis.identifierReferences);
648
705
 
649
706
  for (const reference of analysis.staticImports) {
@@ -692,6 +749,25 @@ async function inferClientRouteModuleSource(options: {
692
749
  sourceTransform: options.sourceTransform,
693
750
  });
694
751
  diagnostics.push(...imported.diagnostics);
752
+ // Propagate the navigation runtime only for imports that are actually
753
+ // rendered here, and only for the specific exports whose subtree renders a
754
+ // `Link`. A merely-referenced import (e.g. `const C = Nav`) recurses for
755
+ // client-boundary detection but must not pull in a transitive `Link`, and
756
+ // a barrel re-exporting Link-free siblings must not over-trigger.
757
+ if (rendered) {
758
+ const importedNavigationExportNames = renderedImportedExportNames(
759
+ reference,
760
+ renderedComponentRoots,
761
+ );
762
+ if (
763
+ matchesInferredExportNames(importedNavigationExportNames, imported.navigationLinkExportNames)
764
+ ) {
765
+ usesNavigationLink = true;
766
+ for (const exportName of renderedLocalExportNames(reference, exportInfo)) {
767
+ navigationLinkExportNames.add(exportName);
768
+ }
769
+ }
770
+ }
695
771
 
696
772
  if (!imported.client) {
697
773
  if (rendered && imported.boundaryGraphFallbackCandidate) {
@@ -786,6 +862,23 @@ async function inferClientRouteModuleSource(options: {
786
862
  sourceTransform: options.sourceTransform,
787
863
  });
788
864
  diagnostics.push(...exported.diagnostics);
865
+ // A re-export renders nothing itself, so it does not set the module's
866
+ // own `usesNavigationLink`; it only forwards per-export `Link` usage so
867
+ // an importer that renders this name can decide precisely. Map the
868
+ // source module's export names onto the names this barrel re-exposes:
869
+ // `export * from` keeps the original names, `export { A as Nav } from`
870
+ // renames them.
871
+ if (reference.exportAll) {
872
+ for (const exportName of exported.navigationLinkExportNames) {
873
+ navigationLinkExportNames.add(exportName);
874
+ }
875
+ } else {
876
+ for (const specifier of reference.specifiers) {
877
+ if (exported.navigationLinkExportNames.includes(specifier.localName)) {
878
+ navigationLinkExportNames.add(specifier.exportedName);
879
+ }
880
+ }
881
+ }
789
882
 
790
883
  if (exported.clientBoundaryModule) {
791
884
  clientProxy = true;
@@ -820,8 +913,10 @@ async function inferClientRouteModuleSource(options: {
820
913
  nestedClientExportNames: Array.from(nestedClientExportNames),
821
914
  clientReferenceSourceFiles: Array.from(new Set(clientReferenceSourceFiles)),
822
915
  diagnostics,
916
+ navigationLinkExportNames: Array.from(navigationLinkExportNames),
823
917
  serverOnly: false,
824
918
  serverOnlyClientRuntime: false,
919
+ usesNavigationLink,
825
920
  };
826
921
  } finally {
827
922
  options.seen.delete(options.filename);
@@ -841,8 +936,10 @@ function emptyClientRouteModuleInferenceResult(
841
936
  nestedClientExportNames: [],
842
937
  clientReferenceSourceFiles: [],
843
938
  diagnostics: [],
939
+ navigationLinkExportNames: [],
844
940
  serverOnly: false,
845
941
  serverOnlyClientRuntime: false,
942
+ usesNavigationLink: false,
846
943
  ...overrides,
847
944
  };
848
945
  }
@@ -870,7 +967,7 @@ async function clientRouteModuleAnalysisForSource(options: {
870
967
  })),
871
968
  ),
872
969
  );
873
- options.cache.moduleAnalysisByFile.set(cacheKey, analysis);
970
+ setLatestModuleCacheEntry(options.cache.moduleAnalysisByFile, options.filename, cacheKey, analysis);
874
971
  return analysis;
875
972
  }
876
973
 
@@ -892,7 +989,7 @@ export async function compilerModuleContextForSource(options: {
892
989
  filename: options.filename,
893
990
  }),
894
991
  );
895
- options.cache.moduleContextByFile.set(cacheKey, context);
992
+ setLatestModuleCacheEntry(options.cache.moduleContextByFile, options.filename, cacheKey, context);
896
993
  return context;
897
994
  }
898
995
 
@@ -900,6 +997,28 @@ function sourceAnalysisCacheKey(filename: string, code: string): string {
900
997
  return `${filename}\0${hashSourceText(code)}`;
901
998
  }
902
999
 
1000
+ // Keeps only the latest content version per file in the source-keyed caches.
1001
+ // The cache persists across dev requests, and keys embed a content hash, so
1002
+ // without this an edited file would accumulate an entry per saved version.
1003
+ // Filenames cannot contain the NUL separator, so the `${filename}\0` prefix
1004
+ // matches exactly that file's prior entries.
1005
+ function setLatestModuleCacheEntry<T>(
1006
+ map: Map<string, T>,
1007
+ filename: string,
1008
+ cacheKey: string,
1009
+ value: T,
1010
+ ): void {
1011
+ const prefix = `${filename}\0`;
1012
+
1013
+ for (const existing of map.keys()) {
1014
+ if (existing !== cacheKey && existing.startsWith(prefix)) {
1015
+ map.delete(existing);
1016
+ }
1017
+ }
1018
+
1019
+ map.set(cacheKey, value);
1020
+ }
1021
+
903
1022
  function hashSourceText(text: string): string {
904
1023
  return createHash("sha256").update(text).digest("hex");
905
1024
  }
@@ -3591,24 +3710,67 @@ function importerInRuntimePackage(
3591
3710
  * source. Returns the hinted value, or `true` when no hint is present (i.e.,
3592
3711
  * preserve the historical "navigation runtime is always present" behavior).
3593
3712
  *
3594
- * Regex-based to avoid pulling the JS parser into the build path. The pattern
3595
- * accepts the common formatting variants:
3596
- * export const clientNavigation = false
3597
- * export const clientNavigation = false ;
3598
- * export const clientNavigation: boolean = false
3713
+ * AST-based so commented-out or string-literal occurrences of the pattern are
3714
+ * not mistaken for a real export.
3599
3715
  */
3600
3716
  export function detectClientNavigationHint(source: string): boolean {
3601
- const match = source.match(
3602
- /export\s+const\s+clientNavigation\s*(?::\s*[^=]+)?=\s*(true|false)\s*;?/,
3717
+ return readTopLevelBooleanExport({ code: source, name: "clientNavigation" }) ?? true;
3718
+ }
3719
+
3720
+ // `Link` ships from the dedicated `/link` subpath and is also re-exported from
3721
+ // the package root for backward compatibility, so both specifiers must count.
3722
+ // The root entry exports many bindings, so a root import only triggers the
3723
+ // navigation runtime when the rendered specifier is `Link` specifically.
3724
+ const navigationLinkPackageSpecifiers = new Set([
3725
+ "@reckona/mreact-router",
3726
+ "@reckona/mreact-router/link",
3727
+ ]);
3728
+
3729
+ function staticImportsRenderLink(
3730
+ staticImports: readonly ClientRouteStaticImportReference[],
3731
+ renderedRoots: ReadonlySet<string>,
3732
+ renderedNames: ReadonlySet<string>,
3733
+ ): boolean {
3734
+ return staticImports.some(
3735
+ (reference) =>
3736
+ navigationLinkPackageSpecifiers.has(reference.source) &&
3737
+ reference.specifiers.some(
3738
+ (specifier) =>
3739
+ (specifier.importedName === "Link" && renderedRoots.has(specifier.localName)) ||
3740
+ (specifier.kind === "namespace" && renderedNames.has(`${specifier.localName}.Link`)),
3741
+ ),
3603
3742
  );
3604
- return match === null ? true : match[1] === "true";
3605
3743
  }
3606
3744
 
3607
- export function detectNavigationRuntimeHint(source: string): boolean {
3608
- const match = source.match(
3609
- /export\s+const\s+navigationRuntime\s*(?::\s*[^=]+)?=\s*(true|false)\s*;?/,
3745
+ function detectLinkComponentUsage(analysis: ClientRouteModuleAnalysis): boolean {
3746
+ // Use the export-reachable rendered roots, not the file-wide JSX roots, so a
3747
+ // `Link` rendered only in dead/unreachable code does not trigger the runtime.
3748
+ return staticImportsRenderLink(
3749
+ analysis.staticImports,
3750
+ new Set(analysis.reachableRenderedComponentRoots),
3751
+ new Set(analysis.reachableRenderedComponentNames),
3610
3752
  );
3611
- return match !== null && match[1] === "true";
3753
+ }
3754
+
3755
+ // Export names whose own render subtree (transitively, within this module)
3756
+ // renders a navigation `Link`. Lets callers attribute `Link` usage to the
3757
+ // specific export they render, so a barrel that re-exports both Link-using and
3758
+ // Link-free components only triggers the runtime for the ones actually rendered.
3759
+ function detectLinkComponentExportNames(analysis: ClientRouteModuleAnalysis): string[] {
3760
+ return Object.keys(analysis.reachableExportRenderedComponentRoots).filter((exportName) =>
3761
+ staticImportsRenderLink(
3762
+ analysis.staticImports,
3763
+ new Set(analysis.reachableExportRenderedComponentRoots[exportName] ?? []),
3764
+ new Set(analysis.reachableExportRenderedComponentNames[exportName] ?? []),
3765
+ ),
3766
+ );
3767
+ }
3768
+
3769
+ // Reads the explicit `export const navigationRuntime = true | false` override.
3770
+ // Returns `undefined` when absent. AST-based so commented-out or string-literal
3771
+ // occurrences of the pattern are not mistaken for a real export.
3772
+ export function detectNavigationRuntimeOverride(source: string): boolean | undefined {
3773
+ return readTopLevelBooleanExport({ code: source, name: "navigationRuntime" });
3612
3774
  }
3613
3775
 
3614
3776
  function detectRouteCellStateHint(code: string): boolean {
package/src/vite.ts CHANGED
@@ -23,8 +23,10 @@ import {
23
23
  import type { AppRouterImportPolicy } from "./import-policy.js";
24
24
  import {
25
25
  collectClientRouteReferences,
26
- detectNavigationRuntimeHint,
26
+ createClientRouteInferenceCache,
27
27
  isClientRouteSource,
28
+ resolveNavigationRuntime,
29
+ type ClientRouteInferenceCache,
28
30
  } from "./client-route-inference.js";
29
31
  import {
30
32
  buildNavigationRuntimeBundle,
@@ -52,6 +54,8 @@ export interface AppRouterViteMiddlewareOptions extends AppRouterProjectOptions
52
54
  }
53
55
 
54
56
  type AppRouterViteRuntimeMiddlewareOptions = AppRouterViteMiddlewareOptions & {
57
+ clientRouteInferenceCache?: ClientRouteInferenceCache | undefined;
58
+ navigationScanVitePlugins?: readonly PluginOption[] | undefined;
55
59
  viteDevServer?: ViteDevServer | undefined;
56
60
  };
57
61
 
@@ -158,6 +162,12 @@ export function createAppRouterVitePlugin(options: AppRouterVitePluginOptions):
158
162
  ],
159
163
  ]);
160
164
 
165
+ // User-declared plugins captured from the resolving config. Unlike the fully
166
+ // resolved `server.config.plugins`, this excludes Vite internals (e.g. the
167
+ // built-in CSS plugin) whose `transform` requires a dev-server environment the
168
+ // lightweight navigation scan cannot provide. Mirrors what the build forwards.
169
+ let userVitePlugins: readonly PluginOption[] | undefined;
170
+
161
171
  const plugin: MreactRouterPlugin = {
162
172
  [mreactRouterConfigKey]: {
163
173
  ...project,
@@ -165,7 +175,9 @@ export function createAppRouterVitePlugin(options: AppRouterVitePluginOptions):
165
175
  },
166
176
  enforce: "pre",
167
177
  name: "mreact-router",
168
- config() {
178
+ config(userConfig) {
179
+ userVitePlugins = userConfig.plugins;
180
+
169
181
  return {
170
182
  optimizeDeps: {
171
183
  exclude: [
@@ -185,6 +197,7 @@ export function createAppRouterVitePlugin(options: AppRouterVitePluginOptions):
185
197
  return () => {
186
198
  const middlewareOptions: AppRouterViteRuntimeMiddlewareOptions = {
187
199
  ...options,
200
+ navigationScanVitePlugins: userVitePlugins,
188
201
  viteDevServer: server,
189
202
  vitePlugins: server.config.plugins,
190
203
  };
@@ -378,13 +391,18 @@ export function mreactRouterConfigFromPlugins(
378
391
  export function createAppRouterViteMiddleware(
379
392
  options: AppRouterViteMiddlewareOptions,
380
393
  ): Connect.NextHandleFunction {
394
+ // Reused across requests so dev navigation-script detection memoizes module
395
+ // contexts/analyses instead of re-walking every route's import graph per
396
+ // request. The source-keyed caches keep only the latest content version per
397
+ // file (see setLatestModuleCacheEntry), so repeated edits do not accumulate
398
+ // stale entries over a long dev session.
399
+ const runtimeOptions: AppRouterViteRuntimeMiddlewareOptions = {
400
+ ...options,
401
+ clientRouteInferenceCache: createClientRouteInferenceCache(),
402
+ };
403
+
381
404
  return (request, response, next) => {
382
- void handleAppRouterViteRequest(
383
- options as AppRouterViteRuntimeMiddlewareOptions,
384
- request,
385
- response,
386
- next,
387
- );
405
+ void handleAppRouterViteRequest(runtimeOptions, request, response, next);
388
406
  };
389
407
  }
390
408
 
@@ -443,7 +461,11 @@ async function handleAppRouterViteRequest(
443
461
  projectRoot: project.projectRoot,
444
462
  },
445
463
  clientStyles: await devRouteStyles(project),
446
- navigationScripts: await devNavigationScripts(project.routesDir),
464
+ navigationScripts: await devNavigationScripts(
465
+ project.routesDir,
466
+ options.clientRouteInferenceCache,
467
+ options.navigationScanVitePlugins,
468
+ ),
447
469
  request,
448
470
  routeCache: options.routeCache,
449
471
  serverActions: options.serverActions,
@@ -671,7 +693,12 @@ async function devRouteStyles(
671
693
  return new Map<string, readonly string[]>(routeStyles);
672
694
  }
673
695
 
674
- async function devNavigationScripts(appDir: string): Promise<ReadonlyMap<string, string>> {
696
+ async function devNavigationScripts(
697
+ appDir: string,
698
+ inferenceCache?: ClientRouteInferenceCache | undefined,
699
+ vitePlugins?: readonly PluginOption[] | undefined,
700
+ ): Promise<ReadonlyMap<string, string>> {
701
+ const cache = inferenceCache ?? createClientRouteInferenceCache();
675
702
  const entries = await Promise.all(
676
703
  (await scanAppRoutes({ appDir })).map(async (route) => {
677
704
  if (route.kind !== "page") {
@@ -679,8 +706,15 @@ async function devNavigationScripts(appDir: string): Promise<ReadonlyMap<string,
679
706
  }
680
707
 
681
708
  const source = await readFile(route.file, "utf8");
709
+ const navigation = await resolveNavigationRuntime({
710
+ appDir,
711
+ cache,
712
+ code: source,
713
+ filename: route.file,
714
+ vitePlugins,
715
+ });
682
716
 
683
- return detectNavigationRuntimeHint(source)
717
+ return navigation
684
718
  ? ([route.path, navigationRuntimeScriptForDev()] as const)
685
719
  : undefined;
686
720
  }),