@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.
@@ -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
- location.href = url;
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
- const body = buildSubmitFormData(form, submitter);
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 !== 'undefined' && console.warn) console.warn(message);
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
- location.href = href;
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
- window.scrollTo({ left: cached.scrollX, top: cached.scrollY, behavior: 'instant' });
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
- fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken, /* revalidating */ true)
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
- location.href = href;
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) incomingSlice[idx] = live;
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 any seed payload the incoming page
2787
- // carries BEFORE its components are grafted into the live DOM and upgrade, so
2788
- // a soft-navigated async component resolves from the seed instead of
2789
- // re-fetching. Scanning `doc` (the detached parse) also strips the seed
2790
- // carriers, so the inert payload never lands in the live document.
2791
- try { scanSeeds(doc); } catch { /* seeding is best-effort */ }
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
- location.href = href;
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
- location.href = href;
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
- location.href = href;
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
- for (let n = target.start.nextSibling; n && n !== target.end; n = n.nextSibling) {
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
- for (let n = target.start.nextSibling; n && n !== target.end; n = n.nextSibling) {
3205
- if (n.nodeType === 1) {
3206
- reactivateScripts(/** @type {Element} */ (n));
3207
- upgradeCustomElements(/** @type {Element} */ (n));
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
- /** @param {Element} container */
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). */