@webjsdev/core 0.7.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.
@@ -0,0 +1,1061 @@
1
+ import { render as clientRender } from './render-client.js';
2
+ import { isCSS, adoptStyles } from './css.js';
3
+ import { register, tagOf } from './registry.js';
4
+ import { parse as deserializeProp } from './serialize.js';
5
+ import { Signal } from './signal.js';
6
+ import {
7
+ captureAuthoredChildren,
8
+ adoptSSRAssignments,
9
+ attachSlotObservers,
10
+ detachSlotObservers,
11
+ ensureSlotState,
12
+ hasSlotState,
13
+ } from './slot.js';
14
+
15
+ const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undefined';
16
+
17
+ /**
18
+ * @typedef {Object} ReactiveController
19
+ * A controller is a reusable piece of lifecycle logic that plugs into a
20
+ * WebComponent host. Controllers let you extract cross-cutting concerns
21
+ * (timers, intersection observers, media queries, form validation, fetch
22
+ * caching) out of the component class and share them across unrelated
23
+ * components.
24
+ *
25
+ * **When to use:** any time two or more components need the same
26
+ * `connectedCallback` / `disconnectedCallback` / pre-render / post-render
27
+ * behaviour. Instead of a mixin or base-class hierarchy, attach a controller.
28
+ *
29
+ * **Why it exists:** mirrors Lit's `ReactiveController` protocol so
30
+ * ecosystem controllers are interoperable.
31
+ *
32
+ * @property {() => void} [hostConnected]
33
+ * Called when the host element is inserted into the DOM
34
+ * (`connectedCallback`). Use for subscriptions, observers, timers.
35
+ * @property {() => void} [hostDisconnected]
36
+ * Called when the host element is removed from the DOM
37
+ * (`disconnectedCallback`). Use for cleanup: unsubscribe, disconnect
38
+ * observers, clear timers.
39
+ * @property {() => void} [hostUpdate]
40
+ * Called just before the host renders (inside `_performRender`, after
41
+ * `willUpdate` but before `render()`). Use for reading layout or
42
+ * preparing data that the render depends on.
43
+ * @property {() => void} [hostUpdated]
44
+ * Called after the host has rendered and the DOM is up to date. Use for
45
+ * post-render side effects that depend on the new DOM (measuring,
46
+ * focusing, scrolling).
47
+ */
48
+
49
+ /**
50
+ * @typedef {Object} PropertyDeclaration
51
+ * Declares how a single reactive property behaves. Used inside
52
+ * `static properties = { propName: { …declaration } }`.
53
+ *
54
+ * @property {Function} [type]
55
+ * Constructor used for attribute → property coercion.
56
+ * Supported built-ins: `String`, `Number`, `Boolean`, `Object`, `Array`.
57
+ * Default: `String`.
58
+ *
59
+ * @property {boolean} [reflect]
60
+ * When `true`, writing to the property also sets the corresponding
61
+ * HTML attribute on the element (kebab-cased). Useful when you want
62
+ * CSS attribute selectors like `my-el[mode="dark"]` to work.
63
+ *
64
+ * @property {boolean} [state]
65
+ * When `true`, the property is *internal-only*: it is NOT exposed as an
66
+ * HTML attribute (excluded from `observedAttributes`) and never reflects.
67
+ * It still triggers a re-render when changed via the generated setter.
68
+ * Use for private reactive state that shouldn't leak into the DOM.
69
+ *
70
+ * @property {(newValue: unknown, oldValue: unknown) => boolean} [hasChanged]
71
+ * Custom dirty-check function. Called by the generated setter before
72
+ * scheduling an update. Return `true` to trigger a re-render, `false`
73
+ * to skip. Default: strict inequality `(a, b) => a !== b`.
74
+ *
75
+ * Note: this fires on the FIRST assignment too, with `oldValue` set
76
+ * to `undefined`. Numeric comparators that subtract against undefined
77
+ * produce `NaN`, which evaluates to `false`, silently rejecting the
78
+ * constructor's initial assignment. Treat `oldValue === undefined` as
79
+ * "always changed" in custom comparators so the initial value lands.
80
+ *
81
+ * @property {{ fromAttribute: (value: string|null, type?: Function) => unknown, toAttribute: (value: unknown, type?: Function) => string|null }} [converter]
82
+ * Custom serialization/deserialization pair for the HTML attribute.
83
+ * `fromAttribute` is called in `attributeChangedCallback`;
84
+ * `toAttribute` is called when reflecting back to the attribute.
85
+ * If omitted, the built-in type-based coercion is used.
86
+ */
87
+
88
+ /**
89
+ * Default change detection: strict inequality.
90
+ * @param {unknown} a
91
+ * @param {unknown} b
92
+ * @returns {boolean}
93
+ */
94
+ function defaultHasChanged(a, b) {
95
+ return a !== b;
96
+ }
97
+
98
+ /**
99
+ * A minimal base for HTML Custom Elements that mirrors Lit's ergonomics
100
+ * while staying JSDoc-only and no-build.
101
+ *
102
+ * Subclasses declare:
103
+ * - `static properties`: attribute/property declarations with type info,
104
+ * reflection, custom converters, and internal-state mode
105
+ * - `static styles`: CSSResult or array thereof (only meaningful with
106
+ * `static shadow = true`; light-DOM components inherit global CSS)
107
+ * - `static shadow`: set `true` to opt in to shadow DOM (default: `false`
108
+ * → light DOM, so Tailwind / global CSS apply directly)
109
+ * - `render()`: returns a TemplateResult
110
+ *
111
+ * The tag name is not a static field: pass it to `.register('tag-name')`
112
+ * at the bottom of the file. Tag must contain a hyphen (HTML spec).
113
+ *
114
+ * Lifecycle (lit-aligned, called in order during each update cycle):
115
+ * 1. `shouldUpdate(changedProperties)`. Skip update if false.
116
+ * 2. `willUpdate(changedProperties)`. Safe to set properties; folds into this cycle.
117
+ * 3. controllers' `hostUpdate()`
118
+ * 4. `update(changedProperties)`. Default impl calls `render()` + commits.
119
+ * 5. controllers' `hostUpdated()`
120
+ * 6. `firstUpdated(changedProperties)`: once, on the first render only
121
+ * 7. `updated(changedProperties)`: every render commit
122
+ * 8. `updateComplete` promise resolves
123
+ *
124
+ * `changedProperties` is a `Map<string, unknown>` where each entry maps
125
+ * a property name to its previous value.
126
+ *
127
+ * Usage:
128
+ * ```js
129
+ * import { signal } from '@webjsdev/core';
130
+ *
131
+ * const count = signal(0);
132
+ *
133
+ * class MyCounter extends WebComponent {
134
+ * render() {
135
+ * return html`<button @click=${() => count.set(count.get() + 1)}>${count.get()}</button>`;
136
+ * }
137
+ * }
138
+ * MyCounter.register('my-counter');
139
+ * ```
140
+ */
141
+
142
+ // Base class choice: real HTMLElement on the browser, a dummy on the server.
143
+ const Base = isBrowser ? HTMLElement : /** @type {any} */ (class {});
144
+
145
+ export class WebComponent extends Base {
146
+ /** Whether to use shadow DOM. Default: false (light DOM). @type {boolean} */
147
+ static shadow = false;
148
+
149
+ /**
150
+ * Hydration strategy for this component.
151
+ *
152
+ * **AI hint:** Set `static hydrate = 'visible'` to defer client-side
153
+ * hydration until the element scrolls into (or near) the viewport. The
154
+ * server-rendered Declarative Shadow DOM content stays visible the whole
155
+ * time: users see the SSR HTML immediately while JavaScript activation
156
+ * is deferred. This is useful for below-the-fold components that don't
157
+ * need interactivity right away.
158
+ *
159
+ * - `undefined` (default): hydrate immediately on `connectedCallback`.
160
+ * - `'visible'`: hydrate when the element enters the viewport
161
+ * (with a 200 px root margin).
162
+ *
163
+ * @type {'visible' | undefined}
164
+ */
165
+ static hydrate = undefined;
166
+
167
+ /**
168
+ * Attribute/property declarations.
169
+ *
170
+ * Each key is a property name; the value is a {@link PropertyDeclaration}.
171
+ *
172
+ * Properties declared here get auto-generated accessors (getter/setter)
173
+ * that trigger re-renders on change, coerce attribute values by type,
174
+ * and optionally reflect back to attributes.
175
+ *
176
+ * Properties with `state: true` are excluded from `observedAttributes`
177
+ * and never reflect: they behave like private reactive state.
178
+ *
179
+ * @type {Record<string, PropertyDeclaration>}
180
+ */
181
+ static properties = {};
182
+
183
+ /**
184
+ * Styles to adopt into the shadow root.
185
+ * @type {import('./css.js').CSSResult | import('./css.js').CSSResult[] | null}
186
+ */
187
+ static styles = null;
188
+
189
+ /**
190
+ * Register this class as a custom element under `tag`.
191
+ *
192
+ * class Counter extends WebComponent { … }
193
+ * Counter.register('my-counter');
194
+ *
195
+ * Delegates to `customElements.define` (or the server-side shim) via the
196
+ * internal registry. The module URL for `<link rel="modulepreload">`
197
+ * hints is derived server-side by scanning the app tree: no need to
198
+ * pass `import.meta.url`.
199
+ *
200
+ * @param {string} tag Must contain a hyphen (HTML spec).
201
+ */
202
+ static register(tag) {
203
+ register(tag, this);
204
+ }
205
+
206
+ /**
207
+ * Returns the list of attribute names the browser should observe.
208
+ * Properties with `state: true` are excluded: they are internal-only
209
+ * and do not correspond to any HTML attribute.
210
+ *
211
+ * @returns {string[]}
212
+ */
213
+ static get observedAttributes() {
214
+ const props = this.properties || {};
215
+ return Object.keys(props)
216
+ .filter((k) => !props[k].state)
217
+ .map(hyphenate);
218
+ }
219
+
220
+ constructor() {
221
+ super();
222
+ this._renderRoot = null;
223
+ this._scheduled = false;
224
+ this._connected = false;
225
+
226
+ /**
227
+ * Set of attached reactive controllers.
228
+ * @type {Set<ReactiveController>}
229
+ */
230
+ this.__controllers = new Set();
231
+
232
+ /**
233
+ * Whether the component has completed its first render.
234
+ * Used to gate the one-time `firstUpdated()` call.
235
+ * @type {boolean}
236
+ */
237
+ this.__firstRendered = false;
238
+
239
+ /**
240
+ * Map of changed properties accumulated since the last render. Keys are
241
+ * property names; values are the previous value before the change. Passed
242
+ * to `shouldUpdate`, `willUpdate`,
243
+ * `update`, `firstUpdated`, and `updated`. Cleared at the start of each
244
+ * render cycle so accumulations during hooks queue for the next cycle.
245
+ * @type {Map<string, unknown>}
246
+ */
247
+ this._changedProperties = new Map();
248
+
249
+ /**
250
+ * Resolver for the currently-pending updateComplete promise. `null` when
251
+ * no update is pending.
252
+ * @type {((value: boolean) => void) | null}
253
+ * @private
254
+ */
255
+ this._updateResolve = null;
256
+
257
+ /**
258
+ * Promise that resolves after the next render commit. Resolves to `true`
259
+ * when there are no further pending updates, `false` otherwise.
260
+ * @type {Promise<boolean>}
261
+ * @private
262
+ */
263
+ this._updatePromise = Promise.resolve(true);
264
+
265
+ /**
266
+ * Set while the component is inside the update phase (between
267
+ * `shouldUpdate` and `updated`). Property assignments during this window
268
+ * fold into the CURRENT `changedProperties` Map without scheduling a
269
+ * new microtask render. Assignments during `updated()` (after the flag
270
+ * clears) queue a fresh cycle.
271
+ * @type {boolean}
272
+ * @private
273
+ */
274
+ this._isUpdating = false;
275
+
276
+ // Install reactive property accessors for `static properties` declarations.
277
+ this._initializeProperties();
278
+ }
279
+
280
+ /**
281
+ * For every key in `static properties`, create a getter/setter pair on
282
+ * the instance that coerces values, runs `hasChanged`, schedules updates,
283
+ * and optionally reflects to the HTML attribute.
284
+ *
285
+ * This is called once from the constructor. The backing store is a plain
286
+ * object (`this.__propValues`) so accessors don't collide with the
287
+ * prototype.
288
+ * @private
289
+ */
290
+ _initializeProperties() {
291
+ const Ctor = /** @type {any} */ (this.constructor);
292
+ const props = Ctor.properties;
293
+ if (!props || typeof props !== 'object') return;
294
+
295
+ /** @type {Record<string, unknown>} */
296
+ this.__propValues = {};
297
+
298
+ for (const [propName, decl] of Object.entries(props)) {
299
+ const d = typeof decl === 'object' ? decl : { type: decl };
300
+ // Capture any value set before the accessor was installed (e.g. via
301
+ // attribute or property assignment before `super()` returns).
302
+ const initial = /** @type {any} */ (this)[propName];
303
+
304
+ Object.defineProperty(this, propName, {
305
+ configurable: true,
306
+ enumerable: true,
307
+ get: () => this.__propValues[propName],
308
+ set: (newVal) => {
309
+ const oldVal = this.__propValues[propName];
310
+ const changed = (d.hasChanged || defaultHasChanged)(newVal, oldVal);
311
+ if (!changed) return;
312
+ this.__propValues[propName] = newVal;
313
+
314
+ // Reflect to attribute if requested (and not internal state).
315
+ if (d.reflect && !d.state && this._connected) {
316
+ this._reflectAttribute(propName, newVal, d);
317
+ }
318
+
319
+ // requestUpdate records the (name, oldValue) entry AND schedules
320
+ // a render. When called during the update phase (willUpdate /
321
+ // hostUpdate / update / hostUpdated), the scheduler short-circuits
322
+ // and the entry folds into the current cycle's changedProperties.
323
+ this.requestUpdate(propName, oldVal);
324
+ },
325
+ });
326
+
327
+ if (initial !== undefined) {
328
+ this.__propValues[propName] = initial;
329
+ }
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Write a property value back to its corresponding HTML attribute.
335
+ * Uses a custom `converter.toAttribute` if provided, otherwise the
336
+ * built-in type-based serialization.
337
+ *
338
+ * @param {string} propName
339
+ * @param {unknown} value
340
+ * @param {PropertyDeclaration} decl
341
+ * @private
342
+ */
343
+ _reflectAttribute(propName, value, decl) {
344
+ const attrName = hyphenate(propName);
345
+ // Guard against re-entrant loops: attributeChangedCallback fires when
346
+ // we call setAttribute, which would call the setter again.
347
+ if (this.__reflectingAttribute) return;
348
+ this.__reflectingAttribute = true;
349
+ try {
350
+ if (decl.converter && decl.converter.toAttribute) {
351
+ const serialized = decl.converter.toAttribute(value, decl.type);
352
+ if (serialized == null) this.removeAttribute(attrName);
353
+ else this.setAttribute(attrName, serialized);
354
+ } else if (decl.type === Boolean) {
355
+ if (value) this.setAttribute(attrName, '');
356
+ else this.removeAttribute(attrName);
357
+ } else if (value == null) {
358
+ this.removeAttribute(attrName);
359
+ } else if (decl.type === Object || decl.type === Array) {
360
+ this.setAttribute(attrName, JSON.stringify(value));
361
+ } else {
362
+ this.setAttribute(attrName, String(value));
363
+ }
364
+ } finally {
365
+ this.__reflectingAttribute = false;
366
+ }
367
+ }
368
+
369
+ connectedCallback() {
370
+ if (!isBrowser) return;
371
+
372
+ // Apply any `data-webjs-prop-*` attributes emitted by SSR. The server
373
+ // emits these for `.prop=${val}` bindings in parent templates so
374
+ // rich-typed values (Array, Object, Date, Map, Set, BigInt, cycles)
375
+ // round-trip through the rendered HTML. Once applied, the attributes
376
+ // are stripped so the settled DOM matches what the user would expect
377
+ // from the JS source: no framework artifacts left on the element.
378
+ // One-time per element. Subsequent reconnections do nothing.
379
+ if (!this.__webjsPropsHydrated) {
380
+ this.__webjsPropsHydrated = true;
381
+ this._hydratePropAttrs();
382
+ }
383
+
384
+ const Ctor = /** @type any */ (this.constructor);
385
+
386
+ // Selective hydration: defer activation until the element scrolls into
387
+ // (or near) the viewport. The DSD content from SSR stays visible the
388
+ // whole time: the user sees the server-rendered HTML.
389
+ if (
390
+ Ctor.hydrate === 'visible' &&
391
+ typeof IntersectionObserver !== 'undefined' &&
392
+ !this.__hydrationActivated
393
+ ) {
394
+ this.__hydrationActivated = false;
395
+ this.__hydrationObserver = new IntersectionObserver(
396
+ (entries) => {
397
+ for (const entry of entries) {
398
+ if (entry.isIntersecting) {
399
+ this.__hydrationObserver.unobserve(this);
400
+ this.__hydrationObserver.disconnect();
401
+ this.__hydrationObserver = null;
402
+ this.__hydrationActivated = true;
403
+ this._activate();
404
+ return;
405
+ }
406
+ }
407
+ },
408
+ { rootMargin: '200px' }
409
+ );
410
+ this.__hydrationObserver.observe(this);
411
+ return;
412
+ }
413
+
414
+ this._activate();
415
+ }
416
+
417
+ /**
418
+ * Internal activation method that performs the actual connectedCallback
419
+ * work: setting up the render root, adopting styles, notifying
420
+ * controllers, and performing the first render.
421
+ *
422
+ * Called directly from `connectedCallback()` for normal components, or
423
+ * deferred via IntersectionObserver when `static hydrate = 'visible'`.
424
+ *
425
+ * @private
426
+ */
427
+ /**
428
+ * Read `data-webjs-prop-*` attributes (emitted by SSR for `.prop=${val}`
429
+ * bindings in parent templates), decode each via the wire serializer,
430
+ * assign the decoded value to the corresponding camelCase property on
431
+ * this instance, and remove the attribute from the DOM. After this
432
+ * runs, inspecting the element shows the same attributes the developer
433
+ * would expect from the JS source.
434
+ *
435
+ * @private
436
+ */
437
+ _hydratePropAttrs() {
438
+ /** @type {string[]} */
439
+ const names = [];
440
+ const attrs = this.attributes;
441
+ for (let i = 0; i < attrs.length; i++) {
442
+ const n = attrs[i].name;
443
+ if (n.startsWith('data-webjs-prop-')) names.push(n);
444
+ }
445
+ for (const fullName of names) {
446
+ const raw = this.getAttribute(fullName);
447
+ this.removeAttribute(fullName);
448
+ if (raw == null) continue;
449
+ const propName = camelCase(fullName.slice('data-webjs-prop-'.length));
450
+ try {
451
+ /** @type any */ (this)[propName] = deserializeProp(raw);
452
+ } catch (err) {
453
+ console.warn(
454
+ `[webjs] failed to decode ${fullName} on <${this.tagName.toLowerCase()}>: ${err && err.message}`
455
+ );
456
+ }
457
+ }
458
+ }
459
+
460
+ _activate() {
461
+ this._connected = true;
462
+ const Ctor = /** @type any */ (this.constructor);
463
+ if (Ctor.shadow === true) {
464
+ const hadSSRShadow = !!this.shadowRoot;
465
+ if (!this.shadowRoot) {
466
+ /** @type any */ (this).attachShadow({ mode: 'open' });
467
+ }
468
+ this._renderRoot = this.shadowRoot;
469
+ const styles = Ctor.styles;
470
+ const list = Array.isArray(styles) ? styles : isCSS(styles) ? [styles] : [];
471
+ if (list.length) {
472
+ // If the shadow root came from Declarative Shadow DOM (SSR), it
473
+ // contains an inline <style> tag. Remove it before switching to
474
+ // adoptedStyleSheets to avoid duplicate styles.
475
+ if (hadSSRShadow) {
476
+ const ssrStyle = this.shadowRoot.querySelector('style');
477
+ if (ssrStyle) ssrStyle.remove();
478
+ }
479
+ adoptStyles(this._renderRoot, list);
480
+ }
481
+ } else {
482
+ this._renderRoot = this;
483
+ // Light DOM: static styles is not supported (no shadow root for
484
+ // adoptedStyleSheets). Warn if the developer set both.
485
+ if (Ctor.styles) {
486
+ console.warn(
487
+ `[webjs] <${tagOf(Ctor) || this.tagName?.toLowerCase()}> has static shadow = false AND static styles. ` +
488
+ `static styles only works with shadow DOM (adoptedStyleSheets). ` +
489
+ `For light DOM, use global CSS or <style> in render().`
490
+ );
491
+ }
492
+ // Light-DOM slot lifecycle phase one. Three sub-paths:
493
+ //
494
+ // a. Reconnection. Slot state already exists from a prior mount.
495
+ // The host still carries the rendered template DOM (plus
496
+ // projected children) from before the disconnect. Skip
497
+ // capture (would wrongly hoover up rendered nodes) and skip
498
+ // SSR adoption. clientRender will see the existing INSTANCE
499
+ // and updateInstance instead of recreating; the DOM stays.
500
+ //
501
+ // b. SSR hydration (first mount, <!--webjs-hydrate--> marker
502
+ // present). Children are already projected into
503
+ // <slot data-webjs-light data-projection="actual"> elements
504
+ // by injectDSD. Adopt those assignments BEFORE _performRender
505
+ // so we retain references to the SSR'd nodes; the renderer's
506
+ // createInstance().replaceChildren() will detach them, but
507
+ // projection re-attaches by moving the same Node refs into
508
+ // the freshly-cloned slot. DOM identity preserved through the
509
+ // hydration round-trip.
510
+ //
511
+ // c. First mount, no SSR. Move authored children into the
512
+ // assignment table before _performRender wipes the host.
513
+ if (hasSlotState(this)) {
514
+ // (a) Reconnection. State already populated; nothing to do here.
515
+ } else if (this.__isHydrating()) {
516
+ ensureSlotState(this);
517
+ adoptSSRAssignments(this);
518
+ } else {
519
+ captureAuthoredChildren(this);
520
+ }
521
+ }
522
+
523
+ // Notify all controllers that the host is connected.
524
+ for (const c of this.__controllers) {
525
+ if (c.hostConnected) c.hostConnected();
526
+ }
527
+
528
+ // First render is deferred to a microtask, matching lit's
529
+ // performUpdate scheduling. The user's connectedCallback override
530
+ // body runs synchronously to completion BEFORE the first render,
531
+ // so subclass setup that needs to inspect attributes / state / etc.
532
+ // observes consistent timing whether SSR-hydrating or first mount.
533
+ // Render-root setup (shadow attachment, light-DOM SSR adoption,
534
+ // controller hostConnected, _connected=true) all stay synchronous
535
+ // above; only the template commit + post-commit slot observers
536
+ // defer. Authoring contract: post-render DOM setup goes in
537
+ // firstUpdated(), NOT connectedCallback().
538
+ //
539
+ // The scheduling matches `_scheduleUpdate`'s wrapper so lifecycle
540
+ // throws are surfaced as console errors instead of bricking the
541
+ // element.
542
+ if (this._updateResolve === null) {
543
+ this._updatePromise = new Promise((resolve) => {
544
+ this._updateResolve = resolve;
545
+ });
546
+ }
547
+ this._scheduled = true;
548
+ queueMicrotask(() => {
549
+ this._scheduled = false;
550
+ try {
551
+ this._performRender();
552
+ } catch (err) {
553
+ console.error(`[webjs] lifecycle hook threw during initial render:`, err);
554
+ }
555
+ // Light-DOM slot lifecycle phase two: after the first render
556
+ // commits, the live <slot> elements exist. Attach mutation
557
+ // observers so authored-child + slot-name changes drive
558
+ // incremental projection. (Shadow DOM uses native slot
559
+ // projection; nothing to attach.)
560
+ if (this._renderRoot === this) {
561
+ attachSlotObservers(this);
562
+ }
563
+ });
564
+ }
565
+
566
+ /**
567
+ * True when this host's first child is the framework's hydration
568
+ * marker, meaning the SSR pipeline already rendered the template's
569
+ * shape (including <slot data-webjs-light> elements with their
570
+ * projected children inside) and the client should bind events
571
+ * without re-creating DOM. Sets __hydratedAtActivate as a side
572
+ * effect so the post-render path picks up the SSR assignment table.
573
+ *
574
+ * @returns {boolean}
575
+ * @private
576
+ */
577
+ __isHydrating() {
578
+ const first = this.firstChild;
579
+ const isHydrate =
580
+ first != null &&
581
+ first.nodeType === 8 &&
582
+ /** @type {Comment} */ (first).data === 'webjs-hydrate';
583
+ if (isHydrate) this.__hydratedAtActivate = true;
584
+ return isHydrate;
585
+ }
586
+
587
+
588
+ /**
589
+ * Called when the element is removed from the DOM.
590
+ *
591
+ * Notifies all attached {@link ReactiveController}s so they can clean up
592
+ * subscriptions, timers, and observers. If you override this in a
593
+ * subclass, always call `super.disconnectedCallback()`.
594
+ */
595
+ disconnectedCallback() {
596
+ this._connected = false;
597
+ // Clean up the hydration observer if the element is removed before
598
+ // it became visible.
599
+ if (this.__hydrationObserver) {
600
+ this.__hydrationObserver.disconnect();
601
+ this.__hydrationObserver = null;
602
+ }
603
+ // Pause slot observers. The per-host state (assignment table,
604
+ // pending fragments, last snapshots) is preserved so a subsequent
605
+ // reconnection picks up where it left off.
606
+ if (this._renderRoot === this) detachSlotObservers(this);
607
+ // Dispose the signal watcher so dependency edges drop. Without
608
+ // this the element holds references to module-scope signals
609
+ // (and vice versa) forever.
610
+ if (this.__signalWatcher) {
611
+ this.__signalWatcher.dispose();
612
+ this.__signalWatcher = undefined;
613
+ }
614
+ for (const c of this.__controllers) {
615
+ if (c.hostDisconnected) c.hostDisconnected();
616
+ }
617
+ }
618
+
619
+ /**
620
+ * @param {string} name Kebab-cased attribute name
621
+ * @param {string|null} _old
622
+ * @param {string|null} value
623
+ */
624
+ attributeChangedCallback(name, _old, value) {
625
+ // When we are reflecting a property back to an attribute, ignore the
626
+ // resulting attributeChangedCallback to avoid infinite loops.
627
+ if (this.__reflectingAttribute) return;
628
+
629
+ const Ctor = /** @type any */ (this.constructor);
630
+ const propName = camelCase(name);
631
+ const def = Ctor.properties && (Ctor.properties[propName] || Ctor.properties[name]);
632
+ if (!def) return;
633
+
634
+ let v;
635
+ if (def.converter && def.converter.fromAttribute) {
636
+ v = def.converter.fromAttribute(value, def.type);
637
+ } else if (def.type === Number) {
638
+ v = value == null ? null : Number(value);
639
+ } else if (def.type === Boolean) {
640
+ v = value != null && value !== 'false';
641
+ } else if (def.type === Object || def.type === Array) {
642
+ try { v = value == null ? null : JSON.parse(value); } catch { v = value; }
643
+ } else {
644
+ v = value;
645
+ }
646
+
647
+ if (this[propName] !== v) {
648
+ this[propName] = v;
649
+ if (this._connected) this.requestUpdate();
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Schedule a re-render. Optionally record a property change in
655
+ * `changedProperties` so hooks can branch on what changed.
656
+ *
657
+ * @param {string} [name] Property name that changed
658
+ * @param {unknown} [oldValue] Previous value of the property
659
+ */
660
+ requestUpdate(name, oldValue) {
661
+ if (name !== undefined && !this._changedProperties.has(name)) {
662
+ this._changedProperties.set(name, oldValue);
663
+ }
664
+ this._scheduleUpdate();
665
+ }
666
+
667
+ /**
668
+ * Internal scheduler driven by reactive-property setters, the signal
669
+ * watcher, and explicit `requestUpdate()` calls. Coalesces multiple
670
+ * changes in the same tick into a single microtask render.
671
+ * Manages the `updateComplete` promise lifecycle. Short-circuits when
672
+ * the component is mid-update (assignments during `willUpdate` / `update`
673
+ * fold into the current cycle's `changedProperties` Map).
674
+ * @private
675
+ */
676
+ _scheduleUpdate() {
677
+ if (this._updateResolve === null) {
678
+ this._updatePromise = new Promise((resolve) => {
679
+ this._updateResolve = resolve;
680
+ });
681
+ }
682
+ if (this._isUpdating) return;
683
+ if (this._scheduled || !this._connected) return;
684
+ this._scheduled = true;
685
+ queueMicrotask(() => {
686
+ this._scheduled = false;
687
+ try {
688
+ this._performRender();
689
+ } catch (err) {
690
+ // _performRender wraps the update phase in try/finally so this
691
+ // catches throws from shouldUpdate / willUpdate / hostUpdate /
692
+ // hostUpdated / firstUpdated / updated. The component is not
693
+ // left in a bad state (the finally blocks reset _isUpdating and
694
+ // resolve updateComplete). Surface the error for visibility.
695
+ console.error(`[webjs] lifecycle hook threw during update cycle:`, err);
696
+ }
697
+ });
698
+ }
699
+
700
+ /**
701
+ * Core update cycle, lit-aligned:
702
+ * 1. Snapshot + clear `changedProperties`
703
+ * 2. `shouldUpdate(changedProperties)`. If false, resolve updateComplete and return.
704
+ * 3. `willUpdate(changedProperties)`. Safe to set properties without re-triggering.
705
+ * 4. Controllers' `hostUpdate()`
706
+ * 5. `update(changedProperties)`. Default impl calls `render()` + commits.
707
+ * Wrapped in error boundary that falls back to `renderError(error)`.
708
+ * 6. Controllers' `hostUpdated()`
709
+ * 7. `firstUpdated(changedProperties)` runs once, on the first render only
710
+ * 8. `updated(changedProperties)` runs after every commit
711
+ * 9. Resolve `updateComplete` promise
712
+ * @private
713
+ */
714
+ _performRender() {
715
+ if (!this._renderRoot) return;
716
+
717
+ // Hold a stable reference to the current Map so all hooks see the same
718
+ // snapshot. During the update phase (steps 3-6) the Map is mutated in
719
+ // place when property setters fire, folding those changes into THIS
720
+ // cycle. The Map is replaced with a fresh empty one only after a
721
+ // successful commit so post-commit assignments queue the NEXT cycle.
722
+ // On `shouldUpdate=false` or hook errors, the Map is preserved so the
723
+ // accumulated changes survive into the next render.
724
+ const changedProperties = this._changedProperties;
725
+
726
+ // --- 1. Mark we're inside an update cycle ---
727
+ this._isUpdating = true;
728
+ let didCommit = false;
729
+
730
+ // --- 2-6. Update phase. Lifecycle-hook throws are logged and
731
+ // swallowed so the component is not left in a deadlocked state
732
+ // (`_isUpdating` stuck true, `updateComplete` never resolves).
733
+ try {
734
+ if (this.shouldUpdate(changedProperties)) {
735
+ // --- 3. willUpdate (may mutate properties; folds into this cycle) ---
736
+ this.willUpdate(changedProperties);
737
+
738
+ // --- 4. controllers' hostUpdate ---
739
+ for (const c of this.__controllers) {
740
+ if (c.hostUpdate) c.hostUpdate();
741
+ }
742
+
743
+ // --- 5. update + DOM commit (with render-error boundary) ---
744
+ try {
745
+ this.update(changedProperties);
746
+ } catch (error) {
747
+ console.error(`[webjs] render error in <${tagOf(/** @type any */ (this.constructor)) || this.tagName?.toLowerCase()}>:`, error);
748
+ try {
749
+ const fallback = this.renderError(/** @type {Error} */ (error));
750
+ if (fallback !== undefined) clientRender(fallback, this._renderRoot);
751
+ } catch (fallbackError) {
752
+ console.error(`[webjs] renderError() also threw:`, fallbackError);
753
+ }
754
+ }
755
+
756
+ // --- 6. controllers' hostUpdated ---
757
+ for (const c of this.__controllers) {
758
+ if (c.hostUpdated) c.hostUpdated();
759
+ }
760
+
761
+ didCommit = true;
762
+ }
763
+ // shouldUpdate=false: preserve _changedProperties so the next
764
+ // requestUpdate keeps accumulating on top of the entries that
765
+ // didn't render this cycle.
766
+ } catch (preCommitError) {
767
+ console.error(`[webjs] lifecycle hook threw during update phase:`, preCommitError);
768
+ } finally {
769
+ this._isUpdating = false;
770
+ if (didCommit) {
771
+ this._changedProperties = new Map();
772
+ }
773
+ }
774
+
775
+ // --- 7-8. Post-commit hooks. Errors here are also caught so the
776
+ // updateComplete promise always resolves.
777
+ if (didCommit) {
778
+ try {
779
+ // --- 7. firstUpdated (once) ---
780
+ if (!this.__firstRendered) {
781
+ this.__firstRendered = true;
782
+ this.firstUpdated(changedProperties);
783
+ }
784
+ // --- 8. updated (every render) ---
785
+ this.updated(changedProperties);
786
+ } catch (postCommitError) {
787
+ console.error(`[webjs] lifecycle hook threw during post-commit phase:`, postCommitError);
788
+ } finally {
789
+ this._resolveUpdate();
790
+ }
791
+ } else {
792
+ this._resolveUpdate();
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Resolve the pending updateComplete promise. Value reflects whether any
798
+ * further updates were queued during the current cycle: `true` means the
799
+ * component has settled, `false` means another render will run.
800
+ * @private
801
+ */
802
+ _resolveUpdate() {
803
+ if (this._updateResolve) {
804
+ const settled = this._changedProperties.size === 0;
805
+ this._updateResolve(settled);
806
+ this._updateResolve = null;
807
+ }
808
+ }
809
+
810
+ // ---------------------------------------------------------------------------
811
+ // Lifecycle hooks: override in subclasses
812
+ // ---------------------------------------------------------------------------
813
+
814
+ /**
815
+ * Decide whether the component should render in response to a queued
816
+ * update. Default implementation returns `true` (always render).
817
+ *
818
+ * **When to use:** override to skip an update when the relevant inputs
819
+ * haven't changed, e.g. an expensive component that only depends on a
820
+ * subset of its reactive properties.
821
+ *
822
+ * ```js
823
+ * shouldUpdate(changedProperties) {
824
+ * return changedProperties.has('itemCount') || changedProperties.has('mode');
825
+ * }
826
+ * ```
827
+ *
828
+ * @param {Map<string, unknown>} _changedProperties
829
+ * @returns {boolean}
830
+ */
831
+ shouldUpdate(_changedProperties) {
832
+ return true;
833
+ }
834
+
835
+ /**
836
+ * Run immediately before `update()`. Safe to set reactive properties
837
+ * here without triggering another update cycle: assignments made inside
838
+ * `willUpdate` are folded into the current `changedProperties` snapshot
839
+ * and rendered in the same pass.
840
+ *
841
+ * **When to use:** computing derived values from changed inputs before
842
+ * `render()` reads them. Lit users typically migrate `shouldUpdate`
843
+ * heuristics that mutate state into `willUpdate`.
844
+ *
845
+ * ```js
846
+ * willUpdate(changedProperties) {
847
+ * if (changedProperties.has('items')) {
848
+ * this.totalCount = this.items.length;
849
+ * }
850
+ * }
851
+ * ```
852
+ *
853
+ * @param {Map<string, unknown>} _changedProperties
854
+ */
855
+ willUpdate(_changedProperties) {}
856
+
857
+ /**
858
+ * The render-and-commit step. Default impl calls `render()` and commits
859
+ * the returned `TemplateResult` to the render root.
860
+ *
861
+ * **When to use:** rarely. Override only when you need to wrap or
862
+ * short-circuit the commit. Most users override `render()` instead.
863
+ * If you do override, you MUST commit something to `this._renderRoot`
864
+ * (or call `super.update(changedProperties)`).
865
+ *
866
+ * @param {Map<string, unknown>} _changedProperties
867
+ */
868
+ update(_changedProperties) {
869
+ // Track signal reads during render so the component re-renders when
870
+ // any of them change. The watcher is lazy-allocated on first
871
+ // render and disposed in disconnectedCallback. observe() clears
872
+ // prior dep edges before fn(), so each render re-records its own
873
+ // (possibly different) read set.
874
+ if (!this.__signalWatcher) {
875
+ this.__signalWatcher = new Signal.subtle.Watcher(() => {
876
+ if (this._connected) this.requestUpdate();
877
+ });
878
+ }
879
+ let tpl;
880
+ this.__signalWatcher.observe(() => { tpl = this.render(); });
881
+ clientRender(tpl, this._renderRoot);
882
+ }
883
+
884
+ /**
885
+ * Called after every render commit (both the first and all subsequent
886
+ * renders). Receives the `changedProperties` Map so you can branch on
887
+ * what changed this cycle.
888
+ *
889
+ * **When to use:** post-render DOM work that depends on the new DOM,
890
+ * triggered by specific property changes. This is the lit-aligned
891
+ * replacement for ad-hoc `requestAnimationFrame` shims that components
892
+ * historically used to defer DOM work after `render()`.
893
+ *
894
+ * ```js
895
+ * updated(changedProperties) {
896
+ * if (changedProperties.has('open') && this.open) {
897
+ * this.querySelector('input')?.focus();
898
+ * }
899
+ * }
900
+ * ```
901
+ *
902
+ * @param {Map<string, unknown>} _changedProperties
903
+ */
904
+ updated(_changedProperties) {}
905
+
906
+ /**
907
+ * Called exactly once, after the component's very first render completes
908
+ * and the DOM is live. Receives the same `changedProperties` Map that
909
+ * `updated()` does for the first render; entries reflect the initial
910
+ * values of reactive properties (old values are `undefined`).
911
+ *
912
+ * **When to use:** one-time post-render setup that requires DOM access.
913
+ * Auto-focusing an input, measuring layout, initializing a third-party
914
+ * library on a DOM node. `connectedCallback` fires before the first
915
+ * render, so querying shadow children there yields nothing.
916
+ *
917
+ * ```js
918
+ * firstUpdated(changedProperties) {
919
+ * this.shadowRoot.querySelector('input')?.focus();
920
+ * }
921
+ * ```
922
+ *
923
+ * @param {Map<string, unknown>} _changedProperties
924
+ */
925
+ firstUpdated(_changedProperties) {}
926
+
927
+ /**
928
+ * A Promise that resolves after the next render commit. Resolves to
929
+ * `true` when the component has settled (no further updates queued),
930
+ * `false` if another render is already scheduled.
931
+ *
932
+ * **When to use:** awaiting a re-render in tests, or in user code that
933
+ * needs to read the post-render DOM after triggering an update.
934
+ *
935
+ * ```js
936
+ * el.count = 5;
937
+ * await el.updateComplete;
938
+ * // DOM now reflects count = 5
939
+ * ```
940
+ *
941
+ * @returns {Promise<boolean>}
942
+ */
943
+ get updateComplete() {
944
+ return this.getUpdateComplete();
945
+ }
946
+
947
+ /**
948
+ * Override point for `updateComplete`. Default returns the internal
949
+ * update promise. Override to await additional async work that should
950
+ * be considered part of the update cycle (e.g. lazy-loaded subcomponents).
951
+ *
952
+ * ```js
953
+ * async getUpdateComplete() {
954
+ * const result = await super.getUpdateComplete();
955
+ * await this._chart?.updateComplete;
956
+ * return result;
957
+ * }
958
+ * ```
959
+ *
960
+ * @returns {Promise<boolean>}
961
+ */
962
+ getUpdateComplete() {
963
+ return this._updatePromise;
964
+ }
965
+
966
+ // ---------------------------------------------------------------------------
967
+ // Reactive controllers
968
+ // ---------------------------------------------------------------------------
969
+
970
+ /**
971
+ * Register a {@link ReactiveController} with this component.
972
+ *
973
+ * **When to use:** call this from your controller's constructor (which
974
+ * typically receives the host as its first argument) or from the
975
+ * component's constructor / `connectedCallback`.
976
+ *
977
+ * **Why it exists:** controllers decouple reusable lifecycle behaviour
978
+ * from the class hierarchy. Instead of extending a base class or using
979
+ * a mixin, you compose controllers:
980
+ *
981
+ * ```js
982
+ * class MouseController {
983
+ * constructor(host) {
984
+ * this.host = host;
985
+ * host.addController(this);
986
+ * }
987
+ * hostConnected() { window.addEventListener('mousemove', this._onMove); }
988
+ * hostDisconnected() { window.removeEventListener('mousemove', this._onMove); }
989
+ * }
990
+ * ```
991
+ *
992
+ * If the host is already connected when the controller is added, the
993
+ * controller's `hostConnected()` is called immediately.
994
+ *
995
+ * @param {ReactiveController} controller
996
+ */
997
+ addController(controller) {
998
+ this.__controllers.add(controller);
999
+ if (this._connected && controller.hostConnected) {
1000
+ controller.hostConnected();
1001
+ }
1002
+ }
1003
+
1004
+ /**
1005
+ * Unregister a previously added {@link ReactiveController}.
1006
+ *
1007
+ * **When to use:** call this when a controller's lifetime is shorter
1008
+ * than the component's: e.g. a controller that tracks a specific
1009
+ * resource and should be swapped out when the resource changes.
1010
+ *
1011
+ * The controller's `hostDisconnected()` is NOT called by `removeController`;
1012
+ * if cleanup is needed, call it yourself before removing.
1013
+ *
1014
+ * @param {ReactiveController} controller
1015
+ */
1016
+ removeController(controller) {
1017
+ this.__controllers.delete(controller);
1018
+ }
1019
+
1020
+ /**
1021
+ * Override in subclasses to return a TemplateResult.
1022
+ * @returns {unknown}
1023
+ */
1024
+ render() {
1025
+ return '';
1026
+ }
1027
+
1028
+ /**
1029
+ * Called when `render()` throws an error on the client side.
1030
+ *
1031
+ * **When to override (AI hint):** Override this to show a fallback UI
1032
+ * when a component's render fails. Without this, the error is logged
1033
+ * and the component renders nothing. The default implementation returns
1034
+ * `undefined` (empty render).
1035
+ *
1036
+ * **Why it exists:** Client-side error boundary. Prevents one broken
1037
+ * component from crashing the entire page. Similar to React's
1038
+ * `componentDidCatch` / Error Boundaries.
1039
+ *
1040
+ * ```js
1041
+ * renderError(error) {
1042
+ * return html`<p style="color:red">Something went wrong: ${error.message}</p>`;
1043
+ * }
1044
+ * ```
1045
+ *
1046
+ * @param {Error} error The error thrown by render().
1047
+ * @returns {unknown} A TemplateResult fallback, or undefined for empty.
1048
+ */
1049
+ renderError(error) {
1050
+ return undefined;
1051
+ }
1052
+ }
1053
+
1054
+ /** @param {string} s */
1055
+ function hyphenate(s) {
1056
+ return s.replace(/([A-Z])/g, '-$1').toLowerCase();
1057
+ }
1058
+ /** @param {string} s */
1059
+ function camelCase(s) {
1060
+ return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1061
+ }