@webjsdev/core 0.7.65 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +5 -0
  2. package/dist/webjs-core-browser.js +6 -5
  3. package/dist/webjs-core-browser.js.map +3 -3
  4. package/dist/webjs-core-testing.js +22 -21
  5. package/dist/webjs-core-testing.js.map +3 -3
  6. package/dist/webjs-core.js +7 -6
  7. package/dist/webjs-core.js.map +3 -3
  8. package/index-browser.js +18 -9
  9. package/index.d.ts +26 -10
  10. package/index.js +21 -9
  11. package/package.json +15 -2
  12. package/src/action-effects-client.d.ts +5 -0
  13. package/src/action-effects-client.js +84 -0
  14. package/src/action-seed-client.js +5 -5
  15. package/src/asset-url.js +12 -13
  16. package/src/async-directive.d.ts +18 -0
  17. package/src/async-directive.js +88 -0
  18. package/src/attribute-directives.d.ts +6 -0
  19. package/src/attribute-directives.js +49 -0
  20. package/src/attribute-reader.js +213 -25
  21. package/src/binding-prefixes.js +10 -53
  22. package/src/component/lifecycle.js +921 -252
  23. package/src/component/reactive.js +7 -14
  24. package/src/component/server-element.js +23 -1
  25. package/src/component.d.ts +160 -44
  26. package/src/context.d.ts +94 -18
  27. package/src/context.js +341 -217
  28. package/src/converters.d.ts +7 -0
  29. package/src/converters.js +38 -0
  30. package/src/css.d.ts +32 -6
  31. package/src/css.js +178 -14
  32. package/src/directive.d.ts +58 -0
  33. package/src/directive.js +119 -0
  34. package/src/directives.d.ts +40 -1
  35. package/src/directives.js +160 -1
  36. package/src/form-action.d.ts +13 -0
  37. package/src/form-action.js +139 -4
  38. package/src/hot.js +23 -0
  39. package/src/html.d.ts +8 -0
  40. package/src/html.js +55 -2
  41. package/src/is-server.d.ts +2 -0
  42. package/src/is-server.js +18 -0
  43. package/src/jsx-guard.js +130 -0
  44. package/src/metadata.d.ts +22 -3
  45. package/src/nav.d.ts +4 -1
  46. package/src/nav.js +44 -11
  47. package/src/optimistic.js +2 -2
  48. package/src/refresh-header.js +52 -0
  49. package/src/registry.js +10 -1
  50. package/src/render-client/adopt-slots.js +250 -0
  51. package/src/render-client/parts.js +790 -42
  52. package/src/render-client/reconciler.js +84 -23
  53. package/src/render-client/template-compiler.js +152 -43
  54. package/src/render-client.d.ts +6 -1
  55. package/src/render-server/attrs.js +2 -2
  56. package/src/render-server/compile.js +50 -2
  57. package/src/render-server/errors.js +37 -0
  58. package/src/render-server/expand.js +100 -20
  59. package/src/render-server/served-attrs.js +78 -0
  60. package/src/render-server/walk.js +177 -5
  61. package/src/render-server.d.ts +3 -0
  62. package/src/render-server.js +2 -0
  63. package/src/repeat.d.ts +7 -2
  64. package/src/repeat.js +20 -6
  65. package/src/router-client/anchors.js +3 -1
  66. package/src/router-client/boundaries.js +15 -1
  67. package/src/router-client/constants.js +30 -0
  68. package/src/router-client/dom-differ.js +17 -0
  69. package/src/router-client/fetch-apply.js +107 -5
  70. package/src/router-client/frames.js +6 -3
  71. package/src/router-client/head-merge.js +58 -10
  72. package/src/router-client/navigator.js +49 -9
  73. package/src/router-client/owned-styles.js +372 -0
  74. package/src/router-client/prefetch.js +6 -4
  75. package/src/router-client/root-attrs.js +43 -0
  76. package/src/router-client/stream.js +7 -0
  77. package/src/router-client/stylesheets.js +191 -0
  78. package/src/router-client/swap.js +53 -10
  79. package/src/routes.d.ts +75 -18
  80. package/src/sentinels.d.ts +2 -0
  81. package/src/sentinels.js +4 -0
  82. package/src/serialize.d.ts +2 -0
  83. package/src/serialize.js +35 -4
  84. package/src/signal.d.ts +7 -0
  85. package/src/signal.js +26 -6
  86. package/src/slot/polyfills.js +3 -3
  87. package/src/slot.js +4 -8
  88. package/src/static-html.d.ts +22 -0
  89. package/src/static-html.js +144 -0
  90. package/src/task.d.ts +63 -20
  91. package/src/task.js +326 -293
  92. package/src/webjs-config.d.ts +2 -9
@@ -1,11 +1,15 @@
1
1
  import { render as clientRender } from '../render-client.js';
2
- import { readAttributeValue, resolveAttributeProperty, observedAttributeNames } from '../attribute-reader.js';
2
+ import {
3
+ readAttributeValue, resolveAttributeProperty, observedAttributeNames, declaredProperties,
4
+ normalizeDecl, addCreatedProperty, attributeNameFor, declarationsEpoch,
5
+ } from '../attribute-reader.js';
3
6
  import { setActiveActionSignal, activeActionSignal } from '../action-abort-client.js';
4
7
  import { carriesFunction } from '../form-action.js';
5
- import { isCSS, adoptStyles } from '../css.js';
8
+ import { adoptStyles, finalizeStyles, lightDomStylesMessage } from '../css.js';
6
9
  import { register, tagOf } from '../registry.js';
7
10
  import { parse as deserializeProp } from '../serialize.js';
8
11
  import { Signal } from '../signal.js';
12
+ import { noChange } from '../sentinels.js';
9
13
  import {
10
14
  captureAuthoredChildren,
11
15
  adoptSSRAssignments,
@@ -72,9 +76,15 @@ const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undef
72
76
  *
73
77
  * @property {boolean} [reflect]
74
78
  * When `true`, writing to the property also sets the corresponding
75
- * HTML attribute on the element (kebab-cased). Useful when you want
79
+ * HTML attribute on the element (Lit's lowercased property name unless
80
+ * `attribute` names one). Useful when you want
76
81
  * CSS attribute selectors like `my-el[mode="dark"]` to work.
77
82
  *
83
+ * @property {boolean} [useDefault]
84
+ * Lit's `useDefault`: the property's first value is its default. That
85
+ * value is not reflected, and removing the attribute restores it instead
86
+ * of the converter's `null`.
87
+ *
78
88
  * @property {boolean} [state]
79
89
  * When `true`, the property is *internal-only*: it is NOT exposed as an
80
90
  * HTML attribute (excluded from `observedAttributes`) and never reflects.
@@ -84,7 +94,7 @@ const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undef
84
94
  * @property {(newValue: unknown, oldValue: unknown) => boolean} [hasChanged]
85
95
  * Custom dirty-check function. Called by the generated setter before
86
96
  * scheduling an update. Return `true` to trigger a re-render, `false`
87
- * to skip. Default: strict inequality `(a, b) => a !== b`.
97
+ * to skip. Default: `(a, b) => !Object.is(a, b)`.
88
98
  *
89
99
  * Note: this fires on the FIRST assignment too, with `oldValue` set
90
100
  * to `undefined`. Numeric comparators that subtract against undefined
@@ -105,15 +115,6 @@ const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undef
105
115
  // Base class choice: real HTMLElement on the browser, the shim on the server.
106
116
  const Base = isBrowser ? HTMLElement : /** @type {any} */ (ServerElement);
107
117
 
108
- /**
109
- * Marker stamped on the anonymous subclass the `WebComponent({...})` factory
110
- * produces. It lets `_assertFactoryProperties` tell the framework's own
111
- * factory-generated `static properties` (allowed) apart from a `static
112
- * properties` a user wrote by hand in a class body (no longer allowed).
113
- */
114
- const FACTORY_PROPS = Symbol('webjs.factoryProps');
115
- // Per-class memo so the constructor-time enforcement walk runs once per class.
116
- const _propsChecked = new WeakSet();
117
118
 
118
119
  /** A settled `updateComplete` shared by every instance until its first update. */
119
120
  const RESOLVED_TRUE = Promise.resolve(true);
@@ -127,48 +128,142 @@ const NO_CONTROLLERS = new Set();
127
128
  */
128
129
  const _accessorPlans = new WeakMap();
129
130
 
131
+ /** Classes whose `finalize()` has run. @type {WeakSet<Function>} */
132
+ const FINALIZED = new WeakSet();
133
+
134
+ /**
135
+ * Per class, the memoized `elementProperties` Map: valid while no class's
136
+ * declarations changed (`declarationsEpoch`), and after a change while the
137
+ * `declaredProperties` map it was built from is the same object, which it
138
+ * stays unless this class or a superclass changed (a `createProperty`).
139
+ * @type {WeakMap<Function, { epoch: number, merged: Record<string, any>, map: Map<string, any> }>}
140
+ */
141
+ const ELEMENT_PROPERTIES = new WeakMap();
142
+
143
+ /**
144
+ * Classes with a subclass whose accessors are installed (an instance of it was
145
+ * constructed). `createProperty` on one of them would reach the subclass's
146
+ * declarations but never its installed accessors, so it is refused, as it is
147
+ * for the class itself.
148
+ * @type {WeakSet<Function>}
149
+ */
150
+ const HAS_CONSTRUCTED_SUBCLASS = new WeakSet();
151
+
152
+ /** Per class, the memoized `elementStyles` list. @type {WeakMap<Function, any[]>} */
153
+ const ELEMENT_STYLES = new WeakMap();
154
+
155
+ /** Light-DOM classes already told that `static styles` needs shadow DOM. @type {WeakSet<Function>} */
156
+ const WARNED_LIGHT_STYLES = new WeakSet();
157
+
158
+ /** Controller classes whose `hostUpdate()` already threw at SSR. @type {WeakSet<Function>} */
159
+ const REPORTED_SSR_CONTROLLERS = new WeakSet();
160
+
161
+ /** What `getPropertyOptions()` answers for an undeclared name, Lit's default declaration. */
162
+ const DEFAULT_DECLARATION = Object.freeze({ type: String });
163
+
164
+ /**
165
+ * The setters WebJs generated, mapped to the author accessor each one wraps
166
+ * (or `null` for the built-in per-instance storage). A subclass constructed
167
+ * after its parent finds the parent's generated pair first; this map lets it
168
+ * wrap the author's accessor underneath instead of mistaking the wrapper for
169
+ * one.
170
+ * @type {WeakMap<Function, { get?: () => unknown, set?: (v: unknown) => void } | null>}
171
+ */
172
+ const GENERATED_SETTERS = new WeakMap();
173
+
174
+ /**
175
+ * The nearest author-written accessor for `name` on `Cls`'s prototype chain,
176
+ * below the WebJs base: what Lit's `getPropertyDescriptor` wraps when a class
177
+ * declares a reactive property AND defines its own `get` / `set` for it.
178
+ * Returns null when there is none (the property uses built-in storage).
179
+ * @param {any} Cls
180
+ * @param {string} name
181
+ */
182
+ function authorAccessor(Cls, name) {
183
+ for (let p = Cls.prototype; p && p !== WebComponentBase.prototype; p = Object.getPrototypeOf(p)) {
184
+ const d = Object.getOwnPropertyDescriptor(p, name);
185
+ if (!d) continue;
186
+ if (d.set && GENERATED_SETTERS.has(d.set)) return GENERATED_SETTERS.get(d.set) ?? null;
187
+ if (d.get || d.set) return { get: d.get, set: d.set };
188
+ return null;
189
+ }
190
+ return null;
191
+ }
192
+
130
193
  /**
131
194
  * Define the getter/setter pair for every declared property on the class's
132
195
  * prototype, once, and record the plan the constructor applies per instance
133
- * (own-value capture and `default`). The setter coerces nothing itself (the
134
- * attribute readers do), runs `hasChanged`, reflects, and schedules an update.
196
+ * (own-value capture and `default`). The descriptor comes from the class's
197
+ * `static getPropertyDescriptor()`, Lit's override point, so a subclass can
198
+ * wrap it.
135
199
  * @param {any} Ctor
136
200
  * @param {Record<string, unknown>} props
137
201
  */
138
202
  function installAccessors(Ctor, props) {
139
203
  /** @type {{ propName: string, decl: any }[]} */
140
204
  const plan = [];
141
- for (const [propName, decl] of Object.entries(props)) {
142
- const d = typeof decl === 'object' && decl !== null ? decl : { type: decl };
143
- plan.push({ propName, decl: d });
144
- const changedCheck = d.hasChanged || defaultHasChanged;
145
- Object.defineProperty(Ctor.prototype, propName, {
146
- configurable: true,
147
- enumerable: true,
148
- get() { return this.__propValues[propName]; },
149
- set(newVal) {
150
- const oldVal = this.__propValues[propName];
151
- if (!changedCheck(newVal, oldVal)) return;
152
- this.__propValues[propName] = newVal;
153
- // Reflect to attribute if requested (and not internal state).
154
- if (d.reflect && !d.state && this._connected) {
155
- this._reflectAttribute(propName, newVal, d);
156
- }
157
- // requestUpdate records the (name, oldValue) entry AND schedules a
158
- // render. During the update phase the scheduler short-circuits and
159
- // the entry folds into the current cycle's changedProperties.
160
- this.requestUpdate(propName, oldVal);
161
- },
162
- });
205
+ for (const propName of Reflect.ownKeys(props)) {
206
+ const decl = props[propName];
207
+ const d = normalizeDecl(decl);
208
+ // `noAccessor` (Lit): the author's own accessor stays and calls
209
+ // `requestUpdate()` itself. A wrapped author accessor owns its storage, so
210
+ // the constructor does not apply a `default` to it (its private fields may
211
+ // not exist yet); a pre-upgrade own value is assigned through the accessor
212
+ // at the first update, as for every declared property.
213
+ const author = authorAccessor(Ctor, propName);
214
+ const builtIn = !d.noAccessor && author === null;
215
+ // Lit's `wrapped`: the property sits on an author accessor, whose initial
216
+ // value the first update records (see `_prepareFirstUpdate`).
217
+ const key = Symbol(String(propName));
218
+ plan.push({ propName, decl: d, builtIn, wrapped: author !== null, key });
219
+ if (d.noAccessor) continue;
220
+ const descriptor = Ctor.getPropertyDescriptor(propName, key, d);
221
+ if (descriptor !== undefined) Object.defineProperty(Ctor.prototype, propName, descriptor);
163
222
  }
164
223
  _accessorPlans.set(Ctor, plan);
224
+ for (let p = Object.getPrototypeOf(Ctor); p && p !== Function.prototype; p = Object.getPrototypeOf(p)) {
225
+ HAS_CONSTRUCTED_SUBCLASS.add(p);
226
+ }
165
227
  return plan;
166
228
  }
167
229
 
230
+
231
+ /**
232
+ * The declarative CLOSED shadow root the parser attached to `el`, through
233
+ * `ElementInternals.shadowRoot`, or null when there is none or internals are
234
+ * unavailable. `attachInternals()` may be called once per element, so the
235
+ * internals object is handed back to the component's own later call instead
236
+ * of that call throwing. A component that already took its internals (in its
237
+ * constructor, the form-associated pattern) makes this throw, and then the
238
+ * caller falls back to attachShadow(), Lit's behaviour.
239
+ * @param {HTMLElement} el
240
+ * @returns {ShadowRoot | null}
241
+ */
242
+ function declarativeClosedRoot(el) {
243
+ if (typeof el.attachInternals !== 'function') return null;
244
+ let internals;
245
+ try {
246
+ internals = el.attachInternals();
247
+ } catch {
248
+ return null;
249
+ }
250
+ Object.defineProperty(el, 'attachInternals', { value: () => internals, configurable: true, writable: true });
251
+ return internals.shadowRoot ?? null;
252
+ }
253
+
168
254
  class WebComponentBase extends Base {
169
255
  /** Whether to use shadow DOM. Default: false (light DOM). @type {boolean} */
170
256
  static shadow = false;
171
257
 
258
+ /**
259
+ * Lit's `shadowRootOptions`: the options `createRenderRoot()` passes to
260
+ * `attachShadow()`, and the attributes the server writes on the Declarative
261
+ * Shadow DOM template (`delegatesFocus`, `clonable`, `serializable`, and a
262
+ * `closed` mode). Read only with `static shadow = true`.
263
+ * @type {ShadowRootInit}
264
+ */
265
+ static shadowRootOptions = { mode: 'open' };
266
+
172
267
  /**
173
268
  * Hydration strategy for this component.
174
269
  *
@@ -196,19 +291,49 @@ class WebComponentBase extends Base {
196
291
  * that trigger re-renders on change, coerce attribute values by type,
197
292
  * and optionally reflect back to attributes.
198
293
  *
199
- * Properties with `state: true` are excluded from `observedAttributes`
200
- * and never reflect: they behave like private reactive state.
294
+ * Properties with `state: true` (internal state) or `attribute: false` (a
295
+ * property-only input) are excluded from `observedAttributes` and never
296
+ * reflect.
201
297
  *
202
298
  * @type {Record<string, PropertyDeclaration>}
203
299
  */
204
300
  static properties = {};
205
301
 
206
302
  /**
207
- * Styles to adopt into the shadow root.
208
- * @type {import('../css.js').CSSResult | import('../css.js').CSSResult[] | null}
303
+ * Styles to adopt into the shadow root, Lit's `static styles`: a `css`
304
+ * result, a `CSSStyleSheet`, or nested arrays of them. Shadow DOM only
305
+ * (`static shadow = true`); a light-DOM component has no root to adopt
306
+ * them into, so declaring them there is reported as an error.
307
+ * @type {unknown}
209
308
  */
210
309
  static styles = null;
211
310
 
311
+ /**
312
+ * Lit's `elementStyles`: `static styles` flattened by `finalizeStyles()`,
313
+ * the list `createRenderRoot()` adopts and the server inlines. Memoized per
314
+ * class.
315
+ * @type {(import('../css.js').CSSResult | CSSStyleSheet)[]}
316
+ */
317
+ static get elementStyles() {
318
+ let list = ELEMENT_STYLES.get(this);
319
+ if (list === undefined) {
320
+ list = this.finalizeStyles(this.styles);
321
+ ELEMENT_STYLES.set(this, list);
322
+ }
323
+ return list;
324
+ }
325
+
326
+ /**
327
+ * Lit's `finalizeStyles(styles)`: flatten nested arrays and drop
328
+ * duplicates, keeping each style's LAST position so a later override still
329
+ * wins. Override it to post-process the list.
330
+ * @param {unknown} styles
331
+ * @returns {(import('../css.js').CSSResult | CSSStyleSheet)[]}
332
+ */
333
+ static finalizeStyles(styles) {
334
+ return finalizeStyles(styles);
335
+ }
336
+
212
337
  /**
213
338
  * Register this class as a custom element under `tag`.
214
339
  *
@@ -226,27 +351,210 @@ class WebComponentBase extends Base {
226
351
  register(tag, this);
227
352
  }
228
353
 
354
+ /**
355
+ * The node `render()` commits into, Lit's `renderRoot`: the element itself
356
+ * for a light-DOM component, its shadow root for `static shadow = true`.
357
+ * Undefined until the element first connects, and always undefined at SSR,
358
+ * which has no DOM. Query rendered children through it
359
+ * (`this.renderRoot.querySelector('input')`) so the same line works in
360
+ * either mode.
361
+ * @type {HTMLElement | ShadowRoot | DocumentFragment | undefined}
362
+ */
363
+ get renderRoot() {
364
+ return this._renderRoot;
365
+ }
366
+
367
+ set renderRoot(value) {
368
+ this._renderRoot = value;
369
+ }
370
+
371
+ /**
372
+ * True once the component has completed its first update, Lit's
373
+ * `hasUpdated`. It is still false inside the first `willUpdate()` and
374
+ * `render()`, and true from `firstUpdated()` on.
375
+ * @type {boolean}
376
+ */
377
+ get hasUpdated() {
378
+ return !!this.__firstRendered;
379
+ }
380
+
381
+ /**
382
+ * True while an update is queued or running, Lit's `isUpdatePending`.
383
+ * @type {boolean}
384
+ */
385
+ get isUpdatePending() {
386
+ return this._scheduled || this._isUpdating || this._initialUpdatePending;
387
+ }
388
+
229
389
  /**
230
390
  * Returns the list of attribute names the browser should observe.
231
- * Properties with `state: true` are excluded: they are internal-only
232
- * and do not correspond to any HTML attribute.
391
+ * Properties with `state: true` or `attribute: false` are excluded: they
392
+ * do not correspond to any HTML attribute.
233
393
  *
234
394
  * @returns {string[]}
235
395
  */
236
396
  static get observedAttributes() {
397
+ this.finalize();
237
398
  // Derived from the same walk `resolveAttributeProperty` matches against, so
238
399
  // every observed name resolves and nothing else does. A camelCase prop with
239
- // no custom `attribute` is observed under its kebab name AND its lowercased
240
- // name (lit's default), since the parser turns `quizId=` into `quizid`
241
- // (#1540).
400
+ // no custom `attribute` is observed under Lit's lowercased name (what it
401
+ // reflects to, and what the parser turns `quizId=` into) AND its kebab
402
+ // name, kept as a read alias.
242
403
  return observedAttributeNames(this);
243
404
  }
244
405
 
406
+ /**
407
+ * Lit's `elementProperties`: every reactive property this class declares,
408
+ * its own and its superclasses', as a Map of name to normalised
409
+ * declaration. Built from the same merge the accessors and the attribute
410
+ * readers use (`static properties`, the factory, `createProperty`).
411
+ * @type {Map<string, PropertyDeclaration>}
412
+ */
413
+ static get elementProperties() {
414
+ const epoch = declarationsEpoch();
415
+ const hit = ELEMENT_PROPERTIES.get(this);
416
+ if (hit && hit.epoch === epoch) return hit.map;
417
+ const merged = declaredProperties(this);
418
+ if (hit && hit.merged === merged) { hit.epoch = epoch; return hit.map; }
419
+ const map = new Map();
420
+ for (const name of Reflect.ownKeys(merged)) map.set(name, normalizeDecl(merged[name]));
421
+ ELEMENT_PROPERTIES.set(this, { epoch, merged, map });
422
+ return map;
423
+ }
424
+
425
+ /**
426
+ * Lit's `getPropertyOptions(name)`: the declaration for `name`, or the
427
+ * default declaration (`{ type: String }`) when it is not declared.
428
+ * @param {string} name
429
+ * @returns {PropertyDeclaration}
430
+ */
431
+ static getPropertyOptions(name) {
432
+ return this.elementProperties.get(name) ?? DEFAULT_DECLARATION;
433
+ }
434
+
435
+ /**
436
+ * Lit's `createProperty(name, options)`: declare a reactive property from
437
+ * code instead of `static properties`. It must run before the first
438
+ * instance is constructed (accessors are installed then, once per class),
439
+ * which is where Lit calls it from, so a later call throws. So does a call
440
+ * on a class one of whose SUBCLASSES has an instance: that subclass's
441
+ * accessors are installed already and could never gain the new property.
442
+ * @param {string} name
443
+ * @param {PropertyDeclaration} [options]
444
+ */
445
+ static createProperty(name, options = {}) {
446
+ if (_accessorPlans.has(this) || HAS_CONSTRUCTED_SUBCLASS.has(this)) {
447
+ throw new Error(
448
+ `[webjs] ${this.name || 'A component'}.createProperty('${name}') ran after an instance of the class${HAS_CONSTRUCTED_SUBCLASS.has(this) && !_accessorPlans.has(this) ? ', or of a subclass,' : ''} was constructed. ` +
449
+ `Declare the property in \`static properties\` (or the WebComponent({ ... }) factory), or call createProperty ` +
450
+ `at class definition time, before any element is created.`,
451
+ );
452
+ }
453
+ addCreatedProperty(this, name, options);
454
+ }
455
+
456
+ /**
457
+ * Lit's `getPropertyDescriptor(name, key, options)`: the accessor pair
458
+ * installed on the prototype for one reactive property. Override it to wrap
459
+ * the default (call `super.getPropertyDescriptor(...)` and delegate to its
460
+ * `get` / `set`); return `undefined` to install no accessor.
461
+ *
462
+ * When the class defines its own `get` / `set` for a declared property, the
463
+ * default wraps it, as Lit's does: reads go to the author's getter, and a
464
+ * write calls the author's setter and then `requestUpdate(name, oldValue,
465
+ * options)`. Otherwise the value is stored on the instance under `key`. Either way
466
+ * `requestUpdate` runs `hasChanged`, records the change, reflects, and
467
+ * schedules the update.
468
+ * @param {string} name
469
+ * @param {symbol} key the instance slot the default accessor stores the value in, as in Lit
470
+ * @param {PropertyDeclaration} options
471
+ * @returns {PropertyDescriptor | undefined}
472
+ */
473
+ static getPropertyDescriptor(name, key, options) {
474
+ const author = authorAccessor(this, name);
475
+ /** @type {PropertyDescriptor} */
476
+ let descriptor;
477
+ if (author) {
478
+ const { get, set } = author;
479
+ descriptor = {
480
+ configurable: true,
481
+ enumerable: true,
482
+ get() { return get?.call(this); },
483
+ set(value) {
484
+ const oldValue = get?.call(this);
485
+ set?.call(this, value);
486
+ this.requestUpdate(name, oldValue, options);
487
+ },
488
+ };
489
+ } else {
490
+ descriptor = {
491
+ configurable: true,
492
+ enumerable: true,
493
+ get() { return this[key]; },
494
+ set(value) {
495
+ const oldValue = this[key];
496
+ this[key] = value;
497
+ // requestUpdate runs hasChanged, records the (name, oldValue) entry,
498
+ // reflects, AND schedules a render. During the update phase the
499
+ // scheduler short-circuits and the entry folds into the current
500
+ // cycle's changedProperties.
501
+ this.requestUpdate(name, oldValue, options);
502
+ },
503
+ };
504
+ }
505
+ GENERATED_SETTERS.set(/** @type {Function} */ (descriptor.set), author);
506
+ return descriptor;
507
+ }
508
+
509
+ /**
510
+ * Lit's `finalize()`: prepare the class's metadata once. Idempotent and
511
+ * called for you (from `observedAttributes` and `register()`), so calling it
512
+ * yourself is never required.
513
+ */
514
+ static finalize() {
515
+ if (FINALIZED.has(this)) return;
516
+ FINALIZED.add(this);
517
+ const parent = Object.getPrototypeOf(this);
518
+ if (parent && typeof parent.finalize === 'function') parent.finalize();
519
+ // Lit creates every `static properties` entry through `createProperty`,
520
+ // so an override of it (a default declaration mixed into each) applies to
521
+ // them too. The base implementation needs no call.
522
+ if (this.createProperty !== WebComponentBase.createProperty && Object.hasOwn(this, 'properties')) {
523
+ const own = this.properties;
524
+ if (own && typeof own === 'object') for (const name of Reflect.ownKeys(own)) this.createProperty(name, normalizeDecl(own[name]));
525
+ }
526
+ void this.elementProperties;
527
+ void this.elementStyles;
528
+ }
529
+
530
+ /**
531
+ * Lit's `addInitializer(initializer)`: run `initializer(instance)` in the
532
+ * constructor of every instance of this class and its subclasses. A
533
+ * subclass inherits its parent's initializers; one added to a subclass does
534
+ * not reach the parent.
535
+ * @param {(element: any) => void} initializer
536
+ */
537
+ static addInitializer(initializer) {
538
+ if (!Object.hasOwn(this, '_initializers')) {
539
+ const inherited = /** @type {any} */ (Object.getPrototypeOf(this))._initializers;
540
+ this._initializers = inherited ? [...inherited] : [];
541
+ }
542
+ this._initializers.push(initializer);
543
+ }
544
+
245
545
  constructor() {
246
546
  super();
247
- this._renderRoot = null;
547
+ /**
548
+ * The node `render()` commits into: the element itself for light DOM, its
549
+ * shadow root for shadow DOM. Undefined until the element connects (and at
550
+ * SSR, where nothing is connected), as in Lit.
551
+ * @type {HTMLElement | ShadowRoot | DocumentFragment | undefined}
552
+ */
553
+ this._renderRoot = undefined;
248
554
  this._scheduled = false;
249
555
  this._connected = false;
556
+ /** Pending from construction until the first update runs, as in Lit. */
557
+ this._initialUpdatePending = true;
250
558
 
251
559
  /**
252
560
  * Set of attached reactive controllers, created by the first
@@ -300,43 +608,14 @@ class WebComponentBase extends Base {
300
608
  */
301
609
  this._isUpdating = false;
302
610
 
303
- // Enforce the declare-free factory DX: a hand-written `static properties`
304
- // in a class body is a hard error (use `extends WebComponent({ … })`).
305
- this._assertFactoryProperties();
306
-
307
- // Install reactive property accessors for `static properties` declarations.
611
+ // Install the reactive property accessors. Props come from Lit's
612
+ // `static properties` and from the `WebComponent({ ... })` factory alike,
613
+ // merged down the class chain by `declaredProperties`.
308
614
  this._initializeProperties();
309
- }
310
615
 
311
- /**
312
- * Throw if a class in this instance's constructor chain declares its own
313
- * `static properties`. Reactive properties must be declared via the
314
- * `extends WebComponent({ … })` factory, which stamps {@link FACTORY_PROPS}
315
- * on the subclass it generates; a `static properties` written by hand in a
316
- * class body carries no such marker and is rejected here (issue #598).
317
- *
318
- * The walk stops at {@link WebComponentBase} (whose `static properties = {}`
319
- * default is internal) and is memoized per class so it runs once.
320
- * @private
321
- */
322
- _assertFactoryProperties() {
323
- const Ctor = /** @type {any} */ (this.constructor);
324
- if (_propsChecked.has(Ctor)) return;
325
- let C = Ctor;
326
- while (C && C !== WebComponentBase) {
327
- if (Object.hasOwn(C, 'properties') && !Object.hasOwn(C, FACTORY_PROPS)) {
328
- const name = C.name || 'a component';
329
- throw new Error(
330
- `${name}: \`static properties\` is no longer supported. Declare reactive ` +
331
- `properties via the factory instead: \`class ${name} extends WebComponent({ ` +
332
- `count: Number })\`. Use the \`prop()\` helper for options ` +
333
- `(\`prop(Number, { reflect: true })\`) and set defaults in the ` +
334
- `constructor after \`super()\`. See https://webjs.dev/docs/components.`,
335
- );
336
- }
337
- C = Object.getPrototypeOf(C);
338
- }
339
- _propsChecked.add(Ctor);
616
+ // Lit's `addInitializer` callbacks, after the properties exist.
617
+ const initializers = /** @type {any} */ (this.constructor)._initializers;
618
+ if (initializers) for (const init of initializers) init(this);
340
619
  }
341
620
 
342
621
  /**
@@ -346,17 +625,13 @@ class WebComponentBase extends Base {
346
625
  * once per class. Per instance this only lifts a value written before the
347
626
  * element upgraded and applies the declared `default`s.
348
627
  *
349
- * Called once from the constructor. The backing store is a plain object
350
- * (`this.__propValues`), so the accessors never collide with it.
628
+ * Called once from the constructor. Each value is stored on the instance
629
+ * under a per-property symbol (Lit's `key`), so the accessors never collide
630
+ * with it.
351
631
  * @private
352
632
  */
353
633
  _initializeProperties() {
354
634
  const Ctor = /** @type {any} */ (this.constructor);
355
- const props = Ctor.properties;
356
- if (!props || typeof props !== 'object') return;
357
-
358
- /** @type {Record<string, unknown>} */
359
- this.__propValues = {};
360
635
 
361
636
  // The accessors live on the PROTOTYPE, installed once per class (#1646).
362
637
  // They used to be defined on every instance, two closures and a
@@ -364,27 +639,32 @@ class WebComponentBase extends Base {
364
639
  // cost of constructing a component on the SSR path (one instance per
365
640
  // rendered element) and a measurable one on upgrade in the browser.
366
641
  let plan = _accessorPlans.get(Ctor);
367
- if (plan === undefined) plan = installAccessors(Ctor, props);
642
+ if (plan === undefined) {
643
+ // Defining or registering the class finalizes it already; a class
644
+ // constructed without either is finalized here, before its accessors.
645
+ Ctor.finalize();
646
+ plan = installAccessors(Ctor, declaredProperties(Ctor));
647
+ }
368
648
 
369
649
  for (let i = 0; i < plan.length; i++) {
370
- const { propName, decl: d } = plan[i];
371
- // Capture any value set before the accessor could apply (an own data
372
- // property written on the element before it upgraded). It shadows the
373
- // prototype accessor, so it is lifted into the backing store.
374
- let initial;
650
+ const { propName, decl: d, builtIn, key } = plan[i];
651
+ // A value set before the element upgraded is an own data property that
652
+ // shadows the prototype accessor. Lit's `__saveInstanceProperties`: take
653
+ // it off now and assign it through the accessor at the first update, so
654
+ // it wins over a default the constructor body writes.
375
655
  if (Object.hasOwn(this, propName)) {
376
- initial = /** @type {any} */ (this)[propName];
656
+ (this.__instanceProperties ??= new Map()).set(propName, /** @type {any} */ (this)[propName]);
377
657
  delete (/** @type {any} */ (this))[propName];
378
658
  }
379
- if (initial !== undefined) {
380
- this.__propValues[propName] = initial;
381
- } else if (d.default !== undefined) {
659
+ if (!builtIn) continue;
660
+ if (d.default !== undefined) {
382
661
  // Declarative `default` option (lit-parity). A function default is
383
662
  // CALLED per instance, so an object / array default is a fresh value
384
663
  // per element. Written straight to the backing store; an applied
385
664
  // attribute (attributeChangedCallback runs later) overrides it.
386
- this.__propValues[propName] =
387
- typeof d.default === 'function' ? d.default() : d.default;
665
+ const value = typeof d.default === 'function' ? d.default() : d.default;
666
+ /** @type {any} */ (this)[key] = value;
667
+ if (d.useDefault) (this.__defaultValues ??= new Map()).set(propName, value);
388
668
  }
389
669
  }
390
670
  }
@@ -400,8 +680,12 @@ class WebComponentBase extends Base {
400
680
  * @private
401
681
  */
402
682
  _reflectAttribute(propName, value, decl) {
403
- // A custom `attribute` option wins over the kebab-cased property name.
404
- const attrName = decl.attribute || hyphenate(propName);
683
+ // A property with no attribute (`state: true` / `attribute: false`) has
684
+ // nothing to reflect to.
685
+ // The attribute name comes from the one rule the readers use, so a prop
686
+ // reflects to exactly the name it observes.
687
+ const attrName = attributeNameFor(propName, decl);
688
+ if (attrName === undefined) return;
405
689
  // Guard against re-entrant loops: attributeChangedCallback fires when
406
690
  // we call setAttribute, which would call the setter again.
407
691
  if (this.__reflectingAttribute) return;
@@ -560,10 +844,16 @@ class WebComponentBase extends Base {
560
844
  const n = attrs[i].name;
561
845
  if (n.startsWith('data-webjs-prop-')) names.push(n);
562
846
  }
847
+ // The last value each binding carried, so a later morph re-reads only the
848
+ // ones the server changed (router-client/dom-differ.js), the way Lit
849
+ // commits a property binding only when its value changed.
850
+ /** @type any */ const self = this;
851
+ if (!self.__webjsPropAttrValues) self.__webjsPropAttrValues = new Map();
563
852
  for (const fullName of names) {
564
853
  const raw = this.getAttribute(fullName);
565
854
  this.removeAttribute(fullName);
566
855
  if (raw == null) continue;
856
+ self.__webjsPropAttrValues.set(fullName, raw);
567
857
  const propName = camelCase(fullName.slice('data-webjs-prop-'.length));
568
858
  try {
569
859
  /** @type any */ (this)[propName] = deserializeProp(raw);
@@ -577,6 +867,17 @@ class WebComponentBase extends Base {
577
867
 
578
868
  _activate() {
579
869
  this._connected = true;
870
+ // A value written on the element before its class was defined is an own
871
+ // data property, and it hides any accessor of the same name for good.
872
+ // `_initializeProperties` saves the declared properties in the
873
+ // constructor; an accessor the subclass defines itself (a getter/setter
874
+ // over a private signal, the shape internal state another element assigns
875
+ // takes) gets the same lift here, on the first connect, since its private
876
+ // field does not exist yet while the base constructor runs.
877
+ if (!this.__accessorsLifted) {
878
+ this.__accessorsLifted = true;
879
+ liftShadowedAccessors(this);
880
+ }
580
881
  // Reflect declared reflect:true properties from their current value now
581
882
  // that the element is connected. Constructor / willUpdate defaults were
582
883
  // set while disconnected (the setter skips reflection then), so this is
@@ -595,34 +896,94 @@ class WebComponentBase extends Base {
595
896
  if (Ctor.shadow !== true && !this.hasAttribute('data-wj-host')) {
596
897
  this.setAttribute('data-wj-host', '');
597
898
  }
598
- if (Ctor.shadow === true) {
599
- const hadSSRShadow = !!this.shadowRoot;
600
- if (!this.shadowRoot) {
601
- /** @type any */ (this).attachShadow({ mode: 'open' });
899
+ this._setupRenderRoot();
900
+ if (Ctor.shadow !== true && this._renderRoot === this) {
901
+ // Install native-write interception AFTER capture (capture uses the
902
+ // host's still-native methods), then arm the sensors. Together they make
903
+ // appendChild / insertBefore / removeChild / innerHTML / slot= flips on a
904
+ // mounted light host drive the slot record, restoring full shadow-DOM
905
+ // parity through the standard DOM API. Interception installs once;
906
+ // sensors are armed on every connect and torn down on disconnect.
907
+ if (hasSlotState(this)) {
908
+ installSlotInterception(this);
909
+ installSlotSensors(this);
602
910
  }
603
- this._renderRoot = this.shadowRoot;
604
- const styles = Ctor.styles;
605
- const list = Array.isArray(styles) ? styles : isCSS(styles) ? [styles] : [];
606
- if (list.length) {
607
- // If the shadow root came from Declarative Shadow DOM (SSR), it
608
- // contains an inline <style> tag. Remove it before switching to
609
- // adoptedStyleSheets to avoid duplicate styles.
610
- if (hadSSRShadow) {
611
- const ssrStyle = this.shadowRoot.querySelector('style');
612
- if (ssrStyle) ssrStyle.remove();
613
- }
614
- adoptStyles(this._renderRoot, list);
911
+ }
912
+
913
+ // Notify all controllers that the host is connected.
914
+ for (const c of this.__controllers || NO_CONTROLLERS) {
915
+ if (c.hostConnected) c.hostConnected();
916
+ }
917
+
918
+ // First render is deferred to a microtask, matching lit's
919
+ // performUpdate scheduling. The user's connectedCallback override
920
+ // body runs synchronously to completion BEFORE the first render,
921
+ // so subclass setup that needs to inspect attributes / state / etc.
922
+ // observes consistent timing whether SSR-hydrating or first mount.
923
+ // Render-root setup (shadow attachment, light-DOM SSR adoption,
924
+ // controller hostConnected, _connected=true) all stay synchronous
925
+ // above; only the template commit + post-commit slot observers
926
+ // defer. Authoring contract: post-render DOM setup goes in
927
+ // firstUpdated(), NOT connectedCallback().
928
+ //
929
+ // The scheduling matches `_scheduleUpdate`'s wrapper so lifecycle
930
+ // throws are surfaced as console errors instead of bricking the
931
+ // element.
932
+ if (this._updateResolve === null) {
933
+ this._updatePromise = new Promise((resolve) => {
934
+ this._updateResolve = resolve;
935
+ });
936
+ }
937
+ // Lit's `enableUpdating(requestedUpdate)` hook: called once the element is
938
+ // ready to update, before the first update is queued.
939
+ this.__updatingEnabled = true;
940
+ this.enableUpdating(true);
941
+ if (this._scheduled) return;
942
+ this._scheduled = true;
943
+ // The renderer's slot parts place the record content as part of the
944
+ // commit; native DOM writes on the host (appendChild, slot= flips,
945
+ // innerHTML) drive the record live through the interception + sensors
946
+ // installed on connect. There is no WebJs-specific slot API.
947
+ queueMicrotask(() => this._runScheduledUpdate('initial render'));
948
+ }
949
+
950
+ /**
951
+ * Create the render root once (Lit's `createRenderRoot()`) and, for a
952
+ * light-DOM host, settle its slot record: capture the authored children,
953
+ * adopt a server-rendered projection, or sweep on a reconnection. Runs on
954
+ * every connect, and from the first update of an element flushed before it
955
+ * ever connected.
956
+ * @private
957
+ */
958
+ _setupRenderRoot() {
959
+ const Ctor = /** @type any */ (this.constructor);
960
+ // Lit's `createRenderRoot()`, called once per element. The server renders
961
+ // from `static shadow` alone (it has no DOM to call this against), so a
962
+ // root that contradicts it would hydrate against markup of the other kind:
963
+ // refuse that loudly instead.
964
+ if (this._renderRoot === undefined) {
965
+ const root = this.createRenderRoot();
966
+ const isShadowRoot = typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot;
967
+ if (isShadowRoot !== (Ctor.shadow === true)) {
968
+ const name = tagOf(Ctor) || this.tagName?.toLowerCase();
969
+ throw new Error(
970
+ isShadowRoot
971
+ ? `[webjs] <${name}> createRenderRoot() returned a shadow root, but the class does not declare \`static shadow = true\`. ` +
972
+ `WebJs renders on the server from \`static shadow\`, so add \`static shadow = true\` (with \`static shadowRootOptions\` for the ` +
973
+ `attachShadow options) and drop the override, or call \`super.createRenderRoot()\` from it.`
974
+ : `[webjs] <${name}> declares \`static shadow = true\`, but createRenderRoot() returned something other than a shadow root. ` +
975
+ `Return \`super.createRenderRoot()\` from the override, or remove \`static shadow = true\` to render into light DOM.`,
976
+ );
615
977
  }
616
- } else {
617
- this._renderRoot = this;
978
+ this._renderRoot = root;
979
+ }
980
+ if (Ctor.shadow !== true && this._renderRoot === this) {
618
981
  // Light DOM: static styles is not supported (no shadow root for
619
- // adoptedStyleSheets). Warn if the developer set both.
620
- if (Ctor.styles) {
621
- console.warn(
622
- `[webjs] <${tagOf(Ctor) || this.tagName?.toLowerCase()}> has static shadow = false AND static styles. ` +
623
- `static styles only works with shadow DOM (adoptedStyleSheets). ` +
624
- `For light DOM, use global CSS or <style> in render().`
625
- );
982
+ // adoptedStyleSheets), so they would silently never apply. Say so,
983
+ // loudly, once per class.
984
+ if (Ctor.styles && !WARNED_LIGHT_STYLES.has(Ctor)) {
985
+ WARNED_LIGHT_STYLES.add(Ctor);
986
+ console.error(lightDomStylesMessage(tagOf(Ctor) || this.tagName?.toLowerCase()));
626
987
  }
627
988
  // Light-DOM slot capture: ONCE per host lifetime (#1015). Three
628
989
  // sub-paths, all of which resolve to "the record is populated
@@ -648,11 +1009,17 @@ class WebComponentBase extends Base {
648
1009
  // c. First mount, no SSR. Partition authored children into the
649
1010
  // record before _performRender wipes the host.
650
1011
  //
651
- // After capture, installSlotInterception + installSlotSensors below make
1012
+ // After capture, installSlotInterception + installSlotSensors in `_activate` make
652
1013
  // native DOM writes on a mounted host live (appendChild, slot= flips,
653
1014
  // innerHTML, HTMLSlotElement.assign), so light-DOM slots match shadow DOM
654
1015
  // through the standard API.
655
- if (hasSlotState(this)) {
1016
+ if (this.render === WebComponentBase.prototype.render) {
1017
+ // A render-less component (no `render()` override). Lit's default
1018
+ // `render()` returns `noChange`, so its children are never touched:
1019
+ // there is no template to project them into, so they are neither
1020
+ // captured nor intercepted, and the commit leaves them in place.
1021
+ this.removeAttribute('data-wj-serialized');
1022
+ } else if (hasSlotState(this)) {
656
1023
  // (a) Reconnection. Record already populated. Sweep any direct child
657
1024
  // added by a raw bypass write while the host was disconnected (no
658
1025
  // sensor was live to catch it), then re-arm the sensors below.
@@ -679,53 +1046,136 @@ class WebComponentBase extends Base {
679
1046
  // (c) First mount with author-written children. Capture them.
680
1047
  captureAuthoredChildren(this);
681
1048
  }
682
- // Install native-write interception AFTER capture (capture uses the
683
- // host's still-native methods), then arm the sensors. Together they make
684
- // appendChild / insertBefore / removeChild / innerHTML / slot= flips on a
685
- // mounted light host drive the slot record, restoring full shadow-DOM
686
- // parity through the standard DOM API. Interception installs once;
687
- // sensors are armed on every connect and torn down on disconnect.
688
- installSlotInterception(this);
689
- installSlotSensors(this);
690
1049
  }
1050
+ }
691
1051
 
692
- // Notify all controllers that the host is connected.
693
- for (const c of this.__controllers || NO_CONTROLLERS) {
694
- if (c.hostConnected) c.hostConnected();
1052
+ /**
1053
+ * The microtask both scheduling sites queue: hand the update to
1054
+ * `scheduleUpdate()` (Lit's override point for WHEN an update runs) and
1055
+ * surface a throw, or the rejection of an async override, as a console
1056
+ * error instead of bricking the element. `_performRender` already resets
1057
+ * `_isUpdating` and settles `updateComplete` in its own `finally` blocks.
1058
+ * @param {string} phase
1059
+ * @private
1060
+ */
1061
+ _runScheduledUpdate(phase) {
1062
+ try {
1063
+ const r = this.scheduleUpdate();
1064
+ if (r && typeof (/** @type {any} */ (r).then) === 'function') {
1065
+ /** @type {Promise<unknown>} */ (r).then(undefined, (err) => {
1066
+ console.error(`[webjs] lifecycle hook threw during ${phase}:`, err);
1067
+ });
1068
+ }
1069
+ } catch (err) {
1070
+ console.error(`[webjs] lifecycle hook threw during ${phase}:`, err);
695
1071
  }
1072
+ }
696
1073
 
697
- // First render is deferred to a microtask, matching lit's
698
- // performUpdate scheduling. The user's connectedCallback override
699
- // body runs synchronously to completion BEFORE the first render,
700
- // so subclass setup that needs to inspect attributes / state / etc.
701
- // observes consistent timing whether SSR-hydrating or first mount.
702
- // Render-root setup (shadow attachment, light-DOM SSR adoption,
703
- // controller hostConnected, _connected=true) all stay synchronous
704
- // above; only the template commit + post-commit slot observers
705
- // defer. Authoring contract: post-render DOM setup goes in
706
- // firstUpdated(), NOT connectedCallback().
707
- //
708
- // The scheduling matches `_scheduleUpdate`'s wrapper so lifecycle
709
- // throws are surfaced as console errors instead of bricking the
710
- // element.
711
- if (this._updateResolve === null) {
712
- this._updatePromise = new Promise((resolve) => {
713
- this._updateResolve = resolve;
714
- });
1074
+ /**
1075
+ * Lit's `scheduleUpdate()`: called from the update microtask to run the
1076
+ * pending update. Override it to change WHEN an update runs, for example to
1077
+ * wait for a frame, and call `super.scheduleUpdate()` (or
1078
+ * `this.performUpdate()`) when it should run. `updateComplete` waits for it.
1079
+ *
1080
+ * ```js
1081
+ * async scheduleUpdate() {
1082
+ * await new Promise((r) => requestAnimationFrame(r));
1083
+ * super.scheduleUpdate();
1084
+ * }
1085
+ * ```
1086
+ *
1087
+ * @returns {void | Promise<unknown>}
1088
+ */
1089
+ scheduleUpdate() {
1090
+ return this.performUpdate();
1091
+ }
1092
+
1093
+ /**
1094
+ * Lit's `performUpdate()`: run the pending update now, synchronously. Does
1095
+ * nothing when no update is pending, so calling it to flush a queued update
1096
+ * early is safe.
1097
+ * @returns {void}
1098
+ */
1099
+ performUpdate() {
1100
+ // As in Lit, an element is pending from construction until its first
1101
+ // update, so a flush before it ever connected still updates.
1102
+ if (!this._scheduled && !this._initialUpdatePending) return;
1103
+ if (!this.__firstRendered) this._prepareFirstUpdate();
1104
+ this._scheduled = false;
1105
+ this._initialUpdatePending = false;
1106
+ this._performRender();
1107
+ }
1108
+
1109
+ /**
1110
+ * The first-update half of Lit's `performUpdate`: create the render root
1111
+ * when the element was never connected, assign the values saved from before
1112
+ * the upgrade, and record each author accessor's initial value (Lit's
1113
+ * `wrapped` properties, which no setter reported). Runs while the update is
1114
+ * still marked pending, so the assignments fold into it.
1115
+ * @private
1116
+ */
1117
+ _prepareFirstUpdate() {
1118
+ if (this._renderRoot === undefined) this._setupRenderRoot();
1119
+ const saved = this.__instanceProperties;
1120
+ if (saved) {
1121
+ this.__instanceProperties = undefined;
1122
+ for (const [name, value] of saved) /** @type {any} */ (this)[name] = value;
715
1123
  }
716
- this._scheduled = true;
717
- queueMicrotask(() => {
718
- this._scheduled = false;
719
- try {
720
- this._performRender();
721
- } catch (err) {
722
- console.error(`[webjs] lifecycle hook threw during initial render:`, err);
1124
+ for (const { propName, decl, wrapped } of _accessorPlans.get(this.constructor) || []) {
1125
+ const value = /** @type {any} */ (this)[propName];
1126
+ if (!wrapped || this._changedProperties.has(propName) || value === undefined) continue;
1127
+ if (decl.useDefault && !(this.__defaultValues ??= new Map()).has(propName)) {
1128
+ this.__defaultValues.set(propName, value);
1129
+ continue;
1130
+ }
1131
+ this._changedProperties.set(propName, undefined);
1132
+ if (decl.reflect) (this.__reflectPending ??= new Set()).add(propName);
1133
+ }
1134
+ }
1135
+
1136
+ /**
1137
+ * Lit's `enableUpdating(requestedUpdate)` hook, called when the element
1138
+ * connects, right before its first update is queued. The default does
1139
+ * nothing.
1140
+ * @param {boolean} _requestedUpdate
1141
+ */
1142
+ enableUpdating(_requestedUpdate) {}
1143
+
1144
+ /**
1145
+ * Lit's `createRenderRoot()`: create the node `render()` commits into. The
1146
+ * default returns the element itself for a light-DOM component, and for
1147
+ * `static shadow = true` the shadow root, attached with
1148
+ * `static shadowRootOptions` (default `{ mode: 'open' }`) unless the server
1149
+ * already rendered one as Declarative Shadow DOM, with `static styles`
1150
+ * adopted into it. Override it for Lit's `createRenderRoot() { return this; }`
1151
+ * (already the default) or to post-process the root; a shadow root is
1152
+ * accepted only with `static shadow = true`, because the server renders
1153
+ * from that flag.
1154
+ * @returns {HTMLElement | ShadowRoot | DocumentFragment}
1155
+ */
1156
+ createRenderRoot() {
1157
+ const Ctor = /** @type any */ (this.constructor);
1158
+ if (Ctor.shadow !== true) return this;
1159
+ const options = Ctor.shadowRootOptions ?? { mode: 'open' };
1160
+ // A CLOSED declarative root is invisible to `this.shadowRoot`, and
1161
+ // attachShadow() on an element that has one empties it (the spec reuses
1162
+ // the root but removes its children), so the server-rendered markup would
1163
+ // vanish and the first render would build from nothing. Lit has the same
1164
+ // gap; ElementInternals reaches the declarative root, so use it.
1165
+ const existing = this.shadowRoot ?? (options.mode === 'closed' ? declarativeClosedRoot(this) : null);
1166
+ const root = existing ?? /** @type any */ (this).attachShadow(options);
1167
+ const list = Ctor.elementStyles;
1168
+ if (list.length) {
1169
+ // A shadow root that came from Declarative Shadow DOM (SSR) carries the
1170
+ // inline <style> the server emitted. Remove it before switching to
1171
+ // adoptedStyleSheets so the rules are not applied twice.
1172
+ if (existing) {
1173
+ const ssrStyle = existing.querySelector('style');
1174
+ if (ssrStyle) ssrStyle.remove();
723
1175
  }
724
- // The renderer's slot parts place the record content as part of the
725
- // commit; native DOM writes on the host (appendChild, slot= flips,
726
- // innerHTML) drive the record live through the interception + sensors
727
- // installed on connect. There is no WebJs-specific slot API.
728
- });
1176
+ adoptStyles(root, list);
1177
+ }
1178
+ return root;
729
1179
  }
730
1180
 
731
1181
  /**
@@ -773,9 +1223,12 @@ class WebComponentBase extends Base {
773
1223
  // Dispose the signal watcher so dependency edges drop. Without
774
1224
  // this the element holds references to module-scope signals
775
1225
  // (and vice versa) forever.
776
- if (this.__signalWatcher) {
777
- this.__signalWatcher.dispose();
778
- this.__signalWatcher = undefined;
1226
+ for (const slot of ['__signalWatcher', '__postWatcher']) {
1227
+ const self = /** @type {any} */ (this);
1228
+ if (self[slot]) {
1229
+ self[slot].dispose();
1230
+ self[slot] = undefined;
1231
+ }
779
1232
  }
780
1233
  for (const c of this.__controllers || NO_CONTROLLERS) {
781
1234
  if (c.hostDisconnected) c.hostDisconnected();
@@ -801,30 +1254,72 @@ class WebComponentBase extends Base {
801
1254
  const resolved = resolveAttributeProperty(this.constructor, name);
802
1255
  if (resolved === undefined) return;
803
1256
  const { propName, def } = resolved;
1257
+ // An accessor that stores its value in the attribute writes it back while
1258
+ // this very read assigns it; Lit's `__reflectingProperty` check ignores
1259
+ // that nested callback instead of recursing.
1260
+ if (this.__attributeToProperty === propName) return;
804
1261
 
805
1262
  // One reader for both sides, in `attribute-reader.js`: this and
806
1263
  // `applyAttrsToInstance` in `render-server.js` call the same function, so
807
1264
  // precedence and every fallback are shared rather than mirrored (#1340).
808
1265
  // It takes an already-decoded value, which the DOM has done here and
809
1266
  // `decodeAttrEntities` does at the SSR call site (#1341).
810
- const v = readAttributeValue(def, value);
1267
+ let v = readAttributeValue(def, value, propName);
1268
+ if (v == null && def.useDefault && this.__defaultValues?.has(propName)) v = this.__defaultValues.get(propName);
811
1269
 
812
- if (this[propName] !== v) {
1270
+ // Assigned unconditionally, as Lit's `_$attributeToProperty` does: the
1271
+ // setter's `hasChanged` decides whether it is a change, so a declared
1272
+ // `hasChanged` that rejects the value schedules no update.
1273
+ this.__attributeToProperty = propName;
1274
+ try {
813
1275
  this[propName] = v;
814
- if (this._connected) this.requestUpdate();
1276
+ } finally {
1277
+ this.__attributeToProperty = undefined;
815
1278
  }
816
1279
  }
817
1280
 
818
1281
  /**
819
- * Schedule a re-render. Optionally record a property change in
820
- * `changedProperties` so hooks can branch on what changed.
1282
+ * Schedule a re-render, Lit's `requestUpdate(name, oldValue, options)`.
1283
+ * With a name, the property's `hasChanged` (from `options`, else its
1284
+ * declaration) compares its current value with `oldValue`: an unchanged
1285
+ * value schedules nothing. A change is recorded in `changedProperties`
1286
+ * and reflected when the property declares `reflect: true`. With no
1287
+ * arguments it always schedules an update.
821
1288
  *
822
1289
  * @param {string} [name] Property name that changed
823
1290
  * @param {unknown} [oldValue] Previous value of the property
1291
+ * @param {PropertyDeclaration} [options] The property's declaration
824
1292
  */
825
- requestUpdate(name, oldValue) {
826
- if (name !== undefined && !this._changedProperties.has(name)) {
827
- this._changedProperties.set(name, oldValue);
1293
+ requestUpdate(name, oldValue, options) {
1294
+ if (name !== undefined) {
1295
+ const decl = options ?? /** @type {any} */ (this.constructor).getPropertyOptions(name);
1296
+ const hasChanged = decl.hasChanged || defaultHasChanged;
1297
+ const value = /** @type {any} */ (this)[name];
1298
+ // Lit's `useDefault`: the first value written is the default, recorded
1299
+ // and neither counted as a change nor reflected.
1300
+ if (decl.useDefault && !(this.__defaultValues ??= new Map()).has(name)) {
1301
+ this.__defaultValues.set(name, oldValue !== undefined ? oldValue : value);
1302
+ if (oldValue === undefined) { this._scheduleUpdate(); return; }
1303
+ }
1304
+ // Writing the unreflected default back IS a change when the attribute
1305
+ // is absent, since it now reflects (Lit's corner case).
1306
+ const attrName = decl.useDefault && decl.reflect ? attributeNameFor(name, decl) : undefined;
1307
+ const restoresDefault = attrName !== undefined
1308
+ && Object.is(value, this.__defaultValues?.get(name)) && !this.hasAttribute(attrName);
1309
+ if (!hasChanged(value, oldValue) && !restoresDefault) return;
1310
+ // Before the first update the recorded old value is `undefined`, except
1311
+ // with `useDefault`, as in Lit's `_$changeProperty`.
1312
+ if (!this._changedProperties.has(name)) {
1313
+ this._changedProperties.set(name, this.__firstRendered || decl.useDefault ? oldValue : undefined);
1314
+ }
1315
+ // Reflect to the attribute if requested (_reflectAttribute skips a prop
1316
+ // with no attribute: `state: true` or `attribute: false`). Disconnected,
1317
+ // it waits for the next update. A value just read FROM the attribute is
1318
+ // never written back to it.
1319
+ if (decl.reflect && this.__attributeToProperty !== name) {
1320
+ if (this._connected) this._reflectAttribute(name, value, decl);
1321
+ else (this.__reflectPending ??= new Set()).add(name);
1322
+ }
828
1323
  }
829
1324
  this._scheduleUpdate();
830
1325
  }
@@ -845,21 +1340,21 @@ class WebComponentBase extends Base {
845
1340
  });
846
1341
  }
847
1342
  if (this._isUpdating) return;
848
- if (this._scheduled || !this._connected) return;
1343
+ // Lit runs updates once updating is enabled at the first connect, connected
1344
+ // or not afterwards, so a disconnected element still updates.
1345
+ if (this._scheduled || !this.__updatingEnabled) return;
849
1346
  this._scheduled = true;
850
- queueMicrotask(() => {
851
- this._scheduled = false;
852
- try {
853
- this._performRender();
854
- } catch (err) {
855
- // _performRender wraps the update phase in try/finally so this
856
- // catches throws from shouldUpdate / willUpdate / hostUpdate /
857
- // hostUpdated / firstUpdated / updated. The component is not
858
- // left in a bad state (the finally blocks reset _isUpdating and
859
- // resolve updateComplete). Surface the error for visibility.
860
- console.error(`[webjs] lifecycle hook threw during update cycle:`, err);
861
- }
862
- });
1347
+ // _performRender wraps the update phase in try/finally, so a throw from
1348
+ // shouldUpdate / willUpdate / hostUpdate / hostUpdated / firstUpdated /
1349
+ // updated leaves the component in a good state; the wrapper only
1350
+ // surfaces it.
1351
+ // Requested while a finished cycle is still settling (from `updated()`, or
1352
+ // after `super.update()`): run once that cycle's `updateComplete` has
1353
+ // resolved, after whoever was already awaiting it, as Lit's
1354
+ // `__enqueueUpdate` awaits the previous promise. Otherwise next microtask.
1355
+ const settling = this.__settlingPromise;
1356
+ if (settling) settling.then(() => this._runScheduledUpdate('update cycle'));
1357
+ else queueMicrotask(() => this._runScheduledUpdate('update cycle'));
863
1358
  }
864
1359
 
865
1360
  /**
@@ -874,18 +1369,92 @@ class WebComponentBase extends Base {
874
1369
  * 7. `firstUpdated(changedProperties)` runs once, on the first render only
875
1370
  * 8. `updated(changedProperties)` runs after every commit
876
1371
  * 9. Resolve `updateComplete` promise
1372
+ *
1373
+ * Every signal the SYNCHRONOUS part of that cycle reads is tracked, not
1374
+ * only the ones `render()` reads, which is the span lit-labs/signals tracks
1375
+ * by running `performUpdate` inside a computed. So a signal read only in
1376
+ * `willUpdate()` or `updated()` (a host attribute derived from internal
1377
+ * state) re-renders the component when it changes. Two watchers split it
1378
+ * (see {@link _tracked}): one over steps 2-6 (`render()` included), one
1379
+ * over the post-commit hooks, so a write `updated()` makes to something
1380
+ * `render()` read still schedules the next update. The DOM commits are
1381
+ * the parts left untracked (see `update()`).
877
1382
  * @private
878
1383
  */
879
1384
  _performRender() {
880
1385
  if (!this._renderRoot) return;
1386
+ this.__cycleRendered = false;
1387
+ const postCommit = this._tracked('__signalWatcher', () => this._runUpdateCycle());
1388
+ // A sync commit runs its post-commit hooks OUTSIDE the first watcher's
1389
+ // phase, under their own watcher.
1390
+ if (postCommit) postCommit();
1391
+ }
1392
+
1393
+ /**
1394
+ * Run one phase of the update cycle with the signals it reads recorded on
1395
+ * the watcher in `slot`, creating the watcher on first use. A watcher
1396
+ * re-records its read set on every phase it observes, and a later change
1397
+ * to any of those signals schedules an update.
1398
+ *
1399
+ * Three rules keep that safe:
1400
+ * - A phase ignores notifications from its OWN writes. A write to a signal
1401
+ * the phase read earlier is the phase's business, not a reason to run it
1402
+ * again: before a commit it is already folded into this pass, and after
1403
+ * one it is often not the component's code at all (an event `updated()`
1404
+ * dispatches, whose listener does a read-modify-write on a store), which
1405
+ * would otherwise loop.
1406
+ * - A pre-commit phase that never reached `render()` (`shouldUpdate()`
1407
+ * returned false, or a hook threw) keeps the signals the last render
1408
+ * read on top of the ones it read itself, or a skipped cycle would
1409
+ * silently unsubscribe the component.
1410
+ * - A watcher disposed during its own phase (a hook that detached the
1411
+ * host) is disposed again afterwards, since the phase kept recording.
1412
+ *
1413
+ * @template T
1414
+ * @param {'__signalWatcher' | '__postWatcher'} slot
1415
+ * @param {() => T} fn
1416
+ * @returns {T}
1417
+ * @private
1418
+ */
1419
+ _tracked(slot, fn) {
1420
+ const self = /** @type {any} */ (this);
1421
+ /** @type {any} */
1422
+ let watcher = self[slot];
1423
+ if (!watcher) {
1424
+ watcher = self[slot] = new Signal.subtle.Watcher(() => {
1425
+ if (!watcher.__inPhase && this._connected) this.requestUpdate();
1426
+ });
1427
+ }
1428
+ const before = slot === '__signalWatcher' ? Signal.subtle.introspectSources(watcher) : null;
1429
+ watcher.__inPhase = true;
1430
+ try {
1431
+ return watcher.observe(fn);
1432
+ } finally {
1433
+ watcher.__inPhase = false;
1434
+ if (before && !this.__cycleRendered) {
1435
+ watcher.watch(...new Set([...before, ...Signal.subtle.introspectSources(watcher)]));
1436
+ }
1437
+ if (!this._connected) {
1438
+ watcher.dispose();
1439
+ if (self[slot] === watcher) self[slot] = undefined;
1440
+ }
1441
+ }
1442
+ }
1443
+
1444
+ /**
1445
+ * Steps 1-6 of the update cycle, the phase {@link _performRender} tracks
1446
+ * with the first watcher. Returns the post-commit half to run for a sync
1447
+ * commit, or nothing (an async commit runs it when it lands).
1448
+ * @returns {(() => void) | undefined}
1449
+ * @private
1450
+ */
1451
+ _runUpdateCycle() {
881
1452
 
882
1453
  // Hold a stable reference to the current Map so all hooks see the same
883
1454
  // snapshot. During the update phase (steps 3-6) the Map is mutated in
884
1455
  // place when property setters fire, folding those changes into THIS
885
- // cycle. The Map is replaced with a fresh empty one only after a
886
- // successful commit so post-commit assignments queue the NEXT cycle.
887
- // On `shouldUpdate=false` or hook errors, the Map is preserved so the
888
- // accumulated changes survive into the next render.
1456
+ // cycle. The Map is replaced when this phase finishes, including skipped
1457
+ // updates and hook errors, so post-commit writes belong to the NEXT cycle.
889
1458
  const changedProperties = this._changedProperties;
890
1459
 
891
1460
  // --- 1. Mark we're inside an update cycle ---
@@ -923,6 +1492,7 @@ class WebComponentBase extends Base {
923
1492
  // in _commitAsync.
924
1493
  if (this.__renderAbort) this.__renderAbort.abort();
925
1494
  this.__renderAbort = typeof AbortController !== 'undefined' ? new AbortController() : null;
1495
+ this.__cycleRendered = true;
926
1496
  try {
927
1497
  const r = this.update(changedProperties);
928
1498
  if (r && typeof r.then === 'function') pendingCommit = r;
@@ -931,26 +1501,18 @@ class WebComponentBase extends Base {
931
1501
  }
932
1502
 
933
1503
  if (!pendingCommit) {
934
- // --- 6. controllers' hostUpdated (sync commit) ---
935
- for (const c of this.__controllers || NO_CONTROLLERS) {
936
- if (c.hostUpdated) c.hostUpdated();
937
- }
938
1504
  didCommit = true;
939
1505
  }
940
1506
  }
941
- // shouldUpdate=false: preserve _changedProperties so the next
942
- // requestUpdate keeps accumulating on top of the entries that
943
- // didn't render this cycle.
944
1507
  } catch (preCommitError) {
945
1508
  console.error(`[webjs] lifecycle hook threw during update phase:`, preCommitError);
946
1509
  } finally {
947
1510
  this._isUpdating = false;
948
- // The snapshot is consumed once the commit is DISPATCHED. For an async
949
- // render it resets here too, so a property change during the in-flight
950
- // fetch starts a fresh cycle (whose render token supersedes this one).
951
- if (didCommit || pendingCommit) {
952
- this._changedProperties = new Map();
953
- }
1511
+ // Consume this phase's snapshot even when shouldUpdate skips it. For an
1512
+ // async render, changes during the fetch start a new cycle whose token
1513
+ // supersedes the in-flight commit.
1514
+ // `update()` already swapped it when it committed (see there).
1515
+ if (this._changedProperties === changedProperties) this._changedProperties = new Map();
954
1516
  }
955
1517
 
956
1518
  // --- Async render: finish the cycle when the pending commit settles. ---
@@ -967,10 +1529,6 @@ class WebComponentBase extends Base {
967
1529
  this.__pendingAsyncCommits--;
968
1530
  // A newer render superseded this one; let the newer cycle finish.
969
1531
  if (token !== this.__renderToken) return;
970
- // --- 6. controllers' hostUpdated (after the async commit) ---
971
- for (const c of this.__controllers || NO_CONTROLLERS) {
972
- if (c.hostUpdated) c.hostUpdated();
973
- }
974
1532
  // --- 7-8 + updateComplete ---
975
1533
  this._postCommit(changedProperties);
976
1534
  };
@@ -1008,16 +1566,21 @@ class WebComponentBase extends Base {
1008
1566
  return;
1009
1567
  }
1010
1568
 
1011
- // --- 7-8. Post-commit hooks (sync path). Errors are caught so the
1012
- // updateComplete promise always resolves.
1569
+ // --- 7-8. Post-commit hooks (sync path), returned so they run outside
1570
+ // this phase. Errors are caught so the updateComplete promise always
1571
+ // resolves.
1013
1572
  if (didCommit) {
1014
- this._postCommit(changedProperties);
1573
+ return () => this._postCommit(changedProperties);
1015
1574
  } else if (!this.__pendingAsyncCommits) {
1016
1575
  // A non-committing cycle resolves updateComplete only when no async
1017
1576
  // commit is in flight; otherwise the pending commit resolves it once it
1018
1577
  // lands, so `await el.updateComplete` never returns before that DOM.
1019
1578
  this._resolveUpdate();
1020
1579
  }
1580
+ // No post-commit phase this cycle: re-arm its watcher, so a change it
1581
+ // was notified of meanwhile does not leave it deaf to the next one.
1582
+ if (!didCommit && /** @type {any} */ (this).__postWatcher) /** @type {any} */ (this).__postWatcher.watch();
1583
+ return undefined;
1021
1584
  }
1022
1585
 
1023
1586
  /**
@@ -1028,7 +1591,7 @@ class WebComponentBase extends Base {
1028
1591
  */
1029
1592
  _resolveUpdate() {
1030
1593
  if (this._updateResolve) {
1031
- const settled = this._changedProperties.size === 0;
1594
+ const settled = !this._scheduled;
1032
1595
  this._updateResolve(settled);
1033
1596
  this._updateResolve = null;
1034
1597
  }
@@ -1060,8 +1623,17 @@ class WebComponentBase extends Base {
1060
1623
  this._isUpdating = true;
1061
1624
  try {
1062
1625
  this.willUpdate(changedProperties);
1626
+ // Each controller is isolated: a `hostUpdate()` that reaches for the
1627
+ // browser (a third-party Lit controller written for the client) is
1628
+ // reported once per controller class and skipped, so the component
1629
+ // still renders its first paint instead of an error state.
1063
1630
  for (const c of this.__controllers || NO_CONTROLLERS) {
1064
- if (c.hostUpdate) c.hostUpdate();
1631
+ if (!c.hostUpdate) continue;
1632
+ try {
1633
+ c.hostUpdate();
1634
+ } catch (err) {
1635
+ reportSsrControllerError(this, c, err);
1636
+ }
1065
1637
  }
1066
1638
  this._reflectDeclaredAttributes();
1067
1639
  } finally {
@@ -1087,10 +1659,16 @@ class WebComponentBase extends Base {
1087
1659
  * @private
1088
1660
  */
1089
1661
  _reflectDeclaredAttributes() {
1090
- const props = /** @type {any} */ (this.constructor).properties || {};
1091
- for (const name of Object.keys(props)) {
1662
+ const props = declaredProperties(this.constructor);
1663
+ for (const name of Reflect.ownKeys(props)) {
1092
1664
  const decl = props[name];
1093
- if (!decl || !decl.reflect || decl.state) continue;
1665
+ if (!decl || !decl.reflect) continue;
1666
+ // A property never given a value has nothing to reflect: Lit reflects a
1667
+ // property only once it changes from `undefined`.
1668
+ if (this[name] === undefined) continue;
1669
+ // A `useDefault` value is not reflected while it is the default, and a
1670
+ // wrapped accessor's default is only recorded at the first update.
1671
+ if (decl.useDefault && (!this.__defaultValues?.has(name) || Object.is(this[name], this.__defaultValues.get(name)))) continue;
1094
1672
  this._reflectAttribute(name, this[name], decl);
1095
1673
  }
1096
1674
  }
@@ -1154,16 +1732,14 @@ class WebComponentBase extends Base {
1154
1732
  * @param {Map<string, unknown>} _changedProperties
1155
1733
  */
1156
1734
  update(_changedProperties) {
1157
- // Track signal reads during render so the component re-renders when
1158
- // any of them change. The watcher is lazy-allocated on first
1159
- // render and disposed in disconnectedCallback. observe() clears
1160
- // prior dep edges before fn(), so each render re-records its own
1161
- // (possibly different) read set.
1162
- if (!this.__signalWatcher) {
1163
- this.__signalWatcher = new Signal.subtle.Watcher(() => {
1164
- if (this._connected) this.requestUpdate();
1165
- });
1735
+ const pendingReflection = this.__reflectPending;
1736
+ this.__reflectPending = undefined;
1737
+ for (const name of pendingReflection || []) {
1738
+ const def = declaredProperties(this.constructor)[name];
1739
+ this._reflectAttribute(name, this[name], def);
1166
1740
  }
1741
+ // The signal reads render() makes are tracked by the watcher
1742
+ // _performRender runs the whole cycle under.
1167
1743
  let tpl;
1168
1744
  // Bind this render's abort signal (#492) around the synchronous part of
1169
1745
  // render() ONLY, so the RPC stub ties an action called there to this
@@ -1176,7 +1752,7 @@ class WebComponentBase extends Base {
1176
1752
  const outer = activeActionSignal();
1177
1753
  setActiveActionSignal(this.__renderAbort ? this.__renderAbort.signal : null);
1178
1754
  try {
1179
- this.__signalWatcher.observe(() => { tpl = this.render(); });
1755
+ tpl = this.render();
1180
1756
  } finally {
1181
1757
  setActiveActionSignal(outer || null);
1182
1758
  }
@@ -1191,10 +1767,33 @@ class WebComponentBase extends Base {
1191
1767
  if (tpl && typeof (/** @type any */ (tpl).then) === 'function') {
1192
1768
  return this._commitAsync(/** @type {Promise<unknown>} */ (tpl));
1193
1769
  }
1194
- clientRender(tpl, this._renderRoot);
1770
+ // Marked before the commit, as LitElement calls `super.update()` between
1771
+ // `render()` and committing: a property a child sets while the template
1772
+ // commits (an event it dispatches from connectedCallback) is a new update.
1773
+ this._markUpdated();
1774
+ // The commit is untracked: a signal read while the DOM is patched (a
1775
+ // `watch()` part, a child's synchronous work) belongs to whatever read
1776
+ // it, not to this component's next render.
1777
+ Signal.subtle.untrack(() => clientRender(tpl, this._renderRoot, { host: this }));
1195
1778
  return undefined;
1196
1779
  }
1197
1780
 
1781
+ /**
1782
+ * Lit's `__markUpdated()`, the last step of the base `update()` once its
1783
+ * commit is done: the update is no longer pending, so a property set after
1784
+ * `super.update()` returns (in an `update()` override, or in `updated()`)
1785
+ * starts the next cycle, with its own `updateComplete`, instead of folding
1786
+ * into this one. This cycle's resolver is kept for `_postCommit`.
1787
+ * @private
1788
+ */
1789
+ _markUpdated() {
1790
+ this._isUpdating = false;
1791
+ this._changedProperties = new Map();
1792
+ this.__cycleResolve = this._updateResolve;
1793
+ this.__settlingPromise = this._updatePromise;
1794
+ this._updateResolve = null;
1795
+ }
1796
+
1198
1797
  /**
1199
1798
  * Commit a promise-returning render() with stale-while-revalidate
1200
1799
  * semantics (#469). The current DOM stays untouched until the template
@@ -1220,7 +1819,7 @@ class WebComponentBase extends Base {
1220
1819
  if (this.__firstRendered && this._overridesRenderFallback()) {
1221
1820
  try {
1222
1821
  const fb = this.renderFallback();
1223
- if (fb !== undefined) clientRender(fb, this._renderRoot);
1822
+ if (fb !== undefined) Signal.subtle.untrack(() => clientRender(fb, this._renderRoot, { host: this }));
1224
1823
  } catch (e) {
1225
1824
  console.error(`[webjs] renderFallback() threw:`, e);
1226
1825
  }
@@ -1239,7 +1838,7 @@ class WebComponentBase extends Base {
1239
1838
  // The sync path routes an identical throw to the same boundary, so
1240
1839
  // this is what makes the two paths agree.
1241
1840
  try {
1242
- clientRender(tpl, this._renderRoot);
1841
+ clientRender(tpl, this._renderRoot, { host: this });
1243
1842
  } catch (commitError) {
1244
1843
  this._handleRenderError(
1245
1844
  commitError instanceof Error ? commitError : new Error(String(commitError)),
@@ -1276,7 +1875,7 @@ class WebComponentBase extends Base {
1276
1875
  );
1277
1876
  try {
1278
1877
  const fallback = this.renderError(error);
1279
- if (fallback !== undefined) clientRender(fallback, this._renderRoot);
1878
+ if (fallback !== undefined) Signal.subtle.untrack(() => clientRender(fallback, this._renderRoot, { host: this }));
1280
1879
  } catch (fallbackError) {
1281
1880
  console.error(`[webjs] renderError() also threw:`, fallbackError);
1282
1881
  }
@@ -1289,16 +1888,32 @@ class WebComponentBase extends Base {
1289
1888
  * @private
1290
1889
  */
1291
1890
  _postCommit(changedProperties) {
1891
+ // The resolver `update()` set aside when it marked the update done, or
1892
+ // else the pending one. A cycle `update()` scheduled after marking has its
1893
+ // own promise in `_updateResolve`, which this one must leave alone.
1894
+ let resolve;
1895
+ if (this.__cycleResolve !== undefined) {
1896
+ resolve = this.__cycleResolve;
1897
+ this.__cycleResolve = undefined;
1898
+ } else {
1899
+ resolve = this._updateResolve;
1900
+ this._updateResolve = null;
1901
+ this.__settlingPromise = this._updatePromise;
1902
+ }
1292
1903
  try {
1293
- if (!this.__firstRendered) {
1294
- this.__firstRendered = true;
1295
- this.firstUpdated(changedProperties);
1296
- }
1297
- this.updated(changedProperties);
1904
+ this._tracked('__postWatcher', () => {
1905
+ for (const c of this.__controllers || NO_CONTROLLERS) c.hostUpdated?.();
1906
+ if (!this.__firstRendered) {
1907
+ this.__firstRendered = true;
1908
+ this.firstUpdated(changedProperties);
1909
+ }
1910
+ this.updated(changedProperties);
1911
+ });
1298
1912
  } catch (postCommitError) {
1299
1913
  console.error(`[webjs] lifecycle hook threw during post-commit phase:`, postCommitError);
1300
1914
  } finally {
1301
- this._resolveUpdate();
1915
+ this.__settlingPromise = undefined;
1916
+ resolve?.(!this._scheduled);
1302
1917
  }
1303
1918
  }
1304
1919
 
@@ -1337,7 +1952,7 @@ class WebComponentBase extends Base {
1337
1952
  *
1338
1953
  * ```js
1339
1954
  * firstUpdated(changedProperties) {
1340
- * this.shadowRoot.querySelector('input')?.focus();
1955
+ * this.renderRoot.querySelector('input')?.focus();
1341
1956
  * }
1342
1957
  * ```
1343
1958
  *
@@ -1381,6 +1996,14 @@ class WebComponentBase extends Base {
1381
1996
  * @returns {Promise<boolean>}
1382
1997
  */
1383
1998
  getUpdateComplete() {
1999
+ // As in Lit, the promise read before the first update settles with that
2000
+ // update, not at once. Created on first read, so an element nothing awaits
2001
+ // (every SSR instance) never allocates one; the server never updates.
2002
+ if (isBrowser && this._initialUpdatePending && this._updateResolve === null) {
2003
+ this._updatePromise = new Promise((resolve) => {
2004
+ this._updateResolve = resolve;
2005
+ });
2006
+ }
1384
2007
  return this._updatePromise;
1385
2008
  }
1386
2009
 
@@ -1439,11 +2062,13 @@ class WebComponentBase extends Base {
1439
2062
  }
1440
2063
 
1441
2064
  /**
1442
- * Override in subclasses to return a TemplateResult.
2065
+ * Override in subclasses to return a TemplateResult. The default returns
2066
+ * `noChange`, as Lit's does, so a component with no `render()` leaves its
2067
+ * children exactly as they are, on the server and in the browser.
1443
2068
  * @returns {unknown}
1444
2069
  */
1445
2070
  render() {
1446
- return '';
2071
+ return noChange;
1447
2072
  }
1448
2073
 
1449
2074
  /**
@@ -1501,10 +2126,33 @@ class WebComponentBase extends Base {
1501
2126
  }
1502
2127
  }
1503
2128
 
1504
- /** @param {string} s */
1505
- function hyphenate(s) {
1506
- return s.replace(/([A-Z])/g, '-$1').toLowerCase();
2129
+ /**
2130
+ * Report a controller whose `hostUpdate()` threw during SSR, once per
2131
+ * controller class.
2132
+ * @param {any} host
2133
+ * @param {any} controller
2134
+ * @param {unknown} err
2135
+ */
2136
+ function reportSsrControllerError(host, controller, err) {
2137
+ const Cls = controller && controller.constructor;
2138
+ if (Cls && REPORTED_SSR_CONTROLLERS.has(Cls)) return;
2139
+ if (Cls) REPORTED_SSR_CONTROLLERS.add(Cls);
2140
+ const tag = host.__ssrTag || tagOf(host.constructor) || 'a component';
2141
+ console.error(
2142
+ `[webjs] ${Cls && Cls.name ? Cls.name : 'A controller'}.hostUpdate() threw during SSR of <${tag}>, so it was skipped ` +
2143
+ `and the component rendered without it. Controllers' hostUpdate() runs on the server too; guard browser-only ` +
2144
+ `work with \`if (isServer) return;\` (isServer from '@webjsdev/core'), or move it to hostConnected() or hostUpdated(), ` +
2145
+ `which never run at SSR.`,
2146
+ err,
2147
+ );
1507
2148
  }
2149
+
2150
+ /**
2151
+ * The error a light-DOM component with `static styles` gets, on the server
2152
+ * (dev) and in the browser.
2153
+ * @param {string | undefined} tag
2154
+ * @returns {string}
2155
+ */
1508
2156
  /** @param {string} s */
1509
2157
  function camelCase(s) {
1510
2158
  return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
@@ -1524,11 +2172,32 @@ export function WebComponent(properties) {
1524
2172
  } else {
1525
2173
  return class extends WebComponentBase {
1526
2174
  static properties = properties;
1527
- static [FACTORY_PROPS] = true;
1528
2175
  };
1529
2176
  }
1530
2177
  }
1531
2178
 
2179
+ /**
2180
+ * Re-assign every own data property of `el` that hides a setter defined on
2181
+ * its class chain below the WebJs base, so the value goes through the
2182
+ * accessor instead of shadowing it.
2183
+ *
2184
+ * @param {any} el
2185
+ */
2186
+ function liftShadowedAccessors(el) {
2187
+ for (const key of Object.keys(el)) {
2188
+ for (let p = Object.getPrototypeOf(el); p && p !== WebComponentBase.prototype; p = Object.getPrototypeOf(p)) {
2189
+ const d = Object.getOwnPropertyDescriptor(p, key);
2190
+ if (!d) continue;
2191
+ if (d.set) {
2192
+ const value = el[key];
2193
+ delete el[key];
2194
+ el[key] = value;
2195
+ }
2196
+ break;
2197
+ }
2198
+ }
2199
+ }
2200
+
1532
2201
  // Ensure static inheritance and instanceof checks work
1533
2202
  Object.setPrototypeOf(WebComponent, WebComponentBase);
1534
2203
  WebComponent.prototype = WebComponentBase.prototype;