@rshono/core 1.0.0-rc.21 → 1.0.0-rc.23

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.
package/README.md CHANGED
@@ -322,8 +322,11 @@ Every target streams, which is the bar a new one has to clear.
322
322
  - **Soft navigation needs the [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API)**
323
323
  — Chrome/Edge 135, Firefox 147, Safari 26.2, [Baseline](https://web.dev/blog/baseline-navigation-api) since
324
324
  January 2026. Where it is missing there is no interception at all and every link is a real browser load,
325
- which a server-rendered app answers correctly; only the soft part is gone. Scroll restoration, the fragment
326
- jump and the post-navigation focus reset are all the browser's.
325
+ which a server-rendered app answers correctly; only the soft part is gone. On a soft navigation the runtime
326
+ owns the scroll: a push reaches the top or its fragment, a traversal returns to where the entry was left,
327
+ and focus resets — `history.scrollRestoration` is `manual` for the document and positions are kept per
328
+ entry, in `sessionStorage` too, so a reload lands where the last document was left. A same-page fragment
329
+ stays the browser's, since it never changes the payload.
327
330
  - **`redirect()` and `notFound()` must be reached before the page shell is sent.** A page streams: the status
328
331
  line and the first bytes go out as soon as the shell is ready, and HTTP has no take-backs after that. Called
329
332
  from a `<Suspense>` boundary that resolves later, the signal can no longer be a 3xx or a 404 — the response
@@ -40,6 +40,12 @@ const flightStream = readFlightPayload();
40
40
  * never sees. Two URLs that differ only by `#hash` describe the same payload.
41
41
  */
42
42
  const documentUrl = () => location.pathname + location.search;
43
+ /**
44
+ * The document URL the payload on screen was rendered for — see {@link documentUrl}. A fragment-only
45
+ * traversal leaves it unchanged, which is how the `popstate` listener knows there is nothing to fetch.
46
+ * Updated in the layout effect that commits a payload.
47
+ */
48
+ let renderedUrl = documentUrl();
43
49
  /** Guarantees somewhere to attach the fatal overlay: the root container is `document`, so a teardown can take `<body>` with it. */
44
50
  function overlayHost() {
45
51
  if (!document.documentElement)
@@ -101,7 +107,7 @@ function showFatal(error, componentStack) {
101
107
  reload.textContent = 'Reload page';
102
108
  reload.style.cssText =
103
109
  'margin-top:1.25rem;padding:0.5rem 1rem;font:inherit;color:#18181b;background:#f4f4f5;border:0;border-radius:4px;cursor:pointer';
104
- reload.addEventListener('click', () => loadOutsideRouter(() => window.location.reload()));
110
+ reload.addEventListener('click', () => loadDocument());
105
111
  box.appendChild(reload);
106
112
  });
107
113
  }
@@ -168,6 +174,101 @@ function requestPayload(href, signal) {
168
174
  * would take the whole client runtime down with it rather than degrading anything.
169
175
  */
170
176
  const canSoftNavigate = typeof navigation !== 'undefined' && typeof NavigateEvent !== 'undefined' && 'sourceElement' in NavigateEvent.prototype;
177
+ /**
178
+ * The runtime owns a soft navigation's scroll, so the browser's own restoration has to be off: left on, a
179
+ * traversal restores the destination's offset against the outgoing tree before the payload for that entry
180
+ * has even been asked for, and the incoming tree overwrites it. `manual` only concerns the entries this
181
+ * document creates — a navigation to another document creates its own entry `auto` — and it also turns the
182
+ * browser's reload restoration off, which {@link readStoredScrollPositions} and the first layout effect put
183
+ * back.
184
+ *
185
+ * Gated on the soft router: below the Navigation API floor every navigation is a real document load, which
186
+ * a server-rendered app answers correctly and the browser restores correctly, and taking that over without
187
+ * a router to repaint the entry would strand the visitor at the top.
188
+ */
189
+ if (canSoftNavigate) {
190
+ try {
191
+ history.scrollRestoration = 'manual';
192
+ }
193
+ catch {
194
+ // A preference, not a requirement: where it cannot be set, the browser's restoration and the runtime's
195
+ // own can race on a traversal, and the runtime's runs at commit, after.
196
+ }
197
+ }
198
+ /**
199
+ * Where each history entry of this document was left, keyed by its Navigation API key. In memory for the
200
+ * document's life; {@link persistScrollPositions} also writes it to `sessionStorage` so a reload — which
201
+ * the `manual` mode above stops the browser restoring — lands where the last document was left.
202
+ */
203
+ const scrollPositions = new Map();
204
+ /** The `sessionStorage` key holding {@link scrollPositions}. */
205
+ const SCROLL_STORAGE_KEY = 'rshono:scroll';
206
+ /** How many entries are kept in that snapshot; the oldest falls out first — `Map` iteration order. */
207
+ const SCROLL_STORAGE_LIMIT = 50;
208
+ /** The key of the history entry being shown, or `undefined` where the soft router does not exist. */
209
+ function currentEntryKey() {
210
+ return canSoftNavigate ? navigation.currentEntry?.key : undefined;
211
+ }
212
+ /**
213
+ * Records where the outgoing entry is being left. Read at `navigate` time — before the browser commits the
214
+ * new entry — and at `pagehide`, so whatever the navigation turns out to be, the entry it leaves has a
215
+ * position to come back to.
216
+ */
217
+ function rememberCurrentScroll() {
218
+ const key = currentEntryKey();
219
+ if (key)
220
+ scrollPositions.set(key, { x: window.scrollX, y: window.scrollY });
221
+ }
222
+ /** The position `key` was left at, or `undefined` when this document never saw it. */
223
+ function scrollPointFor(key) {
224
+ return key === undefined ? undefined : scrollPositions.get(key);
225
+ }
226
+ /**
227
+ * Reads the last document's snapshot back into {@link scrollPositions}. Called once, on startup, before the
228
+ * first payload commits: the in-memory map alone would lose every entry the moment the document is replaced.
229
+ */
230
+ function readStoredScrollPositions() {
231
+ try {
232
+ const raw = sessionStorage.getItem(SCROLL_STORAGE_KEY);
233
+ if (!raw)
234
+ return;
235
+ for (const [key, point] of Object.entries(JSON.parse(raw))) {
236
+ if (Number.isFinite(point?.x) && Number.isFinite(point?.y))
237
+ scrollPositions.set(key, point);
238
+ }
239
+ }
240
+ catch {
241
+ // Blocked site data, or a snapshot written by a different version. Either way there is nothing to restore,
242
+ // and the page is still correct without it.
243
+ }
244
+ }
245
+ /** Writes {@link scrollPositions} out, oldest first, bounded — see {@link SCROLL_STORAGE_LIMIT}. */
246
+ function persistScrollPositions() {
247
+ try {
248
+ while (scrollPositions.size > SCROLL_STORAGE_LIMIT) {
249
+ const oldest = scrollPositions.keys().next().value;
250
+ if (oldest === undefined)
251
+ break;
252
+ scrollPositions.delete(oldest);
253
+ }
254
+ sessionStorage.setItem(SCROLL_STORAGE_KEY, JSON.stringify(Object.fromEntries(scrollPositions)));
255
+ }
256
+ catch {
257
+ // Blocked site data: the in-memory map still carries this document's navigations.
258
+ }
259
+ }
260
+ /**
261
+ * The snapshot starts with the document, not with hydration: the page is visible and clickable while the
262
+ * initial payload streams, and a reload or a navigation away in that window would otherwise lose the offset
263
+ * now that `manual` has stopped the browser restoring it. Nothing here depends on the router being mounted,
264
+ * so it does not wait for {@link listenNavigation}.
265
+ */
266
+ if (canSoftNavigate) {
267
+ window.addEventListener('pagehide', () => {
268
+ rememberCurrentScroll();
269
+ persistScrollPositions();
270
+ });
271
+ }
171
272
  /**
172
273
  * Drops a navigation's result promises. Both reject when a navigation is superseded or cancelled — routine
173
274
  * here, since a second click is meant to abandon the first — and unhandled they would be reported as faults.
@@ -177,26 +278,36 @@ function settle(result) {
177
278
  void result.committed?.catch(ignore);
178
279
  void result.finished?.catch(ignore);
179
280
  }
180
- /** Set by {@link loadOutsideRouter}, read and cleared by the `navigate` listener. */
181
- let bypassRouter = false;
281
+ /**
282
+ * The mark {@link loadDocument} puts on its navigations, so the `navigate` listener below recognizes them as
283
+ * the runtime's own. A symbol because the identity has to survive the trip through the browser intact:
284
+ * `NavigateEvent.info` hands the value back by reference, so only the navigation that was given it matches.
285
+ */
286
+ const documentNavigation = Symbol('rshono:document-navigation');
182
287
  /**
183
288
  * Performs a navigation the router below must **not** intercept, and returns having asked for it.
184
289
  *
185
- * `location.reload()` and `location.assign()` fire a `navigate` event like any other navigation, and
186
- * `listenNavigation` intercepts a `reload` on purpose — that is what `router.refresh()` is. Every caller here
187
- * is reaching for a *new document* precisely because the current one cannot be repaired: the React root a
188
- * soft load would render into is the thing that just failed, or is about to be torn down. Intercepted, the
290
+ * `listenNavigation` intercepts a `reload` on purpose — that is what `router.refresh()` is — and every caller
291
+ * here is reaching for a *new document* precisely because the current one cannot be repaired: the React root
292
+ * a soft load would render into is the thing that just failed, or is about to be torn down. Intercepted, the
189
293
  * escape hatch becomes a payload fetch that lands nowhere — which is how a late `notFound()` left the tab on
190
294
  * its Suspense fallback with no second document ever arriving, and how a late `redirect()` moved the address
191
295
  * bar to a page it then failed to render.
192
296
  *
193
- * One-shot: the listener clears the flag on the next event it sees. If that event never comes — a navigation
194
- * the browser refuses — the cost is that the *next* navigation is a full load rather than a soft one, on a
195
- * document that was on its way out anyway.
297
+ * The mark rides the navigation itself, through `info`, so the listener recognizes the event that owns it
298
+ * instead of consuming a flag set in advance. There is no state to clear and none to leak: a navigation the
299
+ * browser refuses cannot make the next one a full load. Below the Navigation API there is no interception to
300
+ * opt out of, and `location.*` is already a document load.
196
301
  */
197
- function loadOutsideRouter(navigate) {
198
- bypassRouter = true;
199
- navigate();
302
+ function loadDocument(href) {
303
+ if (!canSoftNavigate) {
304
+ if (href === undefined)
305
+ window.location.reload();
306
+ else
307
+ window.location.assign(href);
308
+ return;
309
+ }
310
+ settle(href === undefined ? navigation.reload({ info: documentNavigation }) : navigation.navigate(href, { info: documentNavigation }));
200
311
  }
201
312
  // The imperative actions behind `useNavigation().router`. Each one only *asks*: the browser turns it into a
202
313
  // `navigate` event, which is where `listenNavigation` answers it — so a `router.push` and a link click reach
@@ -272,7 +383,7 @@ function reloadOnceForLateNotFound() {
272
383
  showLateNotFound();
273
384
  return;
274
385
  }
275
- loadOutsideRouter(() => window.location.reload());
386
+ loadDocument();
276
387
  // The reload wins this race whenever it happens at all: the document goes away and takes the timer with
277
388
  // it. What this covers is a reload that does not happen — swallowed by an interceptor, refused by the
278
389
  // browser, held by a `beforeunload` — which used to leave the visitor on a Suspense fallback with nothing
@@ -298,17 +409,79 @@ function handleControlDigest(error, { hard = false } = {}) {
298
409
  reloadOnceForLateNotFound();
299
410
  }
300
411
  else if (hard) {
301
- loadOutsideRouter(() => window.location.assign(new URL(redirect.location, window.location.href).href));
412
+ // The URL is resolved first: a malformed location then throws here, before any navigation is asked for.
413
+ loadDocument(new URL(redirect.location, window.location.href).href);
302
414
  }
303
415
  else {
304
416
  push(redirect.location);
305
417
  }
306
418
  return true;
307
419
  }
420
+ /**
421
+ * Scrolls the document to its start.
422
+ *
423
+ * The options form rather than `scrollTo(0, 0)`: the two-argument call is `auto`, which follows a
424
+ * `scroll-behavior` the app may have set on `html`, and a soft navigation that animates its own reset reads
425
+ * as a glitch rather than a page change.
426
+ */
427
+ function scrollToTop() {
428
+ window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
429
+ }
430
+ /** Puts a saved offset back. Instant for the same reason {@link scrollToTop} passes it. */
431
+ function scrollToPoint(point) {
432
+ window.scrollTo({ top: point.y, left: point.x, behavior: 'instant' });
433
+ }
434
+ /**
435
+ * Scrolls to a fragment's target the way the browser's own fragment jump does.
436
+ *
437
+ * `scrollIntoView` is the algorithm that honours `scroll-padding-top` on the scrolling box and
438
+ * `scroll-margin-top` on the target, which `window.scrollTo` does not. The lookup follows the browser's
439
+ * "find a potential indicated element": the id first, then the name. A malformed percent-escape falls back
440
+ * to the literal fragment, and a fragment nothing matches gets the top of the document — what a browser
441
+ * gives a missing anchor on a real load.
442
+ */
443
+ function jumpToAnchor(hash) {
444
+ const raw = hash.startsWith('#') ? hash.slice(1) : hash;
445
+ // No special case for `#top`: the browser only treats it as the top of the document when no element
446
+ // matches, and the fallback below is that top.
447
+ if (raw === '') {
448
+ scrollToTop();
449
+ return;
450
+ }
451
+ let id = raw;
452
+ try {
453
+ id = decodeURIComponent(raw);
454
+ }
455
+ catch {
456
+ // Malformed escape — the literal fragment is the better guess at the id than nothing.
457
+ }
458
+ const target = document.getElementById(id) ?? document.getElementsByName(id)[0];
459
+ if (target)
460
+ target.scrollIntoView();
461
+ else
462
+ scrollToTop();
463
+ }
464
+ /**
465
+ * Moves focus the way the browser's `focusReset: 'after-transition'` does: the first `autofocus` element,
466
+ * else the document body. A traversal is no longer intercepted, so the browser performs no focus reset for
467
+ * it; without this, Back would leave focus on the link that was clicked on the page being left, and the next
468
+ * Tab would resume there. `preventScroll` because the traversal's own offset has just been restored.
469
+ */
470
+ function resetFocus() {
471
+ const autofocus = document.querySelector('[autofocus]');
472
+ if (autofocus)
473
+ autofocus.focus({ preventScroll: true });
474
+ else
475
+ document.body.focus({ preventScroll: true });
476
+ }
308
477
  /**
309
478
  * Puts a payload on screen, resolving once React has committed it. Replaced by `BrowserRoot`'s own on mount;
310
479
  * the default covers the window before hydration, where `setServerCallback` is already registered but there
311
480
  * is no root to update — a reload is the honest answer, and nothing after it needs to run.
481
+ *
482
+ * `afterCommit`, when given, runs in the same layout effect that releases the commit: after the new tree is
483
+ * in the DOM and before the browser paints, which is the only moment a `#hash` target exists and the
484
+ * pre-scroll position has not been shown.
312
485
  */
313
486
  let setPayload = () => {
314
487
  window.location.reload();
@@ -318,31 +491,48 @@ let setPayload = () => {
318
491
  let startNav = (run) => {
319
492
  void run();
320
493
  };
494
+ /**
495
+ * The navigation whose payload is allowed to settle the screen. React runs async work concurrently, so two
496
+ * navigations are two live fetches with no ordering between them; without this a slow first response landing
497
+ * after a fast second one repaints the page the user already left. The Navigation API aborts an intercepted
498
+ * navigation when a newer one starts, but a traversal has no `event.signal` to watch — `popstate` arrives
499
+ * after the browser has already committed it — so ordering is the runtime's own for those.
500
+ */
501
+ let currentNavigation = 0;
502
+ /** The in-flight navigation's fetch, so a newer one can stop paying for it. */
503
+ let navigationFetch = null;
321
504
  /**
322
505
  * Fetches the payload for `url` and puts it on screen.
323
506
  *
324
- * Resolves once React has **committed** it rather than when the fetch lands: an intercepted navigation
325
- * scrolls and moves focus when this promise settles, and a `#hash` target does not exist until the new tree
326
- * does. Rejects only on a genuine failure — being superseded is not one, and resolves quietly, because the
327
- * navigation that replaced this one owns the screen from then on.
507
+ * Resolves once React has **committed** it rather than when the fetch lands: `afterCommit` runs at that
508
+ * point, and a `#hash` target does not exist until the new tree does. Rejects only on a genuine failure —
509
+ * being superseded is not one, and resolves quietly, because the navigation that replaced this one owns the
510
+ * screen from then on.
328
511
  */
329
- function loadPayload(url, signal) {
512
+ function loadPayload(url, signal, afterCommit) {
513
+ // This navigation's place in the queue, and its own abort switch: a fetch is abandoned by whichever comes
514
+ // first, the browser superseding it (an intercepted navigation carries `event.signal`) or a newer runtime
515
+ // fetch starting (a traversal, an action, a dev refresh).
516
+ const navigation = ++currentNavigation;
517
+ navigationFetch?.abort();
518
+ const controller = (navigationFetch = new AbortController());
519
+ const abort = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
330
520
  // Deliberately not awaited inside the transition: the scope ends once the payload is handed to React, and
331
521
  // React holds `pending` until the update it scheduled commits. Awaiting the commit *inside* the scope would
332
522
  // work too, but only because React happens not to gate a commit on its async scope settling — an internal
333
523
  // this has no reason to depend on across the whole `^19.1.0` peer range.
334
524
  let committed;
335
525
  const run = async () => {
336
- const payload = await requestPayload(url, signal);
337
- // The browser aborts a navigation the moment a newer one starts. Checked again after the await because
338
- // the fetch may already have resolved by then, and applying it would repaint a page the user has left.
339
- if (signal?.aborted)
526
+ const payload = await requestPayload(url, abort);
527
+ // Checked again after the await because the fetch may already have resolved by then, and applying it
528
+ // would repaint a page the user has left.
529
+ if (abort.aborted || navigation !== currentNavigation)
340
530
  return;
341
531
  if (payload.redirect) {
342
532
  push(payload.redirect);
343
533
  return;
344
534
  }
345
- committed = setPayload(payload);
535
+ committed = setPayload(payload, afterCommit);
346
536
  };
347
537
  // `startTransition` runs the work but hands nothing back, so the promise carrying a failure is caught here
348
538
  // instead. Assigned synchronously: React invokes the callback before `startNav` returns.
@@ -353,11 +543,28 @@ function loadPayload(url, signal) {
353
543
  () => committed, (error) => {
354
544
  // Checked before the error is read: an abort is this navigation being replaced, and the one that
355
545
  // replaced it owns the outcome.
356
- if (signal?.aborted || handleControlDigest(error))
546
+ if (abort.aborted || navigation !== currentNavigation || handleControlDigest(error))
357
547
  return;
358
548
  throw error;
359
549
  });
360
550
  }
551
+ /**
552
+ * Whether a destination names a file rather than a page. `public/`, `/_static` and an endpoint route that
553
+ * serves a document (`/llms.txt`, `/sitemap.xml`) answer an RSC fetch with the file itself, not a flight
554
+ * payload — so intercepting one buys nothing: the payload never arrives, and the only recovery left is the
555
+ * document load the browser would have made directly. Handing it that navigation up front also gives the
556
+ * entry the file loads into the browser's own history, so Back is an ordinary cross-document traversal. An
557
+ * intercepted entry is same-document by construction, and traversing one with nothing to repaint it changes
558
+ * the URL and nothing else — which is what a Back press out of an opened file was doing.
559
+ *
560
+ * A dot in the last path segment rather than a list of extensions: a list is never complete, and every miss
561
+ * is the failed round trip above. A page route whose last segment carries a dot (`/release-1.0`) therefore
562
+ * costs a document load — the direction to err in, and what a `data-native` link already asks for by hand.
563
+ */
564
+ function namesAFile(href) {
565
+ const lastSegment = new URL(href).pathname.split('/').pop() ?? '';
566
+ return lastSegment.includes('.');
567
+ }
361
568
  /**
362
569
  * Navigations the browser can hand over but shouldn't:
363
570
  *
@@ -366,10 +573,15 @@ function loadPayload(url, signal) {
366
573
  * - a download, which is not a navigation of this page at all;
367
574
  * - a `POST` form, which is a submission and the server's to answer (a `GET` form carries its fields in the
368
575
  * URL, has no `formData`, and soft-navigates like any other link);
369
- * - a link marked `data-native`, the documented opt-out.
576
+ * - a link marked `data-native`, the documented opt-out;
577
+ * - a destination that names a file — see {@link namesAFile}.
370
578
  */
371
579
  function leaveToBrowser(event) {
372
- return event.hashChange || event.downloadRequest !== null || event.formData !== null || event.sourceElement?.hasAttribute('data-native') === true;
580
+ return (event.hashChange ||
581
+ event.downloadRequest !== null ||
582
+ event.formData !== null ||
583
+ event.sourceElement?.hasAttribute('data-native') === true ||
584
+ namesAFile(event.destination.url));
373
585
  }
374
586
  /**
375
587
  * The whole router, in one listener.
@@ -384,29 +596,81 @@ function listenNavigation() {
384
596
  if (!canSoftNavigate)
385
597
  return () => { };
386
598
  const onNavigate = (event) => {
387
- // Cleared as it is consumed, whatever this event turns out to be: the flag names one navigation, and the
388
- // one it named is the one that just arrived.
389
- if (bypassRouter) {
390
- bypassRouter = false;
599
+ // A document navigation the runtime asked for itself, marked through `info`. Checked before the scroll
600
+ // snapshot: the document is going away, so where its entry was left does not matter, and an unrelated
601
+ // later navigation can never be mistaken for this one.
602
+ if (event.info === documentNavigation)
391
603
  return;
392
- }
604
+ // Whatever the browser is about to do with this navigation, the entry it is leaving is about to lose the
605
+ // offset it was at, and nothing else in this document will put it back. Saved before the branches below
606
+ // return, so a fragment the browser performs itself is covered too.
607
+ rememberCurrentScroll();
393
608
  if (!event.canIntercept || leaveToBrowser(event))
394
609
  return;
395
- // A push or a traversal lands on a new page, so the browser resets the scroll offset — or restores the
396
- // one it remembers — and moves focus, which is what makes a soft navigation announce itself to a screen
397
- // reader. A replace or a refresh stays where it is, so neither should move. Both wait on the handler,
398
- // which is the point of resolving it at commit rather than at fetch.
610
+ // A traversal is repainted by the `popstate` listener below, not intercepted. `intercept`ing one is what
611
+ // makes WebKit stall the rendered viewport for about three seconds on a back swipe
612
+ // (bugs.webkit.org/319414): the view gesture's snapshot is only removed once the navigation finishes, and
613
+ // React's root attaches the wheel listener the bug also needs. Letting the traversal through means the
614
+ // browser commits the entry straight away, and the runtime puts the payload on screen when it arrives.
615
+ if (event.navigationType === 'traverse')
616
+ return;
617
+ // A replace or a refresh stays where it is, so neither should move. A push starts at the top of the
618
+ // page, or at its fragment — and that scroll is the runtime's own now: WebKit performs no
619
+ // `after-transition` reset at all for an intercepted push (bugs.webkit.org/304593). Chromium skips it
620
+ // too, and a fragment jump rides the same code path. Passing `manual` here makes the browser hand the
621
+ // handler over without scrolling; the `afterCommit` callback reaches the target once the new tree is on
622
+ // screen. Focus stays the browser's, reset after the transition.
399
623
  const inPlace = event.navigationType === 'replace' || event.navigationType === 'reload';
624
+ let afterCommit;
625
+ if (event.navigationType === 'push') {
626
+ const { hash } = new URL(event.destination.url);
627
+ afterCommit = hash === '' ? scrollToTop : () => jumpToAnchor(hash);
628
+ }
400
629
  event.intercept({
401
- scroll: inPlace ? 'manual' : 'after-transition',
630
+ scroll: 'manual',
402
631
  focusReset: inPlace ? 'manual' : 'after-transition',
403
632
  // The URL commits before the handler runs, so a failure leaves the address bar describing a page the
404
633
  // document is not showing. A real load is the only way back to agreement.
405
- handler: () => loadPayload(event.destination.url, event.signal).catch(() => loadOutsideRouter(() => window.location.reload())),
634
+ handler: () => loadPayload(event.destination.url, event.signal, afterCommit).catch(() => loadDocument()),
406
635
  });
407
636
  };
637
+ /**
638
+ * Repaints a traversal the listener above deliberately left to the browser, and moves the viewport back
639
+ * where the entry was left. `popstate` fires after the browser has committed the entry, so the destination
640
+ * is `navigation.currentEntry` and its saved offset is in {@link scrollPositions}.
641
+ */
642
+ const onPopState = () => {
643
+ const stored = scrollPointFor(currentEntryKey());
644
+ const hash = location.hash;
645
+ const apply = () => {
646
+ if (stored)
647
+ scrollToPoint(stored);
648
+ else if (hash !== '')
649
+ jumpToAnchor(hash);
650
+ else
651
+ scrollToTop();
652
+ };
653
+ // A fragment-only traversal never changed the payload: the address bar is back at an anchor of the page
654
+ // already on screen, so there is nothing to fetch and nothing to wait for. The browser's own restoration
655
+ // is off, so the offset comes from what `onNavigate` saved when the anchor was followed; no focus reset,
656
+ // which is what leaving those navigations to the browser has always meant.
657
+ if (documentUrl() === renderedUrl) {
658
+ apply();
659
+ return;
660
+ }
661
+ void loadPayload(location.href, undefined, () => {
662
+ apply();
663
+ // The browser performs this for an intercepted navigation; a traversal is no longer intercepted, so
664
+ // without it Back would leave focus on the link that was clicked on the page being left.
665
+ resetFocus();
666
+ }).catch(() => loadDocument());
667
+ };
408
668
  navigation.addEventListener('navigate', onNavigate);
409
- return () => navigation.removeEventListener('navigate', onNavigate);
669
+ window.addEventListener('popstate', onPopState);
670
+ return () => {
671
+ navigation.removeEventListener('navigate', onNavigate);
672
+ window.removeEventListener('popstate', onPopState);
673
+ };
410
674
  }
411
675
  async function main() {
412
676
  // The assertion is load-bearing under the compiler that builds this: TypeScript 7 declares `nonce` on
@@ -433,26 +697,46 @@ async function main() {
433
697
  // Blocked site data. `reloadOnceForLateNotFound` already treats that as the terminating case.
434
698
  }
435
699
  }
700
+ // Everything the last document saved, read before any payload commits: the in-memory map alone would not
701
+ // survive the reload that `history.scrollRestoration = 'manual'` just stopped the browser restoring. The
702
+ // entry's key is stable across a reload, and the offset is applied by `BrowserRoot`'s first layout effect,
703
+ // before the first paint of the new tree.
704
+ if (canSoftNavigate)
705
+ readStoredScrollPositions();
706
+ const restored = scrollPointFor(currentEntryKey());
436
707
  function BrowserRoot() {
437
708
  const [payload, setPayloadState] = React.useState(initialPayload);
438
709
  const [pending, startTransition] = React.useTransition();
439
710
  // The resolver the payload on screen still owes — see {@link loadPayload}.
440
711
  const pendingCommit = React.useRef(null);
712
+ // The scroll the payload about to commit owes, set with it so a payload that supersedes another takes
713
+ // its predecessor's scroll out of the queue along with its commit. Initialised with the reload's offset:
714
+ // the first layout effect below applies it with the first payload, before the new tree is painted.
715
+ const pendingScroll = React.useRef(restored ? () => scrollToPoint(restored) : null);
441
716
  React.useEffect(() => {
442
- setPayload = (next) => new Promise((resolve) => {
717
+ setPayload = (next, afterCommit) => new Promise((resolve) => {
443
718
  // A payload replaced before it ever painted still has a navigation waiting on it. React commits
444
719
  // only the newest, so the effect below never runs for the one it skipped: release it here.
445
720
  pendingCommit.current?.();
446
721
  pendingCommit.current = resolve;
722
+ // Replaced rather than kept: a server action's payload carries no `afterCommit`, and the
723
+ // navigation it superseded must not scroll the page the action is about to replace it with.
724
+ pendingScroll.current = afterCommit ?? null;
447
725
  setPayloadState(next);
448
726
  });
449
727
  startNav = (run) => startTransition(run);
450
728
  }, [startTransition]);
451
729
  /**
452
- * Releases the navigation waiting on this payload, which is what lets the browser scroll and move focus
453
- * now that their target exists. A layout effect, so the pre-scroll position is never painted.
730
+ * Performs the pending scroll and releases the navigation waiting on this payload. A layout effect, so
731
+ * the new tree is in the DOM and the pre-scroll position is never painted.
454
732
  */
455
733
  React.useLayoutEffect(() => {
734
+ const scroll = pendingScroll.current;
735
+ pendingScroll.current = null;
736
+ scroll?.();
737
+ // What the payload that just committed was rendered for — see {@link renderedUrl}. A redirect never
738
+ // reaches here, and an action or a dev refresh keeps the URL it was fetched for.
739
+ renderedUrl = documentUrl();
456
740
  const commit = pendingCommit.current;
457
741
  pendingCommit.current = null;
458
742
  commit?.();
@@ -549,7 +833,7 @@ function initDevRefresh() {
549
833
  let targetHash;
550
834
  function reload(reason, error) {
551
835
  console.warn(`[rshono] ${reason} — reloading`, ...(error === undefined ? [] : [error]));
552
- loadOutsideRouter(() => window.location.reload());
836
+ loadDocument();
553
837
  }
554
838
  async function applyClientUpdate() {
555
839
  const giveUp = await walkHotUpdates(hot, () => __webpack_hash__, () => targetHash);
@@ -562,7 +846,7 @@ function initDevRefresh() {
562
846
  targetHash = message.hash ?? targetHash;
563
847
  if (connectedOnce) {
564
848
  await applyClientUpdate();
565
- await loadPayload(window.location.href).catch(() => loadOutsideRouter(() => window.location.reload()));
849
+ await loadPayload(window.location.href).catch(() => loadDocument());
566
850
  }
567
851
  connectedOnce = true;
568
852
  break;
@@ -572,7 +856,7 @@ function initDevRefresh() {
572
856
  break;
573
857
  case 'rsc-update':
574
858
  console.log('[rshono] server components updated');
575
- await loadPayload(window.location.href).catch(() => loadOutsideRouter(() => window.location.reload()));
859
+ await loadPayload(window.location.href).catch(() => loadDocument());
576
860
  break;
577
861
  }
578
862
  }