@webjsdev/core 0.7.65 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +19 -19
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +6 -6
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +5 -1
  8. package/index.d.ts +5 -1
  9. package/index.js +5 -1
  10. package/package.json +1 -1
  11. package/src/attribute-directives.d.ts +6 -0
  12. package/src/attribute-directives.js +49 -0
  13. package/src/attribute-reader.js +17 -3
  14. package/src/binding-prefixes.js +10 -41
  15. package/src/component/lifecycle.js +177 -59
  16. package/src/component/reactive.js +5 -4
  17. package/src/component.d.ts +11 -5
  18. package/src/directives.d.ts +2 -0
  19. package/src/directives.js +2 -0
  20. package/src/html.d.ts +4 -0
  21. package/src/html.js +22 -1
  22. package/src/render-client/adopt-slots.js +245 -0
  23. package/src/render-client/parts.js +51 -17
  24. package/src/render-client/reconciler.js +28 -12
  25. package/src/render-client/template-compiler.js +19 -5
  26. package/src/render-client.d.ts +2 -1
  27. package/src/render-server/compile.js +16 -2
  28. package/src/render-server/expand.js +39 -12
  29. package/src/render-server/served-attrs.js +79 -0
  30. package/src/render-server/walk.js +42 -2
  31. package/src/router-client/anchors.js +3 -1
  32. package/src/router-client/boundaries.js +15 -1
  33. package/src/router-client/constants.js +30 -0
  34. package/src/router-client/fetch-apply.js +21 -0
  35. package/src/router-client/frames.js +6 -3
  36. package/src/router-client/head-merge.js +58 -10
  37. package/src/router-client/navigator.js +4 -0
  38. package/src/router-client/owned-styles.js +372 -0
  39. package/src/router-client/prefetch.js +3 -0
  40. package/src/router-client/stylesheets.js +191 -0
  41. package/src/router-client/swap.js +49 -9
  42. package/src/sentinels.d.ts +2 -0
  43. package/src/sentinels.js +4 -0
  44. package/src/webjs-config.d.ts +1 -1
package/index-browser.js CHANGED
@@ -23,7 +23,7 @@
23
23
  * Keep this list in sync with `./index.js` for everything else.
24
24
  */
25
25
 
26
- export { html, isTemplate, MARKER } from './src/html.js';
26
+ export { html, svg, isTemplate, MARKER } from './src/html.js';
27
27
  export { css, isCSS, adoptStyles, stylesToString } from './src/css.js';
28
28
  export { WebComponent, prop } from './src/component.js';
29
29
  export { lookup, lookupModuleUrl, isLazy, allTags, primeModuleUrl, tagOf } from './src/registry.js';
@@ -100,3 +100,7 @@ export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent }
100
100
 
101
101
  // Task controller, also available via '@webjsdev/core/task'
102
102
  export { Task, TaskStatus } from './src/task.js';
103
+
104
+ export { nothing, noChange } from './src/sentinels.js';
105
+
106
+ export { classMap, styleMap, ifDefined } from './src/attribute-directives.js';
package/index.d.ts CHANGED
@@ -74,7 +74,7 @@ export type {
74
74
  NonSerializable,
75
75
  } from './src/serializable.d.ts';
76
76
 
77
- export { html, isTemplate, MARKER } from './src/html.js';
77
+ export { html, svg, isTemplate, MARKER } from './src/html.js';
78
78
  // The `html\`\`` return type. A JSDoc typedef in html.js used across the
79
79
  // component / route / testing type surfaces; re-exported so an app or the
80
80
  // docs/website can annotate a value as `TemplateResult` (#772).
@@ -159,3 +159,7 @@ export function optimistic<T, R>(
159
159
  action: () => Promise<R> | R,
160
160
  ): Promise<R>;
161
161
 
162
+
163
+ export { nothing, noChange } from './src/sentinels.js';
164
+
165
+ export { classMap, styleMap, ifDefined } from './src/attribute-directives.js';
package/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * renderer is only reached on the server.
7
7
  */
8
8
 
9
- export { html, isTemplate, MARKER } from './src/html.js';
9
+ export { html, svg, isTemplate, MARKER } from './src/html.js';
10
10
  export { css, isCSS, adoptStyles, stylesToString } from './src/css.js';
11
11
  export { WebComponent, prop } from './src/component.js';
12
12
  export { lookup, lookupModuleUrl, isLazy, allTags, primeModuleUrl, tagOf } from './src/registry.js';
@@ -68,3 +68,7 @@ export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent }
68
68
 
69
69
  // Task controller, also available via '@webjsdev/core/task'
70
70
  export { Task, TaskStatus } from './src/task.js';
71
+
72
+ export { nothing, noChange } from './src/sentinels.js';
73
+
74
+ export { classMap, styleMap, ifDefined } from './src/attribute-directives.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.65",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "The runtime for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Ships the html and css template tags, the WebComponent base class, signals, directives, and the isomorphic renderers.",
6
6
  "types": "./index.d.ts",
@@ -0,0 +1,6 @@
1
+ import { nothing } from './sentinels.js';
2
+ export interface ClassMapDirective { readonly _$webjs: 'class-map'; readonly value: Record<string, unknown>; }
3
+ export interface StyleMapDirective { readonly _$webjs: 'style-map'; readonly value: Record<string, string | number | null | undefined>; }
4
+ export function classMap(classes: Record<string, unknown>): ClassMapDirective;
5
+ export function styleMap(styles: Record<string, string | number | null | undefined>): StyleMapDirective;
6
+ export function ifDefined<T>(value: T): NonNullable<T> | typeof nothing;
@@ -0,0 +1,49 @@
1
+ import { nothing } from './sentinels.js';
2
+
3
+ /** Map class names to whether each should be present. @param {Record<string, unknown>} classes */
4
+ export function classMap(classes) { return { _$webjs: 'class-map', value: classes }; }
5
+ /** Map CSS property names to their values. @param {Record<string, string | number | null | undefined>} styles */
6
+ export function styleMap(styles) { return { _$webjs: 'style-map', value: styles }; }
7
+ /** Remove an attribute when its value is nullish. @template T @param {T} value */
8
+ export function ifDefined(value) { return value ?? nothing; }
9
+
10
+ export function isAttributeMap(value) {
11
+ return value && (value._$webjs === 'class-map' || value._$webjs === 'style-map');
12
+ }
13
+ export function cssName(name) { return name.startsWith('--') ? name : name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); }
14
+ export function attributeMapText(value, name) {
15
+ const classes = value._$webjs === 'class-map';
16
+ if (name !== (classes ? 'class' : 'style')) throw new Error('classMap/styleMap must be used in a class/style attribute.');
17
+ // Lit deliberately pads even an empty map to separate adjacent static classes.
18
+ return classes
19
+ ? ' ' + Object.keys(value.value).filter((key) => value.value[key]).join(' ') + ' '
20
+ : Object.entries(value.value).filter(([, v]) => v != null).map(([key, v]) => `${cssName(key)}:${v};`).join('');
21
+ }
22
+
23
+ /** Commit only the map-owned tokens, preserving static and externally added values. */
24
+ export function applyAttributeMap(part, value) {
25
+ if (part.kind !== 'attr' && part.kind !== 'attr-mixed') throw new Error('classMap/styleMap require an attribute binding.');
26
+ const classes = value._$webjs === 'class-map';
27
+ if (part.name !== (classes ? 'class' : 'style')) throw new Error('classMap/styleMap must be used in a class/style attribute.');
28
+ if (part.group && part.group.length !== 1) throw new Error('classMap/styleMap require a single expression.');
29
+ if (part.__mapKind !== value._$webjs) {
30
+ part.el.setAttribute(part.name, (part.statics || []).join(classes ? ' ' : ''));
31
+ part.__mapKind = value._$webjs;
32
+ part.__mapKeys = new Set();
33
+ part.__staticClasses = classes ? new Set(part.el.classList) : new Set();
34
+ }
35
+ const next = new Set();
36
+ for (const [key, v] of Object.entries(value.value)) {
37
+ if (classes) {
38
+ if (v && !part.__staticClasses.has(key)) { part.el.classList.add(key); next.add(key); }
39
+ } else if (v != null) {
40
+ const name = cssName(key); const text = String(v);
41
+ part.el.style.setProperty(name, text.replace(/\s*!important\s*$/, ''), /!important\s*$/.test(text) ? 'important' : '');
42
+ next.add(name);
43
+ }
44
+ }
45
+ for (const key of part.__mapKeys) if (!next.has(key)) {
46
+ if (classes) part.el.classList.remove(key); else part.el.style.removeProperty(key);
47
+ }
48
+ part.__mapKeys = next;
49
+ }
@@ -39,6 +39,20 @@
39
39
  * JS under a hand-written overlay, so the shared code moves out instead.
40
40
  */
41
41
 
42
+ /**
43
+ * Whether a declared property has an HTML attribute at all. lit's two ways to
44
+ * say no, both honoured: `state: true` (internal state, which lit normalizes
45
+ * to `attribute: false`) and `attribute: false` (a public, property-only
46
+ * input). A property without one is never observed, never read from a source
47
+ * attribute at SSR, and never reflected; a `.prop` binding still reaches it.
48
+ *
49
+ * @param {{ state?: boolean, attribute?: string | false } | null | undefined} decl
50
+ * @returns {boolean}
51
+ */
52
+ export function isAttributeBacked(decl) {
53
+ return !!decl && decl.state !== true && decl.attribute !== false;
54
+ }
55
+
42
56
  /**
43
57
  * Read one attribute string into its declared property value.
44
58
  *
@@ -110,7 +124,7 @@ export function readAttributeValue(def, value) {
110
124
  // The two readers now see the SAME attribute set, so this fallback is not
111
125
  // the only thing they agree on (#1341). Both resolve a name through
112
126
  // `resolveAttributeProperty` below, which matches the same names
113
- // `observedAttributes` lists (via `observedAttributeNames`) and nothing else, so it skips a `state: true` prop and ignores a name that
127
+ // `observedAttributes` lists (via `observedAttributeNames`) and nothing else, so it skips a `state: true` or `attribute: false` prop and ignores a name that
114
128
  // maps to no attribute-backed property exactly as the observed list does.
115
129
  // The SSR caller lowercases its source name first, because the browser
116
130
  // lowercases while parsing. And both are handed a value with every
@@ -130,7 +144,7 @@ function hyphenate(s) {
130
144
  * Resolve an attribute NAME to the property it feeds, and to that property's
131
145
  * normalised declaration.
132
146
  *
133
- * `state: true` props are skipped, because they are absent from
147
+ * `state: true` and `attribute: false` props are skipped, because they are absent from
134
148
  * `observedAttributes` and so the browser never delivers their attribute to any
135
149
  * reader. lit reaches the same outcome the same way, resolving through the very
136
150
  * map `observedAttributes` is built from (`reactive-element.ts`
@@ -236,7 +250,7 @@ function buildAttributeBackedProps(Cls) {
236
250
  const out = [];
237
251
  for (const [k, decl] of Object.entries(props)) {
238
252
  const d = typeof decl === 'object' && decl !== null ? decl : { type: decl };
239
- if (d.state) continue;
253
+ if (!isAttributeBacked(d)) continue;
240
254
  const primary = d.attribute || hyphenate(k);
241
255
  const lower = k.toLowerCase();
242
256
  const alias = !d.attribute && lower !== primary ? lower : undefined;
@@ -1,3 +1,4 @@
1
+ import { nothing, noChange } from './sentinels.js';
1
2
  /**
2
3
  * The template attribute-binding prefixes the renderers recognise.
3
4
  *
@@ -42,48 +43,16 @@ export function isBindingPrefix(ch) {
42
43
  }
43
44
 
44
45
  /**
45
- * The HTML boolean attributes: present means on, absent means off, and the
46
- * value is meant to be empty. A boolean in a PLAIN hole on one of these renders
47
- * exactly like `?attr` (#1579), so `checked=${isDefault}` cannot serve the
48
- * `checked="false"` that HTML reads as checked. Lowercase, from the HTML spec's
49
- * attribute index.
50
- */
51
- const BOOLEAN_ATTRIBUTES = new Set([
52
- 'allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'controls',
53
- 'default', 'defer', 'disabled', 'formnovalidate', 'hidden', 'inert',
54
- 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate',
55
- 'open', 'playsinline', 'readonly', 'required', 'reversed', 'selected',
56
- 'shadowrootclonable', 'shadowrootdelegatesfocus', 'shadowrootserializable',
57
- ]);
58
-
59
- /**
60
- * What a PLAIN attribute hole (`name=${value}`, unquoted, the whole value)
61
- * writes, or `null` when the attribute is omitted. The one rule both renderers
62
- * follow, so the server never serves an attribute the client then removes
63
- * (#1573, #1579):
64
- *
65
- * - `null` / `undefined` omit the attribute. This is the "omit" value, so a
66
- * conditional attribute is `aria-current=${active ? 'page' : null}`.
67
- * - `false` omits it too, EXCEPT on an `aria-*` attribute, where it writes
68
- * `"false"`. For a tri-state ARIA attribute (`aria-expanded`,
69
- * `aria-pressed`, `aria-checked`) `"false"` and absent mean different
70
- * things, so the value an author passes is the value the user agent gets.
71
- * - `true` on an HTML boolean attribute (`checked`, `selected`, `disabled`,
72
- * and the rest) writes the empty value, exactly as `?attr=${true}` does. Anywhere
73
- * else it is `"true"`.
74
- * - anything else is `String(value)`.
75
- *
76
- * A quoted or mixed value (`title="${x}"`, `class="a ${x}"`) is not a plain
77
- * hole: the author wrote the attribute statically, so it stays and a nullish
78
- * piece reads as empty text.
46
+ * Serialize an ordinary whole-value attribute expression for SSR and client
47
+ * commits. Nullish values become empty strings and booleans stringify.
48
+ * `nothing` omits the attribute. `noChange` omits it on initial SSR; client
49
+ * updates handle that sentinel before serialization to retain the prior value.
79
50
  *
80
- * @param {string} name the attribute name as written
81
- * @param {unknown} value the hole's value (`live()` already unwrapped)
51
+ * @param {string} _name attribute name (kept for the shared call signature)
52
+ * @param {unknown} value the expression value (`live()` already unwrapped)
82
53
  * @returns {string | null}
83
54
  */
84
- export function attrHoleValue(name, value) {
85
- if (value == null) return null;
86
- if (value === false) return /^aria-/i.test(name) ? 'false' : null;
87
- if (value === true && BOOLEAN_ATTRIBUTES.has(String(name).toLowerCase())) return '';
88
- return String(value);
55
+ export function attrHoleValue(_name, value) {
56
+ if (value === nothing || value === noChange) return null;
57
+ return String(value ?? '');
89
58
  }
@@ -1,5 +1,5 @@
1
1
  import { render as clientRender } from '../render-client.js';
2
- import { readAttributeValue, resolveAttributeProperty, observedAttributeNames } from '../attribute-reader.js';
2
+ import { readAttributeValue, resolveAttributeProperty, observedAttributeNames, isAttributeBacked } from '../attribute-reader.js';
3
3
  import { setActiveActionSignal, activeActionSignal } from '../action-abort-client.js';
4
4
  import { carriesFunction } from '../form-action.js';
5
5
  import { isCSS, adoptStyles } from '../css.js';
@@ -84,7 +84,7 @@ const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undef
84
84
  * @property {(newValue: unknown, oldValue: unknown) => boolean} [hasChanged]
85
85
  * Custom dirty-check function. Called by the generated setter before
86
86
  * scheduling an update. Return `true` to trigger a re-render, `false`
87
- * to skip. Default: strict inequality `(a, b) => a !== b`.
87
+ * to skip. Default: `(a, b) => !Object.is(a, b)`.
88
88
  *
89
89
  * Note: this fires on the FIRST assignment too, with `oldValue` set
90
90
  * to `undefined`. Numeric comparators that subtract against undefined
@@ -148,11 +148,16 @@ function installAccessors(Ctor, props) {
148
148
  get() { return this.__propValues[propName]; },
149
149
  set(newVal) {
150
150
  const oldVal = this.__propValues[propName];
151
- if (!changedCheck(newVal, oldVal)) return;
152
151
  this.__propValues[propName] = newVal;
153
- // Reflect to attribute if requested (and not internal state).
154
- if (d.reflect && !d.state && this._connected) {
155
- this._reflectAttribute(propName, newVal, d);
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
+ }
156
161
  }
157
162
  // requestUpdate records the (name, oldValue) entry AND schedules a
158
163
  // render. During the update phase the scheduler short-circuits and
@@ -196,8 +201,9 @@ class WebComponentBase extends Base {
196
201
  * that trigger re-renders on change, coerce attribute values by type,
197
202
  * and optionally reflect back to attributes.
198
203
  *
199
- * Properties with `state: true` are excluded from `observedAttributes`
200
- * and never reflect: they behave like private reactive state.
204
+ * Properties with `state: true` (internal state) or `attribute: false` (a
205
+ * property-only input) are excluded from `observedAttributes` and never
206
+ * reflect.
201
207
  *
202
208
  * @type {Record<string, PropertyDeclaration>}
203
209
  */
@@ -228,8 +234,8 @@ class WebComponentBase extends Base {
228
234
 
229
235
  /**
230
236
  * Returns the list of attribute names the browser should observe.
231
- * Properties with `state: true` are excluded: they are internal-only
232
- * and do not correspond to any HTML attribute.
237
+ * Properties with `state: true` or `attribute: false` are excluded: they
238
+ * do not correspond to any HTML attribute.
233
239
  *
234
240
  * @returns {string[]}
235
241
  */
@@ -400,6 +406,9 @@ class WebComponentBase extends Base {
400
406
  * @private
401
407
  */
402
408
  _reflectAttribute(propName, value, decl) {
409
+ // A property with no attribute (`state: true` / `attribute: false`) has
410
+ // nothing to reflect to.
411
+ if (!isAttributeBacked(decl)) return;
403
412
  // A custom `attribute` option wins over the kebab-cased property name.
404
413
  const attrName = decl.attribute || hyphenate(propName);
405
414
  // Guard against re-entrant loops: attributeChangedCallback fires when
@@ -577,6 +586,17 @@ class WebComponentBase extends Base {
577
586
 
578
587
  _activate() {
579
588
  this._connected = true;
589
+ // A value written on the element before its class was defined is an own
590
+ // data property, and it hides any accessor of the same name for good.
591
+ // `_initializeProperties` lifts the declared properties in the
592
+ // constructor; an accessor the subclass defines itself (a getter/setter
593
+ // over a private signal, the shape internal state another element assigns
594
+ // takes) gets the same lift here, on the first connect, since its private
595
+ // field does not exist yet while the base constructor runs.
596
+ if (!this.__accessorsLifted) {
597
+ this.__accessorsLifted = true;
598
+ liftShadowedAccessors(this);
599
+ }
580
600
  // Reflect declared reflect:true properties from their current value now
581
601
  // that the element is connected. Constructor / willUpdate defaults were
582
602
  // set while disconnected (the setter skips reflection then), so this is
@@ -773,9 +793,12 @@ class WebComponentBase extends Base {
773
793
  // Dispose the signal watcher so dependency edges drop. Without
774
794
  // this the element holds references to module-scope signals
775
795
  // (and vice versa) forever.
776
- if (this.__signalWatcher) {
777
- this.__signalWatcher.dispose();
778
- this.__signalWatcher = undefined;
796
+ for (const slot of ['__signalWatcher', '__postWatcher']) {
797
+ const self = /** @type {any} */ (this);
798
+ if (self[slot]) {
799
+ self[slot].dispose();
800
+ self[slot] = undefined;
801
+ }
779
802
  }
780
803
  for (const c of this.__controllers || NO_CONTROLLERS) {
781
804
  if (c.hostDisconnected) c.hostDisconnected();
@@ -874,18 +897,92 @@ class WebComponentBase extends Base {
874
897
  * 7. `firstUpdated(changedProperties)` runs once, on the first render only
875
898
  * 8. `updated(changedProperties)` runs after every commit
876
899
  * 9. Resolve `updateComplete` promise
900
+ *
901
+ * Every signal the SYNCHRONOUS part of that cycle reads is tracked, not
902
+ * only the ones `render()` reads, which is the span lit-labs/signals tracks
903
+ * by running `performUpdate` inside a computed. So a signal read only in
904
+ * `willUpdate()` or `updated()` (a host attribute derived from internal
905
+ * state) re-renders the component when it changes. Two watchers split it
906
+ * (see {@link _tracked}): one over steps 2-6 (`render()` included), one
907
+ * over the post-commit hooks, so a write `updated()` makes to something
908
+ * `render()` read still schedules the next update. The DOM commits are
909
+ * the parts left untracked (see `update()`).
877
910
  * @private
878
911
  */
879
912
  _performRender() {
880
913
  if (!this._renderRoot) return;
914
+ this.__cycleRendered = false;
915
+ const postCommit = this._tracked('__signalWatcher', () => this._runUpdateCycle());
916
+ // A sync commit runs its post-commit hooks OUTSIDE the first watcher's
917
+ // phase, under their own watcher.
918
+ if (postCommit) postCommit();
919
+ }
920
+
921
+ /**
922
+ * Run one phase of the update cycle with the signals it reads recorded on
923
+ * the watcher in `slot`, creating the watcher on first use. A watcher
924
+ * re-records its read set on every phase it observes, and a later change
925
+ * to any of those signals schedules an update.
926
+ *
927
+ * Three rules keep that safe:
928
+ * - A phase ignores notifications from its OWN writes. A write to a signal
929
+ * the phase read earlier is the phase's business, not a reason to run it
930
+ * again: before a commit it is already folded into this pass, and after
931
+ * one it is often not the component's code at all (an event `updated()`
932
+ * dispatches, whose listener does a read-modify-write on a store), which
933
+ * would otherwise loop.
934
+ * - A pre-commit phase that never reached `render()` (`shouldUpdate()`
935
+ * returned false, or a hook threw) keeps the signals the last render
936
+ * read on top of the ones it read itself, or a skipped cycle would
937
+ * silently unsubscribe the component.
938
+ * - A watcher disposed during its own phase (a hook that detached the
939
+ * host) is disposed again afterwards, since the phase kept recording.
940
+ *
941
+ * @template T
942
+ * @param {'__signalWatcher' | '__postWatcher'} slot
943
+ * @param {() => T} fn
944
+ * @returns {T}
945
+ * @private
946
+ */
947
+ _tracked(slot, fn) {
948
+ const self = /** @type {any} */ (this);
949
+ /** @type {any} */
950
+ let watcher = self[slot];
951
+ if (!watcher) {
952
+ watcher = self[slot] = new Signal.subtle.Watcher(() => {
953
+ if (!watcher.__inPhase && this._connected) this.requestUpdate();
954
+ });
955
+ }
956
+ const before = slot === '__signalWatcher' ? Signal.subtle.introspectSources(watcher) : null;
957
+ watcher.__inPhase = true;
958
+ try {
959
+ return watcher.observe(fn);
960
+ } finally {
961
+ watcher.__inPhase = false;
962
+ if (before && !this.__cycleRendered) {
963
+ watcher.watch(...new Set([...before, ...Signal.subtle.introspectSources(watcher)]));
964
+ }
965
+ if (!this._connected) {
966
+ watcher.dispose();
967
+ if (self[slot] === watcher) self[slot] = undefined;
968
+ }
969
+ }
970
+ }
971
+
972
+ /**
973
+ * Steps 1-6 of the update cycle, the phase {@link _performRender} tracks
974
+ * with the first watcher. Returns the post-commit half to run for a sync
975
+ * commit, or nothing (an async commit runs it when it lands).
976
+ * @returns {(() => void) | undefined}
977
+ * @private
978
+ */
979
+ _runUpdateCycle() {
881
980
 
882
981
  // Hold a stable reference to the current Map so all hooks see the same
883
982
  // snapshot. During the update phase (steps 3-6) the Map is mutated in
884
983
  // place when property setters fire, folding those changes into THIS
885
- // cycle. The Map is replaced with a fresh empty one only after a
886
- // successful commit so post-commit assignments queue the NEXT cycle.
887
- // On `shouldUpdate=false` or hook errors, the Map is preserved so the
888
- // accumulated changes survive into the next render.
984
+ // cycle. The Map is replaced when this phase finishes, including skipped
985
+ // updates and hook errors, so post-commit writes belong to the NEXT cycle.
889
986
  const changedProperties = this._changedProperties;
890
987
 
891
988
  // --- 1. Mark we're inside an update cycle ---
@@ -923,6 +1020,7 @@ class WebComponentBase extends Base {
923
1020
  // in _commitAsync.
924
1021
  if (this.__renderAbort) this.__renderAbort.abort();
925
1022
  this.__renderAbort = typeof AbortController !== 'undefined' ? new AbortController() : null;
1023
+ this.__cycleRendered = true;
926
1024
  try {
927
1025
  const r = this.update(changedProperties);
928
1026
  if (r && typeof r.then === 'function') pendingCommit = r;
@@ -931,26 +1029,17 @@ class WebComponentBase extends Base {
931
1029
  }
932
1030
 
933
1031
  if (!pendingCommit) {
934
- // --- 6. controllers' hostUpdated (sync commit) ---
935
- for (const c of this.__controllers || NO_CONTROLLERS) {
936
- if (c.hostUpdated) c.hostUpdated();
937
- }
938
1032
  didCommit = true;
939
1033
  }
940
1034
  }
941
- // shouldUpdate=false: preserve _changedProperties so the next
942
- // requestUpdate keeps accumulating on top of the entries that
943
- // didn't render this cycle.
944
1035
  } catch (preCommitError) {
945
1036
  console.error(`[webjs] lifecycle hook threw during update phase:`, preCommitError);
946
1037
  } finally {
947
1038
  this._isUpdating = false;
948
- // The snapshot is consumed once the commit is DISPATCHED. For an async
949
- // render it resets here too, so a property change during the in-flight
950
- // fetch starts a fresh cycle (whose render token supersedes this one).
951
- if (didCommit || pendingCommit) {
952
- this._changedProperties = new Map();
953
- }
1039
+ // Consume this phase's snapshot even when shouldUpdate skips it. For an
1040
+ // async render, changes during the fetch start a new cycle whose token
1041
+ // supersedes the in-flight commit.
1042
+ this._changedProperties = new Map();
954
1043
  }
955
1044
 
956
1045
  // --- Async render: finish the cycle when the pending commit settles. ---
@@ -967,10 +1056,6 @@ class WebComponentBase extends Base {
967
1056
  this.__pendingAsyncCommits--;
968
1057
  // A newer render superseded this one; let the newer cycle finish.
969
1058
  if (token !== this.__renderToken) return;
970
- // --- 6. controllers' hostUpdated (after the async commit) ---
971
- for (const c of this.__controllers || NO_CONTROLLERS) {
972
- if (c.hostUpdated) c.hostUpdated();
973
- }
974
1059
  // --- 7-8 + updateComplete ---
975
1060
  this._postCommit(changedProperties);
976
1061
  };
@@ -1008,16 +1093,21 @@ class WebComponentBase extends Base {
1008
1093
  return;
1009
1094
  }
1010
1095
 
1011
- // --- 7-8. Post-commit hooks (sync path). Errors are caught so the
1012
- // updateComplete promise always resolves.
1096
+ // --- 7-8. Post-commit hooks (sync path), returned so they run outside
1097
+ // this phase. Errors are caught so the updateComplete promise always
1098
+ // resolves.
1013
1099
  if (didCommit) {
1014
- this._postCommit(changedProperties);
1100
+ return () => this._postCommit(changedProperties);
1015
1101
  } else if (!this.__pendingAsyncCommits) {
1016
1102
  // A non-committing cycle resolves updateComplete only when no async
1017
1103
  // commit is in flight; otherwise the pending commit resolves it once it
1018
1104
  // lands, so `await el.updateComplete` never returns before that DOM.
1019
1105
  this._resolveUpdate();
1020
1106
  }
1107
+ // No post-commit phase this cycle: re-arm its watcher, so a change it
1108
+ // was notified of meanwhile does not leave it deaf to the next one.
1109
+ if (!didCommit && /** @type {any} */ (this).__postWatcher) /** @type {any} */ (this).__postWatcher.watch();
1110
+ return undefined;
1021
1111
  }
1022
1112
 
1023
1113
  /**
@@ -1028,7 +1118,7 @@ class WebComponentBase extends Base {
1028
1118
  */
1029
1119
  _resolveUpdate() {
1030
1120
  if (this._updateResolve) {
1031
- const settled = this._changedProperties.size === 0;
1121
+ const settled = !this._scheduled;
1032
1122
  this._updateResolve(settled);
1033
1123
  this._updateResolve = null;
1034
1124
  }
@@ -1090,7 +1180,7 @@ class WebComponentBase extends Base {
1090
1180
  const props = /** @type {any} */ (this.constructor).properties || {};
1091
1181
  for (const name of Object.keys(props)) {
1092
1182
  const decl = props[name];
1093
- if (!decl || !decl.reflect || decl.state) continue;
1183
+ if (!decl || !decl.reflect) continue;
1094
1184
  this._reflectAttribute(name, this[name], decl);
1095
1185
  }
1096
1186
  }
@@ -1154,16 +1244,14 @@ class WebComponentBase extends Base {
1154
1244
  * @param {Map<string, unknown>} _changedProperties
1155
1245
  */
1156
1246
  update(_changedProperties) {
1157
- // Track signal reads during render so the component re-renders when
1158
- // any of them change. The watcher is lazy-allocated on first
1159
- // render and disposed in disconnectedCallback. observe() clears
1160
- // prior dep edges before fn(), so each render re-records its own
1161
- // (possibly different) read set.
1162
- if (!this.__signalWatcher) {
1163
- this.__signalWatcher = new Signal.subtle.Watcher(() => {
1164
- if (this._connected) this.requestUpdate();
1165
- });
1247
+ const pendingReflection = this.__reflectPending;
1248
+ this.__reflectPending = undefined;
1249
+ for (const name of pendingReflection || []) {
1250
+ const def = this.constructor.properties[name];
1251
+ this._reflectAttribute(name, this[name], def);
1166
1252
  }
1253
+ // The signal reads render() makes are tracked by the watcher
1254
+ // _performRender runs the whole cycle under.
1167
1255
  let tpl;
1168
1256
  // Bind this render's abort signal (#492) around the synchronous part of
1169
1257
  // render() ONLY, so the RPC stub ties an action called there to this
@@ -1176,7 +1264,7 @@ class WebComponentBase extends Base {
1176
1264
  const outer = activeActionSignal();
1177
1265
  setActiveActionSignal(this.__renderAbort ? this.__renderAbort.signal : null);
1178
1266
  try {
1179
- this.__signalWatcher.observe(() => { tpl = this.render(); });
1267
+ tpl = this.render();
1180
1268
  } finally {
1181
1269
  setActiveActionSignal(outer || null);
1182
1270
  }
@@ -1191,7 +1279,10 @@ class WebComponentBase extends Base {
1191
1279
  if (tpl && typeof (/** @type any */ (tpl).then) === 'function') {
1192
1280
  return this._commitAsync(/** @type {Promise<unknown>} */ (tpl));
1193
1281
  }
1194
- clientRender(tpl, this._renderRoot);
1282
+ // The commit is untracked: a signal read while the DOM is patched (a
1283
+ // `watch()` part, a child's synchronous work) belongs to whatever read
1284
+ // it, not to this component's next render.
1285
+ Signal.subtle.untrack(() => clientRender(tpl, this._renderRoot, { host: this }));
1195
1286
  return undefined;
1196
1287
  }
1197
1288
 
@@ -1220,7 +1311,7 @@ class WebComponentBase extends Base {
1220
1311
  if (this.__firstRendered && this._overridesRenderFallback()) {
1221
1312
  try {
1222
1313
  const fb = this.renderFallback();
1223
- if (fb !== undefined) clientRender(fb, this._renderRoot);
1314
+ if (fb !== undefined) Signal.subtle.untrack(() => clientRender(fb, this._renderRoot, { host: this }));
1224
1315
  } catch (e) {
1225
1316
  console.error(`[webjs] renderFallback() threw:`, e);
1226
1317
  }
@@ -1239,7 +1330,7 @@ class WebComponentBase extends Base {
1239
1330
  // The sync path routes an identical throw to the same boundary, so
1240
1331
  // this is what makes the two paths agree.
1241
1332
  try {
1242
- clientRender(tpl, this._renderRoot);
1333
+ clientRender(tpl, this._renderRoot, { host: this });
1243
1334
  } catch (commitError) {
1244
1335
  this._handleRenderError(
1245
1336
  commitError instanceof Error ? commitError : new Error(String(commitError)),
@@ -1276,7 +1367,7 @@ class WebComponentBase extends Base {
1276
1367
  );
1277
1368
  try {
1278
1369
  const fallback = this.renderError(error);
1279
- if (fallback !== undefined) clientRender(fallback, this._renderRoot);
1370
+ if (fallback !== undefined) Signal.subtle.untrack(() => clientRender(fallback, this._renderRoot, { host: this }));
1280
1371
  } catch (fallbackError) {
1281
1372
  console.error(`[webjs] renderError() also threw:`, fallbackError);
1282
1373
  }
@@ -1289,16 +1380,21 @@ class WebComponentBase extends Base {
1289
1380
  * @private
1290
1381
  */
1291
1382
  _postCommit(changedProperties) {
1383
+ const resolve = this._updateResolve;
1384
+ this._updateResolve = null;
1292
1385
  try {
1293
- if (!this.__firstRendered) {
1294
- this.__firstRendered = true;
1295
- this.firstUpdated(changedProperties);
1296
- }
1297
- this.updated(changedProperties);
1386
+ this._tracked('__postWatcher', () => {
1387
+ for (const c of this.__controllers || NO_CONTROLLERS) c.hostUpdated?.();
1388
+ if (!this.__firstRendered) {
1389
+ this.__firstRendered = true;
1390
+ this.firstUpdated(changedProperties);
1391
+ }
1392
+ this.updated(changedProperties);
1393
+ });
1298
1394
  } catch (postCommitError) {
1299
1395
  console.error(`[webjs] lifecycle hook threw during post-commit phase:`, postCommitError);
1300
1396
  } finally {
1301
- this._resolveUpdate();
1397
+ resolve?.(!this._scheduled);
1302
1398
  }
1303
1399
  }
1304
1400
 
@@ -1529,6 +1625,28 @@ export function WebComponent(properties) {
1529
1625
  }
1530
1626
  }
1531
1627
 
1628
+ /**
1629
+ * Re-assign every own data property of `el` that hides a setter defined on
1630
+ * its class chain below the WebJs base, so the value goes through the
1631
+ * accessor instead of shadowing it.
1632
+ *
1633
+ * @param {any} el
1634
+ */
1635
+ function liftShadowedAccessors(el) {
1636
+ for (const key of Object.keys(el)) {
1637
+ for (let p = Object.getPrototypeOf(el); p && p !== WebComponentBase.prototype; p = Object.getPrototypeOf(p)) {
1638
+ const d = Object.getOwnPropertyDescriptor(p, key);
1639
+ if (!d) continue;
1640
+ if (d.set) {
1641
+ const value = el[key];
1642
+ delete el[key];
1643
+ el[key] = value;
1644
+ }
1645
+ break;
1646
+ }
1647
+ }
1648
+ }
1649
+
1532
1650
  // Ensure static inheritance and instanceof checks work
1533
1651
  Object.setPrototypeOf(WebComponent, WebComponentBase);
1534
1652
  WebComponent.prototype = WebComponentBase.prototype;