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",