@webjsdev/core 0.7.46 → 0.7.48
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/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +21 -21
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +5 -1
- package/index.d.ts +4 -1
- package/index.js +5 -1
- package/package.json +2 -2
- package/src/action-seed-client.d.ts +23 -1
- package/src/action-seed-client.js +359 -17
- package/src/component.d.ts +7 -3
- package/src/component.js +69 -0
- package/src/form-action.d.ts +16 -0
- package/src/form-action.js +1128 -33
- package/src/optimistic.js +7 -2
- package/src/render-client.js +978 -111
- package/src/render-server.js +314 -24
- package/src/router-client.js +941 -33
- package/src/webjs-config.d.ts +49 -17
package/src/router-client.js
CHANGED
|
@@ -8,12 +8,17 @@ import './webjs-frame.js';
|
|
|
8
8
|
// live-channel `connectWS` handler.
|
|
9
9
|
import './webjs-stream.js';
|
|
10
10
|
import { renderStream } from './webjs-stream.js';
|
|
11
|
+
import { FORM_ACTION_FIELD } from './form-action.js';
|
|
11
12
|
// Register <webjs-suspense> (the element-level streaming boundary, #471) so it
|
|
12
13
|
// is layout-neutral and available for the progressive soft-nav streaming apply.
|
|
13
14
|
import './webjs-suspense.js';
|
|
14
15
|
// Ingest SSR action seeds (#472) from an incoming soft-nav document before its
|
|
15
16
|
// components hydrate, so a navigated async component resolves from the seed.
|
|
16
17
|
import { scanSeeds } from './action-seed-client.js';
|
|
18
|
+
// A form-bound action's `invalidates` tags (#1155) ride the submission response
|
|
19
|
+
// the same way an RPC mutation's do, so a cached GET action is not served stale
|
|
20
|
+
// after a no-JS-shaped write went through the router.
|
|
21
|
+
import { markStale, parseTagHeader } from './action-cache-client.js';
|
|
17
22
|
// Slot-runtime constants for re-projecting page-authored slotted content of a
|
|
18
23
|
// reused hydrated light-DOM component across a soft nav (#908).
|
|
19
24
|
import {
|
|
@@ -297,6 +302,284 @@ let currentPageUrl = null;
|
|
|
297
302
|
*/
|
|
298
303
|
let prevScrollRestoration = null;
|
|
299
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Hard ceiling on the restore window (#1310). The revalidation is a
|
|
307
|
+
* same-origin GET of a page the browser rendered moments ago, so this is
|
|
308
|
+
* generously past its p99. It exists only so a hung or never-settling fetch
|
|
309
|
+
* can never leave scroll anchoring suppressed for the life of the page.
|
|
310
|
+
*/
|
|
311
|
+
const ANCHOR_SUPPRESS_CEILING_MS = 2000;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Floor on the restore window (#1310). The window's other closer is the
|
|
315
|
+
* revalidation settling, which is only long enough while the revalidation is
|
|
316
|
+
* SLOWER than the restored page's own upgrade-and-render. That holds on a
|
|
317
|
+
* deployed site (measured: growth ~65ms after the swap, the revalidation's swap
|
|
318
|
+
* ~300ms after that) but it is a property of one deployment, not a guarantee: a
|
|
319
|
+
* local server, a 304, or a warm cache answers in single-digit milliseconds and
|
|
320
|
+
* would otherwise close the window before the growth it exists to absorb.
|
|
321
|
+
*
|
|
322
|
+
* So the window lasts at least this long whatever the network does. The value
|
|
323
|
+
* clears the measured revalidation swap with margin and stays well under the
|
|
324
|
+
* ceiling. It is a floor, not a delay: a real user input still closes the
|
|
325
|
+
* window immediately, which is the case that actually matters for not holding
|
|
326
|
+
* anchoring off longer than a reader would want.
|
|
327
|
+
*/
|
|
328
|
+
const ANCHOR_SUPPRESS_FLOOR_MS = 500;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Inputs that mean the reader has taken over the viewport, so the restore is
|
|
332
|
+
* over and the browser's own anchoring should resume.
|
|
333
|
+
*
|
|
334
|
+
* NOT `scroll`. The router's own `scrollTo` and anchoring itself both fire
|
|
335
|
+
* `scroll`, so it cannot tell a reader apart from the restore it is guarding,
|
|
336
|
+
* and no threshold makes it able to. These are input events, so there is
|
|
337
|
+
* nothing to threshold out and the FIRST one closes the window. `keydown` is
|
|
338
|
+
* deliberately not narrowed to scrolling keys: any keypress means interaction,
|
|
339
|
+
* and closing early only restores the browser default, which is the safe
|
|
340
|
+
* direction to err in.
|
|
341
|
+
*
|
|
342
|
+
* @type {string[]}
|
|
343
|
+
*/
|
|
344
|
+
const ANCHOR_RELEASE_EVENTS = ['wheel', 'touchmove', 'keydown', 'pointerdown'];
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Closes the currently open restore window, or null when none is open.
|
|
348
|
+
* @type {(() => void) | null}
|
|
349
|
+
*/
|
|
350
|
+
let releaseScrollAnchor = null;
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Suppress the browser's scroll anchoring for the duration of a back/forward
|
|
354
|
+
* scroll restore (#1310).
|
|
355
|
+
*
|
|
356
|
+
* A snapshot's `scrollY` is recorded against the page at its SETTLED height.
|
|
357
|
+
* The restore replays that number onto a document that has only just been
|
|
358
|
+
* swapped in and is still shorter, because the components in the restored
|
|
359
|
+
* markup have not upgraded and re-rendered yet. When they do, content grows
|
|
360
|
+
* ABOVE the viewport, and scroll anchoring (`overflow-anchor: auto`, the UA
|
|
361
|
+
* default) holds the VISUAL position by adding that growth to `scrollY`. The
|
|
362
|
+
* offset is counted twice. On webjs.dev's `/ui/button` that lands the reader
|
|
363
|
+
* 763px too low, exactly the settled-minus-swapped height delta.
|
|
364
|
+
*
|
|
365
|
+
* Anchoring is right for a reader on a live page and wrong for exactly this
|
|
366
|
+
* window, where the restored number already accounts for the growth. So the
|
|
367
|
+
* window suppresses it rather than re-scrolling afterwards. A re-assert would
|
|
368
|
+
* have to fire on every growth, and a settling restore cannot be told apart
|
|
369
|
+
* from a `<webjs-suspense>` boundary streaming in (#471 / #473). Suppression
|
|
370
|
+
* never MOVES the viewport, it only withholds a correction, so it also cannot
|
|
371
|
+
* yank a reader who has already started scrolling.
|
|
372
|
+
*
|
|
373
|
+
* Chromium, Firefox, and WebKit all implement scroll anchoring, and all three
|
|
374
|
+
* honour `overflow-anchor: none` identically whether it sits on the root
|
|
375
|
+
* scroller or on `<body>`, so there is no engine-specific path here.
|
|
376
|
+
*
|
|
377
|
+
* It goes on the ROOT, and `<body>` is not an alternative even though it looks
|
|
378
|
+
* like the tidier one. Suppressing on `<body>` works identically on all three
|
|
379
|
+
* engines (the property excludes an element and its subtree from being chosen
|
|
380
|
+
* as the anchor, and every candidate lives under `<body>`), and it would avoid
|
|
381
|
+
* writing to the root at all, which is worth wanting: toggling something on the
|
|
382
|
+
* root re-runs global style resolution, and on WebKit that re-resolves
|
|
383
|
+
* `oklch()` token values and repaints them for a frame, which is the #610 flash
|
|
384
|
+
* that made `data-navigating` opt-in.
|
|
385
|
+
*
|
|
386
|
+
* It is disqualified by the RELEASE, not the suppression. On WebKit, anchoring
|
|
387
|
+
* never resumes once it has been suppressed on `<body>`: removing the property,
|
|
388
|
+
* setting it back to `auto`, and both in sequence were each measured, and after
|
|
389
|
+
* every one the next growth above the viewport still failed to move `scrollY`.
|
|
390
|
+
* Suppressing on the root resumes correctly on all three. Since the whole point
|
|
391
|
+
* is that suppression is TEMPORARY, a placement that cannot be undone would
|
|
392
|
+
* leave every WebKit reader, so every iOS browser, with scroll anchoring off
|
|
393
|
+
* for the life of the page after their first Back. That is a far worse trade
|
|
394
|
+
* than one repaint, so the root it is.
|
|
395
|
+
*
|
|
396
|
+
* @returns {() => void} Idempotent release. Safe to call after the window has
|
|
397
|
+
* already closed on user input or the ceiling.
|
|
398
|
+
*/
|
|
399
|
+
function suppressScrollAnchoring() {
|
|
400
|
+
if (typeof document === 'undefined' || !document.documentElement) return () => {};
|
|
401
|
+
// A second restore inside an open window supersedes the first.
|
|
402
|
+
if (releaseScrollAnchor) releaseScrollAnchor();
|
|
403
|
+
const root = document.documentElement;
|
|
404
|
+
// Save and restore the author's own inline value rather than blanking it,
|
|
405
|
+
// the same contract `prevScrollRestoration` keeps above.
|
|
406
|
+
const prev = root.style.getPropertyValue('overflow-anchor');
|
|
407
|
+
root.style.setProperty('overflow-anchor', 'none');
|
|
408
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
409
|
+
let timer = null;
|
|
410
|
+
const release = () => {
|
|
411
|
+
// Only the window that installed this release may close it.
|
|
412
|
+
if (releaseScrollAnchor !== release) return;
|
|
413
|
+
releaseScrollAnchor = null;
|
|
414
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
415
|
+
if (typeof window !== 'undefined') {
|
|
416
|
+
for (const ev of ANCHOR_RELEASE_EVENTS) {
|
|
417
|
+
window.removeEventListener(ev, release, /** @type {any} */ ({ capture: true }));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (prev) root.style.setProperty('overflow-anchor', prev);
|
|
421
|
+
else root.style.removeProperty('overflow-anchor');
|
|
422
|
+
};
|
|
423
|
+
releaseScrollAnchor = release;
|
|
424
|
+
timer = setTimeout(release, ANCHOR_SUPPRESS_CEILING_MS);
|
|
425
|
+
if (typeof window !== 'undefined') {
|
|
426
|
+
for (const ev of ANCHOR_RELEASE_EVENTS) {
|
|
427
|
+
window.addEventListener(ev, release, { capture: true, passive: true });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return release;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Cancels an in-flight catch-up, or null when none is running.
|
|
435
|
+
* @type {(() => void) | null}
|
|
436
|
+
*/
|
|
437
|
+
let cancelScrollCatchUp = null;
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Bumped wherever a restore is superseded (#1310), and read by the one restore
|
|
441
|
+
* path that outlives the call scheduling it. That means a PAGE navigation, a
|
|
442
|
+
* PAGE-level submission, and disabling the router. A frame-targeted nav or
|
|
443
|
+
* submission swaps one region and leaves the page, so it is excluded, exactly
|
|
444
|
+
* like the `loadFrame` case below.
|
|
445
|
+
*
|
|
446
|
+
* Deliberately NOT `currentNavigationToken`, which is the obvious choice and the
|
|
447
|
+
* wrong one: `loadFrame` bumps that too, and its own contract says a frame
|
|
448
|
+
* self-load is not a page navigation. An eager `<webjs-frame src>` inside a
|
|
449
|
+
* RESTORED snapshot loads during the swap, so keying on the nav token would
|
|
450
|
+
* read a routine frame load as a supersede and drop the entire restore, leaving
|
|
451
|
+
* the reader at the outgoing page's offset. That is worse than the defect this
|
|
452
|
+
* whole change fixes. This counter moves only for the three things that really
|
|
453
|
+
* do end a restore.
|
|
454
|
+
*/
|
|
455
|
+
let restoreGeneration = 0;
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Chase a restored scroll offset the document was too SHORT to reach (#1310).
|
|
459
|
+
*
|
|
460
|
+
* The sibling of `suppressScrollAnchoring`, for the case that one deliberately
|
|
461
|
+
* declines. When the recorded offset is past the un-grown document's maximum,
|
|
462
|
+
* the browser clamps, and anchoring then adds the growth back as the page
|
|
463
|
+
* settles. That lands a reader who left at the very bottom back at the bottom,
|
|
464
|
+
* because there the shortfall and the growth are the same number. It is wrong
|
|
465
|
+
* for everyone else: anchoring adds the FULL growth whatever the shortfall was,
|
|
466
|
+
* so a reader who left 100px above the bottom is carried 100px too far.
|
|
467
|
+
*
|
|
468
|
+
* This re-asserts the recorded offset once the document can actually hold it,
|
|
469
|
+
* which is the only moment the number becomes reachable, and then stops.
|
|
470
|
+
*
|
|
471
|
+
* It is deliberately narrow, because #1310 rejected re-asserting the scroll in
|
|
472
|
+
* the general case and that reasoning still holds. The difference is that this
|
|
473
|
+
* knows exactly where it is going and can tell when it has arrived: it runs
|
|
474
|
+
* ONLY on the clamped path, only while the offset is still out of reach, writes
|
|
475
|
+
* once, and stops on the first real input.
|
|
476
|
+
*
|
|
477
|
+
* It does NOT escape the settling-versus-streaming question, and it is worth
|
|
478
|
+
* being exact about that rather than claiming otherwise. It cannot tell the
|
|
479
|
+
* restore settling apart from any other growth, so the guard is its WINDOW: it
|
|
480
|
+
* lives for `ANCHOR_SUPPRESS_FLOOR_MS` from the RESTORE and no longer. That is
|
|
481
|
+
* tighter than the window a landed restore gets, which runs to the later of the
|
|
482
|
+
* floor and the revalidation and is capped by the ceiling, and deliberately so,
|
|
483
|
+
* because this path WRITES scroll. The suppression this path installs once the
|
|
484
|
+
* chase lands is part of the same window, not a second one: it shares this
|
|
485
|
+
* deadline, so the whole clamped path is bounded by one floor measured from the
|
|
486
|
+
* restore however late the landing happens.
|
|
487
|
+
*
|
|
488
|
+
* Be precise about what the bound does and does not buy, since it is easy to
|
|
489
|
+
* overclaim in both directions. WHILE the window is open the reader is
|
|
490
|
+
* protected: until the offset is reachable there is nothing to protect, and
|
|
491
|
+
* from the moment the chase lands on it, suppression holds it against the rest
|
|
492
|
+
* of the growth. AFTER the window closes, both halves stop: the router writes
|
|
493
|
+
* no more scroll, and anchoring is back on, so any growth still arriving is
|
|
494
|
+
* added to `scrollY` and carries the reader down toward the bottom, which is
|
|
495
|
+
* main's behaviour. So the cost of a component that settles later than the
|
|
496
|
+
* window is that the reader drifts below the offset, not that they sit at the
|
|
497
|
+
* clamp.
|
|
498
|
+
*
|
|
499
|
+
* @param {number} targetY The recorded offset to reach.
|
|
500
|
+
* @param {number} targetX
|
|
501
|
+
*/
|
|
502
|
+
function catchUpToRestoredScroll(targetY, targetX) {
|
|
503
|
+
if (typeof window === 'undefined' || typeof requestAnimationFrame !== 'function') return;
|
|
504
|
+
if (cancelScrollCatchUp) cancelScrollCatchUp();
|
|
505
|
+
let rafId = 0;
|
|
506
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
507
|
+
let timer = null;
|
|
508
|
+
/** Release for the suppression installed once the offset is reached. */
|
|
509
|
+
let releaseLanded = null;
|
|
510
|
+
const stop = () => {
|
|
511
|
+
if (cancelScrollCatchUp !== stop) return;
|
|
512
|
+
cancelScrollCatchUp = null;
|
|
513
|
+
if (rafId) cancelAnimationFrame(rafId);
|
|
514
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
515
|
+
if (releaseLanded) { releaseLanded(); releaseLanded = null; }
|
|
516
|
+
for (const ev of ANCHOR_RELEASE_EVENTS) {
|
|
517
|
+
window.removeEventListener(ev, stop, /** @type {any} */ ({ capture: true }));
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
const tick = () => {
|
|
521
|
+
if (cancelScrollCatchUp !== stop) return;
|
|
522
|
+
const maxY = document.documentElement.scrollHeight - window.innerHeight;
|
|
523
|
+
if (maxY >= targetY) {
|
|
524
|
+
// Reachable at last. Land the reader on the recorded offset.
|
|
525
|
+
window.scrollTo({ left: targetX, top: targetY, behavior: 'instant' });
|
|
526
|
+
// And then protect it, because landing is not the end of the story. The
|
|
527
|
+
// growth that made the offset reachable is rarely all of it: the real
|
|
528
|
+
// cause is components upgrading one at a time, so more arrives after
|
|
529
|
+
// this. Anchoring is still on here, deliberately, so every later stage
|
|
530
|
+
// would be added on top of the offset just written and carry the reader
|
|
531
|
+
// below it again. Measured on a two-stage fixture, an offset of 4000
|
|
532
|
+
// ended at 5000.
|
|
533
|
+
//
|
|
534
|
+
// Once the reader IS on the recorded offset the situation is identical to
|
|
535
|
+
// a restore that landed on its first try, so it gets that case's
|
|
536
|
+
// protection for what remains.
|
|
537
|
+
//
|
|
538
|
+
// It shares THIS chase's deadline rather than starting one of its own,
|
|
539
|
+
// which matters: a fresh floor-length timer here would start at landing
|
|
540
|
+
// rather than at the restore, so the clamped path could hold anchoring
|
|
541
|
+
// off for nearly twice the floor and stop being the tighter of the two
|
|
542
|
+
// windows, which is the whole reason for the bound. `stop` owns the
|
|
543
|
+
// release, so the existing timer and the input listeners close it.
|
|
544
|
+
releaseLanded = suppressScrollAnchoring();
|
|
545
|
+
// Deliberately NOT `stop()`: the window has to outlive the landing, up to
|
|
546
|
+
// the deadline already running.
|
|
547
|
+
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
rafId = requestAnimationFrame(tick);
|
|
551
|
+
};
|
|
552
|
+
cancelScrollCatchUp = stop;
|
|
553
|
+
// Same inputs that close a suppression window: the reader has taken over.
|
|
554
|
+
for (const ev of ANCHOR_RELEASE_EVENTS) {
|
|
555
|
+
window.addEventListener(ev, stop, { capture: true, passive: true });
|
|
556
|
+
}
|
|
557
|
+
// Bounded by the FLOOR, not the ceiling. The ceiling is a backstop for a hung
|
|
558
|
+
// fetch; this is a scroll WRITE, so its window is the one thing that decides
|
|
559
|
+
// whether a reader can be moved without asking. Any growth past the target
|
|
560
|
+
// fires it, and growth is not exclusively the restore settling: a
|
|
561
|
+
// <webjs-suspense> boundary resolving, a lazy component entering, or a late
|
|
562
|
+
// image would all qualify. Holding it open for the full ceiling would mean a
|
|
563
|
+
// reader who landed and started READING, and so generates no input to cancel
|
|
564
|
+
// it, could be scrolled up to two seconds after pressing Back. The floor
|
|
565
|
+
// covers the restore's own settling, which is what it is for, and is measured
|
|
566
|
+
// in a few hundred milliseconds rather than seconds.
|
|
567
|
+
timer = setTimeout(stop, ANCHOR_SUPPRESS_FLOOR_MS);
|
|
568
|
+
rafId = requestAnimationFrame(tick);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Run `fn` after two animation frames, so a just-applied DOM has laid out
|
|
573
|
+
* before it reads or acts. Falls back to a macrotask where
|
|
574
|
+
* `requestAnimationFrame` is absent (the linkedom-backed node test harness).
|
|
575
|
+
*
|
|
576
|
+
* @param {() => void} fn
|
|
577
|
+
*/
|
|
578
|
+
function afterTwoFrames(fn) {
|
|
579
|
+
if (typeof requestAnimationFrame !== 'function') { setTimeout(fn, 0); return; }
|
|
580
|
+
requestAnimationFrame(() => requestAnimationFrame(fn));
|
|
581
|
+
}
|
|
582
|
+
|
|
300
583
|
/** Enable the client router. Idempotent. */
|
|
301
584
|
export function enableClientRouter() {
|
|
302
585
|
if (enabled || typeof document === 'undefined') return;
|
|
@@ -341,6 +624,11 @@ export function enableClientRouter() {
|
|
|
341
624
|
// Seed the "current page" tracker so the first navigation can
|
|
342
625
|
// snapshot the page the user is leaving.
|
|
343
626
|
if (typeof location !== 'undefined') currentPageUrl = location.href;
|
|
627
|
+
// Last, once the listeners are on: report whether the load that got us here
|
|
628
|
+
// was a same-origin navigation the router never saw (#1118). Running it after
|
|
629
|
+
// the listeners means a throw inside a diagnostic can never leave the router
|
|
630
|
+
// half-installed.
|
|
631
|
+
reportPreBootNavigation();
|
|
344
632
|
}
|
|
345
633
|
|
|
346
634
|
/** Disable the client router. */
|
|
@@ -362,6 +650,11 @@ export function disableClientRouter() {
|
|
|
362
650
|
history.scrollRestoration = prevScrollRestoration;
|
|
363
651
|
prevScrollRestoration = null;
|
|
364
652
|
}
|
|
653
|
+
// Never leave a restore window open on <html>, nor a catch-up chasing a
|
|
654
|
+
// scroll offset after the router is gone (#1310).
|
|
655
|
+
restoreGeneration += 1;
|
|
656
|
+
if (releaseScrollAnchor) releaseScrollAnchor();
|
|
657
|
+
if (cancelScrollCatchUp) cancelScrollCatchUp();
|
|
365
658
|
currentPageUrl = null;
|
|
366
659
|
}
|
|
367
660
|
|
|
@@ -373,7 +666,9 @@ export function disableClientRouter() {
|
|
|
373
666
|
export async function navigate(url, opts) {
|
|
374
667
|
const target = new URL(url, location.href);
|
|
375
668
|
if (target.origin !== location.origin) {
|
|
376
|
-
|
|
669
|
+
// Cross-origin: an intentional full-page nav, not a degradation, but it
|
|
670
|
+
// ends the session in a test just the same, so it rides the same seam.
|
|
671
|
+
hardNavigate(url);
|
|
377
672
|
return;
|
|
378
673
|
}
|
|
379
674
|
await performNavigation(target.href, opts?.replace ?? false, null);
|
|
@@ -471,6 +766,28 @@ export function revalidate(url) {
|
|
|
471
766
|
*/
|
|
472
767
|
const NON_HTML_EXTENSIONS = /\.(?:pdf|zip|tar|gz|7z|rar|dmg|exe|msi|deb|rpm|apk|ipa|xlsx?|docx?|pptx?|csv|odt|ods|odp|rtf|epub|mobi|xml|json|rss|atom|txt|md|wasm|mp3|mp4|mov|avi|webm|ogg|flac|wav|m4a|m4v|mkv|png|jpe?g|gif|webp|avif|bmp|ico|svg|tiff?|heic)$/i;
|
|
473
768
|
|
|
769
|
+
/**
|
|
770
|
+
* The one place the router hands a navigation back to the browser.
|
|
771
|
+
*
|
|
772
|
+
* Every hard navigation the router performs goes through here rather than
|
|
773
|
+
* assigning `location.href` inline, so a browser test can observe it. The
|
|
774
|
+
* default is exactly the assignment it replaces, so behaviour is unchanged
|
|
775
|
+
* unless something calls `setHardNavigate`.
|
|
776
|
+
*
|
|
777
|
+
* This exists because a hard navigation is UNOBSERVABLE and UNPREVENTABLE from
|
|
778
|
+
* outside. `preventDefault` cancels a default action, not a script assignment,
|
|
779
|
+
* and `location.href` is non-configurable on Chromium, Firefox, and WebKit
|
|
780
|
+
* alike, so a test cannot redefine its setter either (measured; the older
|
|
781
|
+
* `spyOnReload` helper that tried was silently a no-op on every engine). In a
|
|
782
|
+
* web-test-runner session a real navigation aborts the WHOLE session, so one
|
|
783
|
+
* degradation destroys every remaining browser test file and reports `0 failed`
|
|
784
|
+
* on the way out. A seam is the only thing that makes it catchable.
|
|
785
|
+
*
|
|
786
|
+
* @param {string} href
|
|
787
|
+
*/
|
|
788
|
+
let hardNavigate = (href) => { location.href = href; };
|
|
789
|
+
|
|
790
|
+
|
|
474
791
|
/** @param {MouseEvent} e */
|
|
475
792
|
function onClick(e) {
|
|
476
793
|
if (!enabled) return;
|
|
@@ -550,6 +867,9 @@ function onSubmit(e) {
|
|
|
550
867
|
const method = getSubmitMethod(form, submitter);
|
|
551
868
|
if (method === 'dialog') return;
|
|
552
869
|
|
|
870
|
+
const enctype = getSubmitEnctype(form, submitter);
|
|
871
|
+
const isSafeMethod = method === 'get' || method === 'head';
|
|
872
|
+
|
|
553
873
|
const action = getSubmitAction(form, submitter);
|
|
554
874
|
/** @type {URL} */ let url;
|
|
555
875
|
try { url = new URL(action, location.href); }
|
|
@@ -557,7 +877,27 @@ function onSubmit(e) {
|
|
|
557
877
|
if (url.origin !== location.origin) return;
|
|
558
878
|
if (NON_HTML_EXTENSIONS.test(url.pathname)) return;
|
|
559
879
|
|
|
560
|
-
|
|
880
|
+
// Built once, after the cheap bails (a submission the router ignores should
|
|
881
|
+
// not pay for a FormData) and BEFORE the text/plain bail below. That order
|
|
882
|
+
// matters for the dev report: `text/plain` is precisely the case where the
|
|
883
|
+
// router declines the submission, so reporting after the bail would be dead
|
|
884
|
+
// code for the one shape that most needs it, since both paths are then
|
|
885
|
+
// answered with a 405 and the author gets no other signal.
|
|
886
|
+
const rawBody = buildSubmitFormData(form, submitter);
|
|
887
|
+
// Observational, and silent in production. Runs before `preventDefault`.
|
|
888
|
+
warnIfActionSubmissionCannotDeliver(form, submitter, method, rawBody);
|
|
889
|
+
|
|
890
|
+
// #1307: `text/plain` is a legal native encoding the server cannot parse
|
|
891
|
+
// (`looksLikeFormSubmission` accepts multipart and urlencoded only), and
|
|
892
|
+
// there is no honest way to send it over `fetch` and have the response mean
|
|
893
|
+
// anything. Bail to the browser so BOTH paths do the same thing, rather than
|
|
894
|
+
// silently sending multipart, which is what made the same form behave one
|
|
895
|
+
// way with JS and another way without it. Turbo enumerates this encoding and
|
|
896
|
+
// then sends FormData anyway, which is the divergence being avoided here. A
|
|
897
|
+
// safe method ignores the enctype entirely, per the submission algorithm.
|
|
898
|
+
if (!isSafeMethod && enctype === 'text/plain') return;
|
|
899
|
+
|
|
900
|
+
const body = encodeSubmitBody(rawBody, enctype);
|
|
561
901
|
|
|
562
902
|
e.preventDefault();
|
|
563
903
|
// Resolve the target frame for the submit, same precedence as a link:
|
|
@@ -595,6 +935,76 @@ function getSubmitAction(form, submitter) {
|
|
|
595
935
|
return form.getAttribute('action') || form.action || location.href;
|
|
596
936
|
}
|
|
597
937
|
|
|
938
|
+
/**
|
|
939
|
+
* The three `enctype` keywords, plus the normalization a browser applies.
|
|
940
|
+
*
|
|
941
|
+
* Both the missing-value AND the invalid-value default of the `enctype`
|
|
942
|
+
* enumerated attribute are `application/x-www-form-urlencoded`, so
|
|
943
|
+
* `enctype="nonsense"` really does mean urlencoded and has to be sent as such.
|
|
944
|
+
* Only an exact, ASCII-case-insensitive match on one of the other two keywords
|
|
945
|
+
* means anything else.
|
|
946
|
+
*
|
|
947
|
+
* @param {string | null | undefined} raw
|
|
948
|
+
* @returns {'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'}
|
|
949
|
+
*/
|
|
950
|
+
function normalizeEnctype(raw) {
|
|
951
|
+
// Compared UNTRIMMED, the same rule `assertSubmittableForm` applies in
|
|
952
|
+
// `form-action.js`. An enumerated attribute is matched against exact
|
|
953
|
+
// keywords with no whitespace stripping, so `enctype=" multipart/form-data "`
|
|
954
|
+
// falls to the invalid-value default and a BROWSER sends urlencoded for it.
|
|
955
|
+
// Trimming here would send multipart, so the router would disagree with the
|
|
956
|
+
// no-JS path on exactly the shape this function exists to keep in step.
|
|
957
|
+
const v = String(raw || '').toLowerCase();
|
|
958
|
+
if (v === 'multipart/form-data') return 'multipart/form-data';
|
|
959
|
+
if (v === 'text/plain') return 'text/plain';
|
|
960
|
+
return 'application/x-www-form-urlencoded';
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Enctype resolution: the submitter's `formenctype` wins over the form's
|
|
965
|
+
* `enctype`, exactly as `getSubmitMethod` resolves the method (#1307). Turbo
|
|
966
|
+
* resolves it the same way, in `core/drive/form_submission.js`.
|
|
967
|
+
*
|
|
968
|
+
* @param {HTMLFormElement} form
|
|
969
|
+
* @param {HTMLElement | null} submitter
|
|
970
|
+
*/
|
|
971
|
+
function getSubmitEnctype(form, submitter) {
|
|
972
|
+
return normalizeEnctype(
|
|
973
|
+
(submitter && submitter.getAttribute('formenctype')) || form.getAttribute('enctype'),
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Encode a submission body the way the DECLARED enctype says to (#1307).
|
|
979
|
+
*
|
|
980
|
+
* The router used to build a `FormData` and send it with no explicit content
|
|
981
|
+
* type, so `fetch` always derived `multipart/form-data` and the authored
|
|
982
|
+
* `enctype` was never read at all. An author writing
|
|
983
|
+
* `enctype="application/x-www-form-urlencoded"`, which is also the HTML
|
|
984
|
+
* DEFAULT and therefore what a plain `<form method="post">` means, got
|
|
985
|
+
* urlencoded with JS off and multipart with JS on. Same form, two different
|
|
986
|
+
* request bodies, which is exactly what progressive enhancement rules out.
|
|
987
|
+
*
|
|
988
|
+
* A `File` entry serializes as its NAME under urlencoded, which is what the
|
|
989
|
+
* platform's own urlencoded serializer does. (Turbo drops file entries here
|
|
990
|
+
* entirely, in `http/fetch_request.js`, which loses a field the no-JS path
|
|
991
|
+
* sends.)
|
|
992
|
+
*
|
|
993
|
+
* A bound form is unaffected: it carries an explicit
|
|
994
|
+
* `enctype="multipart/form-data"`, and since #1307 a bound submitter carries
|
|
995
|
+
* `formenctype="multipart/form-data"`, so both resolve to multipart as before.
|
|
996
|
+
*
|
|
997
|
+
* @param {FormData} formData
|
|
998
|
+
* @param {'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'} enctype
|
|
999
|
+
* @returns {FormData | URLSearchParams}
|
|
1000
|
+
*/
|
|
1001
|
+
function encodeSubmitBody(formData, enctype) {
|
|
1002
|
+
if (enctype === 'multipart/form-data') return formData;
|
|
1003
|
+
const params = new URLSearchParams();
|
|
1004
|
+
for (const [k, v] of formData) params.append(k, typeof v === 'string' ? v : v.name);
|
|
1005
|
+
return params;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
598
1008
|
/**
|
|
599
1009
|
* Build FormData honoring the submitter's name=value (per HTML5 form
|
|
600
1010
|
* submission algorithm). Modern browsers + the `FormData(form, submitter)`
|
|
@@ -736,10 +1146,89 @@ function resolveTargetFrameId(trigger) {
|
|
|
736
1146
|
*/
|
|
737
1147
|
const warnedKeys = new Set();
|
|
738
1148
|
/** @param {string} key @param {string} message */
|
|
739
|
-
function warnOnce(key, message) {
|
|
1149
|
+
function warnOnce(key, message, level = 'warn') {
|
|
740
1150
|
if (warnedKeys.has(key)) return;
|
|
741
1151
|
warnedKeys.add(key);
|
|
742
|
-
if (typeof console
|
|
1152
|
+
if (typeof console === 'undefined') return;
|
|
1153
|
+
const fn = level === 'error' ? console.error : console.warn;
|
|
1154
|
+
if (fn) fn.call(console, message);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* DEV-ONLY: report at submit time when a submission is carrying a bound
|
|
1159
|
+
* action's identity it cannot actually deliver (#1307).
|
|
1160
|
+
*
|
|
1161
|
+
* This is the backstop for the shapes the renderer deliberately stopped
|
|
1162
|
+
* refusing. A PLAIN `<button formmethod="get">` inside a bound form is a legal
|
|
1163
|
+
* native override, so it renders, and the form's action then simply does not
|
|
1164
|
+
* run. That is what the author asked for, but it is also what a mistake looks
|
|
1165
|
+
* like, and submit time is the only moment the whole picture (the resolved
|
|
1166
|
+
* method, the resolved enctype, and whether a bound identity is actually in
|
|
1167
|
+
* the body) exists in one place.
|
|
1168
|
+
*
|
|
1169
|
+
* Observational: it runs BEFORE `preventDefault` and changes nothing about the
|
|
1170
|
+
* submission. Silent in production, where a console error would be noise the
|
|
1171
|
+
* visitor cannot act on; the server-side `onError` telemetry covers that side.
|
|
1172
|
+
*
|
|
1173
|
+
* @param {HTMLFormElement} form
|
|
1174
|
+
* @param {HTMLElement | null} submitter
|
|
1175
|
+
* @param {string} method lowercased, already resolved with native precedence
|
|
1176
|
+
* @param {FormData} body
|
|
1177
|
+
*/
|
|
1178
|
+
function warnIfActionSubmissionCannotDeliver(form, submitter, method, body) {
|
|
1179
|
+
if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return;
|
|
1180
|
+
// No identity in the body: an ordinary form with nothing to deliver.
|
|
1181
|
+
if (!body.has(FORM_ACTION_FIELD)) return;
|
|
1182
|
+
let path = '';
|
|
1183
|
+
try { path = new URL(form.getAttribute('action') || location.href, location.href).pathname; }
|
|
1184
|
+
catch { path = location.pathname; }
|
|
1185
|
+
if (method !== 'post') {
|
|
1186
|
+
warnOnce(
|
|
1187
|
+
`submit-nowhere:${path}:${method}`,
|
|
1188
|
+
`[webjs] this submission carries a bound server action's identity but submits as ${method.toUpperCase()}, which sends no body, so the identity rides the query string and the action never runs. A submitter's own formmethod overrides the form's, and WebJs honours it rather than refusing it, so check for a formmethod on the button that was pressed.`,
|
|
1189
|
+
'error',
|
|
1190
|
+
);
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
const enctype = (submitter && submitter.getAttribute('formenctype'))
|
|
1194
|
+
|| form.getAttribute('enctype')
|
|
1195
|
+
|| 'application/x-www-form-urlencoded';
|
|
1196
|
+
// `text/plain` ONLY, not the renderer's parseable-enctype allowlist.
|
|
1197
|
+
// `enctype` is an enumerated attribute whose missing AND invalid value
|
|
1198
|
+
// defaults are both `application/x-www-form-urlencoded`, so
|
|
1199
|
+
// `enctype="nonsense"` submits a perfectly parseable body and the action
|
|
1200
|
+
// runs. Testing against the allowlist would report that working form as
|
|
1201
|
+
// broken.
|
|
1202
|
+
if (enctype.toLowerCase() === 'text/plain') {
|
|
1203
|
+
warnOnce(
|
|
1204
|
+
`submit-nowhere:${path}:${enctype}`,
|
|
1205
|
+
`[webjs] this submission carries a bound server action's identity but declares enctype="${enctype}", which the server cannot parse. The router declines to send it so both paths behave the same way, and both are answered with a 405. Drop the enctype and let the binding supply it.`,
|
|
1206
|
+
'error',
|
|
1207
|
+
);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
// The identity is going somewhere OTHER than this page (#1307). A bound
|
|
1211
|
+
// submitter emits no `formaction` url, so the submission targets whatever the
|
|
1212
|
+
// FORM targets, and a form declaring its own `action="/x"` sends its buttons
|
|
1213
|
+
// to `/x` by ordinary native precedence.
|
|
1214
|
+
//
|
|
1215
|
+
// This is the one shape the redesign left both unrefused and, until here,
|
|
1216
|
+
// unreported. The renderer used to throw for it, but only where it could SEE
|
|
1217
|
+
// the form, which is exactly the cross-element judgement that could not be
|
|
1218
|
+
// made from inside a component. So it is reported at submit time instead,
|
|
1219
|
+
// where the resolved target is a fact rather than an inference.
|
|
1220
|
+
//
|
|
1221
|
+
// A warning rather than an error, because it is not necessarily wrong: if
|
|
1222
|
+
// `/x` is a PAGE route the action really does run there, and the 422
|
|
1223
|
+
// re-render simply lands on that page. It is only dead if `/x` is a
|
|
1224
|
+
// `route.ts`, another origin, or nothing at all, and the client cannot tell
|
|
1225
|
+
// which from here.
|
|
1226
|
+
if (path && path !== location.pathname) {
|
|
1227
|
+
warnOnce(
|
|
1228
|
+
`submit-elsewhere:${path}`,
|
|
1229
|
+
`[webjs] this submission carries a bound server action's identity but posts to "${path}" rather than this page, because the enclosing <form> declares its own action. A bound submitter emits no formaction, so the form's target wins, which is what native HTML does. The action runs only if "${path}" is a page route; against a route.ts or another origin the identity is ignored and nothing runs. Drop the form's action attribute to keep the submission on this page.`,
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
743
1232
|
}
|
|
744
1233
|
|
|
745
1234
|
/**
|
|
@@ -763,6 +1252,105 @@ function shouldFullLoadDuringParse(isPopState, frameId) {
|
|
|
763
1252
|
);
|
|
764
1253
|
}
|
|
765
1254
|
|
|
1255
|
+
/**
|
|
1256
|
+
* `sessionStorage` key holding the destination of a full load the ROUTER
|
|
1257
|
+
* itself chose (#1118). Written by `reportFallback` when `willReload` is true,
|
|
1258
|
+
* consumed once by the next document's boot. Per-tab and cleared with the tab,
|
|
1259
|
+
* which is the right lifetime for a marker about one navigation.
|
|
1260
|
+
*/
|
|
1261
|
+
const FALLBACK_MARKER_KEY = 'webjs:nav-fallback';
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Has the pre-boot check already run for THIS document (#1118)? Module scope,
|
|
1265
|
+
* so it resets with the document, which is the lifetime the report is about.
|
|
1266
|
+
*/
|
|
1267
|
+
let reportedPreBoot = false;
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Was THIS document load a same-origin navigation the client router never saw?
|
|
1271
|
+
*
|
|
1272
|
+
* Pure so the branch logic is testable without driving a real navigation
|
|
1273
|
+
* (#1118). Every argument is read from the environment by the one caller.
|
|
1274
|
+
*
|
|
1275
|
+
* @param {string} navType `performance.getEntriesByType('navigation')[0].type`.
|
|
1276
|
+
* Only `'navigate'` qualifies: a `'reload'` and a `'back_forward'` restore are
|
|
1277
|
+
* things the browser does, not clicks the router could have intercepted.
|
|
1278
|
+
* @param {string} referrer `document.referrer`. Must parse to the same origin as
|
|
1279
|
+
* `href`: an empty referrer means a typed URL or an external entry (no router
|
|
1280
|
+
* was running to miss the click), and a cross-origin one means the previous
|
|
1281
|
+
* page was not ours.
|
|
1282
|
+
* @param {string} href `location.href` of the document that just loaded.
|
|
1283
|
+
* @param {string | null} marker the consumed `FALLBACK_MARKER_KEY` value. When
|
|
1284
|
+
* it equals `href` the router already reported this load under its own cause,
|
|
1285
|
+
* so counting it again would double-count a known degradation as an unknown.
|
|
1286
|
+
* @returns {boolean}
|
|
1287
|
+
*/
|
|
1288
|
+
function isPreBootNavigation(navType, referrer, href, marker) {
|
|
1289
|
+
if (navType !== 'navigate') return false;
|
|
1290
|
+
if (!referrer) return false;
|
|
1291
|
+
if (marker && marker === href) return false;
|
|
1292
|
+
try {
|
|
1293
|
+
return new URL(referrer).origin === new URL(href).origin;
|
|
1294
|
+
} catch {
|
|
1295
|
+
return false;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Report a document load that reached us by a same-origin navigation the router
|
|
1301
|
+
* did not soft-navigate (#1118).
|
|
1302
|
+
*
|
|
1303
|
+
* A module script is deferred by spec, so it runs only after HTML parsing
|
|
1304
|
+
* completes, while the links it will intercept are clickable from first paint.
|
|
1305
|
+
* That window cannot be closed from inside the router (see #1118 for why an
|
|
1306
|
+
* inline capture shim was rejected), so it is MEASURED instead: this turns the
|
|
1307
|
+
* frequency into a production number a deployed app can read off the existing
|
|
1308
|
+
* `webjs:navigation-fallback` channel, rather than folklore.
|
|
1309
|
+
*
|
|
1310
|
+
* Deliberately imprecise, and the docs say so: a `data-no-router` link, a
|
|
1311
|
+
* cross-document form post, and an app that opted out of the client router all
|
|
1312
|
+
* land here too. The signal is the RATE, not any single event.
|
|
1313
|
+
*
|
|
1314
|
+
* `willReload` is false because the document load has already happened. That is
|
|
1315
|
+
* exactly the distinction the flag was added for.
|
|
1316
|
+
*/
|
|
1317
|
+
function reportPreBootNavigation() {
|
|
1318
|
+
// Same guard the scroll/current-page seeding above uses: a DOM shim without a
|
|
1319
|
+
// `location` (linkedom under the unit runner) is not a document load to
|
|
1320
|
+
// report on, and reading through would throw inside the boot.
|
|
1321
|
+
if (typeof location === 'undefined') return;
|
|
1322
|
+
// Once per DOCUMENT, not once per enable. `enableClientRouter` is re-callable
|
|
1323
|
+
// after `disableClientRouter()` (the documented per-moment opt-out), and this
|
|
1324
|
+
// reports on the load that produced the document, which does not happen again
|
|
1325
|
+
// when the router is toggled back on. Without this, an app that toggles would
|
|
1326
|
+
// emit a duplicate for a single load and inflate the very rate the report
|
|
1327
|
+
// exists to measure. The marker is already consumed by then, so it cannot
|
|
1328
|
+
// suppress the duplicate on its own.
|
|
1329
|
+
if (reportedPreBoot) return;
|
|
1330
|
+
reportedPreBoot = true;
|
|
1331
|
+
/** @type {string | null} */
|
|
1332
|
+
let marker = null;
|
|
1333
|
+
try {
|
|
1334
|
+
marker = sessionStorage.getItem(FALLBACK_MARKER_KEY);
|
|
1335
|
+
// Consume unconditionally, even when it does not match: a stale marker left
|
|
1336
|
+
// by an earlier navigation must never suppress a later real one.
|
|
1337
|
+
sessionStorage.removeItem(FALLBACK_MARKER_KEY);
|
|
1338
|
+
} catch {
|
|
1339
|
+
// No marker available. Treated as absent, which can only over-report.
|
|
1340
|
+
}
|
|
1341
|
+
let navType = '';
|
|
1342
|
+
try {
|
|
1343
|
+
const nav = performance.getEntriesByType('navigation')[0];
|
|
1344
|
+
navType = nav ? /** @type {PerformanceNavigationTiming} */ (nav).type : '';
|
|
1345
|
+
} catch {
|
|
1346
|
+
// No Navigation Timing Level 2 entry. Without a nav type the check cannot
|
|
1347
|
+
// exclude a reload, so it reports nothing rather than guessing.
|
|
1348
|
+
}
|
|
1349
|
+
if (isPreBootNavigation(navType, document.referrer, location.href, marker)) {
|
|
1350
|
+
reportFallback('pre-boot-navigation', location.href, false);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
766
1354
|
/**
|
|
767
1355
|
* The client router degraded a soft navigation. Records WHY (the `cause`), so
|
|
768
1356
|
* "why did my SPA nav do a full reload?" is answerable instead of guessed at.
|
|
@@ -796,6 +1384,21 @@ function shouldFullLoadDuringParse(isPopState, frameId) {
|
|
|
796
1384
|
* dropped", which are very different user-visible events.
|
|
797
1385
|
*/
|
|
798
1386
|
function reportFallback(cause, href, willReload = true) {
|
|
1387
|
+
if (willReload) {
|
|
1388
|
+
// Leave a marker naming the destination this full load is going to
|
|
1389
|
+
// (#1118). The next document's boot reads it to tell "the router itself
|
|
1390
|
+
// chose this full load, and already reported it under its own cause" from
|
|
1391
|
+
// "a same-origin navigation the router never saw", which is the pre-boot
|
|
1392
|
+
// click window. Best-effort: `sessionStorage` throws in some privacy modes
|
|
1393
|
+
// and partitioned contexts, and a diagnostic must never break a navigation.
|
|
1394
|
+
try {
|
|
1395
|
+
sessionStorage.setItem(FALLBACK_MARKER_KEY, href);
|
|
1396
|
+
} catch {
|
|
1397
|
+
// Without the marker the next boot may attribute this load to the
|
|
1398
|
+
// pre-boot window. That is a false positive in a diagnostic, which is
|
|
1399
|
+
// strictly better than a thrown navigation.
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
799
1402
|
if (typeof document !== 'undefined' && typeof CustomEvent !== 'undefined') {
|
|
800
1403
|
try {
|
|
801
1404
|
document.dispatchEvent(new CustomEvent('webjs:navigation-fallback', {
|
|
@@ -1177,7 +1780,7 @@ async function performNavigation(href, isPopState, frameId) {
|
|
|
1177
1780
|
// nav carries its own boundary element.
|
|
1178
1781
|
if (shouldFullLoadDuringParse(isPopState, frameId) && typeof location !== 'undefined') {
|
|
1179
1782
|
reportFallback('readyState-loading', href);
|
|
1180
|
-
|
|
1783
|
+
hardNavigate(href);
|
|
1181
1784
|
return;
|
|
1182
1785
|
}
|
|
1183
1786
|
|
|
@@ -1188,6 +1791,33 @@ async function performNavigation(href, isPopState, frameId) {
|
|
|
1188
1791
|
// Bump nav generation. Captured below + by anything we await into.
|
|
1189
1792
|
const myToken = ++currentNavigationToken;
|
|
1190
1793
|
|
|
1794
|
+
// A new navigation ends any restore window still open from an earlier one
|
|
1795
|
+
// (#1310). The window outlives its own restore by design (a floor, then a
|
|
1796
|
+
// ceiling), so without this a second navigation inside that span inherits
|
|
1797
|
+
// suppressed anchoring: a Back that CLAMPS opens no window of its own, so it
|
|
1798
|
+
// would run the whole growth under the previous restore's suppression and
|
|
1799
|
+
// freeze its clamp, and a forward nav would carry it onto a different page
|
|
1800
|
+
// entirely. Reopening for this navigation, if it earns one, happens below.
|
|
1801
|
+
// The clamped path's catch-up is cancelled for the same reason: it chases an
|
|
1802
|
+
// offset recorded for the page being navigated away from.
|
|
1803
|
+
//
|
|
1804
|
+
// A FRAME-targeted nav is excluded, for the same reason `loadFrame` is: it
|
|
1805
|
+
// swaps one region and leaves the page, and so the restored scroll offset,
|
|
1806
|
+
// intact. The codebase already treats a click-driven frame nav and a `src`
|
|
1807
|
+
// self-load as the same thing, so exempting one and not the other would be
|
|
1808
|
+
// the split this rule exists to avoid.
|
|
1809
|
+
//
|
|
1810
|
+
// All THREE move together. Exempting only the counter while still closing
|
|
1811
|
+
// the window and aborting the catch-up would leave the split exactly where
|
|
1812
|
+
// it was, one line further down: a form inside a frame, submitted by a
|
|
1813
|
+
// component upgrading in the just-restored page, would hand anchoring back
|
|
1814
|
+
// mid-restore and bring the whole double-count back.
|
|
1815
|
+
if (!frameId) {
|
|
1816
|
+
restoreGeneration += 1;
|
|
1817
|
+
if (releaseScrollAnchor) releaseScrollAnchor();
|
|
1818
|
+
if (cancelScrollCatchUp) cancelScrollCatchUp();
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1191
1821
|
// Snapshot the page the user is LEAVING (with its scroll position)
|
|
1192
1822
|
// so back/forward navigation can restore it. We key under
|
|
1193
1823
|
// `currentPageUrl` rather than `location.href` because on popstate
|
|
@@ -1222,14 +1852,98 @@ async function performNavigation(href, isPopState, frameId) {
|
|
|
1222
1852
|
// Restore window scroll to where the user left it. Use
|
|
1223
1853
|
// behavior:'instant' so an app-level `scroll-behavior: smooth`
|
|
1224
1854
|
// stylesheet does not animate the restore (native nav jumps).
|
|
1855
|
+
//
|
|
1856
|
+
// `cached.scrollY` was recorded at the page's SETTLED height, and the
|
|
1857
|
+
// DOM just swapped in is still shorter until its components upgrade
|
|
1858
|
+
// and re-render. Suppress scroll anchoring across the restore, or the
|
|
1859
|
+
// browser adds that late growth to the restored offset and the reader
|
|
1860
|
+
// lands below where they left (#1310).
|
|
1861
|
+
let releaseAnchor = () => {};
|
|
1225
1862
|
if (typeof window !== 'undefined') {
|
|
1226
|
-
|
|
1863
|
+
// Restore the scroll, then decide whether to suppress anchoring.
|
|
1864
|
+
//
|
|
1865
|
+
// Suppress ONLY when the recorded offset was actually reached. A
|
|
1866
|
+
// document that has not grown yet can be too SHORT to scroll that
|
|
1867
|
+
// far, and the browser clamps to its current maximum. A reader at
|
|
1868
|
+
// the bottom of the settled page is the clear case: the shortfall is
|
|
1869
|
+
// then exactly the growth still to come, and anchoring ADDING that
|
|
1870
|
+
// growth is what carries them back to the bottom. Suppressing there
|
|
1871
|
+
// freezes the clamp instead and strands them a full page-growth
|
|
1872
|
+
// ABOVE where they left, which is this bug's own mirror image. The
|
|
1873
|
+
// two situations want opposite things and are told apart by the one
|
|
1874
|
+
// question that separates them: did the scroll land.
|
|
1875
|
+
//
|
|
1876
|
+
// Both halves must read the SAME layout, and the scroll must be
|
|
1877
|
+
// written against the page being restored. That is why this is
|
|
1878
|
+
// ordered rather than simply inlined, and why the ordering differs
|
|
1879
|
+
// by path.
|
|
1880
|
+
const restoreScroll = () => {
|
|
1881
|
+
window.scrollTo({ left: cached.scrollX, top: cached.scrollY, behavior: 'instant' });
|
|
1882
|
+
if (window.scrollY >= cached.scrollY - 1) {
|
|
1883
|
+
releaseAnchor = suppressScrollAnchoring();
|
|
1884
|
+
} else {
|
|
1885
|
+
// Clamped. Anchoring is left on, since it is what carries the
|
|
1886
|
+
// reader back down, but it adds the FULL growth regardless of
|
|
1887
|
+
// how far short the clamp fell, so on its own it only lands a
|
|
1888
|
+
// reader who left at the very bottom. Chase the recorded offset
|
|
1889
|
+
// instead, once the page is tall enough to hold it.
|
|
1890
|
+
catchUpToRestoredScroll(cached.scrollY, cached.scrollX);
|
|
1891
|
+
}
|
|
1892
|
+
};
|
|
1893
|
+
if (viewTransitionsEnabled() && typeof (/** @type any */ (document)).startViewTransition === 'function') {
|
|
1894
|
+
// Under a view transition `applySwap` defers its DOM mutation a
|
|
1895
|
+
// frame, so running now would write and measure against the
|
|
1896
|
+
// OUTGOING page. Measured with a 60000px outgoing page and a
|
|
1897
|
+
// 3000px restored one: the scroll "landed" at 20000, suppression
|
|
1898
|
+
// opened, and the restored page then clamped to 2416 with
|
|
1899
|
+
// anchoring held off, which is precisely the stranding the
|
|
1900
|
+
// conditional exists to prevent. Wait for the swap to commit.
|
|
1901
|
+
//
|
|
1902
|
+
// Guarded, because this is the one path where the restore
|
|
1903
|
+
// outlives the call that scheduled it. Every cancel site in this
|
|
1904
|
+
// feature (performNavigation, performSubmission,
|
|
1905
|
+
// disableClientRouter) runs at the START of the next thing, so a
|
|
1906
|
+
// navigation, submission, or disable arriving inside the deferred
|
|
1907
|
+
// frame would close the window and then have this reopen it,
|
|
1908
|
+
// scrolling a page it was never meant for to an offset recorded
|
|
1909
|
+
// for the previous history entry. The synchronous branch below
|
|
1910
|
+
// cannot outlive anything and so needs no guard.
|
|
1911
|
+
const myRestore = restoreGeneration;
|
|
1912
|
+
_swapCommit.then(() => {
|
|
1913
|
+
if (myRestore !== restoreGeneration || !enabled) return;
|
|
1914
|
+
restoreScroll();
|
|
1915
|
+
}).catch(() => {});
|
|
1916
|
+
} else {
|
|
1917
|
+
// The synchronous path, and the read must STAY synchronous here.
|
|
1918
|
+
// Deferring it even by a microtask breaks the fix outright: by
|
|
1919
|
+
// then the restored components' renders have been applied, and
|
|
1920
|
+
// reading `scrollY` forces the layout that flushes them, so
|
|
1921
|
+
// anchoring runs DURING the read and hands back the
|
|
1922
|
+
// already-shifted offset. Measured on /ui/button, the suppression
|
|
1923
|
+
// landed 19ms late with `scrollY` already 800 -> 1563. What makes
|
|
1924
|
+
// it correct is not which document it sees but that it sees the
|
|
1925
|
+
// same layout the scroll just landed in.
|
|
1926
|
+
restoreScroll();
|
|
1927
|
+
}
|
|
1227
1928
|
}
|
|
1228
1929
|
// Fire-and-forget revalidation. Uses a fresh AbortController
|
|
1229
1930
|
// since this background fetch is allowed to overlap with the
|
|
1230
1931
|
// next foreground nav (it'll get aborted if a new nav lands).
|
|
1231
|
-
|
|
1932
|
+
//
|
|
1933
|
+
// Closing the anchoring window on THIS revalidation's settle (plus two
|
|
1934
|
+
// frames for the re-applied DOM to lay out) is what keeps the window
|
|
1935
|
+
// tied to one restore. A height observer could not tell a settling
|
|
1936
|
+
// restore from a streaming <webjs-suspense> boundary (#471 / #473).
|
|
1937
|
+
//
|
|
1938
|
+
// The floor is what makes that safe. Waiting on the revalidation ALONE
|
|
1939
|
+
// ties the window's length to network latency rather than to the
|
|
1940
|
+
// growth it guards, so a server that answers faster than the restored
|
|
1941
|
+
// page renders closes it early and the reader lands low again, which
|
|
1942
|
+
// is the whole defect.
|
|
1943
|
+
const revalidated = fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken, /* revalidating */ true)
|
|
1232
1944
|
.catch(() => {});
|
|
1945
|
+
const floor = new Promise((r) => setTimeout(r, ANCHOR_SUPPRESS_FLOOR_MS));
|
|
1946
|
+
Promise.all([revalidated, floor]).then(() => afterTwoFrames(releaseAnchor));
|
|
1233
1947
|
return;
|
|
1234
1948
|
}
|
|
1235
1949
|
}
|
|
@@ -1286,7 +2000,11 @@ async function performNavigation(href, isPopState, frameId) {
|
|
|
1286
2000
|
*
|
|
1287
2001
|
* @param {string} href Absolute target URL.
|
|
1288
2002
|
* @param {string} method Lowercased HTTP verb.
|
|
1289
|
-
* @param {FormData} body
|
|
2003
|
+
* @param {FormData | URLSearchParams} body Encoded per the declared enctype
|
|
2004
|
+
* (#1307): `FormData` for multipart, `URLSearchParams` for urlencoded. Both
|
|
2005
|
+
* iterate as `[name, value]` pairs, which is all the safe-method query-string
|
|
2006
|
+
* promotion below needs, and `fetch` derives the right content type from
|
|
2007
|
+
* either without an explicit header.
|
|
1290
2008
|
* @param {string | null} frameId
|
|
1291
2009
|
* @param {HTMLFormElement | null} [form] The submitted form, for busy + events.
|
|
1292
2010
|
*/
|
|
@@ -1295,6 +2013,15 @@ async function performSubmission(href, method, body, frameId, form) {
|
|
|
1295
2013
|
activeAbortController = new AbortController();
|
|
1296
2014
|
const signal = activeAbortController.signal;
|
|
1297
2015
|
const myToken = ++currentNavigationToken;
|
|
2016
|
+
// Same reasoning as performNavigation: a submission is a navigation, so it
|
|
2017
|
+
// ends any restore window a recent Back left open (#1310), and cancels a
|
|
2018
|
+
// clamped restore's catch-up. Frame-targeted submissions are excluded on the
|
|
2019
|
+
// same reasoning as the frame navs above.
|
|
2020
|
+
if (!frameId) {
|
|
2021
|
+
restoreGeneration += 1;
|
|
2022
|
+
if (releaseScrollAnchor) releaseScrollAnchor();
|
|
2023
|
+
if (cancelScrollCatchUp) cancelScrollCatchUp();
|
|
2024
|
+
}
|
|
1298
2025
|
|
|
1299
2026
|
const isSafe = method === 'get' || method === 'head';
|
|
1300
2027
|
let url = new URL(href, location.href);
|
|
@@ -2078,7 +2805,7 @@ function handleNavigationError(href, status, error) {
|
|
|
2078
2805
|
// carries no `cause` / `willReload`, so it is not a substitute.
|
|
2079
2806
|
if (typeof location !== 'undefined') {
|
|
2080
2807
|
reportFallback('navigation-error-unrecoverable', href);
|
|
2081
|
-
|
|
2808
|
+
hardNavigate(href);
|
|
2082
2809
|
}
|
|
2083
2810
|
}
|
|
2084
2811
|
|
|
@@ -2174,6 +2901,11 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
|
|
|
2174
2901
|
const resp = await fetch(href, init);
|
|
2175
2902
|
respStatus = resp.status;
|
|
2176
2903
|
respOk = resp.ok;
|
|
2904
|
+
// `fetch` follows a 303 transparently and its headers are then unreadable,
|
|
2905
|
+
// so this lands on a non-redirect response: the 422 failure re-render, or
|
|
2906
|
+
// any form response that answers in place.
|
|
2907
|
+
const invalidated = parseTagHeader(resp.headers.get('x-webjs-invalidate'));
|
|
2908
|
+
if (invalidated.length) markStale(invalidated);
|
|
2177
2909
|
const ctype = resp.headers.get('content-type') || '';
|
|
2178
2910
|
const isHTML = /^text\/html\b/i.test(ctype);
|
|
2179
2911
|
const isStream = ctype.toLowerCase().indexOf(STREAM_MIME) === 0;
|
|
@@ -2631,6 +3363,24 @@ function runWithTransition(thunk, afterFinished) {
|
|
|
2631
3363
|
return Promise.resolve();
|
|
2632
3364
|
}
|
|
2633
3365
|
|
|
3366
|
+
/**
|
|
3367
|
+
* Live nodes a regraft actually moved into the incoming tree, so they
|
|
3368
|
+
* survived the swap BY IDENTITY. Membership is strictly narrower than
|
|
3369
|
+
* "carries `data-webjs-permanent`": the regrafts have a both-exist guard, so
|
|
3370
|
+
* a permanent element arriving for the first time is a freshly imported node
|
|
3371
|
+
* that was never preserved and is not in here. `reactivateScripts` reads this
|
|
3372
|
+
* to decide whether a script inside a permanent element is a script the
|
|
3373
|
+
* author kept alive (skip it) or one that has never run (run it).
|
|
3374
|
+
*
|
|
3375
|
+
* Weak and keyed by node identity, so a destroyed node drops out on its own
|
|
3376
|
+
* and a later element reusing the same `#id` is a different object that
|
|
3377
|
+
* correctly re-runs. Never cleared per navigation: a node preserved across
|
|
3378
|
+
* several navigations must keep its exemption on every one of them.
|
|
3379
|
+
*
|
|
3380
|
+
* @type {WeakSet<Element>}
|
|
3381
|
+
*/
|
|
3382
|
+
const regraftedPermanents = new WeakSet();
|
|
3383
|
+
|
|
2634
3384
|
/**
|
|
2635
3385
|
* Persist `data-webjs-permanent` elements across a swap by NODE IDENTITY.
|
|
2636
3386
|
*
|
|
@@ -2684,6 +3434,7 @@ function regraftPermanentElements(currentRoot, incomingRoot) {
|
|
|
2684
3434
|
// incoming placeholder. The swap then adopts the live node.
|
|
2685
3435
|
if (placeholder === live) continue;
|
|
2686
3436
|
parent.replaceChild(live, placeholder);
|
|
3437
|
+
regraftedPermanents.add(live);
|
|
2687
3438
|
}
|
|
2688
3439
|
}
|
|
2689
3440
|
|
|
@@ -2726,12 +3477,16 @@ function regraftPermanentInSlice(liveSlice, incomingSlice) {
|
|
|
2726
3477
|
const parent = placeholder.parentNode;
|
|
2727
3478
|
if (parent) {
|
|
2728
3479
|
parent.replaceChild(live, placeholder);
|
|
3480
|
+
regraftedPermanents.add(live);
|
|
2729
3481
|
} else {
|
|
2730
3482
|
// Placeholder is a top-level slice member with no parent (detached):
|
|
2731
3483
|
// replace it in the incomingSlice array so the reconciler inserts the
|
|
2732
3484
|
// live node in that position.
|
|
2733
3485
|
const idx = incomingSlice.indexOf(placeholder);
|
|
2734
|
-
if (idx !== -1)
|
|
3486
|
+
if (idx !== -1) {
|
|
3487
|
+
incomingSlice[idx] = live;
|
|
3488
|
+
regraftedPermanents.add(live);
|
|
3489
|
+
}
|
|
2735
3490
|
}
|
|
2736
3491
|
}
|
|
2737
3492
|
}
|
|
@@ -2783,12 +3538,28 @@ function upgradeCustomElementsInRange(range) {
|
|
|
2783
3538
|
let _swapCommit = Promise.resolve();
|
|
2784
3539
|
|
|
2785
3540
|
function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc) {
|
|
2786
|
-
// SSR action seeding (#472): ingest
|
|
2787
|
-
//
|
|
2788
|
-
//
|
|
2789
|
-
// re-fetching. Scanning `doc` (the detached parse) also strips the
|
|
2790
|
-
//
|
|
2791
|
-
|
|
3541
|
+
// SSR action seeding (#472): ingest the incoming page's seed payload BEFORE
|
|
3542
|
+
// its components are grafted into the live DOM and upgrade, so a
|
|
3543
|
+
// soft-navigated async component resolves from the seed instead of
|
|
3544
|
+
// re-fetching. Scanning `doc` (the detached parse) also strips the carriers,
|
|
3545
|
+
// so the inert payload never lands in the live document.
|
|
3546
|
+
//
|
|
3547
|
+
// Called at each COMMIT point rather than once up front, because this function
|
|
3548
|
+
// can still decide to throw the response away after parsing it (a hard
|
|
3549
|
+
// navigate, or a background revalidation with no trustworthy boundary plan).
|
|
3550
|
+
// Scanning eagerly would clear the visible page's own unconsumed seeds and
|
|
3551
|
+
// ingest a render that is never painted, so the next `async render()` would
|
|
3552
|
+
// hit on data that disagrees with the HTML on screen. That is the same hole
|
|
3553
|
+
// the frame ID-MISSING case closes, reached through a different discard.
|
|
3554
|
+
//
|
|
3555
|
+
// A frame swap is NOT a page navigation, so say so: the consumer must leave
|
|
3556
|
+
// the surrounding page's state alone (see `scanSeeds`).
|
|
3557
|
+
let seedsScanned = false;
|
|
3558
|
+
const ingestSeeds = () => {
|
|
3559
|
+
if (seedsScanned) return;
|
|
3560
|
+
seedsScanned = true;
|
|
3561
|
+
try { scanSeeds(doc, { frame: !!frameId }); } catch { /* seeding is best-effort */ }
|
|
3562
|
+
};
|
|
2792
3563
|
|
|
2793
3564
|
// Every host in this parsed doc is FRAMEWORK-SERIALIZED markup (an SSR
|
|
2794
3565
|
// fragment or a back/forward snapshot of post-hydration HTML), never
|
|
@@ -2901,14 +3672,14 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
2901
3672
|
} else {
|
|
2902
3673
|
if (sessionStorage) sessionStorage.setItem(flag, '1');
|
|
2903
3674
|
reportFallback('deploy-mismatch', href);
|
|
2904
|
-
|
|
3675
|
+
hardNavigate(href);
|
|
2905
3676
|
return;
|
|
2906
3677
|
}
|
|
2907
3678
|
} catch {
|
|
2908
3679
|
// sessionStorage unavailable (private mode w/ quota etc.):
|
|
2909
3680
|
// fall through to a single reload like before.
|
|
2910
3681
|
reportFallback('deploy-mismatch', href);
|
|
2911
|
-
|
|
3682
|
+
hardNavigate(href);
|
|
2912
3683
|
return;
|
|
2913
3684
|
}
|
|
2914
3685
|
} else if (!mismatch) {
|
|
@@ -2948,6 +3719,9 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
2948
3719
|
|
|
2949
3720
|
// 1. webjs-frame escape hatch.
|
|
2950
3721
|
if (frameId) {
|
|
3722
|
+
// Both outcomes here (a successful subtree swap, or frame-missing) discard
|
|
3723
|
+
// whatever the parse carried, so this is safe wherever it lands.
|
|
3724
|
+
ingestSeeds();
|
|
2951
3725
|
const target = document.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
|
|
2952
3726
|
const source = doc.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
|
|
2953
3727
|
if (target && source) {
|
|
@@ -3003,6 +3777,9 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
3003
3777
|
const plan = here && there ? planBoundarySwap(here, there) : null;
|
|
3004
3778
|
|
|
3005
3779
|
if (plan) {
|
|
3780
|
+
// Committed: this response is being applied, so its seeds are the ones the
|
|
3781
|
+
// user is about to look at.
|
|
3782
|
+
ingestSeeds();
|
|
3006
3783
|
const { mode, live, incoming } = plan;
|
|
3007
3784
|
// ADD-ONLY head merge: the outer layout stays mounted, so its head-bound
|
|
3008
3785
|
// runtime state (Tailwind injection, etc.) must not be invalidated.
|
|
@@ -3046,7 +3823,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
3046
3823
|
reportFallback(!here ? 'live-boundaries-malformed'
|
|
3047
3824
|
: !there ? 'incoming-boundaries-malformed'
|
|
3048
3825
|
: 'no-shared-boundary', href);
|
|
3049
|
-
|
|
3826
|
+
hardNavigate(href);
|
|
3050
3827
|
return;
|
|
3051
3828
|
}
|
|
3052
3829
|
|
|
@@ -3065,6 +3842,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
3065
3842
|
// because the user is already viewing the page). Full head merge;
|
|
3066
3843
|
// `mergeHead` PRESERVES stylesheets and `<style>` unconditionally
|
|
3067
3844
|
// (#936) so the swap can never leave the page unstyled.
|
|
3845
|
+
ingestSeeds(); // committed: past both discard branches above
|
|
3068
3846
|
mergeHead(doc.head);
|
|
3069
3847
|
// Persist permanent elements by node identity across the full-body
|
|
3070
3848
|
// swap: move each live [data-webjs-permanent][id] node into the matching
|
|
@@ -3142,12 +3920,7 @@ function replaceBoundaryRange(target, source) {
|
|
|
3142
3920
|
for (const n of incomingSlice) {
|
|
3143
3921
|
liveParent.insertBefore(n, target.end);
|
|
3144
3922
|
}
|
|
3145
|
-
|
|
3146
|
-
if (n.nodeType === 1) {
|
|
3147
|
-
reactivateScripts(/** @type {Element} */ (n));
|
|
3148
|
-
upgradeCustomElements(/** @type {Element} */ (n));
|
|
3149
|
-
}
|
|
3150
|
-
}
|
|
3923
|
+
activateSwappedRange(target);
|
|
3151
3924
|
}
|
|
3152
3925
|
|
|
3153
3926
|
/**
|
|
@@ -3200,13 +3973,13 @@ function swapMarkerRange(target, source, _doc) {
|
|
|
3200
3973
|
// Run the keyed diff.
|
|
3201
3974
|
reconcileSiblings(liveParent, target.start, target.end, liveSlice, incomingSlice);
|
|
3202
3975
|
|
|
3203
|
-
// Upgrade + activate scripts in the just-swapped range.
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3976
|
+
// Upgrade + activate scripts in the just-swapped range. A top-level script
|
|
3977
|
+
// here is usually one the keyed reconciler REUSED (`keyOf` reads
|
|
3978
|
+
// `data-key || id`), so it re-executes on every soft nav that morphs this
|
|
3979
|
+
// boundary. That is deliberate: a descendant script inside a reused
|
|
3980
|
+
// container has always re-run through this same pass, and a script's
|
|
3981
|
+
// position in the range is not a reason to treat it differently (#1102).
|
|
3982
|
+
activateSwappedRange(target);
|
|
3210
3983
|
}
|
|
3211
3984
|
|
|
3212
3985
|
/**
|
|
@@ -4360,11 +5133,127 @@ async function streamBoundariesProgressively(reader, dec, initialBuf, isCurrent)
|
|
|
4360
5133
|
}
|
|
4361
5134
|
}
|
|
4362
5135
|
|
|
4363
|
-
/**
|
|
5136
|
+
/**
|
|
5137
|
+
* Re-execute every `<script>` in `container`, INCLUDING `container` itself when
|
|
5138
|
+
* it is one (#1102). A script parsed by `DOMParser` carries the spec's
|
|
5139
|
+
* "already started" flag, so the node grafted into the live document is inert
|
|
5140
|
+
* and only a fresh clone runs. `querySelectorAll` never matches the element it
|
|
5141
|
+
* is called on, so a container-is-a-script was silently skipped: the two swap
|
|
5142
|
+
* tiers hand this function each TOP-LEVEL node of the swapped range in turn, so
|
|
5143
|
+
* a script emitted as a sibling of the content (a layout's progressive-
|
|
5144
|
+
* enhancement script, the shape that surfaced this) never ran after a soft nav.
|
|
5145
|
+
*
|
|
5146
|
+
* Replacing the container DETACHES it, which is why both callers snapshot the
|
|
5147
|
+
* range before iterating rather than walking live `nextSibling` links.
|
|
5148
|
+
*
|
|
5149
|
+
* `data-webjs-permanent` splits into two cases here, and the split is the whole
|
|
5150
|
+
* rule (#1252):
|
|
5151
|
+
*
|
|
5152
|
+
* - The marked element IS a script: NEVER exempt, however the walk reaches
|
|
5153
|
+
* it. This holds whether it arrives as the `container` or as a descendant
|
|
5154
|
+
* of one, because the regraft selector has no tag filter and will happily
|
|
5155
|
+
* preserve a `<script id data-webjs-permanent>` by identity. The exemption
|
|
5156
|
+
* below is therefore STRICT containment, never reflexive. This must not be
|
|
5157
|
+
* changed. The regrafts have a both-exist guard, so on the swap that first
|
|
5158
|
+
* mounts a route there is no live node to preserve, the inert parsed copy
|
|
5159
|
+
* is what lands, and exempting it would leave a script that runs on a cold
|
|
5160
|
+
* load and never on a soft navigation. That is precisely the #1102 failure,
|
|
5161
|
+
* reintroduced under the banner of fixing it. A script's only state is that
|
|
5162
|
+
* it ran, and re-running is the contract for everything in a swapped range.
|
|
5163
|
+
* - A script INSIDE a marked element that was ACTUALLY preserved: exempt.
|
|
5164
|
+
* The attribute means the subtree survives as the same live node, which is
|
|
5165
|
+
* what `diffElementInPlace` already implements by returning early rather
|
|
5166
|
+
* than recursing into it. Re-emitting an init script against a widget
|
|
5167
|
+
* instance the author deliberately kept alive is a double-initialization,
|
|
5168
|
+
* not a refresh.
|
|
5169
|
+
*
|
|
5170
|
+
* The filter keys on `regraftedPermanents` (actual preservation by identity),
|
|
5171
|
+
* never on the attribute alone. A permanent element arriving for the FIRST time
|
|
5172
|
+
* was never preserved, so its scripts have never run and must run now; an
|
|
5173
|
+
* attribute-only filter would leave them never running on any path.
|
|
5174
|
+
*
|
|
5175
|
+
* @param {Element} container
|
|
5176
|
+
* @returns {Element} `container`, or its replacement when it was a script that
|
|
5177
|
+
* was re-emitted. The replacement sits wherever `container` was; when
|
|
5178
|
+
* `container` was already detached, `replaceWith` is a spec no-op and the
|
|
5179
|
+
* returned clone is detached too, so callers must not assume it is connected.
|
|
5180
|
+
*/
|
|
4364
5181
|
function reactivateScripts(container) {
|
|
5182
|
+
if (container.tagName === 'SCRIPT') {
|
|
5183
|
+
const fresh = cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (container));
|
|
5184
|
+
// A no-op when the node has no parent. That happens when an EARLIER
|
|
5185
|
+
// script's reactivation ran code that removed this node from the range
|
|
5186
|
+
// (reactivation executes synchronously), so a stale snapshot entry cannot
|
|
5187
|
+
// resurrect itself into the document.
|
|
5188
|
+
container.replaceWith(fresh);
|
|
5189
|
+
return fresh;
|
|
5190
|
+
}
|
|
5191
|
+
// Roots whose subtrees survived this swap by identity. Collected from the
|
|
5192
|
+
// container DOWNWARD so the exemption is bounded to the swapped range by
|
|
5193
|
+
// construction; `closest()` from a script upward could escape into an outer
|
|
5194
|
+
// ancestor that was never part of this swap.
|
|
5195
|
+
/** @type {Element[]} */
|
|
5196
|
+
const preserved = [];
|
|
5197
|
+
if (regraftedPermanents.has(container)) preserved.push(container);
|
|
5198
|
+
for (const el of container.querySelectorAll('[data-webjs-permanent]')) {
|
|
5199
|
+
if (regraftedPermanents.has(el)) preserved.push(el);
|
|
5200
|
+
}
|
|
5201
|
+
|
|
4365
5202
|
for (const old of container.querySelectorAll('script')) {
|
|
5203
|
+
// STRICT containment: a preserved root exempts its DESCENDANTS, never
|
|
5204
|
+
// itself. The regrafts select `[data-webjs-permanent][id]` with no tag
|
|
5205
|
+
// filter, so a `<script id data-webjs-permanent>` present on both sides is
|
|
5206
|
+
// regrafted like any other element and lands in the WeakSet. Skipping it
|
|
5207
|
+
// here would exempt the marked script itself whenever the walk reaches it
|
|
5208
|
+
// as a descendant (the full-body path), while the container branch above
|
|
5209
|
+
// still re-emits it, so one script would get opposite answers depending on
|
|
5210
|
+
// which entry point reached it. `contains()` is reflexive, hence `p !== old`.
|
|
5211
|
+
if (preserved.length && preserved.some((p) => p !== old && p.contains(old))) continue;
|
|
4366
5212
|
old.replaceWith(cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (old)));
|
|
4367
5213
|
}
|
|
5214
|
+
return container;
|
|
5215
|
+
}
|
|
5216
|
+
|
|
5217
|
+
/**
|
|
5218
|
+
* Reactivate scripts + upgrade custom elements across a just-swapped boundary
|
|
5219
|
+
* range. The range is SNAPSHOT first: `reactivateScripts` replaces a top-level
|
|
5220
|
+
* script node, which detaches it and cuts a live `nextSibling` walk, silently
|
|
5221
|
+
* skipping every node after it (#1102).
|
|
5222
|
+
*
|
|
5223
|
+
* The snapshot is taken AFTER the tier has finished writing the range (the
|
|
5224
|
+
* replace tier's insert loop, the morph tier's `reconcileSiblings`), so every
|
|
5225
|
+
* entry is attached when it is recorded. It can still go stale DURING the walk,
|
|
5226
|
+
* because reactivating a script executes it synchronously and that code may
|
|
5227
|
+
* mutate the range. Two consequences, both deliberate. A node an earlier script
|
|
5228
|
+
* REMOVED is skipped, since `replaceWith` on a parentless node is a spec no-op.
|
|
5229
|
+
* A node an earlier script INSERTED is not visited, unlike the live walk this
|
|
5230
|
+
* replaced. That costs nothing reachable: such a node is connected by
|
|
5231
|
+
* definition, so the browser upgrades it on insertion, and `ensureUpgradeObserver`
|
|
5232
|
+
* catches it on the microtask regardless. Do NOT "restore" the live walk to
|
|
5233
|
+
* recover it. A correct live walk has to advance off the REPLACEMENT (advancing
|
|
5234
|
+
* off the detached original is bug #1102 itself), and in that form a script
|
|
5235
|
+
* appending a sibling script loops forever, each clone appending the next; it
|
|
5236
|
+
* would also re-run a script that already executed when its own creator
|
|
5237
|
+
* inserted it. The snapshot is additionally safer under a MOVE, since a live
|
|
5238
|
+
* walk would follow a node out of the range and start re-executing unrelated
|
|
5239
|
+
* scripts in the rest of the body.
|
|
5240
|
+
*
|
|
5241
|
+
* @param {{ start: Comment, end: Comment }} range
|
|
5242
|
+
*/
|
|
5243
|
+
function activateSwappedRange(range) {
|
|
5244
|
+
/** @type {Element[]} */
|
|
5245
|
+
const swapped = [];
|
|
5246
|
+
for (let n = range.start.nextSibling; n && n !== range.end; n = n.nextSibling) {
|
|
5247
|
+
if (n.nodeType === 1) swapped.push(/** @type {Element} */ (n));
|
|
5248
|
+
}
|
|
5249
|
+
for (const el of swapped) {
|
|
5250
|
+
const live = reactivateScripts(el);
|
|
5251
|
+
// Nothing to upgrade in a detached tree. `customElements.upgrade` off
|
|
5252
|
+
// document runs the CONSTRUCTOR (not `connectedCallback`, which waits for
|
|
5253
|
+
// insertion), so this skips constructing elements for a tree that was just
|
|
5254
|
+
// removed and will never be seen.
|
|
5255
|
+
if (live.isConnected !== false) upgradeCustomElements(live);
|
|
5256
|
+
}
|
|
4368
5257
|
}
|
|
4369
5258
|
|
|
4370
5259
|
/* ====================================================================
|
|
@@ -4375,6 +5264,9 @@ export {
|
|
|
4375
5264
|
addNewHeadElements as _addNewHeadElements,
|
|
4376
5265
|
mergeHead as _mergeHead,
|
|
4377
5266
|
reactivateScripts as _reactivateScripts,
|
|
5267
|
+
isPreBootNavigation as _isPreBootNavigation,
|
|
5268
|
+
FALLBACK_MARKER_KEY as _FALLBACK_MARKER_KEY,
|
|
5269
|
+
activateSwappedRange as _activateSwappedRange,
|
|
4378
5270
|
findAnchorInPath as _findAnchorInPath,
|
|
4379
5271
|
activeFrameId as _activeFrameId,
|
|
4380
5272
|
resolveTargetFrameId as _resolveTargetFrameId,
|
|
@@ -4401,6 +5293,8 @@ export {
|
|
|
4401
5293
|
getSubmitMethod as _getSubmitMethod,
|
|
4402
5294
|
getSubmitAction as _getSubmitAction,
|
|
4403
5295
|
buildSubmitFormData as _buildSubmitFormData,
|
|
5296
|
+
getSubmitEnctype as _getSubmitEnctype,
|
|
5297
|
+
encodeSubmitBody as _encodeSubmitBody,
|
|
4404
5298
|
restoreOptimistic as _restoreOptimistic,
|
|
4405
5299
|
eligibleAnchorHref as _eligibleAnchorHref,
|
|
4406
5300
|
viewTransitionsEnabled as _viewTransitionsEnabled,
|
|
@@ -4435,6 +5329,20 @@ export function _resetPrefetch() {
|
|
|
4435
5329
|
clearPrefetchViewTimers();
|
|
4436
5330
|
}
|
|
4437
5331
|
|
|
5332
|
+
/**
|
|
5333
|
+
* Test-only: replace the hard-navigate action so a browser test can observe a
|
|
5334
|
+
* navigation instead of being destroyed by it. Call with no argument to
|
|
5335
|
+
* restore. Underscore-prefixed and kept in this block like every other
|
|
5336
|
+
* test-only export here, so it stays out of `router-client.d.ts` and out of
|
|
5337
|
+
* the app-facing API (the `./client-router` subpath resolves this file under
|
|
5338
|
+
* the `source` condition, so an unprefixed name here would read as public).
|
|
5339
|
+
*
|
|
5340
|
+
* @param {((href: string) => void) | null} [fn]
|
|
5341
|
+
*/
|
|
5342
|
+
export function _setHardNavigate(fn) {
|
|
5343
|
+
hardNavigate = fn || ((href) => { location.href = href; });
|
|
5344
|
+
}
|
|
5345
|
+
|
|
4438
5346
|
/** Test-only: read the monotonic navigation-token counter. */
|
|
4439
5347
|
export function _navToken() { return currentNavigationToken; }
|
|
4440
5348
|
/** Test-only: bump the navigation-token counter (simulates a fresh nav). */
|