@rangojs/router 0.0.0-experimental.b02a2fec → 0.0.0-experimental.b30bbf02

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 (112) hide show
  1. package/README.md +112 -17
  2. package/dist/vite/index.js +1338 -462
  3. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  4. package/package.json +7 -5
  5. package/skills/breadcrumbs/SKILL.md +3 -1
  6. package/skills/handler-use/SKILL.md +362 -0
  7. package/skills/hooks/SKILL.md +33 -20
  8. package/skills/intercept/SKILL.md +20 -0
  9. package/skills/layout/SKILL.md +22 -0
  10. package/skills/links/SKILL.md +90 -16
  11. package/skills/loader/SKILL.md +70 -3
  12. package/skills/middleware/SKILL.md +34 -3
  13. package/skills/migrate-nextjs/SKILL.md +562 -0
  14. package/skills/migrate-react-router/SKILL.md +769 -0
  15. package/skills/parallel/SKILL.md +66 -0
  16. package/skills/rango/SKILL.md +25 -22
  17. package/skills/response-routes/SKILL.md +8 -0
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/server-actions/SKILL.md +739 -0
  20. package/skills/streams-and-websockets/SKILL.md +283 -0
  21. package/skills/typesafety/SKILL.md +3 -1
  22. package/src/browser/app-shell.ts +52 -0
  23. package/src/browser/event-controller.ts +44 -4
  24. package/src/browser/navigation-bridge.ts +71 -5
  25. package/src/browser/navigation-client.ts +64 -13
  26. package/src/browser/navigation-store.ts +25 -1
  27. package/src/browser/partial-update.ts +34 -3
  28. package/src/browser/prefetch/cache.ts +129 -21
  29. package/src/browser/prefetch/fetch.ts +148 -16
  30. package/src/browser/prefetch/queue.ts +36 -5
  31. package/src/browser/rango-state.ts +53 -13
  32. package/src/browser/react/Link.tsx +30 -2
  33. package/src/browser/react/NavigationProvider.tsx +70 -18
  34. package/src/browser/react/filter-segment-order.ts +51 -7
  35. package/src/browser/react/use-navigation.ts +22 -2
  36. package/src/browser/react/use-params.ts +11 -1
  37. package/src/browser/react/use-router.ts +8 -1
  38. package/src/browser/react/use-segments.ts +11 -8
  39. package/src/browser/rsc-router.tsx +34 -6
  40. package/src/browser/segment-reconciler.ts +36 -14
  41. package/src/browser/types.ts +19 -0
  42. package/src/build/route-trie.ts +50 -24
  43. package/src/cache/cf/cf-cache-store.ts +5 -7
  44. package/src/client.tsx +82 -174
  45. package/src/index.rsc.ts +3 -0
  46. package/src/index.ts +40 -9
  47. package/src/outlet-context.ts +1 -1
  48. package/src/response-utils.ts +28 -0
  49. package/src/reverse.ts +7 -3
  50. package/src/route-definition/dsl-helpers.ts +175 -23
  51. package/src/route-definition/helpers-types.ts +63 -14
  52. package/src/route-definition/resolve-handler-use.ts +6 -0
  53. package/src/route-types.ts +7 -0
  54. package/src/router/handler-context.ts +24 -4
  55. package/src/router/lazy-includes.ts +6 -6
  56. package/src/router/loader-resolution.ts +3 -0
  57. package/src/router/manifest.ts +22 -13
  58. package/src/router/match-api.ts +4 -3
  59. package/src/router/match-handlers.ts +1 -0
  60. package/src/router/match-result.ts +21 -2
  61. package/src/router/middleware-types.ts +2 -22
  62. package/src/router/middleware.ts +54 -7
  63. package/src/router/pattern-matching.ts +87 -17
  64. package/src/router/revalidation.ts +15 -1
  65. package/src/router/segment-resolution/fresh.ts +8 -0
  66. package/src/router/segment-resolution/revalidation.ts +128 -100
  67. package/src/router/trie-matching.ts +18 -13
  68. package/src/router/url-params.ts +49 -0
  69. package/src/router.ts +1 -2
  70. package/src/rsc/handler.ts +8 -4
  71. package/src/rsc/helpers.ts +69 -41
  72. package/src/rsc/progressive-enhancement.ts +4 -0
  73. package/src/rsc/response-route-handler.ts +14 -1
  74. package/src/rsc/rsc-rendering.ts +10 -0
  75. package/src/rsc/server-action.ts +4 -0
  76. package/src/rsc/types.ts +6 -0
  77. package/src/segment-content-promise.ts +67 -0
  78. package/src/segment-loader-promise.ts +122 -0
  79. package/src/segment-system.tsx +11 -61
  80. package/src/server/context.ts +26 -3
  81. package/src/server/request-context.ts +10 -42
  82. package/src/ssr/index.tsx +5 -1
  83. package/src/types/handler-context.ts +12 -39
  84. package/src/types/loader-types.ts +5 -6
  85. package/src/types/request-scope.ts +126 -0
  86. package/src/types/route-entry.ts +11 -0
  87. package/src/types/segments.ts +17 -1
  88. package/src/urls/include-helper.ts +24 -14
  89. package/src/urls/path-helper-types.ts +30 -4
  90. package/src/urls/response-types.ts +2 -10
  91. package/src/vite/debug.ts +184 -0
  92. package/src/vite/discovery/discover-routers.ts +31 -3
  93. package/src/vite/discovery/gate-state.ts +171 -0
  94. package/src/vite/discovery/prerender-collection.ts +48 -1
  95. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  96. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  97. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  98. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  99. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  100. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  101. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  102. package/src/vite/plugins/expose-action-id.ts +52 -28
  103. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  104. package/src/vite/plugins/expose-internal-ids.ts +516 -486
  105. package/src/vite/plugins/performance-tracks.ts +17 -9
  106. package/src/vite/plugins/use-cache-transform.ts +56 -43
  107. package/src/vite/plugins/version-injector.ts +37 -11
  108. package/src/vite/rango.ts +49 -14
  109. package/src/vite/router-discovery.ts +558 -53
  110. package/src/vite/utils/banner.ts +1 -1
  111. package/src/vite/utils/package-resolution.ts +41 -1
  112. package/src/vite/utils/prerender-utils.ts +20 -6
@@ -14,6 +14,7 @@ import type {
14
14
  import type { ScopedReverseFunction } from "../reverse.js";
15
15
  import type { Theme } from "../theme/types.js";
16
16
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
17
+ import type { RequestScope } from "../types/request-scope.js";
17
18
 
18
19
  /**
19
20
  * Get variable function type
@@ -57,28 +58,7 @@ export interface CookieOptions {
57
58
  export interface MiddlewareContext<
58
59
  TEnv = any,
59
60
  TParams = Record<string, string>,
60
- > {
61
- /** Original request */
62
- request: Request;
63
-
64
- /** Parsed URL (with internal `_rsc*` params stripped) */
65
- url: URL;
66
-
67
- /**
68
- * The original request URL with all parameters intact, including
69
- * internal `_rsc*` transport params.
70
- */
71
- originalUrl: URL;
72
-
73
- /** URL pathname */
74
- pathname: string;
75
-
76
- /** URL search params */
77
- searchParams: URLSearchParams;
78
-
79
- /** Platform bindings (Cloudflare, etc.) */
80
- env: TEnv;
81
-
61
+ > extends RequestScope<TEnv> {
82
62
  /** URL params extracted from route/middleware pattern */
83
63
  params: TParams;
84
64
 
@@ -10,6 +10,8 @@
10
10
  */
11
11
 
12
12
  import { contextGet, contextSet } from "../context-var.js";
13
+ import { safeDecodeURIComponent } from "./url-params.js";
14
+ import { fireAndForgetWaitUntil } from "../types/request-scope.js";
13
15
  import type {
14
16
  CollectedMiddleware,
15
17
  MiddlewareCollectableEntry,
@@ -22,6 +24,7 @@ import { _getRequestContext } from "../server/request-context.js";
22
24
  import { isAutoGeneratedRouteName } from "../route-name.js";
23
25
  import { appendMetric, createMetricsStore } from "./metrics.js";
24
26
  import { stripInternalParams } from "./handler-context.js";
27
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
25
28
 
26
29
  // Re-export types and cookie utilities for backward compatibility
27
30
  export type {
@@ -112,7 +115,12 @@ function escapeRegex(str: string): string {
112
115
  }
113
116
 
114
117
  /**
115
- * Extract params from a pathname using a pattern's regex and param names
118
+ * Extract params from a pathname using a pattern's regex and param names.
119
+ *
120
+ * Values are URL-decoded so apps see the raw string (e.g. "ivo@example.com")
121
+ * instead of the percent-encoded form ("ivo%40example.com"). This matches the
122
+ * contract assumed by ctx.reverse (which re-encodes) and aligns with
123
+ * Express/React Router/Fastify/Koa.
116
124
  */
117
125
  export function extractParams(
118
126
  pathname: string,
@@ -124,7 +132,7 @@ export function extractParams(
124
132
 
125
133
  const params: Record<string, string> = {};
126
134
  for (let i = 0; i < paramNames.length; i++) {
127
- params[paramNames[i]] = match[i + 1] || "";
135
+ params[paramNames[i]] = safeDecodeURIComponent(match[i + 1] || "");
128
136
  }
129
137
  return params;
130
138
  }
@@ -179,14 +187,22 @@ export function createMiddlewareContext<TEnv>(
179
187
  return responseHolder.response;
180
188
  };
181
189
 
190
+ // Capture reqCtx once: the request-scoped platform fields
191
+ // (originalUrl, executionContext, waitUntil) are immutable per request,
192
+ // so snapshotting beats re-reading ALS on every access. The lazy getters
193
+ // below (routeName, theme, setTheme) stay lazy because those can change
194
+ // during `await next()`.
195
+ const reqCtx = _getRequestContext();
182
196
  return {
183
197
  request,
184
198
  url,
185
- originalUrl: new URL(request.url),
199
+ originalUrl: reqCtx?.originalUrl ?? new URL(request.url),
186
200
  pathname: url.pathname,
187
201
  searchParams: url.searchParams,
188
202
  env: env as MiddlewareContext<TEnv>["env"],
189
203
  params,
204
+ executionContext: reqCtx?.executionContext,
205
+ waitUntil: reqCtx ? reqCtx.waitUntil.bind(reqCtx) : fireAndForgetWaitUntil,
190
206
  // Getter: re-derives from request context on each access so that global
191
207
  // middleware sees the matched route name after await next().
192
208
  get routeName(): MiddlewareContext<TEnv>["routeName"] {
@@ -360,6 +376,11 @@ export async function executeMiddleware<TEnv>(
360
376
  });
361
377
  }
362
378
 
379
+ if (isWebSocketUpgradeResponse(response)) {
380
+ responseHolder.response = response;
381
+ return response;
382
+ }
383
+
363
384
  // Clone response with merged headers (mutable for post-next() modifications)
364
385
  responseHolder.response = new Response(response.body, {
365
386
  status: response.status,
@@ -426,8 +447,16 @@ export async function executeMiddleware<TEnv>(
426
447
  try {
427
448
  result = await entry.handler(ctx, wrappedNext);
428
449
  } catch (error) {
429
- finishMiddleware();
430
- throw error;
450
+ // Thrown Response is short-circuit control flow, not an error.
451
+ // Fall through to the `if (result instanceof Response)` branch below
452
+ // so stub headers and request-context cookies merge as they do for
453
+ // an explicit `return new Response(...)`. Real errors propagate.
454
+ if (error instanceof Response) {
455
+ result = error;
456
+ } else {
457
+ finishMiddleware();
458
+ throw error;
459
+ }
431
460
  }
432
461
  finishMiddleware();
433
462
 
@@ -451,6 +480,10 @@ export async function executeMiddleware<TEnv>(
451
480
  // RequestContext stub headers (from ctx.setCookie) into the
452
481
  // returned Response so they are not lost.
453
482
  if (result instanceof Response) {
483
+ if (isWebSocketUpgradeResponse(result)) {
484
+ responseHolder.response = result;
485
+ return result;
486
+ }
454
487
  const mergedHeaders = new Headers(result.headers);
455
488
  stubResponse.headers.forEach((value, name) => {
456
489
  if (name.toLowerCase() === "set-cookie") {
@@ -527,8 +560,11 @@ export async function executeMiddleware<TEnv>(
527
560
  // last merge point (e.g. cookies().set() called after await next()).
528
561
  // The reqCtx stub may have already been partially merged during finalHandler
529
562
  // or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
563
+ //
564
+ // Skip for upgrade responses: upgrade headers are semantically immutable and
565
+ // set-cookie on an upgrade is not meaningful.
530
566
  const reqCtx = _getRequestContext();
531
- if (reqCtx) {
567
+ if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
532
568
  const stubCookies = reqCtx.res.headers.getSetCookie();
533
569
  if (stubCookies.length > 0) {
534
570
  const existingCookies = new Set(finalResponse.headers.getSetCookie());
@@ -613,7 +649,18 @@ export async function executeInterceptMiddleware<TEnv>(
613
649
  return next();
614
650
  };
615
651
 
616
- const result = await middleware(ctx, guardedNext);
652
+ let result: Response | void;
653
+ try {
654
+ result = await middleware(ctx, guardedNext);
655
+ } catch (error) {
656
+ // Thrown Response is short-circuit control flow, parity with the
657
+ // explicit-return path below. Real errors propagate.
658
+ if (error instanceof Response) {
659
+ result = error;
660
+ } else {
661
+ throw error;
662
+ }
663
+ }
617
664
 
618
665
  if (result instanceof Response) {
619
666
  earlyResponse = result;
@@ -7,6 +7,7 @@
7
7
  import type { RouteEntry, TrailingSlashMode } from "../types";
8
8
  import type { EntryData } from "../server/context";
9
9
  import { debugLog, isRouterDebugEnabled } from "./logging.js";
10
+ import { safeDecodeURIComponent } from "./url-params.js";
10
11
 
11
12
  /**
12
13
  * Parsed segment info
@@ -82,6 +83,13 @@ export interface CompiledPattern {
82
83
  paramNames: string[];
83
84
  optionalParams: Set<string>;
84
85
  hasTrailingSlash: boolean;
86
+ /**
87
+ * Param-name → allowed values for constrained params (e.g. `:lang(en|gb)`).
88
+ * Validated against the **decoded** param value after regex extraction so
89
+ * a URL like `/en%20GB` still matches `:lang(en GB)` — matching the trie
90
+ * path's behavior (trie-matching.ts:validateAndBuild).
91
+ */
92
+ constraints?: Record<string, string[]>;
85
93
  }
86
94
 
87
95
  // Module-level cache for compiled patterns. Route patterns are a finite set
@@ -142,6 +150,7 @@ export function compilePattern(pattern: string): CompiledPattern {
142
150
  const segments = parsePattern(normalizedPattern);
143
151
  const paramNames: string[] = [];
144
152
  const optionalParams = new Set<string>();
153
+ let constraints: Record<string, string[]> | undefined;
145
154
 
146
155
  let regexPattern = "";
147
156
 
@@ -152,11 +161,14 @@ export function compilePattern(pattern: string): CompiledPattern {
152
161
  } else if (segment.type === "param") {
153
162
  paramNames.push(segment.value);
154
163
  const suffixPattern = segment.suffix ? escapeRegex(segment.suffix) : "";
155
- const valuePattern = segment.constraint
156
- ? `(${segment.constraint.map(escapeRegex).join("|")})`
157
- : segment.suffix
158
- ? "([^/]+?)"
159
- : "([^/]+)";
164
+ // Constrained params capture anything here; the allowed values are
165
+ // checked post-decode in findMatch so URL-encoded constraint values
166
+ // (e.g. `:lang(en GB)` via `/en%20GB`) still match.
167
+ const valuePattern = segment.suffix ? "([^/]+?)" : "([^/]+)";
168
+
169
+ if (segment.constraint) {
170
+ (constraints ??= {})[segment.value] = segment.constraint;
171
+ }
160
172
 
161
173
  if (segment.optional) {
162
174
  optionalParams.add(segment.value);
@@ -186,9 +198,35 @@ export function compilePattern(pattern: string): CompiledPattern {
186
198
  paramNames,
187
199
  optionalParams,
188
200
  hasTrailingSlash,
201
+ ...(constraints ? { constraints } : {}),
189
202
  };
190
203
  }
191
204
 
205
+ /**
206
+ * Validate decoded params against a compiled pattern's constraints.
207
+ * Returns false if any constrained param has a non-empty value not in the
208
+ * allowed list. Absent optionals (key missing or `undefined`) are allowed;
209
+ * `""` is also tolerated as "absent" so user-provided params or fixtures
210
+ * that pass empty strings explicitly behave the same way.
211
+ */
212
+ function satisfiesConstraints(
213
+ params: Record<string, string>,
214
+ constraints: Record<string, string[]> | undefined,
215
+ ): boolean {
216
+ if (!constraints) return true;
217
+ for (const name in constraints) {
218
+ const value = params[name];
219
+ if (
220
+ value !== undefined &&
221
+ value !== "" &&
222
+ !constraints[name].includes(value)
223
+ ) {
224
+ return false;
225
+ }
226
+ }
227
+ return true;
228
+ }
229
+
192
230
  /**
193
231
  * Escape special regex characters in a string
194
232
  */
@@ -196,6 +234,27 @@ function escapeRegex(str: string): string {
196
234
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
197
235
  }
198
236
 
237
+ /**
238
+ * Build the named-params record from a regex match. Optional segments that
239
+ * didn't capture leave the corresponding group `undefined`; we skip those
240
+ * keys so `ctx.params.<name>` reads as `undefined` rather than `""`. This
241
+ * keeps the runtime aligned with the `ExtractParams` type and matches the
242
+ * trie matcher's contract (see `trie-matching.ts:validateAndBuild`).
243
+ */
244
+ function buildParamsFromMatch(
245
+ match: RegExpExecArray,
246
+ paramNames: string[],
247
+ ): Record<string, string> {
248
+ const params: Record<string, string> = {};
249
+ paramNames.forEach((name, index) => {
250
+ const captured = match[index + 1];
251
+ if (captured !== undefined) {
252
+ params[name] = safeDecodeURIComponent(captured);
253
+ }
254
+ });
255
+ return params;
256
+ }
257
+
199
258
  /**
200
259
  * Extract the static prefix from a route pattern.
201
260
  * Returns everything before the first param/wildcard.
@@ -247,8 +306,10 @@ export function extractStaticPrefix(pattern: string): string {
247
306
  /**
248
307
  * Match a pathname against registered routes
249
308
  *
250
- * Note: Optional params that are absent in the path will have empty string value.
251
- * Use the pattern definition to determine if a param is optional.
309
+ * Note: Optional params that are absent in the path are omitted from the
310
+ * returned `params` (read as `undefined`), matching the trie matcher and
311
+ * the `ExtractParams<"/:locale?/...">` type. Use the pattern definition or
312
+ * `optionalParams` to determine which keys are optional.
252
313
  *
253
314
  * Trailing slash handling (priority order):
254
315
  * 1. Per-route `trailingSlash` config from route()
@@ -392,8 +453,13 @@ export function findMatch<TEnv>(
392
453
  fullPattern = entry.prefix + pattern;
393
454
  }
394
455
 
395
- const { regex, paramNames, optionalParams, hasTrailingSlash } =
396
- getCompiledPattern(fullPattern);
456
+ const {
457
+ regex,
458
+ paramNames,
459
+ optionalParams,
460
+ hasTrailingSlash,
461
+ constraints,
462
+ } = getCompiledPattern(fullPattern);
397
463
 
398
464
  // Get trailing slash mode for this route (per-route config or pattern-based)
399
465
  const trailingSlashMode: TrailingSlashMode | undefined =
@@ -410,10 +476,13 @@ export function findMatch<TEnv>(
410
476
  // Try exact match first
411
477
  const match = regex.exec(pathname);
412
478
  if (match) {
413
- const params: Record<string, string> = {};
414
- paramNames.forEach((name, index) => {
415
- params[name] = match[index + 1] ?? "";
416
- });
479
+ const params = buildParamsFromMatch(match, paramNames);
480
+
481
+ // Validate constraints against decoded values; a failure falls
482
+ // through to the next route so other patterns can still match.
483
+ if (!satisfiesConstraints(params, constraints)) {
484
+ continue;
485
+ }
417
486
 
418
487
  if (effectiveDebug) {
419
488
  debugLog("findMatch", "matched route", {
@@ -465,10 +534,11 @@ export function findMatch<TEnv>(
465
534
  // Try alternate pathname (opposite trailing slash)
466
535
  const altMatch = regex.exec(alternatePathname);
467
536
  if (altMatch) {
468
- const params: Record<string, string> = {};
469
- paramNames.forEach((name, index) => {
470
- params[name] = altMatch[index + 1] ?? "";
471
- });
537
+ const params = buildParamsFromMatch(altMatch, paramNames);
538
+
539
+ if (!satisfiesConstraints(params, constraints)) {
540
+ continue;
541
+ }
472
542
 
473
543
  // Determine redirect behavior based on mode
474
544
  if (trailingSlashMode === "ignore") {
@@ -59,6 +59,14 @@ interface EvaluateRevalidationOptions<TEnv> {
59
59
  stale?: boolean;
60
60
  /** Trace source hint for the revalidation trace */
61
61
  traceSource?: RevalidationTraceEntry["source"];
62
+ /**
63
+ * Override the segment-type-derived default. When set, the value is used as
64
+ * the seed `defaultShouldRevalidate` passed to user revalidate fns and the
65
+ * reason flows into the trace. Callers use this when client-knowledge
66
+ * (e.g. parallel slot not in clientSegmentIds) should dictate the seed
67
+ * instead of the params/method-based heuristic.
68
+ */
69
+ defaultOverride?: { value: boolean; reason: string };
62
70
  }
63
71
 
64
72
  /**
@@ -81,6 +89,7 @@ export async function evaluateRevalidation<TEnv>(
81
89
  actionContext,
82
90
  stale,
83
91
  traceSource,
92
+ defaultOverride,
84
93
  } = options;
85
94
  const nextParams = segment.params || {};
86
95
  const paramsChanged = !paramsEqual(nextParams, prevParams);
@@ -110,7 +119,12 @@ export async function evaluateRevalidation<TEnv>(
110
119
  let defaultShouldRevalidate: boolean;
111
120
  let defaultReason: string;
112
121
 
113
- if (request.method === "POST") {
122
+ if (defaultOverride) {
123
+ // Caller injected the seed (e.g. parallel slot not in clientSegmentIds).
124
+ // Skip the type-derived heuristic — caller knows better in this context.
125
+ defaultShouldRevalidate = defaultOverride.value;
126
+ defaultReason = defaultOverride.reason;
127
+ } else if (request.method === "POST") {
114
128
  // Actions: revalidate segments that belong to the route, skip parent chain
115
129
  if (segment.type === "route") {
116
130
  // Route segment always revalidates on actions
@@ -515,6 +515,14 @@ export async function resolveParallelEntry<TEnv>(
515
515
  if (handler === undefined) {
516
516
  continue;
517
517
  }
518
+ // Pin `_currentSegmentId` to the slot's own id so handle pushes from
519
+ // inside the slot handler get their own bucket in the HandleStore.
520
+ // Parent-keying would collapse them into the parent layout's bucket;
521
+ // the partial-update merge then replaces the parent's bucket on a
522
+ // slot-only revalidation and drops layout-pushed Meta/Breadcrumbs.
523
+ // filterSegmentOrder() retains slot ids so the client preserves them.
524
+ (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
525
+ `${parentShortCode}.${slot}`;
518
526
  const doneParallelHandler = track(
519
527
  `handler:${parallelEntry.id}.${slot}`,
520
528
  2,