@webjsdev/core 0.7.50 → 0.7.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +20 -20
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +7 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +1 -1
  8. package/index.d.ts +1 -1
  9. package/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/component/lifecycle.js +1481 -0
  12. package/src/component/reactive.js +171 -0
  13. package/src/component/server-element.js +248 -0
  14. package/src/component.js +2 -1896
  15. package/src/render-client/parts.js +2007 -0
  16. package/src/render-client/reconciler.js +388 -0
  17. package/src/render-client/template-compiler.js +525 -0
  18. package/src/render-client.js +1 -2938
  19. package/src/render-server/attrs.js +160 -0
  20. package/src/render-server/dsd.js +445 -0
  21. package/src/render-server/html-scan.js +402 -0
  22. package/src/render-server/slots.js +213 -0
  23. package/src/render-server/stream.js +163 -0
  24. package/src/render-server/template-renderer.js +923 -0
  25. package/src/render-server/text.js +163 -0
  26. package/src/render-server.js +6 -2405
  27. package/src/router-client/anchors.js +44 -0
  28. package/src/router-client/boundaries.js +221 -0
  29. package/src/router-client/constants.js +187 -0
  30. package/src/router-client/diagnostics.js +377 -0
  31. package/src/router-client/dom-differ.js +709 -0
  32. package/src/router-client/dom-parse.js +150 -0
  33. package/src/router-client/events.js +139 -0
  34. package/src/router-client/fetch-apply.js +387 -0
  35. package/src/router-client/form-encoder.js +135 -0
  36. package/src/router-client/frames.js +329 -0
  37. package/src/router-client/head-merge.js +344 -0
  38. package/src/router-client/nav-error.js +105 -0
  39. package/src/router-client/navigator.js +781 -0
  40. package/src/router-client/prefetch.js +824 -0
  41. package/src/router-client/scroll.js +259 -0
  42. package/src/router-client/snapshot-cache.js +97 -0
  43. package/src/router-client/state.js +92 -0
  44. package/src/router-client/stream.js +206 -0
  45. package/src/router-client/swap.js +451 -0
  46. package/src/router-client/upgrade.js +261 -0
  47. package/src/router-client/view-transition.js +223 -0
  48. package/src/router-client.d.ts +10 -1
  49. package/src/router-client.js +75 -5345
  50. package/src/slot/assignment.js +304 -0
  51. package/src/slot/interception.js +558 -0
  52. package/src/slot/polyfills.js +365 -0
  53. package/src/slot/project.js +432 -0
  54. package/src/slot/sensors.js +303 -0
  55. package/src/slot/state.js +245 -0
  56. package/src/slot/symbols.js +85 -0
  57. package/src/slot.js +7 -2199
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Client router: diagnostics.
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 { FORM_ACTION_FIELD } from '../form-action.js';
10
+ import { FALLBACK_MARKER_KEY } from './constants.js';
11
+
12
+ /**
13
+ * Emit a `console.warn` at most once per `key` for the lifetime of the
14
+ * page, so a repeated misconfiguration (a stale `data-webjs-frame` clicked
15
+ * many times) does not spam the console.
16
+ *
17
+ * @type {Set<string>}
18
+ */
19
+ const warnedKeys = new Set();
20
+
21
+ /** @param {string} key @param {string} message */
22
+ export function warnOnce(key, message, level = 'warn') {
23
+ if (warnedKeys.has(key)) return;
24
+ warnedKeys.add(key);
25
+ if (typeof console === 'undefined') return;
26
+ const fn = level === 'error' ? console.error : console.warn;
27
+ if (fn) fn.call(console, message);
28
+ }
29
+
30
+ /**
31
+ * DEV-ONLY: report at submit time when a submission is carrying a bound
32
+ * action's identity it cannot actually deliver (#1307).
33
+ *
34
+ * This is the backstop for the shapes the renderer deliberately stopped
35
+ * refusing. A PLAIN `<button formmethod="get">` inside a bound form is a legal
36
+ * native override, so it renders, and the form's action then simply does not
37
+ * run. That is what the author asked for, but it is also what a mistake looks
38
+ * like, and submit time is the only moment the whole picture (the resolved
39
+ * method, the resolved enctype, and whether a bound identity is actually in
40
+ * the body) exists in one place.
41
+ *
42
+ * Observational: it runs BEFORE `preventDefault` and changes nothing about the
43
+ * submission. Silent in production, where a console error would be noise the
44
+ * visitor cannot act on; the server-side `onError` telemetry covers that side.
45
+ *
46
+ * @param {HTMLFormElement} form
47
+ * @param {HTMLElement | null} submitter
48
+ * @param {string} method lowercased, already resolved with native precedence
49
+ * @param {FormData} body
50
+ */
51
+ export function warnIfActionSubmissionCannotDeliver(form, submitter, method, body) {
52
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return;
53
+ // No identity in the body: an ordinary form with nothing to deliver.
54
+ if (!body.has(FORM_ACTION_FIELD)) return;
55
+ let path = '';
56
+ try { path = new URL(form.getAttribute('action') || location.href, location.href).pathname; }
57
+ catch { path = location.pathname; }
58
+ if (method !== 'post') {
59
+ warnOnce(
60
+ `submit-nowhere:${path}:${method}`,
61
+ `[webjs] this submission carries a bound server action's identity but submits as ${method.toUpperCase()}, which sends no body, so the identity rides the query string and the action never runs. A submitter's own formmethod overrides the form's, and WebJs honours it rather than refusing it, so check for a formmethod on the button that was pressed.`,
62
+ 'error',
63
+ );
64
+ return;
65
+ }
66
+ const enctype = (submitter && submitter.getAttribute('formenctype'))
67
+ || form.getAttribute('enctype')
68
+ || 'application/x-www-form-urlencoded';
69
+ // `text/plain` ONLY, not the renderer's parseable-enctype allowlist.
70
+ // `enctype` is an enumerated attribute whose missing AND invalid value
71
+ // defaults are both `application/x-www-form-urlencoded`, so
72
+ // `enctype="nonsense"` submits a perfectly parseable body and the action
73
+ // runs. Testing against the allowlist would report that working form as
74
+ // broken.
75
+ if (enctype.toLowerCase() === 'text/plain') {
76
+ warnOnce(
77
+ `submit-nowhere:${path}:${enctype}`,
78
+ `[webjs] this submission carries a bound server action's identity but declares enctype="${enctype}", which the server cannot parse. The router declines to send it so both paths behave the same way, and both are answered with a 405. Drop the enctype and let the binding supply it.`,
79
+ 'error',
80
+ );
81
+ return;
82
+ }
83
+ // The identity is going somewhere OTHER than this page (#1307). A bound
84
+ // submitter emits no `formaction` url, so the submission targets whatever the
85
+ // FORM targets, and a form declaring its own `action="/x"` sends its buttons
86
+ // to `/x` by ordinary native precedence.
87
+ //
88
+ // This is the one shape the redesign left both unrefused and, until here,
89
+ // unreported. The renderer used to throw for it, but only where it could SEE
90
+ // the form, which is exactly the cross-element judgement that could not be
91
+ // made from inside a component. So it is reported at submit time instead,
92
+ // where the resolved target is a fact rather than an inference.
93
+ //
94
+ // A warning rather than an error, because it is not necessarily wrong: if
95
+ // `/x` is a PAGE route the action really does run there, and the 422
96
+ // re-render simply lands on that page. It is only dead if `/x` is a
97
+ // `route.ts`, another origin, or nothing at all, and the client cannot tell
98
+ // which from here.
99
+ if (path && path !== location.pathname) {
100
+ warnOnce(
101
+ `submit-elsewhere:${path}`,
102
+ `[webjs] this submission carries a bound server action's identity but posts to "${path}" rather than this page, because the enclosing <form> declares its own action. A bound submitter emits no formaction, so the form's target wins, which is what native HTML does. The action runs only if "${path}" is a page route; against a route.ts or another origin the identity is ignored and nothing runs. Drop the form's action attribute to keep the submission on this page.`,
103
+ );
104
+ }
105
+ }
106
+
107
+ /**
108
+ * True when a nav must degrade to a full page load because the document is
109
+ * still parsing. A forward, main-document nav fired at `readyState: 'loading'`
110
+ * races the DOM: the leaving page's closing layout markers may not be attached
111
+ * yet, so a soft swap would snapshot an incomplete tree and corrupt the DOM
112
+ * (#1008 / #936). Scoped to frameless forward navs (popstate is browser-driven,
113
+ * a frame nav carries its own boundary element).
114
+ *
115
+ * @param {boolean} isPopState
116
+ * @param {string | null | undefined} frameId
117
+ * @returns {boolean}
118
+ */
119
+ export function shouldFullLoadDuringParse(isPopState, frameId) {
120
+ return (
121
+ !isPopState &&
122
+ !frameId &&
123
+ typeof document !== 'undefined' &&
124
+ document.readyState === 'loading'
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Has the pre-boot check already run for THIS document (#1118)? Module scope,
130
+ * so it resets with the document, which is the lifetime the report is about.
131
+ */
132
+ let reportedPreBoot = false;
133
+
134
+ /**
135
+ * Was THIS document load a same-origin navigation the client router never saw?
136
+ *
137
+ * Pure so the branch logic is testable without driving a real navigation
138
+ * (#1118). Every argument is read from the environment by the one caller.
139
+ *
140
+ * @param {string} navType `performance.getEntriesByType('navigation')[0].type`.
141
+ * Only `'navigate'` qualifies: a `'reload'` and a `'back_forward'` restore are
142
+ * things the browser does, not clicks the router could have intercepted.
143
+ * @param {string} referrer `document.referrer`. Must parse to the same origin as
144
+ * `href`: an empty referrer means a typed URL or an external entry (no router
145
+ * was running to miss the click), and a cross-origin one means the previous
146
+ * page was not ours.
147
+ * @param {string} href `location.href` of the document that just loaded.
148
+ * @param {string | null} marker the consumed `FALLBACK_MARKER_KEY` value. When
149
+ * it equals `href` the router already reported this load under its own cause,
150
+ * so counting it again would double-count a known degradation as an unknown.
151
+ * @returns {boolean}
152
+ */
153
+ export function isPreBootNavigation(navType, referrer, href, marker) {
154
+ if (navType !== 'navigate') return false;
155
+ if (!referrer) return false;
156
+ if (marker && marker === href) return false;
157
+ try {
158
+ return new URL(referrer).origin === new URL(href).origin;
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Report a document load that reached us by a same-origin navigation the router
166
+ * did not soft-navigate (#1118).
167
+ *
168
+ * A module script is deferred by spec, so it runs only after HTML parsing
169
+ * completes, while the links it will intercept are clickable from first paint.
170
+ * That window cannot be closed from inside the router (see #1118 for why an
171
+ * inline capture shim was rejected), so it is MEASURED instead: this turns the
172
+ * frequency into a production number a deployed app can read off the existing
173
+ * `webjs:navigation-fallback` channel, rather than folklore.
174
+ *
175
+ * Deliberately imprecise, and the docs say so: a `data-no-router` link, a
176
+ * cross-document form post, and an app that opted out of the client router all
177
+ * land here too. The signal is the RATE, not any single event.
178
+ *
179
+ * `willReload` is false because the document load has already happened. That is
180
+ * exactly the distinction the flag was added for.
181
+ */
182
+ export function reportPreBootNavigation() {
183
+ // Same guard the scroll/current-page seeding above uses: a DOM shim without a
184
+ // `location` (linkedom under the unit runner) is not a document load to
185
+ // report on, and reading through would throw inside the boot.
186
+ if (typeof location === 'undefined') return;
187
+ // Once per DOCUMENT, not once per enable. `enableClientRouter` is re-callable
188
+ // after `disableClientRouter()` (the documented per-moment opt-out), and this
189
+ // reports on the load that produced the document, which does not happen again
190
+ // when the router is toggled back on. Without this, an app that toggles would
191
+ // emit a duplicate for a single load and inflate the very rate the report
192
+ // exists to measure. The marker is already consumed by then, so it cannot
193
+ // suppress the duplicate on its own.
194
+ if (reportedPreBoot) return;
195
+ reportedPreBoot = true;
196
+ /** @type {string | null} */
197
+ let marker = null;
198
+ try {
199
+ marker = sessionStorage.getItem(FALLBACK_MARKER_KEY);
200
+ // Consume unconditionally, even when it does not match: a stale marker left
201
+ // by an earlier navigation must never suppress a later real one.
202
+ sessionStorage.removeItem(FALLBACK_MARKER_KEY);
203
+ } catch {
204
+ // No marker available. Treated as absent, which can only over-report.
205
+ }
206
+ let navType = '';
207
+ try {
208
+ const nav = performance.getEntriesByType('navigation')[0];
209
+ navType = nav ? /** @type {PerformanceNavigationTiming} */ (nav).type : '';
210
+ } catch {
211
+ // No Navigation Timing Level 2 entry. Without a nav type the check cannot
212
+ // exclude a reload, so it reports nothing rather than guessing.
213
+ }
214
+ if (isPreBootNavigation(navType, document.referrer, location.href, marker)) {
215
+ reportFallback('pre-boot-navigation', location.href, false);
216
+ }
217
+ }
218
+
219
+ /**
220
+ * The client router degraded a soft navigation. Records WHY (the `cause`), so
221
+ * "why did my SPA nav do a full reload?" is answerable instead of guessed at.
222
+ *
223
+ * Two channels, deliberately different in reach:
224
+ *
225
+ * - A **`webjs:navigation-fallback` event on `document`, in EVERY environment**,
226
+ * detail `{ cause, href, willReload }`. Dispatch convention matches
227
+ * `webjs:navigate` / `webjs:prefetch` / `webjs:navigation-error`.
228
+ * - A dev-only console warning, deduped per cause so a repeat does not spam.
229
+ *
230
+ * The event exists because the console warning alone made this class of bug
231
+ * UNDIAGNOSABLE in production (#1114). A degradation is correct behaviour, not
232
+ * an error, so nothing was logged and nothing was thrown, and a deployed app had
233
+ * no way to observe that a click had turned into a full document load. The
234
+ * user-visible symptoms (a loading spinner in the browser tab, a whole-document
235
+ * flash including preserved chrome) were then attributed to a styling problem
236
+ * for a full investigation cycle, because the actual cause emitted no signal.
237
+ *
238
+ * An event costs nothing when nobody listens, is greppable from a page console,
239
+ * and lets a deployed app wire this to analytics. It is NOT cancelable: by the
240
+ * time this fires the decision to degrade is already made and is the only safe
241
+ * option (#1015 chose a bounded full load over a heuristic recovery that could
242
+ * corrupt the DOM silently), so there is nothing for a listener to veto.
243
+ *
244
+ * @param {string} cause a short stable slug for the degradation reason
245
+ * @param {string} href the destination the router fell back to loading
246
+ * @param {boolean} [willReload] true when a full document load follows (the
247
+ * default). False for a degradation that does NOT reload, so a listener can
248
+ * tell "this click became a document load" from "this background op was
249
+ * dropped", which are very different user-visible events.
250
+ */
251
+ export function reportFallback(cause, href, willReload = true) {
252
+ if (willReload) {
253
+ // Leave a marker naming the destination this full load is going to
254
+ // (#1118). The next document's boot reads it to tell "the router itself
255
+ // chose this full load, and already reported it under its own cause" from
256
+ // "a same-origin navigation the router never saw", which is the pre-boot
257
+ // click window. Best-effort: `sessionStorage` throws in some privacy modes
258
+ // and partitioned contexts, and a diagnostic must never break a navigation.
259
+ try {
260
+ sessionStorage.setItem(FALLBACK_MARKER_KEY, href);
261
+ } catch {
262
+ // Without the marker the next boot may attribute this load to the
263
+ // pre-boot window. That is a false positive in a diagnostic, which is
264
+ // strictly better than a thrown navigation.
265
+ }
266
+ }
267
+ if (typeof document !== 'undefined' && typeof CustomEvent !== 'undefined') {
268
+ try {
269
+ document.dispatchEvent(new CustomEvent('webjs:navigation-fallback', {
270
+ detail: { cause, href, willReload },
271
+ }));
272
+ } catch {
273
+ // A listener that throws must never turn a correct degradation into a
274
+ // broken navigation. Diagnostics are strictly best-effort.
275
+ }
276
+ }
277
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return;
278
+ // Word the warning from `willReload`: several causes deliberately do NOT
279
+ // reload (a suppressed deploy reload, a discarded background revalidation),
280
+ // and claiming a full page load for those sends a reader hunting the wrong
281
+ // symptom.
282
+ warnOnce(
283
+ `fallback:${cause}`,
284
+ willReload
285
+ ? `[webjs] client router fell back to a full page load (${cause}) navigating to ${href}. This is correct (no DOM corruption), just not a soft nav.`
286
+ : `[webjs] client router degraded a soft navigation (${cause}) for ${href}, without a full page load.`
287
+ );
288
+ }
289
+
290
+ /**
291
+ * Dev-only, fire-once-per-id hint: a streamed Suspense resolution arrived but
292
+ * its boundary placeholder was not in the DOM, so it was dropped (#1051). This
293
+ * is benign when the navigation was superseded, degraded to a full load, or
294
+ * discarded, but a stuck skeleton that is NONE of those has no other signal (it
295
+ * is what made the #1048 view-transition race hard to diagnose). Never warns in
296
+ * production, never throws.
297
+ *
298
+ * @param {string} id the streamed boundary id that could not be applied
299
+ */
300
+ export function warnDropped(id) {
301
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return;
302
+ warnOnce(
303
+ `stream-drop:${id}`,
304
+ `[webjs] dropped a streamed Suspense resolve for "${id}": no #${id} boundary in the DOM. Benign if this navigation was superseded or degraded. A stuck skeleton here means the shell swap did not place the boundary.`
305
+ );
306
+ }
307
+
308
+ /**
309
+ * Dev-only, fire-once hint: the router forces an INSTANT scroll-to-top on a
310
+ * forward navigation (matching a native page load), so an app-level
311
+ * `scroll-behavior: smooth` on <html> does not affect route transitions (it
312
+ * still applies to in-page #anchor links via `scrollIntoView`). A developer
313
+ * who set smooth expecting smooth nav scrolling would otherwise be puzzled.
314
+ * Also flags the iOS sticky-`backdrop-filter` flash this combination can
315
+ * cause (#610). Never warns in production, never throws.
316
+ *
317
+ * The `smoothScrollChecked` flag gates the `getComputedStyle` read (a forced
318
+ * style flush) to AT MOST ONCE per page, so a dev session does not pay a
319
+ * per-navigation reflow after the first forward nav.
320
+ */
321
+ let smoothScrollChecked = false;
322
+
323
+ export function warnIfSmoothScrollOnHtml() {
324
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return;
325
+ if (smoothScrollChecked) return;
326
+ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return;
327
+ const root = document.documentElement;
328
+ if (!root) return;
329
+ smoothScrollChecked = true;
330
+ let behavior;
331
+ try { behavior = getComputedStyle(root).scrollBehavior; } catch { return; }
332
+ if (behavior !== 'smooth') return;
333
+ warnOnce(
334
+ 'scroll-behavior-smooth-html',
335
+ '[webjs] Detected `scroll-behavior: smooth` on <html>. The client router scrolls ' +
336
+ 'to the top instantly on navigation (like a native page load), so route transitions ' +
337
+ 'are not affected by it. It still applies to in-page #anchor links. Pairing it with a ' +
338
+ 'sticky `backdrop-filter` header can also flash on iOS during navigation.'
339
+ );
340
+ }
341
+
342
+ /**
343
+ * Nav-in-flight signalling. The router can expose `data-navigating` on <html>
344
+ * so an app may style a loading indicator with `html[data-navigating] { … }`.
345
+ *
346
+ * This is OPT-IN, set only when the app marks `<html data-webjs-nav-progress>`.
347
+ * The reason it is not unconditional: toggling ANY attribute on the root
348
+ * re-runs global style resolution, and on WebKit (so every iOS browser, since
349
+ * they all use it) that re-resolves `oklch()` / `color-mix(in oklch, …)` token
350
+ * values to an equivalent oklab representation and repaints them for one frame.
351
+ * On a token-driven theme that is a visible background flash on navigation
352
+ * (#610). The flash only shows on a nav slow enough to reach the deferred set
353
+ * below, which a desktop nav rarely is but a mobile forward fetch routinely is,
354
+ * so the symptom is iOS-and-forward-only. With no opt-in the attribute is never
355
+ * written, so the re-resolution never happens and the flash cannot occur.
356
+ */
357
+ export function setNavigating(on) {
358
+ if (typeof document === 'undefined') return;
359
+ const root = document.documentElement;
360
+ if (!root || !root.hasAttribute('data-webjs-nav-progress')) return;
361
+ try {
362
+ if (on) root.setAttribute('data-navigating', '');
363
+ else root.removeAttribute('data-navigating');
364
+ } catch { /* non-DOM environment */ }
365
+ }
366
+
367
+ /* ====================================================================
368
+ * Boundary discovery (the heart of the partial-swap mechanism, #1015)
369
+ * ==================================================================== */
370
+
371
+ /** Test-only: clear the fire-once warning guards so a case can be re-exercised. */
372
+ export function _resetWarnOnce() { warnedKeys.clear(); smoothScrollChecked = false; }
373
+
374
+ /** Test-only: the readyState-loading full-load degradation predicate (#1008). */
375
+ export function _shouldFullLoadDuringParse(isPopState, frameId) {
376
+ return shouldFullLoadDuringParse(isPopState, frameId);
377
+ }