@webjsdev/core 0.8.0 → 0.9.1

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 (85) 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 +20 -19
  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 +14 -9
  9. package/index.d.ts +53 -10
  10. package/index.js +17 -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.d.ts +18 -0
  16. package/src/asset-url.js +12 -13
  17. package/src/async-directive.d.ts +18 -0
  18. package/src/async-directive.js +88 -0
  19. package/src/attribute-reader.js +197 -23
  20. package/src/binding-prefixes.js +0 -12
  21. package/src/component/lifecycle.js +760 -209
  22. package/src/component/reactive.js +2 -10
  23. package/src/component/server-element.js +23 -1
  24. package/src/component.d.ts +151 -41
  25. package/src/context.d.ts +94 -18
  26. package/src/context.js +341 -217
  27. package/src/converters.d.ts +7 -0
  28. package/src/converters.js +38 -0
  29. package/src/csp-nonce.d.ts +18 -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 +38 -1
  35. package/src/directives.js +158 -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 +4 -0
  40. package/src/html.js +35 -3
  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 +8 -3
  51. package/src/render-client/parts.js +745 -31
  52. package/src/render-client/reconciler.js +56 -11
  53. package/src/render-client/template-compiler.js +139 -44
  54. package/src/render-client.d.ts +5 -1
  55. package/src/render-server/attrs.js +2 -2
  56. package/src/render-server/compile.js +34 -0
  57. package/src/render-server/errors.js +37 -0
  58. package/src/render-server/expand.js +61 -8
  59. package/src/render-server/served-attrs.js +1 -2
  60. package/src/render-server/walk.js +135 -3
  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/dom-differ.js +17 -0
  66. package/src/router-client/fetch-apply.js +86 -5
  67. package/src/router-client/navigator.js +45 -9
  68. package/src/router-client/prefetch.js +3 -4
  69. package/src/router-client/root-attrs.js +43 -0
  70. package/src/router-client/stream.js +7 -0
  71. package/src/router-client/swap.js +4 -1
  72. package/src/routes.d.ts +75 -18
  73. package/src/serialize.d.ts +2 -0
  74. package/src/serialize.js +35 -4
  75. package/src/signal.d.ts +7 -0
  76. package/src/signal.js +26 -6
  77. package/src/slot/polyfills.js +3 -3
  78. package/src/slot.js +4 -8
  79. package/src/static-html.d.ts +22 -0
  80. package/src/static-html.js +144 -0
  81. package/src/task.d.ts +63 -20
  82. package/src/task.js +326 -293
  83. package/src/testing.d.ts +42 -0
  84. package/src/webjs-config.d.ts +1 -8
  85. package/src/websocket-client.d.ts +24 -0
@@ -1,11 +1,15 @@
1
1
  import { render as clientRender } from '../render-client.js';
2
- import { readAttributeValue, resolveAttributeProperty, observedAttributeNames, isAttributeBacked } 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.
@@ -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,53 +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
- this.__propValues[propName] = newVal;
152
- if (!changedCheck(newVal, oldVal)) return;
153
- // Reflect to attribute if requested (_reflectAttribute skips a prop
154
- // with no attribute: `state: true` or `attribute: false`).
155
- if (d.reflect) {
156
- if (this._connected) {
157
- this._reflectAttribute(propName, newVal, d);
158
- } else {
159
- (this.__reflectPending ??= new Set()).add(propName);
160
- }
161
- }
162
- // requestUpdate records the (name, oldValue) entry AND schedules a
163
- // render. During the update phase the scheduler short-circuits and
164
- // the entry folds into the current cycle's changedProperties.
165
- this.requestUpdate(propName, oldVal);
166
- },
167
- });
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);
168
222
  }
169
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
+ }
170
227
  return plan;
171
228
  }
172
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
+
173
254
  class WebComponentBase extends Base {
174
255
  /** Whether to use shadow DOM. Default: false (light DOM). @type {boolean} */
175
256
  static shadow = false;
176
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
+
177
267
  /**
178
268
  * Hydration strategy for this component.
179
269
  *
@@ -210,11 +300,40 @@ class WebComponentBase extends Base {
210
300
  static properties = {};
211
301
 
212
302
  /**
213
- * Styles to adopt into the shadow root.
214
- * @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}
215
308
  */
216
309
  static styles = null;
217
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
+
218
337
  /**
219
338
  * Register this class as a custom element under `tag`.
220
339
  *
@@ -232,6 +351,41 @@ class WebComponentBase extends Base {
232
351
  register(tag, this);
233
352
  }
234
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
+
235
389
  /**
236
390
  * Returns the list of attribute names the browser should observe.
237
391
  * Properties with `state: true` or `attribute: false` are excluded: they
@@ -240,19 +394,167 @@ class WebComponentBase extends Base {
240
394
  * @returns {string[]}
241
395
  */
242
396
  static get observedAttributes() {
397
+ this.finalize();
243
398
  // Derived from the same walk `resolveAttributeProperty` matches against, so
244
399
  // every observed name resolves and nothing else does. A camelCase prop with
245
- // no custom `attribute` is observed under its kebab name AND its lowercased
246
- // name (lit's default), since the parser turns `quizId=` into `quizid`
247
- // (#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.
248
403
  return observedAttributeNames(this);
249
404
  }
250
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
+
251
545
  constructor() {
252
546
  super();
253
- 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;
254
554
  this._scheduled = false;
255
555
  this._connected = false;
556
+ /** Pending from construction until the first update runs, as in Lit. */
557
+ this._initialUpdatePending = true;
256
558
 
257
559
  /**
258
560
  * Set of attached reactive controllers, created by the first
@@ -306,43 +608,14 @@ class WebComponentBase extends Base {
306
608
  */
307
609
  this._isUpdating = false;
308
610
 
309
- // Enforce the declare-free factory DX: a hand-written `static properties`
310
- // in a class body is a hard error (use `extends WebComponent({ … })`).
311
- this._assertFactoryProperties();
312
-
313
- // 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`.
314
614
  this._initializeProperties();
315
- }
316
615
 
317
- /**
318
- * Throw if a class in this instance's constructor chain declares its own
319
- * `static properties`. Reactive properties must be declared via the
320
- * `extends WebComponent({ … })` factory, which stamps {@link FACTORY_PROPS}
321
- * on the subclass it generates; a `static properties` written by hand in a
322
- * class body carries no such marker and is rejected here (issue #598).
323
- *
324
- * The walk stops at {@link WebComponentBase} (whose `static properties = {}`
325
- * default is internal) and is memoized per class so it runs once.
326
- * @private
327
- */
328
- _assertFactoryProperties() {
329
- const Ctor = /** @type {any} */ (this.constructor);
330
- if (_propsChecked.has(Ctor)) return;
331
- let C = Ctor;
332
- while (C && C !== WebComponentBase) {
333
- if (Object.hasOwn(C, 'properties') && !Object.hasOwn(C, FACTORY_PROPS)) {
334
- const name = C.name || 'a component';
335
- throw new Error(
336
- `${name}: \`static properties\` is no longer supported. Declare reactive ` +
337
- `properties via the factory instead: \`class ${name} extends WebComponent({ ` +
338
- `count: Number })\`. Use the \`prop()\` helper for options ` +
339
- `(\`prop(Number, { reflect: true })\`) and set defaults in the ` +
340
- `constructor after \`super()\`. See https://webjs.dev/docs/components.`,
341
- );
342
- }
343
- C = Object.getPrototypeOf(C);
344
- }
345
- _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);
346
619
  }
347
620
 
348
621
  /**
@@ -352,17 +625,13 @@ class WebComponentBase extends Base {
352
625
  * once per class. Per instance this only lifts a value written before the
353
626
  * element upgraded and applies the declared `default`s.
354
627
  *
355
- * Called once from the constructor. The backing store is a plain object
356
- * (`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.
357
631
  * @private
358
632
  */
359
633
  _initializeProperties() {
360
634
  const Ctor = /** @type {any} */ (this.constructor);
361
- const props = Ctor.properties;
362
- if (!props || typeof props !== 'object') return;
363
-
364
- /** @type {Record<string, unknown>} */
365
- this.__propValues = {};
366
635
 
367
636
  // The accessors live on the PROTOTYPE, installed once per class (#1646).
368
637
  // They used to be defined on every instance, two closures and a
@@ -370,27 +639,32 @@ class WebComponentBase extends Base {
370
639
  // cost of constructing a component on the SSR path (one instance per
371
640
  // rendered element) and a measurable one on upgrade in the browser.
372
641
  let plan = _accessorPlans.get(Ctor);
373
- 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
+ }
374
648
 
375
649
  for (let i = 0; i < plan.length; i++) {
376
- const { propName, decl: d } = plan[i];
377
- // Capture any value set before the accessor could apply (an own data
378
- // property written on the element before it upgraded). It shadows the
379
- // prototype accessor, so it is lifted into the backing store.
380
- 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.
381
655
  if (Object.hasOwn(this, propName)) {
382
- initial = /** @type {any} */ (this)[propName];
656
+ (this.__instanceProperties ??= new Map()).set(propName, /** @type {any} */ (this)[propName]);
383
657
  delete (/** @type {any} */ (this))[propName];
384
658
  }
385
- if (initial !== undefined) {
386
- this.__propValues[propName] = initial;
387
- } else if (d.default !== undefined) {
659
+ if (!builtIn) continue;
660
+ if (d.default !== undefined) {
388
661
  // Declarative `default` option (lit-parity). A function default is
389
662
  // CALLED per instance, so an object / array default is a fresh value
390
663
  // per element. Written straight to the backing store; an applied
391
664
  // attribute (attributeChangedCallback runs later) overrides it.
392
- this.__propValues[propName] =
393
- 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);
394
668
  }
395
669
  }
396
670
  }
@@ -408,9 +682,10 @@ class WebComponentBase extends Base {
408
682
  _reflectAttribute(propName, value, decl) {
409
683
  // A property with no attribute (`state: true` / `attribute: false`) has
410
684
  // nothing to reflect to.
411
- if (!isAttributeBacked(decl)) return;
412
- // A custom `attribute` option wins over the kebab-cased property name.
413
- const attrName = decl.attribute || hyphenate(propName);
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;
414
689
  // Guard against re-entrant loops: attributeChangedCallback fires when
415
690
  // we call setAttribute, which would call the setter again.
416
691
  if (this.__reflectingAttribute) return;
@@ -569,10 +844,16 @@ class WebComponentBase extends Base {
569
844
  const n = attrs[i].name;
570
845
  if (n.startsWith('data-webjs-prop-')) names.push(n);
571
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();
572
852
  for (const fullName of names) {
573
853
  const raw = this.getAttribute(fullName);
574
854
  this.removeAttribute(fullName);
575
855
  if (raw == null) continue;
856
+ self.__webjsPropAttrValues.set(fullName, raw);
576
857
  const propName = camelCase(fullName.slice('data-webjs-prop-'.length));
577
858
  try {
578
859
  /** @type any */ (this)[propName] = deserializeProp(raw);
@@ -588,7 +869,7 @@ class WebComponentBase extends Base {
588
869
  this._connected = true;
589
870
  // A value written on the element before its class was defined is an own
590
871
  // data property, and it hides any accessor of the same name for good.
591
- // `_initializeProperties` lifts the declared properties in the
872
+ // `_initializeProperties` saves the declared properties in the
592
873
  // constructor; an accessor the subclass defines itself (a getter/setter
593
874
  // over a private signal, the shape internal state another element assigns
594
875
  // takes) gets the same lift here, on the first connect, since its private
@@ -615,34 +896,94 @@ class WebComponentBase extends Base {
615
896
  if (Ctor.shadow !== true && !this.hasAttribute('data-wj-host')) {
616
897
  this.setAttribute('data-wj-host', '');
617
898
  }
618
- if (Ctor.shadow === true) {
619
- const hadSSRShadow = !!this.shadowRoot;
620
- if (!this.shadowRoot) {
621
- /** @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);
622
910
  }
623
- this._renderRoot = this.shadowRoot;
624
- const styles = Ctor.styles;
625
- const list = Array.isArray(styles) ? styles : isCSS(styles) ? [styles] : [];
626
- if (list.length) {
627
- // If the shadow root came from Declarative Shadow DOM (SSR), it
628
- // contains an inline <style> tag. Remove it before switching to
629
- // adoptedStyleSheets to avoid duplicate styles.
630
- if (hadSSRShadow) {
631
- const ssrStyle = this.shadowRoot.querySelector('style');
632
- if (ssrStyle) ssrStyle.remove();
633
- }
634
- 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
+ );
635
977
  }
636
- } else {
637
- this._renderRoot = this;
978
+ this._renderRoot = root;
979
+ }
980
+ if (Ctor.shadow !== true && this._renderRoot === this) {
638
981
  // Light DOM: static styles is not supported (no shadow root for
639
- // adoptedStyleSheets). Warn if the developer set both.
640
- if (Ctor.styles) {
641
- console.warn(
642
- `[webjs] <${tagOf(Ctor) || this.tagName?.toLowerCase()}> has static shadow = false AND static styles. ` +
643
- `static styles only works with shadow DOM (adoptedStyleSheets). ` +
644
- `For light DOM, use global CSS or <style> in render().`
645
- );
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()));
646
987
  }
647
988
  // Light-DOM slot capture: ONCE per host lifetime (#1015). Three
648
989
  // sub-paths, all of which resolve to "the record is populated
@@ -668,11 +1009,17 @@ class WebComponentBase extends Base {
668
1009
  // c. First mount, no SSR. Partition authored children into the
669
1010
  // record before _performRender wipes the host.
670
1011
  //
671
- // After capture, installSlotInterception + installSlotSensors below make
1012
+ // After capture, installSlotInterception + installSlotSensors in `_activate` make
672
1013
  // native DOM writes on a mounted host live (appendChild, slot= flips,
673
1014
  // innerHTML, HTMLSlotElement.assign), so light-DOM slots match shadow DOM
674
1015
  // through the standard API.
675
- 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)) {
676
1023
  // (a) Reconnection. Record already populated. Sweep any direct child
677
1024
  // added by a raw bypass write while the host was disconnected (no
678
1025
  // sensor was live to catch it), then re-arm the sensors below.
@@ -699,53 +1046,136 @@ class WebComponentBase extends Base {
699
1046
  // (c) First mount with author-written children. Capture them.
700
1047
  captureAuthoredChildren(this);
701
1048
  }
702
- // Install native-write interception AFTER capture (capture uses the
703
- // host's still-native methods), then arm the sensors. Together they make
704
- // appendChild / insertBefore / removeChild / innerHTML / slot= flips on a
705
- // mounted light host drive the slot record, restoring full shadow-DOM
706
- // parity through the standard DOM API. Interception installs once;
707
- // sensors are armed on every connect and torn down on disconnect.
708
- installSlotInterception(this);
709
- installSlotSensors(this);
710
1049
  }
1050
+ }
711
1051
 
712
- // Notify all controllers that the host is connected.
713
- for (const c of this.__controllers || NO_CONTROLLERS) {
714
- 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);
715
1071
  }
1072
+ }
716
1073
 
717
- // First render is deferred to a microtask, matching lit's
718
- // performUpdate scheduling. The user's connectedCallback override
719
- // body runs synchronously to completion BEFORE the first render,
720
- // so subclass setup that needs to inspect attributes / state / etc.
721
- // observes consistent timing whether SSR-hydrating or first mount.
722
- // Render-root setup (shadow attachment, light-DOM SSR adoption,
723
- // controller hostConnected, _connected=true) all stay synchronous
724
- // above; only the template commit + post-commit slot observers
725
- // defer. Authoring contract: post-render DOM setup goes in
726
- // firstUpdated(), NOT connectedCallback().
727
- //
728
- // The scheduling matches `_scheduleUpdate`'s wrapper so lifecycle
729
- // throws are surfaced as console errors instead of bricking the
730
- // element.
731
- if (this._updateResolve === null) {
732
- this._updatePromise = new Promise((resolve) => {
733
- this._updateResolve = resolve;
734
- });
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;
735
1123
  }
736
- this._scheduled = true;
737
- queueMicrotask(() => {
738
- this._scheduled = false;
739
- try {
740
- this._performRender();
741
- } catch (err) {
742
- 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();
743
1175
  }
744
- // The renderer's slot parts place the record content as part of the
745
- // commit; native DOM writes on the host (appendChild, slot= flips,
746
- // innerHTML) drive the record live through the interception + sensors
747
- // installed on connect. There is no WebJs-specific slot API.
748
- });
1176
+ adoptStyles(root, list);
1177
+ }
1178
+ return root;
749
1179
  }
750
1180
 
751
1181
  /**
@@ -824,30 +1254,72 @@ class WebComponentBase extends Base {
824
1254
  const resolved = resolveAttributeProperty(this.constructor, name);
825
1255
  if (resolved === undefined) return;
826
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;
827
1261
 
828
1262
  // One reader for both sides, in `attribute-reader.js`: this and
829
1263
  // `applyAttrsToInstance` in `render-server.js` call the same function, so
830
1264
  // precedence and every fallback are shared rather than mirrored (#1340).
831
1265
  // It takes an already-decoded value, which the DOM has done here and
832
1266
  // `decodeAttrEntities` does at the SSR call site (#1341).
833
- 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);
834
1269
 
835
- 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 {
836
1275
  this[propName] = v;
837
- if (this._connected) this.requestUpdate();
1276
+ } finally {
1277
+ this.__attributeToProperty = undefined;
838
1278
  }
839
1279
  }
840
1280
 
841
1281
  /**
842
- * Schedule a re-render. Optionally record a property change in
843
- * `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.
844
1288
  *
845
1289
  * @param {string} [name] Property name that changed
846
1290
  * @param {unknown} [oldValue] Previous value of the property
1291
+ * @param {PropertyDeclaration} [options] The property's declaration
847
1292
  */
848
- requestUpdate(name, oldValue) {
849
- if (name !== undefined && !this._changedProperties.has(name)) {
850
- 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
+ }
851
1323
  }
852
1324
  this._scheduleUpdate();
853
1325
  }
@@ -868,21 +1340,21 @@ class WebComponentBase extends Base {
868
1340
  });
869
1341
  }
870
1342
  if (this._isUpdating) return;
871
- 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;
872
1346
  this._scheduled = true;
873
- queueMicrotask(() => {
874
- this._scheduled = false;
875
- try {
876
- this._performRender();
877
- } catch (err) {
878
- // _performRender wraps the update phase in try/finally so this
879
- // catches throws from shouldUpdate / willUpdate / hostUpdate /
880
- // hostUpdated / firstUpdated / updated. The component is not
881
- // left in a bad state (the finally blocks reset _isUpdating and
882
- // resolve updateComplete). Surface the error for visibility.
883
- console.error(`[webjs] lifecycle hook threw during update cycle:`, err);
884
- }
885
- });
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'));
886
1358
  }
887
1359
 
888
1360
  /**
@@ -1039,7 +1511,8 @@ class WebComponentBase extends Base {
1039
1511
  // Consume this phase's snapshot even when shouldUpdate skips it. For an
1040
1512
  // async render, changes during the fetch start a new cycle whose token
1041
1513
  // supersedes the in-flight commit.
1042
- this._changedProperties = new Map();
1514
+ // `update()` already swapped it when it committed (see there).
1515
+ if (this._changedProperties === changedProperties) this._changedProperties = new Map();
1043
1516
  }
1044
1517
 
1045
1518
  // --- Async render: finish the cycle when the pending commit settles. ---
@@ -1150,8 +1623,17 @@ class WebComponentBase extends Base {
1150
1623
  this._isUpdating = true;
1151
1624
  try {
1152
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.
1153
1630
  for (const c of this.__controllers || NO_CONTROLLERS) {
1154
- if (c.hostUpdate) c.hostUpdate();
1631
+ if (!c.hostUpdate) continue;
1632
+ try {
1633
+ c.hostUpdate();
1634
+ } catch (err) {
1635
+ reportSsrControllerError(this, c, err);
1636
+ }
1155
1637
  }
1156
1638
  this._reflectDeclaredAttributes();
1157
1639
  } finally {
@@ -1177,10 +1659,16 @@ class WebComponentBase extends Base {
1177
1659
  * @private
1178
1660
  */
1179
1661
  _reflectDeclaredAttributes() {
1180
- const props = /** @type {any} */ (this.constructor).properties || {};
1181
- for (const name of Object.keys(props)) {
1662
+ const props = declaredProperties(this.constructor);
1663
+ for (const name of Reflect.ownKeys(props)) {
1182
1664
  const decl = props[name];
1183
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;
1184
1672
  this._reflectAttribute(name, this[name], decl);
1185
1673
  }
1186
1674
  }
@@ -1247,7 +1735,7 @@ class WebComponentBase extends Base {
1247
1735
  const pendingReflection = this.__reflectPending;
1248
1736
  this.__reflectPending = undefined;
1249
1737
  for (const name of pendingReflection || []) {
1250
- const def = this.constructor.properties[name];
1738
+ const def = declaredProperties(this.constructor)[name];
1251
1739
  this._reflectAttribute(name, this[name], def);
1252
1740
  }
1253
1741
  // The signal reads render() makes are tracked by the watcher
@@ -1279,6 +1767,10 @@ class WebComponentBase extends Base {
1279
1767
  if (tpl && typeof (/** @type any */ (tpl).then) === 'function') {
1280
1768
  return this._commitAsync(/** @type {Promise<unknown>} */ (tpl));
1281
1769
  }
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();
1282
1774
  // The commit is untracked: a signal read while the DOM is patched (a
1283
1775
  // `watch()` part, a child's synchronous work) belongs to whatever read
1284
1776
  // it, not to this component's next render.
@@ -1286,6 +1778,22 @@ class WebComponentBase extends Base {
1286
1778
  return undefined;
1287
1779
  }
1288
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
+
1289
1797
  /**
1290
1798
  * Commit a promise-returning render() with stale-while-revalidate
1291
1799
  * semantics (#469). The current DOM stays untouched until the template
@@ -1380,8 +1888,18 @@ class WebComponentBase extends Base {
1380
1888
  * @private
1381
1889
  */
1382
1890
  _postCommit(changedProperties) {
1383
- const resolve = this._updateResolve;
1384
- this._updateResolve = null;
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
+ }
1385
1903
  try {
1386
1904
  this._tracked('__postWatcher', () => {
1387
1905
  for (const c of this.__controllers || NO_CONTROLLERS) c.hostUpdated?.();
@@ -1394,6 +1912,7 @@ class WebComponentBase extends Base {
1394
1912
  } catch (postCommitError) {
1395
1913
  console.error(`[webjs] lifecycle hook threw during post-commit phase:`, postCommitError);
1396
1914
  } finally {
1915
+ this.__settlingPromise = undefined;
1397
1916
  resolve?.(!this._scheduled);
1398
1917
  }
1399
1918
  }
@@ -1433,7 +1952,7 @@ class WebComponentBase extends Base {
1433
1952
  *
1434
1953
  * ```js
1435
1954
  * firstUpdated(changedProperties) {
1436
- * this.shadowRoot.querySelector('input')?.focus();
1955
+ * this.renderRoot.querySelector('input')?.focus();
1437
1956
  * }
1438
1957
  * ```
1439
1958
  *
@@ -1477,6 +1996,14 @@ class WebComponentBase extends Base {
1477
1996
  * @returns {Promise<boolean>}
1478
1997
  */
1479
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
+ }
1480
2007
  return this._updatePromise;
1481
2008
  }
1482
2009
 
@@ -1535,11 +2062,13 @@ class WebComponentBase extends Base {
1535
2062
  }
1536
2063
 
1537
2064
  /**
1538
- * 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.
1539
2068
  * @returns {unknown}
1540
2069
  */
1541
2070
  render() {
1542
- return '';
2071
+ return noChange;
1543
2072
  }
1544
2073
 
1545
2074
  /**
@@ -1597,10 +2126,33 @@ class WebComponentBase extends Base {
1597
2126
  }
1598
2127
  }
1599
2128
 
1600
- /** @param {string} s */
1601
- function hyphenate(s) {
1602
- 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
+ );
1603
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
+ */
1604
2156
  /** @param {string} s */
1605
2157
  function camelCase(s) {
1606
2158
  return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
@@ -1620,7 +2172,6 @@ export function WebComponent(properties) {
1620
2172
  } else {
1621
2173
  return class extends WebComponentBase {
1622
2174
  static properties = properties;
1623
- static [FACTORY_PROPS] = true;
1624
2175
  };
1625
2176
  }
1626
2177
  }