Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "react"],
"jsPlugins": ["./oxlint-plugins/no-thrown-unawaited-redirect.mjs"],
"ignorePatterns": [
"**/dist/**",
"**/build/**",
Expand Down Expand Up @@ -31,6 +32,7 @@
"import/no-duplicates": "error",
"import/namespace": "off",
"react-hooks/exhaustive-deps": "off",
"react-hooks/rules-of-hooks": "off"
"react-hooks/rules-of-hooks": "off",
"trigger/no-thrown-unawaited-redirect": "error"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
);

if (!project) {
throw redirectWithErrorMessage(redirectPath, request, "Project not found");
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
}

const currentPlan = await getCurrentPlan(project.organizationId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
);

if (!project) {
throw redirectWithErrorMessage(redirectPath, request, "Project not found");
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
}

const formData = await request.formData();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
);

if (!project) {
throw redirectWithErrorMessage(redirectPath, request, "Project not found");
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
}

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

if (!parsedFormData.success) {
throw redirectWithErrorMessage(redirectPath, request, "No region specified");
throw await redirectWithErrorMessage(redirectPath, request, "No region specified");
}

const service = new SetDefaultRegionService();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const action = dashboardAction(
});

if (!organization) {
throw redirectWithErrorMessage(form.callerPath, request, "Organization not found");
throw await redirectWithErrorMessage(form.callerPath, request, "Organization not found");
}

let payload: SetPlanBody;
Expand Down Expand Up @@ -139,7 +139,7 @@ export const action = dashboardAction(
}
case "paid": {
if (form.planCode === undefined) {
throw redirectWithErrorMessage(form.callerPath, request, "Not a valid plan");
throw await redirectWithErrorMessage(form.callerPath, request, "Not a valid plan");
}
payload = {
type: "paid" as const,
Expand Down
10 changes: 7 additions & 3 deletions apps/webapp/app/routes/vercel.onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export async function loader({ request }: LoaderFunctionArgs) {

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

if (!params.data.code) {
logger.error("Missing code parameter for Vercel onboarding");
throw redirectWithErrorMessage(
throw await redirectWithErrorMessage(
"/",
request,
"Invalid installation parameters. Please try again from Vercel."
Expand Down Expand Up @@ -151,7 +151,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
organizationId: params.data.organizationId,
userId,
});
throw redirectWithErrorMessage("/", request, "Organization not found. Please try again.");
throw await redirectWithErrorMessage(
"/",
request,
"Organization not found. Please try again."
);
}

return typedjson({
Expand Down
106 changes: 106 additions & 0 deletions oxlint-plugins/no-thrown-unawaited-redirect.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* oxlint custom rule: no-thrown-unawaited-redirect
*
* Catches `throw someRedirectHelper(...)` where the helper is an *async* function
* that returns a Promise<Response> (e.g. `redirectWithErrorMessage`). Throwing the
* un-awaited call throws a *pending Promise* instead of a Response, so Remix renders
* the route's error boundary instead of performing the redirect.
*
* Correct forms are:
* - `throw await redirectWithErrorMessage(...)`
* - `return redirectWithErrorMessage(...)`
*
* Note: the plain synchronous `redirect(...)` from `remix-typedjson` returns a
* `Response` directly, so `throw redirect(...)` is the intended Remix control-flow
* pattern and is intentionally NOT flagged.
*/

// Async redirect helpers that return a Promise. Extend this list as new async
// redirect helpers are added.
const ASYNC_REDIRECT_HELPERS = new Set([
"redirectWithSuccessMessage",
"redirectWithErrorMessage",
"redirectBackWithErrorMessage",
"redirectBackWithSuccessMessage",
"redirectWithImpersonation",
]);

const FUNCTION_TYPES = new Set([
"ArrowFunctionExpression",
"FunctionDeclaration",
"FunctionExpression",
]);

function isInsideAsyncFunction(node, sourceCode) {
const ancestors = sourceCode.getAncestors(node);

for (let index = ancestors.length - 1; index >= 0; index--) {
const ancestor = ancestors[index];

if (FUNCTION_TYPES.has(ancestor.type)) {
return ancestor.async;
}
}

return false;
}

/** @type {import("eslint").Rule.RuleModule} */
const noThrownUnawaitedRedirect = {
meta: {
type: "problem",
docs: {
description:
"Disallow throwing an un-awaited async redirect helper (throws a pending Promise instead of a Response).",
},
fixable: "code",
messages: {
unawaited:
'Throwing an un-awaited "{{name}}()" throws a pending Promise (Remix renders the error boundary instead of redirecting). Use "throw await {{name}}()" or "return {{name}}()".',
},
schema: [],
},
create(context) {
return {
ThrowStatement(node) {
const argument = node.argument;

// Already awaited (`throw await helper()`) -> fine.
if (!argument || argument.type === "AwaitExpression") {
return;
}

// Only care about direct calls: `throw helper(...)`.
if (argument.type !== "CallExpression") {
return;
}

const callee = argument.callee;
if (callee.type !== "Identifier" || !ASYNC_REDIRECT_HELPERS.has(callee.name)) {
return;
}

const canAutofix = isInsideAsyncFunction(node, context.sourceCode);

context.report({
node: argument,
messageId: "unawaited",
data: { name: callee.name },
fix: canAutofix ? (fixer) => fixer.insertTextBefore(argument, "await ") : undefined,
});
},
};
},
};

/** @type {import("eslint").ESLint.Plugin} */
const plugin = {
meta: {
name: "trigger",
},
rules: {
"no-thrown-unawaited-redirect": noThrownUnawaitedRedirect,
},
};

export default plugin;
Comment thread
ericallam marked this conversation as resolved.