@rangojs/router 0.0.0-experimental.6fe6a3cc → 0.0.0-experimental.78a48627

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.
@@ -1745,7 +1745,7 @@ import { resolve } from "node:path";
1745
1745
  // package.json
1746
1746
  var package_default = {
1747
1747
  name: "@rangojs/router",
1748
- version: "0.0.0-experimental.6fe6a3cc",
1748
+ version: "0.0.0-experimental.78a48627",
1749
1749
  description: "Django-inspired RSC router with composable URL patterns",
1750
1750
  keywords: [
1751
1751
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.6fe6a3cc",
3
+ "version": "0.0.0-experimental.78a48627",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -472,6 +472,7 @@ export function createNavigationBridge(
472
472
  cachedHandleData,
473
473
  params: cachedParams,
474
474
  },
475
+ scroll: { restore: true, isStreaming },
475
476
  };
476
477
  const hasTransition = cachedSegments.some((s) => s.transition);
477
478
  if (hasTransition) {
@@ -485,9 +486,6 @@ export function createNavigationBridge(
485
486
  onUpdate(popstateUpdate);
486
487
  }
487
488
 
488
- // Restore scroll position for back/forward navigation
489
- handleNavigationEnd({ restore: true, isStreaming });
490
-
491
489
  // SWR: If stale, trigger background revalidation
492
490
  if (isStale) {
493
491
  debugLog("[Browser] Cache is stale, background revalidating...");
@@ -7,7 +7,6 @@ import type {
7
7
  import { generateHistoryKey } from "./navigation-store.js";
8
8
  import {
9
9
  handleNavigationStart,
10
- handleNavigationEnd,
11
10
  ensureHistoryKey,
12
11
  } from "./scroll-restoration.js";
13
12
  import type { EventController, NavigationHandle } from "./event-controller.js";
@@ -81,11 +80,12 @@ export interface BoundTransaction {
81
80
  readonly currentUrl: string;
82
81
  /** Start streaming and get a token to end it when the stream completes */
83
82
  startStreaming(): StreamingToken;
83
+ /** Commit the navigation. Returns the effective scroll option for the caller to handle. */
84
84
  commit(
85
85
  segmentIds: string[],
86
86
  segments: ResolvedSegment[],
87
87
  overrides?: BoundCommitOverrides,
88
- ): void;
88
+ ): { scroll?: boolean };
89
89
  }
90
90
 
91
91
  /**
@@ -93,7 +93,7 @@ export interface BoundTransaction {
93
93
  * Uses the event controller handle for lifecycle management
94
94
  */
95
95
  interface NavigationTransaction extends Disposable {
96
- commit(options: CommitOptions): void;
96
+ commit(options: CommitOptions): { scroll?: boolean };
97
97
  with(
98
98
  options: Omit<CommitOptions, "segmentIds" | "segments">,
99
99
  ): BoundTransaction;
@@ -120,7 +120,7 @@ export function createNavigationTransaction(
120
120
  /**
121
121
  * Commit the navigation - updates store and URL atomically
122
122
  */
123
- function commit(opts: CommitOptions): void {
123
+ function commit(opts: CommitOptions): { scroll?: boolean } {
124
124
  committed = true;
125
125
 
126
126
  const {
@@ -150,7 +150,7 @@ export function createNavigationTransaction(
150
150
  // Without this, the entry lingers and weakens state-machine invariants.
151
151
  handle.complete(parsedUrl);
152
152
  debugLog("[Browser] Cache-only commit, historyKey:", historyKey);
153
- return;
153
+ return { scroll: false };
154
154
  }
155
155
 
156
156
  // Save current scroll position before navigating
@@ -172,7 +172,7 @@ export function createNavigationTransaction(
172
172
  debugLog("[Browser] Store updated (action)");
173
173
  // Complete navigation to clear loading state
174
174
  handle.complete(parsedUrl);
175
- return;
175
+ return { scroll: false };
176
176
  }
177
177
 
178
178
  // Build history state - include user state, intercept info, and server-set state
@@ -205,14 +205,16 @@ export function createNavigationTransaction(
205
205
  // Complete the navigation in event controller (sets idle state, updates location)
206
206
  handle.complete(parsedUrl);
207
207
 
208
- // Handle scroll after navigation
209
- handleNavigationEnd({ scroll });
208
+ // NOTE: Scroll is NOT handled here. The caller (partial-update.ts) handles
209
+ // scroll AFTER onUpdate() so React has the new content before we scroll.
210
210
 
211
211
  debugLog(
212
212
  "[Browser] Navigation committed, historyKey:",
213
213
  historyKey,
214
214
  intercept ? "(intercept)" : "",
215
215
  );
216
+
217
+ return { scroll };
216
218
  }
217
219
 
218
220
  return {
@@ -263,7 +265,7 @@ export function createNavigationTransaction(
263
265
  overrides?.state !== undefined ? overrides.state : opts.state;
264
266
  // Server-set location state: only from overrides (set by partial-update)
265
267
  const serverState = overrides?.serverState;
266
- commit({
268
+ return commit({
267
269
  ...opts,
268
270
  segmentIds,
269
271
  segments,
@@ -246,7 +246,10 @@ export function createPartialUpdater(
246
246
  forceAwait: true,
247
247
  });
248
248
 
249
- tx.commit(matchedIds, existingSegments);
249
+ const { scroll: commitScroll } = tx.commit(
250
+ matchedIds,
251
+ existingSegments,
252
+ );
250
253
 
251
254
  // Include cachedHandleData in metadata so NavigationProvider can restore
252
255
  // breadcrumbs and other handle data from cache.
@@ -260,6 +263,10 @@ export function createPartialUpdater(
260
263
  ...metadataWithoutHandles,
261
264
  cachedHandleData: mode.targetCacheHandleData,
262
265
  },
266
+ scroll:
267
+ commitScroll !== false
268
+ ? { enabled: commitScroll }
269
+ : { enabled: false },
263
270
  };
264
271
 
265
272
  const cachedHasTransition = existingSegments.some(
@@ -290,11 +297,18 @@ export function createPartialUpdater(
290
297
  forceAwait: true,
291
298
  });
292
299
 
293
- tx.commit(matchedIds, existingSegments);
300
+ const { scroll: leaveScroll } = tx.commit(
301
+ matchedIds,
302
+ existingSegments,
303
+ );
294
304
 
295
305
  onUpdate({
296
306
  root: newTree,
297
307
  metadata: payload.metadata,
308
+ scroll:
309
+ leaveScroll !== false
310
+ ? { enabled: leaveScroll }
311
+ : { enabled: false },
298
312
  });
299
313
 
300
314
  debugLog("[Browser] Navigation complete (left intercept)");
@@ -426,7 +440,11 @@ export function createPartialUpdater(
426
440
  : serverLocationState
427
441
  ? { serverState: serverLocationState }
428
442
  : undefined;
429
- tx.commit(allSegmentIds, reconciled.segments, overrides);
443
+ const { scroll: navScroll } = tx.commit(
444
+ allSegmentIds,
445
+ reconciled.segments,
446
+ overrides,
447
+ );
430
448
 
431
449
  // For stale revalidation: verify history key hasn't changed before updating UI
432
450
  if (mode.type === "stale-revalidation") {
@@ -441,8 +459,13 @@ export function createPartialUpdater(
441
459
 
442
460
  debugLog("[partial-update] updating document");
443
461
 
444
- // Emit update to trigger React render
462
+ // Emit update to trigger React render.
463
+ // Scroll info is included so NavigationProvider applies it after React commits.
445
464
  const hasTransition = reconciled.mainSegments.some((s) => s.transition);
465
+ const scrollPayload =
466
+ navScroll !== false
467
+ ? { enabled: navScroll }
468
+ : { enabled: false as const };
446
469
 
447
470
  if (mode.type === "action" || mode.type === "stale-revalidation") {
448
471
  startTransition(() => {
@@ -452,6 +475,7 @@ export function createPartialUpdater(
452
475
  onUpdate({
453
476
  root: newTree,
454
477
  metadata: payload.metadata!,
478
+ scroll: scrollPayload,
455
479
  });
456
480
  });
457
481
  } else if (hasTransition) {
@@ -462,12 +486,14 @@ export function createPartialUpdater(
462
486
  onUpdate({
463
487
  root: newTree,
464
488
  metadata: payload.metadata!,
489
+ scroll: scrollPayload,
465
490
  });
466
491
  });
467
492
  } else {
468
493
  onUpdate({
469
494
  root: newTree,
470
495
  metadata: payload.metadata!,
496
+ scroll: scrollPayload,
471
497
  });
472
498
  }
473
499
 
@@ -494,15 +520,19 @@ export function createPartialUpdater(
494
520
  }
495
521
 
496
522
  const fullUpdateServerState = payload.metadata?.locationState;
497
- if (fullUpdateServerState) {
498
- tx.commit(segmentIds, segments, { serverState: fullUpdateServerState });
499
- } else {
500
- tx.commit(segmentIds, segments);
501
- }
523
+ const { scroll: fullScroll } = fullUpdateServerState
524
+ ? tx.commit(segmentIds, segments, {
525
+ serverState: fullUpdateServerState,
526
+ })
527
+ : tx.commit(segmentIds, segments);
502
528
 
503
529
  const fullHasTransition = segments.some(
504
530
  (s: ResolvedSegment) => s.transition,
505
531
  );
532
+ const fullScrollPayload =
533
+ fullScroll !== false
534
+ ? { enabled: fullScroll }
535
+ : { enabled: false as const };
506
536
 
507
537
  if (mode.type === "stale-revalidation") {
508
538
  await rawStreamComplete;
@@ -513,6 +543,7 @@ export function createPartialUpdater(
513
543
  onUpdate({
514
544
  root: newTree,
515
545
  metadata: payload.metadata!,
546
+ scroll: fullScrollPayload,
516
547
  });
517
548
  });
518
549
  } else if (mode.type === "action") {
@@ -523,6 +554,7 @@ export function createPartialUpdater(
523
554
  onUpdate({
524
555
  root: newTree,
525
556
  metadata: payload.metadata!,
557
+ scroll: fullScrollPayload,
526
558
  });
527
559
  });
528
560
  } else if (fullHasTransition) {
@@ -533,12 +565,14 @@ export function createPartialUpdater(
533
565
  onUpdate({
534
566
  root: newTree,
535
567
  metadata: payload.metadata!,
568
+ scroll: fullScrollPayload,
536
569
  });
537
570
  });
538
571
  } else {
539
572
  onUpdate({
540
573
  root: newTree,
541
574
  metadata: payload.metadata!,
575
+ scroll: fullScrollPayload,
542
576
  });
543
577
  }
544
578
 
@@ -3,8 +3,10 @@
3
3
  import React, {
4
4
  useState,
5
5
  useEffect,
6
+ useLayoutEffect,
6
7
  useCallback,
7
8
  useMemo,
9
+ useRef,
8
10
  use,
9
11
  type ReactNode,
10
12
  } from "react";
@@ -25,6 +27,7 @@ import { ThemeProvider } from "../../theme/ThemeProvider.js";
25
27
  import { NonceContext } from "./nonce-context.js";
26
28
  import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
27
29
  import { cancelAllPrefetches } from "../prefetch/queue.js";
30
+ import { handleNavigationEnd } from "../scroll-restoration.js";
28
31
 
29
32
  /**
30
33
  * Process handles from an async generator, updating the event controller
@@ -301,9 +304,33 @@ export function NavigationProvider({
301
304
  return unsub;
302
305
  }, [eventController]);
303
306
 
307
+ // Pending scroll action to apply after React commits
308
+ const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
309
+
310
+ // Apply scroll after React commits the new content to the DOM
311
+ useLayoutEffect(() => {
312
+ const scrollAction = pendingScrollRef.current;
313
+ if (!scrollAction) return;
314
+ pendingScrollRef.current = undefined;
315
+
316
+ if (scrollAction.enabled === false) return;
317
+
318
+ handleNavigationEnd({
319
+ restore: scrollAction.restore,
320
+ scroll: scrollAction.enabled,
321
+ isStreaming: scrollAction.isStreaming,
322
+ });
323
+ });
324
+
304
325
  // Subscribe to UI updates (for re-rendering the tree)
305
326
  useEffect(() => {
306
327
  const unsubscribe = store.onUpdate((update) => {
328
+ // Capture scroll intent — it will be applied in useLayoutEffect
329
+ // after React commits this state update to the DOM.
330
+ if (update.scroll) {
331
+ pendingScrollRef.current = update.scroll;
332
+ }
333
+
307
334
  setPayload({
308
335
  root: update.root,
309
336
  metadata: update.metadata,
@@ -288,7 +288,11 @@ export function restoreScrollPosition(options?: {
288
288
  // Not streaming — scroll after React commits and browser paints.
289
289
  // startTransition defers the DOM commit, so scrolling synchronously
290
290
  // would be overwritten when React replaces the content.
291
- requestAnimationFrame(() => {
291
+ const defer =
292
+ typeof requestAnimationFrame === "function"
293
+ ? requestAnimationFrame
294
+ : (fn: () => void) => setTimeout(fn, 0);
295
+ defer(() => {
292
296
  window.scrollTo(0, savedY);
293
297
  debugLog("[Scroll] Restored position:", savedY, "for key:", key);
294
298
  });
@@ -366,13 +370,21 @@ export function handleNavigationEnd(options: {
366
370
  // Fall through to hash or top if no saved position
367
371
  }
368
372
 
369
- // Try hash scrolling first
370
- if (scrollToHash()) {
371
- return;
372
- }
373
+ // Defer hash and scroll-to-top to after React paints the new content,
374
+ // so the user doesn't see the current page jump before the new route appears.
375
+ const defer =
376
+ typeof requestAnimationFrame === "function"
377
+ ? requestAnimationFrame
378
+ : (fn: () => void) => setTimeout(fn, 0);
379
+ defer(() => {
380
+ // Try hash scrolling first
381
+ if (scrollToHash()) {
382
+ return;
383
+ }
373
384
 
374
- // Default: scroll to top
375
- scrollToTop();
385
+ // Default: scroll to top
386
+ scrollToTop();
387
+ });
376
388
  }
377
389
 
378
390
  /**
@@ -215,6 +215,15 @@ export interface SegmentState {
215
215
  export interface NavigationUpdate {
216
216
  root: ReactNode | Promise<ReactNode>;
217
217
  metadata: RscMetadata;
218
+ /** Scroll behavior to apply after React commits this update */
219
+ scroll?: {
220
+ /** For back/forward: restore saved position */
221
+ restore?: boolean;
222
+ /** Set to false to disable scrolling entirely */
223
+ enabled?: boolean;
224
+ /** Function to check if streaming is in progress */
225
+ isStreaming?: () => boolean;
226
+ };
218
227
  }
219
228
 
220
229
  /**
@@ -199,7 +199,13 @@ export function registerRouterManifestLoader(
199
199
  }
200
200
 
201
201
  export async function ensureRouterManifest(routerId: string): Promise<void> {
202
- if (perRouterManifestMap.has(routerId)) return;
202
+ // Check both manifest AND trie. The virtual module's setRouterManifest()
203
+ // pre-sets the manifest at startup, but the per-router trie is only
204
+ // available from the lazy loader. Without this, the lazy loader never
205
+ // runs and findMatch falls back to the global merged trie — which
206
+ // contains routes from ALL routers and breaks multi-router setups.
207
+ if (perRouterManifestMap.has(routerId) && perRouterTrieMap.has(routerId))
208
+ return;
203
209
  const loader = routerManifestLoaders.get(routerId);
204
210
  if (loader) {
205
211
  const mod = await loader();
@@ -52,8 +52,10 @@ export function createFindMatch<TEnv = any>(
52
52
  : undefined;
53
53
 
54
54
  // Phase 1: Try trie match (O(path_length))
55
- // Prefer per-router trie (isolated) over global trie (merged).
56
- const routeTrie = getRouterTrie(deps.routerId) ?? getRouteTrie();
55
+ // Only use the per-router trie. The global trie merges routes from ALL
56
+ // routers and must not be used — in multi-router setups (host routing)
57
+ // overlapping paths like "/" would match the wrong app's route.
58
+ const routeTrie = getRouterTrie(deps.routerId);
57
59
  if (routeTrie) {
58
60
  const trieStart = performance.now();
59
61
  const trieResult = tryTrieMatch(routeTrie, pathname);
@@ -188,6 +188,7 @@ export async function resolveInterceptEntry<TEnv>(
188
188
  context,
189
189
  actionContext,
190
190
  stale,
191
+ traceSource: "intercept-loader",
191
192
  });
192
193
 
193
194
  if (!shouldRevalidate) {
@@ -355,6 +356,7 @@ export async function resolveInterceptLoadersOnly<TEnv>(
355
356
  context,
356
357
  actionContext,
357
358
  stale,
359
+ traceSource: "intercept-loader",
358
360
  });
359
361
 
360
362
  if (!shouldRevalidate) {
@@ -14,6 +14,7 @@ export interface LazyEvalDeps<TEnv = any> {
14
14
  mergedRouteMap: Record<string, string>;
15
15
  nextMountIndex: () => number;
16
16
  getPrecomputedByPrefix: () => Map<string, Record<string, string>> | null;
17
+ routerId?: string;
17
18
  }
18
19
 
19
20
  // Detect lazy includes in handler result and create placeholder entries
@@ -200,6 +201,7 @@ export function evaluateLazyEntry<TEnv = any>(
200
201
  trailingSlash: entry.trailingSlash,
201
202
  handler: (lazyInclude.patterns as UrlPatterns<TEnv>).handler,
202
203
  mountIndex: deps.nextMountIndex(),
204
+ routerId: deps.routerId,
203
205
  // Lazy evaluation fields
204
206
  lazy: true,
205
207
  lazyPatterns: lazyInclude.patterns,
@@ -12,7 +12,10 @@ export interface RevalidationTraceEntry {
12
12
  | "cache-hit"
13
13
  | "loader"
14
14
  | "parallel"
15
- | "orphan-layout";
15
+ | "orphan-layout"
16
+ | "route-handler"
17
+ | "layout-handler"
18
+ | "intercept-loader";
16
19
  defaultShouldRevalidate: boolean;
17
20
  finalShouldRevalidate: boolean;
18
21
  reason: string;
@@ -65,7 +65,9 @@ export async function loadManifest(
65
65
  const mountIndex = entry.mountIndex;
66
66
 
67
67
  // Check module-level cache (persists across requests within same isolate)
68
- const cacheKey = `${VERSION}:${mountIndex ?? ""}:${routeKey}:${isSSR ? 1 : 0}`;
68
+ // Include routerId so multi-router setups (host routing) don't share cached
69
+ // EntryData across routers with overlapping mountIndex + routeKey combinations.
70
+ const cacheKey = `${VERSION}:${entry.routerId ?? ""}:${mountIndex ?? ""}:${routeKey}:${isSSR ? 1 : 0}`;
69
71
  const cached = manifestModuleCache.get(cacheKey);
70
72
  if (cached) {
71
73
  const cacheStart = performance.now();
@@ -189,7 +189,10 @@ export interface RouterContext<TEnv = any> {
189
189
  | "cache-hit"
190
190
  | "loader"
191
191
  | "parallel"
192
- | "orphan-layout";
192
+ | "orphan-layout"
193
+ | "route-handler"
194
+ | "layout-handler"
195
+ | "intercept-loader";
193
196
  }) => Promise<boolean>;
194
197
 
195
198
  // Request context
@@ -608,6 +608,8 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
608
608
  context,
609
609
  actionContext,
610
610
  stale,
611
+ traceSource:
612
+ entry.type === "route" ? "route-handler" : "layout-handler",
611
613
  });
612
614
  emitRevalidationDecision(
613
615
  entry.shortCode,
package/src/router.ts CHANGED
@@ -560,6 +560,7 @@ export function createRouter<TEnv = any>(
560
560
  mergedRouteMap,
561
561
  nextMountIndex: () => mountIndex++,
562
562
  getPrecomputedByPrefix,
563
+ routerId,
563
564
  };
564
565
 
565
566
  function evaluateLazyEntry(entry: RouteEntry<TEnv>): void {
@@ -751,6 +752,7 @@ export function createRouter<TEnv = any>(
751
752
  trailingSlash: trailingSlashConfig,
752
753
  handler: urlPatterns.handler,
753
754
  mountIndex: currentMountIndex,
755
+ routerId,
754
756
  cacheProfiles: resolvedCacheProfiles,
755
757
  ...(prerenderRouteKeys ? { prerenderRouteKeys } : {}),
756
758
  ...(passthroughRouteKeys ? { passthroughRouteKeys } : {}),
@@ -770,6 +772,7 @@ export function createRouter<TEnv = any>(
770
772
  trailingSlash: trailingSlashConfig,
771
773
  handler: urlPatterns.handler,
772
774
  mountIndex: currentMountIndex,
775
+ routerId,
773
776
  cacheProfiles: resolvedCacheProfiles,
774
777
  ...(prerenderRouteKeys ? { prerenderRouteKeys } : {}),
775
778
  ...(passthroughRouteKeys ? { passthroughRouteKeys } : {}),
@@ -813,6 +816,7 @@ export function createRouter<TEnv = any>(
813
816
  trailingSlash: trailingSlashConfig,
814
817
  handler: urlPatterns.handler,
815
818
  mountIndex: mountIndex++,
819
+ routerId,
816
820
  // Lazy evaluation fields
817
821
  lazy: true,
818
822
  lazyPatterns: lazyInclude.patterns,
@@ -55,6 +55,13 @@ export interface RouteEntry<TEnv = any> {
55
55
  | Promise<() => Array<AllUseItems>>;
56
56
  mountIndex: number;
57
57
 
58
+ /**
59
+ * Router ID that owns this entry. Used to namespace the manifest cache
60
+ * so multi-router setups (host routing) don't share cached EntryData
61
+ * across routers with overlapping mountIndex + routeKey combinations.
62
+ */
63
+ routerId?: string;
64
+
58
65
  /**
59
66
  * Route keys in this entry that have pre-render handlers.
60
67
  * Used by the non-trie match path to set the `pr` flag.