@webjsdev/core 0.7.65 → 0.9.0
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/README.md +5 -0
- package/dist/webjs-core-browser.js +6 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +22 -21
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -6
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +18 -9
- package/index.d.ts +26 -10
- package/index.js +21 -9
- package/package.json +15 -2
- package/src/action-effects-client.d.ts +5 -0
- package/src/action-effects-client.js +84 -0
- package/src/action-seed-client.js +5 -5
- package/src/asset-url.js +12 -13
- package/src/async-directive.d.ts +18 -0
- package/src/async-directive.js +88 -0
- package/src/attribute-directives.d.ts +6 -0
- package/src/attribute-directives.js +49 -0
- package/src/attribute-reader.js +213 -25
- package/src/binding-prefixes.js +10 -53
- package/src/component/lifecycle.js +921 -252
- package/src/component/reactive.js +7 -14
- package/src/component/server-element.js +23 -1
- package/src/component.d.ts +160 -44
- package/src/context.d.ts +94 -18
- package/src/context.js +341 -217
- package/src/converters.d.ts +7 -0
- package/src/converters.js +38 -0
- package/src/css.d.ts +32 -6
- package/src/css.js +178 -14
- package/src/directive.d.ts +58 -0
- package/src/directive.js +119 -0
- package/src/directives.d.ts +40 -1
- package/src/directives.js +160 -1
- package/src/form-action.d.ts +13 -0
- package/src/form-action.js +139 -4
- package/src/hot.js +23 -0
- package/src/html.d.ts +8 -0
- package/src/html.js +55 -2
- package/src/is-server.d.ts +2 -0
- package/src/is-server.js +18 -0
- package/src/jsx-guard.js +130 -0
- package/src/metadata.d.ts +22 -3
- package/src/nav.d.ts +4 -1
- package/src/nav.js +44 -11
- package/src/optimistic.js +2 -2
- package/src/refresh-header.js +52 -0
- package/src/registry.js +10 -1
- package/src/render-client/adopt-slots.js +250 -0
- package/src/render-client/parts.js +790 -42
- package/src/render-client/reconciler.js +84 -23
- package/src/render-client/template-compiler.js +152 -43
- package/src/render-client.d.ts +6 -1
- package/src/render-server/attrs.js +2 -2
- package/src/render-server/compile.js +50 -2
- package/src/render-server/errors.js +37 -0
- package/src/render-server/expand.js +100 -20
- package/src/render-server/served-attrs.js +78 -0
- package/src/render-server/walk.js +177 -5
- package/src/render-server.d.ts +3 -0
- package/src/render-server.js +2 -0
- package/src/repeat.d.ts +7 -2
- package/src/repeat.js +20 -6
- package/src/router-client/anchors.js +3 -1
- package/src/router-client/boundaries.js +15 -1
- package/src/router-client/constants.js +30 -0
- package/src/router-client/dom-differ.js +17 -0
- package/src/router-client/fetch-apply.js +107 -5
- package/src/router-client/frames.js +6 -3
- package/src/router-client/head-merge.js +58 -10
- package/src/router-client/navigator.js +49 -9
- package/src/router-client/owned-styles.js +372 -0
- package/src/router-client/prefetch.js +6 -4
- package/src/router-client/root-attrs.js +43 -0
- package/src/router-client/stream.js +7 -0
- package/src/router-client/stylesheets.js +191 -0
- package/src/router-client/swap.js +53 -10
- package/src/routes.d.ts +75 -18
- package/src/sentinels.d.ts +2 -0
- package/src/sentinels.js +4 -0
- package/src/serialize.d.ts +2 -0
- package/src/serialize.js +35 -4
- package/src/signal.d.ts +7 -0
- package/src/signal.js +26 -6
- package/src/slot/polyfills.js +3 -3
- package/src/slot.js +4 -8
- package/src/static-html.d.ts +22 -0
- package/src/static-html.js +144 -0
- package/src/task.d.ts +63 -20
- package/src/task.js +326 -293
- package/src/webjs-config.d.ts +2 -9
|
@@ -15,10 +15,12 @@ import { restoreOptimistic } from './dom-differ.js';
|
|
|
15
15
|
import { parseHTML } from './dom-parse.js';
|
|
16
16
|
import { clearFrameBusy, markFrameBusy } from './frames.js';
|
|
17
17
|
import { handleNavigationError } from './nav-error.js';
|
|
18
|
-
import { navigate } from './navigator.js';
|
|
18
|
+
import { navigate, revalidate } from './navigator.js';
|
|
19
|
+
import { parseRefreshHeader } from '../refresh-header.js';
|
|
19
20
|
import { prefetchTake } from './prefetch.js';
|
|
20
21
|
import { currentNavigationToken } from './state.js';
|
|
21
22
|
import { readStreamedShell, streamBoundariesProgressively } from './stream.js';
|
|
23
|
+
import { loadIncomingStylesheets, releaseIncomingStylesheets } from './stylesheets.js';
|
|
22
24
|
import { _swapCommit, applySwap } from './swap.js';
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -52,7 +54,7 @@ import { _swapCommit, applySwap } from './swap.js';
|
|
|
52
54
|
* top on a forward navigation (#1436), from `data-preserve-scroll` on the
|
|
53
55
|
* link or form, or from `navigate(url, { scroll: false })`. It suppresses only
|
|
54
56
|
* the scroll-to-top writes, never the hash-anchor scroll.
|
|
55
|
-
* @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean }>}
|
|
57
|
+
* @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean, devError?: boolean }>}
|
|
56
58
|
* The fetch outcome, so a caller (the form-submission busy/event lifecycle)
|
|
57
59
|
* can report whether the submission settled as a success, an error, or an
|
|
58
60
|
* abort. `ok` mirrors `response.ok` for an HTTP response (a 422 validation
|
|
@@ -71,7 +73,62 @@ import { _swapCommit, applySwap } from './swap.js';
|
|
|
71
73
|
* degradation to a hard navigation from an importmap mismatch or a poisoned
|
|
72
74
|
* boundary scan). A caller deciding whether to fall back to a full page load
|
|
73
75
|
* wants `applied`; one reporting the submission's success wants `ok`.
|
|
76
|
+
*
|
|
77
|
+
* `devError` is set for a `refresh` answered with `X-Webjs-Dev-Error: 1`
|
|
78
|
+
* (#1689): the dev render failed, nothing was applied, and the page on
|
|
79
|
+
* screen is meant to stay as it is under the dev overlay.
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* Whether two urls name the same document (origin, path and query; the hash
|
|
83
|
+
* is a position within it).
|
|
84
|
+
* @param {string} a @param {string} b
|
|
74
85
|
*/
|
|
86
|
+
function sameDocumentUrl(a, b) {
|
|
87
|
+
try {
|
|
88
|
+
const x = new URL(a, location.href);
|
|
89
|
+
const y = new URL(b, location.href);
|
|
90
|
+
return x.origin === y.origin && x.pathname === y.pathname && x.search === y.search;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The focused element and its text selection, for a same-page update.
|
|
98
|
+
* @returns {{ el: Element, start: number | null, end: number | null } | null}
|
|
99
|
+
*/
|
|
100
|
+
function captureFocus() {
|
|
101
|
+
const el = document.activeElement;
|
|
102
|
+
if (!el || el === document.body || el === document.documentElement) return null;
|
|
103
|
+
/** @type any */
|
|
104
|
+
const input = el;
|
|
105
|
+
let start = null;
|
|
106
|
+
let end = null;
|
|
107
|
+
try { start = input.selectionStart ?? null; end = input.selectionEnd ?? null; } catch { /* not a text control */ }
|
|
108
|
+
return { el, start, end };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Give focus back to the element that had it, when the morph kept the node.
|
|
113
|
+
* @param {{ el: Element, start: number | null, end: number | null }} state
|
|
114
|
+
*/
|
|
115
|
+
function restoreFocus(state) {
|
|
116
|
+
const { el, start, end } = state;
|
|
117
|
+
if (!el.isConnected || document.activeElement === el) return;
|
|
118
|
+
/** @type any */
|
|
119
|
+
const target = el;
|
|
120
|
+
if (typeof target.focus !== 'function') return;
|
|
121
|
+
// WebKit scrolls a text control into view on setSelectionRange even after a
|
|
122
|
+
// preventScroll focus, so the window offset is put back afterwards.
|
|
123
|
+
const x = window.scrollX;
|
|
124
|
+
const y = window.scrollY;
|
|
125
|
+
target.focus({ preventScroll: true });
|
|
126
|
+
if (start !== null && end !== null) {
|
|
127
|
+
try { target.setSelectionRange(start, end); } catch { /* not a text control */ }
|
|
128
|
+
}
|
|
129
|
+
if (window.scrollX !== x || window.scrollY !== y) window.scrollTo({ left: x, top: y, behavior: 'instant' });
|
|
130
|
+
}
|
|
131
|
+
|
|
75
132
|
export async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token, revalidating, opts) {
|
|
76
133
|
method = method || 'GET';
|
|
77
134
|
const refresh = (opts && opts.refresh) || undefined;
|
|
@@ -175,11 +232,27 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
175
232
|
const resp = await fetch(href, init);
|
|
176
233
|
respStatus = resp.status;
|
|
177
234
|
respOk = resp.ok;
|
|
235
|
+
// A dev render that reported an error (#1689). The server marks it, and
|
|
236
|
+
// the dev overlay is already up describing it. A refresh leaves the page
|
|
237
|
+
// that last rendered on screen under the overlay, as Next does, so the
|
|
238
|
+
// edit that fixes the error refreshes it in place; applying the error page
|
|
239
|
+
// would share no boundary with it and hard-navigate, and so would the fix.
|
|
240
|
+
// Only `webjs dev` sends the header.
|
|
241
|
+
if (refresh && resp.headers.get('x-webjs-dev-error') === '1') {
|
|
242
|
+
try { await resp.body?.cancel(); } catch (_) { /* already consumed */ }
|
|
243
|
+
return { ok: false, status: respStatus, aborted: false, applied: false, devError: true };
|
|
244
|
+
}
|
|
178
245
|
// `fetch` follows a 303 transparently and its headers are then unreadable,
|
|
179
246
|
// so this lands on a non-redirect response: the 422 failure re-render, or
|
|
180
247
|
// any form response that answers in place.
|
|
181
248
|
const invalidated = parseTagHeader(resp.headers.get('x-webjs-invalidate'));
|
|
182
249
|
if (invalidated.length) markStale(invalidated);
|
|
250
|
+
// `revalidatePath()` in a form action (a 422 re-render, or any response
|
|
251
|
+
// answered in place): drop the router's cached copies of those paths.
|
|
252
|
+
for (const e of parseRefreshHeader(resp.headers.get('x-webjs-refresh'))) {
|
|
253
|
+
if (e.layout || e.path.includes('[')) revalidate();
|
|
254
|
+
else revalidate(e.path);
|
|
255
|
+
}
|
|
183
256
|
const ctype = resp.headers.get('content-type') || '';
|
|
184
257
|
const isHTML = /^text\/html\b/i.test(ctype);
|
|
185
258
|
const isStream = ctype.toLowerCase().indexOf(STREAM_MIME) === 0;
|
|
@@ -295,6 +368,21 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
295
368
|
// recover in place rather than a destructive full reload.
|
|
296
369
|
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 }; }
|
|
297
370
|
|
|
371
|
+
// Load the stylesheets the incoming page brings before it is swapped in, so
|
|
372
|
+
// its first paint is styled (see stylesheets.js). Null when it brings none,
|
|
373
|
+
// and the swap then stays in this task. A newer navigation that started
|
|
374
|
+
// during the wait owns the page, exactly as after the body read above, and
|
|
375
|
+
// this one's temporary copies go with it.
|
|
376
|
+
const sheets = loadIncomingStylesheets(doc, finalUrl || href, frameId);
|
|
377
|
+
if (sheets) {
|
|
378
|
+
await sheets.ready;
|
|
379
|
+
if (myToken !== currentNavigationToken) {
|
|
380
|
+
releaseIncomingStylesheets(sheets);
|
|
381
|
+
if (streamCtx && streamCtx.reader) { try { streamCtx.reader.cancel(); } catch { /* ignore */ } }
|
|
382
|
+
return { ok: false, status: respStatus, aborted: true, applied: false };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
298
386
|
// #1406: recording the push after the swap finalizes the outgoing page's
|
|
299
387
|
// entry against the DESTINATION document, at a scroll offset the browser has
|
|
300
388
|
// already clamped to that document's height (measured on the gallery: 1600 to
|
|
@@ -326,16 +414,30 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
326
414
|
// (see the `applied` comment below). Keeping the tail call preserves them byte
|
|
327
415
|
// for byte, so this commit changes the ORDER on committing swaps and nothing
|
|
328
416
|
// else.
|
|
417
|
+
// A write that lands back on the page it was submitted from (a validation
|
|
418
|
+
// re-render, or a PRG redirect to the same url) updates that page in place,
|
|
419
|
+
// the way a Next.js server action does after revalidatePath (#1686): the
|
|
420
|
+
// history entry is replaced rather than duplicated, the scroll position is
|
|
421
|
+
// kept, and the focused control keeps focus when it survives the morph.
|
|
422
|
+
const samePage = mutation && !refresh && sameDocumentUrl(finalUrl, location.href);
|
|
329
423
|
let historyRecorded = false;
|
|
330
424
|
const recordHistoryNow = recordHistory
|
|
331
425
|
? () => {
|
|
332
426
|
if (historyRecorded) return;
|
|
333
427
|
historyRecorded = true;
|
|
334
|
-
history.
|
|
428
|
+
if (samePage) history.replaceState(history.state, '', finalUrl);
|
|
429
|
+
else history.pushState(null, '', finalUrl);
|
|
335
430
|
}
|
|
336
431
|
: null;
|
|
432
|
+
const focusState = samePage ? captureFocus() : null;
|
|
337
433
|
|
|
338
434
|
const disposition = applySwap(doc, frameId, !!revalidating, finalUrl, incomingBuild, incomingSrc, refresh || (mutation ? 'layouts' : undefined), recordHistoryNow);
|
|
435
|
+
// The temporary stylesheet copies are released once the swap has COMMITTED:
|
|
436
|
+
// under a view transition the new markup, and the page's own links with it,
|
|
437
|
+
// lands a frame later. A copy with no twin in the result (a discarded or
|
|
438
|
+
// uncommitted swap) goes as soon as the commit settles.
|
|
439
|
+
if (sheets) _swapCommit.then(() => releaseIncomingStylesheets(sheets), () => releaseIncomingStylesheets(sheets));
|
|
440
|
+
if (focusState) _swapCommit.then(() => { if (myToken === currentNavigationToken) restoreFocus(focusState); }, () => {});
|
|
339
441
|
// `'none'` means applySwap returned WITHOUT committing anything: the frame the
|
|
340
442
|
// response was for is missing, or it degraded to a hard navigation (an
|
|
341
443
|
// importmap/build mismatch, a poisoned boundary scan). The page is not left
|
|
@@ -406,7 +508,7 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
406
508
|
// `#section` link is exactly where an app's `scroll-behavior: smooth`
|
|
407
509
|
// is wanted, and native browsers animate it too.
|
|
408
510
|
target.scrollIntoView();
|
|
409
|
-
} else if (!preserveScroll) {
|
|
511
|
+
} else if (!preserveScroll && !samePage) {
|
|
410
512
|
// Scroll-to-top on a forward nav. behavior:'instant' so an app-level
|
|
411
513
|
// `scroll-behavior: smooth` does not animate it (match native nav).
|
|
412
514
|
warnIfSmoothScrollOnHtml();
|
|
@@ -433,7 +535,7 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
433
535
|
}
|
|
434
536
|
|
|
435
537
|
document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId, from: 'navigate' } }));
|
|
436
|
-
return { ok: respOk, status: respStatus, aborted: false, applied };
|
|
538
|
+
return { ok: respOk, status: respStatus, aborted: false, applied, samePage };
|
|
437
539
|
} finally {
|
|
438
540
|
// Clear the frame's busy state on every exit path (the early returns
|
|
439
541
|
// above all unwind through here). No-op when this was not a frame nav.
|
|
@@ -24,10 +24,13 @@ export function activeFrameId(el) {
|
|
|
24
24
|
while (cur) {
|
|
25
25
|
const frame = cur.closest('webjs-frame');
|
|
26
26
|
if (frame && frame.id) return frame.id;
|
|
27
|
-
// Cross shadow boundary upwards if necessary.
|
|
27
|
+
// Cross a shadow boundary upwards if necessary. Only a real ShadowRoot
|
|
28
|
+
// has a host to cross to: a DETACHED element's root is the top of its
|
|
29
|
+
// own subtree, and when that is an <a>, its `host` is the URL's host
|
|
30
|
+
// STRING, which the next closest() call threw on.
|
|
28
31
|
const root = cur.getRootNode();
|
|
29
|
-
if (
|
|
30
|
-
cur =
|
|
32
|
+
if (typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot) {
|
|
33
|
+
cur = root.host;
|
|
31
34
|
} else {
|
|
32
35
|
break;
|
|
33
36
|
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
-
import { META_KEY_CSP_NONCE } from './constants.js';
|
|
9
|
+
import { META_KEY_CSP_NONCE, relTokens } from './constants.js';
|
|
10
|
+
import { isStylesheetLink, stylesheetMerge } from './owned-styles.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Read the CSP nonce that the original page load published via
|
|
@@ -214,7 +215,16 @@ function reconcileHeadMetas(newHead) {
|
|
|
214
215
|
}
|
|
215
216
|
}
|
|
216
217
|
|
|
217
|
-
|
|
218
|
+
/**
|
|
219
|
+
* @param {HTMLHeadElement} newHead
|
|
220
|
+
* @param {{ base?: string | null, frame?: boolean, segment?: string | null }} [opts] `base` is the URL
|
|
221
|
+
* the incoming page came from; `frame` marks a frame swap, which switches no
|
|
222
|
+
* owned stylesheet on (owned-styles.js `stylesheetMerge`).
|
|
223
|
+
*/
|
|
224
|
+
export function addNewHeadElements(newHead, opts = {}) {
|
|
225
|
+
// `styles: false` leaves the stylesheet links to `mergeHeadStylesheets`,
|
|
226
|
+
// which the boundary swap calls inside its commit (#1669).
|
|
227
|
+
const mergeSheet = opts.styles === false ? null : stylesheetMerge(opts);
|
|
218
228
|
const newTitle = newHead.querySelector('title');
|
|
219
229
|
if (newTitle) document.title = newTitle.textContent || '';
|
|
220
230
|
|
|
@@ -236,6 +246,10 @@ export function addNewHeadElements(newHead) {
|
|
|
236
246
|
// A keyed <meta> is add/update/remove reconciled below (#1046), so skip it
|
|
237
247
|
// here to avoid appending a duplicate when its content changed.
|
|
238
248
|
if (el.tagName === 'META' && metaIdentity(el)) continue;
|
|
249
|
+
// One link per stylesheet, and an owned one placed root to leaf (#1669).
|
|
250
|
+
if (isStylesheetLink(el)) {
|
|
251
|
+
if (!mergeSheet || mergeSheet(el, cloneElementWithCorrectNonce)) continue;
|
|
252
|
+
}
|
|
239
253
|
if (!currentSet.has(outerHTMLForDiff(el))) {
|
|
240
254
|
if (el.tagName === 'SCRIPT') {
|
|
241
255
|
document.head.appendChild(
|
|
@@ -252,6 +266,26 @@ export function addNewHeadElements(newHead) {
|
|
|
252
266
|
reconcileHeadMetas(newHead);
|
|
253
267
|
}
|
|
254
268
|
|
|
269
|
+
/**
|
|
270
|
+
* The stylesheet half of `addNewHeadElements`, for the boundary swap to run
|
|
271
|
+
* INSIDE its commit (#1669): adopting the router's loading copies, switching
|
|
272
|
+
* a sheet back on, and inserting an owned one all take effect with the swap,
|
|
273
|
+
* so the outgoing page, and a view transition's capture of it, never paint
|
|
274
|
+
* with the incoming page's CSS. A stylesheet the merge does not handle (an
|
|
275
|
+
* unowned one the head lacks) is appended, as the full merge does.
|
|
276
|
+
* @param {HTMLHeadElement} newHead
|
|
277
|
+
* @param {{ base?: string | null, segment?: string | null, leaving?: (el: Element) => boolean }} [opts]
|
|
278
|
+
*/
|
|
279
|
+
export function mergeHeadStylesheets(newHead, opts = {}) {
|
|
280
|
+
const mergeSheet = stylesheetMerge(opts);
|
|
281
|
+
const currentSet = new Set();
|
|
282
|
+
for (const el of document.head.children) currentSet.add(outerHTMLForDiff(el));
|
|
283
|
+
for (const el of newHead.children) {
|
|
284
|
+
if (!isStylesheetLink(el) || mergeSheet(el, cloneElementWithCorrectNonce)) continue;
|
|
285
|
+
if (!currentSet.has(outerHTMLForDiff(el))) document.head.appendChild(cloneElementWithCorrectNonce(el));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
255
289
|
/**
|
|
256
290
|
* Is `el` a stylesheet the head merge must never remove: a `<style>` or a
|
|
257
291
|
* `<link rel~="stylesheet">`. WebJs ALWAYS keeps these on a soft nav, with no
|
|
@@ -263,22 +297,32 @@ export function addNewHeadElements(newHead) {
|
|
|
263
297
|
* head (the shared app stylesheet is omitted because the client already has it),
|
|
264
298
|
* so "absent from the incoming head" means "optimized away", NOT "removed". A
|
|
265
299
|
* dynamic-removal opt-out would therefore re-introduce #936 (it would strip a
|
|
266
|
-
* still-needed sheet on any partial response)
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
300
|
+
* still-needed sheet on any partial response). Keeping every stylesheet is
|
|
301
|
+
* correct HERE; a genuinely changed one is dropped by the deploy-level hard
|
|
302
|
+
* reload (build-id mismatch), not a soft swap. A section's own stylesheet is
|
|
303
|
+
* the one case that does leave, and it is decided elsewhere, by ownership
|
|
304
|
+
* rather than by head absence: `owned-styles.js` switches a sheet off when the
|
|
305
|
+
* boundary that owns it is swapped, a replace or a morph, or on a full-body
|
|
306
|
+
* swap whose response is complete (#1669). This merge itself never removes
|
|
307
|
+
* one.
|
|
270
308
|
*
|
|
271
309
|
* @param {Element} el
|
|
272
310
|
* @returns {boolean}
|
|
273
311
|
*/
|
|
274
312
|
function isPersistentHeadStyle(el) {
|
|
275
313
|
if (el.tagName === 'STYLE') return true;
|
|
276
|
-
|
|
277
|
-
|
|
314
|
+
// Any stylesheet link, an alternate one included, so not `relIsStylesheet`.
|
|
315
|
+
return el.tagName === 'LINK' && relTokens(el).includes('stylesheet');
|
|
278
316
|
}
|
|
279
317
|
|
|
280
|
-
/**
|
|
281
|
-
|
|
318
|
+
/**
|
|
319
|
+
* @param {HTMLHeadElement} newHead
|
|
320
|
+
* @param {{ base?: string | null, segment?: string | null }} [opts] the URL the incoming page came from, and the range being replaced
|
|
321
|
+
*/
|
|
322
|
+
export function mergeHead(newHead, opts = {}) {
|
|
323
|
+
// `styles: false` leaves the stylesheet links to `mergeHeadStylesheets`,
|
|
324
|
+
// which the full-body swap calls inside its commit (#1669).
|
|
325
|
+
const mergeSheet = opts.styles === false ? null : stylesheetMerge(opts);
|
|
282
326
|
const currentHead = document.head;
|
|
283
327
|
|
|
284
328
|
const newTitle = newHead.querySelector('title');
|
|
@@ -327,6 +371,10 @@ export function mergeHead(newHead) {
|
|
|
327
371
|
// kept above), or the head would carry two and `getCspNonce` could read the
|
|
328
372
|
// wrong one (#1050).
|
|
329
373
|
if (el.tagName === 'META' && metaIdentity(el) === META_KEY_CSP_NONCE) continue;
|
|
374
|
+
// A stylesheet the head already links is not appended twice, and an owned
|
|
375
|
+
// one is placed root to leaf (#1669). Nothing here REMOVES a sheet: this
|
|
376
|
+
// head can be a reduced one (#936).
|
|
377
|
+
if (isStylesheetLink(el) && (!mergeSheet || mergeSheet(el, cloneElementWithCorrectNonce))) continue;
|
|
330
378
|
if (!currentSet.has(outerHTMLForDiff(el))) {
|
|
331
379
|
if (el.tagName === 'SCRIPT') {
|
|
332
380
|
currentHead.appendChild(
|
|
@@ -21,6 +21,7 @@ import { clearPrefetchHover, clearPrefetchRefused, clearPrefetchViewTimers, onPr
|
|
|
21
21
|
import { afterTwoFrames, bumpRestoreGeneration, releaseHeightReservation, releaseScrollAnchor, reserveRestoredHeight, restoreGeneration, suppressScrollAnchoring } from './scroll.js';
|
|
22
22
|
import { snapshotCache, snapshotCurrent, snapshotGet } from './snapshot-cache.js';
|
|
23
23
|
import { _setEnabled, bumpNavToken, clearFragmentNav, consumeFragmentNav, currentNavigationToken, enabled, hardNavigate } from './state.js';
|
|
24
|
+
import { primeLiveStylesheetKeys } from './owned-styles.js';
|
|
24
25
|
import { _swapCommit, applySwap } from './swap.js';
|
|
25
26
|
import { ensureUpgradeObserver } from './upgrade.js';
|
|
26
27
|
import { viewTransitionsEnabled } from './view-transition.js';
|
|
@@ -143,6 +144,9 @@ let prevScrollRestoration = null;
|
|
|
143
144
|
export function enableClientRouter() {
|
|
144
145
|
if (enabled || typeof document === 'undefined') return;
|
|
145
146
|
_setEnabled(true);
|
|
147
|
+
// Key the page's stylesheets against the URL they were served at, before
|
|
148
|
+
// any navigation moves it (#1669, owned-styles.js).
|
|
149
|
+
primeLiveStylesheetKeys();
|
|
146
150
|
// Publish the in-place refresh entry on the global (#1398). The dev
|
|
147
151
|
// live-reload client is a separate served script with no import of this
|
|
148
152
|
// module, so a global is the only seam it has, and its PRESENCE is the
|
|
@@ -150,7 +154,9 @@ export function enableClientRouter() {
|
|
|
150
154
|
// never calls this, and a page that ships no component never loads
|
|
151
155
|
// @webjsdev/core at all, so both no-router cases resolve to a full reload
|
|
152
156
|
// without either side assuming anything about the other.
|
|
153
|
-
|
|
157
|
+
// It publishes the OUTCOME form (`refreshOutcome`), so the client can tell
|
|
158
|
+
// a refresh that applied from one that kept the page under a dev error.
|
|
159
|
+
/** @type any */ (globalThis).__webjsRefreshPage = refreshOutcome;
|
|
154
160
|
// Both `click` and `submit` are BUBBLE phase, not capture. A component's
|
|
155
161
|
// per-element `@click` / `@submit` handler (render-client.js) runs in the
|
|
156
162
|
// at-target phase, BEFORE a document-level bubble listener. So onClick /
|
|
@@ -442,7 +448,24 @@ export function revalidate(url) {
|
|
|
442
448
|
* caller should fall back to a full load.
|
|
443
449
|
*/
|
|
444
450
|
export async function refreshPage(mode) {
|
|
445
|
-
|
|
451
|
+
return (await refreshOutcome(mode)) !== 'failed';
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* What `refreshPage` did, for the dev live-reload client (#1689), which reads
|
|
456
|
+
* it through `globalThis.__webjsRefreshPage`: `'applied'` swapped the new
|
|
457
|
+
* render in, `'settled'` found the page already resolved (a hard navigation,
|
|
458
|
+
* a snapshot restore, or a newer navigation that owns it), `'dev-error'` kept
|
|
459
|
+
* the last good page because the dev server answered with a render error, and
|
|
460
|
+
* `'failed'` applied nothing, so the caller falls back to a full load. The dev
|
|
461
|
+
* client logs an in-place update only for `'applied'`, so the console never
|
|
462
|
+
* reports success for an edit that broke the page.
|
|
463
|
+
*
|
|
464
|
+
* @param {'page' | 'shell'} [mode]
|
|
465
|
+
* @returns {Promise<'applied' | 'settled' | 'dev-error' | 'failed'>}
|
|
466
|
+
*/
|
|
467
|
+
async function refreshOutcome(mode) {
|
|
468
|
+
if (!enabled || typeof location === 'undefined') return 'failed';
|
|
446
469
|
// Every cached copy predates the change, so drop both caches before fetching.
|
|
447
470
|
revalidate();
|
|
448
471
|
// And the refusal memos (#1407), which `revalidate` deliberately leaves alone
|
|
@@ -452,9 +475,8 @@ export async function refreshPage(mode) {
|
|
|
452
475
|
// browser does NOT download: a page gaining or losing a `Suspense(...)`
|
|
453
476
|
// boundary is the plain case, since rendering one does not by itself make the
|
|
454
477
|
// page client-effecting, so it classifies `page` rather than a reload. An edit
|
|
455
|
-
// to anything that SHIPS reloads instead and never arrives,
|
|
456
|
-
// `loading.{js,ts}
|
|
457
|
-
// shipped, and a brand-new one is in no graph at all).
|
|
478
|
+
// to anything that SHIPS reloads instead and never arrives, and so does a
|
|
479
|
+
// brand-new `loading.{js,ts}`, which is in no graph at all.
|
|
458
480
|
clearPrefetchRefused();
|
|
459
481
|
try {
|
|
460
482
|
const res = await performNavigation(location.href, false, null, { refresh: mode === 'shell' ? 'shell' : 'page' });
|
|
@@ -478,10 +500,15 @@ export async function refreshPage(mode) {
|
|
|
478
500
|
// superseded this one and owns the page now, and reloading would yank the
|
|
479
501
|
// reader out of it; Turbo drops a refresh that lands mid-navigation for the
|
|
480
502
|
// same reason.
|
|
481
|
-
|
|
482
|
-
|
|
503
|
+
//
|
|
504
|
+
// And a `devError` is not one (#1689): the dev server rendered an error for
|
|
505
|
+
// this page and the overlay shows it. The page stays as it was under the
|
|
506
|
+
// overlay, and the edit that fixes it refreshes in place.
|
|
507
|
+
if (!res || res.aborted) return 'settled';
|
|
508
|
+
if (res.devError) return 'dev-error';
|
|
509
|
+
return res.applied ? 'applied' : 'failed';
|
|
483
510
|
} catch (_) {
|
|
484
|
-
return
|
|
511
|
+
return 'failed';
|
|
485
512
|
}
|
|
486
513
|
}
|
|
487
514
|
|
|
@@ -860,7 +887,12 @@ export async function performSubmission(href, method, body, frameId, form, opts)
|
|
|
860
887
|
navigatingFlagTimer = null;
|
|
861
888
|
}, 150);
|
|
862
889
|
|
|
863
|
-
|
|
890
|
+
// A write keeps the page it was submitted from on screen while it runs, as a
|
|
891
|
+
// Next.js server-action form does (#1686): `loading.ts` is a navigation
|
|
892
|
+
// skeleton, and showing it for a write detached the very form being
|
|
893
|
+
// submitted, so a validation re-render lost what was typed. A safe-method
|
|
894
|
+
// submission (a search) is a navigation and keeps the skeleton.
|
|
895
|
+
const optimisticState = isSafe ? applyOptimisticLoading() : null;
|
|
864
896
|
|
|
865
897
|
// Submission-state lifecycle: mark the form busy + announce the start, then
|
|
866
898
|
// clear + announce the settle in the finally so EVERY exit (success,
|
|
@@ -896,6 +928,14 @@ export async function performSubmission(href, method, body, frameId, form, opts)
|
|
|
896
928
|
await _swapCommit;
|
|
897
929
|
if (myToken === currentNavigationToken) restoreFormState(typed);
|
|
898
930
|
}
|
|
931
|
+
// A successful write that stayed on this page resets the form, as React
|
|
932
|
+
// does for a form whose action succeeded (#1686), so the next entry starts
|
|
933
|
+
// from the defaults the server just rendered. A redirect elsewhere leaves
|
|
934
|
+
// the page and needs nothing.
|
|
935
|
+
if (!isSafe && form && outcome && outcome.ok && outcome.applied && outcome.samePage && myToken === currentNavigationToken) {
|
|
936
|
+
await _swapCommit;
|
|
937
|
+
if (myToken === currentNavigationToken && form.isConnected && typeof form.reset === 'function') form.reset();
|
|
938
|
+
}
|
|
899
939
|
// Mutating submissions invalidate cached versions of other URLs -
|
|
900
940
|
// do this *after* the response applies so the new page itself is
|
|
901
941
|
// snapshotted on the next nav, not pre-emptively wiped. Clear the
|