what-core 0.13.4 → 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/dist/chunk-XRUIKSAO.min.js +11 -0
- package/dist/chunk-ZDIHSTQJ.min.js +1 -0
- package/dist/index.min.js +17 -6
- package/dist/jsx-dev-runtime.min.js +1 -1
- package/dist/jsx-runtime.min.js +1 -1
- package/dist/render.min.js +1 -1
- package/dist/testing.min.js +1 -1
- package/package.json +1 -1
- package/render.d.ts +7 -0
- package/src/a11y.js +4 -1
- package/src/agent-context.js +1 -1
- package/src/animation.js +8 -1
- package/src/components.js +2 -0
- package/src/data.js +4 -0
- package/src/dom.js +109 -15
- package/src/errors.js +20 -0
- package/src/guardrails.js +1 -0
- package/src/h.js +7 -0
- package/src/helpers.js +2 -0
- package/src/hooks.js +1 -0
- package/src/reactive.js +64 -64
- package/src/render.js +206 -28
- package/src/scheduler.js +4 -2
- package/src/server-context.js +2 -0
- package/src/testing.js +33 -2
- package/dist/chunk-DZTPU2YD.min.js +0 -11
- package/dist/chunk-OKM3GKVP.min.js +0 -1
- /package/dist/{chunk-O3SKPRTY.min.js → chunk-6YOUZ6T4.min.js} +0 -0
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
|
|
package/src/agent-context.js
CHANGED
|
@@ -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.
|
|
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 -
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
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).
|
|
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
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
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 =
|
|
559
|
-
|
|
567
|
+
const queuedSubs = q[qi];
|
|
568
|
+
q[qi] = null; // Allow GC
|
|
560
569
|
qi++;
|
|
561
|
-
|
|
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
|
-
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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;
|