@webjsdev/core 0.7.47 → 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,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
 
@@ -583,6 +867,9 @@ function onSubmit(e) {
583
867
  const method = getSubmitMethod(form, submitter);
584
868
  if (method === 'dialog') return;
585
869
 
870
+ const enctype = getSubmitEnctype(form, submitter);
871
+ const isSafeMethod = method === 'get' || method === 'head';
872
+
586
873
  const action = getSubmitAction(form, submitter);
587
874
  /** @type {URL} */ let url;
588
875
  try { url = new URL(action, location.href); }
@@ -590,7 +877,27 @@ function onSubmit(e) {
590
877
  if (url.origin !== location.origin) return;
591
878
  if (NON_HTML_EXTENSIONS.test(url.pathname)) return;
592
879
 
593
- 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);
594
901
 
595
902
  e.preventDefault();
596
903
  // Resolve the target frame for the submit, same precedence as a link:
@@ -628,6 +935,76 @@ function getSubmitAction(form, submitter) {
628
935
  return form.getAttribute('action') || form.action || location.href;
629
936
  }
630
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
+
631
1008
  /**
632
1009
  * Build FormData honoring the submitter's name=value (per HTML5 form
633
1010
  * submission algorithm). Modern browsers + the `FormData(form, submitter)`
@@ -769,10 +1146,89 @@ function resolveTargetFrameId(trigger) {
769
1146
  */
770
1147
  const warnedKeys = new Set();
771
1148
  /** @param {string} key @param {string} message */
772
- function warnOnce(key, message) {
1149
+ function warnOnce(key, message, level = 'warn') {
773
1150
  if (warnedKeys.has(key)) return;
774
1151
  warnedKeys.add(key);
775
- 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
+ }
776
1232
  }
777
1233
 
778
1234
  /**
@@ -1335,6 +1791,33 @@ async function performNavigation(href, isPopState, frameId) {
1335
1791
  // Bump nav generation. Captured below + by anything we await into.
1336
1792
  const myToken = ++currentNavigationToken;
1337
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
+
1338
1821
  // Snapshot the page the user is LEAVING (with its scroll position)
1339
1822
  // so back/forward navigation can restore it. We key under
1340
1823
  // `currentPageUrl` rather than `location.href` because on popstate
@@ -1369,14 +1852,98 @@ async function performNavigation(href, isPopState, frameId) {
1369
1852
  // Restore window scroll to where the user left it. Use
1370
1853
  // behavior:'instant' so an app-level `scroll-behavior: smooth`
1371
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 = () => {};
1372
1862
  if (typeof window !== 'undefined') {
1373
- 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
+ }
1374
1928
  }
1375
1929
  // Fire-and-forget revalidation. Uses a fresh AbortController
1376
1930
  // since this background fetch is allowed to overlap with the
1377
1931
  // 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)
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)
1379
1944
  .catch(() => {});
1945
+ const floor = new Promise((r) => setTimeout(r, ANCHOR_SUPPRESS_FLOOR_MS));
1946
+ Promise.all([revalidated, floor]).then(() => afterTwoFrames(releaseAnchor));
1380
1947
  return;
1381
1948
  }
1382
1949
  }
@@ -1433,7 +2000,11 @@ async function performNavigation(href, isPopState, frameId) {
1433
2000
  *
1434
2001
  * @param {string} href Absolute target URL.
1435
2002
  * @param {string} method Lowercased HTTP verb.
1436
- * @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.
1437
2008
  * @param {string | null} frameId
1438
2009
  * @param {HTMLFormElement | null} [form] The submitted form, for busy + events.
1439
2010
  */
@@ -1442,6 +2013,15 @@ async function performSubmission(href, method, body, frameId, form) {
1442
2013
  activeAbortController = new AbortController();
1443
2014
  const signal = activeAbortController.signal;
1444
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
+ }
1445
2025
 
1446
2026
  const isSafe = method === 'get' || method === 'head';
1447
2027
  let url = new URL(href, location.href);
@@ -2958,12 +3538,28 @@ function upgradeCustomElementsInRange(range) {
2958
3538
  let _swapCommit = Promise.resolve();
2959
3539
 
2960
3540
  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 */ }
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
+ };
2967
3563
 
2968
3564
  // Every host in this parsed doc is FRAMEWORK-SERIALIZED markup (an SSR
2969
3565
  // fragment or a back/forward snapshot of post-hydration HTML), never
@@ -3123,6 +3719,9 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
3123
3719
 
3124
3720
  // 1. webjs-frame escape hatch.
3125
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();
3126
3725
  const target = document.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
3127
3726
  const source = doc.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
3128
3727
  if (target && source) {
@@ -3178,6 +3777,9 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
3178
3777
  const plan = here && there ? planBoundarySwap(here, there) : null;
3179
3778
 
3180
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();
3181
3783
  const { mode, live, incoming } = plan;
3182
3784
  // ADD-ONLY head merge: the outer layout stays mounted, so its head-bound
3183
3785
  // runtime state (Tailwind injection, etc.) must not be invalidated.
@@ -3240,6 +3842,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
3240
3842
  // because the user is already viewing the page). Full head merge;
3241
3843
  // `mergeHead` PRESERVES stylesheets and `<style>` unconditionally
3242
3844
  // (#936) so the swap can never leave the page unstyled.
3845
+ ingestSeeds(); // committed: past both discard branches above
3243
3846
  mergeHead(doc.head);
3244
3847
  // Persist permanent elements by node identity across the full-body
3245
3848
  // swap: move each live [data-webjs-permanent][id] node into the matching
@@ -4690,6 +5293,8 @@ export {
4690
5293
  getSubmitMethod as _getSubmitMethod,
4691
5294
  getSubmitAction as _getSubmitAction,
4692
5295
  buildSubmitFormData as _buildSubmitFormData,
5296
+ getSubmitEnctype as _getSubmitEnctype,
5297
+ encodeSubmitBody as _encodeSubmitBody,
4693
5298
  restoreOptimistic as _restoreOptimistic,
4694
5299
  eligibleAnchorHref as _eligibleAnchorHref,
4695
5300
  viewTransitionsEnabled as _viewTransitionsEnabled,