diff --git a/integration/vite-define-route-test.ts b/integration/vite-define-route-test.ts
new file mode 100644
index 0000000000..fa3a23266c
--- /dev/null
+++ b/integration/vite-define-route-test.ts
@@ -0,0 +1,254 @@
+import { test, expect } from "@playwright/test";
+
+import { build, createProject } from "./helpers/vite";
+import dedent from "dedent";
+import stripAnsi from "strip-ansi";
+
+// TODO: passing test for object literal
+// TODO: passing test for `defineRoute`
+
+test.describe("defineRoute", () => {
+ test("fails when used outside of route modules", async () => {
+ let cwd = await createProject({
+ "app/non-route.ts": dedent`
+ import { defineRoute } from "react-router"
+ export const planet = "world"
+ `,
+ "app/routes/_index.tsx": dedent`
+ import { planet } from "../non-route"
+ export default function Route() {
+ return "Hello, " + planet + "!"
+ }
+ `,
+ });
+ let result = build({ cwd });
+ expect(result.status).not.toBe(0);
+ let stderr = stripAnsi(result.stderr.toString("utf8"));
+ expect(stderr).toMatch(
+ "`defineRoute` cannot be used outside of route modules"
+ );
+ expect(stderr).toMatch(
+ dedent(
+ `
+ > 1 | import { defineRoute } from "react-router"
+ | ^^^^^^^^^^^
+ `
+ )
+ );
+ });
+
+ test("fails if referenced outside of `export default` call expression", async () => {
+ let cwd = await createProject({
+ "app/routes/_index.tsx": dedent`
+ import { defineRoute } from "react-router"
+ let x = defineRoute
+ export default function Route() {
+ return
Hello
+ }
+ `,
+ });
+ let result = build({ cwd });
+ expect(result.status).not.toBe(0);
+ let stderr = stripAnsi(result.stderr.toString("utf8"));
+ expect(stderr).toMatch(
+ "`defineRoute` must be a function call immediately after `export default`"
+ );
+ expect(stderr).toMatch(
+ dedent(
+ `
+ > 2 | let x = defineRoute
+ | ^^^^^^^^^^^
+ `
+ )
+ );
+ });
+
+ test("fails if default export is an arbitrary value", async () => {
+ let cwd = await createProject({
+ "app/routes/_index.tsx": dedent`
+ // defineRoute : hack to trigger 'defineRoute' detection, remove this once old style routes are unsupported
+ const thing = "stuff"
+ export default thing
+ `,
+ });
+ let result = build({ cwd });
+ expect(result.status).not.toBe(0);
+ let stderr = stripAnsi(result.stderr.toString("utf8"));
+ expect(stderr).toMatch(
+ "Default export of a route module must be either a literal object or a call to `defineRoute`"
+ );
+ expect(stderr).toMatch(
+ dedent(
+ `
+ > 3 | export default thing
+ | ^^^^^
+ `
+ )
+ );
+ });
+
+ test("fails if default export is a call to an arbitrary function", async () => {
+ let cwd = await createProject({
+ "app/routes/_index.tsx": dedent`
+ // defineRoute : hack to trigger 'defineRoute' detection, remove this once old style routes are unsupported
+ const thing = () => "stuff"
+ export default thing()
+ `,
+ });
+ let result = build({ cwd });
+ expect(result.status).not.toBe(0);
+ let stderr = stripAnsi(result.stderr.toString("utf8"));
+ expect(stderr).toMatch(
+ "Default export of a route module must be either a literal object or a call to `defineRoute`"
+ );
+ expect(stderr).toMatch(
+ dedent(
+ `
+ > 3 | export default thing()
+ | ^^^^^^^
+ `
+ )
+ );
+ });
+
+ test("fails if wrong number of args", async () => {
+ let cwd = await createProject({
+ "app/routes/_index.tsx": dedent`
+ import { defineRoute } from "react-router"
+ export default defineRoute()
+ `,
+ });
+ let result = build({ cwd });
+ expect(result.status).not.toBe(0);
+ let stderr = stripAnsi(result.stderr.toString("utf8"));
+ expect(stderr).toMatch("`defineRoute` must take exactly one argument");
+ expect(stderr).toMatch(
+ dedent(
+ `
+ > 2 | export default defineRoute()
+ | ^^^^^^^^^^^^^
+ `
+ )
+ );
+ });
+
+ test("fails if route is not an object literal", async () => {
+ let cwd = await createProject({
+ "app/routes/_index.tsx": dedent`
+ import { defineRoute } from "react-router"
+ const route = {}
+ export default defineRoute(route)
+ `,
+ });
+ let result = build({ cwd });
+ expect(result.status).not.toBe(0);
+ let stderr = stripAnsi(result.stderr.toString("utf8"));
+ expect(stderr).toMatch("`defineRoute` argument must be a literal object");
+ expect(stderr).toMatch(
+ dedent(
+ `
+ > 3 | export default defineRoute(route)
+ | ^^^^^
+ `
+ )
+ );
+ });
+
+ test("fails if route has spread properties", async () => {
+ let cwd = await createProject({
+ "app/routes/_index.tsx": dedent`
+ import { defineRoute } from "react-router"
+ const dynamic = {
+ serverLoader: null
+ }
+ export default defineRoute({
+ ...dynamic,
+ })
+ `,
+ });
+ let result = build({ cwd });
+ expect(result.status).not.toBe(0);
+ let stderr = stripAnsi(result.stderr.toString("utf8"));
+ expect(stderr).toMatch("Properties cannot be spread into route");
+ expect(stderr).toMatch(
+ dedent(
+ `
+ > 6 | ...dynamic,
+ | ^^^^^^^^^^
+ `
+ )
+ );
+ });
+
+ test("fails if route has computed keys", async () => {
+ let cwd = await createProject({
+ "app/routes/_index.tsx": dedent`
+ import { defineRoute } from "react-router"
+ const dynamic = "serverLoader"
+ export default defineRoute({
+ [dynamic]: null,
+ })
+ `,
+ });
+ let result = build({ cwd });
+ expect(result.status).not.toBe(0);
+ let stderr = stripAnsi(result.stderr.toString("utf8"));
+ expect(stderr).toMatch("Route cannot have computed keys");
+ expect(stderr).toMatch(
+ dedent(
+ `
+ > 4 | [dynamic]: null,
+ | ^^^^^^^^^^^^^^^
+ `
+ )
+ );
+ });
+
+ test("fails if route params is not an array literal", async () => {
+ let cwd = await createProject({
+ "app/routes/_index.tsx": dedent`
+ import { defineRoute } from "react-router"
+ const dynamic = ["id", "brand?"]
+ export default defineRoute({
+ params: dynamic,
+ })
+ `,
+ });
+ let result = build({ cwd });
+ expect(result.status).not.toBe(0);
+ let stderr = stripAnsi(result.stderr.toString("utf8"));
+ expect(stderr).toMatch("Route params must be a literal array");
+ expect(stderr).toMatch(
+ dedent(
+ `
+ > 4 | params: dynamic,
+ | ^^^^^^^^^^^^^^^
+ `
+ )
+ );
+ });
+
+ test("fails if route param is not a literal string", async () => {
+ let cwd = await createProject({
+ "app/routes/_index.tsx": dedent`
+ import { defineRoute } from "react-router"
+ const dynamic = "id"
+ export default defineRoute({
+ params: [dynamic, "brand?"],
+ })
+ `,
+ });
+ let result = build({ cwd });
+ expect(result.status).not.toBe(0);
+ let stderr = stripAnsi(result.stderr.toString("utf8"));
+ expect(stderr).toMatch("Route param must be a literal string");
+ expect(stderr).toMatch(
+ dedent(
+ `
+ > 4 | params: [dynamic, "brand?"],
+ | ^^^^^^^
+ `
+ )
+ );
+ });
+});
diff --git a/packages/react-router-dev/vite/define-route.ts b/packages/react-router-dev/vite/define-route.ts
new file mode 100644
index 0000000000..c1b00bc6fb
--- /dev/null
+++ b/packages/react-router-dev/vite/define-route.ts
@@ -0,0 +1,178 @@
+import * as babel from "@babel/core";
+import type { Binding, NodePath } from "@babel/traverse";
+
+import { parse as _parse, t, traverse } from "./babel";
+
+export function transform(code: string) {
+ let ast = parse(code);
+ traverse(ast, {
+ Identifier(path) {
+ if (!isDefineRoute(path)) return;
+ if (t.isImportSpecifier(path.parent)) return;
+ if (!t.isCallExpression(path.parent)) {
+ throw path.buildCodeFrameError(
+ "`defineRoute` must be a function call immediately after `export default`"
+ );
+ }
+ if (!t.isExportDefaultDeclaration(path.parentPath.parent)) {
+ throw path.buildCodeFrameError(
+ "`defineRoute` must be a function call immediately after `export default`"
+ );
+ }
+ },
+ ExportDefaultDeclaration(path) {
+ analyzeRouteExport(path);
+ },
+ });
+}
+
+function analyzeRouteExport(path: NodePath) {
+ let route = path.node.declaration;
+
+ // export default {...}
+ if (t.isObjectExpression(route)) {
+ let routePath = path.get("declaration") as NodePath;
+ return analyzeRoute(routePath);
+ }
+
+ // export default defineRoute({...})
+ if (t.isCallExpression(route)) {
+ let routePath = path.get("declaration") as NodePath;
+ if (!isDefineRoute(routePath.get("callee"))) {
+ throw routePath.buildCodeFrameError(
+ "Default export of a route module must be either a literal object or a call to `defineRoute`"
+ );
+ }
+
+ if (routePath.node.arguments.length !== 1) {
+ throw routePath.buildCodeFrameError(
+ "`defineRoute` must take exactly one argument"
+ );
+ }
+
+ let arg = routePath.node.arguments[0];
+ let argPath = routePath.get("arguments.0") as NodePath;
+ if (!t.isObjectExpression(arg)) {
+ throw argPath.buildCodeFrameError(
+ "`defineRoute` argument must be a literal object"
+ );
+ }
+
+ return analyzeRoute(argPath);
+ }
+ throw path
+ .get("declaration")
+ .buildCodeFrameError(
+ "Default export of a route module must be either a literal object or a call to `defineRoute`"
+ );
+}
+
+function analyzeRoute(path: NodePath) {
+ for (let [i, property] of path.node.properties.entries()) {
+ // spread: defineRoute({ ...dynamic })
+ if (!t.isObjectProperty(property) && !t.isObjectMethod(property)) {
+ let propertyPath = path.get(`properties.${i}`) as NodePath;
+ throw propertyPath.buildCodeFrameError(
+ "Properties cannot be spread into route"
+ );
+ }
+
+ // defineRoute({ [dynamic]: ... })
+ let propertyPath = path.get(`properties.${i}`) as NodePath<
+ t.ObjectProperty | t.ObjectMethod
+ >;
+ if (property.computed || !t.isIdentifier(property.key)) {
+ throw propertyPath.buildCodeFrameError("Route cannot have computed keys");
+ }
+
+ // defineRoute({ params: [...] })
+ let key = property.key.name;
+ if (key === "params") {
+ let paramsPath = propertyPath as NodePath;
+ if (t.isObjectMethod(paramsPath.node)) {
+ throw paramsPath.buildCodeFrameError(
+ "Route params must be a literal array"
+ );
+ }
+ if (!t.isArrayExpression(paramsPath.node.value)) {
+ throw paramsPath.buildCodeFrameError(
+ "Route params must be a literal array"
+ );
+ }
+ for (let [i, element] of paramsPath.node.value.elements.entries()) {
+ if (!t.isStringLiteral(element)) {
+ let elementPath = paramsPath.get(
+ `value.elements.${i}`
+ ) as NodePath;
+ throw elementPath.buildCodeFrameError(
+ "Route param must be a literal string"
+ );
+ }
+ }
+ continue;
+ }
+ }
+
+ throw path.buildCodeFrameError("TODO: not yet implemented");
+}
+
+export function assertNotImported(code: string): void {
+ let ast = parse(code);
+ traverse(ast, {
+ Identifier(path) {
+ if (isDefineRoute(path)) {
+ throw path.buildCodeFrameError(
+ "`defineRoute` cannot be used outside of route modules"
+ );
+ }
+ },
+ });
+}
+
+function parse(source: string) {
+ let ast = _parse(source, {
+ sourceType: "module",
+ plugins: ["jsx", ["typescript", {}]],
+ });
+
+ // Workaround for `path.buildCodeFrameError`
+ // See:
+ // - https://github.com/babel/babel/issues/11889
+ // - https://github.com/babel/babel/issues/11350#issuecomment-606169054
+ // @ts-expect-error `@types/babel__core` is missing types for `File`
+ new babel.File({ filename: undefined }, { code: source, ast });
+
+ return ast;
+}
+
+function isDefineRoute(path: NodePath): boolean {
+ if (!t.isIdentifier(path.node)) return false;
+ let binding = path.scope.getBinding(path.node.name);
+ if (!binding) return false;
+ return isCanonicallyImportedAs(binding, {
+ imported: "defineRoute",
+ source: "react-router",
+ });
+}
+
+function isCanonicallyImportedAs(
+ binding: Binding,
+ {
+ source: sourceName,
+ imported: importedName,
+ }: {
+ source: string;
+ imported: string;
+ }
+): boolean {
+ // import source
+ if (!t.isImportDeclaration(binding?.path.parent)) return false;
+ if (binding.path.parent.source.value !== sourceName) return false;
+
+ // import specifier
+ if (!t.isImportSpecifier(binding?.path.node)) return false;
+ let { imported } = binding.path.node;
+ if (!t.isIdentifier(imported)) return false;
+ if (imported.name !== importedName) return false;
+ return true;
+}
diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts
index 1f337d0e0d..7f743e0795 100644
--- a/packages/react-router-dev/vite/plugin.ts
+++ b/packages/react-router-dev/vite/plugin.ts
@@ -41,6 +41,7 @@ import {
resolveEntryFiles,
resolvePublicPath,
} from "../config";
+import * as defineRoute from "./define-route";
export async function resolveViteConfig({
configFile,
@@ -1244,6 +1245,23 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => {
}
},
},
+ {
+ name: "react-router-define-route",
+ enforce: "pre",
+ async transform(code, id, options) {
+ if (options?.ssr) return;
+
+ if (id.endsWith(BUILD_CLIENT_ROUTE_QUERY_STRING)) return;
+
+ let route = getRoute(ctx.reactRouterConfig, id);
+ if (!route && code.includes("defineRoute")) {
+ return defineRoute.assertNotImported(code);
+ }
+
+ if (!code.includes("defineRoute")) return; // temporary back compat, remove once old style routes are unsupported
+ defineRoute.transform(code);
+ },
+ },
{
name: "react-router-dot-server",
enforce: "pre",