@webjsdev/core 0.7.50 → 0.7.51

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.
Files changed (57) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +20 -20
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +7 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +1 -1
  8. package/index.d.ts +1 -1
  9. package/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/component/lifecycle.js +1481 -0
  12. package/src/component/reactive.js +171 -0
  13. package/src/component/server-element.js +248 -0
  14. package/src/component.js +2 -1896
  15. package/src/render-client/parts.js +2007 -0
  16. package/src/render-client/reconciler.js +388 -0
  17. package/src/render-client/template-compiler.js +525 -0
  18. package/src/render-client.js +1 -2938
  19. package/src/render-server/attrs.js +160 -0
  20. package/src/render-server/dsd.js +445 -0
  21. package/src/render-server/html-scan.js +402 -0
  22. package/src/render-server/slots.js +213 -0
  23. package/src/render-server/stream.js +163 -0
  24. package/src/render-server/template-renderer.js +923 -0
  25. package/src/render-server/text.js +163 -0
  26. package/src/render-server.js +6 -2405
  27. package/src/router-client/anchors.js +44 -0
  28. package/src/router-client/boundaries.js +221 -0
  29. package/src/router-client/constants.js +187 -0
  30. package/src/router-client/diagnostics.js +377 -0
  31. package/src/router-client/dom-differ.js +709 -0
  32. package/src/router-client/dom-parse.js +150 -0
  33. package/src/router-client/events.js +139 -0
  34. package/src/router-client/fetch-apply.js +387 -0
  35. package/src/router-client/form-encoder.js +135 -0
  36. package/src/router-client/frames.js +329 -0
  37. package/src/router-client/head-merge.js +344 -0
  38. package/src/router-client/nav-error.js +105 -0
  39. package/src/router-client/navigator.js +781 -0
  40. package/src/router-client/prefetch.js +824 -0
  41. package/src/router-client/scroll.js +259 -0
  42. package/src/router-client/snapshot-cache.js +97 -0
  43. package/src/router-client/state.js +92 -0
  44. package/src/router-client/stream.js +206 -0
  45. package/src/router-client/swap.js +451 -0
  46. package/src/router-client/upgrade.js +261 -0
  47. package/src/router-client/view-transition.js +223 -0
  48. package/src/router-client.d.ts +10 -1
  49. package/src/router-client.js +75 -5345
  50. package/src/slot/assignment.js +304 -0
  51. package/src/slot/interception.js +558 -0
  52. package/src/slot/polyfills.js +365 -0
  53. package/src/slot/project.js +432 -0
  54. package/src/slot/sensors.js +303 -0
  55. package/src/slot/state.js +245 -0
  56. package/src/slot/symbols.js +85 -0
  57. package/src/slot.js +7 -2199
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Client router: fetch-apply.
3
+ *
4
+ * Moved verbatim out of the pre-split `router-client.js`; that barrel holds
5
+ * the router's full contract and the public entry points.
6
+ *
7
+ * @module
8
+ */
9
+ import { markStale, parseTagHeader } from '../action-cache-client.js';
10
+ import { renderStream } from '../webjs-stream.js';
11
+ import { buildHaveHeader } from './boundaries.js';
12
+ import { STREAM_MIME } from './constants.js';
13
+ import { warnIfSmoothScrollOnHtml } from './diagnostics.js';
14
+ import { restoreOptimistic } from './dom-differ.js';
15
+ import { parseHTML } from './dom-parse.js';
16
+ import { clearFrameBusy, markFrameBusy } from './frames.js';
17
+ import { handleNavigationError } from './nav-error.js';
18
+ import { navigate } from './navigator.js';
19
+ import { prefetchTake } from './prefetch.js';
20
+ import { currentNavigationToken } from './state.js';
21
+ import { readStreamedShell, streamBoundariesProgressively } from './stream.js';
22
+ import { _swapCommit, applySwap } from './swap.js';
23
+
24
+ /**
25
+ * Fetch the target URL and apply the swap.
26
+ *
27
+ * @param {string} href
28
+ * @param {string | null} frameId
29
+ * @param {boolean} recordHistory
30
+ * @param {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number } | null} optimisticState
31
+ * @param {string} [method] HTTP verb (uppercase). Default 'GET'.
32
+ * @param {BodyInit | null} [body] Request body for non-GET methods.
33
+ * @param {AbortSignal | null} [signal] Abort signal. A newer nav cancels this fetch.
34
+ * @param {number} [token] Nav-token captured at the caller's entry; stale → skip apply.
35
+ * @param {boolean} [revalidating] True for the BACKGROUND refresh after a
36
+ * snapshot restore: the user is already viewing a page, so a boundary
37
+ * mismatch must degrade in place (never a jarring `location.href` load).
38
+ * @param {'page' | 'shell'} [refresh] Same-URL in-place refresh (#1398). It
39
+ * suppresses the `X-Webjs-Have` header and picks the swap tier; see
40
+ * `refreshPage`.
41
+ * @param {boolean} [noPrefetch] Never consume a speculative entry, whatever the
42
+ * cache holds (#1407). Set by `loadFrame`: a `<webjs-frame src>` self-load or
43
+ * `src` mutation asks for THIS frame's content now, which is a freshness
44
+ * request rather than the click-follows-hover shape the warm cache serves.
45
+ * @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean }>}
46
+ * The fetch outcome, so a caller (the form-submission busy/event lifecycle)
47
+ * can report whether the submission settled as a success, an error, or an
48
+ * abort. `ok` mirrors `response.ok` for an HTTP response (a 422 validation
49
+ * re-render is `ok:false`), `false` for a transport/parse error, and `false`
50
+ * for an abort (which also sets `aborted:true`). `status` is the HTTP status
51
+ * or `null` when the request never produced one.
52
+ *
53
+ * `applied` is a DIFFERENT question from `ok` and the two must not be
54
+ * conflated (#1398). An HTML body of ANY status is parsed and swapped in
55
+ * place, which is the whole point of the 422-revalidation and error-boundary
56
+ * behaviour, so a page rendered through `notFound()`, `forbidden()`, or an
57
+ * `error.ts` boundary has `ok:false` and `applied:true`. It is `false`
58
+ * wherever no swap committed: a transport failure, a non-HTML body, an
59
+ * unparseable one, a 204/205, a discarded revalidation, an abort, and every
60
+ * `applySwap` path that returns without committing (a missing frame, or a
61
+ * degradation to a hard navigation from an importmap mismatch or a poisoned
62
+ * boundary scan). A caller deciding whether to fall back to a full page load
63
+ * wants `applied`; one reporting the submission's success wants `ok`.
64
+ */
65
+ export async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token, revalidating, refresh, noPrefetch) {
66
+ method = method || 'GET';
67
+ const myToken = typeof token === 'number' ? token : currentNavigationToken;
68
+ let html;
69
+ // Set when the response streams Suspense boundaries (#473): holds the open
70
+ // reader + leftover buffer so the boundaries apply progressively after the
71
+ // shell swap. Null for a buffered (non-streaming) or prefetched response.
72
+ let streamCtx = null;
73
+ let incomingBuild = null;
74
+ let incomingSrc = null;
75
+ /** @type {number | null} */
76
+ let respStatus = null;
77
+ /** @type {boolean} */
78
+ let respOk = false;
79
+ /** @type {string} */
80
+ let finalUrl = href;
81
+ // aria-busy lifecycle: when this nav targets a <webjs-frame>, mark the
82
+ // live frame busy for the duration of its fetch+apply so assistive tech
83
+ // can announce it and CSS can style `webjs-frame[aria-busy="true"]`. The
84
+ // outer try/finally guarantees the busy state is cleared on EVERY exit
85
+ // (success swap, frame-missing, an HTTP/transport error, an abort by a
86
+ // newer nav), never leaving a frame stuck busy.
87
+ const busyFrame = frameId ? markFrameBusy(frameId, myToken) : null;
88
+ try {
89
+ try {
90
+ // Warm-cache fast path: a hover/focus/viewport prefetch may already hold
91
+ // this page. Consume it instead of going to the network, so the click
92
+ // resolves with no round-trip. Only for GET navs carrying no body. A
93
+ // MUTATING form submission is a write and always hits the server; a SAFE
94
+ // (GET) submission is a read like a link click, so it consumes like one,
95
+ // including in a frame dimension. A FRAME nav consumes only an entry
96
+ // fetched under the SAME frame id (#1407): the key carries that dimension,
97
+ // so a page fragment can never be applied into a frame region, nor a frame
98
+ // subtree into a page swap, and a `<webjs-frame src>` SELF-load opts out
99
+ // entirely via `noPrefetch`, since it is asking for fresh content rather
100
+ // than following a hover. The entry
101
+ // is single-use (prefetchTake removes it) and TTL-guarded. A PAGE entry is
102
+ // then validated by its ANCHOR rather than by an identical X-Webjs-Have
103
+ // (#1114): a fragment applies wherever the boundary it starts at is still
104
+ // live, so an unrelated navigation between the prefetch and this click does
105
+ // not disqualify it. A FRAME entry is validated differently, by its
106
+ // `<webjs-frame id>` still being in the document, since a subtree carries no
107
+ // boundary comment to anchor on (#1407).
108
+ // The optimistic skeleton has already deleted nested boundaries by now, so
109
+ // pass the view captured before it ran.
110
+ // A refresh must never consume a prefetch (#1398): every cached copy
111
+ // predates the change that triggered it. `refreshPage` clears both caches
112
+ // before it fetches, so this only closes the window where a prefetch lands
113
+ // between that clear and this read.
114
+ const prefetched = (method === 'GET' && !body && !refresh && !noPrefetch)
115
+ ? prefetchTake(href, optimisticState ? optimisticState.haveKeys : undefined, frameId)
116
+ : null;
117
+ if (prefetched) {
118
+ html = prefetched.html;
119
+ incomingBuild = prefetched.build;
120
+ incomingSrc = prefetched.src;
121
+ finalUrl = prefetched.finalUrl;
122
+ // A consumed prefetch is a successful 200 GET fragment.
123
+ respStatus = 200;
124
+ respOk = true;
125
+ } else {
126
+ const headers = { 'x-webjs-router': '1' };
127
+ // A same-URL refresh sends NO have-header (#1398). This is required, not an
128
+ // optimisation: the server short-circuits at the first layout whose segment
129
+ // path AND route key the client already holds, and a same-URL request
130
+ // matches every one of them, so the response would omit the layouts and a
131
+ // layout edit would be invisible. Sending nothing forces the full chain to
132
+ // render.
133
+ const have = refresh ? '' : buildHaveHeader();
134
+ if (have) headers['x-webjs-have'] = have;
135
+ if (frameId) headers['x-webjs-frame'] = frameId;
136
+ // Content-negotiate a stream-action response on a write submission (a
137
+ // non-GET body). The server returns the stream MIME only when this Accept
138
+ // is present, so with JS off (no router, no Accept) the same form gets a
139
+ // normal render/redirect: the grammar is additive and PE-safe (#248).
140
+ if (body != null && method !== 'GET' && method !== 'HEAD') {
141
+ headers['accept'] = STREAM_MIME + ', text/html';
142
+ }
143
+
144
+ /** @type {RequestInit} */
145
+ // `no-cache` for the same reason as the prefetch fetch (#1131): applySwap
146
+ // hard-reloads on a build change it can only see if these headers are
147
+ // live, not replayed from the HTTP cache.
148
+ const init = { method, headers, credentials: 'same-origin', cache: 'no-cache' };
149
+ if (signal) init.signal = signal;
150
+ if (body != null && method !== 'GET' && method !== 'HEAD') init.body = body;
151
+
152
+ const resp = await fetch(href, init);
153
+ respStatus = resp.status;
154
+ respOk = resp.ok;
155
+ // `fetch` follows a 303 transparently and its headers are then unreadable,
156
+ // so this lands on a non-redirect response: the 422 failure re-render, or
157
+ // any form response that answers in place.
158
+ const invalidated = parseTagHeader(resp.headers.get('x-webjs-invalidate'));
159
+ if (invalidated.length) markStale(invalidated);
160
+ const ctype = resp.headers.get('content-type') || '';
161
+ const isHTML = /^text\/html\b/i.test(ctype);
162
+ const isStream = ctype.toLowerCase().indexOf(STREAM_MIME) === 0;
163
+ // Stream-action response (#248): the body is `<webjs-stream>` elements
164
+ // applied surgically to the live DOM, NOT a region swap. Apply them and
165
+ // return; do not parse the body as a page document (it has no shell). A
166
+ // stream body of any status is fine. This runs BEFORE the !isHTML branch
167
+ // so the non-text/html stream MIME is not treated as a navigation error.
168
+ if (isStream) {
169
+ const text = await resp.text();
170
+ // A newer navigation owns the page, so nothing is applied and `applied`
171
+ // has to say so: it reports whether this response reached the DOM, and a
172
+ // superseded one did not.
173
+ const fresh = myToken === currentNavigationToken;
174
+ if (fresh) {
175
+ // Roll back any optimistic loading skeleton: a stream response patches
176
+ // the page in place, it does not swap the region the skeleton covered.
177
+ restoreOptimistic(optimisticState);
178
+ renderStream(text);
179
+ }
180
+ // `ok` is forced false alongside `aborted`, matching every other abort
181
+ // return in this function. A caller reading `ok` as "this response was
182
+ // not superseded" would otherwise be wrong here and nowhere else, and one
183
+ // path disagreeing with the contract is worse than a slightly lossy
184
+ // value: the HTTP status is still on `status` for anyone who wants it.
185
+ return { ok: fresh && respOk, status: respStatus, aborted: !fresh, applied: fresh };
186
+ }
187
+ // Server-side redirect (PRG, auth-gate, etc.): fetch followed it
188
+ // automatically. Record the FINAL URL in history, not the
189
+ // originally-requested one, so back/forward + bookmarking work.
190
+ if (resp.redirected && resp.url) finalUrl = resp.url;
191
+
192
+ // Empty-body status codes (204 No Content, 205 Reset Content):
193
+ // server-rendered "stay on current page" pattern. Don't try to
194
+ // swap an empty document over the live one. We DO still record
195
+ // history for the originating URL: same as a normal navigation
196
+ // that decided to short-circuit.
197
+ if (resp.status === 204 || resp.status === 205) {
198
+ if (myToken === currentNavigationToken && recordHistory) {
199
+ history.pushState(null, '', finalUrl);
200
+ }
201
+ return { ok: respOk, status: respStatus, aborted: false, applied: false };
202
+ }
203
+
204
+ // Non-HTML response (JSON error, file download, opaque): can't be
205
+ // rendered as a page (a 500 returning `{"error": "..."}` is not an
206
+ // HTML page). Instead of abandoning the SPA with a full reload (which
207
+ // discards the partial-swap shell, scroll, and in-flight state, and
208
+ // eats a second round-trip that may itself fail), dispatch a
209
+ // cancelable `webjs:navigation-error` so the app can recover in place;
210
+ // by default render a minimal in-place error surface. The adjacent
211
+ // HTML-status branch below already renders 4xx/5xx HTML bodies in
212
+ // place; this closes the same gap for a non-HTML error body.
213
+ if (!isHTML) {
214
+ if (myToken === currentNavigationToken) {
215
+ // Roll back any optimistic loading skeleton FIRST, so a
216
+ // preventDefault()-ing app sees the page exactly as it was (the catch
217
+ // block below does the same for a transport failure).
218
+ restoreOptimistic(optimisticState);
219
+ handleNavigationError(href, resp.status, null);
220
+ }
221
+ return { ok: false, status: respStatus, aborted: false, applied: false };
222
+ }
223
+
224
+ // HTML body of ANY status: 2xx, 4xx validation errors, 5xx error
225
+ // pages: is parsed and applied in place. Matches Turbo Drive's
226
+ // `formSubmissionFailedWithResponse` behavior
227
+ // (turbo/src/core/drive/navigator.js:92-107). Critical for the
228
+ // standard server-rendered validation pattern: 422 + re-rendered
229
+ // form with errors keeps the user's typed input and shows context.
230
+ // Capture the server's build hash header BEFORE reading the body.
231
+ // The header is set on every SSR response, including X-Webjs-Have
232
+ // partial responses where the body has no head and no importmap
233
+ // tag to compare. The applySwap importmap-mismatch guard reads
234
+ // this to detect deploys that bumped the vendor pin.
235
+ incomingBuild = resp.headers.get('x-webjs-build');
236
+ incomingSrc = resp.headers.get('x-webjs-src');
237
+ // Progressive streaming (#473): read only up to the first streamed Suspense
238
+ // boundary so the shell (with fallbacks) swaps in immediately; the rest
239
+ // streams in after the swap. A body with no boundaries reads to completion,
240
+ // so a non-streaming nav is identical to the old `resp.text()`.
241
+ const shellRead = await readStreamedShell(resp);
242
+ html = shellRead.shell;
243
+ if (shellRead.streaming) streamCtx = shellRead;
244
+ }
245
+ } catch (err) {
246
+ // Aborted by a newer navigation: let it run, don't fall back. An
247
+ // AbortError is a normal supersede, NOT a navigation error, so it must
248
+ // NEVER dispatch webjs:navigation-error (the key no-false-positive
249
+ // line).
250
+ if (err && /** @type any */ (err).name === 'AbortError') return { ok: false, status: null, aborted: true, applied: false };
251
+ // Stale (a newer nav started before we got the network error): the
252
+ // newer nav owns the page now, so don't clobber it.
253
+ if (myToken !== currentNavigationToken) return { ok: false, status: null, aborted: true, applied: false };
254
+ restoreOptimistic(optimisticState);
255
+ // Transport/parse failure (fetch rejected, e.g. offline / DNS / TLS).
256
+ // Surface a navigation-error so the app can recover in place instead
257
+ // of a destructive full reload.
258
+ handleNavigationError(href, null, err instanceof Error ? err : new Error(String(err)));
259
+ return { ok: false, status: null, aborted: false, applied: false };
260
+ }
261
+
262
+ // A newer navigation started while we awaited the response body -
263
+ // bail before we overwrite its work.
264
+ if (myToken !== currentNavigationToken) {
265
+ if (streamCtx && streamCtx.reader) { try { streamCtx.reader.cancel(); } catch { /* ignore */ } }
266
+ return { ok: false, status: respStatus, aborted: true, applied: false };
267
+ }
268
+
269
+ const doc = parseHTML(html);
270
+ // The body claimed text/html but didn't parse into a document (a
271
+ // malformed/empty HTML body). Surface a navigation-error so the app can
272
+ // recover in place rather than a destructive full reload.
273
+ if (!doc) { restoreOptimistic(optimisticState); handleNavigationError(href, null, new Error('navigation response did not parse as HTML')); return { ok: false, status: respStatus, aborted: false, applied: false }; }
274
+
275
+ // #1406: the history entry this navigation records is what the iOS back-swipe
276
+ // gesture later previews, and WebKit binds a same-document entry's snapshot to
277
+ // the page state at the moment the entry is recorded. Recording it after the
278
+ // swap finalizes it against the DESTINATION document at a scroll offset the
279
+ // browser has already clamped to that document's height (measured on the
280
+ // gallery: 1600 to 252), so the gesture previews a page that never existed and
281
+ // renders blank. So the push rides into `applySwap` as a COMMIT-time callback
282
+ // and fires ahead of the mutation, which is Turbo Drive's ordering
283
+ // (`PageView.renderPage` calls `visit.changeHistory()` ahead of
284
+ // `this.render(renderer)`).
285
+ //
286
+ // One route class gets less than the full benefit: where the innermost
287
+ // boundary carrying a `loading.{js,ts}` template is in the live chain,
288
+ // `applyOptimisticLoading` replaced that range with the skeleton before this
289
+ // function ever ran, so the entry is recorded against the shell plus a
290
+ // skeleton rather than the outgoing page. Better than the destination
291
+ // document, still not the page the reader left. See the `recordHistoryNow`
292
+ // JSDoc in `swap.js` for the full statement of what is and is not claimed.
293
+ //
294
+ // One-shot, exactly like Turbo's `historyChanged` guard, so calling it here
295
+ // AND on the fall-through below is safe. The fall-through is required, not
296
+ // belt-and-braces: `applySwap` can return `'none'` after hard-navigating or
297
+ // after a `webjs:frame-missing` dispatch, and those paths record history today
298
+ // (see the `applied` comment below). Keeping the tail call preserves them byte
299
+ // for byte, so this commit changes the ORDER on committing swaps and nothing
300
+ // else.
301
+ let historyRecorded = false;
302
+ const recordHistoryNow = recordHistory
303
+ ? () => {
304
+ if (historyRecorded) return;
305
+ historyRecorded = true;
306
+ history.pushState(null, '', finalUrl);
307
+ }
308
+ : null;
309
+
310
+ const disposition = applySwap(doc, frameId, !!revalidating, finalUrl, incomingBuild, incomingSrc, refresh, recordHistoryNow);
311
+ // `'none'` means applySwap returned WITHOUT committing anything: the frame the
312
+ // response was for is missing, or it degraded to a hard navigation (an
313
+ // importmap/build mismatch, a poisoned boundary scan). The page is not left
314
+ // in a bad state either way, but nothing was applied IN PLACE, and `applied`
315
+ // has to say so or it repeats the hole it exists to close.
316
+ //
317
+ // Recorded as a FLAG rather than returned early on purpose. These paths used
318
+ // to fall through to the history push, the scroll block, and the streaming
319
+ // tail, and an early return would silently change all three (a click-driven
320
+ // frame nav records history, so a frame-missing response would stop advancing
321
+ // the URL). This commit is about what the outcome REPORTS, not about what the
322
+ // pipeline does, so the fall-through is left exactly as it was.
323
+ const applied = disposition !== 'none';
324
+ // A discarded revalidation must be discarded OUTRIGHT: a streamed response's
325
+ // boundary templates must not splice into the restored snapshot afterward
326
+ // (boundary ids are per-render sequential, so a reduced render's numbering
327
+ // need not line up with the snapshot's). Cancel the reader and stop here.
328
+ if (disposition === 'discard') {
329
+ if (streamCtx && streamCtx.reader) { try { streamCtx.reader.cancel(); } catch { /* ignore */ } }
330
+ return { ok: respOk, status: respStatus, aborted: false, applied: false };
331
+ }
332
+
333
+ if (recordHistoryNow) recordHistoryNow();
334
+
335
+ // Scroll only for foreground (history-recording) navigations. When
336
+ // `recordHistory` is false we're either:
337
+ // (a) the background revalidation after a cached popstate restore
338
+ // - performNavigation already set scroll from the cached
339
+ // position; we must NOT clobber it here.
340
+ // (b) a cache-miss popstate: modern browsers fire scroll-
341
+ // restoration themselves before dispatching popstate, so
342
+ // leaving scroll alone preserves the browser-native UX.
343
+ if (recordHistory) {
344
+ // Use the final URL (after any server-side redirect) so hash
345
+ // anchors point at the document we actually rendered.
346
+ const url = new URL(finalUrl);
347
+ if (url.hash) {
348
+ const t = document.getElementById(url.hash.slice(1));
349
+ // A hash anchor is the one nav scroll we DON'T force instant: a
350
+ // `#section` link is exactly where an app's `scroll-behavior: smooth`
351
+ // is wanted, and native browsers animate it too.
352
+ if (t) t.scrollIntoView();
353
+ else { warnIfSmoothScrollOnHtml(); window.scrollTo({ left: 0, top: 0, behavior: 'instant' }); }
354
+ } else {
355
+ // Scroll-to-top on a forward nav. behavior:'instant' so an app-level
356
+ // `scroll-behavior: smooth` does not animate it (match native nav).
357
+ warnIfSmoothScrollOnHtml();
358
+ window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
359
+ }
360
+ }
361
+
362
+ // Progressive streaming (#473): the shell (with its Suspense fallbacks) is
363
+ // now live, so stream the resolved boundaries in fast-before-slow. Detached
364
+ // (fire-and-forget) so the URL advance + navigate event do not wait on the
365
+ // slow boundary; each apply is guarded by the nav token so a newer navigation
366
+ // stops it. Gated on the swap COMMIT (`_swapCommit`): under an async view
367
+ // transition the shell swap is deferred a frame, so applying a resolve before
368
+ // the placeholder is in the DOM dropped the boundary and stuck the skeleton
369
+ // (#1048). On the synchronous path `_swapCommit` is already resolved, so this
370
+ // is a same-microtask no-op there.
371
+ if (streamCtx && (streamCtx.reader || streamCtx.rest)) {
372
+ _swapCommit.then(() => streamBoundariesProgressively(
373
+ streamCtx.reader,
374
+ streamCtx.dec,
375
+ streamCtx.rest,
376
+ () => myToken === currentNavigationToken,
377
+ ));
378
+ }
379
+
380
+ document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId, from: 'navigate' } }));
381
+ return { ok: respOk, status: respStatus, aborted: false, applied };
382
+ } finally {
383
+ // Clear the frame's busy state on every exit path (the early returns
384
+ // above all unwind through here). No-op when this was not a frame nav.
385
+ if (busyFrame) clearFrameBusy(busyFrame, myToken);
386
+ }
387
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Method resolution: submitter's `formmethod` wins over form's `method`.
3
+ * Returns lowercase.
4
+ *
5
+ * Resolved on PRESENCE, not truthiness (#1322), the same way `getSubmitAction`
6
+ * below resolves the action. The form-submission algorithm reads the
7
+ * submitter's `formmethod` if the submitter HAS one, so a present-but-empty
8
+ * `formmethod=""` overrides the form and then falls to its own invalid-value
9
+ * default, GET. Resolving with `||` instead sent a multipart POST for a button
10
+ * every engine submits as `GET`, which is a JS-on versus JS-off divergence.
11
+ *
12
+ * @param {HTMLFormElement} form
13
+ * @param {HTMLElement | null} submitter
14
+ */
15
+ export function getSubmitMethod(form, submitter) {
16
+ const v = (submitter && submitter.hasAttribute('formmethod'))
17
+ ? (submitter.getAttribute('formmethod') || '')
18
+ : (form.getAttribute('method') || '');
19
+ return (v || 'get').toLowerCase();
20
+ }
21
+
22
+ /**
23
+ * Action resolution: submitter's `formaction` wins over form's `action`.
24
+ * Empty string is valid (means submit-to-current-url).
25
+ *
26
+ * @param {HTMLFormElement} form
27
+ * @param {HTMLElement | null} submitter
28
+ */
29
+ export function getSubmitAction(form, submitter) {
30
+ if (submitter && submitter.hasAttribute('formaction')) {
31
+ return submitter.getAttribute('formaction') || '';
32
+ }
33
+ return form.getAttribute('action') || form.action || location.href;
34
+ }
35
+
36
+ /**
37
+ * The three `enctype` keywords, plus the normalization a browser applies.
38
+ *
39
+ * Both the missing-value AND the invalid-value default of the `enctype`
40
+ * enumerated attribute are `application/x-www-form-urlencoded`, so
41
+ * `enctype="nonsense"` really does mean urlencoded and has to be sent as such.
42
+ * Only an exact, ASCII-case-insensitive match on one of the other two keywords
43
+ * means anything else.
44
+ *
45
+ * @param {string | null | undefined} raw
46
+ * @returns {'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'}
47
+ */
48
+ function normalizeEnctype(raw) {
49
+ // Compared UNTRIMMED, the same rule `assertSubmittableForm` applies in
50
+ // `form-action.js`. An enumerated attribute is matched against exact
51
+ // keywords with no whitespace stripping, so `enctype=" multipart/form-data "`
52
+ // falls to the invalid-value default and a BROWSER sends urlencoded for it.
53
+ // Trimming here would send multipart, so the router would disagree with the
54
+ // no-JS path on exactly the shape this function exists to keep in step.
55
+ const v = String(raw || '').toLowerCase();
56
+ if (v === 'multipart/form-data') return 'multipart/form-data';
57
+ if (v === 'text/plain') return 'text/plain';
58
+ return 'application/x-www-form-urlencoded';
59
+ }
60
+
61
+ /**
62
+ * Enctype resolution: the submitter's `formenctype` wins over the form's
63
+ * `enctype`, exactly as `getSubmitMethod` resolves the method (#1307). Turbo
64
+ * resolves it the same way, in `core/drive/form_submission.js`.
65
+ *
66
+ * On PRESENCE, not truthiness (#1322), for the reason spelled out on
67
+ * `getSubmitMethod`: a present-but-empty `formenctype=""` overrides the form
68
+ * and normalizes to urlencoded, its own invalid-value default, so a button on
69
+ * a multipart form sends urlencoded with JS on exactly as it does with JS off.
70
+ *
71
+ * @param {HTMLFormElement} form
72
+ * @param {HTMLElement | null} submitter
73
+ */
74
+ export function getSubmitEnctype(form, submitter) {
75
+ return normalizeEnctype(
76
+ (submitter && submitter.hasAttribute('formenctype'))
77
+ ? submitter.getAttribute('formenctype')
78
+ : form.getAttribute('enctype'),
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Encode a submission body the way the DECLARED enctype says to (#1307).
84
+ *
85
+ * The router used to build a `FormData` and send it with no explicit content
86
+ * type, so `fetch` always derived `multipart/form-data` and the authored
87
+ * `enctype` was never read at all. An author writing
88
+ * `enctype="application/x-www-form-urlencoded"`, which is also the HTML
89
+ * DEFAULT and therefore what a plain `<form method="post">` means, got
90
+ * urlencoded with JS off and multipart with JS on. Same form, two different
91
+ * request bodies, which is exactly what progressive enhancement rules out.
92
+ *
93
+ * A `File` entry serializes as its NAME under urlencoded, which is what the
94
+ * platform's own urlencoded serializer does. (Turbo drops file entries here
95
+ * entirely, in `http/fetch_request.js`, which loses a field the no-JS path
96
+ * sends.)
97
+ *
98
+ * A bound form is unaffected: it carries an explicit
99
+ * `enctype="multipart/form-data"`, and since #1307 a bound submitter carries
100
+ * `formenctype="multipart/form-data"`, so both resolve to multipart as before.
101
+ *
102
+ * @param {FormData} formData
103
+ * @param {'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'} enctype
104
+ * @returns {FormData | URLSearchParams}
105
+ */
106
+ export function encodeSubmitBody(formData, enctype) {
107
+ if (enctype === 'multipart/form-data') return formData;
108
+ const params = new URLSearchParams();
109
+ for (const [k, v] of formData) params.append(k, typeof v === 'string' ? v : v.name);
110
+ return params;
111
+ }
112
+
113
+ /**
114
+ * Build FormData honoring the submitter's name=value (per HTML5 form
115
+ * submission algorithm). Modern browsers + the `FormData(form, submitter)`
116
+ * ctor handle this automatically; older Safari needs a manual append.
117
+ *
118
+ * @param {HTMLFormElement} form
119
+ * @param {HTMLElement | null} submitter
120
+ * @returns {FormData}
121
+ */
122
+ export function buildSubmitFormData(form, submitter) {
123
+ try {
124
+ return new FormData(form, /** @type any */ (submitter || undefined));
125
+ } catch {
126
+ const fd = new FormData(form);
127
+ if (submitter && submitter.getAttribute('name')) {
128
+ fd.append(
129
+ /** @type {string} */ (submitter.getAttribute('name')),
130
+ submitter.getAttribute('value') || '',
131
+ );
132
+ }
133
+ return fd;
134
+ }
135
+ }