diff --git a/.changeset/mean-birds-bow.md b/.changeset/mean-birds-bow.md new file mode 100644 index 00000000000..3dba094d811 --- /dev/null +++ b/.changeset/mean-birds-bow.md @@ -0,0 +1,9 @@ +--- +'@tanstack/router-utils': patch +'@tanstack/router-generator': patch +'@tanstack/router-plugin': patch +'@tanstack/start-plugin-core': patch +'@tanstack/react-start-rsc': patch +--- + +Standardize Yuku AST guards and compiler terminology, and share directive-preserving statement insertion and source-position helpers across compilation transforms. diff --git a/.changeset/smooth-parrots-argue.md b/.changeset/smooth-parrots-argue.md new file mode 100644 index 00000000000..cabd1e8e255 --- /dev/null +++ b/.changeset/smooth-parrots-argue.md @@ -0,0 +1,42 @@ +--- +'@tanstack/router-utils': minor +'@tanstack/router-plugin': minor +'@tanstack/start-plugin-core': minor +'@tanstack/router-generator': minor +'@tanstack/router-cli': minor +'@tanstack/router-vite-plugin': minor +'@tanstack/react-start-rsc': patch +--- + +Replace Babel-based route and Start compilation with Yuku's parser, semantic analysis, AST utilities, and code generator. Parsing and analysis run on Yuku's native core, or on its WebAssembly core in WebContainers such as StackBlitz and where the native binding cannot load. The WebAssembly core does not run on big-endian hosts (such as s390x) or under `node --jitless`. + +Compiler extension hooks and AST helpers now expose Yuku nodes and semantic context. Babel nodes, traversal paths, and helper APIs are removed; extensions must migrate to the native interfaces. Routing and server-function runtime contracts remain unchanged. + +A `createServerFn` that is not assigned to a module-level variable now fails the build in every environment: one declared inside a function or block, a default export, an object property, an assignment, or a function that returns one, including such wrappers shipped by libraries, since the compiler also processes dependencies. None of these placements worked before: depending on the other Start APIs in the file, the build already failed with "must be assigned to a variable", or the server function was left untransformed, so its handler and server-only imports shipped to the client and its calls returned `undefined`, or the output failed later in the bundler or at runtime. A function that returns an unfinished builder (`defineAction(schema).handler(fn)`) is still not detected. + +A route created inside a function or IIFE (a route factory) is no longer code-split, and the router plugin reports a bundler warning for it in development and builds. Edits to it may need a full page reload. Previously such routes were split, which broke when a split option used the function's locals: a `ReferenceError` at render, a build error, or the wrong component. + +`@tanstack/router-utils`, `@tanstack/router-generator`, `@tanstack/router-plugin`, `@tanstack/router-cli` and `@tanstack/router-vite-plugin` now require Node.js `^20.19.0 || >=22.12.0`: their CommonJS builds load Yuku's ES modules with `require()`, which Node.js 22.0 to 22.11 does not support without a flag. + +Other behaviour changes: + +- An exported function or class that a split route option uses (an exported page component the split component renders, an exported store class or helper) now moves into the route's shared module, which the route module re-exports it from, so the split chunk and every other importer use one instance: `instanceof` checks and module state agree across modules. When nothing else imports it, the bundler can leave it, and the imports only it uses, out of the route module's chunk, so the top-level side effects of those imports run when the split chunk loads. Previously the split chunk declared its own copy. An exported function that reads `Route` is still copied into the chunk when it holds no module state. In React route files, shared components go to a module of their own, apart from the other shared bindings, so that React Refresh can hot-update them. +- A class, enum, namespace or reassigned `let` that only a split route option uses now moves into that option's chunk, like other declarations already did, so its top-level side effects run when the chunk loads instead of when the route module loads. +- A split route option that refers to an exported binding through a TypeScript cast (`errorComponent: ErrorComponent as any`) is no longer split, like the same option without the cast, and reports the existing "will not be code-split" warning. Previously the component was duplicated into the route module and the chunk. +- A `` whose content is passed as a `children` prop is now split like JSX children, so hooks or functions there require `split={false}`, as they do for JSX children. +- `.js` and `.jsx` files are parsed as JavaScript with JSX, so TypeScript syntax in them is a syntax error. +- Exporting a type and a value of the same name from two separate `export` statements (`export type { Post } from './types'` and `export { Post } from './post'`) is now a syntax error, as it is in TypeScript. +- The route generator no longer treats a type-only `Route` export (`export type { X as Route }`, `export { type X as Route }`, `export declare const Route`) as the route export: it exports nothing at runtime, so the file is reported as having no `Route` export. +- Import protection mocks now include the names that `export * as ns`, `export enum` and `export namespace` export, no longer expose names exported inside a namespace, and cover `ns.x` reads that come before `import * as ns`. + +Fixes: + +- Route HMR in Vite now hot-updates Solid routes with automatic code splitting and Vue routes instead of reloading the page. +- Route options wrapped in `as any`, `satisfies` or `!`, and a route factory call wrapped in a cast, are now code-split. Previously the file was left untransformed, so a server route written this way also kept its `server` handlers in the client. +- Split route components written as a call containing JSX (`React.memo(() =>

)`, a Vue `defineComponent` whose `setup` returns JSX) now compile. +- A component used by both a split and an unsplit route option no longer throws "is not defined", and a component declared with `function Page` and exported with `export { Page }` no longer breaks the build. +- Split chunks now import the classic JSX factory (`/** @jsx h */`) the route module exports, declare an enum or namespace that the loader and the component read once, keep the body of a dotted namespace (`namespace A.B {}`), and no longer import values that only types read. A split-only component named like a type no longer also stays in the route module. +- The route generator records route options written as methods or accessors. +- `createServerOnlyFn` and `createIsomorphicFn` passed to `.handler()`, and `.server()` or `.validator()` chain links wrapped in a cast, now compile correctly. +- `` children that read `Route` import it, also when it is exported under a string name; children passed through a `children` prop or a spread render; and `split={false}` works inside a cast. +- Import protection reports usages inside JSX and skips type positions and member properties, and its mocks no longer reuse a name the module declares. diff --git a/.changeset/split-object-form-loader.md b/.changeset/split-object-form-loader.md new file mode 100644 index 00000000000..8da3c0f85fe --- /dev/null +++ b/.changeset/split-object-form-loader.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-plugin': patch +--- + +Split the `handler` of a loader written inline in object form (`loader: { handler, staleReloadMode }`) into its chunk, keeping the other loader options in the route module, so that a split object-form loader loads its data. An object-form loader passed by reference (`const loader = { handler }`, then `loader`) is not supported yet. diff --git a/e2e/react-start/hmr/src/routeTree.gen.ts b/e2e/react-start/hmr/src/routeTree.gen.ts index 73d8bd14f53..59cfa2aca86 100644 --- a/e2e/react-start/hmr/src/routeTree.gen.ts +++ b/e2e/react-start/hmr/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as ChildRouteImport } from './routes/child' import { Route as ComponentHmrRouteImport } from './routes/component-hmr' +import { Route as ComponentHmrExportedSplitRouteImport } from './routes/component-hmr-exported-split' import { Route as ComponentHmrInlineErrorSplitRouteImport } from './routes/component-hmr-inline-error-split' import { Route as ComponentHmrInlineNosplitRouteImport } from './routes/component-hmr-inline-nosplit' import { Route as ComponentHmrInlineSplitRouteImport } from './routes/component-hmr-inline-split' @@ -39,6 +40,12 @@ const ComponentHmrRoute = ComponentHmrRouteImport.update({ path: '/component-hmr', getParentRoute: () => rootRouteImport, } as any) +const ComponentHmrExportedSplitRoute = + ComponentHmrExportedSplitRouteImport.update({ + id: '/component-hmr-exported-split', + path: '/component-hmr-exported-split', + getParentRoute: () => rootRouteImport, + } as any) const ComponentHmrInlineErrorSplitRoute = ComponentHmrInlineErrorSplitRouteImport.update({ id: '/component-hmr-inline-error-split', @@ -105,6 +112,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/child': typeof ChildRoute '/component-hmr': typeof ComponentHmrRoute + '/component-hmr-exported-split': typeof ComponentHmrExportedSplitRoute '/component-hmr-inline-error-split': typeof ComponentHmrInlineErrorSplitRoute '/component-hmr-inline-nosplit': typeof ComponentHmrInlineNosplitRoute '/component-hmr-inline-split': typeof ComponentHmrInlineSplitRoute @@ -121,6 +129,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/child': typeof ChildRoute '/component-hmr': typeof ComponentHmrRoute + '/component-hmr-exported-split': typeof ComponentHmrExportedSplitRoute '/component-hmr-inline-error-split': typeof ComponentHmrInlineErrorSplitRoute '/component-hmr-inline-nosplit': typeof ComponentHmrInlineNosplitRoute '/component-hmr-inline-split': typeof ComponentHmrInlineSplitRoute @@ -138,6 +147,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/child': typeof ChildRoute '/component-hmr': typeof ComponentHmrRoute + '/component-hmr-exported-split': typeof ComponentHmrExportedSplitRoute '/component-hmr-inline-error-split': typeof ComponentHmrInlineErrorSplitRoute '/component-hmr-inline-nosplit': typeof ComponentHmrInlineNosplitRoute '/component-hmr-inline-split': typeof ComponentHmrInlineSplitRoute @@ -156,6 +166,7 @@ export interface FileRouteTypes { | '/' | '/child' | '/component-hmr' + | '/component-hmr-exported-split' | '/component-hmr-inline-error-split' | '/component-hmr-inline-nosplit' | '/component-hmr-inline-split' @@ -172,6 +183,7 @@ export interface FileRouteTypes { | '/' | '/child' | '/component-hmr' + | '/component-hmr-exported-split' | '/component-hmr-inline-error-split' | '/component-hmr-inline-nosplit' | '/component-hmr-inline-split' @@ -188,6 +200,7 @@ export interface FileRouteTypes { | '/' | '/child' | '/component-hmr' + | '/component-hmr-exported-split' | '/component-hmr-inline-error-split' | '/component-hmr-inline-nosplit' | '/component-hmr-inline-split' @@ -205,6 +218,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute ChildRoute: typeof ChildRoute ComponentHmrRoute: typeof ComponentHmrRoute + ComponentHmrExportedSplitRoute: typeof ComponentHmrExportedSplitRoute ComponentHmrInlineErrorSplitRoute: typeof ComponentHmrInlineErrorSplitRoute ComponentHmrInlineNosplitRoute: typeof ComponentHmrInlineNosplitRoute ComponentHmrInlineSplitRoute: typeof ComponentHmrInlineSplitRoute @@ -240,6 +254,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentHmrRouteImport parentRoute: typeof rootRouteImport } + '/component-hmr-exported-split': { + id: '/component-hmr-exported-split' + path: '/component-hmr-exported-split' + fullPath: '/component-hmr-exported-split' + preLoaderRoute: typeof ComponentHmrExportedSplitRouteImport + parentRoute: typeof rootRouteImport + } '/component-hmr-inline-error-split': { id: '/component-hmr-inline-error-split' path: '/component-hmr-inline-error-split' @@ -337,6 +358,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ChildRoute: ChildRoute, ComponentHmrRoute: ComponentHmrRoute, + ComponentHmrExportedSplitRoute: ComponentHmrExportedSplitRoute, ComponentHmrInlineErrorSplitRoute: ComponentHmrInlineErrorSplitRoute, ComponentHmrInlineNosplitRoute: ComponentHmrInlineNosplitRoute, ComponentHmrInlineSplitRoute: ComponentHmrInlineSplitRoute, diff --git a/e2e/react-start/hmr/src/routes/component-hmr-exported-split.tsx b/e2e/react-start/hmr/src/routes/component-hmr-exported-split.tsx new file mode 100644 index 00000000000..c4bfd74e96a --- /dev/null +++ b/e2e/react-start/hmr/src/routes/component-hmr-exported-split.tsx @@ -0,0 +1,40 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useState } from 'react' + +export const Route = createFileRoute('/component-hmr-exported-split')({ + component: ComponentHmrPage, +}) + +// Private values the exported component reads: they are shared with the +// split component, next to the component +const markerClassNames = { marker: 'hmr-marker' } + +function markerClassName() { + return markerClassNames.marker +} + +export function ComponentHmrMarker() { + return ( +

+ component-hmr-exported-split-baseline +

+ ) +} + +function ComponentHmrPage() { + const [count, setCount] = useState(0) + + return ( +
+ +

Count: {count}

+ + +
+ ) +} diff --git a/e2e/react-start/hmr/tests/app.spec.ts b/e2e/react-start/hmr/tests/app.spec.ts index eedb4eed98d..97e4218d22c 100644 --- a/e2e/react-start/hmr/tests/app.spec.ts +++ b/e2e/react-start/hmr/tests/app.spec.ts @@ -27,6 +27,7 @@ const routeFilePaths = { componentHmrNamedSplit: 'routes/component-hmr-named-split.tsx', componentHmrLowercaseNamedSplit: 'routes/component-hmr-lowercase-named-split.tsx', + componentHmrExportedSplit: 'routes/component-hmr-exported-split.tsx', componentHmrNamedNosplit: 'routes/component-hmr-named-nosplit.tsx', componentHmrInlineErrorSplit: 'routes/component-hmr-inline-error-split.tsx', componentHmrNamedErrorSplit: 'routes/component-hmr-named-error-split.tsx', @@ -122,6 +123,11 @@ const routeFileRestoreChecks: Partial< testId: 'component-hmr-marker', text: 'component-hmr-lowercase-named-split-baseline', }, + componentHmrExportedSplit: { + url: '/component-hmr-exported-split', + testId: 'component-hmr-marker', + text: 'component-hmr-exported-split-baseline', + }, componentHmrNamedNosplit: { url: '/component-hmr-named-nosplit', testId: 'component-hmr-marker', @@ -279,6 +285,10 @@ function normalizeRouteSource(routeFileKey: RouteFileKey, source: string) { 'component-hmr-lowercase-named-split-updated', 'component-hmr-lowercase-named-split-baseline', ], + componentHmrExportedSplit: [ + 'component-hmr-exported-split-updated', + 'component-hmr-exported-split-baseline', + ], componentHmrNamedNosplit: [ 'component-hmr-named-nosplit-updated', 'component-hmr-named-nosplit-baseline', @@ -1442,6 +1452,28 @@ test.describe('react-start hmr', () => { await expectComponentHmrStatePreserved(page) }) + // The split component renders an exported component that reads private + // values: all of them go to the route file's shared modules + test('updates an exported component that a code-split route component renders', async ({ + page, + }) => { + await seedComponentHmrState(page, '/component-hmr-exported-split') + + await replaceRouteTextAndWait( + page, + 'componentHmrExportedSplit', + 'component-hmr-exported-split-baseline', + 'component-hmr-exported-split-updated', + async () => { + await hmrExpect(page.getByTestId('component-hmr-marker')).toHaveText( + 'component-hmr-exported-split-updated', + ) + }, + ) + + await expectComponentHmrStatePreserved(page) + }) + test('updates non-root route component during HMR when component is not defined inline and codeSplitGroupings disables code splitting', async ({ page, }) => { diff --git a/e2e/react-start/rsbuild/tests/error-overlay.spec.ts b/e2e/react-start/rsbuild/tests/error-overlay.spec.ts index d90647314ad..227c0d49ca7 100644 --- a/e2e/react-start/rsbuild/tests/error-overlay.spec.ts +++ b/e2e/react-start/rsbuild/tests/error-overlay.spec.ts @@ -376,9 +376,9 @@ test('shows a module syntax error through the error overlay', async ({ [ { "title": "Build failed", - "description": "SyntaxError: Unexpected token (16:22)", + "description": "SyntaxError: /src/routes/index.tsx: Unexpected token ';' (16:22)", "runtime": "client", - "sourceFrame": "File: ./src/routes/index.tsx:1:1\\nx Module build failed (from ):\\n-> x SyntaxError: Unexpected token (16:22)", + "sourceFrame": "File: ./src/routes/index.tsx:1:1\\nx Module build failed (from ):\\n-> x SyntaxError: /src/routes/index.tsx: Unexpected token ';' (16:22)", "importTrace": [ "../../../packages/react-start/dist/plugin/default-entry/client.tsx", "../../../packages/react-start/dist/esm/client.js", @@ -389,9 +389,9 @@ test('shows a module syntax error through the error overlay', async ({ }, { "title": "Build failed", - "description": "SyntaxError: Unexpected token (16:22)", + "description": "SyntaxError: /src/routes/index.tsx: Unexpected token ';' (16:22)", "runtime": "server", - "sourceFrame": "File: ./src/routes/index.tsx:1:1\\nx Module build failed (from ):\\n-> x SyntaxError: Unexpected token (16:22)", + "sourceFrame": "File: ./src/routes/index.tsx:1:1\\nx Module build failed (from ):\\n-> x SyntaxError: /src/routes/index.tsx: Unexpected token ';' (16:22)", "importTrace": [ "../../../packages/react-start/dist/plugin/default-entry/server.ts", "../../../packages/react-start/dist/esm/server.js", diff --git a/packages/react-start-rsc/package.json b/packages/react-start-rsc/package.json index f6b030f1292..7461fc52bc2 100644 --- a/packages/react-start-rsc/package.json +++ b/packages/react-start-rsc/package.json @@ -104,7 +104,8 @@ "@tanstack/start-fn-stubs": "workspace:*", "@tanstack/start-plugin-core": "workspace:*", "@tanstack/start-storage-context": "workspace:*", - "pathe": "catalog:" + "pathe": "catalog:", + "yuku-ast": "catalog:" }, "devDependencies": { "@rspack/core": "2.2.7", diff --git a/packages/react-start-rsc/src/plugin/rscCssTransform.ts b/packages/react-start-rsc/src/plugin/rscCssTransform.ts index 15b882ccd77..5bc8b8a78db 100644 --- a/packages/react-start-rsc/src/plugin/rscCssTransform.ts +++ b/packages/react-start-rsc/src/plugin/rscCssTransform.ts @@ -1,3 +1,5 @@ +import { cloneGeneratedNode, unwrapExpression } from '@tanstack/router-utils' +import { b, is } from 'yuku-ast' import type { StartCompilerImportTransform, StartCompilerTransformContext, @@ -6,8 +8,7 @@ import type { const TSS_SERVERFN_SPLIT_PARAM = 'tss-serverfn-split' const RSC_CSS_OPTIONS_KEY = '__tanstackStartRscCss' -type BabelTypes = StartCompilerTransformContext['types'] -type BabelExpression = ReturnType< +type CompilerExpression = ReturnType< StartCompilerTransformContext['parseExpression'] > @@ -20,7 +21,7 @@ export function createRscCssCompilerTransforms(opts: { loadCssExpression: string serverFnProviderOnly?: boolean | undefined }): Array { - let loadCssExpression: BabelExpression | undefined + let loadCssExpression: CompilerExpression | undefined const getLoadCssExpression = (context: StartCompilerTransformContext) => { loadCssExpression ??= context.parseExpression(opts.loadCssExpression) @@ -54,7 +55,7 @@ function createRscCssCompilerTransform(opts: { kind: RscCssTransformKind getLoadCssExpression: ( context: StartCompilerTransformContext, - ) => BabelExpression + ) => CompilerExpression serverFnProviderOnly?: boolean | undefined }): StartCompilerImportTransform { return { @@ -79,72 +80,43 @@ function createRscCssCompilerTransform(opts: { return } - const t = context.types const loadCssExpression = opts.getLoadCssExpression(context) - const cloneLoadCssExpression = () => t.cloneNode(loadCssExpression) + const cloneLoadCssExpression = () => cloneGeneratedNode(loadCssExpression) for (const candidate of candidates) { - const args = candidate.path.node.arguments - if (args.length !== 1) continue + const args = candidate.node.arguments + if (args.length !== 1) { + continue + } if (opts.kind === 'renderToReadableStream') { - const firstArg = args[0] - if (!firstArg || !t.isExpression(firstArg)) continue - if (!isTopLevelJsx(t, firstArg)) continue - - args[0] = createCssFragment( - t, - firstArg, - cloneLoadCssExpression(), - ) as typeof firstArg + const jsx = unwrapExpression(args[0]) + if (is.JSXElement(jsx) || is.JSXFragment(jsx)) { + args[0] = b.JSXFragment({ + openingFragment: b.JSXOpeningFragment({}), + closingFragment: b.JSXClosingFragment({}), + children: [ + b.JSXExpressionContainer({ + expression: cloneLoadCssExpression(), + }), + jsx, + ], + }) + } continue } - args.push( - t.objectExpression([ - t.objectProperty( - t.identifier(RSC_CSS_OPTIONS_KEY), - cloneLoadCssExpression(), - ), - ]), + const options = context.parseExpression( + `{ ${RSC_CSS_OPTIONS_KEY}: null }`, ) + if ( + is.ObjectExpression(options) && + is.Property(options.properties[0]) + ) { + options.properties[0].value = cloneLoadCssExpression() + args.push(options) + } } }, } } - -function isTopLevelJsx(t: BabelTypes, expr: BabelExpression): boolean { - const unwrapped = unwrapTransparentExpression(t, expr) - return t.isJSXElement(unwrapped) || t.isJSXFragment(unwrapped) -} - -function unwrapTransparentExpression( - t: BabelTypes, - expr: BabelExpression, -): BabelExpression { - let current = expr - while ( - t.isParenthesizedExpression(current) || - t.isTSAsExpression(current) || - t.isTSSatisfiesExpression(current) || - t.isTSTypeAssertion(current) || - t.isTSNonNullExpression(current) - ) { - current = current.expression - } - return current -} - -function createCssFragment( - t: BabelTypes, - original: BabelExpression, - loadCssExpression: BabelExpression, -) { - const unwrapped = unwrapTransparentExpression(t, original) - return t.jsxFragment(t.jsxOpeningFragment(), t.jsxClosingFragment(), [ - t.jsxExpressionContainer(loadCssExpression), - t.isJSXElement(unwrapped) || t.isJSXFragment(unwrapped) - ? unwrapped - : t.jsxExpressionContainer(original), - ]) -} diff --git a/packages/react-start-rsc/tests/rscCssTransform.test.tsx b/packages/react-start-rsc/tests/rscCssTransform.test.tsx index afc4686e948..aa7cd0befce 100644 --- a/packages/react-start-rsc/tests/rscCssTransform.test.tsx +++ b/packages/react-start-rsc/tests/rscCssTransform.test.tsx @@ -70,14 +70,8 @@ describe('RSC CSS compiler transforms', () => { expect(code).toMatchInlineSnapshot(` "import { createCompositeComponent, renderServerComponent, renderToReadableStream } from '@tanstack/react-start/rsc'; - export const renderable = renderServerComponent(, { - __tanstackStartRscCss: import.meta.viteRsc.loadCss() - }); - export const composite = createCompositeComponent(() => ({ - Card: - }), { - __tanstackStartRscCss: import.meta.viteRsc.loadCss() - }); + export const renderable = renderServerComponent(, { __tanstackStartRscCss: import.meta.viteRsc.loadCss() }); + export const composite = createCompositeComponent(() => ({ Card: }), { __tanstackStartRscCss: import.meta.viteRsc.loadCss() }); export const stream = renderToReadableStream(<>{import.meta.viteRsc.loadCss()});" `) }) @@ -101,9 +95,7 @@ describe('RSC CSS compiler transforms', () => { expect(code).toMatchInlineSnapshot(` "import { renderServerComponent, renderToReadableStream } from '@tanstack/react-start/rsc'; const node = ; - export const existingOptions = renderServerComponent(, { - alreadyConfigured: true - }); + export const existingOptions = renderServerComponent(, { alreadyConfigured: true }); export const nonJsxStream = renderToReadableStream(node);" `) }) @@ -133,9 +125,7 @@ describe('RSC CSS compiler transforms', () => { "callerCode": "import { renderServerComponent } from '@tanstack/react-start/rsc'; export const renderable = renderServerComponent();", "providerCode": "import { renderServerComponent } from '@tanstack/react-start/rsc'; - export const renderable = renderServerComponent(, { - __tanstackStartRscCss: import.meta.rspackRsc.loadCss() - });", + export const renderable = renderServerComponent(, { __tanstackStartRscCss: import.meta.rspackRsc.loadCss() });", } `) }) diff --git a/packages/router-cli/package.json b/packages/router-cli/package.json index e12e2bc6ea1..3feb7f7b83d 100644 --- a/packages/router-cli/package.json +++ b/packages/router-cli/package.json @@ -63,7 +63,7 @@ "bin/**" ], "engines": { - "node": ">=20.19" + "node": "^20.19.0 || >=22.12.0" }, "dependencies": { "@tanstack/router-generator": "workspace:*", diff --git a/packages/router-generator/package.json b/packages/router-generator/package.json index d84007be720..102a712a17c 100644 --- a/packages/router-generator/package.json +++ b/packages/router-generator/package.json @@ -62,17 +62,18 @@ "src" ], "engines": { - "node": ">=20.19" + "node": "^20.19.0 || >=22.12.0" }, "dependencies": { - "@babel/types": "catalog:", "@tanstack/router-core": "workspace:*", "@tanstack/router-utils": "workspace:*", "@tanstack/virtual-file-routes": "workspace:*", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.9.6", - "zod": "catalog:" + "zod": "catalog:", + "@yuku-toolchain/types": "catalog:", + "yuku-ast": "catalog:" }, "devDependencies": { "@tanstack/react-router": "workspace:*", diff --git a/packages/router-generator/src/transform/transform.ts b/packages/router-generator/src/transform/transform.ts index 9fcad87948c..188ce72c48a 100644 --- a/packages/router-generator/src/transform/transform.ts +++ b/packages/router-generator/src/transform/transform.ts @@ -1,6 +1,7 @@ import MagicString from 'magic-string' -import * as t from '@babel/types' -import { parseAst } from '@tanstack/router-utils' +import { is, isCallOf, nameOf } from 'yuku-ast' +import { analyzeModule, unwrapExpression } from '@tanstack/router-utils' +import type * as t from '@yuku-toolchain/types' import type { TransformOptions, TransformResult } from './types' const routeConstructors = ['createFileRoute', 'createLazyFileRoute'] as const @@ -51,10 +52,10 @@ export function transform({ filename, node, }: TransformOptions): TransformResult { - let ast: ReturnType + let ast: ReturnType try { - ast = parseAst({ code: source, filename }) + ast = analyzeModule({ code: source, filename }) } catch (error) { return { result: 'error', @@ -62,9 +63,17 @@ export function transform({ } } - const exportedRouteNames = getExportedRouteNames(ast.program.body) + // A runtime `export { x as Route }` of any binding, or `export const Route` + const routeExport = ast.exports.find( + (exp) => + exp.name === 'Route' && + exp.local && + !exp.typeOnly && + (is.ExportSpecifier(exp.node) || + is.VariableDeclarator(ast.parentOf(exp.node))), + ) - if (exportedRouteNames.size === 0) { + if (!routeExport) { return { result: 'no-route-export' } } @@ -72,7 +81,7 @@ export function transform({ calls: routeCalls, hasUnsupportedRouteId, hasMalformedRouteCall, - } = findExportedRouteCalls(ast.program.body, exportedRouteNames) + } = findRouteCalls(ast, routeExport.local!.declarations) if (routeCalls.length === 0 && hasMalformedRouteCall) { return { @@ -106,7 +115,7 @@ export function transform({ } const routeCall = routeCalls[0]! - const routeIdQuote = getRouteIdQuote(source, routeCall.routeIdArg) + const routeIdQuote = getRouteIdQuote(routeCall.routeIdArg) const createFileRouteProps = getCreateFileRouteProps(routeCall.optionsArg) if (createFileRouteProps) { @@ -116,24 +125,24 @@ export function transform({ const expectedCallee = getExpectedRouteConstructor(ctx.lazy) const expectedRouteId = `${routeIdQuote}${ctx.routeId}${routeIdQuote}` const currentRouteId = source.slice( - routeCall.routeIdArg.start!, - routeCall.routeIdArg.end!, + routeCall.routeIdArg.start, + routeCall.routeIdArg.end, ) const targetModule = `@tanstack/${ctx.target}-router` - const imports = parseTargetImports(ast.program.body, source, targetModule) + const imports = parseTargetImports(ast.ast.body, source, targetModule) const s = new MagicString(source) let modified = false if (routeCall.callee.name !== expectedCallee) { - s.update(routeCall.callee.start!, routeCall.callee.end!, expectedCallee) + s.update(routeCall.callee.start, routeCall.callee.end, expectedCallee) modified = true } if (currentRouteId !== expectedRouteId) { s.update( - routeCall.routeIdArg.start!, - routeCall.routeIdArg.end!, + routeCall.routeIdArg.start, + routeCall.routeIdArg.end, expectedRouteId, ) modified = true @@ -162,186 +171,91 @@ export function transform({ } } -function getExportedRouteNames(body: Array) { - const exportedRouteNames = new Set() - - for (const statement of body) { - if (!t.isExportNamedDeclaration(statement) || statement.source) { - continue - } - - if (t.isVariableDeclaration(statement.declaration)) { - for (const declarator of statement.declaration.declarations) { - if (t.isIdentifier(declarator.id) && declarator.id.name === 'Route') { - exportedRouteNames.add('Route') - } - } - } - - for (const specifier of statement.specifiers) { - if ( - !t.isExportSpecifier(specifier) || - getExportedName(specifier.exported) !== 'Route' - ) { - continue - } - - const localName = getLocalBindingName(specifier.local) - if (localName) { - exportedRouteNames.add(localName) - } - } - } - - return exportedRouteNames -} - -function findExportedRouteCalls( - body: Array, - exportedRouteNames: Set, +function findRouteCalls( + module: ReturnType, + declarations: Array, ): RouteCallAnalysis { const calls: Array = [] let hasUnsupportedRouteId = false let hasMalformedRouteCall = false - for (const statement of body) { - const declaration = getVariableDeclaration(statement) - if (!declaration) { + for (const declaration of declarations) { + const declarator = module.parentOf(declaration) + if (!is.VariableDeclarator(declarator)) { continue } - for (const declarator of declaration.declarations) { - if ( - !t.isIdentifier(declarator.id) || - !exportedRouteNames.has(declarator.id.name) - ) { - continue - } - - const init = getRouteConstructorInit(declarator.init) - if (!init) { - if (isDirectRouteConstructorCall(declarator.init)) { - hasMalformedRouteCall = true - } - continue + const init = getRouteConstructorInit(declarator.init) + if (!init) { + if (isCallOf(unwrapExpression(declarator.init), routeConstructors)) { + 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 - } + const routeIdArg = unwrapExpression(init.innerCall.arguments[0]) + if (isSupportedRouteId(routeIdArg)) { + calls.push({ + callee: init.callee, + routeIdArg, + optionsArg: unwrapExpression(init.outerCall.arguments[0]), + }) + } else { + hasUnsupportedRouteId = true } } return { calls, hasUnsupportedRouteId, hasMalformedRouteCall } } -function getVariableDeclaration(statement: t.Statement) { - const declaration = t.isExportNamedDeclaration(statement) - ? statement.declaration - : statement - - return t.isVariableDeclaration(declaration) ? declaration : null -} - -function getExportedName(node: t.Identifier | t.StringLiteral) { - return t.isIdentifier(node) ? node.name : node.value -} - -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(expression: t.Expression | null) { + const outerCall = unwrapExpression(expression) + if (!is.CallExpression(outerCall)) { return null } - - if (!t.isCallExpression(expression.callee)) { - return null - } - - const innerCall = expression.callee - - if ( - !t.isIdentifier(innerCall.callee) || - !isRouteConstructor(innerCall.callee) - ) { + const innerCall = unwrapExpression(outerCall.callee) + if (!isCallOf(innerCall, routeConstructors)) { return null } return { - callee: innerCall.callee, - outerCall: expression, + callee: unwrapExpression(innerCall.callee) as RouteCall['callee'], + outerCall, innerCall, } } -function isDirectRouteConstructorCall( - expression: t.Expression | null | undefined, -) { - return ( - !!expression && - t.isCallExpression(expression) && - t.isIdentifier(expression.callee) && - isRouteConstructor(expression.callee) - ) -} - -function isRouteConstructor(callee: t.Identifier): callee is t.Identifier & { - name: RouteConstructorName -} { - return routeConstructors.includes(callee.name as RouteConstructorName) -} - function isSupportedRouteId( arg: t.CallExpression['arguments'][number] | undefined, ): arg is SupportedRouteId { return ( !!arg && - (t.isStringLiteral(arg) || - (t.isTemplateLiteral(arg) && arg.expressions.length === 0)) + (is.StringLiteral(arg) || + (is.TemplateLiteral(arg) && arg.expressions.length === 0)) ) } -function getRouteIdQuote( - source: string, - arg: SupportedRouteId, -): '"' | "'" | '`' { - const raw = source.slice(arg.start!, arg.end!) - - if (raw.startsWith("'")) return "'" - if (raw.startsWith('"')) return '"' - return '`' +function getRouteIdQuote(arg: SupportedRouteId): '"' | "'" | '`' { + return is.StringLiteral(arg) ? (arg.raw[0] as '"' | "'") : '`' } function getCreateFileRouteProps( arg: t.CallExpression['arguments'][number] | undefined, ) { - if (!arg || !t.isObjectExpression(arg)) { + if (!arg || !is.ObjectExpression(arg)) { return undefined } const props = new Set() for (const property of arg.properties) { - if (!t.isObjectProperty(property) || property.computed) { - continue - } - - if (t.isIdentifier(property.key)) { - props.add(property.key.name) + if (!is.Property(property) || property.computed) { continue } - if (t.isStringLiteral(property.key)) { - props.add(property.key.value) + const name = nameOf(property.key) + if (name !== null) { + props.add(name) } } @@ -349,7 +263,7 @@ function getCreateFileRouteProps( } function parseTargetImports( - body: Array, + body: t.Program['body'], source: string, targetModule: string, ) { @@ -357,35 +271,30 @@ function parseTargetImports( for (const statement of body) { if ( - !t.isImportDeclaration(statement) || + !is.ImportDeclaration(statement) || statement.importKind === 'type' || statement.source.value !== targetModule ) { continue } - const rawSource = source.slice( - statement.source.start!, - statement.source.end!, - ) - const importStatement = source.slice(statement.start!, statement.end!) + const rawSource = source.slice(statement.source.start, statement.source.end) + const importStatement = source.slice(statement.start, statement.end) imports.push({ declaration: statement, defaultImport: statement.specifiers.find((specifier) => - t.isImportDefaultSpecifier(specifier), + is.ImportDefaultSpecifier(specifier), )?.local.name, namespace: statement.specifiers.find((specifier) => - t.isImportNamespaceSpecifier(specifier), + is.ImportNamespaceSpecifier(specifier), )?.local.name, named: statement.specifiers .filter((specifier): specifier is t.ImportSpecifier => - t.isImportSpecifier(specifier), + is.ImportSpecifier(specifier), ) .map((specifier) => ({ - imported: t.isIdentifier(specifier.imported) - ? specifier.imported.name - : specifier.imported.value, + imported: nameOf(specifier.imported), local: specifier.local.name, importKind: specifier.importKind ?? undefined, })), @@ -420,8 +329,8 @@ function updateRouteImports({ } if (analysis.kind === 'rename') { - s.update(analysis.imported.start!, analysis.imported.end!, analysis.next) - s.update(analysis.local.start!, analysis.local.end!, analysis.next) + s.update(analysis.imported.start, analysis.imported.end, analysis.next) + s.update(analysis.local.start, analysis.local.end, analysis.next) return true } @@ -452,12 +361,12 @@ function analyzeRouteImports( for (const declaration of imports) { for (const specifier of declaration.declaration.specifiers) { - if (!t.isImportSpecifier(specifier)) { + if (!is.ImportSpecifier(specifier)) { continue } const imported = specifier.imported - if (!t.isIdentifier(imported)) { + if (!is.Identifier(imported)) { return { kind: 'normalize' } } @@ -539,16 +448,16 @@ function normalizeRouteImports({ if (replacement === null) { s.remove( - declaration.declaration.start!, - getRemovalEnd(source, declaration.declaration.end!), + declaration.declaration.start, + getRemovalEnd(source, declaration.declaration.end), ) modified = true continue } s.update( - declaration.declaration.start!, - declaration.declaration.end!, + declaration.declaration.start, + declaration.declaration.end, replacement, ) modified = true diff --git a/packages/router-generator/tests/transform.test.ts b/packages/router-generator/tests/transform.test.ts index 2267599c15e..fafd471929f 100644 --- a/packages/router-generator/tests/transform.test.ts +++ b/packages/router-generator/tests/transform.test.ts @@ -55,6 +55,24 @@ describe('transform', () => { } }) + it('reads route call parts through parentheses and TypeScript wrappers', () => { + const node = makeNode() + const result = transform({ + source: + "import { createFileRoute } from '@tanstack/react-router'\n" + + "export const Route = (createFileRoute as typeof createFileRoute)(('/old' as const))(({ component: Page, server: {} }) satisfies object)\n", + ctx: { target: 'react', routeId: '/new', lazy: false }, + node, + }) + expect(result).toEqual({ + result: 'modified', + output: + "import { createFileRoute } from '@tanstack/react-router'\n" + + "export const Route = (createFileRoute as typeof createFileRoute)(('/new' as const))(({ component: Page, server: {} }) satisfies object)\n", + }) + expect(node.createFileRouteProps).toEqual(new Set(['component', 'server'])) + }) + it('does not treat root route exports as missing Route exports', async () => { const result = await transform({ source: [ @@ -440,7 +458,19 @@ describe('transform', () => { expect(result.output).toContain("createFileRoute('/new')") }) - it('returns an error for multiple exported route calls', async () => { + it.each([ + "const MyRoute = createFileRoute('/old')({})\nexport type { MyRoute as Route }", + "const MyRoute = createFileRoute('/old')({})\nexport { type MyRoute as Route }", + 'export declare const Route: unknown', + ])('does not treat a type-only Route export as a route: %s', (code) => { + const result = transformToNewRoute( + `import { createFileRoute } from '@tanstack/react-router'\n${code}`, + ) + + expect(result.result).toBe('no-route-export') + }) + + it('rejects duplicate Route exports as a syntax error', async () => { const result = await transform({ source: [ "import { createFileRoute } from '@tanstack/react-router'", @@ -458,12 +488,36 @@ describe('transform', () => { 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("Duplicate export of 'Route'") + }) + + it('returns an error when a redeclared Route var holds two route calls', async () => { + const result = await transform({ + source: [ + "import { createFileRoute } from '@tanstack/react-router'", + '', + "var Route = createFileRoute('/a')({})", + "var Route = createFileRoute('/b')({})", + 'export { Route }', + ].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', + 'expected exactly one createFileRoute/createLazyFileRoute call in /new', ) }) @@ -578,6 +632,24 @@ describe('transform', () => { expect(result).toEqual({ result: 'not-modified' }) expect(node.createFileRouteProps).toEqual(new Set(['component', 'server'])) }) + + // A route with server handlers and a method-shorthand option is not a + // server-only route: Start must keep it in the client route tree. + it('records method, async method and accessor option keys', () => { + const node = makeNode() + const result = transformToNewRoute( + [ + "import { createFileRoute } from '@tanstack/react-router'", + "export const Route = createFileRoute('/new')({ server: {}, component() { return null }, async loader() {}, get head() { return () => ({}) } })", + ].join('\n'), + node, + ) + + expect(result).toEqual({ result: 'not-modified' }) + expect(node.createFileRouteProps).toEqual( + new Set(['server', 'component', 'loader', 'head']), + ) + }) }) // The generator writes its output back over the user's route file, so every diff --git a/packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md b/packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md index b3016930784..8043f097307 100644 --- a/packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md +++ b/packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md @@ -1,7 +1,7 @@ # Automatic Code-Splitting Architecture > Internal documentation for the TanStack Router code-splitting system. -> This covers the Babel-based transform pipeline that splits route files into +> This covers the Yuku-based transform pipeline that splits route files into > lazily-loaded modules (typically separate chunks) at build time. --- @@ -40,13 +40,14 @@ export const Route = createFileRoute('/about')({ }) ``` -The plugin produces up to three modules from the **same physical file**: +The plugin produces up to four modules from the **same physical file**: | Module | What it contains | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `about.tsx` (reference) | Route config with `component` replaced by `lazyRouteComponent($$splitComponentImporter, 'component')` and an injected `const $$splitComponentImporter = () => import('about.tsx?tsr-split=component')` | | `about.tsx?tsr-split=component` (virtual) | Only the component function, exported as `component` | | `about.tsx?tsr-shared=1` (shared) | Optional; created only when bindings are shared between split and non-split properties | +| `about.tsx?tsr-shared=components` | React only, optional: the shared bindings that look like components, so that React Refresh can hot-update them; imports the other shared bindings from `?tsr-shared=1` | The bundler (Vite, Webpack, Rspack) treats query-parameterized imports as distinct modules, so `about.tsx` and `about.tsx?tsr-split=component` are @@ -118,6 +119,14 @@ returns an **array of 3 plugins** that share a closure containing: - `userConfig` — resolved plugin configuration - Helper functions for grouping resolution +### Immutable analysis and output ownership + +`analyzeRouteModule()` parses and resolves bindings with Yuku once per +transform. It records route options, exports, and the dependency graph using +resolved symbol identities, so shadowed names and JSX references are distinct. +Each output gets its own AST copy with a map back to the original nodes. Output +mutation never changes the original semantic snapshot. + ### Plugin 1: `tanstack-router:code-splitter:compile-reference-file` **Filter:** File IDs matching `/\.(m|c)?(j|t)sx?$/` whose code contains one of @@ -157,8 +166,7 @@ Excludes file IDs that include `tsr-split` or `tsr-shared`. virtual file should export (e.g., `['component']`) 3. Reads shared bindings from `sharedBindingsMap` for the base file 4. Calls `compileCodeSplitVirtualRoute()` which: - - Removes shared binding declarations before traversal (prevents scope - collision crashes) + - Constructs an output tree using the immutable source analysis - Keeps only the intended split properties as named exports - Converts user-exported declarations to imports from the base file - Adds `import { ... } from '...?tsr-shared=1'` if shared bindings exist @@ -184,6 +192,11 @@ Excludes file IDs that include `tsr-split` or `tsr-shared`. - Strips `export` wrappers from kept declarations - Adds a single `export { ... }` statement for all shared bindings - Runs dead-code elimination to remove unused imports +4. For React, emits the shared bindings that look like components and that no + other shared binding reads in `?tsr-shared=components`, and the rest in + `?tsr-shared=1`. React Refresh hot-updates only a module whose exports are + all components. The components module imports from the values module, + never the other way around. **Vite hooks:** @@ -297,44 +310,17 @@ split modules import from, ensuring a single shared instance. **Algorithm:** -1. **Collect local bindings** — Cheap loop over `program.body` to find all - module-level `const`/`let`/`var`/`function`/`class` declarations. Deletes - `Route` from the set (must never be extracted). If no local bindings - remain, returns empty set immediately (fast path). - -2. **Find route options** — Uses `babel.traverse` to locate the - `createFileRoute('/')({ ... })` call and extract the options object. - -3. **Fast path: group count check** — Iterates over route option properties, - tracking whether any non-split properties exist, and counting distinct - split group indices. - - If there are no non-split properties and fewer than 2 split groups are - present, nothing can be shared and it returns an empty set. - -4. **Build dependency graph** — `buildDependencyGraph()` creates a map from - each local binding to the set of other local bindings it references. Uses - `collectIdentifiersFromNode()` (a fast recursive walker, much cheaper than - `babel.traverse`) for each declaration. - -5. **Attribute bindings to groups** — For each route option property: - - Collects direct module-level references from the property value - - Expands transitively via the dependency graph (BFS) - - Records which group index each binding belongs to - -6. **Identify shared bindings** — Any binding appearing in 2+ distinct groups - is shared. - -7. **Handle destructured declarators** — If bindings from the same - `const { a, b } = fn()` appear in different groups, the entire declarator - must be shared (can't split the initialization). - -8. **Expand destructured declarations** — If any binding in a destructured - pattern is shared, all bindings in that pattern must be shared. - -9. **Remove Route-dependent bindings** — `removeBindingsDependingOnRoute()` - builds a reverse dependency graph, walks backwards from `Route` via BFS, - and removes any shared binding that transitively depends on `Route`. This - prevents Route duplication in the shared module. +1. Use the analyzed module's root-scope symbols and resolved declarations. + Imports and the `Route` singleton cannot be extracted. +2. Attribute each route property to a split group, or group `-1` for eager + properties. Ignore fallback values and the grouping configuration itself. +3. Collect runtime references using Yuku's resolved references, excluding + type-only positions and locally shadowed bindings. Follow transitive + declaration dependencies without following `Route` into the whole route. +4. Extract symbols reached from at least two groups. Bindings from one + destructured initializer form one unit, including unreferenced siblings. +5. Exclude every symbol that transitively depends on `Route`, including its + destructured siblings, to preserve the single reference-module instance. ### Concrete Example @@ -547,10 +533,10 @@ eliminated. **Key steps:** -1. Parse AST, collect `findReferencedIdentifiers()` for DCE +1. Analyze the source and clone an output tree with semantic provenance 2. For each split-able property in route options: - Check if the value is exported (if so, skip splitting — warn user) - - Generate a dynamic import URL: `addSplitSearchParamToFilename()` + - Generate a dynamic import URL: `splitFilename()` - Create importer: `const $$splitComponentImporter = () => import('...')` - Replace property value with `lazyRouteComponent(importer, 'component')` or `lazyFn(importer, 'loader')` @@ -559,7 +545,7 @@ eliminated. - Remove shared declarations from AST - Add `import { ... } from '...?tsr-shared=1'` - Re-export user-exported shared bindings -5. Run `deadCodeElimination()` +5. Run `removeUnusedBindings()` 6. Generate output code with source maps ### `compileCodeSplitVirtualRoute()` @@ -574,16 +560,15 @@ shared bindings set. **Key steps:** -1. Parse AST, collect referenced identifiers -2. **Remove shared declarations first** (before `babel.traverse`) — critical - to avoid `checkBlockScopedCollisions` crashes +1. Analyze the source and clone an output tree +2. Resolve shared declaration ownership against the original symbols 3. Track split-able nodes and their metadata 4. For each intended split target: - Resolve the property value through bindings - Create a named export (e.g., `export { SplitComponent as component }`) 5. Convert remaining user exports to imports from the base file 6. Add shared bindings import if applicable -7. Run `deadCodeElimination()` +7. Run `removeUnusedBindings()` 8. Strip orphaned expression statements 9. Generate output with source maps @@ -599,14 +584,14 @@ transitive dependencies. **Key steps:** -1. Parse AST, collect referenced identifiers +1. Analyze the source and clone an output tree 2. Build dependency graph, expand shared bindings transitively 3. Filter `program.body` to keep only: - Import declarations (DCE removes unused ones) - Declarations of bindings in the keep set 4. Strip `export` wrappers from kept declarations 5. Add `export { ... }` for all shared bindings -6. Run `deadCodeElimination()` +6. Run `removeUnusedBindings()` 7. Generate output with source maps ### `computeSharedBindings()` @@ -624,100 +609,57 @@ Traverses the AST looking for a `codeSplitGroupings` property on the route options object (supports both `createFileRoute` and `createRoute` call sites). If found, extracts the array-of-arrays value and returns it. -### Helper Functions - -| Function | Purpose | -| --------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `collectIdentifiersFromNode()` | Fast recursive walker to collect referenced identifier names from any AST node. Cheaper than `babel.traverse`. | -| `buildDeclarationMap()` | Maps binding name → AST declaration node for all module-level declarations. | -| `buildDependencyGraph()` | Maps binding name → set of other local bindings it references. | -| `expandTransitively()` | BFS expansion of a set through a dependency graph. | -| `removeBindingsDependingOnRoute()` | Reverse-graph BFS from `Route` to find and remove dependents. | -| `expandDestructuredDeclarations()` | If any binding in a destructured pattern is shared, mark all as shared. | -| `expandSharedDestructuredDeclarators()` | If bindings from same declarator appear in different groups, mark declarator as shared. | -| `collectLocalBindingsFromStatement()` | Collects locally-declared names from a statement (cheap, no traversal). | -| `collectModuleLevelRefsFromNode()` | Intersects `collectIdentifiersFromNode()` with local module-level bindings. | -| `removeSharedDeclarations()` | Filters shared binding declarations out of `program.body`. | -| `findExportedSharedBindings()` | Finds which shared bindings have `export` in the original source. | +### Native helpers + +| Function | Purpose | +| --------------------------- | --------------------------------------------------------------------------------------------- | +| `analyzeRouteModule()` | Build the immutable route syntax and semantic model. | +| `moduleDeclarationGraph()` | Map resolved symbols to declaration units and runtime dependencies. | +| `collectModuleReferences()` | Collect actual module-scope references, including JSX and excluding shadowed/type-only names. | +| `expandTransitively()` | Return a dependency closure without mutating its input. | +| `cloneModuleAst()` | Create an output AST and preserve original-node provenance. | +| `removeUnusedBindings()` | Retain output dependencies using surviving semantic references. | +| `generateModule()` | Print native ASTs with Yuku and original-source maps. | --- ## Dead Code Elimination -After each compiler transforms the AST, it runs `deadCodeElimination()` from -`@tanstack/router-utils`. This is critical because: - -- The reference file no longer uses the component implementation (it was - replaced with a lazy wrapper), so the original component code and its - dependencies should be stripped. -- The virtual file only exports specific properties, so everything else - (loader, beforeLoad, other components) should be stripped. -- The shared file only keeps shared bindings, so everything else should be - stripped. - -### How It Works - -1. **`findReferencedIdentifiers(ast)`** — Collects all `Identifier` NodePaths - in the AST that are in a "referenced" position (not binding sites). Returns - a `Set`. - -2. **`deadCodeElimination(ast, refIdents)`** — Iterates over all bindings in - the program scope. For each binding, checks whether any of its references - are in `refIdents`. If not, the binding is unreferenced and its declaration - is removed. Repeats until no more bindings can be removed (handles chains - where removing one binding makes another unreferenced). +Each compiler first decides which declarations belong to its output, then calls +`removeUnusedBindings()` from `@tanstack/router-utils`. This reconstructs +liveness from **surviving** references in the output tree; the original semantic +tables are never assumed to update after mutation. -### Import Registration for DCE +Source nodes retain their original symbol identity through the clone map. +Compiler-created references explicitly identify their intended symbol/name via +`linkGeneratedReference()`. Parsed compiler snippets carry native reference +provenance too. This avoids treating a new local or property name as a reference +to an unrelated original binding. -When the compilers add new imports (for shared bindings, or convert exports -to imports in virtual files), they must register the import specifier locals -in `refIdents` so DCE can later decide whether to keep or remove them: +Exports and top-level effects seed liveness. Dependency closure keeps nested +captures and whole destructured initializers together. Originally unused user +declarations remain in the reference output rather than silently discarding +intentional initializers; shared output retains only its explicitly owned +bindings and dependencies. Unused original and generated imports are removed. -```ts -sharedImportPath.traverse({ - Identifier(identPath) { - if (identPath.parentPath.isImportSpecifier() && identPath.key === 'local') { - refIdents.add(identPath) - } - }, -}) -``` +Standalone source effects stay in the reference module. Virtual outputs remove +expression statements that do not belong to their retained local declarations; +shared outputs include declarations rather than duplicating standalone effects. -Without this, DCE would have no path references for the new imports and would -always remove them. +Directive prologues remain at the beginning of nonempty outputs: injected imports +and lazy helpers are inserted after them. A virtual output containing only +directives is empty. Code generation preserves comments/annotations and uses the +original source spans for source maps. -### Post-DCE Cleanup in Virtual Files - -DCE only removes unused **declarations**. Bare side-effect statements like -`console.log(...)` survive even when no locally-bound names reference them. -The virtual compiler has an additional cleanup pass: - -```ts -ast.program.body = ast.program.body.filter((stmt) => { - if (!t.isExpressionStatement(stmt)) return true - const refs = collectIdentifiersFromNode(stmt) - return [...refs].some((name) => locallyBound.has(name)) -}) -``` - -This strips expression statements that don't reference any locally-bound name. - -### Directive Prologue Handling - -If the file body is empty after DCE (virtual file where no properties matched), -directive prologues (`'use client'`, `'use strict'`) are also stripped: - -```ts -if (ast.program.body.length === 0) { - ast.program.directives = [] -} -``` +Framework compiler hooks receive native programs, source modules, and explicit +insertion/renaming operations. They no longer receive Babel node paths or scope +objects. --- ## Framework Support -The code-splitting system is framework-agnostic at the Babel transform level. +The code-splitting system is framework-agnostic at the native AST transform level. Framework differences are confined to `framework-options.ts`: | Framework | Package | `createFileRoute` | `lazyFn` | `lazyRouteComponent` | @@ -773,9 +715,8 @@ compiled output as a `console.warn` to make it obvious during local testing.) ### 2. Root Routes Are Never Split -`createRootRoute()` and `createRootRouteWithContext()` are in -`unsplittableCreateRouteFns`. The reference compiler adds HMR handling but -exits before attempting any code-splitting transforms. +Only `createFileRoute()` definitions enter the splitting phase. Root factories +still receive the framework's HMR handling. ### 3. `undefined`, `null`, and `boolean` Values Are Kept In-Place @@ -814,32 +755,24 @@ function useRouteStuff() { Even if `useRouteStuff` is referenced by multiple groups, it cannot be extracted to the shared module because it depends on `Route`. Extracting it would require `Route` in the shared module, duplicating the route singleton. -`removeBindingsDependingOnRoute()` handles this via reverse-graph BFS. - -### 6. `removeSharedDeclarations` Must Run Before `babel.traverse` in Virtual Files - -In `compileCodeSplitVirtualRoute()`, shared declarations are removed -**before** `babel.traverse` runs: +`computeSharedBindings()` excludes these bindings through the reverse dependency +graph, including their destructured declaration siblings. -```ts -if (opts.sharedBindings && opts.sharedBindings.size > 0) { - removeSharedDeclarations(ast, opts.sharedBindings) -} - -babel.traverse(ast, { ... }) -``` +### 6. Shared Imports Replace Their Original Declarations -If done after, Babel's scope analysis would see both the shared declaration -and the new shared import, causing a `checkBlockScopedCollisions` crash -(duplicate `const` binding in the same scope). +An output cannot retain both a shared declaration and its replacement import. +The compiler removes the complete original declaration, then adds the shared +import. All dependency decisions still use the immutable source module's +symbols; output clones are never analyzed while partially transformed. +Exports, including multiple aliases and quoted names, are redirected to the +same shared binding. ### 7. Directive Prologues Survive Code-Splitting -Babel stores directives (like `'use client'`) in `program.directives`, not -in `program.body`. They are preserved through all transforms automatically. -The only exception is when the body is completely empty after DCE — then -directives are stripped too (a file with just `'use client'` and no code is -useless). +Yuku represents directives (like `'use client'`) as expression statements with +directive metadata in `program.body`. Injected imports and helpers are placed +after the directive prologue. Shared and virtual output filtering explicitly +preserves directives; a virtual output with no other statements becomes empty. ### 8. Plugin Ordering in the Array Matters @@ -854,7 +787,7 @@ bindings. Import statements are handled by the bundler's module system — if both the reference file and a virtual file import the same external module, the bundler deduplicates that import automatically. No shared module needed. -### 10. `SPLIT_NODES_CONFIG` — Split Strategy per Property +### 10. Split Strategy per Property Each splittable property has a configured split strategy: @@ -872,9 +805,10 @@ wraps the async function for deferred loading. ### 11. Source Maps Are Preserved -All three compilers pass `sourceMaps: true` and `sourceFileName` to -`generateFromAst()`, ensuring the browser's devtools can map back to the -original source file. +All three compilers print through `generateModule()` with the original source +and filename, so Yuku's code generator emits a source map (with +`sourcesContent`) that lets the browser's devtools map back to the original +source file. --- diff --git a/packages/router-plugin/package.json b/packages/router-plugin/package.json index 969a19e0319..bf5ddac2f44 100644 --- a/packages/router-plugin/package.json +++ b/packages/router-plugin/package.json @@ -114,23 +114,21 @@ "!skills/_artifacts" ], "engines": { - "node": ">=20.19" + "node": "^20.19.0 || >=22.12.0" }, "dependencies": { - "@babel/core": "catalog:", - "@babel/template": "^7.29.7", - "@babel/types": "catalog:", "@tanstack/router-core": "workspace:*", "@tanstack/router-generator": "workspace:*", "@tanstack/router-utils": "workspace:*", "chokidar": "catalog:", "unplugin": "^3.3.0", - "zod": "catalog:" + "zod": "catalog:", + "@yuku-toolchain/types": "catalog:", + "yuku-analyzer": "catalog:", + "yuku-ast": "catalog:" }, "devDependencies": { "@rsbuild/core": "catalog:", - "@types/babel__core": "^7.20.5", - "@types/babel__template": "^7.4.4", "@types/node": ">=20", "@tanstack/vite-config": "catalog:", "vitest": "catalog:", diff --git a/packages/router-plugin/src/core/code-splitter/compilers.ts b/packages/router-plugin/src/core/code-splitter/compilers.ts index 89cdae41add..4748be18978 100644 --- a/packages/router-plugin/src/core/code-splitter/compilers.ts +++ b/packages/router-plugin/src/core/code-splitter/compilers.ts @@ -1,1721 +1,1830 @@ -import * as t from '@babel/types' -import * as babel from '@babel/core' -import * as template from '@babel/template' +import { b, bindingIdentifiers, is, isIdentifierName, walk } from 'yuku-ast' +import { BindingFlags } from 'yuku-analyzer' import { - buildDeclarationMap, - buildDependencyGraph, - collectIdentifiersFromPattern, - collectLocalBindingsFromStatement, - collectModuleLevelRefsFromNode, + analyzeModule, + cloneModuleAst, + collectModuleReferences, createIdentifier, - deadCodeElimination, - expandDestructuredDeclarations, - expandSharedDestructuredDeclarators, expandTransitively, - findReferencedIdentifiers, - generateFromAst, - parseAst, - removeBindingsTransitivelyDependingOn, - retainModuleLevelDeclarations, - stripUnreferencedTopLevelExpressionStatements, - unwrapExportedDeclarations, + generateModule, + keepFilePragmas, + linkGeneratedReference, + moduleDeclarationGraph, + parseStatements, + prependStatements, + removeUnusedBindings, + unwrapExport, + unwrapExpression, } from '@tanstack/router-utils' import { tsrShared, tsrSplit } from '../constants' import { createRouteHmrStatement } from '../hmr' -import { getObjectPropertyKeyName } from '../utils' +import { getObjectPropertyKeyName, getUniqueProgramIdentifier } from '../utils' import { getFrameworkOptions } from './framework-options' +import type { Binding, Module } from 'yuku-analyzer' +import type { + Expression, + Node, + ObjectExpression, + ObjectProperty, + Program, + ProgramStatement, + StringLiteral, +} from '@yuku-toolchain/types' +import type { AnalyzeModuleOptions } from '@tanstack/router-utils' import type { CodeSplitCompilerPlugin, CompileCodeSplitReferenceRouteOptions, + ReferenceRouteCompilerPluginContext, } from './plugins' -import type { GeneratorResult, ParseAstOptions } from '@tanstack/router-utils' import type { CodeSplitGroupings, SplitRouteIdentNodes } from '../constants' import type { SplitNodeMeta } from './types' -export { - buildDeclarationMap, - buildDependencyGraph, - collectIdentifiersFromNode, - collectLocalBindingsFromStatement, - collectModuleLevelRefsFromNode, - expandDestructuredDeclarations, - expandSharedDestructuredDeclarators, - expandTransitively, - removeBindingsTransitivelyDependingOn, -} from '@tanstack/router-utils' +const splitKeys: Array = [ + 'loader', + 'component', + 'pendingComponent', + 'errorComponent', + 'notFoundComponent', +] +const factoryNames = new Set([ + 'createFileRoute', + 'createRoute', + 'createRootRoute', + 'createRootRouteWithContext', +]) -export function removeBindingsDependingOnRoute( - bindings: Set, - dependencyGraph: Map>, -) { - removeBindingsTransitivelyDependingOn(bindings, dependencyGraph, ['Route']) +function splitMeta(key: SplitRouteIdentNodes): SplitNodeMeta { + const capitalized = `${key[0]!.toUpperCase()}${key.slice(1)}` + return { + routeIdent: key, + localImporterIdent: `$$split${capitalized}Importer`, + splitStrategy: key === 'loader' ? 'lazyFn' : 'lazyRouteComponent', + localExporterIdent: `Split${capitalized}`, + exporterIdent: key, + } } -const SPLIT_NODES_CONFIG = new Map([ - [ - 'loader', - { - routeIdent: 'loader', - localImporterIdent: '$$splitLoaderImporter', // const $$splitLoaderImporter = () => import('...') - splitStrategy: 'lazyFn', - localExporterIdent: 'SplitLoader', // const SplitLoader = ... - exporterIdent: 'loader', // export { SplitLoader as loader } - }, - ], - [ - 'component', - { - routeIdent: 'component', - localImporterIdent: '$$splitComponentImporter', // const $$splitComponentImporter = () => import('...') - splitStrategy: 'lazyRouteComponent', - localExporterIdent: 'SplitComponent', // const SplitComponent = ... - exporterIdent: 'component', // export { SplitComponent as component } - }, - ], - [ - 'pendingComponent', - { - routeIdent: 'pendingComponent', - localImporterIdent: '$$splitPendingComponentImporter', // const $$splitPendingComponentImporter = () => import('...') - splitStrategy: 'lazyRouteComponent', - localExporterIdent: 'SplitPendingComponent', // const SplitPendingComponent = ... - exporterIdent: 'pendingComponent', // export { SplitPendingComponent as pendingComponent } - }, - ], - [ - 'errorComponent', - { - routeIdent: 'errorComponent', - localImporterIdent: '$$splitErrorComponentImporter', // const $$splitErrorComponentImporter = () => import('...') - splitStrategy: 'lazyRouteComponent', - localExporterIdent: 'SplitErrorComponent', // const SplitErrorComponent = ... - exporterIdent: 'errorComponent', // export { SplitErrorComponent as errorComponent } - }, - ], - [ - 'notFoundComponent', - { - routeIdent: 'notFoundComponent', - localImporterIdent: '$$splitNotFoundComponentImporter', // const $$splitNotFoundComponentImporter = () => import('...') - splitStrategy: 'lazyRouteComponent', - localExporterIdent: 'SplitNotFoundComponent', // const SplitNotFoundComponent = ... - exporterIdent: 'notFoundComponent', // export { SplitNotFoundComponent as notFoundComponent } - }, - ], -]) +function bareFilename(filename: string) { + return filename.split('?')[0]! +} -const KNOWN_SPLIT_ROUTE_IDENTS = [...SPLIT_NODES_CONFIG.keys()] as const +function splitFilename(filename: string, grouping: Array) { + return `${bareFilename(filename)}?${tsrSplit}=${createIdentifier(grouping)}` +} + +/** + * A module of a route file's shared bindings: `values`, or `components` for + * those {@link partitionSharedBindings} emits in a module of their own. + */ +export type SharedModulePart = 'values' | 'components' -function addSplitSearchParamToFilename( +export function addSharedSearchParamToFilename( filename: string, - grouping: Array, + part: SharedModulePart = 'values', ) { - const [bareFilename] = filename.split('?') - - const params = new URLSearchParams() - params.append(tsrSplit, createIdentifier(grouping)) - - const result = `${bareFilename}?${params.toString()}` - return result + return `${bareFilename(filename)}?${tsrShared}=${part === 'components' ? part : '1'}` } -function removeSplitSearchParamFromFilename(filename: string) { - const [bareFilename] = filename.split('?') - return bareFilename! +type RouteDefinition = { + options: ObjectExpression + factory: string + statement: Node } -export function addSharedSearchParamToFilename(filename: string) { - const [bareFilename] = filename.split('?') - return `${bareFilename}?${tsrShared}=1` +/** Source syntax and semantic identities are immutable across all output chunks. */ +export type RouteModuleAnalysis = { + module: Module + routes: Array + /** + * The route of the module, which route HMR updates: the one the exported + * `Route` holds, else the first one. + */ + moduleRoute: RouteDefinition | undefined + /** + * The module's `Route` is not created by a factory call in module code, but a + * function calls a route factory: it creates the module's route, + * which can be neither split nor updated by route HMR. + */ + routeCreatedInFunction: boolean + graph: ReturnType + /** + * Declaration dependencies without the `Route` singleton. Split and shared + * modules import `Route` rather than declare it, so reading it never brings + * the dependencies of every route option along. + */ + chunkDependencies: Map> + exported: Map> } -const splittableCreateRouteFns = ['createFileRoute'] -const unsplittableCreateRouteFns = [ - 'createRootRoute', - 'createRootRouteWithContext', -] -const allCreateRouteFns = [ - ...splittableCreateRouteFns, - ...unsplittableCreateRouteFns, -] - -/** - * Computes module-level bindings that are shared between split and non-split - * route properties. These bindings need to be extracted into a shared virtual - * module to avoid double-initialization. - * - * A binding is "shared" if it is referenced by at least one split property - * AND at least one non-split property. Only locally-declared module-level - * bindings are candidates (not imports — bundlers dedupe those). - */ -export function computeSharedBindings(opts: { - code: string - filename?: string - codeSplitGroupings: CodeSplitGroupings -}): Set { - const ast = parseAst(opts) +type SourceOptions = AnalyzeModuleOptions & { analysis?: RouteModuleAnalysis } - // Early bailout: collect all module-level locally-declared binding names. - // This is a cheap loop over program.body (no traversal). If the file has - // no local bindings (aside from `Route`), nothing can be shared — skip - // the expensive babel.traverse entirely. - const localModuleLevelBindings = new Set() - for (const node of ast.program.body) { - collectLocalBindingsFromStatement(node, localModuleLevelBindings) +function resolveExpression( + module: Module, + node: Node | null | undefined, + seen = new Set(), +): Node | undefined { + if (!node) { + return undefined } - - // File-based routes always export a route config binding (usually `Route`). - // This must never be extracted into the shared module. - localModuleLevelBindings.delete('Route') - - if (localModuleLevelBindings.size === 0) { - return new Set() + node = unwrapExpression(node) + if (is.Identifier(node)) { + const binding = module.bindingOf(node) + if (!binding || seen.has(binding)) { + return undefined + } + seen.add(binding) + let declaration: Node | null | undefined = binding.declarations[0] + while ( + declaration && + !is.VariableDeclarator(declaration) && + !is.Program(declaration) + ) { + declaration = module.parentOf(declaration) + } + if (is.VariableDeclarator(declaration) && is.Identifier(declaration.id)) { + return resolveExpression(module, declaration.init, seen) + } + return undefined } + return node +} - function findIndexForSplitNode(str: string) { - return opts.codeSplitGroupings.findIndex((group) => - group.includes(str as any), - ) +/** Whether code runs when the module does: at module level or in its blocks. */ +function isModuleCode(module: Module, node: Node) { + for (const scope of module.scopeOf(node).ancestors()) { + if (scope === module.rootScope) { + return true + } + if (scope.kind !== 'block') { + return false + } } + return false +} - // Find the route options object — needs babel.traverse for scope resolution - let routeOptions: t.ObjectExpression | undefined - - babel.traverse(ast, { - CallExpression(path) { - if (!t.isIdentifier(path.node.callee)) return - if (!splittableCreateRouteFns.includes(path.node.callee.name)) return - - if (t.isCallExpression(path.parentPath.node)) { - const opts = resolveIdentifier(path, path.parentPath.node.arguments[0]) - if (t.isObjectExpression(opts)) routeOptions = opts - } else if (t.isVariableDeclarator(path.parentPath.node)) { - const caller = resolveIdentifier(path, path.parentPath.node.init) - if (t.isCallExpression(caller)) { - const opts = resolveIdentifier(path, caller.arguments[0]) - if (t.isObjectExpression(opts)) routeOptions = opts - } +export function analyzeRouteModule( + options: AnalyzeModuleOptions, +): RouteModuleAnalysis { + const module = analyzeModule(options) + const routes: Array = [] + const { exports: moduleExports } = module + const routeIdentifier = moduleExports.find( + (entry) => entry.name === 'Route' && !entry.typeOnly, + )?.local?.declarations[0] + const routeDeclarator = routeIdentifier && module.parentOf(routeIdentifier) + const routeInit = is.VariableDeclarator(routeDeclarator) + ? routeDeclarator.init && unwrapExpression(routeDeclarator.init) + : undefined + let moduleRoute: RouteDefinition | undefined + // A factory call inside a function, and one in module code that initializes + // the exported `Route` + let nestedFactoryCall: Node | undefined + let routeFactoryCall: Node | undefined + const seen = new Set() + module.walk({ + CallExpression(node, ctx) { + const outerCallee = unwrapExpression(node.callee) + const callee = unwrapExpression( + is.CallExpression(outerCallee) ? outerCallee.callee : outerCallee, + ) + if (!is.Identifier(callee) || !factoryNames.has(callee.name)) { + return + } + // A factory call inside a function can close over local bindings, so + // its options cannot be split or hoisted to module scope. A block at + // module level runs with the module: a `var` it declares, such as + // `if (x) { var Route = createFileRoute(...)(...) }`, is a module binding. + if (!isModuleCode(module, node)) { + nestedFactoryCall ??= node + return + } + const ancestors = ctx.ancestors() + if (routeDeclarator && ancestors.includes(routeDeclarator)) { + routeFactoryCall ??= node + } + // The top-level statement, the ancestor after the program + const statement = ancestors[1] ?? node + const options = resolveExpression(module, node.arguments[0]) + if (!is.ObjectExpression(options) || seen.has(options)) { + return + } + seen.add(options) + const route = { options, factory: callee.name, statement } + routes.push(route) + if (node === routeInit) { + moduleRoute = route } }, }) - - if (!routeOptions) return new Set() - - // Fast path: if fewer than 2 distinct groups are referenced by route options, - // nothing can be shared and we can skip the rest of the work. - const splitGroupsPresent = new Set() - let hasNonSplit = false - for (const prop of routeOptions.properties) { - if (!t.isObjectProperty(prop)) continue - const key = getObjectPropertyKeyName(prop) - if (!key) continue - if (key === 'codeSplitGroupings') continue - if (t.isIdentifier(prop.value) && prop.value.name === 'undefined') continue - const groupIndex = findIndexForSplitNode(key) // -1 if non-split - if (groupIndex === -1) { - hasNonSplit = true - } else { - splitGroupsPresent.add(groupIndex) + const exported = new Map>() + for (const entry of moduleExports) { + if (entry.local && entry.name !== null && !entry.typeOnly) { + const names = exported.get(entry.local) ?? [] + if (!names.includes(entry.name)) { + names.push(entry.name) + } + exported.set(entry.local, names) } } + const graph = moduleDeclarationGraph(module) + const chunkDependencies = new Map>() + for (const [binding, dependencies] of graph.dependencies) { + if (binding.name !== 'Route') { + chunkDependencies.set( + binding, + new Set( + [...dependencies].filter((dependency) => dependency.name !== 'Route'), + ), + ) + } + } + return { + module, + routes, + moduleRoute: moduleRoute ?? routes[0], + routeCreatedInFunction: !!nestedFactoryCall && !routeFactoryCall, + graph, + chunkDependencies, + exported, + } +} - if (!hasNonSplit && splitGroupsPresent.size < 2) return new Set() - - // Build dependency graph up front — needed for transitive expansion per-property. - // This graph excludes `Route` (deleted above) so group attribution works correctly. - const declMap = buildDeclarationMap(ast) - const depGraph = buildDependencyGraph(declMap, localModuleLevelBindings) +function sourceAnalysis(options: SourceOptions) { + return options.analysis ?? analyzeRouteModule(options) +} - // Build a second dependency graph that includes `Route` so we can detect - // bindings that transitively depend on it. Such bindings must NOT be - // extracted into the shared module because they would drag the Route - // singleton with them, duplicating it across modules. - const allLocalBindings = new Set(localModuleLevelBindings) - allLocalBindings.add('Route') - const fullDepGraph = buildDependencyGraph(declMap, allLocalBindings) +function isFallback(value: Expression) { + value = unwrapExpression(value) + return ( + is.NullLiteral(value) || + is.BooleanLiteral(value) || + (is.Identifier(value) && value.name === 'undefined') + ) +} - // For each route property, track which "group" it belongs to. - // Non-split properties get group index -1. - // Split properties get their codeSplitGroupings index (0, 1, ...). - // A binding is "shared" if it appears in 2+ distinct groups. - // We expand each property's refs transitively BEFORE comparing groups, - // so indirect refs (e.g., component: MyComp where MyComp uses `shared`) - // are correctly attributed. - const refsByGroup = new Map>() +function properties(route: RouteDefinition) { + return route.options.properties.filter( + (property): property is ObjectProperty => is.Property(property), + ) +} - for (const prop of routeOptions.properties) { - if (!t.isObjectProperty(prop)) continue - const key = getObjectPropertyKeyName(prop) - if (!key) continue +/** The properties the runtime object keeps: a later duplicate key wins. */ +function runtimeProperties(route: RouteDefinition) { + const all = properties(route) + return all.filter((property, index) => { + const key = getObjectPropertyKeyName(property) + return ( + !key || + !all + .slice(index + 1) + .some((later) => getObjectPropertyKeyName(later) === key) + ) + }) +} - if (key === 'codeSplitGroupings') continue +function isDataProperty(property: ObjectProperty) { + return !property.method && property.kind === 'init' +} - const groupIndex = findIndexForSplitNode(key) // -1 if non-split +/** + * The property whose value a split moves into its chunk: the route option + * itself, or the `handler` of a loader in object form, since the router reads + * the other keys of that object (`staleReloadMode`) from the route options. + * Undefined when the option keeps its value in the route module. + */ +function splitProperty(key: string, property: ObjectProperty) { + const value = unwrapExpression(property.value) + if (key !== 'loader' || !is.ObjectExpression(value)) { + return property + } + // The last `handler` wins, unless a spread or a computed key may override it + for (let index = value.properties.length - 1; index >= 0; index--) { + const candidate = value.properties[index]! + if (!is.Property(candidate) || candidate.computed) { + return undefined + } + if (getObjectPropertyKeyName(candidate) === 'handler') { + return isDataProperty(candidate) && !isFallback(candidate.value) + ? candidate + : undefined + } + } + return undefined +} - const directRefs = collectModuleLevelRefsFromNode( - prop.value, - localModuleLevelBindings, +/** Assign each initializer and its dependencies to the chunks that consume it. */ +export function computeSharedBindings( + options: SourceOptions & { codeSplitGroupings: CodeSplitGroupings }, +): Set { + const analysis = sourceAnalysis(options) + const { module, graph, chunkDependencies } = analysis + const groupsByBinding = new Map>() + // Exported split targets are not split: they stay in the route module + const unsplitTargets = new Set() + // What split modules read, up to the exported variables they import from + // the route module rather than declare + const splitReads = new Set() + const splitDependencies = new Map( + [...chunkDependencies].filter( + ([binding]) => + !( + analysis.exported.has(binding) && + is.VariableDeclarator(graph.declarations.get(binding)) + ), + ), + ) + // Only a top-level declaration with a runtime value can move to the shared + // module: an ambient one is provided by the environment and erased with types + const locals = new Set( + [...graph.declarations.keys()].filter( + (binding) => + binding.name !== 'Route' && + !binding.has(BindingFlags.Import) && + !isAmbient(module, binding), + ), + ) + for (const route of analysis.routes) { + if (route.factory !== 'createFileRoute') { + continue + } + for (const property of properties(route)) { + const key = getObjectPropertyKeyName(property) + if (!key || key === 'codeSplitGroupings' || isFallback(property.value)) { + continue + } + const group = isDataProperty(property) + ? options.codeSplitGroupings.findIndex((keys) => + keys.includes(key as SplitRouteIdentNodes), + ) + : -1 + const split = group === -1 ? undefined : splitProperty(key, property) + // What stays in the route module belongs to the reference group (-1) + const parts: Array<[Node, number]> = + split === property + ? [[property.value, group]] + : !split + ? [[property.value, -1]] + : [ + [split.value, group], + ...( + unwrapExpression(property.value) as ObjectExpression + ).properties + .filter((other) => other !== split) + .map((other): [Node, number] => [other, -1]), + ] + const target = split && unwrapExpression(split.value) + const targetBinding = + group !== -1 && is.Identifier(target) ? module.bindingOf(target) : null + const unsplit = !!targetBinding && analysis.exported.has(targetBinding) + if (unsplit) { + unsplitTargets.add(targetBinding) + } + for (const [node, partGroup] of parts) { + const direct = collectModuleReferences(module, node) + if (partGroup !== -1 && !unsplit) { + for (const binding of expandTransitively(direct, splitDependencies)) { + splitReads.add(binding) + } + } + const references = expandTransitively(direct, chunkDependencies) + for (const binding of references) { + if (!locals.has(binding)) { + continue + } + const groups = groupsByBinding.get(binding) ?? new Set() + groups.add(partGroup) + groupsByBinding.set(binding, groups) + } + } + } + } + const shared = new Set() + for (const [binding, groups] of groupsByBinding) { + if (groups.size > 1) { + shared.add(binding) + } + } + // A destructured initializer belongs to one module even when its individual + // bindings are used in different chunks. + for (const siblings of graph.declarationBindings.values()) { + const groups = new Set( + [...siblings].flatMap((binding) => [ + ...(groupsByBinding.get(binding) ?? []), + ]), ) - - // Expand transitively: if component references SharedComp which references - // `shared`, then `shared` is also attributed to component's group. - const allRefs = new Set(directRefs) - expandTransitively(allRefs, depGraph) - - for (const ref of allRefs) { - let groups = refsByGroup.get(ref) - if (!groups) { - groups = new Set() - refsByGroup.set(ref, groups) + if ( + groups.size > 1 || + [...siblings].some((binding) => shared.has(binding)) + ) { + for (const binding of siblings) { + if (locals.has(binding)) { + shared.add(binding) + } } - groups.add(groupIndex) } } - - // Shared = bindings appearing in 2+ distinct groups - const shared = new Set() - for (const [name, groups] of refsByGroup) { - if (groups.size >= 2) shared.add(name) + const forbidden = new Set( + module.rootScope.bindings.filter((binding) => binding.name === 'Route'), + ) + let changed = true + while (changed) { + changed = false + for (const [binding, dependencies] of graph.dependencies) { + if ( + !forbidden.has(binding) && + [...dependencies].some((dependency) => forbidden.has(dependency)) + ) { + forbidden.add(binding) + changed = true + } + } + for (const siblings of graph.declarationBindings.values()) { + if ([...siblings].some((binding) => forbidden.has(binding))) { + for (const binding of siblings) { + if (!forbidden.has(binding)) { + forbidden.add(binding) + changed = true + } + } + } + } } + // An exported function or class that a split module reads moves to the + // shared module with its private dependencies, and the route module + // re-exports it from there: one module instance holds it, and the bundler + // can leave it out of the route module's chunk when nothing else imports it. + const constructors = pureConstructors(module) + const effectful = module.ast.body.filter( + (statement) => !isPureStatement(statement, constructors), + ) + for (const binding of splitReads) { + if ( + shared.has(binding) || + !analysis.exported.has(binding) || + unsplitTargets.has(binding) || + !locals.has(binding) || + forbidden.has(binding) || + !isFunctionOrClass(analysis, binding) + ) { + continue + } + // A destructuring moves with all of its bindings + const moved = new Set() + for (const dependency of expandTransitively( + new Set([binding]), + chunkDependencies, + )) { + const declaration = graph.declarations.get(dependency) + for (const sibling of (declaration && + graph.declarationBindings.get(declaration)) ?? [dependency]) { + if (locals.has(sibling)) { + moved.add(sibling) + } + } + } + const declarations = [...moved].map( + (dependency) => graph.declarations.get(dependency)!, + ) + // A binding that code outside the moved declarations reassigns cannot + // become an import + const writtenOutside = [...moved].some((dependency) => + dependency.references.some( + (reference) => + reference.isWrite && + !declarations.some((declaration) => + contains(declaration, reference.node), + ), + ), + ) + if (writtenOutside) { + continue + } + // The shared module runs before the route module's own statements, so a + // moved statement with effects cannot overtake one that stays behind + const moving = [ + ...declarations, + ...[...shared].map((dependency) => graph.declarations.get(dependency)!), + ] + let staysBefore = false + let overtakes = false + for (const statement of effectful) { + if (!moving.some((declaration) => contains(statement, declaration))) { + staysBefore = true + } else if ( + staysBefore && + declarations.some((declaration) => contains(statement, declaration)) + ) { + overtakes = true + } + } + if (overtakes) { + continue + } + for (const dependency of moved) { + shared.add(dependency) + } + } + return new Set( + [...shared] + .filter((binding) => !forbidden.has(binding)) + .map((binding) => binding.name), + ) +} - // Destructured declarators (e.g. `const { a, b } = fn()`) must be treated - // as a single initialization unit. Even if each binding is referenced by - // only one group, if *different* bindings from the same declarator are - // referenced by different groups, the declarator must be extracted to the - // shared module to avoid double initialization. - expandSharedDestructuredDeclarators(ast, refsByGroup, shared) - - if (shared.size === 0) return shared +/** + * Whether the environment provides a binding's value: its declarations are + * `declare`d or only types, as `declare var X` merged with `interface X`. + * Yuku's `Ambient` flag requires every declaration, interfaces included, to be + * `declare`d. + */ +function isAmbient(module: Module, binding: Binding) { + return ( + binding.has(BindingFlags.Ambient) || + (binding.has(BindingFlags.Interface | BindingFlags.TypeAlias) && + binding.declarations.every((identifier) => { + const declaration = module.parentOf(identifier) + if ( + is.TSInterfaceDeclaration(declaration) || + is.TSTypeAliasDeclaration(declaration) || + is.TSDeclareFunction(declaration) + ) { + return true + } + const statement = is.VariableDeclarator(declaration) + ? module.parentOf(declaration) + : declaration + return !!statement && 'declare' in statement && !!statement.declare + })) + ) +} - // If any binding from a destructured declaration is shared, - // all bindings from that declaration must be shared - expandDestructuredDeclarations(ast, shared) +function contains(outer: Node, inner: Node) { + return outer.start <= inner.start && inner.end <= outer.end +} - // Remove shared bindings that transitively depend on `Route`. - // The Route singleton must stay in the reference file; extracting a - // binding that references it would duplicate Route in the shared module. - removeBindingsTransitivelyDependingOn(shared, fullDepGraph, ['Route']) +/** The initializer of a binding a `const` declares, without type syntax. */ +function constInitializer(analysis: RouteModuleAnalysis, binding: Binding) { + const declaration = analysis.graph.declarations.get(binding) + if (!is.VariableDeclarator(declaration) || !declaration.init) { + return undefined + } + const parent = analysis.module.parentOf(declaration) + return is.VariableDeclaration(parent) && parent.kind === 'const' + ? unwrapExpression(declaration.init) + : undefined +} - return shared +/** A function declaration or `const` function with the name of a component. */ +function isComponentLike(analysis: RouteModuleAnalysis, binding: Binding) { + const declaration = analysis.graph.declarations.get(binding) + const init = constInitializer(analysis, binding) + const fn = is.FunctionDeclaration(declaration) + ? declaration + : is.ArrowFunctionExpression(init) || is.FunctionExpression(init) + ? init + : undefined + return !!fn && !fn.async && !fn.generator && /^[A-Z]/.test(binding.name) } /** - * Find which shared bindings are user-exported in the original source. - * These need to be re-exported from the shared module. + * The modules a route file's shared bindings are emitted in. React Refresh + * hot-updates only a module whose exports are all components, so with + * `splitComponents` the shared bindings that look like components, and that + * no other shared binding reads, go to a `components` module. It imports the + * rest from the `values` module, which never imports it. A component that a + * shared value holds, as in `const columns = [{ render: Cell }]`, stays in the + * `values` module with the value, which React Refresh then cannot hot-update. */ -function findExportedSharedBindings( - ast: t.File, - sharedBindings: Set, -): Set { - const exported = new Set() - for (const stmt of ast.program.body) { - if (!t.isExportNamedDeclaration(stmt) || !stmt.declaration) continue - - if (t.isVariableDeclaration(stmt.declaration)) { - for (const decl of stmt.declaration.declarations) { - for (const name of collectIdentifiersFromPattern(decl.id)) { - if (sharedBindings.has(name)) exported.add(name) +export function partitionSharedBindings( + analysis: RouteModuleAnalysis, + shared: Set | undefined, + splitComponents: boolean | undefined, +): Array<[SharedModulePart, Set]> { + if (!shared?.size) { + return [] + } + if (!splitComponents) { + return [['values', shared]] + } + const bindings = analysis.module.rootScope.bindings.filter( + (binding) => binding.name !== 'Route' && shared.has(binding.name), + ) + const components = new Set( + bindings.filter((binding) => isComponentLike(analysis, binding)), + ) + // A value never reads a component, and a component never assigns a value: + // an imported binding cannot be assigned + let changed = true + while (changed) { + changed = false + for (const binding of bindings) { + if (components.has(binding)) { + continue + } + for (const dependency of analysis.graph.dependencies.get(binding) ?? []) { + changed = components.delete(dependency) || changed + } + for (const reference of binding.references) { + if (!reference.isWrite) { + continue + } + for (const component of components) { + if ( + contains( + analysis.graph.declarations.get(component)!, + reference.node, + ) + ) { + components.delete(component) + changed = true + } } } - } else if ( - t.isFunctionDeclaration(stmt.declaration) && - stmt.declaration.id - ) { - if (sharedBindings.has(stmt.declaration.id.name)) - exported.add(stmt.declaration.id.name) - } else if (t.isClassDeclaration(stmt.declaration) && stmt.declaration.id) { - if (sharedBindings.has(stmt.declaration.id.name)) - exported.add(stmt.declaration.id.name) } } - return exported + const componentNames = new Set([...components].map((binding) => binding.name)) + const values = new Set( + [...shared].filter((name) => !componentNames.has(name)), + ) + return ( + [ + ['values', values], + ['components', componentNames], + ] as Array<[SharedModulePart, Set]> + ).filter(([, names]) => names.size > 0) } -/** - * Remove declarations of shared bindings from the AST. - * Handles both plain and exported declarations, including destructured patterns. - * Removes the entire statement if all bindings in it are shared. - */ -function removeSharedDeclarations(ast: t.File, sharedBindings: Set) { - ast.program.body = ast.program.body.filter((stmt) => { - const decl = - t.isExportNamedDeclaration(stmt) && stmt.declaration - ? stmt.declaration - : stmt +/** A function or class declaration, or a `const` holding a function. */ +function isFunctionOrClass(analysis: RouteModuleAnalysis, binding: Binding) { + const declaration = analysis.graph.declarations.get(binding) + const init = constInitializer(analysis, binding) + return ( + is.FunctionDeclaration(declaration) || + is.ClassDeclaration(declaration) || + is.ArrowFunctionExpression(init) || + is.FunctionExpression(init) + ) +} - if (t.isVariableDeclaration(decl)) { - // Filter out declarators where all bound names are shared - decl.declarations = decl.declarations.filter((declarator) => { - const names = collectIdentifiersFromPattern(declarator.id) - return !names.every((n) => sharedBindings.has(n)) - }) - // If no declarators remain, remove the entire statement - if (decl.declarations.length === 0) return false - } else if (t.isFunctionDeclaration(decl) && decl.id) { - if (sharedBindings.has(decl.id.name)) return false - } else if (t.isClassDeclaration(decl) && decl.id) { - if (sharedBindings.has(decl.id.name)) return false - } +function identifier(name: string) { + return b.Identifier({ name }) +} +function reference(name: string, binding?: Binding) { + const node = identifier(name) + linkGeneratedReference(node, binding ?? name) + return node +} +function string(value: string) { + return b.Literal({ value, raw: JSON.stringify(value) }) as StringLiteral +} +function moduleName(name: string) { + return isIdentifierName(name) ? identifier(name) : string(name) +} +function imports( + names: Array<{ local: string; imported: string }>, + source: string, +) { + return b.ImportDeclaration({ + source: string(source), + attributes: [], + phase: null, + specifiers: names.map(({ local, imported }) => + b.ImportSpecifier({ + local: identifier(local), + imported: moduleName(imported), + }), + ), + }) +} +function exports( + names: Array<{ local: string; exported: string; binding?: Binding }>, + source: string | null = null, +) { + return b.ExportNamedDeclaration({ + declaration: null, + source: source === null ? null : string(source), + attributes: [], + specifiers: names.map(({ local, exported, binding }) => + b.ExportSpecifier({ + local: source === null ? reference(local, binding) : moduleName(local), + exported: moduleName(exported), + }), + ), + }) +} +function variable(name: string, init: Expression) { + return b.VariableDeclaration({ + kind: 'const', + declarations: [b.VariableDeclarator({ id: identifier(name), init })], + }) +} +function call(name: string, args: Array) { + return b.CallExpression({ + callee: reference(name), + arguments: args, + optional: false, + }) +} - return true +/** Print a module emitted for a route file, led by the file's pragmas. */ +function generateRouteModule( + analysis: RouteModuleAnalysis, + program: Program, + filename: string, +) { + keepFilePragmas(analysis.module.ast, program) + return generateModule(program, { + source: analysis.module.source, + filename, }) } -export function compileCodeSplitReferenceRoute( - opts: ParseAstOptions & - CompileCodeSplitReferenceRouteOptions & { - compilerPlugins?: Array +function createOutput(analysis: RouteModuleAnalysis) { + const { program, originalNodes } = cloneModuleAst(analysis.module) + const reservedNames = new Set([ + ...analysis.module.bindings.map((binding) => binding.name), + ...analysis.module.unresolvedReferences.map((reference) => reference.name), + ]) + const copies = new Map() + walk(program, { + enter(node) { + copies.set(originalNodes.get(node)!, node) }, -): GeneratorResult | null { - const ast = parseAst(opts) + }) + // Most outputs never rename a binding: build the index on first use + let declarationExports: Map | undefined + const renameBinding = (node: Node, name: string) => { + const original = originalNodes.get(node) + const binding = original ? analysis.module.bindingOf(original) : null + if (!binding) { + return + } + if (!declarationExports) { + declarationExports = new Map() + for (const entry of analysis.module.exports) { + if (!entry.local || entry.name === null || entry.typeOnly) { + continue + } + // The top-level statement, the last ancestor before the program + let statement: Node = entry.node + for (const ancestor of analysis.module.ancestors(entry.node)) { + if (is.Program(ancestor)) { + break + } + statement = ancestor + } + if (is.ExportNamedDeclaration(statement) && statement.declaration) { + const entries = declarationExports.get(statement) ?? [] + declarationExports.set(statement, [...entries, entry]) + } + } + } + const aliasedNames = new Set() + for (const [statement, entries] of declarationExports) { + const copy = copies.get(statement) + if ( + !entries.some((entry) => entry.local === binding) || + !is.ExportNamedDeclaration(copy) || + !copy.declaration + ) { + continue + } + const index = program.body.indexOf(copy) + if (index !== -1) { + const uniqueEntries = entries.filter((entry) => { + if (aliasedNames.has(entry.name!)) { + return false + } + aliasedNames.add(entry.name!) + return true + }) + const aliases = exports( + uniqueEntries.map((entry) => ({ + local: entry.local!.name, + exported: entry.name!, + binding: entry.local!, + })), + ) + for (const [index, entry] of uniqueEntries.entries()) { + originalNodes.set(aliases.specifiers[index]!.local, entry.node) + } + program.body.splice( + index, + 1, + copy.declaration, + ...(uniqueEntries.length ? [aliases] : []), + ) + } + } + walk(program, { + enter(candidate, context) { + if (!is.Identifier(candidate) && !is.JSXIdentifier(candidate)) { + return + } + const source = originalNodes.get(candidate) + if (source && analysis.module.bindingOf(source) === binding) { + candidate.name = name + const parent = context.parent + if ( + is.Property(parent) && + parent.shorthand && + is.Identifier(parent.key) && + is.Identifier(parent.value) && + parent.key.name !== parent.value.name + ) { + parent.shorthand = false + } + } + }, + }) + } + return { program, originalNodes, reservedNames, copies, renameBinding } +} - const refIdents = findReferencedIdentifiers(ast) +/** Split modules export only their own split values; keep local declarations. */ +function withoutExportSyntax( + statement: ProgramStatement, +): Array { + if (is.ExportNamedDeclaration(statement)) { + return statement.declaration + ? [unwrapExport(statement, statement.declaration)] + : [] + } + if (is.ExportDefaultDeclaration(statement)) { + const { declaration } = statement + // An anonymous default declaration has no binding that could be used here + return (is.FunctionDeclaration(declaration) || + is.ClassDeclaration(declaration)) && + declaration.id + ? [unwrapExport(statement, declaration)] + : [] + } + if (is.ExportAllDeclaration(statement)) { + return [] + } + return [statement] +} - const knownExportedIdents = new Set() +function removeDeclarations(program: Program, names: Set) { + const removedDefaultExports = new Set() + walk(program, { + enter(node, context) { + if ( + (is.TSEnumDeclaration(node) || + is.TSModuleDeclaration(node) || + is.TSDeclareFunction(node)) && + node.id && + is.Identifier(node.id) && + names.has(node.id.name) && + (is.Program(context.parent) || + is.ExportNamedDeclaration(context.parent)) + ) { + context.remove() + } + }, + VariableDeclaration(node, context) { + if ( + is.Program(context.parent) || + is.ExportNamedDeclaration(context.parent) + ) { + node.declarations = node.declarations.filter( + (declarator) => + !bindingIdentifiers(declarator.id).every((id) => + names.has(id.name), + ), + ) + } + }, + FunctionDeclaration(node, context) { + if ( + node.id && + names.has(node.id.name) && + (is.Program(context.parent) || + is.ExportNamedDeclaration(context.parent) || + is.ExportDefaultDeclaration(context.parent)) + ) { + if (is.ExportDefaultDeclaration(context.parent)) { + removedDefaultExports.add(context.parent) + } + context.remove() + } + }, + ClassDeclaration(node, context) { + if ( + node.id && + names.has(node.id.name) && + (is.Program(context.parent) || + is.ExportNamedDeclaration(context.parent) || + is.ExportDefaultDeclaration(context.parent)) + ) { + if (is.ExportDefaultDeclaration(context.parent)) { + removedDefaultExports.add(context.parent) + } + context.remove() + } + }, + leave(node, context) { + if ( + (is.VariableDeclaration(node) && node.declarations.length === 0) || + (is.ExportNamedDeclaration(node) && + !node.declaration && + node.specifiers.length === 0 && + !node.source) || + removedDefaultExports.has(node) + ) { + context.remove() + } + }, + }) +} - function findIndexForSplitNode(str: string) { - return opts.codeSplitGroupings.findIndex((group) => - group.includes(str as any), +function addSharedImports( + program: Program, + parts: Array<[SharedModulePart, Set]>, + filename: string, +) { + for (const [part, names] of parts) { + removeDeclarations(program, names) + prependStatements( + program, + imports( + [...names].map((name) => ({ local: name, imported: name })), + addSharedSearchParamToFilename(filename, part), + ), ) } +} - const frameworkOptions = getFrameworkOptions(opts.targetFramework) - const PACKAGE = frameworkOptions.package - const LAZY_ROUTE_COMPONENT_IDENT = frameworkOptions.idents.lazyRouteComponent - const LAZY_FN_IDENT = frameworkOptions.idents.lazyFn - const stableRouteOptionKeys = [ - ...new Set( - (opts.compilerPlugins ?? []).flatMap( - (plugin) => plugin.getStableRouteOptionKeys?.() ?? [], - ), - ), - ] - - let createRouteFn: string - - let modified = false as boolean - let hmrAdded = false as boolean - let sharedExportedNames: Set | undefined - babel.traverse(ast, { - Program: { - enter(programPath) { - /** - * If the component for the route is being imported from - * another file, this is to track the path to that file - * the path itself doesn't matter, we just need to keep - * track of it so that we can remove it from the imports - * list if it's not being used like: - * - * `import '../shared/imported'` - */ - const removableImportPaths = new Set([]) - - programPath.traverse({ - CallExpression: (path) => { - if (!t.isIdentifier(path.node.callee)) { - return - } - - if (!allCreateRouteFns.includes(path.node.callee.name)) { - return - } - - createRouteFn = path.node.callee.name - - function babelHandleReference(routeOptions: t.Node | undefined) { - const hasImportedOrDefinedIdentifier = (name: string) => { - return programPath.scope.hasBinding(name) - } - - const addRouteHmr = ( - insertionPath: babel.NodePath, - routeOptions: t.ObjectExpression, - ) => { - if (!opts.addHmr || hmrAdded) { - return - } - - opts.compilerPlugins?.forEach((plugin) => { - const pluginResult = plugin.onAddHmr?.({ - programPath, - callExpressionPath: path, - insertionPath, - routeOptions, - createRouteFn, - opts: opts as CompileCodeSplitReferenceRouteOptions, - }) - - if (pluginResult?.modified) { - modified = true - } - }) - - programPath.pushContainer( - 'body', - createRouteHmrStatement(stableRouteOptionKeys, { - hmrStyle: opts.hmrStyle ?? 'vite', - targetFramework: opts.targetFramework, - routeId: opts.hmrRouteId, - }), - ) - modified = true - hmrAdded = true - } - - if (t.isObjectExpression(routeOptions)) { - const insertionPath = path.getStatementParent() ?? path - - opts.compilerPlugins?.forEach((plugin) => { - const pluginResult = plugin.onRouteOptions?.({ - programPath, - callExpressionPath: path, - insertionPath, - routeOptions, - createRouteFn, - opts: opts as CompileCodeSplitReferenceRouteOptions, - }) - - if (pluginResult?.modified) { - modified = true - } - }) - - if (opts.deleteNodes && opts.deleteNodes.size > 0) { - routeOptions.properties = routeOptions.properties.filter( - (prop) => { - if (t.isObjectProperty(prop)) { - const key = getObjectPropertyKeyName(prop) - if (key && opts.deleteNodes!.has(key as any)) { - modified = true - return false - } - } - return true - }, - ) - } - if (!splittableCreateRouteFns.includes(createRouteFn)) { - opts.compilerPlugins?.forEach((plugin) => { - const pluginResult = plugin.onUnsplittableRoute?.({ - programPath, - callExpressionPath: path, - insertionPath, - routeOptions, - createRouteFn, - opts: opts as CompileCodeSplitReferenceRouteOptions, - }) - - if (pluginResult?.modified) { - modified = true - } - }) - - // we can't split this route but we still add HMR handling if enabled - addRouteHmr(insertionPath, routeOptions) - // exit traversal so this route is not split - return programPath.stop() - } - routeOptions.properties.forEach((prop) => { - if (t.isObjectProperty(prop)) { - const key = getObjectPropertyKeyName(prop) - - if (key) { - // If the user has not specified a split grouping for this key - // then we should not split it - const codeSplitGroupingByKey = findIndexForSplitNode(key) - if (codeSplitGroupingByKey === -1) { - return - } - const codeSplitGroup = [ - ...new Set( - opts.codeSplitGroupings[codeSplitGroupingByKey], - ), - ] - - // find key in nodeSplitConfig - const isNodeConfigAvailable = SPLIT_NODES_CONFIG.has( - key as any, - ) - - if (!isNodeConfigAvailable) { - return - } - - // Exit early if the value is a boolean, null, or undefined. - // These values mean "don't use this component, fallback to parent" - // No code splitting needed to preserve fallback behavior - if ( - t.isBooleanLiteral(prop.value) || - t.isNullLiteral(prop.value) || - (t.isIdentifier(prop.value) && - prop.value.name === 'undefined') - ) { - return - } - - const splitNodeMeta = SPLIT_NODES_CONFIG.get(key as any)! - - // We need to extract the existing search params from the filename, if any - // and add the relevant codesplitPrefix to them, then write them back to the filename - const splitUrl = addSplitSearchParamToFilename( - opts.filename, - codeSplitGroup, - ) - - if ( - splitNodeMeta.splitStrategy === 'lazyRouteComponent' - ) { - const value = prop.value - - let shouldSplit = true - - if (t.isIdentifier(value)) { - const existingImportPath = - getImportSpecifierAndPathFromLocalName( - programPath, - value.name, - ).path - if (existingImportPath) { - removableImportPaths.add(existingImportPath) - } - - // exported identifiers should not be split - // since they are already being imported - // and need to be retained in the compiled file - const isExported = hasExport(ast, value) - if (isExported) { - knownExportedIdents.add(value.name) - } - shouldSplit = !isExported - - if (shouldSplit) { - removeIdentifierLiteral(path, value) - } - } - - if (!shouldSplit) { - return - } - - modified = true - - // Prepend the import statement to the program along with the importer function - // Check to see if lazyRouteComponent is already imported before attempting - // to import it again - if ( - !hasImportedOrDefinedIdentifier( - LAZY_ROUTE_COMPONENT_IDENT, - ) - ) { - programPath.unshiftContainer('body', [ - template.statement( - `import { ${LAZY_ROUTE_COMPONENT_IDENT} } from '${PACKAGE}'`, - )(), - ]) - } - - // Check to see if the importer function is already defined - // If not, define it with the dynamic import statement - if ( - !hasImportedOrDefinedIdentifier( - splitNodeMeta.localImporterIdent, - ) - ) { - programPath.unshiftContainer('body', [ - template.statement( - `const ${splitNodeMeta.localImporterIdent} = () => import('${splitUrl}')`, - )(), - ]) - } - - const insertionPath = path.getStatementParent() ?? path - let splitPropValue: t.Expression | undefined - - for (const plugin of opts.compilerPlugins ?? []) { - const pluginPropValue = plugin.onSplitRouteProperty?.( - { - programPath, - callExpressionPath: path, - insertionPath, - routeOptions, - prop, - splitNodeMeta, - lazyRouteComponentIdent: - LAZY_ROUTE_COMPONENT_IDENT, - opts, - }, - ) - - if (!pluginPropValue) { - continue - } - - modified = true - splitPropValue = pluginPropValue - break - } - - if (splitPropValue) { - prop.value = splitPropValue - } else { - prop.value = template.expression( - `${LAZY_ROUTE_COMPONENT_IDENT}(${splitNodeMeta.localImporterIdent}, '${splitNodeMeta.exporterIdent}')`, - )() - } - - // add HMR handling - addRouteHmr(insertionPath, routeOptions) - } else { - // if (splitNodeMeta.splitStrategy === 'lazyFn') { - const value = prop.value - - let shouldSplit = true - - if (t.isIdentifier(value)) { - const existingImportPath = - getImportSpecifierAndPathFromLocalName( - programPath, - value.name, - ).path - if (existingImportPath) { - removableImportPaths.add(existingImportPath) - } - - // exported identifiers should not be split - // since they are already being imported - // and need to be retained in the compiled file - const isExported = hasExport(ast, value) - if (isExported) { - knownExportedIdents.add(value.name) - } - shouldSplit = !isExported - - if (shouldSplit) { - removeIdentifierLiteral(path, value) - } - } - - if (!shouldSplit) { - return - } - modified = true - - // Prepend the import statement to the program along with the importer function - if (!hasImportedOrDefinedIdentifier(LAZY_FN_IDENT)) { - programPath.unshiftContainer( - 'body', - template.smart( - `import { ${LAZY_FN_IDENT} } from '${PACKAGE}'`, - )(), - ) - } - - // Check to see if the importer function is already defined - // If not, define it with the dynamic import statement - if ( - !hasImportedOrDefinedIdentifier( - splitNodeMeta.localImporterIdent, - ) - ) { - programPath.unshiftContainer('body', [ - template.statement( - `const ${splitNodeMeta.localImporterIdent} = () => import('${splitUrl}')`, - )(), - ]) - } - - // Add the lazyFn call with the dynamic import to the prop value - prop.value = template.expression( - `${LAZY_FN_IDENT}(${splitNodeMeta.localImporterIdent}, '${splitNodeMeta.exporterIdent}')`, - )() - } - } - } - - programPath.scope.crawl() - }) - - addRouteHmr(insertionPath, routeOptions) - } - } - - if (t.isCallExpression(path.parentPath.node)) { - // createFileRoute('/')({ ... }) - const options = resolveIdentifier( - path, - path.parentPath.node.arguments[0], - ) - - babelHandleReference(options) - } else if (t.isVariableDeclarator(path.parentPath.node)) { - // createFileRoute({ ... }) - const caller = resolveIdentifier(path, path.parentPath.node.init) - - if (t.isCallExpression(caller)) { - const options = resolveIdentifier(path, caller.arguments[0]) - babelHandleReference(options) - } - } - }, - }) - - /** - * If the component for the route is being imported, - * and it's not being used, remove the import statement - * from the program, by checking that the import has no - * specifiers - */ - if (removableImportPaths.size > 0) { - modified = true - programPath.traverse({ - ImportDeclaration(path) { - if (path.node.specifiers.length > 0) return - if (removableImportPaths.has(path.node.source.value)) { - path.remove() - } - }, - }) +export function compileCodeSplitReferenceRoute( + options: SourceOptions & + CompileCodeSplitReferenceRouteOptions & { + compilerPlugins?: Array + }, +) { + const analysis = sourceAnalysis(options) + const output = createOutput(analysis) + const { program, originalNodes, reservedNames, copies, renameBinding } = + output + const framework = getFrameworkOptions(options.targetFramework) + const lazyImports = new Map() + const knownExported = new Set() + // What split values read, which moves into their chunks + const splitReads = new Set() + let modified = false + for (const route of analysis.routes) { + const routeOptions = copies.get(route.options) as ObjectExpression + const insertionStatement = copies.get(route.statement) as ProgramStatement + const context: ReferenceRouteCompilerPluginContext = { + program, + module: analysis.module, + graph: analysis.graph, + originalNodes, + reservedNames, + renameBinding, + routeOptions, + createRouteFn: route.factory, + opts: options, + insertBefore(nodes) { + // renameBinding unwraps an `export` declaration it renames + const index = program.body.findIndex( + (statement) => + statement === insertionStatement || + (is.ExportNamedDeclaration(insertionStatement) && + statement === insertionStatement.declaration), + ) + program.body.splice(index, 0, ...nodes) + }, + } + for (const plugin of options.compilerPlugins ?? []) { + modified = !!plugin.onRouteOptions?.(context)?.modified || modified + } + if (options.deleteNodes?.size) { + routeOptions.properties = routeOptions.properties.filter((property) => { + const remove = + is.Property(property) && + options.deleteNodes!.has(getObjectPropertyKeyName(property) ?? '') + modified ||= remove + return !remove + }) + } + if (route.factory === 'createFileRoute') { + for (const prop of routeOptions.properties) { + if (!is.Property(prop) || !isDataProperty(prop)) { + continue } - - // Handle shared bindings inside the Program visitor so we have - // access to programPath for cheap refIdents registration. - if (opts.sharedBindings && opts.sharedBindings.size > 0) { - sharedExportedNames = findExportedSharedBindings( - ast, - opts.sharedBindings, - ) - removeSharedDeclarations(ast, opts.sharedBindings) - - const sharedModuleUrl = addSharedSearchParamToFilename(opts.filename) - - const sharedImportSpecifiers = [...opts.sharedBindings].map((name) => - t.importSpecifier(t.identifier(name), t.identifier(name)), - ) - const [sharedImportPath] = programPath.unshiftContainer( - 'body', - t.importDeclaration( - sharedImportSpecifiers, - t.stringLiteral(sharedModuleUrl), + const key = getObjectPropertyKeyName(prop) as SplitRouteIdentNodes + const group = options.codeSplitGroupings.find((keys) => + keys.includes(key), + ) + if (!group || !splitKeys.includes(key) || isFallback(prop.value)) { + continue + } + const target = splitProperty(key, prop) + if (!target) { + continue + } + const original = originalNodes.get(unwrapExpression(target.value)) + const binding = + original && is.Identifier(original) + ? analysis.module.bindingOf(original) + : null + if (binding && analysis.exported.has(binding)) { + knownExported.add(binding.name) + continue + } + const splitValue = originalNodes.get(target.value) + if (splitValue) { + for (const reference of collectModuleReferences( + analysis.module, + splitValue, + )) { + splitReads.add(reference) + } + } + const meta = splitMeta(key) + let lazy = lazyImports.get(meta.splitStrategy) + if (!lazy) { + lazy = getUniqueProgramIdentifier( + reservedNames, + meta.splitStrategy, + ).name + prependStatements( + program, + imports( + [{ local: lazy, imported: meta.splitStrategy }], + framework.package, ), ) - - // Register import specifier locals in refIdents so DCE can remove unused ones - sharedImportPath.traverse({ - Identifier(identPath) { - if ( - identPath.parentPath.isImportSpecifier() && - identPath.key === 'local' - ) { - refIdents.add(identPath) - } - }, - }) - - // Re-export user-exported shared bindings from the shared module - if (sharedExportedNames.size > 0) { - const reExportSpecifiers = [...sharedExportedNames].map((name) => - t.exportSpecifier(t.identifier(name), t.identifier(name)), - ) - programPath.pushContainer( - 'body', - t.exportNamedDeclaration( - null, - reExportSpecifiers, - t.stringLiteral(sharedModuleUrl), - ), - ) + lazyImports.set(meta.splitStrategy, lazy) + } + meta.localImporterIdent = getUniqueProgramIdentifier( + reservedNames, + meta.localImporterIdent, + ).name + prependStatements( + program, + variable( + meta.localImporterIdent, + b.ArrowFunctionExpression({ + id: null, + generator: false, + async: false, + expression: true, + params: [], + body: b.ImportExpression({ + source: string(splitFilename(options.filename, group)), + options: null, + phase: null, + }), + }), + ), + ) + let value: Expression | undefined = undefined + for (const plugin of options.compilerPlugins ?? []) { + value = + plugin.onSplitRouteProperty?.({ + ...context, + prop: target, + splitNodeMeta: meta, + lazyRouteComponentIdent: lazy, + }) ?? undefined + if (value) { + break } } - }, - }, - }) - + target.value = + value ?? + call(lazy, [ + reference(meta.localImporterIdent), + string(meta.exporterIdent), + ]) + target.shorthand = false + modified = true + } + } else { + for (const plugin of options.compilerPlugins ?? []) { + modified = !!plugin.onUnsplittableRoute?.(context)?.modified || modified + } + } + if (options.addHmr && route === analysis.moduleRoute) { + for (const plugin of options.compilerPlugins ?? []) { + plugin.onAddHmr?.(context) + } + const stableKeys = [ + ...new Set( + (options.compilerPlugins ?? []).flatMap( + (plugin) => plugin.getStableRouteOptionKeys?.() ?? [], + ), + ), + ] + program.body.push( + ...createRouteHmrStatement(stableKeys, { + hmrStyle: options.hmrStyle ?? 'vite', + targetFramework: options.targetFramework, + routeId: options.hmrRouteId, + }), + ) + modified = true + } + } if (!modified) { return null } - - deadCodeElimination(ast, refIdents) - - // if there are exported identifiers, then we need to add a warning - // to the file to let the user know that the exported identifiers - // will not in the split file but in the original file, therefore - // increasing the bundle size - if (knownExportedIdents.size > 0) { - const warningMessage = createNotExportableMessage( - opts.filename, - knownExportedIdents, - ) - console.warn(warningMessage) - - // append this warning to the file using a template - if (process.env.NODE_ENV !== 'production') { - const warningTemplate = template.statement( - `console.warn(${JSON.stringify(warningMessage)})`, - )() - ast.program.body.unshift(warningTemplate) + if (options.sharedBindings?.size) { + program.body = program.body.filter((statement) => { + if ( + is.ExportNamedDeclaration(statement) && + !statement.source && + !statement.declaration + ) { + statement.specifiers = statement.specifiers.filter( + (specifier) => + !is.Identifier(specifier.local) || + !options.sharedBindings!.has(specifier.local.name), + ) + return statement.specifiers.length > 0 + } + return !( + is.ExportDefaultDeclaration(statement) && + is.Identifier(statement.declaration) && + options.sharedBindings!.has(statement.declaration.name) + ) + }) + } + const sharedParts = partitionSharedBindings( + analysis, + options.sharedBindings, + options.splitSharedComponents, + ) + addSharedImports(program, sharedParts, options.filename) + for (const [part, names] of sharedParts) { + const sharedExports = [...analysis.exported] + .filter(([binding]) => names.has(binding.name)) + .flatMap(([binding, exportedNames]) => + exportedNames.map((name) => ({ local: binding.name, exported: name })), + ) + if (sharedExports.length) { + program.body.push( + exports( + sharedExports, + addSharedSearchParamToFilename(options.filename, part), + ), + ) } } - - const result = generateFromAst(ast, { - sourceMaps: true, - sourceFileName: opts.filename, - filename: opts.filename, + removeUnusedBindings(analysis.module, program, originalNodes, { + // Imports are not moved: each module that reads one imports it + moved: new Set( + [...expandTransitively(splitReads, analysis.chunkDependencies)].filter( + (binding) => !binding.has(BindingFlags.Import), + ), + ), }) - - // @babel/generator does not populate sourcesContent because it only has - // the AST, not the original text. Without this, Vite's composed - // sourcemap omits the original source, causing downstream consumers - // (e.g. import-protection snippet display) to fall back to the shorter - // compiled output and fail to resolve original line numbers. - if (result.map) { - result.map.sourcesContent = [opts.code] + if (knownExported.size) { + const message = createNotExportableMessage(options.filename, knownExported) + console.warn(message) + if (process.env.NODE_ENV !== 'production') { + prependStatements( + program, + ...parseStatements(`console.warn(${JSON.stringify(message)})`), + ) + } } - - return result + return generateRouteModule(analysis, program, options.filename) } export function compileCodeSplitVirtualRoute( - opts: ParseAstOptions & { + options: SourceOptions & { splitTargets: Array filename: string sharedBindings?: Set + splitSharedComponents?: boolean compilerPlugins?: Array }, -): GeneratorResult { - const ast = parseAst(opts) - const refIdents = findReferencedIdentifiers(ast) - - // Remove shared declarations BEFORE babel.traverse so the scope never sees - // conflicting bindings (avoids checkBlockScopedCollisions crash in DCE) - if (opts.sharedBindings && opts.sharedBindings.size > 0) { - removeSharedDeclarations(ast, opts.sharedBindings) +) { + const analysis = sourceAnalysis(options) + const { program, originalNodes, reservedNames, copies, renameBinding } = + createOutput(analysis) + if (!analysis.routes.some((route) => route.factory === 'createFileRoute')) { + return generateRouteModule(analysis, program, options.filename) } - - const intendedSplitNodes = new Set(opts.splitTargets) - - const knownExportedIdents = new Set() - - babel.traverse(ast, { - Program: { - enter(programPath) { - const trackedNodesToSplitByType: Record< - SplitRouteIdentNodes, - { node: t.Node | undefined; meta: SplitNodeMeta } | undefined - > = { - component: undefined, - loader: undefined, - pendingComponent: undefined, - errorComponent: undefined, - notFoundComponent: undefined, - } - - // Find and track all the known split-able nodes - programPath.traverse({ - CallExpression: (path) => { - if (!t.isIdentifier(path.node.callee)) { - return - } - - if (!splittableCreateRouteFns.includes(path.node.callee.name)) { - return - } - - function babelHandleVirtual(options: t.Node | undefined) { - if (t.isObjectExpression(options)) { - options.properties.forEach((prop) => { - if (t.isObjectProperty(prop)) { - // do not use `intendedSplitNodes` here - // since we have special considerations that need - // to be accounted for like (not splitting exported identifiers) - KNOWN_SPLIT_ROUTE_IDENTS.forEach((splitType) => { - if (getObjectPropertyKeyName(prop) !== splitType) { - return - } - - const value = prop.value - - // If the value for the `key` is `undefined`, then we don't need to include it - // in the split file, so we can just return, since it will kept in-place in the - // reference file - // This is useful for cases like: `createFileRoute('/')({ component: undefined })` - if (t.isIdentifier(value) && value.name === 'undefined') { - return - } - - let isExported = false - if (t.isIdentifier(value)) { - isExported = hasExport(ast, value) - if (isExported) { - knownExportedIdents.add(value.name) - } - } - - // If the node is exported, we need to remove - // the export from the split file - if (isExported && t.isIdentifier(value)) { - removeExports(ast, value) - } else { - const meta = SPLIT_NODES_CONFIG.get(splitType)! - trackedNodesToSplitByType[splitType] = { - node: prop.value, - meta, - } - } - }) - } - }) - - // Remove all of the options - options.properties = [] - } - } - - if (t.isCallExpression(path.parentPath.node)) { - // createFileRoute('/')({ ... }) - const options = resolveIdentifier( - path, - path.parentPath.node.arguments[0], - ) - - babelHandleVirtual(options) - } else if (t.isVariableDeclarator(path.parentPath.node)) { - // createFileRoute({ ... }) - const caller = resolveIdentifier(path, path.parentPath.node.init) - - if (t.isCallExpression(caller)) { - const options = resolveIdentifier(path, caller.arguments[0]) - babelHandleVirtual(options) - } - } - }, - }) - - // Start the transformation to only exported the intended split nodes - intendedSplitNodes.forEach((SPLIT_TYPE) => { - const splitKey = trackedNodesToSplitByType[SPLIT_TYPE] - - if (!splitKey) { - return - } - - let splitNode = splitKey.node - const splitMeta = { ...splitKey.meta, shouldRemoveNode: true } - - // Track the original identifier name before resolving through bindings, - // needed for destructured patterns where the binding resolves to the - // entire VariableDeclarator (ObjectPattern) rather than the specific binding - let originalIdentName: string | undefined - if (t.isIdentifier(splitNode)) { - originalIdentName = splitNode.name - } - - while (t.isIdentifier(splitNode)) { - const binding = programPath.scope.getBinding(splitNode.name) - splitNode = binding?.path.node - } - - // Add the node to the program - if (splitNode) { - for (const plugin of opts.compilerPlugins ?? []) { - plugin.onVirtualRouteSplitNode?.({ - programPath, - splitNode, - splitNodeMeta: splitMeta, - }) - } - - if (t.isFunctionDeclaration(splitNode)) { - // an anonymous function declaration should only happen for `export default function() {...}` - // so we should never get here - if (!splitNode.id) { - throw new Error( - `Function declaration for "${SPLIT_TYPE}" must have an identifier.`, - ) - } - - splitMeta.shouldRemoveNode = false - splitMeta.localExporterIdent = splitNode.id.name - } else if ( - t.isFunctionExpression(splitNode) || - t.isArrowFunctionExpression(splitNode) - ) { - programPath.pushContainer( - 'body', - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(splitMeta.localExporterIdent), - splitNode, - ), - ]), - ) - } else if ( - t.isImportSpecifier(splitNode) || - t.isImportDefaultSpecifier(splitNode) - ) { - programPath.pushContainer( - 'body', - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(splitMeta.localExporterIdent), - splitNode.local, - ), - ]), - ) - } else if (t.isVariableDeclarator(splitNode)) { - if (t.isIdentifier(splitNode.id)) { - splitMeta.localExporterIdent = splitNode.id.name - splitMeta.shouldRemoveNode = false - } else if (t.isObjectPattern(splitNode.id)) { - // Destructured binding like `const { component: MyComp } = createBits()` - // Use the original identifier name that was tracked before resolving - if (originalIdentName) { - splitMeta.localExporterIdent = originalIdentName - } - splitMeta.shouldRemoveNode = false - } else { - throw new Error( - `Unexpected splitNode type ☝️: ${splitNode.type}`, - ) - } - } else if (t.isCallExpression(splitNode)) { - const outputSplitNodeCode = generateFromAst(splitNode).code - const splitNodeAst = babel.parse(outputSplitNodeCode) - - if (!splitNodeAst) { - throw new Error( - `Failed to parse the generated code for "${SPLIT_TYPE}" in the node type "${splitNode.type}"`, - ) - } - - const statement = splitNodeAst.program.body[0] - - if (!statement) { - throw new Error( - `Failed to parse the generated code for "${SPLIT_TYPE}" in the node type "${splitNode.type}" as no statement was found in the program body`, - ) - } - - if (t.isExpressionStatement(statement)) { - const expression = statement.expression - programPath.pushContainer( - 'body', - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(splitMeta.localExporterIdent), - expression, - ), - ]), - ) - } else { - throw new Error( - `Unexpected expression type encounter for "${SPLIT_TYPE}" in the node type "${splitNode.type}"`, - ) - } - } else if (t.isConditionalExpression(splitNode)) { - programPath.pushContainer( - 'body', - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(splitMeta.localExporterIdent), - splitNode, - ), - ]), - ) - } else if (t.isTSAsExpression(splitNode)) { - // remove the type assertion - splitNode = splitNode.expression - programPath.pushContainer( - 'body', - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(splitMeta.localExporterIdent), - splitNode, - ), - ]), - ) - } else if (t.isBooleanLiteral(splitNode)) { - // Handle boolean literals - // This exits early here, since this value will be kept in the reference file - return - } else if (t.isNullLiteral(splitNode)) { - // Handle null literals - // This exits early here, since this value will be kept in the reference file - return - } else { - console.info('Unexpected splitNode type:', splitNode) - throw new Error(`Unexpected splitNode type ☝️: ${splitNode.type}`) - } - } - - if (splitMeta.shouldRemoveNode) { - // If the splitNode exists at the top of the program - // then we need to remove that copy - programPath.node.body = programPath.node.body.filter((node) => { - return node !== splitNode + const generatedExports: Array = [] + const splitReferences = new Set() + for (const route of analysis.routes) { + if (route.factory !== 'createFileRoute') { + continue + } + for (const property of runtimeProperties(route)) { + if (!isDataProperty(property)) { + continue + } + const key = getObjectPropertyKeyName(property) as SplitRouteIdentNodes + if (!options.splitTargets.includes(key) || isFallback(property.value)) { + continue + } + const target = splitProperty(key, property) + if (!target) { + continue + } + const propertyValue = unwrapExpression(target.value) + const binding = is.Identifier(propertyValue) + ? analysis.module.bindingOf(propertyValue) + : null + if (binding && analysis.exported.has(binding)) { + continue + } + for (const reference of collectModuleReferences( + analysis.module, + propertyValue, + )) { + splitReferences.add(reference) + } + const value = copies.get(propertyValue) as Expression + const meta = splitMeta(key) + if (is.Identifier(value)) { + const declaration = binding + ? analysis.graph.declarations.get(binding) + : undefined + const splitNode = declaration ? copies.get(declaration) : undefined + if (splitNode) { + for (const plugin of options.compilerPlugins ?? []) { + plugin.onVirtualRouteSplitNode?.({ + program, + reservedNames, + renameBinding, + splitNode, + splitNodeMeta: meta, }) } - - // Export the node - programPath.pushContainer('body', [ - t.exportNamedDeclaration(null, [ - t.exportSpecifier( - t.identifier(splitMeta.localExporterIdent), // local variable name - t.identifier(splitMeta.exporterIdent), // as what name it should be exported as - ), - ]), - ]) - }) - - // convert exports to imports from the original file - programPath.traverse({ - ExportNamedDeclaration(path) { - // e.g. export const x = 1 or export { x } - // becomes - // import { x } from '${opts.id}' - - if (path.node.declaration) { - if (t.isVariableDeclaration(path.node.declaration)) { - const specifiers = path.node.declaration.declarations.flatMap( - (decl) => { - if (t.isIdentifier(decl.id)) { - return [ - t.importSpecifier( - t.identifier(decl.id.name), - t.identifier(decl.id.name), - ), - ] - } - - if (t.isObjectPattern(decl.id)) { - return collectIdentifiersFromPattern(decl.id).map( - (name) => - t.importSpecifier( - t.identifier(name), - t.identifier(name), - ), - ) - } - - if (t.isArrayPattern(decl.id)) { - return collectIdentifiersFromPattern(decl.id).map( - (name) => - t.importSpecifier( - t.identifier(name), - t.identifier(name), - ), - ) - } - - return [] - }, - ) - - if (specifiers.length === 0) { - path.remove() - return - } - - const importDecl = t.importDeclaration( - specifiers, - t.stringLiteral( - removeSplitSearchParamFromFilename(opts.filename), - ), - ) - - path.replaceWith(importDecl) - - // Track the imported identifier paths so deadCodeElimination can remove them if unused - // We need to traverse the newly created import to get the identifier paths - path.traverse({ - Identifier(identPath) { - // Only track the local binding identifiers (the imported names) - if ( - identPath.parentPath.isImportSpecifier() && - identPath.key === 'local' - ) { - refIdents.add(identPath) - } - }, - }) - } - } - }, - }) - - // Add shared bindings import, registering specifiers in refIdents - // so DCE can remove unused ones (same pattern as import replacements above). - if (opts.sharedBindings && opts.sharedBindings.size > 0) { - const sharedImportSpecifiers = [...opts.sharedBindings].map((name) => - t.importSpecifier(t.identifier(name), t.identifier(name)), - ) - const sharedModuleUrl = addSharedSearchParamToFilename( - removeSplitSearchParamFromFilename(opts.filename), - ) - const [sharedImportPath] = programPath.unshiftContainer( - 'body', - t.importDeclaration( - sharedImportSpecifiers, - t.stringLiteral(sharedModuleUrl), - ), - ) - - sharedImportPath.traverse({ - Identifier(identPath) { - if ( - identPath.parentPath.isImportSpecifier() && - identPath.key === 'local' - ) { - refIdents.add(identPath) - } - }, - }) } - }, - }, - }) - - deadCodeElimination(ast, refIdents) - stripUnreferencedTopLevelExpressionStatements(ast) - - // If the body is empty after DCE, strip directive prologues too. - // A file containing only `'use client'` with no real code is useless. - if (ast.program.body.length === 0) { - ast.program.directives = [] + generatedExports.push( + exports([ + { local: value.name, exported: key, binding: binding ?? undefined }, + ]), + ) + } else { + const name = getUniqueProgramIdentifier( + reservedNames, + meta.localExporterIdent, + ).name + generatedExports.push( + variable(name, value), + exports([{ local: name, exported: key }]), + ) + } + } + ;(copies.get(route.options) as ObjectExpression).properties = [] } - - const result = generateFromAst(ast, { - sourceMaps: true, - sourceFileName: opts.filename, - filename: opts.filename, - }) - - // @babel/generator does not populate sourcesContent — see compileCodeSplitReferenceRoute. - if (result.map) { - result.map.sourcesContent = [opts.code] + const importedExports = exportsImportedBySplitModule( + analysis, + splitReferences, + options.sharedBindings, + ) + program.body = program.body.flatMap(withoutExportSyntax) + removeDeclarations( + program, + new Set(importedExports.map(([binding]) => binding.name)), + ) + if (importedExports.length) { + prependStatements( + program, + imports( + importedExports.map(([binding, names]) => ({ + local: binding.name, + imported: names[0]!, + })), + bareFilename(options.filename), + ), + ) } - - return result + addSharedImports( + program, + partitionSharedBindings( + analysis, + options.sharedBindings, + options.splitSharedComponents, + ), + options.filename, + ) + program.body.push(...generatedExports) + removeUnusedBindings(analysis.module, program, originalNodes, { + partialCopy: true, + }) + stripUnownedExpressions(analysis.module, program, originalNodes) + return generateRouteModule(analysis, program, options.filename) } /** - * Compile the shared virtual module (`?tsr-shared=1`). - * Keeps only shared binding declarations, their transitive dependencies, - * and imports they need. Exports all shared bindings. + * User exports, including the Route singleton, that a split module imports from + * the reference module instead of initializing a second copy. Imports keep their + * source, a destructuring that also declares private bindings stays whole, and + * shared bindings come from the shared module, which holds most exported + * functions and classes split modules read (see `computeSharedBindings`). + * Exported variables are always imported so that every module sees one context + * or store instance. Another exported function is declared in the split module + * when it is stateless, and so is an exported function or class that depends + * on a stateful private binding the split module declares itself, so that both + * observe one module state. */ -export function compileCodeSplitSharedRoute( - opts: ParseAstOptions & { - sharedBindings: Set - filename: string - }, -): GeneratorResult { - const ast = parseAst(opts) - const refIdents = findReferencedIdentifiers(ast) - - // Collect all names that need to stay: shared bindings + their transitive deps - const localBindings = new Set() - for (const node of ast.program.body) { - collectLocalBindingsFromStatement(node, localBindings) +function exportsImportedBySplitModule( + analysis: RouteModuleAnalysis, + splitReferences: Set, + sharedBindings: Set | undefined, +) { + const { graph, exported, chunkDependencies } = analysis + const siblingsOf = (binding: Binding) => { + const declaration = graph.declarations.get(binding) + return declaration ? graph.declarationBindings.get(declaration) : undefined } - - // Route must never be extracted into the shared module. - // Excluding it from the dep graph prevents expandTransitively from - // pulling it in as a transitive dependency of a shared binding. - localBindings.delete('Route') - - const declMap = buildDeclarationMap(ast) - const depGraph = buildDependencyGraph(declMap, localBindings) - - // Start with shared bindings and expand transitively - const keepBindings = new Set(opts.sharedBindings) - keepBindings.delete('Route') - expandTransitively(keepBindings, depGraph) - - retainModuleLevelDeclarations(ast, keepBindings) - unwrapExportedDeclarations(ast) - - // Export all shared bindings (sorted for deterministic output) - const exportNames = [...opts.sharedBindings].sort((a, b) => - a.localeCompare(b), + const isShared = (binding: Binding) => !!sharedBindings?.has(binding.name) + const dependenciesUntil = (stop: (binding: Binding) => boolean) => + new Map([...chunkDependencies].filter(([binding]) => !stop(binding))) + const privateDependencies = dependenciesUntil(isShared) + const imported = new Set( + [...exported.keys()].filter((binding) => { + const siblings = siblingsOf(binding) + return ( + !binding.has(BindingFlags.Import) && + !isShared(binding) && + !!siblings && + [...siblings].every((sibling) => exported.has(sibling)) + ) + }), ) - const exportSpecifiers = exportNames.map((name) => - t.exportSpecifier(t.identifier(name), t.identifier(name)), + // A binding with the same value in every module that declares it: a function + // or constant literal that nothing reassigns + const isStateless = (binding: Binding) => { + const declaration = graph.declarations.get(binding)! + const init = constInitializer(analysis, binding) + return ( + (is.FunctionDeclaration(declaration) || + is.ArrowFunctionExpression(init) || + is.FunctionExpression(init) || + (is.Literal(init) && !is.RegExpLiteral(init)) || + (is.TemplateLiteral(init) && init.expressions.length === 0)) && + !binding.references.some( + (reference) => + reference.isWrite && !contains(declaration, reference.node), + ) + ) + } + // An exported function the shared module does not take, such as one that + // reads `Route`, is declared in the split module, like what it reads that + // the split module does not import, when all of them are stateless and + // write no binding + const importedExports = new Set(imported) + const privateDeclarations = dependenciesUntil( + (binding) => + importedExports.has(binding) || + isShared(binding) || + binding.has(BindingFlags.Import), ) - if (exportSpecifiers.length > 0) { - const exportDecl = t.exportNamedDeclaration(null, exportSpecifiers) - ast.program.body.push(exportDecl) - - // Register export specifier locals in refIdents so DCE doesn't treat - // the exported bindings as unreferenced. - babel.traverse(ast, { - Program(programPath) { - const bodyPaths = programPath.get('body') - const last = bodyPaths[bodyPaths.length - 1] - if (last && last.isExportNamedDeclaration()) { - last.traverse({ - Identifier(identPath) { - if ( - identPath.parentPath.isExportSpecifier() && - identPath.key === 'local' - ) { - refIdents.add(identPath) - } - }, - }) - } - programPath.stop() - }, + for (const binding of importedExports) { + if (!is.FunctionDeclaration(graph.declarations.get(binding))) { + continue + } + const declaredAlong = [ + binding, + ...expandTransitively( + chunkDependencies.get(binding) ?? new Set(), + privateDeclarations, + ), + ].filter( + (dependency) => + dependency === binding || + (graph.declarations.has(dependency) && + !importedExports.has(dependency) && + !isShared(dependency) && + !dependency.has(BindingFlags.Import)), + ) + const copyable = declaredAlong.every((dependency) => { + const declaration = graph.declarations.get(dependency)! + return ( + isStateless(dependency) && + ![...(chunkDependencies.get(dependency) ?? [])].some((read) => + read.references.some( + (reference) => + reference.isWrite && contains(declaration, reference.node), + ), + ) + ) }) + if (copyable) { + imported.delete(binding) + } } - - deadCodeElimination(ast, refIdents) - - // If the body is empty after DCE, strip directive prologues too. - if (ast.program.body.length === 0) { - ast.program.directives = [] + let changed = true + while (changed) { + changed = false + const declared = expandTransitively( + splitReferences, + dependenciesUntil( + (binding) => imported.has(binding) || isShared(binding), + ), + ) + for (const binding of imported) { + // Every module reads one instance of an exported variable + if (is.VariableDeclarator(graph.declarations.get(binding))) { + continue + } + const dependencies = expandTransitively( + chunkDependencies.get(binding) ?? new Set(), + privateDependencies, + ) + if ( + [...dependencies].some( + (dependency) => + declared.has(dependency) && + graph.declarations.has(dependency) && + !dependency.has(BindingFlags.Import) && + !imported.has(dependency) && + !isShared(dependency) && + // A second copy of a stateless binding holds the same value + !isStateless(dependency), + ) + ) { + for (const sibling of siblingsOf(binding)!) { + imported.delete(sibling) + } + changed = true + } + } } + return [...exported].filter(([binding]) => imported.has(binding)) +} - const result = generateFromAst(ast, { - sourceMaps: true, - sourceFileName: opts.filename, - filename: opts.filename, +function stripUnownedExpressions( + module: Module, + program: Program, + originalNodes: WeakMap, +) { + const localNames = new Set() + for (const statement of program.body) { + if (is.VariableDeclaration(statement)) { + for (const declaration of statement.declarations) { + for (const id of bindingIdentifiers(declaration.id)) { + localNames.add(id.name) + } + } + } else if ( + (is.FunctionDeclaration(statement) || is.ClassDeclaration(statement)) && + statement.id + ) { + localNames.add(statement.id.name) + } else if (is.TSEnumDeclaration(statement) && !statement.declare) { + localNames.add(statement.id.name) + } else if (is.TSModuleDeclaration(statement) && !statement.declare) { + // `namespace A.B {}` declares `A` + let id: Node = statement.id + while (is.TSQualifiedName(id)) { + id = id.left + } + if (is.Identifier(id)) { + localNames.add(id.name) + } + } + } + program.body = program.body.filter((statement) => { + if (is.Directive(statement) || !is.ExpressionStatement(statement)) { + return true + } + const original = originalNodes.get(statement) + return ( + !!original && + [...collectModuleReferences(module, original)].some((binding) => + localNames.has(binding.name), + ) + ) }) - - // @babel/generator does not populate sourcesContent — see compileCodeSplitReferenceRoute. - if (result.map) { - result.map.sourcesContent = [opts.code] + if (program.body.every((statement) => is.Directive(statement))) { + program.body = [] } - - return result } /** - * This function should read get the options from by searching for the key `codeSplitGroupings` - * on createFileRoute and return it's values if it exists, else return undefined + * Whether the top-level statements that declare `names` and their + * dependencies have no effect: the values module keeps these declarations, + * or fewer of them. */ -export function detectCodeSplitGroupingsFromRoute(opts: ParseAstOptions): { - groupings: CodeSplitGroupings | undefined -} { - const ast = parseAst(opts) - - let codeSplitGroupings: CodeSplitGroupings | undefined = undefined - - babel.traverse(ast, { - Program: { - enter(programPath) { - programPath.traverse({ - CallExpression(path) { - if (!t.isIdentifier(path.node.callee)) { - return - } - - if ( - !( - path.node.callee.name === 'createRoute' || - path.node.callee.name === 'createFileRoute' - ) - ) { - return - } - - function babelHandleSplittingGroups( - routeOptions: t.Node | undefined, - ) { - if (t.isObjectExpression(routeOptions)) { - routeOptions.properties.forEach((prop) => { - if (t.isObjectProperty(prop)) { - const key = getObjectPropertyKeyName(prop) - if (key === 'codeSplitGroupings') { - const value = prop.value - - if (t.isArrayExpression(value)) { - codeSplitGroupings = value.elements.map((group) => { - if (t.isArrayExpression(group)) { - return group.elements.map((node) => { - if (!t.isStringLiteral(node)) { - throw new Error( - 'You must provide a string literal for the codeSplitGroupings', - ) - } - - return node.value - }) as Array - } - - throw new Error( - 'You must provide arrays with codeSplitGroupings options.', - ) - }) - } else { - throw new Error( - 'You must provide an array of arrays for the codeSplitGroupings.', - ) - } - } - } - }) - } - } - - // Extracting the codeSplitGroupings - if (t.isCallExpression(path.parentPath.node)) { - // createFileRoute('/')({ ... }) - const options = resolveIdentifier( - path, - path.parentPath.node.arguments[0], - ) - - babelHandleSplittingGroups(options) - } else if (t.isVariableDeclarator(path.parentPath.node)) { - // createFileRoute({ ... }) - const caller = resolveIdentifier(path, path.parentPath.node.init) - - if (t.isCallExpression(caller)) { - const options = resolveIdentifier(path, caller.arguments[0]) - babelHandleSplittingGroups(options) - } - } - }, - }) - }, - }, - }) - - return { groupings: codeSplitGroupings } +function declarationsArePure( + analysis: RouteModuleAnalysis, + names: Set, + constructors: Set, +) { + const declarations = [ + ...expandTransitively( + new Set( + analysis.module.rootScope.bindings.filter((binding) => + names.has(binding.name), + ), + ), + analysis.graph.dependencies, + ), + ].flatMap((binding) => analysis.graph.declarations.get(binding) ?? []) + return analysis.module.ast.body.every( + (statement) => + !declarations.some((declaration) => contains(statement, declaration)) || + isPureStatement(statement, constructors), + ) } -function createNotExportableMessage( - filename: string, - idents: Set, -): string { - const list = Array.from(idents).map((d) => `- ${d}`) - - const message = [ - `[tanstack-router] These exports from "${filename}" will not be code-split and will increase your bundle size:`, - ...list, - 'For the best optimization, these items should either have their export statements removed, or be imported from another location that is not a route file.', - ].join('\n') - - return message +export function compileCodeSplitSharedRoute( + options: SourceOptions & { + sharedBindings: Set + filename: string + /** The shared module to emit, `values` by default. */ + part?: SharedModulePart + splitSharedComponents?: boolean + }, +): ReturnType & { sideEffectFree: boolean } { + const analysis = sourceAnalysis(options) + const parts = new Map( + partitionSharedBindings( + analysis, + options.sharedBindings, + options.splitSharedComponents, + ), + ) + const part = options.part ?? 'values' + const own = parts.get(part) ?? new Set() + const values = part === 'components' ? parts.get('values') : undefined + const valuesId = addSharedSearchParamToFilename(options.filename, 'values') + const { program, originalNodes } = createOutput(analysis) + const keep = expandTransitively( + new Set( + analysis.module.rootScope.bindings.filter( + (binding) => binding.name !== 'Route' && own.has(binding.name), + ), + ), + analysis.graph.dependencies, + ) + const names = new Set([...keep].map((binding) => binding.name)) + const remove = new Set( + analysis.module.rootScope.bindings + .filter((binding) => !names.has(binding.name)) + .map((binding) => binding.name), + ) + program.body = program.body + .flatMap(withoutExportSyntax) + .filter( + (statement) => + is.Declaration(statement) || + is.ImportDeclaration(statement) || + is.Directive(statement), + ) + removeDeclarations(program, remove) + if (values) { + // After the module's own imports, which the source evaluates before any + // of its statements + removeDeclarations(program, values) + let index = 0 + program.body.forEach((statement, position) => { + if (is.ImportDeclaration(statement)) { + index = position + 1 + } + }) + program.body.splice( + index, + 0, + imports( + [...values].map((name) => ({ local: name, imported: name })), + valuesId, + ), + ) + } + program.body.push( + exports([...own].sort().map((name) => ({ local: name, exported: name }))), + ) + removeUnusedBindings(analysis.module, program, originalNodes) + const constructors = pureConstructors(analysis.module) + const importsValues = program.body.some( + (statement) => + is.ImportDeclaration(statement) && statement.source.value === valuesId, + ) + return { + ...generateRouteModule(analysis, program, options.filename), + // Evaluating the module has no effect of its own, so a bundler can leave it + // out of a chunk that only re-exports bindings nothing imports from it. + // The components module also evaluates the values module it imports. + sideEffectFree: + program.body.every((statement) => + isPureStatement(statement, constructors), + ) && + (!importsValues || declarationsArePure(analysis, values!, constructors)), + } } -function getImportSpecifierAndPathFromLocalName( - programPath: babel.NodePath, - name: string, -): { - specifier: - | t.ImportSpecifier - | t.ImportDefaultSpecifier - | t.ImportNamespaceSpecifier - | null - path: string | null -} { - let specifier: - | t.ImportSpecifier - | t.ImportDefaultSpecifier - | t.ImportNamespaceSpecifier - | null = null - let path: string | null = null +/** A top-level statement whose evaluation has no observable effect. */ +function isPureStatement(statement: Node, constructors: Set): boolean { + if ( + is.ImportDeclaration(statement) || + is.ExportAllDeclaration(statement) || + is.Directive(statement) || + is.FunctionDeclaration(statement) || + is.TSTypeAliasDeclaration(statement) || + is.TSInterfaceDeclaration(statement) || + is.TSDeclareFunction(statement) + ) { + return true + } + if (is.ExportNamedDeclaration(statement)) { + return ( + !statement.declaration || + isPureStatement(statement.declaration, constructors) + ) + } + if (is.ExportDefaultDeclaration(statement)) { + return isPureStatement(statement.declaration, constructors) + } + if (is.ClassDeclaration(statement)) { + return isPureClass(statement, constructors) + } + if (is.VariableDeclaration(statement)) { + return ( + !!statement.declare || + (statement.kind !== 'using' && + statement.kind !== 'await using' && + statement.declarations.every( + (declaration) => + is.Identifier(declaration.id) && + (!declaration.init || + isPureExpression(declaration.init, constructors)), + )) + ) + } + return isPureExpression(statement, constructors) +} - programPath.traverse({ - ImportDeclaration(importPath) { - const found = importPath.node.specifiers.find( - (targetSpecifier) => targetSpecifier.local.name === name, - ) - if (found) { - specifier = found - path = importPath.node.source.value +function isPureClass(node: Node, constructors: Set): boolean { + if ( + !(is.ClassDeclaration(node) || is.ClassExpression(node)) || + (node.superClass && !is.Identifier(node.superClass)) + ) { + return false + } + // Decorators run when the class is defined, including those of parameters + const decorators: Array = [] + walk(node, { + enter(child) { + if (is.Decorator(child)) { + decorators.push(child) } }, }) - - return { specifier, path } + return ( + !decorators.length && + node.body.body.every((member) => { + if (is.StaticBlock(member)) { + return false + } + if ('computed' in member && member.computed && !is.Literal(member.key)) { + return false + } + if ( + (is.PropertyDefinition(member) || is.AccessorProperty(member)) && + member.static && + member.value + ) { + return isPureExpression(member.value, constructors) + } + return true + }) + ) } -// Reusable function to get literal value or resolve variable to literal -function resolveIdentifier(path: any, node: any): t.Node | undefined { - if (t.isIdentifier(node)) { - const binding = path.scope.getBinding(node.name) - if ( - binding - // && binding.kind === 'const' - ) { - const declarator = binding.path.node - if (t.isObjectExpression(declarator.init)) { - return declarator.init - } else if (t.isFunctionDeclaration(declarator.init)) { - return declarator.init - } - } - return undefined +/** The global constructors whose instances `new` creates without effect. */ +function pureConstructors(module: Module) { + const names = new Set(['Map', 'Set', 'WeakMap', 'WeakSet']) + for (const binding of module.rootScope.bindings) { + names.delete(binding.name) } + return names +} - return node +/** + * An expression whose evaluation has no observable effect. JSX is not one: it + * evaluates its attributes and children, and some frameworks call the + * component. + */ +function isPureExpression(node: Node, constructors: Set): boolean { + node = unwrapExpression(node as Expression) + if ( + is.Literal(node) || + is.Identifier(node) || + is.ArrowFunctionExpression(node) || + is.FunctionExpression(node) + ) { + return true + } + if (is.ClassExpression(node)) { + return isPureClass(node, constructors) + } + // Interpolating or negating an object calls its `toString` or `valueOf` + if (is.TemplateLiteral(node)) { + return node.expressions.every((expression) => is.Literal(expression)) + } + if (is.UnaryExpression(node)) { + return ( + node.operator !== 'delete' && + (['!', 'typeof', 'void'].includes(node.operator) + ? isPureExpression(node.argument, constructors) + : is.Literal(node.argument)) + ) + } + if (is.ArrayExpression(node)) { + return node.elements.every( + (element) => + !element || + (!is.SpreadElement(element) && isPureExpression(element, constructors)), + ) + } + if (is.ObjectExpression(node)) { + return node.properties.every( + (property) => + is.Property(property) && + (!property.computed || is.Literal(property.key)) && + isPureExpression(property.value, constructors), + ) + } + if (is.NewExpression(node)) { + return ( + is.Identifier(node.callee) && + constructors.has(node.callee.name) && + node.arguments.length === 0 + ) + } + if (is.LogicalExpression(node)) { + return ( + isPureExpression(node.left, constructors) && + isPureExpression(node.right, constructors) + ) + } + if (is.ConditionalExpression(node)) { + return ( + isPureExpression(node.test, constructors) && + isPureExpression(node.consequent, constructors) && + isPureExpression(node.alternate, constructors) + ) + } + return false } -function removeIdentifierLiteral(path: babel.NodePath, node: t.Identifier) { - const binding = path.scope.getBinding(node.name) - if (binding) { - // If the binding is a destructured property from an ObjectPattern, - // only remove that property instead of the entire declaration +export function detectCodeSplitGroupingsFromRoute(options: SourceOptions): { + groupings: CodeSplitGroupings | undefined +} { + const analysis = sourceAnalysis(options) + for (const route of analysis.routes) { if ( - t.isVariableDeclarator(binding.path.node) && - t.isObjectPattern(binding.path.node.id) + route.factory !== 'createFileRoute' && + route.factory !== 'createRoute' ) { - const objectPattern = binding.path.node.id - objectPattern.properties = objectPattern.properties.filter((prop) => { - if (!t.isObjectProperty(prop)) { - return true - } - - if (t.isIdentifier(prop.value) && prop.value.name === node.name) { - return false - } - - if ( - t.isAssignmentPattern(prop.value) && - t.isIdentifier(prop.value.left) && - prop.value.left.name === node.name - ) { - return false - } - - return true - }) - - // If no properties remain, remove the entire declaration - if (objectPattern.properties.length === 0) { - binding.path.remove() - } - - return + continue } - - binding.path.remove() - } -} - -function hasExport(ast: t.File, node: t.Identifier): boolean { - let found = false - - babel.traverse(ast, { - ExportNamedDeclaration(path) { - if (path.node.declaration) { - // declared as `const loaderFn = () => {}` - if (t.isVariableDeclaration(path.node.declaration)) { - path.node.declaration.declarations.forEach((decl) => { - if (t.isVariableDeclarator(decl)) { - if (t.isIdentifier(decl.id)) { - if (decl.id.name === node.name) { - found = true - } - } else if ( - t.isObjectPattern(decl.id) || - t.isArrayPattern(decl.id) - ) { - // Handle destructured exports like `export const { a, b } = fn()` - const names = collectIdentifiersFromPattern(decl.id) - if (names.includes(node.name)) { - found = true - } - } - } - }) - } - - // declared as `function loaderFn() {}` - if (t.isFunctionDeclaration(path.node.declaration)) { - if (t.isIdentifier(path.node.declaration.id)) { - if (path.node.declaration.id.name === node.name) { - found = true - } - } - } + for (const property of properties(route)) { + if (getObjectPropertyKeyName(property) !== 'codeSplitGroupings') { + continue } - }, - ExportDefaultDeclaration(path) { - // declared as `export default loaderFn` - if (t.isIdentifier(path.node.declaration)) { - if (path.node.declaration.name === node.name) { - found = true - } + const value = unwrapExpression(property.value) + if (!is.ArrayExpression(value)) { + throw new Error( + 'You must provide an array of arrays for the codeSplitGroupings.', + ) } - - // declared as `export default function loaderFn() {}` - if (t.isFunctionDeclaration(path.node.declaration)) { - if (t.isIdentifier(path.node.declaration.id)) { - if (path.node.declaration.id.name === node.name) { - found = true + return { + groupings: value.elements.map((element) => { + const group = unwrapExpression(element) + if (!is.ArrayExpression(group)) { + throw new Error( + 'You must provide arrays with codeSplitGroupings options.', + ) } - } - } - }, - }) - - return found -} - -function removeExports(ast: t.File, node: t.Identifier): boolean { - let removed = false - - // The checks use sequential if/else if statements since it - // directly mutates the AST and as such doing normal checks - // (using only if statements) could lead to a situation where - // `path.node` is null since it has been already removed from - // the program tree but typescript doesn't know that. - babel.traverse(ast, { - ExportNamedDeclaration(path) { - if (path.node.declaration) { - if (t.isVariableDeclaration(path.node.declaration)) { - // declared as `const loaderFn = () => {}` - path.node.declaration.declarations.forEach((decl) => { - if (t.isVariableDeclarator(decl)) { - if (t.isIdentifier(decl.id)) { - if (decl.id.name === node.name) { - path.remove() - removed = true - } - } else if ( - t.isObjectPattern(decl.id) || - t.isArrayPattern(decl.id) - ) { - // Handle destructured exports like `export const { a, b } = fn()` - const names = collectIdentifiersFromPattern(decl.id) - if (names.includes(node.name)) { - path.remove() - removed = true - } - } + return group.elements.map((element) => { + const item = unwrapExpression(element) + if (!is.StringLiteral(item)) { + throw new Error( + 'You must provide a string literal for the codeSplitGroupings', + ) } + return item.value as SplitRouteIdentNodes }) - } else if (t.isFunctionDeclaration(path.node.declaration)) { - // declared as `export const loaderFn = () => {}` - if (t.isIdentifier(path.node.declaration.id)) { - if (path.node.declaration.id.name === node.name) { - path.remove() - removed = true - } - } - } + }), } - }, - ExportDefaultDeclaration(path) { - // declared as `export default loaderFn` - if (t.isIdentifier(path.node.declaration)) { - if (path.node.declaration.name === node.name) { - path.remove() - removed = true - } - } else if (t.isFunctionDeclaration(path.node.declaration)) { - // declared as `export default function loaderFn() {}` - if (t.isIdentifier(path.node.declaration.id)) { - if (path.node.declaration.id.name === node.name) { - path.remove() - removed = true - } - } - } - }, - }) + } + } + return { groupings: undefined } +} + +function createNotExportableMessage( + filename: string, + identifiers: Set, +) { + return [ + `[tanstack-router] These exports from "${filename}" will not be code-split and will increase your bundle size:`, + ...[...identifiers].map((name) => `- ${name}`), + 'For the best optimization, these items should either have their export statements removed, or be imported from another location that is not a route file.', + ].join('\n') +} - return removed +export function createRouteInFunctionMessage(filename: string) { + return `[tanstack-router] The route in "${filename}" is created inside a function. Route factories are not supported, so it will not be code-split, and edits to its components may need a full page reload. Create it at module level: export const Route = createFileRoute('/path')({ ... })` } diff --git a/packages/router-plugin/src/core/code-splitter/plugins.ts b/packages/router-plugin/src/core/code-splitter/plugins.ts index 1e8ada32b58..6f7109175bf 100644 --- a/packages/router-plugin/src/core/code-splitter/plugins.ts +++ b/packages/router-plugin/src/core/code-splitter/plugins.ts @@ -1,5 +1,13 @@ -import type babel from '@babel/core' -import type * as t from '@babel/types' +import type { Module } from 'yuku-analyzer' +import type { moduleDeclarationGraph } from '@tanstack/router-utils' +import type { + Expression, + Node, + ObjectExpression, + ObjectProperty, + Program, + ProgramStatement, +} from '@yuku-toolchain/types' import type { Config, DeletableNodes, HmrStyle } from '../config' import type { CodeSplitGroupings } from '../constants' import type { SplitNodeMeta } from './types' @@ -14,35 +22,47 @@ export type CompileCodeSplitReferenceRouteOptions = { hmrStyle?: HmrStyle hmrRouteId?: string sharedBindings?: Set + /** Emit the shared bindings that look like components in a module of their own */ + splitSharedComponents?: boolean } export type ReferenceRouteCompilerPluginContext = { - programPath: babel.NodePath - callExpressionPath: babel.NodePath - insertionPath: babel.NodePath - routeOptions: t.ObjectExpression + program: Program + module: Module + /** The module-level declarations of `module` and their dependencies. */ + graph: ReturnType + originalNodes: WeakMap + /** + * The names the source declares or references, and the names given out for + * the output. A declaration a plugin adds must take a name not in it, and + * add that name. + */ + reservedNames: Set + insertBefore: (nodes: Array) => void + renameBinding: (node: Node, name: string) => void + routeOptions: ObjectExpression createRouteFn: string opts: CompileCodeSplitReferenceRouteOptions } -export type ReferenceRouteSplitPropertyCompilerPluginContext = { - programPath: babel.NodePath - callExpressionPath: babel.NodePath - insertionPath: babel.NodePath - routeOptions: t.ObjectExpression - prop: t.ObjectProperty - splitNodeMeta: SplitNodeMeta - lazyRouteComponentIdent: string - opts: CompileCodeSplitReferenceRouteOptions -} +export type ReferenceRouteSplitPropertyCompilerPluginContext = + ReferenceRouteCompilerPluginContext & { + prop: ObjectProperty + splitNodeMeta: SplitNodeMeta + lazyRouteComponentIdent: string + opts: CompileCodeSplitReferenceRouteOptions + } export type ReferenceRouteCompilerPluginResult = { modified?: boolean } export type VirtualRouteSplitNodeCompilerPluginContext = { - programPath: babel.NodePath - splitNode: t.Node + program: Program + /** See `ReferenceRouteCompilerPluginContext['reservedNames']`. */ + reservedNames: Set + renameBinding: (node: Node, name: string) => void + splitNode: Node splitNodeMeta: SplitNodeMeta } @@ -60,7 +80,7 @@ export type CodeSplitCompilerPlugin = { ) => void | ReferenceRouteCompilerPluginResult onSplitRouteProperty?: ( ctx: ReferenceRouteSplitPropertyCompilerPluginContext, - ) => void | t.Expression + ) => void | Expression onVirtualRouteSplitNode?: ( ctx: VirtualRouteSplitNodeCompilerPluginContext, ) => void diff --git a/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-ignored-route-exports.ts b/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-ignored-route-exports.ts index cc2be62034f..0df0258ee74 100644 --- a/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-ignored-route-exports.ts +++ b/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-ignored-route-exports.ts @@ -1,10 +1,17 @@ -import * as template from '@babel/template' -import * as t from '@babel/types' +import { parseStatements } from '@tanstack/router-utils' import { getUniqueProgramIdentifier } from '../../utils' import type { ReferenceRouteCompilerPlugin } from '../plugins' -const buildReactRefreshIgnoredRouteExportsStatements = template.statements( - ` +export function createReactRefreshIgnoredRouteExportsPlugin(): ReferenceRouteCompilerPlugin { + return { + name: 'react-refresh-ignored-route-exports', + onAddHmr(ctx) { + const anchorName = getUniqueProgramIdentifier( + ctx.reservedNames, + 'TSRFastRefreshAnchor', + ) + ctx.program.body.push( + ...parseStatements(` const hot = import.meta.hot if (hot && typeof window !== 'undefined') { hot.data ??= {} @@ -23,44 +30,12 @@ if (hot && typeof window !== 'undefined') { } })() - tsrReactRefresh.ignoredExportsById.set(%%moduleId%%, ['Route']) + tsrReactRefresh.ignoredExportsById.set(${JSON.stringify(ctx.opts.id)}, ['Route']) } -`, - { syntacticPlaceholders: true }, -) - -/** - * A trivial component-shaped export that gives `@vitejs/plugin-react` a valid - * Fast Refresh boundary. Without at least one non-ignored component export, - * the module would be invalidated (full page reload) on every update even - * though our custom route HMR handler already manages the update. - */ -const buildRefreshAnchorStatement = template.statement( - `export function %%anchorName%%() { return null }`, - { syntacticPlaceholders: true }, -) -export function createReactRefreshIgnoredRouteExportsPlugin(): ReferenceRouteCompilerPlugin { - return { - name: 'react-refresh-ignored-route-exports', - onAddHmr(ctx) { - const anchorName = getUniqueProgramIdentifier( - ctx.programPath, - 'TSRFastRefreshAnchor', +export function ${anchorName.name}() { return null } +`), ) - - ctx.programPath.pushContainer( - 'body', - buildReactRefreshIgnoredRouteExportsStatements({ - moduleId: t.stringLiteral(ctx.opts.id), - }), - ) - - ctx.programPath.pushContainer( - 'body', - buildRefreshAnchorStatement({ anchorName }), - ) - return { modified: true } }, } diff --git a/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-route-components.ts b/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-route-components.ts index 44041671e0f..a0da392d197 100644 --- a/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-route-components.ts +++ b/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-route-components.ts @@ -1,8 +1,10 @@ -import * as t from '@babel/types' +import { b, is } from 'yuku-ast' +import { unwrapExpression } from '@tanstack/router-utils' import { getObjectPropertyKeyName, getUniqueProgramIdentifier, } from '../../utils' +import type { ObjectProperty, VariableDeclaration } from '@yuku-toolchain/types' import type { ReferenceRouteCompilerPlugin } from '../plugins' const REACT_REFRESH_ROUTE_COMPONENT_IDENTS = new Set([ @@ -13,11 +15,9 @@ const REACT_REFRESH_ROUTE_COMPONENT_IDENTS = new Set([ 'notFoundComponent', ]) -type RouteComponentContext = { - programPath: Parameters[0] - insertionPath: { insertBefore: (nodes: Array) => void } - routeOptions: t.ObjectExpression -} +type RouteComponentContext = Parameters< + NonNullable +>[0] function isReactComponentName(name: string) { const firstCharacter = name[0] @@ -29,18 +29,18 @@ function isReactComponentName(name: string) { ) } -function getRouteComponentKey(prop: t.ObjectProperty) { +function getRouteComponentKey(prop: ObjectProperty) { const key = getObjectPropertyKeyName(prop) return key && REACT_REFRESH_ROUTE_COMPONENT_IDENTS.has(key) ? key : undefined } function prepareRouteComponentsForReactRefresh(ctx: RouteComponentContext) { - const hoistedDeclarations: Array = [] + const hoistedDeclarations: Array = [] let modified = false for (const prop of ctx.routeOptions.properties) { - if (!t.isObjectProperty(prop)) { + if (!is.Property(prop) || prop.method || prop.kind !== 'init') { continue } @@ -50,56 +50,67 @@ function prepareRouteComponentsForReactRefresh(ctx: RouteComponentContext) { continue } - if (t.isIdentifier(prop.value)) { + prop.value = unwrapExpression(prop.value) + + if (is.Identifier(prop.value)) { if (isReactComponentName(prop.value.name)) { continue } - const bindingNode = ctx.programPath.scope.getBinding(prop.value.name) - ?.path.node + const original = ctx.originalNodes.get(prop.value) + const binding = original ? ctx.module.bindingOf(original) : null + if (binding && ctx.opts.sharedBindings?.has(binding.name)) { + continue + } + const bindingNode = binding + ? ctx.graph.declarations.get(binding) + : undefined const isLocalComponentBinding = - t.isFunctionDeclaration(bindingNode) || - t.isClassDeclaration(bindingNode) || - t.isVariableDeclarator(bindingNode) + is.FunctionDeclaration(bindingNode) || + is.ClassDeclaration(bindingNode) || + is.VariableDeclarator(bindingNode) if (!isLocalComponentBinding) { continue } const componentIdentifier = getUniqueProgramIdentifier( - ctx.programPath, + ctx.reservedNames, `TSR${key[0]!.toUpperCase()}${key.slice(1)}`, ) - ctx.programPath.scope.rename(prop.value.name, componentIdentifier.name) + ctx.renameBinding(prop.value, componentIdentifier.name) modified = true continue } if ( - !t.isArrowFunctionExpression(prop.value) && - !t.isFunctionExpression(prop.value) + !is.ArrowFunctionExpression(prop.value) && + !is.FunctionExpression(prop.value) ) { continue } const hoistedIdentifier = getUniqueProgramIdentifier( - ctx.programPath, + ctx.reservedNames, `TSR${key[0]!.toUpperCase()}${key.slice(1)}`, ) hoistedDeclarations.push( - t.variableDeclaration('const', [ - t.variableDeclarator(hoistedIdentifier, t.cloneNode(prop.value, true)), - ]), + b.VariableDeclaration({ + kind: 'const', + declarations: [ + b.VariableDeclarator({ id: hoistedIdentifier, init: prop.value }), + ], + }), ) - prop.value = t.cloneNode(hoistedIdentifier) + prop.value = b.Identifier({ name: hoistedIdentifier.name }) modified = true } if (hoistedDeclarations.length > 0) { - ctx.insertionPath.insertBefore(hoistedDeclarations) + ctx.insertBefore(hoistedDeclarations) } return modified @@ -121,7 +132,7 @@ export function createReactRefreshRouteComponentsPlugin(): ReferenceRouteCompile onVirtualRouteSplitNode(ctx) { if ( ctx.splitNodeMeta.splitStrategy !== 'lazyRouteComponent' || - !t.isFunctionDeclaration(ctx.splitNode) || + !is.FunctionDeclaration(ctx.splitNode) || !ctx.splitNode.id || isReactComponentName(ctx.splitNode.id.name) ) { @@ -129,14 +140,11 @@ export function createReactRefreshRouteComponentsPlugin(): ReferenceRouteCompile } const componentIdentifier = getUniqueProgramIdentifier( - ctx.programPath, + ctx.reservedNames, ctx.splitNodeMeta.localExporterIdent, ) - ctx.programPath.scope.rename( - ctx.splitNode.id.name, - componentIdentifier.name, - ) + ctx.renameBinding(ctx.splitNode.id, componentIdentifier.name) }, } } diff --git a/packages/router-plugin/src/core/code-splitter/plugins/react-stable-hmr-split-route-components.ts b/packages/router-plugin/src/core/code-splitter/plugins/react-stable-hmr-split-route-components.ts index 99448f452ad..07d929adccc 100644 --- a/packages/router-plugin/src/core/code-splitter/plugins/react-stable-hmr-split-route-components.ts +++ b/packages/router-plugin/src/core/code-splitter/plugins/react-stable-hmr-split-route-components.ts @@ -1,39 +1,9 @@ -import * as template from '@babel/template' -import * as t from '@babel/types' +import { b } from 'yuku-ast' +import { parseStatements } from '@tanstack/router-utils' import { getUniqueProgramIdentifier } from '../../utils' import type { HmrStyle } from '../../config' import type { ReferenceRouteCompilerPlugin } from '../plugins' -function capitalizeIdentifier(str: string) { - return str[0]!.toUpperCase() + str.slice(1) -} - -function createHotDataKey(exportName: string) { - return `tsr-split-component:${exportName}` -} - -const buildStableSplitComponentStatements = template.statements( - ` - const %%stableComponentIdent%% = (() => { - const hot = %%hotExpression%% - const hotData = hot ? (hot.data ??= {}) : undefined - return hotData?.[%%hotDataKey%%] ?? %%lazyRouteComponentIdent%%(%%localImporterIdent%%, %%exporterIdent%%) - })() - if (%%hotExpression%%) { - ((%%hotExpression%%).data ??= {})[%%hotDataKey%%] = %%stableComponentIdent%% - } - `, - { - syntacticPlaceholders: true, - }, -) - -function hotExpressionAstFor(hmrStyle: HmrStyle): t.Expression { - return template.expression.ast( - hmrStyle === 'webpack' ? 'import.meta.webpackHot' : 'import.meta.hot', - ) -} - export function createReactStableHmrSplitRouteComponentsPlugin(opts: { hmrStyle: HmrStyle }): ReferenceRouteCompilerPlugin { @@ -43,28 +13,29 @@ export function createReactStableHmrSplitRouteComponentsPlugin(opts: { if (ctx.splitNodeMeta.splitStrategy !== 'lazyRouteComponent') { return } - - const stableComponentIdent = getUniqueProgramIdentifier( - ctx.programPath, - `TSRSplit${capitalizeIdentifier(ctx.splitNodeMeta.exporterIdent)}`, + const exportName = ctx.splitNodeMeta.exporterIdent + const stable = getUniqueProgramIdentifier( + ctx.reservedNames, + `TSRSplit${exportName[0]!.toUpperCase()}${exportName.slice(1)}`, ) - - const hotDataKey = createHotDataKey(ctx.splitNodeMeta.exporterIdent) - - ctx.insertionPath.insertBefore( - buildStableSplitComponentStatements({ - stableComponentIdent, - hotDataKey: t.stringLiteral(hotDataKey), - hotExpression: hotExpressionAstFor(opts.hmrStyle), - lazyRouteComponentIdent: t.identifier(ctx.lazyRouteComponentIdent), - localImporterIdent: t.identifier( - ctx.splitNodeMeta.localImporterIdent, - ), - exporterIdent: t.stringLiteral(ctx.splitNodeMeta.exporterIdent), - }), + const hot = + opts.hmrStyle === 'webpack' + ? 'import.meta.webpackHot' + : 'import.meta.hot' + const key = JSON.stringify(`tsr-split-component:${exportName}`) + ctx.insertBefore( + parseStatements(` +const ${stable.name} = (() => { + const hot = ${hot} + const hotData = hot ? (hot.data ??= {}) : undefined + return hotData?.[${key}] ?? ${ctx.lazyRouteComponentIdent}(${ctx.splitNodeMeta.localImporterIdent}, ${JSON.stringify(exportName)}) +})() +if (${hot}) { + ((${hot}).data ??= {})[${key}] = ${stable.name} +} +`), ) - - return t.identifier(stableComponentIdent.name) + return b.Identifier({ name: stable.name }) }, } } diff --git a/packages/router-plugin/src/core/hmr/select-adapter.ts b/packages/router-plugin/src/core/hmr/select-adapter.ts index 60395a15349..5261b119a13 100644 --- a/packages/router-plugin/src/core/hmr/select-adapter.ts +++ b/packages/router-plugin/src/core/hmr/select-adapter.ts @@ -1,7 +1,7 @@ import { createViteHmrStatement } from './vite-adapter' import { createWebpackHmrStatement } from './webpack-adapter' import type { Config, HmrStyle } from '../config' -import type * as t from '@babel/types' +import type { ProgramStatement } from '@yuku-toolchain/types' export type CreateRouteHmrStatementOpts = { hmrStyle: HmrStyle @@ -17,7 +17,7 @@ export type CreateRouteHmrStatementOpts = { export function createRouteHmrStatement( stableRouteOptionKeys: Array, opts: CreateRouteHmrStatementOpts, -): Array { +): Array { const routeId = opts.routeId === '/__root' ? '__root__' : opts.routeId if (opts.hmrStyle === 'webpack') { diff --git a/packages/router-plugin/src/core/hmr/vite-adapter.ts b/packages/router-plugin/src/core/hmr/vite-adapter.ts index ba8d42a80ad..68a37db9dde 100644 --- a/packages/router-plugin/src/core/hmr/vite-adapter.ts +++ b/packages/router-plugin/src/core/hmr/vite-adapter.ts @@ -1,10 +1,14 @@ -import * as template from '@babel/template' +import { parseStatements } from '@tanstack/router-utils' import { getHandleRouteUpdateCode } from './handle-route-update' -import type * as t from '@babel/types' +import type { ProgramStatement } from '@yuku-toolchain/types' /** * Emits HMR accept code for Vite / native ESM HMR: `import.meta.hot.accept` - * with a callback that receives the freshly re-imported module. + * with a callback that receives the freshly re-imported module. The call must + * stay literal: Vite marks a module as an HMR boundary only when its code + * contains `import.meta.hot.accept(`, so accepting through an alias makes + * every edit of a route module that no framework refresh plugin accepts + * (Solid split routes, Vue routes) reload the page. * * `targetFramework` is currently unused — Vite's framework-specific fast-refresh * plugins handle component body patching via their own accept boundaries — but @@ -15,16 +19,15 @@ export function createViteHmrStatement( opts: { routeId?: string } = {}, -): Array { +): Array { const handleRouteUpdateCode = getHandleRouteUpdateCode(stableRouteOptionKeys) // The replacement Route object can be uninitialized; keep a generated id as // fallback for the existing router route we need to patch. const routeIdFallback = typeof opts.routeId === 'string' ? JSON.stringify(opts.routeId) : 'Route.id' - return [ - template.statement( - ` + return parseStatements( + ` if (import.meta.hot) { const hot = import.meta.hot const hotData = hot.data ??= {} @@ -41,7 +44,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? ${routeIdFallback} if (routeId) { @@ -53,12 +56,8 @@ if (import.meta.hot) { } handleRouteUpdate(routeId, newModule.Route) } - }) + }) } `, - { - syntacticPlaceholders: true, - }, - )(), - ] + ) } diff --git a/packages/router-plugin/src/core/hmr/webpack-adapter.ts b/packages/router-plugin/src/core/hmr/webpack-adapter.ts index eaf34ccfb18..f5696bb5b2c 100644 --- a/packages/router-plugin/src/core/hmr/webpack-adapter.ts +++ b/packages/router-plugin/src/core/hmr/webpack-adapter.ts @@ -1,7 +1,7 @@ -import * as template from '@babel/template' +import { parseStatements } from '@tanstack/router-utils' import { getHandleRouteUpdateCode } from './handle-route-update' import type { Config } from '../config' -import type * as t from '@babel/types' +import type { ProgramStatement } from '@yuku-toolchain/types' /** * Emits HMR accept code for bundlers with webpack-compatible `module.hot` @@ -21,7 +21,7 @@ export function createWebpackHmrStatement( targetFramework: Config['target'] routeId?: string }, -): Array { +): Array { const handleRouteUpdateCode = getHandleRouteUpdateCode(stableRouteOptionKeys) const staticRouteIdLiteral = typeof opts.routeId === 'string' @@ -63,9 +63,8 @@ export function createWebpackHmrStatement( } catch (_err) { /* noop */ }` : '' - return [ - template.statement( - ` + return parseStatements( + ` if (import.meta.webpackHot) { const hot = import.meta.webpackHot const hotData = hot.data ??= {} @@ -88,9 +87,5 @@ if (import.meta.webpackHot) { hot.accept() } `, - { - syntacticPlaceholders: true, - }, - )(), - ] + ) } diff --git a/packages/router-plugin/src/core/router-code-splitter-plugin.ts b/packages/router-plugin/src/core/router-code-splitter-plugin.ts index 596bd6e0120..26cc45d74c4 100644 --- a/packages/router-plugin/src/core/router-code-splitter-plugin.ts +++ b/packages/router-plugin/src/core/router-code-splitter-plugin.ts @@ -7,20 +7,24 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { decodeIdentifier, logDiff } from '@tanstack/router-utils' import { getConfig, splitGroupingsSchema } from './config' import { + analyzeRouteModule, compileCodeSplitReferenceRoute, compileCodeSplitSharedRoute, compileCodeSplitVirtualRoute, computeSharedBindings, + createRouteInFunctionMessage, detectCodeSplitGroupingsFromRoute, } from './code-splitter/compilers' import { getFrameworkHmrCompilerPlugins } from './code-splitter/plugins/framework-plugins' import { defaultCodeSplitGroupings, splitRouteIdentNodes, + tsrShared, tsrSplit, } from './constants' import { debug, normalizePath, routeFactoryCallCodeFilter } from './utils' import { createRouterPluginContext } from './router-plugin-context' +import type { RouteModuleAnalysis } from './code-splitter/compilers' import type { CodeSplitGroupings, SplitRouteIdentNodes } from './constants' import type { GetRoutesByFileMapResultValue } from '@tanstack/router-generator' import type { CodeSplitCompilerPlugin } from './code-splitter/plugins' @@ -112,9 +116,8 @@ export function createRouterCodeSplitterPlugin( (plugin) => plugin.onVirtualRouteSplitNode, ) } - // Map from normalized route file path → set of shared binding names. - // Populated by the reference compiler, consumed by virtual and shared compilers. - const sharedBindingsMap = new Map>() + // Route files already reported for a route created inside a function + const routesInFunctionReported = new Set() const getGlobalCodeSplitGroupings = () => { return ( @@ -125,17 +128,25 @@ export function createRouterCodeSplitterPlugin( const getShouldSplitFn = () => { return userConfig.codeSplittingOptions?.splitBehavior } - - const handleCompilingReferenceFile = ( + // React Refresh hot-updates only a module whose exports are all components + const splitSharedComponents = () => userConfig.target === 'react' + + /** + * The groupings of a route file and the bindings its split modules share, + * from the code being compiled: the route module, its split chunks and its + * shared modules can be compiled in any order, for example after an edit in + * dev, so none of them may rely on what another one computed. + */ + const resolveCodeSplitting = ( code: string, id: string, - generatorNodeInfo: GetRoutesByFileMapResultValue, - ): UnpluginTransformResult => { - if (debug) console.info('Compiling Route: ', id) - + routeId: string, + analysis: RouteModuleAnalysis, + ) => { const fromCode = detectCodeSplitGroupingsFromRoute({ code, filename: id, + analysis, }) if (fromCode.groupings !== undefined) { @@ -151,7 +162,7 @@ export function createRouterCodeSplitterPlugin( const userShouldSplitFn = getShouldSplitFn() const pluginSplitBehavior = userShouldSplitFn?.({ - routeId: generatorNodeInfo.routeId, + routeId, }) as CodeSplitGroupings | undefined if (pluginSplitBehavior) { @@ -167,20 +178,36 @@ export function createRouterCodeSplitterPlugin( const splitGroupings: CodeSplitGroupings = fromCode.groupings ?? pluginSplitBehavior ?? getGlobalCodeSplitGroupings() - // Compute shared bindings before compiling the reference route const sharedBindings = computeSharedBindings({ code, filename: id, + analysis, codeSplitGroupings: splitGroupings, }) - if (sharedBindings.size > 0) { - sharedBindingsMap.set(id, sharedBindings) - } else { - sharedBindingsMap.delete(id) + return { + splitGroupings, + sharedBindings: sharedBindings.size > 0 ? sharedBindings : undefined, } + } + + const handleCompilingReferenceFile = ( + code: string, + id: string, + generatorNodeInfo: GetRoutesByFileMapResultValue, + analysis: RouteModuleAnalysis, + ): UnpluginTransformResult => { + if (debug) console.info('Compiling Route: ', id) + + const { splitGroupings, sharedBindings } = resolveCodeSplitting( + code, + id, + generatorNodeInfo.routeId, + analysis, + ) const compiledReferenceRoute = compileCodeSplitReferenceRoute({ code, + analysis, codeSplitGroupings: splitGroupings, targetFramework: userConfig.target, filename: id, @@ -191,7 +218,8 @@ export function createRouterCodeSplitterPlugin( addHmr, hmrStyle, hmrRouteId: generatorNodeInfo.routeId, - sharedBindings: sharedBindings.size > 0 ? sharedBindings : undefined, + sharedBindings, + splitSharedComponents: splitSharedComponents(), compilerPlugins, }) @@ -211,6 +239,20 @@ export function createRouterCodeSplitterPlugin( return compiledReferenceRoute } + /** The bindings a split or shared module of a route file shares. */ + const resolveSharedBindings = ( + code: string, + id: string, + analysis: RouteModuleAnalysis, + ) => { + const baseId = id.split('?')[0]! + const generatorFileInfo = routerPluginContext.routesByFile.get(baseId) + return generatorFileInfo + ? resolveCodeSplitting(code, baseId, generatorFileInfo.routeId, analysis) + .sharedBindings + : undefined + } + const handleCompilingVirtualFile = ( code: string, id: string, @@ -233,14 +275,14 @@ export function createRouterCodeSplitterPlugin( splitRouteIdentNodes.includes(p as any), ) as Array - const baseId = id.split('?')[0]! - const resolvedSharedBindings = sharedBindingsMap.get(baseId) - + const analysis = analyzeRouteModule({ code, filename: id }) const result = compileCodeSplitVirtualRoute({ code, + analysis, filename: id, splitTargets: grouping, - sharedBindings: resolvedSharedBindings, + sharedBindings: resolveSharedBindings(code, id, analysis), + splitSharedComponents: splitSharedComponents(), compilerPlugins: virtualRouteCompilerPlugins, }) @@ -272,10 +314,22 @@ export function createRouterCodeSplitterPlugin( const generatorFileInfo = routerPluginContext.routesByFile.get(normalizedId) if (generatorFileInfo) { + const analysis = analyzeRouteModule({ + code, + filename: normalizedId, + }) + if ( + analysis.routeCreatedInFunction && + !routesInFunctionReported.has(normalizedId) + ) { + routesInFunctionReported.add(normalizedId) + this.warn(createRouteInFunctionMessage(normalizedId)) + } return handleCompilingReferenceFile( code, normalizedId, generatorFileInfo, + analysis, ) } @@ -378,19 +432,30 @@ export function createRouterCodeSplitterPlugin( const url = pathToFileURL(id) url.searchParams.delete('v') const normalizedId = normalizePath(fileURLToPath(url)) - const [baseId] = normalizedId.split('?') - - if (!baseId) return null - - const sharedBindings = sharedBindingsMap.get(baseId) - if (!sharedBindings || sharedBindings.size === 0) return null + const analysis = analyzeRouteModule({ + code, + filename: normalizedId, + }) + const sharedBindings = resolveSharedBindings( + code, + normalizedId, + analysis, + ) + if (!sharedBindings) return null if (debug) console.info('Compiling Shared Module: ', id) - const result = compileCodeSplitSharedRoute({ + const { sideEffectFree, ...result } = compileCodeSplitSharedRoute({ code, + analysis, sharedBindings, filename: normalizedId, + part: + new URLSearchParams(normalizedId.split('?')[1]).get(tsrShared) === + 'components' + ? 'components' + : 'values', + splitSharedComponents: splitSharedComponents(), }) if (debug) { @@ -398,7 +463,35 @@ export function createRouterCodeSplitterPlugin( console.log('Output:\n', result.code + '\n\n') } - return result + if (!sideEffectFree) { + // Explicit, never omitted: rolldown reuses the flag an earlier + // build returned when a later one leaves it out, which kept an + // edited, now impure module side-effect free in `vite build + // --watch` (https://github.com/rolldown/rolldown/issues/11223). + // `true` is the default analysis. + return { ...result, moduleSideEffects: true } + } + // The bundler can then drop the route module's re-export of shared + // bindings nothing imports through it, so that they load only with + // the split chunks that use them + const native = this.getNativeBuildContext?.() + if ( + native?.framework === 'webpack' || + native?.framework === 'rspack' + ) { + const module = ( + native.loaderContext as + | { _module?: { factoryMeta?: object } } + | undefined + )?._module + if (module) { + module.factoryMeta = { + ...module.factoryMeta, + sideEffectFree: true, + } + } + } + return { ...result, moduleSideEffects: false } }, }, diff --git a/packages/router-plugin/src/core/router-hmr-plugin.ts b/packages/router-plugin/src/core/router-hmr-plugin.ts index e1dc01a43b1..b1bbfeb0e79 100644 --- a/packages/router-plugin/src/core/router-hmr-plugin.ts +++ b/packages/router-plugin/src/core/router-hmr-plugin.ts @@ -1,5 +1,9 @@ -import { generateFromAst, logDiff, parseAst } from '@tanstack/router-utils' -import { compileCodeSplitReferenceRoute } from './code-splitter/compilers' +import { generateModule, logDiff } from '@tanstack/router-utils' +import { + analyzeRouteModule, + compileCodeSplitReferenceRoute, + createRouteInFunctionMessage, +} from './code-splitter/compilers' import { getFrameworkHmrCompilerPlugins } from './code-splitter/plugins/framework-plugins' import { createRouteHmrStatement } from './hmr' import { debug, normalizePath, routeFactoryCallCodeFilter } from './utils' @@ -26,6 +30,8 @@ export function createRouterHmrPlugin( } let userConfig = resolveUserConfig() + // Route files already reported for a route created inside a function + const routesInFunctionReported = new Set() return { name: 'tanstack-router:hmr', @@ -48,6 +54,14 @@ export function createRouterHmrPlugin( if (debug) console.info('Adding HMR handling to route ', normalizedId) const hmrStyle = userConfig.plugin?.hmr?.style ?? 'vite' + const analysis = analyzeRouteModule({ code, filename: normalizedId }) + if ( + analysis.routeCreatedInFunction && + !routesInFunctionReported.has(normalizedId) + ) { + routesInFunctionReported.add(normalizedId) + this.warn(createRouteInFunctionMessage(normalizedId)) + } if (userConfig.target === 'react') { const compilerPlugins = getFrameworkHmrCompilerPlugins({ @@ -56,6 +70,7 @@ export function createRouterHmrPlugin( }) const compiled = compileCodeSplitReferenceRoute({ code, + analysis, filename: normalizedId, id: normalizedId, addHmr: true, @@ -76,18 +91,18 @@ export function createRouterHmrPlugin( } } - const ast = parseAst({ code, filename: normalizedId }) - ast.program.body.push( + // The reference compiler emits a copy, so the parsed module is intact + const ast = analysis.module.ast + ast.body.push( ...createRouteHmrStatement([], { hmrStyle, targetFramework: userConfig.target, routeId: routeEntry.routeId, }), ) - const result = generateFromAst(ast, { - sourceMaps: true, + const result = generateModule(ast, { + source: code, filename: normalizedId, - sourceFileName: normalizedId, }) if (debug) { logDiff(code, result.code) diff --git a/packages/router-plugin/src/core/utils.ts b/packages/router-plugin/src/core/utils.ts index 61ab1ca278f..e9310d9bb0e 100644 --- a/packages/router-plugin/src/core/utils.ts +++ b/packages/router-plugin/src/core/utils.ts @@ -1,5 +1,5 @@ -import * as t from '@babel/types' -import type babel from '@babel/core' +import { b, nameOf } from 'yuku-ast' +import type { Identifier, Property } from '@yuku-toolchain/types' export const debug = process.env.TSR_VITE_DEBUG && @@ -22,45 +22,31 @@ export const routeFactoryCallCodeFilter = [ /\bcreateRootRouteWithContext\s*(?:<|\()/, ] -export function getObjectPropertyKeyName( - prop: t.ObjectProperty, -): string | undefined { +export function getObjectPropertyKeyName(prop: Property): string | undefined { if (prop.computed) { return undefined } - if (t.isIdentifier(prop.key)) { - return prop.key.name - } - - if (t.isStringLiteral(prop.key)) { - return prop.key.value - } - - return undefined + return nameOf(prop.key) ?? undefined } +/** + * A name not in `reservedNames`, which then holds it. An output's reserved + * names are the names its source declares or references, and the names given + * out for it: an identifier the output gains must be named by this function, + * or must not declare or reference a name a later call could choose + * (`baseName`, or it with a numeric suffix). The HMR snippets only declare and + * reference names such as `hot`, `hotData` and `window`. + */ export function getUniqueProgramIdentifier( - programPath: babel.NodePath, + reservedNames: Set, baseName: string, -): t.Identifier { +): Identifier { let name = baseName let suffix = 2 - - const programScope = programPath.scope.getProgramParent() - - while ( - programScope.hasBinding(name) || - programScope.hasGlobal(name) || - programScope.hasReference(name) - ) { - name = `${baseName}${suffix}` - suffix++ + while (reservedNames.has(name)) { + name = `${baseName}${suffix++}` } - - // Register the name so subsequent calls within the same traversal - // see it and avoid collisions - programScope.references[name] = true - - return t.identifier(name) + reservedNames.add(name) + return b.Identifier({ name }) } diff --git a/packages/router-plugin/tests/add-hmr.test.ts b/packages/router-plugin/tests/add-hmr.test.ts index 810f8e9b8d1..df4f4a2db31 100644 --- a/packages/router-plugin/tests/add-hmr.test.ts +++ b/packages/router-plugin/tests/add-hmr.test.ts @@ -1,6 +1,8 @@ import { readFile, readdir } from 'node:fs/promises' import path from 'node:path' import { describe, expect, it } from 'vitest' +import { is, walk } from 'yuku-ast' +import type { Node } from '@yuku-toolchain/types' import { compileCodeSplitReferenceRoute } from '../src/core/code-splitter/compilers' import { defaultCodeSplitGroupings } from '../src/core/constants' @@ -169,19 +171,31 @@ describe('add-hmr works', () => { expect(output).toContain('/posts') }) - it('prefers the current generated route id over stale Vite hot data', async () => { - const statement = createRouteHmrStatement([], { + it('prefers the current generated route id over stale Vite hot data', () => { + const statements = createRouteHmrStatement([], { hmrStyle: 'vite', targetFramework: 'react', routeId: '/current-route', }) - const output = JSON.stringify(statement) - - expect(output).toContain('"name":"initialRouteId"') - expect(output).toContain( - '"operator":"??","left":{"type":"StringLiteral","value":"/current-route"', - ) - expect(output).toContain('"object":{"type":"Identifier","name":"hotData"}') + let initializer: Node | null = null + for (const statement of statements) { + walk(statement, { + VariableDeclarator(node) { + if (is.Identifier(node.id) && node.id.name === 'initialRouteId') { + initializer = node.init + } + }, + }) + } + expect(initializer).toMatchObject({ + type: 'LogicalExpression', + operator: '??', + left: { type: 'Literal', value: '/current-route' }, + right: { + type: 'MemberExpression', + object: { type: 'Identifier', name: 'hotData' }, + }, + }) }) it('normalizes the generated root route id for Vite HMR', async () => { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx index c771483895a..3c887ac88e6 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx @@ -47,6 +47,8 @@ if (import.meta.hot) { if (!oldRoute) { return } + // Generated route-tree options are not present on the freshly imported route + // module, but they must stay on the live route before rebuilding indexes. const generatedRouteOptionKeys = new Set(['id', 'path', 'getParentRoute']) const generatedRouteOptions = {} generatedRouteOptionKeys.forEach((key) => { @@ -58,6 +60,17 @@ if (import.meta.hot) { const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent + // Keys whose identity must remain stable to prevent React from + // unmounting/remounting the component tree. React Fast Refresh already + // handles hot-updating the function bodies of these components — our job + // is only to update non-component route options (loader, head, etc.). + // For code-split (splittable) routes, the lazyRouteComponent wrapper is + // already cached in the bundler hot data so its identity is stable. + // For unsplittable routes (e.g. root routes), the component is a plain + // function reference that gets recreated on every module re-execution, + // so we must explicitly preserve the old reference. + // Preserve component identity so React doesn't remount. + // React Fast Refresh patches the function bodies in-place. const componentKeys = [ 'component', 'shellComponent', @@ -81,6 +94,9 @@ if (import.meta.hot) { router.resolvePathCache.clear() void router._refreshRoute?.() function syncHotRouteExport(liveRoute) { + // routeTree.gen.ts mutates the original module export with generated + // routing state. Mirror that state onto the fresh HMR export too, so + // aliased route imports keep working after the module is hot-reloaded. newRoute.options = liveRoute.options newRoute.parentRoute = liveRoute.parentRoute newRoute._path = liveRoute._path @@ -101,7 +117,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx index eb655d02877..37f41bc1459 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx @@ -41,6 +41,8 @@ if (import.meta.webpackHot) { if (!oldRoute) { return } + // Generated route-tree options are not present on the freshly imported route + // module, but they must stay on the live route before rebuilding indexes. const generatedRouteOptionKeys = new Set(['id', 'path', 'getParentRoute']) const generatedRouteOptions = {} generatedRouteOptionKeys.forEach((key) => { @@ -52,6 +54,17 @@ if (import.meta.webpackHot) { const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent + // Keys whose identity must remain stable to prevent React from + // unmounting/remounting the component tree. React Fast Refresh already + // handles hot-updating the function bodies of these components — our job + // is only to update non-component route options (loader, head, etc.). + // For code-split (splittable) routes, the lazyRouteComponent wrapper is + // already cached in the bundler hot data so its identity is stable. + // For unsplittable routes (e.g. root routes), the component is a plain + // function reference that gets recreated on every module re-execution, + // so we must explicitly preserve the old reference. + // Preserve component identity so React doesn't remount. + // React Fast Refresh patches the function bodies in-place. const componentKeys = [ 'component', 'shellComponent', @@ -75,6 +88,9 @@ if (import.meta.webpackHot) { router.resolvePathCache.clear() void router._refreshRoute?.() function syncHotRouteExport(liveRoute) { + // routeTree.gen.ts mutates the original module export with generated + // routing state. Mirror that state onto the fresh HMR export too, so + // aliased route imports keep working after the module is hot-reloaded. newRoute.options = liveRoute.options newRoute.parentRoute = liveRoute.parentRoute newRoute._path = liveRoute._path @@ -96,7 +112,9 @@ if (import.meta.webpackHot) { if (tsrEnqueueUpdate) { tsrEnqueueUpdate(() => {}) } - } catch (_err) {} + } catch (_err) { + /* noop */ + } } hot.dispose((data) => { if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx index bf94d5d3fd2..9e2daa9d4bf 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx @@ -66,6 +66,8 @@ if (import.meta.hot) { if (!oldRoute) { return } + // Generated route-tree options are not present on the freshly imported route + // module, but they must stay on the live route before rebuilding indexes. const generatedRouteOptionKeys = new Set(['id', 'path', 'getParentRoute']) const generatedRouteOptions = {} generatedRouteOptionKeys.forEach((key) => { @@ -77,6 +79,17 @@ if (import.meta.hot) { const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent + // Keys whose identity must remain stable to prevent React from + // unmounting/remounting the component tree. React Fast Refresh already + // handles hot-updating the function bodies of these components — our job + // is only to update non-component route options (loader, head, etc.). + // For code-split (splittable) routes, the lazyRouteComponent wrapper is + // already cached in the bundler hot data so its identity is stable. + // For unsplittable routes (e.g. root routes), the component is a plain + // function reference that gets recreated on every module re-execution, + // so we must explicitly preserve the old reference. + // Preserve component identity so React doesn't remount. + // React Fast Refresh patches the function bodies in-place. const componentKeys = [ 'component', 'shellComponent', @@ -100,6 +113,9 @@ if (import.meta.hot) { router.resolvePathCache.clear() void router._refreshRoute?.() function syncHotRouteExport(liveRoute) { + // routeTree.gen.ts mutates the original module export with generated + // routing state. Mirror that state onto the fresh HMR export too, so + // aliased route imports keep working after the module is hot-reloaded. newRoute.options = liveRoute.options newRoute.parentRoute = liveRoute.parentRoute newRoute._path = liveRoute._path @@ -120,7 +136,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx index f12187ccd1a..b40540de83f 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx @@ -32,6 +32,8 @@ if (import.meta.hot) { if (!oldRoute) { return } + // Generated route-tree options are not present on the freshly imported route + // module, but they must stay on the live route before rebuilding indexes. const generatedRouteOptionKeys = new Set(['id', 'path', 'getParentRoute']) const generatedRouteOptions = {} generatedRouteOptionKeys.forEach((key) => { @@ -43,6 +45,17 @@ if (import.meta.hot) { const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent + // Keys whose identity must remain stable to prevent React from + // unmounting/remounting the component tree. React Fast Refresh already + // handles hot-updating the function bodies of these components — our job + // is only to update non-component route options (loader, head, etc.). + // For code-split (splittable) routes, the lazyRouteComponent wrapper is + // already cached in the bundler hot data so its identity is stable. + // For unsplittable routes (e.g. root routes), the component is a plain + // function reference that gets recreated on every module re-execution, + // so we must explicitly preserve the old reference. + // Preserve component identity so React doesn't remount. + // React Fast Refresh patches the function bodies in-place. const componentKeys = [ 'component', 'shellComponent', @@ -66,6 +79,9 @@ if (import.meta.hot) { router.resolvePathCache.clear() void router._refreshRoute?.() function syncHotRouteExport(liveRoute) { + // routeTree.gen.ts mutates the original module export with generated + // routing state. Mirror that state onto the fresh HMR export too, so + // aliased route imports keep working after the module is hot-reloaded. newRoute.options = liveRoute.options newRoute.parentRoute = liveRoute.parentRoute newRoute._path = liveRoute._path @@ -86,7 +102,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx index 9f8df0d43f5..64003a02e10 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx @@ -48,6 +48,8 @@ if (import.meta.hot) { if (!oldRoute) { return } + // Generated route-tree options are not present on the freshly imported route + // module, but they must stay on the live route before rebuilding indexes. const generatedRouteOptionKeys = new Set(['id', 'path', 'getParentRoute']) const generatedRouteOptions = {} generatedRouteOptionKeys.forEach((key) => { @@ -59,6 +61,17 @@ if (import.meta.hot) { const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent + // Keys whose identity must remain stable to prevent React from + // unmounting/remounting the component tree. React Fast Refresh already + // handles hot-updating the function bodies of these components — our job + // is only to update non-component route options (loader, head, etc.). + // For code-split (splittable) routes, the lazyRouteComponent wrapper is + // already cached in the bundler hot data so its identity is stable. + // For unsplittable routes (e.g. root routes), the component is a plain + // function reference that gets recreated on every module re-execution, + // so we must explicitly preserve the old reference. + // Preserve component identity so React doesn't remount. + // React Fast Refresh patches the function bodies in-place. const componentKeys = [ 'component', 'shellComponent', @@ -82,6 +95,9 @@ if (import.meta.hot) { router.resolvePathCache.clear() void router._refreshRoute?.() function syncHotRouteExport(liveRoute) { + // routeTree.gen.ts mutates the original module export with generated + // routing state. Mirror that state onto the fresh HMR export too, so + // aliased route imports keep working after the module is hot-reloaded. newRoute.options = liveRoute.options newRoute.parentRoute = liveRoute.parentRoute newRoute._path = liveRoute._path @@ -102,7 +118,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx index 859380ef535..65e71cc4368 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx @@ -31,6 +31,8 @@ if (import.meta.hot) { if (!oldRoute) { return } + // Generated route-tree options are not present on the freshly imported route + // module, but they must stay on the live route before rebuilding indexes. const generatedRouteOptionKeys = new Set(['id', 'path', 'getParentRoute']) const generatedRouteOptions = {} generatedRouteOptionKeys.forEach((key) => { @@ -42,6 +44,17 @@ if (import.meta.hot) { const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent + // Keys whose identity must remain stable to prevent React from + // unmounting/remounting the component tree. React Fast Refresh already + // handles hot-updating the function bodies of these components — our job + // is only to update non-component route options (loader, head, etc.). + // For code-split (splittable) routes, the lazyRouteComponent wrapper is + // already cached in the bundler hot data so its identity is stable. + // For unsplittable routes (e.g. root routes), the component is a plain + // function reference that gets recreated on every module re-execution, + // so we must explicitly preserve the old reference. + // Preserve component identity so React doesn't remount. + // React Fast Refresh patches the function bodies in-place. const componentKeys = [ 'component', 'shellComponent', @@ -65,6 +78,9 @@ if (import.meta.hot) { router.resolvePathCache.clear() void router._refreshRoute?.() function syncHotRouteExport(liveRoute) { + // routeTree.gen.ts mutates the original module export with generated + // routing state. Mirror that state onto the fresh HMR export too, so + // aliased route imports keep working after the module is hot-reloaded. newRoute.options = liveRoute.options newRoute.parentRoute = liveRoute.parentRoute newRoute._path = liveRoute._path @@ -85,7 +101,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx index 9d942f65a2b..9effe59a03f 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx @@ -39,6 +39,8 @@ if (import.meta.hot) { if (!oldRoute) { return } + // Generated route-tree options are not present on the freshly imported route + // module, but they must stay on the live route before rebuilding indexes. const generatedRouteOptionKeys = new Set(['id', 'path', 'getParentRoute']) const generatedRouteOptions = {} generatedRouteOptionKeys.forEach((key) => { @@ -50,6 +52,17 @@ if (import.meta.hot) { const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent + // Keys whose identity must remain stable to prevent React from + // unmounting/remounting the component tree. React Fast Refresh already + // handles hot-updating the function bodies of these components — our job + // is only to update non-component route options (loader, head, etc.). + // For code-split (splittable) routes, the lazyRouteComponent wrapper is + // already cached in the bundler hot data so its identity is stable. + // For unsplittable routes (e.g. root routes), the component is a plain + // function reference that gets recreated on every module re-execution, + // so we must explicitly preserve the old reference. + // Preserve component identity so React doesn't remount. + // React Fast Refresh patches the function bodies in-place. const componentKeys = [ 'component', 'shellComponent', @@ -73,6 +86,9 @@ if (import.meta.hot) { router.resolvePathCache.clear() void router._refreshRoute?.() function syncHotRouteExport(liveRoute) { + // routeTree.gen.ts mutates the original module export with generated + // routing state. Mirror that state onto the fresh HMR export too, so + // aliased route imports keep working after the module is hot-reloaded. newRoute.options = liveRoute.options newRoute.parentRoute = liveRoute.parentRoute newRoute._path = liveRoute._path @@ -93,7 +109,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx index 555c8fdd6cd..033be707206 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx @@ -30,6 +30,8 @@ if (import.meta.hot) { if (!oldRoute) { return } + // Generated route-tree options are not present on the freshly imported route + // module, but they must stay on the live route before rebuilding indexes. const generatedRouteOptionKeys = new Set(['id', 'path', 'getParentRoute']) const generatedRouteOptions = {} generatedRouteOptionKeys.forEach((key) => { @@ -41,6 +43,17 @@ if (import.meta.hot) { const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent + // Keys whose identity must remain stable to prevent React from + // unmounting/remounting the component tree. React Fast Refresh already + // handles hot-updating the function bodies of these components — our job + // is only to update non-component route options (loader, head, etc.). + // For code-split (splittable) routes, the lazyRouteComponent wrapper is + // already cached in the bundler hot data so its identity is stable. + // For unsplittable routes (e.g. root routes), the component is a plain + // function reference that gets recreated on every module re-execution, + // so we must explicitly preserve the old reference. + // Preserve component identity so React doesn't remount. + // React Fast Refresh patches the function bodies in-place. const componentKeys = [ 'component', 'shellComponent', @@ -64,6 +77,9 @@ if (import.meta.hot) { router.resolvePathCache.clear() void router._refreshRoute?.() function syncHotRouteExport(liveRoute) { + // routeTree.gen.ts mutates the original module export with generated + // routing state. Mirror that state onto the fresh HMR export too, so + // aliased route imports keep working after the module is hot-reloaded. newRoute.options = liveRoute.options newRoute.parentRoute = liveRoute.parentRoute newRoute._path = liveRoute._path @@ -84,7 +100,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx index 9e27af7d699..e04a45c79f2 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx @@ -47,6 +47,8 @@ if (import.meta.hot) { if (!oldRoute) { return } + // Generated route-tree options are not present on the freshly imported route + // module, but they must stay on the live route before rebuilding indexes. const generatedRouteOptionKeys = new Set(['id', 'path', 'getParentRoute']) const generatedRouteOptions = {} generatedRouteOptionKeys.forEach((key) => { @@ -58,6 +60,17 @@ if (import.meta.hot) { const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent + // Keys whose identity must remain stable to prevent React from + // unmounting/remounting the component tree. React Fast Refresh already + // handles hot-updating the function bodies of these components — our job + // is only to update non-component route options (loader, head, etc.). + // For code-split (splittable) routes, the lazyRouteComponent wrapper is + // already cached in the bundler hot data so its identity is stable. + // For unsplittable routes (e.g. root routes), the component is a plain + // function reference that gets recreated on every module re-execution, + // so we must explicitly preserve the old reference. + // Preserve component identity so React doesn't remount. + // React Fast Refresh patches the function bodies in-place. const componentKeys = [ 'component', 'shellComponent', @@ -81,6 +94,9 @@ if (import.meta.hot) { router.resolvePathCache.clear() void router._refreshRoute?.() function syncHotRouteExport(liveRoute) { + // routeTree.gen.ts mutates the original module export with generated + // routing state. Mirror that state onto the fresh HMR export too, so + // aliased route imports keep working after the module is hot-reloaded. newRoute.options = liveRoute.options newRoute.parentRoute = liveRoute.parentRoute newRoute._path = liveRoute._path @@ -101,7 +117,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx index 1a134b88da2..1f6a64ea949 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx @@ -62,6 +62,8 @@ if (import.meta.hot) { if (!oldRoute) { return } + // Generated route-tree options are not present on the freshly imported route + // module, but they must stay on the live route before rebuilding indexes. const generatedRouteOptionKeys = new Set(['id', 'path', 'getParentRoute']) const generatedRouteOptions = {} generatedRouteOptionKeys.forEach((key) => { @@ -73,6 +75,17 @@ if (import.meta.hot) { const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent + // Keys whose identity must remain stable to prevent React from + // unmounting/remounting the component tree. React Fast Refresh already + // handles hot-updating the function bodies of these components — our job + // is only to update non-component route options (loader, head, etc.). + // For code-split (splittable) routes, the lazyRouteComponent wrapper is + // already cached in the bundler hot data so its identity is stable. + // For unsplittable routes (e.g. root routes), the component is a plain + // function reference that gets recreated on every module re-execution, + // so we must explicitly preserve the old reference. + // Preserve component identity so React doesn't remount. + // React Fast Refresh patches the function bodies in-place. const componentKeys = [ 'component', 'shellComponent', @@ -96,6 +109,9 @@ if (import.meta.hot) { router.resolvePathCache.clear() void router._refreshRoute?.() function syncHotRouteExport(liveRoute) { + // routeTree.gen.ts mutates the original module export with generated + // routing state. Mirror that state onto the fresh HMR export too, so + // aliased route imports keep working after the module is hot-reloaded. newRoute.options = liveRoute.options newRoute.parentRoute = liveRoute.parentRoute newRoute._path = liveRoute._path @@ -116,7 +132,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/renamed-export-sibling@false.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/renamed-export-sibling@false.tsx new file mode 100644 index 00000000000..f833e1fc2e0 --- /dev/null +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/renamed-export-sibling@false.tsx @@ -0,0 +1,6 @@ +import { createRootRoute } from '@tanstack/react-router' +export const page = () =>
Page
, + Route = createRootRoute({ + component: page, + pendingComponent: () =>
Loading
, + }) diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/renamed-export-sibling@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/renamed-export-sibling@true.tsx new file mode 100644 index 00000000000..7c455285f60 --- /dev/null +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/renamed-export-sibling@true.tsx @@ -0,0 +1,121 @@ +import { createRootRoute } from '@tanstack/react-router' +const TSRPendingComponent = () =>
Loading
+const TSRComponent = () =>
Page
, + Route = createRootRoute({ + component: TSRComponent, + pendingComponent: TSRPendingComponent, + }) +export { TSRComponent as page, Route } +const hot = import.meta.hot +if (hot && typeof window !== 'undefined') { + hot.data ??= {} + const tsrReactRefresh = (window.__TSR_REACT_REFRESH__ ??= (() => { + const ignoredExportsById = new Map() + const previousGetIgnoredExports = window.__getReactRefreshIgnoredExports + window.__getReactRefreshIgnoredExports = (ctx) => { + const ignoredExports = previousGetIgnoredExports?.(ctx) ?? [] + const moduleIgnored = ignoredExportsById.get(ctx.id) ?? [] + return [...ignoredExports, ...moduleIgnored] + } + return { ignoredExportsById } + })()) + tsrReactRefresh.ignoredExportsById.set('renamed-export-sibling.tsx', [ + 'Route', + ]) +} +export function TSRFastRefreshAnchor() { + return null +} +if (import.meta.hot) { + const hot = import.meta.hot + const hotData = (hot.data ??= {}) + const handleRouteUpdate = function handleRouteUpdate(routeId, newRoute) { + const router = window.__TSR_ROUTER__ + const oldRoute = router.routesById[routeId] + if (!oldRoute) { + return + } + // Generated route-tree options are not present on the freshly imported route + // module, but they must stay on the live route before rebuilding indexes. + const generatedRouteOptionKeys = new Set(['id', 'path', 'getParentRoute']) + const generatedRouteOptions = {} + generatedRouteOptionKeys.forEach((key) => { + if (key in oldRoute.options) { + generatedRouteOptions[key] = oldRoute.options[key] + } + }) + const oldHasShellComponent = 'shellComponent' in oldRoute.options + const newHasShellComponent = 'shellComponent' in newRoute.options + const preserveComponentIdentity = + oldHasShellComponent === newHasShellComponent + // Keys whose identity must remain stable to prevent React from + // unmounting/remounting the component tree. React Fast Refresh already + // handles hot-updating the function bodies of these components — our job + // is only to update non-component route options (loader, head, etc.). + // For code-split (splittable) routes, the lazyRouteComponent wrapper is + // already cached in the bundler hot data so its identity is stable. + // For unsplittable routes (e.g. root routes), the component is a plain + // function reference that gets recreated on every module re-execution, + // so we must explicitly preserve the old reference. + // Preserve component identity so React doesn't remount. + // React Fast Refresh patches the function bodies in-place. + const componentKeys = [ + 'component', + 'shellComponent', + 'pendingComponent', + 'errorComponent', + 'notFoundComponent', + ] + if (preserveComponentIdentity) { + componentKeys.forEach((key) => { + if (key in oldRoute.options && key in newRoute.options) { + newRoute.options[key] = oldRoute.options[key] + } + }) + } + const nextOptions = { ...newRoute.options, ...generatedRouteOptions } + oldRoute.options = nextOptions + oldRoute.update(nextOptions) + router._replaceRouteChunk(oldRoute, newRoute.lazyFn) + router.setRoutes(router.buildRouteTree()) + syncHotRouteExport(oldRoute) + router.resolvePathCache.clear() + void router._refreshRoute?.() + function syncHotRouteExport(liveRoute) { + // routeTree.gen.ts mutates the original module export with generated + // routing state. Mirror that state onto the fresh HMR export too, so + // aliased route imports keep working after the module is hot-reloaded. + newRoute.options = liveRoute.options + newRoute.parentRoute = liveRoute.parentRoute + newRoute._path = liveRoute._path + newRoute._id = liveRoute._id + newRoute._fullPath = liveRoute._fullPath + newRoute._to = liveRoute._to + } + } + const initialRouteId = Route.id ?? hotData['tsr-route-id'] + if (initialRouteId) { + hotData['tsr-route-id'] = initialRouteId + } + const existingRoute = + typeof window !== 'undefined' && initialRouteId + ? window.__TSR_ROUTER__?.routesById?.[initialRouteId] + : undefined + if (initialRouteId && existingRoute && existingRoute !== Route) { + handleRouteUpdate(initialRouteId, Route) + hotData['tsr-route-update-handled'] = Route + } + import.meta.hot.accept((newModule) => { + if (Route && newModule && newModule.Route) { + const routeId = hotData['tsr-route-id'] ?? Route.id + if (routeId) { + hotData['tsr-route-id'] = routeId + } + if (hotData['tsr-route-update-handled'] === newModule.Route) { + delete hotData['tsr-route-update-handled'] + return + } + handleRouteUpdate(routeId, newModule.Route) + } + }) +} diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx index b3bc82d69ed..85c3b561706 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx @@ -62,6 +62,8 @@ if (import.meta.hot) { if (!oldRoute) { return } + // Generated route-tree options are not present on the freshly imported route + // module, but they must stay on the live route before rebuilding indexes. const generatedRouteOptionKeys = new Set(['id', 'path', 'getParentRoute']) const generatedRouteOptions = {} generatedRouteOptionKeys.forEach((key) => { @@ -73,6 +75,17 @@ if (import.meta.hot) { const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent + // Keys whose identity must remain stable to prevent React from + // unmounting/remounting the component tree. React Fast Refresh already + // handles hot-updating the function bodies of these components — our job + // is only to update non-component route options (loader, head, etc.). + // For code-split (splittable) routes, the lazyRouteComponent wrapper is + // already cached in the bundler hot data so its identity is stable. + // For unsplittable routes (e.g. root routes), the component is a plain + // function reference that gets recreated on every module re-execution, + // so we must explicitly preserve the old reference. + // Preserve component identity so React doesn't remount. + // React Fast Refresh patches the function bodies in-place. const componentKeys = [ 'component', 'shellComponent', @@ -96,6 +109,9 @@ if (import.meta.hot) { router.resolvePathCache.clear() void router._refreshRoute?.() function syncHotRouteExport(liveRoute) { + // routeTree.gen.ts mutates the original module export with generated + // routing state. Mirror that state onto the fresh HMR export too, so + // aliased route imports keep working after the module is hot-reloaded. newRoute.options = liveRoute.options newRoute.parentRoute = liveRoute.parentRoute newRoute._path = liveRoute._path @@ -116,7 +132,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx index 05fa745c4b5..81acfed71ba 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx @@ -16,6 +16,8 @@ if (import.meta.hot) { if (!oldRoute) { return } + // Generated route-tree options are not present on the freshly imported route + // module, but they must stay on the live route before rebuilding indexes. const generatedRouteOptionKeys = new Set(['id', 'path', 'getParentRoute']) const generatedRouteOptions = {} generatedRouteOptionKeys.forEach((key) => { @@ -27,6 +29,17 @@ if (import.meta.hot) { const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent + // Keys whose identity must remain stable to prevent React from + // unmounting/remounting the component tree. React Fast Refresh already + // handles hot-updating the function bodies of these components — our job + // is only to update non-component route options (loader, head, etc.). + // For code-split (splittable) routes, the lazyRouteComponent wrapper is + // already cached in the bundler hot data so its identity is stable. + // For unsplittable routes (e.g. root routes), the component is a plain + // function reference that gets recreated on every module re-execution, + // so we must explicitly preserve the old reference. + // Preserve component identity so React doesn't remount. + // React Fast Refresh patches the function bodies in-place. const componentKeys = [] if (preserveComponentIdentity) { componentKeys.forEach((key) => { @@ -44,6 +57,9 @@ if (import.meta.hot) { router.resolvePathCache.clear() void router._refreshRoute?.() function syncHotRouteExport(liveRoute) { + // routeTree.gen.ts mutates the original module export with generated + // routing state. Mirror that state onto the fresh HMR export too, so + // aliased route imports keep working after the module is hot-reloaded. newRoute.options = liveRoute.options newRoute.parentRoute = liveRoute.parentRoute newRoute._path = liveRoute._path @@ -64,7 +80,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/test-files/react/renamed-export-sibling.tsx b/packages/router-plugin/tests/add-hmr/test-files/react/renamed-export-sibling.tsx new file mode 100644 index 00000000000..b5a2970fb8c --- /dev/null +++ b/packages/router-plugin/tests/add-hmr/test-files/react/renamed-export-sibling.tsx @@ -0,0 +1,7 @@ +import { createRootRoute } from '@tanstack/react-router' + +export const page = () =>
Page
, + Route = createRootRoute({ + component: page, + pendingComponent: () =>
Loading
, + }) diff --git a/packages/router-plugin/tests/code-splitter-dead-code.test.ts b/packages/router-plugin/tests/code-splitter-dead-code.test.ts index 038f323caf5..a1b068ba94f 100644 --- a/packages/router-plugin/tests/code-splitter-dead-code.test.ts +++ b/packages/router-plugin/tests/code-splitter-dead-code.test.ts @@ -7,6 +7,7 @@ import { head, importSources, importedNames, + loadRouteModules, transformWithRouteHmrPlugin, } from './regression-helpers' @@ -78,18 +79,76 @@ function restore() { { // Source: babel-dead-code-elimination dead-code-elimination.test.ts "only // eliminates newly unreferenced identifiers", "unexported circular references" - // and "SCC with external caller used -> all preserved" + // and "SCC with external caller used -> all preserved". The helper of + // the split component moves into its chunk: `unused` stays as written, + // with a dangling reference nothing can reach, since nothing calls it. name: 'unused functions, one calling a helper of the split component', setup: `function helper() { return 'helper' } function unused() { return helper() } function ping(): number { return pong() } function pong(): number { return ping() }`, component: '

{helper()}

', - declared: ['helper', 'unused', 'ping', 'pong'], + declared: ['unused', 'ping', 'pong'], + moved: ['helper'], + }, + { + // Regression in the Yuku migration: the only reads are an unused local + // inside the declaration itself, and the reference module drops both + // declarations with their register() calls. + // Inputs adapted from @babel/plugin-proposal-decorators + // 2023-11-misc/destructuring-transform-integration and TypeScript's + // conformance test classes/propertyMemberDeclarations/autoAccessor10.ts. + name: 'declarations only an unused local inside them reads', + setup: `import { register } from './registry' +const plugin = { + id: register('plugin'), + describe() { + const self = plugin + return 'plugin' + }, +} +class Widget { + static { + register('widget') + } + render() { + const type = Widget + return 'widget' + } +}`, + declared: ['plugin', 'Widget'], + }, + { + // Regression in the Yuku migration, same cause: deleting the declaration + // also deletes the ReferenceError its initializer threw when the route + // module loaded (`later` is read before its declaration). + name: 'a declaration only an unused local inside it reads, whose initializer reads a let before its declaration', + setup: `const plugin = { + id: later, + describe() { + const self = plugin + return 'plugin' + }, +} +let later = 1`, + declared: ['plugin', 'later'], + }, + { + // Regression in the Yuku migration, same cause: an uncalled function that + // reads itself through an unused local is deleted, and then the + // declaration only it read, with its register() call. + name: 'a declaration only an uncalled function reads, the function reading itself through a local', + setup: `import { register } from './registry' +const effect = register('effect') +function helper() { + const self = helper + return effect +}`, + declared: ['effect'], }, ])( 'keeps $name that nothing used before splitting', - async ({ setup, component = '
', declared }) => { + async ({ setup, component = '
', declared, moved = [] }) => { const code = `${head}${setup} export const Route = createFileRoute('/')({ component: () => ${component} }) ` @@ -100,8 +159,18 @@ export const Route = createFileRoute('/')({ component: () => ${component} }) for (const name of declared) { expect(binds(reference, name), `${module} binds ${name}`).toBe(true) } + for (const name of moved) { + expect(binds(reference, name), `${module} binds ${name}`).toBe(false) + } } - await expectValidModules(modules) + // Without code splitting, nothing moves. + const unsplit = transformWithRouteHmrPlugin(code) + for (const name of [...declared, ...moved]) { + expect(unsplit, `the route HMR output declares ${name}`).toMatch( + declarationOf(name), + ) + } + await expectValidModules({ ...modules, 'route HMR': unsplit }) }, ) @@ -127,6 +196,30 @@ export const Route = createFileRoute('/')({ await expectValidModules(modules) }) + // Source: tanstack.com src/routes/index.tsx (`type PrincipleProof` next to + // `function PrincipleProof`). TypeScript keeps types and values in separate + // namespaces: a type the reference module keeps that names the type is not + // a read of the value of the same name. + it('moves a component only the split component uses into its chunk when a kept type shares its name', async () => { + const code = `${head}import { heavy } from './heavy' +type Panel = 'a' | 'b' +export type Settings = { panel: Panel } +function Panel() { + return
{heavy()}
+} +export const Route = createFileRoute('/')({ + loader: () => 'data', + component: () => , +}) +` + for (const reference of Object.values(referenceModules(code))) { + expect(importSources(reference)).not.toContain('./heavy') + } + const { modules } = compileRouteModules(code) + expect(importSources(modules['virtual component']!)).toContain('./heavy') + await expectValidModules(modules) + }) + // Source: Next.js ssg/getStaticProps/should-remove-re-exported-function-declarations-dependents-variables-functions-imports it('keeps the import specifiers nothing used before splitting', async () => { const { modules } = @@ -165,28 +258,57 @@ export const Route = createFileRoute('/')({ await expectValidModules(modules) }) + // Regression in the Yuku migration, found by differential fuzzing: the + // component chunk copies a namespace that no split option reads, with its + // body, so its initializer runs again when the chunk loads. + it('runs the initializer of a namespace no split option reads only in the reference module', async () => { + const { modules } = + compileRouteModules(`${head}import { shared } from './shared' +namespace Unused { export const value = shared('side-effect-marker') } +export const Route = createFileRoute('/')({ + component: () =>
{shared('component')}
, +}) +`) + expect(modules.reference).toContain('side-effect-marker') + expect(modules['virtual component']).not.toContain('side-effect-marker') + await expectValidModules(modules) + }) + // TypeScript erases type positions, so a declaration whose binding only // appears in a type is still live code that runs when the route loads. - it.each([ - { - name: 'the reference module', - compile: (code: string) => compileRouteModules(code).modules.reference!, - }, - { - name: 'a route module compiled for HMR', - compile: (code: string) => transformWithRouteHmrPlugin(code), - }, - ])( - 'keeps a declaration only referenced from types in $name', - async ({ compile }) => { - const output = - compile(`${head}import { registerAnalytics } from './analytics' -const analytics = registerAnalytics('/posts') -declare module './analytics' { + it.each( + [ + { + name: 'the reference module', + compile: (code: string) => compileRouteModules(code).modules.reference!, + }, + { + name: 'a route module compiled for HMR', + compile: (code: string) => transformWithRouteHmrPlugin(code), + }, + ].flatMap((target) => + [ + { + types: 'a module augmentation', + typeDeclaration: `declare module './analytics' { interface Registry { posts: typeof analytics } -} +}`, + }, + { + types: 'an exported type alias', + typeDeclaration: 'export type Analytics = typeof analytics', + }, + ].map((use) => ({ ...target, ...use })), + ), + )( + 'keeps a declaration only referenced from $types in $name', + async ({ compile, typeDeclaration }) => { + const output = + compile(`${head}import { registerAnalytics } from './analytics' +const analytics = registerAnalytics('/posts') +${typeDeclaration} export const Route = createFileRoute('/posts')({ component: () =>

posts

, }) @@ -196,9 +318,205 @@ export const Route = createFileRoute('/posts')({ await expectValidModules({ output }) }, ) + + // Inputs adapted from babel-dead-code-elimination dead-code-elimination.test.ts + // "object pattern" and "array pattern" > "unzips if all variables are + // unused", which removes the whole declaration: an empty pattern declares + // nothing, so splitting never left it unused and its initializer still runs. + it('keeps the initializers of empty destructuring patterns', async () => { + const { modules } = + compileRouteModules(`${head}import { init, other } from './init' +const {} = init() +const [] = other() +const { a: {} } = init() +export const Route = createFileRoute('/')({ + loader: () => 'data', + component: () =>

component

, +}) +`) + expect(modules.reference!.match(/\binit\(\)/g)).toHaveLength(2) + expect(modules.reference).toContain('other()') + expect(importSources(modules['virtual component']!)).not.toContain('./init') + await expectValidModules(modules) + }) }) describe('code a split option needs moves with it', () => { + // Inputs adapted from babel-dead-code-elimination dead-code-elimination.test.ts + // "function" > "declaration" and "variable" > "identifier" + it.each([ + { + name: 'an overloaded function', + declarations: `function load(id: string): string +function load(id: number): string +function load(id: any) { return lib.x(id) }`, + render: '{load(1)}', + }, + { + name: 'a redeclared var', + declarations: `var load = lib.a() +var load = lib.b()`, + render: '{load}', + }, + ])( + 'moves $name only the split component uses, and its import, into its chunk', + async ({ declarations, render }) => { + const { modules } = + compileRouteModules(`${head}import { lib } from './lib' +${declarations} +export const Route = createFileRoute('/')({ + loader: () => 'data', + component: () =>

${render}

, +}) +`) + expect(importSources(modules.reference!)).not.toContain('./lib') + expect(modules.reference).not.toMatch(/\bload\b/) + expect(importedNames(modules['virtual component']!, './lib')).toEqual([ + 'lib', + ]) + await expectValidModules(modules) + }, + ) + + // Source: Next.js ssg/getStaticProps/should-support-class-exports + it('moves a class component and the imports of its static fields into its chunk', async () => { + const { modules } = + compileRouteModules(`${head}import * as React from 'react' +import { format } from './format' +class Page extends React.Component { + static title = format('home') + render() { + return
{Page.title}
+ } +} +export const Route = createFileRoute('/')({ + loader: () => 'data', + component: Page, +}) +`) + expect(modules.reference).not.toMatch(declarationOf('Page')) + expect(importSources(modules.reference!)).not.toContain('./format') + expect(modules['virtual component']).toMatch(declarationOf('Page')) + expect(importedNames(modules['virtual component']!, './format')).toEqual([ + 'format', + ]) + await expectValidModules(modules) + }) + + // Regression in the Yuku migration: an unused function that renders the + // split component keeps the component and its imports in the route module, + // which defeats the split. The unused function stays as written, with a + // dangling reference: nothing can call it. + // Source: corpus-diff, Jp-K/DmCompanionAi frontend_old/src/routes/_layout/chat.tsx. + it.each([ + { + name: 'the split component', + setup: `function Page() { + return

{heavy()}

+} +function Unused() { + return +}`, + component: 'Page', + }, + { + name: 'a component the split component renders', + setup: `function Card() { + return

{heavy()}

+} +function Page() { + return +} +const Unused = () => `, + component: 'Page', + }, + { + name: 'a component an inline split component renders', + setup: `function Page() { + return

{heavy()}

+} +function Unused() { + return +}`, + component: '() => ', + }, + ])( + 'moves the split component and its imports into its chunk when an unused function renders $name', + async ({ setup, component }) => { + const { modules } = + compileRouteModules(`${head}import { heavy } from './heavy' +export const Route = createFileRoute('/')({ + component: ${component}, +}) +${setup} +`) + expect(importSources(modules.reference!)).not.toContain('./heavy') + expect(modules.reference).toMatch(declarationOf('Unused')) + expect(importSources(modules['virtual component']!)).toContain('./heavy') + await expectValidModules(modules) + }, + ) + + // Imports do not move: the chunk imports what it reads itself, and the + // unused function keeps the imports it reads, as on main. + it('keeps the imports an unused function reads in the route module', async () => { + const { modules } = + compileRouteModules(`${head}import { format } from './format' +function Page() { + return

{format('page')}

+} +function Unused() { + return

{format('unused')}

+} +export const Route = createFileRoute('/')({ component: Page }) +`) + expect(modules.reference).not.toMatch(declarationOf('Page')) + expect(importedNames(modules.reference!, './format')).toEqual(['format']) + expect(importedNames(modules['virtual component']!, './format')).toEqual([ + 'format', + ]) + await expectValidModules(modules) + }) + + // A function that code staying in the route module can call is no unused + // code: the component it renders stays declared there. + it.each([ + { + name: 'module-level code registers', + setup: `registry.set('preview', Preview)`, + }, + { + name: 'an unused subscription calls back', + setup: `const unsubscribe = subscribe((name) => registry.set(name, Preview))`, + }, + ])( + 'keeps the split component in the route module for a function $name', + async ({ setup }) => { + const { modules, options, chunks } = await loadRouteModules( + `${head}import { subscribe } from './subscribe' +const registry = new Map() +function Page() { + return

page

+} +function Preview() { + return +} +${setup} +export const Route = createFileRoute('/')({ + component: Page, + loader: () => registry.get('preview')(), +}) +`, + { + './subscribe': { subscribe: (callback: any) => callback('preview') }, + }, + ) + expect(options.loader()).toBe('

page

') + expect(chunks.component!.component()).toBe('

page

') + await expectValidModules(modules) + }, + ) + // Source: Qwik optimizer test.rs should_not_move_over_side_effects, // example_drop_side_effects; Turbopack tree-shaker analyzer/route-kind it.each([ @@ -367,9 +685,12 @@ export const Route = createFileRoute('/')({ }) `) expect(sharedBindings).toEqual(['Page']) - expect(modules.shared).toMatch(declarationOf('Page')) + // A React component goes to the shared components module + expect(modules['shared components']).toMatch(declarationOf('Page')) expect(modules.reference).toMatch(declarationOf('Preview')) - expect(importedNames(modules.reference!, sharedModule)).toEqual(['Page']) + expect( + importedNames(modules.reference!, 'route.tsx?tsr-shared=components'), + ).toEqual(['Page']) expect(modules.reference).not.toMatch(declarationOf('Page')) expect(modules['virtual component']).not.toMatch(declarationOf('Page')) await expectValidModules(modules) @@ -396,8 +717,8 @@ export const Route = createFileRoute('/')({ component: () =>
{value}
, }) `) - // Known limitation on main: the statement runs in both modules instead - // of once in the shared module; only check that each module binds `value`. + // Known limitation: the statement runs in both modules instead of once + // in the shared module; only check that each module binds `value`. for (const name of ['reference', 'virtual component']) { expect(binds(modules[name]!, 'value'), `${name} binds value`).toBe(true) } diff --git a/packages/router-plugin/tests/code-splitter-exported-bindings-chunks.test.ts b/packages/router-plugin/tests/code-splitter-exported-bindings-chunks.test.ts new file mode 100644 index 00000000000..0bd9c185e71 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter-exported-bindings-chunks.test.ts @@ -0,0 +1,269 @@ +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { build } from 'vite' +import { describe, expect, it } from 'vitest' +import { buildAndRun, head } from './regression-helpers' + +// The generated route tree imports only `Route` from a route file, and it is in +// the entry chunk. This entry does the same, so nothing else uses the route +// file's other exports (tests that import them are not part of the build). +const entry = `import { createElement } from 'react' +import { renderToString } from 'react-dom/server' +import { Route } from './routes/index' +export { Route } +export const entryUrl = import.meta.url +export async function render(component: any) { + await component.preload?.() + return renderToString(createElement(component)) +} +` + +const appPackage = JSON.stringify({ + name: 'app', + private: true, + type: 'module', +}) + +/** + * Where each marker is built: in the entry chunk or a chunk it statically + * imports, so that it loads with the entry ('entry'), or only in lazily loaded + * chunks ('lazy'). + */ +const placement = `const fs = await import('node:fs') +const path = await import('node:path') +const url = await import('node:url') +const dir = path.dirname(url.fileURLToPath(entry.entryUrl)) +const read = (file) => fs.readFileSync(path.join(dir, file), 'utf8') +const eager = new Set() +const visit = (file) => { + if (eager.has(file)) return + eager.add(file) + for (const [, specifier] of read(file).matchAll(/(?:\\bfrom\\s*|\\bimport\\s*)["']\\.\\/([^"']+)["']/g)) visit(specifier) +} +visit('entry.mjs') +const where = (marker) => [ + ...new Set( + fs + .readdirSync(dir) + .filter((file) => file.endsWith('.mjs') && read(file).includes(marker)) + .map((file) => (eager.has(file) ? 'entry' : 'lazy')), + ), +] +return { + html: await entry.render(entry.Route.options.component), + declaration: where('MARK_DECLARATION'), + dependency: where('MARK_DEPENDENCY'), +}` + +describe('a split option leaves the exports of its route file it uses out of the entry chunk', () => { + // Route files export page components and helpers so that tests can import + // them, and only the split component uses them in the app. + it.each([ + { + name: 'an exported function component', + code: `export function Card() { + return {heavy('MARK_DECLARATION')} +} +function Page() { + return +}`, + }, + { + name: 'a function component exported by specifier', + code: `function Card() { + return {heavy('MARK_DECLARATION')} +} +export { Card } +function Page() { + return +}`, + }, + { + name: 'a default-exported function component', + code: `export default function Card() { + return {heavy('MARK_DECLARATION')} +} +function Page() { + return +}`, + }, + { + // A client build bundles `react/jsx-runtime`, a CommonJS module, whose + // interop call has side effects too + name: 'an exported function component with a dependency that has side effects', + code: `import { label } from '../label' +export function Card() { + return {heavy('MARK_DECLARATION ' + label)} +} +function Page() { + return +}`, + // Only the declaration is asserted: how the bundler groups the modules + // next to a dependency with side effects is up to it + dependencyIn: expect.anything(), + }, + { + name: 'an exported helper function', + code: `export function format(value: number) { + return heavy('MARK_DECLARATION ' + value) +} +function Page() { + return

{format(1)}

+}`, + }, + { + name: 'an exported class', + code: `export class Store { + value = heavy('MARK_DECLARATION') +} +function Page() { + return

{new Store().value}

+}`, + }, + { + name: 'an exported function over module state', + code: `let count = 0 +export function next() { + count++ + return heavy('MARK_DECLARATION ' + count) +} +function Page() { + return

{next()}

+}`, + }, + ])( + '$name', + async ({ code, dependencyIn = ['lazy'] }) => { + const result = await buildAndRun( + `${head}import { heavy } from '../heavy' +${code} +export const Route = createFileRoute('/')({ component: Page }) +`, + placement, + { + files: { + 'entry.ts': entry, + // Without it the app inherits the package's `sideEffects: false` + 'package.json': appPackage, + 'heavy.ts': `export function heavy(label: string) { + return label + ' MARK_DEPENDENCY' +} +`, + 'label.ts': `;(globalThis as any).labelLoaded = true\nexport const label = 'label'\n`, + }, + }, + ) + expect(result).toEqual({ + html: expect.stringContaining('MARK_DECLARATION'), + declaration: ['lazy'], + dependency: dependencyIn, + }) + }, + 30_000, + ) +}) + +type Watcher = Extract>, { close: unknown }> +interface WatcherEvent { + code: string + result?: { close: () => Promise } | null + error?: unknown +} + +describe('a watch build', () => { + // Bug in rolldown (https://github.com/rolldown/rolldown/issues/11223): a + // module keeps the side-effect free flag of an earlier build when its + // transforms stop returning one. After an edit gives the shared module a + // top-level effect, the effect must still load with the entry. + it('loads the effect an edit adds to the shared module with the entry', async () => { + const { tanstackRouter } = await import('../src/vite') + const root = await mkdtemp(path.join(__dirname, '.regression-build-')) + const route = (registry: string) => `${head}${registry} +export function Card() { + return {registry.size} +} +export const Route = createFileRoute('/')({ component: () => }) +` + const pure = route('const registry = new Map()') + const impure = route(`function createRegistry() { + ;(globalThis as any).effects = ['MARK_EFFECT'] + return new Map() +} +const registry = createRegistry()`) + const files: Record = { + 'routes/__root.tsx': `import { createRootRoute } from '@tanstack/react-router' +export const Route = createRootRoute({})`, + 'routes/index.tsx': pure, + 'entry.ts': entry, + 'package.json': appPackage, + } + await mkdir(path.join(root, 'routes')) + for (const [file, code] of Object.entries(files)) { + await writeFile(path.join(root, file), code) + } + const watcher = (await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + tanstackRouter({ + target: 'react', + routesDirectory: './routes', + generatedRouteTree: './routeTree.gen.ts', + autoCodeSplitting: true, + codeSplittingOptions: { addHmr: false }, + }), + ], + build: { + ssr: path.join(root, 'entry.ts'), + outDir: 'dist', + minify: false, + watch: {}, + rollupOptions: { + output: { entryFileNames: 'entry.mjs', chunkFileNames: '[name].mjs' }, + }, + }, + })) as Watcher + const built = () => + new Promise((resolve, reject) => { + const onEvent = (event: WatcherEvent) => { + if (event.code === 'BUNDLE_END') { + void event.result?.close() + } else if (event.code === 'END' || event.code === 'ERROR') { + watcher.off('event', onEvent) + if (event.code === 'ERROR') { + reject(event.error) + } else { + resolve() + } + } + } + watcher.on('event', onEvent) + }) + // Each run imports a fresh copy of the built entry + const effectsAtStartup = async () => { + const entryUrl = pathToFileURL(path.join(root, 'dist/entry.mjs')).href + const { stdout } = await promisify(execFile)(process.execPath, [ + '--input-type=module', + '--eval', + `await import(${JSON.stringify(entryUrl)}) +process.stdout.write(JSON.stringify(globalThis.effects ?? []))`, + ]) + return JSON.parse(stdout) as Array + } + try { + await built() + expect(await effectsAtStartup()).toEqual([]) + const rebuilt = built() + await writeFile(path.join(root, 'routes/index.tsx'), impure) + await rebuilt + expect(await effectsAtStartup()).toEqual(['MARK_EFFECT']) + } finally { + await watcher.close() + await rm(root, { recursive: true, force: true }) + } + }, 60_000) +}) diff --git a/packages/router-plugin/tests/code-splitter-exported-bindings-identity.test.ts b/packages/router-plugin/tests/code-splitter-exported-bindings-identity.test.ts new file mode 100644 index 00000000000..d8557919dcb --- /dev/null +++ b/packages/router-plugin/tests/code-splitter-exported-bindings-identity.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' +import { buildAndRun, head } from './regression-helpers' + +/** + * An entry that imports `Route` and, like another module of the app, `imports` + * (comma separated) from the route file, then runs `setup` before any split + * chunk loads. + */ +function entryImporting(imports: string, setup: string) { + return `import { createElement } from 'react' +import { renderToString } from 'react-dom/server' +import { Route, ${imports} } from './routes/index' +export { Route } +${setup} +export async function render(component: any) { + await component.preload?.() + return renderToString(createElement(component)) +} +` +} + +const render = `return entry.render(entry.Route.options.component)` + +describe('a split option and other importers of the route file share its exports', () => { + it('an instance of an exported class that another module creates is an instance of the class the split component reads', async () => { + const html = await buildAndRun( + `${head}export class Store {} +function Page() { + return

{String((globalThis as any).__store instanceof Store)}

+} +export const Route = createFileRoute('/')({ component: Page }) +`, + render, + { + files: { + 'entry.ts': entryImporting( + 'Store', + ';(globalThis as any).__store = new Store()', + ), + }, + }, + ) + expect(html).toBe('

true

') + }, 30_000) + + it('an exported function over module state keeps one state for the split component and other modules', async () => { + const html = await buildAndRun( + `${head}let count = 0 +export function next() { + return ++count +} +function Page() { + return

{next()}

+} +export const Route = createFileRoute('/')({ component: Page }) +`, + render, + { files: { 'entry.ts': entryImporting('next', 'next()') } }, + ) + // The entry's call is the first, the split component's the second + expect(html).toBe('

2

') + }, 30_000) + + // The module that holds the state then has a top-level call, so the bundler + // has to keep it where it is imported + it('an exported function over state an imported call creates initializes and keeps it once', async () => { + const result = await buildAndRun( + `${head}import { init } from '../state' +const state = init({ count: 0 }) +export function increment() { + return ++state.count +} +function Page() { + return

{increment()}

+} +export const Route = createFileRoute('/')({ component: Page }) +`, + `return [await entry.render(entry.Route.options.component), globalThis.__inits]`, + { + files: { + 'entry.ts': entryImporting('increment', 'increment()'), + 'state.ts': `export function init(value: T) { + ;(globalThis as any).__inits = ((globalThis as any).__inits ?? 0) + 1 + return value +} +`, + }, + }, + ) + expect(result).toEqual(['

2

', 1]) + }, 30_000) + + it('an exported function over state the route file configures first sees the configured state', async () => { + const html = await buildAndRun( + `${head}import { configure, createStore } from '../store' +configure(5) +const store = createStore() +export function useCount() { + return store.count +} +function Page() { + return

{useCount()}

+} +export const Route = createFileRoute('/')({ component: Page }) +`, + render, + { + files: { + 'store.ts': `let initial = 0 +export function configure(count: number) { + initial = count +} +export function createStore() { + return { count: initial } +} +`, + }, + }, + ) + expect(html).toBe('

5

') + }, 30_000) + + // Main copied an exported function into the chunk, so the split component + // rendered another function than the one other modules import. + it('an exported function component is the one other modules import', async () => { + const html = await buildAndRun( + `${head}export function Card() { + return card +} +function Page() { + return

{String((globalThis as any).__Card === Card)}

+} +export const Route = createFileRoute('/')({ component: Page }) +`, + render, + { + files: { + 'entry.ts': entryImporting( + 'Card', + ';(globalThis as any).__Card = Card', + ), + }, + }, + ) + expect(html).toBe('

true

') + }, 30_000) + + it('an exported function component and the private state it reads are the ones other modules import', async () => { + const html = await buildAndRun( + `${head}const registry = new Set() +function size() { + return registry.size +} +export function Card({ id }: { id: string }) { + registry.add(id) + return {size()} +} +function Page() { + return ( +

+ {String((globalThis as any).__Card === Card)} + +

+ ) +} +export const Route = createFileRoute('/')({ component: Page }) +`, + render, + { + files: { + 'entry.ts': entryImporting( + 'Card', + ";(globalThis as any).__Card = Card\nCard({ id: 'entry' })", + ), + }, + }, + ) + expect(html).toBe('

true2

') + }, 30_000) +}) diff --git a/packages/router-plugin/tests/code-splitter-imports-exports.test.ts b/packages/router-plugin/tests/code-splitter-imports-exports.test.ts index b679782bdc8..70f4de2ee0e 100644 --- a/packages/router-plugin/tests/code-splitter-imports-exports.test.ts +++ b/packages/router-plugin/tests/code-splitter-imports-exports.test.ts @@ -1,5 +1,5 @@ import { transformWithOxc } from 'vite' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { compileRouteModules, componentChunk, @@ -10,6 +10,8 @@ import { head, importSources, importedNames, + loadRouteModules, + requestedSources, transformWithRouteHmrPlugin, } from './regression-helpers' @@ -202,6 +204,315 @@ export const Route = createFileRoute('/')({ expect(modules.reference).toMatch(/export \* as lib from ['"]\.\/lib['"]/) await expectValidModules(modules) }) + + // A chunk that re-exported `export *` would depend on all of `./lib` + // through the opaque namespace of its dynamic import, which defeats + // tree-shaking of it. + it('keep module-level export * out of the split chunks', () => { + const { modules } = compileRouteModules(`${head}export * from './lib' +export const Route = createFileRoute('/')({ + component: () =>

index

, +})`) + expect(requestedSources(modules.reference!)).toContain('./lib') + const chunksRequestingLib = Object.keys(modules).filter( + (name) => + name.startsWith('virtual ') && + requestedSources(modules[name]!).includes('./lib'), + ) + expect(chunksRequestingLib).toEqual([]) + }) + + // Source: Next.js ssg/getStaticProps/should-remove-extra-named-export-speicifers, + // should-support-full-re-export and should-support-class-exports + it('keep re-exports and a default-exported class out of the split chunks', async () => { + const { modules } = + compileRouteModules(`${head}import * as React from 'react' +export { getPaths, a as getProps } from './lib' +export { foo, bar as baz } from './lib' +export { helper } from './default-lib' +export default class Test extends React.Component { + render() { + return
test
+ } +} +export const Route = createFileRoute('/')({ + component: () =>
home
, +}) +`) + expect(exportedNames(modules.reference!)).toEqual([ + 'Route', + 'baz', + 'default', + 'foo', + 'getPaths', + 'getProps', + 'helper', + ]) + for (const name of [ + 'virtual component', + 'virtual errorComponent', + 'virtual notFoundComponent', + ]) { + expect(requestedSources(modules[name]!)).not.toContain('./lib') + expect(requestedSources(modules[name]!)).not.toContain('./default-lib') + expect(modules[name]).not.toMatch(declarationOf('Test')) + } + await expectValidModules(modules) + }) + + // Source: Next.js ssg/getStaticProps/should-not-crash-for-class-declarations + it('keep an exported class component in the route module', async () => { + const { modules } = + compileRouteModules(`${head}import * as React from 'react' +export class Page extends React.Component { + render() { + return
page
+ } +} +export const Route = createFileRoute('/')({ component: Page }) +`) + expect(modules.reference).toMatch(declarationOf('Page')) + expect(exportedNames(modules.reference!)).toContain('Page') + await expectValidModules(modules) + }) + + // Source: Next.js ssg/getStaticProps/should-remove-re-exported-function-declarations, + // should-support-named-export-as-default and + // should-support-export-named-as-default-with-a-class + it.each([ + { + name: 'export { Page as default }', + code: `function Page() { + return
page
+} +export { Page as default }`, + }, + { + name: 'export { Page as default, a } with a class', + code: `import * as React from 'react' +class Page extends React.Component { + render() { + return
page
+ } +} +const a = 5 +export { Page as default, a }`, + }, + { + name: 'export { Page as Renamed, kept as keptRenamed }', + code: `const Page = () =>
page
+const kept = () => 'kept' +export { Page as Renamed, kept as keptRenamed }`, + }, + ])( + 'keep a component exported through $name in the route module', + async ({ code }) => { + const { modules } = compileRouteModules(`${head}${code} +export const Route = createFileRoute('/')({ component: Page }) +`) + expect(modules.reference).toMatch(declarationOf('Page')) + await expectValidModules(modules) + }, + ) + + // Inputs adapted from the React Compiler fixture corpus + // (compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler) + it.each([ + { + // FIXTURE_ENTRYPOINT in complex-while.js and most fixtures + name: 'an exported object', + exports: 'export const meta = { fn: Page, params: [{}] }', + }, + { + // gating/gating-use-before-decl.js + name: 'an exported call', + exports: 'export default memo(Page)', + }, + { + // uid-collision-across-functions.js + name: 'an export list', + exports: 'export { Page, Fallback }', + }, + ])( + 'keep a component that $name references in the route module', + async ({ exports }) => { + const { modules } = + compileRouteModules(`${head}import { memo } from 'react' +function Page() { + return
page
+} +function Fallback() { + return
error
+} +${exports} +export const Route = createFileRoute('/')({ component: Page, errorComponent: Fallback }) +`) + expect(modules.reference).toMatch(declarationOf('Page')) + await expectValidModules(modules) + }, + ) + + // Module-level code that reads the split component runs when the route + // module loads, so the component stays declared there. + // Source: differential fuzzing (main removed the declaration and left the + // call reading it). + it('keep a component that a top-level call reads in the route module', async () => { + const { modules } = + compileRouteModules(`${head}import { register } from './registry' +function Page() { + return
page
+} +export const Route = createFileRoute('/')({ component: Page }) +register(Page) +`) + expect(modules.reference).toMatch(declarationOf('Page')) + await expectValidModules(modules) + }) + + // Source: Next.js ssg/getStaticProps/should-not-crash-for-class-declarations + it('move to the shared module when exported by specifier or as a class', async () => { + const { modules, sharedBindings } = + compileRouteModules(`${head}function getPaths() { + return [] +} +export { getPaths } +export class MyClass {} +function Page() { + return
{getPaths().length}{String(new MyClass())}
+} +export const Route = createFileRoute('/')({ component: Page }) +`) + // One declaration, which the route module re-exports: a copied class + // would break `instanceof MyClass` checks across modules + expect(sharedBindings).toEqual(['MyClass', 'getPaths']) + expect(modules.shared).toMatch(declarationOf('MyClass')) + expect(modules.shared).toMatch(declarationOf('getPaths')) + const component = modules['virtual component']! + expect(importedNames(component, 'route.tsx?tsr-shared=1')).toEqual([ + 'MyClass', + 'getPaths', + ]) + for (const module of [component, modules.reference!]) { + expect(module).not.toMatch(declarationOf('MyClass')) + expect(module).not.toMatch(declarationOf('getPaths')) + } + expect(exportedNames(modules.reference!)).toEqual([ + 'MyClass', + 'Route', + 'getPaths', + ]) + await expectValidModules(modules) + }) + + // The shared module cannot take a binding that reads `Route`, which the + // shared module would import from the route module that imports it. + it('are declared by the chunk when they read Route and hold no module state', async () => { + const { modules, sharedBindings } = + compileRouteModules(`${head}const prefix = 'route ' +export function useTitle() { + return prefix + Route.useLoaderData().title +} +let count = 0 +export function next() { + return prefix + Route.id + ++count +} +export let visits = 0 +export function visit() { + return Route.id + ++visits +} +function Page() { + return

{useTitle()}{next()}{visit()}

+} +export const Route = createFileRoute('/')({ + loader: () => ({ title: 'title' }), + component: Page, +}) +`) + expect(sharedBindings).toEqual([]) + const component = modules['virtual component']! + expect(component).toMatch(declarationOf('useTitle')) + // A copy of `next` would count separately from the route module's, and + // one of `visit` would assign the `visits` it imports + expect(component).not.toMatch(declarationOf('next')) + expect(component).not.toMatch(declarationOf('visit')) + expect(importedNames(component, 'route.tsx')).toEqual([ + 'Route', + 'next', + 'visit', + ]) + await expectValidModules(modules) + }) + + it('stay in the route module when only an exported split target that stays there reads them', async () => { + const { modules, sharedBindings } = + compileRouteModules(`${head}export function Card() { + return card +} +export function Page() { + return +} +export const Route = createFileRoute('/')({ component: Page }) +`) + expect(sharedBindings).toEqual([]) + expect(modules.reference).toMatch(declarationOf('Card')) + await expectValidModules(modules) + }) + + it('stay in the route module when it reassigns a binding declared with what they read', async () => { + const { modules, sharedBindings } = + compileRouteModules(`${head}let { title, visits } = { title: 'Home', visits: 0 } +export function reset() { + visits = 0 +} +export function Title() { + return

{title}

+} +export const Route = createFileRoute('/')({ + component: () => , +}) +`) + expect(sharedBindings).toEqual([]) + await expectValidModules(modules) + }) + + // A second initialization in the chunk would be another instance than the + // one other modules import, even when it is built from a private call + // result that the chunk reads as well. + it('are imported by the chunk when built from a private call result', async () => { + const chunk = componentChunk(`${head}import { load } from './data' +const initial = load() +export const cache = new Map(initial) +function Page() { + return <p>{cache.size === initial.length ? 'fresh' : 'changed'}</p> +} +export const Route = createFileRoute('/')({ component: Page }) +`) + expect(chunk).not.toMatch(declarationOf('cache')) + expect(importedNames(chunk, 'route.tsx')).toContain('cache') + await expectValidModules({ chunk }) + }) + + // Inputs adapted from React Compiler gating/gating-test-export-default-function.js + it('are imported by the chunk of another component that renders the default-exported component', async () => { + const { modules } = + compileRouteModules(`${head}export default function Bar(props) { + return <div>{props.bar}</div> +} +function NoForget(props) { + return <Bar>{props.noForget}</Bar> +} +export const Route = createFileRoute('/')({ component: Bar, errorComponent: NoForget }) +`) + const chunk = modules['virtual errorComponent']! + expect(chunk).not.toMatch(declarationOf('Bar')) + // From the route module (its default export) or a shared module + expect([ + ...importedNames(chunk, 'route.tsx'), + ...importedNames(chunk, 'route.tsx?tsr-shared=1'), + ...importedNames(chunk, 'route.tsx?tsr-shared=components'), + ]).toEqual([expect.stringMatching(/^(?:Bar|default)$/)]) + await expectValidModules(modules) + }) }) describe('TypeScript-only syntax', () => { @@ -223,6 +534,64 @@ export const Route = createFileRoute('/')({ await expectValidModules(modules) }) + // An import the code only references from types (without `import type`) + // stays only where such a reference does, for TypeScript to erase. + it.each([ + { + name: 'the loader', + options: `loader: (): Framework => 'react', + component: () => <p>index</p>,`, + importers: ['reference'], + }, + { + name: 'the split component', + options: `loader: () => 'data', + component: () => { + const [framework] = useState<Framework>('react') + return <p>{framework}</p> + },`, + importers: ['virtual component'], + }, + ])( + 'imports a binding only types reference in $name only where they do', + async ({ options, importers }) => { + const { modules } = + compileRouteModules(`${head}import { useState } from 'react' +import { Framework } from './projects' +export const Route = createFileRoute('/')({ + ${options} +}) +`) + expect( + Object.keys(modules).filter((name) => + importSources(modules[name]!).includes('./projects'), + ), + ).toEqual(importers) + await expectValidModules(modules) + }, + ) + + // TypeScript erases a value import that only types read, so the split + // chunks do not import it for the types of code left in the route module. + // Source: mutation testing of the dead code removal (main copied the import + // into every chunk, which then loaded a module none of its code reads). + it('do not import into the split chunks what only the types of a helper left in the route module read', async () => { + const { modules } = + compileRouteModules(`${head}import { Database } from './server/db' +const load = (db: Database) => db.query() +export const Route = createFileRoute('/')({ + loader: ({ context }) => load(context.db), + component: () => <p>page</p>, +}) +`) + expect( + Object.keys(modules).filter((name) => + importSources(modules[name]!).includes('./server/db'), + ), + ).toEqual(['reference']) + await expectValidModules(modules) + }) + // Ambient declarations (`declare ...`) have no runtime binding: the // environment provides the value (a <script> tag, a bundler define, a global). it.each([ @@ -262,6 +631,53 @@ export const Route = createFileRoute('/ambient')({ }, ) + // For example a constant injected with Vite's `define`: no module exports it. + it('leaves an ambient declare const read by the loader and the split component a global', async () => { + vi.stubGlobal('APP_VERSION', '1.0') + try { + const { options, chunks } = + await loadRouteModules(`${head}declare const APP_VERSION: string +export const Route = createFileRoute('/')({ + loader: () => APP_VERSION, + component: () => <p>{APP_VERSION}</p>, +})`) + expect([options.loader(), chunks.component!.component()]).toEqual([ + '1.0', + '<p>1.0</p>', + ]) + } finally { + vi.unstubAllGlobals() + } + }) + + // Regression in the Yuku migration: an ambient var merged with an interface + // has no runtime declaration, but the shared module copies the interface + // and exports the var, so loading it fails with "Export 'readVersion' is + // not defined". + // Source: TypeScript conformance test + // expressions/functionCalls/overloadResolutionConstructors.ts. + it('leaves an ambient var merged with an interface a global when a shared declaration reads it', async () => { + vi.stubGlobal('readVersion', () => '1.0') + try { + const { options, chunks } = + await loadRouteModules(`${head}interface readVersion { + (): string +} +declare var readVersion: readVersion +const version = readVersion() +export const Route = createFileRoute('/')({ + loader: () => version, + component: () => <p>{version}</p>, +})`) + expect([options.loader(), chunks.component!.component()]).toEqual([ + '1.0', + '<p>1.0</p>', + ]) + } finally { + vi.unstubAllGlobals() + } + }) + it('keeps the namespace import that an import alias refers to', () => { const chunk = componentChunk(`${head}import * as lib from './lib' import Helper = lib.Helper @@ -288,35 +704,79 @@ export const Route = createFileRoute('/')({ expect(chunk.component!()).toBe('<p>LOUD|label:x</p>') }) + // Regression in the Yuku migration: TypeScript accepts overload signatures + // for a private method, but compiling the route throws "Duplicate private + // name '#parse'". Yuku analyzer bug, + // https://github.com/yuku-toolchain/yuku/issues/257; passes once Yuku ships + // the fix. + // Source: @babel/plugin-transform-typescript class/private-method-override. + it('gives the split component a class with an overloaded private method', async () => { + const { modules } = compileRouteModules(`${head}class Parser { + #parse(value: string): number + #parse(value: number): string + #parse(value: string | number): string | number { + return typeof value === 'string' ? value.length : String(value) + } + run() { + return [this.#parse('abc'), this.#parse(7)].join('|') + } +} +export const Route = createFileRoute('/')({ + loader: () => 'data', + component: () => <p>{new Parser().run()}</p>, +}) +`) + await expectValidModules(modules) + const chunk = await evaluateModule(modules['virtual component']!) + expect(chunk.component!()).toBe('<p>3|7</p>') + }) + + it('gives the split component the namespace it uses', async () => { + const { modules } = + compileRouteModules(`${head}namespace Labels { export const quiet = 'quiet' } +export const Route = createFileRoute('/')({ + component: () => <p>{Labels.quiet}</p>, +}) +`) + await expectValidModules(modules) + const chunk = await evaluateModule(modules['virtual component']!) + expect(chunk.component!()).toBe('<p>quiet</p>') + }) + + // Source: @vitejs/plugin-rsc transform tests, typescript-eslint + // type-assertion/increment/as-increment.js, + // type-assertion/increment/non-null-increment.js and type-assertion/satisfies.js + it('keeps the parentheses around asserted update operands in a split component', async () => { + const chunk = componentChunk(`${head}let count = 0 +function Page() { + ;(count as number)++ + ;(count satisfies number)++ + count!++ + ;(count as any) += 1 + return count +} +export const Route = createFileRoute('/')({ component: Page }) +`) + await expectValidModules({ chunk }) + expect((await evaluateModule(chunk)).component!()).toBe(4) + }) + // TypeScript lets a type and a value share a name; exporting both is valid. - it.each([ - { - name: 'a type alias and an export specifier', - exports: `export type Post = { id: string } + it('exports a type and a value with the same name', async () => { + const code = `${head}export type Post = { id: string } function Post(id: string): Post { return { id } } -export { Post }`, - }, - { - name: 'a type re-export and a value re-export', - exports: `export type { Post } from './post-types' -export { Post } from './post-values'`, - }, - ])( - 'exports a type and a value with the same name: $name', - async ({ exports }) => { - const code = `${head}${exports} +export { Post } export const Route = createFileRoute('/posts')({ component: () => <p>posts</p>, }) ` - const { modules } = compileRouteModules(code) - expect(exportedNames(modules.reference!)).toContain('Post') - expect(exportedNames(transformWithRouteHmrPlugin(code))).toContain('Post') - await expectValidModules(modules) - }, - ) + const { modules } = compileRouteModules(code) + expect(exportedNames(modules.reference!)).toContain('Post') + expect(exportedNames(transformWithRouteHmrPlugin(code))).toContain('Post') + await expectValidModules(modules) + }) it('keeps decorators before `export` for legacy decorator transforms', () => { // TypeScript `experimentalDecorators` and Babel `decorators-legacy` only diff --git a/packages/router-plugin/tests/code-splitter-jsx.test.ts b/packages/router-plugin/tests/code-splitter-jsx.test.ts index 0718a5da2a0..e1f67028058 100644 --- a/packages/router-plugin/tests/code-splitter-jsx.test.ts +++ b/packages/router-plugin/tests/code-splitter-jsx.test.ts @@ -3,8 +3,13 @@ import path from 'node:path' import { createServer } from 'vite' import { expect, it } from 'vitest' import { tanstackRouter } from '../src/vite' +import { + compileRouteModules, + expectValidModules, + head, +} from './regression-helpers' -it.each(['Widget', '_Widget', '$Widget'])( +it.each(['Widget', '_Widget', '$Widget', 'ÉWidget', 'éWidget'])( 'preserves the shared JSX component %s across separate loader and component chunks', async (name) => { // Keep the temporary app inside the package so runtime imports resolve. @@ -57,6 +62,7 @@ export const Route = createFileRoute('/widget')({ expect(typeof element.type).toBe('function') expect(element.type()).toBe('hello') expect(loaderModule.loader()()).toBe('hello') + expect(element.type).toBe(loaderModule.loader()) } finally { await server?.close() await rm(root, { recursive: true, force: true }) @@ -64,3 +70,53 @@ export const Route = createFileRoute('/widget')({ }, 30_000, ) + +// Regression in the Yuku migration, found by differential fuzzing: JSX text +// right after a closing fragment that starts with non-ASCII punctuation (an +// em dash, a middle dot) fails to parse with "Invalid character at start of +// identifier". Yuku parser bug, +// https://github.com/yuku-toolchain/yuku/issues/260; passes once Yuku ships +// the fix. +it('splits a component whose JSX text after a fragment starts with an em dash', async () => { + const { modules } = + compileRouteModules(`${head}export const Route = createFileRoute('/')({ + component: () => <p><>first</> — second</p>, +}) +`) + expect(modules['virtual component']).toContain('— second') + await expectValidModules(modules) +}) + +// A component option written as a call with JSX inside it (React's memo(), +// Vue's defineComponent()) moves into its chunk as written. +// Sources: differential fuzzing; dev servers of the e2e apps, where main's +// `?tsr-split=component` module failed with HTTP 500 ("Support for the +// experimental syntax 'jsx' isn't currently enabled"). +it.each([ + { + name: 'memo of an inline arrow', + component: `memo(() => <p>page</p>)`, + }, + { + name: 'memo of an inline named function', + component: `memo(function Page() {\n return <p>page</p>\n})`, + }, + { name: 'a call with a JSX argument', component: `memo(<p>page</p>)` }, + { + name: 'a Vue defineComponent with a render function', + imports: `import { createFileRoute } from '@tanstack/vue-router' +import { defineComponent } from 'vue'`, + component: `defineComponent({\n setup: () => () => <div>page</div>,\n})`, + }, +])( + 'compiles a component option written as $name into its chunk', + async ({ imports = `${head}import { memo } from 'react'`, component }) => { + const { modules } = compileRouteModules(`${imports} +export const Route = createFileRoute('/')({ component: ${component} }) +`) + expect(modules.reference).toContain('tsr-split=component') + expect(modules.reference).not.toContain('page') + expect(modules['virtual component']).toContain('page') + await expectValidModules(modules) + }, +) diff --git a/packages/router-plugin/tests/code-splitter-module-state.test.ts b/packages/router-plugin/tests/code-splitter-module-state.test.ts index e9da5473cfe..fd171b9c6b2 100644 --- a/packages/router-plugin/tests/code-splitter-module-state.test.ts +++ b/packages/router-plugin/tests/code-splitter-module-state.test.ts @@ -3,11 +3,26 @@ import { buildAndRun, compileRouteModules, declarationOf, + evaluateModule, expectValidModules, head, importSources, + loadRouteModules, + renderEntry, } from './regression-helpers' +/** Stubs `./state`, counting the calls of `init`. */ +function stateStub() { + const state = { + calls: 0, + init: <T>(value: T) => { + state.calls++ + return value + }, + } + return state +} + /** How many times `pattern` occurs across all emitted modules. */ function countAcrossModules(modules: Record<string, string>, pattern: RegExp) { return Object.values(modules).reduce( @@ -43,12 +58,213 @@ export const Route = createFileRoute('/')({ component: Page }) `const component = entry.Route.options.component return [await entry.render(component), await entry.render(component), entry.store.count]`, ) - // Known limitation on main: the chunk gets its own copy of `state` and - // `renders`, so callers of the exported functions are not checked here. + // Known limitation: the chunk gets its own copy of `state` and `renders`, + // so callers of the exported functions are not checked here. // `store.count` is 2: the chunk mutated the store the route module exports expect(result).toEqual(['<p>1/1/1</p>', '<p>2/2/2</p>', 2]) }, 30_000) + // A declaration the loader and a split component both read runs once, with + // the route module, before any chunk loads; TypeScript enums and namespaces + // compile to such declarations too. Source: mutation testing (main declared + // an enum or a namespace that both read directly in every module). + it.each([ + { + name: 'a const', + declaration: `const value = init(1) +const read = () => value`, + }, + { + name: 'an enum', + declaration: `enum Value { One = init(1) } +const read = () => Value.One`, + }, + { + name: 'a namespace', + declaration: `namespace Value { export const one = init(1) } +const read = () => Value.one`, + }, + { + name: 'an enum (direct reads)', + declaration: `enum Value { One = init(1) }`, + read: 'Value.One', + }, + { + name: 'a namespace (direct reads)', + declaration: `namespace Value { export const one = init(1) }`, + read: 'Value.one', + }, + ])( + 'initializes $name read by the loader and the split component once', + async ({ declaration, read = 'read()' }) => { + const state = stateStub() + let callsBeforeChunks: number | undefined + const { options, chunks } = await loadRouteModules( + `${head}import { init } from './state' +${declaration} +export const Route = createFileRoute('/')({ + loader: () => ${read}, + component: () => <p>{${read}}</p>, + errorComponent: () => <p>error</p>, +})`, + { './state': state }, + () => { + callsBeforeChunks = state.calls + }, + ) + expect([ + options.loader(), + chunks.component!.component(), + chunks.errorComponent!.errorComponent(), + callsBeforeChunks, + state.calls, + ]).toEqual([1, '<p>1</p>', '<p>error</p>', 1, 1]) + }, + ) + + // Regression in the Yuku migration: TypeScript erases `typeof value`, so the + // split component does not need the declaration, but the chunk declares it + // again and its initializer runs twice. + // Source: corpus-diff, forms that cast `as z.infer<typeof schema>` in the + // split component (NFCC-Com/nfcc, sedrino/farm-app). + it('initializes a declaration the split component only reads in a type once', async () => { + const state = stateStub() + const { chunks } = await loadRouteModules( + `${head}import { init } from './state' +const value = init(1) +export const Route = createFileRoute('/')({ + component: () => <p>{typeof ({} as typeof value)}</p>, +})`, + { './state': state }, + ) + expect([chunks.component!.component(), state.calls]).toEqual([ + '<p>object</p>', + 1, + ]) + }) + + // TypeScript erases type positions, so a type that reads a declaration does + // not make a second module declare it. Source: corpus-diff, route files that + // derive a type with `type FormValues = z.infer<typeof schema>`. + it('initializes a declaration a type alias and the split component read once', async () => { + const state = stateStub() + const { chunks } = await loadRouteModules( + `${head}import { init } from './state' +const value = init(1) +type Value = typeof value +export const Route = createFileRoute('/')({ + component: () => <p>{value satisfies Value}</p>, +})`, + { './state': state }, + ) + expect([chunks.component!.component(), state.calls]).toEqual([ + '<p>1</p>', + 1, + ]) + }) + + // A declaration only the split component reads moves into its chunk with the + // module-level statements that write to it. + it.each([ + { name: 'a const', declaration: 'const State = { count: 0 }' }, + { name: 'an enum', declaration: 'enum State { Zero = 0 }' }, + { + name: 'a namespace', + declaration: 'namespace State { export const zero = 0 }', + }, + ])( + 'keeps the writes to $name the split component reads', + async ({ declaration }) => { + const { chunks } = await loadRouteModules(`${head}${declaration} +Object.assign(State, { extra: 7 }) +export const Route = createFileRoute('/')({ + component: () => <p>{(State as any).extra}</p>, +})`) + expect(chunks.component!.component()).toBe('<p>7</p>') + }, + ) + + // A dotted namespace declares its leftmost name, with the body of each + // nested namespace. Source: mutation testing (main emptied the namespace + // body in the chunk, so the split component threw reading `Labels.Page`). + it('keeps a dotted namespace the split component reads, with its body and the writes to it', async () => { + const { modules } = compileRouteModules(`${head}namespace Labels.Page { + export const title = 'title' +} +;(Labels as any).suffix = '!' +export const Route = createFileRoute('/')({ + component: () => <p>{Labels.Page.title}{(Labels as any).suffix}</p>, +}) +`) + const chunk = await evaluateModule(modules['virtual component']!) + expect(chunk.component!()).toBe('<p>title!</p>') + }) + + // Source: Next.js ssg/getStaticProps/should-support-export-named-as-default-with-other-specifiers + it.each([ + { + name: 'export { store as appStore }', + exports: 'export { store as appStore }', + read: 'entry.appStore', + entry: '', + }, + { + name: 'export default store', + exports: 'export default store', + read: 'entry.default', + entry: `export { default } from './routes/index'\n`, + }, + ])( + 'shares one store exported as $name with the split component', + async ({ exports, read, entry }) => { + const result = await buildAndRun( + `${head}import { createStore } from '../store' +const store = createStore() +${exports} +export const Route = createFileRoute('/')({ + component: () => <p>{store.count}</p>, +})`, + `${read}.count = 5 +const html = await entry.render(entry.Route.options.component) +return [html, globalThis.stores]`, + { + files: { + 'store.ts': `export function createStore() { + ;(globalThis as any).stores = ((globalThis as any).stores ?? 0) + 1 + return { count: 0 } +}`, + 'entry.ts': `${renderEntry}${entry}`, + }, + }, + ) + expect(result).toEqual(['<p>5</p>', 1]) + }, + 30_000, + ) + + // Source: Next.js ssg/getStaticProps/should-not-remove-import-used-in-render + it('shares a binding the loader reads and the component renders as a JSX member tag', async () => { + const result = await buildAndRun( + `${head}import { createUi } from '../ui' +const ui = createUi() +export const Route = createFileRoute('/')({ + loader: () => typeof ui.Box, + component: () => <ui.Box />, +})`, + `const html = await entry.render(entry.Route.options.component) +return [html, globalThis.uis]`, + { + files: { + 'ui.tsx': `export function createUi() { + ;(globalThis as any).uis = ((globalThis as any).uis ?? 0) + 1 + return { Box: () => <p>box</p> } +}`, + }, + }, + ) + expect(result).toEqual(['<p>box</p>', 1]) + }, 30_000) + // Source: Qwik optimizer test.rs // should_keep_non_migrated_binding_from_shared_destructuring_declarator_with_default (+ _with_rest) it('evaluates a destructuring split between the loader and the component once', async () => { @@ -173,3 +389,78 @@ export const Route = createFileRoute('/')({ }, ) }) + +describe('split components see the top-level writes to what they read', () => { + // Source: React Router route-chunks-test.ts "reassignment with nullish + // coalescing" and "destructured reassignment" + it('renders bindings reassigned at the top level with their final values', async () => { + const result = await buildAndRun( + `${head}let plain = 'initial' +plain = 'assigned' +let compound = 1 +compound += 1 +let logical = '' +logical ||= 'logical' +let handler: (() => string) | undefined +handler ??= () => 'handler' +let swapped = () => 'original' +;[swapped] = [() => 'swapped'] +export const Route = createFileRoute('/')({ + component: () => <p>{[plain, compound, logical, handler!(), swapped()].join(' ')}</p>, +})`, + `return await entry.render(entry.Route.options.component)`, + ) + expect(result).toBe('<p>assigned 2 logical handler swapped</p>') + }, 30_000) + + // Source: Turbopack tree-shaker analyzer/assign-before-decl-var and + // analyzer/assign-before-decl-fn + it.each([ + { + name: 'a var', + setup: `value = 'assigned' +var value: string`, + render: '{value}', + html: '<p>assigned</p>', + }, + { + name: 'a function declaration', + setup: `value = () => 'reassigned' +function value() { + return 'declared' +}`, + render: '{value()}', + html: '<p>reassigned</p>', + }, + ])( + 'renders $name assigned before its hoisted declaration', + async ({ setup, render, html }) => { + const result = await buildAndRun( + `${head}${setup} +export const Route = createFileRoute('/')({ + component: () => <p>${render}</p>, +})`, + `return await entry.render(entry.Route.options.component)`, + ) + expect(result).toBe(html) + }, + 30_000, + ) + + // Source: React Router remove-exports-test.ts "function statement with + // property assignment" + it('renders a split component that has properties assigned at the top level', async () => { + const result = await buildAndRun( + `${head}function Page() { + return <p>{(Page as any).label}</p> +} +;(Page as any).label = 'page' +export const Route = createFileRoute('/')({ + loader: () => 'data', + component: Page, +})`, + `return await entry.render(entry.Route.options.component)`, + ) + expect(result).toBe('<p>page</p>') + }, 30_000) +}) diff --git a/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts index a0a0eeed679..4165a6fb43f 100644 --- a/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts +++ b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts @@ -1,14 +1,24 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import path from 'node:path' +import { createServer } from 'vite' import { describe, expect, it } from 'vitest' import { normalizePath } from '../src/core/utils' +import { tanstackRouter } from '../src/vite' import { createCodeSplitterTransforms, + createRouteHmrTransform, + declarationOf, expectValidModules, + exportedNames, + head, importSources, + importedNames, + requestedSources, routeFile, transformWithRouteHmrPlugin, } from './regression-helpers' import { getModuleErrors } from './validate-module' +import type { ViteDevServer } from 'vite' import type { Config } from '../src/core/config' const routeSource = (name: string) => ` @@ -188,13 +198,396 @@ export const Route = createFileRoute('/unshared')({ }) }) +/** + * React Refresh hot-updates a module only when every export is a component; + * an edit of another module re-runs it and its importers, or reloads the page + * in Vite bundled dev. A React route file's shared components therefore go to + * `?tsr-shared=components`, which imports the other shared bindings from + * `?tsr-shared=1` and is never imported by it. + */ +describe('the shared modules of a route file', () => { + /** Every module of `code`, compiled through the plugin as `/card`. */ + async function compile( + code: string, + target: 'react' | 'solid' = 'react', + splits: Array<string> = ['component'], + ) { + const file = routeFile('card') + const splitter = await createCodeSplitterTransforms( + { target }, + { [file]: '/card' }, + ) + const ids = { + values: `${file}?tsr-shared=1`, + components: `${file}?tsr-shared=components`, + } + const reference = splitter.reference(code, file)! + const modules: Record<string, string> & { reference: string } = { + reference, + } + for (const split of splits) { + modules[split] = splitter.virtual(code, `${file}?tsr-split=${split}`)! + } + // The components module imports from the values module + for (const name of ['components', 'values'] as const) { + if ( + Object.values(modules).some((module) => + requestedSources(module).includes(ids[name]), + ) + ) { + modules[name] = splitter.shared(code, ids[name])! + } + } + await expectValidModules(modules) + const declaredIn = (name: string) => + Object.keys(modules).filter((module) => + declarationOf(name).test(modules[module]!), + ) + return { modules, ids, declaredIn } + } + + const card = `const registry = new Map<string, number>() +function label(size: number) { + return 'size ' + size +} +export function Card() { + registry.set('card', 1) + return <b>{label(registry.size)}</b> +} +export const Route = createFileRoute('/card')({ component: () => <Card /> }) +` + + it('emits an exported component apart from the private values it reads', async () => { + const { modules, ids, declaredIn } = await compile(`${head}${card}`) + expect(exportedNames(modules.values!)).toEqual(['label', 'registry']) + expect(requestedSources(modules.values!)).toEqual([]) + expect(exportedNames(modules.components!)).toEqual(['Card']) + expect(importedNames(modules.components!, ids.values)).toEqual([ + 'label', + 'registry', + ]) + expect(importedNames(modules.component!, ids.components)).toEqual(['Card']) + expect(requestedSources(modules.component!)).not.toContain(ids.values) + expect(exportedNames(modules.reference)).toEqual(['Card', 'Route']) + expect(requestedSources(modules.reference)).toContain(ids.components) + expect(declaredIn('Card')).toEqual(['components']) + expect(declaredIn('label')).toEqual(['values']) + expect(declaredIn('registry')).toEqual(['values']) + }) + + it('imports a helper the loader and an exported component read from the values module', async () => { + const { modules, ids, declaredIn } = await compile( + `${head}function format(value: string) { + return value.toUpperCase() +} +export function Card() { + return <b>{format('card')}</b> +} +export const Route = createFileRoute('/card')({ + loader: () => format('data'), + component: () => <Card />, +}) +`, + ) + expect(exportedNames(modules.values!)).toEqual(['format']) + expect(exportedNames(modules.components!)).toEqual(['Card']) + expect(importedNames(modules.reference, ids.values)).toEqual(['format']) + expect(importedNames(modules.components!, ids.values)).toEqual(['format']) + expect(declaredIn('format')).toEqual(['values']) + expect(declaredIn('Card')).toEqual(['components']) + }) + + it('emits components that only components read without a values module', async () => { + const { modules, ids } = await compile( + `${head}function Badge() { + return <i>badge</i> +} +export const Card = () => <b><Badge /></b> +export const Route = createFileRoute('/card')({ component: () => <Card /> }) +`, + ) + expect(Object.keys(modules).sort()).toEqual([ + 'component', + 'components', + 'reference', + ]) + expect(exportedNames(modules.components!)).toEqual(['Badge', 'Card']) + expect(requestedSources(modules.components!)).not.toContain(ids.values) + }) + + it('emits a component another shared value reads in the values module with it', async () => { + const { modules, ids, declaredIn } = await compile( + `${head}function Cell() { + return <i>cell</i> +} +const columns = [{ render: Cell }] +export function Table() { + return <b>{columns.map((column) => <column.render />)}</b> +} +export const Route = createFileRoute('/card')({ component: () => <Table /> }) +`, + ) + expect(exportedNames(modules.values!)).toEqual(['Cell', 'columns']) + expect(requestedSources(modules.values!)).toEqual([]) + expect(exportedNames(modules.components!)).toEqual(['Table']) + expect(importedNames(modules.components!, ids.values)).toEqual(['columns']) + expect(declaredIn('Cell')).toEqual(['values']) + }) + + // An import cannot be assigned to + it('emits a component that assigns a shared value in the values module with it', async () => { + const { modules, declaredIn } = await compile( + `${head}let renders = 0 +export function Card() { + renders++ + return <b>{renders}</b> +} +export const Route = createFileRoute('/card')({ + loader: () => renders, + component: () => <Card />, +}) +`, + ) + expect(Object.keys(modules).sort()).toEqual([ + 'component', + 'reference', + 'values', + ]) + expect(exportedNames(modules.values!)).toEqual(['Card', 'renders']) + expect(declaredIn('Card')).toEqual(['values']) + expect(declaredIn('renders')).toEqual(['values']) + }) + + it('re-exports a default-exported component under each of its names from the components module', async () => { + const { modules, ids } = await compile( + `${head}const greeting = 'hello' +export default function Card() { + return <b>{greeting}</b> +} +export { Card as Other } +export const Route = createFileRoute('/card')({ component: () => <Card /> }) +`, + ) + expect(exportedNames(modules.components!)).toEqual(['Card']) + expect(exportedNames(modules.reference)).toEqual([ + 'Other', + 'Route', + 'default', + ]) + expect(requestedSources(modules.reference)).toContain(ids.components) + expect(importedNames(modules.component!, ids.components)).toEqual(['Card']) + }) + + it('emits a component two split options render in the components module', async () => { + const { modules, ids } = await compile( + `${head}const greeting = 'hello' +function Card() { + return <b>{greeting}</b> +} +export const Route = createFileRoute('/card')({ + component: () => <Card />, + errorComponent: () => <Card />, +}) +`, + 'react', + ['component', 'errorComponent'], + ) + expect(exportedNames(modules.values!)).toEqual(['greeting']) + expect(exportedNames(modules.components!)).toEqual(['Card']) + for (const split of ['component', 'errorComponent']) { + expect(importedNames(modules[split]!, ids.components)).toEqual(['Card']) + } + }) + + it('emits one shared module for Solid', async () => { + const { modules, ids, declaredIn } = await compile( + `import { createFileRoute } from '@tanstack/solid-router'\n${card}`, + 'solid', + ) + expect(Object.keys(modules).sort()).toEqual([ + 'component', + 'reference', + 'values', + ]) + expect(exportedNames(modules.values!)).toEqual([ + 'Card', + 'label', + 'registry', + ]) + expect(importedNames(modules.component!, ids.values)).toEqual(['Card']) + expect(declaredIn('Card')).toEqual(['values']) + }) +}) + +/** + * In dev, an edit invalidates every module of the route file: the route + * module, its `?tsr-split=` chunks and its `?tsr-shared=` modules. The browser + * requests them again in no fixed order (unbundled Vite dev sends one request + * each, in parallel), and Vite keeps each result until the next edit. These + * tests request the chunks and the shared module before the route module, and + * compare them with what a dev server started after the edit compiles. + */ +describe('a dev server compiling an edited route out of order', () => { + const route = '/routes/page.tsx' + const shared = `${route}?tsr-shared=1` + const sharedComponents = `${route}?tsr-shared=components` + + function startServer(root: string) { + return createServer({ + root, + configFile: false, + logLevel: 'silent', + // No watcher: the test invalidates the edited file itself, as the + // watcher would + server: { middlewareMode: true, watch: null }, + appType: 'custom', + plugins: [ + tanstackRouter({ + target: 'react', + autoCodeSplitting: true, + routesDirectory: './routes', + generatedRouteTree: './routeTree.gen.ts', + }), + ], + }) + } + + /** Requests `ids` in order: errors, exports and shared imports by id. */ + async function inspect(server: ViteDevServer, ids: Array<string>) { + const modules: Record< + string, + { + errors: Array<string> + exports: Array<string> + fromShared: Array<string> + fromSharedComponents: Array<string> + } + > = {} + for (const id of ids) { + const code = (await server.transformRequest(id))!.code + modules[id] = { + errors: await getModuleErrors(code), + exports: exportedNames(code), + fromShared: importedNames(code, shared), + fromSharedComponents: importedNames(code, sharedComponents), + } + } + return modules + } + + const twoCards = `${head}function CardA() { return <b>A</b> } +function CardB() { return <i>B</i> } +export const Route = createFileRoute('/page')({ + component: () => <p><CardA /><CardB /></p>, + errorComponent: () => <p><CardA /><CardB /></p>, +}) +` + const oneCard = `${head}function CardA() { return <b>A</b> } +export const Route = createFileRoute('/page')({ + component: () => <p><CardA /></p>, + errorComponent: () => <p><CardA /></p>, +}) +` + + // Bug, already on main and much easier to hit since the Yuku migration: the + // route module's transform computes the bindings that go to the shared + // module and stores them by file, and the shared and split transforms read + // that stored set instead of deriving it from the code they compile. + // Requested before the route module after an edit, they compile the new + // source with the previous set: a removed binding is still exported + // ("Export 'suffix' is not defined", and the page stays broken until the + // next edit or a restart), an added one is copied into each chunk instead of + // shared. On main this needs a binding that two split options share (the + // last two rows fail on main too). The migration also moves an exported + // component's private dependencies to the shared module, so removing a + // helper of an exported component that a split component renders hits it + // too (the first row, which passes on main). Fails until the shared and + // split transforms compute the set from the code they receive. + it.each([ + [ + 'removing a helper of an exported component a split component renders', + { + before: `${head}const prefix = 'a' +const suffix = 'b' +export function Card() { return <b>{prefix}{suffix}</b> } +export const Route = createFileRoute('/page')({ component: () => <Card /> }) +`, + after: `${head}const prefix = 'a' +export function Card() { return <b>{prefix}</b> } +export const Route = createFileRoute('/page')({ component: () => <Card /> }) +`, + splits: ['component'], + }, + ], + [ + 'removing a component two split options render', + { + before: twoCards, + after: oneCard, + splits: ['component', 'errorComponent'], + }, + ], + [ + 'adding a component two split options render', + { + before: oneCard, + after: twoCards, + splits: ['component', 'errorComponent'], + }, + ], + ])( + 'compiles the shared module and chunks requested first from the edited source after %s', + async (_, { before, after, splits }) => { + const ids = [ + route, + shared, + sharedComponents, + ...splits.map((split) => `${route}?tsr-split=${split}`), + ] + // Keep the temporary app inside the package so runtime imports resolve. + const root = await mkdtemp(path.join(__dirname, '.dev-edit-order-')) + const file = normalizePath(path.join(root, 'routes/page.tsx')) + let server: ViteDevServer | undefined + try { + await mkdir(path.join(root, 'routes')) + await writeFile( + path.join(root, 'routes/__root.tsx'), + `import { createRootRoute } from '@tanstack/react-router' +export const Route = createRootRoute({})`, + ) + await writeFile(file, before) + server = await startServer(root) + // The page loads: the route module first, then the modules it imports + await inspect(server, ids) + + await writeFile(file, after) + server.environments.client.moduleGraph.onFileChange(file) + const outOfOrder = await inspect(server, [...ids.slice(1), route]) + await server.close() + + server = await startServer(root) + const expected = await inspect(server, ids) + for (const module of Object.values(expected)) { + expect(module.errors).toEqual([]) + } + expect(outOfOrder).toEqual(expected) + } finally { + await server?.close() + await rm(root, { recursive: true, force: true }) + } + }, + 30_000, + ) +}) + describe('route HMR plugin without code splitting', () => { function transformWith( options: Partial<Config> & { target: 'solid' | 'vue' }, + imports = '', ) { const code = ` import { createFileRoute } from '@tanstack/${options.target}-router' -export const Route = createFileRoute('/hmr')({ +${imports}export const Route = createFileRoute('/hmr')({ component: () => <p>hmr</p>, }) ` @@ -216,6 +609,22 @@ export const Route = createFileRoute('/hmr')({ }, ) + // Regression in the Yuku migration: the plugin reprints Solid and Vue + // routes and prints `import type {} from 'zod'` as `import type 'zod'`, a + // syntax error. Yuku codegen bug, + // https://github.com/yuku-toolchain/yuku/issues/258; passes once Yuku ships + // the fix. + // Source: corpus-diff, tinloof/tanstack-wsr src/types.ts (an empty type + // import fixes TypeScript's "inferred type cannot be named" error). + it('keeps routes with an empty type-only import valid', async () => { + const output = transformWith( + { target: 'solid' }, + "import type {} from 'zod'\n", + ) + expect(output).toContain('import.meta.hot') + expect(await getModuleErrors(output)).toEqual([]) + }) + it('uses webpack HMR with the generated route id for non-React routes', async () => { const output = transformWith({ target: 'solid', @@ -228,3 +637,168 @@ export const Route = createFileRoute('/hmr')({ expect(await getModuleErrors(output)).toEqual([]) }) }) + +describe('Vite HMR boundaries', () => { + // Vite marks a module self-accepting only when its code contains a literal + // `import.meta.hot.accept(`. A route module that is not an HMR boundary of + // its own propagates an edit to the route tree and the app entry, and the + // page reloads. React Refresh makes React route modules boundaries; Solid + // Refresh does so only for modules that declare components, and Vue JSX only + // for exported components, so Solid and Vue rely on the route HMR code. + it.each([ + { target: 'solid', autoCodeSplitting: true }, + { target: 'solid', autoCodeSplitting: false }, + { target: 'vue', autoCodeSplitting: true }, + { target: 'vue', autoCodeSplitting: false }, + ] as const)( + 'a $target route module accepts its own updates (autoCodeSplitting: $autoCodeSplitting)', + async ({ target, autoCodeSplitting }) => { + // Keep the temporary app inside the package so runtime imports resolve. + const root = await mkdtemp(path.join(__dirname, '.hmr-boundary-')) + const routerPackage = `@tanstack/${target}-router` + let server: ViteDevServer | undefined + try { + await mkdir(path.join(root, 'routes')) + await writeFile( + path.join(root, 'routes/__root.tsx'), + `import { createRootRoute } from '${routerPackage}' +export const Route = createRootRoute({})`, + ) + await writeFile( + path.join(root, 'routes/index.tsx'), + `import { createFileRoute } from '${routerPackage}' +export const Route = createFileRoute('/')({ + loader: () => 'data', + component: Page, +}) +function Page() { + return 'page' +}`, + ) + server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + server: { watch: null }, + plugins: [ + { + // The framework packages are not dependencies of this package. + name: 'stub-router-package', + enforce: 'pre', + resolveId: (id) => (id === routerPackage ? `\0${id}` : null), + load: (id) => + id === `\0${routerPackage}` + ? 'export const createRootRoute = (o) => o, createFileRoute = () => (o) => o, lazyRouteComponent = () => null' + : null, + }, + tanstackRouter({ + target, + routesDirectory: './routes', + generatedRouteTree: './routeTree.gen.ts', + autoCodeSplitting, + }), + ], + }) + const client = server.environments.client + await client.transformRequest('/routes/index.tsx') + const module = + await client.moduleGraph.getModuleByUrl('/routes/index.tsx') + expect(module?.isSelfAccepting).toBe(true) + } finally { + await server?.close() + await rm(root, { recursive: true, force: true }) + } + }, + 30_000, + ) +}) + +describe('a route created inside a function', () => { + const routeInHelper = ` +import { createFileRoute } from '@tanstack/react-router' +function makeRoute() { + return createFileRoute('/made')({ component: () => <p>made</p> }) +} +export const Route = makeRoute() +` + const routeInIife = ` +import { createFileRoute } from '@tanstack/react-router' +export const Route = (() => + createFileRoute('/made')({ component: () => <p>made</p> }))() +` + const moduleRoute = ` +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/made')({ component: () => <p>made</p> }) +` + const moduleRouteWithCodeRouteInHelper = ` +import { createFileRoute, createRoute } from '@tanstack/react-router' +export function makeChild(label: string) { + return createRoute({ + getParentRoute: () => Route, + path: label, + component: () => <p>{label}</p>, + }) +} +export const Route = createFileRoute('/made')({ component: () => <p>made</p> }) +` + const warning = (file: string) => + expect.stringContaining( + `[tanstack-router] The route in "${file}" is created inside a function.`, + ) + + // Each plugin path: a transform of `file` (registered as the route `/made`) + // and the spy on the warnings it reports to the bundler. + const pluginPaths = { + 'code splitter': async (files: Array<string>) => { + const splitter = await createCodeSplitterTransforms( + {}, + Object.fromEntries(files.map((file) => [file, '/made'])), + ) + return { transform: splitter.reference, warn: splitter.warn } + }, + 'route HMR plugin': async (files: Array<string>) => + createRouteHmrTransform( + { target: 'react' }, + Object.fromEntries(files.map((file) => [file, '/made'])), + ), + } + + describe.each(Object.entries(pluginPaths))('through the %s', (_, create) => { + it.each([ + ['a helper function', routeInHelper], + ['an IIFE', routeInIife], + ])('is reported when built in %s', async (_, code) => { + const file = routeFile('made') + const { transform, warn } = await create([file]) + transform(code, file) + expect(warn).toHaveBeenCalledExactlyOnceWith(warning(file)) + expect(warn.mock.calls[0]![0]).toContain( + "export const Route = createFileRoute('/path')({ ... })", + ) + }) + + it.each([ + ['a module-scope route', moduleRoute], + [ + 'a module-scope route next to a code route a function creates', + moduleRouteWithCodeRouteInHelper, + ], + ])('is not reported for %s', async (_, code) => { + const file = routeFile('made') + const { transform, warn } = await create([file]) + transform(code, file) + expect(warn).not.toHaveBeenCalled() + }) + + it('is reported once per file across repeated transforms', async () => { + const files = [routeFile('made'), routeFile('other')] + const { transform, warn } = await create(files) + for (let edit = 0; edit < 3; edit++) { + for (const file of files) { + transform(`${routeInHelper}// edit ${edit}\n`, file) + } + } + expect(warn.mock.calls).toEqual(files.map((file) => [warning(file)])) + }) + }) +}) diff --git a/packages/router-plugin/tests/code-splitter-pragmas.test.ts b/packages/router-plugin/tests/code-splitter-pragmas.test.ts index 265a787bb06..ddce68c0fcc 100644 --- a/packages/router-plugin/tests/code-splitter-pragmas.test.ts +++ b/packages/router-plugin/tests/code-splitter-pragmas.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from 'vitest' import { compileRouteModules, componentChunk, + evaluateModule, head, + importedNames, + loadRouteModules, transformWithRouteHmrPlugin, } from './regression-helpers' @@ -79,6 +82,132 @@ export const Route = createFileRoute('/pragma')({ }) }) +// With the classic runtime, the JSX of each module compiles to calls of the +// factory its pragmas name, so the factory import must stay wherever JSX does. +describe('split modules keep the classic JSX factories their JSX compiles to', () => { + /** Classic JSX factory rendering intrinsic elements to text. */ + const render = (type: unknown, _props: unknown, ...children: Array<any>) => { + const text = children.flat(Infinity).join('') + if (typeof type === 'function') { + return type() + } + return type === Fragment ? text : `<${String(type)}>${text}</${type}>` + } + const Fragment = Symbol('Fragment') + const stubs = { '@emotion/react': { jsx: render, Frag: Fragment } } + const classicHead = `/** @jsxRuntime classic */ +/** @jsx jsx */ +${head}import { jsx } from '@emotion/react' +` + + it('in the route module, when the code calling the factory is split', async () => { + const { options } = await loadRouteModules( + `${classicHead}function Page() { + return jsx('div', null, 'page') +} +export const Route = createFileRoute('/')({ + component: Page, + pendingComponent: () => <p>pending</p>, +})`, + stubs, + ) + expect(options.pendingComponent()).toBe('<p>pending</p>') + }) + + it('in a split chunk, when only other chunks call the factory', async () => { + const { chunks } = await loadRouteModules( + `${classicHead}export const Route = createFileRoute('/')({ + component: () => <div>page</div>, + errorComponent: () => jsx('b', null, 'error'), +})`, + stubs, + ) + expect(chunks.component!.component()).toBe('<div>page</div>') + expect(chunks.errorComponent!.errorComponent()).toBe('<b>error</b>') + }) + + // Source: mutation testing of the dead code removal (main dropped the + // factory import, so the split component threw "jsx is not defined"). + it('in a split chunk, imported from the route module that exports it', async () => { + /** Emotion's `jsx`, rendering intrinsic elements as `<emotion:tag>`. */ + const emotionJsx = ( + type: string, + _props: unknown, + ...children: Array<string> + ) => `<emotion:${type}>${children.join('')}</emotion:${type}>` + const { modules } = compileRouteModules(`/** @jsxRuntime classic */ +/** @jsx jsx */ +${head}import { jsx as emotion } from '@emotion/react' +export const jsx = emotion +export const Route = createFileRoute('/')({ + component: () => <div>page</div>, +}) +`) + const chunk = await evaluateModule(modules['virtual component']!, { + 'route.tsx': { jsx: emotionJsx }, + }) + expect(chunk.component!()).toBe('<emotion:div>page</emotion:div>') + }) + + it('with the @jsxFrag factory of the fragments that remain', async () => { + const { chunks } = await loadRouteModules( + `/** @jsx jsx */ +/** @jsxFrag Frag */ +${head}import { jsx, Frag } from '@emotion/react' +export const Route = createFileRoute('/')({ + component: () => <>page</>, + errorComponent: () => jsx(Frag, null, 'error'), +})`, + stubs, + ) + expect(chunks.component!.component()).toBe('page') + expect(chunks.errorComponent!.errorComponent()).toBe('error') + }) +}) + +// Dead code removal counts JSX as a use of the factories it compiles to, and +// only of those: a module keeps a classic factory import only while it has +// JSX (fragments, for the @jsxFrag factory) compiling to it. +describe('split modules import only the classic JSX factories their JSX compiles to', () => { + it.each([ + { + name: 'the @jsx factory in the route module, whose JSX all moved', + pragmas: '/** @jsx h */', + specifiers: 'h', + options: `loader: () => 1, + component: () => <div />,`, + module: 'reference', + imported: [], + }, + { + name: 'the @jsx factory, under a pragma with extra spaces', + pragmas: '/** @jsx h */', + specifiers: 'h', + options: `loader: () => 1, + component: () => <div />,`, + module: 'reference', + imported: [], + }, + { + name: 'the @jsxFrag factory in a chunk without fragments', + pragmas: '/** @jsx h */\n/** @jsxFrag Fragment */', + specifiers: 'h, Fragment', + options: `loader: () => <>{1}</>, + component: () => <div />,`, + module: 'virtual component', + imported: ['h'], + }, + ])('drops $name', ({ pragmas, specifiers, options, module, imported }) => { + const { modules } = compileRouteModules(`${pragmas} +${head}import { ${specifiers} } from 'preact' +export const Route = createFileRoute('/')({ + ${options} +}) +`) + expect(importedNames(modules[module]!, 'preact')).toEqual(imported) + }) +}) + // React Refresh and solid-refresh read these comments per module, so every // module holding route code must keep them. describe('split modules keep HMR pragma comments', () => { @@ -122,3 +251,15 @@ export const Route = createFileRoute('/')({ component: Page }) expect(modules['virtual component']).toContain(pragma) }) }) + +// Minifiers keep legal comments in the modules that carry them. +it('split modules keep the leading legal comment of the route file', () => { + const legal = '/*! Example Corp. | MIT License */' + const { modules } = compileRouteModules( + `${legal}\n${head}export const Route = createFileRoute('/')({ + component: () => <p>page</p>, +})`, + ) + expect(modules.reference).toContain(legal) + expect(modules['virtual component']).toContain(legal) +}) diff --git a/packages/router-plugin/tests/code-splitter-route-options.test.ts b/packages/router-plugin/tests/code-splitter-route-options.test.ts index 84d24392d15..8b7f57889f5 100644 --- a/packages/router-plugin/tests/code-splitter-route-options.test.ts +++ b/packages/router-plugin/tests/code-splitter-route-options.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from 'vitest' import { + buildAndRun, compileRouteModules, declarationOf, + evaluateModule, expectValidModules, + exportedNames, head, importSources, importedNames, + loadRouteModules, } from './regression-helpers' describe('route options', () => { @@ -32,6 +36,114 @@ export const Route = createFileRoute('/posts')(options) await expectValidModules(modules) }) + // Sources: corpus-diff, openalgon-alt/agri-prep-path + // src/routes/admin/access.tsx (`createFileRoute('/admin/access')({ … } as any)`); + // differential fuzzing (casts around the route factory call). + it.each([ + { + name: 'options object wrapped in `as any`', + route: (options: string) => `createFileRoute('/')(${options} as any)`, + }, + { + name: 'options object wrapped in `satisfies object`', + route: (options: string) => + `createFileRoute('/')(${options} satisfies object)`, + }, + { + name: 'route factory call wrapped in `as any`', + route: (options: string) => `(createFileRoute('/') as any)(${options})`, + }, + { + name: 'route factory call wrapped in a non-null assertion', + route: (options: string) => `(createFileRoute('/'))!(${options})`, + }, + ])('are split when the $name', async ({ route }) => { + const { modules } = + compileRouteModules(`${head}export const Route = ${route(`{ + ssr: false, + component: Page, +}`)} +function Page() { + return <p>page</p> +} +`) + expect(modules.reference).toContain('tsr-split=component') + expect(modules.reference).not.toMatch(declarationOf('Page')) + expect(modules['virtual component']).toMatch(declarationOf('Page')) + await expectValidModules(modules) + }) + + // Source: React Router route-chunks-test.ts "chunkable" detection of export values + it.each([ + { + name: 'a satisfies expression', + code: `import type { FC } from 'react' +function Page() { + return <div>page</div> +} +export const Route = createFileRoute('/')({ component: Page satisfies FC })`, + }, + { + name: 'a non-null assertion', + code: `const Page: (() => any) | undefined = () => <div>page</div> +export const Route = createFileRoute('/')({ component: Page! })`, + }, + ])('are split when wrapped in $name', async ({ code }) => { + const { modules } = compileRouteModules(`${head}${code}\n`) + expect(modules.reference).toContain('tsr-split=component') + expect(modules.reference).not.toMatch(declarationOf('Page')) + expect(modules['virtual component']).toMatch(declarationOf('Page')) + await expectValidModules(modules) + }) + + // Source: React Router route-chunks-test.ts "isolated exported destructured + // array variable declarations sharing an export statement" and "exported + // destructured array variable declarations sharing an assignment" + it.each([ + { + name: 'its own array destructuring', + code: `import { chunkMessage, mainMessage } from './messages' +const [Page] = [() => <div>{chunkMessage}</div>], + [main] = [mainMessage] +export const Route = createFileRoute('/')({ + loader: () => main, + component: Page, +})`, + }, + { + name: 'an array destructuring shared with the errorComponent', + code: `import { createPair } from './factory' +const [Page, ErrorView] = createPair() +export const Route = createFileRoute('/')({ + component: Page, + errorComponent: ErrorView, +})`, + }, + ])('are split when declared by $name', async ({ code }) => { + const { modules } = compileRouteModules(`${head}${code}\n`) + expect(modules.reference).toContain('tsr-split=component') + expect(modules.reference).not.toMatch(/\bPage\b/) + expect(exportedNames(modules['virtual component']!)).toEqual(['component']) + await expectValidModules(modules) + }) + + // Source: React Router route-chunks-test.ts "exported destructured array + // variable declarations sharing an assignment" + it('keep the modules valid for a component declared by an array destructuring shared with the loader', async () => { + const { modules } = + compileRouteModules(`${head}import { chunkMessage, mainMessage } from './messages' +const [Page, main] = [() => <div>{chunkMessage}</div>, mainMessage] +export const Route = createFileRoute('/')({ + loader: () => main, + component: Page, +}) +`) + // Known limitation: a destructuring is initialized in one module, so the + // whole declaration moves to the shared module that the route module + // imports for `main`, and the component is not lazy-loaded. + await expectValidModules(modules) + }) + it('that repeat a split key are split like the runtime object: the last one wins', async () => { const { modules } = compileRouteModules(`${head}export const Route = createFileRoute('/')({ @@ -43,6 +155,151 @@ export const Route = createFileRoute('/posts')(options) expect(modules['virtual component']).not.toContain('first-marker') await expectValidModules(modules) }) + + // Regression in the Yuku migration, found by differential fuzzing: a `var` + // declared in a block at module level is a module-level binding, so the + // route it creates is no route factory, but it is left unsplit with a + // warning that it is created inside a function. + it('are split when the route is declared with var in a module-level block', async () => { + const { modules } = compileRouteModules(`${head}if (true) { + var Route = createFileRoute('/')({ + component: () => <div>split-marker</div>, + }) +} +export { Route } +`) + expect(modules.reference).not.toContain('split-marker') + expect(modules['virtual component']).toContain('split-marker') + await expectValidModules(modules) + }) +}) + +describe('route options written as methods or accessors', () => { + // Only property values are split; splitting method shorthands is proposed in + // https://github.com/TanStack/router/pull/8459. + it('stay in the route module and keep working, while arrow options are split', async () => { + const { modules, options, chunks } = + await loadRouteModules(`${head}const state = { count: 0 } +export const Route = createFileRoute('/')({ + loader() { + return state + }, + get component() { + return () => <div>{state.count}</div> + }, + set component(_value) { + state.count++ + }, + errorComponent: () => <div>{state.count}</div>, +})`) + expect(Object.keys(chunks)).toEqual(['errorComponent']) + options.component = null + expect([ + options.loader().count, + options.component(), + chunks.errorComponent!.errorComponent(), + ]).toEqual([1, '<div>1</div>', '<div>1</div>']) + await expectValidModules(modules) + }) +}) + +describe('split loaders', () => { + /** Loads `/` with a real router and returns each match's loader data. */ + const routerEntry = `import { createMemoryHistory, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +export async function load() { + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + await router.load() + return router.state.matches.map((match) => match.loaderData ?? null) +}` + + function loadWithSplitLoader(loader: string) { + return buildAndRun( + `${head}export const Route = createFileRoute('/')({ + loader: ${loader}, + component: () => <p>index</p>, +})`, + `return await entry.load()`, + { + files: { 'entry.ts': routerEntry }, + groupings: [['loader'], ['component']], + }, + ) + } + + it('load route data', async () => { + expect(await loadWithSplitLoader(`() => 'loaded'`)).toEqual([ + null, + 'loaded', + ]) + }, 30_000) + + it('load route data in object form', async () => { + expect( + await loadWithSplitLoader( + `{ handler: () => 'loaded', staleReloadMode: 'blocking' }`, + ), + ).toEqual([null, 'loaded']) + }, 30_000) + + // The router reads the other keys of a loader object (`staleReloadMode`) + // from the route options when the route loads. + it('move only the handler of a loader in object form into their chunk', async () => { + const { modules, sharedBindings } = compileRouteModules( + `${head}import { fetchPosts } from './api' +import { init } from './state' +const mode = init('blocking') +export const Route = createFileRoute('/')({ + loader: { + handler: () => [mode, fetchPosts()], + staleReloadMode: mode, + }, + component: () => <p>index</p>, +}) +`, + { groupings: [['loader'], ['component']] }, + ) + // `mode` is read by the handler and by the route module: initialized once + expect(sharedBindings).toEqual(['mode']) + expect(importSources(modules.reference!)).not.toContain('./api') + expect(modules.reference).toContain('staleReloadMode') + const chunk = await evaluateModule(modules['virtual loader']!, { + './api': { fetchPosts: () => 'posts' }, + 'route.tsx?tsr-shared=1': { mode: 'blocking' }, + }) + expect(chunk.loader!()).toEqual(['blocking', 'posts']) + await expectValidModules(modules) + }) + + it.each([ + { + name: 'a method', + loader: `{ handler() { return fetchPosts() } }`, + }, + { + name: 'a spread after the handler', + loader: `{ handler: () => fetchPosts(), ...defaults }`, + }, + ])( + 'keep a loader object whose handler is $name in the route module', + async ({ loader }) => { + const { modules } = compileRouteModules( + `${head}import { fetchPosts, defaults } from './api' +export const Route = createFileRoute('/')({ + loader: ${loader}, + component: () => <p>index</p>, +}) +`, + { groupings: [['loader'], ['component']] }, + ) + expect(importedNames(modules.reference!, './api')).toContain('fetchPosts') + expect(modules.reference).not.toContain('tsr-split=loader') + await expectValidModules(modules) + }, + ) }) describe('split options that read the Route singleton', () => { @@ -77,10 +334,52 @@ export const Route = createFileRoute('/posts')({ expect(sharedBindings).toEqual(['format']) expect(modules.reference).toMatch(declarationOf('fetchPosts')) expect(modules.reference).toMatch(declarationOf('prefix')) + // The loader's helpers stay out of the component chunk + expect(modules['virtual component']).not.toMatch( + declarationOf('fetchPosts'), + ) + expect(modules['virtual component']).not.toMatch(declarationOf('prefix')) await expectValidModules(modules) }, ) + // Source: corpus-diff, route files that declare `const Route` and export it + // at the end with `export { Route }` (rahulroy0322/RacketHub, + // mchar7/nohiki-monorepo). + it('read the route exported through export { Route } from the route module', async () => { + const { modules } = + compileRouteModules(`${head}const Route = createFileRoute('/')({ + component: Page, +}) +function Page() { + return <p>{Route.id}</p> +} +export { Route } +`) + const component = modules['virtual component']! + expect(importedNames(component, 'route.tsx')).toEqual(['Route']) + expect(component).not.toContain('createFileRoute(') + await expectValidModules(modules) + }) + + // Bindings that read `Route` are never shared, so an unsplit option using + // the split component needs its own declaration in the route module. + // Source: corpus-diff, bex-co/bex src/routes/databases.$databaseId.tsx + // (`component: Page, pendingComponent: Page`). + it('keep a component read by a split and an unsplit option declared in the route module', async () => { + const { modules, options } = + await loadRouteModules(`${head}export const Route = createFileRoute('/')({ + component: Page, + pendingComponent: Page, +}) +function Page() { + return <p>{typeof Route}</p> +} +`) + expect(options.pendingComponent()).toBe('<p>object</p>') + await expectValidModules(modules) + }) + it('do not turn a reassigned loader-only binding into an import', () => { const { modules, sharedBindings } = compileRouteModules(`${head}let label = 'initial' diff --git a/packages/router-plugin/tests/code-splitter-runtime.test.ts b/packages/router-plugin/tests/code-splitter-runtime.test.ts index 0eb9b52ea54..54d5ddb6fe4 100644 --- a/packages/router-plugin/tests/code-splitter-runtime.test.ts +++ b/packages/router-plugin/tests/code-splitter-runtime.test.ts @@ -26,25 +26,21 @@ it.each(groupings)( 30_000, ) -// Shared extraction currently duplicates named default function declarations and -// drops TypeScript enum/namespace dependencies. Keep their intended contracts. -it.fails.each(['named-default', 'enum', 'namespace'] as const)( - 'preserves executable route contracts with shared %s declarations', - async (syntax) => { - await assertRuntimeContracts({ - groupings: [['component'], ['loader']], - syntax, - }) - }, - 30_000, -) +// A default-exported function declaration that the loader and the component +// both reach is shared like any other declaration, and declared once. +it('preserves executable route contracts with a shared named default function', async () => { + await assertRuntimeContracts({ + groupings: [['component'], ['loader']], + syntax: 'named-default', + }) +}, 30_000) async function assertRuntimeContracts({ groupings, syntax, }: { groupings: CodeSplitGroupings - syntax?: 'named-default' | 'enum' | 'namespace' + syntax?: 'named-default' }) { // Keep the temporary app inside the package so real runtime imports resolve. const root = await mkdtemp(path.join(__dirname, '.runtime-contract-')) @@ -58,16 +54,6 @@ async function assertRuntimeContracts({ 'export default function createSeed()', ) .replace('export default createSeed', '') - } else if (syntax === 'enum') { - source = source.replace( - 'const Count = { Initial: 0 }', - 'enum Count { Initial = 0 }', - ) - } else if (syntax === 'namespace') { - source = source.replace( - "const Labels = { component: 'count' }", - "namespace Labels { export const component = 'count' }", - ) } await writeFile(path.join(root, 'routes/shared-runtime.tsx'), source) await writeFile( diff --git a/packages/router-plugin/tests/code-splitter-scope.test.ts b/packages/router-plugin/tests/code-splitter-scope.test.ts index 95cb80a9f97..47f8af1fa81 100644 --- a/packages/router-plugin/tests/code-splitter-scope.test.ts +++ b/packages/router-plugin/tests/code-splitter-scope.test.ts @@ -230,6 +230,79 @@ export const Route = createFileRoute('/')({ }) }) +describe('names the compilers add', () => { + it('avoid the names a route file declares, with HMR and every option split', async () => { + const declared = [ + 'lazyRouteComponent', + 'lazyFn', + '$$splitLoaderImporter', + '$$splitComponentImporter', + '$$splitPendingComponentImporter', + 'SplitLoader', + 'SplitComponent', + 'SplitPendingComponent', + 'TSRSplitComponent', + 'TSRSplitPendingComponent', + 'TSRComponent', + 'TSRErrorComponent', + 'TSRFastRefreshAnchor', + ] + const { modules } = compileRouteModules( + `${head}${declared + .map((name) => `export const ${name} = ${JSON.stringify(name)}`) + .join('\n')} +function page() { + return <p>{[lazyRouteComponent, $$splitComponentImporter, SplitComponent, TSRSplitComponent].join()}</p> +} +function pending() { + return <p>{[$$splitPendingComponentImporter, SplitPendingComponent, TSRSplitPendingComponent].join()}</p> +} +export const Route = createFileRoute('/')({ + loader: () => [lazyFn, $$splitLoaderImporter, SplitLoader], + component: page, + pendingComponent: pending, + errorComponent: () => <p>{[TSRComponent, TSRErrorComponent, TSRFastRefreshAnchor].join()}</p>, +}) +`, + { + hmr: true, + groupings: [['loader'], ['component'], ['pendingComponent']], + }, + ) + expect(modules.reference).toMatch( + declarationOf('\\$\\$splitComponentImporter2'), + ) + expect(modules.reference).toMatch(declarationOf('TSRSplitComponent2')) + expect(modules.reference).toMatch(declarationOf('TSRErrorComponent2')) + expect(importedNames(modules.reference!, '@tanstack/react-router')).toEqual( + ['createFileRoute', 'lazyFn', 'lazyRouteComponent'], + ) + expect(modules['virtual component']).toMatch( + declarationOf('SplitComponent2'), + ) + await expectValidModules(modules) + }) + + it('avoid the globals a route file reads', async () => { + const { modules } = compileRouteModules( + `${head}export const Route = createFileRoute('/')({ + component: () => <p>{[lazyRouteComponent, $$splitComponentImporter, SplitComponent].join()}</p>, +}) +`, + ) + expect(modules.reference).toMatch( + /\blazyRouteComponent as lazyRouteComponent2\b/, + ) + expect(modules.reference).toMatch( + declarationOf('\\$\\$splitComponentImporter2'), + ) + expect(modules['virtual component']).toMatch( + declarationOf('SplitComponent2'), + ) + await expectValidModules(modules) + }) +}) + describe('imports read in nested positions follow the code that reads them', () => { // Source: React Router route-chunks-test.ts "default argument", "destructured // argument with default value", "for...of with destructuring and default @@ -336,4 +409,76 @@ export const Route = createFileRoute('/compare')({ ) expect(sharedBindings).toEqual(['a', 'b', 'c', 'r']) }) + + // Regression in the Yuku migration: the chunk prints the comment between + // the operand and `++`, and the line break ends the statement there. + // Yuku codegen bug, https://github.com/yuku-toolchain/yuku/issues/255; + // passes once Yuku ships the fix. + // Source: @babel/generator edgecase/postfix-update-comments. + it('keeps a postfix update whose operand is followed by a comment', async () => { + const chunk = compileRouteModules( + pageRoute(`let count = 0 +function Page() { + ( + count // the render count + )++ + return count +}`), + { filename: 'route.js' }, + ).modules['virtual component']! + await expectValidModules({ chunk }) + expect((await evaluateModule(chunk)).component!()).toBe(1) + }) + + // Regression in the Yuku migration: the parentheses must keep `c in d` as + // the alternate of the conditional, but the chunk regroups it as + // `(a ? b : c) in d`. Yuku parser bug, + // https://github.com/yuku-toolchain/yuku/issues/256; passes once Yuku ships + // the fix. + // Source: Oxc codegen esbuild.rs (ported from esbuild's printer tests). + it('keeps the operands of `in` inside a parenthesized for-loop head', async () => { + const chunk = compileRouteModules( + pageRoute(`function Page() { + const a = true, b = 'b', c = 'x', d = { x: 1 } + let result = 'unset' + for (result = (a ? b : c in d).toString(); ; ) break + return result +}`), + { filename: 'route.js' }, + ).modules['virtual component']! + await expectValidModules({ chunk }) + expect((await evaluateModule(chunk)).component!()).toBe('b') + }) + + // Regression in the Yuku migration: a computed key belongs to the enclosing + // scope, here a derived constructor, so `super()` is valid in it, but + // compiling the route throws "'super()' is only valid in a constructor of a + // derived class". Yuku parser bug, + // https://github.com/yuku-toolchain/yuku/issues/259; passes once Yuku ships + // the fix. + // Shape adapted from @babel/plugin-proposal-decorators + // 2023-11-misc/initField-existing-derived-constructor. + it('keeps a super() call in the computed key of a class field in a derived constructor', async () => { + const chunk = compileRouteModules( + pageRoute(`class Base { + toString() { + return 'key' + } +} +class Derived extends Base { + constructor() { + class Field { + [super()] = 'field' + } + this.field = new Field() + } +} +function Page() { + return new Derived().field.key +}`), + { filename: 'route.js' }, + ).modules['virtual component']! + await expectValidModules({ chunk }) + expect((await evaluateModule(chunk)).component!()).toBe('field') + }) }) diff --git a/packages/router-plugin/tests/code-splitter.test.ts b/packages/router-plugin/tests/code-splitter.test.ts index 96807e4d427..afdacc0ee98 100644 --- a/packages/router-plugin/tests/code-splitter.test.ts +++ b/packages/router-plugin/tests/code-splitter.test.ts @@ -1,24 +1,18 @@ +import { existsSync } from 'node:fs' import { readFile, readdir } from 'node:fs/promises' import path from 'node:path' import { describe, expect, it } from 'vitest' -import { parseAst } from '@tanstack/router-utils' +import { analyzeModule, createIdentifier } from '@tanstack/router-utils' +import { BindingFlags } from 'yuku-analyzer' import { - buildDeclarationMap, - buildDependencyGraph, - collectIdentifiersFromNode, - collectLocalBindingsFromStatement, - collectModuleLevelRefsFromNode, + analyzeRouteModule, compileCodeSplitReferenceRoute, compileCodeSplitSharedRoute, compileCodeSplitVirtualRoute, computeSharedBindings, - expandDestructuredDeclarations, - expandSharedDestructuredDeclarators, - expandTransitively, - removeBindingsDependingOnRoute, + partitionSharedBindings, } from '../src/core/code-splitter/compilers' -import { createIdentifier } from '@tanstack/router-utils' import { defaultCodeSplitGroupings } from '../src/core/constants' import { getFrameworkHmrCompilerPlugins } from '../src/core/code-splitter/plugins/framework-plugins' import { frameworks } from './constants' @@ -58,6 +52,79 @@ const testGroups: Array<{ name: string; groupings: CodeSplitGroupings }> = [ }, ] +describe('one route analysis shared by every output', () => { + const code = `'use client'; +import { createFileRoute } from '@tanstack/react-router' +const state = {count: 0} +export const Route = createFileRoute('/')({loader: () => state, component: () => state.count})` + + function compileOutputs() { + const analysis = analyzeRouteModule({ code, filename: 'route.tsx' }) + const sharedBindings = computeSharedBindings({ + code, + analysis, + codeSplitGroupings: defaultCodeSplitGroupings, + }) + return { + reference: () => + compileCodeSplitReferenceRoute({ + code, + analysis, + sharedBindings, + filename: 'route.tsx', + id: 'route.tsx', + targetFramework: 'react', + addHmr: false, + codeSplitGroupings: defaultCodeSplitGroupings, + })!.code, + component: () => + compileCodeSplitVirtualRoute({ + code, + analysis, + sharedBindings, + filename: 'route.tsx?tsr-split=component', + splitTargets: ['component'], + }).code, + shared: () => + compileCodeSplitSharedRoute({ + code, + analysis, + sharedBindings, + filename: 'route.tsx?tsr-shared=1', + }).code, + } + } + + it('compiles the same outputs whatever order they are generated in', () => { + const first = compileOutputs() + const componentFirst = first.component() + const referenceSecond = first.reference() + const sharedThird = first.shared() + const second = compileOutputs() + expect([second.shared(), second.reference(), second.component()]).toEqual([ + sharedThird, + referenceSecond, + componentFirst, + ]) + expect([first.reference(), first.component(), first.shared()]).toEqual([ + referenceSecond, + componentFirst, + sharedThird, + ]) + }) + + it('keeps "use client" first in the reference, split and shared outputs', () => { + const outputs = compileOutputs() + for (const output of [ + outputs.reference(), + outputs.component(), + outputs.shared(), + ]) { + expect(output).toMatch(/^['"]use client['"]/) + } + }) +}) + describe('code-splitter works', () => { describe.each(frameworks)('FRAMEWORK=%s', (framework) => { describe.each(testGroups)( @@ -86,7 +153,11 @@ describe('code-splitter works', () => { targetFramework: framework, sharedBindings: sharedBindings.size > 0 ? sharedBindings : undefined, + splitSharedComponents: framework === 'react', }) + if (compileResult) { + analyzeModule({ code: compileResult.code, filename }) + } await expect( await formatSnapshot(compileResult?.code || code, filename), @@ -116,7 +187,9 @@ describe('code-splitter works', () => { splitTargets: targets, sharedBindings: sharedBindings.size > 0 ? sharedBindings : undefined, + splitSharedComponents: framework === 'react', }) + analyzeModule({ code: splitResult.code, filename }) const snapshotFilename = path.join( dirs.snapshots, @@ -146,22 +219,54 @@ describe('code-splitter works', () => { groupName, `${filename.replace('.tsx', '')}@shared.tsx`, ) + // Only route files with shared components have this snapshot + const componentsSnapshotFilename = path.join( + dirs.snapshots, + groupName, + `${filename.replace('.tsx', '')}@shared-components.tsx`, + ) if (sharedBindings.size === 0) { // No shared module — snapshot should be empty string await expect('').toMatchFileSnapshot(snapshotFilename) + expect(existsSync(componentsSnapshotFilename)).toBe(false) return } + const splitSharedComponents = framework === 'react' const sharedResult = compileCodeSplitSharedRoute({ code, sharedBindings, + splitSharedComponents, filename: `${filename}?tsr-shared=1`, }) + analyzeModule({ code: sharedResult.code, filename }) await expect( await formatSnapshot(sharedResult.code, filename), ).toMatchFileSnapshot(snapshotFilename) + + const components = partitionSharedBindings( + analyzeRouteModule({ code, filename }), + sharedBindings, + splitSharedComponents, + ).some(([part]) => part === 'components') + if (!components) { + expect(existsSync(componentsSnapshotFilename)).toBe(false) + return + } + const componentsResult = compileCodeSplitSharedRoute({ + code, + sharedBindings, + splitSharedComponents, + part: 'components', + filename: `${filename}?tsr-shared=components`, + }) + analyzeModule({ code: componentsResult.code, filename }) + + await expect( + await formatSnapshot(componentsResult.code, filename), + ).toMatchFileSnapshot(componentsSnapshotFilename) }, ) }, @@ -306,256 +411,7 @@ export const Route = createFileRoute('/')({ }) // ============================================================================ -// LAYER 1: Algebraic Property Tests on Helper Functions -// -// These test that the pure graph/set functions obey mathematical contracts -// independent of any particular route file. -// ============================================================================ - -describe('expandTransitively', () => { - it('is idempotent — running twice yields the same result as once', () => { - // Graph: a -> b -> c, d -> b - const depGraph = new Map<string, Set<string>>([ - ['a', new Set(['b'])], - ['b', new Set(['c'])], - ['c', new Set()], - ['d', new Set(['b'])], - ]) - - const first = new Set(['a']) - expandTransitively(first, depGraph) - const afterFirst = new Set(first) - - expandTransitively(first, depGraph) - expect(first).toEqual(afterFirst) - }) - - it('is monotone — larger initial set produces equal or larger result', () => { - const depGraph = new Map<string, Set<string>>([ - ['a', new Set(['c'])], - ['b', new Set(['d'])], - ['c', new Set()], - ['d', new Set()], - ]) - - const small = new Set(['a']) - expandTransitively(small, depGraph) - - const large = new Set(['a', 'b']) - expandTransitively(large, depGraph) - - for (const item of small) { - expect(large.has(item)).toBe(true) - } - }) - - it('handles cycles without infinite loops', () => { - const depGraph = new Map<string, Set<string>>([ - ['a', new Set(['b'])], - ['b', new Set(['c'])], - ['c', new Set(['a'])], - ]) - - const set = new Set(['a']) - expandTransitively(set, depGraph) - expect(set).toEqual(new Set(['a', 'b', 'c'])) - }) - - it('is a no-op when there are no dependencies', () => { - const depGraph = new Map<string, Set<string>>([ - ['a', new Set()], - ['b', new Set()], - ]) - - const set = new Set(['a']) - expandTransitively(set, depGraph) - expect(set).toEqual(new Set(['a'])) - }) - - it('handles missing entries in the graph gracefully', () => { - const depGraph = new Map<string, Set<string>>() - const set = new Set(['unknown']) - expandTransitively(set, depGraph) - expect(set).toEqual(new Set(['unknown'])) - }) -}) - -describe('buildDependencyGraph', () => { - it('result keys are a subset of localBindings', () => { - const code = ` -const a = 1 -const b = a + 1 -const c = 2 -` - const ast = parseAst({ code }) - const declMap = buildDeclarationMap(ast) - const localBindings = new Set(['a', 'b', 'c']) - const graph = buildDependencyGraph(declMap, localBindings) - - for (const key of graph.keys()) { - expect(localBindings.has(key)).toBe(true) - } - }) - - it('dependency values are also subsets of localBindings', () => { - const code = ` -import { external } from 'somewhere' -const a = external -const b = a + 1 -` - const ast = parseAst({ code }) - const declMap = buildDeclarationMap(ast) - const localBindings = new Set(['a', 'b']) - const graph = buildDependencyGraph(declMap, localBindings) - - for (const deps of graph.values()) { - for (const dep of deps) { - expect(localBindings.has(dep)).toBe(true) - } - } - }) - - it('captures direct references correctly', () => { - const code = ` -const x = 10 -const y = x + 1 -` - const ast = parseAst({ code }) - const declMap = buildDeclarationMap(ast) - const localBindings = new Set(['x', 'y']) - const graph = buildDependencyGraph(declMap, localBindings) - - expect(graph.get('y')).toEqual(new Set(['x'])) - expect(graph.get('x')?.size ?? 0).toBe(0) - }) -}) - -describe('removeBindingsDependingOnRoute', () => { - it('removes bindings that directly reference Route', () => { - const depGraph = new Map<string, Set<string>>([ - ['helper', new Set(['Route'])], - ['standalone', new Set()], - ['Route', new Set()], - ]) - - const shared = new Set(['helper', 'standalone']) - removeBindingsDependingOnRoute(shared, depGraph) - - expect(shared.has('helper')).toBe(false) - expect(shared.has('standalone')).toBe(true) - }) - - it('removes bindings that transitively reference Route', () => { - const depGraph = new Map<string, Set<string>>([ - ['a', new Set(['b'])], - ['b', new Set(['Route'])], - ['c', new Set()], - ['Route', new Set()], - ]) - - const shared = new Set(['a', 'c']) - removeBindingsDependingOnRoute(shared, depGraph) - - expect(shared.has('a')).toBe(false) - expect(shared.has('c')).toBe(true) - }) - - it('is a no-op when nothing depends on Route', () => { - const depGraph = new Map<string, Set<string>>([ - ['a', new Set(['b'])], - ['b', new Set()], - ['Route', new Set()], - ]) - - const shared = new Set(['a', 'b']) - const before = new Set(shared) - removeBindingsDependingOnRoute(shared, depGraph) - - expect(shared).toEqual(before) - }) -}) - -describe('expandDestructuredDeclarations', () => { - it('is idempotent', () => { - const code = `const { a, b } = fn()` - const ast = parseAst({ code }) - - const shared = new Set(['a']) - expandDestructuredDeclarations(ast, shared) - const afterFirst = new Set(shared) - - expandDestructuredDeclarations(ast, shared) - expect(shared).toEqual(afterFirst) - }) - - it('pulls all siblings when one destructured binding is shared', () => { - const code = `const { a, b, c } = fn()` - const ast = parseAst({ code }) - - const shared = new Set(['b']) - expandDestructuredDeclarations(ast, shared) - - expect(shared).toEqual(new Set(['a', 'b', 'c'])) - }) - - it('does not affect non-destructured declarations', () => { - const code = ` -const x = 1 -const y = 2 -` - const ast = parseAst({ code }) - - const shared = new Set(['x']) - expandDestructuredDeclarations(ast, shared) - - expect(shared).toEqual(new Set(['x'])) - }) -}) - -describe('collectLocalBindingsFromStatement', () => { - it('collects variable declaration names', () => { - const code = `const x = 1` - const ast = parseAst({ code }) - const bindings = new Set<string>() - collectLocalBindingsFromStatement(ast.program.body[0]!, bindings) - expect(bindings).toEqual(new Set(['x'])) - }) - - it('collects function declaration names', () => { - const code = `function foo() {}` - const ast = parseAst({ code }) - const bindings = new Set<string>() - collectLocalBindingsFromStatement(ast.program.body[0]!, bindings) - expect(bindings).toEqual(new Set(['foo'])) - }) - - it('collects class declaration names', () => { - const code = `class MyClass {}` - const ast = parseAst({ code }) - const bindings = new Set<string>() - collectLocalBindingsFromStatement(ast.program.body[0]!, bindings) - expect(bindings).toEqual(new Set(['MyClass'])) - }) - - it('collects exported declaration names', () => { - const code = `export const a = 1` - const ast = parseAst({ code }) - const bindings = new Set<string>() - collectLocalBindingsFromStatement(ast.program.body[0]!, bindings) - expect(bindings).toEqual(new Set(['a'])) - }) - - it('collects destructured binding names', () => { - const code = `const { a, b } = obj` - const ast = parseAst({ code }) - const bindings = new Set<string>() - collectLocalBindingsFromStatement(ast.program.body[0]!, bindings) - expect(bindings).toEqual(new Set(['a', 'b'])) - }) -}) - -// ============================================================================ -// LAYER 2: Invariant Tests on computeSharedBindings +// Invariant Tests on computeSharedBindings // // These verify the core "contracts" of the shared bindings computation: // - Route is never extracted @@ -569,13 +425,14 @@ describe('computeSharedBindings invariants', () => { const defaultGroupings = defaultCodeSplitGroupings function getLocalBindings(code: string): Set<string> { - const ast = parseAst({ code }) - const bindings = new Set<string>() - for (const stmt of ast.program.body) { - collectLocalBindingsFromStatement(stmt, bindings) - } - bindings.delete('Route') - return bindings + return new Set( + analyzeModule({ code }) + .rootScope.bindings.filter( + (binding) => + !binding.has(BindingFlags.Import) && binding.name !== 'Route', + ) + .map((binding) => binding.name), + ) } it('INVARIANT: Route is never in the shared set', () => { @@ -632,6 +489,23 @@ export const Route = createFileRoute('/')({ expect(result.has('helper')).toBe(false) }) + it('INVARIANT: vars declared inside nested statements are never shared', () => { + const code = ` +import { createFileRoute } from '@tanstack/react-router' +if (typeof window !== 'undefined') { var inBlock = 1 } +if (typeof window !== 'undefined') var inStatement = 2 +export const Route = createFileRoute('/')({ + loader: () => [inBlock, inStatement], + component: () => <div>{inBlock}{inStatement}</div>, +}) +` + const result = computeSharedBindings({ + code, + codeSplitGroupings: defaultGroupings, + }) + expect([...result]).toEqual([]) + }) + it('INVARIANT: destructured siblings are either all shared or none shared', () => { const code = ` import { createFileRoute } from '@tanstack/react-router' @@ -760,7 +634,7 @@ export const Route = createFileRoute('/')({ }) // ============================================================================ -// LAYER 3: Small-Scope Exhaustive Tests +// Small-Scope Exhaustive Tests // // Inspired by Alloy's "small scope hypothesis" — most bugs show up in small // counterexamples. We exhaustively test all combinations of: @@ -1093,12 +967,14 @@ export const Route = createFileRoute('/')({ expect(result.has('Route')).toBe(false) // Contract 2: all results are real local bindings - const ast = parseAst({ code }) - const localBindings = new Set<string>() - for (const stmt of ast.program.body) { - collectLocalBindingsFromStatement(stmt, localBindings) - } - localBindings.delete('Route') + const localBindings = new Set( + analyzeModule({ code }) + .rootScope.bindings.filter( + (binding) => + !binding.has(BindingFlags.Import) && binding.name !== 'Route', + ) + .map((binding) => binding.name), + ) for (const name of result) { expect(localBindings.has(name)).toBe(true) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/ambient-declarations.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/ambient-declarations.tsx new file mode 100644 index 00000000000..1d2510a614d --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/ambient-declarations.tsx @@ -0,0 +1,24 @@ +const $$splitComponentImporter = () => + import('ambient-declarations.tsx?tsr-split=component') +import { lazyRouteComponent } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +declare function gtag(event: string): void +declare enum Flags { + On = 1, +} +declare namespace Analytics { + function track(event: string): void +} +declare const buildId: string +declare class Tracker { + static send(event: string): void +} +export const Route = createFileRoute('/')({ + loader: () => { + gtag('load') + Analytics.track(buildId) + Tracker.send('load') + return Flags.On + }, + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/ambient-declarations@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/ambient-declarations@component.tsx new file mode 100644 index 00000000000..d42c7aace16 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/ambient-declarations@component.tsx @@ -0,0 +1,23 @@ +declare function gtag(event: string): void +declare enum Flags { + On = 1, +} +declare namespace Analytics { + function track(event: string): void +} +declare const buildId: string +declare class Tracker { + static send(event: string): void +} +const SplitComponent = () => ( + <button + onClick={() => { + gtag('click') + Analytics.track(buildId) + Tracker.send('click') + }} + > + {Flags.On} + </button> +) +export { SplitComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/ambient-declarations@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/ambient-declarations@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/ambient-declarations@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/ambient-declarations@notFoundComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/ambient-declarations@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/ambient-declarations@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/anonymous-default-export.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/anonymous-default-export.tsx new file mode 100644 index 00000000000..7f184fe63f9 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/anonymous-default-export.tsx @@ -0,0 +1,12 @@ +import { cache } from 'anonymous-default-export.tsx?tsr-shared=1' +const $$splitComponentImporter = () => + import('anonymous-default-export.tsx?tsr-split=component') +import { lazyRouteComponent } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/')({ + loader: () => cache.get('title'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) +export default function () { + return null +} diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/anonymous-default-export@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/anonymous-default-export@component.tsx new file mode 100644 index 00000000000..22b1f2bb461 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/anonymous-default-export@component.tsx @@ -0,0 +1,3 @@ +import { cache } from 'anonymous-default-export.tsx?tsr-shared=1' +const SplitComponent = () => <div>{cache.size}</div> +export { SplitComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/anonymous-default-export@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/anonymous-default-export@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/anonymous-default-export@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/anonymous-default-export@notFoundComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/anonymous-default-export@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/anonymous-default-export@shared.tsx new file mode 100644 index 00000000000..94c85bdde03 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/anonymous-default-export@shared.tsx @@ -0,0 +1,2 @@ +const cache = new Map<string, string>() +export { cache } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/arrow-function@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/arrow-function@component.tsx index e36e48f4951..3a388f25424 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/arrow-function@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/arrow-function@component.tsx @@ -1,6 +1,6 @@ +import { Route } from 'arrow-function.tsx' import * as React from 'react' import { Link, Outlet } from '@tanstack/react-router' -import { Route } from 'arrow-function.tsx' const PostsComponent = () => { const posts = Route.useLoaderData() return ( diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/boolean-null-literals@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/boolean-null-literals@component.tsx index 557105b6a35..773784562ab 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/boolean-null-literals@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/boolean-null-literals@component.tsx @@ -1,3 +1,2 @@ -// Test errorComponent with false literal const SplitComponent = () => <div>Test Component</div> export { SplitComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-export@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-export@component.tsx index b0abbd2e6e1..47a62c9fb7b 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-export@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-export@component.tsx @@ -1,6 +1,3 @@ -export function getObjectCallback() { - return { getObject: () => ({ constA: 10, constB: 5 }) } -} function AboutComponent() { return ( <div className="p-2"> diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-export@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-export@errorComponent.tsx index 024e0e620e7..e69de29bb2d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-export@errorComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-export@errorComponent.tsx @@ -1,3 +0,0 @@ -export function getObjectCallback() { - return { getObject: () => ({ constA: 10, constB: 5 }) } -} diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-export@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-export@notFoundComponent.tsx index 024e0e620e7..e69de29bb2d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-export@notFoundComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-export@notFoundComponent.tsx @@ -1,3 +0,0 @@ -export function getObjectCallback() { - return { getObject: () => ({ constA: 10, constB: 5 }) } -} diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-route-options-defaults@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-route-options-defaults@shared.tsx index a7dee6af521..694108daa76 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-route-options-defaults@shared.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/destructured-route-options-defaults@shared.tsx @@ -15,9 +15,9 @@ function ActualComponent() { } export { ActualComponent, - createBits, DefaultComponent, + MyComponent, + createBits, defaultLoader, loader, - MyComponent, } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-binding-private-constant.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-binding-private-constant.tsx new file mode 100644 index 00000000000..3727d4bcc62 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-binding-private-constant.tsx @@ -0,0 +1,12 @@ +const $$splitComponentImporter = () => + import('exported-binding-private-constant.tsx?tsr-split=component') +import { lazyRouteComponent } from '@tanstack/react-router' +import { createContext } from 'react' +import { createFileRoute } from '@tanstack/react-router' +const initialCount = 0 +export const store = { count: initialCount } +const defaultTheme = 'light' +export const ThemeContext = createContext(defaultTheme) +export const Route = createFileRoute('/')({ + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-binding-private-constant@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-binding-private-constant@component.tsx new file mode 100644 index 00000000000..6ee7759381a --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-binding-private-constant@component.tsx @@ -0,0 +1,14 @@ +import { store, ThemeContext } from 'exported-binding-private-constant.tsx' +import { useContext } from 'react' +const initialCount = 0 +const defaultTheme = 'light' +function Page() { + const theme = useContext(ThemeContext) + store.count++ + return ( + <p> + {store.count - initialCount} {theme === defaultTheme ? 'default' : theme} + </p> + ) +} +export { Page as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-binding-private-constant@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-binding-private-constant@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-binding-private-constant@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-binding-private-constant@notFoundComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-binding-private-constant@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-binding-private-constant@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads.tsx new file mode 100644 index 00000000000..82e117dec24 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads.tsx @@ -0,0 +1,16 @@ +const $$splitComponentImporter = () => + import('exported-bindings-split-reads.tsx?tsr-split=component') +import { lazyRouteComponent } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +export function RouteTitle() { + return <h1>{Route.useLoaderData().title}</h1> +} +export const Route = createFileRoute('/')({ + loader: () => ({ title: 'Home' }), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) +export { + Store, + countRender, +} from 'exported-bindings-split-reads.tsx?tsr-shared=1' +export { Card } from 'exported-bindings-split-reads.tsx?tsr-shared=components' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads@component.tsx new file mode 100644 index 00000000000..1efd18492f0 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads@component.tsx @@ -0,0 +1,19 @@ +import { Card } from 'exported-bindings-split-reads.tsx?tsr-shared=components' +import { + Store, + countRender, +} from 'exported-bindings-split-reads.tsx?tsr-shared=1' +import { Route } from 'exported-bindings-split-reads.tsx' +function RouteTitle() { + return <h1>{Route.useLoaderData().title}</h1> +} +function Page() { + const store = new Store() + return ( + <main> + <RouteTitle /> + <Card title={`${store.items.size} items, render ${countRender()}`} /> + </main> + ) +} +export { Page as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads@notFoundComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads@shared-components.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads@shared-components.tsx new file mode 100644 index 00000000000..74b5d261c97 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads@shared-components.tsx @@ -0,0 +1,4 @@ +function Card({ title }: { title: string }) { + return <section>{title}</section> +} +export { Card } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads@shared.tsx new file mode 100644 index 00000000000..b41cf0406ae --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/exported-bindings-split-reads@shared.tsx @@ -0,0 +1,8 @@ +class Store { + items = new Map<string, number>() +} +let renders = 0 +function countRender() { + return ++renders +} +export { Store, countRender, renders } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/function-declaration@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/function-declaration@component.tsx index a0ebf07dee9..c47441b6b75 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/function-declaration@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/function-declaration@component.tsx @@ -1,6 +1,6 @@ +import { Route } from 'function-declaration.tsx' import * as React from 'react' import { Link, Outlet } from '@tanstack/react-router' -import { Route } from 'function-declaration.tsx' function PostsComponent() { const posts = Route.useLoaderData() return ( diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-default-component-destructured-loader@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-default-component-destructured-loader@component.tsx index 9350cda9e8d..f02256e5189 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-default-component-destructured-loader@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-default-component-destructured-loader@component.tsx @@ -1,3 +1,2 @@ import importedComponent from '../../shared/imported' -const SplitComponent = importedComponent -export { SplitComponent as component } +export { importedComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-default-component@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-default-component@component.tsx index d3562270400..3b277870559 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-default-component@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-default-component@component.tsx @@ -1,3 +1,2 @@ import ImportedDefaultComponent from '../../shared/imported' -const SplitComponent = ImportedDefaultComponent -export { SplitComponent as component } +export { ImportedDefaultComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-errorComponent@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-errorComponent@component.tsx index d3562270400..3b277870559 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-errorComponent@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-errorComponent@component.tsx @@ -1,3 +1,2 @@ import ImportedDefaultComponent from '../../shared/imported' -const SplitComponent = ImportedDefaultComponent -export { SplitComponent as component } +export { ImportedDefaultComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-errorComponent@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-errorComponent@errorComponent.tsx index 3f20c3e485e..89ff9e5601c 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-errorComponent@errorComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-errorComponent@errorComponent.tsx @@ -1,3 +1,2 @@ import { importedErrorComponent } from '../../shared/imported' -const SplitErrorComponent = importedErrorComponent -export { SplitErrorComponent as errorComponent } +export { importedErrorComponent as errorComponent } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-notFoundComponent@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-notFoundComponent@component.tsx index d3562270400..3b277870559 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-notFoundComponent@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-notFoundComponent@component.tsx @@ -1,3 +1,2 @@ import ImportedDefaultComponent from '../../shared/imported' -const SplitComponent = ImportedDefaultComponent -export { SplitComponent as component } +export { ImportedDefaultComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-notFoundComponent@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-notFoundComponent@notFoundComponent.tsx index bfe9d4e1c92..65a7423fa23 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-notFoundComponent@notFoundComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-notFoundComponent@notFoundComponent.tsx @@ -1,3 +1,2 @@ import { importedNotFoundComponent } from '../../shared/imported' -const SplitNotFoundComponent = importedNotFoundComponent -export { SplitNotFoundComponent as notFoundComponent } +export { importedNotFoundComponent as notFoundComponent } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-pendingComponent@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-pendingComponent@component.tsx index d3562270400..3b277870559 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-pendingComponent@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported-pendingComponent@component.tsx @@ -1,3 +1,2 @@ import ImportedDefaultComponent from '../../shared/imported' -const SplitComponent = ImportedDefaultComponent -export { SplitComponent as component } +export { ImportedDefaultComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported@component.tsx index e3c06dbe9ea..20045f32586 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/imported@component.tsx @@ -1,3 +1,2 @@ import { importedComponent } from '../../shared' -const SplitComponent = importedComponent -export { SplitComponent as component } +export { importedComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/inline@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/inline@component.tsx index d28b8726342..c6b9d06bdf4 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/inline@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/inline@component.tsx @@ -1,11 +1,10 @@ +import { Route, test } from 'inline.tsx' import * as React from 'react' import * as styles from '../style.css' import { TEST_DATA } from '../test.const' const Button = (props: { children: any }) => { return <button>{props.children}</button> } -import { Route } from 'inline.tsx' -import { test } from 'inline.tsx' const SplitComponent = () => { return ( <div className="p-2"> diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/inline@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/inline@errorComponent.tsx index 23fab71edc2..e7fb079a150 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/inline@errorComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/inline@errorComponent.tsx @@ -1,2 +1,2 @@ -import * as React from 'react' import { Route } from 'inline.tsx' +import * as React from 'react' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/inline@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/inline@notFoundComponent.tsx index 23fab71edc2..e7fb079a150 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/inline@notFoundComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/inline@notFoundComponent.tsx @@ -1,2 +1,2 @@ -import * as React from 'react' import { Route } from 'inline.tsx' +import * as React from 'react' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/jsx-pragmas.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/jsx-pragmas.tsx new file mode 100644 index 00000000000..5e5343d53f5 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/jsx-pragmas.tsx @@ -0,0 +1,21 @@ +/** + * Styled route + * @jsxImportSource @emotion/react + */ +// @refresh reset +import { icon } from 'jsx-pragmas.tsx?tsr-shared=1' +const $$splitErrorComponentImporter = () => + import('jsx-pragmas.tsx?tsr-split=errorComponent') +const $$splitComponentImporter = () => + import('jsx-pragmas.tsx?tsr-split=component') +import { lazyRouteComponent } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +import { fetchTheme } from '../theme' +export const Route = createFileRoute('/styled')({ + loader: async () => ({ icon, theme: await fetchTheme() }), + component: lazyRouteComponent($$splitComponentImporter, 'component'), + errorComponent: lazyRouteComponent( + $$splitErrorComponentImporter, + 'errorComponent', + ), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/jsx-pragmas@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/jsx-pragmas@component.tsx new file mode 100644 index 00000000000..d816b30efe8 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/jsx-pragmas@component.tsx @@ -0,0 +1,10 @@ +/** + * Styled route + * @jsxImportSource @emotion/react + */ +// @refresh reset +import { icon } from 'jsx-pragmas.tsx?tsr-shared=1' +function StyledPage() { + return <main css={{ color: 'hotpink' }}>{icon} styled</main> +} +export { StyledPage as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/jsx-pragmas@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/jsx-pragmas@errorComponent.tsx new file mode 100644 index 00000000000..7cc18d5b8ef --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/jsx-pragmas@errorComponent.tsx @@ -0,0 +1,8 @@ +/** + * Styled route + * @jsxImportSource @emotion/react + */ +// @refresh reset +import { icon } from 'jsx-pragmas.tsx?tsr-shared=1' +const SplitErrorComponent = () => <p css={{ color: 'red' }}>{icon} error</p> +export { SplitErrorComponent as errorComponent } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/jsx-pragmas@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/jsx-pragmas@notFoundComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/jsx-pragmas@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/jsx-pragmas@shared.tsx new file mode 100644 index 00000000000..e5b49279017 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/jsx-pragmas@shared.tsx @@ -0,0 +1,7 @@ +/** + * Styled route + * @jsxImportSource @emotion/react + */ +// @refresh reset +const icon = <svg css={{ width: 16 }} /> +export { icon } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/nested-var-dead-code.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/nested-var-dead-code.tsx new file mode 100644 index 00000000000..912a932a0fb --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/nested-var-dead-code.tsx @@ -0,0 +1,16 @@ +const $$splitComponentImporter = () => + import('nested-var-dead-code.tsx?tsr-split=component') +import { lazyRouteComponent } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +import { loadServerData } from './lib' +if (typeof window !== 'undefined') { +} +try { + var loaderOnly = loadServerData() +} catch {} +if (typeof window !== 'undefined') { +} +export const Route = createFileRoute('/')({ + loader: () => loaderOnly, + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/nested-var-dead-code@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/nested-var-dead-code@component.tsx new file mode 100644 index 00000000000..988e29ca910 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/nested-var-dead-code@component.tsx @@ -0,0 +1,13 @@ +import { compute, connect } from './lib' +if (typeof window !== 'undefined') { + var componentOnly = compute() +} +try { +} catch {} +if (typeof window !== 'undefined') var unbraced = connect() +const SplitComponent = () => ( + <div> + {componentOnly} {unbraced} + </div> +) +export { SplitComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/nested-var-dead-code@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/nested-var-dead-code@errorComponent.tsx new file mode 100644 index 00000000000..d94ba00c758 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/nested-var-dead-code@errorComponent.tsx @@ -0,0 +1,6 @@ +if (typeof window !== 'undefined') { +} +try { +} catch {} +if (typeof window !== 'undefined') { +} diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/nested-var-dead-code@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/nested-var-dead-code@notFoundComponent.tsx new file mode 100644 index 00000000000..d94ba00c758 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/nested-var-dead-code@notFoundComponent.tsx @@ -0,0 +1,6 @@ +if (typeof window !== 'undefined') { +} +try { +} catch {} +if (typeof window !== 'undefined') { +} diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/nested-var-dead-code@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/nested-var-dead-code@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/random-number@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/random-number@component.tsx index 26a9f9c1423..6ffa8447256 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/random-number@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/random-number@component.tsx @@ -1,3 +1,4 @@ +import { textColors, gradients, Route } from 'random-number.tsx' import { Await, Link } from '@tanstack/react-router' import { Carbon } from '~/components/Carbon' import { twMerge } from 'tailwind-merge' @@ -13,8 +14,6 @@ import bytesImage from '~/images/bytes.svg' import bytesUidotdevImage from '~/images/bytes-uidotdev.png' import { useMutation } from '~/hooks/useMutation' import { sample } from '~/utils/utils' -import { textColors } from 'random-number.tsx' -import { gradients } from 'random-number.tsx' const courses = [ { name: 'The Official TanStack React Query Course', @@ -23,7 +22,6 @@ const courses = [ description: `Learn how to build enterprise quality apps with TanStack's React Query the easy way with our brand new course.`, }, ] -import { Route } from 'random-number.tsx' async function bytesSignupServerFn({ email }: { email: string }) { 'use server' return fetch(`https://bytes.dev/api/bytes-optin-cors`, { diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/reexported-import.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/reexported-import.tsx new file mode 100644 index 00000000000..3f1b6cf7d84 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/reexported-import.tsx @@ -0,0 +1,9 @@ +const $$splitComponentImporter = () => + import('reexported-import.tsx?tsr-split=component') +import { lazyRouteComponent } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +import { Foo } from './foo' +export { Foo, Foo as Bar } +export const Route = createFileRoute('/')({ + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/reexported-import@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/reexported-import@component.tsx new file mode 100644 index 00000000000..11dc5fc726c --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/reexported-import@component.tsx @@ -0,0 +1,5 @@ +import { Foo } from './foo' +function RouteComponent() { + return <Foo /> +} +export { RouteComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/reexported-import@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/reexported-import@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/reexported-import@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/reexported-import@notFoundComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/reexported-import@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/reexported-import@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/retain-exports-destructured@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/retain-exports-destructured@component.tsx index d64b42fdcc0..e69de29bb2d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/retain-exports-destructured@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/retain-exports-destructured@component.tsx @@ -1,2 +0,0 @@ -import { AboutComponent } from 'retain-exports-destructured.tsx?tsr-shared=1' -export { AboutComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values.tsx new file mode 100644 index 00000000000..63305a686a9 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values.tsx @@ -0,0 +1,14 @@ +import { label, registry } from 'shared-components-and-values.tsx?tsr-shared=1' +const $$splitComponentImporter = () => + import('shared-components-and-values.tsx?tsr-split=component') +import { lazyRouteComponent } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/')({ + loader: () => label(registry.size), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) +export { + Card, + Header, + Table, +} from 'shared-components-and-values.tsx?tsr-shared=components' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values@component.tsx new file mode 100644 index 00000000000..95092e88216 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values@component.tsx @@ -0,0 +1,15 @@ +import { + Card, + Header, + Table, +} from 'shared-components-and-values.tsx?tsr-shared=components' +function Page() { + return ( + <main> + <Header /> + <Card /> + <Table /> + </main> + ) +} +export { Page as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values@notFoundComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values@shared-components.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values@shared-components.tsx new file mode 100644 index 00000000000..1296304594b --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values@shared-components.tsx @@ -0,0 +1,31 @@ +import { + label, + registry, + columns, +} from 'shared-components-and-values.tsx?tsr-shared=1' +function Card() { + registry.set('card', 'seen') + return <section>{label(registry.size)}</section> +} +function Badge() { + return <i>badge</i> +} +const Header = () => ( + <header> + <Badge /> + </header> +) +function Table() { + return ( + <table> + <tbody> + <tr> + {columns.map((column) => ( + <column.render key={column.key} /> + ))} + </tr> + </tbody> + </table> + ) +} +export { Badge, Card, Header, Table } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values@shared.tsx new file mode 100644 index 00000000000..ae6f75e6986 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-components-and-values@shared.tsx @@ -0,0 +1,9 @@ +const registry = new Map<string, string>() +function label(size: number) { + return `${size} entries` +} +function Cell() { + return <td>cell</td> +} +const columns = [{ key: 'cell', render: Cell }] +export { Cell, columns, label, registry } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-jsx-component-ref@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-jsx-component-ref@component.tsx index 845e1db45cd..9686efb94ca 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-jsx-component-ref@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-jsx-component-ref@component.tsx @@ -1,4 +1,4 @@ -import { shared } from 'shared-jsx-component-ref.tsx?tsr-shared=1' // @ts-nocheck +import { shared } from 'shared-jsx-component-ref.tsx?tsr-shared=1' function SharedComponent() { return <div>{shared}</div> } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-jsx-component-ref@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-jsx-component-ref@shared.tsx index 60ae1006a18..2c995304acb 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-jsx-component-ref@shared.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-jsx-component-ref@shared.tsx @@ -1,3 +1,2 @@ -// @ts-nocheck const shared = 1 export { shared } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-referencing-route@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-referencing-route@component.tsx index b2c4630c90f..98a0588be70 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-referencing-route@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-referencing-route@component.tsx @@ -1,8 +1,8 @@ import { HEADER } from 'shared-referencing-route.tsx?tsr-shared=1' +import { Route } from 'shared-referencing-route.tsx' function usePageTitle() { return `${HEADER} - ${Route.fullPath}` } -import { Route } from 'shared-referencing-route.tsx' const SplitComponent = () => { const title = usePageTitle() return <div>{title}</div> diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime.tsx index 17d70ca9bda..22f7117b7af 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime.tsx @@ -1,9 +1,4 @@ -import { - state, - createState, - createSeed, - increment, -} from 'shared-runtime.tsx?tsr-shared=1' +import { state, increment } from 'shared-runtime.tsx?tsr-shared=1' const $$splitComponentImporter = () => import('shared-runtime.tsx?tsr-split=component') import { lazyRouteComponent } from '@tanstack/react-router' @@ -17,9 +12,9 @@ export const Route = createFileRoute('/shared-runtime')({ }, component: lazyRouteComponent($$splitComponentImporter, 'component'), }) -function createState(initialState: { count: number }): { - state: { count: number } - increment: () => number -} -export { state as firstState, state as secondState, state as 'odd-name' } -export default createSeed +export { + state as firstState, + state as secondState, + state as 'odd-name', + createSeed as default, +} from 'shared-runtime.tsx?tsr-shared=1' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@component.tsx index 5245f6d9eb8..4b78aa7ce7d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@component.tsx @@ -1,20 +1,9 @@ -import { - state, - createState, - createSeed, - Labels, -} from 'shared-runtime.tsx?tsr-shared=1' +import { state, Labels } from 'shared-runtime.tsx?tsr-shared=1' const label = createComponentLabel() -function createState(initialState: { count: number }): { - state: { count: number } - increment: () => number -} function createComponentLabel() { console.log('shared-runtime:component') return Labels.component } -export { state as firstState, state as secondState, state as 'odd-name' } -export default createSeed const SplitComponent = () => { if (state.count === 3) { throw new Error('route source-map contract') diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@errorComponent.tsx index 03cacac5b39..e69de29bb2d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@errorComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@errorComponent.tsx @@ -1,7 +0,0 @@ -import { state, createState, createSeed } from 'shared-runtime.tsx?tsr-shared=1' -function createState(initialState: { count: number }): { - state: { count: number } - increment: () => number -} -export { state as firstState, state as secondState, state as 'odd-name' } -export default createSeed diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@notFoundComponent.tsx index 03cacac5b39..e69de29bb2d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@notFoundComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@notFoundComponent.tsx @@ -1,7 +0,0 @@ -import { state, createState, createSeed } from 'shared-runtime.tsx?tsr-shared=1' -function createState(initialState: { count: number }): { - state: { count: number } - increment: () => number -} -export { state as firstState, state as secondState, state as 'odd-name' } -export default createSeed diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@shared.tsx index c53a7e15682..d0e0b48dae5 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@shared.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-runtime@shared.tsx @@ -6,8 +6,12 @@ function createSeed() { console.log('shared-runtime:seed') return { count: Count.Initial, label: Labels.component } } +function createState(initialState: { count: number }): { + state: { count: number } + increment: () => number +} function createState(initialState: { count: number }) { console.log('shared-runtime:state') return { state: initialState, increment: () => initialState.count++ } } -export { Count, createSeed, createState, increment, Labels, seed, state } +export { Count, Labels, createSeed, createState, increment, seed, state } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/ambient-declarations.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/ambient-declarations.tsx new file mode 100644 index 00000000000..60423a9ac8d --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/ambient-declarations.tsx @@ -0,0 +1,11 @@ +const $$splitComponentImporter = () => + import('ambient-declarations.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +const $$splitLoaderImporter = () => + import('ambient-declarations.tsx?tsr-split=loader') +import { lazyFn } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/')({ + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/ambient-declarations@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/ambient-declarations@component---errorComponent---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..d42c7aace16 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/ambient-declarations@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,23 @@ +declare function gtag(event: string): void +declare enum Flags { + On = 1, +} +declare namespace Analytics { + function track(event: string): void +} +declare const buildId: string +declare class Tracker { + static send(event: string): void +} +const SplitComponent = () => ( + <button + onClick={() => { + gtag('click') + Analytics.track(buildId) + Tracker.send('click') + }} + > + {Flags.On} + </button> +) +export { SplitComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/ambient-declarations@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/ambient-declarations@loader.tsx new file mode 100644 index 00000000000..5f5ab5984c8 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/ambient-declarations@loader.tsx @@ -0,0 +1,18 @@ +declare function gtag(event: string): void +declare enum Flags { + On = 1, +} +declare namespace Analytics { + function track(event: string): void +} +declare const buildId: string +declare class Tracker { + static send(event: string): void +} +const SplitLoader = () => { + gtag('load') + Analytics.track(buildId) + Tracker.send('load') + return Flags.On +} +export { SplitLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/ambient-declarations@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/ambient-declarations@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/anonymous-default-export.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/anonymous-default-export.tsx new file mode 100644 index 00000000000..aff74a811ef --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/anonymous-default-export.tsx @@ -0,0 +1,14 @@ +const $$splitComponentImporter = () => + import('anonymous-default-export.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +const $$splitLoaderImporter = () => + import('anonymous-default-export.tsx?tsr-split=loader') +import { lazyFn } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/')({ + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) +export default function () { + return null +} diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/anonymous-default-export@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/anonymous-default-export@component---errorComponent---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..22b1f2bb461 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/anonymous-default-export@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,3 @@ +import { cache } from 'anonymous-default-export.tsx?tsr-shared=1' +const SplitComponent = () => <div>{cache.size}</div> +export { SplitComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/anonymous-default-export@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/anonymous-default-export@loader.tsx new file mode 100644 index 00000000000..fefaeff6458 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/anonymous-default-export@loader.tsx @@ -0,0 +1,3 @@ +import { cache } from 'anonymous-default-export.tsx?tsr-shared=1' +const SplitLoader = () => cache.get('title') +export { SplitLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/anonymous-default-export@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/anonymous-default-export@shared.tsx new file mode 100644 index 00000000000..94c85bdde03 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/anonymous-default-export@shared.tsx @@ -0,0 +1,2 @@ +const cache = new Map<string, string>() +export { cache } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/arrow-function@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/arrow-function@component---errorComponent---notFoundComponent---pendingComponent.tsx index e36e48f4951..3a388f25424 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/arrow-function@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/arrow-function@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,6 +1,6 @@ +import { Route } from 'arrow-function.tsx' import * as React from 'react' import { Link, Outlet } from '@tanstack/react-router' -import { Route } from 'arrow-function.tsx' const PostsComponent = () => { const posts = Route.useLoaderData() return ( diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/arrow-function@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/arrow-function@loader.tsx index 1c4fc213ed7..edf2f4c55e8 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/arrow-function@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/arrow-function@loader.tsx @@ -1,4 +1,3 @@ import * as React from 'react' import { fetchPosts } from '../posts' -const SplitLoader = fetchPosts -export { SplitLoader as loader } +export { fetchPosts as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/boolean-null-literals@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/boolean-null-literals@component---errorComponent---notFoundComponent---pendingComponent.tsx index 557105b6a35..773784562ab 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/boolean-null-literals@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/boolean-null-literals@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,3 +1,2 @@ -// Test errorComponent with false literal const SplitComponent = () => <div>Test Component</div> export { SplitComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/boolean-null-literals@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/boolean-null-literals@loader.tsx index 0e19f8cafe2..6643f257840 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/boolean-null-literals@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/boolean-null-literals@loader.tsx @@ -1,3 +1,2 @@ -// Test errorComponent with false literal const SplitLoader = async () => ({ data: 'test' }) export { SplitLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-export@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-export@component---errorComponent---notFoundComponent---pendingComponent.tsx index b0abbd2e6e1..47a62c9fb7b 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-export@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-export@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,6 +1,3 @@ -export function getObjectCallback() { - return { getObject: () => ({ constA: 10, constB: 5 }) } -} function AboutComponent() { return ( <div className="p-2"> diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-export@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-export@loader.tsx index 024e0e620e7..e69de29bb2d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-export@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-export@loader.tsx @@ -1,3 +0,0 @@ -export function getObjectCallback() { - return { getObject: () => ({ constA: 10, constB: 5 }) } -} diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-react-memo-imported-component@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-react-memo-imported-component@loader.tsx index 8691aec6ff7..ce2d8c49b76 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-react-memo-imported-component@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-react-memo-imported-component@loader.tsx @@ -1,3 +1,2 @@ import { importedLoader } from '../../shared/imported' -const SplitLoader = importedLoader -export { SplitLoader as loader } +export { importedLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-route-options-defaults@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-route-options-defaults@shared.tsx index a7dee6af521..694108daa76 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-route-options-defaults@shared.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/destructured-route-options-defaults@shared.tsx @@ -15,9 +15,9 @@ function ActualComponent() { } export { ActualComponent, - createBits, DefaultComponent, + MyComponent, + createBits, defaultLoader, loader, - MyComponent, } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-binding-private-constant.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-binding-private-constant.tsx new file mode 100644 index 00000000000..c2a1310b165 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-binding-private-constant.tsx @@ -0,0 +1,12 @@ +const $$splitComponentImporter = () => + import('exported-binding-private-constant.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +import { createContext } from 'react' +import { createFileRoute } from '@tanstack/react-router' +const initialCount = 0 +export const store = { count: initialCount } +const defaultTheme = 'light' +export const ThemeContext = createContext(defaultTheme) +export const Route = createFileRoute('/')({ + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-binding-private-constant@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-binding-private-constant@component---errorComponent---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..6ee7759381a --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-binding-private-constant@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,14 @@ +import { store, ThemeContext } from 'exported-binding-private-constant.tsx' +import { useContext } from 'react' +const initialCount = 0 +const defaultTheme = 'light' +function Page() { + const theme = useContext(ThemeContext) + store.count++ + return ( + <p> + {store.count - initialCount} {theme === defaultTheme ? 'default' : theme} + </p> + ) +} +export { Page as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-binding-private-constant@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-binding-private-constant@loader.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-binding-private-constant@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-binding-private-constant@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads.tsx new file mode 100644 index 00000000000..4920e33fe5d --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads.tsx @@ -0,0 +1,19 @@ +const $$splitComponentImporter = () => + import('exported-bindings-split-reads.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +const $$splitLoaderImporter = () => + import('exported-bindings-split-reads.tsx?tsr-split=loader') +import { lazyFn } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +export function RouteTitle() { + return <h1>{Route.useLoaderData().title}</h1> +} +export const Route = createFileRoute('/')({ + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) +export { + Store, + countRender, +} from 'exported-bindings-split-reads.tsx?tsr-shared=1' +export { Card } from 'exported-bindings-split-reads.tsx?tsr-shared=components' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads@component---errorComponent---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..1efd18492f0 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,19 @@ +import { Card } from 'exported-bindings-split-reads.tsx?tsr-shared=components' +import { + Store, + countRender, +} from 'exported-bindings-split-reads.tsx?tsr-shared=1' +import { Route } from 'exported-bindings-split-reads.tsx' +function RouteTitle() { + return <h1>{Route.useLoaderData().title}</h1> +} +function Page() { + const store = new Store() + return ( + <main> + <RouteTitle /> + <Card title={`${store.items.size} items, render ${countRender()}`} /> + </main> + ) +} +export { Page as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads@loader.tsx new file mode 100644 index 00000000000..a0708ade675 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads@loader.tsx @@ -0,0 +1,2 @@ +const SplitLoader = () => ({ title: 'Home' }) +export { SplitLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads@shared-components.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads@shared-components.tsx new file mode 100644 index 00000000000..74b5d261c97 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads@shared-components.tsx @@ -0,0 +1,4 @@ +function Card({ title }: { title: string }) { + return <section>{title}</section> +} +export { Card } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads@shared.tsx new file mode 100644 index 00000000000..b41cf0406ae --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/exported-bindings-split-reads@shared.tsx @@ -0,0 +1,8 @@ +class Store { + items = new Map<string, number>() +} +let renders = 0 +function countRender() { + return ++renders +} +export { Store, countRender, renders } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/function-declaration@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/function-declaration@component---errorComponent---notFoundComponent---pendingComponent.tsx index a0ebf07dee9..c47441b6b75 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/function-declaration@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/function-declaration@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,6 +1,6 @@ +import { Route } from 'function-declaration.tsx' import * as React from 'react' import { Link, Outlet } from '@tanstack/react-router' -import { Route } from 'function-declaration.tsx' function PostsComponent() { const posts = Route.useLoaderData() return ( diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/function-declaration@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/function-declaration@loader.tsx index 1c4fc213ed7..edf2f4c55e8 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/function-declaration@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/function-declaration@loader.tsx @@ -1,4 +1,3 @@ import * as React from 'react' import { fetchPosts } from '../posts' -const SplitLoader = fetchPosts -export { SplitLoader as loader } +export { fetchPosts as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-default-component-destructured-loader@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-default-component-destructured-loader@component---errorComponent---notFoundComponent---pendingComponent.tsx index 9350cda9e8d..f02256e5189 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-default-component-destructured-loader@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-default-component-destructured-loader@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,3 +1,2 @@ import importedComponent from '../../shared/imported' -const SplitComponent = importedComponent -export { SplitComponent as component } +export { importedComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-default-component-destructured-loader@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-default-component-destructured-loader@loader.tsx index 8691aec6ff7..ce2d8c49b76 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-default-component-destructured-loader@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-default-component-destructured-loader@loader.tsx @@ -1,3 +1,2 @@ import { importedLoader } from '../../shared/imported' -const SplitLoader = importedLoader -export { SplitLoader as loader } +export { importedLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-default-component@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-default-component@component---errorComponent---notFoundComponent---pendingComponent.tsx index d3562270400..3b277870559 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-default-component@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-default-component@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,3 +1,2 @@ import ImportedDefaultComponent from '../../shared/imported' -const SplitComponent = ImportedDefaultComponent -export { SplitComponent as component } +export { ImportedDefaultComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-errorComponent@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-errorComponent@component---errorComponent---notFoundComponent---pendingComponent.tsx index b5bded20a6d..3186d733aa9 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-errorComponent@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-errorComponent@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,7 +1,5 @@ import ImportedDefaultComponent, { importedErrorComponent, } from '../../shared/imported' -const SplitComponent = ImportedDefaultComponent -export { SplitComponent as component } -const SplitErrorComponent = importedErrorComponent -export { SplitErrorComponent as errorComponent } +export { ImportedDefaultComponent as component } +export { importedErrorComponent as errorComponent } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-notFoundComponent@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-notFoundComponent@component---errorComponent---notFoundComponent---pendingComponent.tsx index 9f904a1a193..551246d7ae1 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-notFoundComponent@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-notFoundComponent@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,7 +1,5 @@ import ImportedDefaultComponent, { importedNotFoundComponent, } from '../../shared/imported' -const SplitComponent = ImportedDefaultComponent -export { SplitComponent as component } -const SplitNotFoundComponent = importedNotFoundComponent -export { SplitNotFoundComponent as notFoundComponent } +export { ImportedDefaultComponent as component } +export { importedNotFoundComponent as notFoundComponent } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-pendingComponent@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-pendingComponent@component---errorComponent---notFoundComponent---pendingComponent.tsx index d24c7b15058..084066b62ff 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-pendingComponent@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported-pendingComponent@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,7 +1,5 @@ import ImportedDefaultComponent, { importedPendingComponent, } from '../../shared/imported' -const SplitComponent = ImportedDefaultComponent -export { SplitComponent as component } -const SplitPendingComponent = importedPendingComponent -export { SplitPendingComponent as pendingComponent } +export { ImportedDefaultComponent as component } +export { importedPendingComponent as pendingComponent } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported@component---errorComponent---notFoundComponent---pendingComponent.tsx index e3c06dbe9ea..20045f32586 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,3 +1,2 @@ import { importedComponent } from '../../shared' -const SplitComponent = importedComponent -export { SplitComponent as component } +export { importedComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported@loader.tsx index d0418cbce34..b764e74a92d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/imported@loader.tsx @@ -1,3 +1,2 @@ import { importedLoader } from '../../shared' -const SplitLoader = importedLoader -export { SplitLoader as loader } +export { importedLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/inline@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/inline@component---errorComponent---notFoundComponent---pendingComponent.tsx index d28b8726342..c6b9d06bdf4 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/inline@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/inline@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,11 +1,10 @@ +import { Route, test } from 'inline.tsx' import * as React from 'react' import * as styles from '../style.css' import { TEST_DATA } from '../test.const' const Button = (props: { children: any }) => { return <button>{props.children}</button> } -import { Route } from 'inline.tsx' -import { test } from 'inline.tsx' const SplitComponent = () => { return ( <div className="p-2"> diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/inline@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/inline@loader.tsx index 23fab71edc2..e7fb079a150 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/inline@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/inline@loader.tsx @@ -1,2 +1,2 @@ -import * as React from 'react' import { Route } from 'inline.tsx' +import * as React from 'react' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/jsx-pragmas.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/jsx-pragmas.tsx new file mode 100644 index 00000000000..82989a935d7 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/jsx-pragmas.tsx @@ -0,0 +1,21 @@ +/** + * Styled route + * @jsxImportSource @emotion/react + */ +// @refresh reset +const $$splitErrorComponentImporter = () => + import('jsx-pragmas.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent') +const $$splitComponentImporter = () => + import('jsx-pragmas.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +const $$splitLoaderImporter = () => import('jsx-pragmas.tsx?tsr-split=loader') +import { lazyFn } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/styled')({ + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), + errorComponent: lazyRouteComponent( + $$splitErrorComponentImporter, + 'errorComponent', + ), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/jsx-pragmas@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/jsx-pragmas@component---errorComponent---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..140e7ac86b2 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/jsx-pragmas@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,12 @@ +/** + * Styled route + * @jsxImportSource @emotion/react + */ +// @refresh reset +import { icon } from 'jsx-pragmas.tsx?tsr-shared=1' +function StyledPage() { + return <main css={{ color: 'hotpink' }}>{icon} styled</main> +} +export { StyledPage as component } +const SplitErrorComponent = () => <p css={{ color: 'red' }}>{icon} error</p> +export { SplitErrorComponent as errorComponent } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/jsx-pragmas@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/jsx-pragmas@loader.tsx new file mode 100644 index 00000000000..8bfd9f34686 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/jsx-pragmas@loader.tsx @@ -0,0 +1,9 @@ +/** + * Styled route + * @jsxImportSource @emotion/react + */ +// @refresh reset +import { icon } from 'jsx-pragmas.tsx?tsr-shared=1' +import { fetchTheme } from '../theme' +const SplitLoader = async () => ({ icon, theme: await fetchTheme() }) +export { SplitLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/jsx-pragmas@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/jsx-pragmas@shared.tsx new file mode 100644 index 00000000000..e5b49279017 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/jsx-pragmas@shared.tsx @@ -0,0 +1,7 @@ +/** + * Styled route + * @jsxImportSource @emotion/react + */ +// @refresh reset +const icon = <svg css={{ width: 16 }} /> +export { icon } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/nested-var-dead-code.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/nested-var-dead-code.tsx new file mode 100644 index 00000000000..92b6e60eeda --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/nested-var-dead-code.tsx @@ -0,0 +1,17 @@ +const $$splitComponentImporter = () => + import('nested-var-dead-code.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +const $$splitLoaderImporter = () => + import('nested-var-dead-code.tsx?tsr-split=loader') +import { lazyFn } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +if (typeof window !== 'undefined') { +} +try { +} catch {} +if (typeof window !== 'undefined') { +} +export const Route = createFileRoute('/')({ + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/nested-var-dead-code@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/nested-var-dead-code@component---errorComponent---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..988e29ca910 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/nested-var-dead-code@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,13 @@ +import { compute, connect } from './lib' +if (typeof window !== 'undefined') { + var componentOnly = compute() +} +try { +} catch {} +if (typeof window !== 'undefined') var unbraced = connect() +const SplitComponent = () => ( + <div> + {componentOnly} {unbraced} + </div> +) +export { SplitComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/nested-var-dead-code@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/nested-var-dead-code@loader.tsx new file mode 100644 index 00000000000..6de3585ed15 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/nested-var-dead-code@loader.tsx @@ -0,0 +1,10 @@ +import { loadServerData } from './lib' +if (typeof window !== 'undefined') { +} +try { + var loaderOnly = loadServerData() +} catch {} +if (typeof window !== 'undefined') { +} +const SplitLoader = () => loaderOnly +export { SplitLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/nested-var-dead-code@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/nested-var-dead-code@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/random-number@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/random-number@component---errorComponent---notFoundComponent---pendingComponent.tsx index 26a9f9c1423..6ffa8447256 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/random-number@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/random-number@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,3 +1,4 @@ +import { textColors, gradients, Route } from 'random-number.tsx' import { Await, Link } from '@tanstack/react-router' import { Carbon } from '~/components/Carbon' import { twMerge } from 'tailwind-merge' @@ -13,8 +14,6 @@ import bytesImage from '~/images/bytes.svg' import bytesUidotdevImage from '~/images/bytes-uidotdev.png' import { useMutation } from '~/hooks/useMutation' import { sample } from '~/utils/utils' -import { textColors } from 'random-number.tsx' -import { gradients } from 'random-number.tsx' const courses = [ { name: 'The Official TanStack React Query Course', @@ -23,7 +22,6 @@ const courses = [ description: `Learn how to build enterprise quality apps with TanStack's React Query the easy way with our brand new course.`, }, ] -import { Route } from 'random-number.tsx' async function bytesSignupServerFn({ email }: { email: string }) { 'use server' return fetch(`https://bytes.dev/api/bytes-optin-cors`, { diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/react-memo-component@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/react-memo-component@loader.tsx index 8691aec6ff7..ce2d8c49b76 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/react-memo-component@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/react-memo-component@loader.tsx @@ -1,3 +1,2 @@ import { importedLoader } from '../../shared/imported' -const SplitLoader = importedLoader -export { SplitLoader as loader } +export { importedLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/react-memo-imported-component@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/react-memo-imported-component@loader.tsx index 8691aec6ff7..ce2d8c49b76 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/react-memo-imported-component@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/react-memo-imported-component@loader.tsx @@ -1,3 +1,2 @@ import { importedLoader } from '../../shared/imported' -const SplitLoader = importedLoader -export { SplitLoader as loader } +export { importedLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/reexported-import.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/reexported-import.tsx new file mode 100644 index 00000000000..ba970d66e3c --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/reexported-import.tsx @@ -0,0 +1,9 @@ +const $$splitComponentImporter = () => + import('reexported-import.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +import { Foo } from './foo' +export { Foo, Foo as Bar } +export const Route = createFileRoute('/')({ + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/reexported-import@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/reexported-import@component---errorComponent---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..11dc5fc726c --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/reexported-import@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,5 @@ +import { Foo } from './foo' +function RouteComponent() { + return <Foo /> +} +export { RouteComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/reexported-import@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/reexported-import@loader.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/reexported-import@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/reexported-import@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/retain-export-component@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/retain-export-component@loader.tsx index ddeb4d1e6f8..59ba1dcb63e 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/retain-export-component@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/retain-export-component@loader.tsx @@ -2,5 +2,4 @@ import * as React from 'react' import { importedLoader } from '../../shared/imported' const SIDEBAR_MINI_WIDTH = '80px' const ASIDE_WIDTH = '250px' -const SplitLoader = importedLoader -export { SplitLoader as loader } +export { importedLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/retain-exports-destructured@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/retain-exports-destructured@component---errorComponent---notFoundComponent---pendingComponent.tsx index d64b42fdcc0..e69de29bb2d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/retain-exports-destructured@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/retain-exports-destructured@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,2 +0,0 @@ -import { AboutComponent } from 'retain-exports-destructured.tsx?tsr-shared=1' -export { AboutComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/retain-exports-destructured@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/retain-exports-destructured@loader.tsx index 0397569b0fe..e69de29bb2d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/retain-exports-destructured@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/retain-exports-destructured@loader.tsx @@ -1,2 +0,0 @@ -import { loader } from 'retain-exports-destructured.tsx?tsr-shared=1' -export { loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values.tsx new file mode 100644 index 00000000000..d1f7422ccb4 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values.tsx @@ -0,0 +1,16 @@ +const $$splitComponentImporter = () => + import('shared-components-and-values.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +const $$splitLoaderImporter = () => + import('shared-components-and-values.tsx?tsr-split=loader') +import { lazyFn } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/')({ + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) +export { + Card, + Header, + Table, +} from 'shared-components-and-values.tsx?tsr-shared=components' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values@component---errorComponent---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..95092e88216 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,15 @@ +import { + Card, + Header, + Table, +} from 'shared-components-and-values.tsx?tsr-shared=components' +function Page() { + return ( + <main> + <Header /> + <Card /> + <Table /> + </main> + ) +} +export { Page as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values@loader.tsx new file mode 100644 index 00000000000..88188429ed2 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values@loader.tsx @@ -0,0 +1,3 @@ +import { label, registry } from 'shared-components-and-values.tsx?tsr-shared=1' +const SplitLoader = () => label(registry.size) +export { SplitLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values@shared-components.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values@shared-components.tsx new file mode 100644 index 00000000000..1296304594b --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values@shared-components.tsx @@ -0,0 +1,31 @@ +import { + label, + registry, + columns, +} from 'shared-components-and-values.tsx?tsr-shared=1' +function Card() { + registry.set('card', 'seen') + return <section>{label(registry.size)}</section> +} +function Badge() { + return <i>badge</i> +} +const Header = () => ( + <header> + <Badge /> + </header> +) +function Table() { + return ( + <table> + <tbody> + <tr> + {columns.map((column) => ( + <column.render key={column.key} /> + ))} + </tr> + </tbody> + </table> + ) +} +export { Badge, Card, Header, Table } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values@shared.tsx new file mode 100644 index 00000000000..ae6f75e6986 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-components-and-values@shared.tsx @@ -0,0 +1,9 @@ +const registry = new Map<string, string>() +function label(size: number) { + return `${size} entries` +} +function Cell() { + return <td>cell</td> +} +const columns = [{ key: 'cell', render: Cell }] +export { Cell, columns, label, registry } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-jsx-component-ref@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-jsx-component-ref@component---errorComponent---notFoundComponent---pendingComponent.tsx index 845e1db45cd..9686efb94ca 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-jsx-component-ref@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-jsx-component-ref@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,4 +1,4 @@ -import { shared } from 'shared-jsx-component-ref.tsx?tsr-shared=1' // @ts-nocheck +import { shared } from 'shared-jsx-component-ref.tsx?tsr-shared=1' function SharedComponent() { return <div>{shared}</div> } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-jsx-component-ref@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-jsx-component-ref@loader.tsx index 348aecd9233..cad75c3c66d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-jsx-component-ref@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-jsx-component-ref@loader.tsx @@ -1,3 +1,3 @@ -import { shared } from 'shared-jsx-component-ref.tsx?tsr-shared=1' // @ts-nocheck +import { shared } from 'shared-jsx-component-ref.tsx?tsr-shared=1' const SplitLoader = () => shared export { SplitLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-jsx-component-ref@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-jsx-component-ref@shared.tsx index 60ae1006a18..2c995304acb 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-jsx-component-ref@shared.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-jsx-component-ref@shared.tsx @@ -1,3 +1,2 @@ -// @ts-nocheck const shared = 1 export { shared } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-referencing-route@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-referencing-route@component---errorComponent---notFoundComponent---pendingComponent.tsx index b2c4630c90f..98a0588be70 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-referencing-route@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-referencing-route@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,8 +1,8 @@ import { HEADER } from 'shared-referencing-route.tsx?tsr-shared=1' +import { Route } from 'shared-referencing-route.tsx' function usePageTitle() { return `${HEADER} - ${Route.fullPath}` } -import { Route } from 'shared-referencing-route.tsx' const SplitComponent = () => { const title = usePageTitle() return <div>{title}</div> diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-referencing-route@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-referencing-route@loader.tsx index dbf196f1b32..90e50e488b8 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-referencing-route@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-referencing-route@loader.tsx @@ -1,8 +1,8 @@ import { HEADER } from 'shared-referencing-route.tsx?tsr-shared=1' +import { Route } from 'shared-referencing-route.tsx' function usePageTitle() { return `${HEADER} - ${Route.fullPath}` } -import { Route } from 'shared-referencing-route.tsx' const SplitLoader = async () => { const title = usePageTitle() return { title } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime.tsx index 944c0b8ca87..a96e44b2b9c 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime.tsx @@ -1,4 +1,4 @@ -import { state, createState, createSeed } from 'shared-runtime.tsx?tsr-shared=1' +import { state } from 'shared-runtime.tsx?tsr-shared=1' const $$splitComponentImporter = () => import('shared-runtime.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent') import { lazyRouteComponent } from '@tanstack/react-router' @@ -12,9 +12,9 @@ export const Route = createFileRoute('/shared-runtime')({ loader: lazyFn($$splitLoaderImporter, 'loader'), component: lazyRouteComponent($$splitComponentImporter, 'component'), }) -function createState(initialState: { count: number }): { - state: { count: number } - increment: () => number -} -export { state as firstState, state as secondState, state as 'odd-name' } -export default createSeed +export { + state as firstState, + state as secondState, + state as 'odd-name', + createSeed as default, +} from 'shared-runtime.tsx?tsr-shared=1' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@component---errorComponent---notFoundComponent---pendingComponent.tsx index 5245f6d9eb8..4b78aa7ce7d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,20 +1,9 @@ -import { - state, - createState, - createSeed, - Labels, -} from 'shared-runtime.tsx?tsr-shared=1' +import { state, Labels } from 'shared-runtime.tsx?tsr-shared=1' const label = createComponentLabel() -function createState(initialState: { count: number }): { - state: { count: number } - increment: () => number -} function createComponentLabel() { console.log('shared-runtime:component') return Labels.component } -export { state as firstState, state as secondState, state as 'odd-name' } -export default createSeed const SplitComponent = () => { if (state.count === 3) { throw new Error('route source-map contract') diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@loader.tsx index 6c0c88cab72..4b657b7c7e6 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@loader.tsx @@ -1,15 +1,4 @@ -import { - state, - createState, - createSeed, - increment, -} from 'shared-runtime.tsx?tsr-shared=1' -function createState(initialState: { count: number }): { - state: { count: number } - increment: () => number -} -export { state as firstState, state as secondState, state as 'odd-name' } -export default createSeed +import { state, increment } from 'shared-runtime.tsx?tsr-shared=1' const SplitLoader = () => { increment() return state diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@shared.tsx index c53a7e15682..d0e0b48dae5 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@shared.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-runtime@shared.tsx @@ -6,8 +6,12 @@ function createSeed() { console.log('shared-runtime:seed') return { count: Count.Initial, label: Labels.component } } +function createState(initialState: { count: number }): { + state: { count: number } + increment: () => number +} function createState(initialState: { count: number }) { console.log('shared-runtime:state') return { state: initialState, increment: () => initialState.count++ } } -export { Count, createSeed, createState, increment, Labels, seed, state } +export { Count, Labels, createSeed, createState, increment, seed, state } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/ambient-declarations.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/ambient-declarations.tsx new file mode 100644 index 00000000000..c26cf73c521 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/ambient-declarations.tsx @@ -0,0 +1,11 @@ +const $$splitComponentImporter = () => + import('ambient-declarations.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +const $$splitLoaderImporter = () => + import('ambient-declarations.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyFn } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/')({ + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/ambient-declarations@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/ambient-declarations@component---loader---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..20005e77dd7 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/ambient-declarations@component---loader---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,30 @@ +declare function gtag(event: string): void +declare enum Flags { + On = 1, +} +declare namespace Analytics { + function track(event: string): void +} +declare const buildId: string +declare class Tracker { + static send(event: string): void +} +const SplitLoader = () => { + gtag('load') + Analytics.track(buildId) + Tracker.send('load') + return Flags.On +} +export { SplitLoader as loader } +const SplitComponent = () => ( + <button + onClick={() => { + gtag('click') + Analytics.track(buildId) + Tracker.send('click') + }} + > + {Flags.On} + </button> +) +export { SplitComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/ambient-declarations@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/ambient-declarations@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/ambient-declarations@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/ambient-declarations@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/anonymous-default-export.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/anonymous-default-export.tsx new file mode 100644 index 00000000000..6dcaf9dbdcc --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/anonymous-default-export.tsx @@ -0,0 +1,14 @@ +const $$splitComponentImporter = () => + import('anonymous-default-export.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +const $$splitLoaderImporter = () => + import('anonymous-default-export.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyFn } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/')({ + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) +export default function () { + return null +} diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/anonymous-default-export@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/anonymous-default-export@component---loader---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..0e9bfc513b9 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/anonymous-default-export@component---loader---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,5 @@ +const cache = new Map<string, string>() +const SplitLoader = () => cache.get('title') +export { SplitLoader as loader } +const SplitComponent = () => <div>{cache.size}</div> +export { SplitComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/anonymous-default-export@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/anonymous-default-export@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/anonymous-default-export@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/anonymous-default-export@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/arrow-function@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/arrow-function@component---loader---notFoundComponent---pendingComponent.tsx index e2f2a1ccdb5..a6fa7dc9567 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/arrow-function@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/arrow-function@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,7 +1,7 @@ +import { Route } from 'arrow-function.tsx' import * as React from 'react' import { Link, Outlet } from '@tanstack/react-router' import { fetchPosts } from '../posts' -import { Route } from 'arrow-function.tsx' const PostsComponent = () => { const posts = Route.useLoaderData() return ( @@ -29,6 +29,5 @@ const PostsComponent = () => { </div> ) } -const SplitLoader = fetchPosts -export { SplitLoader as loader } +export { fetchPosts as loader } export { PostsComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/boolean-null-literals@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/boolean-null-literals@component---loader---notFoundComponent---pendingComponent.tsx index 7133d0d77e2..78564ed7f75 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/boolean-null-literals@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/boolean-null-literals@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,5 +1,4 @@ -// Test errorComponent with false literal -const SplitLoader = async () => ({ data: 'test' }) -export { SplitLoader as loader } const SplitComponent = () => <div>Test Component</div> export { SplitComponent as component } +const SplitLoader = async () => ({ data: 'test' }) +export { SplitLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/conditional-properties@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/conditional-properties@component---loader---notFoundComponent---pendingComponent.tsx index 88dcc8e930a..4b2767b39fc 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/conditional-properties@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/conditional-properties@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,7 +1,7 @@ import { isEnabled } from '@features/feature-flags' import TrueImport from '@modules/true-component' import { FalseComponent, falseLoader } from '@modules/false-component' -const SplitLoader = isEnabled ? TrueImport.loader : falseLoader -export { SplitLoader as loader } const SplitComponent = isEnabled ? TrueImport.Component : FalseComponent export { SplitComponent as component } +const SplitLoader = isEnabled ? TrueImport.loader : falseLoader +export { SplitLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-export@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-export@component---loader---notFoundComponent---pendingComponent.tsx index b0abbd2e6e1..47a62c9fb7b 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-export@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-export@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,6 +1,3 @@ -export function getObjectCallback() { - return { getObject: () => ({ constA: 10, constB: 5 }) } -} function AboutComponent() { return ( <div className="p-2"> diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-export@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-export@errorComponent.tsx index 024e0e620e7..e69de29bb2d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-export@errorComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-export@errorComponent.tsx @@ -1,3 +0,0 @@ -export function getObjectCallback() { - return { getObject: () => ({ constA: 10, constB: 5 }) } -} diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-react-memo-imported-component@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-react-memo-imported-component@component---loader---notFoundComponent---pendingComponent.tsx index 66f97a20d0d..1c47f93a093 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-react-memo-imported-component@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-react-memo-imported-component@component---loader---notFoundComponent---pendingComponent.tsx @@ -3,7 +3,6 @@ import { importedLoader } from '../../shared/imported' function Component() { return <div>Component</div> } -const SplitLoader = importedLoader -export { SplitLoader as loader } const SplitComponent = memo(Component) export { SplitComponent as component } +export { importedLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-route-options-defaults@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-route-options-defaults@component---loader---notFoundComponent---pendingComponent.tsx index ae1cd17853b..dd1991207a8 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-route-options-defaults@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/destructured-route-options-defaults@component---loader---notFoundComponent---pendingComponent.tsx @@ -13,5 +13,5 @@ const { component: MyComponent = DefaultComponent, loader = defaultLoader } = function ActualComponent() { return <div>About</div> } -export { loader } export { MyComponent as component } +export { loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-binding-private-constant.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-binding-private-constant.tsx new file mode 100644 index 00000000000..bb2b1ee9cf9 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-binding-private-constant.tsx @@ -0,0 +1,12 @@ +const $$splitComponentImporter = () => + import('exported-binding-private-constant.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +import { createContext } from 'react' +import { createFileRoute } from '@tanstack/react-router' +const initialCount = 0 +export const store = { count: initialCount } +const defaultTheme = 'light' +export const ThemeContext = createContext(defaultTheme) +export const Route = createFileRoute('/')({ + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-binding-private-constant@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-binding-private-constant@component---loader---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..6ee7759381a --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-binding-private-constant@component---loader---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,14 @@ +import { store, ThemeContext } from 'exported-binding-private-constant.tsx' +import { useContext } from 'react' +const initialCount = 0 +const defaultTheme = 'light' +function Page() { + const theme = useContext(ThemeContext) + store.count++ + return ( + <p> + {store.count - initialCount} {theme === defaultTheme ? 'default' : theme} + </p> + ) +} +export { Page as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-binding-private-constant@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-binding-private-constant@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-binding-private-constant@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-binding-private-constant@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-bindings-split-reads.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-bindings-split-reads.tsx new file mode 100644 index 00000000000..27be7cb53c3 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-bindings-split-reads.tsx @@ -0,0 +1,19 @@ +const $$splitComponentImporter = () => + import('exported-bindings-split-reads.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +const $$splitLoaderImporter = () => + import('exported-bindings-split-reads.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyFn } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +export function RouteTitle() { + return <h1>{Route.useLoaderData().title}</h1> +} +export const Route = createFileRoute('/')({ + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) +export { + Store, + countRender, +} from 'exported-bindings-split-reads.tsx?tsr-shared=1' +export { Card } from 'exported-bindings-split-reads.tsx?tsr-shared=components' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-bindings-split-reads@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-bindings-split-reads@component---loader---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..90693c5cb7e --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-bindings-split-reads@component---loader---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,21 @@ +import { Card } from 'exported-bindings-split-reads.tsx?tsr-shared=components' +import { + Store, + countRender, +} from 'exported-bindings-split-reads.tsx?tsr-shared=1' +import { Route } from 'exported-bindings-split-reads.tsx' +function RouteTitle() { + return <h1>{Route.useLoaderData().title}</h1> +} +function Page() { + const store = new Store() + return ( + <main> + <RouteTitle /> + <Card title={`${store.items.size} items, render ${countRender()}`} /> + </main> + ) +} +const SplitLoader = () => ({ title: 'Home' }) +export { SplitLoader as loader } +export { Page as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-bindings-split-reads@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-bindings-split-reads@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-bindings-split-reads@shared-components.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-bindings-split-reads@shared-components.tsx new file mode 100644 index 00000000000..74b5d261c97 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-bindings-split-reads@shared-components.tsx @@ -0,0 +1,4 @@ +function Card({ title }: { title: string }) { + return <section>{title}</section> +} +export { Card } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-bindings-split-reads@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-bindings-split-reads@shared.tsx new file mode 100644 index 00000000000..b41cf0406ae --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/exported-bindings-split-reads@shared.tsx @@ -0,0 +1,8 @@ +class Store { + items = new Map<string, number>() +} +let renders = 0 +function countRender() { + return ++renders +} +export { Store, countRender, renders } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/function-declaration@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/function-declaration@component---loader---notFoundComponent---pendingComponent.tsx index 5289c0c3c4d..24fbaffca76 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/function-declaration@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/function-declaration@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,7 +1,7 @@ +import { Route } from 'function-declaration.tsx' import * as React from 'react' import { Link, Outlet } from '@tanstack/react-router' import { fetchPosts } from '../posts' -import { Route } from 'function-declaration.tsx' function PostsComponent() { const posts = Route.useLoaderData() return ( @@ -29,6 +29,5 @@ function PostsComponent() { </div> ) } -const SplitLoader = fetchPosts -export { SplitLoader as loader } +export { fetchPosts as loader } export { PostsComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-default-component-destructured-loader@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-default-component-destructured-loader@component---loader---notFoundComponent---pendingComponent.tsx index b7ed94da4ac..25150accaec 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-default-component-destructured-loader@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-default-component-destructured-loader@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,5 +1,3 @@ import importedComponent, { importedLoader } from '../../shared/imported' -const SplitLoader = importedLoader -export { SplitLoader as loader } -const SplitComponent = importedComponent -export { SplitComponent as component } +export { importedComponent as component } +export { importedLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-default-component@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-default-component@component---loader---notFoundComponent---pendingComponent.tsx index d3562270400..3b277870559 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-default-component@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-default-component@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,3 +1,2 @@ import ImportedDefaultComponent from '../../shared/imported' -const SplitComponent = ImportedDefaultComponent -export { SplitComponent as component } +export { ImportedDefaultComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-errorComponent@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-errorComponent@component---loader---notFoundComponent---pendingComponent.tsx index d3562270400..3b277870559 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-errorComponent@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-errorComponent@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,3 +1,2 @@ import ImportedDefaultComponent from '../../shared/imported' -const SplitComponent = ImportedDefaultComponent -export { SplitComponent as component } +export { ImportedDefaultComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-errorComponent@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-errorComponent@errorComponent.tsx index 3f20c3e485e..89ff9e5601c 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-errorComponent@errorComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-errorComponent@errorComponent.tsx @@ -1,3 +1,2 @@ import { importedErrorComponent } from '../../shared/imported' -const SplitErrorComponent = importedErrorComponent -export { SplitErrorComponent as errorComponent } +export { importedErrorComponent as errorComponent } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-notFoundComponent@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-notFoundComponent@component---loader---notFoundComponent---pendingComponent.tsx index 9f904a1a193..551246d7ae1 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-notFoundComponent@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-notFoundComponent@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,7 +1,5 @@ import ImportedDefaultComponent, { importedNotFoundComponent, } from '../../shared/imported' -const SplitComponent = ImportedDefaultComponent -export { SplitComponent as component } -const SplitNotFoundComponent = importedNotFoundComponent -export { SplitNotFoundComponent as notFoundComponent } +export { ImportedDefaultComponent as component } +export { importedNotFoundComponent as notFoundComponent } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-pendingComponent@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-pendingComponent@component---loader---notFoundComponent---pendingComponent.tsx index d24c7b15058..084066b62ff 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-pendingComponent@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported-pendingComponent@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,7 +1,5 @@ import ImportedDefaultComponent, { importedPendingComponent, } from '../../shared/imported' -const SplitComponent = ImportedDefaultComponent -export { SplitComponent as component } -const SplitPendingComponent = importedPendingComponent -export { SplitPendingComponent as pendingComponent } +export { ImportedDefaultComponent as component } +export { importedPendingComponent as pendingComponent } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported@component---loader---notFoundComponent---pendingComponent.tsx index 1c4d3e909bc..048e4e1a4f0 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/imported@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,5 +1,3 @@ import { importedComponent, importedLoader } from '../../shared' -const SplitLoader = importedLoader -export { SplitLoader as loader } -const SplitComponent = importedComponent -export { SplitComponent as component } +export { importedComponent as component } +export { importedLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/inline@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/inline@component---loader---notFoundComponent---pendingComponent.tsx index d28b8726342..c6b9d06bdf4 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/inline@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/inline@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,11 +1,10 @@ +import { Route, test } from 'inline.tsx' import * as React from 'react' import * as styles from '../style.css' import { TEST_DATA } from '../test.const' const Button = (props: { children: any }) => { return <button>{props.children}</button> } -import { Route } from 'inline.tsx' -import { test } from 'inline.tsx' const SplitComponent = () => { return ( <div className="p-2"> diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/inline@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/inline@errorComponent.tsx index 23fab71edc2..e7fb079a150 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/inline@errorComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/inline@errorComponent.tsx @@ -1,2 +1,2 @@ -import * as React from 'react' import { Route } from 'inline.tsx' +import * as React from 'react' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/jsx-pragmas.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/jsx-pragmas.tsx new file mode 100644 index 00000000000..b1042b95ce3 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/jsx-pragmas.tsx @@ -0,0 +1,22 @@ +/** + * Styled route + * @jsxImportSource @emotion/react + */ +// @refresh reset +const $$splitErrorComponentImporter = () => + import('jsx-pragmas.tsx?tsr-split=errorComponent') +const $$splitComponentImporter = () => + import('jsx-pragmas.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +const $$splitLoaderImporter = () => + import('jsx-pragmas.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyFn } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/styled')({ + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), + errorComponent: lazyRouteComponent( + $$splitErrorComponentImporter, + 'errorComponent', + ), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/jsx-pragmas@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/jsx-pragmas@component---loader---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..1c2f17454fa --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/jsx-pragmas@component---loader---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,13 @@ +/** + * Styled route + * @jsxImportSource @emotion/react + */ +// @refresh reset +import { icon } from 'jsx-pragmas.tsx?tsr-shared=1' +import { fetchTheme } from '../theme' +function StyledPage() { + return <main css={{ color: 'hotpink' }}>{icon} styled</main> +} +const SplitLoader = async () => ({ icon, theme: await fetchTheme() }) +export { SplitLoader as loader } +export { StyledPage as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/jsx-pragmas@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/jsx-pragmas@errorComponent.tsx new file mode 100644 index 00000000000..7cc18d5b8ef --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/jsx-pragmas@errorComponent.tsx @@ -0,0 +1,8 @@ +/** + * Styled route + * @jsxImportSource @emotion/react + */ +// @refresh reset +import { icon } from 'jsx-pragmas.tsx?tsr-shared=1' +const SplitErrorComponent = () => <p css={{ color: 'red' }}>{icon} error</p> +export { SplitErrorComponent as errorComponent } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/jsx-pragmas@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/jsx-pragmas@shared.tsx new file mode 100644 index 00000000000..e5b49279017 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/jsx-pragmas@shared.tsx @@ -0,0 +1,7 @@ +/** + * Styled route + * @jsxImportSource @emotion/react + */ +// @refresh reset +const icon = <svg css={{ width: 16 }} /> +export { icon } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/nested-var-dead-code.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/nested-var-dead-code.tsx new file mode 100644 index 00000000000..9c6101454c8 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/nested-var-dead-code.tsx @@ -0,0 +1,17 @@ +const $$splitComponentImporter = () => + import('nested-var-dead-code.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +const $$splitLoaderImporter = () => + import('nested-var-dead-code.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyFn } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +if (typeof window !== 'undefined') { +} +try { +} catch {} +if (typeof window !== 'undefined') { +} +export const Route = createFileRoute('/')({ + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/nested-var-dead-code@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/nested-var-dead-code@component---loader---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..44b0d9cc9af --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/nested-var-dead-code@component---loader---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,16 @@ +import { compute, connect, loadServerData } from './lib' +if (typeof window !== 'undefined') { + var componentOnly = compute() +} +try { + var loaderOnly = loadServerData() +} catch {} +if (typeof window !== 'undefined') var unbraced = connect() +const SplitLoader = () => loaderOnly +export { SplitLoader as loader } +const SplitComponent = () => ( + <div> + {componentOnly} {unbraced} + </div> +) +export { SplitComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/nested-var-dead-code@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/nested-var-dead-code@errorComponent.tsx new file mode 100644 index 00000000000..d94ba00c758 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/nested-var-dead-code@errorComponent.tsx @@ -0,0 +1,6 @@ +if (typeof window !== 'undefined') { +} +try { +} catch {} +if (typeof window !== 'undefined') { +} diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/nested-var-dead-code@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/nested-var-dead-code@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/random-number@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/random-number@component---loader---notFoundComponent---pendingComponent.tsx index 37649d8606f..05eb133a5b5 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/random-number@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/random-number@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,3 +1,4 @@ +import { textColors, gradients, Route } from 'random-number.tsx' import { Await, Link, defer } from '@tanstack/react-router' import { Carbon } from '~/components/Carbon' import { twMerge } from 'tailwind-merge' @@ -14,8 +15,6 @@ import bytesImage from '~/images/bytes.svg' import bytesUidotdevImage from '~/images/bytes-uidotdev.png' import { useMutation } from '~/hooks/useMutation' import { sample } from '~/utils/utils' -import { textColors } from 'random-number.tsx' -import { gradients } from 'random-number.tsx' const courses = [ { name: 'The Official TanStack React Query Course', @@ -24,7 +23,6 @@ const courses = [ description: `Learn how to build enterprise quality apps with TanStack's React Query the easy way with our brand new course.`, }, ] -import { Route } from 'random-number.tsx' async function bytesSignupServerFn({ email }: { email: string }) { 'use server' return fetch(`https://bytes.dev/api/bytes-optin-cors`, { diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/react-memo-component@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/react-memo-component@component---loader---notFoundComponent---pendingComponent.tsx index 7c2c8e17de2..c8c0519a844 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/react-memo-component@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/react-memo-component@component---loader---notFoundComponent---pendingComponent.tsx @@ -3,7 +3,6 @@ import { importedLoader } from '../../shared/imported' function Component() { return <div>Component</div> } -const SplitLoader = importedLoader -export { SplitLoader as loader } const SplitComponent = React.memo(Component) export { SplitComponent as component } +export { importedLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/react-memo-imported-component@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/react-memo-imported-component@component---loader---notFoundComponent---pendingComponent.tsx index 83e6e4b28c0..7dd2c38d782 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/react-memo-imported-component@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/react-memo-imported-component@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,6 +1,5 @@ import React from 'react' import { importedLoader, importedComponent } from '../../shared/imported' -const SplitLoader = importedLoader -export { SplitLoader as loader } const SplitComponent = React.memo(importedComponent) export { SplitComponent as component } +export { importedLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/reexported-import.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/reexported-import.tsx new file mode 100644 index 00000000000..b95e7e0b83c --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/reexported-import.tsx @@ -0,0 +1,9 @@ +const $$splitComponentImporter = () => + import('reexported-import.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +import { Foo } from './foo' +export { Foo, Foo as Bar } +export const Route = createFileRoute('/')({ + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/reexported-import@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/reexported-import@component---loader---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..11dc5fc726c --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/reexported-import@component---loader---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,5 @@ +import { Foo } from './foo' +function RouteComponent() { + return <Foo /> +} +export { RouteComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/reexported-import@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/reexported-import@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/reexported-import@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/reexported-import@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/retain-export-component@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/retain-export-component@component---loader---notFoundComponent---pendingComponent.tsx index ddeb4d1e6f8..59ba1dcb63e 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/retain-export-component@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/retain-export-component@component---loader---notFoundComponent---pendingComponent.tsx @@ -2,5 +2,4 @@ import * as React from 'react' import { importedLoader } from '../../shared/imported' const SIDEBAR_MINI_WIDTH = '80px' const ASIDE_WIDTH = '250px' -const SplitLoader = importedLoader -export { SplitLoader as loader } +export { importedLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/retain-exports-destructured@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/retain-exports-destructured@component---loader---notFoundComponent---pendingComponent.tsx index c3ddde13a87..e69de29bb2d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/retain-exports-destructured@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/retain-exports-destructured@component---loader---notFoundComponent---pendingComponent.tsx @@ -1 +0,0 @@ -export { SplitLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-class.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-class.tsx index c9bffc92ba3..ac25451bd3c 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-class.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-class.tsx @@ -5,15 +5,6 @@ const $$splitLoaderImporter = () => import('shared-class.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') import { lazyFn } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router' -class DataStore { - data = new Map() - get(k: string) { - return this.data.get(k) - } - set(k: string, v: unknown) { - this.data.set(k, v) - } -} export const Route = createFileRoute('/store')({ loader: lazyFn($$splitLoaderImporter, 'loader'), component: lazyRouteComponent($$splitComponentImporter, 'component'), diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-class@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-class@errorComponent.tsx index 31a1763b5d4..e69de29bb2d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-class@errorComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-class@errorComponent.tsx @@ -1,9 +0,0 @@ -class DataStore { - data = new Map() - get(k: string) { - return this.data.get(k) - } - set(k: string, v: unknown) { - this.data.set(k, v) - } -} diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-components-and-values.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-components-and-values.tsx new file mode 100644 index 00000000000..33f64e8ba21 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-components-and-values.tsx @@ -0,0 +1,16 @@ +const $$splitComponentImporter = () => + import('shared-components-and-values.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/react-router' +const $$splitLoaderImporter = () => + import('shared-components-and-values.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyFn } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/')({ + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) +export { + Card, + Header, + Table, +} from 'shared-components-and-values.tsx?tsr-shared=components' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-components-and-values@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-components-and-values@component---loader---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..06391567980 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-components-and-values@component---loader---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,18 @@ +import { + Card, + Header, + Table, +} from 'shared-components-and-values.tsx?tsr-shared=components' +import { registry, label } from 'shared-components-and-values.tsx?tsr-shared=1' +function Page() { + return ( + <main> + <Header /> + <Card /> + <Table /> + </main> + ) +} +const SplitLoader = () => label(registry.size) +export { SplitLoader as loader } +export { Page as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-components-and-values@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-components-and-values@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-components-and-values@shared-components.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-components-and-values@shared-components.tsx new file mode 100644 index 00000000000..ce8009f7153 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-components-and-values@shared-components.tsx @@ -0,0 +1,31 @@ +import { + registry, + label, + columns, +} from 'shared-components-and-values.tsx?tsr-shared=1' +function Card() { + registry.set('card', 'seen') + return <section>{label(registry.size)}</section> +} +function Badge() { + return <i>badge</i> +} +const Header = () => ( + <header> + <Badge /> + </header> +) +function Table() { + return ( + <table> + <tbody> + <tr> + {columns.map((column) => ( + <column.render key={column.key} /> + ))} + </tr> + </tbody> + </table> + ) +} +export { Badge, Card, Header, Table } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-components-and-values@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-components-and-values@shared.tsx new file mode 100644 index 00000000000..ae6f75e6986 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-components-and-values@shared.tsx @@ -0,0 +1,9 @@ +const registry = new Map<string, string>() +function label(size: number) { + return `${size} entries` +} +function Cell() { + return <td>cell</td> +} +const columns = [{ key: 'cell', render: Cell }] +export { Cell, columns, label, registry } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-jsx-component-ref@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-jsx-component-ref@component---loader---notFoundComponent---pendingComponent.tsx index 82b634246f6..9fad6817772 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-jsx-component-ref@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-jsx-component-ref@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck const shared = 1 function SharedComponent() { return <div>{shared}</div> diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-referencing-route@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-referencing-route@component---loader---notFoundComponent---pendingComponent.tsx index da98398ae17..1df0c3df5a7 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-referencing-route@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-referencing-route@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,8 +1,8 @@ +import { Route } from 'shared-referencing-route.tsx' const HEADER = 'Page' function usePageTitle() { return `${HEADER} - ${Route.fullPath}` } -import { Route } from 'shared-referencing-route.tsx' const SplitLoader = async () => { const title = usePageTitle() return { title } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime.tsx index fdc18fa37b8..b19b27c2946 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime.tsx @@ -1,4 +1,4 @@ -import { state, createState, createSeed } from 'shared-runtime.tsx?tsr-shared=1' +import { state } from 'shared-runtime.tsx?tsr-shared=1' const $$splitComponentImporter = () => import('shared-runtime.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') import { lazyRouteComponent } from '@tanstack/react-router' @@ -12,9 +12,9 @@ export const Route = createFileRoute('/shared-runtime')({ loader: lazyFn($$splitLoaderImporter, 'loader'), component: lazyRouteComponent($$splitComponentImporter, 'component'), }) -function createState(initialState: { count: number }): { - state: { count: number } - increment: () => number -} -export { state as firstState, state as secondState, state as 'odd-name' } -export default createSeed +export { + state as firstState, + state as secondState, + state as 'odd-name', + createSeed as default, +} from 'shared-runtime.tsx?tsr-shared=1' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@component---loader---notFoundComponent---pendingComponent.tsx index 053cf24d5aa..b5b124e5ff5 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,21 +1,9 @@ -import { - state, - createState, - createSeed, - Labels, - increment, -} from 'shared-runtime.tsx?tsr-shared=1' +import { state, Labels, increment } from 'shared-runtime.tsx?tsr-shared=1' const label = createComponentLabel() -function createState(initialState: { count: number }): { - state: { count: number } - increment: () => number -} function createComponentLabel() { console.log('shared-runtime:component') return Labels.component } -export { state as firstState, state as secondState, state as 'odd-name' } -export default createSeed const SplitLoader = () => { increment() return state diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@errorComponent.tsx index 03cacac5b39..e69de29bb2d 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@errorComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@errorComponent.tsx @@ -1,7 +0,0 @@ -import { state, createState, createSeed } from 'shared-runtime.tsx?tsr-shared=1' -function createState(initialState: { count: number }): { - state: { count: number } - increment: () => number -} -export { state as firstState, state as secondState, state as 'odd-name' } -export default createSeed diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@shared.tsx index c53a7e15682..d0e0b48dae5 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@shared.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-runtime@shared.tsx @@ -6,8 +6,12 @@ function createSeed() { console.log('shared-runtime:seed') return { count: Count.Initial, label: Labels.component } } +function createState(initialState: { count: number }): { + state: { count: number } + increment: () => number +} function createState(initialState: { count: number }) { console.log('shared-runtime:state') return { state: initialState, increment: () => initialState.count++ } } -export { Count, createSeed, createState, increment, Labels, seed, state } +export { Count, Labels, createSeed, createState, increment, seed, state } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/arrow-function@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/arrow-function@component.tsx index c84e6f25d40..5f2a604b7b3 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/arrow-function@component.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/arrow-function@component.tsx @@ -1,6 +1,6 @@ +import { Route } from 'arrow-function.tsx' import * as Solid from 'solid-js' import { Link, Outlet } from '@tanstack/solid-router' -import { Route } from 'arrow-function.tsx' const PostsComponent = () => { const posts = Route.useLoaderData() return ( diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-components-and-values.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-components-and-values.tsx new file mode 100644 index 00000000000..f39d3809b51 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-components-and-values.tsx @@ -0,0 +1,14 @@ +import { label, registry } from 'shared-components-and-values.tsx?tsr-shared=1' +const $$splitComponentImporter = () => + import('shared-components-and-values.tsx?tsr-split=component') +import { lazyRouteComponent } from '@tanstack/solid-router' +import { createFileRoute } from '@tanstack/solid-router' +export const Route = createFileRoute('/')({ + loader: () => label(registry.size), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) +export { + Card, + Header, + Table, +} from 'shared-components-and-values.tsx?tsr-shared=1' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-components-and-values@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-components-and-values@component.tsx new file mode 100644 index 00000000000..6039943f35e --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-components-and-values@component.tsx @@ -0,0 +1,15 @@ +import { + Header, + Card, + Table, +} from 'shared-components-and-values.tsx?tsr-shared=1' +function Page() { + return ( + <main> + <Header /> + <Card /> + <Table /> + </main> + ) +} +export { Page as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-components-and-values@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-components-and-values@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-components-and-values@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-components-and-values@notFoundComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-components-and-values@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-components-and-values@shared.tsx new file mode 100644 index 00000000000..cb9ca073671 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/1-default/shared-components-and-values@shared.tsx @@ -0,0 +1,34 @@ +const registry = new Map<string, string>() +function label(size: number) { + return `${size} entries` +} +function Card() { + registry.set('card', 'seen') + return <section>{label(registry.size)}</section> +} +function Badge() { + return <i>badge</i> +} +const Header = () => ( + <header> + <Badge /> + </header> +) +function Cell() { + return <td>cell</td> +} +const columns = [{ key: 'cell', render: Cell }] +function Table() { + return ( + <table> + <tbody> + <tr> + {columns.map((column) => ( + <column.render key={column.key} /> + ))} + </tr> + </tbody> + </table> + ) +} +export { Badge, Card, Cell, Header, Table, columns, label, registry } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/arrow-function@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/arrow-function@component---errorComponent---notFoundComponent---pendingComponent.tsx index c84e6f25d40..5f2a604b7b3 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/arrow-function@component---errorComponent---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/arrow-function@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -1,6 +1,6 @@ +import { Route } from 'arrow-function.tsx' import * as Solid from 'solid-js' import { Link, Outlet } from '@tanstack/solid-router' -import { Route } from 'arrow-function.tsx' const PostsComponent = () => { const posts = Route.useLoaderData() return ( diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/arrow-function@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/arrow-function@loader.tsx index 888abc85937..47b325bb7c2 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/arrow-function@loader.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/arrow-function@loader.tsx @@ -1,3 +1,2 @@ import { fetchPosts } from '../posts' -const SplitLoader = fetchPosts -export { SplitLoader as loader } +export { fetchPosts as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-components-and-values.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-components-and-values.tsx new file mode 100644 index 00000000000..b6d1e121c7a --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-components-and-values.tsx @@ -0,0 +1,16 @@ +const $$splitComponentImporter = () => + import('shared-components-and-values.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/solid-router' +const $$splitLoaderImporter = () => + import('shared-components-and-values.tsx?tsr-split=loader') +import { lazyFn } from '@tanstack/solid-router' +import { createFileRoute } from '@tanstack/solid-router' +export const Route = createFileRoute('/')({ + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) +export { + Card, + Header, + Table, +} from 'shared-components-and-values.tsx?tsr-shared=1' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-components-and-values@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-components-and-values@component---errorComponent---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..6039943f35e --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-components-and-values@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,15 @@ +import { + Header, + Card, + Table, +} from 'shared-components-and-values.tsx?tsr-shared=1' +function Page() { + return ( + <main> + <Header /> + <Card /> + <Table /> + </main> + ) +} +export { Page as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-components-and-values@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-components-and-values@loader.tsx new file mode 100644 index 00000000000..88188429ed2 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-components-and-values@loader.tsx @@ -0,0 +1,3 @@ +import { label, registry } from 'shared-components-and-values.tsx?tsr-shared=1' +const SplitLoader = () => label(registry.size) +export { SplitLoader as loader } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-components-and-values@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-components-and-values@shared.tsx new file mode 100644 index 00000000000..cb9ca073671 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/2-components-combined-loader-separate/shared-components-and-values@shared.tsx @@ -0,0 +1,34 @@ +const registry = new Map<string, string>() +function label(size: number) { + return `${size} entries` +} +function Card() { + registry.set('card', 'seen') + return <section>{label(registry.size)}</section> +} +function Badge() { + return <i>badge</i> +} +const Header = () => ( + <header> + <Badge /> + </header> +) +function Cell() { + return <td>cell</td> +} +const columns = [{ key: 'cell', render: Cell }] +function Table() { + return ( + <table> + <tbody> + <tr> + {columns.map((column) => ( + <column.render key={column.key} /> + ))} + </tr> + </tbody> + </table> + ) +} +export { Badge, Card, Cell, Header, Table, columns, label, registry } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/arrow-function@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/arrow-function@component---loader---notFoundComponent---pendingComponent.tsx index 7fdf2dd9d09..38e9db00f75 100644 --- a/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/arrow-function@component---loader---notFoundComponent---pendingComponent.tsx +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/arrow-function@component---loader---notFoundComponent---pendingComponent.tsx @@ -1,7 +1,7 @@ +import { Route } from 'arrow-function.tsx' import * as Solid from 'solid-js' import { Link, Outlet } from '@tanstack/solid-router' import { fetchPosts } from '../posts' -import { Route } from 'arrow-function.tsx' const PostsComponent = () => { const posts = Route.useLoaderData() return ( @@ -29,6 +29,5 @@ const PostsComponent = () => { </div> ) } -const SplitLoader = fetchPosts -export { SplitLoader as loader } +export { fetchPosts as loader } export { PostsComponent as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-components-and-values.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-components-and-values.tsx new file mode 100644 index 00000000000..7998dd64125 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-components-and-values.tsx @@ -0,0 +1,16 @@ +const $$splitComponentImporter = () => + import('shared-components-and-values.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyRouteComponent } from '@tanstack/solid-router' +const $$splitLoaderImporter = () => + import('shared-components-and-values.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent') +import { lazyFn } from '@tanstack/solid-router' +import { createFileRoute } from '@tanstack/solid-router' +export const Route = createFileRoute('/')({ + loader: lazyFn($$splitLoaderImporter, 'loader'), + component: lazyRouteComponent($$splitComponentImporter, 'component'), +}) +export { + Card, + Header, + Table, +} from 'shared-components-and-values.tsx?tsr-shared=1' diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-components-and-values@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-components-and-values@component---loader---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..562ef928b4c --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-components-and-values@component---loader---notFoundComponent---pendingComponent.tsx @@ -0,0 +1,19 @@ +import { + Header, + Card, + registry, + label, + Table, +} from 'shared-components-and-values.tsx?tsr-shared=1' +function Page() { + return ( + <main> + <Header /> + <Card /> + <Table /> + </main> + ) +} +const SplitLoader = () => label(registry.size) +export { SplitLoader as loader } +export { Page as component } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-components-and-values@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-components-and-values@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-components-and-values@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-components-and-values@shared.tsx new file mode 100644 index 00000000000..cb9ca073671 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/solid/3-all-combined-errorComponent-separate/shared-components-and-values@shared.tsx @@ -0,0 +1,34 @@ +const registry = new Map<string, string>() +function label(size: number) { + return `${size} entries` +} +function Card() { + registry.set('card', 'seen') + return <section>{label(registry.size)}</section> +} +function Badge() { + return <i>badge</i> +} +const Header = () => ( + <header> + <Badge /> + </header> +) +function Cell() { + return <td>cell</td> +} +const columns = [{ key: 'cell', render: Cell }] +function Table() { + return ( + <table> + <tbody> + <tr> + {columns.map((column) => ( + <column.render key={column.key} /> + ))} + </tr> + </tbody> + </table> + ) +} +export { Badge, Card, Cell, Header, Table, columns, label, registry } diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/ambient-declarations.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/ambient-declarations.tsx new file mode 100644 index 00000000000..2a7ea08f50c --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/test-files/react/ambient-declarations.tsx @@ -0,0 +1,33 @@ +import { createFileRoute } from '@tanstack/react-router' + +declare function gtag(event: string): void +declare enum Flags { + On = 1, +} +declare namespace Analytics { + function track(event: string): void +} +declare const buildId: string +declare class Tracker { + static send(event: string): void +} + +export const Route = createFileRoute('/')({ + loader: () => { + gtag('load') + Analytics.track(buildId) + Tracker.send('load') + return Flags.On + }, + component: () => ( + <button + onClick={() => { + gtag('click') + Analytics.track(buildId) + Tracker.send('click') + }} + > + {Flags.On} + </button> + ), +}) diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/anonymous-default-export.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/anonymous-default-export.tsx new file mode 100644 index 00000000000..e1fa01229d0 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/test-files/react/anonymous-default-export.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from '@tanstack/react-router' + +const cache = new Map<string, string>() + +export const Route = createFileRoute('/')({ + loader: () => cache.get('title'), + component: () => <div>{cache.size}</div>, +}) + +export default function () { + return null +} diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/exported-binding-private-constant.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/exported-binding-private-constant.tsx new file mode 100644 index 00000000000..c2d5a80102e --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/test-files/react/exported-binding-private-constant.tsx @@ -0,0 +1,22 @@ +import { createContext, useContext } from 'react' +import { createFileRoute } from '@tanstack/react-router' + +const initialCount = 0 + +export const store = { count: initialCount } + +const defaultTheme = 'light' + +export const ThemeContext = createContext(defaultTheme) + +function Page() { + const theme = useContext(ThemeContext) + store.count++ + return ( + <p> + {store.count - initialCount} {theme === defaultTheme ? 'default' : theme} + </p> + ) +} + +export const Route = createFileRoute('/')({ component: Page }) diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/exported-bindings-split-reads.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/exported-bindings-split-reads.tsx new file mode 100644 index 00000000000..d611b6e7335 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/test-files/react/exported-bindings-split-reads.tsx @@ -0,0 +1,34 @@ +import { createFileRoute } from '@tanstack/react-router' + +export function Card({ title }: { title: string }) { + return <section>{title}</section> +} + +export class Store { + items = new Map<string, number>() +} + +let renders = 0 + +export function countRender() { + return ++renders +} + +export function RouteTitle() { + return <h1>{Route.useLoaderData().title}</h1> +} + +function Page() { + const store = new Store() + return ( + <main> + <RouteTitle /> + <Card title={`${store.items.size} items, render ${countRender()}`} /> + </main> + ) +} + +export const Route = createFileRoute('/')({ + loader: () => ({ title: 'Home' }), + component: Page, +}) diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/jsx-pragmas.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/jsx-pragmas.tsx new file mode 100644 index 00000000000..ea24154dd72 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/test-files/react/jsx-pragmas.tsx @@ -0,0 +1,19 @@ +/** + * Styled route + * @jsxImportSource @emotion/react + */ +// @refresh reset +import { createFileRoute } from '@tanstack/react-router' +import { fetchTheme } from '../theme' + +const icon = <svg css={{ width: 16 }} /> + +export const Route = createFileRoute('/styled')({ + loader: async () => ({ icon, theme: await fetchTheme() }), + component: StyledPage, + errorComponent: () => <p css={{ color: 'red' }}>{icon} error</p>, +}) + +function StyledPage() { + return <main css={{ color: 'hotpink' }}>{icon} styled</main> +} diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/nested-var-dead-code.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/nested-var-dead-code.tsx new file mode 100644 index 00000000000..d9c8672ed39 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/test-files/react/nested-var-dead-code.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from '@tanstack/react-router' +import { compute, connect, loadServerData } from './lib' + +if (typeof window !== 'undefined') { + var componentOnly = compute() +} + +try { + var loaderOnly = loadServerData() +} catch {} + +if (typeof window !== 'undefined') var unbraced = connect() + +export const Route = createFileRoute('/')({ + loader: () => loaderOnly, + component: () => ( + <div> + {componentOnly} {unbraced} + </div> + ), +}) diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/reexported-import.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/reexported-import.tsx new file mode 100644 index 00000000000..cfc1024320e --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/test-files/react/reexported-import.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from '@tanstack/react-router' +import { Foo } from './foo' + +export { Foo, Foo as Bar } + +export const Route = createFileRoute('/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return <Foo /> +} diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/shared-components-and-values.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/shared-components-and-values.tsx new file mode 100644 index 00000000000..a3ec578f851 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/test-files/react/shared-components-and-values.tsx @@ -0,0 +1,57 @@ +import { createFileRoute } from '@tanstack/react-router' + +const registry = new Map<string, string>() + +function label(size: number) { + return `${size} entries` +} + +export function Card() { + registry.set('card', 'seen') + return <section>{label(registry.size)}</section> +} + +function Badge() { + return <i>badge</i> +} + +export const Header = () => ( + <header> + <Badge /> + </header> +) + +function Cell() { + return <td>cell</td> +} + +const columns = [{ key: 'cell', render: Cell }] + +export function Table() { + return ( + <table> + <tbody> + <tr> + {columns.map((column) => ( + <column.render key={column.key} /> + ))} + </tr> + </tbody> + </table> + ) +} + +function Page() { + return ( + <main> + <Header /> + <Card /> + <Table /> + </main> + ) +} + +export const Route = createFileRoute('/')({ + loader: () => label(registry.size), + component: Page, +}) diff --git a/packages/router-plugin/tests/code-splitter/test-files/solid/shared-components-and-values.tsx b/packages/router-plugin/tests/code-splitter/test-files/solid/shared-components-and-values.tsx new file mode 100644 index 00000000000..7e0650e03fd --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/test-files/solid/shared-components-and-values.tsx @@ -0,0 +1,57 @@ +import { createFileRoute } from '@tanstack/solid-router' + +const registry = new Map<string, string>() + +function label(size: number) { + return `${size} entries` +} + +export function Card() { + registry.set('card', 'seen') + return <section>{label(registry.size)}</section> +} + +function Badge() { + return <i>badge</i> +} + +export const Header = () => ( + <header> + <Badge /> + </header> +) + +function Cell() { + return <td>cell</td> +} + +const columns = [{ key: 'cell', render: Cell }] + +export function Table() { + return ( + <table> + <tbody> + <tr> + {columns.map((column) => ( + <column.render key={column.key} /> + ))} + </tr> + </tbody> + </table> + ) +} + +function Page() { + return ( + <main> + <Header /> + <Card /> + <Table /> + </main> + ) +} + +export const Route = createFileRoute('/')({ + loader: () => label(registry.size), + component: Page, +}) diff --git a/packages/router-plugin/tests/detect-route-codesplit-groupings.test.ts b/packages/router-plugin/tests/detect-route-codesplit-groupings.test.ts index 281f25127eb..3673510831f 100644 --- a/packages/router-plugin/tests/detect-route-codesplit-groupings.test.ts +++ b/packages/router-plugin/tests/detect-route-codesplit-groupings.test.ts @@ -122,7 +122,7 @@ describe('detectCodeSplitGroupingsFromRoute - success', () => { ({ code, expectedGrouping }) => { const result = detectCodeSplitGroupingsFromRoute({ code: code, - sourceFilename: 'test.ts', + filename: 'test.ts', }) expect(result.groupings).toEqual(expectedGrouping) @@ -187,7 +187,7 @@ describe('detectCodeSplitGroupingsFromRoute - fail', () => { expect(() => detectCodeSplitGroupingsFromRoute({ code: code, - sourceFilename: 'test.ts', + filename: 'test.ts', }), ).toThrowError() }) diff --git a/packages/router-plugin/tests/react-refresh-route-components.test.ts b/packages/router-plugin/tests/react-refresh-route-components.test.ts index b0c14b7d8fb..4108ef79c82 100644 --- a/packages/router-plugin/tests/react-refresh-route-components.test.ts +++ b/packages/router-plugin/tests/react-refresh-route-components.test.ts @@ -6,13 +6,20 @@ * transform (the one `@vitejs/plugin-react` uses) on the output to see which * components are registered and how their hook signatures are computed. */ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { createServer } from 'vite' import { describe, expect, it } from 'vitest' +import { tanstackRouter } from '../src/vite' import { compileRouteModules, declarationOf, declaratorName, evaluateModule, + expectRefreshableRouteOption, expectRegisteredRouteOption, + expectValidModules, + exportedBinding, exportedNames, getRouteOption, head, @@ -22,7 +29,7 @@ import { topLevelDeclarators, transformWithRouteHmrPlugin, } from './regression-helpers' -import type { ESTree } from 'vite' +import type { ESTree, ViteDevServer } from 'vite' /** Compiles every module the code splitter emits, with React HMR. */ function compileWithCodeSplitting(code: string) { @@ -43,6 +50,20 @@ describe('React Refresh registers route components', () => { await expectRegisteredRouteOption(code, 'component') }) + // Source: ReactFreshBabelPlugin-test "registers top-level variable + // declarations with arrow functions" + it.each([ + ['as any', `(() => <p>hi</p>) as any`], + ['satisfies', `(() => <p>hi</p>) satisfies FC`], + ['a non-null assertion', `(() => <p>hi</p>)!`], + ])('written inline and wrapped in %s', async (_, component) => { + const code = transformWithRouteHmrPlugin( + `${head}import type { FC } from 'react' +export const Route = createFileRoute('/')({ component: ${component} })`, + ) + await expectRegisteredRouteOption(code, 'component') + }) + it('for every inline component option of a root route, under distinct names', async () => { const options = { component: '() => <p>component</p>', @@ -107,6 +128,18 @@ ${Object.entries(options) }, ) + // React Refresh resolves memo(...) through the function it wraps. + it('wrapped in memo(...) around a top-level component', async () => { + await expectRefreshableRouteOption( + transformWithRouteHmrPlugin(`${head}import { memo } from 'react' +function Page() { + return <p>hi</p> +} +export const Route = createFileRoute('/')({ component: memo(Page) })`), + 'component', + ) + }) + // Source: ReactFreshBabelPlugin-test "does not consider require-like methods to be HOCs" it('but leaves an imported lowercase component to the module that declares it', () => { const code = transformWithRouteHmrPlugin( @@ -190,6 +223,82 @@ export const Route = createFileRoute('/')({ }) }) +describe('React Refresh registers split route components', () => { + /** The binding a chunk exports as `component`, and what React Refresh registers. */ + async function splitComponent(chunk: string) { + const binding = exportedBinding(chunk, 'component') + expect(binding).toBeDefined() + return { + binding: binding!, + registered: (await reactRefresh(chunk)).registered, + } + } + + // Source: ReactFreshBabelPlugin-test "registers likely HOCs with inline functions" + it.each([ + ['memo', `import { memo } from 'react'`, `memo(() => <p>hi</p>)`], + [ + 'forwardRef', + `import { forwardRef } from 'react'`, + `forwardRef(function (props, ref) {\n return <p ref={ref}>hi</p>\n})`, + ], + ])( + 'written inline in a %s call, with the function it wraps', + async (hoc, imports, component) => { + const modules = compileWithCodeSplitting(`${head}${imports} +export const Route = createFileRoute('/')({ component: ${component} })`) + await expectValidModules(modules) + const { binding, registered } = await splitComponent( + modules['virtual component']!, + ) + expect(registered).toEqual( + expect.arrayContaining([binding, `${binding}$${hoc}`]), + ) + }, + ) + + // Source: ReactFreshBabelPlugin-test "registers top-level function + // declarations" (one component, two uses) + it('that is also the unsplit pendingComponent', async () => { + const modules = compileWithCodeSplitting(`${head}function page() { + return <p>hi</p> +} +export const Route = createFileRoute('/')({ component: page, pendingComponent: page })`) + await expectValidModules(modules) + const { binding, registered } = await splitComponent( + modules['virtual component']!, + ) + expect(registered).toContain(binding) + }) + + // Source: ReactFreshBabelPlugin-test "registers top-level exported function + // declarations" (`export { Baz }`) + it('but keeps a component exported by specifier in the route module, registered and exported', async () => { + const modules = + compileWithCodeSplitting(`${head}const page = () => <p>hi</p> +export { page } +export const Route = createFileRoute('/')({ component: page })`) + await expectValidModules(modules) + expect(exportedNames(modules.reference!)).toContain('page') + await expectRegisteredRouteOption(modules.reference!, 'component') + }) + + // Inputs adapted from React Compiler simple-alias.js: a lowercase component + // that the loader also uses moves to the shared module, and React HMR + // renames lowercase split components. + it('keeps the modules valid when a lowercase component is shared with the loader', async () => { + const modules = compileWithCodeSplitting(`${head}function page() { + return <div>hello</div> +} +function load() { + return page.name +} +export const Route = createFileRoute('/')({ component: page, loader: load })`) + expect(modules.shared).toMatch(declarationOf('page')) + await expectValidModules(modules) + }) +}) + describe('React Refresh hoisting picks names nothing else uses', () => { it.each([ [ @@ -256,6 +365,29 @@ export const Route = createRootRoute({ component: () => <div /> }) await expectRegisteredRouteOption(reference!, 'component') }) + it("hoists the components of the module's Route when another route is created first", async () => { + const code = `import { createFileRoute, createRoute } from '@tanstack/react-router' +export const extra = createRoute({ + getParentRoute: () => Route, + path: 'extra', + component: () => <p>extra</p>, +}) +export const Route = createFileRoute('/')({ + component: () => <p>home</p>, + pendingComponent: () => <p>pending</p>, +})` + await expectRefreshableRouteOption( + transformWithRouteHmrPlugin(code), + 'component', + ) + // The split `component` loads from its chunk; the reference module keeps + // `pendingComponent`. + await expectRefreshableRouteOption( + compileWithCodeSplitting(code).reference!, + 'pendingComponent', + ) + }) + it('declares hoisted components before the route that uses them', () => { // The lowercase `page`, declared in the same statement as `Route`, is // renamed for React Refresh as well. @@ -279,3 +411,156 @@ export const page = () => <div />, Route = createRootRoute({ expect(declaration!.start).toBeLessThan(route.start) }) }) + +/** + * React HMR renames and hoists route components, and may split them out of the + * route module. Modules that import the route module's exports still need them, + * as the same values the route options use. These tests load the route module + * from a Vite dev server running the real plugin. + */ +describe.each([true, false])( + 'route module exports with React HMR and autoCodeSplitting=%s', + (autoCodeSplitting) => { + /** + * Transforms and loads `routes/page.tsx` in a dev server, and passes its + * exports to `use` while the server can still load split chunks. + */ + async function withRouteModule( + route: string, + use: (routeModule: Record<string, any>) => Promise<void> | void, + ) { + const root = await mkdtemp(path.join(__dirname, '.hmr-exports-')) + let server: ViteDevServer | undefined + try { + await mkdir(path.join(root, 'routes')) + await writeFile( + path.join(root, 'routes/__root.tsx'), + `import { createRootRoute } from '@tanstack/react-router' +export const Route = createRootRoute({})`, + ) + await writeFile(path.join(root, 'routes/page.tsx'), route) + server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + tanstackRouter({ + target: 'react', + autoCodeSplitting, + codeSplittingOptions: { + defaultBehavior: [['component'], ['loader']], + }, + routesDirectory: './routes', + generatedRouteTree: './routeTree.gen.ts', + }), + ], + server: { middlewareMode: true }, + appType: 'custom', + }) + const transformed = await server.transformRequest('/routes/page.tsx') + // The route module went through the React HMR transforms + expect(transformed?.code).toContain('TSRFastRefreshAnchor') + await use(await server.ssrLoadModule('/routes/page.tsx')) + } finally { + await server?.close() + await rm(root, { recursive: true, force: true }) + } + } + + it.each([ + { + name: 'function', + declaration: 'export function component() { return null }', + options: 'component', + exported: { component: ['component'] }, + }, + { + name: 'overloaded function', + declaration: + 'export function component(): null; export function component() { return null }', + options: 'component', + exported: { component: ['component'] }, + }, + { + name: 'function used by two options', + declaration: 'export function component() { return null }', + options: 'component, errorComponent: component', + exported: { component: ['component', 'errorComponent'] }, + }, + { + name: 'variable', + declaration: 'export const component = () => null', + options: 'component', + exported: { component: ['component'] }, + }, + { + name: 'class', + declaration: `import { Component } from 'react' +export class component extends Component { render() { return null } }`, + options: 'component', + exported: { component: ['component'] }, + }, + { + name: 'variables declared together', + declaration: + 'export const component = () => null, pendingComponent = () => null', + options: 'component, pendingComponent', + exported: { + component: ['component'], + pendingComponent: ['pendingComponent'], + }, + }, + { + name: 'aliased', + declaration: + 'function component() { return null }; export { component as View }', + options: 'component', + exported: { View: ['component'] }, + }, + { + name: 'default', + declaration: 'export default function component() { return null }', + options: 'component', + exported: { default: ['component'] }, + }, + ])( + 'keep a $name component exported as the route option value', + async ({ declaration, options, exported }) => { + await withRouteModule( + `${head}${declaration} +export const Route = createFileRoute('/page')({ ${options} })`, + (routeModule) => { + for (const [name, keys] of Object.entries(exported)) { + expect(routeModule).toHaveProperty(name) + for (const key of keys) { + expect(routeModule[name], `${name} is the ${key} option`).toBe( + routeModule.Route.options[key], + ) + } + } + }, + ) + }, + 30_000, + ) + + it('keep exported overloads and their aliases that unsplit and split options call', async () => { + await withRouteModule( + `${head}export function helper(value: string): string +export function helper(value: number): number +export function helper(value: string | number) { return value } +export { helper as helperAlias } +export const Route = createFileRoute('/page')({ + component: () => null, + beforeLoad: () => helper('before'), + loader: () => helper(42), +})`, + async (routeModule) => { + expect(routeModule.helperAlias).toBe(routeModule.helper) + expect(routeModule.Route.options.beforeLoad()).toBe('before') + expect(await routeModule.Route.options.loader()).toBe(42) + }, + ) + }, 30_000) + }, +) diff --git a/packages/router-plugin/tests/regression-helpers.ts b/packages/router-plugin/tests/regression-helpers.ts index 3cca5f6cff7..8f32840695c 100644 --- a/packages/router-plugin/tests/regression-helpers.ts +++ b/packages/router-plugin/tests/regression-helpers.ts @@ -9,7 +9,7 @@ import path from 'node:path' import { pathToFileURL } from 'node:url' import { promisify } from 'node:util' import { build, parseSync, transformWithOxc } from 'vite' -import { expect } from 'vitest' +import { expect, vi } from 'vitest' import { compileCodeSplitReferenceRoute, compileCodeSplitSharedRoute, @@ -33,9 +33,10 @@ export { declarationOf } from './validate-module' export const head = `import { createFileRoute } from '@tanstack/react-router'\n` /** - * Compiles a route file with the default groupings into every module the code - * splitter emits for it: `reference`, one `virtual <split>` chunk per grouping - * and, when bindings are shared, `shared`. + * Compiles a route file (with the default groupings unless `groupings` is + * given) into every module the code splitter emits for it: `reference`, one + * `virtual <split>` chunk per grouping and, when bindings are shared, `shared` + * and, for the React components among them, `shared components`. */ export function compileRouteModules( code: string, @@ -44,14 +45,15 @@ export function compileRouteModules( targetFramework?: 'react' | 'solid' /** Compile with route HMR and the framework's HMR compiler plugins. */ hmr?: boolean + groupings?: CodeSplitGroupings } = {}, ) { const { filename = 'route.tsx', targetFramework = 'react', hmr = false, + groupings = defaultCodeSplitGroupings, } = options - const groupings = defaultCodeSplitGroupings const compilerPlugins = hmr ? getFrameworkHmrCompilerPlugins({ targetFramework }) : undefined @@ -61,6 +63,7 @@ export function compileRouteModules( codeSplitGroupings: groupings, }) const sharedBindings = computed.size > 0 ? computed : undefined + const splitSharedComponents = targetFramework === 'react' const reference = compileCodeSplitReferenceRoute({ code, filename, @@ -69,6 +72,7 @@ export function compileRouteModules( codeSplitGroupings: groupings, targetFramework, sharedBindings, + splitSharedComponents, compilerPlugins, }) const modules: Record<string, string> = { @@ -81,15 +85,30 @@ export function compileRouteModules( filename: `${filename}?tsr-split=${split}`, splitTargets: targets, sharedBindings, + splitSharedComponents, compilerPlugins, }).code } if (sharedBindings) { + const components = `${filename}?tsr-shared=components` + const requested = Object.values(modules).some((module) => + requestedSources(module).includes(components), + ) modules.shared = compileCodeSplitSharedRoute({ code, sharedBindings, + splitSharedComponents, filename: `${filename}?tsr-shared=1`, }).code + if (requested) { + modules['shared components'] = compileCodeSplitSharedRoute({ + code, + sharedBindings, + splitSharedComponents, + part: 'components', + filename: components, + }).code + } } return { modules, sharedBindings: [...computed].sort() } } @@ -125,6 +144,16 @@ export function importSources(code: string) { ].sort() } +/** Sources a module imports or re-exports from, sorted. */ +export function requestedSources(code: string) { + const reexported = moduleRecord(code).staticExports.flatMap((statement) => + statement.entries.flatMap((entry) => + entry.moduleRequest ? [entry.moduleRequest.value] : [], + ), + ) + return [...new Set([...importSources(code), ...reexported])].sort() +} + /** * The values a module imports from `source`, sorted: `default`, `*` for a * namespace import, or the imported name. Type-only specifiers are skipped. @@ -159,17 +188,39 @@ export function exportedNames(code: string) { .sort() } +/** The local binding a module exports as `name`, if it exports one. */ +export function exportedBinding(code: string, name: string) { + for (const statement of moduleRecord(code).staticExports) { + for (const entry of statement.entries) { + if (!entry.isType && entry.exportName.name === name) { + return entry.localName.name ?? undefined + } + } + } + return undefined +} + /** Absolute, normalized path of a route file in the package's `src/routes`. */ export function routeFile(name: string) { return normalizePath(path.join(process.cwd(), `src/routes/${name}.tsx`)) } -function runTransform(plugin: UnpluginOptions, code: string, id: string) { +/** The bundler's plugin context of a transform; `warn` is a spy. */ +function createTransformContext() { + return { warn: vi.fn<(message: string) => void>() } +} + +function runTransform( + plugin: UnpluginOptions, + code: string, + id: string, + context: ReturnType<typeof createTransformContext>, +) { const transform = plugin.transform if (!transform || typeof transform === 'function') { throw new Error('Expected object transform') } - const result = transform.handler.call({} as never, code, id) as + const result = transform.handler.call(context as never, code, id) as | TransformResult | null | undefined @@ -188,7 +239,8 @@ const sharedPluginName = 'tanstack-router:code-splitter:compile-shared-file' * Drives the three code-splitter transforms the way a bundler does, for the * given route files (absolute path to route id). `configPlugins` are the * plugins of the resolved Vite config; by default the code splitter itself, - * so its plugin-order check runs. + * so its plugin-order check runs. `warn` spies on the warnings the transforms + * report to the bundler. */ export async function createCodeSplitterTransforms( options: Partial<Config>, @@ -222,13 +274,44 @@ export async function createCodeSplitterTransforms( } else if (hook) { await hook.handler.call({} as never, config) } + const transformContext = createTransformContext() return { reference: (code: string, id: string) => - runTransform(byName(referencePluginName), code, id), + runTransform(byName(referencePluginName), code, id, transformContext), virtual: (code: string, id: string) => - runTransform(byName(virtualPluginName), code, id), + runTransform(byName(virtualPluginName), code, id, transformContext), shared: (code: string, id: string) => - runTransform(byName(sharedPluginName), code, id), + runTransform(byName(sharedPluginName), code, id, transformContext), + warn: transformContext.warn, + } +} + +/** + * Creates the route HMR plugin (used when automatic code splitting is off) + * for the given route files (absolute path to route id). `transform` runs it + * on a route file the way a bundler does, and `warn` spies on the warnings it + * reports to the bundler. + */ +export function createRouteHmrTransform( + options: Partial<Config>, + routes: Record<string, string>, +) { + const context = createRouterPluginContext() + for (const [file, routeId] of Object.entries(routes)) { + context.routesByFile.set(file, { routeId }) + } + const plugins = createRouterHmrPlugin(options, context) + const plugin = Array.isArray(plugins) ? plugins[0]! : plugins + const transformContext = createTransformContext() + return { + transform: (code: string, id: string) => { + const output = runTransform(plugin, code, id, transformContext) + if (output === null) { + throw new Error('expected the route HMR plugin to transform the route') + } + return output + }, + warn: transformContext.warn, } } @@ -244,15 +327,9 @@ export function transformWithRouteHmrPlugin( routeId: '/', }, ) { - const context = createRouterPluginContext() - context.routesByFile.set(route.file, { routeId: route.routeId }) - const plugins = createRouterHmrPlugin(options, context) - const plugin = Array.isArray(plugins) ? plugins[0]! : plugins - const output = runTransform(plugin, code, route.file) - if (output === null) { - throw new Error('expected the route HMR plugin to transform the route') - } - return output + return createRouteHmrTransform(options, { + [route.file]: route.routeId, + }).transform(code, route.file) } /** Renders JSX to text: intrinsic elements become tags, components are called. */ @@ -277,8 +354,9 @@ let evaluations = 0 /** * Evaluates an emitted module like a bundler would: JSX becomes plain calls - * that render to text, and named imports are linked to `stubs`, keyed by - * specifier. Every call evaluates a fresh module instance. + * that render to text, and imports (named, namespace or side-effect only) are + * linked to `stubs`, keyed by specifier. Every call evaluates a fresh module + * instance. */ export async function evaluateModule( code: string, @@ -292,14 +370,17 @@ export async function evaluateModule( // Imports are hoisted: link them before any other statement runs. const imports: Array<string> = [] const body = javascript.replace( - /^import\s+\{([^}]*)\}\s+from\s+(["'])(.+?)\2;?$/gm, - (_, named: string, __, source: string) => { + /^import\s+(?:(\{[^}]*\}|\*\s*as\s+[\w$]+)\s+from\s+)?(["'])(.+?)\2;?$/gm, + (_, clause: string | undefined, __, source: string) => { if (!(source in stubs)) { throw new Error(`no stub for import ${source}`) } - imports.push( - `const { ${named.replace(/\bas\b/g, ':')} } = globalThis.${key}[${JSON.stringify(source)}];`, - ) + const exports = `globalThis.${key}[${JSON.stringify(source)}]` + if (clause?.startsWith('{')) { + imports.push(`const ${clause.replace(/\bas\b/g, ':')} = ${exports};`) + } else if (clause) { + imports.push(`const ${clause.replace(/^\*\s*as\s+/, '')} = ${exports};`) + } return '' }, ) @@ -308,6 +389,67 @@ export async function evaluateModule( ) } +/** + * The exports of an emitted module, as another emitted module imports them: + * importing a name the module does not export throws, as in a bundler. + */ +function linkable(specifier: string, exports: Record<string, unknown>) { + return new Proxy(exports, { + get(target, name) { + if (typeof name === 'string' && !(name in target)) { + throw new Error(`"${name}" is not exported by "${specifier}"`) + } + return Reflect.get(target, name) + }, + }) +} + +/** + * Compiles a route file, then evaluates the modules the code splitter emits, + * linked by import specifier: the shared module, the reference module, then + * every split chunk the reference module imports, as once all chunks have + * loaded. `stubs` provides the route's other imports; `beforeChunksLoad` runs + * once the route module has loaded. Returns the modules, the route options + * and the exports of each chunk by split name. + */ +export async function loadRouteModules( + code: string, + stubs: Record<string, Record<string, unknown>> = {}, + beforeChunksLoad?: () => void, +) { + const { modules } = compileRouteModules(code) + const linked: Record<string, Record<string, unknown>> = { + ...stubs, + '@tanstack/react-router': { + createFileRoute: () => (options: unknown) => ({ options }), + lazyRouteComponent: () => () => null, + }, + } + for (const [name, specifier] of [ + ['shared', 'route.tsx?tsr-shared=1'], + ['reference', 'route.tsx'], + ] as const) { + if (modules[name]) { + linked[specifier] = linkable( + specifier, + await evaluateModule(modules[name], linked), + ) + } + } + beforeChunksLoad?.() + const chunks: Record<string, Record<string, any>> = {} + for (const [, split] of modules.reference!.matchAll( + /\?tsr-split=([\w-]+)/g, + )) { + chunks[split!] ??= await evaluateModule( + modules[`virtual ${split}`]!, + linked, + ) + } + const { Route } = linked['route.tsx'] as { Route: { options: any } } + return { modules, options: Route.options, chunks } +} + /** * Applies Vite's Oxc React Refresh transform (the one `@vitejs/plugin-react` * uses) and returns the registered component names and the hook signature @@ -393,8 +535,59 @@ export async function expectRegisteredRouteOption( return binding } +/** Whether a callee is `memo` or `forwardRef`, imported or namespaced. */ +function isMemoOrForwardRef(callee: ESTree.Expression | ESTree.Super) { + const name = + callee.type === 'Identifier' + ? callee.name + : callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' + ? callee.property.name + : undefined + return name === 'memo' || name === 'forwardRef' +} + +/** + * Asserts that React Refresh can hot-update the component of `option`: the + * option is a binding React Refresh registers, or a `memo(...)` / + * `forwardRef(...)` call (nested or not) of one, since React Refresh resolves + * those wrappers through the function they wrap. + */ +export async function expectRefreshableRouteOption( + code: string, + option: string, +) { + expect(await getModuleErrors(code)).toEqual([]) + let node: ESTree.Expression | ESTree.Argument | undefined = getRouteOption( + parseModule(code), + option, + ).value + while (node?.type === 'CallExpression' && isMemoOrForwardRef(node.callee)) { + node = node.arguments[0] + } + const binding = node?.type === 'Identifier' ? node.name : undefined + const { registered } = await reactRefresh(code) + expect( + binding !== undefined && registered.includes(binding), + `\`${option}\` is neither a binding React Refresh registers nor a memo/forwardRef call of one (registered: ${registered.join(', ')})`, + ).toBe(true) +} + const runNode = promisify(execFile) +/** + * The default `entry.ts` of `buildAndRun`: re-exports the route module and + * `render(component)`, which preloads a component and renders it to a string. + */ +export const renderEntry = `import { createElement } from 'react' +import { renderToString } from 'react-dom/server' +export * from './routes/index' +export async function render(component: any) { + await component.preload?.() + return renderToString(createElement(component)) +} +` + /** * Builds a small app with the real Vite plugin (code splitting on, default * groupings unless `groupings` is given): `routes/index.tsx` holds `route`, @@ -421,14 +614,7 @@ export async function buildAndRun( 'routes/__root.tsx': `import { createRootRoute } from '@tanstack/react-router' export const Route = createRootRoute({})`, 'routes/index.tsx': route, - 'entry.ts': `import { createElement } from 'react' -import { renderToString } from 'react-dom/server' -export * from './routes/index' -export async function render(component: any) { - await component.preload?.() - return renderToString(createElement(component)) -} -`, + 'entry.ts': renderEntry, ...options.files, } for (const [file, code] of Object.entries(files)) { diff --git a/packages/router-plugin/tests/router-plugin-context.test.ts b/packages/router-plugin/tests/router-plugin-context.test.ts index d3fb0563e60..66354531306 100644 --- a/packages/router-plugin/tests/router-plugin-context.test.ts +++ b/packages/router-plugin/tests/router-plugin-context.test.ts @@ -1,7 +1,7 @@ import path from 'node:path' -import * as t from '@babel/types' +import { is } from 'yuku-ast' import { describe, expect, it, vi } from 'vitest' -import { parseAst } from '@tanstack/router-utils' +import { analyzeModule } from '@tanstack/router-utils' import { createRouterCodeSplitterPlugin } from '../src/core/router-code-splitter-plugin' import { unpluginRouterComposedFactory } from '../src/core/router-composed-plugin' import { createRouterHmrPlugin } from '../src/core/router-hmr-plugin' @@ -78,12 +78,12 @@ function getCode(result: TransformResult | null | undefined) { } function countProgramHotDeclarations(code: string) { - const ast = parseAst({ code }) - return ast.program.body.filter((statement) => { + const ast = analyzeModule({ code }) + return ast.ast.body.filter((statement) => { return ( - t.isVariableDeclaration(statement) && + is.VariableDeclaration(statement) && statement.declarations.some((declaration) => { - return t.isIdentifier(declaration.id) && declaration.id.name === 'hot' + return is.Identifier(declaration.id) && declaration.id.name === 'hot' }) ) }).length @@ -192,9 +192,9 @@ export const Route = createFileRoute('/changing')({ const combined = await setup(true) const first = await separate(source('firstState', 'first version')) expect(first.reference).toContain('tsr-split=component') - const virtualImports = parseAst({ + const virtualImports = analyzeModule({ code: first.virtual, - }).program.body.filter((statement) => t.isImportDeclaration(statement)) + }).ast.body.filter((statement) => is.ImportDeclaration(statement)) expect(virtualImports).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/packages/router-plugin/tests/shared-bindings-helpers.test.ts b/packages/router-plugin/tests/shared-bindings-helpers.test.ts index 3198c15fb98..0664a7e2cc1 100644 --- a/packages/router-plugin/tests/shared-bindings-helpers.test.ts +++ b/packages/router-plugin/tests/shared-bindings-helpers.test.ts @@ -1,55 +1,12 @@ import { describe, expect, it } from 'vitest' -import * as t from '@babel/types' -import { parseAst } from '@tanstack/router-utils' - import { addSharedSearchParamToFilename, - buildDeclarationMap, - buildDependencyGraph, - collectIdentifiersFromNode, - collectLocalBindingsFromStatement, - collectModuleLevelRefsFromNode, + compileCodeSplitSharedRoute, computeSharedBindings, - expandDestructuredDeclarations, - expandTransitively, - removeBindingsDependingOnRoute, } from '../src/core/code-splitter/compilers' import { defaultCodeSplitGroupings } from '../src/core/constants' import type { CodeSplitGroupings } from '../src/core/constants' -// ─── Test helpers ────────────────────────────────────────── - -function parse(code: string) { - return parseAst({ code }) -} - -function collectBindings(code: string): Set<string> { - const ast = parse(code) - const bindings = new Set<string>() - for (const node of ast.program.body) { - collectLocalBindingsFromStatement(node, bindings) - } - return bindings -} - -/** Build a dep graph from code for convenience */ -function graphFromCode(code: string) { - const ast = parse(code) - const locals = new Set<string>() - for (const node of ast.program.body) { - collectLocalBindingsFromStatement(node, locals) - } - const declMap = buildDeclarationMap(ast) - return { - ast, - locals, - declMap, - depGraph: buildDependencyGraph(declMap, locals), - } -} - -// ─── addSharedSearchParamToFilename ──────────────────────── - describe('addSharedSearchParamToFilename', () => { it('should append tsr-shared=1 to bare filename', () => { expect(addSharedSearchParamToFilename('src/routes/index.tsx')).toBe( @@ -66,503 +23,6 @@ describe('addSharedSearchParamToFilename', () => { }) }) -// ─── collectIdentifiersFromNode ─────────────────────────── - -describe('collectIdentifiersFromNode', () => { - it('should collect from a simple identifier', () => { - const ast = parse('const x = foo') - const decl = ast.program.body[0] as t.VariableDeclaration - const init = decl.declarations[0]!.init! - expect(collectIdentifiersFromNode(init)).toEqual(new Set(['foo'])) - }) - - it('should collect from a binary expression', () => { - const ast = parse('const x = a + b') - const decl = ast.program.body[0] as t.VariableDeclaration - const init = decl.declarations[0]!.init! - expect(collectIdentifiersFromNode(init)).toEqual(new Set(['a', 'b'])) - }) - - it('should ignore object literal keys', () => { - const ast = parse('const x = { key: value }') - const decl = ast.program.body[0] as t.VariableDeclaration - const init = decl.declarations[0]!.init! - const ids = collectIdentifiersFromNode(init) - expect(ids).toContain('value') - expect(ids).not.toContain('key') - }) - - it('should ignore member expression properties', () => { - const ast = parse('const x = obj.prop + obj[dynamic]') - const decl = ast.program.body[0] as t.VariableDeclaration - const init = decl.declarations[0]!.init! - const ids = collectIdentifiersFromNode(init) - expect(ids).toContain('obj') - expect(ids).toContain('dynamic') - expect(ids).not.toContain('prop') - }) - - it('should collect from a call expression', () => { - const ast = parse('const x = foo(bar, baz)') - const decl = ast.program.body[0] as t.VariableDeclaration - const init = decl.declarations[0]!.init! - expect(collectIdentifiersFromNode(init)).toEqual( - new Set(['foo', 'bar', 'baz']), - ) - }) - - it('should collect from arrow function body', () => { - const ast = parse('const x = () => a + b') - const decl = ast.program.body[0] as t.VariableDeclaration - const init = decl.declarations[0]!.init! - const ids = collectIdentifiersFromNode(init) - expect(ids).toContain('a') - expect(ids).toContain('b') - }) - - it('should collect from member expressions', () => { - const ast = parse('const x = obj.prop') - const decl = ast.program.body[0] as t.VariableDeclaration - const init = decl.declarations[0]!.init! - const ids = collectIdentifiersFromNode(init) - expect(ids).toContain('obj') - // property name is not a referenced identifier - expect(ids).not.toContain('prop') - }) - - it('should collect from nested structures', () => { - const ast = parse('const x = { key: fn(val) }') - const decl = ast.program.body[0] as t.VariableDeclaration - const init = decl.declarations[0]!.init! - const ids = collectIdentifiersFromNode(init) - expect(ids).toContain('fn') - expect(ids).toContain('val') - // object literal keys are not referenced identifiers - expect(ids).not.toContain('key') - }) - - it('should return empty set for a numeric literal', () => { - const ast = parse('const x = 42') - const decl = ast.program.body[0] as t.VariableDeclaration - const init = decl.declarations[0]!.init! - expect(collectIdentifiersFromNode(init).size).toBe(0) - }) - - it('should collect from function declaration body', () => { - const ast = parse('function foo() { return bar + baz }') - const fn = ast.program.body[0]! - const ids = collectIdentifiersFromNode(fn) - // function name is a binding, not a reference - expect(ids).not.toContain('foo') - expect(ids).toContain('bar') - expect(ids).toContain('baz') - }) -}) - -// ─── collectLocalBindingsFromStatement ───────────────────── - -describe('collectLocalBindingsFromStatement', () => { - it('should collect from const declaration', () => { - expect(collectBindings('const x = 1')).toEqual(new Set(['x'])) - }) - - it('should collect from let declaration', () => { - expect(collectBindings('let x = 1')).toEqual(new Set(['x'])) - }) - - it('should collect from multiple declarators', () => { - expect(collectBindings('const x = 1, y = 2')).toEqual(new Set(['x', 'y'])) - }) - - it('should collect from function declaration', () => { - expect(collectBindings('function foo() {}')).toEqual(new Set(['foo'])) - }) - - it('should collect from class declaration', () => { - expect(collectBindings('class Foo {}')).toEqual(new Set(['Foo'])) - }) - - it('should collect from exported variable declaration', () => { - expect(collectBindings('export const x = 1')).toEqual(new Set(['x'])) - }) - - it('should collect from exported function declaration', () => { - expect(collectBindings('export function foo() {}')).toEqual( - new Set(['foo']), - ) - }) - - it('should collect from exported class declaration', () => { - expect(collectBindings('export class Foo {}')).toEqual(new Set(['Foo'])) - }) - - it('should collect from object destructuring', () => { - expect(collectBindings('const { a, b } = obj')).toEqual(new Set(['a', 'b'])) - }) - - it('should collect from nested object destructuring', () => { - expect(collectBindings('const { a: { b, c } } = obj')).toEqual( - new Set(['b', 'c']), - ) - }) - - it('should collect from array destructuring', () => { - expect(collectBindings('const [a, b] = arr')).toEqual(new Set(['a', 'b'])) - }) - - it('should collect from destructuring with defaults', () => { - expect(collectBindings('const { a = 1, b = 2 } = obj')).toEqual( - new Set(['a', 'b']), - ) - }) - - it('should collect from destructuring with rest', () => { - expect(collectBindings('const { a, ...rest } = obj')).toEqual( - new Set(['a', 'rest']), - ) - }) - - it('should collect from exported destructuring', () => { - expect(collectBindings('export const { a, b } = fn()')).toEqual( - new Set(['a', 'b']), - ) - }) - - it('should NOT collect from import declarations', () => { - expect(collectBindings("import { a } from './mod'")).toEqual(new Set()) - }) - - it('should NOT collect from expression statements', () => { - expect(collectBindings('console.log("hello")')).toEqual(new Set()) - }) - - it('should collect from renamed destructuring', () => { - expect(collectBindings('const { a: renamedA } = obj')).toEqual( - new Set(['renamedA']), - ) - }) - - it('should collect from multiple statements', () => { - const code = ` -const x = 1 -function foo() {} -class Bar {} -` - expect(collectBindings(code)).toEqual(new Set(['x', 'foo', 'Bar'])) - }) -}) - -// ─── buildDeclarationMap ────────────────────────────────── - -describe('buildDeclarationMap', () => { - it('should map variable names to their declarator nodes', () => { - const ast = parse('const x = 1\nconst y = 2') - const map = buildDeclarationMap(ast) - expect(map.has('x')).toBe(true) - expect(map.has('y')).toBe(true) - expect(t.isVariableDeclarator(map.get('x'))).toBe(true) - }) - - it('should map function declarations', () => { - const ast = parse('function foo() {}') - const map = buildDeclarationMap(ast) - expect(map.has('foo')).toBe(true) - expect(t.isFunctionDeclaration(map.get('foo'))).toBe(true) - }) - - it('should map class declarations', () => { - const ast = parse('class Bar {}') - const map = buildDeclarationMap(ast) - expect(map.has('Bar')).toBe(true) - expect(t.isClassDeclaration(map.get('Bar'))).toBe(true) - }) - - it('should map exported declarations', () => { - const ast = parse('export const x = 1\nexport function foo() {}') - const map = buildDeclarationMap(ast) - expect(map.has('x')).toBe(true) - expect(map.has('foo')).toBe(true) - }) - - it('should map destructured bindings to same declarator', () => { - const ast = parse('const { a, b } = fn()') - const map = buildDeclarationMap(ast) - expect(map.has('a')).toBe(true) - expect(map.has('b')).toBe(true) - expect(map.get('a')).toBe(map.get('b')) - }) - - it('should not include imports', () => { - const ast = parse("import { x } from './mod'\nconst y = 1") - const map = buildDeclarationMap(ast) - expect(map.has('x')).toBe(false) - expect(map.has('y')).toBe(true) - }) -}) - -// ─── buildDependencyGraph ───────────────────────────────── - -describe('buildDependencyGraph', () => { - it('should detect direct dependency', () => { - const { depGraph } = graphFromCode('const a = 1\nconst b = a + 1') - expect(depGraph.get('b')).toEqual(new Set(['a'])) - expect(depGraph.get('a')).toEqual(new Set()) - }) - - it('should not include self-references', () => { - const { depGraph } = graphFromCode('const a = () => a()') - expect(depGraph.get('a')!.has('a')).toBe(false) - }) - - it('should not include imported identifiers', () => { - const { depGraph } = graphFromCode( - "import { ext } from './mod'\nconst a = ext + 1", - ) - // ext is not in locals, so not in deps - expect(depGraph.get('a')!.has('ext')).toBe(false) - }) - - it('should handle multiple deps', () => { - const { depGraph } = graphFromCode( - 'const a = 1\nconst b = 2\nconst c = a + b', - ) - expect(depGraph.get('c')).toEqual(new Set(['a', 'b'])) - }) - - it('should handle function declaration deps', () => { - const { depGraph } = graphFromCode( - 'const config = {}\nfunction init() { return config }', - ) - expect(depGraph.get('init')).toEqual(new Set(['config'])) - }) - - it('should handle chain deps (each link direct only)', () => { - const { depGraph } = graphFromCode('const a = 1\nconst b = a\nconst c = b') - expect(depGraph.get('c')).toEqual(new Set(['b'])) - expect(depGraph.get('b')).toEqual(new Set(['a'])) - expect(depGraph.get('a')).toEqual(new Set()) - }) -}) - -// ─── collectModuleLevelRefsFromNode ──────────────────────── - -describe('collectModuleLevelRefsFromNode', () => { - it('should find identifier reference in expression', () => { - const code = 'const x = 1\nconst y = x + 1' - const ast = parse(code) - const locals = new Set(['x', 'y']) - - const yDecl = ast.program.body[1] as t.VariableDeclaration - const valueNode = yDecl.declarations[0]!.init! - - const refs = collectModuleLevelRefsFromNode(valueNode, locals) - expect(refs).toContain('x') - expect(refs).not.toContain('y') - }) - - it('should return empty set when no local bindings referenced', () => { - const code = "import { foo } from './mod'\nconst x = foo()" - const ast = parse(code) - const locals = new Set(['x']) // foo is imported, not local - - const xDecl = ast.program.body[1] as t.VariableDeclaration - const valueNode = xDecl.declarations[0]!.init! - - const refs = collectModuleLevelRefsFromNode(valueNode, locals) - expect(refs.size).toBe(0) - }) - - it('should find multiple references', () => { - const code = 'const a = 1\nconst b = 2\nconst c = a + b' - const ast = parse(code) - const locals = new Set(['a', 'b', 'c']) - - const cDecl = ast.program.body[2] as t.VariableDeclaration - const valueNode = cDecl.declarations[0]!.init! - - const refs = collectModuleLevelRefsFromNode(valueNode, locals) - expect(refs).toContain('a') - expect(refs).toContain('b') - }) - - it('should find refs inside function call arguments', () => { - const code = 'const config = {}\nconst result = process(config)' - const ast = parse(code) - const locals = new Set(['config', 'result']) - - const resultDecl = ast.program.body[1] as t.VariableDeclaration - const valueNode = resultDecl.declarations[0]!.init! - - const refs = collectModuleLevelRefsFromNode(valueNode, locals) - expect(refs).toContain('config') - }) - - it('should only return direct refs (not transitive)', () => { - const code = 'const a = 1\nconst b = a\nconst c = b' - const ast = parse(code) - const locals = new Set(['a', 'b', 'c']) - - const cDecl = ast.program.body[2] as t.VariableDeclaration - const valueNode = cDecl.declarations[0]!.init! - - const refs = collectModuleLevelRefsFromNode(valueNode, locals) - // Only b is directly referenced, NOT a (transitive expansion is separate) - expect(refs).toContain('b') - expect(refs).not.toContain('a') - }) -}) - -// ─── expandTransitively ─────────────────────────────────── - -describe('expandTransitively', () => { - it('should expand shared set with deps of shared bindings', () => { - const { depGraph } = graphFromCode('const dep = 1\nconst shared = dep + 1') - const shared = new Set(['shared']) - - expandTransitively(shared, depGraph) - expect(shared).toContain('dep') - }) - - it('should handle multi-level transitive expansion', () => { - const { depGraph } = graphFromCode('const a = 1\nconst b = a\nconst c = b') - const shared = new Set(['c']) - - expandTransitively(shared, depGraph) - expect(shared).toContain('b') - expect(shared).toContain('a') - }) - - it('should not expand bindings not in graph', () => { - const depGraph = new Map<string, Set<string>>() - depGraph.set('shared', new Set(['ext'])) - // ext has no entry in graph - const shared = new Set(['shared']) - - expandTransitively(shared, depGraph) - // ext added because it's a dep, even if it has no graph entry - expect(shared).toContain('ext') - expect(shared.size).toBe(2) - }) - - it('should not loop on circular references', () => { - const { depGraph } = graphFromCode( - 'const a = () => b()\nconst b = () => a()', - ) - const shared = new Set(['a']) - - expandTransitively(shared, depGraph) - expect(shared).toContain('a') - expect(shared).toContain('b') - }) - - it('should leave shared untouched when binding has no deps', () => { - const depGraph = new Map<string, Set<string>>() - depGraph.set('x', new Set()) - const shared = new Set(['x']) - - expandTransitively(shared, depGraph) - expect(shared.size).toBe(1) - expect(shared).toContain('x') - }) - - it('should handle function declaration deps', () => { - const { depGraph } = graphFromCode( - 'const config = {}\nfunction init() { return config }', - ) - const shared = new Set(['init']) - - expandTransitively(shared, depGraph) - expect(shared).toContain('config') - }) - - it('should handle empty graph', () => { - const depGraph = new Map<string, Set<string>>() - const shared = new Set(['x']) - - expandTransitively(shared, depGraph) - expect(shared.size).toBe(1) - }) -}) - -// ─── expandDestructuredDeclarations ─────────────────────── - -describe('expandDestructuredDeclarations', () => { - it('should add all siblings when one destructured binding is shared', () => { - const ast = parse('const { a, b, c } = fn()') - const shared = new Set(['a']) - - expandDestructuredDeclarations(ast, shared) - expect(shared).toContain('a') - expect(shared).toContain('b') - expect(shared).toContain('c') - }) - - it('should handle array destructuring', () => { - const ast = parse('const [x, y] = fn()') - const shared = new Set(['y']) - - expandDestructuredDeclarations(ast, shared) - expect(shared).toContain('x') - expect(shared).toContain('y') - }) - - it('should not expand non-destructured declarations', () => { - const ast = parse('const a = 1\nconst b = 2') - const shared = new Set(['a']) - - expandDestructuredDeclarations(ast, shared) - expect(shared).toContain('a') - expect(shared).not.toContain('b') - }) - - it('should handle exported destructured declarations', () => { - const ast = parse('export const { a, b } = fn()') - const shared = new Set(['b']) - - expandDestructuredDeclarations(ast, shared) - expect(shared).toContain('a') - expect(shared).toContain('b') - }) - - it('should not touch declarations where no binding is shared', () => { - const ast = parse('const { a, b } = fn()\nconst { c, d } = other()') - const shared = new Set(['a']) - - expandDestructuredDeclarations(ast, shared) - expect(shared).toContain('a') - expect(shared).toContain('b') - expect(shared).not.toContain('c') - expect(shared).not.toContain('d') - }) - - it('should handle nested destructuring', () => { - const ast = parse('const { a, b: { c } } = fn()') - const shared = new Set(['a']) - - expandDestructuredDeclarations(ast, shared) - expect(shared).toContain('a') - expect(shared).toContain('c') - }) - - it('should handle destructuring with defaults', () => { - const ast = parse('const { a = 1, b = 2 } = fn()') - const shared = new Set(['a']) - - expandDestructuredDeclarations(ast, shared) - expect(shared).toContain('b') - }) - - it('should handle destructuring with rest', () => { - const ast = parse('const { a, ...rest } = fn()') - const shared = new Set(['a']) - - expandDestructuredDeclarations(ast, shared) - expect(shared).toContain('rest') - }) -}) - -// ─── computeSharedBindings (integration) ────────────────── - describe('computeSharedBindings', () => { const defaultGroupings = defaultCodeSplitGroupings @@ -953,88 +413,98 @@ export const Route = createFileRoute('/mixed')({ // ─── removeBindingsDependingOnRoute ─────────────────────── -describe('removeBindingsDependingOnRoute', () => { - it('should remove a binding that directly depends on Route', () => { - const depGraph = new Map<string, Set<string>>() - depGraph.set('helper', new Set(['Route'])) - depGraph.set('Route', new Set()) - const shared = new Set(['helper']) - - removeBindingsDependingOnRoute(shared, depGraph) - expect(shared).not.toContain('helper') - }) - - it('should remove a binding that transitively depends on Route', () => { - const depGraph = new Map<string, Set<string>>() - depGraph.set('a', new Set(['b'])) - depGraph.set('b', new Set(['Route'])) - depGraph.set('Route', new Set()) - const shared = new Set(['a']) - - removeBindingsDependingOnRoute(shared, depGraph) - expect(shared).not.toContain('a') - }) - - it('should keep a binding that does NOT depend on Route', () => { - const depGraph = new Map<string, Set<string>>() - depGraph.set('safe', new Set(['dep'])) - depGraph.set('dep', new Set()) - depGraph.set('Route', new Set()) - const shared = new Set(['safe']) - - removeBindingsDependingOnRoute(shared, depGraph) - expect(shared).toContain('safe') - }) - - it('should handle mixed: remove Route-dependent, keep safe bindings', () => { - const depGraph = new Map<string, Set<string>>() - depGraph.set('safe', new Set(['dep'])) - depGraph.set('dep', new Set()) - depGraph.set('unsafe', new Set(['Route'])) - depGraph.set('Route', new Set()) - const shared = new Set(['safe', 'unsafe']) - - removeBindingsDependingOnRoute(shared, depGraph) - expect(shared).toContain('safe') - expect(shared).not.toContain('unsafe') - }) - - it('should handle empty shared set', () => { - const depGraph = new Map<string, Set<string>>() - const shared = new Set<string>() - - removeBindingsDependingOnRoute(shared, depGraph) - expect(shared.size).toBe(0) - }) - - it('should handle binding with no graph entry', () => { - const depGraph = new Map<string, Set<string>>() - const shared = new Set(['orphan']) - - removeBindingsDependingOnRoute(shared, depGraph) - // No deps found → does not depend on Route → keep - expect(shared).toContain('orphan') - }) - - it('should handle circular deps that do NOT reach Route', () => { - const depGraph = new Map<string, Set<string>>() - depGraph.set('a', new Set(['b'])) - depGraph.set('b', new Set(['a'])) - depGraph.set('Route', new Set()) - const shared = new Set(['a']) - - removeBindingsDependingOnRoute(shared, depGraph) - expect(shared).toContain('a') - }) - - it('should handle circular deps that DO reach Route', () => { - const depGraph = new Map<string, Set<string>>() - depGraph.set('a', new Set(['b'])) - depGraph.set('b', new Set(['a', 'Route'])) - depGraph.set('Route', new Set()) - const shared = new Set(['a']) - - removeBindingsDependingOnRoute(shared, depGraph) - expect(shared).not.toContain('a') - }) +// A side-effect-free shared module lets the bundler drop the route module's +// re-export of it, so its top-level code may run later or not at all. +describe('the shared module is side-effect free', () => { + it.each([ + { + name: 'functions, classes and new collections', + value: 'new Map()', + pure: true, + }, + { + name: 'literals and functions', + value: "[1, 'a', () => 2, { a: `b` }]", + pure: true, + }, + { name: 'a call', value: 'register()', pure: false }, + { name: 'JSX', value: '<b>{track()}</b>', pure: false }, + { name: 'an interpolated binding', value: '`${prefix}`', pure: false }, + { name: 'a negated binding', value: '-prefix', pure: false }, + { + name: 'a collection of a shadowed constructor', + imports: "import { Map } from 'immutable'\n", + value: 'new Map()', + pure: false, + }, + { + name: 'a static accessor initialized by a call', + value: 'class { static accessor instance = register() }', + pure: false, + }, + { + name: 'a class with a decorated parameter', + value: 'class { constructor(@inject() service: unknown) {} }', + pure: false, + }, + ])('only without effects: $name', ({ imports = '', value, pure }) => { + const code = `${imports}import { createFileRoute } from '@tanstack/react-router' +import { inject, prefix, register, track } from './effects' +const value = ${value} +export function read() { + return value +} +export const Route = createFileRoute('/')({ + component: () => <p>{String(read())}</p>, +}) +` + const sharedBindings = computeSharedBindings({ + code, + filename: 'route.tsx', + codeSplitGroupings: defaultCodeSplitGroupings, + }) + expect([...sharedBindings].sort()).toEqual(['read', 'value']) + const { sideEffectFree } = compileCodeSplitSharedRoute({ + code, + sharedBindings, + filename: 'route.tsx?tsr-shared=1', + }) + expect(sideEffectFree).toBe(pure) + }) + + // The components module evaluates the values module it imports + it.each([ + { value: 'new Map()', pure: true }, + { value: 'register()', pure: false }, + ])( + 'a React components module only when the values it reads have no effects: $value', + ({ value, pure }) => { + const code = `import { createFileRoute } from '@tanstack/react-router' +import { register } from './effects' +const value = ${value} +export function Card() { + return <p>{String(value)}</p> +} +export const Route = createFileRoute('/')({ + component: () => <Card />, +}) +` + const sharedBindings = computeSharedBindings({ + code, + filename: 'route.tsx', + codeSplitGroupings: defaultCodeSplitGroupings, + }) + const compile = (part: 'values' | 'components') => + compileCodeSplitSharedRoute({ + code, + sharedBindings, + splitSharedComponents: true, + part, + filename: `route.tsx?tsr-shared=${part === 'values' ? '1' : part}`, + }) + expect(compile('components').code).toContain('Card') + expect(compile('values').sideEffectFree).toBe(pure) + expect(compile('components').sideEffectFree).toBe(pure) + }, + ) }) diff --git a/packages/router-plugin/tests/utils.test.ts b/packages/router-plugin/tests/utils.test.ts index 6812b59bc38..7381adc1f0b 100644 --- a/packages/router-plugin/tests/utils.test.ts +++ b/packages/router-plugin/tests/utils.test.ts @@ -1,6 +1,5 @@ -import * as babel from '@babel/core' -import * as t from '@babel/types' -import { parseAst } from '@tanstack/router-utils' +import { parseExpression } from '@tanstack/router-utils' +import { is } from 'yuku-ast' import { describe, expect, it } from 'vitest' import { getObjectPropertyKeyName, @@ -8,7 +7,6 @@ import { normalizePath, routeFactoryCallCodeFilter, } from '../src/core/utils' -import type { NodePath } from '@babel/core' function matchesRouteFactoryCallCodeFilter(code: string) { return routeFactoryCallCodeFilter.some((pattern) => { @@ -18,24 +16,6 @@ function matchesRouteFactoryCallCodeFilter(code: string) { }) } -function getProgramPath(code: string): NodePath<t.Program> { - const ast = parseAst({ code }) - let programPath: NodePath<t.Program> | undefined - - babel.traverse(ast, { - Program(path: NodePath<t.Program>) { - programPath = path - path.stop() - }, - }) - - if (!programPath) { - throw new Error('Program path not found') - } - - return programPath -} - describe('normalizePath', () => { it('should convert Windows backslashes to forward slashes', () => { expect(normalizePath('C:\\Users\\project\\src\\routes\\index.tsx')).toBe( @@ -72,40 +52,31 @@ describe('normalizePath', () => { describe('getUniqueProgramIdentifier', () => { it('returns the base name when unused', () => { - const programPath = getProgramPath('const existing = 1') - - expect(getUniqueProgramIdentifier(programPath, 'TSRComponent').name).toBe( - 'TSRComponent', - ) - }) - - it('appends numeric suffixes for existing bindings', () => { - const programPath = getProgramPath( - 'const TSRComponent = 1\nconst TSRComponent2 = 2', - ) - - expect(getUniqueProgramIdentifier(programPath, 'TSRComponent').name).toBe( - 'TSRComponent3', - ) + expect( + getUniqueProgramIdentifier(new Set(['existing']), 'TSRComponent').name, + ).toBe('TSRComponent') }) - it('avoids globals too', () => { - const programPath = getProgramPath('const existing = window') - - expect(getUniqueProgramIdentifier(programPath, 'window').name).toBe( - 'window2', - ) + it('appends numeric suffixes for reserved names', () => { + expect( + getUniqueProgramIdentifier( + new Set(['TSRComponent', 'TSRComponent2']), + 'TSRComponent', + ).name, + ).toBe('TSRComponent3') }) it('avoids collisions across consecutive calls with the same base name', () => { - const programPath = getProgramPath('const existing = 1') + const reservedNames = new Set(['existing']) - const first = getUniqueProgramIdentifier(programPath, 'TSRComponent') - const second = getUniqueProgramIdentifier(programPath, 'TSRComponent') + const first = getUniqueProgramIdentifier(reservedNames, 'TSRComponent') + const second = getUniqueProgramIdentifier(reservedNames, 'TSRComponent') expect(first.name).toBe('TSRComponent') expect(second.name).toBe('TSRComponent2') - expect(first.name).not.toBe(second.name) + expect(reservedNames).toEqual( + new Set(['existing', 'TSRComponent', 'TSRComponent2']), + ) }) }) @@ -142,38 +113,19 @@ describe('routeFactoryCallCodeFilter', () => { }) describe('getObjectPropertyKeyName', () => { - it('returns identifier keys', () => { - const prop = t.objectProperty(t.identifier('component'), t.identifier('x')) - - expect(getObjectPropertyKeyName(prop)).toBe('component') - }) - - it('returns string literal keys', () => { - const prop = t.objectProperty( - t.stringLiteral('errorComponent'), - t.identifier('x'), - ) - - expect(getObjectPropertyKeyName(prop)).toBe('errorComponent') - }) - - it('returns undefined for computed identifier keys', () => { - const prop = t.objectProperty( - t.identifier('component'), - t.identifier('x'), - true, - ) - - expect(getObjectPropertyKeyName(prop)).toBeUndefined() - }) - - it('returns undefined for computed member expression keys', () => { - const prop = t.objectProperty( - t.memberExpression(t.identifier('foo'), t.identifier('bar')), - t.identifier('x'), - true, - ) - - expect(getObjectPropertyKeyName(prop)).toBeUndefined() + it.each([ + ['component: x', 'component'], + ['"errorComponent": x', 'errorComponent'], + ['[component]: x', undefined], + ['[foo.bar]: x', undefined], + ])('reads static keys in %s', (source, expected) => { + const expression = parseExpression(`{${source}}`) + if ( + !is.ObjectExpression(expression) || + !is.Property(expression.properties[0]) + ) { + throw new Error('Expected an object property') + } + expect(getObjectPropertyKeyName(expression.properties[0])).toBe(expected) }) }) diff --git a/packages/router-utils/package.json b/packages/router-utils/package.json index 17f3e514184..081707f53cd 100644 --- a/packages/router-utils/package.json +++ b/packages/router-utils/package.json @@ -33,7 +33,7 @@ "test:types:ts59": "node ../../node_modules/typescript59/lib/tsc.js", "test:types:ts60": "tsc6", "test:types:ts70": "tsc", - "test:unit": "exit 0; vitest --typecheck", + "test:unit": "vitest --typecheck", "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .", "build": "vite build" }, @@ -60,20 +60,21 @@ "src" ], "engines": { - "node": ">=20.19" + "node": "^20.19.0 || >=22.12.0" }, "dependencies": { - "@babel/generator": "^7.29.8", - "@babel/parser": "^7.29.8", - "@babel/types": "catalog:", "ansis": "^4.3.1", - "babel-dead-code-elimination": "^1.0.12", "diff": "^9.0.0", "pathe": "catalog:", - "tinyglobby": "catalog:" + "tinyglobby": "catalog:", + "@yuku-core/wasm": "catalog:", + "@yuku-toolchain/types": "catalog:", + "yuku-analyzer": "catalog:", + "yuku-ast": "catalog:", + "yuku-codegen": "catalog:", + "yuku-core": "catalog:" }, "devDependencies": { - "@types/babel__generator": "^7.27.0", "vite": "*", "@types/node": ">=20", "@tanstack/vite-config": "catalog:", diff --git a/packages/router-utils/src/ast.ts b/packages/router-utils/src/ast.ts index 46e319c2c4e..00d238d0106 100644 --- a/packages/router-utils/src/ast.ts +++ b/packages/router-utils/src/ast.ts @@ -1,195 +1,317 @@ -import { parse } from '@babel/parser' -import _generate from '@babel/generator' -import * as t from '@babel/types' -import { - deadCodeElimination as _deadCodeElimination, - findReferencedIdentifiers, -} from 'babel-dead-code-elimination' -import type { GeneratorOptions, GeneratorResult } from '@babel/generator' -import type { ParseResult, ParserOptions } from '@babel/parser' -import type * as _babel_types from '@babel/types' - -export type ParseAstOptions = ParserOptions & { +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { analyze } from 'yuku-analyzer' +import { generate } from 'yuku-codegen' +import { is, walk } from 'yuku-ast' +import { load as loadNativeCore } from 'yuku-core' +import { loadSync as loadWasmCore } from '@yuku-core/wasm' +import type { Binding, Module } from 'yuku-analyzer' +import type { Core, Expression, Node, Program } from '@yuku-toolchain/types' +import type { GenerateResult as YukuGenerateResult } from 'yuku-codegen' + +let core: Core | undefined + +function loadWasm(): Core { + // `@yuku-core/wasm`'s `loadSync()` needs the bytes, so resolve the file + // from whichever build runs. Check `import.meta` first: the CJS build + // compiles it to `{}`, and `node -e` defines a global `__filename` in ESM + const require = createRequire( + // @ts-ignore TS1470: `import.meta` is only read in the ESM build + import.meta.url ?? __filename, // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- `{}.url` in the CJS build + ) + return loadWasmCore( + readFileSync(require.resolve('@yuku-core/wasm/yuku-core.wasm')), + ) +} + +function getCore(): Core { + if (!core) { + // WebContainers (StackBlitz, bolt.new) cannot load native addons, and + // Yuku no longer falls back to WebAssembly on its own since + // https://github.com/yuku-toolchain/yuku/releases/tag/v0.17.0 + if (process.versions.webcontainer != null) { + core = loadWasm() + } else { + // Platforms without a native binary, or installs without optional + // dependencies, use WebAssembly + try { + core = loadNativeCore() + } catch (nativeError) { + try { + core = loadWasm() + } catch (wasmError) { + throw new AggregateError( + [nativeError, wasmError], + 'Yuku could load neither its native binding nor its WebAssembly core', + ) + } + } + } + } + return core +} + +export interface GenerateResult extends Omit<YukuGenerateResult, 'map'> { + map: { + version: number + names: Array<string> + sources: Array<string> + mappings: string + sourcesContent?: Array<string | null> + } | null +} + +export interface AnalyzeModuleOptions { code: string filename?: string } -export type ParseAstResult = ParseResult<_babel_types.File> -export function parseAst({ +/** One-based line and zero-based UTF-16 column at a source offset. */ +export function sourcePosition(code: string, offset: number) { + const lines = code.slice(0, offset).split(/\r\n|[\n\r\u2028\u2029]/) + return { line: lines.length, column: lines[lines.length - 1]!.length } +} + +/** Insert statements without displacing the module's directive prologue. */ +export function prependStatements( + program: Program, + ...statements: Program['body'] +): void { + let index = 0 + while (is.Directive(program.body[index])) { + index++ + } + program.body.splice(index, 0, ...statements) +} + +/** Parse and bind once. Treat this module's AST as the immutable source model. */ +export function analyzeModule({ code, - filename, - sourceFilename, - plugins, - ...opts -}: ParseAstOptions): ParseAstResult { - const inferredFilename = filename ?? sourceFilename - return parse(code, { - plugins: plugins ?? getDefaultParserPluginsForFilename(inferredFilename), + filename = 'input.tsx', +}: AnalyzeModuleOptions): Module { + // Without the query, and a hash after the last path segment's extension (a + // `#` may also be part of a directory name) + const physicalName = filename.replace(/\?.*$/, '').replace(/#[^/\\]*$/, '') + const module = analyze(code, { + path: filename, + // JavaScript route files may contain JSX but never TypeScript, where + // `a < b > (c)` would be a call with type arguments + lang: /\.[cm]?ts$/.test(physicalName) + ? 'ts' + : /\.[cm]?jsx?$/.test(physicalName) + ? 'jsx' + : 'tsx', sourceType: 'module', - sourceFilename, - ...opts, + attachComments: true, + core: getCore(), }) + const error = module.diagnostics.find( + (diagnostic) => diagnostic.severity === 'error', + ) + if (error) { + const loc = sourcePosition(code, error.start) + throw Object.assign( + new SyntaxError( + `${filename}: ${error.message} (${loc.line}:${loc.column})`, + ), + { loc, pos: error.start }, + ) + } + return module } -function getDefaultParserPluginsForFilename( - filename: string | undefined, -): NonNullable<ParserOptions['plugins']> { - const plugins: NonNullable<ParserOptions['plugins']> = [ - 'typescript', - 'explicitResourceManagement', - 'importAttributes', - 'deprecatedImportAssert', - ['decorators', { decoratorsBeforeExport: true }], - 'decoratorAutoAccessors', - ] +/** + * File-level pragmas: JSX transform configuration, React Refresh's + * `@refresh reset`, and solid-refresh's `@refresh reload` and `@refresh skip`. + */ +const filePragma = + /@jsx(?:Frag|ImportSource|Runtime)?\b|@refresh (?:reload|reset|skip)\b/ - if (!isPlainTypeScriptFile(filename)) { - plugins.unshift('jsx') - } +/** Legal comments, which minifiers keep: `/*! … *\/`, `@license`, `@preserve`. */ +const legalComment = /^!|@license\b|@preserve\b/ - return plugins +/** + * Pragma comments configure transforms that run after these compilers for the + * whole file, and Oxc only reads them from the comments that lead the file. So + * an output must start with the source's top-level pragmas: removing a + * statement drops the comments attached to it, and inserted statements would + * otherwise land above them. The legal comments that lead the source lead + * every output too. + */ +export function keepFilePragmas(source: Program, output: Program): void { + const first = output.body[0] + const kept = source.body.flatMap( + (statement, index) => + statement.comments?.filter( + (comment) => + filePragma.test(comment.value) || + (index === 0 && + comment.position === 'before' && + legalComment.test(comment.value)), + ) ?? [], + ) + if (!kept.length || !first) { + return + } + const values = new Set(kept.map((comment) => comment.value)) + for (const statement of output.body) { + if (statement.comments) { + statement.comments = statement.comments.filter( + (comment) => !values.has(comment.value), + ) + } + } + first.comments = [ + ...kept.map((comment) => ({ + ...comment, + position: 'before' as const, + sameLine: false, + })), + ...(first.comments ?? []), + ] } -function isPlainTypeScriptFile(filename: string | undefined): boolean { - if (!filename) { - return false +/** + * The declaration of an `export` statement, to replace the statement: it keeps + * the statement's comments, such as a `#__NO_SIDE_EFFECTS__` annotation. + */ +export function unwrapExport<T extends Node>( + statement: Node, + declaration: T, +): T { + if (statement !== declaration && statement.comments?.length) { + declaration.comments = [ + ...statement.comments, + ...(declaration.comments ?? []), + ] } - - return /\.[cm]?ts(?:$|[?#])/.test(filename) + return declaration } -let generate = _generate +/** Print an output tree without modifying the source model or its semantic tables. */ +export function generateModule( + program: Node, + options?: { source: string; filename: string }, +): GenerateResult { + const result = generate(program, { + comments: 'all', + ...(options + ? { + sourceMap: { + source: options.source, + sourceFileName: options.filename, + sourcesContent: options.source, + }, + } + : {}), + }) + if (result.diagnostics.length) { + throw new Error( + result.diagnostics.map((diagnostic) => diagnostic.message).join('\n'), + ) + } + const { map } = result + return { + ...result, + // Without the null `file` and `sourceRoot` Yuku emits when those options + // are unset; `sourcesContent` holds the source passed above + map: map && { + version: map.version, + names: map.names, + sources: map.sources, + mappings: map.mappings, + sourcesContent: map.sourcesContent!, + }, + } +} -if ('default' in generate) { - generate = generate.default as typeof generate +export interface ModuleAstClone { + program: Program + /** Resolve surviving copied nodes against the original semantic snapshot. */ + originalNodes: WeakMap<Node, Node> } -type GenerateFromAstOptions = GeneratorOptions & - Required<Pick<GeneratorOptions, 'sourceFileName' | 'filename'>> -export function generateFromAst( - ast: _babel_types.Node, - opts?: GenerateFromAstOptions, -): GeneratorResult { - return generate( - ast, - opts - ? { importAttributesKeyword: 'with', sourceMaps: true, ...opts } - : undefined, - ) + +export function cloneModuleAst(module: Module): ModuleAstClone { + const program = structuredClone(module.ast) + const originals: Array<Node> = [] + walk(module.ast, { + enter(node) { + originals.push(node) + }, + }) + const originalNodes = new WeakMap<Node, Node>() + let index = 0 + walk(program, { + enter(node) { + originalNodes.set(node, originals[index++]!) + }, + }) + return { program, originalNodes } } -export type { GeneratorResult } from '@babel/generator' -/** - * Strips TypeScript type-only exports and imports from an AST. - * - * This is necessary because babel-dead-code-elimination doesn't handle - * TypeScript type exports/imports. When a type export references an import - * that pulls in server-only code, the dead code elimination won't remove - * that import because it sees the type as still referencing it. - * - * This function removes: - * - `export type Foo = ...` - * - `export interface Foo { ... }` - * - `export type { Foo } from './module'` - * - `export type * from './module'` - * - Type specifiers in mixed exports: `export { value, type Foo }` -> `export { value }` - * - `import type { Foo } from './module'` - * - Type specifiers in mixed imports: `import { value, type Foo } from './module'` -> `import { value }` - * - * Note: Non-exported type/interface declarations are preserved as they may be - * used as type annotations within the code. - * - * @param ast - The Babel AST (or ParseResult) to mutate - */ -export function stripTypeExports(ast: ParseResult<_babel_types.File>): void { - // Filter the program body to remove type-only nodes - ast.program.body = ast.program.body.filter((node) => { - // Handle export declarations - if (t.isExportNamedDeclaration(node)) { - // Remove entire export if it's a type-only export - // e.g., `export type Foo = string`, `export interface Bar {}`, `export type { X } from './y'` - if (node.exportKind === 'type') { - return false - } +const generatedReferences = new WeakMap<Node, Binding | string>() - // For value exports with mixed specifiers, filter out type-only specifiers - // e.g., `export { value, type TypeOnly }` -> `export { value }` - if (node.specifiers.length > 0) { - node.specifiers = node.specifiers.filter((specifier) => { - if (t.isExportSpecifier(specifier)) { - return specifier.exportKind !== 'type' - } - return true - }) - - // If all specifiers were removed, remove the entire export declaration - // (unless it has a declaration like `export const x = 1`) - if (node.specifiers.length === 0 && !node.declaration) { - return false - } - } - } +/** Explicitly connect a new reference to a source binding; never infer local scope. */ +export function linkGeneratedReference<T extends Node>( + node: T, + reference: Binding | string, +): T { + generatedReferences.set(node, reference) + return node +} - // Handle type-only export-all declarations - // e.g., `export type * from './module'` - if (t.isExportAllDeclaration(node)) { - if (node.exportKind === 'type') { - return false - } - } +export function generatedReferenceOf(node: Node): Binding | string | undefined { + return generatedReferences.get(node) +} - // Handle import declarations - if (t.isImportDeclaration(node)) { - // Remove entire import if it's a type-only import - // e.g., `import type { Foo } from './module'` - if (node.importKind === 'type') { - return false +/** Copy a generated fragment without dropping its source reference provenance. */ +export function cloneGeneratedNode<T extends Node>(node: T): T { + const copy = structuredClone(node) + const originals: Array<Node> = [] + walk(node, { + enter(original) { + originals.push(original) + }, + }) + let index = 0 + walk(copy, { + enter(current) { + const reference = generatedReferences.get(originals[index++]!) + if (reference) { + generatedReferences.set(current, reference) } + }, + }) + return copy +} - // For value imports with mixed specifiers, filter out type-only specifiers - // e.g., `import { value, type TypeOnly } from './module'` -> `import { value }` - if (node.specifiers.length > 0) { - node.specifiers = node.specifiers.filter((specifier) => { - if (t.isImportSpecifier(specifier)) { - return specifier.importKind !== 'type' - } - return true - }) - - // If all specifiers were removed, remove the entire import declaration - if (node.specifiers.length === 0) { - return false - } - } +/** Parse compiler-owned snippets; their nodes have no original-source spans. */ +export function parseStatements(code: string): Program['body'] { + // Nothing to parse + if (!code) { + return [] + } + const module = analyzeModule({ code, filename: 'generated.tsx' }) + for (const reference of module.unresolvedReferences) { + if (!reference.inTypePosition) { + generatedReferences.set(reference.node, reference.name) } - - return true + } + walk(module.ast, { + enter(node) { + node.start = 0 + node.end = 0 + }, }) + return module.ast.body } -// Re-export findReferencedIdentifiers from babel-dead-code-elimination -export { findReferencedIdentifiers } - -/** - * Performs dead code elimination on the AST, with TypeScript type stripping. - * - * This is a wrapper around babel-dead-code-elimination that first strips - * TypeScript type-only exports and imports. This is necessary because - * babel-dead-code-elimination doesn't handle type exports, which can cause - * imports to be retained when they're only referenced by type exports. - * - * @param ast - The Babel AST to mutate - * @param candidates - Optional set of identifier paths to consider for removal. - * If provided, only these identifiers will be candidates for removal. - * This should be the result of `findReferencedIdentifiers(ast)` called - * before any AST transformations. - */ -export function deadCodeElimination( - ast: ParseResult<_babel_types.File>, - candidates?: ReturnType<typeof findReferencedIdentifiers>, -): void { - // First strip TypeScript type-only exports and imports - stripTypeExports(ast) - - // Then run the original dead code elimination - _deadCodeElimination(ast, candidates) +export function parseExpression(code: string): Expression { + const statement = parseStatements(`(${code});`)[0] + if (!is.ExpressionStatement(statement)) { + throw new Error('Expected an expression') + } + return is.ParenthesizedExpression(statement.expression) + ? statement.expression.expression + : statement.expression } diff --git a/packages/router-utils/src/compiler-helpers.ts b/packages/router-utils/src/compiler-helpers.ts index 5460c349141..3079f105d55 100644 --- a/packages/router-utils/src/compiler-helpers.ts +++ b/packages/router-utils/src/compiler-helpers.ts @@ -1,902 +1,911 @@ -import * as t from '@babel/types' - -type CompilerNodePath<TNode extends t.Node = t.Node> = { - node: TNode - parentPath: CompilerNodePath | null - isVariableDeclarator: () => boolean -} - -type ReplacePathNode<TPath, TNode extends t.Node> = Omit<TPath, 'node'> & { - node: TNode -} - -type IdentifierScopeFrame = { - kind: 'program' | 'function' | 'block' - bindings: Set<string> +import { BindingFlags } from 'yuku-analyzer' +import { b, bindingIdentifiers, is, nameOf, unwrap, walk } from 'yuku-ast' +import { generatedReferenceOf } from './ast' +import type { Binding, Module, Scope } from 'yuku-analyzer' +import type { Expression, Node, Program } from '@yuku-toolchain/types' + +export interface ModuleDeclarationGraph { + /** The statement of its own scope declaring each binding, if any. */ + declarations: Map<Binding, Node> + dependencies: Map<Binding, Set<Binding>> + /** Every removable declaration, including `var` nested in statements. */ + declarationBindings: Map<Node, Set<Binding>> +} + +/** Runtime references resolved by Yuku, including JSX and excluding shadowed names. */ +export function collectModuleReferences( + module: Module, + node: Node, +): Set<Binding> { + const references = new Set<Binding>() + module.walk( + { + enter(current) { + const reference = module.referenceOf(current) + if ( + reference && + !reference.inTypePosition && + reference.binding?.scope === module.rootScope + ) { + references.add(reference.binding) + } + }, + }, + node, + ) + return references } -type IdentifierScopeStack = Array<IdentifierScopeFrame> -export type ModuleInfoBinding = - | { - type: 'import' - source: string - importedName: string +/** The node that declares an identifier and goes when its bindings do. */ +function declarationOf(module: Module, identifier: Node): Node | undefined { + let current: Node | null = identifier + while (current) { + if (is.VariableDeclarator(current)) { + // A for-in or for-of loop assigns its head on every iteration + const statement = module.parentOf(current) + const parent = statement && module.parentOf(statement) + return is.ForInStatement(parent) || is.ForOfStatement(parent) + ? undefined + : current } - | { - type: 'var' - init: t.Expression | null - } - -export interface ExtractedModuleInfo { - bindings: Map<string, ModuleInfoBinding> - exports: Map<string, string> - reExportAllSources: Array<string> -} - -function getTransparentWrapperExpression(node: t.Node): t.Expression | null { - if ( - t.isTSAsExpression(node) || - t.isTSSatisfiesExpression(node) || - t.isTSTypeAssertion(node) || - t.isTSNonNullExpression(node) || - t.isParenthesizedExpression(node) - ) { - return node.expression - } - - return null -} - -export function unwrapExpression(expr: t.Expression): t.Expression { - let inner = getTransparentWrapperExpression(expr) - while (inner) { - expr = inner - inner = getTransparentWrapperExpression(expr) - } - - return expr -} - -export function getVariableDeclaratorForExpressionPath< - TPath extends CompilerNodePath<t.Expression>, ->(path: TPath): ReplacePathNode<TPath, t.VariableDeclarator> | null { - let currentPath: CompilerNodePath = path - let parentPath = currentPath.parentPath - - while ( - parentPath && - getTransparentWrapperExpression(parentPath.node) === currentPath.node - ) { - currentPath = parentPath - parentPath = parentPath.parentPath - } - - if ( - parentPath?.isVariableDeclarator() && - t.isVariableDeclarator(parentPath.node) && - parentPath.node.init === currentPath.node - ) { - return parentPath as ReplacePathNode<TPath, t.VariableDeclarator> - } - - return null -} - -function getModuleExportName(node: t.Identifier | t.StringLiteral) { - return t.isIdentifier(node) ? node.name : node.value -} - -function addVariableDeclarationModuleInfo( - declaration: t.VariableDeclaration, - bindings: Map<string, ModuleInfoBinding>, - exportMap?: Map<string, string>, -) { - for (const declarator of declaration.declarations) { - for (const name of collectIdentifiersFromPattern(declarator.id)) { - bindings.set(name, { - type: 'var', - init: declarator.init ?? null, - }) - exportMap?.set(name, name) + if ( + is.FunctionDeclaration(current) || + is.ClassDeclaration(current) || + is.TSDeclareFunction(current) || + is.TSEnumDeclaration(current) || + is.TSModuleDeclaration(current) + ) { + return current.id === identifier ? current : undefined } - } -} - -function addDeclarationModuleInfo( - declaration: t.Declaration, - bindings: Map<string, ModuleInfoBinding>, - exportMap?: Map<string, string>, -) { - if (t.isVariableDeclaration(declaration)) { - addVariableDeclarationModuleInfo(declaration, bindings, exportMap) - return - } - - if ( - (t.isFunctionDeclaration(declaration) || - t.isClassDeclaration(declaration)) && - declaration.id - ) { - bindings.set(declaration.id.name, { - type: 'var', - init: null, - }) - exportMap?.set(declaration.id.name, declaration.id.name) - } -} - -function hasIdentifierBinding(scopes: IdentifierScopeStack, name: string) { - for (let i = scopes.length - 1; i >= 0; i--) { - if (scopes[i]!.bindings.has(name)) { - return true + if ( + is.ImportSpecifier(current) || + is.ImportDefaultSpecifier(current) || + is.ImportNamespaceSpecifier(current) + ) { + return current } - } - return false -} - -function currentIdentifierScope(scopes: IdentifierScopeStack) { - return scopes[scopes.length - 1]! -} - -function nearestFunctionIdentifierScope(scopes: IdentifierScopeStack) { - for (let i = scopes.length - 1; i >= 0; i--) { - const scope = scopes[i]! - if (scope.kind === 'function' || scope.kind === 'program') { - return scope + if (is.Function(current) || is.Class(current) || is.Program(current)) { + return undefined } + current = module.parentOf(current) } - return currentIdentifierScope(scopes) -} - -function addIdentifierPatternBindings( - pattern: t.LVal | t.Node | null | undefined, - scope: IdentifierScopeFrame, -) { - for (const name of collectIdentifiersFromPattern(pattern)) { - scope.bindings.add(name) - } + return undefined } -function addIdentifierDeclarationBindings( - declaration: t.Node, - scopes: IdentifierScopeStack, -) { - if (t.isVariableDeclaration(declaration)) { - const scope = - declaration.kind === 'var' - ? nearestFunctionIdentifierScope(scopes) - : currentIdentifierScope(scopes) - for (const declarator of declaration.declarations) { - addIdentifierPatternBindings(declarator.id, scope) - } - return - } - - if ( - (t.isFunctionDeclaration(declaration) || - t.isClassDeclaration(declaration) || - t.isTSTypeAliasDeclaration(declaration) || - t.isTSInterfaceDeclaration(declaration) || - t.isTSEnumDeclaration(declaration)) && - declaration.id - ) { - currentIdentifierScope(scopes).bindings.add(declaration.id.name) - } -} - -function addIdentifierImportBindings( - node: t.ImportDeclaration, - scope: IdentifierScopeFrame, -) { - for (const specifier of node.specifiers) { - scope.bindings.add(specifier.local.name) +/** + * Whether a declaration is a statement of its binding's own scope. A `var` + * nested in another statement, such as a block or a loop head, initializes as + * part of that statement: its declarator can be removed from it, but the + * binding has no declaration to move or import on its own. + */ +function declaresInOwnScope( + module: Module, + binding: Binding, + declaration: Node, +): boolean { + if (!is.VariableDeclarator(declaration)) { + return true } + let container = module.parentOf(module.parentOf(declaration)!) + if (is.ExportNamedDeclaration(container)) { + container = module.parentOf(container) + } + const scope = module.scopeOf(declaration) + return ( + (is.Program(container) || + is.BlockStatement(container) || + is.StaticBlock(container) || + is.SwitchCase(container) || + is.TSModuleBlock(container)) && + (binding.scope === scope || + (scope.kind === 'functionBody' && binding.scope === scope.parent)) + ) } -function createNestedIdentifierScope( - kind: IdentifierScopeFrame['kind'], - scopes: IdentifierScopeStack, -): IdentifierScopeStack { - return [...scopes, { kind, bindings: new Set() }] +interface DeclarationIndex extends Pick< + ModuleDeclarationGraph, + 'declarations' | 'declarationBindings' +> { + /** + * Destructuring elements that can be dropped on their own, with the bindings + * each declares: array pattern elements, and properties of object patterns + * without a rest element (dropping one would change what the rest collects). + */ + patternElements: Map<Node, Set<Binding>> } -function addIdentifierBlockBindings( - body: Array<t.Node>, - scopes: IdentifierScopeStack, +function addOwner( + owners: Map<Node, Set<Binding>>, + node: Node, + binding: Binding, ) { - for (const statement of body) { - if (t.isImportDeclaration(statement)) { - addIdentifierImportBindings(statement, currentIdentifierScope(scopes)) - } else if (t.isExportNamedDeclaration(statement) && statement.declaration) { - addIdentifierDeclarationBindings(statement.declaration, scopes) - } else { - addIdentifierDeclarationBindings(statement, scopes) + const bindings = owners.get(node) ?? new Set<Binding>() + bindings.add(binding) + owners.set(node, bindings) +} + +function declarationIndex( + module: Module, + bindings: Array<Binding>, +): DeclarationIndex { + const declarations = new Map<Binding, Node>() + const declarationBindings = new Map<Node, Set<Binding>>() + const patternElements = new Map<Node, Set<Binding>>() + for (const binding of bindings) { + // Parameters belong to their function or signature, never to a statement, + // even when a signature sits in a declaration's type arguments + if (binding.flags & (BindingFlags.Parameter | BindingFlags.TypeParameter)) { + continue + } + for (const identifier of binding.declarations) { + const declaration = declarationOf(module, identifier) + if (!declaration) { + continue + } + const previous = declarations.get(binding) + if ( + declaresInOwnScope(module, binding, declaration) && + (!previous || + (is.TSDeclareFunction(previous) && + !is.TSDeclareFunction(declaration))) + ) { + declarations.set(binding, declaration) + } + addOwner(declarationBindings, declaration, binding) + if (is.VariableDeclarator(declaration)) { + let element: Node = identifier + while (element !== declaration.id) { + const pattern = module.parentOf(element)! + if ( + is.ArrayPattern(pattern) || + (is.ObjectPattern(pattern) && + !pattern.properties.some((property) => is.RestElement(property))) + ) { + addOwner(patternElements, element, binding) + } + element = pattern + } + } } } + return { declarations, declarationBindings, patternElements } } -function walkIdentifierChildren( - current: t.Node, - parent: t.Node | undefined, - scopes: IdentifierScopeStack, - ids: Set<string>, -) { - for (const key of t.VISITOR_KEYS[current.type] ?? []) { - const child = (current as any)[key] - if (Array.isArray(child)) { - for (const item of child) { - if (item && typeof item.type === 'string') { - walkIdentifierNode(item, current, parent, key, scopes, ids) +/** Bindings sharing a declarator are one initialization unit. */ +export function moduleDeclarationGraph(module: Module): ModuleDeclarationGraph { + const { declarations, declarationBindings } = declarationIndex( + module, + module.rootScope.bindings, + ) + const dependencies = new Map<Binding, Set<Binding>>() + for (const [declaration, owners] of declarationBindings) { + const references = collectModuleReferences(module, declaration) + for (const binding of owners) { + const combined = dependencies.get(binding) ?? new Set<Binding>() + for (const reference of references) { + if (reference !== binding) { + combined.add(reference) } } - } else if (child && typeof child.type === 'string') { - walkIdentifierNode(child, current, parent, key, scopes, ids) + dependencies.set(binding, combined) } } + return { declarations, declarationBindings, dependencies } } -function walkIdentifierNode( - current: t.Node | null | undefined, - parent: t.Node | undefined, - grandparent: t.Node | undefined, - parentKey: string | undefined, - scopes: IdentifierScopeStack, - ids: Set<string>, -) { - if (!current) return - - if (t.isIdentifier(current)) { - if ( - (!parent || t.isReferenced(current, parent, grandparent)) && - !hasIdentifierBinding(scopes, current.name) - ) { - ids.add(current.name) +export function expandTransitively<T>( + roots: Set<T>, + dependencies: Map<T, Set<T>>, +): Set<T> { + const expanded = new Set(roots) + for (const item of expanded) { + for (const dependency of dependencies.get(item) ?? []) { + expanded.add(dependency) } - return } + return expanded +} - if (t.isJSXIdentifier(current)) { - if (parent && t.isJSXAttribute(parent) && parentKey === 'name') { - return - } - - if (parent && t.isJSXMemberExpression(parent) && parentKey === 'property') { - return - } - - const first = current.name[0] - if (first && first === first.toLowerCase()) { - return +/** Erase imports/exports that exist only in TypeScript's type space. */ +export function stripTypeExports(program: Program): void { + program.body = program.body.filter((statement) => { + if (is.ImportDeclaration(statement)) { + if (statement.importKind === 'type') { + return false + } + const hadSpecifiers = statement.specifiers.length > 0 + statement.specifiers = statement.specifiers.filter( + (specifier) => + !is.ImportSpecifier(specifier) || specifier.importKind !== 'type', + ) + return !hadSpecifiers || statement.specifiers.length > 0 } - - if (!hasIdentifierBinding(scopes, current.name)) { - ids.add(current.name) + if (is.ExportAllDeclaration(statement)) { + return statement.exportKind !== 'type' } - return - } - - if (t.isProgram(current)) { - const nestedScopes = createNestedIdentifierScope('program', scopes) - addIdentifierBlockBindings(current.body, nestedScopes) - for (const child of current.body) { - walkIdentifierNode(child, current, parent, 'body', nestedScopes, ids) + if (is.ExportNamedDeclaration(statement)) { + if ( + statement.exportKind === 'type' || + is.TSInterfaceDeclaration(statement.declaration) || + is.TSTypeAliasDeclaration(statement.declaration) + ) { + return false + } + const hadSpecifiers = statement.specifiers.length > 0 + statement.specifiers = statement.specifiers.filter( + (specifier) => specifier.exportKind !== 'type', + ) + return ( + !!statement.declaration || + !hadSpecifiers || + statement.specifiers.length > 0 + ) } - return - } + return true + }) +} - if (t.isBlockStatement(current)) { - const nestedScopes = createNestedIdentifierScope('block', scopes) - addIdentifierBlockBindings(current.body, nestedScopes) - for (const child of current.body) { - walkIdentifierNode(child, current, parent, 'body', nestedScopes, ids) - } - return - } +interface BindingUses { + /** Declarations present in the traced program. */ + present: Set<Binding> + /** Bindings used outside of any declaration, including exports. */ + roots: Set<Binding> + /** Bindings used by each declaration's own code. */ + uses: Map<Binding, Set<Binding>> + /** Declarations that execute only when their enclosing declaration does. */ + parents: Map<Binding, Set<Binding>> + /** + * The owners (null outside of any declaration) of each import's erased + * references: references from types, and for an import named `React`, the + * JSX that a classic JSX runtime compiles to `React.createElement` calls. + */ + erasedReferences: Map<Binding, Set<Set<Binding> | null>> + /** Bindings a type references outside of their own declaration. */ + typeReferenced: Set<Binding> +} + +interface JsxFactoryNames { + /** Names JSX elements compile to calls of. */ + element: Array<string> + /** Names fragments compile to references of, the element names included. */ + fragment: Array<string> +} +/** + * The names a classic JSX runtime compiles JSX to references of, as the + * file's `@jsx` and `@jsxFrag` pragmas name them (`React.createElement` → + * `React`). `@jsxRuntime automatic` makes them ignored. The default `React` + * factory is an erased reference instead (see `BindingUses`). + */ +function jsxFactoryNames(module: Module): JsxFactoryNames { + const names: JsxFactoryNames = { element: [], fragment: [] } if ( - t.isFunctionDeclaration(current) || - t.isFunctionExpression(current) || - t.isArrowFunctionExpression(current) || - t.isObjectMethod(current) || - t.isClassMethod(current) || - t.isClassPrivateMethod(current) - ) { - if (t.isFunctionDeclaration(current) && current.id) { - currentIdentifierScope(scopes).bindings.add(current.id.name) - } - - const nestedScopes = createNestedIdentifierScope('function', scopes) - if ( - (t.isFunctionDeclaration(current) || t.isFunctionExpression(current)) && - current.id - ) { - currentIdentifierScope(nestedScopes).bindings.add(current.id.name) - } - for (const param of current.params) { - addIdentifierPatternBindings(param, currentIdentifierScope(nestedScopes)) - } - - walkIdentifierChildren(current, parent, nestedScopes, ids) - return - } - - if (t.isCatchClause(current)) { - const nestedScopes = createNestedIdentifierScope('block', scopes) - addIdentifierPatternBindings( - current.param, - currentIdentifierScope(nestedScopes), - ) - walkIdentifierNode( - current.param, - current, - parent, - 'param', - nestedScopes, - ids, + !module.comments.some((comment) => + /@jsxRuntime\s+automatic\b/.test(comment.value), ) - walkIdentifierNode(current.body, current, parent, 'body', nestedScopes, ids) - return - } - - if (t.isImportDeclaration(current)) { - addIdentifierImportBindings(current, currentIdentifierScope(scopes)) - return - } - - if (t.isClassDeclaration(current) || t.isClassExpression(current)) { - if (t.isClassDeclaration(current) && current.id) { - currentIdentifierScope(scopes).bindings.add(current.id.name) - } - - const nestedScopes = current.id - ? createNestedIdentifierScope('block', scopes) - : scopes - if (current.id) { - currentIdentifierScope(nestedScopes).bindings.add(current.id.name) + ) { + for (const comment of module.comments) { + for (const [, pragma, name] of comment.value.matchAll( + /@(jsx|jsxFrag)\s+([\p{L}\p{N}_$]+)/gu, + )) { + names[pragma === 'jsx' ? 'element' : 'fragment'].push(name!) + } } - - walkIdentifierChildren(current, parent, nestedScopes, ids) - return + names.fragment = [...names.element, ...names.fragment] } + return names +} - if (t.isVariableDeclaration(current)) { - addIdentifierDeclarationBindings(current, scopes) - } else if (t.isVariableDeclarator(current)) { - const scope = - parent && t.isVariableDeclaration(parent) && parent.kind === 'var' - ? nearestFunctionIdentifierScope(scopes) - : currentIdentifierScope(scopes) - addIdentifierPatternBindings(current.id, scope) - } else if ( - t.isTSTypeAliasDeclaration(current) || - t.isTSInterfaceDeclaration(current) || - t.isTSEnumDeclaration(current) - ) { - currentIdentifierScope(scopes).bindings.add(current.id.name) - } +function jsxFactoriesOf(names: JsxFactoryNames, node: Node): Array<string> { + return is.JSXFragment(node) ? names.fragment : names.element +} - walkIdentifierChildren(current, parent, scopes, ids) +function addEdge( + edges: Map<Binding, Set<Binding>>, + from: Binding, + to: Binding, +) { + const targets = edges.get(from) ?? new Set<Binding>() + targets.add(to) + edges.set(from, targets) } /** - * Recursively walk an AST node and collect referenced identifier-like names. - * This avoids Babel path/scope allocation for module-level dependency scans. + * Runtime uses only: TypeScript erases types, so they neither use a declaration + * nor keep it alive; erased references to imports are recorded apart. + * `originOf` maps a traced node to its source node, if any. */ -export function collectIdentifiersFromNode(node: t.Node): Set<string> { - const ids = new Set<string>() - walkIdentifierNode( - node, - undefined, - undefined, - undefined, - [{ kind: 'program', bindings: new Set() }], - ids, - ) - return ids -} - -export function collectIdentifiersFromPattern( - node: t.LVal | t.Node | null | undefined, -): Array<string> { - if (!node) { - return [] - } - - if (t.isIdentifier(node)) { - return [node.name] - } - - if (t.isAssignmentPattern(node)) { - return collectIdentifiersFromPattern(node.left) - } - - if (t.isRestElement(node)) { - return collectIdentifiersFromPattern(node.argument) - } - - if (t.isObjectPattern(node)) { - return node.properties.flatMap((prop) => { - if (t.isObjectProperty(prop)) { - return collectIdentifiersFromPattern(prop.value as t.LVal) +function collectBindingUses( + module: Module, + program: Program, + index: DeclarationIndex, + factories: JsxFactoryNames, + originOf: (node: Node) => Node | undefined, +): BindingUses { + const result: BindingUses = { + present: new Set(), + roots: new Set(), + uses: new Map(), + parents: new Map(), + erasedReferences: new Map(), + typeReferenced: new Set(), + } + const reactImport = module.rootScope.find('React') + const erasedReference = (owner: Set<Binding> | null, binding: Binding) => { + if (binding.has(BindingFlags.Import)) { + const owners = result.erasedReferences.get(binding) ?? new Set() + owners.add(owner) + result.erasedReferences.set(binding, owners) + } + } + const ownerStack: Array<Set<Binding> | null> = [] + const scopeStack: Array<Scope> = [] + const use = (owner: Set<Binding> | null, binding: Binding) => { + if (!owner) { + result.roots.add(binding) + return + } + for (const source of owner) { + addEdge(result.uses, source, binding) + } + } + // Export records are uses even when there are no lexical references. A + // transform that removes an export may also remove its declaration graph. + const markExport = (owner: Set<Binding> | null, identifier: Node) => { + const originalIdentifier = originOf(identifier) + const binding = originalIdentifier + ? module.bindingOf(originalIdentifier) + : module.rootScope.find(nameOf(identifier) ?? '') + if (binding) { + use(owner, binding) + } + } + walk(program, { + enter(node) { + const original = originOf(node) + const own = original ? index.declarationBindings.get(original) : undefined + const parentOwner = ownerStack.at(-1) ?? null + // A destructuring element's default value and computed key evaluate for + // its own bindings; the initializer evaluates for all of them. + const owner = + own ?? + (original ? index.patternElements.get(original) : undefined) ?? + parentOwner + ownerStack.push(owner) + const scope = original + ? module.scopeOf(original) + : (scopeStack.at(-1) ?? module.rootScope) + scopeStack.push(scope) + if (own) { + for (const binding of own) { + result.present.add(binding) + if (parentOwner) { + for (const parent of parentOwner) { + addEdge(result.parents, binding, parent) + } + } + } } - if (t.isRestElement(prop)) { - return collectIdentifiersFromPattern(prop.argument) + if (is.ExportNamedDeclaration(node) && node.declaration) { + const declaration = node.declaration + if (is.VariableDeclaration(declaration)) { + for (const item of declaration.declarations) { + for (const identifier of bindingIdentifiers(item.id)) { + markExport(owner, identifier) + } + } + } else if ('id' in declaration && declaration.id) { + markExport(owner, declaration.id) + } } - return [] - }) - } - - if (t.isArrayPattern(node)) { - return node.elements.flatMap((element) => - collectIdentifiersFromPattern(element), - ) - } - - return [] -} - -export function collectLocalBindingsFromStatement( - node: t.Statement | t.ModuleDeclaration, - bindings: Set<string>, -) { - const declaration = - t.isExportNamedDeclaration(node) && node.declaration - ? node.declaration - : t.isExportDefaultDeclaration(node) - ? node.declaration - : node - - if (t.isVariableDeclaration(declaration)) { - for (const declarator of declaration.declarations) { - for (const name of collectIdentifiersFromPattern(declarator.id)) { - bindings.add(name) + if ( + is.ExportDefaultDeclaration(node) && + (is.FunctionDeclaration(node.declaration) || + is.ClassDeclaration(node.declaration)) && + node.declaration.id + ) { + markExport(owner, node.declaration.id) } - } - } else if (t.isFunctionDeclaration(declaration) && declaration.id) { - bindings.add(declaration.id.name) - } else if (t.isClassDeclaration(declaration) && declaration.id) { - bindings.add(declaration.id.name) - } -} - -export function extractModuleInfoFromAst(ast: t.File): ExtractedModuleInfo { - const bindings = new Map<string, ModuleInfoBinding>() - const exportMap = new Map<string, string>() - const reExportAllSources: Array<string> = [] - - for (const node of ast.program.body) { - if (t.isImportDeclaration(node)) { - const source = node.source.value - for (const specifier of node.specifiers) { - if (t.isImportSpecifier(specifier)) { - bindings.set(specifier.local.name, { - type: 'import', - source, - importedName: getModuleExportName(specifier.imported), - }) - } else if (t.isImportDefaultSpecifier(specifier)) { - bindings.set(specifier.local.name, { - type: 'import', - source, - importedName: 'default', - }) - } else if (t.isImportNamespaceSpecifier(specifier)) { - bindings.set(specifier.local.name, { - type: 'import', - source, - importedName: '*', - }) - } + if (is.ExportSpecifier(node)) { + markExport(owner, node.local) } - continue - } - - if (t.isVariableDeclaration(node)) { - addVariableDeclarationModuleInfo(node, bindings) - continue - } - - if (t.isFunctionDeclaration(node) || t.isClassDeclaration(node)) { - addDeclarationModuleInfo(node, bindings) - continue - } - - if (t.isExportNamedDeclaration(node)) { - if (node.declaration) { - addDeclarationModuleInfo(node.declaration, bindings, exportMap) + const reference = original ? module.referenceOf(original) : null + if (reference?.inTypePosition && reference.binding) { + erasedReference(owner, reference.binding) + if (!owner?.has(reference.binding)) { + result.typeReferenced.add(reference.binding) + } } - - for (const specifier of node.specifiers) { - if (t.isExportNamespaceSpecifier(specifier)) { - const exported = getModuleExportName(specifier.exported) - exportMap.set(exported, exported) - if (node.source) { - bindings.set(exported, { - type: 'import', - source: node.source.value, - importedName: '*', - }) - } - } else if (t.isExportSpecifier(specifier)) { - const local = getModuleExportName(specifier.local) - const exported = getModuleExportName(specifier.exported) - exportMap.set(exported, local) - - if (node.source) { - bindings.set(local, { - type: 'import', - source: node.source.value, - importedName: local, - }) + if (is.JSXElement(node) || is.JSXFragment(node)) { + if (reactImport) { + erasedReference(owner, reactImport) + } + for (const name of jsxFactoriesOf(factories, node)) { + const factory = module.lookup(name, { from: scope }) + if (factory) { + use(owner, factory) } } } - continue - } - - if (t.isExportDefaultDeclaration(node)) { - const declaration = node.declaration - if (t.isIdentifier(declaration)) { - exportMap.set('default', declaration.name) - } else if ( - (t.isFunctionDeclaration(declaration) || - t.isClassDeclaration(declaration)) && - declaration.id - ) { - bindings.set(declaration.id.name, { - type: 'var', - init: null, - }) - exportMap.set('default', declaration.id.name) - } else { - const synth = '__default_export__' - bindings.set(synth, { - type: 'var', - init: t.isExpression(declaration) ? declaration : null, - }) - exportMap.set('default', synth) + let binding = + reference && !reference.inTypePosition ? reference.binding : null + const generated = generatedReferenceOf(node) + if (!original && generated) { + binding = + typeof generated === 'string' + ? module.lookup(generated, { from: scope }) + : generated } + if (binding) { + use(owner, binding) + } + }, + leave() { + ownerStack.pop() + scopeStack.pop() + }, + }) + return result +} + +/** + * Declarations that nothing outside their own reference cycle uses at runtime, + * such as `const stop = subscribe(() => stop())`. They exist for their side + * effects, or for the TypeScript transform to erase, as imports only used in + * types (except a classic JSX runtime's `React`, which JSX needs). + */ +function findInitiallyUnused({ present, roots, uses }: BindingUses) { + // Tarjan's strongly connected components, iterative to bound stack depth + const order = new Map<Binding, number>() + const lowLink = new Map<Binding, number>() + const component = new Map<Binding, Array<Binding>>() + const stack: Array<Binding> = [] + for (const start of present) { + if (order.has(start)) { continue } - - if (t.isExportAllDeclaration(node)) { - reExportAllSources.push(node.source.value) + const work: Array<[Binding, Iterator<Binding>]> = [] + const visit = (binding: Binding) => { + lowLink.set(binding, order.size) + order.set(binding, order.size) + stack.push(binding) + work.push([binding, (uses.get(binding) ?? new Set()).values()]) + } + visit(start) + while (work.length) { + const [binding, targets] = work.at(-1)! + const next = targets.next() + if (!next.done) { + if (!order.has(next.value)) { + visit(next.value) + } else if (!component.has(next.value)) { + lowLink.set( + binding, + Math.min(lowLink.get(binding)!, order.get(next.value)!), + ) + } + continue + } + work.pop() + const caller = work.at(-1)?.[0] + if (caller) { + lowLink.set( + caller, + Math.min(lowLink.get(caller)!, lowLink.get(binding)!), + ) + } + if (lowLink.get(binding) === order.get(binding)) { + const members: Array<Binding> = [] + let member: Binding + do { + member = stack.pop()! + members.push(member) + component.set(member, members) + } while (member !== binding) + } } } - - return { - bindings, - exports: exportMap, - reExportAllSources, + const used = new Set<Array<Binding>>() + for (const root of roots) { + const members = component.get(root) + if (members) { + used.add(members) + } } -} - -export function buildDeclarationMap(ast: t.File): Map<string, t.Node> { - const map = new Map<string, t.Node>() - - for (const statement of ast.program.body) { - const declaration = - t.isExportNamedDeclaration(statement) && statement.declaration - ? statement.declaration - : t.isExportDefaultDeclaration(statement) - ? statement.declaration - : statement - - if (t.isVariableDeclaration(declaration)) { - for (const declarator of declaration.declarations) { - for (const name of collectIdentifiersFromPattern(declarator.id)) { - map.set(name, declarator) - } + for (const [from, targets] of uses) { + for (const to of targets) { + const members = component.get(to) + if (members && members !== component.get(from)) { + used.add(members) } - } else if (t.isFunctionDeclaration(declaration) && declaration.id) { - map.set(declaration.id.name, declaration) - } else if (t.isClassDeclaration(declaration) && declaration.id) { - map.set(declaration.id.name, declaration) } } - - return map + return new Set( + [...present].filter((binding) => !used.has(component.get(binding)!)), + ) } -export function buildDependencyGraph( - declarationMap: Map<string, t.Node>, - localBindings: Set<string>, -): Map<string, Set<string>> { - const graph = new Map<string, Set<string>>() - - for (const [name, declarationNode] of declarationMap) { - if (!localBindings.has(name)) continue +interface InitiallyUnused { + /** Survive wherever their enclosing code does. */ + preserved: Set<Binding> + /** + * Imports only types reference, which survive wherever such a reference (or + * the JSX a classic runtime compiles with them) does. + */ + erased: Set<Binding> + /** + * Bindings whose code can run: declarations that initialize when their + * enclosing code does, and the functions code that can run reads. An unused + * function, and what is declared inside it, cannot run. + */ + runnable: Set<Binding> +} - const dependencies = new Set<string>() - for (const id of collectIdentifiersFromNode(declarationNode)) { - if (id !== name && localBindings.has(id)) { - dependencies.add(id) +/** + * The bindings that survive in a traced program: the roots, the preserved + * declarations wherever their enclosing code survives, and what surviving + * code uses. `follows` filters the uses an output does not keep alive. + */ +function survivingBindings( + uses: BindingUses, + { preserved, erased }: Pick<InitiallyUnused, 'preserved' | 'erased'>, + follows: (from: Binding, to: Binding) => boolean = () => true, +): Set<Binding> { + const live = new Set(uses.roots) + const dependencies = new Map<Binding, Set<Binding>>() + for (const [from, targets] of uses.uses) { + for (const to of targets) { + if (follows(from, to)) { + addEdge(dependencies, from, to) } } - graph.set(name, dependencies) } - - return graph -} - -export function collectModuleLevelRefsFromNode( - node: t.Node, - localModuleLevelBindings: Set<string>, -): Set<string> { - const refs = new Set<string>() - - for (const name of collectIdentifiersFromNode(node)) { - if (localModuleLevelBindings.has(name)) { - refs.add(name) + for (const [binding, parents] of uses.parents) { + for (const parent of parents) { + // A live nested declaration needs its enclosing code to exist + addEdge(dependencies, binding, parent) + // and a preserved one survives wherever its enclosing code does + if (preserved.has(binding)) { + addEdge(dependencies, parent, binding) + } } } - - return refs -} - -export function expandTransitively( - bindings: Set<string>, - dependencyGraph: Map<string, Set<string>>, -) { - const queue = [...bindings] - const visited = new Set<string>() - - while (queue.length > 0) { - const name = queue.pop()! - if (visited.has(name)) continue - visited.add(name) - - const dependencies = dependencyGraph.get(name) - if (!dependencies) continue - - for (const dependency of dependencies) { - if (!bindings.has(dependency)) { - bindings.add(dependency) - queue.push(dependency) - } + for (const binding of uses.present) { + if (preserved.has(binding) && !uses.parents.has(binding)) { + live.add(binding) } } -} - -export function expandSharedDestructuredDeclarators( - ast: t.File, - refsByGroup: Map<string, Set<number>>, - sharedBindings: Set<string>, -) { - for (const statement of ast.program.body) { - const declaration = - t.isExportNamedDeclaration(statement) && statement.declaration - ? statement.declaration - : statement - - if (!t.isVariableDeclaration(declaration)) continue - - for (const declarator of declaration.declarations) { - if ( - !t.isObjectPattern(declarator.id) && - !t.isArrayPattern(declarator.id) - ) { + for (const [binding, owners] of uses.erasedReferences) { + if (!erased.has(binding)) { + continue + } + for (const owner of owners) { + if (!owner) { + live.add(binding) continue } - - const names = collectIdentifiersFromPattern(declarator.id) - const usedGroups = new Set<number>() - - for (const name of names) { - const groups = refsByGroup.get(name) - if (!groups) continue - for (const group of groups) { - usedGroups.add(group) - } - } - - if (usedGroups.size >= 2) { - for (const name of names) { - sharedBindings.add(name) - } + for (const from of owner) { + addEdge(dependencies, from, binding) } } } + return expandTransitively(live, dependencies) } -export function expandDestructuredDeclarations( - ast: t.File, - bindings: Set<string>, -) { - for (const statement of ast.program.body) { - const declaration = - t.isExportNamedDeclaration(statement) && statement.declaration - ? statement.declaration - : statement - - if (!t.isVariableDeclaration(declaration)) continue - - for (const declarator of declaration.declarations) { - if ( - !t.isObjectPattern(declarator.id) && - !t.isArrayPattern(declarator.id) - ) { - continue - } - - const names = collectIdentifiersFromPattern(declarator.id) - if (names.some((name) => bindings.has(name))) { - for (const name of names) { - bindings.add(name) - } - } - } +/** A declaration whose initialization runs no code of its own. */ +function isFunctionDeclaration(declaration: Node) { + if (is.VariableDeclarator(declaration)) { + const init = declaration.init && unwrap(declaration.init) + return is.ArrowFunctionExpression(init) || is.FunctionExpression(init) } + return ( + is.FunctionDeclaration(declaration) || is.TSDeclareFunction(declaration) + ) } -export function removeBindingsTransitivelyDependingOn( - bindings: Set<string>, - dependencyGraph: Map<string, Set<string>>, - roots: Iterable<string>, -) { - const reverseGraph = new Map<string, Set<string>>() - - for (const [name, dependencies] of dependencyGraph) { - for (const dependency of dependencies) { - let parents = reverseGraph.get(dependency) - if (!parents) { - parents = new Set() - reverseGraph.set(dependency, parents) +function initiallyUnused( + module: Module, + index: DeclarationIndex, + factories: JsxFactoryNames, +): InitiallyUnused { + const uses = collectBindingUses( + module, + module.ast, + index, + factories, + (node) => node, + ) + const all = findInitiallyUnused(uses) + const erased = new Set( + [...all].filter( + (binding) => + binding.has(BindingFlags.Import) && + binding.references.some((reference) => reference.inTypePosition), + ), + ) + const preserved = new Set([...all].filter((binding) => !erased.has(binding))) + // Only uses by surviving code count: a declaration that only unused code + // inside it reads, as `const plugin = { m() { const self = plugin } }`, was + // unused too. The unchanged source keeps every declaration. + const surviving = survivingBindings(uses, { preserved, erased }) + for (const binding of uses.present) { + if (!surviving.has(binding) && !erased.has(binding)) { + preserved.add(binding) + } + } + const initializing = new Set<Binding>() + for (const [declaration, owners] of index.declarationBindings) { + if (!isFunctionDeclaration(declaration)) { + for (const binding of owners) { + initializing.add(binding) } - parents.add(name) } } - - const visited = new Set<string>() - const queue = [...roots] - - while (queue.length > 0) { - const current = queue.pop()! - if (visited.has(current)) continue - visited.add(current) - - const parents = reverseGraph.get(current) - if (!parents) continue - + const functions = new Set( + [...uses.present].filter((binding) => !initializing.has(binding)), + ) + const nested = new Map<Binding, Set<Binding>>() + for (const [binding, parents] of uses.parents) { for (const parent of parents) { - if (!visited.has(parent)) { - queue.push(parent) - } + addEdge(nested, parent, binding) } } - - for (const name of [...bindings]) { - if (visited.has(name)) { - bindings.delete(name) + const runnable = new Set(uses.roots) + for (const binding of uses.present) { + if (!uses.parents.has(binding) && !functions.has(binding)) { + runnable.add(binding) } } -} - -export function removeModuleLevelBindings( - ast: t.File, - namesToRemove: Set<string>, -) { - ast.program.body = ast.program.body.filter((statement) => { - const declaration = - t.isExportNamedDeclaration(statement) && statement.declaration - ? statement.declaration - : statement - - if (t.isVariableDeclaration(declaration)) { - declaration.declarations = declaration.declarations.filter( - (declarator) => - !collectIdentifiersFromPattern(declarator.id).some((name) => - namesToRemove.has(name), - ), - ) - return declaration.declarations.length > 0 + for (const binding of runnable) { + for (const used of uses.uses.get(binding) ?? []) { + runnable.add(used) } - - if (t.isFunctionDeclaration(declaration) && declaration.id) { - return !namesToRemove.has(declaration.id.name) - } - - if (t.isClassDeclaration(declaration) && declaration.id) { - return !namesToRemove.has(declaration.id.name) - } - - if (t.isExportDefaultDeclaration(statement)) { - const defaultDeclaration = statement.declaration - if ( - (t.isFunctionDeclaration(defaultDeclaration) || - t.isClassDeclaration(defaultDeclaration)) && - defaultDeclaration.id - ) { - return !namesToRemove.has(defaultDeclaration.id.name) + for (const child of nested.get(binding) ?? []) { + if (!functions.has(child)) { + runnable.add(child) } } - - return true - }) + } + return { preserved, erased, runnable } } -export function retainModuleLevelDeclarations( - ast: t.File, - bindingsToKeep: Set<string>, -) { - ast.program.body = ast.program.body.filter((statement) => { - if (t.isImportDeclaration(statement)) return true - - const declaration = - t.isExportNamedDeclaration(statement) && statement.declaration - ? statement.declaration - : statement +export interface RemoveUnusedBindingsOptions { + /** + * Declarations that moved to another module, as split route options and + * what they read. Code that cannot run, such as an unused function, keeps a + * dangling reference to them rather than keeping them in this one. + */ + moved?: Set<Binding> + /** + * The program is a copy of the module for some of its code (a split chunk): + * an initially unused declaration that it reads only in types, or a + * namespace nothing in it reads, is not copied into it. + */ + partialCopy?: boolean +} - if (t.isVariableDeclaration(declaration)) { - declaration.declarations = declaration.declarations.filter((declarator) => - collectIdentifiersFromPattern(declarator.id).some((name) => - bindingsToKeep.has(name), +/** + * Rebuild liveness from surviving nodes, using original binding identity. This + * removes dependencies of erased route options without reparsing generated code. + * Only declarations the source used, and whose uses the transform erased, are + * removed: initially unused ones survive wherever their enclosing code does, + * except imports only types reference, which survive with those references. + * Callers decide output ownership before invoking this lexical binding cleanup. + */ +export function removeUnusedBindings( + module: Module, + program: Program, + originalNodes: WeakMap<Node, Node>, + options: RemoveUnusedBindingsOptions = {}, +): void { + stripTypeExports(program) + const index = declarationIndex(module, module.bindings) + const factories = jsxFactoryNames(module) + const { preserved, erased, runnable } = initiallyUnused( + module, + index, + factories, + ) + const output = collectBindingUses(module, program, index, factories, (node) => + originalNodes.get(node), + ) + const kept = options.partialCopy + ? new Set( + [...preserved].filter( + (binding) => + !output.typeReferenced.has(binding) && + !( + binding.has(BindingFlags.ValueModule) && + binding.declarations.every((identifier) => + is.TSModuleDeclaration(module.parentOf(identifier)), + ) + ), ), ) - return declaration.declarations.length > 0 - } - - if (t.isFunctionDeclaration(declaration) && declaration.id) { - return bindingsToKeep.has(declaration.id.name) - } - - if (t.isClassDeclaration(declaration) && declaration.id) { - return bindingsToKeep.has(declaration.id.name) + : preserved + const { moved } = options + const retained = survivingBindings( + output, + { preserved: kept, erased }, + moved && ((from, to) => runnable.has(from) || !moved.has(to)), + ) + const removable = new Set( + [...output.present].filter((binding) => !retained.has(binding)), + ) + const isRemovable = (node: Node | null) => { + const original = node && originalNodes.get(node) + const owners = + original && + (index.declarationBindings.get(original) ?? + index.patternElements.get(original)) + return !!owners && [...owners].every((binding) => removable.has(binding)) + } + // Names the surviving code references, to drop unused generated imports + const referencedNames = new Set<string>() + walk(program, { + enter(node, context) { + if (isRemovable(node)) { + context.remove() + return + } + if (is.ObjectPattern(node)) { + node.properties = node.properties.filter( + (property) => !isRemovable(property), + ) + } else if (is.ArrayPattern(node)) { + // Holes keep the positions of the remaining elements + node.elements = node.elements.map((element) => + isRemovable(element) ? null : element, + ) + } else if (is.JSXElement(node) || is.JSXFragment(node)) { + for (const name of jsxFactoriesOf(factories, node)) { + referencedNames.add(name) + } + } + const original = originalNodes.get(node) + if (original) { + const reference = module.referenceOf(original) + if (reference && !reference.inTypePosition) { + referencedNames.add(reference.name) + } + } else { + const generated = generatedReferenceOf(node) + if (generated) { + referencedNames.add( + typeof generated === 'string' ? generated : generated.name, + ) + } + } + }, + leave(node, context) { + if (is.VariableDeclaration(node) && node.declarations.length === 0) { + // A statement slot, as in `if (x) var y = z`, cannot be left empty; + // a statement list, a loop head and an `export` can. + if ( + context.index === null && + context.key !== 'init' && + context.key !== 'declaration' + ) { + context.replace(b.BlockStatement({ body: [] })) + } else { + context.remove() + } + } else if (is.ImportDeclaration(node) && node.specifiers.length === 0) { + const original = originalNodes.get(node) + if ( + original && + is.ImportDeclaration(original) && + original.specifiers.length > 0 + ) { + context.remove() + } + } else if ( + is.ExportNamedDeclaration(node) && + !node.declaration && + node.specifiers.length === 0 && + !node.source + ) { + context.remove() + } + }, + }) + program.body = program.body.filter((statement) => { + if ( + !is.ImportDeclaration(statement) || + originalNodes.has(statement) || + statement.specifiers.length === 0 + ) { + return true } - - return false + statement.specifiers = statement.specifiers.filter((specifier) => + referencedNames.has(specifier.local.name), + ) + return statement.specifiers.length > 0 }) } -export function unwrapExportedDeclarations(ast: t.File) { - const body: Array<t.Statement | t.ModuleDeclaration> = [] +export type ModuleInfoBinding = + | { type: 'import'; source: string; importedName: string } + | { type: 'var'; init: Expression | null } - for (const statement of ast.program.body) { - if (t.isExportNamedDeclaration(statement)) { - if (statement.declaration) { - body.push(statement.declaration) - } +export interface ExtractedModuleInfo { + bindings: Map<string, ModuleInfoBinding> + exports: Map<string, string> + reExportAllSources: Array<string> +} + +/** The bundler owns cross-module resolution; Yuku provides local module records. */ +export function extractModuleInfo(sourceModule: Module): ExtractedModuleInfo { + const bindings = new Map<string, ModuleInfoBinding>() + const exportBindings = new Map<string, string>() + const reExportAllSources: Array<string> = [] + const graph = declarationIndex(sourceModule, sourceModule.rootScope.bindings) + for (const [binding, declaration] of graph.declarations) { + bindings.set(binding.name, { + type: 'var', + init: is.VariableDeclarator(declaration) ? declaration.init : null, + }) + } + for (const record of sourceModule.imports) { + if (record.local && record.specifier && !record.typeOnly) { + bindings.set(record.local.name, { + type: 'import', + source: record.specifier, + importedName: record.isNamespace ? '*' : (record.name ?? 'default'), + }) + } + } + let syntheticIndex = 0 + const syntheticName = () => { + let name: string + do { + name = `__module_export_${syntheticIndex++}__` + } while (bindings.has(name)) + return name + } + for (const record of sourceModule.exports) { + if (record.typeOnly) { continue } - - if (t.isExportDefaultDeclaration(statement)) { - const declaration = statement.declaration - if ( - (t.isFunctionDeclaration(declaration) || - t.isClassDeclaration(declaration)) && - declaration.id + if (record.kind === 'star' && record.specifier) { + reExportAllSources.push(record.specifier) + } else if (record.name) { + if (record.local) { + exportBindings.set(record.name, record.local.name) + } else if (record.specifier) { + const name = syntheticName() + bindings.set(name, { + type: 'import', + source: record.specifier, + importedName: + record.kind === 'namespace' + ? '*' + : (record.fromName ?? record.name), + }) + exportBindings.set(record.name, name) + } else if ( + record.name === 'default' && + is.ExportDefaultDeclaration(record.node) ) { - body.push(declaration) + const declaration = record.node.declaration + const name = syntheticName() + bindings.set(name, { + type: 'var', + init: is.Expression(declaration) ? declaration : null, + }) + exportBindings.set('default', name) } - continue - } - - if (t.isExportAllDeclaration(statement)) { - continue } - - body.push(statement) } - - ast.program.body = body + return { bindings, exports: exportBindings, reExportAllSources } } -export function stripUnreferencedTopLevelExpressionStatements(ast: t.File) { - const locallyBound = new Set<string>() - - for (const statement of ast.program.body) { - collectLocalBindingsFromStatement(statement, locallyBound) - } - - ast.program.body = ast.program.body.filter((statement) => { - if (!t.isExpressionStatement(statement)) return true - - for (const name of collectIdentifiersFromNode(statement)) { - if (locallyBound.has(name)) { - return true - } - } - - return false - }) +/** + * The expression inside parentheses and erased TypeScript assertions. Any + * other node, or none, is returned as is. + */ +export function unwrapExpression<T extends Node | null | undefined>( + node: T, +): T | Expression { + return unwrap(node as Expression) } diff --git a/packages/router-utils/src/index.ts b/packages/router-utils/src/index.ts index 12c3c8e20c0..a8ca8903e5f 100644 --- a/packages/router-utils/src/index.ts +++ b/packages/router-utils/src/index.ts @@ -1,34 +1,29 @@ export { - parseAst, - generateFromAst, - deadCodeElimination, - findReferencedIdentifiers, - stripTypeExports, + analyzeModule, + generateModule, + keepFilePragmas, + unwrapExport, + cloneModuleAst, + cloneGeneratedNode, + parseStatements, + parseExpression, + linkGeneratedReference, + prependStatements, + sourcePosition, } from './ast' -export type { ParseAstOptions, ParseAstResult, GeneratorResult } from './ast' +export type { AnalyzeModuleOptions, GenerateResult } from './ast' export { logDiff } from './logger' - export { copyFilesPlugin } from './copy-files-plugin' - export { createIdentifier, decodeIdentifier } from './path-ids' - export { - buildDeclarationMap, - buildDependencyGraph, - collectIdentifiersFromNode, - collectIdentifiersFromPattern, - collectLocalBindingsFromStatement, - collectModuleLevelRefsFromNode, - expandDestructuredDeclarations, - expandSharedDestructuredDeclarators, - expandTransitively, - extractModuleInfoFromAst, - getVariableDeclaratorForExpressionPath, - removeBindingsTransitivelyDependingOn, - removeModuleLevelBindings, - retainModuleLevelDeclarations, - stripUnreferencedTopLevelExpressionStatements, + collectModuleReferences, + extractModuleInfo, unwrapExpression, - unwrapExportedDeclarations, + moduleDeclarationGraph, + expandTransitively, + removeUnusedBindings, +} from './compiler-helpers' +export type { + ModuleInfoBinding, + RemoveUnusedBindingsOptions, } from './compiler-helpers' -export type { ExtractedModuleInfo, ModuleInfoBinding } from './compiler-helpers' diff --git a/packages/router-utils/tests/ast.test-d.ts b/packages/router-utils/tests/ast.test-d.ts new file mode 100644 index 00000000000..e3b0dc16aca --- /dev/null +++ b/packages/router-utils/tests/ast.test-d.ts @@ -0,0 +1,32 @@ +import { expectTypeOf, test } from 'vitest' +import { b } from 'yuku-ast' +import { + analyzeModule, + cloneGeneratedNode, + cloneModuleAst, + generateModule, + linkGeneratedReference, + prependStatements, + sourcePosition, +} from '../src/ast' +import type { Identifier, Program } from '@yuku-toolchain/types' +import type { Module } from 'yuku-analyzer' + +test('Yuku AST and semantic handles preserve their types', () => { + const module = analyzeModule({ code: 'const value = 1' }) + expectTypeOf(module).toEqualTypeOf<Module>() + const clone = cloneModuleAst(module) + expectTypeOf(clone.program).toEqualTypeOf<Program>() + const reference = linkGeneratedReference( + b.Identifier({ name: 'value' }), + 'value', + ) + expectTypeOf(reference).toEqualTypeOf<Identifier>() + expectTypeOf(cloneGeneratedNode(reference)).toEqualTypeOf<Identifier>() + expectTypeOf(generateModule(clone.program).code).toEqualTypeOf<string>() + expectTypeOf(prependStatements(clone.program)).toEqualTypeOf<void>() + expectTypeOf(sourcePosition('', 0)).toEqualTypeOf<{ + line: number + column: number + }>() +}) diff --git a/packages/router-utils/tests/compiler-helpers.test.ts b/packages/router-utils/tests/compiler-helpers.test.ts index 00b75d67e51..92f1b757870 100644 --- a/packages/router-utils/tests/compiler-helpers.test.ts +++ b/packages/router-utils/tests/compiler-helpers.test.ts @@ -1,249 +1,599 @@ -import * as t from '@babel/types' +import { runInNewContext } from 'node:vm' import { describe, expect, test } from 'vitest' +import { is, walk } from 'yuku-ast' import { - buildDeclarationMap, - collectIdentifiersFromNode, - collectLocalBindingsFromStatement, - extractModuleInfoFromAst, + analyzeModule, + cloneGeneratedNode, + cloneModuleAst, + generateModule, + parseStatements, +} from '../src/ast' +import { + collectModuleReferences, + extractModuleInfo, + moduleDeclarationGraph, + removeUnusedBindings, } from '../src/compiler-helpers' -import { parseAst } from '../src/ast' - -function getVariableInit(code: string) { - const ast = parseAst({ code, filename: 'test.tsx' }) - const declaration = ast.program.body.find((node) => - t.isVariableDeclaration(node), - ) as t.VariableDeclaration - return declaration.declarations[0]!.init! -} - -function collectSortedIdentifiers(node: t.Node) { - return [...collectIdentifiersFromNode(node)].sort() -} - -function collectSortedStatementBindings(code: string) { - const ast = parseAst({ code, filename: 'test.tsx' }) - const bindings = new Set<string>() - for (const statement of ast.program.body) { - collectLocalBindingsFromStatement(statement, bindings) - } - return [...bindings].sort() -} -function collectSortedDeclarationMapEntries(code: string) { - const ast = parseAst({ code, filename: 'test.tsx' }) - return [...buildDeclarationMap(ast)] - .map(([name, node]): [string, string] => [name, node.type]) - .sort((left, right) => (left[0] < right[0] ? -1 : 1)) -} - -function collectModuleInfoSnapshot(code: string) { - const ast = parseAst({ code, filename: 'test.tsx' }) - const info = extractModuleInfoFromAst(ast) - return { - bindings: [...info.bindings] - .map(([name, binding]) => [ - name, - binding.type === 'import' - ? `${binding.source}:${binding.importedName}` - : (binding.init?.type ?? null), - ]) - .sort((left, right) => (left[0]! < right[0]! ? -1 : 1)), - exports: [...info.exports].sort((left, right) => - left[0] < right[0] ? -1 : 1, - ), - reExportAllSources: info.reExportAllSources, - } +function cleanup(code: string, erase: string) { + const module = analyzeModule({ code }) + const { program, originalNodes } = cloneModuleAst(module) + walk(program, { + Property(node, context) { + if (is.Identifier(node.key, erase)) { + context.remove() + } + }, + }) + removeUnusedBindings(module, program, originalNodes) + return generateModule(program).code } -describe('collectIdentifiersFromNode', () => { - test('collects free identifiers without reporting nested local bindings', () => { - const init = getVariableInit(` -const value = outer + (() => { - const local = dep - - function nested(param = fallback) { - const Inner = () => <LocalComponent value={param} /> - const LocalExpr = class NamedLocal { - method() { - return NamedLocal + local + param +describe('semantic dependency analysis', () => { + test('resolves nested closures, defaults, imports, and JSX without false shadowed dependencies', () => { + const module = analyzeModule({ + code: ` + import { Component, dep, fallback } from 'pkg' + const value = (param = fallback) => { + const dep = param + try { throw dep } catch (Component) { console.log(Component) } + return () => <Component value={dep} /> } + `, + }) + const graph = moduleDeclarationGraph(module) + const value = module.rootScope.find('value')! + expect( + [...graph.dependencies.get(value)!].map((symbol) => symbol.name).sort(), + ).toEqual(['Component', 'fallback']) + expect( + [...collectModuleReferences(module, graph.declarations.get(value)!)] + .map((symbol) => symbol.name) + .sort(), + ).toEqual(['Component', 'fallback']) + }) + + test('maps named default function and class declarations', () => { + for (const code of [ + 'export default function named() {}', + 'export default class named {}', + ]) { + const module = analyzeModule({ code }) + expect( + moduleDeclarationGraph(module).declarations.get( + module.rootScope.find('named')!, + )?.type, + ).toMatch(/Declaration$/) } + }) - class LocalComponent {} + test('keeps distinct re-export identities when imported names collide', () => { + const module = analyzeModule({ + code: ` + const foo = localFactory() + export { foo } + export { foo as a } from './a' + export { foo as b } from './b' + `, + }) + const info = extractModuleInfo(module) + expect(info.bindings.get(info.exports.get('foo')!)?.type).toBe('var') + expect(info.bindings.get(info.exports.get('a')!)).toEqual({ + type: 'import', + source: './a', + importedName: 'foo', + }) + expect(info.bindings.get(info.exports.get('b')!)).toEqual({ + type: 'import', + source: './b', + importedName: 'foo', + }) + }) - return local + param + imported + Inner + LocalExpr - } + test('includes overloaded implementations and merged namespace dependencies', () => { + const module = analyzeModule({ + code: ` + const dep = 1 + const another = 2 + function helper(value: string): string + function helper(value: number): number + function helper(value: any) { return dep + value } + namespace Merged { export const a = dep } + namespace Merged { export const b = another } + export const result = helper(Merged.a + Merged.b) + `, + }) + const graph = moduleDeclarationGraph(module) + expect( + [...graph.dependencies.get(module.rootScope.find('helper')!)!].map( + (symbol) => symbol.name, + ), + ).toEqual(['dep']) + expect( + [...graph.dependencies.get(module.rootScope.find('Merged')!)!] + .map((symbol) => symbol.name) + .sort(), + ).toEqual(['another', 'dep']) + const { program, originalNodes } = cloneModuleAst(module) + removeUnusedBindings(module, program, originalNodes) + const output = generateModule(program).code + expect(output).toContain('return dep + value') + expect(output).toContain('const b = another') + expect(output).toContain('const dep = 1') + expect(output).toContain('const another = 2') + }) - return nested() -})() -`) + test('removes every overload and namespace block once they are unused', () => { + const output = cleanup( + ` + const dep = 1 + function helper(value: string): string + function helper(value: any) { return dep + value } + namespace Merged { export const a = 1 } + namespace Merged { export const b = 2 } + export const Route = createRoute({ loader: () => helper(Merged.a + Merged.b) }) + `, + 'loader', + ) + expect(output).not.toContain('helper') + expect(output).not.toContain('Merged') + expect(output).not.toContain('dep') + }) - expect(collectSortedIdentifiers(init)).toMatchInlineSnapshot(` + test('tracks runtime enum and namespace declarations as owned bindings', () => { + const code = + 'enum E { A }; namespace N { export const x = E.A } export const Route = createRoute({ loader: () => N.x });' + const module = analyzeModule({ code }) + expect( [ - "dep", - "fallback", - "imported", - "outer", - ] - `) + ...moduleDeclarationGraph(module).dependencies.get( + module.rootScope.find('N')!, + )!, + ].map((symbol) => symbol.name), + ).toEqual(['E']) + const output = cleanup(code, 'loader') + expect(output).not.toContain('enum E') + expect(output).not.toContain('namespace N') }) +}) - test('respects nested variable, parameter, catch, and import shadowing', () => { - const ast = parseAst({ - code: ` -import { external as localImport } from 'pkg' +describe('output liveness', () => { + test('removes transitively erased server dependencies while preserving unrelated side effects', () => { + const output = cleanup( + ` + import { server } from './server' + import './side-effect' + const load = () => server() + const unused = initialize() + export const Route = createRoute({ loader: load, component: () => 'ok' }) + `, + 'loader', + ) + expect(output).not.toContain('./server') + expect(output).not.toContain('const load') + expect(output).toContain("import './side-effect'") + expect(output).toContain('initialize()') + }) -const value = (localParam = defaultValue) => { - const localShadow = factory(localImport) + test('drops the destructuring elements of erased bindings', () => { + const output = cleanup( + `const { a, b } = initialize(); export const Route = createRoute({ loader: () => a, component: () => b })`, + 'loader', + ) + expect(output).toContain('const { b } = initialize()') + expect(output.match(/initialize\(\)/g)).toHaveLength(1) + }) - try { - throw thrown - } catch (thrown) { - const factory = () => localShadow + localParam + thrown - return factory() - } -} -`, - filename: 'test.ts', + test.each([ + [ + 'an object pattern default', + 'const { erased = readSecret(), kept } = config', + ], + [ + 'an array pattern default', + 'const [erased = readSecret(), kept] = config', + ], + ['a computed key', 'const { [readSecret()]: erased, kept } = config'], + [ + 'a nested pattern default', + 'const { db: { erased = readSecret() } = {}, kept } = config', + ], + [ + 'a function default', + 'const { erased = () => readSecret(), kept } = config', + ], + ])( + 'removes the dependencies of %s of an erased binding', + (_, declaration) => { + const output = cleanup( + ` + import { readSecret } from './server' + import { config } from './config' + ${declaration} + export const Route = createRoute({ loader: () => erased, component: () => kept }) + `, + 'loader', + ) + expect(output).not.toContain('./server') + expect(output).not.toContain('erased') + expect(output).toMatch(/\bkept\b[^]*= config/) + }, + ) + + test('keeps the elements that an object rest binding excludes', () => { + const output = cleanup( + ` + import { readSecret } from './server' + const { secret = readSecret(), ...rest } = config + export const Route = createRoute({ loader: () => secret, component: () => rest }) + `, + 'loader', + ) + expect(output).toContain('secret = readSecret(), ...rest') + expect(output).toContain('./server') + }) + + test.each([true, false])( + 'executes whole destructured initializers only when a binding is live: %s', + (used) => { + const module = analyzeModule({ + code: `const { first, middle, last } = initialize(); globalThis.result = [first, middle, last][2]`, + }) + const { program, originalNodes } = cloneModuleAst(module) + if (!used) { + program.body.pop() + } + removeUnusedBindings(module, program, originalNodes) + const events: Array<string> = [] + const context = { + result: undefined, + initialize() { + events.push('initialize') + return { + get first() { + events.push('first') + return 0 + }, + get middle() { + events.push('middle') + return 1 + }, + get last() { + events.push('last') + return 2 + }, + } + }, + } + runInNewContext(generateModule(program).code, context) + expect(context.result).toBe(used ? 2 : undefined) + expect(events).toEqual( + used ? ['initialize', 'first', 'middle', 'last'] : [], + ) + }, + ) + + test('removes unreachable cycles after their last consumer is erased', () => { + const output = cleanup( + 'const a = () => b(); const b = () => a(); export const Route = createRoute({ loader: a })', + 'loader', + ) + expect(output).not.toContain('const a') + expect(output).not.toContain('const b') + }) + + test('preserves side effects that only reference themselves or each other', () => { + const output = cleanup( + ` + import { store } from './store' + import { createPersister } from './persist' + const stop = store.subscribe(() => stop()) + const persister = createPersister({ onRestore: () => restore() }) + function restore() { persister.restore() } + export function useThing() { + const observer = new MutationObserver(() => observer.disconnect()) + return 1 + } + export const Route = createRoute({ loader: () => 1 }) + `, + 'loader', + ) + expect(output).toContain('store.subscribe(') + expect(output).toContain("import { store } from './store'") + expect(output).toContain('createPersister(') + expect(output).toContain('function restore()') + expect(output).toContain('new MutationObserver(') + }) + + test('removes dependencies of an unused local once its enclosing declaration is erased', () => { + const output = cleanup( + ` + import { useMutation } from './mutation' + function save() {} + function Component() { + const mutation = useMutation({ fn: save }) + return null + } + export const Route = createRoute({ component: Component, loader: () => 1 }) + `, + 'component', + ) + expect(output).not.toContain('Component') + expect(output).not.toContain('./mutation') + expect(output).not.toContain('function save') + }) + + test('does not change original AST or symbol identities when generating multiple outputs', () => { + const module = analyzeModule({ + code: 'const value = 1; export const Route = value;', }) + const original = generateModule(module.ast).code + const first = cloneModuleAst(module) + first.program.body.splice(0, 1) + const second = cloneModuleAst(module) + expect(generateModule(second.program).code).toBe(original) + expect(module.rootScope.find('value')!.references).toHaveLength(1) + }) - expect(collectSortedIdentifiers(ast.program)).toMatchInlineSnapshot(` - [ - "defaultValue", - "factory", - "thrown", - ] - `) + test('retains exported namespace members through the live namespace owner', () => { + const module = analyzeModule({ + code: `namespace Labels { export const component = 'count' } export { Labels }`, + }) + const { program, originalNodes } = cloneModuleAst(module) + removeUnusedBindings(module, program, originalNodes) + expect(generateModule(program).code).toContain( + "export const component = 'count'", + ) }) -}) -describe('collectLocalBindingsFromStatement', () => { - test('collects ids from named default function and class declarations', () => { - expect({ - class: collectSortedStatementBindings( - `export default class DefaultComponent {}`, - ), - function: collectSortedStatementBindings( - `export default function DefaultRoute() {}`, - ), - anonymous: collectSortedStatementBindings( - `export default function () {}`, - ), - }).toMatchInlineSnapshot(` - { - "anonymous": [], - "class": [ - "DefaultComponent", - ], - "function": [ - "DefaultRoute", - ], + test('does not retain a runtime import that only types still reference', () => { + const output = cleanup( + ` + import { server } from './server-only' + type Server = typeof server + export const Route = createRoute({ loader: () => server() }) + `, + 'loader', + ) + expect(output).not.toContain('./server-only') + }) + + // Types are erased, so they neither use a declaration nor keep it alive + test('keeps declarations only referenced from types for their side effects', () => { + const output = cleanup( + ` + import { registerAnalytics } from './analytics' + const analytics = registerAnalytics() + declare global { + interface Window { analytics: typeof analytics } } - `) + export const Route = createRoute({ loader: () => 1 }) + `, + 'loader', + ) + expect(output).toContain('const analytics = registerAnalytics()') + expect(output).toContain("from './analytics'") }) -}) -describe('buildDeclarationMap', () => { - test('maps named default function and class declarations', () => { - expect({ - class: collectSortedDeclarationMapEntries( - `export default class DefaultComponent {}`, - ), - function: collectSortedDeclarationMapEntries( - `export default function DefaultRoute() {}`, - ), - anonymous: collectSortedDeclarationMapEntries(`export default class {}`), - }).toMatchInlineSnapshot(` - { - "anonymous": [], - "class": [ - [ - "DefaultComponent", - "ClassDeclaration", - ], - ], - "function": [ - [ - "DefaultRoute", - "FunctionDeclaration", - ], - ], + // The TypeScript transform erases them, except a classic JSX factory import + test('keeps an import only types reference while code referencing it survives', () => { + const output = cleanup( + ` + import * as React from 'react' + function Layout({ children }: { children: React.ReactNode }) { + return <main>{children}</main> } - `) + export const Route = createRoute({ component: Layout, loader: () => 1 }) + `, + 'loader', + ) + expect(output).toContain("import * as React from 'react'") }) -}) -describe('extractModuleInfoFromAst', () => { - test('extracts imports, local exports, default exports, and re-export sources', () => { - expect( - collectModuleInfoSnapshot(` - import defaultImport, { named as localNamed } from 'pkg' - import * as ns from 'pkg-ns' - const local = localNamed - export const exported = local - export function loader() {} - export default class DefaultRoute {} - export { remote as renamed } from './remote' - export * from './all' - `), - ).toMatchInlineSnapshot(` - { - "bindings": [ - [ - "DefaultRoute", - null, - ], - [ - "defaultImport", - "pkg:default", - ], - [ - "exported", - "Identifier", - ], - [ - "loader", - null, - ], - [ - "local", - "Identifier", - ], - [ - "localNamed", - "pkg:named", - ], - [ - "ns", - "pkg-ns:*", - ], - [ - "remote", - "./remote:remote", - ], - ], - "exports": [ - [ - "default", - "DefaultRoute", - ], - [ - "exported", - "exported", - ], - [ - "loader", - "loader", - ], - [ - "renamed", - "remote", - ], - ], - "reExportAllSources": [ - "./all", - ], + test('removes an import only types reference with the last code referencing it', () => { + const output = cleanup( + ` + import { Framework } from './projects' + export const Route = createRoute({ loader: (): Framework => 'react', component: () => 1 }) + `, + 'loader', + ) + expect(output).not.toContain('./projects') + }) + + // A classic JSX runtime compiles JSX to `React.createElement` calls + test('keeps a React import only types reference wherever JSX survives', () => { + const output = cleanup( + ` + import * as React from 'react' + export const Route = createRoute({ loader: (): React.ReactNode => null, component: () => <main /> }) + `, + 'loader', + ) + expect(output).toContain("import * as React from 'react'") + }) + + test.each([ + ['function type parameters', '(theme: string) => void'], + ['construct signature parameters', 'new (theme: string) => object'], + ['method signature parameters', '{ set(theme: string): void }'], + ['generic type parameters', '<Theme>() => void'], + ['inferred type parameters', 'string extends infer Theme ? 1 : 0'], + ])( + 'removes a dead declaration whose type arguments declare %s', + (_, type) => { + const output = cleanup( + `import { createContext } from 'react' +const ThemeContext = createContext<${type}>(null!) +export const Route = createRoute({ component: () => <ThemeContext.Provider />, loader: () => 1 })`, + 'component', + ) + expect(output).not.toContain('ThemeContext') + expect(output).not.toContain('react') + }, + ) + + test('removes a dead namespace including its exported members', () => { + const output = cleanup( + `namespace Labels { export const component = initialize() } export const Route = createRoute({ loader: () => Labels.component })`, + 'loader', + ) + expect(output).not.toContain('namespace Labels') + expect(output).not.toContain('initialize()') + }) + + test('removes nested erased captures without keeping their enclosing dead closure', () => { + const output = cleanup( + ` + import { server } from './server' + function factory() { + const initiallyUnused = sideEffect() + return server() + } + export const Route = createRoute({ loader: factory }) + `, + 'loader', + ) + expect(output).not.toContain('./server') + expect(output).not.toContain('factory') + expect(output).not.toContain('initiallyUnused') + }) + + test('removes dependencies scoped inside surviving functions', () => { + const output = cleanup( + ` + import { server } from './server' + export function factory() { + const load = () => server() + const initiallyUnused = sideEffect() + return createRoute({ loader: load, component: () => 'ok' }) } - `) + `, + 'loader', + ) + expect(output).not.toContain('./server') + expect(output).not.toContain('const load') + expect(output).toContain('initiallyUnused') + }) + + test('removes erased var declarators nested in statements and keeps the statements', () => { + const output = cleanup( + ` + import { a, b, c, d, e, f } from './server' + if (cond) { var inIf = a(); keep() } + try { var inTry = b() } catch {} + switch (mode) { case 1: var inCase = c() } + outer: { var inLabel = d(); break outer } + for (var i = 0, inFor = e(); i < 1; i++) {} + export function factory(flag) { + if (flag) { var inFunction = f() } + return createRoute({ loader: () => inFunction, component: () => 'ok' }) + } + export const Route = createRoute({ + loader: () => [inIf, inTry, inCase, inLabel, inFor], + component: () => 'ok', + }) + `, + 'loader', + ) + expect(output).not.toContain('./server') + expect(output).not.toMatch(/\b[a-f]\(\)/) + expect(output).toContain('keep()') + expect(output).toContain('break outer') + expect(output).toMatch(/for \(var i = 0; i < 1; i\+\+\)/) + expect(analyzeModule({ code: output }).diagnostics).toEqual([]) + }) + + test('keeps a statement wherever an erased var was its only content', () => { + const output = cleanup( + ` + import { a, b, c, d, e, f } from './server' + if (cond) var inIf = a(); else var inElse = b() + while (once()) var inWhile = c() + outer: var inLabel = d() + for (var inFor = e(); ; ) break + for (;;) var inBody = f() + export const Route = createRoute({ + loader: () => [inIf, inElse, inWhile, inLabel, inFor, inBody], + component: () => 'ok', + }) + `, + 'loader', + ) + expect(output).not.toContain('./server') + expect(output).not.toMatch(/\b[a-f]\(\)/) + expect(output).toContain('cond') + expect(output).toContain('once()') + expect(analyzeModule({ code: output }).diagnostics).toEqual([]) + }) + + test('removes every declarator of an erased var redeclared in a nested block', () => { + const output = cleanup( + ` + import { a, b } from './server' + var value = a() + if (cond) { var value = b() } + export const Route = createRoute({ loader: () => value, component: () => 'ok' }) + `, + 'loader', + ) + expect(output).not.toContain('./server') + expect(output).not.toContain('value') + }) + + test('keeps nested vars that are still used or never were', () => { + const output = cleanup( + ` + import { a } from './server' + if (cond) { var used = a(); var initiallyUnused = sideEffect() } + for (var key in object) {} + export const Route = createRoute({ + loader: () => [used, key], + component: () => used, + }) + `, + 'loader', + ) + expect(output).toContain('var used = a()') + expect(output).toContain('var initiallyUnused = sideEffect()') + expect(output).toContain('for (var key in object)') + }) + + test('generated parameter shadowing cannot retain an erased server import', () => { + const module = analyzeModule({ + code: `import { server } from './server'; export const result = server()`, + }) + const { program, originalNodes } = cloneModuleAst(module) + program.body.splice( + 1, + 1, + ...parseStatements('export const result = (server) => server()'), + ) + removeUnusedBindings(module, program, originalNodes) + expect(generateModule(program).code).not.toContain('./server') + }) + + test('retains source dependencies introduced in generated nodes', () => { + const module = analyzeModule({ code: 'const value = 1;' }) + const { program, originalNodes } = cloneModuleAst(module) + program.body.push(...parseStatements('export const result = value')) + removeUnusedBindings(module, program, originalNodes) + expect(generateModule(program).code).toContain('const value = 1') + }) + + test('retains nested source references when cloning a generated fragment', async () => { + const module = analyzeModule({ code: 'const value = 41;' }) + const { program, originalNodes } = cloneModuleAst(module) + const generated = parseStatements( + 'export const result = (() => ({ read: () => value + 1 }))().read()', + )[0]! + program.body.push(cloneGeneratedNode(generated)) + removeUnusedBindings(module, program, originalNodes) + const output = generateModule(program).code + expect(output).toContain('const value = 41') + const evaluated = await import( + `data:text/javascript;base64,${Buffer.from(output).toString('base64')}` + ) + expect(evaluated.result).toBe(42) }) }) diff --git a/packages/router-utils/tests/diagnostics.test.ts b/packages/router-utils/tests/diagnostics.test.ts new file mode 100644 index 00000000000..01ed82c9b9a --- /dev/null +++ b/packages/router-utils/tests/diagnostics.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from 'vitest' +import { analyzeModule } from '../src' + +test.each(['\n', '\r\n', '\r', '\u2028', '\u2029'])( + 'reports actionable syntax locations with %j line endings and Unicode source', + (newline) => { + const code = `// 😀é${newline}/* 😀é */ export const broken = ;` + + expect(() => + analyzeModule({ code, filename: '/routes/index.tsx' }), + ).toThrow( + expect.objectContaining({ + name: 'SyntaxError', + message: "/routes/index.tsx: Unexpected token ';' (2:32)", + loc: { line: 2, column: 32 }, + pos: code.indexOf(';'), + }), + ) + }, +) + +test('reports the first line and a zero-based column for syntax errors', () => { + expect(() => analyzeModule({ code: 'const broken = ;' })).toThrow( + expect.objectContaining({ + name: 'SyntaxError', + message: "input.tsx: Unexpected token ';' (1:15)", + loc: { line: 1, column: 15 }, + pos: 15, + }), + ) +}) diff --git a/packages/router-utils/tests/jsx-bindings.test.ts b/packages/router-utils/tests/jsx-bindings.test.ts new file mode 100644 index 00000000000..85362b5f2cd --- /dev/null +++ b/packages/router-utils/tests/jsx-bindings.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from 'vitest' +import { analyzeModule, moduleDeclarationGraph } from '../src' + +test.each(['Widget', '_Widget', '$Widget', 'ÉWidget', 'éWidget'])( + 'records JSX component %s as a runtime dependency', + (name) => { + const module = analyzeModule({ + code: `const ${name} = () => null; +export const render = () => <${name}></${name}>;`, + }) + const graph = moduleDeclarationGraph(module) + const component = module.rootScope.find(name)! + expect(component.references).toHaveLength(2) + expect(graph.dependencies.get(module.rootScope.find('render')!)).toEqual( + new Set([component]), + ) + }, +) + +test('distinguishes intrinsic JSX tags, member roots, and shadowed components', () => { + const module = analyzeModule({ + code: `const _Widget = () => null; +const widgets = {}; +export const render = () => { + const _Widget = () => null; + return <><_Widget /><widgets.Item /><div /><Widget-card /><svg:path /></>; +};`, + }) + const graph = moduleDeclarationGraph(module) + const outer = module.rootScope.find('_Widget')! + const inner = module.bindings.find( + (binding) => binding.name === '_Widget' && binding !== outer, + )! + expect(outer.references).toHaveLength(0) + expect(inner.references).toHaveLength(1) + expect(module.unresolvedReferences).toHaveLength(0) + expect(graph.dependencies.get(module.rootScope.find('render')!)).toEqual( + new Set([module.rootScope.find('widgets')!]), + ) +}) diff --git a/packages/router-utils/tests/native-fallback.test.ts b/packages/router-utils/tests/native-fallback.test.ts new file mode 100644 index 00000000000..2348afc9853 --- /dev/null +++ b/packages/router-utils/tests/native-fallback.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, expect, test, vi } from 'vitest' + +// Platforms without a native binary, or installs without optional +// dependencies, cannot load Yuku's native binding. +const wasm = vi.hoisted(() => ({ fail: false, loads: 0 })) + +vi.mock('yuku-core', async (importOriginal) => ({ + ...(await importOriginal<typeof import('yuku-core')>()), + load: () => { + throw new Error('Failed to load native binding') + }, +})) + +vi.mock('@yuku-core/wasm', async (importOriginal) => { + const original = await importOriginal<typeof import('@yuku-core/wasm')>() + return { + ...original, + loadSync: (...args: Parameters<typeof original.loadSync>) => { + wasm.loads++ + if (wasm.fail) { + throw new Error('WebAssembly is unavailable') + } + return original.loadSync(...args) + }, + } +}) + +beforeEach(() => { + // The loaded core is cached per module instance + vi.resetModules() + wasm.fail = false + wasm.loads = 0 +}) + +test('analyzes and prints modules with the WebAssembly core when the native binding cannot load', async () => { + const { analyzeModule, cloneModuleAst, generateModule } = + await import('../src') + const module = analyzeModule({ + code: `import { lazy } from 'react' +const Page = lazy(() => import('./page')) +export const render = (id: string) => <Page id={id} /> +`, + filename: 'route.tsx', + }) + expect(module.rootScope.find('Page')!.references).toHaveLength(1) + expect(generateModule(cloneModuleAst(module).program).code).toContain( + 'export const render = (id: string) => <Page id={id} />', + ) + analyzeModule({ code: 'export const other = 1' }) + expect(wasm.loads).toBe(1) +}) + +test('reports both causes when neither core can load', async () => { + wasm.fail = true + const { analyzeModule } = await import('../src') + const error = (() => { + try { + analyzeModule({ code: 'export const value = 1' }) + } catch (caught) { + return caught + } + return undefined + })() + expect(error).toBeInstanceOf(AggregateError) + expect( + (error as AggregateError).errors.map((cause: Error) => cause.message), + ).toEqual(['Failed to load native binding', 'WebAssembly is unavailable']) +}) diff --git a/packages/router-utils/tests/prepend-statements.test.ts b/packages/router-utils/tests/prepend-statements.test.ts new file mode 100644 index 00000000000..3de8076df4b --- /dev/null +++ b/packages/router-utils/tests/prepend-statements.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'vitest' +import { + analyzeModule, + cloneModuleAst, + generateModule, + prependStatements, +} from '../src' + +function parseProgram(code: string) { + return cloneModuleAst(analyzeModule({ code })).program +} + +describe('prependStatements', () => { + test.each(['', `'use client'; 'use strict';`])( + 'inserts after the complete prologue in a module with no other statements: %j', + (code) => { + const program = parseProgram(code) + prependStatements(program, ...parseProgram(`import './setup';`).body) + + expect(generateModule(program).code).toEqual( + generateModule(parseProgram(`${code} import './setup';`)).code, + ) + }, + ) + + test('keeps each batch in order and prepends later batches after the directives', () => { + const program = parseProgram(`'use client'; 'use strict'; original();`) + prependStatements(program, ...parseProgram('first(); second();').body) + prependStatements(program, ...parseProgram('third(); fourth();').body) + + expect(generateModule(program).code).toEqual( + generateModule( + parseProgram(`'use client'; 'use strict'; +third(); fourth(); first(); second(); original();`), + ).code, + ) + }) + + test('leaves string statements outside the prologue in their original position', () => { + const program = parseProgram(`'use client'; original(); 'use server';`) + prependStatements(program, ...parseProgram(`import './setup';`).body) + + expect(generateModule(program).code).toEqual( + generateModule( + parseProgram( + `'use client'; import './setup'; original(); 'use server';`, + ), + ).code, + ) + }) +}) diff --git a/packages/router-utils/tests/source-language.test.ts b/packages/router-utils/tests/source-language.test.ts new file mode 100644 index 00000000000..b068003cd77 --- /dev/null +++ b/packages/router-utils/tests/source-language.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest' +import { analyzeModule, collectModuleReferences } from '../src' + +const comparisonChain = `const a = 1, b = 2, c = 3 +export const r = a < b > (c) +export const element = <p>{r}</p>` + +function referencedNames(filename: string) { + const module = analyzeModule({ code: comparisonChain, filename }) + return [...collectModuleReferences(module, module.ast)] + .map((binding) => binding.name) + .sort() +} + +describe('source language follows the file extension', () => { + // JavaScript has no type arguments: `a < b > (c)` is two comparisons, while + // TypeScript reads it as a call of `a` with the type argument `b`. + test.each([ + 'route.js', + 'route.jsx', + 'route.mjs', + 'route.cjs', + '/src/routes/index.js?tsr-split=component', + ])('parses %s as JavaScript with JSX', (filename) => { + expect(referencedNames(filename)).toEqual(['a', 'b', 'c', 'r']) + }) + + test('parses .tsx files as TypeScript with JSX', () => { + expect(referencedNames('route.tsx')).toEqual(['a', 'c', 'r']) + }) +}) diff --git a/packages/router-utils/tests/stripTypeExports.test.ts b/packages/router-utils/tests/stripTypeExports.test.ts index 32d25f1cbe9..21e03e248c9 100644 --- a/packages/router-utils/tests/stripTypeExports.test.ts +++ b/packages/router-utils/tests/stripTypeExports.test.ts @@ -1,13 +1,11 @@ import { describe, expect, test } from 'vitest' -import { generateFromAst, parseAst, stripTypeExports } from '../src/ast' +import { generateModule, analyzeModule } from '../src/ast' +import { stripTypeExports } from '../src/compiler-helpers' function transform(code: string): string { - const ast = parseAst({ code }) - stripTypeExports(ast) - return generateFromAst(ast, { - sourceFileName: 'test.ts', - filename: 'test.ts', - }).code + const ast = analyzeModule({ code }) + stripTypeExports(ast.ast) + return generateModule(ast.ast, { source: code, filename: 'test.ts' }).code } describe('stripTypeExports', () => { @@ -16,14 +14,14 @@ describe('stripTypeExports', () => { const code = `import data from './data.json' with { type: 'json' } export const value = data` - expect(() => parseAst({ code })).not.toThrow() + expect(() => analyzeModule({ code })).not.toThrow() }) test('parses import attributes with deprecated assert syntax', () => { const code = `import data from './data.json' assert { type: 'json' } export const value = data` - expect(() => parseAst({ code })).not.toThrow() + expect(() => analyzeModule({ code })).not.toThrow() }) test('parses angle-bracket type assertions in .ts files without JSX', () => { @@ -31,7 +29,7 @@ export const value = data` return <T>value }` - expect(() => parseAst({ code, filename: 'cast.ts' })).not.toThrow() + expect(() => analyzeModule({ code, filename: 'cast.ts' })).not.toThrow() }) }) diff --git a/packages/router-utils/tests/webcontainer.test.ts b/packages/router-utils/tests/webcontainer.test.ts new file mode 100644 index 00000000000..642ac23fa0b --- /dev/null +++ b/packages/router-utils/tests/webcontainer.test.ts @@ -0,0 +1,55 @@ +import { afterAll, beforeAll, expect, test, vi } from 'vitest' +import { analyzeModule, cloneModuleAst, generateModule } from '../src' + +vi.mock('yuku-core', async (importOriginal) => ({ + ...(await importOriginal<typeof import('yuku-core')>()), + load: () => { + throw new Error('WebContainers cannot load native addons') + }, +})) + +// WebContainers report the previous call's byte counts for +// `TextEncoder.encodeInto('')`: https://github.com/stackblitz/webcontainer-core/issues/2173 +const encodeInto = TextEncoder.prototype.encodeInto + +beforeAll(() => { + process.versions.webcontainer = '1.0.0' + let previous = { read: 0, written: 0 } + TextEncoder.prototype.encodeInto = function (source, destination) { + if (source === '') { + return previous + } + previous = encodeInto.call(this, source, destination) + return previous + } +}) + +afterAll(() => { + delete process.versions.webcontainer + TextEncoder.prototype.encodeInto = encodeInto +}) + +test('analyzes and prints modules with the WebAssembly core in WebContainers', () => { + const code = `import { lazy } from 'react' +const Page = lazy(() => import('./page')) +export const render = (props: { id: string }) => <Page id={props.id} /> +` + const module = analyzeModule({ code, filename: 'route.tsx' }) + expect(module.rootScope.find('Page')!.references).toHaveLength(1) + expect(module.rootScope.find('lazy')!.references).toHaveLength(1) + expect(generateModule(cloneModuleAst(module).program).code) + .toMatchInlineSnapshot(` + "import { lazy } from 'react'; + const Page = lazy(() => import('./page')); + export const render = (props: { + id: string; + }) => <Page id={props.id} />;" + `) +}) + +test('analyzes an empty module in WebContainers', () => { + analyzeModule({ code: 'export const value = 1', filename: 'value.ts' }) + const module = analyzeModule({ code: '', filename: 'empty.ts' }) + expect(module.ast.body).toEqual([]) + expect(generateModule(cloneModuleAst(module).program).code).toBe('') +}) diff --git a/packages/router-vite-plugin/package.json b/packages/router-vite-plugin/package.json index f1d1039a205..f3a37d6a19d 100644 --- a/packages/router-vite-plugin/package.json +++ b/packages/router-vite-plugin/package.json @@ -61,7 +61,7 @@ "src" ], "engines": { - "node": ">=20.19" + "node": "^20.19.0 || >=22.12.0" }, "dependencies": { "@tanstack/router-plugin": "workspace:*" diff --git a/packages/start-plugin-core/package.json b/packages/start-plugin-core/package.json index 204197bf17b..70364466d93 100644 --- a/packages/start-plugin-core/package.json +++ b/packages/start-plugin-core/package.json @@ -85,9 +85,6 @@ "node": ">=22.12.0" }, "dependencies": { - "@babel/code-frame": "7.29.7", - "@babel/core": "catalog:", - "@babel/types": "catalog:", "@jridgewell/remapping": "^2.3.5", "@tanstack/router-core": "workspace:*", "@tanstack/router-generator": "workspace:*", @@ -105,12 +102,13 @@ "ufo": "^1.6.4", "vitefu": "^1.1.3", "xmlbuilder2": "^4.0.3", - "zod": "catalog:" + "zod": "catalog:", + "@yuku-toolchain/types": "catalog:", + "yuku-analyzer": "catalog:", + "yuku-ast": "catalog:" }, "devDependencies": { "@rsbuild/core": "catalog:", - "@types/babel__code-frame": "^7.0.6", - "@types/babel__core": "^7.20.5", "@types/node": ">=20", "@types/picomatch": "^4.0.2", "prettier": "^3.9.6", diff --git a/packages/start-plugin-core/src/hydrate-when-transform.ts b/packages/start-plugin-core/src/hydrate-when-transform.ts index d88ddb60d06..b84cdb3ddc8 100644 --- a/packages/start-plugin-core/src/hydrate-when-transform.ts +++ b/packages/start-plugin-core/src/hydrate-when-transform.ts @@ -1,25 +1,26 @@ import { relative } from 'node:path' import crypto from 'node:crypto' -import babel from '@babel/core' -import * as t from '@babel/types' +import { b, is, nameOf, walk } from 'yuku-ast' import { - buildDeclarationMap, - buildDependencyGraph, - collectIdentifiersFromNode, - collectIdentifiersFromPattern, - collectLocalBindingsFromStatement, - deadCodeElimination, + analyzeModule, + cloneModuleAst, + collectModuleReferences, expandTransitively, - findReferencedIdentifiers, - generateFromAst, - parseAst, - removeModuleLevelBindings, - retainModuleLevelDeclarations, - stripUnreferencedTopLevelExpressionStatements, - unwrapExportedDeclarations, + generateModule, + keepFilePragmas, + moduleDeclarationGraph, + parseExpression, + parseStatements, + prependStatements, + removeUnusedBindings, + unwrapExport, + unwrapExpression, } from '@tanstack/router-utils' +import { BindingFlags } from 'yuku-analyzer' import { tssHydrate } from './hydration-constants' import { cleanId, codeFrameError } from './start-compiler/utils' +import type { Binding, Module } from 'yuku-analyzer' +import type * as t from '@yuku-toolchain/types' import type { CompileStartFrameworkOptions, StartCompilerPlugin, @@ -53,34 +54,6 @@ function createBoundaryId(root: string, sourceId: string) { } } -function getJSXElementName(node: t.JSXElement) { - const name = node.openingElement.name - return t.isJSXIdentifier(name) ? name.name : undefined -} - -function getJSXAttribute(node: t.JSXOpeningElement, name: string) { - for (const item of node.attributes) { - if (t.isJSXAttribute(item) && t.isJSXIdentifier(item.name, { name })) { - return item - } - } - - return undefined -} - -function getBooleanProp(node: t.JSXOpeningElement, name: string) { - const attr = getJSXAttribute(node, name) - if (!attr) return undefined - if (!attr.value) return true - if (t.isStringLiteral(attr.value)) return attr.value.value !== 'false' - if (t.isJSXExpressionContainer(attr.value)) { - if (t.isBooleanLiteral(attr.value.expression)) { - return attr.value.expression.value - } - } - return undefined -} - function parseHydrateVirtualId(id: string) { const queryIndex = id.indexOf('?') const sourceId = cleanId(queryIndex === -1 ? id : id.slice(0, queryIndex)) @@ -109,647 +82,447 @@ function parseHydrateVirtualId(id: string) { } } -function isObjectPropertyName( - property: t.ObjectMethod | t.ObjectProperty, - name: string, -) { - if (t.isIdentifier(property.key) && !property.computed) { - return property.key.name === name - } - - return t.isStringLiteral(property.key) && property.key.value === name +interface HydrateAst { + ast: t.Program + module: Module + originalNodes: WeakMap<t.Node, t.Node> } -function isReferenceInsideAnyNode( - referencePath: babel.NodePath, - nodes: ReadonlySet<t.Node>, -) { - if (nodes.has(referencePath.node)) return true - return Boolean(referencePath.findParent((parent) => nodes.has(parent.node))) +function getJSXElementName(node: t.JSXElement) { + return is.JSXIdentifier(node.openingElement.name) + ? node.openingElement.name.name + : undefined } -function stripBindingsOnlyReferencedBy( - path: babel.NodePath<t.JSXElement>, - node: t.Node, - seen = new Set<string>(), - preserve = new Set<string>(), -) { - stripBindingsOnlyReferencedByNodes(path.scope, [node], seen, preserve) +function getJSXAttribute(node: t.JSXOpeningElement, name: string) { + return node.attributes.find( + (attribute): attribute is t.JSXAttribute => + is.JSXAttribute(attribute) && + is.JSXIdentifier(attribute.name) && + attribute.name.name === name, + ) } -function stripBindingsOnlyReferencedByNodes( - scope: babel.NodePath['scope'], - nodes: ReadonlyArray<t.Node>, - seen = new Set<string>(), - preserve = new Set<string>(), -) { - const nodeSet = new Set(nodes) - const names = new Set<string>() - nodes.forEach((node) => { - collectIdentifiersFromNode(node).forEach((name) => names.add(name)) - }) - - for (const name of names) { - if (seen.has(name)) continue - if (preserve.has(name)) continue - const binding = scope.getBinding(name) - if (!binding?.constant) continue - if ( - binding.path.findParent( - (parentPath) => - parentPath.isExportNamedDeclaration() || - parentPath.isExportDefaultDeclaration(), - ) - ) { - continue - } - if (binding.referencePaths.length === 0) continue - if ( - !binding.referencePaths.every((referencePath) => - isReferenceInsideAnyNode(referencePath, nodeSet), - ) - ) { - continue - } - - seen.add(name) - - const declarationPath = binding.path.isVariableDeclarator() - ? binding.path - : binding.path.findParent((parentPath) => - parentPath.isVariableDeclarator(), - ) - const patternHasExternalReferences = - declarationPath?.isVariableDeclarator() && - !t.isIdentifier(declarationPath.node.id) && - collectIdentifiersFromPattern(declarationPath.node.id).some( - (bindingName) => { - if (bindingName === binding.identifier.name) return false - - const siblingBinding = binding.scope.getBinding(bindingName) - return siblingBinding?.referencePaths.some( - (referencePath) => - !isReferenceInsideAnyNode(referencePath, nodeSet), - ) - }, - ) - - if (patternHasExternalReferences) { - continue - } - - const bindingNode = binding.path.node - if (t.isVariableDeclarator(bindingNode) && bindingNode.init) { - stripBindingsOnlyReferencedByNodes( - binding.scope, - [bindingNode.init], - seen, - preserve, - ) - } else if ( - t.isFunctionDeclaration(bindingNode) || - t.isClassDeclaration(bindingNode) - ) { - stripBindingsOnlyReferencedByNodes( - binding.scope, - [bindingNode], - seen, - preserve, - ) - } - - if (binding.path.isVariableDeclarator()) { - const declarationPath = binding.path.parentPath - if ( - declarationPath.isVariableDeclaration() && - declarationPath.node.declarations.length === 1 - ) { - declarationPath.remove() - continue - } - - binding.path.remove() - continue - } - - if ( - binding.path.isImportSpecifier() || - binding.path.isImportDefaultSpecifier() || - binding.path.isImportNamespaceSpecifier() - ) { - const importPath = binding.path.parentPath - if ( - importPath.isImportDeclaration() && - importPath.node.specifiers.length === 1 - ) { - importPath.remove() - continue - } - - binding.path.remove() - continue +function getBooleanProp(node: t.JSXOpeningElement, name: string) { + const attribute = getJSXAttribute(node, name) + if (!attribute) { + return undefined + } + if (!attribute.value) { + return true + } + if (is.StringLiteral(attribute.value)) { + return attribute.value.value !== 'false' + } + if (is.JSXExpressionContainer(attribute.value)) { + const expression = unwrapExpression(attribute.value.expression) + if (is.BooleanLiteral(expression)) { + return expression.value } - - binding.path.remove() } + return undefined } -function getSingleUseObjectExpressionBinding( - path: babel.NodePath<t.JSXElement>, - identifier: t.Identifier, -) { - const binding = path.scope.getBinding(identifier.name) - if (!binding?.constant) return undefined - if (binding.referencePaths.length !== 1) return undefined - if (binding.referencePaths[0]?.node !== identifier) return undefined - if (!binding.path.isVariableDeclarator()) return undefined - const init = binding.path.node.init - return t.isObjectExpression(init) ? init : undefined +function propertyIs(property: t.Property, name: string) { + return ( + (!property.computed || is.StringLiteral(property.key)) && + nameOf(property.key) === name + ) } function objectExpressionMayHaveProperty( node: t.ObjectExpression, name: string, ) { - return node.properties.some((property) => { - if (t.isSpreadElement(property)) return true - if (!t.isObjectMethod(property) && !t.isObjectProperty(property)) { - return true - } - if (property.computed) return true - return isObjectPropertyName(property, name) - }) + return node.properties.some( + (property) => + is.SpreadElement(property) || + property.computed || + propertyIs(property, name), + ) } -function stripObjectExpressionProperty( - path: babel.NodePath<t.JSXElement>, - node: t.ObjectExpression, - name: string, -) { - let modified = false - - node.properties = node.properties.filter((property) => { - if ( - (t.isObjectMethod(property) || t.isObjectProperty(property)) && - isObjectPropertyName(property, name) - ) { - stripBindingsOnlyReferencedBy( - path, - t.isObjectProperty(property) ? property.value : property.body, - ) - modified = true - return false - } - - return true - }) +function stripObjectExpressionProperty(node: t.ObjectExpression, name: string) { + const before = node.properties.length + node.properties = node.properties.filter( + (property) => !is.Property(property) || !propertyIs(property, name), + ) + return before !== node.properties.length +} - return modified +function sourceNode(context: HydrateAst, node: t.Node) { + return context.originalNodes.get(node) ?? node } -function throwBoundaryError( - code: string, - path: babel.NodePath<t.JSXElement>, - message: string, -): never { - if (path.node.loc) { - throw codeFrameError(code, path.node.loc, message) +function getSingleUseObjectExpressionBinding( + context: HydrateAst, + identifier: t.Identifier, + objectExpressions: WeakMap<t.Node, t.ObjectExpression>, +) { + const original = sourceNode(context, identifier) + const binding = context.module.bindingOf(original) + if ( + !binding || + binding.references.length !== 1 || + binding.references[0]?.node !== original || + binding.references.some((reference) => reference.isWrite) + ) { + return undefined } - throw new Error(message) + const declarationIdentifier = binding.declarations[0] + const declaration = + declarationIdentifier && context.module.parentOf(declarationIdentifier) + if (!is.VariableDeclarator(declaration) || !declaration.init) { + return undefined + } + const init = unwrapExpression(declaration.init) + if (!is.ObjectExpression(init)) { + return undefined + } + return objectExpressions.get(init) } -function inspectSplitBoundary(options: { - code: string - path: babel.NodePath<t.JSXElement> - validate?: boolean - collectCaptured?: boolean - nestedHydrate?: { - localName: string - } -}) { - const { path } = options - const capturedNames = options.collectCaptured ? new Set<string>() : undefined - const nestedHydrate = options.nestedHydrate +function inspectSplitBoundary( + context: HydrateAst, + node: t.JSXElement, + options: { + code: string + validate?: boolean + collectCaptured?: boolean + nestedHydrate?: { localName: string } + }, +) { + const captured = new Set<string>() let nestedBoundaryCount = 0 - + const fail = (message: string): never => { + throw codeFrameError(options.code, sourceNode(context, node), message) + } if (options.validate) { - for (const child of path.node.children) { + for (const child of node.children) { + const expression = is.JSXExpressionContainer(child) + ? unwrapExpression(child.expression) + : undefined if ( - t.isJSXExpressionContainer(child) && - (t.isFunctionExpression(child.expression) || - t.isArrowFunctionExpression(child.expression)) + is.FunctionExpression(expression) || + is.ArrowFunctionExpression(expression) ) { - throwBoundaryError( - options.code, - path, + fail( 'Hydrate cannot code-split function-as-children. Use split={false} for this boundary.', ) } } } - - const rootVisitors = { - JSXOpeningElement(openingPath: babel.NodePath<t.JSXOpeningElement>) { - if (openingPath.node === path.node.openingElement) { - openingPath.skip() + const originalBoundary = sourceNode(context, node) + walk(node, { + enter(current) { + if (!options.collectCaptured) { + return } - }, - JSXClosingElement(closingPath: babel.NodePath<t.JSXClosingElement>) { - closingPath.skip() - }, - } - - const validateVisitors = options.validate - ? { - CallExpression(callPath: babel.NodePath<t.CallExpression>) { - if (!t.isIdentifier(callPath.node.callee)) return - if (!/^use[A-Z0-9]/.test(callPath.node.callee.name)) return - - throwBoundaryError( - options.code, - path, - 'Hydrate cannot code-split JSX that calls hooks during render. Move the hook call into a child component or use split={false}.', - ) - }, - ThisExpression(thisPath: babel.NodePath<t.ThisExpression>) { - void thisPath - throwBoundaryError( - options.code, - path, - 'Hydrate cannot code-split JSX that captures this.', - ) - }, - Super(superPath: babel.NodePath<t.Super>) { - void superPath - throwBoundaryError( - options.code, - path, - 'Hydrate cannot code-split JSX that captures super.', - ) - }, + const reference = context.module.referenceOf(sourceNode(context, current)) + const binding = reference?.binding + if ( + !reference || + reference.inTypePosition || + !binding || + binding.scope === context.module.rootScope + ) { + return } - : {} - - const nestedHydrateVisitors = nestedHydrate - ? { - JSXElement(nestedPath: babel.NodePath<t.JSXElement>) { - if (getJSXElementName(nestedPath.node) !== nestedHydrate.localName) { + for (const declaration of binding.declarations) { + for (const ancestor of context.module.ancestors(declaration)) { + if (ancestor === originalBoundary) { return } - - const split = getBooleanProp(nestedPath.node.openingElement, 'split') - if (split === false) return - - nestedBoundaryCount++ - }, + } } - : {} - - const captureVisitors = capturedNames - ? { - Identifier(identifierPath: babel.NodePath<t.Identifier>) { - const parent = identifierPath.parent - if ( - t.isJSXOpeningElement(parent) || - t.isJSXClosingElement(parent) || - (t.isObjectProperty(parent, { key: identifierPath.node }) && - !parent.computed && - !parent.shorthand) || - (t.isMemberExpression(parent, { - property: identifierPath.node, - }) && - !parent.computed) - ) { - return - } - - const binding = identifierPath.scope.getBinding( - identifierPath.node.name, - ) - if (!binding) return - if (t.isProgram(binding.scope.block)) return - if ( - path.node === binding.scope.block || - path.isAncestor(binding.path) - ) - return - - capturedNames.add(identifierPath.node.name) - }, - JSXIdentifier(identifierPath: babel.NodePath<t.JSXIdentifier>) { - if (identifierPath.parentKey !== 'name') return - const name = identifierPath.node.name - if (!/^[A-Z]/.test(name)) return - const binding = identifierPath.scope.getBinding(name) - if (!binding) return - if (t.isProgram(binding.scope.block)) return - - capturedNames.add(name) - }, + captured.add(binding.name) + }, + JSXOpeningElement(current, visitor) { + if (current === node.openingElement) { + visitor.skip() } - : {} - - path.traverse({ - ...rootVisitors, - ...validateVisitors, - ...nestedHydrateVisitors, - ...captureVisitors, + }, + JSXClosingElement(_current, visitor) { + visitor.skip() + }, + JSXElement(current) { + if ( + current !== node && + options.nestedHydrate && + getJSXElementName(current) === options.nestedHydrate.localName && + getBooleanProp(current.openingElement, 'split') !== false + ) { + nestedBoundaryCount++ + } + }, + CallExpression(current) { + const callee = unwrapExpression(current.callee) + if ( + options.validate && + is.Identifier(callee) && + /^use[A-Z0-9]/.test(callee.name) + ) { + fail( + 'Hydrate cannot code-split JSX that calls hooks during render. Move the hook call into a child component or use split={false}.', + ) + } + }, + ThisExpression() { + if (options.validate) { + fail('Hydrate cannot code-split JSX that captures this.') + } + }, + Super() { + if (options.validate) { + fail('Hydrate cannot code-split JSX that captures super.') + } + }, }) - - return { - captured: capturedNames ? [...capturedNames].sort() : [], - nestedBoundaryCount, - } + return { captured: [...captured].sort(), nestedBoundaryCount } } -function getHydrateImport( - ast: t.File, +function getHydrateLocalName( + module: Module, framework: CompileStartFrameworkOptions, ) { - const hydrateImportSource = `@tanstack/${framework}-start` - - for (const node of ast.program.body) { - if (!t.isImportDeclaration(node)) continue - if (node.source.value !== hydrateImportSource) continue - - for (const specifier of node.specifiers) { - if ( - t.isImportSpecifier(specifier) && - t.isIdentifier(specifier.imported, { name: 'Hydrate' }) - ) { - return { - hydrateLocalName: specifier.local.name, - } - } - } - } - - return undefined + return module.imports.find( + (record) => + record.kind === 'named' && + record.name === 'Hydrate' && + record.specifier === `@tanstack/${framework}-start`, + )?.local?.name } -function getMeaningfulChildren( - children: Array<t.JSXElement['children'][number]>, -) { +function getMeaningfulChildren(children: t.JSXElement['children']) { return children.filter( - (child) => !(t.isJSXText(child) && child.value.trim() === ''), + (child) => !is.JSXText(child) || child.value.trim() !== '', ) } -function transformHydrateAst(options: { - ast: t.File - code: string - id: string - root: string - env: 'client' | 'server' - framework: CompileStartFrameworkOptions - indexOffset?: number -}) { - if (!options.code.includes('Hydrate')) return null +function setChildren(node: t.JSXElement, children: t.JSXElement['children']) { + node.children = children + if (node.openingElement.selfClosing) { + node.openingElement.selfClosing = false + node.closingElement = b.JSXClosingElement({ + name: { ...node.openingElement.name }, + }) + } +} + +/** + * Moves a boundary's `children` prop into its JSX children, so + * `<Hydrate children={content} />` splits like `<Hydrate>{content}</Hydrate>`. + * + * @returns false when props may still provide the children (e.g. a spread + * after the `children` prop), which cannot be split. + */ +function moveChildrenPropIntoChildren(node: t.JSXElement) { + if (getMeaningfulChildren(node.children).length) { + // JSX children take precedence over a children prop. + return true + } + // The last spread or children prop determines the children. + const attributes = node.openingElement.attributes + let index = attributes.length - 1 + for (; index >= 0; index--) { + const attribute = attributes[index]! + if ( + is.JSXSpreadAttribute(attribute) || + (is.JSXIdentifier(attribute.name) && attribute.name.name === 'children') + ) { + break + } + } + const attribute = attributes[index] + if (!attribute) { + return true + } + if ( + is.JSXSpreadAttribute(attribute) || + !attribute.value || + is.StringLiteral(attribute.value) + ) { + return false + } + attributes.splice(index, 1) + setChildren(node, [attribute.value]) + return true +} - const hydrateImport = getHydrateImport(options.ast, options.framework) - if (!hydrateImport) return null - const { hydrateLocalName: localName } = hydrateImport +function transformHydrateAst( + options: HydrateAst & { + code: string + id: string + root: string + env: 'client' | 'server' + framework: CompileStartFrameworkOptions + indexOffset?: number + }, +) { + if (!options.code.includes('Hydrate')) { + return null + } + const localName = getHydrateLocalName(options.module, options.framework) + if (!localName) { + return null + } const sourceId = cleanId(options.id) const getBoundaryId = createBoundaryId(options.root, sourceId) - let nextBoundaryIndex = options.indexOffset ?? 0 - const state = { - modified: false, - extractedChildNodes: [] as Array<t.Node>, - capturedNames: new Set<string>(), - } - let lazyIdent: t.Identifier | undefined - - babel.traverse(options.ast, { - Program(programPath) { - programPath.traverse({ - JSXElement(path) { - if (getJSXElementName(path.node) !== localName) return - - if (options.env === 'server') { - path.node.openingElement.attributes = - path.node.openingElement.attributes.filter((item) => { - if ( - t.isJSXAttribute(item) && - t.isJSXIdentifier(item.name, { name: 'fallback' }) - ) { - if (item.value) { - stripBindingsOnlyReferencedBy(path, item.value) - } - state.modified = true - return false - } - - if ( - t.isJSXSpreadAttribute(item) && - t.isObjectExpression(item.argument) - ) { - if ( - stripObjectExpressionProperty( - path, - item.argument, - 'fallback', + const transformation = { modified: false } + let lazyName: string | undefined + const names = new Set([ + ...options.module.bindings.map((binding) => binding.name), + ...options.module.unresolvedReferences.map((reference) => reference.name), + ]) + const fresh = (base: string) => { + let name = `_${base}` + let count = 2 + while (names.has(name)) { + name = `_${base}${count++}` + } + names.add(name) + return name + } + const prepend: t.Program['body'] = [] + // Index before mutation so spreads can resolve declarations later in the file. + const objectExpressions = new WeakMap<t.Node, t.ObjectExpression>() + walk(options.ast, { + ObjectExpression(node) { + objectExpressions.set(sourceNode(options, node), node) + }, + }) + walk(options.ast, { + JSXElement(node, visitor) { + if (getJSXElementName(node) !== localName) { + return + } + if (options.env === 'server') { + node.openingElement.attributes = node.openingElement.attributes.filter( + (attribute) => { + if ( + is.JSXAttribute(attribute) && + is.JSXIdentifier(attribute.name) && + attribute.name.name === 'fallback' + ) { + transformation.modified = true + return false + } + if (is.JSXSpreadAttribute(attribute)) { + const argument = unwrapExpression(attribute.argument) + const object = is.ObjectExpression(argument) + ? argument + : is.Identifier(argument) + ? getSingleUseObjectExpressionBinding( + options, + argument, + objectExpressions, ) - ) { - state.modified = true - } - return item.argument.properties.length > 0 - } - - if ( - t.isJSXSpreadAttribute(item) && - t.isIdentifier(item.argument) - ) { - const init = getSingleUseObjectExpressionBinding( - path, - item.argument, - ) - if ( - init && - stripObjectExpressionProperty(path, init, 'fallback') - ) { - state.modified = true - } - } - - return true - }) - } - - const split = getBooleanProp(path.node.openingElement, 'split') - if (split === false) return - - const boundaryInspection = inspectSplitBoundary({ - code: options.code, - path, - validate: true, - collectCaptured: options.env === 'client', - ...(options.env === 'client' - ? { - nestedHydrate: { - localName, - }, - } - : {}), - }) - - const index = nextBoundaryIndex - nextBoundaryIndex += 1 + boundaryInspection.nestedBoundaryCount - const id = getBoundaryId(index) - const exportName = `H${index}` - - const existingHydrateId = getJSXAttribute( - path.node.openingElement, - 'h', - ) - if (existingHydrateId) { - existingHydrateId.value = t.stringLiteral(id) - } else { - path.node.openingElement.attributes.push( - t.jsxAttribute(t.jsxIdentifier('h'), t.stringLiteral(id)), - ) - } - state.modified = true - - if (options.env === 'server') return - - const needsPreloadProp = path.node.openingElement.attributes.some( - (attribute) => { - if (t.isJSXAttribute(attribute)) { - return t.isJSXIdentifier(attribute.name, { name: 'prefetch' }) + : undefined + if (object && stripObjectExpressionProperty(object, 'fallback')) { + transformation.modified = true } - - if (t.isJSXSpreadAttribute(attribute)) { - if (t.isObjectExpression(attribute.argument)) { - return objectExpressionMayHaveProperty( - attribute.argument, - 'prefetch', - ) - } - - if (t.isIdentifier(attribute.argument)) { - const init = getSingleUseObjectExpressionBinding( - path, - attribute.argument, - ) - return init - ? objectExpressionMayHaveProperty(init, 'prefetch') - : true - } - - return true + if (is.ObjectExpression(argument)) { + return argument.properties.length > 0 } - - return false - }, + } + return true + }, + ) + } + if (getBooleanProp(node.openingElement, 'split') === false) { + return + } + const splittable = moveChildrenPropIntoChildren(node) + const inspection = inspectSplitBoundary(options, node, { + code: options.code, + validate: true, + collectCaptured: options.env === 'client', + ...(options.env === 'client' ? { nestedHydrate: { localName } } : {}), + }) + const index = nextBoundaryIndex + nextBoundaryIndex += 1 + inspection.nestedBoundaryCount + const id = getBoundaryId(index) + const exportName = `H${index}` + const existingId = getJSXAttribute(node.openingElement, 'h') + const idValue = b.Literal({ + value: id, + raw: JSON.stringify(id), + }) as t.StringLiteral + if (existingId) { + existingId.value = idValue + } else { + node.openingElement.attributes.push( + b.JSXAttribute({ + name: b.JSXIdentifier({ name: 'h' }), + value: idValue, + }), + ) + } + transformation.modified = true + if (options.env === 'server' || !splittable) { + return + } + const needsPreload = node.openingElement.attributes.some((attribute) => { + if (is.JSXAttribute(attribute)) { + return ( + is.JSXIdentifier(attribute.name) && + attribute.name.name === 'prefetch' ) - const childReferenceNodes = getMeaningfulChildren(path.node.children) - - state.extractedChildNodes.push(...childReferenceNodes) - boundaryInspection.captured.forEach((name) => { - state.capturedNames.add(name) - }) - - if (!lazyIdent) { - lazyIdent = - programPath.scope.generateUidIdentifier('lazyRouteComponent') - programPath.unshiftContainer('body', [ - t.importDeclaration( - [ - t.importSpecifier( - lazyIdent, - t.identifier('lazyRouteComponent'), - ), - ], - t.stringLiteral(`@tanstack/${options.framework}-router`), - ), - ]) - } - - const importIdParams = new URLSearchParams() - importIdParams.set(tssHydrate, id) - const componentIdent = - programPath.scope.generateUidIdentifier(exportName) - const declarations = [ - t.variableDeclarator( - componentIdent, - t.callExpression(lazyIdent, [ - t.arrowFunctionExpression( - [], - t.callExpression(t.import(), [ - t.stringLiteral(`${sourceId}?${importIdParams.toString()}`), - ]), - ), - t.stringLiteral(exportName), - ]), - ), - ] - - let preloadIdent: t.Identifier | undefined - if (needsPreloadProp) { - preloadIdent = programPath.scope.generateUidIdentifier( - `${exportName}_preload`, - ) - declarations.push( - t.variableDeclarator( - preloadIdent, - t.memberExpression(componentIdent, t.identifier('preload')), - ), - ) - } - - programPath.unshiftContainer('body', [ - t.variableDeclaration('const', declarations), - ]) - if (preloadIdent) { - path.node.openingElement.attributes.push( - t.jsxAttribute( - t.jsxIdentifier('p'), - t.jsxExpressionContainer(preloadIdent), - ), - ) - } - - path.node.children = [ - t.jsxText('\n'), - t.jsxExpressionContainer( - t.jsxElement( - t.jsxOpeningElement( - t.jsxIdentifier(componentIdent.name), - boundaryInspection.captured.map((name) => - t.jsxAttribute( - t.jsxIdentifier(name), - t.jsxExpressionContainer(t.identifier(name)), - ), - ), - true, - ), - null, - [], - true, - ), - ), - t.jsxText('\n'), - ] - path.skip() - }, + } + const argument = unwrapExpression(attribute.argument) + const object = is.ObjectExpression(argument) + ? argument + : is.Identifier(argument) + ? getSingleUseObjectExpressionBinding( + options, + argument, + objectExpressions, + ) + : undefined + return !object || objectExpressionMayHaveProperty(object, 'prefetch') }) - - if (state.extractedChildNodes.length > 0) { - stripBindingsOnlyReferencedByNodes( - programPath.scope, - state.extractedChildNodes, - new Set<string>(), - state.capturedNames, + if (!lazyName) { + lazyName = fresh('lazyRouteComponent') + prepend.push( + ...parseStatements( + `import { lazyRouteComponent as ${lazyName} } from ${JSON.stringify(`@tanstack/${options.framework}-router`)}`, + ), ) } - - programPath.skip() + const query = new URLSearchParams({ [tssHydrate]: id }) + const componentName = fresh(exportName) + prepend.push( + ...parseStatements( + `const ${componentName} = ${lazyName}(() => import(${JSON.stringify(`${sourceId}?${query}`)}), ${JSON.stringify(exportName)})`, + ), + ) + if (needsPreload) { + const preloadName = fresh(`${exportName}_preload`) + prepend.push( + ...parseStatements(`const ${preloadName} = ${componentName}.preload`), + ) + node.openingElement.attributes.push( + b.JSXAttribute({ + name: b.JSXIdentifier({ name: 'p' }), + value: b.JSXExpressionContainer({ + expression: parseExpression(preloadName), + }), + }), + ) + } + const props = inspection.captured + .map((name) => `${name}={${name}}`) + .join(' ') + const child = parseExpression(`<${componentName} ${props} />`) + setChildren(node, [b.JSXExpressionContainer({ expression: child })]) + visitor.skip() }, }) - - if (!state.modified) return null - + if (!transformation.modified) { + return null + } + prependStatements(options.ast, ...prepend) return true } @@ -760,130 +533,153 @@ function loadHydrateVirtualModule(options: { framework: CompileStartFrameworkOptions }) { const { sourceId, splitId, boundaryIndex } = parseHydrateVirtualId(options.id) - if (!splitId || boundaryIndex < 0) return null + if (!splitId || boundaryIndex < 0) { + return null + } + const module = analyzeModule({ code: options.code, filename: sourceId }) + const localName = getHydrateLocalName(module, options.framework) + if (!localName) { + return null + } + const { program: ast, originalNodes } = cloneModuleAst(module) + const context = { ast, module, originalNodes } const getBoundaryId = createBoundaryId(options.root, sourceId) - - const ast = parseAst({ code: options.code, sourceFilename: sourceId }) - const hydrateImport = getHydrateImport(ast, options.framework) - if (!hydrateImport) return null - const { hydrateLocalName: localName } = hydrateImport - let target: t.JSXElement | undefined - let targetIndex = -1 - let targetCaptured: Array<string> = [] + let captures: Array<string> = [] let index = 0 - - babel.traverse(ast, { - JSXElement(path) { - if (getJSXElementName(path.node) !== localName) return - const split = getBooleanProp(path.node.openingElement, 'split') - if (split === false) return - + walk(ast, { + JSXElement(node, visitor) { + if ( + getJSXElementName(node) !== localName || + getBooleanProp(node.openingElement, 'split') === false + ) { + return + } if (index === boundaryIndex) { - const id = getBoundaryId(index) - if (id !== splitId) { - path.stop() - return + if ( + getBoundaryId(index) === splitId && + moveChildrenPropIntoChildren(node) + ) { + target = node + captures = inspectSplitBoundary(context, node, { + code: options.code, + collectCaptured: true, + }).captured } - targetCaptured = inspectSplitBoundary({ - code: options.code, - path, - collectCaptured: true, - }).captured - target = t.cloneNode(path.node, true) - targetIndex = index - path.stop() - return + visitor.stop() } index++ }, }) - - if (!target || targetIndex < 0) return null - - const children = target.children - const exportName = `H${targetIndex}` - const refIdents = findReferencedIdentifiers(ast) - - removeModuleLevelBindings(ast, new Set(['Route'])) - const localBindings = new Set<string>() - for (const node of ast.program.body) { - collectLocalBindingsFromStatement(node, localBindings) - } - - const keepBindings = new Set<string>() - const meaningfulChildren = getMeaningfulChildren(children) - let returnExpression: t.Expression | t.JSXElement | t.JSXFragment = - t.nullLiteral() - - if (meaningfulChildren.length === 1) { - const child = meaningfulChildren[0]! - if (t.isJSXExpressionContainer(child)) { - returnExpression = t.isJSXEmptyExpression(child.expression) - ? t.nullLiteral() - : child.expression - } else if (t.isJSXElement(child) || t.isJSXFragment(child)) { - returnExpression = child - } else if (t.isJSXText(child)) { - returnExpression = t.stringLiteral(child.value) + if (!target) { + return null + } + const children = getMeaningfulChildren(target.children) + const child = children.length === 1 ? children[0] : undefined + let expression: t.Expression = b.Literal({ value: null, raw: 'null' }) + if (is.JSXExpressionContainer(child)) { + if (!is.JSXEmptyExpression(child.expression)) { + expression = child.expression } - } else if (meaningfulChildren.length > 1) { - returnExpression = t.jsxFragment( - t.jsxOpeningFragment(), - t.jsxClosingFragment(), - children, - ) + } else if (is.JSXElement(child) || is.JSXFragment(child)) { + expression = child + } else if (children.length) { + // Keep text as JSX so the JSX compiler applies entity and whitespace rules. + expression = b.JSXFragment({ + openingFragment: b.JSXOpeningFragment({}), + closingFragment: b.JSXClosingFragment({}), + children: target.children, + }) } - for (const name of collectIdentifiersFromNode(returnExpression)) { - if (localBindings.has(name)) { - keepBindings.add(name) + const graph = moduleDeclarationGraph(module) + const keep = new Set<Binding>() + for (const child of children) { + for (const binding of collectModuleReferences( + module, + sourceNode(context, child), + )) { + keep.add(binding) } } - - if (keepBindings.size > 0) { - expandTransitively( - keepBindings, - buildDependencyGraph(buildDeclarationMap(ast), localBindings), + // The route is a singleton: code reading the module's `Route` imports the + // parent module's instead of creating the route again with its options. + const route = module.rootScope.bindings.find( + (binding) => binding.name === 'Route' && !binding.has(BindingFlags.Import), + ) + const retained = expandTransitively( + keep, + new Map([...graph.dependencies].filter(([binding]) => binding !== route)), + ) + if (route && retained.delete(route)) { + const { exports: moduleExports } = module + const exportedNames = moduleExports + .filter((entry) => entry.local === route && !entry.typeOnly) + .map((entry) => entry.name) + const exported = exportedNames.includes('Route') + ? 'Route' + : exportedNames.find((name) => name !== null) + if (!exported) { + throw codeFrameError( + options.code, + sourceNode(context, target), + 'Hydrate cannot code-split children that read Route when the module does not export it. Export Route, or use split={false} for this boundary.', + ) + } + prependStatements( + ast, + ...parseStatements( + `import { ${/^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(exported) ? exported : JSON.stringify(exported)} as Route } from ${JSON.stringify(sourceId)}`, + ), ) } - - retainModuleLevelDeclarations(ast, keepBindings) - unwrapExportedDeclarations(ast) - - ast.program.body.push( - t.exportNamedDeclaration( - t.functionDeclaration( - t.identifier(exportName), - targetCaptured.length > 0 - ? [ - t.objectPattern( - targetCaptured.map((name) => - t.objectProperty( - t.identifier(name), - t.identifier(name), - false, - true, - ), - ), - ), - ] - : [], - t.blockStatement([t.returnStatement(returnExpression)]), - ), - ), + const selected = new Set( + [...graph.declarationBindings] + .filter(([, owners]) => + [...owners].some((binding) => retained.has(binding)), + ) + .map(([declaration]) => declaration), ) - - deadCodeElimination(ast, refIdents) - stripUnreferencedTopLevelExpressionStatements(ast) - - const result = generateFromAst(ast, { - sourceMaps: true, - sourceFileName: options.id, - filename: options.id, + ast.body = ast.body.flatMap((statement): t.Program['body'] => { + if (is.ImportDeclaration(statement) || is.Directive(statement)) { + return [statement] + } + const declaration = + is.ExportNamedDeclaration(statement) || + is.ExportDefaultDeclaration(statement) + ? statement.declaration + : statement + if (is.VariableDeclaration(declaration)) { + declaration.declarations = declaration.declarations.filter((item) => + selected.has(sourceNode(context, item)), + ) + return declaration.declarations.length + ? [unwrapExport(statement, declaration)] + : [] + } + if ( + (is.FunctionDeclaration(declaration) || + is.ClassDeclaration(declaration) || + is.TSDeclareFunction(declaration) || + is.TSModuleDeclaration(declaration) || + is.TSEnumDeclaration(declaration)) && + selected.has(sourceNode(context, declaration)) + ) { + return [unwrapExport(statement, declaration)] + } + return [] }) - return result + const params = captures.length ? `{ ${captures.join(', ')} }` : '' + const output = parseStatements( + `export function H${boundaryIndex}(${params}) {}`, + )[0] as t.ExportNamedDeclaration + const component = output.declaration as t.FunctionDeclaration + component.body!.body.push(b.ReturnStatement({ argument: expression })) + ast.body.push(output) + removeUnusedBindings(module, ast, originalNodes) + keepFilePragmas(module.ast, ast) + // The chunk's code comes from the file itself, not from the virtual module + return generateModule(ast, { source: options.code, filename: sourceId }) } - export function createHydrateCompilerPlugin(): StartCompilerPlugin { type SourceEntry = { code: string @@ -943,6 +739,8 @@ export function createHydrateCompilerPlugin(): StartCompilerPlugin { : virtualModule.boundaryIndex + 1 const result = transformHydrateAst({ ast: context.ast, + module: context.module, + originalNodes: context.originalNodes, code: context.code, id: context.id, root: context.root, diff --git a/packages/start-plugin-core/src/import-protection/INTERNALS.md b/packages/start-plugin-core/src/import-protection/INTERNALS.md index 4f19e4bd7ea..22e5d88ed6d 100644 --- a/packages/start-plugin-core/src/import-protection/INTERNALS.md +++ b/packages/start-plugin-core/src/import-protection/INTERNALS.md @@ -7,7 +7,7 @@ Start import protection. Its current structure is: -- parse module code once per `TransformResult` +- parse and resolve module bindings once per `TransformResult` with Yuku - derive a read-only `ImportAnalysis` object from that AST - reuse that analysis for source extraction, import-specifier locations, mock export discovery, named export discovery, and usage lookup @@ -27,7 +27,7 @@ It builds and caches `ImportAnalysis` on `TransformResult.analysis`. `ImportAnalysis` contains: -- parsed AST +- immutable Yuku module with AST and resolved references - line index - import sources in source order - import specifier literal locations @@ -151,7 +151,6 @@ It includes: - `export ... from 'x'` - `export * from 'x'` - `import('x')` -- dynamic import call forms supported by Babel's AST shape Adapter code that needs source extraction should call into `analysis.ts` directly. @@ -188,11 +187,11 @@ handle default export separately. Usage lookup is shared across adapters through `analysis.ts`. -The walker: +The usage lookup: -- tracks lexical/program/block/function bindings manually -- respects shadowing across nested scopes, block bindings, function params, and - `catch` params +- queries Yuku's resolved runtime references instead of approximating scopes +- identifies imports by symbol identity, including shadowing across nested scopes, + block bindings, function params, and `catch` params - treats certain compiler-recognized boundaries as safe when looking for original unsafe usage @@ -217,7 +216,7 @@ Analysis and rewrite are intentionally separate. Reasons: - adapters frequently need inspection without mutation -- mutation wants a fresh, isolated AST/codegen pass +- mutation uses a cloned output AST and Yuku code generation with source maps - read-only analysis is much easier to cache safely - source-location helpers and usage lookup should not depend on whether a module was rewritten diff --git a/packages/start-plugin-core/src/import-protection/analysis.ts b/packages/start-plugin-core/src/import-protection/analysis.ts index 85f90b0a87e..4b5bc7f3123 100644 --- a/packages/start-plugin-core/src/import-protection/analysis.ts +++ b/packages/start-plugin-core/src/import-protection/analysis.ts @@ -1,68 +1,58 @@ -import * as t from '@babel/types' -import { parseAst } from '@tanstack/router-utils' - -import { buildLineIndex } from './sourceLocation' +import { analyzeModule, unwrapExpression } from '@tanstack/router-utils' +import { is, isWrapper, literalValue, nameOf } from 'yuku-ast' +import { buildLineIndex, indexToLineColumn } from './sourceLocation' import { getOrCreate } from './utils' +import type * as t from '@yuku-toolchain/types' +import type { Binding, Module } from 'yuku-analyzer' import type { LineIndex, TransformResult } from './sourceLocation' -import type { ParseAstResult } from '@tanstack/router-utils' export type UsagePos = { line: number; column0: number } - type BoundaryEnv = 'client' | 'server' - -type ImportBindingInfo = { - importedLocalNames: Set<string> -} - type UsageCacheKey = `${BoundaryEnv | 'post'}::${string}` -function mayContainImportOrExport(code: string): boolean { - return code.includes('import') || code.includes('export') -} - export type ImportAnalysis = { - ast: ParseAstResult + module: Module lineIndex: LineIndex importSourcesInOrder: Array<string> importSpecifierLocationIndex: Map<string, number> - importBindingsBySource: Map<string, ImportBindingInfo> + importBindingsBySource: Map<string, Set<Binding>> mockExportNamesBySource: Map<string, Array<string>> namedExports: Array<string> usageByKey: Map<UsageCacheKey, UsagePos | null> } +function mayContainImportOrExport(code: string): boolean { + return code.includes('import') || code.includes('export') +} + function makeTransientResult( code: string, filename?: string, perf?: TransformResult['perf'], ): TransformResult { - const result: TransformResult = { + return { code, filename, map: undefined, originalCode: undefined, + ...(perf ? { perf } : {}), } - - if (perf) { - result.perf = perf - } - - return result } -function getOrParseAst(result: TransformResult): ParseAstResult { - if (result.parsedAst) { +function getOrAnalyzeModule(result: TransformResult): Module { + if (result.analyzedModule) { result.perf?.count('analysis.parseAst.cached') - return result.parsedAst + return result.analyzedModule } - const startedAt = result.perf ? performance.now() : 0 result.perf?.count('analysis.parseAst.calls') - try { - const ast = parseAst({ code: result.code, filename: result.filename }) - result.parsedAst = ast - return ast + const module = analyzeModule({ + code: result.code, + filename: result.filename, + }) + result.analyzedModule = module + return module } finally { if (result.perf) { result.perf.time('analysis.parseAst', startedAt) @@ -70,123 +60,50 @@ function getOrParseAst(result: TransformResult): ParseAstResult { } } -function getModuleExportName(node: t.Identifier | t.StringLiteral): string { - return t.isIdentifier(node) ? node.name : node.value -} - function getStringLiteralValueStart(node: t.StringLiteral): number { - if (node.start == null) { - return -1 - } - - const raw = node.extra?.raw - if (typeof raw === 'string' && (raw.startsWith("'") || raw.startsWith('"'))) { - return node.start + 1 - } - - return node.start + return node.start + 1 } function isTypeOnlyImportDeclaration(node: t.ImportDeclaration): boolean { - if (node.importKind === 'type') return true - if (node.specifiers.length === 0) return false - - return node.specifiers.every( - (specifier) => - t.isImportSpecifier(specifier) && specifier.importKind === 'type', + return ( + node.importKind === 'type' || + (node.specifiers.length > 0 && + node.specifiers.every( + (specifier) => + is.ImportSpecifier(specifier) && specifier.importKind === 'type', + )) ) } function isTypeOnlyExportNamedDeclaration( node: t.ExportNamedDeclaration, ): boolean { - if (node.exportKind === 'type') return true - if (!node.source || node.declaration || node.specifiers.length === 0) { - return false - } - - return node.specifiers.every( - (specifier) => - t.isExportSpecifier(specifier) && specifier.exportKind === 'type', + return ( + node.exportKind === 'type' || + (!!node.source && + !node.declaration && + node.specifiers.length > 0 && + node.specifiers.every((specifier) => specifier.exportKind === 'type')) ) } -function collectIdentifiersFromPattern( - pattern: t.LVal, - add: (name: string) => void, -): void { - if (t.isIdentifier(pattern)) { - add(pattern.name) - } else if (t.isObjectPattern(pattern)) { - for (const prop of pattern.properties) { - if (t.isRestElement(prop)) { - collectIdentifiersFromPattern(prop.argument as t.LVal, add) - } else { - collectIdentifiersFromPattern(prop.value as t.LVal, add) - } - } - } else if (t.isArrayPattern(pattern)) { - for (const elem of pattern.elements) { - if (elem) collectIdentifiersFromPattern(elem as t.LVal, add) - } - } else if (t.isAssignmentPattern(pattern)) { - collectIdentifiersFromPattern(pattern.left, add) - } else if (t.isRestElement(pattern)) { - collectIdentifiersFromPattern(pattern.argument as t.LVal, add) - } -} - export function isValidExportName(name: string): boolean { - if (name === 'default' || name.length === 0) return false - const first = name.charCodeAt(0) - if ( - !( - (first >= 65 && first <= 90) || - (first >= 97 && first <= 122) || - first === 95 || - first === 36 - ) - ) - return false - for (let i = 1; i < name.length; i++) { - const ch = name.charCodeAt(i) - if ( - !( - (ch >= 65 && ch <= 90) || - (ch >= 97 && ch <= 122) || - (ch >= 48 && ch <= 57) || - ch === 95 || - ch === 36 - ) - ) - return false - } - return true + return name !== 'default' && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) } function buildImportAnalysis(result: TransformResult): ImportAnalysis { - const ast = getOrParseAst(result) + const module = getOrAnalyzeModule(result) + const { imports, exports } = module const importSourcesInOrder: Array<string> = [] const importSpecifierLocationIndex = new Map<string, number>() - const importBindingsBySource = new Map<string, ImportBindingInfo>() - const memberBindingSources = new Map<string, Set<string>>() + const importBindingsBySource = new Map<string, Set<Binding>>() const mockNamesBySource = new Map<string, Set<string>>() - const namedExports = new Set<string>() - - const getBindingInfo = (source: string): ImportBindingInfo => - getOrCreate(importBindingsBySource, source, () => ({ - importedLocalNames: new Set<string>(), - })) const addSpecifierLocation = (node: t.StringLiteral) => { importSourcesInOrder.push(node.value) const index = getStringLiteralValueStart(node) - if (index === -1) { - return - } - const prev = importSpecifierLocationIndex.get(node.value) if (prev == null || index < prev) { importSpecifierLocationIndex.set(node.value, index) @@ -194,136 +111,76 @@ function buildImportAnalysis(result: TransformResult): ImportAnalysis { } const addMockName = (source: string, name: string) => { - if (name === 'default' || name.length === 0) return + if (name === 'default' || name.length === 0) { + return + } getOrCreate(mockNamesBySource, source, () => new Set<string>()).add(name) } - const addMemberBinding = (localName: string, source: string) => { - getOrCreate(memberBindingSources, localName, () => new Set<string>()).add( - source, - ) + for (const imp of imports) { + if ( + imp.typeOnly || + !imp.local || + (imp.kind !== 'named' && imp.kind !== 'namespace') + ) { + continue + } + const source = imp.specifier + getOrCreate(importBindingsBySource, source, () => new Set()).add(imp.local) + if (is.ImportSpecifier(imp.node)) { + addMockName(source, imp.name!) + continue + } + + // Members read from a namespace or default import + for (const reference of imp.local.references) { + const { node: object, parent } = transparentParent(module, reference.node) + if (is.MemberExpression(parent) && parent.object === object) { + const name = parent.computed + ? literalValue(parent.property) + : nameOf(parent.property) + if (typeof name === 'string') { + addMockName(source, name) + } + } + } } - const addNamedExport = (name: string) => { - if (name !== 'default' && name.length > 0) { - namedExports.add(name) + const namedExports = new Set<string>() + for (const exp of exports) { + if (exp.typeOnly) { + continue + } + if (exp.kind === 'reExport') { + addMockName(exp.specifier!, exp.fromName!) + } + if (exp.name && exp.name !== 'default') { + namedExports.add(exp.name) } } const visit = (node: t.Node): void => { - if (t.isImportDeclaration(node)) { - const isTypeOnly = isTypeOnlyImportDeclaration(node) - if (!isTypeOnly) { + if (is.ImportDeclaration(node)) { + if (!isTypeOnlyImportDeclaration(node)) { addSpecifierLocation(node.source) - const source = node.source.value - const bindingInfo = getBindingInfo(source) - for (const specifier of node.specifiers) { - if (t.isImportNamespaceSpecifier(specifier)) { - bindingInfo.importedLocalNames.add(specifier.local.name) - addMemberBinding(specifier.local.name, source) - continue - } - - if (t.isImportDefaultSpecifier(specifier)) { - bindingInfo.importedLocalNames.add(specifier.local.name) - addMemberBinding(specifier.local.name, source) - continue - } - - if (!t.isImportSpecifier(specifier)) continue - if (specifier.importKind === 'type') continue - - bindingInfo.importedLocalNames.add(specifier.local.name) - const importedName = getModuleExportName(specifier.imported) - if (importedName !== 'default') { - addMockName(source, importedName) - } - } } - } else if (t.isExportNamedDeclaration(node)) { - const isTypeOnly = isTypeOnlyExportNamedDeclaration(node) - if (!isTypeOnly && node.source && t.isStringLiteral(node.source)) { + } else if (is.ExportNamedDeclaration(node)) { + if (node.source && !isTypeOnlyExportNamedDeclaration(node)) { addSpecifierLocation(node.source) } - - if (!isTypeOnly && node.source?.value) { - const source = node.source.value - for (const specifier of node.specifiers) { - if (!t.isExportSpecifier(specifier)) continue - if (specifier.exportKind === 'type') continue - addMockName(source, getModuleExportName(specifier.local)) - } - } - - if (!isTypeOnly) { - if (node.declaration) { - const decl = node.declaration - if (t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) { - if (decl.id?.name) addNamedExport(decl.id.name) - } else if (t.isVariableDeclaration(decl)) { - for (const d of decl.declarations) { - collectIdentifiersFromPattern(d.id as t.LVal, addNamedExport) - } - } - } - - for (const specifier of node.specifiers) { - if (!t.isExportSpecifier(specifier)) continue - if (specifier.exportKind === 'type') continue - const exportedName = getModuleExportName(specifier.exported) - addNamedExport(exportedName) - } - } - } else if (t.isExportAllDeclaration(node)) { + } else if (is.ExportAllDeclaration(node)) { if (node.exportKind !== 'type') { addSpecifierLocation(node.source) } - } else if (t.isImportExpression(node)) { - if (t.isStringLiteral(node.source)) { - addSpecifierLocation(node.source) - } - } else if (t.isCallExpression(node) && t.isImport(node.callee)) { - const sourceNode = node.arguments[0] - if (t.isStringLiteral(sourceNode)) { - addSpecifierLocation(sourceNode) - } - } else if ( - t.isMemberExpression(node) || - t.isOptionalMemberExpression(node) - ) { - const object = node.object - if (t.isIdentifier(object)) { - const sources = memberBindingSources.get(object.name) - if (sources) { - const property = node.property - for (const source of sources) { - if (!node.computed && t.isIdentifier(property)) { - addMockName(source, property.name) - } else if (node.computed && t.isStringLiteral(property)) { - addMockName(source, property.value) - } - } - } - } - } - - const keys = t.VISITOR_KEYS[node.type] - if (!keys) return - for (const key of keys) { - const child = (node as unknown as Record<string, unknown>)[key] - if (Array.isArray(child)) { - for (const item of child) { - if (item && typeof item === 'object' && 'type' in item) { - visit(item as t.Node) - } - } - } else if (child && typeof child === 'object' && 'type' in child) { - visit(child as t.Node) + } else if (is.ImportExpression(node)) { + const source = unwrapExpression(node.source) + if (is.StringLiteral(source)) { + addSpecifierLocation(source) } } } - visit(ast.program) + module.walk({ enter: visit }) const mockExportNamesBySource = new Map<string, Array<string>>() for (const [source, names] of mockNamesBySource) { @@ -334,7 +191,7 @@ function buildImportAnalysis(result: TransformResult): ImportAnalysis { result.lineIndex = lineIndex const analysis = { - ast, + module, lineIndex, importSourcesInOrder, importSpecifierLocationIndex, @@ -418,27 +275,29 @@ export function getNamedExports( function isCompilerSafeBoundaryCall( call: t.CallExpression, - fnNode: t.Function, + fnNode: t.Function | t.ArrowFunctionExpression, envType: BoundaryEnv, ): boolean { - const directArgument = call.arguments.some((arg) => arg === fnNode) + const directArgument = call.arguments.some( + (arg) => unwrapExpression(arg) === fnNode, + ) if (!directArgument) { return false } - const callee = call.callee + const callee = unwrapExpression(call.callee) - if (t.isIdentifier(callee)) { + if (is.Identifier(callee)) { return envType === 'client' ? callee.name === 'createServerOnlyFn' : callee.name === 'createClientOnlyFn' } - if (!t.isMemberExpression(callee) || callee.computed) { + if (!is.MemberExpression(callee) || callee.computed) { return false } - if (!t.isIdentifier(callee.property)) { + if (!is.Identifier(callee.property)) { return false } @@ -468,235 +327,48 @@ function isCompilerSafeBoundaryCall( return false } -function getCalleeRootName( - node: t.Expression | t.Super | t.V8IntrinsicIdentifier, -): string | undefined { - if (t.isIdentifier(node)) { +function getCalleeRootName(node: t.Node): string | undefined { + node = unwrapExpression(node) + if (is.Identifier(node)) { return node.name } - if (t.isCallExpression(node)) { + if (is.CallExpression(node)) { return getCalleeRootName(node.callee) } - if (t.isMemberExpression(node)) { + if (is.MemberExpression(node)) { return getCalleeRootName(node.object) } return undefined } -function getBoundNamesFromPattern(pattern: t.LVal, out: Set<string>): void { - if (t.isIdentifier(pattern)) { - out.add(pattern.name) - } else if (t.isObjectPattern(pattern)) { - for (const prop of pattern.properties) { - if (t.isRestElement(prop)) { - getBoundNamesFromPattern(prop.argument as t.LVal, out) - } else { - getBoundNamesFromPattern(prop.value as t.LVal, out) - } - } - } else if (t.isArrayPattern(pattern)) { - for (const elem of pattern.elements) { - if (elem) getBoundNamesFromPattern(elem as t.LVal, out) - } - } else if (t.isAssignmentPattern(pattern)) { - getBoundNamesFromPattern(pattern.left, out) - } else if (t.isRestElement(pattern)) { - getBoundNamesFromPattern(pattern.argument as t.LVal, out) +function transparentParent(module: Module, node: t.Node) { + let parent = module.parentOf(node) + while (isWrapper(parent) && parent.expression === node) { + node = parent + parent = module.parentOf(node) } + return { node, parent } } -function addPatternBindingsIfTracked( - pattern: t.LVal, - tracked: Set<string>, - out: Set<string>, -): void { - const names = new Set<string>() - getBoundNamesFromPattern(pattern, names) - for (const name of names) { - if (tracked.has(name)) { - out.add(name) - } - } -} - -function collectHoistedVarBindings( +function isInsideCompilerSafeBoundary( + module: Module, node: t.Node, - tracked: Set<string>, - out: Set<string>, - isRoot = true, -): void { - if (!isRoot && t.isFunction(node)) { - return - } - - if (t.isVariableDeclaration(node) && node.kind === 'var') { - for (const decl of node.declarations) { - addPatternBindingsIfTracked(decl.id as t.LVal, tracked, out) - } - } - - const keys = t.VISITOR_KEYS[node.type] - if (!keys) return - for (const key of keys) { - const child = (node as unknown as Record<string, unknown>)[key] - if (Array.isArray(child)) { - for (const item of child) { - if (item && typeof item === 'object' && 'type' in item) { - collectHoistedVarBindings(item as t.Node, tracked, out, false) - } - } - } else if (child && typeof child === 'object' && 'type' in child) { - collectHoistedVarBindings(child as t.Node, tracked, out, false) - } - } -} - -function collectProgramBindings( - program: t.Program, - tracked: Set<string>, -): Set<string> { - const bindings = new Set<string>() - - for (const node of program.body) { - if (t.isVariableDeclaration(node)) { - for (const decl of node.declarations) { - addPatternBindingsIfTracked(decl.id as t.LVal, tracked, bindings) - } - continue - } - - if (t.isFunctionDeclaration(node) || t.isClassDeclaration(node)) { - if (node.id && tracked.has(node.id.name)) { - bindings.add(node.id.name) - } - } - } - - return bindings -} - -function collectBlockBindings( - block: t.BlockStatement, - tracked: Set<string>, -): Set<string> { - const bindings = new Set<string>() - - for (const node of block.body) { - if (t.isVariableDeclaration(node) && node.kind !== 'var') { - for (const decl of node.declarations) { - addPatternBindingsIfTracked(decl.id as t.LVal, tracked, bindings) - } - continue - } - - if (t.isFunctionDeclaration(node) || t.isClassDeclaration(node)) { - if (node.id && tracked.has(node.id.name)) { - bindings.add(node.id.name) - } - } - } - - return bindings -} - -function collectFunctionBindings( - fn: t.Function, - tracked: Set<string>, -): Set<string> { - const bindings = new Set<string>() - - if ( - (t.isFunctionDeclaration(fn) || t.isFunctionExpression(fn)) && - fn.id && - tracked.has(fn.id.name) - ) { - bindings.add(fn.id.name) - } - - for (const param of fn.params) { - addPatternBindingsIfTracked(param as t.LVal, tracked, bindings) - } - - if (t.isBlockStatement(fn.body)) { - collectHoistedVarBindings(fn.body, tracked, bindings) - } - - return bindings -} - -type UsageWalkContext = { - parents: Array<t.Node> - scopeStack: Array<Set<string>> -} - -function isShadowedByScope( - name: string, - scopeStack: Array<Set<string>>, -): boolean { - for (let i = scopeStack.length - 1; i >= 0; i--) { - if (scopeStack[i]?.has(name)) { - return true - } - } - return false -} - -function isBindingIdentifierInParent( - node: t.Identifier, - parent: t.Node | undefined, -): boolean { - if (!parent) return false - - if (t.isImportSpecifier(parent) || t.isImportDefaultSpecifier(parent)) { - return parent.local === node - } - if (t.isImportNamespaceSpecifier(parent)) { - return parent.local === node - } - if (t.isFunctionDeclaration(parent) || t.isFunctionExpression(parent)) { - return ( - parent.id === node || parent.params.includes(node as unknown as t.Pattern) - ) - } - if (t.isArrowFunctionExpression(parent)) { - return parent.params.includes(node as unknown as t.Pattern) - } - if (t.isClassDeclaration(parent) || t.isClassExpression(parent)) { - return parent.id === node - } - if (t.isVariableDeclarator(parent)) { - return parent.id === node - } - if (t.isCatchClause(parent)) { - return parent.param === node - } - - return false -} - -function isInsideCompilerSafeBoundaryNodes( - parents: Array<t.Node>, envType: BoundaryEnv, ): boolean { - for (let i = parents.length - 1; i >= 0; i--) { - const node = parents[i]! - if (!t.isFunction(node)) { - continue - } - - const call = parents[i - 1] - if ( - call && - t.isCallExpression(call) && - isCompilerSafeBoundaryCall(call, node, envType) - ) { - return true + for (const ancestor of module.ancestors(node)) { + if (is.Function(ancestor)) { + const { parent: call } = transparentParent(module, ancestor) + if ( + is.CallExpression(call) && + isCompilerSafeBoundaryCall(call, ancestor, envType) + ) { + return true + } } } - return false } @@ -710,191 +382,38 @@ function findUsagePosInAnalysis( if (analysis.usageByKey.has(cacheKey)) { return analysis.usageByKey.get(cacheKey) ?? undefined } - - const imported = - analysis.importBindingsBySource.get(source)?.importedLocalNames - if (!imported || imported.size === 0) { - analysis.usageByKey.set(cacheKey, null) - return undefined - } - + const module = analysis.module + const bindings = analysis.importBindingsBySource.get(source) let preferred: UsagePos | undefined let anyUsage: UsagePos | undefined - - const visit = (node: t.Node, ctx: UsageWalkContext): void => { - if (preferred && anyUsage) { - return - } - - if (t.isProgram(node)) { - const nextCtx = { - parents: [...ctx.parents, node], - scopeStack: [...ctx.scopeStack, collectProgramBindings(node, imported)], - } - for (const child of node.body) { - visit(child, nextCtx) - } - return - } - - if (t.isFunction(node)) { - const functionCtx = { - parents: [...ctx.parents, node], - scopeStack: [ - ...ctx.scopeStack, - collectFunctionBindings(node, imported), - ], - } - - visit(node.body, functionCtx) - return - } - - if (t.isBlockStatement(node)) { - const nextCtx = { - parents: [...ctx.parents, node], - scopeStack: [...ctx.scopeStack, collectBlockBindings(node, imported)], - } - for (const child of node.body) { - visit(child, nextCtx) - } - return - } - - if (t.isCatchClause(node)) { - const bindings = new Set<string>() - if (node.param) { - addPatternBindingsIfTracked(node.param as t.LVal, imported, bindings) - } - const nextCtx = { - parents: [...ctx.parents, node], - scopeStack: bindings.size - ? [...ctx.scopeStack, bindings] - : ctx.scopeStack, - } - visit(node.body, nextCtx) - return - } - + for (const reference of module.references) { if ( - t.isForStatement(node) && - t.isVariableDeclaration(node.init) && - node.init.kind !== 'var' + !reference.binding || + !bindings?.has(reference.binding) || + reference.inTypePosition ) { - const bindings = new Set<string>() - for (const decl of node.init.declarations) { - addPatternBindingsIfTracked(decl.id as t.LVal, imported, bindings) - } - const nextCtx = { - parents: [...ctx.parents, node], - scopeStack: bindings.size - ? [...ctx.scopeStack, bindings] - : ctx.scopeStack, - } - visit(node.init, nextCtx) - if (node.test) visit(node.test, nextCtx) - if (node.update) visit(node.update, nextCtx) - visit(node.body, nextCtx) - return - } - - if ( - (t.isForInStatement(node) || t.isForOfStatement(node)) && - t.isVariableDeclaration(node.left) && - node.left.kind !== 'var' - ) { - const bindings = new Set<string>() - for (const decl of node.left.declarations) { - addPatternBindingsIfTracked(decl.id as t.LVal, imported, bindings) - } - const nextCtx = { - parents: [...ctx.parents, node], - scopeStack: bindings.size - ? [...ctx.scopeStack, bindings] - : ctx.scopeStack, - } - visit(node.left, nextCtx) - visit(node.right, nextCtx) - visit(node.body, nextCtx) - return + continue } - - const nextParents = [...ctx.parents, node] - - if (t.isIdentifier(node)) { - const parent = ctx.parents[ctx.parents.length - 1] - if (imported.has(node.name)) { - if (!isBindingIdentifierInParent(node, parent)) { - if ( - !( - t.isObjectProperty(parent) && - parent.key === node && - !parent.computed && - !parent.shorthand - ) && - !( - t.isObjectMethod(parent) && - parent.key === node && - !parent.computed - ) && - !(t.isExportSpecifier(parent) && parent.exported === node) && - !isShadowedByScope(node.name, ctx.scopeStack) && - !( - envType && isInsideCompilerSafeBoundaryNodes(ctx.parents, envType) - ) - ) { - const loc = node.loc?.start - if (loc) { - const pos: UsagePos = { line: loc.line, column0: loc.column } - const isPreferred = - (t.isCallExpression(parent) && parent.callee === node) || - (t.isNewExpression(parent) && parent.callee === node) || - ((t.isMemberExpression(parent) || - t.isOptionalMemberExpression(parent)) && - parent.object === node) - - if (isPreferred) { - preferred ||= pos - } else { - anyUsage ||= pos - } - } - } - } - } + const node = reference.node + if (envType && isInsideCompilerSafeBoundary(module, node, envType)) { + continue } - - if (t.isImportDeclaration(node)) { - return + const { node: expression, parent } = transparentParent(module, node) + const { line, column } = indexToLineColumn(analysis.lineIndex, node.start) + const pos = { line, column0: column - 1 } + const isPreferred = + ((is.CallExpression(parent) || is.NewExpression(parent)) && + parent.callee === expression) || + (is.MemberExpression(parent) && parent.object === expression) + if (isPreferred) { + preferred ??= pos + } else { + anyUsage ??= pos } - - const keys = t.VISITOR_KEYS[node.type] - if (!keys) return - for (const key of keys) { - const child = (node as unknown as Record<string, unknown>)[key] - if (Array.isArray(child)) { - for (const item of child) { - if (item && typeof item === 'object' && 'type' in item) { - visit(item as t.Node, { - parents: nextParents, - scopeStack: ctx.scopeStack, - }) - } - } - } else if (child && typeof child === 'object' && 'type' in child) { - visit(child as t.Node, { - parents: nextParents, - scopeStack: ctx.scopeStack, - }) - } + if (preferred && anyUsage) { + break } } - - visit(analysis.ast.program, { - parents: [], - scopeStack: [], - }) - const pos = preferred ?? anyUsage ?? null analysis.usageByKey.set(cacheKey, pos) return pos ?? undefined diff --git a/packages/start-plugin-core/src/import-protection/rewrite.ts b/packages/start-plugin-core/src/import-protection/rewrite.ts index fb7e1904763..cb620c74ba3 100644 --- a/packages/start-plugin-core/src/import-protection/rewrite.ts +++ b/packages/start-plugin-core/src/import-protection/rewrite.ts @@ -1,226 +1,119 @@ -import * as t from '@babel/types' -import { generateFromAst, parseAst } from '@tanstack/router-utils' - +import { + analyzeModule, + cloneModuleAst, + generateModule, + parseStatements, +} from '@tanstack/router-utils' +import { is, isValidIdentifier, nameOf } from 'yuku-ast' import { MOCK_MODULE_ID } from './constants' import type { SourceMapLike } from './sourceLocation' -import type { ParseAstResult } from '@tanstack/router-utils' - -function getModuleExportName(node: t.Identifier | t.StringLiteral): string { - return t.isIdentifier(node) ? node.name : node.value -} -function toMemberExpressionProperty(name: string): { - property: t.Identifier | t.StringLiteral - computed: boolean -} { - return t.isValidIdentifier(name) - ? { property: t.identifier(name), computed: false } - : { property: t.stringLiteral(name), computed: true } +function memberAccess(object: string, name: string): string { + return isValidIdentifier(name) + ? `${object}.${name}` + : `${object}[${JSON.stringify(name)}]` } -function toModuleExportNameNode(name: string): t.Identifier | t.StringLiteral { - return t.isValidIdentifier(name) ? t.identifier(name) : t.stringLiteral(name) -} - -function createInternalReexportVarName( - localName: string, - mockIndex: number, - usedNames: Set<string>, -): string { - const baseName = t.isValidIdentifier(localName) - ? `__tss_reexport_${localName}` - : `__tss_reexport_${mockIndex}` - - let candidate = baseName - let suffix = 0 - while (usedNames.has(candidate)) { - suffix++ - candidate = `${baseName}_${suffix}` - } - - usedNames.add(candidate) - return candidate +function exportName(name: string): string { + return isValidIdentifier(name) ? name : JSON.stringify(name) } -/** - * Rewrite static imports/re-exports from denied sources using Babel AST transforms. - * - * Transforms: - * import { a as b, c } from 'denied' - * Into: - * import __tss_deny_0 from 'tanstack-start-import-protection:mock' - * const b = __tss_deny_0.a - * const c = __tss_deny_0.c - * - * Also handles: - * import def from 'denied' -> import def from mock - * import * as ns from 'denied' -> import ns from mock - * export { x } from 'denied' -> export const x = mock.x - * export * from 'denied' -> removed - * export { x as y } from 'denied' -> export const y = mock.x - */ +/** Rewrite denied static edges using one source analysis and an isolated output AST. */ export function rewriteDeniedImports( code: string, id: string, deniedSources: Set<string>, getMockModuleId: (source: string) => string = () => MOCK_MODULE_ID, ): { code: string; map?: SourceMapLike } | undefined { - return rewriteDeniedImportsFromAst( - parseAst({ code, filename: id }), - id, - deniedSources, - getMockModuleId, - ) -} - -function rewriteDeniedImportsFromAst( - ast: ParseAstResult, - id: string, - deniedSources: Set<string>, - getMockModuleId: (source: string) => string = () => MOCK_MODULE_ID, -): { code: string; map?: SourceMapLike } | undefined { + const module = analyzeModule({ code, filename: id }) + const { program } = cloneModuleAst(module) + const usedNames = new Set(module.bindings.map((binding) => binding.name)) + let counter = 0 + const uniqueName = (prefix: string) => { + let name = prefix + while (usedNames.has(name)) { + name = `${prefix}_${counter++}` + } + usedNames.add(name) + return name + } let modified = false - let mockCounter = 0 - - // Walk program body in reverse so splice indices stay valid - for (let i = ast.program.body.length - 1; i >= 0; i--) { - const node = ast.program.body[i]! - - if (t.isImportDeclaration(node)) { - if (node.importKind === 'type') continue - if (!deniedSources.has(node.source.value)) continue - - const mockVar = `__tss_deny_${mockCounter++}` - const replacements: Array<t.Statement> = [] - - replacements.push( - t.importDeclaration( - [t.importDefaultSpecifier(t.identifier(mockVar))], - t.stringLiteral(getMockModuleId(node.source.value)), - ), + for (let i = program.body.length - 1; i >= 0; i--) { + const node = program.body[i]! + if ( + !( + is.ImportDeclaration(node) || + is.ExportNamedDeclaration(node) || + is.ExportAllDeclaration(node) ) - - for (const specifier of node.specifiers) { - if ( - t.isImportDefaultSpecifier(specifier) || - t.isImportNamespaceSpecifier(specifier) - ) { - replacements.push( - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(specifier.local.name), - t.identifier(mockVar), - ), - ]), - ) - } else if (t.isImportSpecifier(specifier)) { - if (specifier.importKind === 'type') continue - const importedName = getModuleExportName(specifier.imported) - const memberProperty = toMemberExpressionProperty(importedName) - replacements.push( - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(specifier.local.name), - t.memberExpression( - t.identifier(mockVar), - memberProperty.property, - memberProperty.computed, - ), - ), - ]), - ) - } - } - - ast.program.body.splice(i, 1, ...replacements) + ) { + continue + } + if (!node.source || !deniedSources.has(node.source.value)) { + continue + } + if ( + (is.ImportDeclaration(node) && node.importKind === 'type') || + (!is.ImportDeclaration(node) && node.exportKind === 'type') + ) { + continue + } + if (is.ExportAllDeclaration(node)) { + program.body.splice(i, 1) modified = true continue } - - if (t.isExportNamedDeclaration(node) && node.source) { - if (node.exportKind === 'type') continue - if (!deniedSources.has(node.source.value)) continue - - const mockIndex = mockCounter++ - const mockVar = `__tss_deny_${mockIndex}` - const replacements: Array<t.Statement> = [] - - replacements.push( - t.importDeclaration( - [t.importDefaultSpecifier(t.identifier(mockVar))], - t.stringLiteral(getMockModuleId(node.source.value)), - ), - ) - const usedInternalVars = new Set<string>() - const exportSpecifiers: Array<{ - localName: string - exportedName: string - }> = [] + const mockIndex = counter++ + const mockVar = uniqueName(`__tss_deny_${mockIndex}`) + const statements = [ + `import ${mockVar} from ${JSON.stringify(getMockModuleId(node.source.value))}`, + ] + if (is.ImportDeclaration(node)) { for (const specifier of node.specifiers) { - if (t.isExportSpecifier(specifier)) { - if (specifier.exportKind === 'type') continue - const localName = getModuleExportName(specifier.local) - const exportedName = getModuleExportName(specifier.exported) - const memberProperty = toMemberExpressionProperty(localName) - - const internalVar = createInternalReexportVarName( - localName, - mockIndex, - usedInternalVars, + if (is.ImportSpecifier(specifier)) { + if (specifier.importKind === 'type') { + continue + } + const imported = nameOf(specifier.imported) + statements.push( + `const ${specifier.local.name} = ${memberAccess(mockVar, imported)}`, ) - replacements.push( - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(internalVar), - t.memberExpression( - t.identifier(mockVar), - memberProperty.property, - memberProperty.computed, - ), - ), - ]), - ) - exportSpecifiers.push({ localName: internalVar, exportedName }) + } else { + statements.push(`const ${specifier.local.name} = ${mockVar}`) } } - - if (exportSpecifiers.length > 0) { - replacements.push( - t.exportNamedDeclaration( - null, - exportSpecifiers.map((s) => - t.exportSpecifier( - t.identifier(s.localName), - toModuleExportNameNode(s.exportedName), - ), - ), - ), + } else { + const exportSpecifiers: Array<string> = [] + for (const specifier of node.specifiers) { + if (specifier.exportKind === 'type') { + continue + } + const local = nameOf(specifier.local) + const exported = nameOf(specifier.exported) + const internal = uniqueName( + isValidIdentifier(local) + ? `__tss_reexport_${local}` + : `__tss_reexport_${mockIndex}`, ) + statements.push(`const ${internal} = ${memberAccess(mockVar, local)}`) + exportSpecifiers.push(`${internal} as ${exportName(exported)}`) + } + if (exportSpecifiers.length) { + statements.push(`export { ${exportSpecifiers.join(', ')} }`) } - - ast.program.body.splice(i, 1, ...replacements) - modified = true - continue } - - if (t.isExportAllDeclaration(node)) { - if (node.exportKind === 'type') continue - if (!deniedSources.has(node.source.value)) continue - - ast.program.body.splice(i, 1) - modified = true - continue + const replacements = parseStatements(statements.join('\n')) + for (const statement of replacements) { + statement.start = node.start + statement.end = node.end } + program.body.splice(i, 1, ...replacements) + modified = true } - - if (!modified) return undefined - - const result = generateFromAst(ast, { - sourceMaps: true, - sourceFileName: id, - filename: id, - }) - + if (!modified) { + return undefined + } + const result = generateModule(program, { source: code, filename: id }) return { code: result.code, ...(result.map ? { map: result.map as SourceMapLike } : {}), diff --git a/packages/start-plugin-core/src/import-protection/sourceLocation.ts b/packages/start-plugin-core/src/import-protection/sourceLocation.ts index ee346b327b3..0db8c1f2911 100644 --- a/packages/start-plugin-core/src/import-protection/sourceLocation.ts +++ b/packages/start-plugin-core/src/import-protection/sourceLocation.ts @@ -9,7 +9,7 @@ import { import { getOrCreate, normalizeFilePath } from './utils' import type { ImportAnalysis } from './analysis' import type { Loc } from './trace' -import type { ParseAstResult } from '@tanstack/router-utils' +import type { Module } from 'yuku-analyzer' import type { RawSourceMap } from 'source-map' // Source-map type compatible with both Rollup's SourceMap and source-map's @@ -42,7 +42,7 @@ export interface TransformResult { originalResult?: TransformResult /** Precomputed line index for `code` (index → line/col). */ lineIndex?: LineIndex - parsedAst?: ParseAstResult + analyzedModule?: Module analysis?: ImportAnalysis perf?: ImportProtectionPerfRecorder } diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index 54ac57e9b7f..057655e2959 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -497,8 +497,8 @@ export function tanStackStartRsbuild( // All modules imported from the RSC layer inherit // the react-server condition (transitive propagation), except // route split virtual modules. Those remain ordinary SSR/client - // route code; only `?tsr-shared=1` modules may be shared with the - // provider subtree. + // route code; only the `?tsr-shared=` modules may be shared with + // the provider subtree. moduleRules.push({ issuerLayer: RSBUILD_RSC_LAYERS.rsc, resourceQuery: { diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts index 3cd1725fa09..3ed33ac041b 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts @@ -26,7 +26,6 @@ import type { CompileStartFrameworkOptions, StartCompilerImportTransform, StartCompilerPlugin, - StartCompilerTransformResult, } from '../types' import type { DevServerFnModuleSpecifierEncoder, @@ -259,7 +258,7 @@ export function registerStartCompilerTransforms( let nextCode = code let previousResult: { code: string - map: StartCompilerTransformResult['map'] + map: Rspack.RawSourceMap | null } | null = null const id = ctx.resource const root = getRoot() @@ -275,7 +274,9 @@ export function registerStartCompilerTransforms( nextCode = virtualResult.code previousResult = { code: virtualResult.code, - map: virtualResult.map ?? null, + // Downstream JSX loaders consume Source Map v3 objects. Rspack's + // type requires `file` and excludes valid null source contents. + map: (virtualResult.map ?? null) as Rspack.RawSourceMap | null, } } @@ -391,7 +392,7 @@ export function registerStartCompilerTransforms( if (result) { return { code: result.code, - map: result.map ?? null, + map: (result.map ?? null) as Rspack.RawSourceMap | null, } } diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index a39f7258e4f..0c6ec82973a 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -1,25 +1,27 @@ import crypto from 'node:crypto' -import * as t from '@babel/types' +import { is, isWrapper, walk } from 'yuku-ast' import { - deadCodeElimination, - extractModuleInfoFromAst, - findReferencedIdentifiers, - generateFromAst, - getVariableDeclaratorForExpressionPath, - parseAst, + analyzeModule, + cloneModuleAst, + extractModuleInfo, + generateModule, + keepFilePragmas, + parseExpression, + removeUnusedBindings, unwrapExpression, } from '@tanstack/router-utils' -import babel from '@babel/core' import { handleCreateServerFn } from './handleCreateServerFn' import { handleCreateMiddleware } from './handleCreateMiddleware' import { handleCreateIsomorphicFn } from './handleCreateIsomorphicFn' import { handleEnvOnlyFn } from './handleEnvOnly' import { handleClientOnlyJSX } from './handleClientOnlyJSX' -import { cleanId } from './utils' +import { cleanId, createAstEditor, getVariableDeclarator } from './utils' +import type * as t from '@yuku-toolchain/types' +import type { Module } from 'yuku-analyzer' import type { CompilationContext, DevServerFnModuleSpecifierEncoder, - MethodChainPaths, + MethodChain, RewriteCandidate, ServerFn, } from './types' @@ -29,17 +31,17 @@ import type { StartCompilerEnvironment, StartCompilerImportTransform, StartCompilerPlugin, + StartCompilerTransformContext, StartCompilerTransformResult, } from '../types' -type Binding = ModuleInfoBinding & { +type CompilerBinding = ModuleInfoBinding & { resolvedKind?: Kind } -type ImportBinding = Extract<Binding, { type: 'import' }> +type CompilerImportBinding = Extract<CompilerBinding, { type: 'import' }> type Kind = 'None' | `Root` | `Builder` | LookupKind -type ParsedAst = ReturnType<typeof parseAst> type StartCompilerAstPlugin = StartCompilerPlugin & { transformAst: NonNullable<StartCompilerPlugin['transformAst']> } @@ -103,7 +105,9 @@ function isStartCompilerEnvironmentEnabled( | undefined, env: StartCompilerEnvironment, ): boolean { - if (!environment) return true + if (!environment) { + return true + } if (Array.isArray(environment)) { return environment.includes(env) } @@ -244,7 +248,9 @@ export function detectKindsInCode( } for (const transform of opts?.compilerTransforms ?? []) { - if (!isCompilerTransformEnabledForEnv(transform, env)) continue + if (!isCompilerTransformEnabledForEnv(transform, env)) { + continue + } transform.detect.lastIndex = 0 if (transform.detect.test(code)) { detected.add(getExternalLookupKind(transform)) @@ -295,136 +301,18 @@ export type LookupConfig = { interface ExportResolution { moduleInfo: ModuleInfo localName: string - binding: Binding + binding: CompilerBinding } interface ModuleInfo { id: string - bindings: Map<string, Binding> + bindings: Map<string, CompilerBinding> // Maps exported name → local binding name exports: Map<string, string> // Track `export * from './module'` declarations for re-export resolution reExportAllSources: Array<string> } -/** - * Checks if all kinds in the set are guaranteed to be top-level only. - * Only ServerFn is always declared at module level (must be assigned to a variable). - * Middleware, IsomorphicFn, ServerOnlyFn, ClientOnlyFn can be nested inside functions. - * When all kinds are top-level-only, we can use a fast scan instead of full traversal. - */ -function areAllKindsTopLevelOnly(kinds: Set<LookupKind>): boolean { - return kinds.size === 1 && kinds.has('ServerFn') -} - -/** - * Checks if we need to detect JSX elements (e.g., <ClientOnly>). - */ -function needsJSXDetection( - kinds: Set<LookupKind>, - externalLookupSetup?: Map<ExternalLookupKind, DirectCallSetup>, -): boolean { - for (const kind of kinds) { - if (getLookupSetup(kind, externalLookupSetup)?.type === 'jsx') { - return true - } - } - return false -} - -/** - * Checks if a CallExpression is a direct-call candidate for NESTED detection. - * Returns true if the callee is a known factory function name. - * This is stricter than top-level detection because we need to filter out - * invocations of existing server functions (e.g., `myServerFn()`). - */ -function isNestedDirectCallCandidate( - node: t.CallExpression, - lookupKinds: Set<LookupKind>, - externalLookupSetup?: Map<ExternalLookupKind, DirectCallSetup>, -): boolean { - let calleeName: string | undefined - if (t.isIdentifier(node.callee)) { - calleeName = node.callee.name - } else if ( - t.isMemberExpression(node.callee) && - t.isIdentifier(node.callee.property) - ) { - calleeName = node.callee.property.name - } - if (!calleeName) return false - for (const kind of lookupKinds) { - if (isExternalLookupKind(kind)) continue - const setup = getLookupSetup(kind, externalLookupSetup) - if (setup?.type === 'directCall' && setup.factoryNames.has(calleeName)) { - return true - } - } - return false -} - -function isSimpleDirectCallExpression(node: t.CallExpression): boolean { - return ( - t.isIdentifier(node.callee) || - (t.isMemberExpression(node.callee) && - t.isIdentifier(node.callee.object) && - t.isIdentifier(node.callee.property)) - ) -} - -function isTopLevelDirectCallCandidateNode(node: t.CallExpression): boolean { - return isSimpleDirectCallExpression(node) -} - -function getPotentialCandidateCallExpression( - node: t.Expression | null | undefined, -): t.CallExpression | null { - if (!node) { - return null - } - - const unwrapped = unwrapExpression(node) - return t.isCallExpression(unwrapped) ? unwrapped : null -} - -/** - * Checks if a CallExpression path is a top-level direct-call candidate. - * Top-level means the call is the init of a VariableDeclarator at program level. - * We accept any simple identifier call or namespace call at top level - * (e.g., `createServerOnlyFn()`, `TanStackStart.createServerOnlyFn()`) and let - * resolution verify it. This handles renamed imports. - */ -function isTopLevelDirectCallCandidate( - path: babel.NodePath<t.CallExpression>, -): boolean { - const node = path.node - - // Must be a simple identifier call or namespace call - if (!isSimpleDirectCallExpression(node)) { - return false - } - - // Must be top-level: VariableDeclarator -> VariableDeclaration -> Program - // or VariableDeclarator -> VariableDeclaration -> ExportNamedDeclaration -> Program. - const variableDeclarator = getVariableDeclaratorForExpressionPath( - path as babel.NodePath<t.Expression>, - ) - if (!variableDeclarator) { - return false - } - - const variableDeclaration = variableDeclarator.parentPath - if (!variableDeclaration.isVariableDeclaration()) { - return false - } - - const parent = variableDeclaration.parentPath - return ( - parent.isProgram() || - (parent.isExportNamedDeclaration() && parent.parentPath.isProgram()) - ) -} - function isDirectCallCandidateForKind( kind: Exclude<LookupKind, 'ClientOnlyJSX'>, externalLookupSetup?: Map<ExternalLookupKind, DirectCallSetup>, @@ -432,67 +320,18 @@ function isDirectCallCandidateForKind( return getLookupSetup(kind, externalLookupSetup)?.type === 'directCall' } -function hasBuiltInDirectCallKinds(kinds: Set<LookupKind>): boolean { - for (const kind of kinds) { - if (isExternalLookupKind(kind)) continue - if (BuiltInLookupSetup[kind].type === 'directCall') return true - } - return false -} - -function hasExternalLookupKinds(kinds: Set<LookupKind>): boolean { - for (const kind of kinds) { - if (isExternalLookupKind(kind)) return true - } - return false -} - -interface ExternalDirectCallCandidates { - identifiers: Map<string, ExternalLookupKind> - namespaces: Map<string, Map<string, ExternalLookupKind>> -} - -interface CallExpressionCandidate { - path: babel.NodePath<t.CallExpression> - /** Set when import scanning already proved the call's lookup kind. */ - kind?: Exclude<LookupKind, 'ClientOnlyJSX'> -} - -function hasExternalDirectCallCandidates( - candidates: ExternalDirectCallCandidates, +/** Whether `node` initializes a variable declared directly in the module body. */ +function isModuleLevelInitializer( + node: t.Node, + parentOf: (node: t.Node) => t.Node | null, ): boolean { - return candidates.identifiers.size > 0 || candidates.namespaces.size > 0 -} - -function getExternalDirectCallCandidateKind( - path: babel.NodePath<t.CallExpression>, - candidates: ExternalDirectCallCandidates, -): ExternalLookupKind | undefined { - const node = path.node - - if (t.isIdentifier(node.callee)) { - const kind = candidates.identifiers.get(node.callee.name) - if (!kind) return undefined - - const binding = path.scope.getBinding(node.callee.name) - return binding?.path.isImportSpecifier() ? kind : undefined - } - - if ( - t.isMemberExpression(node.callee) && - t.isIdentifier(node.callee.object) && - t.isIdentifier(node.callee.property) - ) { - const kind = candidates.namespaces - .get(node.callee.object.name) - ?.get(node.callee.property.name) - if (!kind) return undefined - - const binding = path.scope.getBinding(node.callee.object.name) - return binding?.path.isImportNamespaceSpecifier() ? kind : undefined - } - - return undefined + const declarator = getVariableDeclarator(node, parentOf) + const declaration = declarator && parentOf(declarator) + const parent = declaration && parentOf(declaration) + return ( + is.Program(parent) || + (is.ExportNamedDeclaration(parent) && is.Program(parentOf(parent))) + ) } export class StartCompiler { @@ -505,10 +344,6 @@ export class StartCompiler { >() private externalLookupSetup = new Map<ExternalLookupKind, DirectCallSetup>() private compilerPlugins: Array<StartCompilerPlugin> - private externalDirectCallKindsBySource = new Map< - string, - Map<string, ExternalLookupKind> - >() private resolveIdCache = new Map<string, string | null>() private exportResolutionCache = new Map< string, @@ -576,22 +411,15 @@ export class StartCompiler { for (const transform of options.compilerTransforms ?? []) { const kind = getExternalLookupKind(transform) - if (!this.validLookupKinds.has(kind)) continue + if (!this.validLookupKinds.has(kind)) { + continue + } this.externalTransformsByKind.set(kind, transform) const factoryNames = new Set<string>() for (const entry of transform.imports) { factoryNames.add(entry.rootExport) - - let rootExports = this.externalDirectCallKindsBySource.get( - entry.libName, - ) - if (!rootExports) { - rootExports = new Map() - this.externalDirectCallKindsBySource.set(entry.libName, rootExports) - } - rootExports.set(entry.rootExport, kind) } this.externalLookupSetup.set(kind, { @@ -676,46 +504,6 @@ export class StartCompiler { return this.options.mode ?? 'dev' } - private getExternalDirectCallCandidates( - kinds: Set<LookupKind>, - moduleInfo: ModuleInfo, - ): ExternalDirectCallCandidates { - const identifiers = new Map<string, ExternalLookupKind>() - const namespaces = new Map<string, Map<string, ExternalLookupKind>>() - - if (this.externalDirectCallKindsBySource.size === 0) { - return { identifiers, namespaces } - } - - for (const [localName, binding] of moduleInfo.bindings) { - if (binding.type !== 'import') continue - - const rootExports = this.externalDirectCallKindsBySource.get( - binding.source, - ) - if (!rootExports) continue - - if (binding.importedName === '*') { - const namespaceExports = new Map<string, ExternalLookupKind>() - for (const [rootExport, kind] of rootExports) { - if (kinds.has(kind)) { - namespaceExports.set(rootExport, kind) - } - } - if (namespaceExports.size > 0) { - namespaces.set(localName, namespaceExports) - } - } else { - const kind = rootExports.get(binding.importedName) - if (kind && kinds.has(kind)) { - identifiers.set(localName, kind) - } - } - } - - return { identifiers, namespaces } - } - private async resolveIdCached(id: string, importer?: string) { if (this.mode === 'dev') { return this.options.resolveId(id, importer) @@ -818,15 +606,12 @@ export class StartCompiler { /** * Extracts bindings and exports from an already-parsed AST. */ - private extractModuleInfo( - ast: ReturnType<typeof parseAst>, - id: string, - ): ModuleInfo { - const extracted = extractModuleInfoFromAst(ast) + private extractModuleInfo(module: Module, id: string): ModuleInfo { + const extracted = extractModuleInfo(module) const info: ModuleInfo = { id, - bindings: new Map(extracted.bindings), + bindings: extracted.bindings, exports: extracted.exports, reExportAllSources: extracted.reExportAllSources, } @@ -843,9 +628,12 @@ export class StartCompiler { id: string parserFilename?: string }) { - const ast = parseAst({ code, filename: parserFilename ?? cleanId(id) }) - const info = this.extractModuleInfo(ast, id) - return { info, ast } + const module = analyzeModule({ + code, + filename: parserFilename ?? cleanId(id), + }) + const info = this.extractModuleInfo(module, id) + return { info, module } } public invalidateModule(id: string) { @@ -937,10 +725,14 @@ export class StartCompiler { await Promise.all( Array.from(importSources, async (source) => { const resolved = await resolveSource(source, moduleInfo.id) - if (!resolved) return + if (!resolved) { + return + } const targetId = cleanId(resolved) - if (targetId === moduleId) return + if (targetId === moduleId) { + return + } let importers = importersByTarget.get(targetId) if (!importers) { @@ -988,463 +780,274 @@ export class StartCompiler { /** Pre-detected kinds present in this file. If not provided, all valid kinds are checked. */ detectedKinds?: Set<LookupKind> warn?: (message: string) => void - }) { + }): Promise<StartCompilerTransformResult | null> { if (!this.initialized) { await this.init() } - // Use detected kinds if provided, otherwise fall back to all valid kinds for this env const fileKinds = detectedKinds - ? new Set([...detectedKinds].filter((k) => this.validLookupKinds.has(k))) + ? new Set( + [...detectedKinds].filter((kind) => this.validLookupKinds.has(kind)), + ) : this.validLookupKinds - - const astTransformPlugins = this.getAstTransformPluginsForCode(code) - // Always parse and extract module info upfront. - // This ensures the module is cached for import resolution even if no candidates are found. - const ast = this.ingestModule({ code, id, parserFilename }).ast - const warnFn = warn ?? this.options.warn - let astHasChanges = false - - builtInTransforms: { - // Early exit if no built-in or import transforms need this file. - if (fileKinds.size === 0) { - break builtInTransforms + const candidateKinds = new Set(fileKinds) + if ( + fileKinds.has('ServerFn') && + ![...fileKinds].some(isExternalLookupKind) + ) { + for (const kind of this.validLookupKinds) { + if ( + !isExternalLookupKind(kind) && + getLookupSetup(kind)?.type === 'directCall' + ) { + candidateKinds.add(kind) + } } - - const hasExternalKinds = hasExternalLookupKinds(fileKinds) - const checkDirectCalls = - hasBuiltInDirectCallKinds(fileKinds) || - (fileKinds.has('ServerFn') && - !hasExternalKinds && - hasBuiltInDirectCallKinds(this.validLookupKinds)) - // Optimization: ServerFn is always a top-level declaration (must be assigned to a variable). - // If the file only has ServerFn, we can skip full AST traversal and only visit - // the specific top-level declarations that have candidates. - const canUseFastPath = areAllKindsTopLevelOnly(fileKinds) - - // Single-pass traversal to: - // 1. Collect candidate paths (only candidates, not all CallExpressions) - // 2. Build a map for looking up paths of nested calls in method chains - const candidatePaths: Array<CallExpressionCandidate> = [] - // Map for nested chain lookup - only populated for CallExpressions that are - // part of a method chain (callee.object is a CallExpression) - const chainCallPaths = new Map< - t.CallExpression, - babel.NodePath<t.CallExpression> - >() - - // JSX candidates (e.g., <ClientOnly>) - const jsxCandidatePaths: Array<babel.NodePath<t.JSXElement>> = [] - const checkJSX = needsJSXDetection(fileKinds, this.externalLookupSetup) - // Get module info that was just cached by ingestModule - const moduleInfo = this.moduleCache.get(id)! - const externalDirectCallCandidates = this.getExternalDirectCallCandidates( - fileKinds, - moduleInfo, - ) - const checkExternalDirectCalls = hasExternalDirectCallCandidates( - externalDirectCallCandidates, - ) - - if (canUseFastPath) { - // Fast path: only visit top-level statements that have potential candidates - - // Collect indices of top-level statements that contain candidates - const candidateIndices: Array<number> = [] - for (let i = 0; i < ast.program.body.length; i++) { - const node = ast.program.body[i]! - let declarations: Array<t.VariableDeclarator> | undefined - - if (t.isVariableDeclaration(node)) { - declarations = node.declarations - } else if (t.isExportNamedDeclaration(node) && node.declaration) { - if (t.isVariableDeclaration(node.declaration)) { - declarations = node.declaration.declarations - } - } - - if (declarations) { - for (const decl of declarations) { - const init = getPotentialCandidateCallExpression(decl.init) - if (init) { - if ( - isMethodChainCandidate(init, fileKinds) || - (checkDirectCalls && isTopLevelDirectCallCandidateNode(init)) - ) { - candidateIndices.push(i) - break // Only need to mark this statement once - } - } - } - } + } + const { info: sourceInfo, module } = this.ingestModule({ + code, + id, + parserFilename, + }) + const { program: ast, originalNodes } = cloneModuleAst(module) + const editor = createAstEditor(ast) + const context: CompilationContext = { + ast, + module, + originalNodes, + ...editor, + code, + id, + env: this.options.env, + envName: this.options.envName, + mode: this.mode, + root: this.options.root, + framework: this.options.framework, + providerEnvName: this.options.providerEnvName, + parseExpression, + warn: warn ?? this.options.warn, + generateFunctionId: (options) => this.generateFunctionId(options), + getKnownServerFns: this.options.getKnownServerFns, + serverFnProviderModuleDirectives: + this.options.serverFnProviderModuleDirectives, + onServerFnsById: this.options.onServerFnsById, + } + const calls: Array<{ node: t.CallExpression; kind?: ExternalLookupKind }> = + [] + const jsx: Array<t.JSXElement> = [] + walk(ast, { + CallExpression: (node, position) => { + // An inner link of a chain is compiled with the chain's last call, also + // when parentheses or a TypeScript assertion wrap it or its member + let member = position.parent + while (isWrapper(member)) { + member = editor.parentOf(member) } - - // Early exit: no potential candidates found at top level - if (candidateIndices.length === 0) { - break builtInTransforms + let caller = is.MemberExpression(member) + ? editor.parentOf(member) + : null + while (isWrapper(caller)) { + caller = editor.parentOf(caller) } - - // Targeted traversal: only visit the specific statements that have candidates - // This is much faster than traversing the entire AST - babel.traverse(ast, { - Program(programPath) { - const bodyPaths = programPath.get('body') - for (const idx of candidateIndices) { - const stmtPath = bodyPaths[idx] - if (!stmtPath) continue - - // Traverse only this statement's subtree - stmtPath.traverse({ - CallExpression(path) { - const node = path.node - const parent = path.parent - - // Check if this call is part of a larger chain (inner call) - if ( - t.isMemberExpression(parent) && - t.isCallExpression(path.parentPath.parent) - ) { - chainCallPaths.set(node, path) - return - } - - // Method chain pattern - if (isMethodChainCandidate(node, fileKinds)) { - candidatePaths.push({ path }) - return - } - - if (checkExternalDirectCalls) { - const kind = getExternalDirectCallCandidateKind( - path, - externalDirectCallCandidates, - ) - if (kind) { - candidatePaths.push({ path, kind }) - return - } - } - - if (isTopLevelDirectCallCandidate(path)) { - candidatePaths.push({ path }) - } - }, - }) - } - // Stop traversal after processing Program - programPath.stop() - }, - }) - } else { - // Normal path: full traversal for non-fast-path kinds - babel.traverse(ast, { - CallExpression: (path) => { - const node = path.node - const parent = path.parent - - // Check if this call is part of a larger chain (inner call) - // If so, store it for method chain lookup but don't treat as candidate - if ( - t.isMemberExpression(parent) && - t.isCallExpression(path.parentPath.parent) - ) { - // This is an inner call in a chain - store for later lookup - chainCallPaths.set(node, path) - return - } - - // Pattern 1: Method chain pattern (.handler(), .server(), .client(), etc.) - if (isMethodChainCandidate(node, fileKinds)) { - candidatePaths.push({ path }) - return - } - - // External direct-call transforms are import-bound. Direct imports - // already identify the transform kind, so skip async import tracing. - if (checkExternalDirectCalls) { - const kind = getExternalDirectCallCandidateKind( - path, - externalDirectCallCandidates, - ) - if (kind) { - candidatePaths.push({ path, kind }) - return - } - } - - if (checkDirectCalls && isTopLevelDirectCallCandidate(path)) { - candidatePaths.push({ path }) - return - } - - // Pattern 2: Direct call pattern - if (checkDirectCalls) { - if ( - isNestedDirectCallCandidate( - node, - fileKinds, - this.externalLookupSetup, - ) - ) { - candidatePaths.push({ path }) - return - } - } - }, - // Pattern 3: JSX element pattern (e.g., <ClientOnly>) - // Collect JSX elements where the component is imported from a known package - // and resolves to a JSX kind (e.g., ClientOnly from @tanstack/react-router) - JSXElement: (path) => { - if (!checkJSX) return - - const openingElement = path.node.openingElement - const nameNode = openingElement.name - - // Only handle simple identifier names (not namespaced or member expressions) - if (!t.isJSXIdentifier(nameNode)) return - - const componentName = nameNode.name - const binding = moduleInfo.bindings.get(componentName) - - // Must be an import binding from a known package - if (!binding || binding.type !== 'import') return - - // Verify the import source is a known TanStack router package - const knownExports = this.knownRootImports.get(binding.source) - if (!knownExports) return - - // Verify the imported name resolves to a JSX kind (e.g., ClientOnlyJSX) - const kind = knownExports.get(binding.importedName) - if (kind !== 'ClientOnlyJSX') return - - jsxCandidatePaths.push(path) - }, - }) - } - - if (candidatePaths.length === 0 && jsxCandidatePaths.length === 0) { - break builtInTransforms - } - - // Resolve only candidates whose import scan did not already prove the kind. - const resolvedCandidates: Array<{ - path: babel.NodePath<t.CallExpression> - kind: Kind - }> = [] - const unresolvedCandidates: Array<CallExpressionCandidate> = [] - - for (const candidate of candidatePaths) { - if (candidate.kind) { - resolvedCandidates.push({ - path: candidate.path, - kind: candidate.kind, - }) - } else { - unresolvedCandidates.push(candidate) + if (is.CallExpression(caller)) { + return } - } - - if (unresolvedCandidates.length > 0) { - resolvedCandidates.push( - ...(await Promise.all( - unresolvedCandidates.map(async (candidate) => ({ - path: candidate.path, - kind: await this.resolveExprKind(candidate.path.node, id), - })), - )), - ) - } - - // Filter to valid candidates - const validCandidates = resolvedCandidates.filter(({ path, kind }) => { + if (isMethodChainCandidate(node, fileKinds)) { + calls.push({ node }) + return + } + const callee = unwrapExpression(node.callee) + const name = is.Identifier(callee) + ? callee.name + : is.MemberExpression(callee) && is.Identifier(callee.property) + ? callee.property.name + : null + if (!name) { + return + } + const receiver = is.MemberExpression(callee) + ? unwrapExpression(callee.object) + : null + const simpleDirectCall = + is.Identifier(callee) || is.Identifier(receiver) + // External transforms are bound to configured imports. Their known kind + // must take precedence over generic built-in alias tracing. + const root = is.Identifier(callee) + ? callee + : is.Identifier(receiver) + ? receiver + : null + const binding = root && module.bindingOf(originalNodes.get(root)!) + const moduleBinding = binding && sourceInfo.bindings.get(binding.name) if ( - !this.validLookupKinds.has( - kind as Exclude<LookupKind, 'ClientOnlyJSX'>, - ) + moduleBinding?.type === 'import' && + binding?.scope === module.rootScope && + (moduleBinding.importedName === '*' + ? is.MemberExpression(callee) && !callee.computed + : is.Identifier(callee)) ) { - return false + const kind = this.knownRootImports + .get(moduleBinding.source) + ?.get( + moduleBinding.importedName === '*' + ? name + : moduleBinding.importedName, + ) + if (kind && isExternalLookupKind(kind) && candidateKinds.has(kind)) { + calls.push({ node, kind }) + return + } } - + for (const kind of candidateKinds) { + if (isExternalLookupKind(kind)) { + continue + } + const setup = getLookupSetup(kind) + if (setup?.type !== 'directCall') { + continue + } + if ( + setup.factoryNames.has(name) || + (simpleDirectCall && + isModuleLevelInitializer(node, editor.parentOf)) + ) { + calls.push({ node }) + return + } + } + }, + JSXElement: (node) => { if ( - isLookupKind(kind) && - kind !== 'ClientOnlyJSX' && - !isMethodChainCandidate(path.node, fileKinds) + !fileKinds.has('ClientOnlyJSX') || + !is.JSXIdentifier(node.openingElement.name) ) { - return isDirectCallCandidateForKind(kind, this.externalLookupSetup) + return } - - return true - }) as Array<{ - path: babel.NodePath<t.CallExpression> - kind: Exclude<LookupKind, 'ClientOnlyJSX'> - }> - - if (validCandidates.length === 0 && jsxCandidatePaths.length === 0) { - break builtInTransforms - } - - // Process valid candidates to collect method chains - const pathsToRewrite: Array<{ - path: babel.NodePath<t.CallExpression> - kind: Exclude<LookupKind, 'ClientOnlyJSX'> - methodChain: MethodChainPaths - }> = [] - - for (const { path, kind } of validCandidates) { - const node = path.node - - // Collect method chain paths by walking DOWN from root through the chain - const methodChain: MethodChainPaths = { - middleware: null, - validator: null, - handler: null, - server: null, - client: null, + const original = originalNodes.get(node.openingElement.name)! + const binding = module.bindingOf(original) + if (!binding || binding.scope !== module.rootScope) { + return } - - // Walk down the call chain using nodes, look up paths from map - let currentNode: t.CallExpression = node - let currentPath: babel.NodePath<t.CallExpression> = path - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - const callee = currentNode.callee - if (!t.isMemberExpression(callee)) { - break - } - - // Record method chain path if it's a known method - if (t.isIdentifier(callee.property)) { - const name = callee.property.name as keyof MethodChainPaths - if (name in methodChain) { - // Get first argument path - const args = currentPath.get('arguments') - const firstArgPath = - Array.isArray(args) && args.length > 0 - ? (args[0] ?? null) - : null - methodChain[name] = { - callPath: currentPath, - firstArgPath, - } - } - } - - // Move to the inner call (the object of the member expression) - if (!t.isCallExpression(callee.object)) { + const moduleBinding = sourceInfo.bindings.get(binding.name) + if ( + moduleBinding?.type === 'import' && + this.knownRootImports + .get(moduleBinding.source) + ?.get(moduleBinding.importedName) === 'ClientOnlyJSX' + ) { + jsx.push(node) + } + }, + }) + const candidatesByKind = new Map< + Exclude<LookupKind, 'ClientOnlyJSX'>, + Array<RewriteCandidate> + >() + const resolved = await Promise.all( + calls.map(async ({ node, kind }) => { + if (kind) { + return { node, kind } + } + // Only module bindings can participate in the cross-module builder graph. + // Local shadowing is decided by Yuku's resolved references before tracing. + let base: t.Expression = node + for (;;) { + base = unwrapExpression(base) + if (is.CallExpression(base)) { + base = base.callee + } else if (is.MemberExpression(base)) { + base = base.object + } else { break } - currentNode = callee.object - // Look up path from chain map, or use candidate path if not found - const nextPath = chainCallPaths.get(currentNode) - if (!nextPath) { - break + } + if (is.Identifier(base)) { + const binding = module.bindingOf(originalNodes.get(base)!) + if (!binding || binding.scope !== module.rootScope) { + return { node, kind: 'None' as Kind } } - currentPath = nextPath } - - pathsToRewrite.push({ path, kind, methodChain }) + return { node, kind: await this.resolveExprKind(node, id) } + }), + ) + for (const { node, kind } of resolved) { + if ( + !isLookupKind(kind) || + kind === 'ClientOnlyJSX' || + !this.validLookupKinds.has(kind) + ) { + continue } - - const refIdents = findReferencedIdentifiers(ast) - - const context: CompilationContext = { - ast, - id, - code, - env: this.options.env, - envName: this.options.envName, - mode: this.mode, - root: this.options.root, - framework: this.options.framework, - providerEnvName: this.options.providerEnvName, - types: t, - parseExpression: (expressionCode) => - babel.template.expression(expressionCode, { - placeholderPattern: false, - })(), - warn: warnFn, - - generateFunctionId: (opts) => this.generateFunctionId(opts), - getKnownServerFns: this.options.getKnownServerFns, - serverFnProviderModuleDirectives: - this.options.serverFnProviderModuleDirectives, - onServerFnsById: this.options.onServerFnsById, + if ( + !isMethodChainCandidate(node, fileKinds) && + !isDirectCallCandidateForKind(kind, this.externalLookupSetup) + ) { + continue } - - // Group candidates by kind for batch processing - const candidatesByKind = new Map< - Exclude<LookupKind, 'ClientOnlyJSX'>, - Array<RewriteCandidate> - >() - - for (const { path: candidatePath, kind, methodChain } of pathsToRewrite) { - const candidate: RewriteCandidate = { path: candidatePath, methodChain } - const existing = candidatesByKind.get(kind) - if (existing) { - existing.push(candidate) - } else { - candidatesByKind.set(kind, [candidate]) + const methodChain: MethodChain = { + middleware: null, + validator: null, + handler: null, + server: null, + client: null, + } + let current = node + for (;;) { + const callee = unwrapExpression(current.callee) + if (!is.MemberExpression(callee)) { + break + } + if ( + is.Identifier(callee.property) && + callee.property.name in methodChain + ) { + methodChain[callee.property.name as keyof MethodChain] = { + call: current, + firstArg: current.arguments[0] ?? null, + } + } + const object = unwrapExpression(callee.object) + if (!is.CallExpression(object)) { + break } + current = object } - - // External transforms run before built-ins by default so they can augment - // user handlers before server function extraction clones provider bodies. - this.runExternalTransforms('pre', candidatesByKind, context) - + const candidates = candidatesByKind.get(kind) ?? [] + candidates.push({ node, methodChain }) + candidatesByKind.set(kind, candidates) + } + let modified = candidatesByKind.size > 0 || jsx.length > 0 + if (modified) { + // A candidate inside code an earlier handler replaced (e.g. a server fn + // handler a caller replaced with its RPC) is no longer in the output. + const liveCandidates = (kind: Exclude<LookupKind, 'ClientOnlyJSX'>) => + candidatesByKind + .get(kind) + ?.filter(({ node }) => !editor.isReplaced(node)) + this.runExternalTransforms('pre', liveCandidates, context) for (const kind of BuiltInKindHandlerOrder) { - const candidates = candidatesByKind.get(kind) - if (!candidates) continue - const handler = BuiltInKindHandlers[kind] - handler(candidates, context, kind) + const candidates = liveCandidates(kind) + if (candidates?.length) { + BuiltInKindHandlers[kind](candidates, context, kind) + } } - - this.runExternalTransforms('post', candidatesByKind, context) - - // Handle JSX candidates (e.g., <ClientOnly>) - // Validation was already done during traversal - just call the handler - for (const jsxPath of jsxCandidatePaths) { - handleClientOnlyJSX(jsxPath, { env: 'server' }) + this.runExternalTransforms('post', liveCandidates, context) + for (const element of jsx) { + handleClientOnlyJSX(element, { env: 'server' }) } - - deadCodeElimination(ast, refIdents) - astHasChanges = true - } - - if (astTransformPlugins.length > 0) { - astHasChanges = - this.runAstTransforms({ - ast, - code, - id, - transforms: astTransformPlugins, - warn: warnFn, - }) || astHasChanges } - - return astHasChanges ? this.generateResultFromAst(ast, code, id) : null - } - - private generateResultFromAst( - ast: ParsedAst, - sourceCode: string, - id: string, - ): StartCompilerTransformResult { - const result = generateFromAst(ast, { - sourceMaps: true, - sourceFileName: id, - filename: id, - }) - - // @babel/generator does not populate sourcesContent because it only has - // the AST, not the original text. Without this, Vite's composed sourcemap - // omits the original source, causing downstream consumers to fall back to - // the compiled output and fail to resolve original line numbers. - if (result.map) { - result.map.sourcesContent = [sourceCode] + modified = + this.runAstTransforms( + context, + this.getAstTransformPluginsForCode(code), + ) || modified + if (!modified) { + return null } - - return result + // One liveness pass after every transform: AST plugins also orphan bindings. + removeUnusedBindings(module, ast, originalNodes) + keepFilePragmas(module.ast, ast) + return generateModule(ast, { source: code, filename: id }) } private getAstTransformPluginsForCode( @@ -1452,67 +1055,45 @@ export class StartCompiler { ): Array<StartCompilerAstPlugin> { return this.compilerPlugins.filter( (plugin): plugin is StartCompilerAstPlugin => { - if (!plugin.transformAst) return false - if (!plugin.detect) return true + if (!plugin.transformAst) { + return false + } + if (!plugin.detect) { + return true + } plugin.detect.lastIndex = 0 return plugin.detect.test(code) }, ) } - private runAstTransforms({ - ast, - code, - id, - transforms, - warn, - }: { - ast: ParsedAst - code: string - id: string - transforms: Array<StartCompilerAstPlugin> - warn?: (message: string) => void - }): boolean { + private runAstTransforms( + context: StartCompilerTransformContext, + transforms: Array<StartCompilerAstPlugin>, + ): boolean { let modified = false - for (const plugin of transforms) { - const context = { - ast, - code, - id, - env: this.options.env, - envName: this.options.envName, - mode: this.mode, - root: this.options.root, - framework: this.options.framework, - providerEnvName: this.options.providerEnvName, - types: t, - parseExpression: (expressionCode: string) => - babel.template.expression(expressionCode, { - placeholderPattern: false, - })(), - warn, - } - modified = plugin.transformAst(context) || modified } - return modified } private runExternalTransforms( order: 'pre' | 'post', - candidatesByKind: Map< - Exclude<LookupKind, 'ClientOnlyJSX'>, - Array<RewriteCandidate> - >, + getCandidates: ( + kind: Exclude<LookupKind, 'ClientOnlyJSX'>, + ) => Array<RewriteCandidate> | undefined, context: CompilationContext, ) { for (const [kind, transform] of this.externalTransformsByKind) { - if ((transform.order ?? 'pre') !== order) continue + if ((transform.order ?? 'pre') !== order) { + continue + } - const candidates = candidatesByKind.get(kind) - if (!candidates) continue + const candidates = getCandidates(kind) + if (!candidates?.length) { + continue + } transform.transform(candidates, context) } @@ -1663,7 +1244,7 @@ export class StartCompiler { } private async resolveKnownImportKind( - binding: ImportBinding, + binding: CompilerImportBinding, resolved?: ExportResolution, ): Promise<Kind> { const directKind = @@ -1721,7 +1302,7 @@ export class StartCompiler { } private async resolveImportKind( - binding: ImportBinding, + binding: CompilerImportBinding, fileId: string, visited: Set<string>, ): Promise<Kind> { @@ -1778,7 +1359,7 @@ export class StartCompiler { } private async resolveBindingKind( - binding: Binding, + binding: CompilerBinding, fileId: string, visited = new Set<string>(), ): Promise<Kind> { @@ -1805,7 +1386,7 @@ export class StartCompiler { getLookupSetup(resolvedKind, this.externalLookupSetup)?.type === 'directCall' && binding.init && - t.isCallExpression(unwrapExpression(binding.init)) + is.CallExpression(unwrapExpression(binding.init)) ) { binding.resolvedKind = 'None' return 'None' @@ -1815,11 +1396,11 @@ export class StartCompiler { } private async resolveExprKind( - expr: t.Expression | null, + expr: t.Expression | t.Super | null, fileId: string, visited = new Set<string>(), ): Promise<Kind> { - if (!expr) { + if (!expr || is.Super(expr)) { return 'None' } @@ -1827,21 +1408,15 @@ export class StartCompiler { let result: Kind = 'None' - if (t.isCallExpression(expr)) { - if (!t.isExpression(expr.callee)) { - return 'None' - } - const calleeKind = await this.resolveCalleeKind( - expr.callee, - fileId, - visited, - ) + if (is.CallExpression(expr)) { + const callee = unwrapExpression(expr.callee) + const calleeKind = await this.resolveCalleeKind(callee, fileId, visited) if (calleeKind === 'Root' || calleeKind === 'Builder') { return 'Builder' } // For method chain patterns (callee is MemberExpression like .server() or .client()), // return the resolved kind if valid - if (t.isMemberExpression(expr.callee)) { + if (is.MemberExpression(callee)) { if (this.validLookupKinds.has(calleeKind as LookupKind)) { return calleeKind } @@ -1852,16 +1427,16 @@ export class StartCompiler { // @tanstack/start-fn-stubs (via fast path or slow path through re-exports). // This handles both direct imports from @tanstack/react-start and imports // from intermediate packages that re-export from @tanstack/start-client-core. - if (t.isIdentifier(expr.callee)) { + if (is.Identifier(callee)) { if (this.validLookupKinds.has(calleeKind as LookupKind)) { return calleeKind } } - } else if (t.isMemberExpression(expr) && t.isIdentifier(expr.property)) { + } else if (is.MemberExpression(expr) && is.Identifier(expr.property)) { result = await this.resolveCalleeKind(expr.object, fileId, visited) } - if (result === 'None' && t.isIdentifier(expr)) { + if (result === 'None' && is.Identifier(expr)) { result = await this.resolveIdentifierKind(expr.name, fileId, visited) } @@ -1869,15 +1444,16 @@ export class StartCompiler { } private async resolveCalleeKind( - callee: t.Expression, + callee: t.Expression | t.Super, fileId: string, visited = new Set<string>(), ): Promise<Kind> { - if (t.isIdentifier(callee)) { + callee = unwrapExpression(callee) + if (is.Identifier(callee)) { return this.resolveIdentifierKind(callee.name, fileId, visited) } - if (t.isMemberExpression(callee) && t.isIdentifier(callee.property)) { + if (is.MemberExpression(callee) && is.Identifier(callee.property)) { const prop = callee.property.name // Check if this property matches any method chain pattern @@ -1888,7 +1464,9 @@ export class StartCompiler { // Check each possible kind that uses this identifier for (const kind of possibleKinds) { - if (!this.validLookupKinds.has(kind)) continue + if (!this.validLookupKinds.has(kind)) { + continue + } if (kind === 'ServerFn') { if (base === 'Root' || base === 'Builder') { @@ -1915,9 +1493,10 @@ export class StartCompiler { } // Check if the object is a namespace import - if (t.isIdentifier(callee.object)) { + const receiver = unwrapExpression(callee.object) + if (is.Identifier(receiver)) { const info = await this.getModuleInfo(fileId) - const binding = info.bindings.get(callee.object.name) + const binding = info.bindings.get(receiver.name) if ( binding && binding.type === 'import' && @@ -1965,8 +1544,8 @@ function isMethodChainCandidate( node: t.CallExpression, lookupKinds: Set<LookupKind>, ): boolean { - const callee = node.callee - if (!t.isMemberExpression(callee) || !t.isIdentifier(callee.property)) { + const callee = unwrapExpression(node.callee) + if (!is.MemberExpression(callee) || !is.Identifier(callee.property)) { return false } diff --git a/packages/start-plugin-core/src/start-compiler/handleClientOnlyJSX.ts b/packages/start-plugin-core/src/start-compiler/handleClientOnlyJSX.ts index 9676e4b7bd2..c98d9c614d4 100644 --- a/packages/start-plugin-core/src/start-compiler/handleClientOnlyJSX.ts +++ b/packages/start-plugin-core/src/start-compiler/handleClientOnlyJSX.ts @@ -1,5 +1,4 @@ -import type * as t from '@babel/types' -import type * as babel from '@babel/core' +import type { JSXElement } from '@yuku-toolchain/types' /** * Handles <ClientOnly> JSX elements on the server side. @@ -18,11 +17,9 @@ import type * as babel from '@babel/core' * <ClientOnly /> */ export function handleClientOnlyJSX( - path: babel.NodePath<t.JSXElement>, + element: JSXElement, _opts: { env: 'server' }, ): void { - const element = path.node - // Remove all children - they are client-only code element.children = [] diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateIsomorphicFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateIsomorphicFn.ts index 8331a008491..e9da1667eaf 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateIsomorphicFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateIsomorphicFn.ts @@ -1,38 +1,23 @@ -import * as t from '@babel/types' +import { is } from 'yuku-ast' +import { parseExpression } from '@tanstack/router-utils' import type { CompilationContext, RewriteCandidate } from './types' -/** - * Handles createIsomorphicFn transformations for a batch of candidates. - * - * @param candidates - All IsomorphicFn candidates to process - * @param context - The compilation context - */ export function handleCreateIsomorphicFn( candidates: Array<RewriteCandidate>, context: CompilationContext, ): void { - for (const candidate of candidates) { - const { path, methodChain } = candidate - - // Get the environment-specific call (.client() or .server()) - const envCallInfo = + for (const { node, methodChain } of candidates) { + const selected = context.env === 'client' ? methodChain.client : methodChain.server - - if (!envCallInfo) { - // No implementation for this environment - replace with no-op - path.replaceWith(t.arrowFunctionExpression([], t.blockStatement([]))) + if (!selected) { + context.replaceNode(node, parseExpression('() => {}')) continue } - - // Extract the function argument from the environment-specific call - const innerFn = envCallInfo.firstArgPath?.node - - if (!t.isExpression(innerFn)) { + if (!is.Expression(selected.firstArg)) { throw new Error( `createIsomorphicFn().${context.env}(func) must be called with a function!`, ) } - - path.replaceWith(innerFn) + context.replaceNode(node, selected.firstArg) } } diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateMiddleware.ts b/packages/start-plugin-core/src/start-compiler/handleCreateMiddleware.ts index 22575ccc507..cfad8d38853 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateMiddleware.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateMiddleware.ts @@ -19,7 +19,7 @@ export function handleCreateMiddleware( const { validator, server } = candidate.methodChain if (validator) { - const innerInputExpression = validator.callPath.node.arguments[0] + const innerInputExpression = validator.call.arguments[0] if (!innerInputExpression) { throw new Error( @@ -28,12 +28,12 @@ export function handleCreateMiddleware( } // remove the validator call expression - stripMethodCall(validator.callPath) + stripMethodCall(validator.call, context) } if (server) { // remove the server call expression - stripMethodCall(server.callPath) + stripMethodCall(server.call, context) } } } diff --git a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts index dd441bcc243..ec0cfbf5a8e 100644 --- a/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler/handleCreateServerFn.ts @@ -1,193 +1,26 @@ -import * as t from '@babel/types' -import babel from '@babel/core' -import { hasKeys } from '@tanstack/router-core' -import { getVariableDeclaratorForExpressionPath } from '@tanstack/router-utils' +import { b, is } from 'yuku-ast' +import { + parseExpression, + parseStatements, + prependStatements, + unwrapExport, +} from '@tanstack/router-utils' import path from 'pathe' -import { cleanId, codeFrameError, stripMethodCall } from './utils' +import { + cleanId, + codeFrameError, + getVariableDeclarator, + stripMethodCall, +} from './utils' +import type { + Program, + ProgramStatement, + VariableDeclaration, +} from '@yuku-toolchain/types' import type { CompilationContext, RewriteCandidate, ServerFn } from './types' -import type { CompileStartFrameworkOptions } from '../types' const TSS_SERVERFN_SPLIT_PARAM = 'tss-serverfn-split' -const providerHmrAcceptTemplate = babel.template.statements( - ` -if (import.meta.hot) { - import.meta.hot.accept(() => {}) -} -if (import.meta.webpackHot) { - import.meta.webpackHot.accept(() => {}) -} -`, - { - placeholderPattern: false, - }, -) - -// ============================================================================ -// Pre-compiled babel templates (compiled once at module load time) -// ============================================================================ - -// Template for provider files: createServerRpc(serverFnMeta, fn) -const serverRpcTemplate = babel.template.expression( - `createServerRpc(%%serverFnMeta%%, %%fn%%)`, -) - -// Template for client caller files: createClientRpc(functionId) -const clientRpcTemplate = babel.template.expression( - `createClientRpc(%%functionId%%)`, -) - -// Template for SSR caller files: createSsrRpc(functionId) -const ssrRpcManifestTemplate = babel.template.expression( - `createSsrRpc(%%functionId%%)`, -) - -// ============================================================================ -// Runtime code cache (cached per framework to avoid repeated AST generation) -// ============================================================================ - -type RuntimeCodeType = 'provider' | 'client' | 'ssr' -type FrameworkRuntimeCache = Record<RuntimeCodeType, t.Statement> -const RuntimeCodeCache = new Map< - CompileStartFrameworkOptions, - FrameworkRuntimeCache ->() - -function getCachedRuntimeCode( - framework: CompileStartFrameworkOptions, - type: RuntimeCodeType, -): t.Statement { - let cache = RuntimeCodeCache.get(framework) - if (!cache) { - cache = { - provider: babel.template.ast( - `import { createServerRpc } from '@tanstack/${framework}-start/server-rpc'`, - { placeholderPattern: false }, - ) as t.Statement, - client: babel.template.ast( - `import { createClientRpc } from '@tanstack/${framework}-start/client-rpc'`, - { placeholderPattern: false }, - ) as t.Statement, - ssr: babel.template.ast( - `import { createSsrRpc } from '@tanstack/${framework}-start/ssr-rpc'`, - { placeholderPattern: false }, - ) as t.Statement, - } - RuntimeCodeCache.set(framework, cache) - } - return cache[type] -} - -/** - * Environment-specific configuration for server function transformation. - * This is computed internally based on the compilation context. - */ -interface EnvConfig { - /** Whether this environment is a client environment */ - isClientEnvironment: boolean - /** The runtime code type to use for imports */ - runtimeCodeType: RuntimeCodeType -} - -/** - * Gets the environment configuration for the current compilation context. - */ -function getEnvConfig( - context: CompilationContext, - isProviderFile: boolean, -): EnvConfig { - const { env } = context - - if (isProviderFile) { - return { - isClientEnvironment: false, - runtimeCodeType: 'provider', - } - } - - if (env === 'client') { - return { - isClientEnvironment: true, - runtimeCodeType: 'client', - } - } - - // Server caller (SSR) - return { - isClientEnvironment: false, - runtimeCodeType: 'ssr', - } -} - -/** - * Builds the serverFnMeta object literal AST node. - * The object contains: { id, name, filename } - */ -function buildServerFnMetaObject( - functionId: string, - variableName: string, - filename: string, -): t.ObjectExpression { - return t.objectExpression([ - t.objectProperty(t.identifier('id'), t.stringLiteral(functionId)), - t.objectProperty(t.identifier('name'), t.stringLiteral(variableName)), - t.objectProperty(t.identifier('filename'), t.stringLiteral(filename)), - ]) -} - -/** - * Generates the RPC stub expression for provider files. - * Uses pre-compiled template for performance. - */ -function generateProviderRpcStub( - serverFnMeta: t.ObjectExpression, - fn: t.Expression, -): t.Expression { - return serverRpcTemplate({ - serverFnMeta, - fn, - }) -} - -/** - * Generates the RPC stub expression for caller files. - * Uses pre-compiled templates for performance. - * Note: Client and SSR callers only receive the functionId string, not the full metadata. - */ -function generateCallerRpcStub( - functionId: string, - envConfig: EnvConfig, -): t.Expression { - const functionIdLiteral = t.stringLiteral(functionId) - - if (envConfig.runtimeCodeType === 'client') { - return clientRpcTemplate({ - functionId: functionIdLiteral, - }) - } - - return ssrRpcManifestTemplate({ - functionId: functionIdLiteral, - }) -} - -/** - * Handles createServerFn transformations for a batch of candidates. - * - * This function performs extraction and replacement of server functions - * - * For caller files (non-provider): - * - Replaces the server function with an RPC stub - * - Does not include the handler function body - * - * For provider files: - * - Creates an extractedFn that calls __executeServer - * - Modifies .handler() to pass (extractedFn, serverFn) - two arguments - * - * @param candidates - All ServerFn candidates to process - * @param context - The compilation context with helpers and mutable state - * @returns Result containing runtime code to add, or null if no candidates processed - */ export function handleCreateServerFn( candidates: Array<RewriteCandidate>, context: CompilationContext, @@ -195,337 +28,206 @@ export function handleCreateServerFn( if (candidates.length === 0) { return } - - const isProviderFile = context.id.includes(TSS_SERVERFN_SPLIT_PARAM) - if (isProviderFile && context.serverFnProviderModuleDirectives) { - ensureDirectivePrologue( - context.ast, - context.serverFnProviderModuleDirectives, - ) - } - - // Get environment-specific configuration - const envConfig = getEnvConfig(context, isProviderFile) - - // Track function names to ensure uniqueness within this file - const functionNameSet = new Set<string>() - + const isProvider = context.id.includes(TSS_SERVERFN_SPLIT_PARAM) + const runtime = isProvider + ? 'provider' + : context.env === 'client' + ? 'client' + : 'ssr' + const runtimeName = + runtime === 'provider' + ? 'createServerRpc' + : runtime === 'client' + ? 'createClientRpc' + : 'createSsrRpc' + const functionNames = new Set<string>() const exportNames = new Set<string>() const serverFnsById: Record<string, ServerFn> = {} - const [baseFilename] = context.id.split('?') as [string] const extractedFilename = `${baseFilename}?${TSS_SERVERFN_SPLIT_PARAM}` const relativeFilename = path.relative(context.root, baseFilename) const knownFns = context.getKnownServerFns() - const cleanedContextId = cleanId(context.id) - for (const candidate of candidates) { - const { path: candidatePath, methodChain } = candidate - const { validator, handler } = methodChain - - const candidateVariableDeclarator = getVariableDeclaratorForExpressionPath( - candidatePath as babel.NodePath<t.Expression>, - ) - - // Check if the call is assigned to a variable - if (!candidateVariableDeclarator) { - throw new Error('createServerFn must be assigned to a variable!') + for (const { node, methodChain } of candidates) { + const declarator = getVariableDeclarator(node, context.parentOf) + if (!declarator) { + throw codeFrameError( + context.code, + node, + 'createServerFn must be assigned to a variable!', + ) } - - // Get the identifier name of the variable - const variableDeclarator = candidateVariableDeclarator.node - if (!t.isIdentifier(variableDeclarator.id)) { + if (!is.Identifier(declarator.id)) { throw codeFrameError( context.code, - variableDeclarator.id.loc!, + declarator.id, 'createServerFn must be assigned to a simple identifier, not a destructuring pattern', ) } - const existingVariableName = variableDeclarator.id.name - - // Generate unique function name with _createServerFn_handler suffix - // The function name is derived from the variable name - let functionName = `${existingVariableName}_createServerFn_handler` - while (functionNameSet.has(functionName)) { + // The provider module exports the extracted handler next to the + // declaration, so only module-level declarations can be split. + const declaration = context.parentOf(declarator) as VariableDeclaration + const parent = context.parentOf(declaration) + const statement = is.ExportNamedDeclaration(parent) ? parent : declaration + if (!is.Program(context.parentOf(statement))) { + throw codeFrameError( + context.code, + declarator, + 'createServerFn must be assigned to a top-level variable, not declared inside a function or block!', + ) + } + const variableName = declarator.id.name + let functionName = `${variableName}_createServerFn_handler` + while (functionNames.has(functionName)) { functionName = incrementFunctionNameVersion(functionName) } - functionNameSet.add(functionName) - - // Generate function ID using pre-computed relative filename + functionNames.add(functionName) const functionId = context.generateFunctionId({ filename: relativeFilename, functionName, extractedFilename, }) - - // Check if this function was already discovered by the client build const knownFn = knownFns[functionId] - // A server function is client-referenced when: - // 1. We're in the client (browser) environment, OR - // 2. It was already discovered by another environment (knownFn), OR - // 3. We're in an SSR caller environment — any server function reachable from - // SSR module graph is callable via client navigation HTTP requests - const isClientReferenced = - envConfig.isClientEnvironment || - !!knownFn || - envConfig.runtimeCodeType === 'ssr' - - // Use canonical extracted filename from known functions if available - const canonicalExtractedFilename = - knownFn?.extractedFilename ?? extractedFilename - - // Handle validators - remove on client + const validator = methodChain.validator if (validator) { - const innerInputExpression = validator.callPath.node.arguments[0] - - if (!innerInputExpression) { + if (!validator.call.arguments[0]) { throw new Error( 'createServerFn().validator() must be called with a validator!', ) } - - // If we're on the client, remove the validator call expression if (context.env === 'client') { - stripMethodCall(validator.callPath) + stripMethodCall(validator.call, context) } } - - const handlerFnPath = handler?.firstArgPath - - if (!handler || !handlerFnPath?.node) { + const handler = methodChain.handler + if (!handler?.firstArg) { throw codeFrameError( context.code, - candidatePath.node.callee.loc!, - `createServerFn must be called with a "handler" property!`, + node.callee, + 'createServerFn must be called with a "handler" property!', ) } - - // Validate the handler argument is an expression (not a SpreadElement, etc.) - if (!t.isExpression(handlerFnPath.node)) { + if (!is.Expression(handler.firstArg)) { throw codeFrameError( context.code, - handlerFnPath.node.loc!, - `handler() must be called with an expression, not a ${handlerFnPath.node.type}`, + handler.firstArg, + `handler() must be called with an expression, not a ${handler.firstArg.type}`, ) } - - const handlerFn = handlerFnPath.node - - // Register function only from caller files (not provider files) - // to avoid duplicates - provider files process the same functions - - if (!isProviderFile) { + if (!isProvider) { serverFnsById[functionId] = { functionName, functionId, - filename: cleanedContextId, - extractedFilename: canonicalExtractedFilename, - isClientReferenced, + filename: cleanId(context.id), + extractedFilename: knownFn?.extractedFilename ?? extractedFilename, + isClientReferenced: + context.env === 'client' || !!knownFn || runtime === 'ssr', } - } - - if (isProviderFile) { - // PROVIDER FILE: This is the extracted file that contains the actual implementation - // We need to: - // 1. Create an extractedFn that calls __executeServer - // 2. Modify .handler() to pass (extractedFn, serverFn) - two arguments - // - // Expected output format: - // const extractedFn = createServerRpc({id, name, filename}, (opts) => varName.__executeServer(opts)); - // const varName = createServerFn().handler(extractedFn, originalHandler); - - // Build the arrow function: (opts) => varName.__executeServer(opts) - // The signal parameter is passed through to allow abort signal propagation - const executeServerArrowFn = t.arrowFunctionExpression( - [t.identifier('opts')], - t.callExpression( - t.memberExpression( - t.identifier(existingVariableName), - t.identifier('__executeServer'), - ), - [t.identifier('opts')], - ), + context.replaceNode( + handler.firstArg, + parseExpression(`${runtimeName}(${JSON.stringify(functionId)})`), ) - - // Build the serverFnMeta object - const serverFnMeta = buildServerFnMetaObject( - functionId, - existingVariableName, - relativeFilename, - ) - - // Generate the replacement using pre-compiled template - const extractedFnInit = generateProviderRpcStub( - serverFnMeta, - executeServerArrowFn, - ) - - // Build the extracted function statement - const extractedFnStatement = t.variableDeclaration('const', [ - t.variableDeclarator(t.identifier(functionName), extractedFnInit), - ]) - - // Find the variable declaration statement containing our createServerFn - const variableDeclaration = candidateVariableDeclarator.parentPath - if (!variableDeclaration.isVariableDeclaration()) { - throw new Error( - 'Expected createServerFn to be in a VariableDeclaration', - ) - } - - // Insert the extracted function statement before the variable declaration - variableDeclaration.insertBefore(extractedFnStatement) - - // Modify the .handler() call to pass two arguments: (extractedFn, serverFn) - // The handlerFnPath.node contains the original serverFn - const extractedFnIdentifier = t.identifier(functionName) - const serverFnNode = t.cloneNode(handlerFn, true) - - // Replace handler's arguments with [extractedFn, serverFn] - handler.callPath.node.arguments = [extractedFnIdentifier, serverFnNode] - - // Only export the extracted handler (e.g., myFn_createServerFn_handler) - // The manifest and all import paths only look up this suffixed name. - // The original variable (e.g., myFn) stays in the file but is not exported - // since it's only used internally. - exportNames.add(functionName) - } else { - // CALLER FILE: This file calls the server function but doesn't contain the implementation - // We need to: - // 1. Remove the handler function body (it will be in the provider file) - // 2. Replace the handler argument with an RPC stub - // - // IMPORTANT: We must keep the createServerFn().handler(extractedFn) structure - // so that the client middleware chain can unwrap the {result, error, context} response. - // - // Expected output format: - // const myFn = createServerFn().handler(createClientRpc("id")) - // or - // const myFn = createServerFn().handler(createSsrRpc("id")) - - // Generate the RPC stub using pre-compiled templates - // Note: Caller files only pass functionId, not the full serverFnMeta - const rpcStub = generateCallerRpcStub(functionId, envConfig) - - // Replace ONLY the handler argument with the RPC stub - // Keep the createServerFn().handler() wrapper intact for client middleware - handlerFnPath.replaceWith(rpcStub) + continue } + const metadata = JSON.stringify({ + id: functionId, + name: variableName, + filename: relativeFilename, + }) + const extracted = parseStatements( + `const ${functionName} = createServerRpc(${metadata}, (opts) => ${variableName}.__executeServer(opts));`, + )[0]! + context.ast.body.splice(context.ast.body.indexOf(statement), 0, extracted) + // Move the existing handler node; its source-binding identity remains intact. + handler.call.arguments = [parseExpression(functionName), handler.firstArg] + exportNames.add(functionName) } - // For provider files, add exports for all extracted functions - if (isProviderFile) { - // Remove all existing exports first - safeRemoveExports(context.ast) - - // Export all server function related variables from exportNames - // These were populated by handleCreateServerFn: - // 1. Extracted handlers: const fn_createServerFn_handler = createServerRpc(...) - // 2. Original variables: const fn = createServerFn().handler(...) - if (exportNames.size > 0) { - context.ast.program.body.push( - t.exportNamedDeclaration( - undefined, - Array.from(exportNames).map((name) => - t.exportSpecifier(t.identifier(name), t.identifier(name)), + if (isProvider) { + removeExports(context.ast) + if (exportNames.size) { + context.ast.body.push( + b.ExportNamedDeclaration({ + declaration: null, + source: null, + attributes: [], + exportKind: 'value', + specifiers: [...exportNames].map((name) => + b.ExportSpecifier({ + local: b.Identifier({ name }), + exported: b.Identifier({ name }), + exportKind: 'value', + }), ), - ), + }), ) } - if (context.mode === 'dev') { - context.ast.program.body.push(...providerHmrAcceptTemplate()) + context.ast.body.push( + ...parseStatements( + 'if (import.meta.hot) { import.meta.hot.accept(() => {}); } if (import.meta.webpackHot) { import.meta.webpackHot.accept(() => {}); }', + ), + ) } + const existing = new Set( + context.ast.body.filter(is.Directive).map((node) => node.directive), + ) + const missing = (context.serverFnProviderModuleDirectives ?? []).filter( + (directive) => { + if (!directive || existing.has(directive)) { + return false + } + existing.add(directive) + return true + }, + ) + context.ast.body.unshift( + ...parseStatements( + missing.map((directive) => `${JSON.stringify(directive)};`).join('\n'), + ), + ) + } else if (Object.keys(serverFnsById).length) { + context.onServerFnsById?.(serverFnsById) } - - // Notify about discovered functions (only for non-provider files) - if (!isProviderFile && hasKeys(serverFnsById) && context.onServerFnsById) { - context.onServerFnsById(serverFnsById) - } - - // Add runtime import using cached AST node - const runtimeCode = getCachedRuntimeCode( - context.framework, - envConfig.runtimeCodeType, + const importPath = runtime === 'provider' ? 'server-rpc' : `${runtime}-rpc` + prependStatements( + context.ast, + ...parseStatements( + `import { ${runtimeName} } from '@tanstack/${context.framework}-start/${importPath}';`, + ), ) - context.ast.program.body.unshift(t.cloneNode(runtimeCode)) } -/** - * Makes an identifier safe for use as a JavaScript identifier. - */ -function makeIdentifierSafe(identifier: string): string { - return identifier - .replace(/[^a-zA-Z0-9_$]/g, '_') // Replace unsafe chars with underscore - .replace(/^[0-9]/, '_$&') // Prefix leading number with underscore - .replace(/^\$/, '_$') // Prefix leading $ with underscore - .replace(/_{2,}/g, '_') // Collapse multiple underscores - .replace(/^_|_$/g, '') // Trim leading/trailing underscores -} - -/** - * Increments the version number suffix on a function name. - */ -function incrementFunctionNameVersion(functionName: string): string { - const [realReferenceName, count] = functionName.split(/_(\d+)$/) - const resolvedCount = Number(count || '0') - const suffix = `_${resolvedCount + 1}` - return makeIdentifierSafe(realReferenceName!) + suffix +function incrementFunctionNameVersion(name: string): string { + const [base, count] = name.split(/_(\d+)$/) + return ( + base! + .replace(/[^a-zA-Z0-9_$]/g, '_') + .replace(/^[0-9]/, '_$&') + .replace(/^\$/, '_$') + .replace(/_{2,}/g, '_') + .replace(/^_|_$/g, '') + `_${Number(count || '0') + 1}` + ) } -/** - * Removes all exports from the AST while preserving the declarations. - * Used for provider files where we want to re-export only the server functions. - */ -function safeRemoveExports(ast: t.File): void { - ast.program.body = ast.program.body.flatMap((node) => { - if ( - t.isExportNamedDeclaration(node) || - t.isExportDefaultDeclaration(node) - ) { +function removeExports(program: Program) { + program.body = program.body.flatMap<ProgramStatement>((node) => { + if (is.ExportNamedDeclaration(node) || is.ExportDefaultDeclaration(node)) { if ( - t.isFunctionDeclaration(node.declaration) || - t.isClassDeclaration(node.declaration) || - t.isVariableDeclaration(node.declaration) + is.FunctionDeclaration(node.declaration) || + is.ClassDeclaration(node.declaration) ) { - // do not remove export if it is an anonymous function / class, - // otherwise this would produce a syntax error - if ( - t.isFunctionDeclaration(node.declaration) || - t.isClassDeclaration(node.declaration) - ) { - if (!node.declaration.id) { - return node - } - } - return node.declaration - } else if (node.declaration === null) { - // remove e.g. `export { RouteComponent as component }` + return node.declaration.id + ? [unwrapExport(node, node.declaration)] + : [node] + } + if (is.VariableDeclaration(node.declaration)) { + return [unwrapExport(node, node.declaration)] + } + if (!node.declaration) { return [] } } - return node + return [node] }) } - -function ensureDirectivePrologue( - ast: t.File, - directiveValues: ReadonlyArray<string>, -): void { - const directives = ast.program.directives - const existingDirectives = new Set( - directives.map((directive) => directive.value.value), - ) - const missingDirectives: Array<string> = [] - - for (const directiveValue of directiveValues) { - if (!directiveValue || existingDirectives.has(directiveValue)) continue - - existingDirectives.add(directiveValue) - missingDirectives.push(directiveValue) - } - - for (let i = missingDirectives.length - 1; i >= 0; i--) { - directives.unshift(t.directive(t.directiveLiteral(missingDirectives[i]!))) - } -} diff --git a/packages/start-plugin-core/src/start-compiler/handleEnvOnly.ts b/packages/start-plugin-core/src/start-compiler/handleEnvOnly.ts index 1fa6ab21cfb..23110b3ad9e 100644 --- a/packages/start-plugin-core/src/start-compiler/handleEnvOnly.ts +++ b/packages/start-plugin-core/src/start-compiler/handleEnvOnly.ts @@ -1,54 +1,27 @@ -import * as t from '@babel/types' +import { is } from 'yuku-ast' +import { parseExpression } from '@tanstack/router-utils' import type { CompilationContext, RewriteCandidate } from './types' import type { LookupKind } from './compiler' -function capitalize(str: string) { - if (!str) return '' - return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() -} - -/** - * Handles serverOnly/clientOnly function transformations for a batch of candidates. - * - * @param candidates - All EnvOnly candidates to process (all same kind) - * @param context - The compilation context - * @param kind - The specific kind (ServerOnlyFn or ClientOnlyFn) - */ export function handleEnvOnlyFn( candidates: Array<RewriteCandidate>, context: CompilationContext, kind: LookupKind, ): void { - const targetEnv = kind === 'ClientOnlyFn' ? 'client' : 'server' - - for (const candidate of candidates) { - const { path } = candidate - - if (context.env === targetEnv) { - // Matching environment - extract the inner function - const innerFn = path.node.arguments[0] - - if (!t.isExpression(innerFn)) { - throw new Error( - `create${capitalize(targetEnv)}OnlyFn() must be called with a function!`, - ) + const target = kind === 'ClientOnlyFn' ? 'client' : 'server' + const factory = `create${target[0]!.toUpperCase()}${target.slice(1)}OnlyFn` + for (const { node } of candidates) { + if (context.env === target) { + const argument = node.arguments[0] + if (!is.Expression(argument)) { + throw new Error(`${factory}() must be called with a function!`) } - - path.replaceWith(innerFn) + context.replaceNode(node, argument) } else { - // Wrong environment - replace with a function that throws an error - path.replaceWith( - t.arrowFunctionExpression( - [], - t.blockStatement([ - t.throwStatement( - t.newExpression(t.identifier('Error'), [ - t.stringLiteral( - `create${capitalize(targetEnv)}OnlyFn() functions can only be called on the ${targetEnv}!`, - ), - ]), - ), - ]), + context.replaceNode( + node, + parseExpression( + `() => { throw new Error(${JSON.stringify(`${factory}() functions can only be called on the ${target}!`)}); }`, ), ) } diff --git a/packages/start-plugin-core/src/start-compiler/types.ts b/packages/start-plugin-core/src/start-compiler/types.ts index 0dde184961a..dfdffd71443 100644 --- a/packages/start-plugin-core/src/start-compiler/types.ts +++ b/packages/start-plugin-core/src/start-compiler/types.ts @@ -1,5 +1,4 @@ -import type * as babel from '@babel/core' -import type * as t from '@babel/types' +import type { CallExpression } from '@yuku-toolchain/types' import type { StartCompilerTransformContext } from '../types' /** @@ -22,31 +21,20 @@ export interface CompilationContext extends StartCompilerTransformContext { } /** - * Batched plugin handler signature. - * Receives ALL candidates of a specific kind in one call. - * Mutates the CompilationContext directly. - */ -export type BatchedPluginHandler<TOpts = unknown> = ( - candidates: Array<RewriteCandidate>, - context: CompilationContext, - opts: TOpts, -) => void - -/** - * Info about a method call in the chain, including the call expression path - * and the path to its first argument (if any). + * Info about a method call in the chain, including the call expression + * and its first argument (if any). */ export interface MethodCallInfo { - callPath: babel.NodePath<t.CallExpression> - /** Path to the first argument, or null if no arguments */ - firstArgPath: babel.NodePath | null + call: CallExpression + /** First argument, or null if no arguments. */ + firstArg: CallExpression['arguments'][number] | null } /** - * Pre-collected method chain paths for a root call expression. + * Pre-collected native method-chain nodes for a root call expression. * This avoids needing to traverse the AST again in handlers. */ -export interface MethodChainPaths { +export interface MethodChain { middleware: MethodCallInfo | null validator: MethodCallInfo | null handler: MethodCallInfo | null @@ -54,14 +42,12 @@ export interface MethodChainPaths { client: MethodCallInfo | null } -export type MethodChainKey = keyof MethodChainPaths - /** * Information about a candidate that needs to be rewritten. */ export interface RewriteCandidate { - path: babel.NodePath<t.CallExpression> - methodChain: MethodChainPaths + node: CallExpression + methodChain: MethodChain } /** @@ -101,30 +87,9 @@ export type GenerateFunctionIdFnOptional = ( ) => string | undefined /** - * Function type for generating replacement code for server functions. - * Used internally by handleCreateServerFn. + * Encodes the extracted module specifier used in development server function IDs. */ export type DevServerFnModuleSpecifierEncoder = (opts: { extractedFilename: string root: string }) => string - -export type ReplacerFn = (opts: { - /** Placeholder for the original function expression */ - fn: string - /** The filename where the extracted implementation lives */ - extractedFilename: string - /** The original source filename */ - filename: string - /** The unique function ID */ - functionId: string - /** The export name for this function */ - functionName: string - /** True if this is the source/provider file (has the implementation) */ - isSourceFn: boolean - /** - * True when this function was already discovered by a previous build (e.g., client). - * For SSR callers, this means the function is in the manifest. - */ - isClientReferenced: boolean -}) => string diff --git a/packages/start-plugin-core/src/start-compiler/utils.ts b/packages/start-plugin-core/src/start-compiler/utils.ts index 384a13113d2..848f11ad7f6 100644 --- a/packages/start-plugin-core/src/start-compiler/utils.ts +++ b/packages/start-plugin-core/src/start-compiler/utils.ts @@ -1,39 +1,35 @@ -import { codeFrameColumns } from '@babel/code-frame' -import * as t from '@babel/types' -import type babel from '@babel/core' +import { CHILD_KEYS, is, isWrapper, walk } from 'yuku-ast' +import { sourcePosition, unwrapExpression } from '@tanstack/router-utils' +import type { + CallExpression, + Node, + Program, + VariableDeclarator, +} from '@yuku-toolchain/types' +import type { StartCompilerTransformContext } from '../types' + +/** JavaScript line terminators, as counted by editors and parsers. */ +const lineTerminator = /\r\n|[\n\r\u2028\u2029]/ export function codeFrameError( code: string, - loc: { - start: { line: number; column: number } - end: { line: number; column: number } - }, + node: Pick<Node, 'start' | 'end'>, message: string, ) { - const frame = codeFrameColumns( - code, - { - start: loc.start, - end: loc.end, - }, - { - highlightCode: true, - message, - }, - ) - + const start = sourcePosition(code, node.start) + const lines = code.split(lineTerminator) + const frame = lines + .slice(Math.max(0, start.line - 2), start.line + 1) + .map((line, index) => { + const number = Math.max(1, start.line - 1) + index + return `${number} | ${line}${number === start.line ? `\n | ${' '.repeat(start.column)}^ ${message}` : ''}` + }) + .join('\n') return new Error(frame) } -/** - * Converts a bundler module ID to its physical-file identity for diagnostics, - * filesystem matching, and file-based invalidation. - * - * Do not use this for IDs passed to resolve/load hooks or as module cache keys: - * virtual prefixes and queries can be part of the module's semantic identity. - */ +/** Keep semantic module IDs intact; this is only for physical-file matching. */ export function cleanId(id: string): string { - // Remove null byte prefix used by Vite/Rollup for virtual modules if (id.startsWith('\0')) { id = id.slice(1) } @@ -41,19 +37,89 @@ export function cleanId(id: string): string { return queryIndex === -1 ? id : id.substring(0, queryIndex) } -/** - * Strips a method call by replacing it with its callee object. - * E.g., `foo().bar()` -> `foo()` - * - * This is a common pattern used when removing method calls from chains - * (e.g., removing .server() from middleware on client, or .validator() on client). - * - * @param callPath - The path to the CallExpression to strip - */ +/** Output-tree edits use native node identity; semantic queries use the source module. */ +export function createAstEditor(ast: Program) { + const parents = new WeakMap<Node, Node>() + const replaced = new WeakSet<Node>() + function indexSubtree(root: Node) { + walk(root, { + enter(node, context) { + if (context.parent) { + parents.set(node, context.parent) + } + }, + }) + } + indexSubtree(ast) + /** Replace `node` in the field of `parent` holding it, if any. */ + function replaceChild(parent: Node, node: Node, replacement: Node) { + const record = parent as unknown as Record<string, unknown> + for (const key of CHILD_KEYS[parent.type] ?? []) { + const field = record[key] + if (field === node) { + record[key] = replacement + return true + } + if (Array.isArray(field)) { + const index = field.indexOf(node) + if (index !== -1) { + field[index] = replacement + return true + } + } + } + return false + } + return { + parentOf(node: Node): Node | null { + return parents.get(node) ?? null + }, + replaceNode(node: Node, replacement: Node): void { + const parent = parents.get(node) + if (!parent) { + throw new Error('Cannot replace a node outside the output tree') + } + if (!replaceChild(parent, node, replacement)) { + throw new Error('Cannot replace a detached output node') + } + parents.set(replacement, parent) + indexSubtree(replacement) + replaced.add(node) + }, + /** Whether `replaceNode` removed `node` or one of its ancestors. */ + isReplaced(node: Node): boolean { + for ( + let current: Node | undefined = node; + current; + current = parents.get(current) + ) { + if (replaced.has(current)) { + return true + } + } + return false + }, + } +} + +export function getVariableDeclarator( + node: Node, + parentOf: (node: Node) => Node | null, +): VariableDeclarator | null { + let parent = parentOf(node) + while (is.ChainExpression(parent) || isWrapper(parent)) { + node = parent + parent = parentOf(node) + } + return is.VariableDeclarator(parent) && parent.init === node ? parent : null +} + export function stripMethodCall( - callPath: babel.NodePath<t.CallExpression>, + call: CallExpression, + context: StartCompilerTransformContext, ): void { - if (t.isMemberExpression(callPath.node.callee)) { - callPath.replaceWith(callPath.node.callee.object) + const callee = unwrapExpression(call.callee) + if (is.MemberExpression(callee)) { + context.replaceNode(call, callee.object) } } diff --git a/packages/start-plugin-core/src/types.ts b/packages/start-plugin-core/src/types.ts index db94e343d54..b17189ca2de 100644 --- a/packages/start-plugin-core/src/types.ts +++ b/packages/start-plugin-core/src/types.ts @@ -1,6 +1,11 @@ -import type * as babel from '@babel/core' -import type * as t from '@babel/types' -import type { GeneratorResult } from '@tanstack/router-utils' +import type { + CallExpression, + Expression, + Node, + Program, +} from '@yuku-toolchain/types' +import type { Module } from 'yuku-analyzer' +import type { GenerateResult } from '@tanstack/router-utils' import type { TanStackStartOutputConfig } from './schema' export type CompileStartFrameworkOptions = 'react' | 'solid' | 'vue' @@ -31,11 +36,15 @@ export interface StartCompilerImportTransformImport { } export interface StartCompilerTransformCandidate { - path: babel.NodePath<t.CallExpression> + node: CallExpression } export interface StartCompilerTransformContext { - readonly ast: t.File + readonly ast: Program + readonly module: Module + readonly originalNodes: WeakMap<Node, Node> + replaceNode: (node: Node, replacement: Node) => void + parentOf: (node: Node) => Node | null readonly code: string readonly id: string readonly env: StartCompilerEnvironment @@ -44,8 +53,7 @@ export interface StartCompilerTransformContext { readonly root: string readonly framework: CompileStartFrameworkOptions readonly providerEnvName: string - readonly types: typeof t - parseExpression: (code: string) => t.Expression + parseExpression: (code: string) => Expression warn?: (message: string) => void } @@ -66,7 +74,7 @@ export interface StartCompilerImportTransform { export interface StartCompilerTransformResult { code: string - map?: GeneratorResult['map'] | null + map?: GenerateResult['map'] | null } export interface StartCompilerVirtualModuleContext { diff --git a/packages/start-plugin-core/tests/compiler.test.ts b/packages/start-plugin-core/tests/compiler.test.ts index e5ab41dbc9a..975e4bb16ed 100644 --- a/packages/start-plugin-core/tests/compiler.test.ts +++ b/packages/start-plugin-core/tests/compiler.test.ts @@ -78,7 +78,10 @@ function createFullCompiler( }) } -function createExternalTransformCompiler() { +function createExternalTransformCompiler(options?: { + loadModule?: (id: string) => Promise<void> + resolveId?: (id: string, importer?: string) => Promise<string | null> +}) { const compilerTransforms: Array<StartCompilerImportTransform> = [ { name: 'test-render-option-injection', @@ -91,18 +94,10 @@ function createExternalTransformCompiler() { ], detect: /\brenderThing\b/, transform: (candidates, context) => { - const t = context.types for (const candidate of candidates) { - const args = candidate.path.node.arguments + const args = candidate.node.arguments if (args.length !== 1) continue - args.push( - t.objectExpression([ - t.objectProperty( - t.identifier('injected'), - context.parseExpression('loadThing()'), - ), - ]), - ) + args.push(context.parseExpression('({ injected: loadThing() })')) } }, }, @@ -119,8 +114,8 @@ function createExternalTransformCompiler() { compilerTransforms, }), getKnownServerFns: () => ({}), - loadModule: async () => {}, - resolveId: async (id) => id, + loadModule: options?.loadModule ?? (async () => {}), + resolveId: options?.resolveId ?? (async (id) => id), mode: 'build', compilerTransforms, }) @@ -458,6 +453,102 @@ describe('compiler handles multiple files with different kinds', () => { }) describe('compiler handles external import transforms', () => { + test.each([ + 'export const component = renderThing.bind(null)', + 'export const component = renderThing.call(null)', + 'const renderThing = "other"; export const component = runtime[renderThing](<Card />)', + 'export function component(runtime) { return runtime.renderThing(<Card />) }', + ])( + 'only transforms direct calls to configured bindings: %s', + async (body) => { + const compiler = createExternalTransformCompiler() + const namedImport = body.includes('const renderThing =') + ? '' + : "import { renderThing } from '@example/runtime'" + const result = await compiler.compile({ + code: ` + ${namedImport} + import * as runtime from '@example/runtime' + ${body} + `, + id: '/test/src/runtime.tsx', + detectedKinds: new Set(['External:test-render-option-injection']), + }) + expect(result).toBeNull() + }, + ) + + // A call named like a configured transform but imported from another module + // is ruled out by its import alone, so that module is never resolved or + // loaded. Loading it would fail the build: a virtual runtime module (here + // `\0`-prefixed, like the RSC runtime) has no file behind it, and the + // Rsbuild host loads compiler dependencies from the input file system. + test.each([ + 'export const component = renderThing(<Card />)', + 'export function component() { return renderThing(<Card />) }', + 'export const component = runtime.renderThing(<Card />)', + 'export function component() { return (runtime).renderThing(<Card />) }', + ])('does not load unrelated virtual runtime imports: %s', async (body) => { + const resolved: Array<string> = [] + const loaded: Array<string> = [] + const compiler = createExternalTransformCompiler({ + resolveId: async (id) => { + resolved.push(id) + return `\0${id}` + }, + loadModule: async (id) => { + loaded.push(id) + }, + }) + + const result = await compiler.compile({ + code: ` + import { renderThing } from 'virtual:rsc-runtime' + import * as runtime from 'virtual:rsc-runtime' + ${body} + `, + id: '/test/src/runtime.tsx', + detectedKinds: new Set(['External:test-render-option-injection']), + }) + + expect(result).toBeNull() + expect(resolved).toEqual([]) + expect(loaded).toEqual([]) + }) + + test('transforms configured aliases without tracing unrelated runtime calls', async () => { + const resolved: Array<string> = [] + const loaded: Array<string> = [] + const compiler = createExternalTransformCompiler({ + resolveId: async (id) => { + resolved.push(id) + return id + }, + loadModule: async (id) => { + loaded.push(id) + }, + }) + const result = await compiler.compile({ + code: ` + import { renderThing as renderCard } from '@example/runtime' + import * as configured from '@example/runtime' + import { renderThing } from 'virtual:rsc-runtime' + export const foreign = renderThing(<ForeignCard />) + export function component() { + return [renderCard(<Card />), (configured).renderThing(<OtherCard />)] + } + `, + id: '/test/src/card.tsx', + detectedKinds: new Set(['External:test-render-option-injection']), + }) + + expect(result).not.toBeNull() + expect(result!.code.match(/injected: loadThing\(\)/g)).toHaveLength(2) + expect(result!.code).toContain('renderThing(<ForeignCard />)') + expect(resolved).toEqual([]) + expect(loaded).toEqual([]) + }) + test('runs configured direct-call transforms before server function extraction', async () => { const compiler = createExternalTransformCompiler() @@ -1378,6 +1469,78 @@ describe('re-export chain resolution', () => { }) }) +test('resolves distinct re-export aliases without conflating their source bindings', async () => { + const sources: Record<string, string> = { + './barrel': `export { wrap as allowed } from './server'; export { wrap as denied } from './client'`, + './server': `export { createServerOnlyFn as wrap } from '@tanstack/start-fn-stubs'`, + './client': `export { createClientOnlyFn as wrap } from '@tanstack/start-fn-stubs'`, + } + const compiler: StartCompiler = new StartCompiler({ + ...getDefaultTestOptions('server'), + env: 'server', + mode: 'build', + lookupKinds: getLookupKindsForEnv('server'), + lookupConfigurations: getLookupConfigurationsForEnv('server', 'react'), + getKnownServerFns: () => ({}), + resolveId: async (id) => (sources[id] ? id : null), + loadModule: async (id) => { + if (sources[id]) { + compiler.ingestModule({ id, code: sources[id] }) + } + }, + }) + const result = await compiler.compile({ + id: '/test/entry.ts', + code: `import { allowed, denied } from './barrel'; export const a = allowed(() => 'server-value'); export const b = denied(() => 'client-value')`, + }) + expect(result).not.toBeNull() + expect(result!.code).not.toContain('./barrel') + const evaluate = new Function( + `${result!.code.replace(/\bexport (?=const)/g, '')}\nreturn { a, b }`, + ) + const { a, b } = evaluate() + expect(a()).toBe('server-value') + expect(() => b()).toThrow('can only be called on the client') +}) + +test('transforms an aliased env-only factory whose kind was not text-detected', async () => { + const sources: Record<string, string> = { + '/test/env.ts': `export { createClientOnlyFn as clientOnly } from '@tanstack/react-start'`, + } + const compiler: StartCompiler = new StartCompiler({ + ...getDefaultTestOptions('server'), + env: 'server', + mode: 'build', + lookupKinds: getLookupKindsForEnv('server'), + lookupConfigurations: getLookupConfigurationsForEnv('server', 'react'), + getKnownServerFns: () => ({}), + resolveId: async (id) => + id === './env' ? '/test/env.ts' : id.startsWith('@tanstack/') ? id : null, + loadModule: async (id) => { + if (sources[id]) { + compiler.ingestModule({ id, code: sources[id] }) + } + }, + }) + const code = `import { createServerOnlyFn } from '@tanstack/react-start' +import { clientOnly } from './env' +import { secret } from './secret.server' +import { readWindow } from './browser' +export const s = createServerOnlyFn(() => secret()) +export const c = clientOnly(() => readWindow())` + const result = await compiler.compile({ + id: '/test/entry.ts', + code, + detectedKinds: detectKindsInCode(code, 'server'), + }) + expect(result).not.toBeNull() + expect(result!.code).not.toContain('readWindow') + expect(result!.code).not.toContain('./browser') + expect(result!.code).toContain( + 'createClientOnlyFn() functions can only be called on the client!', + ) +}) + test('leaves unrelated SSR middleware chains to the bundler when a server function factory is present', async () => { const compiler = new StartCompiler({ ...getDefaultTestOptions('server'), @@ -1439,22 +1602,17 @@ test('compiles a server function through a parenthesized namespace receiver', as expect(result!.code).toContain('createClientRpc') }) -// The compiler currently mistakes the local parameter for the namespace import. -// Keep the intended behavior executable until binding resolution is corrected. -test.fails( - 'preserves a shadowed parenthesized namespace receiver', - async () => { - const compiler = createFullCompiler('client') - const code = `import * as Start from '@tanstack/react-start'; export function fn(Start) { return (Start).createServerFn().handler(() => 'local-runtime-body') }` - const result = await compiler.compile({ - id: '/test/shadowed-namespace.ts', - code, - }) - const output = result?.code ?? code - expect(output).toContain('local-runtime-body') - expect(output).not.toContain('createClientRpc') - }, -) +test('preserves a shadowed parenthesized namespace receiver', async () => { + const compiler = createFullCompiler('client') + const code = `import * as Start from '@tanstack/react-start'; export function fn(Start) { return (Start).createServerFn().handler(() => 'local-runtime-body') }` + const result = await compiler.compile({ + id: '/test/shadowed-namespace.ts', + code, + }) + const output = result?.code ?? code + expect(output).toContain('local-runtime-body') + expect(output).not.toContain('createClientRpc') +}) test.each([ `(Start).createServerOnlyFn(() => 'private-server-body')`, diff --git a/packages/start-plugin-core/tests/createServerFn/caller-dead-code.test.ts b/packages/start-plugin-core/tests/createServerFn/caller-dead-code.test.ts index 07c7a2f2cf5..db3d40efb04 100644 --- a/packages/start-plugin-core/tests/createServerFn/caller-dead-code.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/caller-dead-code.test.ts @@ -262,6 +262,36 @@ export function identity<db>(value: db): db { }`, check: (client) => expect(client.identity('id')).toBe('id'), }, + { + // Source: typescript-eslint ts-enum/member-ref.js + name: 'an enum member', + code: `export enum Flags { + db = 1, + other = db + 1, +}`, + check: (client) => expect(client.Flags.other).toBe(2), + }, + { + // Source: typescript-eslint functions/function-declaration/overload.js + name: 'an overload signature parameter', + code: `export function call(db: string): string +export function call(value: string) { + return value +}`, + check: (client) => expect(client.call('call')).toBe('call'), + }, + { + // Source: typescript-eslint class/declaration/parameter-properties.js + name: 'a parameter property', + code: `export class Service { + constructor(private db: string) {} + read() { + return this.db + } +}`, + check: (client) => + expect(new client.Service('param').read()).toBe('param'), + }, { // Source: typescript-eslint jsx/attribute.js, jsx/namespaced-attribute.js, // jsx/component-intrinsic-name.js, jsx/this-jsxidentifier.js @@ -349,6 +379,40 @@ export const fn = createServerFn() './schema.server', ]) }) + + // The validator only runs on the server, so the client drops it and the + // imports only it reads, also when a cast wraps the chain link. Source: + // differential fuzzing (main kept the validator and its import in the client + // bundle, code the client never runs). + test.each([ + { name: 'satisfies', wrap: (link: string) => `(${link} satisfies any)` }, + { name: 'cast', wrap: (link: string) => `(${link} as any)` }, + ])( + 'when the validator link is wrapped in a $name expression', + async ({ wrap }) => { + const compiled = + await compileAll(`${head}import { schema } from './schema' +export const fn = ${wrap(`createServerFn().validator(schema)`)}.handler(async () => 'ok')`) + expect(importSources(compiled.client)).toEqual([]) + expect(importSources(compiled.provider)).toEqual(['./schema']) + }, + ) +}) + +// Source: Next.js server-actions fixtures server-graph/8 (comments in actions) +test.each([ + `// uses SERVICE_ROLE_KEY to bypass row level security\n async () => 1,`, + `/*! uses SERVICE_ROLE_KEY to bypass row level security */\n async () => 1,`, + `/* SERVICE_ROLE_KEY */ async () => 1,`, + `async () => 1, // uses SERVICE_ROLE_KEY`, +])('callers drop the comments around the handler: %s', async (handler) => { + const compiled = + await compileAll(`${head}export const fn = createServerFn().handler( + ${handler} +)`) + for (const caller of [compiled.client, compiled.ssr]) { + expect(caller).not.toContain('SERVICE_ROLE_KEY') + } }) // Source: @vitejs/plugin-rsc hoist/function-hoist-block.js, @@ -493,6 +557,82 @@ export const currentTheme = theme`) }) describe('declarations only the handler needs', () => { + // Source: babel-dead-code-elimination "function" > "declaration", + // "variable" > "identifier" and "repeated elimination"; Next.js + // server-actions fixtures server-graph/5, /6 + test.each([ + { + name: 'a class', + declarations: `class Repo extends db.Base { + static instance = new Repo() + find() { return 'found' } +}`, + use: 'Repo.instance.find()', + expected: 'found', + }, + { + name: 'an overloaded function', + declarations: `function Repo(id: string): string +function Repo(id: number): string +function Repo(id: any) { return db.x(id) }`, + use: 'Repo(1)', + expected: 'x1', + }, + { + name: 'an enum and a namespace', + declarations: `enum Role { Admin = 'admin' } +namespace Repo { + export const conn = db.connect() +}`, + use: '[Role.Admin, Repo.conn]', + expected: ['admin', 'connected'], + }, + { + name: 'a redeclared var', + declarations: `var Repo = db.a() +var Repo = db.b()`, + use: 'Repo', + expected: 'b', + }, + ])( + 'callers drop $name only the handler uses', + async ({ declarations, use, expected }) => { + const compiled = await compileAll(`${head}import { db } from './db.server' +${declarations} +export const fn = createServerFn().handler(async () => ${use})`) + for (const caller of [compiled.client, compiled.ssr]) { + expect(importSources(caller)).toEqual([]) + expect(caller).not.toMatch(/\b(?:Repo|Role)\b/) + } + expect( + await callProvider(compiled.provider, 'fn', { + './db.server': `export const db = { + Base: class {}, + x: (id) => 'x' + id, + connect: () => 'connected', + a: () => 'a', + b: () => 'b', +}`, + }), + ).toEqual(expected) + }, + ) + + // Source: React Compiler fixture context-variable-as-jsx-element-tag.js + test('callers drop a handler-only function that declares a local of its own name', async () => { + const compiled = + await compileAll(`${head}import { readSecret } from './server-only' +function Report() { + let Report = readSecret() + return <Report /> +} +export const getReport = createServerFn().handler(async () => Report())`) + for (const caller of [compiled.client, compiled.ssr]) { + expect(importSources(caller)).toEqual([]) + expect(caller).not.toContain('readSecret') + } + }) + // Source: babel-dead-code-elimination "SCC dead code elimination" (mutual // recursion, self-recursive functions, unexported circular references) test('callers drop handler-only cycles and default parameters but keep cycles nothing used', async () => { diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index 29557e7b9c2..0389b28be85 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -1,6 +1,5 @@ import { readFile, readdir } from 'node:fs/promises' import path from 'node:path' -import { stripVTControlCharacters } from 'node:util' import { describe, expect, test, vi } from 'vitest' import { StartCompiler } from '../../src/start-compiler/compiler' import { formatSnapshot } from '../format-snapshot' @@ -133,21 +132,22 @@ describe('createServerFn compiles correctly', async () => { ['U+2028', '\u2028'], ['U+2029', '\u2029'], ])( - 'reports the location of a diagnostic with %s line endings', + 'frames compile errors on the right line with %s line terminators', async (_name, eol) => { - const error = await compile({ - code: [ - `import { createServerFn } from '@tanstack/react-start'`, - `export const { fn } = createServerFn().handler(async () => 1)`, - ].join(eol), - env: 'client', - isProviderFile: false, - mode: 'build', - }).catch((caught: unknown) => caught) + const code = [ + `import { createServerFn } from '@tanstack/react-start'`, + `const other = 1`, + `const { fn } = createServerFn().handler(async () => other)`, + ].join(eol) - expect(error).toBeInstanceOf(Error) - expect(stripVTControlCharacters((error as Error).message)).toMatch( - /> 2 \| export const \{ fn \} = createServerFn\(\)/, + await expect( + compile({ code, env: 'client', isProviderFile: false, mode: 'build' }), + ).rejects.toThrow( + [ + `2 | const other = 1`, + `3 | const { fn } = createServerFn().handler(async () => other)`, + ` | ^ createServerFn must be assigned to a simple identifier, not a destructuring pattern`, + ].join('\n'), ) }, ) diff --git a/packages/start-plugin-core/tests/createServerFn/declaration-sites.test.ts b/packages/start-plugin-core/tests/createServerFn/declaration-sites.test.ts index f42a13cdba7..e569f04e95e 100644 --- a/packages/start-plugin-core/tests/createServerFn/declaration-sites.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/declaration-sites.test.ts @@ -1,122 +1,196 @@ -import { expect, test } from 'vitest' -import { compileFor, outputs } from '../regression-helpers' -import { getModuleErrors } from '../validate-module' +import { describe, expect, test } from 'vitest' +import { + callProvider, + compileAll, + compileErrorMessage, + compileFor, + outputs, +} from '../regression-helpers' -const head = `import { createServerFn } from '@tanstack/react-start'\n` -const top = `export const top = createServerFn().handler(async () => 'top')\n` -const inFunction = `export function make() { - const fn = createServerFn().handler(async () => 'nested') - return fn -}` -const inSwitchCase = `export function pick(kind: string) { +// A server fn must initialize a module-level variable: the provider module +// exports its extracted handler next to that declaration. Anywhere else it is +// rejected with a clear compile error in every output, whatever else the +// module imports, instead of shipping its handler and server-only imports to +// the client. + +const notAssigned = 'createServerFn must be assigned to a variable!' +const nested = + 'createServerFn must be assigned to a top-level variable, not declared inside a function or block!' + +const imports = { + 'createServerFn alone': { + head: `import { createServerFn } from '@tanstack/react-start'\n`, + tail: '', + }, + 'another Start factory': { + head: `import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'\n`, + tail: `\nexport const serverOnly = createServerOnlyFn(() => db.top())`, + }, +} + +describe.each(Object.entries(imports))( + 'with %s imported', + (_, { head, tail }) => { + test.each([ + { + name: 'a function body', + code: `export function make() { + const inner = createServerFn().handler(async () => db.inner()) + return inner +}`, + error: nested, + }, + { + name: 'a function body next to a top-level server fn', + code: `export const top = createServerFn().handler(async () => db.top()) +export function make() { + const inner = createServerFn().handler(async () => db.inner()) + return inner +}`, + error: nested, + }, + { + name: 'a switch case inside a function', + code: `export function pick(kind: string) { switch (kind) { case 'a': - const fn = createServerFn().handler(async () => 'nested') - return fn + const inner = createServerFn().handler(async () => db.inner()) + return inner } -}` - -// A `createServerFn` declared below the module top level must not produce an -// invalid module, and top-level server fns next to it are still extracted. -// Rejecting it with a compile error is valid too, but not crashing on it. -// Known limitation on main: only module-level declarations are extracted; the -// nested one is left untransformed, so its handler ships to the client. Which -// nested server fns are extracted is left open here. -test.each([ - { name: 'a function', code: inFunction, topLevel: [] }, - { - name: 'a switch case inside a function', - code: inSwitchCase, - topLevel: [], - }, - { - name: 'a switch case at the module top level', - code: `switch (import.meta.env.MODE) { +}`, + error: nested, + }, + { + name: 'a switch case at the module top level', + code: `switch (import.meta.env.MODE) { case 'a': - const fn = createServerFn().handler(async () => 'nested') - console.log(fn) + const inner = createServerFn().handler(async () => db.inner()) + console.log(inner) }`, - topLevel: [], - }, - { - name: 'a function next to a top-level one', - code: `${top}${inFunction}`, - topLevel: ['top'], - }, - { - name: 'a switch case next to a top-level one', - code: `${top}${inSwitchCase}`, - topLevel: ['top'], - }, -])( - 'a server fn declared in $name compiles to valid modules or is rejected', - async ({ code, topLevel }) => { - const errors: Record<string, Array<string>> = {} - for (const output of outputs) { - let result: Awaited<ReturnType<typeof compileFor>> - try { - result = await compileFor(output, `${head}${code}`) - } catch (error) { - expect((error as Error).message, output).not.toMatch( - /Expected createServerFn declaration in a statement list/, - ) - continue - } - errors[output] = - result.code === null ? [] : await getModuleErrors(result.code) - if (output === 'client') { - const extractedNames = Object.values(result.serverFns).map( - (fn) => fn.functionName, - ) - expect(extractedNames).toEqual( - expect.arrayContaining( - topLevel.map((name) => `${name}_createServerFn_handler`), - ), + error: nested, + }, + { + name: 'a block at the module top level', + code: `export const registry: Array<unknown> = [] +{ + const inner = createServerFn().handler(async () => db.inner()) + registry.push(inner) +}`, + error: nested, + }, + { + // Source: SolidStart compile.spec.ts "keeps client and server ids + // aligned around nested server functions" + name: 'the handler of another server fn', + code: `export const outer = createServerFn().handler(async () => { + const inner = createServerFn().handler(async () => db.inner()) + return typeof inner +})`, + error: nested, + }, + { + name: 'an object property', + code: `export const api = { read: createServerFn().handler(async () => db.inner()) }`, + error: notAssigned, + }, + { + name: 'a class field', + code: `export class Api { read = createServerFn().handler(async () => db.inner()) }`, + error: notAssigned, + }, + { + name: 'an anonymous default export', + code: `export default createServerFn().handler(async () => db.inner())`, + error: notAssigned, + }, + { + name: 'an assignment', + code: `export let fn +fn = createServerFn().handler(async () => db.inner())`, + error: notAssigned, + }, + { + name: 'a function return value', + code: `export function make() { + return createServerFn().handler(async () => db.inner()) +}`, + error: notAssigned, + }, + { + name: 'a call argument', + code: `register(createServerFn().handler(async () => db.inner()))`, + error: notAssigned, + }, + { + // Source: Next.js server-actions fixtures server-graph/18, /19 + name: 'a JSX attribute', + code: `export function Page() { + return <form action={createServerFn().handler(async () => db.inner()) as any} /> +}`, + error: notAssigned, + }, + { + name: 'a conditional initializer', + code: `export const fn = import.meta.env.SSR ? createServerFn().handler(async () => db.inner()) : null`, + error: notAssigned, + }, + { + // Source: Next.js server-actions fixtures server-graph/56 + name: 'an object destructuring declarator', + code: `export const { fn } = { fn: createServerFn().handler(async () => db.inner()) }`, + error: notAssigned, + }, + { + // Source: Next.js server-actions fixtures server-graph/20 + name: 'an array destructuring declarator', + code: `export const [fn] = [createServerFn().handler(async () => db.inner())]`, + error: notAssigned, + }, + { + name: 'a validator call without a validator', + code: `export const fn = createServerFn().validator().handler(async () => db.inner())`, + error: 'createServerFn().validator() must be called with a validator!', + }, + ])('every output rejects a server fn in $name', async ({ code, error }) => { + const source = `${head}import { db } from './db.server'\n${code}${tail}` + for (const output of outputs) { + const thrown = await compileFor(output, source).then( + () => undefined, + (caught: unknown) => caught, ) + expect(thrown, output).toBeInstanceOf(Error) + expect(compileErrorMessage(thrown), output).toContain(error) } - } - for (const [output, moduleErrors] of Object.entries(errors)) { - expect(moduleErrors, output).toEqual([]) - } + }) }, ) -// A server fn must be a variable initializer; anywhere else it is rejected -// in every output instead of being shipped untransformed. -// Known limitation on main: with createServerFn as the only factory import -// these modules are returned untransformed, so the extra import is needed. -const rejectionHead = `import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'\n` -test.each([ - { - name: 'an object property', - code: `export const api = { read: createServerFn().handler(async () => 1) }`, - error: 'createServerFn must be assigned to a variable!', - }, - { - name: 'a class field', - code: `export class Api { read = createServerFn().handler(async () => 1) }`, - error: 'createServerFn must be assigned to a variable!', - }, - { - name: 'an anonymous default export', - code: `export default createServerFn().handler(async () => 1)`, - error: 'createServerFn must be assigned to a variable!', - }, - { - name: 'an assignment', - code: `export let fn\nfn = createServerFn().handler(async () => 1)`, - error: 'createServerFn must be assigned to a variable!', - }, - { - name: 'a validator call without a validator', - code: `export const fn = createServerFn().validator().handler(async () => 1)`, - error: 'createServerFn().validator() must be called with a validator!', - }, -])('rejects $name', async ({ code, error }) => { - for (const output of outputs) { - await expect( - compileFor(output, `${rejectionHead}${code}`), - output, - ).rejects.toThrow(error) - } +test('compileErrorMessage drops the code frame that quotes the source', async () => { + const error = await compileFor( + 'client', + `${imports['createServerFn alone'].head}const args: Array<() => unknown> = [] +export const fn = createServerFn().handler(...args)`, + ).catch((caught: unknown) => caught) + expect((error as Error).message).toContain('export const fn') + const message = compileErrorMessage(error) + expect(message).toMatch(/handler\(\) must be called with an expression/) + expect(message).not.toContain('export const fn') +}) + +test('a server fn created through an optional call chain compiles like a plain chain', async () => { + const { client, provider } = await compileAll( + `${imports['createServerFn alone'].head}export const fn = createServerFn()?.handler(async () => 'from the server')`, + ) + expect(client).toContain('createClientRpc') + expect(client).not.toContain('from the server') + expect(await callProvider(provider, 'fn')).toBe('from the server') +}) + +test('a .ts module in a directory whose name contains # compiles as TypeScript', async () => { + const { code } = await compileFor( + 'client', + `${imports['createServerFn alone'].head}export const fn = createServerFn().handler(async ({ data }) => <string>data)`, + { id: '/test/c#proj/src/module.ts' }, + ) + expect(code).toContain('createClientRpc') }) diff --git a/packages/start-plugin-core/tests/createServerFn/handler-scope.test.ts b/packages/start-plugin-core/tests/createServerFn/handler-scope.test.ts index 2f94d1b465e..868e29d800c 100644 --- a/packages/start-plugin-core/tests/createServerFn/handler-scope.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/handler-scope.test.ts @@ -113,14 +113,35 @@ export const later = createServerFn().handler(async () => 'later:')`, code: `export const fn = (createServerFn().handler(async () => db.x()))!`, expected: 'x', }, + { + name: 'a createServerOnlyFn call', + code: `import { createServerOnlyFn } from '@tanstack/react-start' +export const fn = createServerFn().handler(createServerOnlyFn(async () => db.x()))`, + expected: 'x', + }, + { + // Source: corpus-diff, imaimai17468/tanstack-start-handler-call-expression-repro + name: 'a createIsomorphicFn call', + code: `import { createIsomorphicFn } from '@tanstack/react-start' +export const fn = createServerFn().handler( + createIsomorphicFn() + .server(async () => db.x()) + .client(() => 'client'), +)`, + expected: 'x', + }, ])( 'the handler can be $name', async ({ code, expected, read = async (value) => value }) => { const compiled = await compileAll( `${head}import { db } from './db.server'\n${code}`, ) - for (const caller of [compiled.client, compiled.ssr]) { - expect(importSources(caller)).toEqual([]) + // Callers call the server fn through its RPC. + for (const output of ['client', 'ssr'] as const) { + expect(importSources(compiled[output]), output).toEqual([]) + expect((await evaluateModule(compiled[output])).fn, output).toEqual({ + rpc: { [output]: expect.any(String) }, + }) } const value = await callProvider(compiled.provider, 'fn', { './db.server': dbServer, diff --git a/packages/start-plugin-core/tests/createServerFn/webcontainer.test.ts b/packages/start-plugin-core/tests/createServerFn/webcontainer.test.ts new file mode 100644 index 00000000000..ed37e17c2a9 --- /dev/null +++ b/packages/start-plugin-core/tests/createServerFn/webcontainer.test.ts @@ -0,0 +1,31 @@ +import { afterAll, beforeAll, expect, test } from 'vitest' +import { callProvider, compileAll } from '../regression-helpers' + +// WebContainers (StackBlitz, bolt.new) run the compilers on Yuku's +// WebAssembly core, and their `TextEncoder.encodeInto('')` reports the +// previous call's byte counts: https://github.com/stackblitz/webcontainer-core/issues/2173 +const encodeInto = TextEncoder.prototype.encodeInto + +beforeAll(() => { + process.versions.webcontainer = '1.0.0' + let previous = { read: 0, written: 0 } + TextEncoder.prototype.encodeInto = function (source, destination) { + if (source === '') { + return previous + } + previous = encodeInto.call(this, source, destination) + return previous + } +}) + +afterAll(() => { + delete process.versions.webcontainer + TextEncoder.prototype.encodeInto = encodeInto +}) + +test('compiles and runs a server function in a WebContainer', async () => { + const { provider } = + await compileAll(`import { createServerFn } from '@tanstack/react-start' +export const greet = createServerFn().handler(async () => 'hello')`) + expect(await callProvider(provider, 'greet')).toBe('hello') +}) diff --git a/packages/start-plugin-core/tests/hydrate-captures.test.ts b/packages/start-plugin-core/tests/hydrate-captures.test.ts index c9b98c7b4e6..5c18cbf3947 100644 --- a/packages/start-plugin-core/tests/hydrate-captures.test.ts +++ b/packages/start-plugin-core/tests/hydrate-captures.test.ts @@ -73,6 +73,18 @@ test.each<{ params: ['value'], expected: '<p>outer</p>', }, + { + // Source: Qwik optimizer should_not_auto_export_var_shadowed_in_labeled_block + name: 'only through references, not through a label of the same name', + children: `{(() => { + field: { + break field + } + return 'after' +})()}`, + params: [], + expected: '<p>after</p>', + }, { // Source: @vitejs/plugin-rsc hoist/computed-destructuring-key-captures-outer-binding.js name: 'through a computed destructuring key', @@ -169,6 +181,69 @@ export function Page({ entries }) { ) }) +// Source: Qwik optimizer jsx_underscore_component_tag; React Compiler fixture +// jsx-underscore-prefix-component.js +test.each(['Local', '_Local', '$Local', 'ÉLocal', '组件'])( + 'client: a local component named %s is captured', + async (name) => { + const { chunk } = await compileChunk(`export function Page({ label }) { + const ${name} = () => <b>{label}</b> + return <Hydrate><${name} /><i>{label}</i></Hydrate> +}`) + expect(getChunkParams(chunk)).toEqual(['label', name].sort()) + expect( + await renderChunk(chunk, { label: 'l', [name]: () => '<b>row</b>' }), + ).toBe('<b>row</b><i>l</i>') + }, +) + +// Source: Qwik optimizer jsx_member_tag_object_is_captured, +// destructured_prop_used_as_member_tag +test('client: the object of a member-expression tag is captured', async () => { + const { parent, chunk } = + await compileChunk(`function Home() { return <i>home</i> } +export function Page({ Model }) { + const ui = { Home } + return <Hydrate><ui.Home /><Model.Item /></Hydrate> +}`) + expect(getChunkParams(chunk)).toEqual(['Model', 'ui']) + expect(parent).toContain('ui={ui}') + expect( + await renderChunk(chunk, { + ui: { Home: () => '<i>home</i>' }, + Model: { Item: () => '<b>item</b>' }, + }), + ).toBe('<i>home</i><b>item</b>') +}) + +// Source: Qwik optimizer jsx_tag_names_are_not_segment_uses (non-reference +// identifiers) +test('client: property names, method keys and optional members are not captured', async () => { + // A spurious `later={later}` prop would read `later` before it is + // initialized and throw. + const { chunk } = await compileChunk(`export function Page({ obj }) { + const el = <Hydrate><p>{obj?.later + ({ later() { return '!' } }).later()}</p></Hydrate> + const later = 1 + console.log(later) + return el +}`) + expect(getChunkParams(chunk)).toEqual(['obj']) + expect(await renderChunk(chunk, { obj: { later: 'L' } })).toBe('<p>L!</p>') +}) + +// Source: Qwik optimizer example_ts_enums (component-local enum) +test('client: a local enum is captured', async () => { + const { parent, chunk } = await compileChunk(`export function Page() { + enum Color { + Red = 'red', + } + return <Hydrate><p>{Color.Red}</p></Hydrate> +}`) + expect(getChunkParams(chunk)).toEqual(['Color']) + expect(parent).toMatch(/Color=\{Color\}/) + expect(await renderChunk(chunk, { Color: { Red: 'red' } })).toBe('<p>red</p>') +}) + // Source: Qwik optimizer jsx_tag_names_are_not_segment_uses, // jsx_lowercase_tag_outside_segments, jsx_tag_named_like_inlined_const test.each([ diff --git a/packages/start-plugin-core/tests/hydrate-split-modules.test.ts b/packages/start-plugin-core/tests/hydrate-split-modules.test.ts index cb62a7d1bd9..93c80c418d0 100644 --- a/packages/start-plugin-core/tests/hydrate-split-modules.test.ts +++ b/packages/start-plugin-core/tests/hydrate-split-modules.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, test } from 'vitest' import { createHydrateCompilerPlugin } from '../src/hydrate-when-transform' import { compileCode, + compileErrorMessage, compileFirstChunk, compileHydrate, + directivePrologue, evaluateHydrateParent, evaluateModule, getBoundaryIds, @@ -16,6 +18,7 @@ import { renderChunk, } from './regression-helpers' import { declarationOf, getModuleErrors } from './validate-module' +import type { ModuleStub } from './regression-helpers' // On the client `<Hydrate>` children move into a lazily loaded chunk module // and the parent renders the chunk component in their place; on the server @@ -24,6 +27,117 @@ import { declarationOf, getModuleErrors } from './validate-module' const head = `import { Hydrate } from '@tanstack/react-start'\n` +/** + * JSX runtime of the evaluated modules: intrinsic elements render as tags and, + * like React, components never receive `key` (nor `ref` before React 19). + */ +const reactRuntime = `const __Fragment = Symbol('Fragment') +const __jsx = (type, props, ...children) => { + const text = children.flat(Infinity).filter((c) => c != null && c !== false && c !== true).join('') + if (type === __Fragment) return text + if (typeof type === 'function') { + const { key, ref, ...rest } = props ?? {} + return type(children.length ? { ...rest, children: text } : rest) + } + if (typeof type !== 'string') throw new Error('cannot render ' + String(type)) + return '<' + type + '>' + text + '</' + type + '>' +} +` + +/** + * Evaluates `code` compiled for the client once its `<Hydrate>` chunks have + * loaded: each lazy chunk component renders the chunk export it loads, and + * the modules are linked like a bundler would (`evaluateHydrateParent`). + * `beforeChunksLoad` runs app code between the two. + */ +async function loadClientModule( + code: string, + options: { + stubs?: Record<string, ModuleStub> + beforeChunksLoad?: (module: Record<string, any>) => void + } = {}, +) { + const { parent, chunks, plugin } = await compileHydrate('client', code) + const lazyChunks: Array<{ name: string; rendered: boolean }> = [] + const chunkExports: Record<string, (props: unknown) => unknown> = {} + const userStubs = options.stubs ?? {} + const stubs: Record<string, ModuleStub> = { + ...hydrateParentStubs, + ...userStubs, + '@tanstack/react-router': { + ...(userStubs['@tanstack/react-router'] as Record<string, unknown>), + lazyRouteComponent: (_load: unknown, name = 'default') => { + const lazyChunk = { name, rendered: false } + lazyChunks.push(lazyChunk) + return (props: unknown) => { + lazyChunk.rendered = true + return chunkExports[name]!(props) + } + }, + }, + } + expect(await getModuleErrors(parent)).toEqual([]) + const { module, parentModuleStubs } = await evaluateHydrateParent( + plugin, + parent, + stubs, + reactRuntime, + ) + options.beforeChunksLoad?.(module) + for (const chunk of chunks) { + expect(await getModuleErrors(chunk)).toEqual([]) + Object.assign( + chunkExports, + await evaluateModule( + chunk, + { ...stubs, ...(await parentModuleStubs(chunk)) }, + reactRuntime, + ), + ) + } + return { module, lazyChunks } +} + +describe('harness controls', () => { + test('client: loadClientModule renders every boundary through its lazy chunk', async () => { + const { module, lazyChunks } = + await loadClientModule(`${head}const helper = () => '!' +export function Page() { + const label = 'hi' + return <><Hydrate><p>{label}{helper()}</p></Hydrate><Hydrate><b>two</b></Hydrate></> +}`) + expect(module.Page()).toBe('[<p>hi!</p>][<b>two</b>]') + expect(lazyChunks.map((chunk) => chunk.rendered)).toEqual([true, true]) + }) + + test('client: loadClientModule links stubs and runs app code before the chunks load', async () => { + const events: Array<string> = [] + const { module } = await loadClientModule( + `${head}import { Link } from '@tanstack/react-router' +import { label } from './label' +export function Page() { + return <Hydrate><p><Link />{label()}</p></Hydrate> +}`, + { + stubs: { + // Merged with the lazy chunk loader the harness provides. + '@tanstack/react-router': { Link: () => 'link ' }, + './label': { + get label() { + events.push('label linked') + return () => 'stubbed' + }, + }, + }, + beforeChunksLoad: (parent) => + events.push(`app code with ${Object.keys(parent).join()}`), + }, + ) + expect(module.Page()).toBe('[<p>link stubbed</p>]') + expect(events).toEqual(['app code with Page', 'label linked']) + }) +}) + describe('the parent module', () => { const widgetPage = `${head}import { visible } from '@tanstack/react-start/hydration' import { Chart, FallbackPane } from './widgets' @@ -46,39 +160,67 @@ export function Page() { expect(parent).not.toContain('FallbackPane') }) - test("client: 'use client' stays first in the parent module and the split chunk", async () => { + test("client: the directive prologue stays first in the parent module and 'use client' in the split chunk", async () => { const { parent, chunks } = await compileHydrate( 'client', `'use client' +'use strict' ${head}import { Chart } from './chart' export function Page() { return <Hydrate><Chart /></Hydrate> }`, ) - expect(parent).toMatch(/^\s*['"]use client['"]/) - expect(chunks[0]).toMatch(/^\s*['"]use client['"]/) + expect(directivePrologue(parent)).toEqual(['use client', 'use strict']) + expect(directivePrologue(chunks[0]!)[0]).toBe('use client') }) - test('client: a self-closing Hydrate with a children prop renders nothing next to its boundary', async () => { - const { parent } = await compileHydrate( - 'client', - `${head}import { idle } from '@tanstack/react-start/hydration' + // Children passed as props split like JSX children, or stay in place when + // props only provide them at runtime; either way they render once, without + // a chunk that never renders. + test.each([ + { + name: 'a spread on an element without children', + code: `export function Page(props) { + return <Hydrate {...props}></Hydrate> +}`, + props: { children: 'spread' }, + html: '[spread]', + }, + { + name: 'a children prop of a self-closing element', + code: `import { idle } from '@tanstack/react-start/hydration' import { Chart } from './chart' export function Page() { - return <section><Hydrate when={idle()} children={<Chart />} /></section> + return <Hydrate when={idle()} children={<Chart />} /> }`, - ) - expect(await getModuleErrors(parent)).toEqual([]) - const module = await evaluateModule(parent, { - ...hydrateParentStubs, - './chart': { Chart: () => 'chart' }, - }) - // Whether the children prop renders inline or from the chunk, nothing - // may render next to the boundary. - expect(module.Page()).toMatch( - /^<section>\[(?:chart|lazy\(\))\]<\/section>$/, - ) - }) + props: {}, + html: '[chart]', + }, + { + // A string children prop is left in place, while the same text as JSX + // children (`<Hydrate>text</Hydrate>`) is split like any JSX children + // (see the text-only child tests): the compiler only moves a children + // prop holding an expression into the JSX children. Plain text has + // nothing to hydrate, so both render the same. + name: 'a string children prop', + code: `export function Page() { + return <Hydrate children="text" /> +}`, + props: {}, + html: '[text]', + }, + ])( + 'client: children passed through $name render without unused chunks', + async ({ code, props, html }) => { + const { module, lazyChunks } = await loadClientModule(`${head}${code}`, { + stubs: { './chart': { Chart: () => 'chart' } }, + }) + expect({ + html: module.Page(props), + unusedChunks: lazyChunks.filter((chunk) => !chunk.rendered).length, + }).toEqual({ html, unusedChunks: 0 }) + }, + ) test('client: a stale h attribute is replaced by the generated boundary id', async () => { const { parent } = await compileHydrate( @@ -103,6 +245,25 @@ export function Page() { expect(parent).toBeNull() }) + // Source: differential fuzzing (main failed the build with "Hydrate cannot + // code-split function-as-children"). + test.each([ + { name: 'satisfies', split: '(false satisfies boolean)' }, + { name: 'cast', split: '(false as boolean)' }, + ])( + 'split={false} wrapped in a $name expression keeps function-as-children in place', + async ({ split }) => { + const code = `${head}export function Page() { + return <Hydrate split={${split}}>{() => <p>render prop</p>}</Hydrate> +}` + for (const env of ['client', 'server'] as const) { + const { parent, chunks } = await compileHydrate(env, code) + expect(chunks, env).toEqual([]) + expect(parent, env).toContain('render prop') + } + }, + ) + test.each(['split', 'split="true"'])( 'client: %s splits the children', async (split) => { @@ -275,51 +436,226 @@ export function Page() { }) describe('the split chunk', () => { + // The server renders the children as JSX text. test.each([ { name: 'entities', text: '& & &', rendered: '& & &' }, + { + name: 'numeric entities', + text: '© © ©', + rendered: '© © ©', + }, { name: 'multi-line text with entities', text: '\n Fish &\n Chips ©\n ', rendered: 'Fish & Chips ©', }, + { + name: 'lines with trailing whitespace', + text: '\n first line \n second line\n ', + rendered: 'first line second line', + }, ])( 'client: a text-only child renders like JSX text: $name', async ({ text, rendered }) => { + const jsx = await evaluateModule(`export const text = <>${text}</>`) + expect(jsx.text).toBe(rendered) const { chunks } = await compileHydrate( 'client', `${head}export function Page() { return <Hydrate>${text}</Hydrate> }`, ) - // Whitespace collapsing aside, the text must render like the - // server-rendered JSX text. - const output = await renderChunk(chunks[0]!) - expect(output.replace(/\s+/g, ' ').trim()).toBe(rendered) + expect(await renderChunk(chunks[0]!)).toBe(rendered) }, ) + describe('the route of the parent module', () => { + /** Router stub recording the routes it creates. */ + const routerStub = (created: Array<string>) => ({ + '@tanstack/react-router': { + createFileRoute: + (id: string) => (options: { loader?: () => unknown }) => { + created.push(id) + return { id, useLoaderData: () => options.loader?.() } + }, + }, + }) + + test.each([ + { + name: 'directly', + code: `export const Route = createFileRoute('/posts')({ component: Page }) +export function Page() { + return <Hydrate><p>{Route.id}</p></Hydrate> +}`, + rendered: '[<p>/posts</p>]', + }, + { + name: 'through a module helper', + code: `export const Route = createFileRoute('/posts')({ + loader: () => 'data', + component: Page, +}) +function Comments() { + return <p>{Route.useLoaderData()}</p> +} +export function Page() { + return <Hydrate><Comments /></Hydrate> +}`, + rendered: '[<p>data</p>]', + }, + { + name: 'exported under a specifier', + code: `const Route = createFileRoute('/posts')({ component: Page }) +export { Route } +export function Page() { + return <Hydrate><p>{Route.id}</p></Hydrate> +}`, + rendered: '[<p>/posts</p>]', + }, + { + name: 'exported as default', + code: `const Route = createFileRoute('/posts')({ component: Page }) +export default Route +export function Page() { + return <Hydrate><p>{Route.id}</p></Hydrate> +}`, + rendered: '[<p>/posts</p>]', + }, + ])( + 'client: children reading Route $name use the one route', + async ({ code, rendered }) => { + const created: Array<string> = [] + const { module } = await loadClientModule( + `${head}import { createFileRoute } from '@tanstack/react-router' +${code}`, + { stubs: routerStub(created) }, + ) + expect(module.Page()).toBe(rendered) + expect(created).toEqual(['/posts']) + }, + ) + + // The chunk imports the route under its export name, quoted when it is + // not an identifier. Source: mutation testing (main dropped `Route` from + // the chunk). + test('client: children reading a Route exported under a string name import it', async () => { + const { chunks } = await compileHydrate( + 'client', + `${head}import { createFileRoute } from '@tanstack/react-router' +const Route = createFileRoute('/posts')({ component: Page }) +export { Route as 'the-route' } +export function Page() { + return <Hydrate><p>{Route.id}</p></Hydrate> +}`, + ) + expect(await getModuleErrors(chunks[0]!)).toEqual([]) + expect( + await renderChunk( + chunks[0]!, + {}, + { + '/test/src/module.tsx': `const Route = { id: '/posts' } +export { Route as 'the-route' }`, + }, + ), + ).toBe('<p>/posts</p>') + }) + + test('client: children reading a Route the module does not export are rejected', async () => { + const error = await compileHydrate( + 'client', + `${head}import { createFileRoute } from '@tanstack/react-router' +const Route = createFileRoute('/posts')({ component: Page }) +export function Page() { + return <Hydrate><p>{Route.id}</p></Hydrate> +}`, + ) + .then(({ parent, plugin }) => + loadChunk(plugin, 'client', getChunkIds(parent)[0]!), + ) + .catch((error: unknown) => error) + expect(compileErrorMessage(error)).toMatch(/\bHydrate\b[^]*\bRoute\b/) + }) + }) + test('client: children may contain return statements', async () => { const { chunks } = await compileHydrate( 'client', `${head}const letters = ['a', 'b'] export function Page() { - return <Hydrate><ul>{letters.map((letter) => { const upper = letter.toUpperCase(); return <li>{upper}</li> })}</ul></Hydrate> + return <Hydrate><ul onClick={() => { return 'click' }}>{letters.map((letter) => { const upper = letter.toUpperCase(); return <li>{upper}</li> })}{(function () { return 'iife' })()}</ul></Hydrate> }`, ) expect(await getModuleErrors(chunks[0]!)).toEqual([]) - expect(await renderChunk(chunks[0]!)).toBe('<ul><li>A</li><li>B</li></ul>') + // Render the click handler's result next to the element. + const runtime = `const __Fragment = Symbol('Fragment') +const __jsx = (type, props, ...children) => { + const text = children.flat(Infinity).filter((c) => c != null && c !== false && c !== true).join('') + const click = props?.onClick ? '(' + props.onClick() + ')' : '' + return '<' + type + '>' + click + text + '</' + type + '>' +} +` + const module = await evaluateModule(chunks[0]!, {}, runtime) + expect(getChunkComponent(module)({})).toBe( + '<ul>(click)<li>A</li><li>B</li>iife</ul>', + ) + }) + + // `<Hydrate children={…}>` is split like JSX children: the children prop + // moves into the chunk, the other props stay on the boundary. + test.each([ + { name: 'last', attributes: 'when={idle()} children={<Chart />}' }, + { name: 'first', attributes: 'children={<Chart />} when={idle()}' }, + ])( + 'client: a children prop ($name) moves into the chunk', + async ({ attributes }) => { + const { parent, chunks } = await compileHydrate( + 'client', + `${head}import { idle } from '@tanstack/react-start/hydration' +import { Chart } from './chart' +export function Page() { + return <Hydrate ${attributes} /> +}`, + ) + expect(importSources(parent)).not.toContain('./chart') + expect(parent).toContain('when={idle()}') + expect(chunks).toHaveLength(1) + expect( + await renderChunk( + chunks[0]!, + {}, + { './chart': { Chart: () => 'chart' } }, + ), + ).toBe('chart') + }, + ) + + test('client: a self-closing boundary without a children prop keeps its props', async () => { + const { parent, chunks } = await compileHydrate( + 'client', + `${head}import { idle } from '@tanstack/react-start/hydration' +export function Page() { + return <Hydrate when={idle()} /> +}`, + ) + expect(await getModuleErrors(parent)).toEqual([]) + expect(parent).toContain('when={idle()}') + for (const chunk of chunks) { + expect(await renderChunk(chunk)).toBeNull() + } }) // Leaving such a boundary unsplit is valid too. - test('client: empty and comment-only boundaries load chunks that render nothing', async () => { + test('client: empty, self-closing and comment-only boundaries load chunks that render nothing', async () => { const { parent, chunks } = await compileHydrate( 'client', `${head}export function Page() { - return <div><Hydrate></Hydrate><Hydrate>{/* nothing */}</Hydrate></div> + return <div><Hydrate></Hydrate><Hydrate /><Hydrate>{/* nothing */}</Hydrate></div> }`, ) expect(await getModuleErrors(parent)).toEqual([]) - expect(chunks.length).toBeLessThanOrEqual(2) + expect(chunks.length).toBeLessThanOrEqual(3) for (const chunk of chunks) { expect(await getModuleErrors(chunk)).toEqual([]) expect(await renderChunk(chunk)).toBeNull() @@ -342,6 +678,26 @@ export function Page() { ).toBeNull() }) + // Regression in the Yuku migration: the chunk's source map names its + // virtual module id (`module.tsx?tss-hydrate=…`) as the source instead of + // the file the children come from, so devtools and error reporters cannot + // find the original file. + test('client: the source map of a chunk names the file the children come from', async () => { + const { parent, plugin } = await compileHydrate( + 'client', + `${head}export function Page() { + return <Hydrate><p>chunk</p></Hydrate> +}`, + ) + const chunk = plugin.loadVirtualModule!({ + id: getChunkIds(parent)[0]!, + root: '/test', + env: 'client', + envName: 'client', + }) + expect(chunk?.map?.sources).toEqual(['/test/src/module.tsx']) + }) + // Source: Qwik optimizer example_capture_imports, // jsx_member_tag_object_is_captured, example_jsx test('client: default, namespace and aliased imports and namespaced names in the children', async () => { @@ -397,6 +753,57 @@ const __jsx = (type, props) => JSON.stringify(props) expect(getChunkComponent(module)({})).toBe(expected.H0()) }) + // Source: Qwik optimizer example_exports, example_ts_enums + test.each([ + { + name: 'exported declarations of every form', + code: `export const [a, { b }] = ['a', { b: 'b' }] +const exp1 = 'e1' +const internal = 'i' +export { exp1, internal as expr2 } +export function foo() { return 'foo' } +export class Bar { static id = 'bar' } +export default function DefaultFn() { return 'default' } +export function Page() { + return <Hydrate><p>{[a, b, exp1, internal, foo(), Bar.id, DefaultFn()].join('|')}</p></Hydrate> +}`, + rendered: '<p>a|b|e1|i|foo|bar|default</p>', + }, + { + name: 'TypeScript enums and namespaces', + code: `export enum Tone { Loud = 'LOUD' } +namespace Labels { export const quiet = 'quiet' } +export function Page() { + return <Hydrate><p>{Tone.Loud + ':' + Labels.quiet}</p></Hydrate> +}`, + rendered: '<p>LOUD:quiet</p>', + }, + ])( + 'client: $name used by the children reach the chunk', + async ({ code, rendered }) => { + // Known limitation: the chunk declares its own copy of these module + // declarations, so the parent and the chunk do not share class + // identity or module state, and initializers run twice. Only the + // rendered output is asserted here. + const { chunks } = await compileHydrate('client', `${head}${code}`) + expect(await getModuleErrors(chunks[0]!)).toEqual([]) + expect(await renderChunk(chunks[0]!)).toBe(rendered) + }, + ) + + // Source: Qwik optimizer example_use_optimization (use* calls inside the + // extracted scope) + test('client: an optional hook call in the children is rejected', async () => { + const error = await compileHydrate( + 'client', + `import { useId } from 'react' +${head}export function Page() { + return <Hydrate><p id={useId?.()}>x</p></Hydrate> +}`, + ).catch((caught: unknown) => caught) + expect(compileErrorMessage(error)).toMatch(/hook/i) + }) + // Source: Qwik optimizer example_default_export test('client: an anonymous default-exported component splits its boundary', async () => { const { parent, chunks } = await compileHydrate( @@ -430,19 +837,32 @@ export function Page() { ).toBe('<p>A|d|{"extra":"x"}</p>') }) - // Source: Qwik optimizer should_preserve_let_when_migrated_into_segment - test('client: a module-level let used only by the children stays mutable', async () => { - const { chunks } = await compileHydrate( - 'client', - `${head}let renders = 0 + // Source: Qwik optimizer should_preserve_let_when_migrated_into_segment; + // React Compiler fixture should-bailout-without-compilation-annotation-mode.js + test.each([ + { name: 'read and written', update: '{++renders}', rendered: ['1', '2'] }, + { + name: 'only written', + update: `{(() => { renders = 'set' })()}`, + rendered: ['', ''], + }, + ])( + 'client: a module-level let only the children use stays mutable when $name', + async ({ update, rendered }) => { + const { chunks } = await compileHydrate( + 'client', + `${head}let renders = 0 export function Page() { - return <Hydrate><p>{++renders}</p></Hydrate> + return <Hydrate><p>${update}</p></Hydrate> }`, - ) - const component = getChunkComponent(await evaluateModule(chunks[0]!)) - expect(component({})).toBe('<p>1</p>') - expect(component({})).toBe('<p>2</p>') - }) + ) + expect(await getModuleErrors(chunks[0]!)).toEqual([]) + const component = getChunkComponent(await evaluateModule(chunks[0]!)) + expect([component({}), component({})]).toEqual( + rendered.map((text) => `<p>${text}</p>`), + ) + }, + ) // Source: Qwik optimizer should_keep_non_migrated_binding_from_shared_destructuring_declarator // (+ _array_destructuring_declarator, _with_rest) diff --git a/packages/start-plugin-core/tests/hydrate-when-transform.test.ts b/packages/start-plugin-core/tests/hydrate-when-transform.test.ts index 442482bd86c..ffde4e57159 100644 --- a/packages/start-plugin-core/tests/hydrate-when-transform.test.ts +++ b/packages/start-plugin-core/tests/hydrate-when-transform.test.ts @@ -1,8 +1,14 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import * as t from '@babel/types' -import { generateFromAst, parseAst } from '@tanstack/router-utils' +import { walk } from 'yuku-ast' +import { + analyzeModule, + cloneModuleAst, + generateModule, + parseExpression, + removeUnusedBindings, +} from '@tanstack/router-utils' import { describe, expect, test } from 'vitest' import { createHydrateCompilerPlugin } from '../src/hydrate-when-transform' import type { CompileStartFrameworkOptions } from '../src/types' @@ -59,6 +65,14 @@ function withSourceHash(id: string, sourceHash: string) { return `${id.slice(0, separatorIndex + 1)}${sourceHash}` } +/** + * Runs only the Hydrate plugin's `transformAst` on a fresh AST, with a minimal + * editor and its own unused-binding cleanup, bypassing `StartCompiler` (no + * Start factories, generated-import insertion or file pragmas). It unit-tests + * the transform's output; tests of what compiled modules do belong in + * hydrate-split-modules.test.ts and hydrate-captures.test.ts, which compile + * through the real pipeline (`compileHydrate` in regression-helpers.ts). + */ function compileHydrate(options: { code: string id: string @@ -70,9 +84,12 @@ function compileHydrate(options: { }) { const plugin = options.plugin ?? createHydrateCompilerPlugin() const envName = options.envName ?? options.env - const ast = parseAst({ code: options.code, sourceFilename: options.id }) + const module = analyzeModule({ code: options.code, filename: options.id }) + const { program: ast, originalNodes } = cloneModuleAst(module) const result = plugin.transformAst?.({ ast, + module, + originalNodes, code: options.code, id: options.id, root: options.root, @@ -81,14 +98,37 @@ function compileHydrate(options: { mode: 'dev', framework: options.framework ?? 'react', providerEnvName: 'ssr', - types: t, - parseExpression: (expressionCode) => t.identifier(expressionCode), + parseExpression, + replaceNode(node, replacement) { + walk(ast, { + enter(current, context) { + if (current === node) { + context.replace(replacement) + context.stop() + } + }, + }) + }, + parentOf(node) { + let parent: import('@yuku-toolchain/types').Node | null = null + walk(ast, { + enter(current, context) { + if (current === node) { + parent = context.parent + context.stop() + } + }, + }) + return parent + }, }) - if (!result) return null + if (!result) { + return null + } - const generated = generateFromAst(ast, { - sourceMaps: true, - sourceFileName: options.id, + removeUnusedBindings(module, ast, originalNodes) + const generated = generateModule(ast, { + source: options.code, filename: options.id, }) diff --git a/packages/start-plugin-core/tests/hydrateWhen/hydrateWhen.test.ts b/packages/start-plugin-core/tests/hydrateWhen/hydrateWhen.test.ts index d47bc3972d8..3253c708d8e 100644 --- a/packages/start-plugin-core/tests/hydrateWhen/hydrateWhen.test.ts +++ b/packages/start-plugin-core/tests/hydrateWhen/hydrateWhen.test.ts @@ -1,7 +1,12 @@ import { readFile, readdir } from 'node:fs/promises' -import { traverse } from '@babel/core' -import * as t from '@babel/types' -import { generateFromAst, parseAst } from '@tanstack/router-utils' +import { walk } from 'yuku-ast' +import { + analyzeModule, + cloneModuleAst, + generateModule, + parseExpression, + removeUnusedBindings, +} from '@tanstack/router-utils' import path from 'pathe' import { describe, expect, test } from 'vitest' import { createHydrateCompilerPlugin } from '../../src/hydrate-when-transform' @@ -68,6 +73,14 @@ function getHydrateBoundariesFromCode(code: string): Array<HydrateBoundary> { return boundaries.sort((a, b) => a.index - b.index) } +/** + * Runs only the Hydrate plugin's `transformAst` on a fresh AST, with a minimal + * editor and its own unused-binding cleanup, bypassing `StartCompiler` (no + * Start factories, generated-import insertion or file pragmas). It unit-tests + * the transform's output; tests of what compiled modules do belong in + * hydrate-split-modules.test.ts and hydrate-captures.test.ts, which compile + * through the real pipeline (`compileHydrate` in regression-helpers.ts). + */ function compile(opts: { env: 'client' | 'server' code: string @@ -79,9 +92,12 @@ function compile(opts: { root: opts.root ?? fixtureRoot, } const plugin = createHydrateCompilerPlugin() - const ast = parseAst({ code: options.code, sourceFilename: options.id }) + const module = analyzeModule({ code: options.code, filename: options.id }) + const { program: ast, originalNodes } = cloneModuleAst(module) const result = plugin.transformAst?.({ ast, + module, + originalNodes, code: options.code, id: options.id, root: options.root, @@ -90,14 +106,37 @@ function compile(opts: { mode: 'dev', framework: 'react', providerEnvName: 'ssr', - types: t, - parseExpression: (expressionCode) => t.identifier(expressionCode), + parseExpression, + replaceNode(node, replacement) { + walk(ast, { + enter(current, context) { + if (current === node) { + context.replace(replacement) + context.stop() + } + }, + }) + }, + parentOf(node) { + let parent: import('@yuku-toolchain/types').Node | null = null + walk(ast, { + enter(current, context) { + if (current === node) { + parent = context.parent + context.stop() + } + }, + }) + return parent + }, }) - if (!result) return null + if (!result) { + return null + } - const generated = generateFromAst(ast, { - sourceMaps: true, - sourceFileName: options.id, + removeUnusedBindings(module, ast, originalNodes) + const generated = generateModule(ast, { + source: options.code, filename: options.id, }) @@ -166,16 +205,12 @@ describe('Hydrate compiler transform fixtures', async () => { code, id: fixtureId('global.tsx'), })! - const output = parseAst({ code: compiled.code }) - const globalReferences: Array<string> = [] - traverse(output, { - ReferencedIdentifier(path) { - if (path.node.name === '_H0' && !path.scope.hasBinding('_H0', true)) { - globalReferences.push(path.node.name) - } - }, - }) - expect(globalReferences).toEqual(['_H0']) + const output = analyzeModule({ code: compiled.code }) + expect( + output.unresolvedReferences.filter( + (reference) => reference.name === '_H0', + ), + ).toHaveLength(1) }) test('retains captured local components and values across extraction', async () => { @@ -200,20 +235,25 @@ describe('Hydrate compiler transform fixtures', async () => { root: fixtureRoot, }) expect(loaded).toBeTruthy() - const output = parseAst({ code: loaded!.code }) + const output = analyzeModule({ code: loaded!.code }) + expect(output.rootScope.find('Page')).toBeNull() + const capturedNames = ['Local', 'count', 'name'] const capturedReferences = new Set<string>() - traverse(output, { - Program(path) { - expect(path.scope.getBinding('Page')).toBeUndefined() - }, - ReferencedIdentifier(path) { - if (['Local', 'count', 'name'].includes(path.node.name)) { - capturedReferences.add(path.node.name) - expect(path.scope.hasBinding(path.node.name, true)).toBe(true) + output.walk({ + enter(node) { + const reference = output.referenceOf(node) + if (reference && capturedNames.includes(reference.name)) { + capturedReferences.add(reference.name) + expect(reference.binding).toBeTruthy() } }, }) - expect([...capturedReferences].sort()).toEqual(['Local', 'count', 'name']) + expect([...capturedReferences].sort()).toEqual(capturedNames) + expect( + output.unresolvedReferences.filter((reference) => + capturedNames.includes(reference.name), + ), + ).toHaveLength(0) }) test('should extract virtual modules and keep nested ids stable', async () => { diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenBasic.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenBasic.tsx index 5f057addab2..88cf5570c0e 100644 --- a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenBasic.tsx +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenBasic.tsx @@ -1,9 +1,9 @@ -const _H = _lazyRouteComponent( - () => import('<fixtureRoot>/hydrateWhenBasic.tsx?tss-hydrate=0_3cf0187f82'), - 'H0', - ), - _H0_preload = _H.preload import { lazyRouteComponent as _lazyRouteComponent } from '@tanstack/react-router' +const _H0 = _lazyRouteComponent( + () => import('<fixtureRoot>/hydrateWhenBasic.tsx?tss-hydrate=0_3cf0187f82'), + 'H0', +) +const _H0_preload = _H0.preload import { Hydrate } from '@tanstack/react-start' import { idle, visible } from '@tanstack/react-start/hydration' import { FallbackPane } from './widgets' @@ -17,7 +17,7 @@ export function Page() { h="0_3cf0187f82" p={_H0_preload} > - {<_H />} + {<_H0 />} </Hydrate> </section> ) diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenMultiple.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenMultiple.tsx index 34203df6ad8..a321df3605d 100644 --- a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenMultiple.tsx +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenMultiple.tsx @@ -1,32 +1,32 @@ -const _H3 = _lazyRouteComponent( +import { lazyRouteComponent as _lazyRouteComponent } from '@tanstack/react-router' +const _H0 = _lazyRouteComponent( () => - import('<fixtureRoot>/hydrateWhenMultiple.tsx?tss-hydrate=2_21aa371e0f'), - 'H2', + import('<fixtureRoot>/hydrateWhenMultiple.tsx?tss-hydrate=0_21aa371e0f'), + 'H0', ) -const _H2 = _lazyRouteComponent( +const _H1 = _lazyRouteComponent( () => import('<fixtureRoot>/hydrateWhenMultiple.tsx?tss-hydrate=1_21aa371e0f'), 'H1', ) -const _H = _lazyRouteComponent( +const _H2 = _lazyRouteComponent( () => - import('<fixtureRoot>/hydrateWhenMultiple.tsx?tss-hydrate=0_21aa371e0f'), - 'H0', + import('<fixtureRoot>/hydrateWhenMultiple.tsx?tss-hydrate=2_21aa371e0f'), + 'H2', ) -import { lazyRouteComponent as _lazyRouteComponent } from '@tanstack/react-router' import { Hydrate } from '@tanstack/react-start' import { load, media, visible } from '@tanstack/react-start/hydration' export function Page() { return ( <> <Hydrate when={load()} h="0_21aa371e0f"> - {<_H />} + {<_H0 />} </Hydrate> <Hydrate when={visible()} h="1_21aa371e0f"> - {<_H2 />} + {<_H1 />} </Hydrate> <Hydrate when={media('(min-width: 800px)')} h="2_21aa371e0f"> - {<_H3 />} + {<_H2 />} </Hydrate> </> ) diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNested.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNested.tsx index adec83789cb..617e6c839cb 100644 --- a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNested.tsx +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNested.tsx @@ -1,12 +1,12 @@ +import { lazyRouteComponent as _lazyRouteComponent } from '@tanstack/react-router' +const _H0 = _lazyRouteComponent( + () => import('<fixtureRoot>/hydrateWhenNested.tsx?tss-hydrate=0_466696e41d'), + 'H0', +) const _H2 = _lazyRouteComponent( () => import('<fixtureRoot>/hydrateWhenNested.tsx?tss-hydrate=2_466696e41d'), 'H2', ) -const _H = _lazyRouteComponent( - () => import('<fixtureRoot>/hydrateWhenNested.tsx?tss-hydrate=0_466696e41d'), - 'H0', -) -import { lazyRouteComponent as _lazyRouteComponent } from '@tanstack/react-router' import { Hydrate } from '@tanstack/react-start' import { idle, visible } from '@tanstack/react-start/hydration' const unused = 'remove me from virtual modules' @@ -14,7 +14,7 @@ export function Page() { return ( <> <Hydrate when={visible()} h="0_466696e41d"> - {<_H />} + {<_H0 />} </Hydrate> <Hydrate when={idle()} h="2_466696e41d"> {<_H2 />} diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNever.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNever.tsx index c2af676fa4f..b24c47d6657 100644 --- a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNever.tsx +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNever.tsx @@ -1,14 +1,14 @@ -const _H = _lazyRouteComponent( +import { lazyRouteComponent as _lazyRouteComponent } from '@tanstack/react-router' +const _H0 = _lazyRouteComponent( () => import('<fixtureRoot>/hydrateWhenNever.tsx?tss-hydrate=0_b752509d76'), 'H0', ) -import { lazyRouteComponent as _lazyRouteComponent } from '@tanstack/react-router' import { Hydrate } from '@tanstack/react-start' import { never } from '@tanstack/react-start/hydration' export function Page() { return ( <Hydrate when={never()} h="0_b752509d76"> - {<_H />} + {<_H0 />} </Hydrate> ) } diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenObjectFallback.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenObjectFallback.tsx index a0353594f24..88953e27b00 100644 --- a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenObjectFallback.tsx +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenObjectFallback.tsx @@ -1,19 +1,19 @@ -const _H3 = _lazyRouteComponent( +import { lazyRouteComponent as _lazyRouteComponent } from '@tanstack/react-router' +const _H0 = _lazyRouteComponent( () => - import('<fixtureRoot>/hydrateWhenObjectFallback.tsx?tss-hydrate=2_7f4dc3aa80'), - 'H2', + import('<fixtureRoot>/hydrateWhenObjectFallback.tsx?tss-hydrate=0_7f4dc3aa80'), + 'H0', ) -const _H2 = _lazyRouteComponent( +const _H1 = _lazyRouteComponent( () => import('<fixtureRoot>/hydrateWhenObjectFallback.tsx?tss-hydrate=1_7f4dc3aa80'), 'H1', ) -const _H = _lazyRouteComponent( +const _H2 = _lazyRouteComponent( () => - import('<fixtureRoot>/hydrateWhenObjectFallback.tsx?tss-hydrate=0_7f4dc3aa80'), - 'H0', + import('<fixtureRoot>/hydrateWhenObjectFallback.tsx?tss-hydrate=2_7f4dc3aa80'), + 'H2', ) -import { lazyRouteComponent as _lazyRouteComponent } from '@tanstack/react-router' import { Hydrate } from '@tanstack/react-start' import { idle, visible } from '@tanstack/react-start/hydration' const spreadProps = { @@ -28,7 +28,7 @@ export function Page() { fallback={<div data-testid="direct-fallback">Direct</div>} h="0_7f4dc3aa80" > - {<_H />} + {<_H0 />} </Hydrate> <Hydrate {...{ @@ -37,10 +37,10 @@ export function Page() { }} h="1_7f4dc3aa80" > - {<_H2 />} + {<_H1 />} </Hydrate> <Hydrate {...spreadProps} h="2_7f4dc3aa80"> - {<_H3 />} + {<_H2 />} </Hydrate> </> ) diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenPrefetchState.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenPrefetchState.tsx index 05028ec03ba..d38ab74d201 100644 --- a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenPrefetchState.tsx +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenPrefetchState.tsx @@ -1,10 +1,10 @@ -const _H = _lazyRouteComponent( - () => - import('<fixtureRoot>/hydrateWhenPrefetchState.tsx?tss-hydrate=0_24b5f958eb'), - 'H0', - ), - _H0_preload = _H.preload import { lazyRouteComponent as _lazyRouteComponent } from '@tanstack/react-router' +const _H0 = _lazyRouteComponent( + () => + import('<fixtureRoot>/hydrateWhenPrefetchState.tsx?tss-hydrate=0_24b5f958eb'), + 'H0', +) +const _H0_preload = _H0.preload import { useState } from 'react' import { Hydrate } from '@tanstack/react-start' import { interaction } from '@tanstack/react-start/hydration' @@ -23,7 +23,7 @@ export function Page() { h="0_24b5f958eb" p={_H0_preload} > - {<_H />} + {<_H0 />} </Hydrate> </section> ) diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenRenamed.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenRenamed.tsx index 509b2b988b6..e6c7af19bc2 100644 --- a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenRenamed.tsx +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenRenamed.tsx @@ -1,14 +1,14 @@ -const _H = _lazyRouteComponent( +import { lazyRouteComponent as _lazyRouteComponent } from '@tanstack/react-router' +const _H0 = _lazyRouteComponent( () => import('<fixtureRoot>/hydrateWhenRenamed.tsx?tss-hydrate=0_f555ef3ac2'), 'H0', ) -import { lazyRouteComponent as _lazyRouteComponent } from '@tanstack/react-router' import { Hydrate as HW } from '@tanstack/react-start' import { interaction } from '@tanstack/react-start/hydration' export function Page() { return ( <HW when={interaction({ events: 'focusin' })} h="0_f555ef3ac2"> - {<_H />} + {<_H0 />} </HW> ) } diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/virtual/hydrateWhenNested.tsx.H0.client.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/virtual/hydrateWhenNested.tsx.H0.client.tsx index f068af9a5ba..fe09f7c3c8c 100644 --- a/packages/start-plugin-core/tests/hydrateWhen/snapshots/virtual/hydrateWhenNested.tsx.H0.client.tsx +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/virtual/hydrateWhenNested.tsx.H0.client.tsx @@ -1,8 +1,8 @@ -const _H = _lazyRouteComponent( +import { lazyRouteComponent as _lazyRouteComponent } from '@tanstack/react-router' +const _H1 = _lazyRouteComponent( () => import('<fixtureRoot>/hydrateWhenNested.tsx?tss-hydrate=1_466696e41d'), 'H1', ) -import { lazyRouteComponent as _lazyRouteComponent } from '@tanstack/react-router' import { Hydrate } from '@tanstack/react-start' import { interaction } from '@tanstack/react-start/hydration' function Outer() { @@ -13,7 +13,7 @@ export function H0() { <> <Outer /> <Hydrate when={interaction()} h="1_466696e41d"> - {<_H />} + {<_H1 />} </Hydrate> </> ) diff --git a/packages/start-plugin-core/tests/importProtection/analysis.exports.test.ts b/packages/start-plugin-core/tests/importProtection/analysis.exports.test.ts index 4cb1081e94c..6ecbb5cb5ff 100644 --- a/packages/start-plugin-core/tests/importProtection/analysis.exports.test.ts +++ b/packages/start-plugin-core/tests/importProtection/analysis.exports.test.ts @@ -59,6 +59,15 @@ describe('collectMockExportNamesBySource', () => { expect(result.get('mod-a')).toEqual(['alpha', 'beta']) }) + test('collects namespace member usage that precedes the import', () => { + const code = [ + `export const read = () => ns.alpha`, + `import * as ns from 'mod-a'`, + ].join('\n') + const result = getMockExportNamesBySource(code) + expect(result.get('mod-a')).toEqual(['alpha']) + }) + test('collects optional namespace member usage', () => { const code = [ `import * as ns from 'mod-a'`, @@ -242,6 +251,24 @@ describe('collectNamedExports', () => { expect(getNamedExports(code)).toEqual(['hello']) }) + test('collects namespace re-exports, enums and namespaces', () => { + const code = [ + `export * as ns from './foo'`, + `export enum Color { Red }`, + `export namespace Util { export const x = 1 }`, + ].join('\n') + expect(getNamedExports(code)).toEqual(['Color', 'Util', 'ns']) + }) + + test('ignores ambient exports', () => { + const code = [ + `export declare const a: string`, + `export declare class B {}`, + `export declare function c(): void`, + ].join('\n') + expect(getNamedExports(code)).toEqual([]) + }) + test('collects string-literal exported names', () => { const code = [`const local = 1`, `export { local as "foo-bar" }`].join('\n') expect(getNamedExports(code)).toEqual(['foo-bar']) diff --git a/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts b/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts index 4580eb4c35b..ab39498b3bb 100644 --- a/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts +++ b/packages/start-plugin-core/tests/importProtection/analysis.usage.test.ts @@ -80,6 +80,34 @@ describe('findPostCompileUsagePos', () => { expect(p!.line).toBe(3) }) + test('ignores a member property named like the import but still finds later usage', () => { + const p = pos( + `import { eq } from 'denied';\nconst a = db.eq(1);\nconst b = eq`, + 'denied', + ) + expect(p).toEqual({ line: 3, column0: 'const b = '.length }) + }) + + test('ignores type positions, which TypeScript erases', () => { + expect( + pos( + `import { Secret, secret } from 'denied';\nconst a: Secret = 1;\ntype S = typeof secret`, + 'denied', + ), + ).toBeUndefined() + }) + + test.each([ + { name: 'a JSX element', usage: '<Secret />' }, + { name: 'a JSX member element', usage: '<ui.Secret />' }, + ])('finds $name usage', ({ usage }) => { + const p = pos( + `import Secret, * as ui from 'denied';\nconst A = () => ${usage}`, + 'denied', + ) + expect(p).toEqual({ line: 2, column0: 'const A = () => <'.length }) + }) + test('ignores shadowed identifiers in nested function scope', () => { const p = pos( `import { x } from 'denied';\nfunction inner(x: any){ return x }\nconst y = x;`, diff --git a/packages/start-plugin-core/tests/importProtection/rewrite.test.ts b/packages/start-plugin-core/tests/importProtection/rewrite.test.ts index 1a2ea2c6e57..d79019a6747 100644 --- a/packages/start-plugin-core/tests/importProtection/rewrite.test.ts +++ b/packages/start-plugin-core/tests/importProtection/rewrite.test.ts @@ -156,6 +156,41 @@ describe('rewriteDeniedImports', () => { expect(out).not.toContain('./server-b') }) + // Source: mutation testing of the rewrite (main named the mock import + // `__tss_deny_0` too, so the module declared it twice). + test('does not reuse a name the module declares for the mock import', () => { + const out = rewrite( + `const __tss_deny_0 = 'mine' +import { foo } from './secret.server' +export const both = [foo, __tss_deny_0]`, + ['./secret.server'], + ) + const mockVar = out.match(/import (\w+) from/)![1] + expect(mockVar).not.toBe('__tss_deny_0') + expect(out).toContain(`const foo = ${mockVar}.foo`) + }) + + // Regression in the Yuku migration: the JSX pragma that follows a directive + // is printed with the denied import it precedes, so the rewrite drops it and + // the module compiles its JSX with the default runtime. In dev, the rewrite + // runs before JSX plugins that come later in the plugin list, such as + // @vitejs/plugin-vue-jsx, which reads `@jsx` from any comment. + // Source: crlf differential run (main kept the comment after the directive). + test('keeps a JSX pragma that follows a directive in effect', async () => { + const result = rewriteDeniedImports( + `'use client' +/** @jsxImportSource preact */ +import { foo } from './secret.server' +export const Page = () => <div>{foo}</div>`, + '/test.tsx', + new Set(['./secret.server']), + ) + const { code } = await transformWithOxc(result!.code, 'module.tsx', { + jsx: { runtime: 'automatic', importSource: 'react' }, + }) + expect(code).toContain('preact/jsx-runtime') + }) + test('returns a source map', () => { const result = rewriteDeniedImports( `import { foo } from './secret.server';`, diff --git a/packages/start-plugin-core/tests/regression-helpers.ts b/packages/start-plugin-core/tests/regression-helpers.ts index dcdc80d79af..acf48cb25b7 100644 --- a/packages/start-plugin-core/tests/regression-helpers.ts +++ b/packages/start-plugin-core/tests/regression-helpers.ts @@ -4,7 +4,8 @@ * `<Hydrate>` children into chunks, and evaluate compiled modules against a * minimal Start runtime. */ -import { transformWithOxc } from 'vite' +import { stripVTControlCharacters } from 'node:util' +import { parseSync, transformWithOxc } from 'vite' import { expect } from 'vitest' import { createHydrateCompilerPlugin } from '../src/hydrate-when-transform' import { @@ -263,6 +264,44 @@ export async function callProvider( return handler({ data }) } +/** + * The message of a compile error without its code frame. A code frame quotes + * the source lines, so matching the whole message would also match the input + * code, whatever the error. + */ +export function compileErrorMessage(error: unknown) { + const message = error instanceof Error ? error.message : String(error) + return stripVTControlCharacters(message) + .split('\n') + .flatMap((line) => { + // A quoted source line: ` 12 | code` or `> 12 | code`. + if (/^\s*>?\s*\d+\s*\|/.test(line)) { + return [] + } + // A marker line, which may end with the message: ` | ^^^ message`. + const marker = /^\s*\|\s*\^*\s*(.*)$/.exec(line) + return (marker ? marker[1]! : line).trim() || [] + }) + .join('\n') +} + +/** The directives of a module's directive prologue, in order. */ +export function directivePrologue(code: string) { + const { program } = parseSync('module.tsx', code, { sourceType: 'module' }) + const directives: Array<string> = [] + for (const statement of program.body) { + if ( + statement.type !== 'ExpressionStatement' || + !('directive' in statement) || + typeof statement.directive !== 'string' + ) { + break + } + directives.push(statement.directive) + } + return directives +} + /** The value of `run`, or the message of the error it throws. */ export function settle(run: () => unknown) { try { @@ -309,15 +348,13 @@ export function loadChunk( /** * Compiles a module with `<Hydrate>` and loads the split chunks it imports, - * ordered by boundary index. + * ordered by boundary index. A module the compiler leaves untouched is its + * own parent, without chunks. */ export async function compileHydrate(env: 'client' | 'server', code: string) { const plugin = createHydrateCompilerPlugin() const { compile } = createStartCompiler({ env, compilerPlugins: [plugin] }) - const parent = await compile(code) - if (parent === null) { - throw new Error('expected the module to be transformed') - } + const parent = (await compile(code)) ?? code const chunks = getChunkIds(parent).map((id) => { const chunk = loadChunk(plugin, env, id) if (chunk === null) { @@ -361,7 +398,7 @@ export function getChunkParams(chunk: string) { components.find(([, name]) => /^H\d+$/.test(name!)) ?? (components.length === 1 ? components[0] : undefined) const params = component?.[2] ?? '' - return [...params.matchAll(/[\w$]+/g)].map(([name]) => name).sort() + return [...params.matchAll(/[\p{L}\p{N}_$]+/gu)].map(([name]) => name).sort() } /** diff --git a/packages/start-plugin-core/tests/start-compiler-factories.test.ts b/packages/start-plugin-core/tests/start-compiler-factories.test.ts index f00175d205c..e818b81fac4 100644 --- a/packages/start-plugin-core/tests/start-compiler-factories.test.ts +++ b/packages/start-plugin-core/tests/start-compiler-factories.test.ts @@ -1,5 +1,7 @@ +import { transformWithOxc } from 'vite' import { describe, expect, test } from 'vitest' import { + callProvider, clientOnlyError, compileAll, compileCode, @@ -20,6 +22,7 @@ import type { Output } from './regression-helpers' // factory, through any chain of project modules. const head = `import { createClientOnlyFn, createIsomorphicFn, createServerOnlyFn } from '@tanstack/react-start'\n` +type Wrap = (expression: string) => string describe('environment-specific functions', () => { // Source: Waku vite-plugin-allow-server.test.ts "keeps only allowServer @@ -116,6 +119,258 @@ export default createMiddleware().server(async ({ next }) => { expect((await evaluateModule(client!)).default).toHaveProperty('server') }) + describe('a middleware chain with a wrapped link', () => { + // A `.ts` module, so that `<any>x` is a type assertion. + const id = '/test/src/module.ts' + const serverLink = `.server(async ({ next }) => next({ context: db.read('SERVER_SECRET') }))` + const clientLink = `.client(async ({ next }) => next({ sendContext: 'client' }))` + const validatorLink = `.validator((data) => check(data, 'VALIDATOR_SECRET'))` + const middlewareModule = (chain: string) => + [ + `import { createMiddleware } from '@tanstack/react-start'`, + `import { db } from './db.server'`, + chain.includes('check(') && `import { check } from './check.server'`, + `export const mw = ${chain}`, + ] + .filter(Boolean) + .join('\n') + + // Records the implementation of each chain link. + const chainOf = (links: Array<[string, any]>): Record<string, any> => { + const link = (method: string) => (impl: unknown) => + chainOf([...links, [method, impl]]) + return { + links, + middleware: link('middleware'), + validator: link('validator'), + server: link('server'), + client: link('client'), + } + } + + // The links an output keeps, each with the result of its implementation. + async function runLinks(output: Output, code: string) { + // The server output leaves a module without env-only code untouched. + const compiled = (await compileCode(output, code, { id })) ?? code + expect(await getModuleErrors(compiled, 'module.ts'), output).toEqual([]) + const { code: javascript } = await transformWithOxc(compiled, 'module.ts') + const { mw } = await evaluateModule(javascript, { + '@tanstack/react-start': { createMiddleware: () => chainOf([]) }, + './db.server': `export const db = { read: (key) => 'db:' + key }`, + './check.server': `export const check = (data, key) => data + ':' + key`, + }) + const links: Array<[string, unknown]> = [] + for (const [method, impl] of mw.links) { + links.push([ + method, + method === 'middleware' + ? impl + : await impl( + method === 'validator' ? 'data' : { next: (opts: any) => opts }, + ), + ]) + } + return { compiled, links } + } + + // Parentheses and each TypeScript wrapper are different AST nodes; the + // last row nests three. + const wrappers = [ + { name: 'parentheses', wrap: (x: string) => `(${x})` }, + { name: 'double parentheses', wrap: (x: string) => `((${x}))` }, + { name: 'as any', wrap: (x: string) => `(${x} as any)` }, + { + name: 'as unknown as T', + wrap: (x: string) => `(${x} as unknown as Middleware)`, + }, + { name: 'satisfies', wrap: (x: string) => `(${x} satisfies unknown)` }, + { name: 'non-null', wrap: (x: string) => `${x}!` }, + { name: 'angle-bracket', wrap: (x: string) => `(<any>${x})` }, + { + name: 'parentheses, as and non-null', + wrap: (x: string) => `((${x}) as any)!`, + }, + ] + const serverResult = { context: 'db:SERVER_SECRET' } + const clientResult = { sendContext: 'client' } + + // Regression in the Yuku migration, found by differential fuzzing and + // confirmed in Vite and Rsbuild builds: when the wrapped part of the chain + // ends with `.server()` and another link follows, the client compile + // throws "Cannot replace a detached output node". `main` builds these. + test.each( + [ + { + end: '.server()', + chain: (wrap: Wrap) => + `${wrap(`createMiddleware({ type: 'function' })${serverLink}`)}${clientLink}`, + clientLinks: [['client', clientResult]], + ssrLinks: [ + ['server', serverResult], + ['client', clientResult], + ], + }, + { + end: '.server(), and the whole chain', + chain: (wrap: Wrap) => + wrap( + `${wrap(`createMiddleware({ type: 'function' })${serverLink}`)}${clientLink}`, + ), + clientLinks: [['client', clientResult]], + ssrLinks: [ + ['server', serverResult], + ['client', clientResult], + ], + }, + { + end: '.middleware().validator().server()', + chain: (wrap: Wrap) => + `${wrap(`createMiddleware({ type: 'function' }).middleware([])${validatorLink}${serverLink}`)}${clientLink}`, + clientLinks: [ + ['middleware', []], + ['client', clientResult], + ], + ssrLinks: [ + ['middleware', []], + ['validator', 'data:VALIDATOR_SECRET'], + ['server', serverResult], + ['client', clientResult], + ], + }, + ].flatMap((chain) => + wrappers.map((wrapper) => ({ ...chain, ...wrapper })), + ), + )( + 'the client strips the server implementation when $name wraps the chain up to $end', + async ({ chain, wrap, clientLinks, ssrLinks }) => { + const code = middlewareModule(chain(wrap)) + const client = await runLinks('client', code) + expect(importSources(client.compiled)).toEqual([]) + expect(client.compiled).not.toMatch(/SERVER_SECRET|VALIDATOR_SECRET/) + expect(client.links).toEqual(clientLinks) + expect((await runLinks('ssr', code)).links).toEqual(ssrLinks) + }, + ) + + // The validator only runs on the server. Source: Vite and Rsbuild builds + // (main kept a wrapped validator link and its import in the client). + test.each([ + { name: 'satisfies', wrap: (x: string) => `(${x} satisfies unknown)` }, + { name: 'as any', wrap: (x: string) => `(${x} as any)` }, + ])( + 'the client strips a validator link wrapped in $name', + async ({ wrap }) => { + const code = middlewareModule( + `${wrap(`createMiddleware({ type: 'function' })${validatorLink}`)}${serverLink}${clientLink}`, + ) + const client = await runLinks('client', code) + expect(importSources(client.compiled)).toEqual([]) + expect(client.compiled).not.toMatch(/SERVER_SECRET|VALIDATOR_SECRET/) + expect(client.links).toEqual([['client', clientResult]]) + expect((await runLinks('ssr', code)).links).toEqual([ + ['validator', 'data:VALIDATOR_SECRET'], + ['server', serverResult], + ['client', clientResult], + ]) + }, + ) + }) + + // Source: differential fuzzing and Vite and Rsbuild builds (main lost the + // implementation of the wrapped link: the output that needs it returned + // undefined). + test.each([ + `(createIsomorphicFn().server(() => 'server') satisfies any).client(() => 'client')`, + `(createIsomorphicFn().server(() => 'server') as any).client(() => 'client')`, + `(createIsomorphicFn().client(() => 'client') as any).server(() => 'server')`, + ])( + 'each output keeps its implementation of an isomorphic fn with a wrapped chain link: %s', + async (chain) => { + const code = `import { createIsomorphicFn } from '@tanstack/react-start' +export const fn = ${chain}` + const results: Record<string, unknown> = {} + for (const output of outputs) { + const compiled = await compileCode(output, code) + results[output] = (await evaluateModule(compiled!)).fn!() + } + expect(results).toEqual({ + client: 'client', + ssr: 'server', + provider: 'server', + }) + }, + ) + + // Source: Vite and Rsbuild builds. `main` left these calls untransformed: + // the client bundle shipped the server implementation and its imports, and + // ran it in the browser; a client-only fn ran during SSR. + test.each( + [ + { + shape: 'createServerOnlyFn(fn)', + code: (wrap: Wrap) => + `${wrap('createServerOnlyFn')}(() => db.x('SERVER_SECRET'))`, + client: serverOnlyError, + server: 'db:SERVER_SECRET', + }, + { + shape: 'createIsomorphicFn().server().client()', + code: (wrap: Wrap) => + `${wrap('createIsomorphicFn')}().server(() => db.x('SERVER_SECRET')).client(() => chart())`, + client: 'chart', + server: 'db:SERVER_SECRET', + }, + { + shape: 'createIsomorphicFn().client().server()', + code: (wrap: Wrap) => + `${wrap('createIsomorphicFn')}().client(() => chart()).server(() => db.x('SERVER_SECRET'))`, + client: 'chart', + server: 'db:SERVER_SECRET', + }, + { + shape: 'createClientOnlyFn(fn)', + code: (wrap: Wrap) => `${wrap('createClientOnlyFn')}(() => chart())`, + client: 'chart', + server: clientOnlyError, + }, + ].flatMap((row) => + [ + { name: 'as any', wrap: (x: string) => `(${x} as any)` }, + { name: 'non-null', wrap: (x: string) => `${x}!` }, + ].map((wrapper) => ({ ...row, ...wrapper })), + ), + )( + 'each output keeps its implementation of $shape with the callee in $name', + async ({ code: factory, wrap, client, server }) => { + const code = `export const fn = ${factory(wrap)}` + const source = [ + head, + code.includes('db.x') && `import { db } from './db.server'\n`, + code.includes('chart()') && `import { chart } from './chart.client'\n`, + code, + ] + .filter(Boolean) + .join('') + const stubs = { + './db.server': `export const db = { x: (key) => 'db:' + key }`, + './chart.client': `export const chart = () => 'chart'`, + } + const compiled = { + client: (await compileCode('client', source)) ?? source, + ssr: (await compileCode('ssr', source)) ?? source, + } + expect(importSources(compiled.client)).not.toContain('./db.server') + expect(compiled.client).not.toContain('SERVER_SECRET') + expect(importSources(compiled.ssr)).not.toContain('./chart.client') + expect(settle((await evaluateModule(compiled.client, stubs)).fn)).toBe( + client, + ) + expect(settle((await evaluateModule(compiled.ssr, stubs)).fn)).toBe( + server, + ) + }, + ) + // SolidStart rejects these captures because it hoists `"use server"` // functions; env-only functions stay where they are written. // Source: SolidStart directives validate.ts (assertHoistable), compile.spec.ts @@ -172,28 +427,64 @@ export const mw = createMiddleware().server(async ({ next }) => }) // Source: babel-dead-code-elimination "variable" > "within for...in" - // Hook calls are different (dropping one changes the hook order; pinned - // separately); a plain server call like this one must go. - test('the client drops a var nested in a function block that only the server implementation reads', async () => { + // Hook calls are different (dropping one changes the hook order); a plain + // server call like these must go. + test('the client drops vars nested in blocks that only the server implementation reads', async () => { const client = await compileCode( 'client', - `${head}import { serverCall } from './db.server' + `${head}import { connect, serverCall } from './db.server' +if (typeof window === 'undefined') { + var connection = connect() +} export function getValue(flag: boolean) { if (flag) { var local = serverCall() } - const read = createIsomorphicFn().server(() => local).client(() => 'client') + const read = createIsomorphicFn().server(() => [connection, local]).client(() => 'client') return read() }`, ) expect(await getModuleErrors(client!)).toEqual([]) expect(importSources(client!)).toEqual([]) - expect(client).not.toContain('serverCall') + expect(client).not.toMatch(/\b(?:connect|serverCall)\b/) + expect((await evaluateModule(client!)).getValue(true)).toBe('client') + }) + + // Source: SolidStart directives plugin.ts transformFunction (directive + // functions nested in a server function are compiled too) + test('the provider compiles env-specific functions inside a server fn handler', async () => { + const provider = await compileCode( + 'provider', + `import { createServerFn, createClientOnlyFn, createIsomorphicFn, createServerOnlyFn } from '@tanstack/react-start' +import { chart } from './chart.client' +import { db } from './db.server' +export const fn = createServerFn().handler(async () => { + const draw = createClientOnlyFn(() => chart()) + const now = createIsomorphicFn().server(() => db.now()).client(() => chart()) + const read = createServerOnlyFn(() => db.read()) + return [settle(draw), now(), read()] +}) +function settle(run: () => unknown) { + try { + return run() + } catch (error) { + return (error as Error).message + } +}`, + ) + expect(await getModuleErrors(provider!)).toEqual([]) + expect(importSources(provider!)).toEqual(['./db.server']) + expect( + await callProvider(provider!, 'fn', { + './db.server': `export const db = { now: () => 'now', read: () => 'read' }`, + }), + ).toEqual([clientOnlyError.slice('throws: '.length), 'now', 'read']) }) // Source: Waku vite-plugin-allow-server.test.ts "skips files without a use // client directive even if the string exists", "does not require - // allowServer to come from waku/client" (inverted) + // allowServer to come from waku/client" (inverted: only calls of the Start + // imports are compiled) test.each([ { name: 'only mentions the factories in strings and comments', @@ -220,6 +511,62 @@ export const value = createServerOnlyFn(() => 'mine')`, } }) + // Source: Waku vite-plugin-allow-server.test.ts "does not require + // allowServer to come from waku/client" (inverted) + test.each(outputs)( + '%s: parameters and locals shadowing the factories are left alone next to a compiled factory call', + async (output) => { + const code = `import * as Start from '@tanstack/react-start' +import { createServerFn, createServerOnlyFn, createClientOnlyFn, createIsomorphicFn, createMiddleware } from '@tanstack/react-start' +export const real = createServerOnlyFn(() => 'real') +export function wrap(createServerOnlyFn) { + return createServerOnlyFn(() => 'param') +} +export function receiver(Start) { + return (Start).createServerOnlyFn(() => 'receiver') +} +export function local() { + const createClientOnlyFn = (fn: () => string) => fn + return createClientOnlyFn(() => 'local') +} +export function iso(createIsomorphicFn) { + return createIsomorphicFn().server(() => 's').client(() => 'c') +} +export function mw(createMiddleware) { + return createMiddleware().server(() => 's') +} +export function make(createServerFn) { + const made = createServerFn().handler(() => 'param') + return made +}` + const module = await evaluateModule((await compileCode(output, code))!) + const identity = (fn: () => string) => fn + const builder = { + server: (server: () => string) => ({ + client: (client: () => string) => [server(), client()], + }), + handler: (handler: () => string) => handler(), + } + expect({ + real: settle(module.real), + wrap: module.wrap(identity)(), + receiver: module.receiver({ createServerOnlyFn: identity })(), + local: module.local()(), + iso: module.iso(() => builder), + mw: module.mw(() => ({ server: (fn: () => string) => fn() })), + make: module.make(() => builder), + }).toEqual({ + real: output === 'client' ? serverOnlyError : 'real', + wrap: 'param', + receiver: 'receiver', + local: 'local', + iso: ['s', 'c'], + mw: 's', + make: 'param', + }) + }, + ) + // Source: Waku vite-plugin-allow-server.test.ts "throws when allowServer // receives zero arguments" test.each([ @@ -265,7 +612,7 @@ describe('factories resolved through project modules', () => { '/test/src/mw.ts': `import { createMiddleware } from '@tanstack/react-start' export default createMiddleware({ type: 'function' })`, }, - // Known limitation on main: middleware is detected by the + // Known limitation: middleware is detected by the // `createMiddleware` text, so a module that only calls an imported // builder is not compiled and needs this import. code: `import { createMiddleware } from '@tanstack/react-start' diff --git a/packages/start-plugin-core/tests/start-compiler-jsx-pragma.test.ts b/packages/start-plugin-core/tests/start-compiler-jsx-pragma.test.ts index a531efdddb5..fdf576a9bb2 100644 --- a/packages/start-plugin-core/tests/start-compiler-jsx-pragma.test.ts +++ b/packages/start-plugin-core/tests/start-compiler-jsx-pragma.test.ts @@ -1,7 +1,15 @@ import { transformWithOxc } from 'vite' import { expect, test } from 'vitest' import { createHydrateCompilerPlugin } from '../src/hydrate-when-transform' -import { compileCode, compileHydrate } from './regression-helpers' +import { + compileCode, + compileHydrate, + evaluateHydrateParent, + evaluateModule, + getChunkComponent, + hydrateParentStubs, + outputs, +} from './regression-helpers' /** * A file-level JSX pragma decides which JSX runtime the bundler's own JSX @@ -91,6 +99,26 @@ export function List() { expect(await transformJsx(client!)).toMatch(/\bh\(Fragment\b/) }) +// Oxc (Vite 8's JSX transform) only reads JSX pragmas from the comments that +// lead the file, so the imports the compiler inserts must stay below them. +test.each(outputs)( + '%s: inserted server function imports stay below the @jsxImportSource pragma', + async (output) => { + const compiled = await compileCode( + output, + `/** @jsxImportSource @emotion/react */ +import { createServerFn } from '@tanstack/react-start' +export const getGreeting = createServerFn().handler(async () => ( + <b css={{ color: 'hotpink' }}>hi</b> +)) +export function Card() { + return <div css={{ color: 'hotpink' }}>card</div> +}`, + ) + expect(await jsxRuntimeOf(compiled!)).toBe('@emotion/react/jsx-runtime') + }, +) + test('client: a pragma after a directive stays ahead of inserted server function imports', async () => { const client = await compileCode( 'client', @@ -122,14 +150,28 @@ export function Card() { expect(await jsxRuntimeOf(chunks[0]!)).toBe('@emotion/react/jsx-runtime') }) -test('client: the parent keeps the pragma when the import it sits on moves into a Hydrate chunk', async () => { - // Vite 7 and earlier transform JSX with esbuild, which honours the pragma - // wherever it appears; check the comment itself survives. - const parent = await compileCode( - 'client', - `/** @jsxImportSource @emotion/react */ -import { Widget } from './widget' -import { Hydrate } from '@tanstack/react-start' +// Whether the import the pragma sits on moves into the chunk or stays, the +// parent keeps the pragma ahead of the chunk loaders it inserts. Vite 7 and +// earlier transform JSX with esbuild, which honours the pragma wherever it +// appears, so the comment itself must survive too. +test.each([ + { + name: 'moves into a Hydrate chunk', + imports: `import { Widget } from './widget' +import { Hydrate } from '@tanstack/react-start'`, + }, + { + name: 'stays in the parent', + imports: `import { Hydrate } from '@tanstack/react-start' +import { Widget } from './widget'`, + }, +])( + 'client: the parent keeps the pragma when the import it sits on $name', + async ({ imports }) => { + const parent = await compileCode( + 'client', + `/** @jsxImportSource @emotion/react */ +${imports} export function Card() { return ( <div css={{ color: 'hotpink' }}> @@ -139,7 +181,103 @@ export function Card() { </div> ) }`, - { compilerPlugins: [createHydrateCompilerPlugin()] }, + { compilerPlugins: [createHydrateCompilerPlugin()] }, + ) + expect(parent).toContain('@jsxImportSource @emotion/react') + expect(await jsxRuntimeOf(parent!)).toBe('@emotion/react/jsx-runtime') + }, +) + +/** Classic JSX factory: intrinsic elements render as tags, components are called. */ +const renderJsx = (type: unknown, props: unknown, ...children: Array<any>) => { + const text = children + .flat(Infinity) + .filter((child) => child != null && child !== false && child !== true) + .join('') + if (typeof type === 'function') { + return type({ ...(props as object), children: text }) + } + return typeof type === 'string' ? `<${type}>${text}</${type}>` : text +} + +// With the classic runtime, JSX compiles to calls of the factories the file's +// pragmas name: their imports stay wherever JSX does, even when the code +// calling them explicitly moves to the server. +test.each([ + { + name: '@jsx factory', + pragmas: '/** @jsx h */', + imports: 'h', + handler: `h('p', null)`, + component: '<div>card</div>', + rendered: '<div>card</div>', + }, + { + name: '@jsxFrag factory', + pragmas: '/** @jsx h */\n/** @jsxFrag Fragment */', + imports: 'h, Fragment', + handler: `h(Fragment, null)`, + component: '<>card</>', + rendered: 'card', + }, +])( + 'client: the $name stays imported for the JSX left once server functions are compiled', + async ({ pragmas, imports, handler, component, rendered }) => { + const client = await compileCode( + 'client', + `${pragmas} +import { createServerFn } from '@tanstack/react-start' +import { ${imports} } from './jsx' +export const fn = createServerFn().handler(async () => ${handler}) +export const Card = () => ${component}`, + ) + const module = await evaluateModule(client!, { + './jsx': { h: renderJsx, Fragment: Symbol('Fragment') }, + }) + expect(module.Card()).toBe(rendered) + }, +) + +test('client: a Hydrate parent and its chunk keep the @jsx factory their JSX compiles to', async () => { + const { parent, chunks, plugin } = await compileHydrate( + 'client', + `/** @jsx h */ +import { h } from 'preact' +import { Hydrate, createServerFn } from '@tanstack/react-start' +export const fn = createServerFn().handler(async () => h('p', null)) +export function Page() { + return ( + <main> + <Hydrate> + <b>chunk</b> + </Hydrate> + </main> + ) +}`, + ) + let chunkComponent: (props: Record<string, unknown>) => unknown = () => null + const stubs = { + ...hydrateParentStubs, + preact: { h: renderJsx }, + '@tanstack/react-start': { + ...hydrateParentStubs['@tanstack/react-start'], + createServerFn: () => ({ handler: () => () => null }), + }, + '@tanstack/react-router': { + lazyRouteComponent: () => (props: Record<string, unknown>) => + chunkComponent(props), + }, + } + const { module, parentModuleStubs } = await evaluateHydrateParent( + plugin, + parent, + stubs, + ) + chunkComponent = getChunkComponent( + await evaluateModule(chunks[0]!, { + ...stubs, + ...(await parentModuleStubs(chunks[0]!)), + }), ) - expect(parent).toContain('@jsxImportSource @emotion/react') + expect(module.Page()).toBe('<main>[<b>chunk</b>]</main>') }) diff --git a/packages/start-plugin-core/tests/start-compiler-preserves-user-code.test.ts b/packages/start-plugin-core/tests/start-compiler-preserves-user-code.test.ts index fae6872b272..8e634489452 100644 --- a/packages/start-plugin-core/tests/start-compiler-preserves-user-code.test.ts +++ b/packages/start-plugin-core/tests/start-compiler-preserves-user-code.test.ts @@ -1,6 +1,10 @@ +import { SourceMapConsumer } from 'source-map' import { describe, expect, test } from 'vitest' +import { detectKindsInCode } from '../src/start-compiler/compiler' import { compileCode, + compileHydrate, + createStartCompiler, evaluateModule, importSources, } from './regression-helpers' @@ -32,6 +36,77 @@ const persister = createPersister({ onRestore: () => restore() }) function restore() { persister.restore() } +export const fn = createServerFn().handler(async () => 1)`, + }, + { + // Regression in the Yuku migration: the only reads are an unused local + // inside the declaration itself, and the client output drops both + // declarations with their register() calls. + // Inputs adapted from @babel/plugin-proposal-decorators + // 2023-11-misc/destructuring-transform-integration and TypeScript's + // conformance test classes/propertyMemberDeclarations/autoAccessor10.ts. + name: 'declarations only an unused local inside them reads', + sideEffect: "register('widget')", + declared: ['plugin', 'Widget'], + code: `import { createServerFn } from '@tanstack/react-start' +import { register } from './registry' +const plugin = { + id: register('plugin'), + describe() { + const self = plugin + return 'plugin' + }, +} +class Widget { + static { + register('widget') + } + render() { + const type = Widget + return 'widget' + } +} +export const fn = createServerFn().handler(async () => 1)`, + }, + { + // Regression in the Yuku migration, same cause: the destructuring runs the + // getter, and the client output drops it with the register() call. + name: 'a destructuring only an unused local inside it reads', + sideEffect: "register('getter')", + declared: [], + code: `import { createServerFn } from '@tanstack/react-start' +import { register } from './registry' +const { plugin } = { + get plugin() { + register('getter') + return { + describe() { + const self = plugin + return 'plugin' + }, + } + }, +} +export const fn = createServerFn().handler(async () => 1)`, + }, + { + // Regression in the Yuku migration, same cause inside a function body: an + // uncalled nested function that reads itself through an unused local is + // deleted, and then the statement only it read, with its register() call, + // which ran on every call of Page. + name: 'a statement in a function body that only an uncalled nested function reads', + sideEffect: "register('nested-effect')", + declared: ['effect'], + code: `import { createServerFn } from '@tanstack/react-start' +import { register } from './registry' +export function Page() { + function describe() { + const self = describe + return effect + } + const effect = register('nested-effect') + return 'page' +} export const fn = createServerFn().handler(async () => 1)`, }, ])('keeps $name', async ({ code, sideEffect, declared }) => { @@ -140,3 +215,150 @@ export const fn = createServerFn().handler(async () => red)`, ) expect((await evaluateModule(client!)).label).toBe('red') }) + +// An empty destructuring pattern binds nothing, but its initializer still +// runs. +// Source: babel-dead-code-elimination "object pattern" > "unzips if all +// variables are unused", "array pattern" > "unzips if all variables are +// unused" +test.each(['client', 'provider'] as const)( + '%s: keeps the initializers of empty destructuring patterns', + async (output) => { + const result = await compileCode( + output, + `import { createServerFn } from '@tanstack/react-start' +import { init, other } from './init' +const {} = init() +const [] = other() +const { a: {} } = init() +export const fn = createServerFn().handler(async () => 'ok')`, + ) + const calls: Array<string> = [] + await evaluateModule(result!, { + './init': { + init: () => { + calls.push('init') + return { a: {} } + }, + other: () => { + calls.push('other') + return [] + }, + }, + }) + expect(calls).toEqual(['init', 'other', 'init']) + }, +) + +// Source: typescript-eslint type-assertion/increment/as-increment.js, +// type-assertion/increment/non-null-increment.js, type-assertion/satisfies.js +test('keeps the parentheses of asserted update operands', async () => { + const client = await compileCode( + 'client', + `import { createServerFn } from '@tanstack/react-start' +export const fn = createServerFn().handler(async () => 1) +let count = 0 +export function bump() { + ;(count as number)++ + ;(count satisfies number)++ + count!++ + ;(count as any) += 1 + return count +}`, + ) + expect(await getModuleErrors(client!)).toEqual([]) + expect((await evaluateModule(client!)).bump()).toBe(4) +}) + +// Bundlers read these comments: minifiers keep legal comments, and Rollup and +// Rolldown treat calls of functions annotated `__NO_SIDE_EFFECTS__` as pure. +describe('keeps the comments bundlers read', () => { + const legal = '/*! Example Corp. | MIT License */' + + test('client: a leading legal comment survives the removal of the import it leads', async () => { + const client = await compileCode( + 'client', + `${legal} +import { createIsomorphicFn } from '@tanstack/react-start' +const where = createIsomorphicFn() + .server(() => 'server') + .client(() => 'client') +export const label = () => where()`, + ) + expect(client).toContain(legal) + }) + + test('client: a Hydrate chunk keeps the leading legal comment of its module', async () => { + const { chunks } = await compileHydrate( + 'client', + `${legal} +import { Hydrate } from '@tanstack/react-start' +export function Page() { + return <Hydrate><p>hi</p></Hydrate> +}`, + ) + expect(chunks[0]).toContain(legal) + }) + + test.each([ + { + name: 'function', + code: `/* #__NO_SIDE_EFFECTS__ */ +export function makeLabel() { + return 'label' +}`, + }, + { + name: 'arrow function', + code: `/* @__NO_SIDE_EFFECTS__ */ +export const makeLabel = () => 'label'`, + }, + ])( + 'the annotation of an exported $name copied to a Hydrate chunk and the provider', + async ({ code }) => { + const source = `import { Hydrate, createServerFn } from '@tanstack/react-start' +${code} +export const fn = createServerFn().handler(async () => makeLabel()) +export function Page() { + return <Hydrate><p>{makeLabel()}</p></Hydrate> +}` + const { chunks } = await compileHydrate('client', source) + const provider = await compileCode('provider', source) + for (const output of [chunks[0]!, provider!]) { + expect(output).toMatch( + /__NO_SIDE_EFFECTS__ \*\/\s*(?:function|const) makeLabel\b/, + ) + } + }, + ) +}) + +// Regression in the Yuku migration: the source map maps the string value of a +// JSX attribute to the start of the attribute name. Yuku codegen bug, +// https://github.com/yuku-toolchain/yuku/issues/254; passes once Yuku ships the +// fix. +test('maps the string value of a JSX attribute to its own source column', async () => { + const code = `import { createServerFn } from '@tanstack/react-start' +export const fn = createServerFn().handler(async () => 1) +export const Box = () => <div className="box" /> +` + const { compiler } = createStartCompiler({ env: 'client' }) + const result = await compiler.compile({ + code, + id: '/test/src/module.tsx', + detectedKinds: detectKindsInCode(code, 'client'), + }) + const lines = result!.code.split('\n') + const line = lines.findIndex((text) => text.includes('"box"')) + const consumer = await new SourceMapConsumer(result!.map as never) + try { + expect( + consumer.originalPositionFor({ + line: line + 1, + column: lines[line]!.indexOf('"box"'), + }), + ).toMatchObject({ line: 3, column: code.split('\n')[2]!.indexOf('"box"') }) + } finally { + consumer.destroy() + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47be5f55103..e47a118da81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,12 +9,6 @@ catalogs: '@arethetypeswrong/cli': specifier: ^0.18.4 version: 0.18.4 - '@babel/core': - specifier: ^7.29.7 - version: 7.29.7 - '@babel/types': - specifier: ^7.29.8 - version: 7.29.8 '@eslint-react/eslint-plugin': specifier: ^1.26.2 version: 1.26.2 @@ -39,6 +33,12 @@ catalogs: '@vitest/ui': specifier: ^4.1.4 version: 4.1.4 + '@yuku-core/wasm': + specifier: 0.19.0 + version: 0.19.0 + '@yuku-toolchain/types': + specifier: 0.19.0 + version: 0.19.0 chokidar: specifier: ^5.0.0 version: 5.0.0 @@ -78,6 +78,18 @@ catalogs: vue: specifier: ^3.5.25 version: 3.5.25 + yuku-analyzer: + specifier: 0.19.0 + version: 0.19.0 + yuku-ast: + specifier: 0.19.0 + version: 0.19.0 + yuku-codegen: + specifier: 0.19.0 + version: 0.19.0 + yuku-core: + specifier: 0.19.0 + version: 0.19.0 zod: specifier: ^4.5.4 version: 4.5.4 @@ -4249,7 +4261,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic: dependencies: @@ -4283,7 +4295,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic-esbuild-file-based: dependencies: @@ -4317,7 +4329,7 @@ importers: version: 0.27.4 esbuild-plugin-solid: specifier: ^0.6.0 - version: 0.6.0(esbuild@0.27.4)(solid-js@1.9.12)(supports-color@7.2.0) + version: 0.6.0(esbuild@0.27.4)(solid-js@1.9.12)(supports-color@10.2.2) e2e/solid-router/basic-file-based: dependencies: @@ -4360,7 +4372,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic-file-based-code-splitting: dependencies: @@ -4400,7 +4412,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic-scroll-restoration: dependencies: @@ -4440,7 +4452,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic-solid-query: dependencies: @@ -4480,7 +4492,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic-solid-query-file-based: dependencies: @@ -4526,7 +4538,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic-virtual-file-based: dependencies: @@ -4569,7 +4581,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/basic-virtual-named-export-config-file-based: dependencies: @@ -4612,7 +4624,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/generator-cli-only: dependencies: @@ -4649,7 +4661,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/js-only-file-based: dependencies: @@ -4686,7 +4698,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/rspack-basic-file-based: dependencies: @@ -4827,7 +4839,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/sentry-integration: dependencies: @@ -4839,7 +4851,7 @@ importers: version: 7.120.4 '@sentry/vite-plugin': specifier: ^4.6.1 - version: 4.6.1(supports-color@7.2.0) + version: 4.6.1(supports-color@10.2.2) '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) @@ -4870,7 +4882,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-router/view-transitions: dependencies: @@ -4916,7 +4928,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/basic: dependencies: @@ -5068,7 +5080,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/basic-cloudflare: dependencies: @@ -5114,7 +5126,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) wrangler: specifier: ^4.74.0 version: 4.75.0 @@ -5181,7 +5193,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/basic-tsr-config: dependencies: @@ -5221,7 +5233,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/csp: dependencies: @@ -5258,7 +5270,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/css-modules: dependencies: @@ -5298,7 +5310,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/custom-basepath: dependencies: @@ -5442,7 +5454,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/query-integration: dependencies: @@ -5500,7 +5512,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/scroll-restoration: dependencies: @@ -5558,7 +5570,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/selective-ssr: dependencies: @@ -5598,7 +5610,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/serialization-adapters: dependencies: @@ -5644,7 +5656,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/server-functions: dependencies: @@ -5714,7 +5726,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/server-routes: dependencies: @@ -5784,7 +5796,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/solid-query-layout-suspense: dependencies: @@ -5821,7 +5833,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/spa-mode: dependencies: @@ -5861,7 +5873,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/start-manifest: dependencies: @@ -5898,7 +5910,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/streaming-ssr: dependencies: @@ -5941,7 +5953,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/virtual-routes: dependencies: @@ -6002,7 +6014,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/solid-start/website: dependencies: @@ -6057,7 +6069,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) e2e/vue-router/basepath-file-based: dependencies: @@ -6100,7 +6112,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 @@ -6140,7 +6152,7 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 @@ -6314,7 +6326,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 @@ -6360,7 +6372,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.9.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.9.3)) typescript: specifier: ~5.9.0 version: 5.9.3 @@ -6415,7 +6427,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 @@ -6470,7 +6482,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 @@ -6519,7 +6531,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 @@ -6574,7 +6586,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 @@ -6620,7 +6632,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 @@ -6672,7 +6684,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@7.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@7.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@7.0.2)) vite: specifier: ^8.3.4 version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) @@ -6831,7 +6843,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 @@ -6849,7 +6861,7 @@ importers: version: 7.120.4 '@sentry/vite-plugin': specifier: ^4.6.1 - version: 4.6.1(supports-color@7.2.0) + version: 4.6.1(supports-color@10.2.2) '@sentry/vue': specifier: ^10.32.0 version: 10.32.0(@tanstack/vue-router@packages+vue-router)(vue@3.5.25(typescript@5.8.3)) @@ -6880,7 +6892,7 @@ importers: version: link:../../e2e-utils '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 @@ -6932,7 +6944,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 @@ -7084,7 +7096,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -7142,7 +7154,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -7185,7 +7197,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) rimraf: specifier: 'catalog:' version: 6.1.2 @@ -7252,7 +7264,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -7292,7 +7304,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) sass: specifier: ^1.97.2 version: 1.97.2 @@ -7402,7 +7414,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -7463,7 +7475,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -7518,7 +7530,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) combinate: specifier: ^1.1.11 version: 1.1.11 @@ -7564,7 +7576,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -7613,7 +7625,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -7686,7 +7698,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) combinate: specifier: ^1.1.11 version: 1.1.11 @@ -7759,7 +7771,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) combinate: specifier: ^1.1.11 version: 1.1.11 @@ -7808,7 +7820,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -7845,7 +7857,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -7888,7 +7900,7 @@ importers: version: typescript@7.0.2 '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -7946,7 +7958,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) combinate: specifier: ^1.1.11 version: 1.1.11 @@ -8007,7 +8019,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) srvx: specifier: ^1.0.0 version: 1.0.5 @@ -11535,7 +11547,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/authenticated-routes-firebase: dependencies: @@ -11581,7 +11593,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic: dependencies: @@ -11621,7 +11633,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-default-search-params: dependencies: @@ -11661,7 +11673,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-devtools-panel: dependencies: @@ -11695,7 +11707,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-file-based: dependencies: @@ -11735,7 +11747,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-non-nested-devtools: dependencies: @@ -11775,7 +11787,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-solid-query: dependencies: @@ -11818,7 +11830,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-solid-query-file-based: dependencies: @@ -11864,7 +11876,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-ssr-file-based: dependencies: @@ -12005,7 +12017,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/basic-virtual-inside-file-based: dependencies: @@ -12048,7 +12060,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/deferred-data: dependencies: @@ -12085,7 +12097,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/i18n-paraglide: dependencies: @@ -12122,7 +12134,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/kitchen-sink: dependencies: @@ -12162,7 +12174,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/kitchen-sink-file-based: dependencies: @@ -12205,7 +12217,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/kitchen-sink-solid-query: dependencies: @@ -12251,7 +12263,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/kitchen-sink-solid-query-file-based: dependencies: @@ -12300,7 +12312,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/large-file-based: dependencies: @@ -12343,7 +12355,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/location-masking: dependencies: @@ -12380,7 +12392,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/navigation-blocking: dependencies: @@ -12417,7 +12429,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/quickstart: dependencies: @@ -12448,7 +12460,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/quickstart-esbuild-file-based: dependencies: @@ -12479,7 +12491,7 @@ importers: version: 0.27.4 esbuild-plugin-solid: specifier: ^0.6.0 - version: 0.6.0(esbuild@0.27.4)(solid-js@1.9.12)(supports-color@7.2.0) + version: 0.6.0(esbuild@0.27.4)(solid-js@1.9.12)(supports-color@10.2.2) examples/solid/quickstart-file-based: dependencies: @@ -12519,7 +12531,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/quickstart-rspack-file-based: dependencies: @@ -12771,7 +12783,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/search-validator-adapters: dependencies: @@ -12884,7 +12896,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-basic-auth: dependencies: @@ -12945,7 +12957,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-basic-authjs: dependencies: @@ -12991,7 +13003,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-basic-cloudflare: dependencies: @@ -13031,7 +13043,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) wrangler: specifier: ^4.74.0 version: 4.75.0 @@ -13114,7 +13126,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-basic-solid-query: dependencies: @@ -13166,7 +13178,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-basic-static: dependencies: @@ -13212,7 +13224,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-bun: dependencies: @@ -13389,7 +13401,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-i18n-paraglide: dependencies: @@ -13432,7 +13444,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-large: dependencies: @@ -13481,7 +13493,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-streaming-data-from-server-functions: dependencies: @@ -13515,7 +13527,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-supabase-basic: dependencies: @@ -13561,7 +13573,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/start-tailwind-v4: dependencies: @@ -13604,7 +13616,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/view-transitions: dependencies: @@ -13644,7 +13656,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/with-framer-motion: dependencies: @@ -13684,7 +13696,7 @@ importers: version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.11 - version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) examples/solid/with-trpc: dependencies: @@ -13764,7 +13776,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' @@ -13865,7 +13877,7 @@ importers: version: 6.0.5(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) + version: 5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3)) typescript: specifier: ~5.8.3 version: 5.8.3 @@ -14371,6 +14383,9 @@ importers: pathe: specifier: 'catalog:' version: 2.0.3 + yuku-ast: + specifier: 'catalog:' + version: 0.19.0 devDependencies: '@arethetypeswrong/cli': specifier: 'catalog:' @@ -14665,9 +14680,6 @@ importers: packages/router-generator: dependencies: - '@babel/types': - specifier: 'catalog:' - version: 7.29.8 '@tanstack/router-core': specifier: workspace:* version: link:../router-core @@ -14677,6 +14689,9 @@ importers: '@tanstack/virtual-file-routes': specifier: workspace:* version: link:../virtual-file-routes + '@yuku-toolchain/types': + specifier: 'catalog:' + version: 0.19.0 jiti: specifier: ^2.7.0 version: 2.7.0 @@ -14686,6 +14701,9 @@ importers: prettier: specifier: ^3.9.6 version: 3.9.6 + yuku-ast: + specifier: 'catalog:' + version: 0.19.0 zod: specifier: 'catalog:' version: 4.5.4 @@ -14723,15 +14741,6 @@ importers: packages/router-plugin: dependencies: - '@babel/core': - specifier: 'catalog:' - version: 7.29.7(supports-color@10.2.2) - '@babel/template': - specifier: ^7.29.7 - version: 7.29.7 - '@babel/types': - specifier: 'catalog:' - version: 7.29.8 '@tanstack/react-router': specifier: workspace:* version: link:../react-router @@ -14744,6 +14753,9 @@ importers: '@tanstack/router-utils': specifier: workspace:* version: link:../router-utils + '@yuku-toolchain/types': + specifier: 'catalog:' + version: 0.19.0 chokidar: specifier: 'catalog:' version: 5.0.0 @@ -14756,6 +14768,12 @@ importers: vite-plugin-solid: specifier: ^2.11.10 || ^3.0.0-0 version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) + yuku-analyzer: + specifier: 'catalog:' + version: 0.19.0 + yuku-ast: + specifier: 'catalog:' + version: 0.19.0 zod: specifier: 'catalog:' version: 4.5.4 @@ -14769,12 +14787,6 @@ importers: '@tanstack/vite-config': specifier: 'catalog:' version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(rollup@4.63.1)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - '@types/babel__core': - specifier: ^7.20.5 - version: 7.20.5 - '@types/babel__template': - specifier: ^7.4.4 - version: 7.4.4 '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -14841,21 +14853,15 @@ importers: packages/router-utils: dependencies: - '@babel/generator': - specifier: ^7.29.8 - version: 7.29.8 - '@babel/parser': - specifier: ^7.29.8 - version: 7.29.8 - '@babel/types': + '@yuku-core/wasm': + specifier: 'catalog:' + version: 0.19.0 + '@yuku-toolchain/types': specifier: 'catalog:' - version: 7.29.8 + version: 0.19.0 ansis: specifier: ^4.3.1 version: 4.3.1 - babel-dead-code-elimination: - specifier: ^1.0.12 - version: 1.0.12(supports-color@10.2.2) diff: specifier: ^9.0.0 version: 9.0.0 @@ -14865,6 +14871,18 @@ importers: tinyglobby: specifier: 'catalog:' version: 0.2.17 + yuku-analyzer: + specifier: 'catalog:' + version: 0.19.0 + yuku-ast: + specifier: 'catalog:' + version: 0.19.0 + yuku-codegen: + specifier: 'catalog:' + version: 0.19.0 + yuku-core: + specifier: 'catalog:' + version: 0.19.0 devDependencies: '@arethetypeswrong/cli': specifier: 'catalog:' @@ -14872,9 +14890,6 @@ importers: '@tanstack/vite-config': specifier: 'catalog:' version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - '@types/babel__generator': - specifier: ^7.27.0 - version: 7.27.0 '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -15279,15 +15294,6 @@ importers: packages/start-plugin-core: dependencies: - '@babel/code-frame': - specifier: 7.29.7 - version: 7.29.7 - '@babel/core': - specifier: 'catalog:' - version: 7.29.7(supports-color@10.2.2) - '@babel/types': - specifier: 'catalog:' - version: 7.29.8 '@jridgewell/remapping': specifier: ^2.3.5 version: 2.3.5 @@ -15306,6 +15312,9 @@ importers: '@tanstack/start-server-core': specifier: workspace:* version: link:../start-server-core + '@yuku-toolchain/types': + specifier: 'catalog:' + version: 0.19.0 exsolve: specifier: ^1.1.1 version: 1.1.1 @@ -15339,6 +15348,12 @@ importers: xmlbuilder2: specifier: ^4.0.3 version: 4.0.3 + yuku-analyzer: + specifier: 'catalog:' + version: 0.19.0 + yuku-ast: + specifier: 'catalog:' + version: 0.19.0 zod: specifier: 'catalog:' version: 4.5.4 @@ -15352,12 +15367,6 @@ importers: '@tanstack/vite-config': specifier: 'catalog:' version: 0.5.2(@types/node@25.0.9)(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - '@types/babel__code-frame': - specifier: ^7.0.6 - version: 7.0.6 - '@types/babel__core': - specifier: ^7.20.5 - version: 7.20.5 '@types/node': specifier: 25.0.9 version: 25.0.9 @@ -21933,9 +21942,6 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - '@types/babel__code-frame@7.0.6': - resolution: {integrity: sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==} - '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -22831,6 +22837,79 @@ packages: '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} + '@yuku-core/binding-android-arm64@0.19.0': + resolution: {integrity: sha512-wdL2XmTiZixAOaHk9Z0JJZ8u9Qm8d333Zjpc6gaxXFX+UR5BlsfxZ7a2+2Rm71pamppsPYGUGFq2oVLbh6DISw==} + cpu: [arm64] + os: [android] + + '@yuku-core/binding-darwin-arm64@0.19.0': + resolution: {integrity: sha512-7sNJa6XJhyetnTy64jw3NEi4YgUcezHk1G5GH9FSvXAuC8wIlbaOwT9lgq5qo5sspjrZJQlBR1TWflP49uRpMw==} + cpu: [arm64] + os: [darwin] + + '@yuku-core/binding-darwin-x64@0.19.0': + resolution: {integrity: sha512-pTC7dYEc59isdyizc3nsuyFNBu2LU5wMYZ1be6UMr59PY/AJNtO1aBLWV0MgL/z7C1nv/2sX4TNjGgKLoeevJw==} + cpu: [x64] + os: [darwin] + + '@yuku-core/binding-freebsd-x64@0.19.0': + resolution: {integrity: sha512-xrZjF6XOEwyLOiDIfrwDdRCS6ghxcJeirNC1KivQhVcUwlRonaU79S3/Uiyx3lBylPnT8qKJ3/aMT+H52nGTJQ==} + cpu: [x64] + os: [freebsd] + + '@yuku-core/binding-linux-arm-gnu@0.19.0': + resolution: {integrity: sha512-0OyIH53C0I1CbADHn9FSdCfX5qbpeLwJhmOVhonFJNYheybOnkSJ50qGMfTJIb8y5zrwqvLs7FVBvz6Fk8maZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-core/binding-linux-arm-musl@0.19.0': + resolution: {integrity: sha512-z/iPfkCOCSXE34VXNt0uP1ORSW+4mEa9b5CiJcWZeqZH08g49bFU/jRM2T04f+Wpw5mJ6HIzNBiojxeAbrHjeg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-core/binding-linux-arm64-gnu@0.19.0': + resolution: {integrity: sha512-jLNTi+gzaRwBJ+gwhCJ+rDb5dmx/8hEBx93UWd7hjMSqPTL1D1wvVSvXVAldV5kALvju7b0/m51EHEhs4+I1fA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-core/binding-linux-arm64-musl@0.19.0': + resolution: {integrity: sha512-YwyPIdW/rN2Fmv0MTFWi47FaB3F9g+2b+sJISyAw83Yd60RWH4T3Nx6XkAHHVN+uKJNBAd6So7/BVihSiFYvuQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-core/binding-linux-x64-gnu@0.19.0': + resolution: {integrity: sha512-/vskaAXVX3ESR43FiTdfiYS7z1b9uoQJwVQ/0RgUHgN2sWoAyCZs+aX4CgU7eKGIHTVfp9VFwv+c+2XchYiEtQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-core/binding-linux-x64-musl@0.19.0': + resolution: {integrity: sha512-FuXL5igeSKib9Rye02TdH+D6SQezTyGxIATMu1Qsn/QywrzwpGl35OLpS2C1x5vpGLeVE2XgiLd5mVPFHmIglQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-core/binding-win32-arm64@0.19.0': + resolution: {integrity: sha512-YuJAe7zEKT1Xa7LuHlhGJpPnYQeVtvo0rZvNjJRqkH0hQGOw4C8QeP30Tg5VKGzZfo2x8CjhjEhKt4TmLwfmdg==} + cpu: [arm64] + os: [win32] + + '@yuku-core/binding-win32-x64@0.19.0': + resolution: {integrity: sha512-o8iY/mXwlRkedp0LNTr4+TEfXTPQwoNZmv6k+1WIAd1ZXEn8xJJTBCtB0osaVvdZo/x/cNmV16UrWrrGpM5Mvw==} + cpu: [x64] + os: [win32] + + '@yuku-core/wasm@0.19.0': + resolution: {integrity: sha512-W7f7twcdejuVNJg5UERgxYLlkrw/QXqMLzr99NYbeYKoLNbSN7d4DYeIlYpwgZKvAvJc/4W1FICncCykuWTOQg==} + engines: {node: '>=18.0.0'} + + '@yuku-toolchain/types@0.19.0': + resolution: {integrity: sha512-tFkNbsP4WXX9UMUC5syJGdK9dPMLK0PPaIuXCcCYH75sZ8MrebfBZ2XvCILRTw8AgLivSSCfDa4R67iYBDa4Kg==} + '@zkochan/js-yaml@0.0.7': resolution: {integrity: sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==} hasBin: true @@ -23085,9 +23164,6 @@ packages: b4a@1.6.7: resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} - babel-dead-code-elimination@1.0.12: - resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} - babel-loader@10.0.0: resolution: {integrity: sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA==} engines: {node: ^18.20.0 || ^20.10.0 || >=22.0.0} @@ -29353,6 +29429,22 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + yuku-analyzer@0.19.0: + resolution: {integrity: sha512-Iu7DV5cWOQ3ERSA7oBquz/RT5IEGsUyUeCivYs9lQaZ4nSjM31yD17mH616JKcTyVoVWYivsFZ+twT3UyBosTA==} + engines: {node: '>=14.18.0'} + + yuku-ast@0.19.0: + resolution: {integrity: sha512-PyCsiScaelhxBGzL8dyeJkLqvRZycoh78R31Vrt/WqYrm0hMFoKwIw/X7Dx5mBKLWAMlV9I9HEyBOHnj2gZVyw==} + engines: {node: '>=14.18.0'} + + yuku-codegen@0.19.0: + resolution: {integrity: sha512-uymAKHjDuaP4BEpkXC/BSLz4DUEF5oArZ5AuD3888nb2miChjWSgpPdojEJxm3pjpXkgUaCRJQyNAbnMN33wHg==} + engines: {node: '>=14.18.0'} + + yuku-core@0.19.0: + resolution: {integrity: sha512-kYK9e1XdfirNhkipzW0wmMcSWcVF/CWnuugodfVYRCUwvoBefweL9HzB96OiKEhsx78V5WbUxbJqlizMX869Qg==} + engines: {node: '>=14.18.0'} + zeptomatch@2.0.2: resolution: {integrity: sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g==} @@ -29551,26 +29643,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/core@7.29.7(supports-color@7.2.0)': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.8 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) - '@babel/types': 7.29.8 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@7.2.0) - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/generator@7.29.8': dependencies: '@babel/parser': 7.29.8 @@ -29620,15 +29692,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-create-class-features-plugin@7.27.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-create-class-features-plugin@7.27.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.27.1(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.27.1(supports-color@10.2.2) '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@7.2.0) - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@10.2.2) + '@babel/traverse': 7.29.8(supports-color@10.2.2) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -29646,19 +29718,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@7.2.0) - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@7.2.0) - '@babel/traverse': 7.29.8(supports-color@7.2.0) - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/helper-globals@7.28.0': {} '@babel/helper-globals@7.29.7': {} @@ -29670,13 +29729,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-member-expression-to-functions@7.27.1(supports-color@7.2.0)': - dependencies: - '@babel/traverse': 7.29.8(supports-color@7.2.0) - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - '@babel/helper-member-expression-to-functions@7.28.5(supports-color@10.2.2)': dependencies: '@babel/traverse': 7.29.8(supports-color@10.2.2) @@ -29684,13 +29736,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-member-expression-to-functions@7.28.5(supports-color@7.2.0)': - dependencies: - '@babel/traverse': 7.29.8(supports-color@7.2.0) - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-imports@7.18.6': dependencies: '@babel/types': 7.29.8 @@ -29702,13 +29747,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.27.1(supports-color@7.2.0)': - dependencies: - '@babel/traverse': 7.29.8(supports-color@7.2.0) - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-imports@7.28.6(supports-color@10.2.2)': dependencies: '@babel/traverse': 7.29.8(supports-color@10.2.2) @@ -29723,13 +29761,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': - dependencies: - '@babel/traverse': 7.29.8(supports-color@7.2.0) - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@babel/core': 7.28.5(supports-color@10.2.2) @@ -29766,15 +29797,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - '@babel/helper-optimise-call-expression@7.27.1': dependencies: '@babel/types': 7.29.8 @@ -29792,12 +29814,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.27.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-replace-supers@7.27.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@10.2.2) '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -29810,15 +29832,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-member-expression-to-functions': 7.28.5(supports-color@7.2.0) - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.8(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.27.1(supports-color@10.2.2)': dependencies: '@babel/traverse': 7.29.8(supports-color@10.2.2) @@ -29826,13 +29839,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.27.1(supports-color@7.2.0)': - dependencies: - '@babel/traverse': 7.29.8(supports-color@7.2.0) - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -29888,19 +29894,14 @@ snapshots: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5(supports-color@10.2.2))': dependencies: '@babel/core': 7.28.5(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7(supports-color@10.2.2))': @@ -29908,11 +29909,6 @@ snapshots: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7(supports-color@7.2.0))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) @@ -29937,14 +29933,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-typescript@7.27.1(@babel/core@7.28.5(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@babel/core': 7.28.5(supports-color@10.2.2) @@ -29956,14 +29944,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.27.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-typescript@7.27.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.27.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@7.2.0) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@10.2.2) + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2)) transitivePeerDependencies: - supports-color @@ -29978,17 +29966,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1(supports-color@7.2.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0)) - transitivePeerDependencies: - - supports-color - '@babel/preset-typescript@7.27.1(@babel/core@7.28.5(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@babel/core': 7.28.5(supports-color@10.2.2) @@ -30000,14 +29977,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.27.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/preset-typescript@7.27.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.27.1 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-typescript': 7.27.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-typescript': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -30068,18 +30045,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/traverse@7.29.8(supports-color@7.2.0)': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.8 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.8 - '@babel/template': 7.29.7 - '@babel/types': 7.29.8 - debug: 4.4.3(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -34813,20 +34778,6 @@ snapshots: - encoding - supports-color - '@sentry/bundler-plugin-core@4.6.1(supports-color@7.2.0)': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@sentry/babel-plugin-component-annotate': 4.6.1 - '@sentry/cli': 2.58.4(supports-color@7.2.0) - dotenv: 16.6.1 - find-up: 5.0.0 - glob: 10.5.0 - magic-string: 0.30.8 - unplugin: 1.0.1 - transitivePeerDependencies: - - encoding - - supports-color - '@sentry/cli-darwin@2.58.4': optional: true @@ -34871,26 +34822,6 @@ snapshots: - encoding - supports-color - '@sentry/cli@2.58.4(supports-color@7.2.0)': - dependencies: - https-proxy-agent: 5.0.1(supports-color@7.2.0) - node-fetch: 2.7.0 - progress: 2.0.3 - proxy-from-env: 1.1.0 - which: 2.0.2 - optionalDependencies: - '@sentry/cli-darwin': 2.58.4 - '@sentry/cli-linux-arm': 2.58.4 - '@sentry/cli-linux-arm64': 2.58.4 - '@sentry/cli-linux-i686': 2.58.4 - '@sentry/cli-linux-x64': 2.58.4 - '@sentry/cli-win32-arm64': 2.58.4 - '@sentry/cli-win32-i686': 2.58.4 - '@sentry/cli-win32-x64': 2.58.4 - transitivePeerDependencies: - - encoding - - supports-color - '@sentry/core@10.32.0': {} '@sentry/core@7.120.4': @@ -34931,14 +34862,6 @@ snapshots: - encoding - supports-color - '@sentry/vite-plugin@4.6.1(supports-color@7.2.0)': - dependencies: - '@sentry/bundler-plugin-core': 4.6.1(supports-color@7.2.0) - unplugin: 1.0.1 - transitivePeerDependencies: - - encoding - - supports-color - '@sentry/vue@10.32.0(@tanstack/vue-router@packages+vue-router)(vue@3.5.25(typescript@5.8.3))': dependencies: '@sentry/browser': 10.32.0 @@ -35766,8 +35689,6 @@ snapshots: '@types/aria-query@5.0.4': {} - '@types/babel__code-frame@7.0.6': {} - '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -36719,49 +36640,25 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue-jsx@5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2))': + '@vitejs/plugin-vue-jsx@5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.9.3))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@rolldown/pluginutils': 1.0.0-rc.9 - '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vue: 3.5.25(@typescript/typescript6@6.0.2) - transitivePeerDependencies: - - supports-color - - '@vitejs/plugin-vue-jsx@5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.8.3))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@rolldown/pluginutils': 1.0.0-rc.9 - '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vue: 3.5.25(typescript@5.8.3) - transitivePeerDependencies: - - supports-color - - '@vitejs/plugin-vue-jsx@5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@5.9.3))': - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@rolldown/pluginutils': 1.0.0-rc.9 - '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue: 3.5.25(typescript@5.9.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue-jsx@5.1.5(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@7.0.2))': + '@vitejs/plugin-vue-jsx@5.1.5(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(typescript@7.0.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@rolldown/pluginutils': 1.0.0-rc.9 - '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) vue: 3.5.25(typescript@7.0.2) transitivePeerDependencies: @@ -36959,22 +36856,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': - dependencies: - '@babel/helper-module-imports': 7.27.1(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) - '@babel/types': 7.29.8 - '@vue/babel-helper-vue-transform-on': 2.0.1 - '@vue/babel-plugin-resolve-type': 2.0.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@vue/shared': 3.5.25 - optionalDependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - transitivePeerDependencies: - - supports-color - '@vue/babel-plugin-resolve-type@1.5.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 @@ -36997,17 +36878,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@vue/babel-plugin-resolve-type@2.0.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.27.1(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/parser': 7.29.8 - '@vue/compiler-sfc': 3.5.25 - transitivePeerDependencies: - - supports-color - '@vue/compiler-core@3.5.25': dependencies: '@babel/parser': 7.29.8 @@ -37295,6 +37165,48 @@ snapshots: '@yarnpkg/lockfile@1.1.0': {} + '@yuku-core/binding-android-arm64@0.19.0': + optional: true + + '@yuku-core/binding-darwin-arm64@0.19.0': + optional: true + + '@yuku-core/binding-darwin-x64@0.19.0': + optional: true + + '@yuku-core/binding-freebsd-x64@0.19.0': + optional: true + + '@yuku-core/binding-linux-arm-gnu@0.19.0': + optional: true + + '@yuku-core/binding-linux-arm-musl@0.19.0': + optional: true + + '@yuku-core/binding-linux-arm64-gnu@0.19.0': + optional: true + + '@yuku-core/binding-linux-arm64-musl@0.19.0': + optional: true + + '@yuku-core/binding-linux-x64-gnu@0.19.0': + optional: true + + '@yuku-core/binding-linux-x64-musl@0.19.0': + optional: true + + '@yuku-core/binding-win32-arm64@0.19.0': + optional: true + + '@yuku-core/binding-win32-x64@0.19.0': + optional: true + + '@yuku-core/wasm@0.19.0': + dependencies: + '@yuku-toolchain/types': 0.19.0 + + '@yuku-toolchain/types@0.19.0': {} + '@zkochan/js-yaml@0.0.7': dependencies: argparse: 2.0.1 @@ -37581,15 +37493,6 @@ snapshots: b4a@1.6.7: {} - babel-dead-code-elimination@1.0.12(supports-color@10.2.2): - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/parser': 7.29.8 - '@babel/traverse': 7.29.8(supports-color@10.2.2) - '@babel/types': 7.29.8 - transitivePeerDependencies: - - supports-color - babel-loader@10.0.0(@babel/core@7.28.5(supports-color@10.2.2))(webpack@5.97.1): dependencies: '@babel/core': 7.28.5(supports-color@10.2.2) @@ -37614,15 +37517,6 @@ snapshots: html-entities: 2.3.3 parse5: 7.3.0 - babel-plugin-jsx-dom-expressions@0.40.3(@babel/core@7.29.7(supports-color@7.2.0)): - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.18.6 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/types': 7.29.8 - html-entities: 2.3.3 - parse5: 7.3.0 - babel-plugin-macros@3.1.0: dependencies: '@babel/runtime': 7.26.7 @@ -37654,13 +37548,6 @@ snapshots: optionalDependencies: solid-js: 1.9.12 - babel-preset-solid@1.9.10(@babel/core@7.29.7(supports-color@7.2.0))(solid-js@1.9.12): - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - babel-plugin-jsx-dom-expressions: 0.40.3(@babel/core@7.29.7(supports-color@7.2.0)) - optionalDependencies: - solid-js: 1.9.12 - balanced-match@1.0.2: {} balanced-match@4.0.3: {} @@ -38835,11 +38722,11 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 - esbuild-plugin-solid@0.6.0(esbuild@0.27.4)(solid-js@1.9.12)(supports-color@7.2.0): + esbuild-plugin-solid@0.6.0(esbuild@0.27.4)(solid-js@1.9.12)(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/preset-typescript': 7.27.1(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - babel-preset-solid: 1.9.10(@babel/core@7.29.7(supports-color@7.2.0))(solid-js@1.9.12) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/preset-typescript': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-preset-solid: 1.9.10(@babel/core@7.29.7(supports-color@10.2.2))(solid-js@1.9.12) esbuild: 0.27.4 solid-js: 1.9.12 transitivePeerDependencies: @@ -43209,15 +43096,6 @@ snapshots: transitivePeerDependencies: - supports-color - solid-refresh@0.6.3(solid-js@1.9.12)(supports-color@7.2.0): - dependencies: - '@babel/generator': 7.29.8 - '@babel/helper-module-imports': 7.27.1(supports-color@7.2.0) - '@babel/types': 7.29.8 - solid-js: 1.9.12 - transitivePeerDependencies: - - supports-color - solid-refresh@0.7.8(solid-js@1.9.12): dependencies: '@babel/generator': 7.29.8 @@ -44154,21 +44032,6 @@ snapshots: transitivePeerDependencies: - supports-color - vite-plugin-solid@2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@7.2.0)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): - dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@types/babel__core': 7.20.5 - babel-preset-solid: 1.9.10(@babel/core@7.29.7(supports-color@7.2.0))(solid-js@1.9.12) - merge-anything: 5.1.7 - solid-js: 1.9.12 - solid-refresh: 0.6.3(solid-js@1.9.12)(supports-color@7.2.0) - vite: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) - vitefu: 1.1.1(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)) - optionalDependencies: - '@testing-library/jest-dom': 6.6.3 - transitivePeerDependencies: - - supports-color - vite-tsconfig-paths@6.1.1(@typescript/typescript6@6.0.2)(supports-color@10.2.2)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0)): dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -45089,6 +44952,37 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 + yuku-analyzer@0.19.0: + dependencies: + '@yuku-toolchain/types': 0.19.0 + yuku-ast: 0.19.0 + yuku-core: 0.19.0 + + yuku-ast@0.19.0: + dependencies: + '@yuku-toolchain/types': 0.19.0 + + yuku-codegen@0.19.0: + dependencies: + '@yuku-toolchain/types': 0.19.0 + + yuku-core@0.19.0: + dependencies: + '@yuku-toolchain/types': 0.19.0 + optionalDependencies: + '@yuku-core/binding-android-arm64': 0.19.0 + '@yuku-core/binding-darwin-arm64': 0.19.0 + '@yuku-core/binding-darwin-x64': 0.19.0 + '@yuku-core/binding-freebsd-x64': 0.19.0 + '@yuku-core/binding-linux-arm-gnu': 0.19.0 + '@yuku-core/binding-linux-arm-musl': 0.19.0 + '@yuku-core/binding-linux-arm64-gnu': 0.19.0 + '@yuku-core/binding-linux-arm64-musl': 0.19.0 + '@yuku-core/binding-linux-x64-gnu': 0.19.0 + '@yuku-core/binding-linux-x64-musl': 0.19.0 + '@yuku-core/binding-win32-arm64': 0.19.0 + '@yuku-core/binding-win32-x64': 0.19.0 + zeptomatch@2.0.2: dependencies: grammex: 3.1.11 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dd055cb001c..71b4687ca5e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,8 +32,6 @@ packages: - 'e2e/eslint-plugin-start' catalog: - '@babel/core': ^7.29.7 - '@babel/types': ^7.29.8 '@arethetypeswrong/cli': ^0.18.4 '@eslint-react/eslint-plugin': ^1.26.2 '@rsbuild/core': ^2.2.5 @@ -70,6 +68,13 @@ catalog: '@tanstack/react-query': ^5.102.0 '@tanstack/solid-query': ^5.102.0 '@tanstack/vue-query': ^5.102.0 + # Yuku packages are released together: keep them on one exact version. + '@yuku-core/wasm': 0.19.0 + '@yuku-toolchain/types': 0.19.0 + yuku-analyzer: 0.19.0 + yuku-ast: 0.19.0 + yuku-codegen: 0.19.0 + yuku-core: 0.19.0 overrides: '@types/babel__traverse': '^7.28.0'