@webjsdev/core 0.7.50 → 0.7.53

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 (64) 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 +3 -3
  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/css.d.ts +9 -0
  16. package/src/escape.d.ts +2 -0
  17. package/src/html.d.ts +9 -0
  18. package/src/render-client/parts.js +2039 -0
  19. package/src/render-client/reconciler.js +388 -0
  20. package/src/render-client/template-compiler.js +525 -0
  21. package/src/render-client.js +1 -2938
  22. package/src/render-server/attrs.js +160 -0
  23. package/src/render-server/dsd.js +445 -0
  24. package/src/render-server/html-scan.js +402 -0
  25. package/src/render-server/slots.js +213 -0
  26. package/src/render-server/stream.js +163 -0
  27. package/src/render-server/template-renderer.js +938 -0
  28. package/src/render-server/text.js +163 -0
  29. package/src/render-server.js +6 -2405
  30. package/src/repeat.d.ts +15 -0
  31. package/src/rich-fetch.d.ts +4 -0
  32. package/src/router-client/anchors.js +44 -0
  33. package/src/router-client/boundaries.js +221 -0
  34. package/src/router-client/constants.js +206 -0
  35. package/src/router-client/diagnostics.js +377 -0
  36. package/src/router-client/dom-differ.js +709 -0
  37. package/src/router-client/dom-parse.js +150 -0
  38. package/src/router-client/events.js +182 -0
  39. package/src/router-client/fetch-apply.js +432 -0
  40. package/src/router-client/form-encoder.js +135 -0
  41. package/src/router-client/frames.js +329 -0
  42. package/src/router-client/head-merge.js +344 -0
  43. package/src/router-client/nav-error.js +105 -0
  44. package/src/router-client/navigator.js +952 -0
  45. package/src/router-client/prefetch.js +831 -0
  46. package/src/router-client/scroll.js +308 -0
  47. package/src/router-client/snapshot-cache.js +113 -0
  48. package/src/router-client/state.js +155 -0
  49. package/src/router-client/stream.js +206 -0
  50. package/src/router-client/swap.js +454 -0
  51. package/src/router-client/upgrade.js +261 -0
  52. package/src/router-client/view-transition.js +223 -0
  53. package/src/router-client.d.ts +11 -2
  54. package/src/router-client.js +78 -5345
  55. package/src/slot/assignment.js +304 -0
  56. package/src/slot/interception.js +558 -0
  57. package/src/slot/polyfills.js +365 -0
  58. package/src/slot/project.js +432 -0
  59. package/src/slot/sensors.js +303 -0
  60. package/src/slot/state.js +245 -0
  61. package/src/slot/symbols.js +85 -0
  62. package/src/slot.js +7 -2199
  63. package/src/suspense.d.ts +9 -0
  64. package/src/websocket-client.d.ts +22 -0
@@ -0,0 +1,432 @@
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 {{ refresh?: 'page' | 'shell', noPrefetch?: boolean, preserveScroll?: boolean }} [opts]
39
+ * Per-navigation POLICY, as a bag rather than three more positionals. The
40
+ * nine parameters above are request inputs; these three decide what the
41
+ * pipeline does with the response, and the list was already at eleven.
42
+ *
43
+ * `refresh` is a same-URL in-place refresh (#1398). It suppresses the
44
+ * `X-Webjs-Have` header and picks the swap tier; see `refreshPage`.
45
+ *
46
+ * `noPrefetch` never consumes a speculative entry, whatever the cache holds
47
+ * (#1407). Set by `loadFrame`: a `<webjs-frame src>` self-load or `src`
48
+ * mutation asks for THIS frame's content now, which is a freshness request
49
+ * rather than the click-follows-hover shape the warm cache serves.
50
+ *
51
+ * `preserveScroll` keeps the reader's current offset instead of scrolling to
52
+ * top on a forward navigation (#1436), from `data-preserve-scroll` on the
53
+ * link or form, or from `navigate(url, { scroll: false })`. It suppresses only
54
+ * the scroll-to-top writes, never the hash-anchor scroll.
55
+ * @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean }>}
56
+ * The fetch outcome, so a caller (the form-submission busy/event lifecycle)
57
+ * can report whether the submission settled as a success, an error, or an
58
+ * abort. `ok` mirrors `response.ok` for an HTTP response (a 422 validation
59
+ * re-render is `ok:false`), `false` for a transport/parse error, and `false`
60
+ * for an abort (which also sets `aborted:true`). `status` is the HTTP status
61
+ * or `null` when the request never produced one.
62
+ *
63
+ * `applied` is a DIFFERENT question from `ok` and the two must not be
64
+ * conflated (#1398). An HTML body of ANY status is parsed and swapped in
65
+ * place, which is the whole point of the 422-revalidation and error-boundary
66
+ * behaviour, so a page rendered through `notFound()`, `forbidden()`, or an
67
+ * `error.ts` boundary has `ok:false` and `applied:true`. It is `false`
68
+ * wherever no swap committed: a transport failure, a non-HTML body, an
69
+ * unparseable one, a 204/205, a discarded revalidation, an abort, and every
70
+ * `applySwap` path that returns without committing (a missing frame, or a
71
+ * degradation to a hard navigation from an importmap mismatch or a poisoned
72
+ * boundary scan). A caller deciding whether to fall back to a full page load
73
+ * wants `applied`; one reporting the submission's success wants `ok`.
74
+ */
75
+ export async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token, revalidating, opts) {
76
+ method = method || 'GET';
77
+ const refresh = (opts && opts.refresh) || undefined;
78
+ const noPrefetch = !!(opts && opts.noPrefetch);
79
+ const preserveScroll = !!(opts && opts.preserveScroll);
80
+ const myToken = typeof token === 'number' ? token : currentNavigationToken;
81
+ let html;
82
+ // Set when the response streams Suspense boundaries (#473): holds the open
83
+ // reader + leftover buffer so the boundaries apply progressively after the
84
+ // shell swap. Null for a buffered (non-streaming) or prefetched response.
85
+ let streamCtx = null;
86
+ let incomingBuild = null;
87
+ let incomingSrc = null;
88
+ /** @type {number | null} */
89
+ let respStatus = null;
90
+ /** @type {boolean} */
91
+ let respOk = false;
92
+ /** @type {string} */
93
+ let finalUrl = href;
94
+ // aria-busy lifecycle: when this nav targets a <webjs-frame>, mark the
95
+ // live frame busy for the duration of its fetch+apply so assistive tech
96
+ // can announce it and CSS can style `webjs-frame[aria-busy="true"]`. The
97
+ // outer try/finally guarantees the busy state is cleared on EVERY exit
98
+ // (success swap, frame-missing, an HTTP/transport error, an abort by a
99
+ // newer nav), never leaving a frame stuck busy.
100
+ const busyFrame = frameId ? markFrameBusy(frameId, myToken) : null;
101
+ try {
102
+ try {
103
+ // Warm-cache fast path: a hover/focus/viewport prefetch may already hold
104
+ // this page. Consume it instead of going to the network, so the click
105
+ // resolves with no round-trip. Only for GET navs carrying no body. A
106
+ // MUTATING form submission is a write and always hits the server; a SAFE
107
+ // (GET) submission is a read like a link click, so it consumes like one,
108
+ // including in a frame dimension. A FRAME nav consumes only an entry
109
+ // fetched under the SAME frame id (#1407): the key carries that dimension,
110
+ // so a page fragment can never be applied into a frame region, nor a frame
111
+ // subtree into a page swap, and a `<webjs-frame src>` SELF-load opts out
112
+ // entirely via `noPrefetch`, since it is asking for fresh content rather
113
+ // than following a hover. The entry
114
+ // is single-use (prefetchTake removes it) and TTL-guarded. A PAGE entry is
115
+ // then validated by its ANCHOR rather than by an identical X-Webjs-Have
116
+ // (#1114): a fragment applies wherever the boundary it starts at is still
117
+ // live, so an unrelated navigation between the prefetch and this click does
118
+ // not disqualify it. A FRAME entry is validated differently, by its
119
+ // `<webjs-frame id>` still being in the document, since a subtree carries no
120
+ // boundary comment to anchor on (#1407).
121
+ // The optimistic skeleton has already deleted nested boundaries by now, so
122
+ // pass the view captured before it ran.
123
+ // A refresh must never consume a prefetch (#1398): every cached copy
124
+ // predates the change that triggered it. `refreshPage` clears both caches
125
+ // before it fetches, so this only closes the window where a prefetch lands
126
+ // between that clear and this read.
127
+ const prefetched = (method === 'GET' && !body && !refresh && !noPrefetch)
128
+ ? prefetchTake(href, optimisticState ? optimisticState.haveKeys : undefined, frameId)
129
+ : null;
130
+ if (prefetched) {
131
+ html = prefetched.html;
132
+ incomingBuild = prefetched.build;
133
+ incomingSrc = prefetched.src;
134
+ finalUrl = prefetched.finalUrl;
135
+ // A consumed prefetch is a successful 200 GET fragment.
136
+ respStatus = 200;
137
+ respOk = true;
138
+ } else {
139
+ const headers = { 'x-webjs-router': '1' };
140
+ // A same-URL refresh sends NO have-header (#1398). This is required, not an
141
+ // optimisation: the server short-circuits at the first layout whose segment
142
+ // path AND route key the client already holds, and a same-URL request
143
+ // matches every one of them, so the response would omit the layouts and a
144
+ // layout edit would be invisible. Sending nothing forces the full chain to
145
+ // render.
146
+ const have = refresh ? '' : buildHaveHeader();
147
+ if (have) headers['x-webjs-have'] = have;
148
+ if (frameId) headers['x-webjs-frame'] = frameId;
149
+ // Content-negotiate a stream-action response on a write submission (a
150
+ // non-GET body). The server returns the stream MIME only when this Accept
151
+ // is present, so with JS off (no router, no Accept) the same form gets a
152
+ // normal render/redirect: the grammar is additive and PE-safe (#248).
153
+ if (body != null && method !== 'GET' && method !== 'HEAD') {
154
+ headers['accept'] = STREAM_MIME + ', text/html';
155
+ }
156
+
157
+ /** @type {RequestInit} */
158
+ // `no-cache` for the same reason as the prefetch fetch (#1131): applySwap
159
+ // hard-reloads on a build change it can only see if these headers are
160
+ // live, not replayed from the HTTP cache.
161
+ const init = { method, headers, credentials: 'same-origin', cache: 'no-cache' };
162
+ if (signal) init.signal = signal;
163
+ if (body != null && method !== 'GET' && method !== 'HEAD') init.body = body;
164
+
165
+ const resp = await fetch(href, init);
166
+ respStatus = resp.status;
167
+ respOk = resp.ok;
168
+ // `fetch` follows a 303 transparently and its headers are then unreadable,
169
+ // so this lands on a non-redirect response: the 422 failure re-render, or
170
+ // any form response that answers in place.
171
+ const invalidated = parseTagHeader(resp.headers.get('x-webjs-invalidate'));
172
+ if (invalidated.length) markStale(invalidated);
173
+ const ctype = resp.headers.get('content-type') || '';
174
+ const isHTML = /^text\/html\b/i.test(ctype);
175
+ const isStream = ctype.toLowerCase().indexOf(STREAM_MIME) === 0;
176
+ // Stream-action response (#248): the body is `<webjs-stream>` elements
177
+ // applied surgically to the live DOM, NOT a region swap. Apply them and
178
+ // return; do not parse the body as a page document (it has no shell). A
179
+ // stream body of any status is fine. This runs BEFORE the !isHTML branch
180
+ // so the non-text/html stream MIME is not treated as a navigation error.
181
+ if (isStream) {
182
+ const text = await resp.text();
183
+ // A newer navigation owns the page, so nothing is applied and `applied`
184
+ // has to say so: it reports whether this response reached the DOM, and a
185
+ // superseded one did not.
186
+ const fresh = myToken === currentNavigationToken;
187
+ if (fresh) {
188
+ // Roll back any optimistic loading skeleton: a stream response patches
189
+ // the page in place, it does not swap the region the skeleton covered.
190
+ restoreOptimistic(optimisticState);
191
+ renderStream(text);
192
+ }
193
+ // `ok` is forced false alongside `aborted`, matching every other abort
194
+ // return in this function. A caller reading `ok` as "this response was
195
+ // not superseded" would otherwise be wrong here and nowhere else, and one
196
+ // path disagreeing with the contract is worse than a slightly lossy
197
+ // value: the HTTP status is still on `status` for anyone who wants it.
198
+ return { ok: fresh && respOk, status: respStatus, aborted: !fresh, applied: fresh };
199
+ }
200
+ // Server-side redirect (PRG, auth-gate, etc.): fetch followed it
201
+ // automatically. Record the FINAL URL in history, not the
202
+ // originally-requested one, so back/forward + bookmarking work.
203
+ if (resp.redirected && resp.url) finalUrl = resp.url;
204
+
205
+ // Empty-body status codes (204 No Content, 205 Reset Content):
206
+ // server-rendered "stay on current page" pattern. Don't try to
207
+ // swap an empty document over the live one. We DO still record
208
+ // history for the originating URL: same as a normal navigation
209
+ // that decided to short-circuit.
210
+ if (resp.status === 204 || resp.status === 205) {
211
+ if (myToken === currentNavigationToken && recordHistory) {
212
+ history.pushState(null, '', finalUrl);
213
+ }
214
+ return { ok: respOk, status: respStatus, aborted: false, applied: false };
215
+ }
216
+
217
+ // Non-HTML response (JSON error, file download, opaque): can't be
218
+ // rendered as a page (a 500 returning `{"error": "..."}` is not an
219
+ // HTML page). Instead of abandoning the SPA with a full reload (which
220
+ // discards the partial-swap shell, scroll, and in-flight state, and
221
+ // eats a second round-trip that may itself fail), dispatch a
222
+ // cancelable `webjs:navigation-error` so the app can recover in place;
223
+ // by default render a minimal in-place error surface. The adjacent
224
+ // HTML-status branch below already renders 4xx/5xx HTML bodies in
225
+ // place; this closes the same gap for a non-HTML error body.
226
+ if (!isHTML) {
227
+ if (myToken === currentNavigationToken) {
228
+ // Roll back any optimistic loading skeleton FIRST, so a
229
+ // preventDefault()-ing app sees the page exactly as it was (the catch
230
+ // block below does the same for a transport failure).
231
+ restoreOptimistic(optimisticState);
232
+ handleNavigationError(href, resp.status, null);
233
+ }
234
+ return { ok: false, status: respStatus, aborted: false, applied: false };
235
+ }
236
+
237
+ // HTML body of ANY status: 2xx, 4xx validation errors, 5xx error
238
+ // pages: is parsed and applied in place. Matches Turbo Drive's
239
+ // `formSubmissionFailedWithResponse` behavior
240
+ // (turbo/src/core/drive/navigator.js:92-107). Critical for the
241
+ // standard server-rendered validation pattern: 422 + re-rendered
242
+ // form with errors keeps the user's typed input and shows context.
243
+ // Capture the server's build hash header BEFORE reading the body.
244
+ // The header is set on every SSR response, including X-Webjs-Have
245
+ // partial responses where the body has no head and no importmap
246
+ // tag to compare. The applySwap importmap-mismatch guard reads
247
+ // this to detect deploys that bumped the vendor pin.
248
+ incomingBuild = resp.headers.get('x-webjs-build');
249
+ incomingSrc = resp.headers.get('x-webjs-src');
250
+ // Progressive streaming (#473): read only up to the first streamed Suspense
251
+ // boundary so the shell (with fallbacks) swaps in immediately; the rest
252
+ // streams in after the swap. A body with no boundaries reads to completion,
253
+ // so a non-streaming nav is identical to the old `resp.text()`.
254
+ const shellRead = await readStreamedShell(resp);
255
+ html = shellRead.shell;
256
+ if (shellRead.streaming) streamCtx = shellRead;
257
+ }
258
+ } catch (err) {
259
+ // Aborted by a newer navigation: let it run, don't fall back. An
260
+ // AbortError is a normal supersede, NOT a navigation error, so it must
261
+ // NEVER dispatch webjs:navigation-error (the key no-false-positive
262
+ // line).
263
+ if (err && /** @type any */ (err).name === 'AbortError') return { ok: false, status: null, aborted: true, applied: false };
264
+ // Stale (a newer nav started before we got the network error): the
265
+ // newer nav owns the page now, so don't clobber it.
266
+ if (myToken !== currentNavigationToken) return { ok: false, status: null, aborted: true, applied: false };
267
+ restoreOptimistic(optimisticState);
268
+ // Transport/parse failure (fetch rejected, e.g. offline / DNS / TLS).
269
+ // Surface a navigation-error so the app can recover in place instead
270
+ // of a destructive full reload.
271
+ handleNavigationError(href, null, err instanceof Error ? err : new Error(String(err)));
272
+ return { ok: false, status: null, aborted: false, applied: false };
273
+ }
274
+
275
+ // A newer navigation started while we awaited the response body -
276
+ // bail before we overwrite its work.
277
+ if (myToken !== currentNavigationToken) {
278
+ if (streamCtx && streamCtx.reader) { try { streamCtx.reader.cancel(); } catch { /* ignore */ } }
279
+ return { ok: false, status: respStatus, aborted: true, applied: false };
280
+ }
281
+
282
+ const doc = parseHTML(html);
283
+ // The body claimed text/html but didn't parse into a document (a
284
+ // malformed/empty HTML body). Surface a navigation-error so the app can
285
+ // recover in place rather than a destructive full reload.
286
+ 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 }; }
287
+
288
+ // #1406: recording the push after the swap finalizes the outgoing page's
289
+ // entry against the DESTINATION document, at a scroll offset the browser has
290
+ // already clamped to that document's height (measured on the gallery: 1600 to
291
+ // 252). Ordering it ahead of the mutation keeps the entry tied to the page it
292
+ // belongs to.
293
+ //
294
+ // #1406 also claimed this fixed the blank iOS back-swipe preview, via WebKit
295
+ // binding the gesture snapshot at the moment the entry is recorded. That is
296
+ // FALSE (#1428, on-device): Turbo Drive uses this same ordering and previews
297
+ // blank too, and the cause was `history.scrollRestoration = 'manual'`
298
+ // suppressing per-entry scroll recording. The ordering is kept on its own
299
+ // merits. So the push rides into `applySwap` as a COMMIT-time callback
300
+ // and fires ahead of the mutation, which is Turbo Drive's ordering
301
+ // (`PageView.renderPage` calls `visit.changeHistory()` ahead of
302
+ // `this.render(renderer)`).
303
+ //
304
+ // One route class gets less than the full benefit: where the innermost
305
+ // boundary carrying a `loading.{js,ts}` template is in the live chain,
306
+ // `applyOptimisticLoading` replaced that range with the skeleton before this
307
+ // function ever ran, so the entry is recorded against the shell plus a
308
+ // skeleton rather than the outgoing page. Better than the destination
309
+ // document, still not the page the reader left. See the `recordHistoryNow`
310
+ // JSDoc in `swap.js` for the full statement of what is and is not claimed.
311
+ //
312
+ // One-shot, exactly like Turbo's `historyChanged` guard, so calling it here
313
+ // AND on the fall-through below is safe. The fall-through is required, not
314
+ // belt-and-braces: `applySwap` can return `'none'` after hard-navigating or
315
+ // after a `webjs:frame-missing` dispatch, and those paths record history today
316
+ // (see the `applied` comment below). Keeping the tail call preserves them byte
317
+ // for byte, so this commit changes the ORDER on committing swaps and nothing
318
+ // else.
319
+ let historyRecorded = false;
320
+ const recordHistoryNow = recordHistory
321
+ ? () => {
322
+ if (historyRecorded) return;
323
+ historyRecorded = true;
324
+ history.pushState(null, '', finalUrl);
325
+ }
326
+ : null;
327
+
328
+ const disposition = applySwap(doc, frameId, !!revalidating, finalUrl, incomingBuild, incomingSrc, refresh, recordHistoryNow);
329
+ // `'none'` means applySwap returned WITHOUT committing anything: the frame the
330
+ // response was for is missing, or it degraded to a hard navigation (an
331
+ // importmap/build mismatch, a poisoned boundary scan). The page is not left
332
+ // in a bad state either way, but nothing was applied IN PLACE, and `applied`
333
+ // has to say so or it repeats the hole it exists to close.
334
+ //
335
+ // Recorded as a FLAG rather than returned early on purpose. These paths used
336
+ // to fall through to the history push, the scroll block, and the streaming
337
+ // tail, and an early return would silently change all three (a click-driven
338
+ // frame nav records history, so a frame-missing response would stop advancing
339
+ // the URL). This commit is about what the outcome REPORTS, not about what the
340
+ // pipeline does, so the fall-through is left exactly as it was.
341
+ const applied = disposition !== 'none';
342
+ // A discarded revalidation must be discarded OUTRIGHT: a streamed response's
343
+ // boundary templates must not splice into the restored snapshot afterward
344
+ // (boundary ids are per-render sequential, so a reduced render's numbering
345
+ // need not line up with the snapshot's). Cancel the reader and stop here.
346
+ if (disposition === 'discard') {
347
+ if (streamCtx && streamCtx.reader) { try { streamCtx.reader.cancel(); } catch { /* ignore */ } }
348
+ return { ok: respOk, status: respStatus, aborted: false, applied: false };
349
+ }
350
+
351
+ if (recordHistoryNow) recordHistoryNow();
352
+
353
+ // Scroll only for foreground (history-recording) navigations. When
354
+ // `recordHistory` is false we're either:
355
+ // (a) the background revalidation after a cached popstate restore
356
+ // - the restore is already settled (the browser replayed the
357
+ // offset and the restore window is guarding it); a write here
358
+ // would land on top of it.
359
+ // (b) a cache-miss popstate: the browser performs its own restore
360
+ // for the entry, so leaving scroll alone preserves the
361
+ // browser-native UX. Note the UA's replay lands a frame AFTER
362
+ // the popstate handler, not before it (#1428, measured), which
363
+ // is the ordering the restore window is built around.
364
+ //
365
+ // And never for a FRAME-scoped response (#1427). `recordHistory` means "a
366
+ // foreground navigation the reader initiated", which a frame click is (it
367
+ // advances the URL, deliberately), so a frame swap used to fall into the
368
+ // page-navigation scroll by omission rather than by decision. A frame swaps
369
+ // ONE region and leaves the rest of the document standing, the scroll offset
370
+ // included, so the router writes no scroll for it: on a page whose frame sits
371
+ // below the fold, scrolling to top throws the region the reader just clicked
372
+ // in off screen. Turbo, which `<webjs-frame>` is modelled on, likewise never
373
+ // scrolls on a frame navigation (its `autoscroll` opt-in is a separate
374
+ // feature WebJs does not have). This is the same rule `restoreGeneration` in
375
+ // `scroll.js` already applies when deciding what ends a scroll-restore
376
+ // window, so the two now agree on what a frame nav is.
377
+ //
378
+ // The hash branch is excluded too. A `#anchor` on a frame link is no more a
379
+ // request to move the document viewport than the frame swap itself is, and
380
+ // one rule ("a frame swap never moves the window") beats two. `_top` and an
381
+ // unresolvable `data-webjs-frame` id both resolve to a null `frameId` in
382
+ // `resolveTargetFrameId`, so they stay page navigations and still scroll.
383
+ // `preserveScroll` (#1436) suppresses the scroll-to-TOP writes and nothing
384
+ // else. A hash anchor still wins, because the reader named a target and a
385
+ // named target beats a blanket preference; that is the one arm below that is
386
+ // not guarded. The arm where the hash names an element the response does not
387
+ // contain scrolls to top today and is guarded with the rest, so "preserve"
388
+ // means one thing on every path.
389
+ if (recordHistory && !frameId) {
390
+ // Use the final URL (after any server-side redirect) so hash
391
+ // anchors point at the document we actually rendered.
392
+ const url = new URL(finalUrl);
393
+ const target = url.hash ? document.getElementById(url.hash.slice(1)) : null;
394
+ if (target) {
395
+ // A hash anchor is the one nav scroll we DON'T force instant: a
396
+ // `#section` link is exactly where an app's `scroll-behavior: smooth`
397
+ // is wanted, and native browsers animate it too.
398
+ target.scrollIntoView();
399
+ } else if (!preserveScroll) {
400
+ // Scroll-to-top on a forward nav. behavior:'instant' so an app-level
401
+ // `scroll-behavior: smooth` does not animate it (match native nav).
402
+ warnIfSmoothScrollOnHtml();
403
+ window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
404
+ }
405
+ }
406
+
407
+ // Progressive streaming (#473): the shell (with its Suspense fallbacks) is
408
+ // now live, so stream the resolved boundaries in fast-before-slow. Detached
409
+ // (fire-and-forget) so the URL advance + navigate event do not wait on the
410
+ // slow boundary; each apply is guarded by the nav token so a newer navigation
411
+ // stops it. Gated on the swap COMMIT (`_swapCommit`): under an async view
412
+ // transition the shell swap is deferred a frame, so applying a resolve before
413
+ // the placeholder is in the DOM dropped the boundary and stuck the skeleton
414
+ // (#1048). On the synchronous path `_swapCommit` is already resolved, so this
415
+ // is a same-microtask no-op there.
416
+ if (streamCtx && (streamCtx.reader || streamCtx.rest)) {
417
+ _swapCommit.then(() => streamBoundariesProgressively(
418
+ streamCtx.reader,
419
+ streamCtx.dec,
420
+ streamCtx.rest,
421
+ () => myToken === currentNavigationToken,
422
+ ));
423
+ }
424
+
425
+ document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId, from: 'navigate' } }));
426
+ return { ok: respOk, status: respStatus, aborted: false, applied };
427
+ } finally {
428
+ // Clear the frame's busy state on every exit path (the early returns
429
+ // above all unwind through here). No-op when this was not a frame nav.
430
+ if (busyFrame) clearFrameBusy(busyFrame, myToken);
431
+ }
432
+ }
@@ -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
+ }