From 6620e350fc1295ddbf3b3995714eb745362bf317 Mon Sep 17 00:00:00 2001 From: Daniel Leong Date: Wed, 1 Jul 2026 11:08:04 -0400 Subject: [PATCH] feat(router-generator): support higher-order wrappers around createFileRoute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route-file transform previously required the exported `Route` binding to be exactly `createFileRoute('/path')({...})`, so a route wrapped by a higher-order handler (e.g. `wrap('x')(createFileRoute('/path')({...}))`) was silently left untouched — no route-id injection, no import management. Recursively locate the single nested `createFileRoute(path)(options)` call within the exported `Route` init, regardless of any wrapping calls. The wrapper stays opaque to the generator; only the inner constructor call is rewritten. Malformed wrapped routes now surface the same helpful error. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/transform/transform.ts | 101 +++++++++---- .../router-generator/tests/transform.test.ts | 136 ++++++++++++++++++ 2 files changed, 211 insertions(+), 26 deletions(-) diff --git a/packages/router-generator/src/transform/transform.ts b/packages/router-generator/src/transform/transform.ts index 9fcad87948..3b00d3eb63 100644 --- a/packages/router-generator/src/transform/transform.ts +++ b/packages/router-generator/src/transform/transform.ts @@ -218,23 +218,30 @@ function findExportedRouteCalls( continue } - const init = getRouteConstructorInit(declarator.init) - if (!init) { - if (isDirectRouteConstructorCall(declarator.init)) { + // The exported `Route` may be wrapped by one or more higher-order + // handlers (e.g. `wrap('x')(createFileRoute('/path')({...}))`), so search + // the whole init expression for the `createFileRoute('/path')({...})` + // call rather than requiring it to be the top-level init. The wrapper is + // opaque to the generator; we only need the inner constructor call. + const inits = findRouteConstructorInits(declarator.init) + if (inits.length === 0) { + if (containsRouteConstructorCall(declarator.init)) { hasMalformedRouteCall = true } continue } - const routeIdArg = init.innerCall.arguments[0] - if (isSupportedRouteId(routeIdArg)) { - calls.push({ - callee: init.callee, - routeIdArg, - optionsArg: init.outerCall.arguments[0], - }) - } else { - hasUnsupportedRouteId = true + for (const init of inits) { + const routeIdArg = init.innerCall.arguments[0] + if (isSupportedRouteId(routeIdArg)) { + calls.push({ + callee: init.callee, + routeIdArg, + optionsArg: init.outerCall.arguments[0], + }) + } else { + hasUnsupportedRouteId = true + } } } } @@ -258,16 +265,16 @@ function getLocalBindingName(node: t.Identifier | t.StringLiteral) { return t.isIdentifier(node) ? node.name : null } -function getRouteConstructorInit(expression: t.Expression | null | undefined) { - if (!expression || !t.isCallExpression(expression)) { +function getRouteConstructorInit(node: t.Node | null | undefined) { + if (!node || !t.isCallExpression(node)) { return null } - if (!t.isCallExpression(expression.callee)) { + if (!t.isCallExpression(node.callee)) { return null } - const innerCall = expression.callee + const innerCall = node.callee if ( !t.isIdentifier(innerCall.callee) || @@ -278,20 +285,62 @@ function getRouteConstructorInit(expression: t.Expression | null | undefined) { return { callee: innerCall.callee, - outerCall: expression, + outerCall: node, innerCall, } } -function isDirectRouteConstructorCall( - expression: t.Expression | null | undefined, -) { - return ( - !!expression && - t.isCallExpression(expression) && - t.isIdentifier(expression.callee) && - isRouteConstructor(expression.callee) - ) +// Walks the call-expression tree of an exported route binding to find every +// `createFileRoute('/path')({...})` call, regardless of any higher-order +// wrappers around it (e.g. `wrap('x')(createFileRoute('/path')({...}))`). Only +// call expressions are traversed (callee + arguments), which is sufficient for +// HOF wrappers of arbitrary depth. +function findRouteConstructorInits(node: t.Node | null | undefined) { + const inits: Array>> = + [] + + const visit = (current: t.Node | null | undefined) => { + if (!current || !t.isCallExpression(current)) { + return + } + + const init = getRouteConstructorInit(current) + if (init) { + // Don't descend into a matched call's id/options arguments; a route + // constructor cannot legitimately appear inside them. + inits.push(init) + return + } + + visit(current.callee) + for (const arg of current.arguments) { + visit(arg) + } + } + + visit(node) + + return inits +} + +// Detects a route constructor call (`createFileRoute(...)`) anywhere in the +// expression, even when it is malformed (e.g. missing the options call or +// wrapped by a HOF). Used to surface a helpful error instead of silently +// leaving the route untouched. +function containsRouteConstructorCall(node: t.Node | null | undefined): boolean { + if (!node || !t.isCallExpression(node)) { + return false + } + + if (t.isIdentifier(node.callee) && isRouteConstructor(node.callee)) { + return true + } + + if (containsRouteConstructorCall(node.callee)) { + return true + } + + return node.arguments.some((arg) => containsRouteConstructorCall(arg)) } function isRouteConstructor(callee: t.Identifier): callee is t.Identifier & { diff --git a/packages/router-generator/tests/transform.test.ts b/packages/router-generator/tests/transform.test.ts index 8a53f3278e..989be2a5df 100644 --- a/packages/router-generator/tests/transform.test.ts +++ b/packages/router-generator/tests/transform.test.ts @@ -522,4 +522,140 @@ describe('transform', () => { ) expect(result.output).not.toContain('createLazyFileRoute') }) + + describe('higher-order route wrappers', () => { + it('injects the route id into a wrapped createFileRoute call', async () => { + const node = makeNode() + const result = await transform({ + source: [ + "import { createFileRoute } from '@tanstack/react-router'", + "import { createExperimentRoute } from '../experiments'", + '', + "export const Route = createExperimentRoute('exp-1')(", + " createFileRoute('/old')({ component: Home }),", + ')', + ].join('\n'), + ctx: { + target: 'react', + routeId: '/new', + lazy: false, + }, + node, + }) + + expect(result.result).toBe('modified') + if (result.result !== 'modified') { + throw new Error(`expected modified result, got ${result.result}`) + } + expect(result.output).toContain("createFileRoute('/new')") + // the wrapper call must be preserved untouched + expect(result.output).toContain("createExperimentRoute('exp-1')(") + // options are still read for the wrapped call + expect(node.createFileRouteProps).toEqual(new Set(['component'])) + }) + + it('swaps eager to lazy constructor inside a wrapper', async () => { + const result = await transform({ + source: [ + "import { createFileRoute } from '@tanstack/react-router'", + "import { createExperimentRoute } from '../experiments'", + '', + "export const Route = createExperimentRoute('exp-1')(", + " createFileRoute('/old')({}),", + ')', + ].join('\n'), + ctx: { + target: 'react', + routeId: '/new', + lazy: true, + }, + node: makeNode('lazy'), + }) + + expect(result.result).toBe('modified') + if (result.result !== 'modified') { + throw new Error(`expected modified result, got ${result.result}`) + } + expect(result.output).toContain("createLazyFileRoute('/new')") + expect(result.output).toContain("createExperimentRoute('exp-1')(") + expect(result.output).toContain('createLazyFileRoute }') + }) + + it('handles multiple nested wrappers', async () => { + const result = await transform({ + source: [ + "import { createFileRoute } from '@tanstack/react-router'", + "import { withA, withB } from '../wrappers'", + '', + "export const Route = withA(withB('b')(createFileRoute('/old')({})))", + ].join('\n'), + ctx: { + target: 'react', + routeId: '/new', + lazy: false, + }, + node: makeNode(), + }) + + expect(result.result).toBe('modified') + if (result.result !== 'modified') { + throw new Error(`expected modified result, got ${result.result}`) + } + expect(result.output).toContain("createFileRoute('/new')") + expect(result.output).toContain("withA(withB('b')(") + }) + + it('returns a malformed error for a wrapped createFileRoute missing options', async () => { + const result = await transform({ + source: [ + "import { createFileRoute } from '@tanstack/react-router'", + "import { createExperimentRoute } from '../experiments'", + '', + "export const Route = createExperimentRoute('exp-1')(createFileRoute('/old'))", + ].join('\n'), + ctx: { + target: 'react', + routeId: '/new', + lazy: false, + }, + node: makeNode(), + }) + + expect(result.result).toBe('error') + if (result.result !== 'error') { + throw new Error(`expected error result, got ${result.result}`) + } + expect(String(result.error)).toContain( + "expected Route export in /new to use createFileRoute('/path')({...})", + ) + }) + + it('errors when more than one createFileRoute call is present', async () => { + const result = await transform({ + source: [ + "import { createFileRoute } from '@tanstack/react-router'", + "import { combine } from '../wrappers'", + '', + 'export const Route = combine(', + " createFileRoute('/a')({}),", + " createFileRoute('/b')({}),", + ')', + ].join('\n'), + ctx: { + target: 'react', + routeId: '/new', + lazy: false, + }, + node: makeNode(), + }) + + expect(result.result).toBe('error') + if (result.result !== 'error') { + throw new Error(`expected error result, got ${result.result}`) + } + expect(String(result.error)).toContain( + 'expected exactly one createFileRoute/createLazyFileRoute call', + ) + }) + }) })