@webjsdev/core 0.7.49 → 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 (61) 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 +21 -21
  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/attribute-reader.js +178 -0
  12. package/src/component/lifecycle.js +1481 -0
  13. package/src/component/reactive.js +171 -0
  14. package/src/component/server-element.js +248 -0
  15. package/src/component.d.ts +10 -0
  16. package/src/component.js +2 -1931
  17. package/src/html-entities.js +2179 -0
  18. package/src/render-client/parts.js +2007 -0
  19. package/src/render-client/reconciler.js +388 -0
  20. package/src/render-client/template-compiler.js +525 -0
  21. package/src/render-client.js +1 -2939
  22. package/src/render-server/attrs.js +160 -0
  23. package/src/render-server/dsd.js +445 -0
  24. package/src/render-server/html-scan.js +402 -0
  25. package/src/render-server/slots.js +213 -0
  26. package/src/render-server/stream.js +163 -0
  27. package/src/render-server/template-renderer.js +923 -0
  28. package/src/render-server/text.js +163 -0
  29. package/src/render-server.js +6 -2334
  30. package/src/router-client/anchors.js +44 -0
  31. package/src/router-client/boundaries.js +221 -0
  32. package/src/router-client/constants.js +187 -0
  33. package/src/router-client/diagnostics.js +377 -0
  34. package/src/router-client/dom-differ.js +709 -0
  35. package/src/router-client/dom-parse.js +150 -0
  36. package/src/router-client/events.js +139 -0
  37. package/src/router-client/fetch-apply.js +387 -0
  38. package/src/router-client/form-encoder.js +135 -0
  39. package/src/router-client/frames.js +329 -0
  40. package/src/router-client/head-merge.js +344 -0
  41. package/src/router-client/nav-error.js +105 -0
  42. package/src/router-client/navigator.js +781 -0
  43. package/src/router-client/prefetch.js +824 -0
  44. package/src/router-client/scroll.js +259 -0
  45. package/src/router-client/snapshot-cache.js +97 -0
  46. package/src/router-client/state.js +92 -0
  47. package/src/router-client/stream.js +206 -0
  48. package/src/router-client/swap.js +451 -0
  49. package/src/router-client/upgrade.js +261 -0
  50. package/src/router-client/view-transition.js +223 -0
  51. package/src/router-client.d.ts +10 -1
  52. package/src/router-client.js +75 -5345
  53. package/src/slot/assignment.js +304 -0
  54. package/src/slot/interception.js +558 -0
  55. package/src/slot/polyfills.js +365 -0
  56. package/src/slot/project.js +432 -0
  57. package/src/slot/sensors.js +303 -0
  58. package/src/slot/state.js +245 -0
  59. package/src/slot/symbols.js +85 -0
  60. package/src/slot.js +7 -2199
  61. package/src/webjs-config.d.ts +13 -2
package/src/component.js CHANGED
@@ -1,1931 +1,2 @@
1
- import { render as clientRender } from './render-client.js';
2
- import { setActiveActionSignal } from './action-abort-client.js';
3
- import { carriesFunction } from './form-action.js';
4
- import { isCSS, adoptStyles } from './css.js';
5
- import { register, tagOf } from './registry.js';
6
- import { parse as deserializeProp } from './serialize.js';
7
- import { Signal } from './signal.js';
8
- import {
9
- captureAuthoredChildren,
10
- adoptSSRAssignments,
11
- ensureSlotState,
12
- hasSlotState,
13
- hasFrameworkRenderedSubtree,
14
- installSlotInterception,
15
- installSlotSensors,
16
- teardownSlotSensors,
17
- reconnectSweep,
18
- } from './slot.js';
19
-
20
- const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undefined';
21
-
22
- /**
23
- * @typedef {Object} ReactiveController
24
- * A controller is a reusable piece of lifecycle logic that plugs into a
25
- * WebComponent host. Controllers let you extract cross-cutting concerns
26
- * (timers, intersection observers, media queries, form validation, fetch
27
- * caching) out of the component class and share them across unrelated
28
- * components.
29
- *
30
- * **When to use:** any time two or more components need the same
31
- * `connectedCallback` / `disconnectedCallback` / pre-render / post-render
32
- * behaviour. Instead of a mixin or base-class hierarchy, attach a controller.
33
- *
34
- * **Why it exists:** mirrors Lit's `ReactiveController` protocol so
35
- * ecosystem controllers are interoperable.
36
- *
37
- * @property {() => void} [hostConnected]
38
- * Called when the host element is inserted into the DOM
39
- * (`connectedCallback`). Use for subscriptions, observers, timers.
40
- * @property {() => void} [hostDisconnected]
41
- * Called when the host element is removed from the DOM
42
- * (`disconnectedCallback`). Use for cleanup: unsubscribe, disconnect
43
- * observers, clear timers.
44
- * @property {() => void} [hostUpdate]
45
- * Called just before the host renders (inside `_performRender`, after
46
- * `willUpdate` but before `render()`). Use for reading layout or
47
- * preparing data that the render depends on.
48
- * @property {() => void} [hostUpdated]
49
- * Called after the host has rendered and the DOM is up to date. Use for
50
- * post-render side effects that depend on the new DOM (measuring,
51
- * focusing, scrolling).
52
- */
53
-
54
- /**
55
- * @typedef {Object} PropertyDeclaration
56
- * Declares how a single reactive property behaves. Used inside
57
- * `static properties = { propName: { …declaration } }`.
58
- *
59
- * @property {Function} [type]
60
- * Constructor used for attribute → property coercion.
61
- * Supported built-ins: `String`, `Number`, `Boolean`, `Object`, `Array`.
62
- * Default: `String`.
63
- *
64
- * @property {boolean} [reflect]
65
- * When `true`, writing to the property also sets the corresponding
66
- * HTML attribute on the element (kebab-cased). Useful when you want
67
- * CSS attribute selectors like `my-el[mode="dark"]` to work.
68
- *
69
- * @property {boolean} [state]
70
- * When `true`, the property is *internal-only*: it is NOT exposed as an
71
- * HTML attribute (excluded from `observedAttributes`) and never reflects.
72
- * It still triggers a re-render when changed via the generated setter.
73
- * Use for private reactive state that shouldn't leak into the DOM.
74
- *
75
- * @property {(newValue: unknown, oldValue: unknown) => boolean} [hasChanged]
76
- * Custom dirty-check function. Called by the generated setter before
77
- * scheduling an update. Return `true` to trigger a re-render, `false`
78
- * to skip. Default: strict inequality `(a, b) => a !== b`.
79
- *
80
- * Note: this fires on the FIRST assignment too, with `oldValue` set
81
- * to `undefined`. Numeric comparators that subtract against undefined
82
- * produce `NaN`, which evaluates to `false`, silently rejecting the
83
- * constructor's initial assignment. Treat `oldValue === undefined` as
84
- * "always changed" in custom comparators so the initial value lands.
85
- *
86
- * @property {{ fromAttribute: (value: string|null, type?: Function) => unknown, toAttribute: (value: unknown, type?: Function) => string|null }} [converter]
87
- * Custom serialization/deserialization pair for the HTML attribute.
88
- * `fromAttribute` is called in `attributeChangedCallback`;
89
- * `toAttribute` is called when reflecting back to the attribute.
90
- * If omitted, the built-in type-based coercion is used.
91
- */
92
-
93
- /**
94
- * Default change detection: strict inequality.
95
- * @param {unknown} a
96
- * @param {unknown} b
97
- * @returns {boolean}
98
- */
99
- function defaultHasChanged(a, b) {
100
- return a !== b;
101
- }
102
-
103
- /**
104
- * `String(v)` that cannot itself throw.
105
- *
106
- * A rejection reason is whatever the author rejected with, and not every value
107
- * converts to a primitive: `Object.create(null)` has no `toString`, and a
108
- * revoked Proxy throws on any operation. Coercing one in an error-reporting
109
- * path turned a reportable failure into a second, unreported one.
110
- *
111
- * @param {unknown} v
112
- * @returns {string}
113
- */
114
- function safeString(v) {
115
- try {
116
- return String(v);
117
- } catch {
118
- return Object.prototype.toString.call(v);
119
- }
120
- }
121
-
122
- /**
123
- * Warn that a function value was dropped rather than reflected (#1169).
124
- *
125
- * A silently missing attribute is its own confusion, so say what went and why.
126
- * The message deliberately does NOT include the value: printing the source is
127
- * the leak this guard exists to prevent, and a server log is not always a
128
- * private place.
129
- *
130
- * UNCONDITIONAL, matching the `.prop=${fn}` unserializable-value drop in
131
- * `render-server.js`, which is the sibling path this guard mirrors. Two
132
- * reasons not to gate it on a dev flag. It fires only on a genuine mistake (a
133
- * function is never a meaningful attribute value), so there is no volume to
134
- * suppress, and reflection runs per assignment rather than per frame.
135
- *
136
- * More decisively, a `NODE_ENV` gate does not survive the dist build.
137
- * `scripts/build-framework-dist.js` runs esbuild with `platform: 'browser'`
138
- * and `minify: true`, which substitutes `process.env.NODE_ENV` with
139
- * `"production"` and folds the check to a constant. The SSR half of the
140
- * warning would then be unreachable in every published build, which is how
141
- * every installed app runs, and SSR is the half that matters most, since that
142
- * is where the leaked source reached visitors.
143
- *
144
- * @param {{ constructor: unknown, tagName?: string }} host
145
- * @param {string} propName
146
- * @param {string} attrName
147
- */
148
- function warnFunctionReflection(host, propName, attrName) {
149
- if (typeof console === 'undefined' || !console.warn) return;
150
- const tag = tagOf(/** @type any */ (host.constructor)) || host.tagName?.toLowerCase() || 'unknown';
151
- console.warn(
152
- `[webjs] reflect:true property "${propName}" on <${tag}> holds a function `
153
- + `(or an array carrying one), which has no HTML attribute representation. `
154
- + `Removing "${attrName}" instead of stringifying it (a stringified function `
155
- + `writes its source into the page, so a server action's body would ship to `
156
- + `the browser). Pass a string, or drop reflect on this property.`
157
- );
158
- }
159
-
160
- /**
161
- * Warn that a value with no JSON representation was dropped rather than
162
- * reflected (#1253).
163
- *
164
- * `JSON.stringify` throws for three reasons an app hits in practice: a cycle,
165
- * a `BigInt`, and an author `toJSON()` that throws. All three mean the same
166
- * thing here, that there is no string to put in the attribute, so all three
167
- * get the same answer as a function does.
168
- *
169
- * The caught message IS included, unlike `warnFunctionReflection`, which
170
- * withholds its value on purpose. A function's string form is its source,
171
- * which is the leak that guard exists to prevent; `JSON.stringify`'s own
172
- * message names the property path rather than a value, and the sibling
173
- * `.prop=${val}` SSR drop in `render-server.js` already ends the same way.
174
- *
175
- * UNCONDITIONAL, for the reason recorded on `warnFunctionReflection` above: a
176
- * `NODE_ENV` gate is folded to a constant by the dist build, which would make
177
- * the SSR half unreachable in every published build, and the SSR half is where
178
- * the component silently vanishes.
179
- *
180
- * @param {{ constructor: unknown, tagName?: string }} host
181
- * @param {string} propName
182
- * @param {string} attrName
183
- * @param {string} [detail] the message `JSON.stringify` threw
184
- */
185
- function warnUnserializableReflection(host, propName, attrName, detail) {
186
- if (typeof console === 'undefined' || !console.warn) return;
187
- const tag = tagOf(/** @type any */ (host.constructor)) || host.tagName?.toLowerCase() || 'unknown';
188
- console.warn(
189
- `[webjs] reflect:true property "${propName}" on <${tag}> holds a value `
190
- + `JSON.stringify cannot serialize (a cycle, a BigInt, or a throwing `
191
- + `toJSON), so it has no HTML attribute representation. Removing `
192
- + `"${attrName}" instead. Detail: ${detail}`
193
- );
194
- }
195
-
196
- /**
197
- * A minimal base for HTML Custom Elements that mirrors Lit's ergonomics
198
- * while staying JSDoc-only and no-build.
199
- *
200
- * Subclasses declare:
201
- * - `static properties`: attribute/property declarations with type info,
202
- * reflection, custom converters, and internal-state mode
203
- * - `static styles`: CSSResult or array thereof (only meaningful with
204
- * `static shadow = true`; light-DOM components inherit global CSS)
205
- * - `static shadow`: set `true` to opt in to shadow DOM (default: `false`
206
- * → light DOM, so Tailwind / global CSS apply directly)
207
- * - `render()`: returns a TemplateResult
208
- *
209
- * The tag name is not a static field: pass it to `.register('tag-name')`
210
- * at the bottom of the file. Tag must contain a hyphen (HTML spec).
211
- *
212
- * Lifecycle (lit-aligned, called in order during each update cycle):
213
- * 1. `shouldUpdate(changedProperties)`. Skip update if false.
214
- * 2. `willUpdate(changedProperties)`. Safe to set properties; folds into this cycle.
215
- * 3. controllers' `hostUpdate()`
216
- * 4. `update(changedProperties)`. Default impl calls `render()` + commits.
217
- * 5. controllers' `hostUpdated()`
218
- * 6. `firstUpdated(changedProperties)`: once, on the first render only
219
- * 7. `updated(changedProperties)`: every render commit
220
- * 8. `updateComplete` promise resolves
221
- *
222
- * `changedProperties` is a `Map<string, unknown>` where each entry maps
223
- * a property name to its previous value.
224
- *
225
- * MAINTAINER NOTE. Adding a new overridable lifecycle hook here means
226
- * the display-only component elision analyser must learn about it too,
227
- * or it will wrongly elide a component that now does client work. Add
228
- * the hook name to `CLIENT_LIFECYCLE_HOOKS` in
229
- * `packages/server/src/component-elision.js`. The guard test at
230
- * `packages/server/test/elision/lifecycle-coverage.test.js` introspects
231
- * this prototype and fails until you do.
232
- *
233
- * Usage:
234
- * ```js
235
- * import { signal } from '@webjsdev/core';
236
- *
237
- * const count = signal(0);
238
- *
239
- * class MyCounter extends WebComponent {
240
- * render() {
241
- * return html`<button @click=${() => count.set(count.get() + 1)}>${count.get()}</button>`;
242
- * }
243
- * }
244
- * MyCounter.register('my-counter');
245
- * ```
246
- */
247
-
248
- /**
249
- * Inert `ElementInternals`-shaped object returned by the server shim's
250
- * `attachInternals()`. Modeled on `@lit-labs/ssr-dom-shim`'s
251
- * `ElementInternalsShim` (lit repo, `packages/labs/ssr-dom-shim/src/lib/
252
- * element-internals.ts`): form-association, validity, and custom-state
253
- * calls are no-ops at SSR (no form, no constraint validation, no `:state()`
254
- * matching server-side), so a component that calls `this.attachInternals()`
255
- * in its constructor renders instead of crashing. The browser runs the real
256
- * `attachInternals()` on hydration.
257
- *
258
- * Deliberate deviation from lit: lit's `checkValidity` / `reportValidity`
259
- * THROW on the server. WebJs returns `true` instead, to keep SSR
260
- * progressive-enhancement-safe (a stray validity call in a constructor must
261
- * not 500 the page); the browser does the real validation.
262
- * @returns {any}
263
- */
264
- function makeServerInternals() {
265
- return {
266
- states: new Set(),
267
- shadowRoot: null,
268
- form: null,
269
- labels: [],
270
- role: '',
271
- willValidate: true,
272
- validity: /** @type {any} */ ({}),
273
- validationMessage: '',
274
- setFormValue() {},
275
- setValidity() {},
276
- checkValidity() { return true; },
277
- reportValidity() { return true; },
278
- };
279
- }
280
-
281
- /**
282
- * Server-side stand-in for `HTMLElement`. The SSR pipeline constructs
283
- * component instances in Node, where `HTMLElement` does not exist, so the
284
- * base class is this shim. It is modeled on `@lit-labs/ssr-dom-shim`'s
285
- * `ElementShim` (lit repo, `packages/labs/ssr-dom-shim/src/index.ts`): the
286
- * attribute methods (`getAttribute` / `setAttribute` / `hasAttribute` /
287
- * `removeAttribute` / `toggleAttribute`, the `attributes` getter) are backed
288
- * by a Map, so lit muscle-memory patterns that read attributes in `render()`
289
- * or set them while deriving state work server-side; the SSR walker seeds
290
- * the Map from the element's source attributes and reads it back to surface
291
- * reflected/added attributes in the output. Event methods are no-ops (no
292
- * server event loop), and `attachInternals()` returns the inert object
293
- * above. The genuinely browser-only surface (`querySelector`, layout reads,
294
- * `attachShadow`, `focus`) is deliberately absent and still throws at SSR,
295
- * which the `no-browser-globals-in-render` rule and the SSR crash hint flag.
296
- *
297
- * Deliberate deviation from lit: this shim lowercases attribute names so
298
- * `getAttribute('Foo')` after `setAttribute('foo', x)` resolves, matching how
299
- * a real browser treats HTML attribute names as case-insensitive. lit's shim
300
- * keys the Map by the raw name (a known fidelity gap in lit-labs).
301
- */
302
- class ServerElement {
303
- constructor() {
304
- /**
305
- * Backing store for the attribute methods. Keys are lowercased
306
- * attribute names (HTML attributes are case-insensitive). Seeded by the
307
- * SSR walker from the element's source attributes.
308
- * @type {Map<string, string>}
309
- */
310
- this.__ssrAttrs = new Map();
311
- /** @type {any} */
312
- this.__internals = null;
313
- }
314
-
315
- /** Mirrors `Element.attributes`: an array of `{ name, value }`. */
316
- get attributes() {
317
- return [...this.__ssrAttrs].map(([name, value]) => ({ name, value }));
318
- }
319
-
320
- /** @param {string} name */
321
- getAttribute(name) {
322
- const v = this.__ssrAttrs.get(String(name).toLowerCase());
323
- return v === undefined ? null : v;
324
- }
325
-
326
- /** @param {string} name @param {unknown} value */
327
- setAttribute(name, value) {
328
- // Emulate the browser casting all values to string (lit does the same).
329
- this.__ssrAttrs.set(String(name).toLowerCase(), String(value));
330
- }
331
-
332
- /** @param {string} name */
333
- removeAttribute(name) {
334
- this.__ssrAttrs.delete(String(name).toLowerCase());
335
- }
336
-
337
- /** @param {string} name */
338
- hasAttribute(name) {
339
- return this.__ssrAttrs.has(String(name).toLowerCase());
340
- }
341
-
342
- /** @param {string} name @param {boolean} [force] */
343
- toggleAttribute(name, force) {
344
- // Steps mirror https://dom.spec.whatwg.org/#dom-element-toggleattribute
345
- const key = String(name).toLowerCase();
346
- const present = this.__ssrAttrs.has(key);
347
- const next = force === undefined ? !present : force;
348
- if (next) {
349
- this.__ssrAttrs.set(key, '');
350
- return true;
351
- }
352
- this.__ssrAttrs.delete(key);
353
- return false;
354
- }
355
-
356
- /** @returns {string[]} */
357
- getAttributeNames() {
358
- return [...this.__ssrAttrs.keys()];
359
- }
360
-
361
- /**
362
- * Minimal `Element.closest()` for SSR. The SSR walker threads the chain
363
- * of enclosing custom-element instances into each instance
364
- * (`__ssrAncestors`); this walks self-then-ancestors and returns the
365
- * nearest whose tag matches. Only bare tag-name selectors are supported
366
- * server-side (`closest('ui-tabs')`), which is what compound components
367
- * need to read parent state for a correct first paint; anything more
368
- * specific (class, attribute, or descendant selectors) returns null,
369
- * matching the pre-shim behaviour. The browser runs the real `closest()`
370
- * on hydration.
371
- * @param {string} selector
372
- * @returns {any}
373
- */
374
- closest(selector) {
375
- const sel = String(selector).trim().toLowerCase();
376
- // Tag-name selectors only at SSR; bail (null) on anything else.
377
- if (!/^[a-z][a-z0-9-]*$/.test(sel)) return null;
378
- if (this.__ssrTag === sel) return this;
379
- const chain = this.__ssrAncestors;
380
- if (!Array.isArray(chain)) return null;
381
- for (let i = chain.length - 1; i >= 0; i--) {
382
- if (chain[i] && chain[i].__ssrTag === sel) return chain[i];
383
- }
384
- return null;
385
- }
386
-
387
- /**
388
- * `HTMLElement.dataset`: a live view over the element's `data-*`
389
- * attributes, backed by the SSR attribute Map. Reading / writing
390
- * `el.dataset.fooBar` maps to the `data-foo-bar` attribute (camelCase
391
- * to kebab-case), so a `render()` that sets `this.dataset.state = 'on'`
392
- * surfaces `data-state="on"` in the SSR'd host tag instead of crashing
393
- * on an undefined `dataset`.
394
- * @returns {Record<string, string>}
395
- */
396
- get dataset() {
397
- if (this.__dataset) return this.__dataset;
398
- const el = this;
399
- const toAttr = (p) => 'data-' + String(p).replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
400
- this.__dataset = new Proxy(/** @type {Record<string,string>} */ ({}), {
401
- get(_t, prop) {
402
- if (typeof prop !== 'string') return undefined;
403
- const v = el.getAttribute(toAttr(prop));
404
- return v === null ? undefined : v;
405
- },
406
- set(_t, prop, value) {
407
- if (typeof prop === 'string') el.setAttribute(toAttr(prop), value);
408
- return true;
409
- },
410
- has(_t, prop) {
411
- return typeof prop === 'string' && el.hasAttribute(toAttr(prop));
412
- },
413
- deleteProperty(_t, prop) {
414
- if (typeof prop === 'string') el.removeAttribute(toAttr(prop));
415
- return true;
416
- },
417
- ownKeys() {
418
- return el.getAttributeNames()
419
- .filter((n) => n.startsWith('data-'))
420
- .map((n) => n.slice(5).replace(/-([a-z])/g, (_m, c) => c.toUpperCase()));
421
- },
422
- getOwnPropertyDescriptor(_t, prop) {
423
- if (typeof prop === 'string' && el.hasAttribute(toAttr(prop))) {
424
- return { enumerable: true, configurable: true, value: el.getAttribute(toAttr(prop)) };
425
- }
426
- return undefined;
427
- },
428
- });
429
- return this.__dataset;
430
- }
431
-
432
- // IDL properties that reflect to a content attribute. A render() that
433
- // mutates these on the host (a light-DOM compound-component pattern, e.g.
434
- // `this.className = ...`, `this.hidden = !active`) then surfaces the
435
- // matching attribute in the SSR'd host tag, matching the browser.
436
- get className() { return this.getAttribute('class') ?? ''; }
437
- set className(v) { this.setAttribute('class', v); }
438
- get hidden() { return this.hasAttribute('hidden'); }
439
- set hidden(v) { this.toggleAttribute('hidden', !!v); }
440
- get id() { return this.getAttribute('id') ?? ''; }
441
- set id(v) { this.setAttribute('id', v); }
442
- get title() { return this.getAttribute('title') ?? ''; }
443
- set title(v) { this.setAttribute('title', v); }
444
- get slot() { return this.getAttribute('slot') ?? ''; }
445
- set slot(v) { this.setAttribute('slot', v); }
446
- get role() { return this.getAttribute('role'); }
447
- set role(v) { v == null ? this.removeAttribute('role') : this.setAttribute('role', v); }
448
- get tabIndex() { const v = this.getAttribute('tabindex'); return v === null ? -1 : (Number.parseInt(v, 10) || 0); }
449
- set tabIndex(v) { this.setAttribute('tabindex', String(v)); }
450
-
451
- // No server event loop: listeners never fire at SSR. The no-op keeps a
452
- // constructor that wires delegated listeners (a common lit pattern) from
453
- // crashing; the browser re-runs the constructor on hydration where the
454
- // real HTMLElement methods apply.
455
- addEventListener() {}
456
- removeEventListener() {}
457
- dispatchEvent() { return true; }
458
-
459
- /** @returns {any} */
460
- attachInternals() {
461
- // Match the browser (and lit's shim): a second attach is an error.
462
- if (this.__internals !== null) {
463
- throw new Error(
464
- "Failed to execute 'attachInternals' on 'HTMLElement': " +
465
- 'ElementInternals for the specified element was already attached.',
466
- );
467
- }
468
- this.__internals = makeServerInternals();
469
- return this.__internals;
470
- }
471
- }
472
-
473
- // ARIAMixin IDL reflections (`el.ariaPressed = 'true'` writes aria-pressed).
474
- // A render() that sets ARIA state via the IDL properties then surfaces the
475
- // matching aria-* attribute in the SSR'd host tag, matching the browser. The
476
- // IDL name maps to the content attribute by lowercasing the part after
477
- // `aria` and prefixing `aria-` (ariaPressed -> aria-pressed).
478
- const ARIA_IDL_PROPS = [
479
- 'ariaAtomic', 'ariaAutoComplete', 'ariaBusy', 'ariaChecked', 'ariaColCount',
480
- 'ariaColIndex', 'ariaColSpan', 'ariaCurrent', 'ariaDescription', 'ariaDisabled',
481
- 'ariaExpanded', 'ariaHasPopup', 'ariaHidden', 'ariaInvalid', 'ariaKeyShortcuts',
482
- 'ariaLabel', 'ariaLevel', 'ariaLive', 'ariaModal', 'ariaMultiLine',
483
- 'ariaMultiSelectable', 'ariaOrientation', 'ariaPlaceholder', 'ariaPosInSet',
484
- 'ariaPressed', 'ariaReadOnly', 'ariaRequired', 'ariaRoleDescription',
485
- 'ariaRowCount', 'ariaRowIndex', 'ariaRowSpan', 'ariaSelected', 'ariaSetSize',
486
- 'ariaSort', 'ariaValueMax', 'ariaValueMin', 'ariaValueNow', 'ariaValueText',
487
- ];
488
- for (const idl of ARIA_IDL_PROPS) {
489
- const attr = 'aria-' + idl.slice(4).toLowerCase();
490
- Object.defineProperty(ServerElement.prototype, idl, {
491
- configurable: true,
492
- get() { return this.getAttribute(attr); },
493
- set(v) { v == null ? this.removeAttribute(attr) : this.setAttribute(attr, String(v)); },
494
- });
495
- }
496
-
497
- // Base class choice: real HTMLElement on the browser, the shim on the server.
498
- const Base = isBrowser ? HTMLElement : /** @type {any} */ (ServerElement);
499
-
500
- /**
501
- * Marker stamped on the anonymous subclass the `WebComponent({...})` factory
502
- * produces. It lets `_assertFactoryProperties` tell the framework's own
503
- * factory-generated `static properties` (allowed) apart from a `static
504
- * properties` a user wrote by hand in a class body (no longer allowed).
505
- */
506
- const FACTORY_PROPS = Symbol('webjs.factoryProps');
507
-
508
- // Per-class memo so the constructor-time enforcement walk runs once per class.
509
- const _propsChecked = new WeakSet();
510
-
511
- class WebComponentBase extends Base {
512
- /** Whether to use shadow DOM. Default: false (light DOM). @type {boolean} */
513
- static shadow = false;
514
-
515
- /**
516
- * Hydration strategy for this component.
517
- *
518
- * **AI hint:** Set `static hydrate = 'visible'` to defer client-side
519
- * hydration until the element scrolls into (or near) the viewport. The
520
- * server-rendered Declarative Shadow DOM content stays visible the whole
521
- * time: users see the SSR HTML immediately while JavaScript activation
522
- * is deferred. This is useful for below-the-fold components that don't
523
- * need interactivity right away.
524
- *
525
- * - `undefined` (default): hydrate immediately on `connectedCallback`.
526
- * - `'visible'`: hydrate when the element enters the viewport
527
- * (with a 200 px root margin).
528
- *
529
- * @type {'visible' | undefined}
530
- */
531
- static hydrate = undefined;
532
-
533
- /**
534
- * Attribute/property declarations.
535
- *
536
- * Each key is a property name; the value is a {@link PropertyDeclaration}.
537
- *
538
- * Properties declared here get auto-generated accessors (getter/setter)
539
- * that trigger re-renders on change, coerce attribute values by type,
540
- * and optionally reflect back to attributes.
541
- *
542
- * Properties with `state: true` are excluded from `observedAttributes`
543
- * and never reflect: they behave like private reactive state.
544
- *
545
- * @type {Record<string, PropertyDeclaration>}
546
- */
547
- static properties = {};
548
-
549
- /**
550
- * Styles to adopt into the shadow root.
551
- * @type {import('./css.js').CSSResult | import('./css.js').CSSResult[] | null}
552
- */
553
- static styles = null;
554
-
555
- /**
556
- * Register this class as a custom element under `tag`.
557
- *
558
- * class Counter extends WebComponent { … }
559
- * Counter.register('my-counter');
560
- *
561
- * Delegates to `customElements.define` (or the server-side shim) via the
562
- * internal registry. The module URL for `<link rel="modulepreload">`
563
- * hints is derived server-side by scanning the app tree: no need to
564
- * pass `import.meta.url`.
565
- *
566
- * @param {string} tag Must contain a hyphen (HTML spec).
567
- */
568
- static register(tag) {
569
- register(tag, this);
570
- }
571
-
572
- /**
573
- * Returns the list of attribute names the browser should observe.
574
- * Properties with `state: true` are excluded: they are internal-only
575
- * and do not correspond to any HTML attribute.
576
- *
577
- * @returns {string[]}
578
- */
579
- static get observedAttributes() {
580
- const props = this.properties || {};
581
- return Object.keys(props)
582
- .filter((k) => !(typeof props[k] === 'object' && props[k].state))
583
- .map((k) => (typeof props[k] === 'object' && props[k].attribute) || hyphenate(k));
584
- }
585
-
586
- constructor() {
587
- super();
588
- this._renderRoot = null;
589
- this._scheduled = false;
590
- this._connected = false;
591
-
592
- /**
593
- * Set of attached reactive controllers.
594
- * @type {Set<ReactiveController>}
595
- */
596
- this.__controllers = new Set();
597
-
598
- /**
599
- * Whether the component has completed its first render.
600
- * Used to gate the one-time `firstUpdated()` call.
601
- * @type {boolean}
602
- */
603
- this.__firstRendered = false;
604
-
605
- /**
606
- * Map of changed properties accumulated since the last render. Keys are
607
- * property names; values are the previous value before the change. Passed
608
- * to `shouldUpdate`, `willUpdate`,
609
- * `update`, `firstUpdated`, and `updated`. Cleared at the start of each
610
- * render cycle so accumulations during hooks queue for the next cycle.
611
- * @type {Map<string, unknown>}
612
- */
613
- this._changedProperties = new Map();
614
-
615
- /**
616
- * Resolver for the currently-pending updateComplete promise. `null` when
617
- * no update is pending.
618
- * @type {((value: boolean) => void) | null}
619
- * @private
620
- */
621
- this._updateResolve = null;
622
-
623
- /**
624
- * Promise that resolves after the next render commit. Resolves to `true`
625
- * when there are no further pending updates, `false` otherwise.
626
- * @type {Promise<boolean>}
627
- * @private
628
- */
629
- this._updatePromise = Promise.resolve(true);
630
-
631
- /**
632
- * Set while the component is inside the update phase (between
633
- * `shouldUpdate` and `updated`). Property assignments during this window
634
- * fold into the CURRENT `changedProperties` Map without scheduling a
635
- * new microtask render. Assignments during `updated()` (after the flag
636
- * clears) queue a fresh cycle.
637
- * @type {boolean}
638
- * @private
639
- */
640
- this._isUpdating = false;
641
-
642
- // Enforce the declare-free factory DX: a hand-written `static properties`
643
- // in a class body is a hard error (use `extends WebComponent({ … })`).
644
- this._assertFactoryProperties();
645
-
646
- // Install reactive property accessors for `static properties` declarations.
647
- this._initializeProperties();
648
- }
649
-
650
- /**
651
- * Throw if a class in this instance's constructor chain declares its own
652
- * `static properties`. Reactive properties must be declared via the
653
- * `extends WebComponent({ … })` factory, which stamps {@link FACTORY_PROPS}
654
- * on the subclass it generates; a `static properties` written by hand in a
655
- * class body carries no such marker and is rejected here (issue #598).
656
- *
657
- * The walk stops at {@link WebComponentBase} (whose `static properties = {}`
658
- * default is internal) and is memoized per class so it runs once.
659
- * @private
660
- */
661
- _assertFactoryProperties() {
662
- const Ctor = /** @type {any} */ (this.constructor);
663
- if (_propsChecked.has(Ctor)) return;
664
- let C = Ctor;
665
- while (C && C !== WebComponentBase) {
666
- if (Object.hasOwn(C, 'properties') && !Object.hasOwn(C, FACTORY_PROPS)) {
667
- const name = C.name || 'a component';
668
- throw new Error(
669
- `${name}: \`static properties\` is no longer supported. Declare reactive ` +
670
- `properties via the factory instead: \`class ${name} extends WebComponent({ ` +
671
- `count: Number })\`. Use the \`prop()\` helper for options ` +
672
- `(\`prop(Number, { reflect: true })\`) and set defaults in the ` +
673
- `constructor after \`super()\`. See https://webjs.dev/docs/components.`,
674
- );
675
- }
676
- C = Object.getPrototypeOf(C);
677
- }
678
- _propsChecked.add(Ctor);
679
- }
680
-
681
- /**
682
- * For every key in `static properties`, create a getter/setter pair on
683
- * the instance that coerces values, runs `hasChanged`, schedules updates,
684
- * and optionally reflects to the HTML attribute.
685
- *
686
- * This is called once from the constructor. The backing store is a plain
687
- * object (`this.__propValues`) so accessors don't collide with the
688
- * prototype.
689
- * @private
690
- */
691
- _initializeProperties() {
692
- const Ctor = /** @type {any} */ (this.constructor);
693
- const props = Ctor.properties;
694
- if (!props || typeof props !== 'object') return;
695
-
696
- /** @type {Record<string, unknown>} */
697
- this.__propValues = {};
698
-
699
- for (const [propName, decl] of Object.entries(props)) {
700
- const d = typeof decl === 'object' ? decl : { type: decl };
701
- // Capture any value set before the accessor was installed (e.g. via
702
- // attribute or property assignment before `super()` returns).
703
- const initial = /** @type {any} */ (this)[propName];
704
-
705
- Object.defineProperty(this, propName, {
706
- configurable: true,
707
- enumerable: true,
708
- get: () => this.__propValues[propName],
709
- set: (newVal) => {
710
- const oldVal = this.__propValues[propName];
711
- const changed = (d.hasChanged || defaultHasChanged)(newVal, oldVal);
712
- if (!changed) return;
713
- this.__propValues[propName] = newVal;
714
-
715
- // Reflect to attribute if requested (and not internal state).
716
- if (d.reflect && !d.state && this._connected) {
717
- this._reflectAttribute(propName, newVal, d);
718
- }
719
-
720
- // requestUpdate records the (name, oldValue) entry AND schedules
721
- // a render. When called during the update phase (willUpdate /
722
- // hostUpdate / update / hostUpdated), the scheduler short-circuits
723
- // and the entry folds into the current cycle's changedProperties.
724
- this.requestUpdate(propName, oldVal);
725
- },
726
- });
727
-
728
- if (initial !== undefined) {
729
- this.__propValues[propName] = initial;
730
- } else if (d.default !== undefined) {
731
- // Declarative `default` option (lit-parity). A function default is
732
- // CALLED per instance, so an object / array default is a fresh value
733
- // per element. Written straight to the backing store; an applied
734
- // attribute (attributeChangedCallback runs later) overrides it.
735
- this.__propValues[propName] =
736
- typeof d.default === 'function' ? d.default() : d.default;
737
- }
738
- }
739
- }
740
-
741
- /**
742
- * Write a property value back to its corresponding HTML attribute.
743
- * Uses a custom `converter.toAttribute` if provided, otherwise the
744
- * built-in type-based serialization.
745
- *
746
- * @param {string} propName
747
- * @param {unknown} value
748
- * @param {PropertyDeclaration} decl
749
- * @private
750
- */
751
- _reflectAttribute(propName, value, decl) {
752
- // A custom `attribute` option wins over the kebab-cased property name.
753
- const attrName = decl.attribute || hyphenate(propName);
754
- // Guard against re-entrant loops: attributeChangedCallback fires when
755
- // we call setAttribute, which would call the setter again.
756
- if (this.__reflectingAttribute) return;
757
- this.__reflectingAttribute = true;
758
- try {
759
- if (decl.converter && decl.converter.toAttribute) {
760
- const serialized = decl.converter.toAttribute(value, decl.type);
761
- if (serialized == null) this.removeAttribute(attrName);
762
- else this.setAttribute(attrName, serialized);
763
- } else if (typeof value === 'function') {
764
- // #1169: the value IS a function, and no branch below has a
765
- // meaningful serialization for one. `String(fn)` is the function's
766
- // SOURCE, so a reflected `'use server'` action would ship its whole
767
- // body, closure secrets included, to every visitor, and
768
- // `JSON.stringify(fn)` is `undefined`, which `setAttribute` writes as
769
- // the literal string "undefined". Treat it like `null` and remove the
770
- // attribute, matching what the `.prop=${fn}` SSR binding already does
771
- // for an unserializable value.
772
- //
773
- // Placed AFTER the converter branch on purpose: a custom
774
- // `toAttribute` is author-controlled, so its author has taken
775
- // responsibility for serializing whatever they are handed.
776
- this.removeAttribute(attrName);
777
- warnFunctionReflection(this, propName, attrName);
778
- } else if (decl.type === Boolean) {
779
- if (value) this.setAttribute(attrName, '');
780
- else this.removeAttribute(attrName);
781
- } else if (value == null) {
782
- this.removeAttribute(attrName);
783
- } else if (decl.type === Object || decl.type === Array) {
784
- // One rule about an unserializable reflected value, in two halves.
785
- //
786
- // A JSON-typed prop CARRYING a function is safe and stays whole:
787
- // `JSON.stringify` drops a function to `null` in an array and omits
788
- // the key in an object, so `[1, 2, fn]` serializes to `[1,2,null]`
789
- // with no source and no data loss. Refusing here would discard the
790
- // `1` and the `2` on a path that never leaked.
791
- //
792
- // A value `JSON.stringify` cannot serialize AT ALL is the other half
793
- // (#1253): a cycle, a `BigInt`, or an author `toJSON()` that throws.
794
- // The throw used to escape reflection entirely, so a property
795
- // assignment threw from the setter, a client upgrade threw out of
796
- // `connectedCallback` before the first render, and an SSR render was
797
- // swallowed by per-component isolation, which surfaces an error box in
798
- // dev and emits the component EMPTY at a 200 in PRODUCTION, where the
799
- // cause is only in the server log. There is no string to put in the
800
- // attribute, so the
801
- // attribute goes, exactly as it does for a function. The `catch` IS
802
- // the detection: `JSON.stringify` already walks the value, so its own
803
- // failure covers all three causes, where a cycle-only pre-walk would
804
- // miss the other two and pay a full traversal per reflection.
805
- let serialized;
806
- let serializable = true;
807
- try {
808
- serialized = JSON.stringify(value);
809
- } catch (e) {
810
- serializable = false;
811
- this.removeAttribute(attrName);
812
- warnUnserializableReflection(this, propName, attrName, e && e.message);
813
- }
814
- // Outside the `try` on purpose, so a genuine `setAttribute` failure
815
- // (an invalid attribute name) still surfaces instead of being folded
816
- // into the unserializable path.
817
- if (serializable) this.setAttribute(attrName, serialized);
818
- } else if (carriesFunction(value)) {
819
- // The string fall-through is the one place a CARRIED function still
820
- // leaks. `String([fn])` is `Array.prototype.join`, which runs
821
- // `String()` on every element, so `[serverAction]` writes the same
822
- // source `serverAction` does. Hence the recursive predicate the
823
- // form-action guard already uses, rather than a bare `typeof`, but
824
- // scoped to this branch rather than applied above, where JSON
825
- // handles the same shape losslessly.
826
- this.removeAttribute(attrName);
827
- warnFunctionReflection(this, propName, attrName);
828
- } else {
829
- this.setAttribute(attrName, String(value));
830
- }
831
- } finally {
832
- this.__reflectingAttribute = false;
833
- }
834
- }
835
-
836
- connectedCallback() {
837
- if (!isBrowser) return;
838
-
839
- // Apply any `data-webjs-prop-*` attributes emitted by SSR. The server
840
- // emits these for `.prop=${val}` bindings in parent templates so
841
- // rich-typed values (Array, Object, Date, Map, Set, BigInt, cycles)
842
- // round-trip through the rendered HTML. Once applied, the attributes
843
- // are stripped so the settled DOM matches what the user would expect
844
- // from the JS source: no framework artifacts left on the element.
845
- // One-time per element. Subsequent reconnections do nothing.
846
- if (!this.__webjsPropsHydrated) {
847
- this.__webjsPropsHydrated = true;
848
- this._hydratePropAttrs();
849
- }
850
-
851
- const Ctor = /** @type any */ (this.constructor);
852
-
853
- // Selective hydration: defer activation until the element scrolls into
854
- // (or near) the viewport. The DSD content from SSR stays visible the
855
- // whole time: the user sees the server-rendered HTML.
856
- if (
857
- Ctor.hydrate === 'visible' &&
858
- typeof IntersectionObserver !== 'undefined' &&
859
- !this.__hydrationActivated
860
- ) {
861
- this.__hydrationActivated = false;
862
- this.__hydrationObserver = new IntersectionObserver(
863
- (entries) => {
864
- for (const entry of entries) {
865
- if (entry.isIntersecting) {
866
- this.__hydrationObserver.unobserve(this);
867
- this.__hydrationObserver.disconnect();
868
- this.__hydrationObserver = null;
869
- this.__hydrationActivated = true;
870
- this._activate();
871
- return;
872
- }
873
- }
874
- },
875
- { rootMargin: '200px' }
876
- );
877
- this.__hydrationObserver.observe(this);
878
- return;
879
- }
880
-
881
- this._activate();
882
- }
883
-
884
- /**
885
- * Internal activation method that performs the actual connectedCallback
886
- * work: setting up the render root, adopting styles, notifying
887
- * controllers, and performing the first render.
888
- *
889
- * Called directly from `connectedCallback()` for normal components, or
890
- * deferred via IntersectionObserver when `static hydrate = 'visible'`.
891
- *
892
- * @private
893
- */
894
- /**
895
- * Read `data-webjs-prop-*` attributes (emitted by SSR for `.prop=${val}`
896
- * bindings in parent templates), decode each via the wire serializer,
897
- * assign the decoded value to the corresponding camelCase property on
898
- * this instance, and remove the attribute from the DOM. After this
899
- * runs, inspecting the element shows the same attributes the developer
900
- * would expect from the JS source.
901
- *
902
- * @private
903
- */
904
- _hydratePropAttrs() {
905
- /** @type {string[]} */
906
- const names = [];
907
- const attrs = this.attributes;
908
- for (let i = 0; i < attrs.length; i++) {
909
- const n = attrs[i].name;
910
- if (n.startsWith('data-webjs-prop-')) names.push(n);
911
- }
912
- for (const fullName of names) {
913
- const raw = this.getAttribute(fullName);
914
- this.removeAttribute(fullName);
915
- if (raw == null) continue;
916
- const propName = camelCase(fullName.slice('data-webjs-prop-'.length));
917
- try {
918
- /** @type any */ (this)[propName] = deserializeProp(raw);
919
- } catch (err) {
920
- console.warn(
921
- `[webjs] failed to decode ${fullName} on <${this.tagName.toLowerCase()}>: ${err && err.message}`
922
- );
923
- }
924
- }
925
- }
926
-
927
- _activate() {
928
- this._connected = true;
929
- // Reflect declared reflect:true properties from their current value now
930
- // that the element is connected. Constructor / willUpdate defaults were
931
- // set while disconnected (the setter skips reflection then), so this is
932
- // what makes a freshly-created client element carry the same reflected
933
- // attributes the SSR walker emitted. Same-value reflects are no-ops, so a
934
- // hydrated element (whose attribute already arrived from SSR) is unchanged.
935
- this._reflectDeclaredAttributes();
936
- const Ctor = /** @type any */ (this.constructor);
937
- // Mark LIGHT-DOM component hosts so the framework default host rule
938
- // (`@layer webjs-host { :where([data-wj-host]) { display: block } }`)
939
- // applies. SSR already stamps this on server-rendered light hosts
940
- // (idempotent here); this also covers a client-only light component (never
941
- // SSR'd) so it does not collapse. Shadow hosts are NOT marked: a document
942
- // rule would override the shadow author's own `:host { display: … }`, so
943
- // shadow components control their host display via `:host` in `static styles`.
944
- if (Ctor.shadow !== true && !this.hasAttribute('data-wj-host')) {
945
- this.setAttribute('data-wj-host', '');
946
- }
947
- if (Ctor.shadow === true) {
948
- const hadSSRShadow = !!this.shadowRoot;
949
- if (!this.shadowRoot) {
950
- /** @type any */ (this).attachShadow({ mode: 'open' });
951
- }
952
- this._renderRoot = this.shadowRoot;
953
- const styles = Ctor.styles;
954
- const list = Array.isArray(styles) ? styles : isCSS(styles) ? [styles] : [];
955
- if (list.length) {
956
- // If the shadow root came from Declarative Shadow DOM (SSR), it
957
- // contains an inline <style> tag. Remove it before switching to
958
- // adoptedStyleSheets to avoid duplicate styles.
959
- if (hadSSRShadow) {
960
- const ssrStyle = this.shadowRoot.querySelector('style');
961
- if (ssrStyle) ssrStyle.remove();
962
- }
963
- adoptStyles(this._renderRoot, list);
964
- }
965
- } else {
966
- this._renderRoot = this;
967
- // Light DOM: static styles is not supported (no shadow root for
968
- // adoptedStyleSheets). Warn if the developer set both.
969
- if (Ctor.styles) {
970
- console.warn(
971
- `[webjs] <${tagOf(Ctor) || this.tagName?.toLowerCase()}> has static shadow = false AND static styles. ` +
972
- `static styles only works with shadow DOM (adoptedStyleSheets). ` +
973
- `For light DOM, use global CSS or <style> in render().`
974
- );
975
- }
976
- // Light-DOM slot capture: ONCE per host lifetime (#1015). Three
977
- // sub-paths, all of which resolve to "the record is populated
978
- // exactly once and never re-captured":
979
- //
980
- // a. Reconnection. The record already exists from a prior mount.
981
- // The host still carries the rendered template DOM (plus
982
- // placed slot children) from before the disconnect. Skip
983
- // capture (would wrongly hoover up rendered nodes) and skip
984
- // SSR adoption. clientRender will see the existing INSTANCE
985
- // and updateInstance instead of recreating; the DOM stays.
986
- //
987
- // b. SSR hydration (first mount, <!--webjs-hydrate--> marker
988
- // present). Children are already inside their
989
- // <slot data-webjs-light data-projection="actual"> elements
990
- // (injectDSD placed them). Adopt those assignments BEFORE
991
- // _performRender so we retain references to the SSR'd nodes;
992
- // the renderer's createInstance().replaceChildren() will
993
- // detach them, but the slot-apply step re-attaches the same
994
- // Node refs into the freshly-cloned slot. DOM identity
995
- // preserved through the hydration round-trip.
996
- //
997
- // c. First mount, no SSR. Partition authored children into the
998
- // record before _performRender wipes the host.
999
- //
1000
- // After capture, installSlotInterception + installSlotSensors below make
1001
- // native DOM writes on a mounted host live (appendChild, slot= flips,
1002
- // innerHTML, HTMLSlotElement.assign), so light-DOM slots match shadow DOM
1003
- // through the standard API.
1004
- if (hasSlotState(this)) {
1005
- // (a) Reconnection. Record already populated. Sweep any direct child
1006
- // added by a raw bypass write while the host was disconnected (no
1007
- // sensor was live to catch it), then re-arm the sensors below.
1008
- reconnectSweep(this);
1009
- } else if (
1010
- this.__isHydrating() ||
1011
- this.hasAttribute('data-wj-serialized') ||
1012
- hasFrameworkRenderedSubtree(this)
1013
- ) {
1014
- // (b) Framework-rendered markup, from any of three signals: boot-time
1015
- // SSR hydration (the webjs-hydrate marker), the router's
1016
- // data-wj-serialized stamp (applySwap marks every host in a parsed
1017
- // doc, covering a snapshot restore whose serialized shape has no
1018
- // projected slot to detect), or the structural detector (own
1019
- // data-webjs-light[data-projection] slots, attributes only the
1020
- // renderer / SSR stamps). Adopt-not-capture: capturing restored
1021
- // post-hydration HTML would hoover the rendered tree into the
1022
- // record and duplicate content on the next render (the #1006
1023
- // shape, on the restore path).
1024
- this.removeAttribute('data-wj-serialized');
1025
- ensureSlotState(this);
1026
- adoptSSRAssignments(this);
1027
- } else {
1028
- // (c) First mount with author-written children. Capture them.
1029
- captureAuthoredChildren(this);
1030
- }
1031
- // Install native-write interception AFTER capture (capture uses the
1032
- // host's still-native methods), then arm the sensors. Together they make
1033
- // appendChild / insertBefore / removeChild / innerHTML / slot= flips on a
1034
- // mounted light host drive the slot record, restoring full shadow-DOM
1035
- // parity through the standard DOM API. Interception installs once;
1036
- // sensors are armed on every connect and torn down on disconnect.
1037
- installSlotInterception(this);
1038
- installSlotSensors(this);
1039
- }
1040
-
1041
- // Notify all controllers that the host is connected.
1042
- for (const c of this.__controllers) {
1043
- if (c.hostConnected) c.hostConnected();
1044
- }
1045
-
1046
- // First render is deferred to a microtask, matching lit's
1047
- // performUpdate scheduling. The user's connectedCallback override
1048
- // body runs synchronously to completion BEFORE the first render,
1049
- // so subclass setup that needs to inspect attributes / state / etc.
1050
- // observes consistent timing whether SSR-hydrating or first mount.
1051
- // Render-root setup (shadow attachment, light-DOM SSR adoption,
1052
- // controller hostConnected, _connected=true) all stay synchronous
1053
- // above; only the template commit + post-commit slot observers
1054
- // defer. Authoring contract: post-render DOM setup goes in
1055
- // firstUpdated(), NOT connectedCallback().
1056
- //
1057
- // The scheduling matches `_scheduleUpdate`'s wrapper so lifecycle
1058
- // throws are surfaced as console errors instead of bricking the
1059
- // element.
1060
- if (this._updateResolve === null) {
1061
- this._updatePromise = new Promise((resolve) => {
1062
- this._updateResolve = resolve;
1063
- });
1064
- }
1065
- this._scheduled = true;
1066
- queueMicrotask(() => {
1067
- this._scheduled = false;
1068
- try {
1069
- this._performRender();
1070
- } catch (err) {
1071
- console.error(`[webjs] lifecycle hook threw during initial render:`, err);
1072
- }
1073
- // The renderer's slot parts place the record content as part of the
1074
- // commit; native DOM writes on the host (appendChild, slot= flips,
1075
- // innerHTML) drive the record live through the interception + sensors
1076
- // installed on connect. There is no WebJs-specific slot API.
1077
- });
1078
- }
1079
-
1080
- /**
1081
- * True when this host's first child is the framework's hydration
1082
- * marker, meaning the SSR pipeline already rendered the template's
1083
- * shape (including <slot data-webjs-light> elements with their
1084
- * projected children inside) and the client should bind events
1085
- * without re-creating DOM. Sets __hydratedAtActivate as a side
1086
- * effect so the post-render path picks up the SSR assignment table.
1087
- *
1088
- * @returns {boolean}
1089
- * @private
1090
- */
1091
- __isHydrating() {
1092
- const first = this.firstChild;
1093
- const isHydrate =
1094
- first != null &&
1095
- first.nodeType === 8 &&
1096
- /** @type {Comment} */ (first).data === 'webjs-hydrate';
1097
- if (isHydrate) this.__hydratedAtActivate = true;
1098
- return isHydrate;
1099
- }
1100
-
1101
-
1102
- /**
1103
- * Called when the element is removed from the DOM.
1104
- *
1105
- * Notifies all attached {@link ReactiveController}s so they can clean up
1106
- * subscriptions, timers, and observers. If you override this in a
1107
- * subclass, always call `super.disconnectedCallback()`.
1108
- */
1109
- disconnectedCallback() {
1110
- this._connected = false;
1111
- // Clean up the hydration observer if the element is removed before
1112
- // it became visible.
1113
- if (this.__hydrationObserver) {
1114
- this.__hydrationObserver.disconnect();
1115
- this.__hydrationObserver = null;
1116
- }
1117
- // Tear down the slot sensors, processing any queued records first (a bare
1118
- // disconnect() drops them). The per-host slot record + interception are
1119
- // preserved so a reconnection picks up where it left off (sensors re-arm in
1120
- // connectedCallback).
1121
- teardownSlotSensors(this);
1122
- // Dispose the signal watcher so dependency edges drop. Without
1123
- // this the element holds references to module-scope signals
1124
- // (and vice versa) forever.
1125
- if (this.__signalWatcher) {
1126
- this.__signalWatcher.dispose();
1127
- this.__signalWatcher = undefined;
1128
- }
1129
- for (const c of this.__controllers) {
1130
- if (c.hostDisconnected) c.hostDisconnected();
1131
- }
1132
- }
1133
-
1134
- /**
1135
- * @param {string} name Kebab-cased attribute name
1136
- * @param {string|null} _old
1137
- * @param {string|null} value
1138
- */
1139
- attributeChangedCallback(name, _old, value) {
1140
- // When we are reflecting a property back to an attribute, ignore the
1141
- // resulting attributeChangedCallback to avoid infinite loops.
1142
- if (this.__reflectingAttribute) return;
1143
-
1144
- const Ctor = /** @type any */ (this.constructor);
1145
- const allProps = (Ctor.properties || {});
1146
- // Resolve the incoming attribute name to its property. A custom
1147
- // `attribute` option wins; otherwise the kebab-cased property name. Falls
1148
- // back to the camelCase of the attribute for the common (kebab) case.
1149
- let propName, raw;
1150
- for (const [k, decl] of Object.entries(allProps)) {
1151
- const d = typeof decl === 'object' ? decl : { type: decl };
1152
- if ((d.attribute || hyphenate(k)) === name) { propName = k; raw = decl; break; }
1153
- }
1154
- if (raw === undefined) { propName = camelCase(name); raw = allProps[propName] || allProps[name]; }
1155
- if (raw === undefined) return;
1156
- // A declaration is either a full descriptor (`{ type: Number, … }`) or the
1157
- // bare-constructor shorthand the factory accepts (`count: Number`), in which
1158
- // case the value IS the type. Normalise so type-based coercion fires either
1159
- // way (matches `_initializeProperties`).
1160
- const def = typeof raw === 'object' ? raw : { type: raw };
1161
-
1162
- let v;
1163
- if (def.converter && def.converter.fromAttribute) {
1164
- v = def.converter.fromAttribute(value, def.type);
1165
- } else if (def.type === Number) {
1166
- v = value == null ? null : Number(value);
1167
- } else if (def.type === Boolean) {
1168
- v = value != null && value !== 'false';
1169
- } else if (def.type === Object || def.type === Array) {
1170
- // An attribute that is not parseable JSON yields `null` rather than the
1171
- // raw string (#1253), because a STRING is never a valid value for a
1172
- // property the author declared `Object` or `Array`, whatever put it
1173
- // there. lit's `defaultConverter.fromAttribute` lands on the same `null`,
1174
- // for the reason its own comment gives: an element does not complain
1175
- // about being mis-configured.
1176
- //
1177
- // `applyAttrsToInstance` in `render-server.js` is the SSR counterpart of
1178
- // THIS branch and yields `null` for the same input. They have to agree,
1179
- // or an element SSRs holding one value and re-renders holding another.
1180
- // The agreement is about an attribute that is PRESENT and unparseable;
1181
- // an attribute that was never there does not reach either reader, so
1182
- // such a prop simply keeps its constructor value.
1183
- //
1184
- // That agreement is about this FALLBACK, not a guarantee that the two
1185
- // readers see the same attributes in the first place. They reach an
1186
- // attribute by different routes (this one via `observedAttributes` and
1187
- // the browser's own name-lowercasing, the SSR one by walking the parsed
1188
- // source tag), and hand-written markup can land in the gaps between
1189
- // those routes, so an author literal can still be read by one and not
1190
- // the other. Those gaps predate #1253 and it neither causes nor closes
1191
- // any of them; no attempt is made here to enumerate them, because every
1192
- // attempt so far has been incomplete.
1193
- //
1194
- // Scoped to the default converter. A prop supplying its own
1195
- // `converter.fromAttribute` is handled by the arm above and the SSR
1196
- // reader has no converter arm at all, so the two sides already read such
1197
- // a prop differently. That gap predates #1253 and is left alone.
1198
- try { v = value == null ? null : JSON.parse(value); } catch { v = null; }
1199
- } else {
1200
- v = value;
1201
- }
1202
-
1203
- if (this[propName] !== v) {
1204
- this[propName] = v;
1205
- if (this._connected) this.requestUpdate();
1206
- }
1207
- }
1208
-
1209
- /**
1210
- * Schedule a re-render. Optionally record a property change in
1211
- * `changedProperties` so hooks can branch on what changed.
1212
- *
1213
- * @param {string} [name] Property name that changed
1214
- * @param {unknown} [oldValue] Previous value of the property
1215
- */
1216
- requestUpdate(name, oldValue) {
1217
- if (name !== undefined && !this._changedProperties.has(name)) {
1218
- this._changedProperties.set(name, oldValue);
1219
- }
1220
- this._scheduleUpdate();
1221
- }
1222
-
1223
- /**
1224
- * Internal scheduler driven by reactive-property setters, the signal
1225
- * watcher, and explicit `requestUpdate()` calls. Coalesces multiple
1226
- * changes in the same tick into a single microtask render.
1227
- * Manages the `updateComplete` promise lifecycle. Short-circuits when
1228
- * the component is mid-update (assignments during `willUpdate` / `update`
1229
- * fold into the current cycle's `changedProperties` Map).
1230
- * @private
1231
- */
1232
- _scheduleUpdate() {
1233
- if (this._updateResolve === null) {
1234
- this._updatePromise = new Promise((resolve) => {
1235
- this._updateResolve = resolve;
1236
- });
1237
- }
1238
- if (this._isUpdating) return;
1239
- if (this._scheduled || !this._connected) return;
1240
- this._scheduled = true;
1241
- queueMicrotask(() => {
1242
- this._scheduled = false;
1243
- try {
1244
- this._performRender();
1245
- } catch (err) {
1246
- // _performRender wraps the update phase in try/finally so this
1247
- // catches throws from shouldUpdate / willUpdate / hostUpdate /
1248
- // hostUpdated / firstUpdated / updated. The component is not
1249
- // left in a bad state (the finally blocks reset _isUpdating and
1250
- // resolve updateComplete). Surface the error for visibility.
1251
- console.error(`[webjs] lifecycle hook threw during update cycle:`, err);
1252
- }
1253
- });
1254
- }
1255
-
1256
- /**
1257
- * Core update cycle, lit-aligned:
1258
- * 1. Snapshot + clear `changedProperties`
1259
- * 2. `shouldUpdate(changedProperties)`. If false, resolve updateComplete and return.
1260
- * 3. `willUpdate(changedProperties)`. Safe to set properties without re-triggering.
1261
- * 4. Controllers' `hostUpdate()`
1262
- * 5. `update(changedProperties)`. Default impl calls `render()` + commits.
1263
- * Wrapped in error boundary that falls back to `renderError(error)`.
1264
- * 6. Controllers' `hostUpdated()`
1265
- * 7. `firstUpdated(changedProperties)` runs once, on the first render only
1266
- * 8. `updated(changedProperties)` runs after every commit
1267
- * 9. Resolve `updateComplete` promise
1268
- * @private
1269
- */
1270
- _performRender() {
1271
- if (!this._renderRoot) return;
1272
-
1273
- // Hold a stable reference to the current Map so all hooks see the same
1274
- // snapshot. During the update phase (steps 3-6) the Map is mutated in
1275
- // place when property setters fire, folding those changes into THIS
1276
- // cycle. The Map is replaced with a fresh empty one only after a
1277
- // successful commit so post-commit assignments queue the NEXT cycle.
1278
- // On `shouldUpdate=false` or hook errors, the Map is preserved so the
1279
- // accumulated changes survive into the next render.
1280
- const changedProperties = this._changedProperties;
1281
-
1282
- // --- 1. Mark we're inside an update cycle ---
1283
- this._isUpdating = true;
1284
- let didCommit = false;
1285
- // Set when render() was async: the DOM commit lands when this settles,
1286
- // so the post-commit half of the cycle defers until then (#469).
1287
- let pendingCommit = null;
1288
-
1289
- // --- 2-6. Update phase. Lifecycle-hook throws are logged and
1290
- // swallowed so the component is not left in a deadlocked state
1291
- // (`_isUpdating` stuck true, `updateComplete` never resolves).
1292
- try {
1293
- if (this.shouldUpdate(changedProperties)) {
1294
- // --- 3. willUpdate (may mutate properties; folds into this cycle) ---
1295
- this.willUpdate(changedProperties);
1296
-
1297
- // --- 4. controllers' hostUpdate ---
1298
- for (const c of this.__controllers) {
1299
- if (c.hostUpdate) c.hostUpdate();
1300
- }
1301
-
1302
- // --- 5. update + DOM commit (with render-error boundary) ---
1303
- // Stamp a fresh render token for THIS commit, sync or async. A later
1304
- // cycle that reaches here bumps it, so an in-flight async render whose
1305
- // token no longer matches drops its now-stale resolution. Stamping
1306
- // here (not inside _commitAsync) is what guards the async-superseded-
1307
- // by-sync case (#469); a shouldUpdate=false cycle never reaches here,
1308
- // so it does not invalidate an in-flight async render.
1309
- this.__renderToken = (this.__renderToken || 0) + 1;
1310
- // Abort the superseded render's in-flight action fetches (#492), start a
1311
- // fresh controller for this render, and bind it as the active signal so
1312
- // the RPC stub ties its fetches to this render. A superseded fetch's
1313
- // AbortError is dropped by the render-token guard in _commitAsync.
1314
- if (this.__renderAbort) this.__renderAbort.abort();
1315
- this.__renderAbort = typeof AbortController !== 'undefined' ? new AbortController() : null;
1316
- setActiveActionSignal(this.__renderAbort ? this.__renderAbort.signal : null);
1317
- try {
1318
- const r = this.update(changedProperties);
1319
- if (r && typeof r.then === 'function') pendingCommit = r;
1320
- } catch (error) {
1321
- this._handleRenderError(/** @type {Error} */ (error));
1322
- } finally {
1323
- // The synchronous portion of render() has run; actions invoked there
1324
- // already captured the signal. Clear it so a later event handler is
1325
- // not bound to a stale render's controller.
1326
- setActiveActionSignal(null);
1327
- }
1328
-
1329
- if (!pendingCommit) {
1330
- // --- 6. controllers' hostUpdated (sync commit) ---
1331
- for (const c of this.__controllers) {
1332
- if (c.hostUpdated) c.hostUpdated();
1333
- }
1334
- didCommit = true;
1335
- }
1336
- }
1337
- // shouldUpdate=false: preserve _changedProperties so the next
1338
- // requestUpdate keeps accumulating on top of the entries that
1339
- // didn't render this cycle.
1340
- } catch (preCommitError) {
1341
- console.error(`[webjs] lifecycle hook threw during update phase:`, preCommitError);
1342
- } finally {
1343
- this._isUpdating = false;
1344
- // The snapshot is consumed once the commit is DISPATCHED. For an async
1345
- // render it resets here too, so a property change during the in-flight
1346
- // fetch starts a fresh cycle (whose render token supersedes this one).
1347
- if (didCommit || pendingCommit) {
1348
- this._changedProperties = new Map();
1349
- }
1350
- }
1351
-
1352
- // --- Async render: finish the cycle when the pending commit settles. ---
1353
- if (pendingCommit) {
1354
- const token = this.__renderToken;
1355
- // Count this in-flight async commit so a non-committing cycle
1356
- // (shouldUpdate=false) running during the fetch does NOT resolve
1357
- // updateComplete early: the pending commit owns the resolution.
1358
- this.__pendingAsyncCommits = (this.__pendingAsyncCommits || 0) + 1;
1359
- const settle = () => {
1360
- // Always decrement first, even when superseded, so the in-flight
1361
- // count never leaks (a superseded cycle returns below without
1362
- // committing, but it is no longer pending).
1363
- this.__pendingAsyncCommits--;
1364
- // A newer render superseded this one; let the newer cycle finish.
1365
- if (token !== this.__renderToken) return;
1366
- // --- 6. controllers' hostUpdated (after the async commit) ---
1367
- for (const c of this.__controllers) {
1368
- if (c.hostUpdated) c.hostUpdated();
1369
- }
1370
- // --- 7-8 + updateComplete ---
1371
- this._postCommit(changedProperties);
1372
- };
1373
- // A rejection has to settle the cycle too, or the counter stays >= 1
1374
- // forever: that both wedges `updateComplete` for this instance and
1375
- // disables the `!this.__pendingAsyncCommits` escape below for every
1376
- // later non-committing cycle. `_commitAsync` is written not to reject,
1377
- // but it is not the only thenable that reaches here: `update()` is a
1378
- // documented override point and may return one, so the handler belongs
1379
- // at this site rather than only at the one implementation of it.
1380
- pendingCommit.then(settle, (error) => {
1381
- // The token check gates the DOM write, NOT the release. A superseded
1382
- // cycle rejecting late (a discarded fetch, or the #492 abort of its own
1383
- // in-flight action surfacing as an AbortError) must not commit its
1384
- // error state over the newer render that already replaced it, which is
1385
- // the same rule both handlers inside _commitAsync follow. `settle()`
1386
- // still runs either way: it does its own token check before touching
1387
- // anything, and the decrement it owns has to happen regardless or the
1388
- // counter wedges exactly as it did before this handler existed.
1389
- // `finally`, not a trailing statement: the release must survive
1390
- // anything the reporting path throws. `String(error)` is evaluated in
1391
- // the argument expression, OUTSIDE _handleRenderError's own try/catch,
1392
- // so a rejection reason that cannot be coerced (a null-prototype
1393
- // object, a revoked Proxy) threw a TypeError straight out of this
1394
- // handler and skipped the decrement, wedging the counter in exactly
1395
- // the way this handler exists to prevent.
1396
- try {
1397
- if (token === this.__renderToken) {
1398
- this._handleRenderError(error instanceof Error ? error : new Error(safeString(error)));
1399
- }
1400
- } finally {
1401
- settle();
1402
- }
1403
- });
1404
- return;
1405
- }
1406
-
1407
- // --- 7-8. Post-commit hooks (sync path). Errors are caught so the
1408
- // updateComplete promise always resolves.
1409
- if (didCommit) {
1410
- this._postCommit(changedProperties);
1411
- } else if (!this.__pendingAsyncCommits) {
1412
- // A non-committing cycle resolves updateComplete only when no async
1413
- // commit is in flight; otherwise the pending commit resolves it once it
1414
- // lands, so `await el.updateComplete` never returns before that DOM.
1415
- this._resolveUpdate();
1416
- }
1417
- }
1418
-
1419
- /**
1420
- * Resolve the pending updateComplete promise. Value reflects whether any
1421
- * further updates were queued during the current cycle: `true` means the
1422
- * component has settled, `false` means another render will run.
1423
- * @private
1424
- */
1425
- _resolveUpdate() {
1426
- if (this._updateResolve) {
1427
- const settled = this._changedProperties.size === 0;
1428
- this._updateResolve(settled);
1429
- this._updateResolve = null;
1430
- }
1431
- }
1432
-
1433
- /**
1434
- * Run the pre-render half of the update cycle synchronously for SSR.
1435
- *
1436
- * The SSR walker constructs the instance, applies attributes/props, then
1437
- * calls this BEFORE `render()`. It mirrors how lit drives the update
1438
- * lifecycle server-side: `willUpdate` runs so derived state computed there
1439
- * is correct in the SSR'd HTML, then reactive controllers' `hostUpdate`
1440
- * runs, then reflect:true properties are written to attributes so they
1441
- * appear in the serialized output. It deliberately does NOT run
1442
- * `shouldUpdate` (SSR always produces the first paint, there is no prior
1443
- * render to skip), `update` (the walker commits by calling `render`
1444
- * itself), or the post-commit hooks `firstUpdated` / `updated` (browser-only
1445
- * DOM work).
1446
- *
1447
- * `_isUpdating` is set so a property assignment inside `willUpdate` folds
1448
- * into the current `changedProperties` snapshot instead of scheduling a
1449
- * fresh cycle (the SSR scheduler short-circuits anyway, since the element
1450
- * is not connected). Hook throws propagate to the walker's per-component
1451
- * try/catch, which logs an actionable hint and skips DSD for that element,
1452
- * the same as a throwing `render()`.
1453
- */
1454
- performServerUpdate() {
1455
- const changedProperties = this._changedProperties;
1456
- this._isUpdating = true;
1457
- try {
1458
- this.willUpdate(changedProperties);
1459
- for (const c of this.__controllers) {
1460
- if (c.hostUpdate) c.hostUpdate();
1461
- }
1462
- this._reflectDeclaredAttributes();
1463
- } finally {
1464
- this._isUpdating = false;
1465
- }
1466
- }
1467
-
1468
- /**
1469
- * Reflect every reflect:true (non-state) property to its attribute from its
1470
- * CURRENT value, regardless of whether the value arrived via the setter.
1471
- *
1472
- * The property setter only reflects on a connected change, so a value set
1473
- * in the constructor (a default) or in `willUpdate` is not reflected by the
1474
- * setter alone. This syncs those: the SSR walker calls it in
1475
- * `performServerUpdate` (writing into the server attribute shim, which the
1476
- * walker reads back into the rendered HTML), and `_activate` calls it on
1477
- * the client's first connected render. Running it on both sides is what
1478
- * keeps a server-rendered element and a freshly-created client element
1479
- * agree on their reflected attributes (matching lit, which reflects during
1480
- * the first update). `_reflectAttribute`'s re-entrancy guard makes a
1481
- * same-value reflect a no-op, so re-reflecting an attribute that already
1482
- * arrived from SSR does not churn.
1483
- * @private
1484
- */
1485
- _reflectDeclaredAttributes() {
1486
- const props = /** @type {any} */ (this.constructor).properties || {};
1487
- for (const name of Object.keys(props)) {
1488
- const decl = props[name];
1489
- if (!decl || !decl.reflect || decl.state) continue;
1490
- this._reflectAttribute(name, this[name], decl);
1491
- }
1492
- }
1493
-
1494
- // ---------------------------------------------------------------------------
1495
- // Lifecycle hooks: override in subclasses
1496
- // ---------------------------------------------------------------------------
1497
-
1498
- /**
1499
- * Decide whether the component should render in response to a queued
1500
- * update. Default implementation returns `true` (always render).
1501
- *
1502
- * **When to use:** override to skip an update when the relevant inputs
1503
- * haven't changed, e.g. an expensive component that only depends on a
1504
- * subset of its reactive properties.
1505
- *
1506
- * ```js
1507
- * shouldUpdate(changedProperties) {
1508
- * return changedProperties.has('itemCount') || changedProperties.has('mode');
1509
- * }
1510
- * ```
1511
- *
1512
- * @param {Map<string, unknown>} _changedProperties
1513
- * @returns {boolean}
1514
- */
1515
- shouldUpdate(_changedProperties) {
1516
- return true;
1517
- }
1518
-
1519
- /**
1520
- * Run immediately before `update()`. Safe to set reactive properties
1521
- * here without triggering another update cycle: assignments made inside
1522
- * `willUpdate` are folded into the current `changedProperties` snapshot
1523
- * and rendered in the same pass.
1524
- *
1525
- * **When to use:** computing derived values from changed inputs before
1526
- * `render()` reads them. Lit users typically migrate `shouldUpdate`
1527
- * heuristics that mutate state into `willUpdate`.
1528
- *
1529
- * ```js
1530
- * willUpdate(changedProperties) {
1531
- * if (changedProperties.has('items')) {
1532
- * this.totalCount = this.items.length;
1533
- * }
1534
- * }
1535
- * ```
1536
- *
1537
- * @param {Map<string, unknown>} _changedProperties
1538
- */
1539
- willUpdate(_changedProperties) {}
1540
-
1541
- /**
1542
- * The render-and-commit step. Default impl calls `render()` and commits
1543
- * the returned `TemplateResult` to the render root.
1544
- *
1545
- * **When to use:** rarely. Override only when you need to wrap or
1546
- * short-circuit the commit. Most users override `render()` instead.
1547
- * If you do override, you MUST commit something to `this._renderRoot`
1548
- * (or call `super.update(changedProperties)`).
1549
- *
1550
- * @param {Map<string, unknown>} _changedProperties
1551
- */
1552
- update(_changedProperties) {
1553
- // Track signal reads during render so the component re-renders when
1554
- // any of them change. The watcher is lazy-allocated on first
1555
- // render and disposed in disconnectedCallback. observe() clears
1556
- // prior dep edges before fn(), so each render re-records its own
1557
- // (possibly different) read set.
1558
- if (!this.__signalWatcher) {
1559
- this.__signalWatcher = new Signal.subtle.Watcher(() => {
1560
- if (this._connected) this.requestUpdate();
1561
- });
1562
- }
1563
- let tpl;
1564
- this.__signalWatcher.observe(() => { tpl = this.render(); });
1565
- // Bare-await async render (#469): render() returned a promise. Use
1566
- // stale-while-revalidate: keep the current DOM (the SSR first paint on
1567
- // hydration, or the prior content on a re-fetch) visible until the new
1568
- // template resolves, then commit it. Returns the pending promise so
1569
- // _performRender defers the post-commit half of the cycle (hostUpdated,
1570
- // firstUpdated/updated, updateComplete) until the real commit lands.
1571
- // (Note: only signal reads BEFORE the first `await` are tracked; reads
1572
- // after a suspension point do not establish reactive dependencies.)
1573
- if (tpl && typeof (/** @type any */ (tpl).then) === 'function') {
1574
- return this._commitAsync(/** @type {Promise<unknown>} */ (tpl));
1575
- }
1576
- clientRender(tpl, this._renderRoot);
1577
- return undefined;
1578
- }
1579
-
1580
- /**
1581
- * Commit a promise-returning render() with stale-while-revalidate
1582
- * semantics (#469). The current DOM stays untouched until the template
1583
- * resolves; on a client RE-FETCH an author-defined renderFallback()
1584
- * optionally swaps in a loading state first. The cycle's render token
1585
- * (stamped by _performRender before update() ran) drops a superseded
1586
- * resolution so an out-of-order fetch never commits stale DOM, INCLUDING
1587
- * when the superseding render is synchronous (a later cycle re-stamps the
1588
- * token whether or not it is async). A rejection routes to the
1589
- * renderError() boundary, isolated to this component. Returns a promise
1590
- * that settles once the commit (or its error / fallback) has been applied.
1591
- *
1592
- * @param {Promise<unknown>} pending
1593
- * @returns {Promise<void>}
1594
- * @private
1595
- */
1596
- _commitAsync(pending) {
1597
- const token = this.__renderToken;
1598
- // First paint (hydration): keep the SSR DOM, never a fallback, so
1599
- // first-paint data stays visible with no skeleton flash. Re-fetch: an
1600
- // author-defined renderFallback() overrides the stale-while-revalidate
1601
- // default with an explicit loading state.
1602
- if (this.__firstRendered && this._overridesRenderFallback()) {
1603
- try {
1604
- const fb = this.renderFallback();
1605
- if (fb !== undefined) clientRender(fb, this._renderRoot);
1606
- } catch (e) {
1607
- console.error(`[webjs] renderFallback() threw:`, e);
1608
- }
1609
- }
1610
- return Promise.resolve(pending).then(
1611
- (tpl) => {
1612
- if (token !== this.__renderToken) return; // superseded by a newer render
1613
- // The COMMIT throws too, not just the fetch, and a sibling rejection
1614
- // handler cannot see it: `.then(onFulfil, onRejected)` never routes
1615
- // onFulfil's own throw to onRejected. Left uncaught, the returned
1616
- // promise rejected and _performRender's `.then` had no rejection
1617
- // handler, so the error escaped as an unhandled rejection,
1618
- // renderError() never ran, __pendingAsyncCommits never decremented
1619
- // (wedging it >= 1 forever, which also disables the non-committing
1620
- // cycle's _resolveUpdate escape), and updateComplete never settled.
1621
- // The sync path routes an identical throw to the same boundary, so
1622
- // this is what makes the two paths agree.
1623
- try {
1624
- clientRender(tpl, this._renderRoot);
1625
- } catch (commitError) {
1626
- this._handleRenderError(
1627
- commitError instanceof Error ? commitError : new Error(String(commitError)),
1628
- );
1629
- }
1630
- },
1631
- (error) => {
1632
- if (token !== this.__renderToken) return;
1633
- this._handleRenderError(error instanceof Error ? error : new Error(String(error)));
1634
- },
1635
- );
1636
- }
1637
-
1638
- /**
1639
- * Whether the subclass overrides renderFallback() (vs the inert default).
1640
- * @returns {boolean}
1641
- * @private
1642
- */
1643
- _overridesRenderFallback() {
1644
- return this.renderFallback !== WebComponent.prototype.renderFallback;
1645
- }
1646
-
1647
- /**
1648
- * Client-side render-error boundary, shared by the sync and async commit
1649
- * paths. Logs, then commits renderError()'s output (if any) so one broken
1650
- * component does not crash the page.
1651
- * @param {Error} error
1652
- * @private
1653
- */
1654
- _handleRenderError(error) {
1655
- console.error(
1656
- `[webjs] render error in <${tagOf(/** @type any */ (this.constructor)) || this.tagName?.toLowerCase()}>:`,
1657
- error,
1658
- );
1659
- try {
1660
- const fallback = this.renderError(error);
1661
- if (fallback !== undefined) clientRender(fallback, this._renderRoot);
1662
- } catch (fallbackError) {
1663
- console.error(`[webjs] renderError() also threw:`, fallbackError);
1664
- }
1665
- }
1666
-
1667
- /**
1668
- * Post-commit half of the update cycle (steps 7-8 + updateComplete),
1669
- * shared by the sync path and the async (post-resolution) path.
1670
- * @param {Map<string, unknown>} changedProperties
1671
- * @private
1672
- */
1673
- _postCommit(changedProperties) {
1674
- try {
1675
- if (!this.__firstRendered) {
1676
- this.__firstRendered = true;
1677
- this.firstUpdated(changedProperties);
1678
- }
1679
- this.updated(changedProperties);
1680
- } catch (postCommitError) {
1681
- console.error(`[webjs] lifecycle hook threw during post-commit phase:`, postCommitError);
1682
- } finally {
1683
- this._resolveUpdate();
1684
- }
1685
- }
1686
-
1687
- /**
1688
- * Called after every render commit (both the first and all subsequent
1689
- * renders). Receives the `changedProperties` Map so you can branch on
1690
- * what changed this cycle.
1691
- *
1692
- * **When to use:** post-render DOM work that depends on the new DOM,
1693
- * triggered by specific property changes. This is the lit-aligned
1694
- * replacement for ad-hoc `requestAnimationFrame` shims that components
1695
- * historically used to defer DOM work after `render()`.
1696
- *
1697
- * ```js
1698
- * updated(changedProperties) {
1699
- * if (changedProperties.has('open') && this.open) {
1700
- * this.querySelector('input')?.focus();
1701
- * }
1702
- * }
1703
- * ```
1704
- *
1705
- * @param {Map<string, unknown>} _changedProperties
1706
- */
1707
- updated(_changedProperties) {}
1708
-
1709
- /**
1710
- * Called exactly once, after the component's very first render completes
1711
- * and the DOM is live. Receives the same `changedProperties` Map that
1712
- * `updated()` does for the first render; entries reflect the initial
1713
- * values of reactive properties (old values are `undefined`).
1714
- *
1715
- * **When to use:** one-time post-render setup that requires DOM access.
1716
- * Auto-focusing an input, measuring layout, initializing a third-party
1717
- * library on a DOM node. `connectedCallback` fires before the first
1718
- * render, so querying shadow children there yields nothing.
1719
- *
1720
- * ```js
1721
- * firstUpdated(changedProperties) {
1722
- * this.shadowRoot.querySelector('input')?.focus();
1723
- * }
1724
- * ```
1725
- *
1726
- * @param {Map<string, unknown>} _changedProperties
1727
- */
1728
- firstUpdated(_changedProperties) {}
1729
-
1730
- /**
1731
- * A Promise that resolves after the next render commit. Resolves to
1732
- * `true` when the component has settled (no further updates queued),
1733
- * `false` if another render is already scheduled.
1734
- *
1735
- * **When to use:** awaiting a re-render in tests, or in user code that
1736
- * needs to read the post-render DOM after triggering an update.
1737
- *
1738
- * ```js
1739
- * el.count = 5;
1740
- * await el.updateComplete;
1741
- * // DOM now reflects count = 5
1742
- * ```
1743
- *
1744
- * @returns {Promise<boolean>}
1745
- */
1746
- get updateComplete() {
1747
- return this.getUpdateComplete();
1748
- }
1749
-
1750
- /**
1751
- * Override point for `updateComplete`. Default returns the internal
1752
- * update promise. Override to await additional async work that should
1753
- * be considered part of the update cycle (e.g. lazy-loaded subcomponents).
1754
- *
1755
- * ```js
1756
- * async getUpdateComplete() {
1757
- * const result = await super.getUpdateComplete();
1758
- * await this._chart?.updateComplete;
1759
- * return result;
1760
- * }
1761
- * ```
1762
- *
1763
- * @returns {Promise<boolean>}
1764
- */
1765
- getUpdateComplete() {
1766
- return this._updatePromise;
1767
- }
1768
-
1769
- // ---------------------------------------------------------------------------
1770
- // Reactive controllers
1771
- // ---------------------------------------------------------------------------
1772
-
1773
- /**
1774
- * Register a {@link ReactiveController} with this component.
1775
- *
1776
- * **When to use:** call this from your controller's constructor (which
1777
- * typically receives the host as its first argument) or from the
1778
- * component's constructor / `connectedCallback`.
1779
- *
1780
- * **Why it exists:** controllers decouple reusable lifecycle behaviour
1781
- * from the class hierarchy. Instead of extending a base class or using
1782
- * a mixin, you compose controllers:
1783
- *
1784
- * ```js
1785
- * class MouseController {
1786
- * constructor(host) {
1787
- * this.host = host;
1788
- * host.addController(this);
1789
- * }
1790
- * hostConnected() { window.addEventListener('mousemove', this._onMove); }
1791
- * hostDisconnected() { window.removeEventListener('mousemove', this._onMove); }
1792
- * }
1793
- * ```
1794
- *
1795
- * If the host is already connected when the controller is added, the
1796
- * controller's `hostConnected()` is called immediately.
1797
- *
1798
- * @param {ReactiveController} controller
1799
- */
1800
- addController(controller) {
1801
- this.__controllers.add(controller);
1802
- if (this._connected && controller.hostConnected) {
1803
- controller.hostConnected();
1804
- }
1805
- }
1806
-
1807
- /**
1808
- * Unregister a previously added {@link ReactiveController}.
1809
- *
1810
- * **When to use:** call this when a controller's lifetime is shorter
1811
- * than the component's: e.g. a controller that tracks a specific
1812
- * resource and should be swapped out when the resource changes.
1813
- *
1814
- * The controller's `hostDisconnected()` is NOT called by `removeController`;
1815
- * if cleanup is needed, call it yourself before removing.
1816
- *
1817
- * @param {ReactiveController} controller
1818
- */
1819
- removeController(controller) {
1820
- this.__controllers.delete(controller);
1821
- }
1822
-
1823
- /**
1824
- * Override in subclasses to return a TemplateResult.
1825
- * @returns {unknown}
1826
- */
1827
- render() {
1828
- return '';
1829
- }
1830
-
1831
- /**
1832
- * Called when `render()` throws an error on the client side.
1833
- *
1834
- * **When to override (AI hint):** Override this to show a fallback UI
1835
- * when a component's render fails. Without this, the error is logged
1836
- * and the component renders nothing. The default implementation returns
1837
- * `undefined` (empty render).
1838
- *
1839
- * **Why it exists:** Client-side error boundary. Prevents one broken
1840
- * component from crashing the entire page. Similar to React's
1841
- * `componentDidCatch` / Error Boundaries.
1842
- *
1843
- * ```js
1844
- * renderError(error) {
1845
- * return html`<p style="color:red">Something went wrong: ${error.message}</p>`;
1846
- * }
1847
- * ```
1848
- *
1849
- * @param {Error} error The error thrown by render().
1850
- * @returns {unknown} A TemplateResult fallback, or undefined for empty.
1851
- */
1852
- renderError(error) {
1853
- return undefined;
1854
- }
1855
-
1856
- /**
1857
- * Optional loading UI for an async render() (#469), shown ONLY during a
1858
- * client RE-FETCH (a prop / dependency change re-runs `async render()`),
1859
- * NEVER on the first paint.
1860
- *
1861
- * **When to override (AI hint):** the client re-fetch default is
1862
- * stale-while-revalidate (the component keeps showing its current content
1863
- * until the new render resolves). Override `renderFallback()` only when you
1864
- * want a loading state (skeleton / spinner) shown during the re-fetch
1865
- * instead of the stale content, e.g. when stale data would mislead.
1866
- *
1867
- * **What it does NOT do:** it does not affect the first paint (SSR always
1868
- * blocks and bakes real data in), and it does NOT create a server-streaming
1869
- * boundary. To show a first-paint fallback for slow data, wrap the component
1870
- * in `<webjs-suspense .fallback=${...}>` instead. It is a prop-aware method,
1871
- * not a static field, so it can branch on the component's current state.
1872
- *
1873
- * ```js
1874
- * renderFallback() { return html`<div class="skeleton h-24"></div>`; }
1875
- * async render() { const u = await getUser(this.id); return html`<h3>${u.name}</h3>`; }
1876
- * ```
1877
- *
1878
- * @returns {unknown} A TemplateResult loading state, or undefined to keep
1879
- * the stale-while-revalidate default.
1880
- */
1881
- renderFallback() {
1882
- return undefined;
1883
- }
1884
- }
1885
-
1886
- /** @param {string} s */
1887
- function hyphenate(s) {
1888
- return s.replace(/([A-Z])/g, '-$1').toLowerCase();
1889
- }
1890
- /** @param {string} s */
1891
- function camelCase(s) {
1892
- return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1893
- }
1894
-
1895
- /**
1896
- * Dual-role WebComponent class and factory.
1897
- * Can be extended directly: `class X extends WebComponent`
1898
- * Or called as a class factory: `class X extends WebComponent({ count: Number })`
1899
- *
1900
- * @param {Record<string, any>} [properties]
1901
- * @returns {any}
1902
- */
1903
- export function WebComponent(properties) {
1904
- if (new.target) {
1905
- return Reflect.construct(WebComponentBase, arguments, new.target);
1906
- } else {
1907
- return class extends WebComponentBase {
1908
- static properties = properties;
1909
- static [FACTORY_PROPS] = true;
1910
- };
1911
- }
1912
- }
1913
-
1914
- // Ensure static inheritance and instanceof checks work
1915
- Object.setPrototypeOf(WebComponent, WebComponentBase);
1916
- WebComponent.prototype = WebComponentBase.prototype;
1917
-
1918
- /**
1919
- * Helper to define properties with custom options.
1920
- *
1921
- * @param {any} [type]
1922
- * @param {any} [opts]
1923
- * @returns {any}
1924
- */
1925
- export function prop(type, opts = {}) {
1926
- if (type && typeof type === 'object' && !('call' in type)) {
1927
- opts = type;
1928
- type = undefined;
1929
- }
1930
- return { ...(type ? { type } : {}), ...opts };
1931
- }
1
+ export { WebComponent } from './component/lifecycle.js';
2
+ export { prop } from './component/reactive.js';