what-core 0.13.3 → 0.13.5

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.
package/src/a11y.js CHANGED
@@ -53,12 +53,13 @@ export function useFocusRestore() {
53
53
  // Keep focus within a container (for modals, dialogs, etc.)
54
54
 
55
55
  export function useFocusTrap(containerRef) {
56
+ /** @type {HTMLElement | null} */
56
57
  let previousFocus = null;
57
58
 
58
59
  function activate() {
59
60
  if (typeof document === 'undefined') return;
60
61
 
61
- previousFocus = document.activeElement;
62
+ previousFocus = /** @type {HTMLElement | null} */ (document.activeElement);
62
63
  const container = containerRef.current || containerRef;
63
64
 
64
65
  if (!container || typeof container.querySelectorAll !== 'function') return;
@@ -131,6 +132,7 @@ export function FocusTrap({ children, active = true }) {
131
132
  const containerRef = { current: null };
132
133
  const refVersion = signal(0);
133
134
  const trap = useFocusTrap(containerRef);
135
+ /** @type {(() => void) | null | undefined} */
134
136
  let trapCleanup = null;
135
137
 
136
138
  const setRef = (el) => {
@@ -176,6 +178,7 @@ export function FocusTrap({ children, active = true }) {
176
178
 
177
179
  // --- Screen Reader Announcements ---
178
180
 
181
+ /** @type {HTMLDivElement | null} */
179
182
  let announcer = null;
180
183
  let announcerId = 0;
181
184
 
@@ -8,7 +8,7 @@ import { getCollectedErrors } from './errors.js';
8
8
  // --- Version ---
9
9
  // Keep in sync with packages/core/package.json (checked by
10
10
  // core/test/guardrails.test.js so it can't silently go stale again).
11
- const VERSION = '0.13.3';
11
+ const VERSION = '0.13.5';
12
12
 
13
13
  // --- Component Registry ---
14
14
  // Tracks mounted components for agent inspection.
package/src/animation.js CHANGED
@@ -29,7 +29,9 @@ export function spring(initialValue, options = {}) {
29
29
  const velocity = signal(0);
30
30
  const isAnimating = signal(false);
31
31
 
32
+ /** @type {number | null} */
32
33
  let rafId = null;
34
+ /** @type {number | null} */
33
35
  let lastTime = null;
34
36
 
35
37
  function tick(time) {
@@ -138,13 +140,16 @@ export function tween(from, to, options = {}) {
138
140
  const value = signal(from);
139
141
  const isAnimating = signal(true);
140
142
 
143
+ /** @type {number | null} */
141
144
  let startTime = null;
145
+ /** @type {number | null} */
142
146
  let rafId = null;
143
147
 
144
148
  function tick(time) {
145
149
  if (startTime === null) startTime = time;
150
+ const t0 = /** @type {number} */ (startTime);
146
151
 
147
- const elapsed = time - startTime;
152
+ const elapsed = time - t0;
148
153
  const t = Math.min(elapsed / duration, 1);
149
154
  const easedT = easing(t);
150
155
  const currentValue = from + (to - from) * easedT;
@@ -269,6 +274,7 @@ export function useGesture(element, handlers = {}) {
269
274
  let lastTime = 0;
270
275
  let lastX = 0;
271
276
  let lastY = 0;
277
+ /** @type {ReturnType<typeof setTimeout> | null} */
272
278
  let longPressTimer = null;
273
279
 
274
280
  function handleStart(e) {
@@ -377,6 +383,7 @@ export function useGesture(element, handlers = {}) {
377
383
  }
378
384
 
379
385
  // Pinch handling (touch only)
386
+ /** @type {number | null} */
380
387
  let initialPinchDistance = null;
381
388
 
382
389
  function handlePinchMove(e) {
package/src/components.js CHANGED
@@ -24,6 +24,7 @@ export function memo(Component, _areEqual) {
24
24
  }
25
25
 
26
26
  // Injected by dom.js
27
+ /** @type {(() => any) | null} */
27
28
  let _getCurrentComponent = null;
28
29
  export function _injectGetCurrentComponent(fn) { _getCurrentComponent = fn; }
29
30
 
@@ -284,6 +285,7 @@ export function Match(props) {
284
285
  // Late-bound renderers, injected by render.js. components.js cannot import
285
286
  // render.js directly (render -> dom -> components is already a cycle), so the
286
287
  // same injection precedent as _injectGetCurrentComponent applies here.
288
+ /** @type {{ hydrate?: Function, insert?: Function } | null} */
287
289
  let _islandRuntime = null;
288
290
 
289
291
  /** @internal */
package/src/data.js CHANGED
@@ -226,6 +226,7 @@ export function useFetch(url, options = {}) {
226
226
  const data = signal(initialData);
227
227
  const error = signal(null);
228
228
  const isLoading = signal(true);
229
+ /** @type {AbortController | null} */
229
230
  let abortController = null;
230
231
 
231
232
  async function fetchData() {
@@ -342,6 +343,7 @@ export function useSWR(rawKey, fetcher, options = {}) {
342
343
  return empty && fetching;
343
344
  });
344
345
 
346
+ /** @type {AbortController | null} */
345
347
  let abortController = null;
346
348
 
347
349
  // `force` is invalidateQueries(): "this data is wrong now". It bypasses the
@@ -603,6 +605,7 @@ export function useQuery(options) {
603
605
  // effect's cleanup must leave it alone. One shared controller meant an
604
606
  // unrelated re-render cancelled a button click's refetch(), whose promise
605
607
  // then resolved with `undefined`.
608
+ /** @type {{ controller: AbortController, manual: boolean, direction?: string } | null} */
606
609
  let inFlight = null;
607
610
  let cleanupTimer = null;
608
611
 
@@ -895,6 +898,7 @@ export function useInfiniteQuery(options) {
895
898
  // The page request in flight and who asked for it. See the matching note in
896
899
  // useQuery: an explicit fetchNextPage()/refetch() belongs to the caller, and
897
900
  // the effect's cleanup must not cancel one just because it re-ran.
901
+ /** @type {{ controller: AbortController, manual: boolean, direction?: string } | null} */
898
902
  let inFlight = null;
899
903
 
900
904
  // clearCache() reaches an infinite query through here, because its pages
package/src/dom.js CHANGED
@@ -198,6 +198,69 @@ export function disposeTree(node) {
198
198
  }
199
199
  }
200
200
 
201
+ // --- _liveRegionNodes(tracked) ---
202
+ //
203
+ // The nodes a reactive region must remove, as the DOM stands NOW rather than as
204
+ // it stood when the region last rendered.
205
+ //
206
+ // A region records what its value produced and reuses that record as the removal
207
+ // set on its next run. For content the region built outright the record stays
208
+ // true, because nothing else edits those nodes. Content that manages ITSELF is
209
+ // not like that: a mapArray list and a nested reactive region both own an effect
210
+ // and keep replacing their own nodes on their own schedule, so by the time the
211
+ // outer region is torn down its record describes a shape that no longer exists.
212
+ //
213
+ // Removing only the recorded nodes therefore stranded everything the inner
214
+ // effect had produced since:
215
+ //
216
+ // {() => show() && <>{() => items().map(i => <li key={i}>{i}</li>)}<p>z</p></>}
217
+ //
218
+ // mounted with two items and grown to three, then switched off, removed the two
219
+ // rows it had recorded and left the third in the DOM. Switching back on rendered
220
+ // a full fresh list beside that orphan, and the orphan survived every later
221
+ // cycle because it was never in any record.
222
+ //
223
+ // Self-managing content is bracketed by a start/end marker pair for exactly this
224
+ // reason: `<!--list-->`/`<!--/list-->` around an embedded list, `<!--fn-->`/
225
+ // `<!--/fn-->` around a nested region. Everything the inner effect ever inserts
226
+ // lands between the pair, so walking the live range picks up what was added late
227
+ // and passes over what has already gone. `_rangeEnd` on the start marker is the
228
+ // pairing this reads.
229
+ //
230
+ // Only teardown calls this. A region's record stays exactly what it produced,
231
+ // because that is also what decides whether anything changed and what gets
232
+ // repositioned on a re-render, and an inner effect's nodes are its own business
233
+ // in both of those.
234
+ //
235
+ // The walk appends the live range to the record rather than replacing it, so a
236
+ // node still standing is named twice. That is deliberate: every caller already
237
+ // skips a node whose parent is not the one it is clearing, so the second visit
238
+ // costs a pointer compare, and both disposal routes are idempotent by design.
239
+ // Deduplicating would mean a Set on a path that runs for every teardown, to
240
+ // prevent nothing.
241
+ export function _liveRegionNodes(tracked) {
242
+ /** @type {any[] | null} */
243
+ let out = null;
244
+ for (const node of tracked) {
245
+ const end = /** @type {any} */ (node)._rangeEnd;
246
+ // Not a range start, or a range whose two ends have been separated by an
247
+ // earlier teardown: walking that would run past where the range closed and
248
+ // sweep up whatever comes after it. Two orphaned markers share a null parent
249
+ // and pass this test, but an orphan has no nextSibling, so the walk below is
250
+ // empty and the guard does not need to say so a second time.
251
+ if (!end || end.parentNode !== node.parentNode) continue;
252
+ if (!out) out = tracked.slice();
253
+ const buf = /** @type {any[]} */ (out);
254
+ for (let n = node.nextSibling; n; n = n.nextSibling) {
255
+ buf.push(n);
256
+ if (n === end) break;
257
+ }
258
+ }
259
+ // No range in the record — which is the overwhelmingly common case — means no
260
+ // allocation and the caller iterates exactly what it passed in.
261
+ return out || tracked;
262
+ }
263
+
201
264
  // Mount a component tree into a DOM container
202
265
  export function mount(vnode, container) {
203
266
  if (typeof container === 'string') {
@@ -239,11 +302,32 @@ export function createDOM(vnode, parent, isSvg) {
239
302
  // function branch below would call vnode() with no parent and throw.
240
303
  if (typeof vnode === 'function' && vnode._mapArray) {
241
304
  const frag = document.createDocumentFragment();
242
- const endMarker = document.createComment('/list-frag');
243
- frag.appendChild(endMarker);
244
- // The inserter inserts its content before `endMarker` within `frag`; once
245
- // `frag` is appended to the real DOM the nodes (and endMarker) carry over.
246
- vnode(frag, endMarker);
305
+ // Open the list with a start marker, the same bracket shape the reactive
306
+ // region below uses, and for the same reason: whoever embeds this fragment
307
+ // has to be able to find the list's content LATER, not just now.
308
+ //
309
+ // A list reached through here is embedded in some other region's value —
310
+ // `{cond && <>{items.map(...)}<p/></>}` and every variation of it. That
311
+ // region records the nodes it inserted and reuses the record as its removal
312
+ // set on the next run. The record is a snapshot, and the list is not
313
+ // snapshot-shaped: it owns an effect and goes on inserting and removing rows
314
+ // for as long as it is mounted. Rows appended after mount were absent from
315
+ // the record, so switching the region off removed the mount-time rows and
316
+ // orphaned the rest — and switching it back on rendered a second, complete
317
+ // list beside the orphans.
318
+ //
319
+ // Two markers describe a moving target that a list of nodes cannot:
320
+ // everything the list ever inserts lands between them, so reconcileInsert
321
+ // (render.js) can sweep the live range at teardown instead of trusting the
322
+ // snapshot. `_rangeEnd` is the pairing it reads.
323
+ const startMarker = document.createComment('list');
324
+ frag.appendChild(startMarker);
325
+ // Passing a null marker makes the inserter APPEND its own `/list` end marker
326
+ // to `frag` (insertBefore(node, null) is an append) and insert every row
327
+ // before it, so the list closes the range itself and no second bookend is
328
+ // needed. Once `frag` is appended to the real DOM the markers and the rows
329
+ // between them carry over together.
330
+ /** @type {any} */ (startMarker)._rangeEnd = vnode(frag, null);
247
331
  return frag;
248
332
  }
249
333
 
@@ -296,8 +380,11 @@ export function createDOM(vnode, parent, isSvg) {
296
380
  const realParent = endMarker.parentNode;
297
381
  if (!realParent) return; // not mounted yet — first run handled below
298
382
 
299
- // Remove old nodes between markers
300
- for (const old of currentNodes) {
383
+ // Remove old nodes between markers. The removal set is the LIVE one: a
384
+ // list or a nested region inside this one has been replacing its own
385
+ // nodes since they were recorded, and what it produced late is just as
386
+ // much this region's to remove as what it produced at mount.
387
+ for (const old of _liveRegionNodes(currentNodes)) {
301
388
  disposeTree(old);
302
389
  if (old.parentNode === realParent) realParent.removeChild(old);
303
390
  }
@@ -327,6 +414,11 @@ export function createDOM(vnode, parent, isSvg) {
327
414
  startMarker._dispose = dispose;
328
415
  // Also store dispose on endMarker so disposeTree can find it from either marker
329
416
  endMarker._dispose = dispose;
417
+ // This region manages itself, so an OUTER region that embeds it cannot
418
+ // describe it with a list of nodes: everything between the markers is
419
+ // replaced whenever this effect re-runs. Pairing them lets the outer
420
+ // teardown sweep the live range. See _liveRegionNodes.
421
+ /** @type {any} */ (startMarker)._rangeEnd = endMarker;
330
422
  return frag;
331
423
  }
332
424
 
@@ -1075,24 +1167,26 @@ function setProp(el, key, value, isSvg) {
1075
1167
  // aria-*/role BEFORE the boolean fast-path: these are enumerated string
1076
1168
  // attributes, so a boolean has to serialize as "true"/"false", never as HTML
1077
1169
  // boolean syntax. See _isAriaAttr.
1078
- if (_isAriaAttr(key)) {
1170
+ // data-* joins aria-* here rather than falling through to the boolean branch
1171
+ // below. Both are enumerated: `data-open="false"` is a distinct state from an
1172
+ // absent `data-open`, and `[data-open="false"]` is an ordinary CSS selector,
1173
+ // so collapsing false to "remove the attribute" throws information away.
1174
+ // The compiled path in render.js has always stringified these, so keeping the
1175
+ // generic boolean branch first is also what made an SSR page disagree with
1176
+ // its own compiled client on hydration.
1177
+ if (_isAriaAttr(key) || key.startsWith('data-')) {
1079
1178
  el.setAttribute(key, typeof value === 'boolean' ? String(value) : value);
1080
1179
  return;
1081
1180
  }
1082
1181
 
1083
- // Boolean attributes
1182
+ // Boolean attributes. A genuine HTML boolean like `disabled` is present or
1183
+ // absent; there is no `disabled="false"`.
1084
1184
  if (typeof value === 'boolean') {
1085
1185
  if (value) el.setAttribute(key, '');
1086
1186
  else el.removeAttribute(key);
1087
1187
  return;
1088
1188
  }
1089
1189
 
1090
- // data-*
1091
- if (key.startsWith('data-')) {
1092
- el.setAttribute(key, value);
1093
- return;
1094
- }
1095
-
1096
1190
  // SVG
1097
1191
  if (isSvg) {
1098
1192
  if (value === false || value == null) {
package/src/errors.js CHANGED
@@ -245,6 +245,25 @@ function Row() { return { name: 'a' }; }
245
245
  function Row({ name }) { return <li>{name}</li>; }`,
246
246
  },
247
247
 
248
+ COMPILED_JSX_IN_SSR: {
249
+ code: 'ERR_COMPILED_JSX_IN_SSR',
250
+ severity: 'error',
251
+ template: 'what-compiler output cannot be server-rendered: {{file}}.',
252
+ suggestion: 'what-compiler lowers JSX to module-scope _$template() calls that run document.createElement() at import time, and to _$createComponent() which builds DOM. Neither has a server-rendered form, so a module it compiled throws "document is not defined" when a server imports it. Server-rendered views have two supported spellings: author them with h(), or compile them with the automatic JSX runtime (jsxImportSource: "what-framework"), which emits h() calls that renderToString understands. what-compiler stays on the client entry, where the fine-grained output is the point.',
253
+ codeExample: `// Bad — a server module compiled by what-compiler:
254
+ // vite.config.js: plugins: [what()] + vite build --ssr
255
+ export function Page() { return <h1>Hi</h1>; } // throws on import
256
+
257
+ // Good — h(), which renderToString understands:
258
+ import { h } from 'what-framework';
259
+ export function Page() { return h('h1', null, 'Hi'); }
260
+
261
+ // Good — the automatic JSX runtime for the server build:
262
+ // vite.config.js (server): esbuild: { jsx: 'automatic',
263
+ // jsxImportSource: 'what-framework' }
264
+ export function Page() { return <h1>Hi</h1>; } // lowers to h()`,
265
+ },
266
+
248
267
  FORM_ACTION_NOT_REGISTERED: {
249
268
  code: 'ERR_FORM_ACTION_NOT_REGISTERED',
250
269
  severity: 'error',
@@ -450,6 +469,7 @@ createWhatError('MISSING_KEY', { component: 'TodoList' });`,
450
469
  // into every bundle that imports what-core: it took the counter app from
451
470
  // 6.4 KB gzipped to 12.1 KB and tripped check:size. Inside a function, a
452
471
  // bundler that drops getErrorDefinition drops the catalogue with it.
472
+ /** @type {Map<string, any> | null} */
453
473
  let _codeIndex = null;
454
474
 
455
475
  /** Look up a catalogue entry by its `ERR_*` code. Returns undefined if unknown. */
package/src/guardrails.js CHANGED
@@ -161,6 +161,7 @@ export function validateImports(importNames) {
161
161
  // Simple Levenshtein-based closest match
162
162
  function findClosest(input) {
163
163
  const lower = input.toLowerCase();
164
+ /** @type {string | null} */
164
165
  let best = null;
165
166
  let bestDist = Infinity;
166
167
 
package/src/h.js CHANGED
@@ -8,6 +8,13 @@
8
8
  const EMPTY_OBJ = Object.create(null);
9
9
  const EMPTY_ARR = [];
10
10
 
11
+ /**
12
+ * Children are collected from `arguments` rather than a rest param so a
13
+ * 0-1 child call does not allocate. `@type` (not `@param`) is what TS 7
14
+ * uses to type that hidden tail; do not turn it into `...children`.
15
+ *
16
+ * @type {(tag: any, props?: any, ...children: any[]) => any}
17
+ */
11
18
  export function h(tag, props) {
12
19
  props = props || EMPTY_OBJ;
13
20
  // Collect children from arguments[2..n] without rest args — avoids array allocation
package/src/helpers.js CHANGED
@@ -76,6 +76,7 @@ export function throttle(fn, ms) {
76
76
  }
77
77
 
78
78
  // Component context ref — injected by dom.js to avoid circular imports
79
+ /** @type {(() => any) | null} */
79
80
  let _getCurrentComponentRef = null;
80
81
  export function _setComponentRef(fn) { _getCurrentComponentRef = fn; }
81
82
 
@@ -121,6 +122,7 @@ export function useLocalStorage(key, initial) {
121
122
  });
122
123
 
123
124
  // Listen for changes from other tabs
125
+ /** @type {((e: StorageEvent) => void) | null} */
124
126
  let storageHandler = null;
125
127
  if (typeof window !== 'undefined') {
126
128
  storageHandler = (e) => {
package/src/hooks.js CHANGED
@@ -400,6 +400,7 @@ export function createResource(fetcher, options = {}) {
400
400
  const loading = signal(seeded == null);
401
401
  const error = signal(null);
402
402
 
403
+ /** @type {AbortController | null} */
403
404
  let controller = null;
404
405
 
405
406
  const refetch = async (source) => {
package/src/reactive.js CHANGED
@@ -38,8 +38,11 @@ export function __setDevToolsHooks(hooks) {
38
38
  if (__DEV__) __devtools = hooks;
39
39
  }
40
40
 
41
+ /** @type {WhatEffectNode | null} */
41
42
  let currentEffect = null;
43
+ /** @type {WhatOwner | null} */
42
44
  let currentRoot = null;
45
+ /** @type {WhatOwner | null} */
43
46
  let currentOwner = null; // Ownership tree: tracks current owner context
44
47
  let insideComputed = false; // Track whether we're inside a computed() callback (dev-mode warning)
45
48
  let batchDepth = 0;
@@ -55,6 +58,7 @@ let pendingNeedSort = false; // Track whether pendingEffects actually needs sor
55
58
  // to iterative. When a computed fn() reads another dirty computed, instead
56
59
  // of recursing, we throw a sentinel that gets caught by the outer loop.
57
60
  const NEEDS_UPSTREAM = Symbol('needs_upstream');
61
+ /** @type {any[] | null} */
58
62
  let iterativeEvalStack = null; // array when inside evaluation loop, null otherwise
59
63
 
60
64
  // --- Signal ---
@@ -75,6 +79,7 @@ export function signal(initial, debugName) {
75
79
  // Track the last effect that subscribed — skip redundant tracking when the
76
80
  // same effect reads this signal multiple times (common in template bindings).
77
81
  // lastTrackedEpoch tracks the effect's cleanup epoch to detect stale caches.
82
+ /** @type {WhatEffectNode | null} */
78
83
  let lastTracked = null;
79
84
  let lastTrackedEpoch = 0;
80
85
 
@@ -161,6 +166,7 @@ export function signal(initial, debugName) {
161
166
  export function computed(fn) {
162
167
  let value, dirty = true;
163
168
  const subs = new Set();
169
+ /** @type {WhatEffectNode | null} */
164
170
  let lastTracked = null;
165
171
  let lastTrackedEpoch = 0;
166
172
 
@@ -187,7 +193,14 @@ export function computed(fn) {
187
193
  inner._markDirty = () => { dirty = true; };
188
194
  inner._isDirty = () => dirty;
189
195
 
196
+ if (currentRoot) {
197
+ currentRoot.disposals.push(() => _disposeEffect(inner));
198
+ }
199
+
190
200
  function read() {
201
+ // Like an owned memo, a disposed computed retains its last value but must
202
+ // never recreate subscriptions (or enter the dirty-evaluation trampoline).
203
+ if (inner.disposed) return value;
191
204
  const ce = currentEffect;
192
205
  if (ce !== null) {
193
206
  if (ce !== lastTracked || ce._epoch !== lastTrackedEpoch) {
@@ -210,7 +223,7 @@ export function computed(fn) {
210
223
 
211
224
  read._signal = true;
212
225
  read.peek = () => {
213
- if (dirty) _evaluateComputed(inner);
226
+ if (dirty && !inner.disposed) _evaluateComputed(inner);
214
227
  return value;
215
228
  };
216
229
 
@@ -247,7 +260,7 @@ function _evaluateComputed(computedEffect) {
247
260
  while (stack.length > 0) {
248
261
  const current = stack[stack.length - 1];
249
262
 
250
- if (!current._isDirty || !current._isDirty()) {
263
+ if (current.disposed || !current._isDirty || !current._isDirty()) {
251
264
  // Already clean — pop and continue
252
265
  stack.pop();
253
266
  continue;
@@ -260,7 +273,7 @@ function _evaluateComputed(computedEffect) {
260
273
  const deps = current.deps;
261
274
  for (let i = 0; i < deps.length; i++) {
262
275
  const depOwner = deps[i]._owner;
263
- if (depOwner && depOwner._computed && depOwner._isDirty && depOwner._isDirty()) {
276
+ if (depOwner && !depOwner.disposed && depOwner._computed && depOwner._isDirty && depOwner._isDirty()) {
264
277
  stack.push(depOwner);
265
278
  pushedUpstream = true;
266
279
  }
@@ -325,6 +338,11 @@ export function effect(fn, opts) {
325
338
  try {
326
339
  const result = e.fn();
327
340
  if (typeof result === 'function') e._cleanup = result;
341
+ } catch (err) {
342
+ // No disposer is returned when setup throws: undo the partial subscription
343
+ // graph now, including an update queued during the failed first run.
344
+ _disposeEffect(e);
345
+ throw err;
328
346
  } finally {
329
347
  currentEffect = prev;
330
348
  }
@@ -406,7 +424,7 @@ function _runEffect(e) {
406
424
 
407
425
  // Stable effect fast path: deps don't change, skip cleanup/re-subscribe.
408
426
  // This is critical for performance: effects like `() => el.className = sig() ? 'a' : ''`
409
- // always read the same signal(s). After auto-promotion, re-runs skip the O(deps)
427
+ // always read the same signal(s). With explicit opt-in, re-runs skip the O(deps)
410
428
  // cleanup + re-subscribe cycle entirely.
411
429
  if (e._stable) {
412
430
  if (e._cleanup) {
@@ -430,10 +448,6 @@ function _runEffect(e) {
430
448
  return;
431
449
  }
432
450
 
433
- // Save the single dep for auto-stable detection (safe: 1-dep effects
434
- // have deterministic dep sets — no conditional reads possible).
435
- const singleDep = e.deps.length === 1 ? e.deps[0] : null;
436
-
437
451
  cleanup(e);
438
452
  // Run effect cleanup from previous run
439
453
  if (e._cleanup) {
@@ -459,16 +473,9 @@ function _runEffect(e) {
459
473
  currentEffect = prev;
460
474
  }
461
475
 
462
- // Auto-promote to stable: effects with exactly 1 dep that remains the same
463
- // after re-run have a fixed dependency graph. Skip cleanup/re-subscribe
464
- // on future re-runs. This is safe because a single-dep effect can't have
465
- // conditional signal reads that change which signal is tracked.
466
- // Guard: don't promote self-triggering effects (those that write to the signal
467
- // they read, causing re-queuing). Check e._pending to detect this.
468
- if (singleDep !== null && e.deps.length === 1 && e.deps[0] === singleDep
469
- && !e._cleanup && !e._pending) {
470
- e._stable = true;
471
- }
476
+ // Repeatedly observing one dependency does not prove a stable graph: that
477
+ // signal may open a branch on a later run. Only explicit { stable: true }
478
+ // effects may skip dependency tracking.
472
479
 
473
480
  if (__DEV__ && __devtools?.onEffectRun) __devtools.onEffectRun?.(e);
474
481
  }
@@ -502,6 +509,7 @@ function cleanup(e) {
502
509
  // call notify() recursively. The queue drains iteratively in the outermost call.
503
510
 
504
511
  let notifyDepth = 0; // Tracks recursive notify depth
512
+ /** @type {(Set<any> | null)[] | null} */
505
513
  let notifyQueue = null; // Reusable queue, allocated on first recursive call
506
514
  let notifyQueueLen = 0; // Length of the queue
507
515
 
@@ -552,13 +560,15 @@ function notify(subs) {
552
560
  _processSubscriber(e);
553
561
  }
554
562
  // Drain any queued subscriber sets from recursive notify calls
555
- if (notifyQueueLen > 0) {
563
+ if (notifyQueueLen > 0 && notifyQueue) {
556
564
  let qi = 0;
565
+ const q = notifyQueue;
557
566
  while (qi < notifyQueueLen) {
558
- const queuedSubs = notifyQueue[qi];
559
- notifyQueue[qi] = null; // Allow GC
567
+ const queuedSubs = q[qi];
568
+ q[qi] = null; // Allow GC
560
569
  qi++;
561
- for (const e of queuedSubs) {
570
+ // Slots 0..len-1 are Sets; null is only written after a slot is drained.
571
+ for (const e of /** @type {Set<any>} */ (queuedSubs)) {
562
572
  _processSubscriber(e);
563
573
  }
564
574
  }
@@ -701,7 +711,12 @@ export function memo(fn) {
701
711
 
702
712
  e._level = 1;
703
713
 
704
- _runEffect(e);
714
+ try {
715
+ _runEffect(e);
716
+ } catch (err) {
717
+ _disposeEffect(e);
718
+ throw err;
719
+ }
705
720
  _updateLevel(e);
706
721
 
707
722
  // Register subscriber set owner for level tracking
@@ -797,6 +812,7 @@ export function runWithOwner(owner, fn) {
797
812
  export function createRoot(fn) {
798
813
  const prevRoot = currentRoot;
799
814
  const prevOwner = currentOwner;
815
+ /** @type {WhatOwner} */
800
816
  const root = {
801
817
  /** @type {Array<() => void>} */
802
818
  disposals: [],
@@ -814,28 +830,7 @@ export function createRoot(fn) {
814
830
  currentOwner = root;
815
831
 
816
832
  try {
817
- const dispose = () => {
818
- if (root._disposed) return;
819
- root._disposed = true;
820
-
821
- // Dispose children first (depth-first, reverse order)
822
- for (let i = root.children.length - 1; i >= 0; i--) {
823
- _disposeRoot(root.children[i]);
824
- }
825
- root.children.length = 0;
826
-
827
- // Dispose own effects (reverse order for LIFO cleanup)
828
- for (let i = root.disposals.length - 1; i >= 0; i--) {
829
- root.disposals[i]();
830
- }
831
- root.disposals.length = 0;
832
-
833
- // Remove from parent's children list
834
- if (root.owner) {
835
- const idx = root.owner.children.indexOf(root);
836
- if (idx >= 0) root.owner.children.splice(idx, 1);
837
- }
838
- };
833
+ const dispose = () => _disposeRoot(root);
839
834
  return fn(dispose);
840
835
  } finally {
841
836
  currentRoot = prevRoot;
@@ -847,18 +842,35 @@ export function createRoot(fn) {
847
842
  function _disposeRoot(root) {
848
843
  if (root._disposed) return;
849
844
  root._disposed = true;
850
-
851
- // Dispose children first
852
- for (let i = root.children.length - 1; i >= 0; i--) {
853
- _disposeRoot(root.children[i]);
845
+ let failed = false;
846
+ let firstError;
847
+
848
+ // A user cleanup failure must not strand siblings or earlier effects after
849
+ // the scope has already become disposed. Finish LIFO cleanup, then rethrow
850
+ // the first error so callers keep the original useful failure.
851
+ while (root.children.length > 0) {
852
+ // Remove before calling user code: a child may dispose another sibling,
853
+ // mutating this same array. Numeric indexes would then skip or overrun it.
854
+ const child = root.children.pop();
855
+ try { _disposeRoot(child); } catch (err) {
856
+ if (!failed) { failed = true; firstError = err; }
857
+ }
854
858
  }
855
859
  root.children.length = 0;
856
860
 
857
861
  // Dispose own effects
858
862
  for (let i = root.disposals.length - 1; i >= 0; i--) {
859
- root.disposals[i]();
863
+ try { root.disposals[i](); } catch (err) {
864
+ if (!failed) { failed = true; firstError = err; }
865
+ }
860
866
  }
861
867
  root.disposals.length = 0;
868
+
869
+ if (root.owner) {
870
+ const idx = root.owner.children.indexOf(root);
871
+ if (idx >= 0) root.owner.children.splice(idx, 1);
872
+ }
873
+ if (failed) throw firstError;
862
874
  }
863
875
 
864
876
  // --- _createItemScope ---
@@ -868,6 +880,7 @@ function _disposeRoot(root) {
868
880
  export function _createItemScope(fn) {
869
881
  const prevRoot = currentRoot;
870
882
  const prevOwner = currentOwner;
883
+ /** @type {WhatOwner} */
871
884
  const scope = {
872
885
  /** @type {Array<() => void>} */
873
886
  disposals: [],
@@ -880,20 +893,7 @@ export function _createItemScope(fn) {
880
893
  currentOwner = scope;
881
894
 
882
895
  try {
883
- const dispose = () => {
884
- if (scope._disposed) return;
885
- scope._disposed = true;
886
- // Dispose children
887
- for (let i = scope.children.length - 1; i >= 0; i--) {
888
- _disposeRoot(scope.children[i]);
889
- }
890
- scope.children.length = 0;
891
- // Dispose own effects
892
- for (let i = scope.disposals.length - 1; i >= 0; i--) {
893
- scope.disposals[i]();
894
- }
895
- scope.disposals.length = 0;
896
- };
896
+ const dispose = () => _disposeRoot(scope);
897
897
  return fn(dispose);
898
898
  } finally {
899
899
  currentRoot = prevRoot;