Skip to content

Commit de53662

Browse files
claude[bot]claudeericallam
authored
Add oxlint rule to catch thrown un-awaited redirect helpers (#4222)
## ✅ Checklist - [x] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) - [x] The PR title follows the convention. - [x] I ran and tested the code works --- ## Testing - Verified the rule emits exactly five errors for un-awaited throws of the known async redirect helpers while ignoring awaited throws, returned promises, and synchronous `redirect(...)`. - Verified `--fix` inserts `await` in async functions and produces a clean second lint run. - Verified synchronous functions remain diagnostic-only so autofix cannot introduce invalid syntax. - Ran `pnpm run format`, `pnpm run lint`, `pnpm run typecheck --filter webapp`, and `git diff --check`. --- ## Changelog Adds an Oxlint rule that prevents async redirect helpers from being thrown without awaiting their `Response`. Existing violations are fixed, the autofix is limited to async functions, and the plugin uses an explicit ESM extension. --- ## Screenshots See the test-results comment for CLI evidence. 💯 Link to Devin session: https://app.devin.ai/sessions/e60ad7610773401da3d3040cf1252337 Requested by: @ericallam --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Eric Allam <eric@trigger.dev>
1 parent b4866f0 commit de53662

7 files changed

Lines changed: 122 additions & 10 deletions

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.mjs"],
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
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
134134
);
135135

136136
if (!project) {
137-
throw redirectWithErrorMessage(redirectPath, request, "Project not found");
137+
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
138138
}
139139

140140
const currentPlan = await getCurrentPlan(project.organizationId);

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
155155
);
156156

157157
if (!project) {
158-
throw redirectWithErrorMessage(redirectPath, request, "Project not found");
158+
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
159159
}
160160

161161
const formData = await request.formData();

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
103103
);
104104

105105
if (!project) {
106-
throw redirectWithErrorMessage(redirectPath, request, "Project not found");
106+
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
107107
}
108108

109109
const formData = await request.formData();
110110
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));
111111

112112
if (!parsedFormData.success) {
113-
throw redirectWithErrorMessage(redirectPath, request, "No region specified");
113+
throw await redirectWithErrorMessage(redirectPath, request, "No region specified");
114114
}
115115

116116
const service = new SetDefaultRegionService();

apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const action = dashboardAction(
8080
});
8181

8282
if (!organization) {
83-
throw redirectWithErrorMessage(form.callerPath, request, "Organization not found");
83+
throw await redirectWithErrorMessage(form.callerPath, request, "Organization not found");
8484
}
8585

8686
let payload: SetPlanBody;
@@ -139,7 +139,7 @@ export const action = dashboardAction(
139139
}
140140
case "paid": {
141141
if (form.planCode === undefined) {
142-
throw redirectWithErrorMessage(form.callerPath, request, "Not a valid plan");
142+
throw await redirectWithErrorMessage(form.callerPath, request, "Not a valid plan");
143143
}
144144
payload = {
145145
type: "paid" as const,

apps/webapp/app/routes/vercel.onboarding.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
6969

7070
if (!params.success) {
7171
logger.error("Invalid params for Vercel onboarding", { error: params.error });
72-
throw redirectWithErrorMessage(
72+
throw await redirectWithErrorMessage(
7373
"/",
7474
request,
7575
"Invalid installation parameters. Please try again from Vercel."
@@ -89,7 +89,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
8989

9090
if (!params.data.code) {
9191
logger.error("Missing code parameter for Vercel onboarding");
92-
throw redirectWithErrorMessage(
92+
throw await redirectWithErrorMessage(
9393
"/",
9494
request,
9595
"Invalid installation parameters. Please try again from Vercel."
@@ -151,7 +151,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
151151
organizationId: params.data.organizationId,
152152
userId,
153153
});
154-
throw redirectWithErrorMessage("/", request, "Organization not found. Please try again.");
154+
throw await redirectWithErrorMessage(
155+
"/",
156+
request,
157+
"Organization not found. Please try again."
158+
);
155159
}
156160

157161
return typedjson({
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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+
const FUNCTION_TYPES = new Set([
29+
"ArrowFunctionExpression",
30+
"FunctionDeclaration",
31+
"FunctionExpression",
32+
]);
33+
34+
function isInsideAsyncFunction(node, sourceCode) {
35+
const ancestors = sourceCode.getAncestors(node);
36+
37+
for (let index = ancestors.length - 1; index >= 0; index--) {
38+
const ancestor = ancestors[index];
39+
40+
if (FUNCTION_TYPES.has(ancestor.type)) {
41+
return ancestor.async;
42+
}
43+
}
44+
45+
return false;
46+
}
47+
48+
/** @type {import("eslint").Rule.RuleModule} */
49+
const noThrownUnawaitedRedirect = {
50+
meta: {
51+
type: "problem",
52+
docs: {
53+
description:
54+
"Disallow throwing an un-awaited async redirect helper (throws a pending Promise instead of a Response).",
55+
},
56+
fixable: "code",
57+
messages: {
58+
unawaited:
59+
'Throwing an un-awaited "{{name}}()" throws a pending Promise (Remix renders the error boundary instead of redirecting). Use "throw await {{name}}()" or "return {{name}}()".',
60+
},
61+
schema: [],
62+
},
63+
create(context) {
64+
return {
65+
ThrowStatement(node) {
66+
const argument = node.argument;
67+
68+
// Already awaited (`throw await helper()`) -> fine.
69+
if (!argument || argument.type === "AwaitExpression") {
70+
return;
71+
}
72+
73+
// Only care about direct calls: `throw helper(...)`.
74+
if (argument.type !== "CallExpression") {
75+
return;
76+
}
77+
78+
const callee = argument.callee;
79+
if (callee.type !== "Identifier" || !ASYNC_REDIRECT_HELPERS.has(callee.name)) {
80+
return;
81+
}
82+
83+
const canAutofix = isInsideAsyncFunction(node, context.sourceCode);
84+
85+
context.report({
86+
node: argument,
87+
messageId: "unawaited",
88+
data: { name: callee.name },
89+
fix: canAutofix ? (fixer) => fixer.insertTextBefore(argument, "await ") : undefined,
90+
});
91+
},
92+
};
93+
},
94+
};
95+
96+
/** @type {import("eslint").ESLint.Plugin} */
97+
const plugin = {
98+
meta: {
99+
name: "trigger",
100+
},
101+
rules: {
102+
"no-thrown-unawaited-redirect": noThrownUnawaitedRedirect,
103+
},
104+
};
105+
106+
export default plugin;

0 commit comments

Comments
 (0)