@stateward/plugin-errors 1.0.1-rc.1 → 1.0.1
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/README.md +61 -3
- package/package.json +2 -2
- package/dist/async-handler-unhandled.d.ts +0 -7
- package/dist/async-handler-unhandled.js +0 -342
- package/dist/exhaustive-catch.d.ts +0 -6
- package/dist/exhaustive-catch.js +0 -190
- package/dist/exhaustive-fallback.d.ts +0 -6
- package/dist/exhaustive-fallback.js +0 -157
- package/dist/finally-may-throw.d.ts +0 -6
- package/dist/finally-may-throw.js +0 -184
- package/dist/no-swallow.d.ts +0 -8
- package/dist/no-swallow.js +0 -320
- package/dist/render-throws-without-boundary.d.ts +0 -6
- package/dist/render-throws-without-boundary.js +0 -151
- package/dist/rethrow-unknown.d.ts +0 -6
- package/dist/rethrow-unknown.js +0 -260
- package/dist/throw-in-hook.d.ts +0 -8
- package/dist/throw-in-hook.js +0 -510
- package/dist/throws-matches-doc.d.ts +0 -7
- package/dist/throws-matches-doc.js +0 -188
package/dist/throw-in-hook.js
DELETED
|
@@ -1,510 +0,0 @@
|
|
|
1
|
-
import ts from "@stateward/core/typescript";
|
|
2
|
-
import { defineRule, matchesName, option } from "@stateward/core";
|
|
3
|
-
import { hasModifier, sameFileFunction, scanRunning, stripParentheses, throwsUncaught, } from "./syntax.js";
|
|
4
|
-
import { HEADLINE_MAX, fitted, oneLine, withExample } from "./text.js";
|
|
5
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
6
|
-
export const GROUP = "react";
|
|
7
|
-
const EXPLAIN = `# errors/throw-in-hook
|
|
8
|
-
|
|
9
|
-
A component or hook that throws while rendering takes down the screen up to the nearest error boundary.
|
|
10
|
-
|
|
11
|
-
## Why
|
|
12
|
-
|
|
13
|
-
When a component or hook throws while React renders it, React unmounts everything up to the nearest error boundary, or the whole app when there is none. The parent never gets a value back, so it cannot show the failure itself. A \`Cart\` that throws \`new Error("no cart")\` for a missing id blanks the page where it could have shown "cart not found".
|
|
14
|
-
|
|
15
|
-
## When to use it
|
|
16
|
-
|
|
17
|
-
Use it for app code that returns failures as state (\`RemoteData\`, \`Result\`). Many apps throw on purpose, to an error boundary or for Suspense, and libraries throw to flag a misuse, so it is off until you add its add-on (below), then a warning in every preset. For a throw that is meant, document it with \`@throws\`, accept the finding with \`// stateward-ignore errors/throw-in-hook: <why>\` above the line, or with \`stateward baseline\`.
|
|
18
|
-
|
|
19
|
-
## What it catches
|
|
20
|
-
|
|
21
|
-
In the body of a component (a capitalised function with JSX or hook calls) or a custom hook (\`use\` plus a capital letter):
|
|
22
|
-
|
|
23
|
-
- a \`throw\` not caught by a \`try\` / \`catch\` in the same body;
|
|
24
|
-
- a call to a function in the same file that has a \`throw\` outside its own \`try\` / \`catch\`.
|
|
25
|
-
|
|
26
|
-
### Bad
|
|
27
|
-
|
|
28
|
-
\`\`\`tsx
|
|
29
|
-
function Cart({ id }: { id: string }) {
|
|
30
|
-
const cart = useCartData(id);
|
|
31
|
-
if (cart === undefined) throw new Error("no cart");
|
|
32
|
-
return <List items={cart.items} />;
|
|
33
|
-
}
|
|
34
|
-
\`\`\`
|
|
35
|
-
|
|
36
|
-
### Good
|
|
37
|
-
|
|
38
|
-
\`\`\`tsx
|
|
39
|
-
function Cart({ id }: { id: string }) {
|
|
40
|
-
const cart = useCartData(id);
|
|
41
|
-
if (cart === undefined) return <NotFound />;
|
|
42
|
-
return <List items={cart.items} />;
|
|
43
|
-
}
|
|
44
|
-
\`\`\`
|
|
45
|
-
|
|
46
|
-
### Fix
|
|
47
|
-
|
|
48
|
-
Render the failure, or return it as state the caller can render. There is no quick fix.
|
|
49
|
-
|
|
50
|
-
## What it does not catch
|
|
51
|
-
|
|
52
|
-
It stays quiet for:
|
|
53
|
-
|
|
54
|
-
- code in a nested function: event handlers, effects, \`useMemo\` and \`useCallback\` callbacks, promise callbacks;
|
|
55
|
-
- a \`throw\` caught in the same body, and a thrown promise (Suspense);
|
|
56
|
-
- the usual check in a context hook: \`if (!ctx) throw new Error("useAuth must be used within AuthProvider")\` on a value from \`useContext\`, or a comparison with a "missing" constant declared at the top of the file. A missing Provider is a programming mistake, and the throw names it;
|
|
57
|
-
- a check on what the caller passed: a \`throw\` when a prop or parameter is missing (\`if (!render && !children) throw …\`, \`props.title === undefined\`), or when a value from a context hook is missing (\`const editor = usePreferencesEditor(); if (!editor) throw …\`). A throw on a truthy value (\`if (error) throw error\`) or on a comparison (\`id === ""\`) is still reported;
|
|
58
|
-
- a call to such a context hook in the same file (\`useSidebar()\` in \`<SidebarTrigger>\`);
|
|
59
|
-
- functions from other files.
|
|
60
|
-
|
|
61
|
-
Set \`skipContextGuards\` or \`skipInputGuards\` to \`false\` to report those checks of a context or of the caller's input too.
|
|
62
|
-
|
|
63
|
-
Whether an error boundary is above it is checked by \`errors/render-throws-without-boundary\`.
|
|
64
|
-
|
|
65
|
-
## Turn it on or off
|
|
66
|
-
|
|
67
|
-
Turn it on in \`stateward.config.ts\`:
|
|
68
|
-
|
|
69
|
-
\`\`\`ts
|
|
70
|
-
addons: ["errors/hook-throws"]
|
|
71
|
-
\`\`\`
|
|
72
|
-
|
|
73
|
-
Then change or turn off the rule itself:
|
|
74
|
-
|
|
75
|
-
\`\`\`ts
|
|
76
|
-
rules: { "errors/throw-in-hook": "off" } // or "error"
|
|
77
|
-
groups: { "errors/react": "off" } // the whole group
|
|
78
|
-
rules: { "errors/throw-in-hook": ["warning", { ignore: ["use*Suspense"] }] } // hooks that throw on purpose
|
|
79
|
-
\`\`\`
|
|
80
|
-
`;
|
|
81
|
-
const OPTIONS = {
|
|
82
|
-
skipContextGuards: option.boolean({
|
|
83
|
-
default: true,
|
|
84
|
-
description: 'Stay quiet on the usual check in a context hook: `if (!ctx) throw new Error("useAuth must be used within AuthProvider")`.',
|
|
85
|
-
}),
|
|
86
|
-
skipInputGuards: option.boolean({
|
|
87
|
-
default: true,
|
|
88
|
-
description: "Stay quiet on a throw when a prop, a parameter or a context hook's value is missing (a mistake of the caller).",
|
|
89
|
-
}),
|
|
90
|
-
ignore: option.strings({
|
|
91
|
-
default: [],
|
|
92
|
-
description: "Components and hooks (names or globs like use*Suspense) that may throw while rendering.",
|
|
93
|
-
}),
|
|
94
|
-
};
|
|
95
|
-
function nameOf(fn) {
|
|
96
|
-
if (ts.isFunctionDeclaration(fn))
|
|
97
|
-
return fn.name?.text;
|
|
98
|
-
const parent = fn.parent;
|
|
99
|
-
if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && ts.isVariableDeclaration(parent)) {
|
|
100
|
-
return ts.isIdentifier(parent.name) ? parent.name.text : undefined;
|
|
101
|
-
}
|
|
102
|
-
return undefined;
|
|
103
|
-
}
|
|
104
|
-
function isHookName(name) {
|
|
105
|
-
return /^use[A-Z]/.test(name);
|
|
106
|
-
}
|
|
107
|
-
/** Whether the render body has JSX or a call to a hook. */
|
|
108
|
-
function rendersOrUsesHooks(body) {
|
|
109
|
-
let found = false;
|
|
110
|
-
scanRunning(body, (node) => {
|
|
111
|
-
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
|
|
112
|
-
found = true;
|
|
113
|
-
}
|
|
114
|
-
else if (ts.isCallExpression(node)) {
|
|
115
|
-
const callee = stripParentheses(node.expression);
|
|
116
|
-
if (ts.isIdentifier(callee) && isHookName(callee.text))
|
|
117
|
-
found = true;
|
|
118
|
-
}
|
|
119
|
-
});
|
|
120
|
-
return found;
|
|
121
|
-
}
|
|
122
|
-
function renderOf(fn) {
|
|
123
|
-
const isFunction = ts.isFunctionDeclaration(fn) || ts.isArrowFunction(fn) || ts.isFunctionExpression(fn);
|
|
124
|
-
if (!isFunction || fn.body === undefined || hasModifier(fn, ts.SyntaxKind.AsyncKeyword)) {
|
|
125
|
-
return undefined;
|
|
126
|
-
}
|
|
127
|
-
if (fn.asteriskToken !== undefined)
|
|
128
|
-
return undefined;
|
|
129
|
-
const name = nameOf(fn);
|
|
130
|
-
if (name === undefined)
|
|
131
|
-
return undefined;
|
|
132
|
-
if (isHookName(name))
|
|
133
|
-
return { kind: "hook", name, label: name, fn, body: fn.body };
|
|
134
|
-
if (/^[A-Z]/.test(name) && rendersOrUsesHooks(fn.body)) {
|
|
135
|
-
return { kind: "component", name, label: `<${name}>`, fn, body: fn.body };
|
|
136
|
-
}
|
|
137
|
-
return undefined;
|
|
138
|
-
}
|
|
139
|
-
/** `throw promise` is how Suspense is asked to wait, not a failure. */
|
|
140
|
-
function throwsPromise(statement, ctx) {
|
|
141
|
-
return /^(?:Promise|PromiseLike)</.test(ctx.types.typeTextOf(statement.expression));
|
|
142
|
-
}
|
|
143
|
-
/** `useContext(X)` or `React.useContext(X)`. */
|
|
144
|
-
function isUseContextCall(expression) {
|
|
145
|
-
const node = stripParentheses(expression);
|
|
146
|
-
if (!ts.isCallExpression(node))
|
|
147
|
-
return false;
|
|
148
|
-
const callee = stripParentheses(node.expression);
|
|
149
|
-
const name = ts.isPropertyAccessExpression(callee) ? callee.name : callee;
|
|
150
|
-
return ts.isIdentifier(name) && name.text === "useContext";
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* Whether an expression is the value a context gives: a `useContext(X)` call, or a local `const`
|
|
154
|
-
* initialized with one. A property of it (`ctx.user`) is the app's state, not the Provider.
|
|
155
|
-
*/
|
|
156
|
-
function isContextValue(expression, ctx) {
|
|
157
|
-
const node = stripParentheses(expression);
|
|
158
|
-
if (isUseContextCall(node))
|
|
159
|
-
return true;
|
|
160
|
-
if (!ts.isIdentifier(node))
|
|
161
|
-
return false;
|
|
162
|
-
const symbol = ctx.types.symbolAt(node);
|
|
163
|
-
const declaration = symbol === undefined ? undefined : ctx.types.declarationOf(symbol)?.node;
|
|
164
|
-
if (declaration === undefined || !ts.isVariableDeclaration(declaration))
|
|
165
|
-
return false;
|
|
166
|
-
const list = declaration.parent;
|
|
167
|
-
return (ts.isVariableDeclarationList(list) &&
|
|
168
|
-
(list.flags & ts.NodeFlags.Const) !== 0 &&
|
|
169
|
-
declaration.initializer !== undefined &&
|
|
170
|
-
isUseContextCall(declaration.initializer));
|
|
171
|
-
}
|
|
172
|
-
function isNullish(expression) {
|
|
173
|
-
const node = stripParentheses(expression);
|
|
174
|
-
return (node.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(node) && node.text === "undefined"));
|
|
175
|
-
}
|
|
176
|
-
/**
|
|
177
|
-
* Whether a condition holds when a context value is missing: `!ctx`, `ctx == null`,
|
|
178
|
-
* `ctx === undefined` (either side), or an `||` of such checks.
|
|
179
|
-
*/
|
|
180
|
-
function checksMissingContext(condition, ctx) {
|
|
181
|
-
const node = stripParentheses(condition);
|
|
182
|
-
if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
|
|
183
|
-
return isContextValue(node.operand, ctx);
|
|
184
|
-
}
|
|
185
|
-
if (!ts.isBinaryExpression(node))
|
|
186
|
-
return false;
|
|
187
|
-
const operator = node.operatorToken.kind;
|
|
188
|
-
if (operator === ts.SyntaxKind.BarBarToken) {
|
|
189
|
-
return checksMissingContext(node.left, ctx) && checksMissingContext(node.right, ctx);
|
|
190
|
-
}
|
|
191
|
-
if (operator !== ts.SyntaxKind.EqualsEqualsToken &&
|
|
192
|
-
operator !== ts.SyntaxKind.EqualsEqualsEqualsToken) {
|
|
193
|
-
return false;
|
|
194
|
-
}
|
|
195
|
-
return ((isMissingMarker(node.right, ctx) && isContextValue(node.left, ctx)) ||
|
|
196
|
-
(isMissingMarker(node.left, ctx) && isContextValue(node.right, ctx)));
|
|
197
|
-
}
|
|
198
|
-
/**
|
|
199
|
-
* What a context gives when its Provider is missing: `null`, `undefined`, or a sentinel, a
|
|
200
|
-
* `const` declared at the top of the file (`const missing = {}` as the context's default).
|
|
201
|
-
*/
|
|
202
|
-
function isMissingMarker(expression, ctx) {
|
|
203
|
-
if (isNullish(expression))
|
|
204
|
-
return true;
|
|
205
|
-
const node = stripParentheses(expression);
|
|
206
|
-
if (!ts.isIdentifier(node))
|
|
207
|
-
return false;
|
|
208
|
-
const symbol = ctx.types.symbolAt(node);
|
|
209
|
-
const declaration = symbol === undefined ? undefined : ctx.types.declarationOf(symbol)?.node;
|
|
210
|
-
if (declaration === undefined || !ts.isVariableDeclaration(declaration))
|
|
211
|
-
return false;
|
|
212
|
-
const list = declaration.parent;
|
|
213
|
-
return (ts.isVariableDeclarationList(list) &&
|
|
214
|
-
(list.flags & ts.NodeFlags.Const) !== 0 &&
|
|
215
|
-
ts.isVariableStatement(list.parent) &&
|
|
216
|
-
ts.isSourceFile(list.parent.parent));
|
|
217
|
-
}
|
|
218
|
-
/** Whether a declaration is a parameter of `fn`, or a name bound by destructuring one. */
|
|
219
|
-
function isParameterOf(declaration, fn) {
|
|
220
|
-
for (let node = declaration; node !== fn; node = node.parent) {
|
|
221
|
-
if (ts.isParameter(node))
|
|
222
|
-
return node.parent === fn;
|
|
223
|
-
if (!ts.isBindingElement(node) &&
|
|
224
|
-
!ts.isObjectBindingPattern(node) &&
|
|
225
|
-
!ts.isArrayBindingPattern(node)) {
|
|
226
|
-
return false;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return false;
|
|
230
|
-
}
|
|
231
|
-
/**
|
|
232
|
-
* A hook that hands over a context: named `use…Context`, or one whose body is `useContext(X)`
|
|
233
|
-
* (`const usePreferencesEditor = () => useContext(EditorContext)`).
|
|
234
|
-
*/
|
|
235
|
-
function isContextHook(callee, ctx) {
|
|
236
|
-
const node = stripParentheses(callee);
|
|
237
|
-
if (!ts.isIdentifier(node))
|
|
238
|
-
return false;
|
|
239
|
-
if (/^use\w*Context$/.test(node.text))
|
|
240
|
-
return true;
|
|
241
|
-
const symbol = ctx.types.symbolAt(node);
|
|
242
|
-
const declaration = symbol === undefined ? undefined : ctx.types.declarationOf(symbol)?.node;
|
|
243
|
-
if (declaration === undefined)
|
|
244
|
-
return false;
|
|
245
|
-
const fn = ts.isVariableDeclaration(declaration) && declaration.initializer !== undefined
|
|
246
|
-
? stripParentheses(declaration.initializer)
|
|
247
|
-
: declaration;
|
|
248
|
-
if (!ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn) && !ts.isFunctionDeclaration(fn)) {
|
|
249
|
-
return false;
|
|
250
|
-
}
|
|
251
|
-
const body = fn.body;
|
|
252
|
-
if (body === undefined)
|
|
253
|
-
return false;
|
|
254
|
-
if (!ts.isBlock(body))
|
|
255
|
-
return isUseContextCall(body);
|
|
256
|
-
// A wrapper that reads a context, maybe guards it, and returns it (`usePreferencesEditor`).
|
|
257
|
-
let reads = false;
|
|
258
|
-
scanRunning(body, (node) => {
|
|
259
|
-
if (ts.isCallExpression(node) && isUseContextCall(node))
|
|
260
|
-
reads = true;
|
|
261
|
-
});
|
|
262
|
-
return reads;
|
|
263
|
-
}
|
|
264
|
-
/**
|
|
265
|
-
* A name destructured from a parameter in the body: `const { children, render } = props;`.
|
|
266
|
-
*/
|
|
267
|
-
function isDestructuredParameter(declaration, render, ctx) {
|
|
268
|
-
if (!ts.isBindingElement(declaration))
|
|
269
|
-
return false;
|
|
270
|
-
let pattern = declaration.parent;
|
|
271
|
-
while (ts.isBindingElement(pattern.parent))
|
|
272
|
-
pattern = pattern.parent.parent;
|
|
273
|
-
const variable = pattern.parent;
|
|
274
|
-
if (!ts.isVariableDeclaration(variable) || variable.initializer === undefined)
|
|
275
|
-
return false;
|
|
276
|
-
const source = stripParentheses(variable.initializer);
|
|
277
|
-
return ts.isIdentifier(source) && isParameterName(source, render, ctx);
|
|
278
|
-
}
|
|
279
|
-
/**
|
|
280
|
-
* Whether an expression is an input the caller is responsible for: a parameter (or a prop
|
|
281
|
-
* destructured from one, or `props.x`), or a context value read through a context hook.
|
|
282
|
-
*/
|
|
283
|
-
function isCallerInput(expression, render, ctx) {
|
|
284
|
-
const node = stripParentheses(expression);
|
|
285
|
-
if (ts.isPropertyAccessExpression(node)) {
|
|
286
|
-
const owner = stripParentheses(node.expression);
|
|
287
|
-
// `props.title`; a property of a context value is the app's state (`ctx.user`), not a prop.
|
|
288
|
-
return ts.isIdentifier(owner) && isParameterName(owner, render, ctx);
|
|
289
|
-
}
|
|
290
|
-
if (!ts.isIdentifier(node))
|
|
291
|
-
return false;
|
|
292
|
-
const symbol = ctx.types.symbolAt(node);
|
|
293
|
-
const declaration = symbol === undefined ? undefined : ctx.types.declarationOf(symbol)?.node;
|
|
294
|
-
if (declaration === undefined)
|
|
295
|
-
return false;
|
|
296
|
-
if (isParameterOf(declaration, render.fn) || isDestructuredParameter(declaration, render, ctx)) {
|
|
297
|
-
return true;
|
|
298
|
-
}
|
|
299
|
-
if (!ts.isVariableDeclaration(declaration) || declaration.initializer === undefined)
|
|
300
|
-
return false;
|
|
301
|
-
const list = declaration.parent;
|
|
302
|
-
if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0)
|
|
303
|
-
return false;
|
|
304
|
-
const init = stripParentheses(declaration.initializer);
|
|
305
|
-
return ts.isCallExpression(init) && isContextHook(init.expression, ctx);
|
|
306
|
-
}
|
|
307
|
-
function isParameterName(node, render, ctx) {
|
|
308
|
-
const symbol = ctx.types.symbolAt(node);
|
|
309
|
-
const declaration = symbol === undefined ? undefined : ctx.types.declarationOf(symbol)?.node;
|
|
310
|
-
return declaration !== undefined && isParameterOf(declaration, render.fn);
|
|
311
|
-
}
|
|
312
|
-
/**
|
|
313
|
-
* Whether a condition holds only when an input is missing: `!x`, `x == null`, `x === undefined`
|
|
314
|
-
* (either side), and `&&` / `||` of such checks, every one on a caller's input.
|
|
315
|
-
*/
|
|
316
|
-
function checksMissingInput(condition, render, ctx) {
|
|
317
|
-
const node = stripParentheses(condition);
|
|
318
|
-
if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
|
|
319
|
-
return isCallerInput(node.operand, render, ctx);
|
|
320
|
-
}
|
|
321
|
-
if (!ts.isBinaryExpression(node))
|
|
322
|
-
return false;
|
|
323
|
-
const operator = node.operatorToken.kind;
|
|
324
|
-
if (operator === ts.SyntaxKind.BarBarToken ||
|
|
325
|
-
operator === ts.SyntaxKind.AmpersandAmpersandToken) {
|
|
326
|
-
return (checksMissingInput(node.left, render, ctx) && checksMissingInput(node.right, render, ctx));
|
|
327
|
-
}
|
|
328
|
-
if (operator !== ts.SyntaxKind.EqualsEqualsToken &&
|
|
329
|
-
operator !== ts.SyntaxKind.EqualsEqualsEqualsToken) {
|
|
330
|
-
return false;
|
|
331
|
-
}
|
|
332
|
-
return ((isNullish(node.right) && isCallerInput(node.left, render, ctx)) ||
|
|
333
|
-
(isNullish(node.left) && isCallerInput(node.right, render, ctx)));
|
|
334
|
-
}
|
|
335
|
-
/** An invariant: `if (!render && !children) throw …`, a programming error of the caller. */
|
|
336
|
-
function guardsInput(statement, render, ctx) {
|
|
337
|
-
for (const guard of enclosingIfs(statement, render.body)) {
|
|
338
|
-
if (checksMissingInput(guard.expression, render, ctx))
|
|
339
|
-
return true;
|
|
340
|
-
}
|
|
341
|
-
return false;
|
|
342
|
-
}
|
|
343
|
-
/** The `if` whose `then` branch holds `node`, nearest first, up to the render body. */
|
|
344
|
-
function* enclosingIfs(node, body) {
|
|
345
|
-
let child = node;
|
|
346
|
-
for (let parent = node.parent; child !== body; child = parent, parent = parent.parent) {
|
|
347
|
-
if (ts.isIfStatement(parent) && parent.thenStatement === child)
|
|
348
|
-
yield parent;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
/** The guard of a context hook: `if (!ctx) throw new Error("useX must be used within XProvider")`. */
|
|
352
|
-
function guardsContext(statement, body, ctx) {
|
|
353
|
-
for (const guard of enclosingIfs(statement, body)) {
|
|
354
|
-
if (checksMissingContext(guard.expression, ctx))
|
|
355
|
-
return true;
|
|
356
|
-
}
|
|
357
|
-
return false;
|
|
358
|
-
}
|
|
359
|
-
/** A `throw` that unmounts the screen: not a Suspense promise, not the guard of a context hook. */
|
|
360
|
-
function isRenderFailure(statement, body, ctx) {
|
|
361
|
-
return (!throwsPromise(statement, ctx) &&
|
|
362
|
-
!(ctx.options.skipContextGuards && guardsContext(statement, body, ctx)));
|
|
363
|
-
}
|
|
364
|
-
/**
|
|
365
|
-
* Whether a function declared in this file throws a render failure outside its own `try` /
|
|
366
|
-
* `catch`. A context hook's guard (`useSidebar` called by `<SidebarTrigger>`) is not one.
|
|
367
|
-
*/
|
|
368
|
-
function calleeFails(declared, ctx) {
|
|
369
|
-
if (!throwsUncaught(declared))
|
|
370
|
-
return false;
|
|
371
|
-
const body = declared.body ?? declared;
|
|
372
|
-
let fails = false;
|
|
373
|
-
scanRunning(body, (node, guarded) => {
|
|
374
|
-
if (!guarded && ts.isThrowStatement(node) && isRenderFailure(node, body, ctx))
|
|
375
|
-
fails = true;
|
|
376
|
-
});
|
|
377
|
-
return fails;
|
|
378
|
-
}
|
|
379
|
-
function causeOf(node, render, ctx) {
|
|
380
|
-
if (ts.isThrowStatement(node)) {
|
|
381
|
-
return isRenderFailure(node, render.body, ctx) &&
|
|
382
|
-
!(ctx.options.skipInputGuards && guardsInput(node, render, ctx))
|
|
383
|
-
? node
|
|
384
|
-
: undefined;
|
|
385
|
-
}
|
|
386
|
-
if (!ts.isCallExpression(node))
|
|
387
|
-
return undefined;
|
|
388
|
-
const declared = sameFileFunction(node.expression, ctx);
|
|
389
|
-
if (declared === undefined || hasModifier(declared, ts.SyntaxKind.AsyncKeyword))
|
|
390
|
-
return undefined;
|
|
391
|
-
return calleeFails(declared, ctx) ? node : undefined;
|
|
392
|
-
}
|
|
393
|
-
function checkRender(render, ctx) {
|
|
394
|
-
if (matchesName(render.name, ctx.options.ignore))
|
|
395
|
-
return;
|
|
396
|
-
let found;
|
|
397
|
-
scanRunning(render.body, (node, guarded) => {
|
|
398
|
-
if (found === undefined && !guarded)
|
|
399
|
-
found = causeOf(node, render, ctx);
|
|
400
|
-
});
|
|
401
|
-
if (found === undefined)
|
|
402
|
-
return;
|
|
403
|
-
const cause = found;
|
|
404
|
-
ctx.report({
|
|
405
|
-
node: found,
|
|
406
|
-
message: fitted(HEADLINE_MAX, (cut) => `\`${cut(render.label)}\` throws while rendering, unmounting the screen up to an error boundary`),
|
|
407
|
-
hint: hint(render, cause, ctx),
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
/** Text from the developer's code is used in an example up to this length; longer, a neutral name. */
|
|
411
|
-
const OWN_TEXT_MAX = 50;
|
|
412
|
-
function ownText(node, ctx, neutral) {
|
|
413
|
-
const text = node === undefined ? "" : oneLine(node.getText(ctx.sourceFile));
|
|
414
|
-
return text === "" || text.length > OWN_TEXT_MAX ? neutral : text;
|
|
415
|
-
}
|
|
416
|
-
/** For a component: render the failure, with the throw's own condition and error where it has them. */
|
|
417
|
-
function componentHint(cause, render, ctx) {
|
|
418
|
-
const [guard] = ts.isThrowStatement(cause) ? enclosingIfs(cause, render.body) : [];
|
|
419
|
-
const thrown = ts.isThrowStatement(cause) &&
|
|
420
|
-
(ts.isIdentifier(stripParentheses(cause.expression)) ||
|
|
421
|
-
ts.isPropertyAccessExpression(stripParentheses(cause.expression)))
|
|
422
|
-
? cause.expression
|
|
423
|
-
: undefined;
|
|
424
|
-
const condition = ownText(guard?.expression, ctx, "failed");
|
|
425
|
-
const error = ownText(thrown, ctx, "error");
|
|
426
|
-
return withExample("render the failure instead, so the screen stays:", [
|
|
427
|
-
`if (${condition}) return <ErrorMessage error={${error}} />;`,
|
|
428
|
-
]);
|
|
429
|
-
}
|
|
430
|
-
/** What a hook returns: its declared type, else each `return` (more than one type is a union). */
|
|
431
|
-
function returnsObjectOrUnion(fn, ctx) {
|
|
432
|
-
const returned = [];
|
|
433
|
-
if (fn.type !== undefined) {
|
|
434
|
-
returned.push(fn.type);
|
|
435
|
-
}
|
|
436
|
-
else if (fn.body !== undefined && !ts.isBlock(fn.body)) {
|
|
437
|
-
returned.push(fn.body);
|
|
438
|
-
}
|
|
439
|
-
else if (fn.body !== undefined) {
|
|
440
|
-
scanRunning(fn.body, (node) => {
|
|
441
|
-
if (ts.isReturnStatement(node))
|
|
442
|
-
returned.push(node.expression ?? node);
|
|
443
|
-
});
|
|
444
|
-
}
|
|
445
|
-
const texts = new Set(returned.map((node) => (ts.isReturnStatement(node) ? "undefined" : ctx.types.typeTextOf(node))));
|
|
446
|
-
if (texts.size > 1)
|
|
447
|
-
return true;
|
|
448
|
-
return returned.some((node) => !ts.isReturnStatement(node) &&
|
|
449
|
-
(isUnionText(ctx.types.typeTextOf(node)) || ctx.types.propertyKeysOf(node) !== "not-object"));
|
|
450
|
-
}
|
|
451
|
-
/** Whether a printed type is a union at its top level: `A | B`, not `{ a: A | B }` or `() => A | B`. */
|
|
452
|
-
function isUnionText(text) {
|
|
453
|
-
let depth = 0;
|
|
454
|
-
let union = false;
|
|
455
|
-
for (let i = 0; i < text.length; i += 1) {
|
|
456
|
-
const char = text[i];
|
|
457
|
-
if (char === "(" || char === "[" || char === "{" || char === "<")
|
|
458
|
-
depth += 1;
|
|
459
|
-
else if (char === ")" ||
|
|
460
|
-
char === "]" ||
|
|
461
|
-
char === "}" ||
|
|
462
|
-
(char === ">" && text[i - 1] !== "=")) {
|
|
463
|
-
depth -= 1;
|
|
464
|
-
}
|
|
465
|
-
else if (depth === 0 && char === "=" && text[i + 1] === ">")
|
|
466
|
-
return false;
|
|
467
|
-
else if (depth === 0 && char === "|")
|
|
468
|
-
union = true;
|
|
469
|
-
}
|
|
470
|
-
return union;
|
|
471
|
-
}
|
|
472
|
-
function hint(render, cause, ctx) {
|
|
473
|
-
switch (render.kind) {
|
|
474
|
-
case "component":
|
|
475
|
-
return componentHint(cause, render, ctx);
|
|
476
|
-
case "hook":
|
|
477
|
-
return returnsObjectOrUnion(render.fn, ctx)
|
|
478
|
-
? withExample("return the failure as state so the caller can render it:", [
|
|
479
|
-
'return { kind: "failed", error } as const;',
|
|
480
|
-
])
|
|
481
|
-
: "return a value the caller can check instead of throwing";
|
|
482
|
-
default: {
|
|
483
|
-
const unreachable = render.kind;
|
|
484
|
-
return unreachable;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
export const throwInHook = defineRule({
|
|
489
|
-
kind: "file",
|
|
490
|
-
meta: {
|
|
491
|
-
description: "A component or hook that throws while rendering takes down the screen up to the nearest error boundary.",
|
|
492
|
-
defaultSeverity: "warning",
|
|
493
|
-
tier: "addon",
|
|
494
|
-
addon: "hook-throws",
|
|
495
|
-
group: GROUP,
|
|
496
|
-
heuristic: true,
|
|
497
|
-
options: OPTIONS,
|
|
498
|
-
explain: EXPLAIN,
|
|
499
|
-
},
|
|
500
|
-
check(ctx) {
|
|
501
|
-
const visit = (node) => {
|
|
502
|
-
const render = renderOf(node);
|
|
503
|
-
if (render !== undefined)
|
|
504
|
-
checkRender(render, ctx);
|
|
505
|
-
ts.forEachChild(node, visit);
|
|
506
|
-
};
|
|
507
|
-
visit(ctx.sourceFile);
|
|
508
|
-
},
|
|
509
|
-
});
|
|
510
|
-
//# sourceMappingURL=throw-in-hook.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
2
|
-
export declare const GROUP = "inference";
|
|
3
|
-
export declare const throwsMatchesDoc: import("@stateward/core").ProjectRule<import("./index.js").ThrowsSummary, {
|
|
4
|
-
severities: import("@stateward/core").SeveritiesOption<"missing" | "extra">;
|
|
5
|
-
ignoreClasses: import("@stateward/core").StringsOption;
|
|
6
|
-
}, "inference">;
|
|
7
|
-
//# sourceMappingURL=throws-matches-doc.d.ts.map
|