@tanstack/router-plugin
Version:
Modern and scalable routing for React applications
331 lines (291 loc) • 9.57 kB
text/typescript
/**
* It is important to familiarize yourself with how the code-splitting works in this plugin.
* https://github.com/TanStack/router/pull/3355
*/
import { fileURLToPath, pathToFileURL } from 'node:url'
import { logDiff } from '@tanstack/router-utils'
import { getConfig, splitGroupingsSchema } from './config'
import {
compileCodeSplitReferenceRoute,
compileCodeSplitVirtualRoute,
detectCodeSplitGroupingsFromRoute,
} from './code-splitter/compilers'
import {
defaultCodeSplitGroupings,
splitRouteIdentNodes,
tsrSplit,
} from './constants'
import { decodeIdentifier } from './code-splitter/path-ids'
import { debug, normalizePath } from './utils'
import type { CodeSplitGroupings, SplitRouteIdentNodes } from './constants'
import type { GetRoutesByFileMapResultValue } from '@tanstack/router-generator'
import type { Config } from './config'
import type {
UnpluginFactory,
TransformResult as UnpluginTransformResult,
} from 'unplugin'
const PLUGIN_NAME = 'unplugin:router-code-splitter'
const CODE_SPLITTER_PLUGIN_NAME =
'tanstack-router:code-splitter:compile-reference-file'
type TransformationPluginInfo = {
pluginNames: Array<string>
pkg: string
usage: string
}
/**
* JSX transformation plugins grouped by framework.
* These plugins must come AFTER the TanStack Router plugin in the Vite config.
*/
const TRANSFORMATION_PLUGINS_BY_FRAMEWORK: Record<
string,
Array<TransformationPluginInfo>
> = {
react: [
{
// Babel-based React plugin
pluginNames: ['vite:react-babel', 'vite:react-refresh'],
pkg: '@vitejs/plugin-react',
usage: 'react()',
},
{
// SWC-based React plugin
pluginNames: ['vite:react-swc', 'vite:react-swc:resolve-runtime'],
pkg: '@vitejs/plugin-react-swc',
usage: 'reactSwc()',
},
{
// OXC-based React plugin (deprecated but should still be handled)
pluginNames: ['vite:react-oxc:config', 'vite:react-oxc:refresh-runtime'],
pkg: '@vitejs/plugin-react-oxc',
usage: 'reactOxc()',
},
],
solid: [
{
pluginNames: ['solid'],
pkg: 'vite-plugin-solid',
usage: 'solid()',
},
],
}
export const unpluginRouterCodeSplitterFactory: UnpluginFactory<
Partial<Config | (() => Config)> | undefined
> = (options = {}, { framework: _framework }) => {
let ROOT: string = process.cwd()
let userConfig: Config
function initUserConfig() {
if (typeof options === 'function') {
userConfig = options()
} else {
userConfig = getConfig(options, ROOT)
}
}
const isProduction = process.env.NODE_ENV === 'production'
const getGlobalCodeSplitGroupings = () => {
return (
userConfig.codeSplittingOptions?.defaultBehavior ||
defaultCodeSplitGroupings
)
}
const getShouldSplitFn = () => {
return userConfig.codeSplittingOptions?.splitBehavior
}
const handleCompilingReferenceFile = (
code: string,
id: string,
generatorNodeInfo: GetRoutesByFileMapResultValue,
): UnpluginTransformResult => {
if (debug) console.info('Compiling Route: ', id)
const fromCode = detectCodeSplitGroupingsFromRoute({
code,
})
if (fromCode.groupings) {
const res = splitGroupingsSchema.safeParse(fromCode.groupings)
if (!res.success) {
const message = res.error.errors.map((e) => e.message).join('. ')
throw new Error(
`The groupings for the route "${id}" are invalid.\n${message}`,
)
}
}
const userShouldSplitFn = getShouldSplitFn()
const pluginSplitBehavior = userShouldSplitFn?.({
routeId: generatorNodeInfo.routePath,
}) as CodeSplitGroupings | undefined
if (pluginSplitBehavior) {
const res = splitGroupingsSchema.safeParse(pluginSplitBehavior)
if (!res.success) {
const message = res.error.errors.map((e) => e.message).join('. ')
throw new Error(
`The groupings returned when using \`splitBehavior\` for the route "${id}" are invalid.\n${message}`,
)
}
}
const splitGroupings: CodeSplitGroupings =
fromCode.groupings || pluginSplitBehavior || getGlobalCodeSplitGroupings()
const compiledReferenceRoute = compileCodeSplitReferenceRoute({
code,
codeSplitGroupings: splitGroupings,
targetFramework: userConfig.target,
filename: id,
id,
deleteNodes: userConfig.codeSplittingOptions?.deleteNodes
? new Set(userConfig.codeSplittingOptions.deleteNodes)
: undefined,
addHmr:
(userConfig.codeSplittingOptions?.addHmr ?? true) && !isProduction,
})
if (compiledReferenceRoute === null) {
if (debug) {
console.info(
`No changes made to route "${id}", skipping code-splitting.`,
)
}
return null
}
if (debug) {
logDiff(code, compiledReferenceRoute.code)
console.log('Output:\n', compiledReferenceRoute.code + '\n\n')
}
return compiledReferenceRoute
}
const handleCompilingVirtualFile = (
code: string,
id: string,
): UnpluginTransformResult => {
if (debug) console.info('Splitting Route: ', id)
const [_, ...pathnameParts] = id.split('?')
const searchParams = new URLSearchParams(pathnameParts.join('?'))
const splitValue = searchParams.get(tsrSplit)
if (!splitValue) {
throw new Error(
`The split value for the virtual route "${id}" was not found.`,
)
}
const rawGrouping = decodeIdentifier(splitValue)
const grouping = [...new Set(rawGrouping)].filter((p) =>
splitRouteIdentNodes.includes(p as any),
) as Array<SplitRouteIdentNodes>
const result = compileCodeSplitVirtualRoute({
code,
filename: id,
splitTargets: grouping,
})
if (debug) {
logDiff(code, result.code)
console.log('Output:\n', result.code + '\n\n')
}
return result
}
const includedCode = [
'createFileRoute(',
'createRootRoute(',
'createRootRouteWithContext(',
]
return [
{
name: 'tanstack-router:code-splitter:compile-reference-file',
enforce: 'pre',
transform: {
filter: {
id: {
exclude: tsrSplit,
// this is necessary for webpack / rspack to avoid matching .html files
include: /\.(m|c)?(j|t)sx?$/,
},
code: {
include: includedCode,
},
},
handler(code, id) {
const normalizedId = normalizePath(id)
const generatorFileInfo =
globalThis.TSR_ROUTES_BY_ID_MAP?.get(normalizedId)
if (
generatorFileInfo &&
includedCode.some((included) => code.includes(included))
) {
return handleCompilingReferenceFile(
code,
normalizedId,
generatorFileInfo,
)
}
return null
},
},
vite: {
configResolved(config) {
ROOT = config.root
initUserConfig()
// Validate plugin order - router must come before JSX transformation plugins
const routerPluginIndex = config.plugins.findIndex(
(p) => p.name === CODE_SPLITTER_PLUGIN_NAME,
)
if (routerPluginIndex === -1) return
const frameworkPlugins =
TRANSFORMATION_PLUGINS_BY_FRAMEWORK[userConfig.target]
if (!frameworkPlugins) return
for (const transformPlugin of frameworkPlugins) {
const transformPluginIndex = config.plugins.findIndex((p) =>
transformPlugin.pluginNames.includes(p.name),
)
if (
transformPluginIndex !== -1 &&
transformPluginIndex < routerPluginIndex
) {
throw new Error(
`Plugin order error: '${transformPlugin.pkg}' is placed before '@tanstack/router-plugin'.\n\n` +
`The TanStack Router plugin must come BEFORE JSX transformation plugins.\n\n` +
`Please update your Vite config:\n\n` +
` plugins: [\n` +
` tanstackRouter(),\n` +
` ${transformPlugin.usage},\n` +
` ]\n`,
)
}
}
},
applyToEnvironment(environment) {
if (userConfig.plugin?.vite?.environmentName) {
return userConfig.plugin.vite.environmentName === environment.name
}
return true
},
},
rspack(compiler) {
ROOT = process.cwd()
initUserConfig()
if (compiler.options.mode === 'production') {
compiler.hooks.done.tap(PLUGIN_NAME, () => {
console.info('✅ ' + PLUGIN_NAME + ': code-splitting done!')
})
}
},
webpack(compiler) {
ROOT = process.cwd()
initUserConfig()
if (compiler.options.mode === 'production') {
compiler.hooks.done.tap(PLUGIN_NAME, () => {
console.info('✅ ' + PLUGIN_NAME + ': code-splitting done!')
})
}
},
},
{
name: 'tanstack-router:code-splitter:compile-virtual-file',
enforce: 'pre',
transform: {
filter: {
id: /tsr-split/,
},
handler(code, id) {
const url = pathToFileURL(id)
url.searchParams.delete('v')
const normalizedId = normalizePath(fileURLToPath(url))
return handleCompilingVirtualFile(code, normalizedId)
},
},
},
]
}