Skip to content

Commit f80e1b5

Browse files
committed
Add oxlint rule: no-thrown-unawaited-redirect
Adds a custom oxlint JS plugin (trigger/no-thrown-unawaited-redirect) that flags `throw <asyncRedirectHelper>(...)` on ThrowStatement. These helpers return a Promise, so throwing them un-awaited throws a pending Promise and Remix renders the error boundary instead of redirecting. The rule provides an autofix that inserts `await`. Plain synchronous `throw redirect(...)` is not flagged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ8nrziWFcXhqGewJgo4ei
1 parent 9f76c92 commit f80e1b5

2 files changed

Lines changed: 89 additions & 1 deletion

File tree

.oxlintrc.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"$schema": "./node_modules/oxlint/configuration_schema.json",
33
"plugins": ["typescript", "import", "react"],
4+
"jsPlugins": ["./oxlint-plugins/no-thrown-unawaited-redirect.js"],
45
"ignorePatterns": [
56
"**/dist/**",
67
"**/build/**",
@@ -31,6 +32,7 @@
3132
"import/no-duplicates": "error",
3233
"import/namespace": "off",
3334
"react-hooks/exhaustive-deps": "off",
34-
"react-hooks/rules-of-hooks": "off"
35+
"react-hooks/rules-of-hooks": "off",
36+
"trigger/no-thrown-unawaited-redirect": "error"
3537
}
3638
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* oxlint custom rule: no-thrown-unawaited-redirect
3+
*
4+
* Catches `throw someRedirectHelper(...)` where the helper is an *async* function
5+
* that returns a Promise<Response> (e.g. `redirectWithErrorMessage`). Throwing the
6+
* un-awaited call throws a *pending Promise* instead of a Response, so Remix renders
7+
* the route's error boundary instead of performing the redirect.
8+
*
9+
* Correct forms are:
10+
* - `throw await redirectWithErrorMessage(...)`
11+
* - `return redirectWithErrorMessage(...)`
12+
*
13+
* Note: the plain synchronous `redirect(...)` from `remix-typedjson` returns a
14+
* `Response` directly, so `throw redirect(...)` is the intended Remix control-flow
15+
* pattern and is intentionally NOT flagged.
16+
*/
17+
18+
// Async redirect helpers that return a Promise. Extend this list as new async
19+
// redirect helpers are added.
20+
const ASYNC_REDIRECT_HELPERS = new Set([
21+
"redirectWithSuccessMessage",
22+
"redirectWithErrorMessage",
23+
"redirectBackWithErrorMessage",
24+
"redirectBackWithSuccessMessage",
25+
"redirectWithImpersonation",
26+
]);
27+
28+
/** @type {import("eslint").Rule.RuleModule} */
29+
const noThrownUnawaitedRedirect = {
30+
meta: {
31+
type: "problem",
32+
docs: {
33+
description:
34+
"Disallow throwing an un-awaited async redirect helper (throws a pending Promise instead of a Response).",
35+
},
36+
fixable: "code",
37+
messages: {
38+
unawaited:
39+
'Throwing an un-awaited "{{name}}()" throws a pending Promise (Remix renders the error boundary instead of redirecting). Use "throw await {{name}}()" or "return {{name}}()".',
40+
},
41+
schema: [],
42+
},
43+
create(context) {
44+
return {
45+
ThrowStatement(node) {
46+
const argument = node.argument;
47+
48+
// Already awaited (`throw await helper()`) -> fine.
49+
if (!argument || argument.type === "AwaitExpression") {
50+
return;
51+
}
52+
53+
// Only care about direct calls: `throw helper(...)`.
54+
if (argument.type !== "CallExpression") {
55+
return;
56+
}
57+
58+
const callee = argument.callee;
59+
if (callee.type !== "Identifier" || !ASYNC_REDIRECT_HELPERS.has(callee.name)) {
60+
return;
61+
}
62+
63+
context.report({
64+
node: argument,
65+
messageId: "unawaited",
66+
data: { name: callee.name },
67+
fix(fixer) {
68+
return fixer.insertTextBefore(argument, "await ");
69+
},
70+
});
71+
},
72+
};
73+
},
74+
};
75+
76+
/** @type {import("eslint").ESLint.Plugin} */
77+
const plugin = {
78+
meta: {
79+
name: "trigger",
80+
},
81+
rules: {
82+
"no-thrown-unawaited-redirect": noThrownUnawaitedRedirect,
83+
},
84+
};
85+
86+
export default plugin;

0 commit comments

Comments
 (0)