@rangojs/router 0.29.0 → 0.30.1

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.
@@ -233,10 +233,24 @@ const handedSeen = new Map<string, WeakSet<object>>();
233
233
  // Thenables the router handed unread that React found already settled when
234
234
  // it read them: it suspended on a value that was there. The one cause of a
235
235
  // fallback the router owns (I3); consumed by the fallback it caused.
236
- const needless = new WeakSet<object>();
236
+ let needless = new WeakSet<object>();
237
237
 
238
238
  let report: SuspenseAuditReport | undefined;
239
239
 
240
+ /**
241
+ * One loaded copy of this module. A test that re-imports the module after
242
+ * vi.resetModules() runs two, and the one its static imports use is not the
243
+ * one on `__rangoSuspenseAudit`. The router's unit-test lock resets and
244
+ * checks each (src/__tests__/kit/suspense-audit-lock.ts).
245
+ */
246
+ export interface SuspenseAuditInstance {
247
+ /** The violation counters above zero and the last events, if any. */
248
+ violations(): string | undefined;
249
+ forget(): void;
250
+ /** Resolves once the judgments queued so far have run. */
251
+ settle(): Promise<void>;
252
+ }
253
+
240
254
  function getReport(): SuspenseAuditReport {
241
255
  if (report) return report;
242
256
  const created: SuspenseAuditReport = {
@@ -277,9 +291,16 @@ function getReport(): SuspenseAuditReport {
277
291
  };
278
292
  report = created;
279
293
  if (typeof window !== "undefined") {
280
- (
281
- window as unknown as { __rangoSuspenseAudit?: SuspenseAuditReport }
282
- ).__rangoSuspenseAudit = created;
294
+ const target = window as unknown as {
295
+ __rangoSuspenseAudit?: SuspenseAuditReport;
296
+ __rangoSuspenseAudits?: SuspenseAuditInstance[];
297
+ };
298
+ target.__rangoSuspenseAudit = created;
299
+ (target.__rangoSuspenseAudits ??= []).push({
300
+ violations: () => violationSummary(created),
301
+ forget: forgetSuspenseAudit,
302
+ settle: () => new Promise((resolve) => afterMicrotasks(resolve)),
303
+ });
283
304
  }
284
305
  return created;
285
306
  }
@@ -306,17 +327,35 @@ const RULES: Record<SuspenseAuditKind, [invariant: string, counter: Counter]> =
306
327
  mutated: ["I7", "mutations"],
307
328
  };
308
329
 
330
+ function eventLine(
331
+ kind: SuspenseAuditKind,
332
+ boundary: string,
333
+ detail: string,
334
+ ): string {
335
+ return `${RULES[kind][0]} ${kind} at ${boundary}: ${detail}`;
336
+ }
337
+
338
+ function violationSummary(r: SuspenseAuditReport): string | undefined {
339
+ const counts = Object.values(RULES)
340
+ .filter(([, counter]) => r[counter] > 0)
341
+ .map(([invariant, counter]) => `${invariant} ${counter}: ${r[counter]}`);
342
+ if (counts.length === 0) return undefined;
343
+ const events = r.events.map(
344
+ (event) => ` ${eventLine(event.kind, event.boundary, event.detail)}`,
345
+ );
346
+ return [counts.join(", "), ...events].join("\n");
347
+ }
348
+
309
349
  function violate(
310
350
  kind: SuspenseAuditKind,
311
351
  boundary: string,
312
352
  detail: string,
313
353
  ): void {
314
- const [invariant, counter] = RULES[kind];
315
354
  const r = getReport();
316
- r[counter] += 1;
355
+ r[RULES[kind][1]] += 1;
317
356
  r.events.push({ kind, boundary, detail, url: href(), at: performance.now() });
318
357
  if (r.events.length > MAX_EVENTS) r.events.shift();
319
- console.error(`${PREFIX} ${invariant} ${kind} at ${boundary}: ${detail}`);
358
+ console.error(`${PREFIX} ${eventLine(kind, boundary, detail)}`);
320
359
  }
321
360
 
322
361
  // Every hand-over, mount and fallback, for a debug run to read back. Call
@@ -617,9 +656,9 @@ let pendingRoot: PromiseLike<unknown> | undefined;
617
656
  // I7: the fields of each segment object handed to React, at hand-over, and
618
657
  // the segments themselves, weakly: the history cache keeps a tree's segments
619
658
  // after the next tree drops them, and a write there is still a write.
620
- const handedFields = new WeakMap<object, Record<string, unknown>>();
659
+ let handedFields = new WeakMap<object, Record<string, unknown>>();
621
660
  const handedSegments = new Set<WeakRef<object>>();
622
- const trackedSegments = new WeakSet<object>();
661
+ let trackedSegments = new WeakSet<object>();
623
662
 
624
663
  function heldTree(): TreeAudit | undefined {
625
664
  if (pendingRoot) {
@@ -1016,15 +1055,23 @@ export function auditTreeUpdate(root?: unknown): void {
1016
1055
  }
1017
1056
  }
1018
1057
 
1019
- /** Forget every tree and mount seen so far. For tests of the audit itself. */
1058
+ /**
1059
+ * Forget every tree, mount, hand-over and count seen so far: the root that
1060
+ * held them is gone. A test unmounts its root, production never does
1061
+ * (testing/render-route.tsx, src/__tests__/kit/suspense-audit-lock.ts).
1062
+ * Creates no report: with the audit off there is none.
1063
+ */
1020
1064
  export function forgetSuspenseAudit(): void {
1021
1065
  held = undefined;
1022
1066
  handedSegments.clear();
1067
+ handedFields = new WeakMap();
1068
+ trackedSegments = new WeakSet();
1069
+ needless = new WeakSet();
1023
1070
  pendingRoot = undefined;
1024
1071
  treeCause = undefined;
1025
1072
  unmounted.clear();
1026
1073
  remounted.length = 0;
1027
1074
  settledSinceTree.clear();
1028
1075
  mountCounts.clear();
1029
- getReport().reset();
1076
+ report?.reset();
1030
1077
  }
@@ -128,7 +128,7 @@ import { isUnderTestRunner } from "../runtime-env.js";
128
128
  import { setupNavigationBridgeDelegatedPrefetch } from "../browser/navigation-bridge.js";
129
129
  import { resetPrefetchObserverForTesting } from "../browser/prefetch/observer.js";
130
130
  import type { PrefetchStrategy } from "../router/prefetch-default.js";
131
- import { auditTreeCause } from "../suspense-audit.js";
131
+ import { auditTreeCause, forgetSuspenseAudit } from "../suspense-audit.js";
132
132
 
133
133
  const TEST_ORIGIN = "http://localhost";
134
134
  let activePrefetchRegistrations = 0;
@@ -475,15 +475,15 @@ export interface TestRouterHandle {
475
475
  * await act(async () => tail.resolve());
476
476
  * // useNavigation(): idle, isStreaming false
477
477
  *
478
- * A navigation started while React holds a page goes inside
479
- * `await act(async () => ...)`. Its first update makes React retry the held
480
- * page, which suspends again; a synchronous act() scope drops that retry
481
- * and React renders no later update of the same priority.
478
+ * A navigation started while React holds a page shows its page once the
479
+ * call resolves, with or without act(). A `<Link>` click over a held page
480
+ * still goes inside `await act(async () => ...)` (docs/testing.md).
482
481
  *
483
482
  * @example
484
- * await act(async () => {
485
- * await router.navigate("/products/3", { transition: false });
486
- * });
483
+ * await router.navigate("/products/2", { loaders: [[ProductLoader, next]] });
484
+ * // React holds /products/2
485
+ * await router.navigate("/products/3", { transition: false });
486
+ * // /products/3 is on screen
487
487
  */
488
488
  navigate(
489
489
  url: string,
@@ -914,9 +914,10 @@ export async function renderRoute(
914
914
  const handle = eventController.startNavigation(
915
915
  new URL(target, TEST_ORIGIN).href,
916
916
  );
917
- // Synchronous, in the caller's task: a <Link> click's own act() scope
918
- // takes it, and no act() of the caller's can overlap it.
919
- act(() => eventController.flushRouteState());
917
+ // Runs in the caller's task, so a <Link> click's own act() takes it.
918
+ // Awaited: React retries a held page with this update, and an unawaited
919
+ // act() drops that suspended retry for good.
920
+ await act(() => eventController.flushRouteState());
920
921
  try {
921
922
  const streamUntil = navOptions?.streamUntil;
922
923
  if (streamUntil) {
@@ -927,18 +928,19 @@ export async function renderRoute(
927
928
  });
928
929
  if (handle.signal.aborted) return;
929
930
  const stream = handle.startStreaming();
930
- act(() => eventController.flushRouteState());
931
+ await act(() => eventController.flushRouteState());
931
932
  // A navigation that is left stops counting as streaming at the abort
932
933
  // (partial-update.ts endStream); the abort's notification goes out
933
934
  // with the newer navigation's.
934
935
  handle.signal.addEventListener("abort", () => stream.end(), {
935
936
  once: true,
936
937
  });
937
- const end = (): void =>
938
- act(() => {
938
+ const end = async (): Promise<void> => {
939
+ await act(() => {
939
940
  stream.end();
940
941
  eventController.flushRouteState();
941
942
  });
943
+ };
942
944
  void streamUntil.then(end, end);
943
945
  }
944
946
  await commitNavigation(handle, target, navOptions);
@@ -1184,6 +1186,10 @@ export async function renderRoute(
1184
1186
  ...makeMetadata(url.pathname, initialSegments, initialMatch.params),
1185
1187
  defaultPrefetch: options.defaultPrefetch,
1186
1188
  };
1189
+ // The suspense audit holds one document's tree and mounts: a root an earlier
1190
+ // renderRoute mounted is gone (a test unmounts its root, production never
1191
+ // does). A no-op unless this repo's tests run the audit.
1192
+ forgetSuspenseAudit();
1187
1193
  const initialTree = await renderSegments(initialSegments, {
1188
1194
  outletPending: options.outletPending,
1189
1195
  });
@@ -593,11 +593,23 @@ export function isSourceMap(fileName: string): boolean {
593
593
  }
594
594
 
595
595
  /**
596
- * The bundler's region comments around plugin-rsc's server-reference map, as
597
- * they appear in an unminified chunk (`\0` is printed as two characters).
596
+ * The bundler's module marker: the comment line before each module's code in
597
+ * an unminified chunk, naming the module (`\0` printed as two characters).
598
+ * rolldown before 1.2.15 wraps the code in `//#region <id>` and
599
+ * `//#endregion`; 1.2.15 (rolldown/rolldown#11235) prints only `// <id>`
600
+ * where the module starts.
598
601
  */
599
- const SERVER_REFERENCES_REGION_START = `//#region \\0${SERVER_REFERENCES_MODULE_ID.slice(1)}\n`;
600
- const REGION_END = "//#endregion";
602
+ const MODULE_MARKER = /^\/\/(#region)? (.+)$/gm;
603
+ /**
604
+ * plugin-rsc's server-reference map: its marker in either form (group 1), and
605
+ * its body up to the next `// ` line or the region's end. The body stops at
606
+ * any `// ` line, a comment of its own included, so it never takes in the
607
+ * code of the module after it.
608
+ */
609
+ const SERVER_REFERENCE_MAP = new RegExp(
610
+ `(^//(?:#region)? ${escapeRegExp(`\\0${SERVER_REFERENCES_MODULE_ID.slice(1)}`)}\n)[\\s\\S]*?(?=^//(?: |#endregion))`,
611
+ "m",
612
+ );
601
613
 
602
614
  /**
603
615
  * Drop the body of the server-reference map from a chunk's source.
@@ -609,33 +621,61 @@ const REGION_END = "//#endregion";
609
621
  * hashed by the routers that reach it (resolveRouterMembers), so nothing the
610
622
  * map says is lost by skipping it.
611
623
  *
612
- * Works on the region comments, so a minified server build (which has none)
613
- * keeps the map in every router's hash: more clearing, never less.
624
+ * Works on the module markers, so a minified server build (which has none),
625
+ * or a map no marker follows, keeps the map in every router's hash: more
626
+ * clearing, never less.
614
627
  */
615
628
  export function stripServerReferenceMap(source: string): string {
616
- const start = source.indexOf(SERVER_REFERENCES_REGION_START);
617
- if (start === -1) return source;
618
- const bodyStart = start + SERVER_REFERENCES_REGION_START.length;
619
- const end = source.indexOf(REGION_END, bodyStart);
620
- if (end === -1) return source;
621
- return source.slice(0, bodyStart) + source.slice(end);
629
+ return source.replace(SERVER_REFERENCE_MAP, "$1");
622
630
  }
623
631
 
624
- const REGION_LINE = /^\/\/#region .*$/gm;
632
+ /**
633
+ * Every way the bundler can print one of `moduleIds` in a marker. rolldown
634
+ * prints a path relative to its working directory (`stabilize_id`), so any
635
+ * trailing run of the path's segments, and any other id as it is.
636
+ */
637
+ function printedModuleIds(moduleIds: readonly string[]): Set<string> {
638
+ const printed = new Set<string>();
639
+ for (const id of moduleIds) {
640
+ if (!isAbsolutePath(id)) {
641
+ printed.add(id.replaceAll("\0", "\\0"));
642
+ continue;
643
+ }
644
+ const segments = id.replaceAll("\\", "/").split("/");
645
+ for (let index = 0; index < segments.length; index++) {
646
+ printed.add(segments.slice(index).join("/"));
647
+ }
648
+ }
649
+ return printed;
650
+ }
625
651
 
626
652
  /**
627
- * Take the module paths out of a chunk's region comments.
653
+ * Take the module paths out of a chunk's module markers.
654
+ *
655
+ * The path says where the build ran, not what it built: it is relative to the
656
+ * directory `vite build` was started from (`vite build apps/web` from a
657
+ * repository root prints other paths than `vite build` in the app), it holds
658
+ * the pnpm store directory with its peer suffix, and a virtual id is printed
659
+ * verbatim, absolute paths in its query included. The code after the marker
660
+ * is hashed either way.
628
661
  *
629
- * The bundler prints one before each module's code, and the path says where
630
- * the build ran, not what it built: it is relative to the directory `vite
631
- * build` was started from (`vite build apps/web` from a repository root
632
- * prints other paths than `vite build` in the app), it holds the pnpm store
633
- * directory with its peer suffix, and a virtual id is printed verbatim,
634
- * absolute paths in its query included. The code under the comment is hashed
635
- * either way.
662
+ * A `//#region` line is always a marker. A `// ` line is one only when it
663
+ * names a module of the chunk (`moduleIds`): a kept comment or a line of a
664
+ * multi-line string can start the same way, and is hashed.
636
665
  */
637
- export function stripRegionPaths(source: string): string {
638
- return source.replace(REGION_LINE, "//#region");
666
+ export function stripModulePaths(
667
+ source: string,
668
+ moduleIds: readonly string[],
669
+ ): string {
670
+ let printed: Set<string> | undefined;
671
+ return source.replace(
672
+ MODULE_MARKER,
673
+ (line, region: string | undefined, name: string) => {
674
+ if (region) return "//#region";
675
+ printed ??= printedModuleIds(moduleIds);
676
+ return printed.has(name.replace(/^(?:\.\.\/)+/, "")) ? "//" : line;
677
+ },
678
+ );
639
679
  }
640
680
 
641
681
  /** One thing a version is a hash of: its name and the digest of its bytes. */
@@ -676,8 +716,8 @@ const FOREIGN_FILE = "~";
676
716
  *
677
717
  * Three things are taken out of a chunk's text first, each because it says
678
718
  * where or how the build ran: the server-reference map's body
679
- * ({@link stripServerReferenceMap}), the paths in region comments
680
- * ({@link stripRegionPaths}), and the hashed names of bundle files. A name
719
+ * ({@link stripServerReferenceMap}), the paths in module markers
720
+ * ({@link stripModulePaths}), and the hashed names of bundle files. A name
681
721
  * carries the content hash of its file, and every chunk importing the file
682
722
  * has the name in its bytes, so one router's change would otherwise move the
683
723
  * digest of every chunk naming its chunk. Only names with a content hash are
@@ -756,8 +796,9 @@ export function digestBundle(
756
796
  };
757
797
  }
758
798
  const references: string[] = [];
759
- const text = stripRegionPaths(
799
+ const text = stripModulePaths(
760
800
  stripServerReferenceMap(decoder.decode(bytes)),
801
+ chunk.moduleIds,
761
802
  );
762
803
  const blanked = reference
763
804
  ? text.replace(reference, (name) => {
@@ -121,7 +121,7 @@ export function canonicalAssetsManifestBinding(
121
121
  * (`ServerReferencesManager.claimMap`), which varies with the concurrent
122
122
  * scan builds (e2e/test-app: 2 of 2 consecutive builds differed). Sorted
123
123
  * here so a minified server build, where stripServerReferenceMap finds no
124
- * region comments, still hashes the same bytes.
124
+ * module markers, still hashes the same bytes.
125
125
  * 2. Local name of the assets-manifest import. plugin-rsc imports the manifest
126
126
  * under three names; the bundler merges them into one import per chunk and
127
127
  * keeps a different one from build to build (the cache-versions fixture: