what-core 0.12.4 → 0.13.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.
package/src/errors.js CHANGED
@@ -157,8 +157,294 @@ redirect(ALLOWED.has(query.next) ? query.next : '/');`,
157
157
  try { html = renderToString(<App />); }
158
158
  catch (e) { if (e.name === 'RouterRedirect') return Response.redirect(e.to, 302); throw e; }`,
159
159
  },
160
+
161
+ // --- Outside core ---
162
+ //
163
+ // Every package in the workspace threw bare `new Error(...)` with good prose
164
+ // and no code, so nothing downstream could branch on the failure and the
165
+ // what_errors MCP tool could only ever enumerate core's own. The entries
166
+ // below are the catalogue for the rest of the framework.
167
+ //
168
+ // These are catalogued here but NOT constructed here. A throw outside core
169
+ // carries only its `code`; the suggestion and the worked example live once,
170
+ // in this file, and are resolved from the code by classifyError() or by
171
+ // reading ERROR_CODES directly.
172
+ //
173
+ // That split is a size decision, and it was measured. Importing
174
+ // createWhatError() into what-server's client-shipped action surface retains
175
+ // this whole catalogue through the bundler: esbuild took that surface from
176
+ // 8.4 KB minified / 3.5 KB gzipped to 25.9 KB / 9.7 KB. Six kilobytes of
177
+ // gzipped prose in every visitor's browser is the wrong trade for making
178
+ // three server-side messages structured.
179
+ //
180
+ // The same rule keeps the packages that genuinely cannot import core honest:
181
+ // what-isr never imports what-server, which is what keeps it usable from any
182
+ // adapter; the compiler runs inside Babel at build time; the MCP server has
183
+ // no framework dependency; and the CLI loads the project's runtime, not its
184
+ // own. `scripts/check-error-codes.mjs` asserts every `ERR_*` literal thrown
185
+ // anywhere under packages/*/src appears here, so nothing drifts.
186
+
187
+ NO_SECURE_RANDOM: {
188
+ code: 'ERR_NO_SECURE_RANDOM',
189
+ severity: 'error',
190
+ template: '[what] No secure random source available for CSRF token generation.',
191
+ suggestion: 'Neither globalThis.crypto.getRandomValues nor node:crypto was reachable. On Node this means a build older than 18 or a bundler that stripped node:crypto; on an edge runtime it means the Web Crypto global was not provided. A CSRF token from Math.random is not a token, so this refuses rather than degrading.',
192
+ codeExample: `// Node 18+ exposes Web Crypto globally; nothing to configure.
193
+ // If a bundler dropped it, restore the global before creating the server:
194
+ import { webcrypto } from 'node:crypto';
195
+ globalThis.crypto ??= webcrypto;`,
196
+ },
197
+
198
+ ACTION_FAILED: {
199
+ code: 'ERR_ACTION_FAILED',
200
+ severity: 'error',
201
+ template: '{{message}}',
202
+ suggestion: 'The server action rejected. The message is the one the action threw, forwarded to the client by the action handler. Throw a typed error from the action and branch on its shape rather than parsing this string.',
203
+ codeExample: `// In the action, fail with something the client can read:
204
+ export const save = action(async (data) => {
205
+ if (!data.email) throw Object.assign(new Error('Email required'), { field: 'email' });
206
+ });`,
207
+ },
208
+
209
+ STATIC_WRITE_ESCAPE: {
210
+ code: 'ERR_STATIC_WRITE_ESCAPE',
211
+ severity: 'error',
212
+ template: '[what-server] Refusing to write outside outDir: {{path}}.',
213
+ suggestion: 'A route path resolved to a location outside the export directory, which a "../" segment in a route or a param can do. Sanitize the route path, or drop the route from the static export.',
214
+ codeExample: `// Bad — a param that can contain a slash escapes outDir:
215
+ { path: '/docs/:slug*', mode: 'static' }
216
+
217
+ // Good — constrain the param, or precompute the exact paths:
218
+ { path: '/docs/:slug', mode: 'static', paths: () => slugs.map(slug => ({ slug })) }`,
219
+ },
220
+
221
+ INVALID_SSR_TAG: {
222
+ code: 'ERR_INVALID_SSR_TAG',
223
+ severity: 'error',
224
+ template: '[what-server] Invalid tag name in SSR: {{tag}}.',
225
+ suggestion: 'renderToString reached a vnode whose tag is neither a string nor a component function. This is almost always a component that returned a raw object, or a value interpolated where an element was expected.',
226
+ codeExample: `// Bad — returns a plain object, not a vnode:
227
+ function Row() { return { name: 'a' }; }
228
+
229
+ // Good — return elements, and interpolate values as children:
230
+ function Row({ name }) { return <li>{name}</li>; }`,
231
+ },
232
+
233
+ FORM_ACTION_NOT_REGISTERED: {
234
+ code: 'ERR_FORM_ACTION_NOT_REGISTERED',
235
+ severity: 'error',
236
+ template: '[what] <Form action={fn}>: that function is not a server action.',
237
+ suggestion: 'Wrap it with action() from what-server, or pass the action id as a string. A plain function has no id, so there is nothing for the form post to address.',
238
+ codeExample: `// Bad — a plain function:
239
+ async function save(data) {}
240
+ <Form action={save} />
241
+
242
+ // Good — a registered action:
243
+ export const save = action(async (data) => {});
244
+ <Form action={save} />`,
245
+ },
246
+
247
+ FORM_ACTION_MISSING: {
248
+ code: 'ERR_FORM_ACTION_MISSING',
249
+ severity: 'error',
250
+ template: '[what] <Form> requires an `action` prop: a server action or its id.',
251
+ suggestion: 'Pass the action itself, or the string id it was registered under.',
252
+ codeExample: `// Bad:
253
+ <Form method="post" />
254
+
255
+ // Good:
256
+ <Form action={save} />
257
+ <Form action="save-user" />`,
258
+ },
259
+
260
+ ISLAND_STORE_OUTSIDE_RENDER: {
261
+ code: 'ERR_ISLAND_STORE_OUTSIDE_RENDER',
262
+ severity: 'error',
263
+ template: '[what-server] Island store "{{name}}" was accessed outside an active server render.',
264
+ suggestion: 'A module-scoped island store resolves against the current request, so it can only be read or written from a component rendered by renderDocument/renderPage. Reading one at module scope, or from a background task, has no request to bind to.',
265
+ codeExample: `// Bad — runs at import time, with no request in scope:
266
+ const count = cart.items.length;
267
+
268
+ // Good — read it inside a component the server is rendering:
269
+ function Cart() { return <span>{cart.items.length}</span>; }`,
270
+ },
271
+
272
+ ISR_MISSING_CLIENT: {
273
+ code: 'ERR_ISR_MISSING_CLIENT',
274
+ severity: 'error',
275
+ template: '[what-isr] createRedisStore requires { client }.',
276
+ suggestion: 'what-isr ships no Redis driver on purpose, so the client is injected. Pass an ioredis or node-redis instance (get/set/del/sadd/srem/smembers, optionally expire/scan/keys).',
277
+ codeExample: `import Redis from 'ioredis';
278
+ const store = createRedisStore({ client: new Redis(process.env.REDIS_URL) });`,
279
+ },
280
+
281
+ ISR_VARY_UNRESOLVED: {
282
+ code: 'ERR_ISR_VARY_UNRESOLVED',
283
+ severity: 'error',
284
+ template: '[what-isr] cannot build a cache key: `vary` is declared but could not be resolved against the request.',
285
+ suggestion: 'A declared vary is a list of names that must be resolved against real request headers before it can be part of a key. Either pass the request headers alongside the declaration, or pass an already-resolved name -> value object. Guessing would cache one visitor page under another visitor key.',
286
+ codeExample: `// Bad — a declaration with nothing to resolve it against:
287
+ cacheKey({ path, vary: ['cookie:session'] });
288
+
289
+ // Good — supply the headers:
290
+ cacheKey({ path, vary: ['cookie:session'], headers: request.headers });
291
+
292
+ // Good — or resolve it yourself:
293
+ cacheKey({ path, vary: { 'cookie:session': sessionId } });`,
294
+ },
295
+
296
+ ISR_VARY_NO_HEADERS: {
297
+ code: 'ERR_ISR_VARY_NO_HEADERS',
298
+ severity: 'error',
299
+ template: '[what-isr] route declares `vary` but the adapter supplied no request headers; refusing to cache.',
300
+ suggestion: 'The route varies its output per header, and the adapter called the engine without them. Caching anyway would serve one variant to every request. Forward the request headers from the adapter into the engine call.',
301
+ codeExample: `// In the adapter:
302
+ await engine.handle(routeMatch, { headers: request.headers });`,
303
+ },
304
+
305
+ DUPLICATE_ACTION_ID: {
306
+ code: 'ERR_DUPLICATE_ACTION_ID',
307
+ severity: 'error',
308
+ template: 'Duplicate server action ID "{{id}}".',
309
+ suggestion: 'Action ids are the wire address of a server action, so two actions cannot share one. Ids derive from the file path and export name, so this usually means the same action is being registered twice, or an explicit id was reused.',
310
+ codeExample: `// Bad — two actions pinned to the same id:
311
+ export const save = action(fn, { id: 'save' });
312
+ export const store = action(fn, { id: 'save' });
313
+
314
+ // Good — let ids derive, or make them distinct:
315
+ export const save = action(fn);
316
+ export const store = action(fn, { id: 'store-user' });`,
317
+ },
318
+
319
+ PAGE_NO_DEFAULT_EXPORT: {
320
+ code: 'ERR_PAGE_NO_DEFAULT_EXPORT',
321
+ severity: 'error',
322
+ template: 'Page module has no default-exported component.',
323
+ suggestion: 'A page file must default-export the component to render. A named export cannot be found by the file-router.',
324
+ codeExample: `// Bad:
325
+ export function Home() { return <h1>Hi</h1>; }
326
+
327
+ // Good:
328
+ export default function Home() { return <h1>Hi</h1>; }`,
329
+ },
330
+
331
+ HOOK_OUTSIDE_RENDER: {
332
+ code: 'ERR_HOOK_OUTSIDE_RENDER',
333
+ severity: 'error',
334
+ template: '[what-react] {{hookName}}() called outside of a component render.',
335
+ suggestion: 'Hooks can only be called while a what-react component is rendering. When this happens inside a React library, the usual cause is two module instances: make sure every `react` and `react-dom` import is aliased to what-react by the reactCompat() vite plugin.',
336
+ codeExample: `// vite.config.js
337
+ import { reactCompat } from 'what-react/vite';
338
+ export default { plugins: [reactCompat()] };`,
339
+ },
340
+
341
+ CHILDREN_ONLY: {
342
+ code: 'ERR_CHILDREN_ONLY',
343
+ severity: 'error',
344
+ template: 'React.Children.only expected to receive a single React element child.',
345
+ suggestion: 'Children.only asserts exactly one element. Pass one child, or use Children.toArray/Children.map when the count can vary.',
346
+ codeExample: `// Bad:
347
+ <Tooltip><span>a</span><span>b</span></Tooltip>
348
+
349
+ // Good:
350
+ <Tooltip><span>a</span></Tooltip>`,
351
+ },
352
+
353
+ USE_INVALID_ARG: {
354
+ code: 'ERR_USE_INVALID_ARG',
355
+ severity: 'error',
356
+ template: '[what-react] use() expects a promise or a context.',
357
+ suggestion: 'use() reads either a thenable or a context object. Anything else has nothing to suspend on or subscribe to.',
358
+ codeExample: `// Good:
359
+ const value = use(ThemeContext);
360
+ const data = use(fetchUser(id));`,
361
+ },
362
+
363
+ PRETEXT_NOT_INSTALLED: {
364
+ code: 'ERR_PRETEXT_NOT_INSTALLED',
365
+ severity: 'error',
366
+ template: '[what-text] Failed to load @chenglou/pretext: {{message}}.',
367
+ suggestion: 'what-text declares pretext as an optional peer so the package installs without it. Install it to use the text engine: npm install @chenglou/pretext',
368
+ codeExample: `npm install @chenglou/pretext`,
369
+ },
370
+
371
+ DESTRUCTURED_PROPS: {
372
+ code: 'ERR_DESTRUCTURED_PROPS',
373
+ severity: 'warning',
374
+ template: "Destructuring '{{binding}}' in the component body snapshots props and loses reactivity.",
375
+ suggestion: "What components run ONCE, so the body is not re-run when a prop changes. Reading `props.foo` goes through the reactive proxy and tracks; `const { foo } = props` reads the value once and detaches it. Read through the proxy inside JSX and effects, or wrap each field in an accessor.",
376
+ codeExample: `// Bad - snapshots at first run, never updates:
377
+ function Row(props) {
378
+ const { label } = props;
379
+ return <span>{label}</span>;
380
+ }
381
+
382
+ // Good - reads through the proxy each time:
383
+ function Row(props) {
384
+ return <span>{props.label}</span>;
385
+ }
386
+
387
+ // Good - an accessor keeps the destructured name:
388
+ function Row(props) {
389
+ const label = () => props.label;
390
+ return <span>{label()}</span>;
391
+ }`,
392
+ },
393
+
394
+ // --- Fallbacks ---
395
+ // classifyError() returns one of these when a raw Error does not match any
396
+ // known pattern. They are catalogued so what_errors never reports a code an
397
+ // agent cannot look up.
398
+
399
+ RUNTIME: {
400
+ code: 'ERR_RUNTIME',
401
+ severity: 'error',
402
+ template: '{{message}}',
403
+ suggestion: 'An error surfaced that the framework could not classify, so the message is the original one verbatim. The stack, file and line on the error narrow it; if the same shape shows up repeatedly it is worth its own code here.',
404
+ codeExample: `// Inspect the structured form rather than the string:
405
+ try { render(); } catch (e) { console.log(classifyError(e).toJSON()); }`,
406
+ },
407
+
408
+ UNKNOWN: {
409
+ code: 'ERR_UNKNOWN',
410
+ severity: 'error',
411
+ template: 'Unknown error: {{errorCode}}.',
412
+ suggestion: 'createWhatError() was called with a code that is not in this catalogue. Check the spelling against ERROR_CODES, or add the entry.',
413
+ codeExample: `// Bad - not a catalogue key:
414
+ createWhatError('MISSING_KEYS');
415
+
416
+ // Good:
417
+ createWhatError('MISSING_KEY', { component: 'TodoList' });`,
418
+ },
419
+
420
+ UNKNOWN_TOOL: {
421
+ code: 'ERR_UNKNOWN_TOOL',
422
+ severity: 'error',
423
+ template: 'Unknown tool: {{name}}.',
424
+ suggestion: 'The MCP client called a tool this server does not expose. Call tools/list to enumerate what is available; a stale client cache is the usual cause.',
425
+ codeExample: `// List what the server actually exposes:
426
+ { "method": "tools/list" }`,
427
+ },
160
428
  };
161
429
 
430
+ // Reverse index: an error carries `code: 'ERR_X'`, and the catalogue is keyed
431
+ // by its short name.
432
+ //
433
+ // Built on first use, NOT at module load. A top-level `new Map(...)` over
434
+ // ERROR_CODES is a side effect that references the catalogue, which pins it
435
+ // into every bundle that imports what-core: it took the counter app from
436
+ // 6.4 KB gzipped to 12.1 KB and tripped check:size. Inside a function, a
437
+ // bundler that drops getErrorDefinition drops the catalogue with it.
438
+ let _codeIndex = null;
439
+
440
+ /** Look up a catalogue entry by its `ERR_*` code. Returns undefined if unknown. */
441
+ export function getErrorDefinition(code) {
442
+ if (_codeIndex === null) {
443
+ _codeIndex = new Map(Object.values(ERROR_CODES).map((def) => [def.code, def]));
444
+ }
445
+ return _codeIndex.get(code);
446
+ }
447
+
162
448
  // --- WhatError ---
163
449
  // Structured error class with full context for agent consumption.
164
450
 
@@ -169,6 +455,18 @@ export class WhatError extends Error {
169
455
  // there and `suggestion` had to carry the whole fix in prose. It matters more
170
456
  // here than in a framework aimed at humans: the audience reading toJSON() is
171
457
  // usually an agent, and a diff-shaped example is the part it can copy.
458
+ /**
459
+ * @param {object} init
460
+ * @param {string} init.code
461
+ * @param {string} [init.message]
462
+ * @param {string} [init.suggestion]
463
+ * @param {{ bad?: string, good?: string } | string} [init.codeExample]
464
+ * @param {string} [init.file]
465
+ * @param {number} [init.line]
466
+ * @param {string} [init.component]
467
+ * @param {string} [init.signal]
468
+ * @param {string} [init.effect]
469
+ */
172
470
  constructor({ code, message, suggestion, codeExample, file, line, component, signal, effect }) {
173
471
  super(message);
174
472
  this.name = 'WhatError';
@@ -265,6 +563,29 @@ export function clearCollectedErrors() {
265
563
  export function classifyError(err, context = {}) {
266
564
  const msg = err?.message || String(err);
267
565
 
566
+ // An error the framework threw already says what it is. Every throw outside
567
+ // core carries a `code` and nothing else — the suggestion and the worked
568
+ // example live once, in ERROR_CODES — so resolving the code here is what
569
+ // makes them reachable at all. Message sniffing below is only for errors
570
+ // that predate the code, or that come from user code.
571
+ if (err && typeof err.code === 'string') {
572
+ const def = getErrorDefinition(err.code);
573
+ if (def) {
574
+ return new WhatError({
575
+ code: def.code,
576
+ // The thrown message is the specific one; the template is generic.
577
+ message: msg,
578
+ suggestion: def.suggestion,
579
+ codeExample: def.codeExample,
580
+ file: context.file,
581
+ line: context.line,
582
+ component: context.component,
583
+ signal: context.signal || context.signalName,
584
+ effect: context.effect || context.effectName,
585
+ });
586
+ }
587
+ }
588
+
268
589
  // Infinite effect loop
269
590
  if (msg.includes('infinite effect loop') || msg.includes('25 iterations')) {
270
591
  return createWhatError('INFINITE_EFFECT', context);
package/src/form.js CHANGED
@@ -21,6 +21,7 @@ function _applyRef(ref, el) {
21
21
  function _composeHandlers(registered, callerHandler) {
22
22
  if (typeof registered !== 'function') return callerHandler;
23
23
  if (typeof callerHandler !== 'function') return registered;
24
+ /** @this {any} */
24
25
  return function composedHandler(...args) {
25
26
  registered.apply(this, args);
26
27
  return callerHandler.apply(this, args);
@@ -340,7 +341,7 @@ function createFormController(options = {}) {
340
341
  if (!el._propEffects) el._propEffects = {};
341
342
  const key = 'checked:property';
342
343
  if (el._propEffects[key]) {
343
- try { el._propEffects[key](); } catch (err) { /* already disposed */ }
344
+ try { el._propEffects[key](); } catch { /* already disposed */ }
344
345
  }
345
346
  // radioValueOf(el) is re-read on every run rather than captured, so a
346
347
  // registration without a declared value still binds correctly once the
package/src/head.js CHANGED
@@ -130,7 +130,8 @@ function escapeHtml(str) {
130
130
  function escapeSelectorValue(key) {
131
131
  const s = String(key);
132
132
  if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(s);
133
- return s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c.codePointAt(0).toString(16)} `);
133
+ // `c` is a single matched code unit, so codePointAt(0) is always defined.
134
+ return s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${/** @type {number} */ (c.codePointAt(0)).toString(16)} `);
134
135
  }
135
136
 
136
137
  function setHeadTag(tag, key, attrs) {
package/src/hooks.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // Components run ONCE. Hooks return signal accessors (functions) so the
4
4
  // fine-grained runtime handles reactive updates automatically via effects.
5
5
 
6
- import { signal, computed, effect, batch, untrack, createRoot, __DEV__ } from './reactive.js';
6
+ import { signal, computed, effect, batch, untrack, __DEV__ } from './reactive.js';
7
7
  import { getCurrentComponent } from './dom.js';
8
8
  import { getServerContext, isServerRender } from './server-context.js';
9
9
  import { getLoaderData as _getLoaderData, getResource as _getResource } from './hydration-data.js';
@@ -138,7 +138,7 @@ export function useEffect(fn, deps) {
138
138
  if (ctx.disposed) return;
139
139
  hook.dispose = effect(() => {
140
140
  if (hook.cleanup) {
141
- try { hook.cleanup(); } catch (e) { /* cleanup error */ }
141
+ try { hook.cleanup(); } catch { /* cleanup error */ }
142
142
  hook.cleanup = null;
143
143
  }
144
144
  const result = fn();
@@ -174,7 +174,7 @@ export function useEffect(fn, deps) {
174
174
 
175
175
  // Run cleanup from previous execution
176
176
  if (hook.cleanup) {
177
- try { hook.cleanup(); } catch (e) { /* cleanup error */ }
177
+ try { hook.cleanup(); } catch { /* cleanup error */ }
178
178
  hook.cleanup = null;
179
179
  }
180
180
 
@@ -195,7 +195,7 @@ export function useEffect(fn, deps) {
195
195
  // computed() auto-tracks signal dependencies.
196
196
  // Returns a computed signal function (call it to read the value).
197
197
 
198
- export function useMemo(fn, deps) {
198
+ export function useMemo(fn, _deps) {
199
199
  const ctx = getCtx('useMemo');
200
200
  const { index, exists } = getHook(ctx);
201
201
 
@@ -211,7 +211,7 @@ export function useMemo(fn, deps) {
211
211
  // executes once, so the callback reference is inherently stable.
212
212
  // Simply store and return the function on first call.
213
213
 
214
- export function useCallback(fn, deps) {
214
+ export function useCallback(fn, _deps) {
215
215
  const ctx = getCtx('useCallback');
216
216
  const { index, exists } = getHook(ctx);
217
217
 
@@ -295,7 +295,7 @@ export function createContext(defaultValue) {
295
295
  };
296
296
  // The context value is only published once the provider body runs, so
297
297
  // compiled children must not be built during this call. See createComponent.
298
- context.Provider._deferChildren = true;
298
+ /** @type {any} */ (context.Provider)._deferChildren = true;
299
299
  return context;
300
300
  }
301
301
 
@@ -452,14 +452,7 @@ export function createResource(fetcher, options = {}) {
452
452
  return [data, { loading, error, refetch, mutate }];
453
453
  }
454
454
 
455
- // --- Dep comparison (kept for potential external use) ---
456
-
457
- function depsChanged(oldDeps, newDeps) {
458
- if (oldDeps === undefined) return true;
459
- if (!oldDeps || !newDeps) return true;
460
- if (oldDeps.length !== newDeps.length) return true;
461
- for (let i = 0; i < oldDeps.length; i++) {
462
- if (!Object.is(oldDeps[i], newDeps[i])) return true;
463
- }
464
- return false;
465
- }
455
+ // A local `depsChanged` lived here, commented "kept for potential external
456
+ // use". It was never exported, so there was no external use to keep it for,
457
+ // and nothing in this package called it. react-compat has its own copy, which
458
+ // is the one that is actually used.
package/src/index.js CHANGED
@@ -178,6 +178,7 @@ export {
178
178
  export {
179
179
  WhatError,
180
180
  ERROR_CODES,
181
+ getErrorDefinition,
181
182
  createWhatError,
182
183
  classifyError,
183
184
  collectError,
package/src/reactive.js CHANGED
@@ -30,6 +30,7 @@ export const __DEV__ =
30
30
 
31
31
  // DevTools hooks — set by what-devtools when installed.
32
32
  // These are no-ops in production (dead-code eliminated with __DEV__).
33
+ /** @type {WhatDevToolsHooks | null} */
33
34
  export let __devtools = null;
34
35
 
35
36
  /** @internal Install devtools hooks. Called by what-devtools. */
@@ -96,7 +97,7 @@ export function signal(initial, debugName) {
96
97
  // Invalidate lastTracked since value changed — any effect that reads
97
98
  // this signal during re-run needs to re-track.
98
99
  lastTracked = null;
99
- if (__DEV__ && __devtools) __devtools.onSignalUpdate(sig);
100
+ if (__DEV__ && __devtools) __devtools.onSignalUpdate?.(sig);
100
101
  if (subs.size > 0) notify(subs);
101
102
  }
102
103
 
@@ -134,11 +135,21 @@ export function signal(initial, debugName) {
134
135
  sig._signal = true;
135
136
  if (__DEV__) {
136
137
  sig._subs = subs;
138
+ // Back-reference from the subscriber Set to its signal, for trackSignals()
139
+ // in testing.js. An effect records dependencies as the `subs` Sets it
140
+ // joined (see the read path above), which is one-directional: given an
141
+ // effect you can count its deps but not name them.
142
+ //
143
+ // Deliberately NOT `_owner`. That property is load-bearing for topological
144
+ // level computation, and a signal's Set is documented above as having
145
+ // `_owner === undefined` precisely because signals are level 0. A separate
146
+ // dev-only name keeps that invariant intact.
147
+ subs._signalOwner = sig;
137
148
  if (debugName) sig._debugName = debugName;
138
149
  }
139
150
 
140
151
  // Notify devtools of signal creation
141
- if (__DEV__ && __devtools) __devtools.onSignalCreate(sig);
152
+ if (__DEV__ && __devtools) __devtools.onSignalCreate?.(sig);
142
153
 
143
154
  return sig;
144
155
  }
@@ -337,7 +348,7 @@ export function effect(fn, opts) {
337
348
  // effect could never re-fire anyway, so releasing is safe.
338
349
  if (e.deps.length === 0 && e._cleanup === null) {
339
350
  e.disposed = true;
340
- if (__DEV__ && __devtools) __devtools.onEffectDispose(e);
351
+ if (__DEV__ && __devtools) __devtools.onEffectDispose?.(e);
341
352
  return _noopDispose;
342
353
  }
343
354
 
@@ -369,6 +380,7 @@ function _createEffect(fn, lazy) {
369
380
  // IMPORTANT: V8 optimizes objects with a consistent "hidden class" (shape).
370
381
  // All properties must be declared upfront even if null — adding properties
371
382
  // later causes shape transitions which deoptimize property access globally.
383
+ /** @type {WhatEffectNode} */
372
384
  const e = {
373
385
  fn,
374
386
  deps: [], // array of subscriber sets (cheaper than Set for typical 1-3 deps)
@@ -385,7 +397,7 @@ function _createEffect(fn, lazy) {
385
397
  _cleanup: null, // cleanup function returned by effect fn (declared upfront for shape)
386
398
  _epoch: 0, // incremented on cleanup — used by signal lastTracked cache
387
399
  };
388
- if (__DEV__ && __devtools) __devtools.onEffectCreate(e);
400
+ if (__DEV__ && __devtools) __devtools.onEffectCreate?.(e);
389
401
  return e;
390
402
  }
391
403
 
@@ -409,12 +421,12 @@ function _runEffect(e) {
409
421
  const result = e.fn();
410
422
  if (typeof result === 'function') e._cleanup = result;
411
423
  } catch (err) {
412
- if (__devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
424
+ if (__devtools?.onError) __devtools.onError?.(err, { type: 'effect', effect: e });
413
425
  if (__DEV__) console.warn('[what] Error in stable effect:', err);
414
426
  } finally {
415
427
  currentEffect = prev;
416
428
  }
417
- if (__DEV__ && __devtools?.onEffectRun) __devtools.onEffectRun(e);
429
+ if (__DEV__ && __devtools?.onEffectRun) __devtools.onEffectRun?.(e);
418
430
  return;
419
431
  }
420
432
 
@@ -426,7 +438,7 @@ function _runEffect(e) {
426
438
  // Run effect cleanup from previous run
427
439
  if (e._cleanup) {
428
440
  try { e._cleanup(); } catch (err) {
429
- if (__DEV__ && __devtools?.onError) __devtools.onError(err, { type: 'effect-cleanup', effect: e });
441
+ if (__DEV__ && __devtools?.onError) __devtools.onError?.(err, { type: 'effect-cleanup', effect: e });
430
442
  if (__DEV__) console.warn('[what] Error in effect cleanup:', err);
431
443
  }
432
444
  e._cleanup = null;
@@ -441,7 +453,7 @@ function _runEffect(e) {
441
453
  }
442
454
  } catch (err) {
443
455
  if (err === NEEDS_UPSTREAM) throw err; // Iterative eval sentinel — not a real error
444
- if (__DEV__ && __devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
456
+ if (__DEV__ && __devtools?.onError) __devtools.onError?.(err, { type: 'effect', effect: e });
445
457
  throw err;
446
458
  } finally {
447
459
  currentEffect = prev;
@@ -458,12 +470,12 @@ function _runEffect(e) {
458
470
  e._stable = true;
459
471
  }
460
472
 
461
- if (__DEV__ && __devtools?.onEffectRun) __devtools.onEffectRun(e);
473
+ if (__DEV__ && __devtools?.onEffectRun) __devtools.onEffectRun?.(e);
462
474
  }
463
475
 
464
476
  function _disposeEffect(e) {
465
477
  e.disposed = true;
466
- if (__DEV__ && __devtools) __devtools.onEffectDispose(e);
478
+ if (__DEV__ && __devtools) __devtools.onEffectDispose?.(e);
467
479
  cleanup(e);
468
480
  // Run cleanup on dispose
469
481
  if (e._cleanup) {
@@ -509,11 +521,11 @@ function _processSubscriber(e) {
509
521
  try {
510
522
  const result = e.fn();
511
523
  if (typeof result === 'function') {
512
- if (e._cleanup) try { e._cleanup(); } catch (err) { /* ignore */ }
524
+ if (e._cleanup) try { e._cleanup(); } catch { /* ignore */ }
513
525
  e._cleanup = result;
514
526
  }
515
527
  } catch (err) {
516
- if (__DEV__ && __devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
528
+ if (__DEV__ && __devtools?.onError) __devtools.onError?.(err, { type: 'effect', effect: e });
517
529
  if (__DEV__) console.warn('[what] Error in stable effect:', err);
518
530
  } finally {
519
531
  currentEffect = prev;
@@ -617,7 +629,7 @@ function flush() {
617
629
  _runEffect(e);
618
630
  } catch (err) {
619
631
  if (err === NEEDS_UPSTREAM) throw err;
620
- if (__DEV__ && __devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
632
+ if (__DEV__ && __devtools?.onError) __devtools.onError?.(err, { type: 'effect', effect: e });
621
633
  // Surface in production too — an uncaught reactive-update error is a
622
634
  // real bug; staying silent (as the old throw-out-of-flush did once it
623
635
  // escaped) hides it. console.error never aborts the batch.
@@ -786,6 +798,7 @@ export function createRoot(fn) {
786
798
  const prevRoot = currentRoot;
787
799
  const prevOwner = currentOwner;
788
800
  const root = {
801
+ /** @type {Array<() => void>} */
789
802
  disposals: [],
790
803
  owner: currentOwner, // parent owner for ownership tree
791
804
  children: [], // child roots (ownership tree)
@@ -856,6 +869,7 @@ export function _createItemScope(fn) {
856
869
  const prevRoot = currentRoot;
857
870
  const prevOwner = currentOwner;
858
871
  const scope = {
872
+ /** @type {Array<() => void>} */
859
873
  disposals: [],
860
874
  owner: null, // No parent registration
861
875
  children: [], // Kept for compat with effects that create sub-roots
@@ -914,6 +928,7 @@ if (__DEV__ && typeof WeakRef !== 'undefined') {
914
928
  // needed before the devtools entry point runs; once devtools install, the
915
929
  // buffer is drained and subsequent creations flow through the real hooks.
916
930
  const PREINSTALL_CAP = 2000;
931
+ /** @type {{ signals: Set<any>, effects: Set<any>, components: any[] }} */
917
932
  const buffer = { signals: new Set(), effects: new Set(), components: [] };
918
933
  __devtools = {
919
934
  __isPreinstallBuffer: true,
@@ -940,9 +955,10 @@ if (__DEV__ && typeof WeakRef !== 'undefined') {
940
955
  * __setDevToolsHooks replaces the placeholder. Returns arrays of live refs.
941
956
  */
942
957
  export function __drainPreinstallBuffer() {
943
- if (!__DEV__) return { signals: [], effects: [], components: [] };
958
+ if (!__DEV__) return /** @type {{ signals: any[], effects: any[], components: any[] }} */ ({ signals: [], effects: [], components: [] });
944
959
  // If the current __devtools is the real one (no __isPreinstallBuffer), the
945
960
  // caller installed late and there is nothing to drain from this side.
961
+ /** @type {{ signals: any[], effects: any[], components: any[] }} */
946
962
  const out = { signals: [], effects: [], components: [] };
947
963
  const buf = (typeof __preinstallSnapshot !== 'undefined') ? __preinstallSnapshot : null;
948
964
  if (!buf) return out;
@@ -954,6 +970,7 @@ export function __drainPreinstallBuffer() {
954
970
 
955
971
  // Capture the placeholder buffer at module load so __drainPreinstallBuffer
956
972
  // can return it AFTER __setDevToolsHooks has replaced __devtools.
973
+ /** @type {WhatDevToolsHooks['__buffer'] | null} */
957
974
  let __preinstallSnapshot = null;
958
975
  if (__DEV__ && __devtools?.__isPreinstallBuffer) {
959
976
  __preinstallSnapshot = __devtools.__buffer;