diff --git a/.oxlintrc.json b/.oxlintrc.json index 7eed6b1b82e..e32d206dc38 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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/**", @@ -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" } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index 67ae202d64e..8c1de33c6f6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -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); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx index edd8cce1737..1bc359af598 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.concurrency/route.tsx @@ -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(); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 783b60cb1ca..3ca761e4cd1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -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(); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 7414960f47a..3077a0838cb 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -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; @@ -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, diff --git a/apps/webapp/app/routes/vercel.onboarding.tsx b/apps/webapp/app/routes/vercel.onboarding.tsx index 61ffdb637a7..fb4d223978c 100644 --- a/apps/webapp/app/routes/vercel.onboarding.tsx +++ b/apps/webapp/app/routes/vercel.onboarding.tsx @@ -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." @@ -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." @@ -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({ diff --git a/oxlint-plugins/no-thrown-unawaited-redirect.mjs b/oxlint-plugins/no-thrown-unawaited-redirect.mjs new file mode 100644 index 00000000000..6b85b738ffd --- /dev/null +++ b/oxlint-plugins/no-thrown-unawaited-redirect.mjs @@ -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 (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;