@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.
- package/README.md +5 -4
- package/dist/router.d.ts +9 -8
- package/dist/router.js.map +1 -1
- package/dist/runtime/client.d.ts +1 -1
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/control.js +12 -1
- package/dist/runtime/control.js.map +1 -1
- package/dist/runtime/entry.client.js +414 -115
- package/dist/runtime/entry.client.js.map +1 -1
- package/dist/runtime/entry.rsc.js +41 -9
- package/dist/runtime/entry.rsc.js.map +1 -1
- package/dist/runtime/navigation-preparation.d.ts +52 -0
- package/dist/runtime/navigation-preparation.js +155 -0
- package/dist/runtime/navigation-preparation.js.map +1 -0
- package/dist/runtime/navigation-transition.d.ts +6 -0
- package/dist/runtime/navigation-transition.js +62 -0
- package/dist/runtime/navigation-transition.js.map +1 -0
- package/dist/runtime/navigation.d.ts +28 -26
- package/dist/runtime/navigation.js +50 -47
- package/dist/runtime/navigation.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
417
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
739
|
-
|
|
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
|
|
745
|
-
// instead. Assigned synchronously: React invokes the callback before
|
|
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
|
-
|
|
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
|
|
843
|
-
// document is not showing.
|
|
844
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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] =
|
|
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
|
-
|
|
926
|
-
const
|
|
927
|
-
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|