@rangojs/router 0.24.2 → 0.24.4

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.
@@ -445,6 +445,53 @@ function bumpCounter(store: HelperContext, key: string): number {
445
445
  return store.counters[key]++;
446
446
  }
447
447
 
448
+ type ShortCodeType = "layout" | "parallel" | "route" | "loader" | "cache";
449
+
450
+ /**
451
+ * The next shortCode of `type` under the store's parent, which `take`
452
+ * consumes; a peek may look `ahead` past it. A root entry is prefixed with the
453
+ * mount index and counted per mount. A child is counted per parent with
454
+ * includeScope appended: while a lazy include's direct children evaluate,
455
+ * includeScope is a per-include token like "I0" / "I1I0" that partitions the
456
+ * parent's counter namespace so routes inside one include cannot collide with
457
+ * siblings declared outside it.
458
+ */
459
+ function nextShortCode(
460
+ store: HelperContext,
461
+ type: ShortCodeType,
462
+ take: boolean,
463
+ ahead = 0,
464
+ ): string {
465
+ const parent = store.parent;
466
+ const prefix = SHORT_CODE_PREFIX[type];
467
+ const mountPrefix =
468
+ store.mountIndex !== undefined ? `M${store.mountIndex}` : "";
469
+ const includeScope = store.includeScope ?? "";
470
+ const counterKey = parent
471
+ ? `${parent.shortCode}${includeScope}_${type}`
472
+ : mountPrefix
473
+ ? `${mountPrefix}_root_${type}`
474
+ : `root_${type}`;
475
+ const index = take
476
+ ? bumpCounter(store, counterKey)
477
+ : (store.counters[counterKey] ?? 0) + ahead;
478
+ return parent
479
+ ? `${parent.shortCode}${includeScope}${prefix}${index}`
480
+ : `${mountPrefix}${prefix}${index}`;
481
+ }
482
+
483
+ /**
484
+ * The shortCode the `ahead`-th next `getShortCode(type)` call in this DSL
485
+ * context returns, without taking it. clientUrls() materialization reads the
486
+ * code of every group route this way, including the routes an evaluation for
487
+ * another route skips (client-urls/layout-loader-copies.ts).
488
+ */
489
+ export function peekShortCode(type: ShortCodeType, ahead: number): string {
490
+ const store = RangoContext.getStore();
491
+ invariant(store, "No context RangoContext available");
492
+ return nextShortCode(store, type, false, ahead);
493
+ }
494
+
448
495
  export const getContext = (): {
449
496
  context: AsyncLocalStorage<HelperContext>;
450
497
  getStore: () => HelperContext;
@@ -522,29 +569,7 @@ export const getContext = (): {
522
569
  ) => {
523
570
  const store = context.getStore();
524
571
  invariant(store, "No context RangoContext available");
525
-
526
- const parent = store.parent;
527
- const prefix = SHORT_CODE_PREFIX[type];
528
- const mountPrefix =
529
- store.mountIndex !== undefined ? `M${store.mountIndex}` : "";
530
-
531
- const includeScope = store.includeScope ?? "";
532
-
533
- if (!parent) {
534
- // Root entry: prefix with mount index and use mount-scoped counter
535
- const counterKey = mountPrefix
536
- ? `${mountPrefix}_root_${type}`
537
- : `root_${type}`;
538
- return `${mountPrefix}${prefix}${bumpCounter(store, counterKey)}`;
539
- } else {
540
- // Child entry: use parent-scoped counter with includeScope appended.
541
- // When we're evaluating a lazy include's direct children, includeScope
542
- // is a per-include token like "I0" / "I1I0" that partitions the
543
- // parent's counter namespace so routes inside one include cannot
544
- // collide with siblings declared outside it.
545
- const counterKey = `${parent.shortCode}${includeScope}_${type}`;
546
- return `${parent.shortCode}${includeScope}${prefix}${bumpCounter(store, counterKey)}`;
547
- }
572
+ return nextShortCode(store, type, true);
548
573
  },
549
574
  runWithStore: <T>(
550
575
  store: HelperContext,
package/src/ssr/index.tsx CHANGED
@@ -403,6 +403,62 @@ function trackDocumentClientLoads<T>(decode: () => T): {
403
403
  };
404
404
  }
405
405
 
406
+ /**
407
+ * Cap on settleSsrFalseFirstFlush's turns. Each turn retries one generation of
408
+ * tasks parked on thenables that settle in microtasks. A useActionState form
409
+ * is one generation (the Flight client caches the encoding, so the retry
410
+ * encodes synchronously); a formAction button inside it renders on that retry
411
+ * and is a second; the same form behind `lazy()` adds a third. ssr-handler
412
+ * tests pin all three. A deeper chain flushes its fallback as before.
413
+ */
414
+ const SSR_FALSE_FIRST_FLUSH_TURNS = 3;
415
+
416
+ /**
417
+ * True when the matched chain has a `loader(Def, { ssr: false })` entry: its
418
+ * payload segments carry `awaitBeforeFlush` (fresh.ts).
419
+ */
420
+ function hasAwaitBeforeFlush(payload: {
421
+ metadata?: { segments?: readonly { awaitBeforeFlush?: true }[] };
422
+ }): boolean {
423
+ return payload.metadata?.segments?.some((s) => s.awaitBeforeFlush) === true;
424
+ }
425
+
426
+ /**
427
+ * ssr: false documents only: before the first flush, give Fizz the turns a
428
+ * task waiting on an already-settling thenable needs, so a boundary that
429
+ * waits on nothing else completes in place (#1138).
430
+ *
431
+ * Fizz's useActionState binds its state onto the action, and the
432
+ * `<form action>` encode of that bound server reference throws the Flight
433
+ * client's encodeFormData thenable once (defaultEncodeFormAction). It settles
434
+ * in microtasks, but once the request has left its opening status, Fizz's
435
+ * pingTask retries the task on a setTimeout(0) turn. renderToReadableStream
436
+ * resolves at shell-ready, so the first read would flush the boundary's
437
+ * fallback before that turn, which a browser without JavaScript keeps (the
438
+ * form lands in `<div hidden id="S:n">`).
439
+ *
440
+ * A document already whole (allReady settled) returns with no macrotask.
441
+ * Otherwise one setTimeout(0) at a time until allReady, at most
442
+ * SSR_FALSE_FIRST_FLUSH_TURNS: a timer set after Fizz's ping fires after it.
443
+ * A boundary waiting on I/O (an unflagged loader, a streamed promise) still
444
+ * streams its fallback, after at most that many turns.
445
+ */
446
+ async function settleSsrFalseFirstFlush(
447
+ allReady: Promise<void>,
448
+ ): Promise<void> {
449
+ let whole = false;
450
+ const settle = (): void => {
451
+ whole = true;
452
+ };
453
+ // Promise.resolve: a stub renderer's stream may carry no allReady.
454
+ Promise.resolve(allReady).then(settle, settle);
455
+ // A settled allReady's reaction was queued before this continuation's.
456
+ await Promise.resolve();
457
+ for (let turn = 0; !whole && turn < SSR_FALSE_FIRST_FLUSH_TURNS; turn++) {
458
+ await macrotask();
459
+ }
460
+ }
461
+
406
462
  /**
407
463
  * Route an SSR error through the deps.onError notification callback with the
408
464
  * "rendering" phase. Swallows callback failures so a broken reporter never
@@ -832,13 +888,10 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
832
888
  // Awaiting the payload here is latency-neutral: fizz cannot emit even
833
889
  // <html> until this same promise settles. A rejection lands in the
834
890
  // catch below — the same reportRenderError path a shell error takes.
835
- let progressiveChunkSize = deps.progressiveChunkSize;
836
- if (progressiveChunkSize === undefined) {
837
- const resolved = await payload;
838
- if (resolved.metadata?.segments?.some((s) => s.awaitBeforeFlush)) {
839
- progressiveChunkSize = Number.MAX_SAFE_INTEGER;
840
- }
841
- }
891
+ const awaitsBeforeFlush = hasAwaitBeforeFlush(await payload);
892
+ const progressiveChunkSize =
893
+ deps.progressiveChunkSize ??
894
+ (awaitsBeforeFlush ? Number.MAX_SAFE_INTEGER : undefined);
842
895
 
843
896
  // Render React tree to HTML stream
844
897
  // Pass formState for useActionState progressive enhancement if provided
@@ -870,6 +923,9 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
870
923
  // cannot process streamed HTML.
871
924
  if (streamMode === "allReady") {
872
925
  await htmlStream.allReady;
926
+ } else if (awaitsBeforeFlush) {
927
+ // The shell rendered from the payload, so it has settled.
928
+ await settleSsrFalseFirstFlush(htmlStream.allReady);
873
929
  }
874
930
 
875
931
  // The entry's Fizz hint at default priority, after the head chunk
@@ -54,6 +54,21 @@ function applyNamePrefix(prefix: string | undefined, name: string): string {
54
54
  return `${prefix}.${name}`;
55
55
  }
56
56
 
57
+ /**
58
+ * The key path() registers a route under: its name, or a `$path_` name built
59
+ * from its pattern, behind the include's name prefix.
60
+ */
61
+ export function routeKeyOf(
62
+ namePrefix: string | undefined,
63
+ pattern: string,
64
+ name: string | undefined,
65
+ ): string {
66
+ return applyNamePrefix(
67
+ namePrefix,
68
+ name || `$path_${pattern.replace(/[/:*?]/g, "_")}`,
69
+ );
70
+ }
71
+
57
72
  function resolveResponseType(
58
73
  options: PathOptions | undefined,
59
74
  ): string | undefined {
@@ -143,12 +158,10 @@ export function createPathHelper<TEnv>(): PathFn<TEnv> {
143
158
 
144
159
  const prefixedPattern = applyUrlPrefix(urlPrefix, pattern);
145
160
 
146
- const localName =
147
- options?.name || `$path_${pattern.replace(/[/:*?]/g, "_")}`;
148
161
  if (options?.name) {
149
162
  validateUserRouteName(options.name);
150
163
  }
151
- const routeName = applyNamePrefix(namePrefix, localName);
164
+ const routeName = routeKeyOf(namePrefix, pattern, options?.name);
152
165
 
153
166
  const namespace = `${ctx.namespace}.${store.getNextIndex("route")}.${routeName}`;
154
167