@rshono/core 1.0.0-rc.28 → 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)
@@ -343,21 +347,30 @@ const documentNavigation = Symbol('rshono:document-navigation');
343
347
  * its Suspense fallback with no second document ever arriving, and how a late `redirect()` moved the address
344
348
  * bar to a page it then failed to render.
345
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
+ *
346
354
  * The mark rides the navigation itself, through `info`, so the listener recognizes the event that owns it
347
355
  * instead of consuming a flag set in advance. There is no state to clear and none to leak: a navigation the
348
356
  * browser refuses cannot make the next one a full load. Below the Navigation API there is no interception to
349
357
  * opt out of, and `location.*` is already a document load.
350
358
  */
351
- function loadDocument(href) {
359
+ function loadDocument(href, { replace = false } = {}) {
360
+ finishAttempt(activeAttempt, { kind: 'document-navigation' });
352
361
  if (!canSoftNavigate) {
353
362
  if (href === undefined)
354
363
  window.location.reload();
364
+ else if (replace)
365
+ window.location.replace(href);
355
366
  else
356
367
  window.location.assign(href);
357
368
  return;
358
369
  }
359
370
  try {
360
- settle(href === undefined ? navigation.reload({ info: documentNavigation }) : navigation.navigate(href, { info: documentNavigation }));
371
+ settle(href === undefined
372
+ ? navigation.reload({ info: documentNavigation })
373
+ : navigation.navigate(href, { history: replace ? 'replace' : 'auto', info: documentNavigation }));
361
374
  }
362
375
  catch {
363
376
  // The Navigation API refuses a document that is not fully active — it is unloading — and a URL it cannot
@@ -367,6 +380,8 @@ function loadDocument(href) {
367
380
  try {
368
381
  if (href === undefined)
369
382
  window.location.reload();
383
+ else if (replace)
384
+ window.location.replace(href);
370
385
  else
371
386
  window.location.assign(href);
372
387
  }
@@ -379,42 +394,102 @@ function loadDocument(href) {
379
394
  // The imperative actions behind `useNavigation().router`. Each one only *asks*: the browser turns it into a
380
395
  // `navigate` event, which is where `listenNavigation` answers it — so a `router.push` and a link click reach
381
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
+ }
382
453
  function push(href) {
383
- if (canSoftNavigate)
384
- settle(navigation.navigate(href, { history: 'push' }));
385
- else
386
- window.location.assign(href);
454
+ return navigateAttempt(href, 'push');
387
455
  }
388
456
  function replace(href) {
389
- if (canSoftNavigate)
390
- settle(navigation.navigate(href, { history: 'replace' }));
391
- else
392
- window.location.replace(href);
457
+ return navigateAttempt(href, 'replace');
393
458
  }
394
- // A traversal is the browser's to perform either way — `navigation` only hands it back as an interceptable
395
- // event first. Nothing to go back to is a rejection there and a no-op here; both amount to the same thing.
396
459
  function back() {
397
- if (canSoftNavigate)
398
- settle(navigation.back());
399
- else
460
+ if (!canSoftNavigate) {
400
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 }));
401
468
  }
402
469
  function forward() {
403
- if (canSoftNavigate)
404
- settle(navigation.forward());
405
- else
470
+ if (!canSoftNavigate) {
406
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 }));
407
478
  }
408
- // A refresh keeps the URL, and is still a navigation: it arrives as `navigationType: 'reload'`, which is what
409
- // tells the listener to leave scroll and focus where the user left them.
410
479
  function refresh() {
411
- if (canSoftNavigate)
412
- settle(navigation.reload());
413
- else
480
+ if (!canSoftNavigate) {
414
481
  window.location.reload();
482
+ return Promise.resolve({ kind: 'document-navigation' });
483
+ }
484
+ const attempt = newAttempt();
485
+ return requestNavigation(attempt, () => navigation.reload({ info: attempt }));
415
486
  }
416
- /** How long the recovery reload is given to replace this document before the panel is painted instead. */
417
- 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;
418
493
  /** The `sessionStorage` key bounding the recovery reload for one URL. Spent here, released in {@link main}. */
419
494
  const lateNotFoundKey = () => `rshono:late-not-found:${documentUrl()}`;
420
495
  /**
@@ -458,7 +533,7 @@ function reloadOnceForLateNotFound() {
458
533
  setTimeout(() => {
459
534
  if (!document.querySelector('[data-rshono-fatal]'))
460
535
  showLateNotFound();
461
- }, RELOAD_GRACE_MS);
536
+ }, RECOVERY_GRACE_MS);
462
537
  }
463
538
  /**
464
539
  * Turns a control-signal digest — how `redirect()` / `notFound()` reach the browser — into a real
@@ -466,8 +541,13 @@ function reloadOnceForLateNotFound() {
466
541
  *
467
542
  * `hard` forces a full document load, for signals that surfaced *through React*: it unmounts the root on
468
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.
469
549
  */
470
- function handleControlDigest(error, { hard = false } = {}) {
550
+ function handleControlDigest(error, { hard = false, history = 'replace' } = {}) {
471
551
  const digest = error?.digest;
472
552
  if (!isControlDigest(digest))
473
553
  return false;
@@ -477,10 +557,14 @@ function handleControlDigest(error, { hard = false } = {}) {
477
557
  }
478
558
  else if (hard) {
479
559
  // The URL is resolved first: a malformed location then throws here, before any navigation is asked for.
480
- 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);
481
565
  }
482
566
  else {
483
- push(redirect.location);
567
+ void push(redirect.location);
484
568
  }
485
569
  return true;
486
570
  }
@@ -622,13 +706,46 @@ let setPayload = () => {
622
706
  window.location.reload();
623
707
  return new Promise(() => { });
624
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
+ }
625
738
  /**
626
739
  * Re-dispatches the payload already waiting to commit, for {@link loadPayload}'s lost-ping retry. Unlike
627
740
  * {@link setPayload} it creates and releases no commit promise, so re-rendering the same payload cannot
628
741
  * resolve the navigation's commit early. Assigned with {@link setPayload} by `BrowserRoot`.
629
742
  */
630
743
  let reapplyPayload = () => { };
631
- /** 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
+ */
632
749
  let startNav = (run) => {
633
750
  void run();
634
751
  };
@@ -640,7 +757,23 @@ let startNav = (run) => {
640
757
  * after the browser has already committed it — so ordering is the runtime's own for those.
641
758
  */
642
759
  let currentNavigation = 0;
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
+ }
643
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
+ }
644
777
  /**
645
778
  * Fetches the payload for `url` and puts it on screen.
646
779
  *
@@ -648,8 +781,11 @@ let navigationFetches = [];
648
781
  * point, and a `#hash` target does not exist until the new tree does. Rejects only on a genuine failure —
649
782
  * being superseded is not one, and resolves quietly, because the navigation that replaced this one owns the
650
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.
651
787
  */
652
- function loadPayload(url, signal, afterCommit) {
788
+ function loadPayload(url, { signal, afterCommit, attempt, prepared } = {}) {
653
789
  // This navigation's place in the queue, and its own abort switch: the navigation stops being applied by
654
790
  // whichever comes first, the browser superseding it (an intercepted navigation carries `event.signal`) or a
655
791
  // newer runtime fetch starting (a traversal, an action, a dev refresh). The fetch itself runs on until its
@@ -661,12 +797,7 @@ function loadPayload(url, signal, afterCommit) {
661
797
  // A newer navigation owns the screen now, and an older fetch whose payload never reached React is reading
662
798
  // into a tree that does not exist — stopping it cannot reject a chunk anything holds. One that did reach
663
799
  // React waits for this payload's commit; see {@link navigationFetches}.
664
- navigationFetches = navigationFetches.filter((fetch) => {
665
- if (fetch === entry || fetch.applied)
666
- return true;
667
- fetch.controller.abort();
668
- return false;
669
- });
800
+ abortNavigationFetches((fetch) => fetch !== entry && !fetch.applied);
670
801
  // The browser supersedes an intercepted navigation the moment a newer one starts, and `signal` is how it
671
802
  // says so. A fetch React has been handed keeps running (see {@link navigationFetches}), but one that never
672
803
  // reached React stops here: the navigation taking over may be one the runtime does not intercept (a
@@ -688,42 +819,69 @@ function loadPayload(url, signal, afterCommit) {
688
819
  // work too, but only because React happens not to gate a commit on its async scope settling — an internal
689
820
  // this has no reason to depend on across the whole `^19.1.0` peer range.
690
821
  let committed;
691
- // Whether the payload update has committed. A streamed transition can suspend on a flight chunk whose later
692
- // resolution React never pings the lane back for; the retry below re-schedules the update once the stream
693
- // has closed, but only while this is still false.
694
822
  let committedSettled = false;
695
823
  const run = async () => {
696
- const { payload, settled } = requestPayload(url, controller.signal);
697
- 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;
698
842
  // Checked again after the await because the fetch may already have resolved by then, and applying it
699
843
  // would repaint a page the user has left.
700
844
  if (stale())
701
845
  return;
702
846
  if (nextPayload.redirect) {
703
- 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);
704
855
  return;
705
856
  }
706
857
  // The fetch crossed an await, so its payload update needs a new synchronous transition scope.
707
858
  // Keep the commit promise outside the async Action; React tracks the scheduled update until commit.
708
- React.startTransition(() => {
859
+ startNavigationPayload(() => {
709
860
  // From here React holds this payload's stream, so a superseded fetch may not be stopped until a newer
710
861
  // payload commits — see {@link navigationFetches}.
862
+ navigationModels.set(controller, nextPayload);
711
863
  entry.applied = true;
712
864
  committed = setPayload(nextPayload, afterCommit);
713
- 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));
714
868
  });
715
869
  // This payload is on screen, so every fetch it superseded is reading into a tree React has replaced.
716
870
  // Stopping them now cannot reject a chunk the committed tree holds — see {@link navigationFetches}.
717
871
  // Only the older ones: a newer navigation may have started while this payload was in flight, and its
718
872
  // fetch is the one the screen is waiting for.
719
873
  void committed?.then(() => {
874
+ if (renderedModel !== nextPayload)
875
+ return;
720
876
  navigationFetches = navigationFetches.filter((fetch) => {
721
877
  if (fetch.navigation >= navigation)
722
878
  return true;
723
879
  fetch.controller.abort();
724
880
  return false;
725
881
  });
726
- });
882
+ },
883
+ // A rejected commit is the caller's to report; this chain only exists to reap older fetches.
884
+ () => { });
727
885
  // A streamed payload can suspend a transition on a flight chunk that resolves after the shell without
728
886
  // React ever pinging the suspended lane back, which strands the navigation on the previous tree. The
729
887
  // chunks have all landed by the time the stream closes, so one re-application — still a transition, so a
@@ -731,18 +889,18 @@ function loadPayload(url, signal, afterCommit) {
731
889
  // lost ping stranded. It goes through `reapplyPayload` rather than `setPayload`: the payload is not
732
890
  // replaced, so the commit the navigation waits on has to stay attached to it. The macrotask lets the
733
891
  // decoder process the last chunk and React's own retry run first, so the normal path is not interrupted.
734
- void settled.then(() => {
892
+ void fetched.settled.then(() => {
735
893
  if (stale() || committedSettled)
736
894
  return;
737
895
  setTimeout(() => {
738
- if (stale() || committedSettled)
739
- return;
740
- React.startTransition(reapplyPayload);
896
+ if (!stale() && !committedSettled)
897
+ React.startTransition(reapplyPayload);
741
898
  }, 0);
742
899
  });
743
900
  };
744
- // `startTransition` runs the work but hands nothing back, so the promise carrying a failure is caught here
745
- // 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.
746
904
  let work;
747
905
  startNav(() => (work = run()));
748
906
  return work.then(
@@ -790,6 +948,30 @@ function leaveToBrowser(event) {
790
948
  event.sourceElement?.hasAttribute('data-native') === true ||
791
949
  namesAFile(event.destination.url));
792
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
+ }
793
975
  /**
794
976
  * The whole router, in one listener.
795
977
  *
@@ -802,12 +984,25 @@ function leaveToBrowser(event) {
802
984
  function listenNavigation() {
803
985
  if (!canSoftNavigate)
804
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
+ });
805
994
  const onNavigate = (event) => {
806
995
  // A document navigation the runtime asked for itself, marked through `info`. Checked before the scroll
807
996
  // snapshot: the document is going away, so where its entry was left does not matter, and an unrelated
808
997
  // later navigation can never be mistaken for this one.
809
998
  if (event.info === documentNavigation)
810
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;
811
1006
  // A commit for the destination being left is no longer the screen's; its pending fragment target (or
812
1007
  // `autofocus` element) must not scroll or focus the page that replaces it.
813
1008
  cancelElementWatches();
@@ -815,14 +1010,29 @@ function listenNavigation() {
815
1010
  // offset it was at, and nothing else in this document will put it back. Saved before the branches below
816
1011
  // return, so a fragment the browser performs itself is covered too.
817
1012
  rememberCurrentScroll();
818
- 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
+ }
819
1021
  return;
1022
+ }
820
1023
  // A traversal is repainted by the `popstate` listener below, not intercepted. `intercept`ing one is what
821
1024
  // makes WebKit stall the rendered viewport for about three seconds on a back swipe
822
1025
  // (bugs.webkit.org/319414): the view gesture's snapshot is only removed once the navigation finishes, and
823
1026
  // React's root attaches the wheel listener the bug also needs. Letting the traversal through means the
824
1027
  // browser commits the entry straight away, and the runtime puts the payload on screen when it arrives.
825
- 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))
826
1036
  return;
827
1037
  // A replace or a refresh stays where it is, so neither should move. A push starts at the top of the
828
1038
  // page, or at its fragment — and that scroll is the runtime's own now: WebKit performs no
@@ -831,17 +1041,21 @@ function listenNavigation() {
831
1041
  // handler over without scrolling; the `afterCommit` callback reaches the target once the new tree is on
832
1042
  // screen. Focus stays the browser's, reset after the transition.
833
1043
  const inPlace = event.navigationType === 'replace' || event.navigationType === 'reload';
834
- let afterCommit;
835
- if (event.navigationType === 'push') {
836
- const { hash } = new URL(event.destination.url);
837
- afterCommit = hash === '' ? scrollToTop : () => jumpToAnchor(hash);
838
- }
1044
+ const hop = attempt?.hop;
839
1045
  event.intercept({
840
1046
  scroll: 'manual',
841
1047
  focusReset: inPlace ? 'manual' : 'after-transition',
842
- // The URL commits before the handler runs, so a failure leaves the address bar describing a page the
843
- // document is not showing. A real load is the only way back to agreement.
844
- 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
+ }),
845
1059
  });
846
1060
  };
847
1061
  /**
@@ -850,6 +1064,9 @@ function listenNavigation() {
850
1064
  * is `navigation.currentEntry` and its saved offset is in {@link scrollPositions}.
851
1065
  */
852
1066
  const onPopState = () => {
1067
+ const traversal = traversalAttempt;
1068
+ const attempt = traversal && traversal.key === currentEntryKey() ? traversal.attempt : undefined;
1069
+ traversalAttempt = undefined;
853
1070
  // Same ownership as `onNavigate`: this traversal's commit replaces whatever the last one was waiting for.
854
1071
  cancelElementWatches();
855
1072
  const stored = scrollPointFor(currentEntryKey());
@@ -861,6 +1078,7 @@ function listenNavigation() {
861
1078
  jumpToAnchor(hash);
862
1079
  else
863
1080
  scrollToTop();
1081
+ finishAttempt(attempt, { kind: 'rendered', url: new URL(location.href) });
864
1082
  };
865
1083
  // A fragment-only traversal never changed the payload: the address bar is back at an anchor of the page
866
1084
  // already on screen, so there is nothing to fetch and nothing to wait for. The browser's own restoration
@@ -870,16 +1088,28 @@ function listenNavigation() {
870
1088
  apply();
871
1089
  return;
872
1090
  }
873
- void loadPayload(location.href, undefined, () => {
874
- apply();
875
- // The browser performs this for an intercepted navigation; a traversal is no longer intercepted, so
876
- // without it Back would leave focus on the link that was clicked on the page being left.
877
- resetFocus();
878
- }).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
+ });
879
1108
  };
880
1109
  navigation.addEventListener('navigate', onNavigate);
881
1110
  window.addEventListener('popstate', onPopState);
882
1111
  return () => {
1112
+ disposePreparation();
883
1113
  navigation.removeEventListener('navigate', onNavigate);
884
1114
  window.removeEventListener('popstate', onPopState);
885
1115
  };
@@ -916,58 +1146,92 @@ async function main() {
916
1146
  if (canSoftNavigate)
917
1147
  readStoredScrollPositions();
918
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);
919
1185
  function BrowserRoot() {
920
1186
  const [payload, setPayloadState] = React.useState(initialPayload);
921
- const [pending, startTransition] = React.useTransition();
1187
+ const [pending, startTransition] = useNavigationTransition(payload, restoreCommittedPayload);
922
1188
  // The payload the last `setPayload` put on screen or on its way there, so `reapplyPayload` re-renders
923
1189
  // that one rather than a payload a later navigation or action has already replaced.
924
1190
  const currentPayload = React.useRef(initialPayload);
925
- // The resolver the payload on screen still owes — see {@link loadPayload}.
926
- const pendingCommit = React.useRef(null);
927
- // The scroll the payload about to commit owes, set with it so a payload that supersedes another takes
928
- // its predecessor's scroll out of the queue along with its commit. Initialised with the reload's offset:
929
- // the first layout effect below applies it with the first payload, before the new tree is painted.
930
- const pendingScroll = React.useRef(restored ? () => scrollToPoint(restored) : null);
931
- // Descendant passive effects can start a navigation before the root's passive effects run.
932
- // 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);
933
1194
  React.useLayoutEffect(() => {
934
- 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;
935
1199
  currentPayload.current = next;
936
- return new Promise((resolve) => {
937
- // A payload replaced before it ever painted still has a navigation waiting on it. React commits
938
- // only the newest, so the effect below never runs for the one it skipped: release it here.
939
- pendingCommit.current?.();
940
- pendingCommit.current = resolve;
941
- // Replaced rather than kept: a server action's payload carries no `afterCommit`, and the
942
- // navigation it superseded must not scroll the page the action is about to replace it with.
943
- pendingScroll.current = afterCommit ?? null;
944
- setPayloadState(next);
945
- });
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);
946
1210
  };
947
- // A re-dispatch of the payload above, for the retry in `loadPayload`: keeping `pendingCommit` and
948
- // `pendingScroll` in place means the navigation already waiting resolves when this render commits,
949
- // not when the retry runs.
950
- reapplyPayload = () => setPayloadState(currentPayload.current);
951
1211
  startNav = (run) => startTransition(run);
952
- }, [startTransition]);
953
- /**
954
- * Performs the pending scroll and releases the navigation waiting on this payload. A layout effect, so
955
- * the new tree is in the DOM and the pre-scroll position is never painted.
956
- */
1212
+ }, [renders, startTransition]);
957
1213
  React.useLayoutEffect(() => {
958
- const scroll = pendingScroll.current;
959
- pendingScroll.current = null;
960
- scroll?.();
961
- // What the payload that just committed was rendered for — see {@link renderedUrl}. A redirect never
962
- // reaches here, and an action or a dev refresh keeps the URL it was fetched for.
963
- renderedUrl = documentUrl();
964
- const commit = pendingCommit.current;
965
- pendingCommit.current = null;
966
- commit?.();
967
- }, [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]);
968
1231
  // A descendant may navigate from its first passive effect. Intercept it before that effect runs.
969
1232
  React.useLayoutEffect(() => listenNavigation(), []);
970
- 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]);
971
1235
  return _jsx(RouterContext.Provider, { value: router, children: payload.root });
972
1236
  }
973
1237
  setServerCallback(async (id, args) => {
@@ -984,18 +1248,37 @@ async function main() {
984
1248
  let payload;
985
1249
  try {
986
1250
  payload = await createFromFetch(payloadResponse(request), { temporaryReferences });
1251
+ if (documentUrl() === calledFrom)
1252
+ cancelNavigationPreparation();
987
1253
  }
988
1254
  catch (error) {
989
- 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' }))
990
1258
  return undefined;
991
1259
  throw error;
992
1260
  }
993
1261
  if (payload.redirect) {
994
- push(payload.redirect);
1262
+ void push(payload.redirect);
995
1263
  return undefined;
996
1264
  }
997
- if (documentUrl() === calledFrom)
998
- 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
+ }
999
1282
  if (payload.notFound)
1000
1283
  return undefined;
1001
1284
  const result = payload.returnValue;
@@ -1027,17 +1310,33 @@ async function main() {
1027
1310
  hydrateRoot(document, _jsx(BrowserRoot, {}), {
1028
1311
  formState: initialPayload.formState,
1029
1312
  onCaughtError: (error, errorInfo) => {
1030
- if (handleControlDigest(error, { hard: true }))
1313
+ if (handleControlDigest(error, { hard: true, history: controlDigestHistory() }))
1031
1314
  return;
1032
1315
  // A boundary handled it and the tree is intact, so no overlay over the app's own fallback.
1033
1316
  console.error(error, errorInfo.componentStack ?? '');
1034
1317
  },
1035
1318
  onUncaughtError: (error, errorInfo) => {
1036
- if (handleControlDigest(error, { hard: true }))
1319
+ if (handleControlDigest(error, { hard: true, history: controlDigestHistory() }))
1037
1320
  return;
1038
1321
  // Nothing caught it, so React tears the root down — and the root is `document`.
1039
1322
  globalThis.reportError(error);
1040
- 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);
1041
1340
  },
1042
1341
  });
1043
1342
  if (import.meta.webpackHot) {