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/dist/chunk-ENARGHSD.min.js +11 -0
- package/dist/chunk-OKM3GKVP.min.js +1 -0
- package/dist/index.min.js +82 -5
- package/dist/render.min.js +1 -1
- package/dist/testing.min.js +1 -1
- package/index.d.ts +369 -43
- package/package.json +1 -1
- package/render.d.ts +7 -0
- package/src/a11y.js +237 -26
- package/src/agent-context.js +1 -1
- package/src/animation.js +13 -6
- package/src/components.js +1 -1
- package/src/data.js +643 -93
- package/src/dom.js +29 -19
- package/src/errors.js +333 -1
- package/src/form.js +330 -31
- package/src/head.js +2 -1
- package/src/hooks.js +30 -20
- package/src/index.js +1 -0
- package/src/reactive.js +31 -14
- package/src/render.js +502 -36
- package/src/scheduler.js +24 -7
- package/src/skeleton.js +16 -1
- package/src/store.js +0 -1
- package/src/testing.js +101 -50
- package/src/warnings.js +83 -0
- package/testing.d.ts +17 -1
- package/dist/chunk-JVEPLFIB.min.js +0 -11
- package/dist/chunk-VTPLA4AS.min.js +0 -1
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,
|
|
6
|
-
import { reportError, _injectGetCurrentComponent
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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: []
|
|
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
|
-
|
|
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
|
-
|
|
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: []
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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: []
|
|
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
|
|
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
|
|
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
|
-
|
|
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);
|