@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.
- package/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +20 -20
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -1
- package/index.d.ts +3 -3
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/component/lifecycle.js +1481 -0
- package/src/component/reactive.js +171 -0
- package/src/component/server-element.js +248 -0
- package/src/component.js +2 -1896
- package/src/css.d.ts +9 -0
- package/src/escape.d.ts +2 -0
- package/src/html.d.ts +9 -0
- package/src/render-client/parts.js +2039 -0
- package/src/render-client/reconciler.js +388 -0
- package/src/render-client/template-compiler.js +525 -0
- package/src/render-client.js +1 -2938
- package/src/render-server/attrs.js +160 -0
- package/src/render-server/dsd.js +445 -0
- package/src/render-server/html-scan.js +402 -0
- package/src/render-server/slots.js +213 -0
- package/src/render-server/stream.js +163 -0
- package/src/render-server/template-renderer.js +938 -0
- package/src/render-server/text.js +163 -0
- package/src/render-server.js +6 -2405
- package/src/repeat.d.ts +15 -0
- package/src/rich-fetch.d.ts +4 -0
- package/src/router-client/anchors.js +44 -0
- package/src/router-client/boundaries.js +221 -0
- package/src/router-client/constants.js +206 -0
- package/src/router-client/diagnostics.js +377 -0
- package/src/router-client/dom-differ.js +709 -0
- package/src/router-client/dom-parse.js +150 -0
- package/src/router-client/events.js +182 -0
- package/src/router-client/fetch-apply.js +432 -0
- package/src/router-client/form-encoder.js +135 -0
- package/src/router-client/frames.js +329 -0
- package/src/router-client/head-merge.js +344 -0
- package/src/router-client/nav-error.js +105 -0
- package/src/router-client/navigator.js +952 -0
- package/src/router-client/prefetch.js +831 -0
- package/src/router-client/scroll.js +308 -0
- package/src/router-client/snapshot-cache.js +113 -0
- package/src/router-client/state.js +155 -0
- package/src/router-client/stream.js +206 -0
- package/src/router-client/swap.js +454 -0
- package/src/router-client/upgrade.js +261 -0
- package/src/router-client/view-transition.js +223 -0
- package/src/router-client.d.ts +11 -2
- package/src/router-client.js +78 -5345
- package/src/slot/assignment.js +304 -0
- package/src/slot/interception.js +558 -0
- package/src/slot/polyfills.js +365 -0
- package/src/slot/project.js +432 -0
- package/src/slot/sensors.js +303 -0
- package/src/slot/state.js +245 -0
- package/src/slot/symbols.js +85 -0
- package/src/slot.js +7 -2199
- package/src/suspense.d.ts +9 -0
- 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
|
+
}
|