@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.
- package/README.md +5 -4
- package/dist/cli/dev.js +32 -1
- package/dist/cli/dev.js.map +1 -1
- package/dist/deploy/node/runtime.js +16 -2
- package/dist/deploy/node/runtime.js.map +1 -1
- 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/dev-protocol.d.ts +10 -0
- package/dist/runtime/dev-protocol.js.map +1 -1
- package/dist/runtime/entry.client.js +592 -127
- 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)
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
364
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
|
473
|
-
|
|
664
|
+
const find = () => document.getElementById(id) ?? document.getElementsByName(id)[0] ?? null;
|
|
665
|
+
const target = find();
|
|
666
|
+
if (target) {
|
|
474
667
|
target.scrollIntoView();
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
489
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
523
|
-
|
|
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:
|
|
534
|
-
// first, the browser superseding it (an intercepted navigation carries `event.signal`) or a
|
|
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
|
-
|
|
538
|
-
const
|
|
539
|
-
|
|
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
|
-
|
|
551
|
-
|
|
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 (
|
|
844
|
+
if (stale())
|
|
555
845
|
return;
|
|
556
846
|
if (nextPayload.redirect) {
|
|
557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
892
|
+
void fetched.settled.then(() => {
|
|
893
|
+
if (stale() || committedSettled)
|
|
575
894
|
return;
|
|
576
895
|
setTimeout(() => {
|
|
577
|
-
if (
|
|
578
|
-
|
|
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
|
|
584
|
-
// 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.
|
|
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 (
|
|
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
|
-
|
|
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
|
|
679
|
-
// document is not showing.
|
|
680
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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] =
|
|
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
|
-
|
|
760
|
-
const
|
|
761
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|