what-core 0.12.3 → 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/dom.js CHANGED
@@ -2,8 +2,8 @@
2
2
  // Components run ONCE. Signals create individual DOM effects.
3
3
  // No VDOM reconciler, no diffing — direct DOM manipulation driven by signals.
4
4
 
5
- import { effect, batch, untrack, signal, __DEV__, __devtools } from './reactive.js';
6
- import { reportError, _injectGetCurrentComponent, shallowEqual } from './components.js';
5
+ import { effect, untrack, signal, __DEV__, __devtools } from './reactive.js';
6
+ import { reportError, _injectGetCurrentComponent } from './components.js';
7
7
  import { _setComponentRef } from './helpers.js';
8
8
  // SVG elements that need namespace
9
9
  const SVG_ELEMENTS = new Set([
@@ -119,7 +119,7 @@ function disposeComponent(ctx) {
119
119
  // Run effect disposals
120
120
  if (ctx.effects) {
121
121
  for (const dispose of ctx.effects) {
122
- try { dispose(); } catch (e) { /* already disposed */ }
122
+ try { dispose(); } catch { /* already disposed */ }
123
123
  }
124
124
  }
125
125
 
@@ -139,7 +139,7 @@ function disposeComponent(ctx) {
139
139
  }
140
140
  }
141
141
 
142
- if (__DEV__ && __devtools?.onComponentUnmount) __devtools.onComponentUnmount(ctx);
142
+ if (__DEV__ && __devtools?.onComponentUnmount) __devtools.onComponentUnmount?.(ctx);
143
143
  mountedComponents.delete(ctx);
144
144
  }
145
145
 
@@ -169,7 +169,7 @@ export function disposeTree(node) {
169
169
  const disposers = node._hydrationDisposers;
170
170
  node._hydrationDisposers = null;
171
171
  for (let i = 0; i < disposers.length; i++) {
172
- try { disposers[i](); } catch (e) { /* already disposed */ }
172
+ try { disposers[i](); } catch { /* already disposed */ }
173
173
  }
174
174
  }
175
175
  // Check comment node WeakMap for component context — only for comment nodes
@@ -181,12 +181,12 @@ export function disposeTree(node) {
181
181
  }
182
182
  // Dispose reactive function child effects ({() => ...} wrappers)
183
183
  if (node._dispose) {
184
- try { node._dispose(); } catch (e) { /* already disposed */ }
184
+ try { node._dispose(); } catch { /* already disposed */ }
185
185
  }
186
186
  // Dispose reactive prop effects (value: () => ..., class: () => ..., etc.)
187
187
  if (node._propEffects) {
188
188
  for (const key in node._propEffects) {
189
- try { node._propEffects[key](); } catch (e) { /* already disposed */ }
189
+ try { node._propEffects[key](); } catch { /* already disposed */ }
190
190
  }
191
191
  }
192
192
  // Recursively dispose children
@@ -384,7 +384,7 @@ const _propsProxyHandler = {
384
384
  }
385
385
  return undefined;
386
386
  },
387
- set(target, key) {
387
+ set(_target, _key) {
388
388
  // Props are read-only from the component's perspective.
389
389
  // Reject all writes — especially dangerous prototype-chain keys.
390
390
  return false;
@@ -436,6 +436,7 @@ export function _beginComponentSSR(Component) {
436
436
  const ctx = {
437
437
  hooks: [],
438
438
  hookIndex: 0,
439
+ /** @type {Array<() => void>} */
439
440
  effects: [],
440
441
  cleanups: [],
441
442
  mounted: false,
@@ -563,6 +564,7 @@ function createComponent(vnode, parent, isSvg) {
563
564
  const ctx = {
564
565
  hooks: [],
565
566
  hookIndex: 0,
567
+ /** @type {Array<() => void>} */
566
568
  effects: [],
567
569
  cleanups: [],
568
570
  mounted: false,
@@ -587,7 +589,7 @@ function createComponent(vnode, parent, isSvg) {
587
589
 
588
590
  // Track for disposal
589
591
  mountedComponents.add(ctx);
590
- if (__DEV__ && __devtools?.onComponentMount) __devtools.onComponentMount(ctx);
592
+ if (__DEV__ && __devtools?.onComponentMount) __devtools.onComponentMount?.(ctx);
591
593
 
592
594
  // Props signal for reactive updates from parent
593
595
  const propsChildren = children.length === 0 ? undefined : children.length === 1 ? children[0] : children;
@@ -699,7 +701,9 @@ function createErrorBoundary(vnode, parent) {
699
701
  const endComment = document.createComment('eb:end');
700
702
 
701
703
  const boundaryCtx = {
702
- hooks: [], hookIndex: 0, effects: [], cleanups: [],
704
+ hooks: /** @type {any[]} */ ([]), hookIndex: 0,
705
+ effects: /** @type {Array<() => void>} */ ([]),
706
+ cleanups: /** @type {Array<() => void>} */ ([]),
703
707
  mounted: false, disposed: false,
704
708
  _parentCtx: componentStack[componentStack.length - 1] || null,
705
709
  _errorBoundary: handleError,
@@ -719,11 +723,12 @@ function createErrorBoundary(vnode, parent) {
719
723
  componentStack.push(boundaryCtx);
720
724
 
721
725
  // Remove old content between comment boundaries
722
- if (startComment.parentNode) {
726
+ const openParent = startComment.parentNode;
727
+ if (openParent) {
723
728
  while (startComment.nextSibling && startComment.nextSibling !== endComment) {
724
729
  const old = startComment.nextSibling;
725
730
  disposeTree(old);
726
- old.parentNode.removeChild(old);
731
+ openParent.removeChild(old);
727
732
  }
728
733
  }
729
734
 
@@ -767,7 +772,9 @@ function createSuspenseBoundary(vnode, parent) {
767
772
  const endComment = document.createComment('sb:end');
768
773
 
769
774
  const boundaryCtx = {
770
- hooks: [], hookIndex: 0, effects: [], cleanups: [],
775
+ hooks: /** @type {any[]} */ ([]), hookIndex: 0,
776
+ effects: /** @type {Array<() => void>} */ ([]),
777
+ cleanups: /** @type {Array<() => void>} */ ([]),
771
778
  mounted: false, disposed: false,
772
779
  _parentCtx: componentStack[componentStack.length - 1] || null,
773
780
  _suspenseBoundary: boundary,
@@ -795,11 +802,12 @@ function createSuspenseBoundary(vnode, parent) {
795
802
  componentStack.push(boundaryCtx);
796
803
 
797
804
  // Remove old content between comment boundaries
798
- if (startComment.parentNode) {
805
+ const openParent = startComment.parentNode;
806
+ if (openParent) {
799
807
  while (startComment.nextSibling && startComment.nextSibling !== endComment) {
800
808
  const old = startComment.nextSibling;
801
809
  disposeTree(old);
802
- old.parentNode.removeChild(old);
810
+ openParent.removeChild(old);
803
811
  }
804
812
  }
805
813
 
@@ -830,7 +838,7 @@ function createSuspenseBoundary(vnode, parent) {
830
838
  }
831
839
 
832
840
  // Portal component handler
833
- function createPortalDOM(vnode, parent) {
841
+ function createPortalDOM(vnode, _parent) {
834
842
  const { container } = vnode.props;
835
843
  const children = vnode.children;
836
844
 
@@ -840,7 +848,9 @@ function createPortalDOM(vnode, parent) {
840
848
  }
841
849
 
842
850
  const portalCtx = {
843
- hooks: [], hookIndex: 0, effects: [], cleanups: [],
851
+ hooks: /** @type {any[]} */ ([]), hookIndex: 0,
852
+ effects: /** @type {Array<() => void>} */ ([]),
853
+ cleanups: /** @type {Array<() => void>} */ ([]),
844
854
  mounted: false, disposed: false,
845
855
  _parentCtx: componentStack[componentStack.length - 1] || null,
846
856
  };
@@ -942,7 +952,7 @@ function setProp(el, key, value, isSvg) {
942
952
  if (typeof value === 'function' && !_isEventProp(key) && key !== 'ref') {
943
953
  if (!el._propEffects) el._propEffects = {};
944
954
  if (el._propEffects[key]) {
945
- try { el._propEffects[key](); } catch (e) { /* already disposed */ }
955
+ try { el._propEffects[key](); } catch { /* already disposed */ }
946
956
  }
947
957
  el._propEffects[key] = effect(() => {
948
958
  const resolved = value();
@@ -1056,7 +1066,7 @@ function setProp(el, key, value, isSvg) {
1056
1066
  // reset first so removeAttribute() clears both the attribute and the property.
1057
1067
  if (value == null) {
1058
1068
  if (key in el) {
1059
- try { el[key] = ''; } catch (e) { /* read-only reflected prop */ }
1069
+ try { el[key] = ''; } catch { /* read-only reflected prop */ }
1060
1070
  }
1061
1071
  el.removeAttribute(key);
1062
1072
  return;
package/src/errors.js CHANGED
@@ -157,17 +157,322 @@ 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
 
165
451
  export class WhatError extends Error {
166
- constructor({ code, message, suggestion, file, line, component, signal, effect }) {
452
+ // codeExample carries the bad/good pair from the error's ERROR_CODES entry.
453
+ // Every entry above already had one; the class simply dropped it on the
454
+ // floor, so the field the docs promise on the serialized error was never
455
+ // there and `suggestion` had to carry the whole fix in prose. It matters more
456
+ // here than in a framework aimed at humans: the audience reading toJSON() is
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
+ */
470
+ constructor({ code, message, suggestion, codeExample, file, line, component, signal, effect }) {
167
471
  super(message);
168
472
  this.name = 'WhatError';
169
473
  this.code = code;
170
474
  this.suggestion = suggestion;
475
+ this.codeExample = codeExample;
171
476
  this.file = file;
172
477
  this.line = line;
173
478
  this.component = component;
@@ -180,6 +485,7 @@ export class WhatError extends Error {
180
485
  code: this.code,
181
486
  message: this.message,
182
487
  suggestion: this.suggestion,
488
+ codeExample: this.codeExample,
183
489
  file: this.file,
184
490
  line: this.line,
185
491
  component: this.component,
@@ -214,6 +520,9 @@ export function createWhatError(errorCode, context = {}) {
214
520
  code: def.code,
215
521
  message,
216
522
  suggestion: def.suggestion,
523
+ // Verbatim from the definition: codeExample is a worked bad/good pair, not
524
+ // a template, so there is nothing in it to interpolate.
525
+ codeExample: def.codeExample,
217
526
  file: context.file,
218
527
  line: context.line,
219
528
  component: context.component,
@@ -254,6 +563,29 @@ export function clearCollectedErrors() {
254
563
  export function classifyError(err, context = {}) {
255
564
  const msg = err?.message || String(err);
256
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
+
257
589
  // Infinite effect loop
258
590
  if (msg.includes('infinite effect loop') || msg.includes('25 iterations')) {
259
591
  return createWhatError('INFINITE_EFFECT', context);