@rangojs/router 0.0.0-experimental.42 → 0.0.0-experimental.4518794d

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.42",
1748
+ version: "0.0.0-experimental.4518794d",
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.42",
3
+ "version": "0.0.0-experimental.4518794d",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -461,6 +461,10 @@ export function createNavigationBridge(
461
461
  }
462
462
  eventController.setParams(cachedParams);
463
463
 
464
+ // Scroll to top immediately to avoid flicker from the previous
465
+ // page's scroll position while React paints the cached content.
466
+ window.scrollTo(0, 0);
467
+
464
468
  const popstateUpdate = {
465
469
  root,
466
470
  metadata: {
@@ -485,16 +489,8 @@ export function createNavigationBridge(
485
489
  onUpdate(popstateUpdate);
486
490
  }
487
491
 
488
- // Restore scroll position for back/forward navigation.
489
- // Defer to next frame so React has committed the cached content to
490
- // the DOM before we measure scrollHeight and restore scroll position.
491
- const defer =
492
- typeof requestAnimationFrame === "function"
493
- ? requestAnimationFrame
494
- : (fn: () => void) => setTimeout(fn, 0);
495
- defer(() => {
496
- handleNavigationEnd({ restore: true, isStreaming });
497
- });
492
+ // Restore the actual saved scroll position after React paints.
493
+ handleNavigationEnd({ restore: true, isStreaming });
498
494
 
499
495
  // SWR: If stale, trigger background revalidation
500
496
  if (isStale) {
@@ -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,
@@ -19,6 +19,7 @@ import type { BoundTransaction } from "./navigation-transaction.js";
19
19
  import { ServerRedirect } from "../errors.js";
20
20
  import { debugLog } from "./logging.js";
21
21
  import { validateRedirectOrigin } from "./validate-redirect-origin.js";
22
+ import { handleNavigationEnd } from "./scroll-restoration.js";
22
23
 
23
24
  /**
24
25
  * Configuration for creating a partial updater
@@ -246,7 +247,10 @@ export function createPartialUpdater(
246
247
  forceAwait: true,
247
248
  });
248
249
 
249
- tx.commit(matchedIds, existingSegments);
250
+ const { scroll: commitScroll } = tx.commit(
251
+ matchedIds,
252
+ existingSegments,
253
+ );
250
254
 
251
255
  // Include cachedHandleData in metadata so NavigationProvider can restore
252
256
  // breadcrumbs and other handle data from cache.
@@ -276,6 +280,7 @@ export function createPartialUpdater(
276
280
  onUpdate(cachedUpdate);
277
281
  }
278
282
 
283
+ handleNavigationEnd({ scroll: commitScroll });
279
284
  debugLog("[Browser] Navigation complete (rendered from cache)");
280
285
  return;
281
286
  }
@@ -290,13 +295,17 @@ export function createPartialUpdater(
290
295
  forceAwait: true,
291
296
  });
292
297
 
293
- tx.commit(matchedIds, existingSegments);
298
+ const { scroll: leaveScroll } = tx.commit(
299
+ matchedIds,
300
+ existingSegments,
301
+ );
294
302
 
295
303
  onUpdate({
296
304
  root: newTree,
297
305
  metadata: payload.metadata,
298
306
  });
299
307
 
308
+ handleNavigationEnd({ scroll: leaveScroll });
300
309
  debugLog("[Browser] Navigation complete (left intercept)");
301
310
  return;
302
311
  }
@@ -426,7 +435,11 @@ export function createPartialUpdater(
426
435
  : serverLocationState
427
436
  ? { serverState: serverLocationState }
428
437
  : undefined;
429
- tx.commit(allSegmentIds, reconciled.segments, overrides);
438
+ const { scroll: navScroll } = tx.commit(
439
+ allSegmentIds,
440
+ reconciled.segments,
441
+ overrides,
442
+ );
430
443
 
431
444
  // For stale revalidation: verify history key hasn't changed before updating UI
432
445
  if (mode.type === "stale-revalidation") {
@@ -471,6 +484,9 @@ export function createPartialUpdater(
471
484
  });
472
485
  }
473
486
 
487
+ // Scroll after onUpdate so React has the new content before we scroll
488
+ handleNavigationEnd({ scroll: navScroll });
489
+
474
490
  debugLog("[Browser] Navigation complete");
475
491
  return;
476
492
  } else {
@@ -494,11 +510,11 @@ export function createPartialUpdater(
494
510
  }
495
511
 
496
512
  const fullUpdateServerState = payload.metadata?.locationState;
497
- if (fullUpdateServerState) {
498
- tx.commit(segmentIds, segments, { serverState: fullUpdateServerState });
499
- } else {
500
- tx.commit(segmentIds, segments);
501
- }
513
+ const { scroll: fullScroll } = fullUpdateServerState
514
+ ? tx.commit(segmentIds, segments, {
515
+ serverState: fullUpdateServerState,
516
+ })
517
+ : tx.commit(segmentIds, segments);
502
518
 
503
519
  const fullHasTransition = segments.some(
504
520
  (s: ResolvedSegment) => s.transition,
@@ -542,6 +558,7 @@ export function createPartialUpdater(
542
558
  });
543
559
  }
544
560
 
561
+ handleNavigationEnd({ scroll: fullScroll });
545
562
  return;
546
563
  }
547
564
  }
@@ -370,13 +370,21 @@ export function handleNavigationEnd(options: {
370
370
  // Fall through to hash or top if no saved position
371
371
  }
372
372
 
373
- // Try hash scrolling first
374
- if (scrollToHash()) {
375
- return;
376
- }
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
+ }
377
384
 
378
- // Default: scroll to top
379
- scrollToTop();
385
+ // Default: scroll to top
386
+ scrollToTop();
387
+ });
380
388
  }
381
389
 
382
390
  /**