@webjsdev/core 0.7.47 → 0.7.49
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 +3 -2
- 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 +13 -5
- package/index.d.ts +2 -2
- package/index.js +2 -2
- package/package.json +1 -1
- 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 +94 -2
- package/src/form-action.js +204 -203
- package/src/render-client.js +94 -96
- package/src/render-server.js +100 -118
- package/src/router-client.js +644 -21
- package/src/websocket-client.js +5 -2
package/src/router-client.js
CHANGED
|
@@ -8,6 +8,7 @@ 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';
|
|
@@ -301,6 +302,284 @@ let currentPageUrl = null;
|
|
|
301
302
|
*/
|
|
302
303
|
let prevScrollRestoration = null;
|
|
303
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
|
+
|
|
304
583
|
/** Enable the client router. Idempotent. */
|
|
305
584
|
export function enableClientRouter() {
|
|
306
585
|
if (enabled || typeof document === 'undefined') return;
|
|
@@ -371,6 +650,11 @@ export function disableClientRouter() {
|
|
|
371
650
|
history.scrollRestoration = prevScrollRestoration;
|
|
372
651
|
prevScrollRestoration = null;
|
|
373
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();
|
|
374
658
|
currentPageUrl = null;
|
|
375
659
|
}
|
|
376
660
|
|
|
@@ -575,14 +859,21 @@ function onSubmit(e) {
|
|
|
575
859
|
const submitter = /** @type {HTMLElement | null} */ (e.submitter ?? null);
|
|
576
860
|
if (submitter && submitter.hasAttribute('data-no-router')) return;
|
|
577
861
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
862
|
+
// Presence, not truthiness, exactly as `getSubmitAction` resolves the action
|
|
863
|
+
// (#1322). The form-submission algorithm asks whether the submitter HAS a
|
|
864
|
+
// `formtarget`, so a present-but-empty one overrides the form and then means
|
|
865
|
+
// the current browsing context, per the rules for choosing a navigable.
|
|
866
|
+
const target = (submitter && submitter.hasAttribute('formtarget'))
|
|
867
|
+
? (submitter.getAttribute('formtarget') || '')
|
|
868
|
+
: (form.getAttribute('target') || '');
|
|
581
869
|
if (target && target !== '_self') return;
|
|
582
870
|
|
|
583
871
|
const method = getSubmitMethod(form, submitter);
|
|
584
872
|
if (method === 'dialog') return;
|
|
585
873
|
|
|
874
|
+
const enctype = getSubmitEnctype(form, submitter);
|
|
875
|
+
const isSafeMethod = method === 'get' || method === 'head';
|
|
876
|
+
|
|
586
877
|
const action = getSubmitAction(form, submitter);
|
|
587
878
|
/** @type {URL} */ let url;
|
|
588
879
|
try { url = new URL(action, location.href); }
|
|
@@ -590,7 +881,27 @@ function onSubmit(e) {
|
|
|
590
881
|
if (url.origin !== location.origin) return;
|
|
591
882
|
if (NON_HTML_EXTENSIONS.test(url.pathname)) return;
|
|
592
883
|
|
|
593
|
-
|
|
884
|
+
// Built once, after the cheap bails (a submission the router ignores should
|
|
885
|
+
// not pay for a FormData) and BEFORE the text/plain bail below. That order
|
|
886
|
+
// matters for the dev report: `text/plain` is precisely the case where the
|
|
887
|
+
// router declines the submission, so reporting after the bail would be dead
|
|
888
|
+
// code for the one shape that most needs it, since both paths are then
|
|
889
|
+
// answered with a 405 and the author gets no other signal.
|
|
890
|
+
const rawBody = buildSubmitFormData(form, submitter);
|
|
891
|
+
// Observational, and silent in production. Runs before `preventDefault`.
|
|
892
|
+
warnIfActionSubmissionCannotDeliver(form, submitter, method, rawBody);
|
|
893
|
+
|
|
894
|
+
// #1307: `text/plain` is a legal native encoding the server cannot parse
|
|
895
|
+
// (`looksLikeFormSubmission` accepts multipart and urlencoded only), and
|
|
896
|
+
// there is no honest way to send it over `fetch` and have the response mean
|
|
897
|
+
// anything. Bail to the browser so BOTH paths do the same thing, rather than
|
|
898
|
+
// silently sending multipart, which is what made the same form behave one
|
|
899
|
+
// way with JS and another way without it. Turbo enumerates this encoding and
|
|
900
|
+
// then sends FormData anyway, which is the divergence being avoided here. A
|
|
901
|
+
// safe method ignores the enctype entirely, per the submission algorithm.
|
|
902
|
+
if (!isSafeMethod && enctype === 'text/plain') return;
|
|
903
|
+
|
|
904
|
+
const body = encodeSubmitBody(rawBody, enctype);
|
|
594
905
|
|
|
595
906
|
e.preventDefault();
|
|
596
907
|
// Resolve the target frame for the submit, same precedence as a link:
|
|
@@ -604,14 +915,21 @@ function onSubmit(e) {
|
|
|
604
915
|
* Method resolution: submitter's `formmethod` wins over form's `method`.
|
|
605
916
|
* Returns lowercase.
|
|
606
917
|
*
|
|
918
|
+
* Resolved on PRESENCE, not truthiness (#1322), the same way `getSubmitAction`
|
|
919
|
+
* below resolves the action. The form-submission algorithm reads the
|
|
920
|
+
* submitter's `formmethod` if the submitter HAS one, so a present-but-empty
|
|
921
|
+
* `formmethod=""` overrides the form and then falls to its own invalid-value
|
|
922
|
+
* default, GET. Resolving with `||` instead sent a multipart POST for a button
|
|
923
|
+
* every engine submits as `GET`, which is a JS-on versus JS-off divergence.
|
|
924
|
+
*
|
|
607
925
|
* @param {HTMLFormElement} form
|
|
608
926
|
* @param {HTMLElement | null} submitter
|
|
609
927
|
*/
|
|
610
928
|
function getSubmitMethod(form, submitter) {
|
|
611
|
-
const v = (submitter && submitter.
|
|
612
|
-
|
|
613
|
-
|| '
|
|
614
|
-
return v.toLowerCase();
|
|
929
|
+
const v = (submitter && submitter.hasAttribute('formmethod'))
|
|
930
|
+
? (submitter.getAttribute('formmethod') || '')
|
|
931
|
+
: (form.getAttribute('method') || '');
|
|
932
|
+
return (v || 'get').toLowerCase();
|
|
615
933
|
}
|
|
616
934
|
|
|
617
935
|
/**
|
|
@@ -628,6 +946,83 @@ function getSubmitAction(form, submitter) {
|
|
|
628
946
|
return form.getAttribute('action') || form.action || location.href;
|
|
629
947
|
}
|
|
630
948
|
|
|
949
|
+
/**
|
|
950
|
+
* The three `enctype` keywords, plus the normalization a browser applies.
|
|
951
|
+
*
|
|
952
|
+
* Both the missing-value AND the invalid-value default of the `enctype`
|
|
953
|
+
* enumerated attribute are `application/x-www-form-urlencoded`, so
|
|
954
|
+
* `enctype="nonsense"` really does mean urlencoded and has to be sent as such.
|
|
955
|
+
* Only an exact, ASCII-case-insensitive match on one of the other two keywords
|
|
956
|
+
* means anything else.
|
|
957
|
+
*
|
|
958
|
+
* @param {string | null | undefined} raw
|
|
959
|
+
* @returns {'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'}
|
|
960
|
+
*/
|
|
961
|
+
function normalizeEnctype(raw) {
|
|
962
|
+
// Compared UNTRIMMED, the same rule `assertSubmittableForm` applies in
|
|
963
|
+
// `form-action.js`. An enumerated attribute is matched against exact
|
|
964
|
+
// keywords with no whitespace stripping, so `enctype=" multipart/form-data "`
|
|
965
|
+
// falls to the invalid-value default and a BROWSER sends urlencoded for it.
|
|
966
|
+
// Trimming here would send multipart, so the router would disagree with the
|
|
967
|
+
// no-JS path on exactly the shape this function exists to keep in step.
|
|
968
|
+
const v = String(raw || '').toLowerCase();
|
|
969
|
+
if (v === 'multipart/form-data') return 'multipart/form-data';
|
|
970
|
+
if (v === 'text/plain') return 'text/plain';
|
|
971
|
+
return 'application/x-www-form-urlencoded';
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Enctype resolution: the submitter's `formenctype` wins over the form's
|
|
976
|
+
* `enctype`, exactly as `getSubmitMethod` resolves the method (#1307). Turbo
|
|
977
|
+
* resolves it the same way, in `core/drive/form_submission.js`.
|
|
978
|
+
*
|
|
979
|
+
* On PRESENCE, not truthiness (#1322), for the reason spelled out on
|
|
980
|
+
* `getSubmitMethod`: a present-but-empty `formenctype=""` overrides the form
|
|
981
|
+
* and normalizes to urlencoded, its own invalid-value default, so a button on
|
|
982
|
+
* a multipart form sends urlencoded with JS on exactly as it does with JS off.
|
|
983
|
+
*
|
|
984
|
+
* @param {HTMLFormElement} form
|
|
985
|
+
* @param {HTMLElement | null} submitter
|
|
986
|
+
*/
|
|
987
|
+
function getSubmitEnctype(form, submitter) {
|
|
988
|
+
return normalizeEnctype(
|
|
989
|
+
(submitter && submitter.hasAttribute('formenctype'))
|
|
990
|
+
? submitter.getAttribute('formenctype')
|
|
991
|
+
: form.getAttribute('enctype'),
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Encode a submission body the way the DECLARED enctype says to (#1307).
|
|
997
|
+
*
|
|
998
|
+
* The router used to build a `FormData` and send it with no explicit content
|
|
999
|
+
* type, so `fetch` always derived `multipart/form-data` and the authored
|
|
1000
|
+
* `enctype` was never read at all. An author writing
|
|
1001
|
+
* `enctype="application/x-www-form-urlencoded"`, which is also the HTML
|
|
1002
|
+
* DEFAULT and therefore what a plain `<form method="post">` means, got
|
|
1003
|
+
* urlencoded with JS off and multipart with JS on. Same form, two different
|
|
1004
|
+
* request bodies, which is exactly what progressive enhancement rules out.
|
|
1005
|
+
*
|
|
1006
|
+
* A `File` entry serializes as its NAME under urlencoded, which is what the
|
|
1007
|
+
* platform's own urlencoded serializer does. (Turbo drops file entries here
|
|
1008
|
+
* entirely, in `http/fetch_request.js`, which loses a field the no-JS path
|
|
1009
|
+
* sends.)
|
|
1010
|
+
*
|
|
1011
|
+
* A bound form is unaffected: it carries an explicit
|
|
1012
|
+
* `enctype="multipart/form-data"`, and since #1307 a bound submitter carries
|
|
1013
|
+
* `formenctype="multipart/form-data"`, so both resolve to multipart as before.
|
|
1014
|
+
*
|
|
1015
|
+
* @param {FormData} formData
|
|
1016
|
+
* @param {'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'} enctype
|
|
1017
|
+
* @returns {FormData | URLSearchParams}
|
|
1018
|
+
*/
|
|
1019
|
+
function encodeSubmitBody(formData, enctype) {
|
|
1020
|
+
if (enctype === 'multipart/form-data') return formData;
|
|
1021
|
+
const params = new URLSearchParams();
|
|
1022
|
+
for (const [k, v] of formData) params.append(k, typeof v === 'string' ? v : v.name);
|
|
1023
|
+
return params;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
631
1026
|
/**
|
|
632
1027
|
* Build FormData honoring the submitter's name=value (per HTML5 form
|
|
633
1028
|
* submission algorithm). Modern browsers + the `FormData(form, submitter)`
|
|
@@ -769,10 +1164,89 @@ function resolveTargetFrameId(trigger) {
|
|
|
769
1164
|
*/
|
|
770
1165
|
const warnedKeys = new Set();
|
|
771
1166
|
/** @param {string} key @param {string} message */
|
|
772
|
-
function warnOnce(key, message) {
|
|
1167
|
+
function warnOnce(key, message, level = 'warn') {
|
|
773
1168
|
if (warnedKeys.has(key)) return;
|
|
774
1169
|
warnedKeys.add(key);
|
|
775
|
-
if (typeof console
|
|
1170
|
+
if (typeof console === 'undefined') return;
|
|
1171
|
+
const fn = level === 'error' ? console.error : console.warn;
|
|
1172
|
+
if (fn) fn.call(console, message);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* DEV-ONLY: report at submit time when a submission is carrying a bound
|
|
1177
|
+
* action's identity it cannot actually deliver (#1307).
|
|
1178
|
+
*
|
|
1179
|
+
* This is the backstop for the shapes the renderer deliberately stopped
|
|
1180
|
+
* refusing. A PLAIN `<button formmethod="get">` inside a bound form is a legal
|
|
1181
|
+
* native override, so it renders, and the form's action then simply does not
|
|
1182
|
+
* run. That is what the author asked for, but it is also what a mistake looks
|
|
1183
|
+
* like, and submit time is the only moment the whole picture (the resolved
|
|
1184
|
+
* method, the resolved enctype, and whether a bound identity is actually in
|
|
1185
|
+
* the body) exists in one place.
|
|
1186
|
+
*
|
|
1187
|
+
* Observational: it runs BEFORE `preventDefault` and changes nothing about the
|
|
1188
|
+
* submission. Silent in production, where a console error would be noise the
|
|
1189
|
+
* visitor cannot act on; the server-side `onError` telemetry covers that side.
|
|
1190
|
+
*
|
|
1191
|
+
* @param {HTMLFormElement} form
|
|
1192
|
+
* @param {HTMLElement | null} submitter
|
|
1193
|
+
* @param {string} method lowercased, already resolved with native precedence
|
|
1194
|
+
* @param {FormData} body
|
|
1195
|
+
*/
|
|
1196
|
+
function warnIfActionSubmissionCannotDeliver(form, submitter, method, body) {
|
|
1197
|
+
if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return;
|
|
1198
|
+
// No identity in the body: an ordinary form with nothing to deliver.
|
|
1199
|
+
if (!body.has(FORM_ACTION_FIELD)) return;
|
|
1200
|
+
let path = '';
|
|
1201
|
+
try { path = new URL(form.getAttribute('action') || location.href, location.href).pathname; }
|
|
1202
|
+
catch { path = location.pathname; }
|
|
1203
|
+
if (method !== 'post') {
|
|
1204
|
+
warnOnce(
|
|
1205
|
+
`submit-nowhere:${path}:${method}`,
|
|
1206
|
+
`[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.`,
|
|
1207
|
+
'error',
|
|
1208
|
+
);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
const enctype = (submitter && submitter.getAttribute('formenctype'))
|
|
1212
|
+
|| form.getAttribute('enctype')
|
|
1213
|
+
|| 'application/x-www-form-urlencoded';
|
|
1214
|
+
// `text/plain` ONLY, not the renderer's parseable-enctype allowlist.
|
|
1215
|
+
// `enctype` is an enumerated attribute whose missing AND invalid value
|
|
1216
|
+
// defaults are both `application/x-www-form-urlencoded`, so
|
|
1217
|
+
// `enctype="nonsense"` submits a perfectly parseable body and the action
|
|
1218
|
+
// runs. Testing against the allowlist would report that working form as
|
|
1219
|
+
// broken.
|
|
1220
|
+
if (enctype.toLowerCase() === 'text/plain') {
|
|
1221
|
+
warnOnce(
|
|
1222
|
+
`submit-nowhere:${path}:${enctype}`,
|
|
1223
|
+
`[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.`,
|
|
1224
|
+
'error',
|
|
1225
|
+
);
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
// The identity is going somewhere OTHER than this page (#1307). A bound
|
|
1229
|
+
// submitter emits no `formaction` url, so the submission targets whatever the
|
|
1230
|
+
// FORM targets, and a form declaring its own `action="/x"` sends its buttons
|
|
1231
|
+
// to `/x` by ordinary native precedence.
|
|
1232
|
+
//
|
|
1233
|
+
// This is the one shape the redesign left both unrefused and, until here,
|
|
1234
|
+
// unreported. The renderer used to throw for it, but only where it could SEE
|
|
1235
|
+
// the form, which is exactly the cross-element judgement that could not be
|
|
1236
|
+
// made from inside a component. So it is reported at submit time instead,
|
|
1237
|
+
// where the resolved target is a fact rather than an inference.
|
|
1238
|
+
//
|
|
1239
|
+
// A warning rather than an error, because it is not necessarily wrong: if
|
|
1240
|
+
// `/x` is a PAGE route the action really does run there, and the 422
|
|
1241
|
+
// re-render simply lands on that page. It is only dead if `/x` is a
|
|
1242
|
+
// `route.ts`, another origin, or nothing at all, and the client cannot tell
|
|
1243
|
+
// which from here.
|
|
1244
|
+
if (path && path !== location.pathname) {
|
|
1245
|
+
warnOnce(
|
|
1246
|
+
`submit-elsewhere:${path}`,
|
|
1247
|
+
`[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.`,
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
776
1250
|
}
|
|
777
1251
|
|
|
778
1252
|
/**
|
|
@@ -1081,7 +1555,7 @@ function setNavigating(on) {
|
|
|
1081
1555
|
* @returns {Map<string, { routeKey: string, start: Comment, end: Comment }> | null}
|
|
1082
1556
|
* The boundary map, or null when the tree's boundaries are malformed.
|
|
1083
1557
|
*/
|
|
1084
|
-
|
|
1558
|
+
function collectBoundaries(root) {
|
|
1085
1559
|
/** @type {Map<string, { routeKey: string, start: Comment, end: Comment }>} */
|
|
1086
1560
|
const out = new Map();
|
|
1087
1561
|
/** @type {{ segment: string, routeKey: string, start: Comment }[]} */
|
|
@@ -1198,7 +1672,7 @@ export function collectBoundaries(root) {
|
|
|
1198
1672
|
* live: { routeKey: string, start: Comment, end: Comment },
|
|
1199
1673
|
* incoming: { routeKey: string, start: Comment, end: Comment } } | null}
|
|
1200
1674
|
*/
|
|
1201
|
-
|
|
1675
|
+
function planBoundarySwap(here, there) {
|
|
1202
1676
|
// Shared segments, shallowest first (a nested path prefix is shorter).
|
|
1203
1677
|
const shared = [...here.keys()].filter((s) => there.has(s)).sort((a, b) => a.length - b.length);
|
|
1204
1678
|
if (shared.length === 0) return null;
|
|
@@ -1335,6 +1809,33 @@ async function performNavigation(href, isPopState, frameId) {
|
|
|
1335
1809
|
// Bump nav generation. Captured below + by anything we await into.
|
|
1336
1810
|
const myToken = ++currentNavigationToken;
|
|
1337
1811
|
|
|
1812
|
+
// A new navigation ends any restore window still open from an earlier one
|
|
1813
|
+
// (#1310). The window outlives its own restore by design (a floor, then a
|
|
1814
|
+
// ceiling), so without this a second navigation inside that span inherits
|
|
1815
|
+
// suppressed anchoring: a Back that CLAMPS opens no window of its own, so it
|
|
1816
|
+
// would run the whole growth under the previous restore's suppression and
|
|
1817
|
+
// freeze its clamp, and a forward nav would carry it onto a different page
|
|
1818
|
+
// entirely. Reopening for this navigation, if it earns one, happens below.
|
|
1819
|
+
// The clamped path's catch-up is cancelled for the same reason: it chases an
|
|
1820
|
+
// offset recorded for the page being navigated away from.
|
|
1821
|
+
//
|
|
1822
|
+
// A FRAME-targeted nav is excluded, for the same reason `loadFrame` is: it
|
|
1823
|
+
// swaps one region and leaves the page, and so the restored scroll offset,
|
|
1824
|
+
// intact. The codebase already treats a click-driven frame nav and a `src`
|
|
1825
|
+
// self-load as the same thing, so exempting one and not the other would be
|
|
1826
|
+
// the split this rule exists to avoid.
|
|
1827
|
+
//
|
|
1828
|
+
// All THREE move together. Exempting only the counter while still closing
|
|
1829
|
+
// the window and aborting the catch-up would leave the split exactly where
|
|
1830
|
+
// it was, one line further down: a form inside a frame, submitted by a
|
|
1831
|
+
// component upgrading in the just-restored page, would hand anchoring back
|
|
1832
|
+
// mid-restore and bring the whole double-count back.
|
|
1833
|
+
if (!frameId) {
|
|
1834
|
+
restoreGeneration += 1;
|
|
1835
|
+
if (releaseScrollAnchor) releaseScrollAnchor();
|
|
1836
|
+
if (cancelScrollCatchUp) cancelScrollCatchUp();
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1338
1839
|
// Snapshot the page the user is LEAVING (with its scroll position)
|
|
1339
1840
|
// so back/forward navigation can restore it. We key under
|
|
1340
1841
|
// `currentPageUrl` rather than `location.href` because on popstate
|
|
@@ -1369,14 +1870,98 @@ async function performNavigation(href, isPopState, frameId) {
|
|
|
1369
1870
|
// Restore window scroll to where the user left it. Use
|
|
1370
1871
|
// behavior:'instant' so an app-level `scroll-behavior: smooth`
|
|
1371
1872
|
// stylesheet does not animate the restore (native nav jumps).
|
|
1873
|
+
//
|
|
1874
|
+
// `cached.scrollY` was recorded at the page's SETTLED height, and the
|
|
1875
|
+
// DOM just swapped in is still shorter until its components upgrade
|
|
1876
|
+
// and re-render. Suppress scroll anchoring across the restore, or the
|
|
1877
|
+
// browser adds that late growth to the restored offset and the reader
|
|
1878
|
+
// lands below where they left (#1310).
|
|
1879
|
+
let releaseAnchor = () => {};
|
|
1372
1880
|
if (typeof window !== 'undefined') {
|
|
1373
|
-
|
|
1881
|
+
// Restore the scroll, then decide whether to suppress anchoring.
|
|
1882
|
+
//
|
|
1883
|
+
// Suppress ONLY when the recorded offset was actually reached. A
|
|
1884
|
+
// document that has not grown yet can be too SHORT to scroll that
|
|
1885
|
+
// far, and the browser clamps to its current maximum. A reader at
|
|
1886
|
+
// the bottom of the settled page is the clear case: the shortfall is
|
|
1887
|
+
// then exactly the growth still to come, and anchoring ADDING that
|
|
1888
|
+
// growth is what carries them back to the bottom. Suppressing there
|
|
1889
|
+
// freezes the clamp instead and strands them a full page-growth
|
|
1890
|
+
// ABOVE where they left, which is this bug's own mirror image. The
|
|
1891
|
+
// two situations want opposite things and are told apart by the one
|
|
1892
|
+
// question that separates them: did the scroll land.
|
|
1893
|
+
//
|
|
1894
|
+
// Both halves must read the SAME layout, and the scroll must be
|
|
1895
|
+
// written against the page being restored. That is why this is
|
|
1896
|
+
// ordered rather than simply inlined, and why the ordering differs
|
|
1897
|
+
// by path.
|
|
1898
|
+
const restoreScroll = () => {
|
|
1899
|
+
window.scrollTo({ left: cached.scrollX, top: cached.scrollY, behavior: 'instant' });
|
|
1900
|
+
if (window.scrollY >= cached.scrollY - 1) {
|
|
1901
|
+
releaseAnchor = suppressScrollAnchoring();
|
|
1902
|
+
} else {
|
|
1903
|
+
// Clamped. Anchoring is left on, since it is what carries the
|
|
1904
|
+
// reader back down, but it adds the FULL growth regardless of
|
|
1905
|
+
// how far short the clamp fell, so on its own it only lands a
|
|
1906
|
+
// reader who left at the very bottom. Chase the recorded offset
|
|
1907
|
+
// instead, once the page is tall enough to hold it.
|
|
1908
|
+
catchUpToRestoredScroll(cached.scrollY, cached.scrollX);
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
if (viewTransitionsEnabled() && typeof (/** @type any */ (document)).startViewTransition === 'function') {
|
|
1912
|
+
// Under a view transition `applySwap` defers its DOM mutation a
|
|
1913
|
+
// frame, so running now would write and measure against the
|
|
1914
|
+
// OUTGOING page. Measured with a 60000px outgoing page and a
|
|
1915
|
+
// 3000px restored one: the scroll "landed" at 20000, suppression
|
|
1916
|
+
// opened, and the restored page then clamped to 2416 with
|
|
1917
|
+
// anchoring held off, which is precisely the stranding the
|
|
1918
|
+
// conditional exists to prevent. Wait for the swap to commit.
|
|
1919
|
+
//
|
|
1920
|
+
// Guarded, because this is the one path where the restore
|
|
1921
|
+
// outlives the call that scheduled it. Every cancel site in this
|
|
1922
|
+
// feature (performNavigation, performSubmission,
|
|
1923
|
+
// disableClientRouter) runs at the START of the next thing, so a
|
|
1924
|
+
// navigation, submission, or disable arriving inside the deferred
|
|
1925
|
+
// frame would close the window and then have this reopen it,
|
|
1926
|
+
// scrolling a page it was never meant for to an offset recorded
|
|
1927
|
+
// for the previous history entry. The synchronous branch below
|
|
1928
|
+
// cannot outlive anything and so needs no guard.
|
|
1929
|
+
const myRestore = restoreGeneration;
|
|
1930
|
+
_swapCommit.then(() => {
|
|
1931
|
+
if (myRestore !== restoreGeneration || !enabled) return;
|
|
1932
|
+
restoreScroll();
|
|
1933
|
+
}).catch(() => {});
|
|
1934
|
+
} else {
|
|
1935
|
+
// The synchronous path, and the read must STAY synchronous here.
|
|
1936
|
+
// Deferring it even by a microtask breaks the fix outright: by
|
|
1937
|
+
// then the restored components' renders have been applied, and
|
|
1938
|
+
// reading `scrollY` forces the layout that flushes them, so
|
|
1939
|
+
// anchoring runs DURING the read and hands back the
|
|
1940
|
+
// already-shifted offset. Measured on /ui/button, the suppression
|
|
1941
|
+
// landed 19ms late with `scrollY` already 800 -> 1563. What makes
|
|
1942
|
+
// it correct is not which document it sees but that it sees the
|
|
1943
|
+
// same layout the scroll just landed in.
|
|
1944
|
+
restoreScroll();
|
|
1945
|
+
}
|
|
1374
1946
|
}
|
|
1375
1947
|
// Fire-and-forget revalidation. Uses a fresh AbortController
|
|
1376
1948
|
// since this background fetch is allowed to overlap with the
|
|
1377
1949
|
// next foreground nav (it'll get aborted if a new nav lands).
|
|
1378
|
-
|
|
1950
|
+
//
|
|
1951
|
+
// Closing the anchoring window on THIS revalidation's settle (plus two
|
|
1952
|
+
// frames for the re-applied DOM to lay out) is what keeps the window
|
|
1953
|
+
// tied to one restore. A height observer could not tell a settling
|
|
1954
|
+
// restore from a streaming <webjs-suspense> boundary (#471 / #473).
|
|
1955
|
+
//
|
|
1956
|
+
// The floor is what makes that safe. Waiting on the revalidation ALONE
|
|
1957
|
+
// ties the window's length to network latency rather than to the
|
|
1958
|
+
// growth it guards, so a server that answers faster than the restored
|
|
1959
|
+
// page renders closes it early and the reader lands low again, which
|
|
1960
|
+
// is the whole defect.
|
|
1961
|
+
const revalidated = fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken, /* revalidating */ true)
|
|
1379
1962
|
.catch(() => {});
|
|
1963
|
+
const floor = new Promise((r) => setTimeout(r, ANCHOR_SUPPRESS_FLOOR_MS));
|
|
1964
|
+
Promise.all([revalidated, floor]).then(() => afterTwoFrames(releaseAnchor));
|
|
1380
1965
|
return;
|
|
1381
1966
|
}
|
|
1382
1967
|
}
|
|
@@ -1433,7 +2018,11 @@ async function performNavigation(href, isPopState, frameId) {
|
|
|
1433
2018
|
*
|
|
1434
2019
|
* @param {string} href Absolute target URL.
|
|
1435
2020
|
* @param {string} method Lowercased HTTP verb.
|
|
1436
|
-
* @param {FormData} body
|
|
2021
|
+
* @param {FormData | URLSearchParams} body Encoded per the declared enctype
|
|
2022
|
+
* (#1307): `FormData` for multipart, `URLSearchParams` for urlencoded. Both
|
|
2023
|
+
* iterate as `[name, value]` pairs, which is all the safe-method query-string
|
|
2024
|
+
* promotion below needs, and `fetch` derives the right content type from
|
|
2025
|
+
* either without an explicit header.
|
|
1437
2026
|
* @param {string | null} frameId
|
|
1438
2027
|
* @param {HTMLFormElement | null} [form] The submitted form, for busy + events.
|
|
1439
2028
|
*/
|
|
@@ -1442,6 +2031,15 @@ async function performSubmission(href, method, body, frameId, form) {
|
|
|
1442
2031
|
activeAbortController = new AbortController();
|
|
1443
2032
|
const signal = activeAbortController.signal;
|
|
1444
2033
|
const myToken = ++currentNavigationToken;
|
|
2034
|
+
// Same reasoning as performNavigation: a submission is a navigation, so it
|
|
2035
|
+
// ends any restore window a recent Back left open (#1310), and cancels a
|
|
2036
|
+
// clamped restore's catch-up. Frame-targeted submissions are excluded on the
|
|
2037
|
+
// same reasoning as the frame navs above.
|
|
2038
|
+
if (!frameId) {
|
|
2039
|
+
restoreGeneration += 1;
|
|
2040
|
+
if (releaseScrollAnchor) releaseScrollAnchor();
|
|
2041
|
+
if (cancelScrollCatchUp) cancelScrollCatchUp();
|
|
2042
|
+
}
|
|
1445
2043
|
|
|
1446
2044
|
const isSafe = method === 'get' || method === 'head';
|
|
1447
2045
|
let url = new URL(href, location.href);
|
|
@@ -2958,12 +3556,28 @@ function upgradeCustomElementsInRange(range) {
|
|
|
2958
3556
|
let _swapCommit = Promise.resolve();
|
|
2959
3557
|
|
|
2960
3558
|
function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc) {
|
|
2961
|
-
// SSR action seeding (#472): ingest
|
|
2962
|
-
//
|
|
2963
|
-
//
|
|
2964
|
-
// re-fetching. Scanning `doc` (the detached parse) also strips the
|
|
2965
|
-
//
|
|
2966
|
-
|
|
3559
|
+
// SSR action seeding (#472): ingest the incoming page's seed payload BEFORE
|
|
3560
|
+
// its components are grafted into the live DOM and upgrade, so a
|
|
3561
|
+
// soft-navigated async component resolves from the seed instead of
|
|
3562
|
+
// re-fetching. Scanning `doc` (the detached parse) also strips the carriers,
|
|
3563
|
+
// so the inert payload never lands in the live document.
|
|
3564
|
+
//
|
|
3565
|
+
// Called at each COMMIT point rather than once up front, because this function
|
|
3566
|
+
// can still decide to throw the response away after parsing it (a hard
|
|
3567
|
+
// navigate, or a background revalidation with no trustworthy boundary plan).
|
|
3568
|
+
// Scanning eagerly would clear the visible page's own unconsumed seeds and
|
|
3569
|
+
// ingest a render that is never painted, so the next `async render()` would
|
|
3570
|
+
// hit on data that disagrees with the HTML on screen. That is the same hole
|
|
3571
|
+
// the frame ID-MISSING case closes, reached through a different discard.
|
|
3572
|
+
//
|
|
3573
|
+
// A frame swap is NOT a page navigation, so say so: the consumer must leave
|
|
3574
|
+
// the surrounding page's state alone (see `scanSeeds`).
|
|
3575
|
+
let seedsScanned = false;
|
|
3576
|
+
const ingestSeeds = () => {
|
|
3577
|
+
if (seedsScanned) return;
|
|
3578
|
+
seedsScanned = true;
|
|
3579
|
+
try { scanSeeds(doc, { frame: !!frameId }); } catch { /* seeding is best-effort */ }
|
|
3580
|
+
};
|
|
2967
3581
|
|
|
2968
3582
|
// Every host in this parsed doc is FRAMEWORK-SERIALIZED markup (an SSR
|
|
2969
3583
|
// fragment or a back/forward snapshot of post-hydration HTML), never
|
|
@@ -3123,6 +3737,9 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
3123
3737
|
|
|
3124
3738
|
// 1. webjs-frame escape hatch.
|
|
3125
3739
|
if (frameId) {
|
|
3740
|
+
// Both outcomes here (a successful subtree swap, or frame-missing) discard
|
|
3741
|
+
// whatever the parse carried, so this is safe wherever it lands.
|
|
3742
|
+
ingestSeeds();
|
|
3126
3743
|
const target = document.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
|
|
3127
3744
|
const source = doc.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
|
|
3128
3745
|
if (target && source) {
|
|
@@ -3178,6 +3795,9 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
3178
3795
|
const plan = here && there ? planBoundarySwap(here, there) : null;
|
|
3179
3796
|
|
|
3180
3797
|
if (plan) {
|
|
3798
|
+
// Committed: this response is being applied, so its seeds are the ones the
|
|
3799
|
+
// user is about to look at.
|
|
3800
|
+
ingestSeeds();
|
|
3181
3801
|
const { mode, live, incoming } = plan;
|
|
3182
3802
|
// ADD-ONLY head merge: the outer layout stays mounted, so its head-bound
|
|
3183
3803
|
// runtime state (Tailwind injection, etc.) must not be invalidated.
|
|
@@ -3240,6 +3860,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
|
|
|
3240
3860
|
// because the user is already viewing the page). Full head merge;
|
|
3241
3861
|
// `mergeHead` PRESERVES stylesheets and `<style>` unconditionally
|
|
3242
3862
|
// (#936) so the swap can never leave the page unstyled.
|
|
3863
|
+
ingestSeeds(); // committed: past both discard branches above
|
|
3243
3864
|
mergeHead(doc.head);
|
|
3244
3865
|
// Persist permanent elements by node identity across the full-body
|
|
3245
3866
|
// swap: move each live [data-webjs-permanent][id] node into the matching
|
|
@@ -4690,6 +5311,8 @@ export {
|
|
|
4690
5311
|
getSubmitMethod as _getSubmitMethod,
|
|
4691
5312
|
getSubmitAction as _getSubmitAction,
|
|
4692
5313
|
buildSubmitFormData as _buildSubmitFormData,
|
|
5314
|
+
getSubmitEnctype as _getSubmitEnctype,
|
|
5315
|
+
encodeSubmitBody as _encodeSubmitBody,
|
|
4693
5316
|
restoreOptimistic as _restoreOptimistic,
|
|
4694
5317
|
eligibleAnchorHref as _eligibleAnchorHref,
|
|
4695
5318
|
viewTransitionsEnabled as _viewTransitionsEnabled,
|