@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.
@@ -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
- const target = (submitter && submitter.getAttribute('formtarget'))
579
- || form.getAttribute('target')
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
- const body = buildSubmitFormData(form, submitter);
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.getAttribute('formmethod'))
612
- || form.getAttribute('method')
613
- || 'get';
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 !== 'undefined' && console.warn) console.warn(message);
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
- export function collectBoundaries(root) {
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
- export function planBoundarySwap(here, there) {
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
- window.scrollTo({ left: cached.scrollX, top: cached.scrollY, behavior: 'instant' });
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
- fetchAndApply(href, frameId, /* recordHistory */ false, optimisticState, 'GET', null, signal, myToken, /* revalidating */ true)
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 any seed payload the incoming page
2962
- // carries BEFORE its components are grafted into the live DOM and upgrade, so
2963
- // a soft-navigated async component resolves from the seed instead of
2964
- // re-fetching. Scanning `doc` (the detached parse) also strips the seed
2965
- // carriers, so the inert payload never lands in the live document.
2966
- try { scanSeeds(doc); } catch { /* seeding is best-effort */ }
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,