@stateward/plugin-errors 1.0.1-rc.1 → 1.1.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/README.md +60 -3
- package/dist/catch-hides-unexpected.js +47 -58
- package/dist/catch-misses-error.js +57 -67
- package/dist/finally-can-throw.js +45 -54
- package/dist/inference/format.d.ts +3 -3
- package/dist/inference/format.js +5 -5
- package/dist/inference/solve.js +1 -1
- package/dist/inference/summarize.d.ts +1 -1
- package/dist/inference/summarize.js +2 -2
- package/dist/must-handle-error.js +58 -66
- package/dist/narrow-try.d.ts +6 -1
- package/dist/narrow-try.js +137 -77
- package/dist/plugin.d.ts +0 -27
- package/dist/plugin.js +1 -3
- package/dist/react/boundaries.d.ts +3 -3
- package/dist/react/boundaries.js +5 -5
- package/dist/react/fallbacks.js +2 -2
- package/dist/refactors.d.ts +1 -1
- package/dist/refactors.js +1 -1
- package/dist/render-throw-without-boundary.js +64 -47
- package/dist/result-ignored.js +31 -37
- package/dist/result-unchecked.js +58 -61
- package/dist/rules.meta.d.ts +22 -47
- package/dist/rules.meta.js +19 -47
- package/dist/throws-doc-mismatch.js +35 -44
- package/package.json +2 -2
- package/dist/async-handler-unhandled.d.ts +0 -7
- package/dist/async-handler-unhandled.js +0 -342
- package/dist/catch-without-intent.d.ts +0 -10
- package/dist/catch-without-intent.js +0 -590
- 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/no-swallow.js
DELETED
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
import ts from "@stateward/core/typescript";
|
|
2
|
-
import { defineRule, matchesName, option } from "@stateward/core";
|
|
3
|
-
import { withExample } from "./text.js";
|
|
4
|
-
import { stripParentheses } from "./syntax.js";
|
|
5
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
6
|
-
export const GROUP = "catch";
|
|
7
|
-
const EXPLAIN = `# errors/no-swallow
|
|
8
|
-
|
|
9
|
-
A catch that only logs, or does nothing, and then carries on treats a failure as if it had not happened.
|
|
10
|
-
|
|
11
|
-
## Why
|
|
12
|
-
|
|
13
|
-
In \`load\`, a failed \`fetchUser\` is logged and \`render(user)\` runs anyway, with a \`user\` that was never fetched. The page shows a blank profile instead of an error, and the only trace is a line in the console.
|
|
14
|
-
|
|
15
|
-
## When to use it
|
|
16
|
-
|
|
17
|
-
Use it for code that must never lose a failure: payments, sync, data import; or to review a module's catches once. In most apps many catches that only log are deliberate fallbacks (a setting read from storage, a best-effort cache write), so it is off until you add its add-on (below), then a warning in every preset. Make it an error once that code is clean.
|
|
18
|
-
|
|
19
|
-
## What it catches
|
|
20
|
-
|
|
21
|
-
A \`catch\` that is empty or only logs (\`console.*\`, a call on \`logger\` or \`log\`, or \`.error\`, \`.warn\`, \`.info\`, \`.debug\` on any object; the options below change these lists), when the function carries on after the \`try\`.
|
|
22
|
-
|
|
23
|
-
### Bad
|
|
24
|
-
|
|
25
|
-
\`\`\`ts
|
|
26
|
-
async function load(id: string) {
|
|
27
|
-
let user;
|
|
28
|
-
try {
|
|
29
|
-
user = await fetchUser(id);
|
|
30
|
-
} catch (e) {
|
|
31
|
-
console.error(e);
|
|
32
|
-
}
|
|
33
|
-
return render(user);
|
|
34
|
-
}
|
|
35
|
-
\`\`\`
|
|
36
|
-
|
|
37
|
-
### Good
|
|
38
|
-
|
|
39
|
-
\`\`\`ts
|
|
40
|
-
async function load(id: string) {
|
|
41
|
-
try {
|
|
42
|
-
return render(await fetchUser(id));
|
|
43
|
-
} catch (e) {
|
|
44
|
-
return err(e);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
\`\`\`
|
|
48
|
-
|
|
49
|
-
### Fix
|
|
50
|
-
|
|
51
|
-
Rethrow the error, return a failure the caller can see, or show it to the user. The message shows \`throw e;\`, or \`return err(e);\` when the file imports \`err\` from \`@stateward/values\`. There is no quick fix.
|
|
52
|
-
|
|
53
|
-
## What it does not catch
|
|
54
|
-
|
|
55
|
-
It stays quiet when:
|
|
56
|
-
|
|
57
|
-
- the \`catch\` does anything else (\`return\`, \`throw\`, a call that shows the error);
|
|
58
|
-
- an empty \`catch\` holds a comment, which says it is on purpose;
|
|
59
|
-
- the \`try\` returns its value and the code after it returns a default (\`try { return JSON.parse(stored); } catch (e) { console.warn(e); } return defaults;\`);
|
|
60
|
-
- every variable the \`try\` assigns already had a value to fall back on (\`let flags = DEFAULTS; try { flags = JSON.parse(stored); } catch …\`; a parameter counts, a \`let\` with no value does not);
|
|
61
|
-
- the \`try\` is the last thing the function, or a loop's body, does (a retry loop moves on to the next attempt).
|
|
62
|
-
|
|
63
|
-
A function of your own that only logs is not recognised as logging until you list it in \`logFunctions\`.
|
|
64
|
-
|
|
65
|
-
## Turn it on or off
|
|
66
|
-
|
|
67
|
-
Turn it on in \`stateward.config.ts\`:
|
|
68
|
-
|
|
69
|
-
\`\`\`ts
|
|
70
|
-
addons: ["errors/swallow"]
|
|
71
|
-
\`\`\`
|
|
72
|
-
|
|
73
|
-
Then change or turn off the rule itself:
|
|
74
|
-
|
|
75
|
-
\`\`\`ts
|
|
76
|
-
rules: { "errors/no-swallow": "off" } // or "error"
|
|
77
|
-
groups: { "errors/catch": "off" } // the whole group
|
|
78
|
-
rules: { "errors/no-swallow": ["warning", { logFunctions: ["logError"] }] } // your own log helpers
|
|
79
|
-
\`\`\`
|
|
80
|
-
`;
|
|
81
|
-
const OPTIONS = {
|
|
82
|
-
loggers: option.strings({
|
|
83
|
-
default: ["console", "logger", "log"],
|
|
84
|
-
description: "Objects whose every method only logs (globs like *Logger): a catch that only calls them swallows the error.",
|
|
85
|
-
}),
|
|
86
|
-
logMethods: option.strings({
|
|
87
|
-
default: ["error", "warn", "info", "debug"],
|
|
88
|
-
description: "Method names that only log, on any object (`Sentry.captureException` is not one).",
|
|
89
|
-
}),
|
|
90
|
-
logFunctions: option.strings({
|
|
91
|
-
default: [],
|
|
92
|
-
description: "Plain functions of your own that only log, like logError (globs allowed).",
|
|
93
|
-
}),
|
|
94
|
-
};
|
|
95
|
-
/** The name an expression ends in: `logger`, `this.logger`, `app.log`. */
|
|
96
|
-
function lastName(expression) {
|
|
97
|
-
const node = stripParentheses(expression);
|
|
98
|
-
if (ts.isIdentifier(node))
|
|
99
|
-
return node.text;
|
|
100
|
-
return ts.isPropertyAccessExpression(node) ? node.name.text : undefined;
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* A call that only logs: any method of a logger (`console.x()`, `logger.x()`), a log method on any
|
|
104
|
-
* object (`app.warn()`), or one of the team's own log functions (`logError(e)`).
|
|
105
|
-
*/
|
|
106
|
-
function isLoggingCall(expression, logging) {
|
|
107
|
-
const call = stripParentheses(expression);
|
|
108
|
-
if (!ts.isCallExpression(call))
|
|
109
|
-
return false;
|
|
110
|
-
const callee = stripParentheses(call.expression);
|
|
111
|
-
if (ts.isIdentifier(callee))
|
|
112
|
-
return matchesName(callee.text, logging.logFunctions);
|
|
113
|
-
if (!ts.isPropertyAccessExpression(callee))
|
|
114
|
-
return false;
|
|
115
|
-
if (logging.logMethods.includes(callee.name.text))
|
|
116
|
-
return true;
|
|
117
|
-
const name = lastName(stripParentheses(callee.expression));
|
|
118
|
-
return name !== undefined && matchesName(name, logging.loggers);
|
|
119
|
-
}
|
|
120
|
-
/** What the catch does: nothing, or only log. Undefined when it does anything else. */
|
|
121
|
-
function swallowKind(block, sourceFile, logging) {
|
|
122
|
-
const statements = block.statements.filter((statement) => !ts.isEmptyStatement(statement));
|
|
123
|
-
if (statements.length === 0) {
|
|
124
|
-
// A comment inside an empty catch says it is on purpose.
|
|
125
|
-
return hasComment(block, sourceFile) ? undefined : "ignores";
|
|
126
|
-
}
|
|
127
|
-
const onlyLogs = statements.every((statement) => ts.isExpressionStatement(statement) && isLoggingCall(statement.expression, logging));
|
|
128
|
-
return onlyLogs ? "logs" : undefined;
|
|
129
|
-
}
|
|
130
|
-
function hasComment(block, sourceFile) {
|
|
131
|
-
const inner = sourceFile.text.slice(block.getStart(sourceFile) + 1, block.getEnd() - 1);
|
|
132
|
-
return inner.trim() !== "";
|
|
133
|
-
}
|
|
134
|
-
/** Statements that run nothing: a bare `;` and declarations that only exist at compile time. */
|
|
135
|
-
function runsNothing(statement) {
|
|
136
|
-
return (ts.isEmptyStatement(statement) ||
|
|
137
|
-
ts.isFunctionDeclaration(statement) ||
|
|
138
|
-
ts.isInterfaceDeclaration(statement) ||
|
|
139
|
-
ts.isTypeAliasDeclaration(statement));
|
|
140
|
-
}
|
|
141
|
-
/** `return;`, `break;` and `continue;` end the flow: nothing after them carries on. */
|
|
142
|
-
function endsFlow(statement) {
|
|
143
|
-
if (ts.isReturnStatement(statement))
|
|
144
|
-
return statement.expression === undefined;
|
|
145
|
-
return ts.isBreakStatement(statement) || ts.isContinueStatement(statement);
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* Whether the function runs something after the `try` statement: a statement that follows it in
|
|
149
|
-
* its block or in a block around it, up to the function (or the file, for top-level code). The end
|
|
150
|
-
* of a loop's body runs nothing after it: the loop goes on to its next turn (a retry loop's next
|
|
151
|
-
* attempt).
|
|
152
|
-
*/
|
|
153
|
-
function continuesAfter(tryStatement) {
|
|
154
|
-
let node = tryStatement;
|
|
155
|
-
for (let parent = node.parent;; parent = node.parent) {
|
|
156
|
-
if (ts.isFunctionLike(parent))
|
|
157
|
-
return false;
|
|
158
|
-
if (ts.isBlock(parent) || ts.isSourceFile(parent) || ts.isCaseOrDefaultClause(parent)) {
|
|
159
|
-
const statements = parent.statements;
|
|
160
|
-
const after = statements.slice(statements.findIndex((candidate) => candidate === node) + 1);
|
|
161
|
-
const next = after.find((statement) => !runsNothing(statement));
|
|
162
|
-
if (next !== undefined)
|
|
163
|
-
return !endsFlow(next);
|
|
164
|
-
}
|
|
165
|
-
if (ts.isSourceFile(parent))
|
|
166
|
-
return false;
|
|
167
|
-
if (ts.isIterationStatement(parent, false) && parent.statement === node)
|
|
168
|
-
return false;
|
|
169
|
-
node = parent;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
/** Whether `node` has a `return` with a value that runs in this function (not in a nested one). */
|
|
173
|
-
function returnsValue(node) {
|
|
174
|
-
if (ts.isReturnStatement(node))
|
|
175
|
-
return node.expression !== undefined;
|
|
176
|
-
if (ts.isFunctionLike(node) || ts.isClassLike(node))
|
|
177
|
-
return false;
|
|
178
|
-
return ts.forEachChild(node, (child) => (returnsValue(child) ? true : undefined)) === true;
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* A fallback: the `try` returns the value it produced, and the statement right after it returns
|
|
182
|
-
* another one (`try { return JSON.parse(stored); } catch (e) { console.warn(e); } return
|
|
183
|
-
* defaults;`). The caller gets a value either way; nothing runs on a result that was never made.
|
|
184
|
-
*/
|
|
185
|
-
function fallsBackToAValue(tryStatement) {
|
|
186
|
-
const list = tryStatement.parent;
|
|
187
|
-
if (!ts.isBlock(list) && !ts.isSourceFile(list) && !ts.isCaseOrDefaultClause(list))
|
|
188
|
-
return false;
|
|
189
|
-
const after = list.statements.slice(list.statements.indexOf(tryStatement) + 1);
|
|
190
|
-
const next = after.find((statement) => !runsNothing(statement));
|
|
191
|
-
return (next !== undefined &&
|
|
192
|
-
ts.isReturnStatement(next) &&
|
|
193
|
-
next.expression !== undefined &&
|
|
194
|
-
returnsValue(tryStatement.tryBlock));
|
|
195
|
-
}
|
|
196
|
-
/** The names the `try` block assigns with `=` (not in a nested function): `flags = parse(…)`. */
|
|
197
|
-
function assignedNames(block) {
|
|
198
|
-
const names = [];
|
|
199
|
-
const visit = (node) => {
|
|
200
|
-
if (ts.isFunctionLike(node) || ts.isClassLike(node))
|
|
201
|
-
return;
|
|
202
|
-
if (ts.isBinaryExpression(node) &&
|
|
203
|
-
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
204
|
-
ts.isIdentifier(stripParentheses(node.left))) {
|
|
205
|
-
const target = stripParentheses(node.left);
|
|
206
|
-
if (ts.isIdentifier(target))
|
|
207
|
-
names.push(target.text);
|
|
208
|
-
}
|
|
209
|
-
ts.forEachChild(node, visit);
|
|
210
|
-
};
|
|
211
|
-
visit(block);
|
|
212
|
-
return names;
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* Whether `name` already holds a value before the `try`: a `let` or `var` declared with an
|
|
216
|
-
* initializer in a block around it (before it, up to the module), or a parameter of a function
|
|
217
|
-
* around it.
|
|
218
|
-
*/
|
|
219
|
-
function holdsAValueBefore(name, tryStatement) {
|
|
220
|
-
let node = tryStatement;
|
|
221
|
-
for (let parent = node.parent;; parent = node.parent) {
|
|
222
|
-
if (ts.isBlock(parent) || ts.isSourceFile(parent) || ts.isCaseOrDefaultClause(parent)) {
|
|
223
|
-
const before = parent.statements.slice(0, parent.statements.findIndex((s) => s === node));
|
|
224
|
-
for (const statement of before) {
|
|
225
|
-
if (!ts.isVariableStatement(statement))
|
|
226
|
-
continue;
|
|
227
|
-
for (const declaration of statement.declarationList.declarations) {
|
|
228
|
-
if (ts.isIdentifier(declaration.name) && declaration.name.text === name) {
|
|
229
|
-
return declaration.initializer !== undefined;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
// A parameter holds the caller's value; past it, a closure's or the module's variable.
|
|
235
|
-
if (ts.isFunctionLike(parent) &&
|
|
236
|
-
parent.parameters.some((parameter) => ts.isIdentifier(parameter.name) && parameter.name.text === name)) {
|
|
237
|
-
return true;
|
|
238
|
-
}
|
|
239
|
-
if (ts.isSourceFile(parent))
|
|
240
|
-
return false;
|
|
241
|
-
node = parent;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* A fallback assigned before the `try`: every variable the `try` assigns already held a value
|
|
246
|
-
* (`let flags = DEFAULTS; try { flags = parse(…); } catch (e) { log(e); } return flags;`). The
|
|
247
|
-
* code after the `try` runs on that value when the `try` fails, which is what the author chose.
|
|
248
|
-
*/
|
|
249
|
-
function replacesAFallback(tryStatement) {
|
|
250
|
-
const names = assignedNames(tryStatement.tryBlock);
|
|
251
|
-
return names.length > 0 && names.every((name) => holdsAValueBefore(name, tryStatement));
|
|
252
|
-
}
|
|
253
|
-
function headline(kind) {
|
|
254
|
-
return `this catch ${kind} the error and carries on as if nothing failed`;
|
|
255
|
-
}
|
|
256
|
-
/** The local name of `err` when the file imports it from `@stateward/values`. */
|
|
257
|
-
function importedErr(sourceFile) {
|
|
258
|
-
for (const statement of sourceFile.statements) {
|
|
259
|
-
if (!ts.isImportDeclaration(statement) ||
|
|
260
|
-
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
261
|
-
statement.moduleSpecifier.text !== "@stateward/values" ||
|
|
262
|
-
statement.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword) {
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
const bindings = statement.importClause?.namedBindings;
|
|
266
|
-
if (bindings === undefined || !ts.isNamedImports(bindings))
|
|
267
|
-
continue;
|
|
268
|
-
const element = bindings.elements.find((candidate) => !candidate.isTypeOnly && (candidate.propertyName ?? candidate.name).text === "err");
|
|
269
|
-
if (element !== undefined)
|
|
270
|
-
return element.name.text;
|
|
271
|
-
}
|
|
272
|
-
return undefined;
|
|
273
|
-
}
|
|
274
|
-
/** The example uses only what the file has: `err` when it imports it, else a plain rethrow. */
|
|
275
|
-
function hint(variable, sourceFile) {
|
|
276
|
-
const err = importedErr(sourceFile);
|
|
277
|
-
return withExample("rethrow it, return a failure the caller can see, or show it to the user:", [
|
|
278
|
-
err === undefined ? `throw ${variable};` : `return ${err}(${variable});`,
|
|
279
|
-
]);
|
|
280
|
-
}
|
|
281
|
-
function checkCatch(clause, ctx) {
|
|
282
|
-
const kind = swallowKind(clause.block, ctx.sourceFile, ctx.options);
|
|
283
|
-
if (kind === undefined ||
|
|
284
|
-
!continuesAfter(clause.parent) ||
|
|
285
|
-
fallsBackToAValue(clause.parent) ||
|
|
286
|
-
replacesAFallback(clause.parent)) {
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
const declaration = clause.variableDeclaration;
|
|
290
|
-
const variable = declaration !== undefined && ts.isIdentifier(declaration.name)
|
|
291
|
-
? declaration.name.text
|
|
292
|
-
: "error";
|
|
293
|
-
ctx.report({
|
|
294
|
-
node: clause.getFirstToken(ctx.sourceFile) ?? clause,
|
|
295
|
-
message: headline(kind),
|
|
296
|
-
hint: hint(variable, ctx.sourceFile),
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
export const noSwallow = defineRule({
|
|
300
|
-
kind: "file",
|
|
301
|
-
meta: {
|
|
302
|
-
description: "A catch that only logs, or does nothing, and then carries on treats a failure as if it had not happened.",
|
|
303
|
-
defaultSeverity: "warning",
|
|
304
|
-
tier: "addon",
|
|
305
|
-
addon: "swallow",
|
|
306
|
-
group: GROUP,
|
|
307
|
-
heuristic: true,
|
|
308
|
-
options: OPTIONS,
|
|
309
|
-
explain: EXPLAIN,
|
|
310
|
-
},
|
|
311
|
-
check(ctx) {
|
|
312
|
-
const visit = (node) => {
|
|
313
|
-
if (ts.isCatchClause(node))
|
|
314
|
-
checkCatch(node, ctx);
|
|
315
|
-
ts.forEachChild(node, visit);
|
|
316
|
-
};
|
|
317
|
-
visit(ctx.sourceFile);
|
|
318
|
-
},
|
|
319
|
-
});
|
|
320
|
-
//# sourceMappingURL=no-swallow.js.map
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
2
|
-
export declare const GROUP = "react";
|
|
3
|
-
export declare const renderThrowsWithoutBoundary: import("@stateward/core").ProjectRule<import("./react/boundaries.js").ReactSummary, {
|
|
4
|
-
boundaries: import("@stateward/core").RecordOption;
|
|
5
|
-
}, "react">;
|
|
6
|
-
//# sourceMappingURL=render-throws-without-boundary.d.ts.map
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
import { defineRule, highestUncovered, option, shortestPath } from "@stateward/core";
|
|
2
|
-
import { className } from "./inference/format.js";
|
|
3
|
-
import { modelOf, summarizeReact } from "./react/boundaries.js";
|
|
4
|
-
import { LIBRARY_BOUNDARIES } from "./react/tree.js";
|
|
5
|
-
import { HEADLINE_MAX, codeList, fitted, withExample } from "./text.js";
|
|
6
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
7
|
-
export const GROUP = "react";
|
|
8
|
-
const EXPLAIN = `# errors/render-throws-without-boundary
|
|
9
|
-
|
|
10
|
-
A component that can throw a known error while rendering has an error boundary above it.
|
|
11
|
-
|
|
12
|
-
## Why
|
|
13
|
-
|
|
14
|
-
When a component throws while React renders it, or in one of its effects, React unmounts everything up to the nearest error boundary. With no boundary above, the whole app goes blank. A \`<UserPage>\` whose \`useUser\` throws \`NotFoundError\` for a wrong id takes the menu, the header and every other route down with it, where a boundary could have shown "this user does not exist".
|
|
15
|
-
|
|
16
|
-
## When to use it
|
|
17
|
-
|
|
18
|
-
Use it in React apps whose code throws its own error classes. It is a core rule, a warning in every preset: whether a page should handle an error itself or let a boundary do it is your call. If every throw in render is a bug you want to see crash in development, turn it off.
|
|
19
|
-
|
|
20
|
-
## What it catches
|
|
21
|
-
|
|
22
|
-
A place that renders a component which can throw a known error while rendering, with no error boundary above it on the way from your entry point (\`createRoot(…).render(…)\` or \`hydrateRoot\`). The component counts what its body and its hooks throw, what its \`useEffect\` and \`useLayoutEffect\` callbacks throw, and what the components it renders outside a boundary throw. A boundary is react-error-boundary's \`<ErrorBoundary>\` (the \`boundaries\` option adds other packages' boundaries), or a class of yours with \`getDerivedStateFromError\` or \`componentDidCatch\`.
|
|
23
|
-
|
|
24
|
-
It is reported once, at the highest place where the boundary is missing, with the path down to the call that throws. With a boundary around the whole app, it says nothing.
|
|
25
|
-
|
|
26
|
-
### Bad
|
|
27
|
-
|
|
28
|
-
\`\`\`tsx
|
|
29
|
-
// useUser throws NotFoundError for an id it does not know.
|
|
30
|
-
createRoot(root).render(<App />); // App renders <UserPage /> for /users/:id
|
|
31
|
-
\`\`\`
|
|
32
|
-
|
|
33
|
-
### Good
|
|
34
|
-
|
|
35
|
-
\`\`\`tsx
|
|
36
|
-
createRoot(root).render(
|
|
37
|
-
<ErrorBoundary FallbackComponent={ErrorPage}>
|
|
38
|
-
<App />
|
|
39
|
-
</ErrorBoundary>,
|
|
40
|
-
);
|
|
41
|
-
\`\`\`
|
|
42
|
-
|
|
43
|
-
### Fix
|
|
44
|
-
|
|
45
|
-
Put an error boundary above the place the message names, or nearer to the component that throws (around one route). There is no quick fix: where the boundary goes, and what it shows, is your call.
|
|
46
|
-
|
|
47
|
-
## What it does not catch
|
|
48
|
-
|
|
49
|
-
- Errors in event handlers, timers and async code: a boundary never catches them, so they are not counted. \`errors/async-handler-unhandled\` checks async handlers.
|
|
50
|
-
- Errors stateward cannot see (a package it cannot read, a callback, a dynamic call): it is about known error classes, so these never make a finding. The hover on a boundary lists them.
|
|
51
|
-
- An app with no \`createRoot(…).render(…)\` or \`hydrateRoot\` it can find: nothing is checked.
|
|
52
|
-
- Routes in \`createBrowserRouter([...])\` route objects: React Router catches their errors itself (\`errorElement\`). \`memo\`, \`forwardRef\` and \`lazy\` components, and boundaries from other packages you have not listed in \`boundaries\`, are not followed.
|
|
53
|
-
- An element passed to a function or stored in an object or array (\`showToast(<UserPage />)\`): where it renders cannot be followed, so it never makes a finding. An element held in a local \`const\` or \`let\` is checked where the variable is rendered.
|
|
54
|
-
- A class that a \`try\` in the component may catch (with a test other than \`instanceof\`): it is not counted.
|
|
55
|
-
|
|
56
|
-
## Turn it on or off
|
|
57
|
-
|
|
58
|
-
In \`stateward.config.ts\`:
|
|
59
|
-
|
|
60
|
-
\`\`\`ts
|
|
61
|
-
rules: { "errors/render-throws-without-boundary": "off" } // or "error"
|
|
62
|
-
groups: { "errors/react": "off" } // the whole group
|
|
63
|
-
rules: {
|
|
64
|
-
"errors/render-throws-without-boundary": [
|
|
65
|
-
"warning",
|
|
66
|
-
{ boundaries: { "react-error-boundary": ["ErrorBoundary"], "@sentry/react": ["ErrorBoundary"] } },
|
|
67
|
-
],
|
|
68
|
-
} // Sentry's boundary counts too
|
|
69
|
-
\`\`\`
|
|
70
|
-
`;
|
|
71
|
-
/** At most this many notes; a longer path keeps its first steps and its last. */
|
|
72
|
-
const MAX_NOTES = 5;
|
|
73
|
-
function headline(component, names) {
|
|
74
|
-
return fitted(HEADLINE_MAX, (cut) => `\`<${cut(component)}>\` can throw ${codeList(names, cut)} while rendering, with no error boundary above it`);
|
|
75
|
-
}
|
|
76
|
-
function hint(component) {
|
|
77
|
-
return withExample("wrap it in an error boundary, here or nearer to what throws; for example with react-error-boundary:", ["<ErrorBoundary FallbackComponent={ErrorPage}>", ` <${component} />`, "</ErrorBoundary>"]);
|
|
78
|
-
}
|
|
79
|
-
/** The renders down from the site to the component that throws `cls`, then the call that throws it. */
|
|
80
|
-
function pathNotes(model, site, cls) {
|
|
81
|
-
const classes = model.analysis.classes;
|
|
82
|
-
const path = shortestPath(site.to, (id) => model.edges(id), (edge) => model.nearest(edge) === undefined && model.needs(edge.to).has(cls), (id) => model.own(id).classes.get(cls)?.certain === true);
|
|
83
|
-
if (path === undefined)
|
|
84
|
-
return [];
|
|
85
|
-
const notes = path.map((edge) => ({
|
|
86
|
-
location: { file: edge.file, start: edge.render.at[0], end: edge.render.at[1] },
|
|
87
|
-
message: `\`<${edge.render.name}>\` is rendered here`,
|
|
88
|
-
}));
|
|
89
|
-
const thrower = path.at(-1)?.to ?? site.to;
|
|
90
|
-
const brought = model.own(thrower).classes.get(cls);
|
|
91
|
-
if (brought !== undefined) {
|
|
92
|
-
notes.push({
|
|
93
|
-
location: brought.location,
|
|
94
|
-
message: `${brought.what} \`${className(cls, classes)}\``,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
if (notes.length <= MAX_NOTES)
|
|
98
|
-
return notes;
|
|
99
|
-
const skipped = notes.length - (MAX_NOTES - 1);
|
|
100
|
-
const last = notes.at(-1);
|
|
101
|
-
const head = notes.slice(0, MAX_NOTES - 2);
|
|
102
|
-
const first = notes[MAX_NOTES - 2];
|
|
103
|
-
return first === undefined || last === undefined
|
|
104
|
-
? head
|
|
105
|
-
: [...head, { location: first.location, message: `… ${String(skipped)} more steps …` }, last];
|
|
106
|
-
}
|
|
107
|
-
export const renderThrowsWithoutBoundary = defineRule({
|
|
108
|
-
kind: "project",
|
|
109
|
-
meta: {
|
|
110
|
-
description: "A component that can throw a known error while rendering has an error boundary above it.",
|
|
111
|
-
defaultSeverity: "warning",
|
|
112
|
-
tier: "core",
|
|
113
|
-
group: GROUP,
|
|
114
|
-
needsJsDoc: true,
|
|
115
|
-
options: {
|
|
116
|
-
boundaries: option.record({
|
|
117
|
-
default: LIBRARY_BOUNDARIES,
|
|
118
|
-
description: 'Error boundary components from packages, by package: add Sentry\'s with `{ "@sentry/react": ["ErrorBoundary"] }` (keep react-error-boundary\'s to keep it).',
|
|
119
|
-
}),
|
|
120
|
-
},
|
|
121
|
-
explain: EXPLAIN,
|
|
122
|
-
},
|
|
123
|
-
summarize: (ctx) => summarizeReact(ctx, ctx.options.boundaries),
|
|
124
|
-
check(summaries, ctx) {
|
|
125
|
-
const model = modelOf(summaries);
|
|
126
|
-
const classes = model.analysis.classes;
|
|
127
|
-
const found = highestUncovered({
|
|
128
|
-
roots: model.entries,
|
|
129
|
-
edges: (id) => model.edges(id),
|
|
130
|
-
covered: (edge) => model.nearest(edge) !== undefined,
|
|
131
|
-
needs: (id) => model.needs(id),
|
|
132
|
-
key: (edge) => `${edge.file}:${String(edge.render.at[0])}`,
|
|
133
|
-
});
|
|
134
|
-
for (const { edge, needs } of found) {
|
|
135
|
-
const named = needs
|
|
136
|
-
.map((id) => ({ id, name: className(id, classes) }))
|
|
137
|
-
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
138
|
-
const [first] = named;
|
|
139
|
-
if (first === undefined)
|
|
140
|
-
continue;
|
|
141
|
-
const component = model.functions.get(edge.to)?.fn.name ?? edge.render.name;
|
|
142
|
-
ctx.report({
|
|
143
|
-
message: headline(component, named.map((one) => one.name)),
|
|
144
|
-
hint: hint(edge.render.name),
|
|
145
|
-
location: { file: edge.file, start: edge.render.at[0], end: edge.render.at[1] },
|
|
146
|
-
related: pathNotes(model, edge, first.id),
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
},
|
|
150
|
-
});
|
|
151
|
-
//# sourceMappingURL=render-throws-without-boundary.js.map
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
/** The group the rule belongs to in the docs and in `stateward status`. */
|
|
2
|
-
export declare const GROUP = "catch";
|
|
3
|
-
export declare const rethrowUnknown: import("@stateward/core").FileRule<{
|
|
4
|
-
restUsesError: import("@stateward/core").BooleanOption;
|
|
5
|
-
}, "catch">;
|
|
6
|
-
//# sourceMappingURL=rethrow-unknown.d.ts.map
|