@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.
Files changed (92) hide show
  1. package/README.md +5 -0
  2. package/dist/webjs-core-browser.js +6 -5
  3. package/dist/webjs-core-browser.js.map +3 -3
  4. package/dist/webjs-core-testing.js +22 -21
  5. package/dist/webjs-core-testing.js.map +3 -3
  6. package/dist/webjs-core.js +7 -6
  7. package/dist/webjs-core.js.map +3 -3
  8. package/index-browser.js +18 -9
  9. package/index.d.ts +26 -10
  10. package/index.js +21 -9
  11. package/package.json +15 -2
  12. package/src/action-effects-client.d.ts +5 -0
  13. package/src/action-effects-client.js +84 -0
  14. package/src/action-seed-client.js +5 -5
  15. package/src/asset-url.js +12 -13
  16. package/src/async-directive.d.ts +18 -0
  17. package/src/async-directive.js +88 -0
  18. package/src/attribute-directives.d.ts +6 -0
  19. package/src/attribute-directives.js +49 -0
  20. package/src/attribute-reader.js +213 -25
  21. package/src/binding-prefixes.js +10 -53
  22. package/src/component/lifecycle.js +921 -252
  23. package/src/component/reactive.js +7 -14
  24. package/src/component/server-element.js +23 -1
  25. package/src/component.d.ts +160 -44
  26. package/src/context.d.ts +94 -18
  27. package/src/context.js +341 -217
  28. package/src/converters.d.ts +7 -0
  29. package/src/converters.js +38 -0
  30. package/src/css.d.ts +32 -6
  31. package/src/css.js +178 -14
  32. package/src/directive.d.ts +58 -0
  33. package/src/directive.js +119 -0
  34. package/src/directives.d.ts +40 -1
  35. package/src/directives.js +160 -1
  36. package/src/form-action.d.ts +13 -0
  37. package/src/form-action.js +139 -4
  38. package/src/hot.js +23 -0
  39. package/src/html.d.ts +8 -0
  40. package/src/html.js +55 -2
  41. package/src/is-server.d.ts +2 -0
  42. package/src/is-server.js +18 -0
  43. package/src/jsx-guard.js +130 -0
  44. package/src/metadata.d.ts +22 -3
  45. package/src/nav.d.ts +4 -1
  46. package/src/nav.js +44 -11
  47. package/src/optimistic.js +2 -2
  48. package/src/refresh-header.js +52 -0
  49. package/src/registry.js +10 -1
  50. package/src/render-client/adopt-slots.js +250 -0
  51. package/src/render-client/parts.js +790 -42
  52. package/src/render-client/reconciler.js +84 -23
  53. package/src/render-client/template-compiler.js +152 -43
  54. package/src/render-client.d.ts +6 -1
  55. package/src/render-server/attrs.js +2 -2
  56. package/src/render-server/compile.js +50 -2
  57. package/src/render-server/errors.js +37 -0
  58. package/src/render-server/expand.js +100 -20
  59. package/src/render-server/served-attrs.js +78 -0
  60. package/src/render-server/walk.js +177 -5
  61. package/src/render-server.d.ts +3 -0
  62. package/src/render-server.js +2 -0
  63. package/src/repeat.d.ts +7 -2
  64. package/src/repeat.js +20 -6
  65. package/src/router-client/anchors.js +3 -1
  66. package/src/router-client/boundaries.js +15 -1
  67. package/src/router-client/constants.js +30 -0
  68. package/src/router-client/dom-differ.js +17 -0
  69. package/src/router-client/fetch-apply.js +107 -5
  70. package/src/router-client/frames.js +6 -3
  71. package/src/router-client/head-merge.js +58 -10
  72. package/src/router-client/navigator.js +49 -9
  73. package/src/router-client/owned-styles.js +372 -0
  74. package/src/router-client/prefetch.js +6 -4
  75. package/src/router-client/root-attrs.js +43 -0
  76. package/src/router-client/stream.js +7 -0
  77. package/src/router-client/stylesheets.js +191 -0
  78. package/src/router-client/swap.js +53 -10
  79. package/src/routes.d.ts +75 -18
  80. package/src/sentinels.d.ts +2 -0
  81. package/src/sentinels.js +4 -0
  82. package/src/serialize.d.ts +2 -0
  83. package/src/serialize.js +35 -4
  84. package/src/signal.d.ts +7 -0
  85. package/src/signal.js +26 -6
  86. package/src/slot/polyfills.js +3 -3
  87. package/src/slot.js +4 -8
  88. package/src/static-html.d.ts +22 -0
  89. package/src/static-html.js +144 -0
  90. package/src/task.d.ts +63 -20
  91. package/src/task.js +326 -293
  92. 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.pushState(null, '', finalUrl);
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 (root && /** @type any */ (root).host) {
30
- cur = /** @type any */ (root).host;
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
- export function addNewHeadElements(newHead) {
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), and WebJs is Tailwind-first (one
267
- * global sheet, no page-specific sheets to drop), so the knob would be unsafe
268
- * and unused. Keeping every stylesheet is correct here; a genuinely changed one
269
- * is dropped by the deploy-level hard reload (build-id mismatch), not a soft swap.
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
- return el.tagName === 'LINK' &&
277
- (el.getAttribute('rel') || '').toLowerCase().split(/\s+/).includes('stylesheet');
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
- /** @param {HTMLHeadElement} newHead */
281
- export function mergeHead(newHead) {
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
- /** @type any */ (globalThis).__webjsRefreshPage = refreshPage;
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
- if (!enabled || typeof location === 'undefined') return false;
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, which covers a
456
- // `loading.{js,ts}` either direction (it can never be elided, so it is always
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
- if (!res || res.aborted) return true;
482
- return res.applied;
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 false;
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
- const optimisticState = applyOptimisticLoading();
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