From f80e1b5253cc5b48c9411ae6c3bfda7506fbe35a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 09:56:20 +0000 Subject: [PATCH 1/4] Add oxlint rule: no-thrown-unawaited-redirect Adds a custom oxlint JS plugin (trigger/no-thrown-unawaited-redirect) that flags `throw (...)` 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 Claude-Session: https://claude.ai/code/session_01EZ8nrziWFcXhqGewJgo4ei --- .oxlintrc.json | 4 +- .../no-thrown-unawaited-redirect.js | 86 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 oxlint-plugins/no-thrown-unawaited-redirect.js diff --git a/.oxlintrc.json b/.oxlintrc.json index 7eed6b1b82e..937514c1234 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.js"], "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/oxlint-plugins/no-thrown-unawaited-redirect.js b/oxlint-plugins/no-thrown-unawaited-redirect.js new file mode 100644 index 00000000000..e7b77043c9e --- /dev/null +++ b/oxlint-plugins/no-thrown-unawaited-redirect.js @@ -0,0 +1,86 @@ +/** + * 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", +]); + +/** @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; + } + + context.report({ + node: argument, + messageId: "unawaited", + data: { name: callee.name }, + fix(fixer) { + return fixer.insertTextBefore(argument, "await "); + }, + }); + }, + }; + }, +}; + +/** @type {import("eslint").ESLint.Plugin} */ +const plugin = { + meta: { + name: "trigger", + }, + rules: { + "no-thrown-unawaited-redirect": noThrownUnawaitedRedirect, + }, +}; + +export default plugin; From 08ba53a43d1745838073cf0f4a01785548b9da4e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 10:03:00 +0000 Subject: [PATCH 2/4] Fix thrown un-awaited redirect calls flagged by new lint rule Change `throw redirectWithErrorMessage(...)` to `throw await redirectWithErrorMessage(...)` at the 9 sites flagged by the new trigger/no-thrown-unawaited-redirect oxlint rule so the redirect Response is thrown instead of a pending Promise. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EZ8nrziWFcXhqGewJgo4ei --- .../route.tsx | 2 +- .../route.tsx | 2 +- .../route.tsx | 4 ++-- .../routes/resources.orgs.$organizationSlug.select-plan.tsx | 4 ++-- apps/webapp/app/routes/vercel.onboarding.tsx | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) 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..7c899bfc2ab 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,7 @@ 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({ From 96ac097ef815880802d550f4e7c2ab57fa9f2f99 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 10:07:35 +0000 Subject: [PATCH 3/4] Format autofixed redirect throws Wrap the redirect throw in vercel.onboarding.tsx that exceeded the 100-char print width after the await insertion, so oxfmt --check passes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EZ8nrziWFcXhqGewJgo4ei --- apps/webapp/app/routes/vercel.onboarding.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/vercel.onboarding.tsx b/apps/webapp/app/routes/vercel.onboarding.tsx index 7c899bfc2ab..fb4d223978c 100644 --- a/apps/webapp/app/routes/vercel.onboarding.tsx +++ b/apps/webapp/app/routes/vercel.onboarding.tsx @@ -151,7 +151,11 @@ export async function loader({ request }: LoaderFunctionArgs) { organizationId: params.data.organizationId, userId, }); - throw await redirectWithErrorMessage("/", request, "Organization not found. Please try again."); + throw await redirectWithErrorMessage( + "/", + request, + "Organization not found. Please try again." + ); } return typedjson({ From c3ebd91b6ad8ecf65f83ddb6d550b583da95e8c6 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 10 Jul 2026 10:17:00 +0000 Subject: [PATCH 4/4] fix(lint): make redirect autofix safe --- .oxlintrc.json | 2 +- ...ct.js => no-thrown-unawaited-redirect.mjs} | 26 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) rename oxlint-plugins/{no-thrown-unawaited-redirect.js => no-thrown-unawaited-redirect.mjs} (80%) diff --git a/.oxlintrc.json b/.oxlintrc.json index 937514c1234..e32d206dc38 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,7 +1,7 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": ["typescript", "import", "react"], - "jsPlugins": ["./oxlint-plugins/no-thrown-unawaited-redirect.js"], + "jsPlugins": ["./oxlint-plugins/no-thrown-unawaited-redirect.mjs"], "ignorePatterns": [ "**/dist/**", "**/build/**", diff --git a/oxlint-plugins/no-thrown-unawaited-redirect.js b/oxlint-plugins/no-thrown-unawaited-redirect.mjs similarity index 80% rename from oxlint-plugins/no-thrown-unawaited-redirect.js rename to oxlint-plugins/no-thrown-unawaited-redirect.mjs index e7b77043c9e..6b85b738ffd 100644 --- a/oxlint-plugins/no-thrown-unawaited-redirect.js +++ b/oxlint-plugins/no-thrown-unawaited-redirect.mjs @@ -25,6 +25,26 @@ const ASYNC_REDIRECT_HELPERS = new Set([ "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: { @@ -60,13 +80,13 @@ const noThrownUnawaitedRedirect = { return; } + const canAutofix = isInsideAsyncFunction(node, context.sourceCode); + context.report({ node: argument, messageId: "unawaited", data: { name: callee.name }, - fix(fixer) { - return fixer.insertTextBefore(argument, "await "); - }, + fix: canAutofix ? (fixer) => fixer.insertTextBefore(argument, "await ") : undefined, }); }, };