@rshono/core 1.0.0-rc.27 → 1.0.0-rc.29

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.
@@ -2,12 +2,14 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React from 'react';
3
3
  import { hydrateRoot } from 'react-dom/client';
4
4
  import { createFromFetch, createFromReadableStream, createTemporaryReferenceSet, encodeReply, setServerCallback, } from 'react-server-dom-rspack/client.browser';
5
+ import { cancelNavigationPreparation, isPreparedNavigation, listenNavigationPreparation, useNavigationPreparation, } from './navigation-preparation.js';
5
6
  import { isControlDigest, parseRedirectDigest } from './control.js';
6
7
  // Dev-only: its one caller sits behind `import.meta.webpackHot`, which a production build compiles to
7
8
  // `false` — so this module is dropped there.
8
9
  import { walkHotUpdates } from './hot-update.js';
9
10
  import { RouterContext } from './navigation.js';
10
11
  import { createRscRequest } from './request.js';
12
+ import { startNavigationPayload, useNavigationTransition } from './navigation-transition.js';
11
13
  const isDev = process.env.NODE_ENV === 'development';
12
14
  /** The flight payload the document carried, read back out of `__FLIGHT_DATA` — see `flight-inject.ts`. */
13
15
  function readFlightPayload() {
@@ -46,6 +48,8 @@ const documentUrl = () => location.pathname + location.search;
46
48
  * Updated in the layout effect that commits a payload.
47
49
  */
48
50
  let renderedUrl = documentUrl();
51
+ let renderedModel;
52
+ const navigationModels = new WeakMap();
49
53
  /** Guarantees somewhere to attach the fatal overlay: the root container is `document`, so a teardown can take `<body>` with it. */
50
54
  function overlayHost() {
51
55
  if (!document.documentElement)
@@ -134,6 +138,41 @@ function showLateNotFound() {
134
138
  }
135
139
  /** What every flight response is typed as. The charset and any other parameters follow it. */
136
140
  const FLIGHT_CONTENT_TYPE = 'text/x-component';
141
+ /** How much of a body that is not a payload is quoted back in the error. */
142
+ const REFUSAL_BODY_LIMIT = 200;
143
+ /**
144
+ * The first {@link REFUSAL_BODY_LIMIT} characters of a response body, without buffering the rest.
145
+ *
146
+ * `response.text()` reads the whole body first, so a proxy's multi-megabyte error page would be buffered and
147
+ * decoded before the 200 characters that are kept — on a path that exists to say what answered instead of a
148
+ * payload. Reading one chunk at a time and cancelling stops paying for bytes nothing will look at.
149
+ */
150
+ async function refusalBody(response) {
151
+ if (!response.body)
152
+ return '';
153
+ const reader = response.body.getReader();
154
+ const decoder = new TextDecoder();
155
+ let text = '';
156
+ try {
157
+ while (text.length < REFUSAL_BODY_LIMIT) {
158
+ const { done, value } = await reader.read();
159
+ if (done)
160
+ break;
161
+ // `stream: true` so a multi-byte character split across two reads is not replaced by U+FFFD.
162
+ text += decoder.decode(value, { stream: true });
163
+ }
164
+ // Flush a trailing partial multi-byte sequence, so a body cut mid-character reads as U+FFFD rather than
165
+ // dropping the bytes that were already read.
166
+ text += decoder.decode();
167
+ }
168
+ catch {
169
+ // A body that failed mid-read says no more than the status already did.
170
+ }
171
+ finally {
172
+ void reader.cancel().catch(() => { });
173
+ }
174
+ return text.slice(0, REFUSAL_BODY_LIMIT).trim();
175
+ }
137
176
  /**
138
177
  * Fetches a payload, refusing a response that is not one.
139
178
  *
@@ -151,7 +190,7 @@ async function payloadResponse(request) {
151
190
  return response;
152
191
  // Read for the message: a plain-text refusal says what it refused only in its body, and HTTP/2 has no
153
192
  // `statusText` at all. Bounded, because this is an error path and the body is not ours to trust.
154
- const body = await response.text().then((text) => text.trim().slice(0, 200), () => '');
193
+ const body = await refusalBody(response);
155
194
  const status = `${response.status}${response.statusText ? ` ${response.statusText}` : ''}`;
156
195
  throw new Error(`[rshono] the server answered ${status} (${contentType ?? 'no content type'}) instead of a payload${body ? `: ${body}` : ''}`);
157
196
  }
@@ -308,60 +347,149 @@ const documentNavigation = Symbol('rshono:document-navigation');
308
347
  * its Suspense fallback with no second document ever arriving, and how a late `redirect()` moved the address
309
348
  * bar to a page it then failed to render.
310
349
  *
350
+ * `replace` puts the loaded document in place of the current entry rather than after it, which is what a
351
+ * redirect recovery owes: the entry being left is the URL that raised the redirect, and a 30x would have
352
+ * replaced it, so Back must reach whatever preceded it rather than the URL that redirects again.
353
+ *
311
354
  * The mark rides the navigation itself, through `info`, so the listener recognizes the event that owns it
312
355
  * instead of consuming a flag set in advance. There is no state to clear and none to leak: a navigation the
313
356
  * browser refuses cannot make the next one a full load. Below the Navigation API there is no interception to
314
357
  * opt out of, and `location.*` is already a document load.
315
358
  */
316
- function loadDocument(href) {
359
+ function loadDocument(href, { replace = false } = {}) {
360
+ finishAttempt(activeAttempt, { kind: 'document-navigation' });
317
361
  if (!canSoftNavigate) {
318
362
  if (href === undefined)
319
363
  window.location.reload();
364
+ else if (replace)
365
+ window.location.replace(href);
320
366
  else
321
367
  window.location.assign(href);
322
368
  return;
323
369
  }
324
- settle(href === undefined ? navigation.reload({ info: documentNavigation }) : navigation.navigate(href, { info: documentNavigation }));
370
+ try {
371
+ settle(href === undefined
372
+ ? navigation.reload({ info: documentNavigation })
373
+ : navigation.navigate(href, { history: replace ? 'replace' : 'auto', info: documentNavigation }));
374
+ }
375
+ catch {
376
+ // The Navigation API refuses a document that is not fully active — it is unloading — and a URL it cannot
377
+ // parse. Both callers are recovery paths, where a throw here becomes an unhandled rejection rather than
378
+ // the document load they asked for. `location.*` is the same load without the interception, and its own
379
+ // refusal (the same unparseable URL) is swallowed because there is then no load left to make.
380
+ try {
381
+ if (href === undefined)
382
+ window.location.reload();
383
+ else if (replace)
384
+ window.location.replace(href);
385
+ else
386
+ window.location.assign(href);
387
+ }
388
+ catch {
389
+ // Nothing to navigate to. The caller is already recovering from a failure, and the address bar still
390
+ // describes the document on screen.
391
+ }
392
+ }
325
393
  }
326
394
  // The imperative actions behind `useNavigation().router`. Each one only *asks*: the browser turns it into a
327
395
  // `navigate` event, which is where `listenNavigation` answers it — so a `router.push` and a link click reach
328
396
  // the same code by the same route, and inherit the same fetch, scroll and `pending` flag.
397
+ // Carry each caller's completion through its browser event, fetch, and DOM commit.
398
+ const attemptMark = Symbol('rshono:navigation-attempt');
399
+ let activeAttempt;
400
+ let traversalAttempt;
401
+ function finishAttempt(attempt, outcome) {
402
+ if (!attempt || attempt.done)
403
+ return;
404
+ attempt.done = true;
405
+ attempt.resolve(outcome);
406
+ if (activeAttempt === attempt)
407
+ activeAttempt = undefined;
408
+ }
409
+ function rejectAttempt(attempt, error) {
410
+ if (!attempt || attempt.done)
411
+ return;
412
+ attempt.done = true;
413
+ attempt.reject(error);
414
+ if (activeAttempt === attempt)
415
+ activeAttempt = undefined;
416
+ }
417
+ function newAttempt() {
418
+ const { promise, resolve, reject } = Promise.withResolvers();
419
+ // Ignoring a router call must not create an unhandled rejection; awaiting this original promise still rejects.
420
+ void promise.catch(() => { });
421
+ return { mark: attemptMark, hop: 0, done: false, browserOnlyRendered: false, promise, resolve, reject };
422
+ }
423
+ function requestNavigation(attempt, run) {
424
+ const hop = attempt.hop;
425
+ try {
426
+ const result = run();
427
+ settle(result);
428
+ void result.finished?.then(() => {
429
+ if (attempt.hop === hop && attempt.browserOnlyRendered) {
430
+ finishAttempt(attempt, { kind: 'rendered', url: new URL(location.href) });
431
+ }
432
+ }, (error) => {
433
+ if (attempt.hop === hop)
434
+ rejectAttempt(attempt, error);
435
+ });
436
+ }
437
+ catch (error) {
438
+ rejectAttempt(attempt, error);
439
+ }
440
+ return attempt.promise;
441
+ }
442
+ function navigateAttempt(href, history, attempt = newAttempt()) {
443
+ if (!canSoftNavigate) {
444
+ if (history === 'replace')
445
+ window.location.replace(href);
446
+ else
447
+ window.location.assign(href);
448
+ finishAttempt(attempt, { kind: 'document-navigation' });
449
+ return attempt.promise;
450
+ }
451
+ return requestNavigation(attempt, () => navigation.navigate(href, { history, info: attempt }));
452
+ }
329
453
  function push(href) {
330
- if (canSoftNavigate)
331
- settle(navigation.navigate(href, { history: 'push' }));
332
- else
333
- window.location.assign(href);
454
+ return navigateAttempt(href, 'push');
334
455
  }
335
456
  function replace(href) {
336
- if (canSoftNavigate)
337
- settle(navigation.navigate(href, { history: 'replace' }));
338
- else
339
- window.location.replace(href);
457
+ return navigateAttempt(href, 'replace');
340
458
  }
341
- // A traversal is the browser's to perform either way — `navigation` only hands it back as an interceptable
342
- // event first. Nothing to go back to is a rejection there and a no-op here; both amount to the same thing.
343
459
  function back() {
344
- if (canSoftNavigate)
345
- settle(navigation.back());
346
- else
460
+ if (!canSoftNavigate) {
347
461
  window.history.back();
462
+ return Promise.resolve({ kind: 'document-navigation' });
463
+ }
464
+ if (!navigation.canGoBack)
465
+ return Promise.resolve({ kind: 'no-op' });
466
+ const attempt = newAttempt();
467
+ return requestNavigation(attempt, () => navigation.back({ info: attempt }));
348
468
  }
349
469
  function forward() {
350
- if (canSoftNavigate)
351
- settle(navigation.forward());
352
- else
470
+ if (!canSoftNavigate) {
353
471
  window.history.forward();
472
+ return Promise.resolve({ kind: 'document-navigation' });
473
+ }
474
+ if (!navigation.canGoForward)
475
+ return Promise.resolve({ kind: 'no-op' });
476
+ const attempt = newAttempt();
477
+ return requestNavigation(attempt, () => navigation.forward({ info: attempt }));
354
478
  }
355
- // A refresh keeps the URL, and is still a navigation: it arrives as `navigationType: 'reload'`, which is what
356
- // tells the listener to leave scroll and focus where the user left them.
357
479
  function refresh() {
358
- if (canSoftNavigate)
359
- settle(navigation.reload());
360
- else
480
+ if (!canSoftNavigate) {
361
481
  window.location.reload();
482
+ return Promise.resolve({ kind: 'document-navigation' });
483
+ }
484
+ const attempt = newAttempt();
485
+ return requestNavigation(attempt, () => navigation.reload({ info: attempt }));
362
486
  }
363
- /** How long the recovery reload is given to replace this document before the panel is painted instead. */
364
- const RELOAD_GRACE_MS = 2000;
487
+ /**
488
+ * How long a recovery document load is given to replace this one before the framework paints the panel the
489
+ * load stands in for. Both recoveries — a late `notFound()` and a payload whose render tore the root down —
490
+ * ask the browser for a real document, and this timer is all that is left when one is refused or swallowed.
491
+ */
492
+ const RECOVERY_GRACE_MS = 2000;
365
493
  /** The `sessionStorage` key bounding the recovery reload for one URL. Spent here, released in {@link main}. */
366
494
  const lateNotFoundKey = () => `rshono:late-not-found:${documentUrl()}`;
367
495
  /**
@@ -405,7 +533,7 @@ function reloadOnceForLateNotFound() {
405
533
  setTimeout(() => {
406
534
  if (!document.querySelector('[data-rshono-fatal]'))
407
535
  showLateNotFound();
408
- }, RELOAD_GRACE_MS);
536
+ }, RECOVERY_GRACE_MS);
409
537
  }
410
538
  /**
411
539
  * Turns a control-signal digest — how `redirect()` / `notFound()` reach the browser — into a real
@@ -413,8 +541,13 @@ function reloadOnceForLateNotFound() {
413
541
  *
414
542
  * `hard` forces a full document load, for signals that surfaced *through React*: it unmounts the root on
415
543
  * an uncaught error, leaving no live tree to soft-navigate with.
544
+ *
545
+ * A redirect is followed *replacing* the entry that raised it, the way a 30x replaces the URL that answered
546
+ * with one. `history` lets the action-reply paths that must not — the fetch catch, and a digest surfacing
547
+ * through React during the reply's render — keep the push they have always had. `notFound()` has nowhere to
548
+ * go but a reload, and a reload is its own entry already.
416
549
  */
417
- function handleControlDigest(error, { hard = false } = {}) {
550
+ function handleControlDigest(error, { hard = false, history = 'replace' } = {}) {
418
551
  const digest = error?.digest;
419
552
  if (!isControlDigest(digest))
420
553
  return false;
@@ -424,10 +557,14 @@ function handleControlDigest(error, { hard = false } = {}) {
424
557
  }
425
558
  else if (hard) {
426
559
  // The URL is resolved first: a malformed location then throws here, before any navigation is asked for.
427
- loadDocument(new URL(redirect.location, window.location.href).href);
560
+ // Replacing is what keeps the URL that raised the redirect from becoming an entry Back bounces through.
561
+ loadDocument(new URL(redirect.location, window.location.href).href, { replace: history === 'replace' });
562
+ }
563
+ else if (history === 'replace') {
564
+ void replace(redirect.location);
428
565
  }
429
566
  else {
430
- push(redirect.location);
567
+ void push(redirect.location);
431
568
  }
432
569
  return true;
433
570
  }
@@ -445,6 +582,58 @@ function scrollToTop() {
445
582
  function scrollToPoint(point) {
446
583
  window.scrollTo({ top: point.y, left: point.x, behavior: 'instant' });
447
584
  }
585
+ /**
586
+ * How long a commit waits for a fragment target or `autofocus` element that a boundary has not streamed in
587
+ * yet. Past it the fallback stands — the top of the document, or body focus — and the watch is dropped, so a
588
+ * fragment that names nothing leaves no observer running behind it.
589
+ */
590
+ const ELEMENT_WATCH_TIMEOUT_MS = 5_000;
591
+ const pendingElementWatches = new Set();
592
+ let elementObserver = null;
593
+ function stopElementWatch(watch) {
594
+ clearTimeout(watch.timer);
595
+ if (!pendingElementWatches.delete(watch))
596
+ return;
597
+ if (pendingElementWatches.size === 0) {
598
+ elementObserver?.disconnect();
599
+ elementObserver = null;
600
+ }
601
+ }
602
+ /** Drops every watch; a new navigation owns the screen from here. */
603
+ function cancelElementWatches() {
604
+ for (const watch of [...pendingElementWatches])
605
+ stopElementWatch(watch);
606
+ }
607
+ /**
608
+ * Runs `run` as soon as `find` finds an element, or gives up after {@link ELEMENT_WATCH_TIMEOUT_MS}. When
609
+ * the element is already there, `run` happens synchronously. `find` and `run` are always passed as a matching
610
+ * pair; the type-erasure here is what lets one observer serve watches of different element shapes.
611
+ */
612
+ function watchForElement(find, run) {
613
+ const present = find();
614
+ if (present) {
615
+ run(present);
616
+ return;
617
+ }
618
+ if (elementObserver === null) {
619
+ elementObserver = new MutationObserver(() => {
620
+ for (const watch of [...pendingElementWatches]) {
621
+ const element = watch.find();
622
+ if (element) {
623
+ stopElementWatch(watch);
624
+ watch.run(element);
625
+ }
626
+ }
627
+ });
628
+ elementObserver.observe(document.documentElement, { childList: true, subtree: true });
629
+ }
630
+ const watch = {
631
+ find,
632
+ run: run,
633
+ timer: setTimeout(() => stopElementWatch(watch), ELEMENT_WATCH_TIMEOUT_MS),
634
+ };
635
+ pendingElementWatches.add(watch);
636
+ }
448
637
  /**
449
638
  * Scrolls to a fragment's target the way the browser's own fragment jump does.
450
639
  *
@@ -453,6 +642,9 @@ function scrollToPoint(point) {
453
642
  * "find a potential indicated element": the id first, then the name. A malformed percent-escape falls back
454
643
  * to the literal fragment, and a fragment nothing matches gets the top of the document — what a browser
455
644
  * gives a missing anchor on a real load.
645
+ *
646
+ * A streamed payload commits its shell before a target inside a suspended boundary exists, so a miss is not
647
+ * final: the top is applied right away, and the watch follows the fragment in when its element arrives.
456
648
  */
457
649
  function jumpToAnchor(hash) {
458
650
  const raw = hash.startsWith('#') ? hash.slice(1) : hash;
@@ -469,24 +661,37 @@ function jumpToAnchor(hash) {
469
661
  catch {
470
662
  // Malformed escape — the literal fragment is the better guess at the id than nothing.
471
663
  }
472
- const target = document.getElementById(id) ?? document.getElementsByName(id)[0];
473
- if (target)
664
+ const find = () => document.getElementById(id) ?? document.getElementsByName(id)[0] ?? null;
665
+ const target = find();
666
+ if (target) {
474
667
  target.scrollIntoView();
475
- else
476
- scrollToTop();
668
+ return;
669
+ }
670
+ scrollToTop();
671
+ watchForElement(find, (element) => element.scrollIntoView());
477
672
  }
478
673
  /**
479
674
  * Moves focus the way the browser's `focusReset: 'after-transition'` does: the first `autofocus` element,
480
675
  * else the document body. A traversal is no longer intercepted, so the browser performs no focus reset for
481
676
  * it; without this, Back would leave focus on the link that was clicked on the page being left, and the next
482
677
  * Tab would resume there. `preventScroll` because the traversal's own offset has just been restored.
678
+ *
679
+ * The page may still be streaming — a traversal into a page whose `autofocus` element sits in a suspended
680
+ * boundary — and the browser's own reset follows the element in, so this watches for it with the same bound
681
+ * as a fragment.
483
682
  */
484
683
  function resetFocus() {
485
684
  const autofocus = document.querySelector('[autofocus]');
486
- if (autofocus)
685
+ if (autofocus) {
487
686
  autofocus.focus({ preventScroll: true });
488
- else
489
- document.body.focus({ preventScroll: true });
687
+ return;
688
+ }
689
+ document.body.focus({ preventScroll: true });
690
+ watchForElement(() => document.querySelector('[autofocus]'), (element) => {
691
+ // Only if focus is still where this reset left it: a user who has since focused something else keeps it.
692
+ if (document.activeElement === document.body)
693
+ element.focus({ preventScroll: true });
694
+ });
490
695
  }
491
696
  /**
492
697
  * Puts a payload on screen, resolving once React has committed it. Replaced by `BrowserRoot`'s own on mount;
@@ -501,13 +706,46 @@ let setPayload = () => {
501
706
  window.location.reload();
502
707
  return new Promise(() => { });
503
708
  };
709
+ /**
710
+ * The payload {@link setPayload} handed React that is still waiting to commit, or `undefined` once it has.
711
+ *
712
+ * Read by `onUncaughtError`, where React has decided to unmount the root. A navigation payload that never
713
+ * committed is the one case a document load can still repair: the URL already names its destination, and
714
+ * every recovery the runtime has — `popstate`, a link click, `setPayload` — writes into a root that no longer
715
+ * exists. The server answers that document request with the app's error page, which is a root of its own. An
716
+ * action reply is not: a GET of the URL it was called from does not re-run the action, so the page renders as
717
+ * if nothing had happened and the panel is the answer. Deliberately `undefined` for the initial payload and
718
+ * cleared as soon as a render commits: a deterministic client fault must reach the fatal panel rather than
719
+ * reload the tab forever.
720
+ *
721
+ * Failing the render rejects the commit promise rather than settling it as a success, so every waiter —
722
+ * `loadPayload`'s caller, the prepared handler — learns the render it asked for has failed and can take its
723
+ * recovery branch. A render React is only suspending is not failed: it has not given up on it, and neither
724
+ * may the navigation.
725
+ */
726
+ let pendingRender;
727
+ /**
728
+ * The history mode for a `redirect()` that surfaced through React, where {@link pendingRender} is the render
729
+ * that raised it. A navigation payload names the URL that raised the redirect, so the redirect replaces that
730
+ * entry. An action reply re-renders the page the visitor is already on — a GET of it does not re-run the
731
+ * action — so its redirect keeps the push the action's other redirect paths have, and Back returns to the
732
+ * page that submitted the form. `undefined` — the initial payload, or a render past its commit — is not an
733
+ * action reply and replaces.
734
+ */
735
+ function controlDigestHistory() {
736
+ return pendingRender?.recoversByLoad === false ? 'push' : 'replace';
737
+ }
504
738
  /**
505
739
  * Re-dispatches the payload already waiting to commit, for {@link loadPayload}'s lost-ping retry. Unlike
506
740
  * {@link setPayload} it creates and releases no commit promise, so re-rendering the same payload cannot
507
741
  * resolve the navigation's commit early. Assigned with {@link setPayload} by `BrowserRoot`.
508
742
  */
509
743
  let reapplyPayload = () => { };
510
- /** Runs work inside the nav transition so `useNavigation().pending` stays true across the round-trip. */
744
+ /**
745
+ * Runs a navigation's work through `useNavigationTransition`'s runner, which tracks the promise it returns so
746
+ * `useNavigation().pending` stays true across the whole round-trip rather than only inside a React transition.
747
+ * The default covers the window before hydration, where no root observes `pending`.
748
+ */
511
749
  let startNav = (run) => {
512
750
  void run();
513
751
  };
@@ -519,8 +757,23 @@ let startNav = (run) => {
519
757
  * after the browser has already committed it — so ordering is the runtime's own for those.
520
758
  */
521
759
  let currentNavigation = 0;
522
- /** The in-flight navigation's fetch, so a newer one can stop paying for it. */
523
- let navigationFetch = null;
760
+ /** An urgent traversal releases a suspended destination in favor of the last committed payload. */
761
+ function restoreCommittedPayload(payload) {
762
+ currentNavigation++;
763
+ // The payload has rendered before, but an uncaught error would still tear the root down. Nothing awaits
764
+ // this commit, so the rejection needs a consumer of its own to reach the document-load recovery.
765
+ void setPayload(payload).catch(() => loadDocument());
766
+ }
767
+ let navigationFetches = [];
768
+ /** Stop only the requests whose streams no longer belong to a retained render. */
769
+ function abortNavigationFetches(shouldAbort) {
770
+ navigationFetches = navigationFetches.filter((fetch) => {
771
+ if (!shouldAbort(fetch))
772
+ return true;
773
+ fetch.controller.abort();
774
+ return false;
775
+ });
776
+ }
524
777
  /**
525
778
  * Fetches the payload for `url` and puts it on screen.
526
779
  *
@@ -528,41 +781,107 @@ let navigationFetch = null;
528
781
  * point, and a `#hash` target does not exist until the new tree does. Rejects only on a genuine failure —
529
782
  * being superseded is not one, and resolves quietly, because the navigation that replaced this one owns the
530
783
  * screen from then on.
784
+ *
785
+ * `prepared`, when given, is a fetch that was started before the URL committed: this call takes over its
786
+ * abort wiring, so from here it follows the same applied-fetch rules as one this function started.
531
787
  */
532
- function loadPayload(url, signal, afterCommit) {
533
- // This navigation's place in the queue, and its own abort switch: a fetch is abandoned by whichever comes
534
- // first, the browser superseding it (an intercepted navigation carries `event.signal`) or a newer runtime
535
- // fetch starting (a traversal, an action, a dev refresh).
788
+ function loadPayload(url, { signal, afterCommit, attempt, prepared } = {}) {
789
+ // This navigation's place in the queue, and its own abort switch: the navigation stops being applied by
790
+ // whichever comes first, the browser superseding it (an intercepted navigation carries `event.signal`) or a
791
+ // newer runtime fetch starting (a traversal, an action, a dev refresh). The fetch itself runs on until its
792
+ // payload is on screen and a newer one commits — see {@link navigationFetches}.
536
793
  const navigation = ++currentNavigation;
537
- navigationFetch?.abort();
538
- const controller = (navigationFetch = new AbortController());
539
- const abort = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
794
+ const controller = new AbortController();
795
+ const entry = { navigation, controller, applied: false };
796
+ navigationFetches.push(entry);
797
+ // A newer navigation owns the screen now, and an older fetch whose payload never reached React is reading
798
+ // into a tree that does not exist — stopping it cannot reject a chunk anything holds. One that did reach
799
+ // React waits for this payload's commit; see {@link navigationFetches}.
800
+ abortNavigationFetches((fetch) => fetch !== entry && !fetch.applied);
801
+ // The browser supersedes an intercepted navigation the moment a newer one starts, and `signal` is how it
802
+ // says so. A fetch React has been handed keeps running (see {@link navigationFetches}), but one that never
803
+ // reached React stops here: the navigation taking over may be one the runtime does not intercept (a
804
+ // fragment jump, a download), leaving no commit to stop it later.
805
+ let abandoned = signal?.aborted ?? false;
806
+ const abandon = () => {
807
+ abandoned = true;
808
+ if (!entry.applied)
809
+ controller.abort();
810
+ };
811
+ if (abandoned)
812
+ abandon();
813
+ else
814
+ signal?.addEventListener('abort', abandon, { once: true });
815
+ /** Whether this navigation has lost the screen to the browser or to a newer runtime fetch. */
816
+ const stale = () => abandoned || navigation !== currentNavigation || controller.signal.aborted;
540
817
  // Deliberately not awaited inside the transition: the scope ends once the payload is handed to React, and
541
818
  // React holds `pending` until the update it scheduled commits. Awaiting the commit *inside* the scope would
542
819
  // work too, but only because React happens not to gate a commit on its async scope settling — an internal
543
820
  // this has no reason to depend on across the whole `^19.1.0` peer range.
544
821
  let committed;
545
- // Whether the payload update has committed. A streamed transition can suspend on a flight chunk whose later
546
- // resolution React never pings the lane back for; the retry below re-schedules the update once the stream
547
- // has closed, but only while this is still false.
548
822
  let committedSettled = false;
549
823
  const run = async () => {
550
- const { payload, settled } = requestPayload(url, abort);
551
- const nextPayload = await payload;
824
+ let fetched;
825
+ if (prepared) {
826
+ // The payload is already on the wire and its abort switch belonged to the navigation event up to the
827
+ // commit. Take over here so the runtime's ownership rules apply from now on: a superseded fetch that
828
+ // never reached React stops at once, while one React is reading waits for a newer commit.
829
+ const owned = prepared;
830
+ const abortPrepared = () => owned.controller.abort(controller.signal.reason);
831
+ if (controller.signal.aborted)
832
+ abortPrepared();
833
+ else
834
+ controller.signal.addEventListener('abort', abortPrepared, { once: true });
835
+ void owned.fetched.settled.then(() => controller.signal.removeEventListener('abort', abortPrepared));
836
+ fetched = owned.fetched;
837
+ }
838
+ else {
839
+ fetched = requestPayload(url, controller.signal);
840
+ }
841
+ const nextPayload = await fetched.payload;
552
842
  // Checked again after the await because the fetch may already have resolved by then, and applying it
553
843
  // would repaint a page the user has left.
554
- if (abort.aborted || navigation !== currentNavigation)
844
+ if (stale())
555
845
  return;
556
846
  if (nextPayload.redirect) {
557
- push(nextPayload.redirect);
847
+ // Replacing for the same reason as {@link handleControlDigest}: the URL whose payload carried the
848
+ // redirect is the entry being left, and a push would make Back bounce through it into the same redirect.
849
+ if (attempt) {
850
+ attempt.hop++;
851
+ void navigateAttempt(nextPayload.redirect, 'replace', attempt);
852
+ }
853
+ else
854
+ void replace(nextPayload.redirect);
558
855
  return;
559
856
  }
560
857
  // The fetch crossed an await, so its payload update needs a new synchronous transition scope.
561
858
  // Keep the commit promise outside the async Action; React tracks the scheduled update until commit.
562
- React.startTransition(() => {
859
+ startNavigationPayload(() => {
860
+ // From here React holds this payload's stream, so a superseded fetch may not be stopped until a newer
861
+ // payload commits — see {@link navigationFetches}.
862
+ navigationModels.set(controller, nextPayload);
863
+ entry.applied = true;
563
864
  committed = setPayload(nextPayload, afterCommit);
564
- void committed.then(() => (committedSettled = true));
865
+ // Either way this render is settled, so the lost-ping retry below must not wait for it or re-apply it:
866
+ // a failed render would re-enter a root React has already torn down.
867
+ void committed.then(() => (committedSettled = true), () => (committedSettled = true));
565
868
  });
869
+ // This payload is on screen, so every fetch it superseded is reading into a tree React has replaced.
870
+ // Stopping them now cannot reject a chunk the committed tree holds — see {@link navigationFetches}.
871
+ // Only the older ones: a newer navigation may have started while this payload was in flight, and its
872
+ // fetch is the one the screen is waiting for.
873
+ void committed?.then(() => {
874
+ if (renderedModel !== nextPayload)
875
+ return;
876
+ navigationFetches = navigationFetches.filter((fetch) => {
877
+ if (fetch.navigation >= navigation)
878
+ return true;
879
+ fetch.controller.abort();
880
+ return false;
881
+ });
882
+ },
883
+ // A rejected commit is the caller's to report; this chain only exists to reap older fetches.
884
+ () => { });
566
885
  // A streamed payload can suspend a transition on a flight chunk that resolves after the shell without
567
886
  // React ever pinging the suspended lane back, which strands the navigation on the previous tree. The
568
887
  // chunks have all landed by the time the stream closes, so one re-application — still a transition, so a
@@ -570,18 +889,18 @@ function loadPayload(url, signal, afterCommit) {
570
889
  // lost ping stranded. It goes through `reapplyPayload` rather than `setPayload`: the payload is not
571
890
  // replaced, so the commit the navigation waits on has to stay attached to it. The macrotask lets the
572
891
  // decoder process the last chunk and React's own retry run first, so the normal path is not interrupted.
573
- void settled.then(() => {
574
- if (abort.aborted || navigation !== currentNavigation || committedSettled)
892
+ void fetched.settled.then(() => {
893
+ if (stale() || committedSettled)
575
894
  return;
576
895
  setTimeout(() => {
577
- if (abort.aborted || navigation !== currentNavigation || committedSettled)
578
- return;
579
- React.startTransition(reapplyPayload);
896
+ if (!stale() && !committedSettled)
897
+ React.startTransition(reapplyPayload);
580
898
  }, 0);
581
899
  });
582
900
  };
583
- // `startTransition` runs the work but hands nothing back, so the promise carrying a failure is caught here
584
- // instead. Assigned synchronously: React invokes the callback before `startNav` returns.
901
+ // `startTransition` runs the work but hands nothing back, so the promise carrying a failure is caught by
902
+ // the rejection handler on `work` below instead. Assigned synchronously: React invokes the callback before
903
+ // `startNav` returns.
585
904
  let work;
586
905
  startNav(() => (work = run()));
587
906
  return work.then(
@@ -589,7 +908,7 @@ function loadPayload(url, signal, afterCommit) {
589
908
  () => committed, (error) => {
590
909
  // Checked before the error is read: an abort is this navigation being replaced, and the one that
591
910
  // replaced it owns the outcome.
592
- if (abort.aborted || navigation !== currentNavigation || handleControlDigest(error))
911
+ if (stale() || handleControlDigest(error))
593
912
  return;
594
913
  throw error;
595
914
  });
@@ -629,6 +948,30 @@ function leaveToBrowser(event) {
629
948
  event.sourceElement?.hasAttribute('data-native') === true ||
630
949
  namesAFile(event.destination.url));
631
950
  }
951
+ /**
952
+ * The scroll a committing navigation owes: a push reaches its own fragment or the top, an in-place change
953
+ * stays where it is. Read at commit rather than at dispatch, so a redirect the preparation followed lands
954
+ * on the fragment it actually committed.
955
+ */
956
+ function afterCommitFor(event) {
957
+ if (event.navigationType !== 'push')
958
+ return undefined;
959
+ const { hash } = new URL(event.destination.url);
960
+ return hash === '' ? scrollToTop : () => jumpToAnchor(hash);
961
+ }
962
+ /** Applies a prepared payload and resolves after React commits it, keeping native `finished` pending until then. */
963
+ function applyPrepared(event, prepared) {
964
+ const info = event.info;
965
+ const attempt = info?.mark === attemptMark ? info : undefined;
966
+ const hop = attempt?.hop;
967
+ return loadPayload(event.destination.url, { signal: event.signal, afterCommit: afterCommitFor(event), attempt, prepared }).then(() => {
968
+ if (attempt?.hop === hop && !event.signal.aborted)
969
+ finishAttempt(attempt, { kind: 'rendered', url: new URL(location.href) });
970
+ }, (error) => {
971
+ rejectAttempt(attempt, error);
972
+ loadDocument();
973
+ });
974
+ }
632
975
  /**
633
976
  * The whole router, in one listener.
634
977
  *
@@ -641,24 +984,55 @@ function leaveToBrowser(event) {
641
984
  function listenNavigation() {
642
985
  if (!canSoftNavigate)
643
986
  return () => { };
987
+ const disposePreparation = listenNavigationPreparation({
988
+ fetchPayload: requestPayload,
989
+ shouldIntercept: (event) => event.info !== documentNavigation && event.canIntercept && !leaveToBrowser(event),
990
+ canCommitInPlace: (href) => !namesAFile(href),
991
+ apply: applyPrepared,
992
+ fallbackToDocument: loadDocument,
993
+ });
644
994
  const onNavigate = (event) => {
645
995
  // A document navigation the runtime asked for itself, marked through `info`. Checked before the scroll
646
996
  // snapshot: the document is going away, so where its entry was left does not matter, and an unrelated
647
997
  // later navigation can never be mistaken for this one.
648
998
  if (event.info === documentNavigation)
649
999
  return;
1000
+ const info = event.info;
1001
+ const attempt = info?.mark === attemptMark ? info : undefined;
1002
+ if (activeAttempt && activeAttempt !== attempt)
1003
+ rejectAttempt(activeAttempt, new DOMException('Navigation superseded', 'AbortError'));
1004
+ activeAttempt = attempt;
1005
+ traversalAttempt = undefined;
1006
+ // A commit for the destination being left is no longer the screen's; its pending fragment target (or
1007
+ // `autofocus` element) must not scroll or focus the page that replaces it.
1008
+ cancelElementWatches();
650
1009
  // Whatever the browser is about to do with this navigation, the entry it is leaving is about to lose the
651
1010
  // offset it was at, and nothing else in this document will put it back. Saved before the branches below
652
1011
  // return, so a fragment the browser performs itself is covered too.
653
1012
  rememberCurrentScroll();
654
- if (!event.canIntercept || leaveToBrowser(event))
1013
+ if (!event.canIntercept || leaveToBrowser(event)) {
1014
+ if (attempt) {
1015
+ if (event.hashChange) {
1016
+ attempt.browserOnlyRendered = true;
1017
+ }
1018
+ else
1019
+ finishAttempt(attempt, { kind: 'document-navigation' });
1020
+ }
655
1021
  return;
1022
+ }
656
1023
  // A traversal is repainted by the `popstate` listener below, not intercepted. `intercept`ing one is what
657
1024
  // makes WebKit stall the rendered viewport for about three seconds on a back swipe
658
1025
  // (bugs.webkit.org/319414): the view gesture's snapshot is only removed once the navigation finishes, and
659
1026
  // React's root attaches the wheel listener the bug also needs. Letting the traversal through means the
660
1027
  // browser commits the entry straight away, and the runtime puts the payload on screen when it arrives.
661
- if (event.navigationType === 'traverse')
1028
+ if (event.navigationType === 'traverse') {
1029
+ if (attempt)
1030
+ traversalAttempt = { attempt, key: event.destination.key };
1031
+ return;
1032
+ }
1033
+ // The preparation listener claimed this push or replace: it set the scroll and focus options and will
1034
+ // apply the prepared payload from its own precommit, so there is no handler to register here.
1035
+ if (isPreparedNavigation(event))
662
1036
  return;
663
1037
  // A replace or a refresh stays where it is, so neither should move. A push starts at the top of the
664
1038
  // page, or at its fragment — and that scroll is the runtime's own now: WebKit performs no
@@ -667,17 +1041,21 @@ function listenNavigation() {
667
1041
  // handler over without scrolling; the `afterCommit` callback reaches the target once the new tree is on
668
1042
  // screen. Focus stays the browser's, reset after the transition.
669
1043
  const inPlace = event.navigationType === 'replace' || event.navigationType === 'reload';
670
- let afterCommit;
671
- if (event.navigationType === 'push') {
672
- const { hash } = new URL(event.destination.url);
673
- afterCommit = hash === '' ? scrollToTop : () => jumpToAnchor(hash);
674
- }
1044
+ const hop = attempt?.hop;
675
1045
  event.intercept({
676
1046
  scroll: 'manual',
677
1047
  focusReset: inPlace ? 'manual' : 'after-transition',
678
- // The URL commits before the handler runs, so a failure leaves the address bar describing a page the
679
- // document is not showing. A real load is the only way back to agreement.
680
- handler: () => loadPayload(event.destination.url, event.signal, afterCommit).catch(() => loadDocument()),
1048
+ // The URL has committed by the time this runs, so a failure leaves the address bar describing a page
1049
+ // the document is not showing. That is the whole path for a reload, and the fallback path for a push
1050
+ // or replace on a browser without precommit support — see `listenNavigationPreparation`. A real load
1051
+ // is the only way back to agreement.
1052
+ handler: () => loadPayload(event.destination.url, { signal: event.signal, afterCommit: afterCommitFor(event), attempt }).then(() => {
1053
+ if (attempt?.hop === hop && !event.signal.aborted)
1054
+ finishAttempt(attempt, { kind: 'rendered', url: new URL(location.href) });
1055
+ }, (error) => {
1056
+ rejectAttempt(attempt, error);
1057
+ loadDocument();
1058
+ }),
681
1059
  });
682
1060
  };
683
1061
  /**
@@ -686,6 +1064,11 @@ function listenNavigation() {
686
1064
  * is `navigation.currentEntry` and its saved offset is in {@link scrollPositions}.
687
1065
  */
688
1066
  const onPopState = () => {
1067
+ const traversal = traversalAttempt;
1068
+ const attempt = traversal && traversal.key === currentEntryKey() ? traversal.attempt : undefined;
1069
+ traversalAttempt = undefined;
1070
+ // Same ownership as `onNavigate`: this traversal's commit replaces whatever the last one was waiting for.
1071
+ cancelElementWatches();
689
1072
  const stored = scrollPointFor(currentEntryKey());
690
1073
  const hash = location.hash;
691
1074
  const apply = () => {
@@ -695,6 +1078,7 @@ function listenNavigation() {
695
1078
  jumpToAnchor(hash);
696
1079
  else
697
1080
  scrollToTop();
1081
+ finishAttempt(attempt, { kind: 'rendered', url: new URL(location.href) });
698
1082
  };
699
1083
  // A fragment-only traversal never changed the payload: the address bar is back at an anchor of the page
700
1084
  // already on screen, so there is nothing to fetch and nothing to wait for. The browser's own restoration
@@ -704,16 +1088,28 @@ function listenNavigation() {
704
1088
  apply();
705
1089
  return;
706
1090
  }
707
- void loadPayload(location.href, undefined, () => {
708
- apply();
709
- // The browser performs this for an intercepted navigation; a traversal is no longer intercepted, so
710
- // without it Back would leave focus on the link that was clicked on the page being left.
711
- resetFocus();
712
- }).catch(() => loadDocument());
1091
+ // A root redirect transfers completion to the next navigation, just as for push/replace.
1092
+ const hop = attempt?.hop;
1093
+ void loadPayload(location.href, {
1094
+ afterCommit: () => {
1095
+ apply();
1096
+ // The browser performs this for an intercepted navigation; a traversal is no longer intercepted, so
1097
+ // without it Back would leave focus on the link that was clicked on the page being left.
1098
+ resetFocus();
1099
+ },
1100
+ attempt,
1101
+ }).then(() => {
1102
+ if (attempt?.hop === hop)
1103
+ finishAttempt(attempt, { kind: 'rendered', url: new URL(location.href) });
1104
+ }, (error) => {
1105
+ rejectAttempt(attempt, error);
1106
+ loadDocument();
1107
+ });
713
1108
  };
714
1109
  navigation.addEventListener('navigate', onNavigate);
715
1110
  window.addEventListener('popstate', onPopState);
716
1111
  return () => {
1112
+ disposePreparation();
717
1113
  navigation.removeEventListener('navigate', onNavigate);
718
1114
  window.removeEventListener('popstate', onPopState);
719
1115
  };
@@ -750,58 +1146,92 @@ async function main() {
750
1146
  if (canSoftNavigate)
751
1147
  readStoredScrollPositions();
752
1148
  const restored = scrollPointFor(currentEntryKey());
1149
+ /**
1150
+ * Creates a one-shot completion for this render. Superseding it releases its waiter without
1151
+ * running scroll/focus work; committing runs that work before the browser paints; failing
1152
+ * releases the waiter with the reason the render never will do either.
1153
+ */
1154
+ function createRender(payload, href, afterCommit, recoversByLoad = true) {
1155
+ const { promise, resolve, reject } = Promise.withResolvers();
1156
+ let finished = false;
1157
+ return {
1158
+ payload,
1159
+ href,
1160
+ recoversByLoad,
1161
+ committed: promise,
1162
+ commit: () => {
1163
+ if (finished)
1164
+ return;
1165
+ finished = true;
1166
+ afterCommit?.();
1167
+ resolve();
1168
+ },
1169
+ // Supersession releases the old waiter, but never runs its scroll/focus callback.
1170
+ supersede: () => {
1171
+ finished = true;
1172
+ resolve();
1173
+ },
1174
+ // React tore the tree down, so this render never commits. Rejecting is what settles the navigation's
1175
+ // `finished` and the prepared handler; see {@link pendingRender}.
1176
+ fail: (error) => {
1177
+ if (finished)
1178
+ return;
1179
+ finished = true;
1180
+ reject(error);
1181
+ },
1182
+ };
1183
+ }
1184
+ const initialRender = createRender(initialPayload, documentUrl(), restored ? () => scrollToPoint(restored) : undefined);
753
1185
  function BrowserRoot() {
754
1186
  const [payload, setPayloadState] = React.useState(initialPayload);
755
- const [pending, startTransition] = React.useTransition();
1187
+ const [pending, startTransition] = useNavigationTransition(payload, restoreCommittedPayload);
756
1188
  // The payload the last `setPayload` put on screen or on its way there, so `reapplyPayload` re-renders
757
1189
  // that one rather than a payload a later navigation or action has already replaced.
758
1190
  const currentPayload = React.useRef(initialPayload);
759
- // The resolver the payload on screen still owes — see {@link loadPayload}.
760
- const pendingCommit = React.useRef(null);
761
- // The scroll the payload about to commit owes, set with it so a payload that supersedes another takes
762
- // its predecessor's scroll out of the queue along with its commit. Initialised with the reload's offset:
763
- // the first layout effect below applies it with the first payload, before the new tree is painted.
764
- const pendingScroll = React.useRef(restored ? () => scrollToPoint(restored) : null);
765
- // Descendant passive effects can start a navigation before the root's passive effects run.
766
- // Install the live payload setter and transition runner before any such navigation starts.
1191
+ const [renders] = React.useState(() => new WeakMap([[initialPayload, initialRender]]));
1192
+ const scheduledRender = React.useRef(initialRender);
1193
+ const [revision, reviseCommit] = React.useState(0);
767
1194
  React.useLayoutEffect(() => {
768
- setPayload = (next, afterCommit) => {
1195
+ setPayload = (next, afterCommit, { recoversByLoad = true } = {}) => {
1196
+ const scheduled = createRender(next, documentUrl(), afterCommit, recoversByLoad);
1197
+ scheduledRender.current.supersede();
1198
+ scheduledRender.current = scheduled;
769
1199
  currentPayload.current = next;
770
- return new Promise((resolve) => {
771
- // A payload replaced before it ever painted still has a navigation waiting on it. React commits
772
- // only the newest, so the effect below never runs for the one it skipped: release it here.
773
- pendingCommit.current?.();
774
- pendingCommit.current = resolve;
775
- // Replaced rather than kept: a server action's payload carries no `afterCommit`, and the
776
- // navigation it superseded must not scroll the page the action is about to replace it with.
777
- pendingScroll.current = afterCommit ?? null;
778
- setPayloadState(next);
779
- });
1200
+ renders.set(next, scheduled);
1201
+ pendingRender = scheduled;
1202
+ setPayloadState(next);
1203
+ // A restoration to the same payload still owns a new commit and post-commit callback.
1204
+ reviseCommit((value) => value + 1);
1205
+ return scheduled.committed;
1206
+ };
1207
+ reapplyPayload = () => {
1208
+ setPayloadState(currentPayload.current);
1209
+ reviseCommit((value) => value + 1);
780
1210
  };
781
- // A re-dispatch of the payload above, for the retry in `loadPayload`: keeping `pendingCommit` and
782
- // `pendingScroll` in place means the navigation already waiting resolves when this render commits,
783
- // not when the retry runs.
784
- reapplyPayload = () => setPayloadState(currentPayload.current);
785
1211
  startNav = (run) => startTransition(run);
786
- }, [startTransition]);
787
- /**
788
- * Performs the pending scroll and releases the navigation waiting on this payload. A layout effect, so
789
- * the new tree is in the DOM and the pre-scroll position is never painted.
790
- */
1212
+ }, [renders, startTransition]);
791
1213
  React.useLayoutEffect(() => {
792
- const scroll = pendingScroll.current;
793
- pendingScroll.current = null;
794
- scroll?.();
795
- // What the payload that just committed was rendered for — see {@link renderedUrl}. A redirect never
796
- // reaches here, and an action or a dev refresh keeps the URL it was fetched for.
797
- renderedUrl = documentUrl();
798
- const commit = pendingCommit.current;
799
- pendingCommit.current = null;
800
- commit?.();
801
- }, [payload]);
1214
+ const render = renders.get(payload);
1215
+ if (!render)
1216
+ return;
1217
+ renderedUrl = render.href;
1218
+ renderedModel = payload;
1219
+ // Only this render's own completion clears it: an older record can commit late (a re-apply or a bailed
1220
+ // transition), and it must not drop the newer render `onUncaughtError` would have to fail.
1221
+ if (pendingRender === render)
1222
+ pendingRender = undefined;
1223
+ render.commit();
1224
+ navigationFetches = navigationFetches.filter((fetch) => {
1225
+ if (fetch.navigation >= currentNavigation || navigationModels.get(fetch.controller) === payload)
1226
+ return true;
1227
+ fetch.controller.abort();
1228
+ return false;
1229
+ });
1230
+ }, [payload, revision, renders]);
802
1231
  // A descendant may navigate from its first passive effect. Intercept it before that effect runs.
803
1232
  React.useLayoutEffect(() => listenNavigation(), []);
804
- const router = React.useMemo(() => ({ push, replace, back, forward, refresh, pending }), [pending]);
1233
+ const preparing = useNavigationPreparation();
1234
+ const router = React.useMemo(() => ({ push, replace, back, forward, refresh, pending: pending || preparing }), [pending, preparing]);
805
1235
  return _jsx(RouterContext.Provider, { value: router, children: payload.root });
806
1236
  }
807
1237
  setServerCallback(async (id, args) => {
@@ -818,18 +1248,37 @@ async function main() {
818
1248
  let payload;
819
1249
  try {
820
1250
  payload = await createFromFetch(payloadResponse(request), { temporaryReferences });
1251
+ if (documentUrl() === calledFrom)
1252
+ cancelNavigationPreparation();
821
1253
  }
822
1254
  catch (error) {
823
- if (handleControlDigest(error))
1255
+ // The current entry is the page the action was called from, not a redirecting one, so its redirect
1256
+ // pushes the target after it — the entry a Back is owed is the page that submitted the form.
1257
+ if (handleControlDigest(error, { history: 'push' }))
824
1258
  return undefined;
825
1259
  throw error;
826
1260
  }
827
1261
  if (payload.redirect) {
828
- push(payload.redirect);
1262
+ void push(payload.redirect);
829
1263
  return undefined;
830
1264
  }
831
- if (documentUrl() === calledFrom)
832
- React.startTransition(() => void setPayload(payload));
1265
+ if (documentUrl() === calledFrom) {
1266
+ // An action response contains the page after the mutation. Any older navigation snapshot must
1267
+ // lose ownership before it can replace that newer data. Applied streams stay alive until commit.
1268
+ const actionGeneration = ++currentNavigation;
1269
+ rejectAttempt(activeAttempt, new DOMException('Navigation superseded by server action', 'AbortError'));
1270
+ abortNavigationFetches((fetch) => !fetch.applied);
1271
+ React.startTransition(() => {
1272
+ const committed = setPayload(payload, undefined, { recoversByLoad: false });
1273
+ void committed.then(() => {
1274
+ abortNavigationFetches((fetch) => fetch.navigation < actionGeneration);
1275
+ },
1276
+ // An action reply that tears the tree down has no caller awaiting the commit, so the rejection is
1277
+ // consumed here rather than left unhandled. The panel `onUncaughtError` painted is the recovery:
1278
+ // the page a document load would render does not re-run the action that failed.
1279
+ () => { });
1280
+ });
1281
+ }
833
1282
  if (payload.notFound)
834
1283
  return undefined;
835
1284
  const result = payload.returnValue;
@@ -861,17 +1310,33 @@ async function main() {
861
1310
  hydrateRoot(document, _jsx(BrowserRoot, {}), {
862
1311
  formState: initialPayload.formState,
863
1312
  onCaughtError: (error, errorInfo) => {
864
- if (handleControlDigest(error, { hard: true }))
1313
+ if (handleControlDigest(error, { hard: true, history: controlDigestHistory() }))
865
1314
  return;
866
1315
  // A boundary handled it and the tree is intact, so no overlay over the app's own fallback.
867
1316
  console.error(error, errorInfo.componentStack ?? '');
868
1317
  },
869
1318
  onUncaughtError: (error, errorInfo) => {
870
- if (handleControlDigest(error, { hard: true }))
1319
+ if (handleControlDigest(error, { hard: true, history: controlDigestHistory() }))
871
1320
  return;
872
1321
  // Nothing caught it, so React tears the root down — and the root is `document`.
873
1322
  globalThis.reportError(error);
874
- showFatal(error, errorInfo.componentStack);
1323
+ const recover = pendingRender;
1324
+ // A navigation payload is the case a document load can still repair: the URL already names its
1325
+ // destination, and the server answers that document request with the app's error page. An action reply
1326
+ // is not — a GET of the URL it was called from does not re-run the action, so the page would render as
1327
+ // if nothing had happened and the load would silently discard the error. The panel is the answer, and it
1328
+ // is painted now because no load is coming to replace it. See {@link pendingRender}.
1329
+ if (!recover?.recoversByLoad) {
1330
+ showFatal(error, errorInfo.componentStack);
1331
+ recover?.fail(error);
1332
+ return;
1333
+ }
1334
+ // The panel waits for that load to arrive. Painted now, it flashes over the error page on its way and
1335
+ // vanishes when the page commits; the timer dies with the document a successful load replaces, so the
1336
+ // panel only appears when the recovery was never made. A render React is only suspending is not failed;
1337
+ // see {@link pendingRender}.
1338
+ setTimeout(() => showFatal(error, errorInfo.componentStack), RECOVERY_GRACE_MS);
1339
+ recover.fail(error);
875
1340
  },
876
1341
  });
877
1342
  if (import.meta.webpackHot) {