@webjsdev/core 0.7.64 → 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 (58) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +21 -22
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +6 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +6 -2
  8. package/index.d.ts +7 -3
  9. package/index.js +6 -2
  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 +37 -3
  14. package/src/binding-prefixes.js +10 -41
  15. package/src/component/lifecycle.js +261 -101
  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/escape.js +12 -2
  21. package/src/form-action.js +7 -7
  22. package/src/html.d.ts +4 -0
  23. package/src/html.js +22 -1
  24. package/src/optimistic.js +3 -3
  25. package/src/registry.d.ts +5 -0
  26. package/src/registry.js +35 -4
  27. package/src/render-client/adopt-slots.js +245 -0
  28. package/src/render-client/parts.js +53 -19
  29. package/src/render-client/reconciler.js +28 -12
  30. package/src/render-client/template-compiler.js +19 -5
  31. package/src/render-client.d.ts +2 -1
  32. package/src/render-server/attrs.js +5 -4
  33. package/src/render-server/compile.js +433 -0
  34. package/src/render-server/dsd.js +11 -435
  35. package/src/render-server/errors.js +84 -0
  36. package/src/render-server/expand.js +346 -0
  37. package/src/render-server/html-scan.js +19 -358
  38. package/src/render-server/served-attrs.js +79 -0
  39. package/src/render-server/stream.js +11 -13
  40. package/src/render-server/template-renderer.js +13 -936
  41. package/src/render-server/walk.js +798 -0
  42. package/src/router-client/anchors.js +3 -1
  43. package/src/router-client/boundaries.js +15 -1
  44. package/src/router-client/constants.js +30 -0
  45. package/src/router-client/fetch-apply.js +21 -0
  46. package/src/router-client/frames.js +6 -3
  47. package/src/router-client/head-merge.js +58 -10
  48. package/src/router-client/navigator.js +4 -0
  49. package/src/router-client/owned-styles.js +372 -0
  50. package/src/router-client/prefetch.js +3 -0
  51. package/src/router-client/snapshot-cache.js +2 -12
  52. package/src/router-client/stylesheets.js +191 -0
  53. package/src/router-client/swap.js +49 -9
  54. package/src/sentinels.d.ts +2 -0
  55. package/src/sentinels.js +4 -0
  56. package/src/slot.js +1 -1
  57. package/src/webjs-config.d.ts +1 -1
  58. package/src/render-server/slots.js +0 -213
@@ -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
@@ -115,6 +115,61 @@ const FACTORY_PROPS = Symbol('webjs.factoryProps');
115
115
  // Per-class memo so the constructor-time enforcement walk runs once per class.
116
116
  const _propsChecked = new WeakSet();
117
117
 
118
+ /** A settled `updateComplete` shared by every instance until its first update. */
119
+ const RESOLVED_TRUE = Promise.resolve(true);
120
+ /** Iterated in place of a controller set no instance has created. */
121
+ const NO_CONTROLLERS = new Set();
122
+
123
+ /**
124
+ * Per class, the declared reactive properties with their accessors installed
125
+ * on the prototype.
126
+ * @type {WeakMap<Function, { propName: string, decl: any }[]>}
127
+ */
128
+ const _accessorPlans = new WeakMap();
129
+
130
+ /**
131
+ * Define the getter/setter pair for every declared property on the class's
132
+ * 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.
135
+ * @param {any} Ctor
136
+ * @param {Record<string, unknown>} props
137
+ */
138
+ function installAccessors(Ctor, props) {
139
+ /** @type {{ propName: string, decl: any }[]} */
140
+ 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
+ });
168
+ }
169
+ _accessorPlans.set(Ctor, plan);
170
+ return plan;
171
+ }
172
+
118
173
  class WebComponentBase extends Base {
119
174
  /** Whether to use shadow DOM. Default: false (light DOM). @type {boolean} */
120
175
  static shadow = false;
@@ -146,8 +201,9 @@ class WebComponentBase extends Base {
146
201
  * that trigger re-renders on change, coerce attribute values by type,
147
202
  * and optionally reflect back to attributes.
148
203
  *
149
- * Properties with `state: true` are excluded from `observedAttributes`
150
- * 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.
151
207
  *
152
208
  * @type {Record<string, PropertyDeclaration>}
153
209
  */
@@ -178,8 +234,8 @@ class WebComponentBase extends Base {
178
234
 
179
235
  /**
180
236
  * Returns the list of attribute names the browser should observe.
181
- * Properties with `state: true` are excluded: they are internal-only
182
- * 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.
183
239
  *
184
240
  * @returns {string[]}
185
241
  */
@@ -199,10 +255,12 @@ class WebComponentBase extends Base {
199
255
  this._connected = false;
200
256
 
201
257
  /**
202
- * Set of attached reactive controllers.
203
- * @type {Set<ReactiveController>}
258
+ * Set of attached reactive controllers, created by the first
259
+ * `addController` (#1646: most instances never attach one, and the SSR
260
+ * walk constructs an instance per rendered element).
261
+ * @type {Set<ReactiveController> | null}
204
262
  */
205
- this.__controllers = new Set();
263
+ this.__controllers = null;
206
264
 
207
265
  /**
208
266
  * Whether the component has completed its first render.
@@ -235,7 +293,7 @@ class WebComponentBase extends Base {
235
293
  * @type {Promise<boolean>}
236
294
  * @private
237
295
  */
238
- this._updatePromise = Promise.resolve(true);
296
+ this._updatePromise = RESOLVED_TRUE;
239
297
 
240
298
  /**
241
299
  * Set while the component is inside the update phase (between
@@ -288,13 +346,14 @@ class WebComponentBase extends Base {
288
346
  }
289
347
 
290
348
  /**
291
- * For every key in `static properties`, create a getter/setter pair on
292
- * the instance that coerces values, runs `hasChanged`, schedules updates,
293
- * and optionally reflects to the HTML attribute.
349
+ * Give the instance its reactive-property backing store and make sure the
350
+ * class's getter/setter pairs (which run `hasChanged`, schedule updates and
351
+ * optionally reflect to the HTML attribute) are installed on its PROTOTYPE,
352
+ * once per class. Per instance this only lifts a value written before the
353
+ * element upgraded and applies the declared `default`s.
294
354
  *
295
- * This is called once from the constructor. The backing store is a plain
296
- * object (`this.__propValues`) so accessors don't collide with the
297
- * prototype.
355
+ * Called once from the constructor. The backing store is a plain object
356
+ * (`this.__propValues`), so the accessors never collide with it.
298
357
  * @private
299
358
  */
300
359
  _initializeProperties() {
@@ -305,35 +364,24 @@ class WebComponentBase extends Base {
305
364
  /** @type {Record<string, unknown>} */
306
365
  this.__propValues = {};
307
366
 
308
- for (const [propName, decl] of Object.entries(props)) {
309
- const d = typeof decl === 'object' ? decl : { type: decl };
310
- // Capture any value set before the accessor was installed (e.g. via
311
- // attribute or property assignment before `super()` returns).
312
- const initial = /** @type {any} */ (this)[propName];
313
-
314
- Object.defineProperty(this, propName, {
315
- configurable: true,
316
- enumerable: true,
317
- get: () => this.__propValues[propName],
318
- set: (newVal) => {
319
- const oldVal = this.__propValues[propName];
320
- const changed = (d.hasChanged || defaultHasChanged)(newVal, oldVal);
321
- if (!changed) return;
322
- this.__propValues[propName] = newVal;
323
-
324
- // Reflect to attribute if requested (and not internal state).
325
- if (d.reflect && !d.state && this._connected) {
326
- this._reflectAttribute(propName, newVal, d);
327
- }
328
-
329
- // requestUpdate records the (name, oldValue) entry AND schedules
330
- // a render. When called during the update phase (willUpdate /
331
- // hostUpdate / update / hostUpdated), the scheduler short-circuits
332
- // and the entry folds into the current cycle's changedProperties.
333
- this.requestUpdate(propName, oldVal);
334
- },
335
- });
336
-
367
+ // The accessors live on the PROTOTYPE, installed once per class (#1646).
368
+ // They used to be defined on every instance, two closures and a
369
+ // defineProperty per declared property, which was the largest single
370
+ // cost of constructing a component on the SSR path (one instance per
371
+ // rendered element) and a measurable one on upgrade in the browser.
372
+ let plan = _accessorPlans.get(Ctor);
373
+ if (plan === undefined) plan = installAccessors(Ctor, props);
374
+
375
+ 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;
381
+ if (Object.hasOwn(this, propName)) {
382
+ initial = /** @type {any} */ (this)[propName];
383
+ delete (/** @type {any} */ (this))[propName];
384
+ }
337
385
  if (initial !== undefined) {
338
386
  this.__propValues[propName] = initial;
339
387
  } else if (d.default !== undefined) {
@@ -358,6 +406,9 @@ class WebComponentBase extends Base {
358
406
  * @private
359
407
  */
360
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;
361
412
  // A custom `attribute` option wins over the kebab-cased property name.
362
413
  const attrName = decl.attribute || hyphenate(propName);
363
414
  // Guard against re-entrant loops: attributeChangedCallback fires when
@@ -535,6 +586,17 @@ class WebComponentBase extends Base {
535
586
 
536
587
  _activate() {
537
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
+ }
538
600
  // Reflect declared reflect:true properties from their current value now
539
601
  // that the element is connected. Constructor / willUpdate defaults were
540
602
  // set while disconnected (the setter skips reflection then), so this is
@@ -596,7 +658,7 @@ class WebComponentBase extends Base {
596
658
  // b. SSR hydration (first mount, <!--webjs-hydrate--> marker
597
659
  // present). Children are already inside their
598
660
  // <slot data-webjs-light data-projection="actual"> elements
599
- // (injectDSD placed them). Adopt those assignments BEFORE
661
+ // (the SSR walk placed them). Adopt those assignments BEFORE
600
662
  // _performRender so we retain references to the SSR'd nodes;
601
663
  // the renderer's createInstance().replaceChildren() will
602
664
  // detach them, but the slot-apply step re-attaches the same
@@ -648,7 +710,7 @@ class WebComponentBase extends Base {
648
710
  }
649
711
 
650
712
  // Notify all controllers that the host is connected.
651
- for (const c of this.__controllers) {
713
+ for (const c of this.__controllers || NO_CONTROLLERS) {
652
714
  if (c.hostConnected) c.hostConnected();
653
715
  }
654
716
 
@@ -731,11 +793,14 @@ class WebComponentBase extends Base {
731
793
  // Dispose the signal watcher so dependency edges drop. Without
732
794
  // this the element holds references to module-scope signals
733
795
  // (and vice versa) forever.
734
- if (this.__signalWatcher) {
735
- this.__signalWatcher.dispose();
736
- 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
+ }
737
802
  }
738
- for (const c of this.__controllers) {
803
+ for (const c of this.__controllers || NO_CONTROLLERS) {
739
804
  if (c.hostDisconnected) c.hostDisconnected();
740
805
  }
741
806
  }
@@ -832,18 +897,92 @@ class WebComponentBase extends Base {
832
897
  * 7. `firstUpdated(changedProperties)` runs once, on the first render only
833
898
  * 8. `updated(changedProperties)` runs after every commit
834
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()`).
835
910
  * @private
836
911
  */
837
912
  _performRender() {
838
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() {
839
980
 
840
981
  // Hold a stable reference to the current Map so all hooks see the same
841
982
  // snapshot. During the update phase (steps 3-6) the Map is mutated in
842
983
  // place when property setters fire, folding those changes into THIS
843
- // cycle. The Map is replaced with a fresh empty one only after a
844
- // successful commit so post-commit assignments queue the NEXT cycle.
845
- // On `shouldUpdate=false` or hook errors, the Map is preserved so the
846
- // 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.
847
986
  const changedProperties = this._changedProperties;
848
987
 
849
988
  // --- 1. Mark we're inside an update cycle ---
@@ -862,7 +1001,7 @@ class WebComponentBase extends Base {
862
1001
  this.willUpdate(changedProperties);
863
1002
 
864
1003
  // --- 4. controllers' hostUpdate ---
865
- for (const c of this.__controllers) {
1004
+ for (const c of this.__controllers || NO_CONTROLLERS) {
866
1005
  if (c.hostUpdate) c.hostUpdate();
867
1006
  }
868
1007
 
@@ -881,6 +1020,7 @@ class WebComponentBase extends Base {
881
1020
  // in _commitAsync.
882
1021
  if (this.__renderAbort) this.__renderAbort.abort();
883
1022
  this.__renderAbort = typeof AbortController !== 'undefined' ? new AbortController() : null;
1023
+ this.__cycleRendered = true;
884
1024
  try {
885
1025
  const r = this.update(changedProperties);
886
1026
  if (r && typeof r.then === 'function') pendingCommit = r;
@@ -889,26 +1029,17 @@ class WebComponentBase extends Base {
889
1029
  }
890
1030
 
891
1031
  if (!pendingCommit) {
892
- // --- 6. controllers' hostUpdated (sync commit) ---
893
- for (const c of this.__controllers) {
894
- if (c.hostUpdated) c.hostUpdated();
895
- }
896
1032
  didCommit = true;
897
1033
  }
898
1034
  }
899
- // shouldUpdate=false: preserve _changedProperties so the next
900
- // requestUpdate keeps accumulating on top of the entries that
901
- // didn't render this cycle.
902
1035
  } catch (preCommitError) {
903
1036
  console.error(`[webjs] lifecycle hook threw during update phase:`, preCommitError);
904
1037
  } finally {
905
1038
  this._isUpdating = false;
906
- // The snapshot is consumed once the commit is DISPATCHED. For an async
907
- // render it resets here too, so a property change during the in-flight
908
- // fetch starts a fresh cycle (whose render token supersedes this one).
909
- if (didCommit || pendingCommit) {
910
- this._changedProperties = new Map();
911
- }
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();
912
1043
  }
913
1044
 
914
1045
  // --- Async render: finish the cycle when the pending commit settles. ---
@@ -925,10 +1056,6 @@ class WebComponentBase extends Base {
925
1056
  this.__pendingAsyncCommits--;
926
1057
  // A newer render superseded this one; let the newer cycle finish.
927
1058
  if (token !== this.__renderToken) return;
928
- // --- 6. controllers' hostUpdated (after the async commit) ---
929
- for (const c of this.__controllers) {
930
- if (c.hostUpdated) c.hostUpdated();
931
- }
932
1059
  // --- 7-8 + updateComplete ---
933
1060
  this._postCommit(changedProperties);
934
1061
  };
@@ -966,16 +1093,21 @@ class WebComponentBase extends Base {
966
1093
  return;
967
1094
  }
968
1095
 
969
- // --- 7-8. Post-commit hooks (sync path). Errors are caught so the
970
- // 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.
971
1099
  if (didCommit) {
972
- this._postCommit(changedProperties);
1100
+ return () => this._postCommit(changedProperties);
973
1101
  } else if (!this.__pendingAsyncCommits) {
974
1102
  // A non-committing cycle resolves updateComplete only when no async
975
1103
  // commit is in flight; otherwise the pending commit resolves it once it
976
1104
  // lands, so `await el.updateComplete` never returns before that DOM.
977
1105
  this._resolveUpdate();
978
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;
979
1111
  }
980
1112
 
981
1113
  /**
@@ -986,7 +1118,7 @@ class WebComponentBase extends Base {
986
1118
  */
987
1119
  _resolveUpdate() {
988
1120
  if (this._updateResolve) {
989
- const settled = this._changedProperties.size === 0;
1121
+ const settled = !this._scheduled;
990
1122
  this._updateResolve(settled);
991
1123
  this._updateResolve = null;
992
1124
  }
@@ -1018,7 +1150,7 @@ class WebComponentBase extends Base {
1018
1150
  this._isUpdating = true;
1019
1151
  try {
1020
1152
  this.willUpdate(changedProperties);
1021
- for (const c of this.__controllers) {
1153
+ for (const c of this.__controllers || NO_CONTROLLERS) {
1022
1154
  if (c.hostUpdate) c.hostUpdate();
1023
1155
  }
1024
1156
  this._reflectDeclaredAttributes();
@@ -1048,7 +1180,7 @@ class WebComponentBase extends Base {
1048
1180
  const props = /** @type {any} */ (this.constructor).properties || {};
1049
1181
  for (const name of Object.keys(props)) {
1050
1182
  const decl = props[name];
1051
- if (!decl || !decl.reflect || decl.state) continue;
1183
+ if (!decl || !decl.reflect) continue;
1052
1184
  this._reflectAttribute(name, this[name], decl);
1053
1185
  }
1054
1186
  }
@@ -1112,16 +1244,14 @@ class WebComponentBase extends Base {
1112
1244
  * @param {Map<string, unknown>} _changedProperties
1113
1245
  */
1114
1246
  update(_changedProperties) {
1115
- // Track signal reads during render so the component re-renders when
1116
- // any of them change. The watcher is lazy-allocated on first
1117
- // render and disposed in disconnectedCallback. observe() clears
1118
- // prior dep edges before fn(), so each render re-records its own
1119
- // (possibly different) read set.
1120
- if (!this.__signalWatcher) {
1121
- this.__signalWatcher = new Signal.subtle.Watcher(() => {
1122
- if (this._connected) this.requestUpdate();
1123
- });
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);
1124
1252
  }
1253
+ // The signal reads render() makes are tracked by the watcher
1254
+ // _performRender runs the whole cycle under.
1125
1255
  let tpl;
1126
1256
  // Bind this render's abort signal (#492) around the synchronous part of
1127
1257
  // render() ONLY, so the RPC stub ties an action called there to this
@@ -1134,7 +1264,7 @@ class WebComponentBase extends Base {
1134
1264
  const outer = activeActionSignal();
1135
1265
  setActiveActionSignal(this.__renderAbort ? this.__renderAbort.signal : null);
1136
1266
  try {
1137
- this.__signalWatcher.observe(() => { tpl = this.render(); });
1267
+ tpl = this.render();
1138
1268
  } finally {
1139
1269
  setActiveActionSignal(outer || null);
1140
1270
  }
@@ -1149,7 +1279,10 @@ class WebComponentBase extends Base {
1149
1279
  if (tpl && typeof (/** @type any */ (tpl).then) === 'function') {
1150
1280
  return this._commitAsync(/** @type {Promise<unknown>} */ (tpl));
1151
1281
  }
1152
- 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 }));
1153
1286
  return undefined;
1154
1287
  }
1155
1288
 
@@ -1178,7 +1311,7 @@ class WebComponentBase extends Base {
1178
1311
  if (this.__firstRendered && this._overridesRenderFallback()) {
1179
1312
  try {
1180
1313
  const fb = this.renderFallback();
1181
- if (fb !== undefined) clientRender(fb, this._renderRoot);
1314
+ if (fb !== undefined) Signal.subtle.untrack(() => clientRender(fb, this._renderRoot, { host: this }));
1182
1315
  } catch (e) {
1183
1316
  console.error(`[webjs] renderFallback() threw:`, e);
1184
1317
  }
@@ -1197,7 +1330,7 @@ class WebComponentBase extends Base {
1197
1330
  // The sync path routes an identical throw to the same boundary, so
1198
1331
  // this is what makes the two paths agree.
1199
1332
  try {
1200
- clientRender(tpl, this._renderRoot);
1333
+ clientRender(tpl, this._renderRoot, { host: this });
1201
1334
  } catch (commitError) {
1202
1335
  this._handleRenderError(
1203
1336
  commitError instanceof Error ? commitError : new Error(String(commitError)),
@@ -1234,7 +1367,7 @@ class WebComponentBase extends Base {
1234
1367
  );
1235
1368
  try {
1236
1369
  const fallback = this.renderError(error);
1237
- if (fallback !== undefined) clientRender(fallback, this._renderRoot);
1370
+ if (fallback !== undefined) Signal.subtle.untrack(() => clientRender(fallback, this._renderRoot, { host: this }));
1238
1371
  } catch (fallbackError) {
1239
1372
  console.error(`[webjs] renderError() also threw:`, fallbackError);
1240
1373
  }
@@ -1247,16 +1380,21 @@ class WebComponentBase extends Base {
1247
1380
  * @private
1248
1381
  */
1249
1382
  _postCommit(changedProperties) {
1383
+ const resolve = this._updateResolve;
1384
+ this._updateResolve = null;
1250
1385
  try {
1251
- if (!this.__firstRendered) {
1252
- this.__firstRendered = true;
1253
- this.firstUpdated(changedProperties);
1254
- }
1255
- 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
+ });
1256
1394
  } catch (postCommitError) {
1257
1395
  console.error(`[webjs] lifecycle hook threw during post-commit phase:`, postCommitError);
1258
1396
  } finally {
1259
- this._resolveUpdate();
1397
+ resolve?.(!this._scheduled);
1260
1398
  }
1261
1399
  }
1262
1400
 
@@ -1374,7 +1512,7 @@ class WebComponentBase extends Base {
1374
1512
  * @param {ReactiveController} controller
1375
1513
  */
1376
1514
  addController(controller) {
1377
- this.__controllers.add(controller);
1515
+ (this.__controllers ??= new Set()).add(controller);
1378
1516
  if (this._connected && controller.hostConnected) {
1379
1517
  controller.hostConnected();
1380
1518
  }
@@ -1393,7 +1531,7 @@ class WebComponentBase extends Base {
1393
1531
  * @param {ReactiveController} controller
1394
1532
  */
1395
1533
  removeController(controller) {
1396
- this.__controllers.delete(controller);
1534
+ if (this.__controllers) this.__controllers.delete(controller);
1397
1535
  }
1398
1536
 
1399
1537
  /**
@@ -1487,6 +1625,28 @@ export function WebComponent(properties) {
1487
1625
  }
1488
1626
  }
1489
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
+
1490
1650
  // Ensure static inheritance and instanceof checks work
1491
1651
  Object.setPrototypeOf(WebComponent, WebComponentBase);
1492
1652
  WebComponent.prototype = WebComponentBase.prototype;
@@ -1,13 +1,13 @@
1
1
  import { tagOf } from '../registry.js';
2
2
 
3
3
  /**
4
- * Default change detection: strict inequality.
4
+ * Default change detection: Object.is inequality (NaN is stable; signed zero changes).
5
5
  * @param {unknown} a
6
6
  * @param {unknown} b
7
7
  * @returns {boolean}
8
8
  */
9
9
  export function defaultHasChanged(a, b) {
10
- return a !== b;
10
+ return !Object.is(a, b);
11
11
  }
12
12
 
13
13
  /**
@@ -108,8 +108,9 @@ export function warnUnserializableReflection(host, propName, attrName, detail) {
108
108
  * while staying JSDoc-only and no-build.
109
109
  *
110
110
  * Subclasses declare:
111
- * - `static properties`: attribute/property declarations with type info,
112
- * reflection, custom converters, and internal-state mode
111
+ * - reactive properties, through the `WebComponent({ ... })` factory:
112
+ * attribute/property declarations with type info, reflection and custom
113
+ * converters (internal state is an instance signal, not a property)
113
114
  * - `static styles`: CSSResult or array thereof (only meaningful with
114
115
  * `static shadow = true`; light-DOM components inherit global CSS)
115
116
  * - `static shadow`: set `true` to opt in to shadow DOM (default: `false`
@@ -85,11 +85,11 @@ interface WebComponentBase extends HTMLElement {
85
85
  * Schedule a re-render. Optionally record a property change so lifecycle
86
86
  * hooks can branch on what changed via `changedProperties`.
87
87
  *
88
- * For instance-local state, prefer a reactive property (`WebComponent({
89
- * foo: prop({ state: true }) })`) or a signal from
90
- * `@webjsdev/core`'s `signal()`. The framework's built-in
91
- * SignalWatcher wires every signal read in `render()` to a re-render
92
- * automatically; explicit `requestUpdate()` is rarely needed.
88
+ * For instance-local state, use a reactive property (`WebComponent({
89
+ * open: prop<boolean>({ state: true }) })`) or a signal from
90
+ * `@webjsdev/core`'s `signal()`. The framework's built-in SignalWatcher
91
+ * wires every signal the update cycle reads to a re-render automatically;
92
+ * explicit `requestUpdate()` is rarely needed.
93
93
  */
94
94
  requestUpdate(name?: string, oldValue?: unknown): void;
95
95
  /** A Promise that resolves after the next render commit. */
@@ -210,6 +210,12 @@ export declare function prop<T>(
210
210
  opts?: Omit<PropertyDeclaration<T>, 'type'>
211
211
  ): PropertyDeclaration<T>;
212
212
 
213
+ // No type constructor and no attribute: nothing says what the value is, so it
214
+ // is `unknown` until narrowed (`prop<boolean>({ state: true })`).
215
+ export declare function prop<T = unknown>(
216
+ opts: PropertyDeclaration<T> & ({ state: true } | { attribute: false })
217
+ ): PropertyDeclaration<T>;
218
+
213
219
  export declare function prop<T = string>(
214
220
  opts?: PropertyDeclaration<T>
215
221
  ): PropertyDeclaration<T>;