From 0f6a1c74448f1339eb62e0e7d612f35912840c34 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 5 Aug 2024 16:12:14 +1000 Subject: [PATCH 01/63] WIP: Route chunks --- packages/react-router-dev/config.ts | 14 +- packages/react-router-dev/manifest.ts | 32 +-- packages/react-router-dev/vite/plugin.ts | 233 ++++++++++++++++-- .../react-router/lib/dom/ssr/components.tsx | 36 ++- .../lib/dom/ssr/routes-test-stub.tsx | 5 +- packages/react-router/lib/dom/ssr/routes.tsx | 45 +++- .../react-router/lib/server-runtime/routes.ts | 2 + playground/compiler/app/routes/_index.tsx | 10 +- .../app/routes/chunkable/clientLoader.ts | 4 + .../compiler/app/routes/chunkable/route.tsx | 8 + .../compiler/app/routes/unchunkable.tsx | 11 + playground/compiler/vite.config.ts | 9 +- 12 files changed, 355 insertions(+), 54 deletions(-) create mode 100644 playground/compiler/app/routes/chunkable/clientLoader.ts create mode 100644 playground/compiler/app/routes/chunkable/route.tsx create mode 100644 playground/compiler/app/routes/unchunkable.tsx diff --git a/packages/react-router-dev/config.ts b/packages/react-router-dev/config.ts index 532b5ee89e..3f7df8b05c 100644 --- a/packages/react-router-dev/config.ts +++ b/packages/react-router-dev/config.ts @@ -72,7 +72,12 @@ export type ServerBundlesBuildManifest = BaseBuildManifest & { type ServerModuleFormat = "esm" | "cjs"; -interface FutureConfig {} +interface FutureConfig { + /** + * Automatically split route modules into multiple chunks when possible. + */ + unstable_routeChunks?: boolean; +} export type BuildManifest = DefaultBuildManifest | ServerBundlesBuildManifest; @@ -184,7 +189,7 @@ export type ResolvedVitePluginConfig = Readonly<{ /** * Enabled future flags */ - future: FutureConfig; + future: Required; /** * An array of URLs to prerender to HTML files at build time. */ @@ -359,6 +364,7 @@ export async function resolveReactRouterConfig({ basename, buildDirectory: userBuildDirectory, buildEnd, + future: userFuture, ignoredRouteFiles, routes: userRoutesFunction, prerender: prerenderConfig, @@ -435,7 +441,9 @@ export async function resolveReactRouterConfig({ } } - let future: FutureConfig = {}; + let future: FutureConfig = { + unstable_routeChunks: Boolean(userFuture?.unstable_routeChunks), + }; let reactRouterConfig: ResolvedVitePluginConfig = deepFreeze({ appDirectory, diff --git a/packages/react-router-dev/manifest.ts b/packages/react-router-dev/manifest.ts index e9b628aa63..4dbf9500dc 100644 --- a/packages/react-router-dev/manifest.ts +++ b/packages/react-router-dev/manifest.ts @@ -1,3 +1,20 @@ +export type ManifestRoute = { + id: string; + parentId?: string; + path?: string; + index?: boolean; + caseSensitive?: boolean; + module: string; + clientLoaderModule: string | undefined; + clientActionModule: string | undefined; + imports?: string[]; + hasAction: boolean; + hasLoader: boolean; + hasClientAction: boolean; + hasClientLoader: boolean; + hasErrorBoundary: boolean; +}; + export type Manifest = { version: string; url?: string; @@ -6,20 +23,7 @@ export type Manifest = { imports: string[]; }; routes: { - [routeId: string]: { - id: string; - parentId?: string; - path?: string; - index?: boolean; - caseSensitive?: boolean; - module: string; - imports?: string[]; - hasAction: boolean; - hasLoader: boolean; - hasClientAction: boolean; - hasClientLoader: boolean; - hasErrorBoundary: boolean; - }; + [routeId: string]: ManifestRoute; }; hmr?: { timestamp?: number; diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index ba8f5f0103..b0bd23111c 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -25,7 +25,10 @@ import colors from "picocolors"; import { type ConfigRoute, type RouteManifest } from "../config/routes"; import { findConfig } from "../config/findConfig"; -import type { Manifest as ReactRouterManifest } from "../manifest"; +import type { + ManifestRoute, + Manifest as ReactRouterManifest, +} from "../manifest"; import invariant from "../invariant"; import type { NodeRequestHandler } from "./node-adapter"; import { fromNodeRequest, toNodeRequest } from "./node-adapter"; @@ -130,6 +133,10 @@ const CLIENT_ROUTE_EXPORTS = [ // Each route gets its own virtual module marked with an entry query string const ROUTE_ENTRY_QUERY_STRING = "?route-entry=1"; +const ROUTE_CHUNK_QUERY_STRING = "?route-chunk="; +const MAIN_ROUTE_CHUNK_QUERY_STRING = `${ROUTE_CHUNK_QUERY_STRING}main`; +const CLIENT_ACTION_CHUNK_QUERY_STRING = `${ROUTE_CHUNK_QUERY_STRING}action-loader`; +const CLIENT_LOADER_CHUNK_QUERY_STRING = `${ROUTE_CHUNK_QUERY_STRING}client-loader`; const isRouteEntry = (id: string): boolean => { return id.endsWith(ROUTE_ENTRY_QUERY_STRING); @@ -601,8 +608,31 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { ); let sourceExports = routeManifestExports[key]; let isRootRoute = route.parentId === undefined; + let hasClientAction = sourceExports.includes("clientAction"); + let hasClientLoader = sourceExports.includes("clientLoader"); + + let code = await fse.readFile(routeFilePath, "utf-8"); + let { hasClientActionChunk, hasClientLoaderChunk } = detectRouteChunks({ + ctx, + code, + }); + + let clientActionAssets = hasClientActionChunk + ? getReactRouterManifestBuildAssets( + ctx, + viteManifest, + routeFilePath + CLIENT_ACTION_CHUNK_QUERY_STRING + ) + : null; + let clientLoaderAssets = hasClientLoaderChunk + ? getReactRouterManifestBuildAssets( + ctx, + viteManifest, + routeFilePath + CLIENT_LOADER_CHUNK_QUERY_STRING + ) + : null; - let routeManifestEntry = { + let routeManifestEntry: ReactRouterManifest["routes"][string] = { id: route.id, parentId: route.parentId, path: route.path, @@ -610,8 +640,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { caseSensitive: route.caseSensitive, hasAction: sourceExports.includes("action"), hasLoader: sourceExports.includes("loader"), - hasClientAction: sourceExports.includes("clientAction"), - hasClientLoader: sourceExports.includes("clientLoader"), + hasClientAction, + hasClientLoader, hasErrorBoundary: sourceExports.includes("ErrorBoundary"), ...getReactRouterManifestBuildAssets( ctx, @@ -622,6 +652,12 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { // global reset styles, etc. isRootRoute ? [ctx.entryClientFilePath] : [] ), + clientActionModule: clientActionAssets + ? clientActionAssets.module + : undefined, + clientLoaderModule: clientLoaderAssets + ? clientLoaderAssets.module + : undefined, }; browserRoutes[key] = routeManifestEntry; @@ -677,23 +713,42 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { for (let [key, route] of Object.entries(ctx.reactRouterConfig.routes)) { let sourceExports = routeManifestExports[key]; + let hasClientAction = sourceExports.includes("clientAction"); + let hasClientLoader = sourceExports.includes("clientLoader"); + let routeModulePath = combineURLs( + ctx.publicPath, + `${resolveFileUrl( + ctx, + resolveRelativeRouteFilePath(route, ctx.reactRouterConfig) + )}` + ); + + let code = await fse.readFile( + path.resolve(ctx.reactRouterConfig.appDirectory, route.file), + "utf-8" + ); + let { hasClientActionChunk, hasClientLoaderChunk } = detectRouteChunks({ + ctx, + code, + }); + routes[key] = { id: route.id, parentId: route.parentId, path: route.path, index: route.index, caseSensitive: route.caseSensitive, - module: combineURLs( - ctx.publicPath, - `${resolveFileUrl( - ctx, - resolveRelativeRouteFilePath(route, ctx.reactRouterConfig) - )}${ROUTE_ENTRY_QUERY_STRING}` - ), + module: `${routeModulePath}${ROUTE_ENTRY_QUERY_STRING}`, + clientActionModule: hasClientActionChunk + ? `${routeModulePath}${CLIENT_ACTION_CHUNK_QUERY_STRING}` + : undefined, + clientLoaderModule: hasClientLoaderChunk + ? `${routeModulePath}${CLIENT_LOADER_CHUNK_QUERY_STRING}` + : undefined, hasAction: sourceExports.includes("action"), hasLoader: sourceExports.includes("loader"), - hasClientAction: sourceExports.includes("clientAction"), - hasClientLoader: sourceExports.includes("clientLoader"), + hasClientAction, + hasClientLoader, hasErrorBoundary: sourceExports.includes("ErrorBoundary"), imports: [], }; @@ -852,13 +907,37 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { preserveEntrySignatures: "exports-only", input: [ ctx.entryClientFilePath, - ...Object.values(ctx.reactRouterConfig.routes).map( - (route) => - `${path.resolve( - ctx.reactRouterConfig.appDirectory, - route.file - )}${ROUTE_ENTRY_QUERY_STRING}` - ), + ...Object.values( + ctx.reactRouterConfig.routes + ).flatMap((route) => { + let routeFilePath = path.resolve( + ctx.reactRouterConfig.appDirectory, + route.file + ); + + let code = fse.readFileSync( + routeFilePath, + "utf-8" + ); + let { + hasClientActionChunk, + hasClientLoaderChunk, + } = detectRouteChunks({ ctx, code }); + + return [ + `${routeFilePath}${ROUTE_ENTRY_QUERY_STRING}`, + ...(hasClientActionChunk + ? [ + `${routeFilePath}${CLIENT_ACTION_CHUNK_QUERY_STRING}`, + ] + : []), + ...(hasClientLoaderChunk + ? [ + `${routeFilePath}${CLIENT_LOADER_CHUNK_QUERY_STRING}`, + ] + : []), + ]; + }), ], }, } @@ -1217,6 +1296,9 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { routeModuleId ); + let { hasRouteChunks, hasClientActionChunk, hasClientLoaderChunk } = + detectRouteChunks({ ctx, code }); + let reexports = sourceExports .filter( (exportName) => @@ -1224,8 +1306,43 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { SERVER_ONLY_ROUTE_EXPORTS.includes(exportName)) || CLIENT_ROUTE_EXPORTS.includes(exportName) ) + .filter((exportName) => + !options?.ssr && hasClientActionChunk + ? exportName !== "clientAction" + : true + ) + .filter((exportName) => + !options?.ssr && hasClientLoaderChunk + ? exportName !== "clientLoader" + : true + ) .join(", "); - return `export { ${reexports} } from "./${routeFileName}";`; + + return `export { ${reexports} } from "./${routeFileName}${ + !options?.ssr && hasRouteChunks ? MAIN_ROUTE_CHUNK_QUERY_STRING : "" + }";`; + }, + }, + { + name: "react-router-route-chunks", + enforce: "pre", + async transform(code, id, options) { + // Routes aren't chunked on the server + if (options?.ssr) return; + + // Ignore anything that isn't marked as a route chunk + if (!id.includes(ROUTE_CHUNK_QUERY_STRING)) return; + + let chunks = getRouteChunks({ ctx, code }); + if (id.endsWith(MAIN_ROUTE_CHUNK_QUERY_STRING)) { + return chunks.main; + } + if (id.endsWith(CLIENT_ACTION_CHUNK_QUERY_STRING)) { + return chunks.clientAction; + } + if (id.endsWith(CLIENT_LOADER_CHUNK_QUERY_STRING)) { + return chunks.clientLoader; + } }, }, { @@ -1488,7 +1605,6 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { async handleHotUpdate({ server, file, modules, read }) { let route = getRoute(ctx.reactRouterConfig, file); - type ManifestRoute = ReactRouterManifest["routes"][string]; type HmrEventData = { route: ManifestRoute | null }; let hmrEventData: HmrEventData = { route: null }; @@ -1633,7 +1749,7 @@ async function getRouteMetadata( viteChildCompiler: Vite.ViteDevServer | null, route: ConfigRoute, readRouteFile?: () => string | Promise -) { +): Promise { let sourceExports = await getRouteModuleExports( viteChildCompiler, ctx, @@ -1641,7 +1757,7 @@ async function getRouteMetadata( readRouteFile ); - let info = { + let info: ManifestRoute & { url: string } = { id: route.id, parentId: route.parentId, path: route.path, @@ -1662,6 +1778,9 @@ async function getRouteMetadata( resolveRelativeRouteFilePath(route, ctx.reactRouterConfig) )}?import` ), // Ensure the Vite dev server responds with a JS module + // TODO: HMR support + clientActionModule: undefined, + clientLoaderModule: undefined, hasAction: sourceExports.includes("action"), hasClientAction: sourceExports.includes("clientAction"), hasLoader: sourceExports.includes("loader"), @@ -1897,3 +2016,69 @@ function createPrerenderRoutes( }; }); } + +// TODO: Make this real +function detectRouteChunks({ + ctx, + code, +}: { + ctx: ReactRouterPluginContext; + code: string; +}): { + hasClientActionChunk: boolean; + hasClientLoaderChunk: boolean; + hasRouteChunks: boolean; +} { + if (!ctx.reactRouterConfig.future.unstable_routeChunks) { + return { + hasClientActionChunk: false, + hasClientLoaderChunk: false, + hasRouteChunks: false, + }; + } + + let hasClientActionChunk = code.includes( + 'export { clientAction } from "./clientAction";' + ); + let hasClientLoaderChunk = code.includes( + 'export { clientLoader } from "./clientLoader";' + ); + let hasRouteChunks = hasClientActionChunk || hasClientLoaderChunk; + + return { + hasClientActionChunk, + hasClientLoaderChunk, + hasRouteChunks, + }; +} + +// TODO: Make this real +function getRouteChunks({ + ctx, + code, +}: { + ctx: ReactRouterPluginContext; + code: string; +}) { + invariant( + ctx.reactRouterConfig.future.unstable_routeChunks, + "Route chunks shouldn't be requested when future.unstable_routeChunks is disabled" + ); + + let { hasClientActionChunk, hasClientLoaderChunk } = detectRouteChunks({ + ctx, + code, + }); + + return { + main: code + .replace('export { clientAction } from "./clientAction";', "") + .replace('export { clientLoader } from "./clientLoader";', ""), + clientAction: hasClientActionChunk + ? `export { clientAction } from "./clientAction";` + : undefined, + clientLoader: hasClientLoaderChunk + ? `export { clientLoader } from "./clientLoader";` + : undefined, + }; +} diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index 9f83e34a3a..b8c6d58a6f 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -645,12 +645,36 @@ export function Scripts(props: ScriptsProps) { : "" }${!enableFogOfWar ? `import ${JSON.stringify(manifest.url)}` : ""}; ${matches - .map( - (match, index) => - `import * as route${index} from ${JSON.stringify( - manifest.routes[match.route.id].module - )};` - ) + .map((match, routeIndex) => { + let routeVarName = `route${routeIndex}`; + let manifestEntry = manifest.routes[match.route.id]; + let { clientActionModule, clientLoaderModule, module } = manifestEntry; + + // Ordered lowest to highest priority in terms of merging chunks + let chunks: Array<{ module: string; varName: string }> = [ + ...(clientActionModule + ? [{ module: clientActionModule, varName: `${routeVarName}_a` }] + : []), + ...(clientLoaderModule + ? [{ module: clientLoaderModule, varName: `${routeVarName}_l` }] + : []), + { module, varName: `${routeVarName}_m` }, + ]; + + if (chunks.length === 1) { + return `import * as ${routeVarName} from ${JSON.stringify(module)};`; + } + + let chunkImportsSnippet = chunks + .map((chunk) => `import * as ${chunk.varName} from "${chunk.module}";`) + .join("\n"); + + let mergedChunksSnippet = `const ${routeVarName} = {${chunks + .map((chunk) => `...${chunk.varName}`) + .join(",")}};`; + + return [chunkImportsSnippet, mergedChunksSnippet].join("\n"); + }) .join("\n")} ${ enableFogOfWar diff --git a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx index 5035dea725..9c12a4825c 100644 --- a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx +++ b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx @@ -186,7 +186,10 @@ function processRoutes( hasClientAction: false, hasClientLoader: false, hasErrorBoundary: route.ErrorBoundary != null, - module: "build/stub-path-to-module.js", // any need for this? + // any need for these? + module: "build/stub-path-to-module.js", + clientActionModule: undefined, + clientLoaderModule: undefined, }; manifest.routes[newRoute.id] = entryRoute; diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index 7874281735..c8392addcc 100644 --- a/packages/react-router/lib/dom/ssr/routes.tsx +++ b/packages/react-router/lib/dom/ssr/routes.tsx @@ -40,6 +40,8 @@ export interface EntryRoute extends Route { imports?: string[]; css?: string[]; module: string; + clientActionModule: string | undefined; + clientLoaderModule: string | undefined; parentId?: string; } @@ -392,6 +394,23 @@ export function createClientRoutes( if (isSpaMode) return Promise.resolve(null); return fetchServerLoader(singleFetch); }); + } else if (route.clientLoaderModule) { + dataRoute.loader = async ( + args: LoaderFunctionArgs, + singleFetch?: unknown + ) => { + invariant(route.clientLoaderModule); + let { clientLoader } = await import( + /* webpackIgnore: true */ route.clientLoaderModule + ); + return clientLoader({ + ...args, + async serverLoader() { + preventInvalidServerHandlerCall("loader", route, isSpaMode); + return fetchServerLoader(singleFetch); + }, + }); + }; } if (!route.hasClientAction) { dataRoute.action = ( @@ -404,6 +423,23 @@ export function createClientRoutes( } return fetchServerAction(singleFetch); }); + } else if (route.clientActionModule) { + dataRoute.action = async ( + args: ActionFunctionArgs, + singleFetch?: unknown + ) => { + invariant(route.clientActionModule); + let { clientAction } = await import( + /* webpackIgnore: true */ route.clientActionModule + ); + return clientAction({ + ...args, + async serverAction() { + preventInvalidServerHandlerCall("action", route, isSpaMode); + return fetchServerAction(singleFetch); + }, + }); + }; } // Load all other modules via route.lazy() @@ -414,22 +450,23 @@ export function createClientRoutes( ); let lazyRoute: Partial = { ...mod }; - if (mod.clientLoader) { + if (mod.clientLoader && !route.clientLoaderModule) { let clientLoader = mod.clientLoader; lazyRoute.loader = ( args: LoaderFunctionArgs, singleFetch?: unknown - ) => - clientLoader({ + ) => { + return clientLoader({ ...args, async serverLoader() { preventInvalidServerHandlerCall("loader", route, isSpaMode); return fetchServerLoader(singleFetch); }, }); + }; } - if (mod.clientAction) { + if (mod.clientAction && !route.clientActionModule) { let clientAction = mod.clientAction; lazyRoute.action = ( args: ActionFunctionArgs, diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index 994e3fde04..624328a780 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -32,6 +32,8 @@ export interface EntryRoute extends Route { imports?: string[]; css?: string[]; module: string; + clientActionModule: string | undefined; + clientLoaderModule: string | undefined; parentId?: string; } diff --git a/playground/compiler/app/routes/_index.tsx b/playground/compiler/app/routes/_index.tsx index ecfc25c614..a4aaa631a5 100644 --- a/playground/compiler/app/routes/_index.tsx +++ b/playground/compiler/app/routes/_index.tsx @@ -1,4 +1,4 @@ -import type { MetaFunction } from "react-router"; +import { Link, type MetaFunction } from "react-router"; export const meta: MetaFunction = () => { return [ @@ -11,6 +11,14 @@ export default function Index() { return (

Welcome to React Router

+
    +
  • + Go to Chunkable +
  • +
  • + Go to Un-chunkable +
  • +
); } diff --git a/playground/compiler/app/routes/chunkable/clientLoader.ts b/playground/compiler/app/routes/chunkable/clientLoader.ts new file mode 100644 index 0000000000..e20a889184 --- /dev/null +++ b/playground/compiler/app/routes/chunkable/clientLoader.ts @@ -0,0 +1,4 @@ +export const clientLoader = async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return "hello from chunkable client loader!"; +}; diff --git a/playground/compiler/app/routes/chunkable/route.tsx b/playground/compiler/app/routes/chunkable/route.tsx new file mode 100644 index 0000000000..72a8a67aea --- /dev/null +++ b/playground/compiler/app/routes/chunkable/route.tsx @@ -0,0 +1,8 @@ +import { useLoaderData } from "react-router"; +import { clientLoader } from "./clientLoader"; +export { clientLoader } from "./clientLoader"; + +export default function Hello() { + const message = useLoaderData() as Awaited>; + return
{message}
; +} diff --git a/playground/compiler/app/routes/unchunkable.tsx b/playground/compiler/app/routes/unchunkable.tsx new file mode 100644 index 0000000000..44a0617a80 --- /dev/null +++ b/playground/compiler/app/routes/unchunkable.tsx @@ -0,0 +1,11 @@ +import { useLoaderData } from "react-router"; + +export const clientLoader = async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return "hello from unchunkable client loader!"; +}; + +export default function Hello() { + const message = useLoaderData() as Awaited>; + return
{message}
; +} diff --git a/playground/compiler/vite.config.ts b/playground/compiler/vite.config.ts index ac6bab89fb..d91c79714c 100644 --- a/playground/compiler/vite.config.ts +++ b/playground/compiler/vite.config.ts @@ -6,5 +6,12 @@ import tsconfigPaths from "vite-tsconfig-paths"; installGlobals(); export default defineConfig({ - plugins: [reactRouter(), tsconfigPaths()], + plugins: [ + reactRouter({ + future: { + unstable_routeChunks: true, + }, + }), + tsconfigPaths(), + ], }); From b4c0f7078348a404935d60d5a008fb4c079063e4 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 22 Aug 2024 14:50:48 +1000 Subject: [PATCH 02/63] Migrate playground to routes.ts --- playground/compiler/app/routes.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/playground/compiler/app/routes.ts b/playground/compiler/app/routes.ts index e1c94521d2..3f99a8f0b0 100644 --- a/playground/compiler/app/routes.ts +++ b/playground/compiler/app/routes.ts @@ -1,3 +1,7 @@ -import { type RoutesConfig, index } from "@react-router/dev/routes"; +import { type RoutesConfig, route, index } from "@react-router/dev/routes"; -export const routes: RoutesConfig = [index("routes/_index.tsx")]; +export const routes: RoutesConfig = [ + index("routes/_index.tsx"), + route("/chunkable", "routes/chunkable/route.tsx"), + route("/unchunkable", "routes/unchunkable.tsx"), +]; From 7a4fa892a03b8cd544f002aa54bff293effb346d Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 22 Aug 2024 14:52:26 +1000 Subject: [PATCH 03/63] Separate ctx checks from splitting logic --- packages/react-router-dev/vite/plugin.ts | 60 ++++--------------- .../react-router-dev/vite/route-chunks.ts | 39 ++++++++++++ 2 files changed, 52 insertions(+), 47 deletions(-) create mode 100644 packages/react-router-dev/vite/route-chunks.ts diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 2c14c77637..2e0eea8511 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -39,6 +39,7 @@ import * as VirtualModule from "./vmod"; import { resolveFileUrl } from "./resolve-file-url"; import { combineURLs } from "./combine-urls"; import { removeExports } from "./remove-exports"; +import { detectRouteChunks, getRouteChunks } from "./route-chunks"; import { importViteEsmSync, preloadViteEsm } from "./import-vite-esm-sync"; import { type ReactRouterConfig, @@ -634,10 +635,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { let hasClientLoader = sourceExports.includes("clientLoader"); let code = await fse.readFile(routeFilePath, "utf-8"); - let { hasClientActionChunk, hasClientLoaderChunk } = detectRouteChunks({ - ctx, - code, - }); + let { hasClientActionChunk, hasClientLoaderChunk } = + detectRouteChunksIfEnabled({ ctx, code }); let clientActionAssets = hasClientActionChunk ? getReactRouterManifestBuildAssets( @@ -749,10 +748,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { path.resolve(ctx.reactRouterConfig.appDirectory, route.file), "utf-8" ); - let { hasClientActionChunk, hasClientLoaderChunk } = detectRouteChunks({ - ctx, - code, - }); + let { hasClientActionChunk, hasClientLoaderChunk } = + detectRouteChunksIfEnabled({ ctx, code }); routes[key] = { id: route.id, @@ -967,7 +964,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { let { hasClientActionChunk, hasClientLoaderChunk, - } = detectRouteChunks({ ctx, code }); + } = detectRouteChunksIfEnabled({ ctx, code }); return [ `${routeFilePath}${ROUTE_ENTRY_QUERY_STRING}`, @@ -1355,7 +1352,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { ); let { hasRouteChunks, hasClientActionChunk, hasClientLoaderChunk } = - detectRouteChunks({ ctx, code }); + detectRouteChunksIfEnabled({ ctx, code }); let reexports = sourceExports .filter( @@ -1391,7 +1388,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { // Ignore anything that isn't marked as a route chunk if (!id.includes(ROUTE_CHUNK_QUERY_STRING)) return; - let chunks = getRouteChunks({ ctx, code }); + let chunks = getRouteChunksIfEnabled({ ctx, code }); if (id.endsWith(MAIN_ROUTE_CHUNK_QUERY_STRING)) { return chunks.main; } @@ -2090,17 +2087,13 @@ function createPrerenderRoutes( } // TODO: Make this real -function detectRouteChunks({ +function detectRouteChunksIfEnabled({ ctx, code, }: { ctx: ReactRouterPluginContext; code: string; -}): { - hasClientActionChunk: boolean; - hasClientLoaderChunk: boolean; - hasRouteChunks: boolean; -} { +}): ReturnType { if (!ctx.reactRouterConfig.future.unstable_routeChunks) { return { hasClientActionChunk: false, @@ -2109,23 +2102,11 @@ function detectRouteChunks({ }; } - let hasClientActionChunk = code.includes( - 'export { clientAction } from "./clientAction";' - ); - let hasClientLoaderChunk = code.includes( - 'export { clientLoader } from "./clientLoader";' - ); - let hasRouteChunks = hasClientActionChunk || hasClientLoaderChunk; - - return { - hasClientActionChunk, - hasClientLoaderChunk, - hasRouteChunks, - }; + return detectRouteChunks({ code }); } // TODO: Make this real -function getRouteChunks({ +function getRouteChunksIfEnabled({ ctx, code, }: { @@ -2137,20 +2118,5 @@ function getRouteChunks({ "Route chunks shouldn't be requested when future.unstable_routeChunks is disabled" ); - let { hasClientActionChunk, hasClientLoaderChunk } = detectRouteChunks({ - ctx, - code, - }); - - return { - main: code - .replace('export { clientAction } from "./clientAction";', "") - .replace('export { clientLoader } from "./clientLoader";', ""), - clientAction: hasClientActionChunk - ? `export { clientAction } from "./clientAction";` - : undefined, - clientLoader: hasClientLoaderChunk - ? `export { clientLoader } from "./clientLoader";` - : undefined, - }; + return getRouteChunks({ code }); } diff --git a/packages/react-router-dev/vite/route-chunks.ts b/packages/react-router-dev/vite/route-chunks.ts new file mode 100644 index 0000000000..336a856346 --- /dev/null +++ b/packages/react-router-dev/vite/route-chunks.ts @@ -0,0 +1,39 @@ +// TODO: Make this real +export function detectRouteChunks({ code }: { code: string }): { + hasClientActionChunk: boolean; + hasClientLoaderChunk: boolean; + hasRouteChunks: boolean; +} { + let hasClientActionChunk = code.includes( + 'export { clientAction } from "./clientAction";' + ); + let hasClientLoaderChunk = code.includes( + 'export { clientLoader } from "./clientLoader";' + ); + let hasRouteChunks = hasClientActionChunk || hasClientLoaderChunk; + + return { + hasClientActionChunk, + hasClientLoaderChunk, + hasRouteChunks, + }; +} + +// TODO: Make this real +export function getRouteChunks({ code }: { code: string }) { + let { hasClientActionChunk, hasClientLoaderChunk } = detectRouteChunks({ + code, + }); + + return { + main: code + .replace('export { clientAction } from "./clientAction";', "") + .replace('export { clientLoader } from "./clientLoader";', ""), + clientAction: hasClientActionChunk + ? `export { clientAction } from "./clientAction";` + : undefined, + clientLoader: hasClientLoaderChunk + ? `export { clientLoader } from "./clientLoader";` + : undefined, + }; +} From f16cadad7bb6588ea3f2a8b46f57a55044f95ccf Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 23 Aug 2024 19:05:09 +1000 Subject: [PATCH 04/63] Add initial `hasChunkableExport` implementation --- .../vite/route-chunks-test.ts | 117 ++++++++++++++ .../react-router-dev/vite/route-chunks.ts | 152 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 packages/react-router-dev/vite/route-chunks-test.ts diff --git a/packages/react-router-dev/vite/route-chunks-test.ts b/packages/react-router-dev/vite/route-chunks-test.ts new file mode 100644 index 0000000000..6d6b7851dc --- /dev/null +++ b/packages/react-router-dev/vite/route-chunks-test.ts @@ -0,0 +1,117 @@ +import dedent from "dedent"; + +import { + getTopLevelStatementsByExportName, + hasChunkableExport, +} from "./route-chunks"; + +describe("hasChunkableExport", () => { + function chunkable(code: string, exportName: string) { + const topLevelStatementsByExport = getTopLevelStatementsByExportName(code); + return hasChunkableExport(topLevelStatementsByExport, exportName); + } + + describe("chunkable", () => { + test("default function export with no identifiers", () => { + const code = dedent` + export default function () { + return null; + } + + export const other = () => { + return null; + } + `; + expect(chunkable(code, "default")).toBe(true); + }); + + test("const function containing no identifiers", () => { + const code = dedent` + export const target = () => { + return null; + } + + export const other = () => { + return null; + } + `; + expect(chunkable(code, "target")).toBe(true); + }); + + test("const function referencing its own identifiers", () => { + const code = dedent` + import { targetMessage } from "./targetMessage"; + const getTargetMessage = () => targetMessage; + export const target = () => getTargetMessage(); + + import { otherMessage } from "./otherMessage"; + const getOtherMessage = () => otherMessage; + export const other = () => getOtherMessage(); + `; + expect(chunkable(code, "target")).toBe(true); + }); + + test("function declaration referencing its own identifiers", () => { + const code = dedent` + import { targetMessage } from "./targetMessage"; + const getTargetMessage = () => targetMessage; + export function target() { return getTargetMessage(); } + + import { otherMessage } from "./otherMessage"; + const getOtherMessage = () => otherMessage; + export function other() { return getOtherMessage(); } + `; + expect(chunkable(code, "target")).toBe(true); + }); + }); + + describe("not chunkable", () => { + test("export not present", () => { + const code = dedent` + export default function () {} + `; + expect(chunkable(code, "target")).toBe(false); + }); + + test("const function interacting with shared import", () => { + const code = dedent` + import { sharedMessage } from "./sharedMessage"; + + const targetMessage = sharedMessage; + const getTargetMessage = () => targetMessage; + export const target = () => getTargetMessage(); + + const otherMessage = sharedMessage; + const getOtherMessage = () => otherMessage; + export const other = () => getOtherMessage(); + `; + expect(chunkable(code, "target")).toBe(false); + }); + + test("const function interacting with shared import statement", () => { + const code = dedent` + import { targetMessage, otherMessage } from "./messages"; + + const getTargetMessage = () => targetMessage; + export const target = () => getTargetMessage(); + + const getOtherMessage = () => otherMessage; + export const other = () => getOtherMessage(); + `; + expect(chunkable(code, "target")).toBe(false); + }); + + test("const function interacting with shared named import", () => { + const code = dedent` + import * as messages from "./messages"; + + const getTargetMessage = () => messages.targetMessage; + export const target = () => getTargetMessage(); + + const getOtherMessage = () => messages.otherMessage; + export const other = () => getOtherMessage(); + `; + expect(chunkable(code, "target")).toBe(false); + }); + }); +}); diff --git a/packages/react-router-dev/vite/route-chunks.ts b/packages/react-router-dev/vite/route-chunks.ts index 336a856346..fae1d819d4 100644 --- a/packages/react-router-dev/vite/route-chunks.ts +++ b/packages/react-router-dev/vite/route-chunks.ts @@ -1,3 +1,155 @@ +import { type BabelTypes, type NodePath, parse, traverse } from "./babel"; +import invariant from "../invariant"; + +type Identifier = BabelTypes.Identifier; + +export function getTopLevelStatementsByExportName( + code: string +): Map> { + let ast = parse(code, { sourceType: "module" }); + let topLevelStatementsByExportName = new Map>(); + + traverse(ast, { + ExportDeclaration(exportPath) { + let visited = new Set(); + let identifiers = new Set>(); + + collectIdentifiers(visited, identifiers, exportPath); + + let topLevelStatements = new Set([ + exportPath, + ...getTopLevelStatementsForPaths(identifiers), + ]); + for (let exportName of getExportNames(exportPath)) { + topLevelStatementsByExportName.set(exportName, topLevelStatements); + } + }, + }); + + return topLevelStatementsByExportName; +} + +function collectIdentifiers( + visited: Set, + identifiers: Set>, + path: NodePath +): void { + visited.add(path); + path.traverse({ + Identifier(path) { + identifiers.add(path); + let binding = path.scope.getBinding(path.node.name); + if (binding?.path && !visited.has(binding.path)) { + collectIdentifiers(visited, identifiers, binding.path); + } + }, + }); +} + +function getTopLevelStatementsForPaths(paths: Set): Set { + let topLevelStatements = new Set(); + for (let path of paths) { + let ancestry = path.getAncestry(); + // The last node is the Program node so we want the ancestor before that + let topLevelStatement = ancestry[ancestry.length - 2]; + topLevelStatements.add(topLevelStatement); + } + return topLevelStatements; +} + +function getExportNames( + path: NodePath +): string[] { + // export default ...; + if (path.node.type === "ExportDefaultDeclaration") { + return ["default"]; + } + + // export { foo }; + // export { bar } from "./module"; + if (path.node.type === "ExportNamedDeclaration") { + if (path.node.declaration?.type === "VariableDeclaration") { + let declaration = path.node.declaration; + return declaration.declarations.map((declaration) => { + if (declaration.id.type === "Identifier") { + return declaration.id.name; + } + + throw new Error( + "Exporting of destructured identifiers not yet implemented" + ); + }); + } + + // export function foo() {} + if (path.node.declaration?.type === "FunctionDeclaration") { + let id = path.node.declaration.id; + invariant(id, "Expected exported function declaration to have a name"); + return [id.name]; + } + + // export class Foo() {} + if (path.node.declaration?.type === "ClassDeclaration") { + let id = path.node.declaration.id; + invariant(id, "Expected exported class declaration to have a name"); + return [id.name]; + } + } + + return []; +} + +function areSetsDisjoint(set1: Set, set2: Set): boolean { + let smallerSet = set1; + let largerSet = set2; + if (set1.size > set2.size) { + smallerSet = set2; + largerSet = set1; + } + for (let element of smallerSet) { + if (largerSet.has(element)) { + return false; + } + } + return true; +} + +export function hasChunkableExport( + topLevelStatementsByExportName: Map>, + exportName: string +): boolean { + let topLevelStatements = topLevelStatementsByExportName.get(exportName); + + // Export wasn't found in the file + if (!topLevelStatements) { + return false; + } + + // Export had no identifiers to collect, so it's isolated + // e.g. export default function () { return "string" } + if (topLevelStatements.size === 0) { + return true; + } + + // Loop through all other exports to see if they have any top level statements + // in common with the export we're trying to create a chunk for + for (let [ + currentExportName, + currentTopLevelStatements, + ] of topLevelStatementsByExportName) { + if (currentExportName === exportName) { + continue; + } + // As soon as we find any top level statements in common with another export, + // we know this export cannot be placed in its own chunk + if (!areSetsDisjoint(currentTopLevelStatements, topLevelStatements)) { + return false; + } + } + + return true; +} + // TODO: Make this real export function detectRouteChunks({ code }: { code: string }): { hasClientActionChunk: boolean; From e54e8cabf81087971780a264d5e46ebdf4b72a06 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 26 Aug 2024 19:33:12 +1000 Subject: [PATCH 05/63] Add initial route chunk generation logic --- .../vite/route-chunks-test.ts | 302 ++++++++++++++---- .../react-router-dev/vite/route-chunks.ts | 110 ++++++- 2 files changed, 330 insertions(+), 82 deletions(-) diff --git a/packages/react-router-dev/vite/route-chunks-test.ts b/packages/react-router-dev/vite/route-chunks-test.ts index 6d6b7851dc..c1a5b31c4f 100644 --- a/packages/react-router-dev/vite/route-chunks-test.ts +++ b/packages/react-router-dev/vite/route-chunks-test.ts @@ -1,117 +1,279 @@ import dedent from "dedent"; import { - getTopLevelStatementsByExportName, hasChunkableExport, + getChunkedExport, + omitChunkedExports, } from "./route-chunks"; -describe("hasChunkableExport", () => { - function chunkable(code: string, exportName: string) { - const topLevelStatementsByExport = getTopLevelStatementsByExportName(code); - return hasChunkableExport(topLevelStatementsByExport, exportName); - } - +describe("route chunks", () => { describe("chunkable", () => { - test("default function export with no identifiers", () => { + test("functions with no identifiers", () => { const code = dedent` - export default function () { + export default function () { return null; } + export function target1() { return null; } + export function other1() { return null; } + export const target2 = () => null; + export const other2 = () => null; + `; + expect(hasChunkableExport(code, "default")).toBe(true); + expect(hasChunkableExport(code, "target1")).toBe(true); + expect(hasChunkableExport(code, "target2")).toBe(true); + expect(getChunkedExport(code, "default")?.code).toMatchInlineSnapshot(` + "export default function () { return null; - } - - export const other = () => { + }" + `); + expect(getChunkedExport(code, "target1")?.code).toMatchInlineSnapshot(` + "export function target1() { + return null; + }" + `); + expect(getChunkedExport(code, "target2")?.code).toMatchInlineSnapshot( + `"export const target2 = () => null;"` + ); + expect(omitChunkedExports(code, ["default", "target1", "target2"])?.code) + .toMatchInlineSnapshot(` + "export function other1() { return null; } - `; - expect(chunkable(code, "default")).toBe(true); + export const other2 = () => null;" + `); }); - test("const function containing no identifiers", () => { + test("functions referencing their own identifiers", () => { const code = dedent` - export const target = () => { - return null; - } + import { targetMessage1 } from "./targetMessage1"; + import { targetMessage2 } from "./targetMessage2"; + import { otherMessage1 } from "./otherMessage1"; + import { otherMessage2 } from "./otherMessage2"; - export const other = () => { - return null; - } + const getTargetMessage1 = () => targetMessage1; + const getOtherMessage1 = () => otherMessage1; + function getTargetMessage2() { return targetMessage2; } + function getOtherMessage2() { return otherMessage2; } + + export function target1() { return getTargetMessage1(); } + export function other1() { return getOtherMessage1(); } + export const target2 = () => getTargetMessage2(); + export const other2 = () => getOtherMessage2(); `; - expect(chunkable(code, "target")).toBe(true); + expect(hasChunkableExport(code, "target1")).toBe(true); + expect(hasChunkableExport(code, "target2")).toBe(true); + expect(getChunkedExport(code, "target1")?.code).toMatchInlineSnapshot(` + "import { targetMessage1 } from "./targetMessage1"; + const getTargetMessage1 = () => targetMessage1; + export function target1() { + return getTargetMessage1(); + }" + `); + expect(getChunkedExport(code, "target2")?.code).toMatchInlineSnapshot(` + "import { targetMessage2 } from "./targetMessage2"; + function getTargetMessage2() { + return targetMessage2; + } + export const target2 = () => getTargetMessage2();" + `); + expect(omitChunkedExports(code, ["target1", "target2"])?.code) + .toMatchInlineSnapshot(` + "import { otherMessage1 } from "./otherMessage1"; + import { otherMessage2 } from "./otherMessage2"; + const getOtherMessage1 = () => otherMessage1; + function getOtherMessage2() { + return otherMessage2; + } + export function other1() { + return getOtherMessage1(); + } + export const other2 = () => getOtherMessage2();" + `); }); + }); - test("const function referencing its own identifiers", () => { + describe("partially chunkable", () => { + test("function with no identifiers, one with shared identifiers", () => { const code = dedent` - import { targetMessage } from "./targetMessage"; - const getTargetMessage = () => targetMessage; - export const target = () => getTargetMessage(); - - import { otherMessage } from "./otherMessage"; - const getOtherMessage = () => otherMessage; - export const other = () => getOtherMessage(); - `; - expect(chunkable(code, "target")).toBe(true); + import { sharedMessage } from "./sharedMessage"; + export default function () { return null; } + export function target1() { return null; } + export const target2 = () => sharedMessage; + export const other1 = () => sharedMessage; + export const other2 = () => sharedMessage; + `; + expect(hasChunkableExport(code, "default")).toBe(true); + expect(hasChunkableExport(code, "target1")).toBe(true); + expect(hasChunkableExport(code, "target2")).toBe(false); + expect(getChunkedExport(code, "default")?.code).toMatchInlineSnapshot(` + "export default function () { + return null; + }" + `); + expect(getChunkedExport(code, "target1")?.code).toMatchInlineSnapshot(` + "export function target1() { + return null; + }" + `); + expect(getChunkedExport(code, "target2")).toBeUndefined(); + expect(omitChunkedExports(code, ["default", "target1", "target2"])?.code) + .toMatchInlineSnapshot(` + "import { sharedMessage } from "./sharedMessage"; + export const target2 = () => sharedMessage; + export const other1 = () => sharedMessage; + export const other2 = () => sharedMessage;" + `); }); - test("function declaration referencing its own identifiers", () => { + test("function referencing its own identifiers, another one sharing an identifier", () => { const code = dedent` - import { targetMessage } from "./targetMessage"; - const getTargetMessage = () => targetMessage; - export function target() { return getTargetMessage(); } + import { targetMessage1 } from "./targetMessage1"; + import { sharedMessage } from "./sharedMessage"; - import { otherMessage } from "./otherMessage"; - const getOtherMessage = () => otherMessage; - export function other() { return getOtherMessage(); } + const getTargetMessage1 = () => targetMessage1; + const getOtherMessage1 = () => sharedMessage; + function getTargetMessage2() { return sharedMessage; } + function getOtherMessage2() { return sharedMessage; } + + export function target1() { return getTargetMessage1(); } + export function other1() { return getOtherMessage1(); } + export const target2 = () => getTargetMessage2(); + export const other2 = () => getOtherMessage2(); `; - expect(chunkable(code, "target")).toBe(true); + expect(hasChunkableExport(code, "target1")).toBe(true); + expect(hasChunkableExport(code, "target2")).toBe(false); + expect(getChunkedExport(code, "target1")?.code).toMatchInlineSnapshot(` + "import { targetMessage1 } from "./targetMessage1"; + const getTargetMessage1 = () => targetMessage1; + export function target1() { + return getTargetMessage1(); + }" + `); + expect(getChunkedExport(code, "target2")).toBeUndefined(); + expect(omitChunkedExports(code, ["target1", "target2"])?.code) + .toMatchInlineSnapshot(` + "import { sharedMessage } from "./sharedMessage"; + const getOtherMessage1 = () => sharedMessage; + function getTargetMessage2() { + return sharedMessage; + } + function getOtherMessage2() { + return sharedMessage; + } + export function other1() { + return getOtherMessage1(); + } + export const target2 = () => getTargetMessage2(); + export const other2 = () => getOtherMessage2();" + `); }); }); describe("not chunkable", () => { - test("export not present", () => { + test("exports not present", () => { const code = dedent` export default function () {} `; - expect(chunkable(code, "target")).toBe(false); + expect(hasChunkableExport(code, "target1")).toBe(false); + expect(getChunkedExport(code, "target1")).toBeUndefined(); + expect(hasChunkableExport(code, "target2")).toBe(false); + expect(getChunkedExport(code, "target2")).toBeUndefined(); + expect( + omitChunkedExports(code, ["target1", "target2"])?.code + ).toMatchInlineSnapshot(`"export default function () {}"`); }); - test("const function interacting with shared import", () => { + test("functions sharing a variable", () => { const code = dedent` - import { sharedMessage } from "./sharedMessage"; - - const targetMessage = sharedMessage; - const getTargetMessage = () => targetMessage; - export const target = () => getTargetMessage(); - - const otherMessage = sharedMessage; - const getOtherMessage = () => otherMessage; - export const other = () => getOtherMessage(); + const sharedMessage = "shared"; + const getTargetMessage1 = () => sharedMessage; + const getTargetMessage2 = () => sharedMessage; + const getOtherMessage1 = () => sharedMessage; + const getOtherMessage2 = () => sharedMessage; + export const target1 = () => getTargetMessage1(); + export const target2 = () => getTargetMessage2(); + export const other1 = () => getOtherMessage1(); + export const other2 = () => getOtherMessage2(); `; - expect(chunkable(code, "target")).toBe(false); + expect(hasChunkableExport(code, "target1")).toBe(false); + expect(getChunkedExport(code, "target1")).toBeUndefined(); + expect(hasChunkableExport(code, "target2")).toBe(false); + expect(getChunkedExport(code, "target2")).toBeUndefined(); + expect(omitChunkedExports(code, ["target1", "target2"])?.code) + .toMatchInlineSnapshot(` + "const sharedMessage = "shared"; + const getTargetMessage1 = () => sharedMessage; + const getTargetMessage2 = () => sharedMessage; + const getOtherMessage1 = () => sharedMessage; + const getOtherMessage2 = () => sharedMessage; + export const target1 = () => getTargetMessage1(); + export const target2 = () => getTargetMessage2(); + export const other1 = () => getOtherMessage1(); + export const other2 = () => getOtherMessage2();" + `); }); - test("const function interacting with shared import statement", () => { + test("functions sharing an import statement", () => { const code = dedent` - import { targetMessage, otherMessage } from "./messages"; - - const getTargetMessage = () => targetMessage; - export const target = () => getTargetMessage(); - - const getOtherMessage = () => otherMessage; - export const other = () => getOtherMessage(); + import { + targetMessage1, + targetMessage2, + otherMessage1, + otherMessage2 + } from "./messages"; + const getTargetMessage1 = () => targetMessage1; + const getTargetMessage2 = () => targetMessage2; + const getOtherMessage1 = () => otherMessage1; + const getOtherMessage2 = () => otherMessage2; + export const target1 = () => getTargetMessage1(); + export const target2 = () => getTargetMessage2(); + export const other1 = () => getOtherMessage1(); + export const other2 = () => getOtherMessage2(); `; - expect(chunkable(code, "target")).toBe(false); + expect(hasChunkableExport(code, "target1")).toBe(false); + expect(getChunkedExport(code, "target1")).toBeUndefined(); + expect(hasChunkableExport(code, "target2")).toBe(false); + expect(getChunkedExport(code, "target2")).toBeUndefined(); + expect(omitChunkedExports(code, ["target1", "target2"])?.code) + .toMatchInlineSnapshot(` + "import { targetMessage1, targetMessage2, otherMessage1, otherMessage2 } from "./messages"; + const getTargetMessage1 = () => targetMessage1; + const getTargetMessage2 = () => targetMessage2; + const getOtherMessage1 = () => otherMessage1; + const getOtherMessage2 = () => otherMessage2; + export const target1 = () => getTargetMessage1(); + export const target2 = () => getTargetMessage2(); + export const other1 = () => getOtherMessage1(); + export const other2 = () => getOtherMessage2();" + `); }); - test("const function interacting with shared named import", () => { + test("functions sharing a named import", () => { const code = dedent` import * as messages from "./messages"; - - const getTargetMessage = () => messages.targetMessage; - export const target = () => getTargetMessage(); - - const getOtherMessage = () => messages.otherMessage; - export const other = () => getOtherMessage(); + const getTargetMessage1 = () => messages.targetMessage1; + const getTargetMessage2 = () => messages.targetMessage2; + const getOtherMessage1 = () => messages.otherMessage1; + const getOtherMessage2 = () => messages.otherMessage2; + export const target1 = () => getTargetMessage1(); + export const target2 = () => getTargetMessage2(); + export const other1 = () => getOtherMessage1(); + export const other2 = () => getOtherMessage2(); `; - expect(chunkable(code, "target")).toBe(false); + expect(hasChunkableExport(code, "target1")).toBe(false); + expect(getChunkedExport(code, "target1")).toBeUndefined(); + expect(hasChunkableExport(code, "target2")).toBe(false); + expect(getChunkedExport(code, "target2")).toBeUndefined(); + expect(omitChunkedExports(code, ["target1", "target2"])?.code) + .toMatchInlineSnapshot(` + "import * as messages from "./messages"; + const getTargetMessage1 = () => messages.targetMessage1; + const getTargetMessage2 = () => messages.targetMessage2; + const getOtherMessage1 = () => messages.otherMessage1; + const getOtherMessage2 = () => messages.otherMessage2; + export const target1 = () => getTargetMessage1(); + export const target2 = () => getTargetMessage2(); + export const other1 = () => getOtherMessage1(); + export const other2 = () => getOtherMessage2();" + `); }); }); }); diff --git a/packages/react-router-dev/vite/route-chunks.ts b/packages/react-router-dev/vite/route-chunks.ts index fae1d819d4..73807c8c0f 100644 --- a/packages/react-router-dev/vite/route-chunks.ts +++ b/packages/react-router-dev/vite/route-chunks.ts @@ -1,13 +1,22 @@ -import { type BabelTypes, type NodePath, parse, traverse } from "./babel"; +import type { GeneratorOptions, GeneratorResult } from "@babel/generator"; +import { + type BabelTypes, + type NodePath, + parse, + traverse, + generate, + t, +} from "./babel"; import invariant from "../invariant"; +type Statement = BabelTypes.Statement; type Identifier = BabelTypes.Identifier; -export function getTopLevelStatementsByExportName( +function getTopLevelStatementsByExportName( code: string -): Map> { +): Map> { let ast = parse(code, { sourceType: "module" }); - let topLevelStatementsByExportName = new Map>(); + let topLevelStatementsByExportName = new Map>(); traverse(ast, { ExportDeclaration(exportPath) { @@ -17,7 +26,7 @@ export function getTopLevelStatementsByExportName( collectIdentifiers(visited, identifiers, exportPath); let topLevelStatements = new Set([ - exportPath, + exportPath.node, ...getTopLevelStatementsForPaths(identifiers), ]); for (let exportName of getExportNames(exportPath)) { @@ -46,12 +55,16 @@ function collectIdentifiers( }); } -function getTopLevelStatementsForPaths(paths: Set): Set { - let topLevelStatements = new Set(); +function getTopLevelStatementsForPaths(paths: Set): Set { + let topLevelStatements = new Set(); for (let path of paths) { let ancestry = path.getAncestry(); // The last node is the Program node so we want the ancestor before that - let topLevelStatement = ancestry[ancestry.length - 2]; + let topLevelStatement = ancestry[ancestry.length - 2].node as Statement; + invariant( + t.isStatement(topLevelStatement), + `Expected statement, found type "${topLevelStatement.type}"` + ); topLevelStatements.add(topLevelStatement); } return topLevelStatements; @@ -114,10 +127,8 @@ function areSetsDisjoint(set1: Set, set2: Set): boolean { return true; } -export function hasChunkableExport( - topLevelStatementsByExportName: Map>, - exportName: string -): boolean { +export function hasChunkableExport(code: string, exportName: string): boolean { + let topLevelStatementsByExportName = getTopLevelStatementsByExportName(code); let topLevelStatements = topLevelStatementsByExportName.get(exportName); // Export wasn't found in the file @@ -150,6 +161,81 @@ export function hasChunkableExport( return true; } +function replaceBody( + ast: BabelTypes.File, + replacer: (body: Array) => Array +): BabelTypes.File { + return { + ...ast, + program: { + ...ast.program, + body: replacer(ast.program.body), + }, + }; +} + +export function getChunkedExport( + code: string, + exportName: string, + generateOptions: GeneratorOptions = {} +): GeneratorResult | undefined { + let ast = parse(code, { sourceType: "module" }); + let topLevelStatementsByExportName = getTopLevelStatementsByExportName(code); + + if (!hasChunkableExport(code, exportName)) { + return undefined; + } + + let topLevelStatements = topLevelStatementsByExportName.get(exportName); + invariant(topLevelStatements, "Expected export to have top level statements"); + + let topLevelStatementsArray = Array.from(topLevelStatements); + let chunkAst = replaceBody(ast, (body) => + body.filter((node) => + topLevelStatementsArray.some((statement) => + t.isNodesEquivalent(node, statement) + ) + ) + ); + + return generate(chunkAst, generateOptions); +} + +export function omitChunkedExports( + code: string, + exportNames: string[], + generateOptions: GeneratorOptions = {} +): GeneratorResult | undefined { + let ast = parse(code, { sourceType: "module" }); + let topLevelStatementsByExportName = getTopLevelStatementsByExportName(code); + let omittedStatements = new Set(); + + for (let exportName of exportNames) { + let topLevelStatements = topLevelStatementsByExportName.get(exportName); + if (!topLevelStatements || !hasChunkableExport(code, exportName)) { + continue; + } + for (let statement of topLevelStatements) { + omittedStatements.add(statement); + } + } + + let omittedStatementsArray = Array.from(omittedStatements); + let astWithChunksOmitted = replaceBody(ast, (body) => + body.filter((node) => + omittedStatementsArray.every( + (statement) => !t.isNodesEquivalent(node, statement) + ) + ) + ); + + if (astWithChunksOmitted.program.body.length === 0) { + return undefined; + } + + return generate(astWithChunksOmitted, generateOptions); +} + // TODO: Make this real export function detectRouteChunks({ code }: { code: string }): { hasClientActionChunk: boolean; From 3b76b6d5ec6db65fb3e237c9028a13a7e9d4fad7 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 27 Aug 2024 19:28:56 +1000 Subject: [PATCH 06/63] Wire up route chunks logic to Vite plugin --- packages/react-router-dev/vite/plugin.ts | 135 +++++++++++------- .../react-router-dev/vite/route-chunks.ts | 32 ++--- playground/compiler/app/routes.ts | 2 +- .../{chunkable/route.tsx => chunkable.tsx} | 7 +- .../app/routes/chunkable/clientLoader.ts | 4 - .../compiler/app/routes/unchunkable.tsx | 7 +- 6 files changed, 106 insertions(+), 81 deletions(-) rename playground/compiler/app/routes/{chunkable/route.tsx => chunkable.tsx} (55%) delete mode 100644 playground/compiler/app/routes/chunkable/clientLoader.ts diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 2e0eea8511..b256a7036c 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -315,12 +315,12 @@ const getRouteManifestModuleExports = async ( return Object.fromEntries(entries); }; -const getRouteModuleExports = async ( +const compileRouteFile = async ( viteChildCompiler: Vite.ViteDevServer | null, ctx: ReactRouterPluginContext, routeFile: string, readRouteFile?: () => string | Promise -): Promise => { +): Promise => { if (!viteChildCompiler) { throw new Error("Vite child compiler not found"); } @@ -350,7 +350,27 @@ const getRouteModuleExports = async ( ]); let transformed = await pluginContainer.transform(code, id, { ssr }); - let [, exports] = esModuleLexer(transformed.code); + return transformed.code; +}; + +const getRouteModuleExports = async ( + viteChildCompiler: Vite.ViteDevServer | null, + ctx: ReactRouterPluginContext, + routeFile: string, + readRouteFile?: () => string | Promise +): Promise => { + if (!viteChildCompiler) { + throw new Error("Vite child compiler not found"); + } + + let code = await compileRouteFile( + viteChildCompiler, + ctx, + routeFile, + readRouteFile + ); + + let [, exports] = esModuleLexer(code); let exportNames = exports.map((e) => e.n); return exportNames; @@ -605,6 +625,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { reactRouterServerManifest: ReactRouterManifest; }> => { invariant(viteConfig); + invariant(viteChildCompiler); let viteManifest = await loadViteManifest( getClientBuildDirectory(ctx.reactRouterConfig) @@ -625,31 +646,27 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { ); for (let [key, route] of Object.entries(ctx.reactRouterConfig.routes)) { - let routeFilePath = path.join( - ctx.reactRouterConfig.appDirectory, - route.file - ); + let routeFile = path.join(ctx.reactRouterConfig.appDirectory, route.file); let sourceExports = routeManifestExports[key]; let isRootRoute = route.parentId === undefined; let hasClientAction = sourceExports.includes("clientAction"); let hasClientLoader = sourceExports.includes("clientLoader"); - let code = await fse.readFile(routeFilePath, "utf-8"); let { hasClientActionChunk, hasClientLoaderChunk } = - detectRouteChunksIfEnabled({ ctx, code }); + await detectRouteChunksIfEnabled(ctx, { routeFile, viteChildCompiler }); let clientActionAssets = hasClientActionChunk ? getReactRouterManifestBuildAssets( ctx, viteManifest, - routeFilePath + CLIENT_ACTION_CHUNK_QUERY_STRING + routeFile + CLIENT_ACTION_CHUNK_QUERY_STRING ) : null; let clientLoaderAssets = hasClientLoaderChunk ? getReactRouterManifestBuildAssets( ctx, viteManifest, - routeFilePath + CLIENT_LOADER_CHUNK_QUERY_STRING + routeFile + CLIENT_LOADER_CHUNK_QUERY_STRING ) : null; @@ -667,7 +684,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { ...getReactRouterManifestBuildAssets( ctx, viteManifest, - routeFilePath, + routeFile, // If this is the root route, we also need to include assets from the // client entry file as this is a common way for consumers to import // global reset styles, etc. @@ -725,6 +742,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { // In dev, the server and browser manifests are the same let getReactRouterManifestForDev = async (): Promise => { + invariant(viteChildCompiler); + let routes: ReactRouterManifest["routes"] = {}; let routeManifestExports = await getRouteManifestModuleExports( @@ -733,6 +752,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { ); for (let [key, route] of Object.entries(ctx.reactRouterConfig.routes)) { + let routeFile = route.file; let sourceExports = routeManifestExports[key]; let hasClientAction = sourceExports.includes("clientAction"); let hasClientLoader = sourceExports.includes("clientLoader"); @@ -744,12 +764,8 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { )}` ); - let code = await fse.readFile( - path.resolve(ctx.reactRouterConfig.appDirectory, route.file), - "utf-8" - ); let { hasClientActionChunk, hasClientLoaderChunk } = - detectRouteChunksIfEnabled({ ctx, code }); + await detectRouteChunksIfEnabled(ctx, { routeFile, viteChildCompiler }); routes[key] = { id: route.id, @@ -961,19 +977,25 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { routeFilePath, "utf-8" ); - let { - hasClientActionChunk, - hasClientLoaderChunk, - } = detectRouteChunksIfEnabled({ ctx, code }); + + // If these keywords are present in the route + // module, we need to include their relevant route + // module chunks as entry points just in case + // they're needed. If they're not, they'll resolve + // to empty modules in the final build. + let possiblyHasClientActionChunk = + code.includes("clientAction"); + let possiblyHasClientLoaderChunk = + code.includes("clientLoader"); return [ `${routeFilePath}${ROUTE_ENTRY_QUERY_STRING}`, - ...(hasClientActionChunk + ...(possiblyHasClientActionChunk ? [ `${routeFilePath}${CLIENT_ACTION_CHUNK_QUERY_STRING}`, ] : []), - ...(hasClientLoaderChunk + ...(possiblyHasClientLoaderChunk ? [ `${routeFilePath}${CLIENT_LOADER_CHUNK_QUERY_STRING}`, ] @@ -1338,7 +1360,6 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { }, { name: "react-router-route-entry", - enforce: "pre", async transform(code, id, options) { if (!isRouteEntry(id)) return; let routeModuleId = id.replace(ROUTE_ENTRY_QUERY_STRING, ""); @@ -1352,7 +1373,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { ); let { hasRouteChunks, hasClientActionChunk, hasClientLoaderChunk } = - detectRouteChunksIfEnabled({ ctx, code }); + await detectRouteChunksIfEnabled(ctx, code); let reexports = sourceExports .filter( @@ -1380,7 +1401,6 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { }, { name: "react-router-route-chunks", - enforce: "pre", async transform(code, id, options) { // Routes aren't chunked on the server if (options?.ssr) return; @@ -1388,15 +1408,20 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { // Ignore anything that isn't marked as a route chunk if (!id.includes(ROUTE_CHUNK_QUERY_STRING)) return; - let chunks = getRouteChunksIfEnabled({ ctx, code }); + let chunks = await getRouteChunksIfEnabled(ctx, code); + + if (chunks === null) { + return "// Route chunks disabled"; + } + if (id.endsWith(MAIN_ROUTE_CHUNK_QUERY_STRING)) { - return chunks.main; + return chunks.main ?? "// No main chunk"; } if (id.endsWith(CLIENT_ACTION_CHUNK_QUERY_STRING)) { - return chunks.clientAction; + return chunks.clientAction ?? "// No client action chunk"; } if (id.endsWith(CLIENT_LOADER_CHUNK_QUERY_STRING)) { - return chunks.clientLoader; + return chunks.clientLoader ?? "// No client loader chunk"; } }, }, @@ -2086,14 +2111,18 @@ function createPrerenderRoutes( }); } -// TODO: Make this real -function detectRouteChunksIfEnabled({ - ctx, - code, -}: { - ctx: ReactRouterPluginContext; - code: string; -}): ReturnType { +const resolveRouteFileCode = async ( + ctx: ReactRouterPluginContext, + input: string | { routeFile: string; viteChildCompiler: Vite.ViteDevServer } +): Promise => + typeof input === "string" + ? input + : await compileRouteFile(input.viteChildCompiler, ctx, input.routeFile); + +async function detectRouteChunksIfEnabled( + ctx: ReactRouterPluginContext, + input: string | { routeFile: string; viteChildCompiler: Vite.ViteDevServer } +): Promise> { if (!ctx.reactRouterConfig.future.unstable_routeChunks) { return { hasClientActionChunk: false, @@ -2102,21 +2131,27 @@ function detectRouteChunksIfEnabled({ }; } + let code = await resolveRouteFileCode(ctx, input); + if (!code.includes("clientLoader") && !code.includes("clientAction")) { + return { + hasClientActionChunk: false, + hasClientLoaderChunk: false, + hasRouteChunks: false, + }; + } + return detectRouteChunks({ code }); } -// TODO: Make this real -function getRouteChunksIfEnabled({ - ctx, - code, -}: { - ctx: ReactRouterPluginContext; - code: string; -}) { - invariant( - ctx.reactRouterConfig.future.unstable_routeChunks, - "Route chunks shouldn't be requested when future.unstable_routeChunks is disabled" - ); +async function getRouteChunksIfEnabled( + ctx: ReactRouterPluginContext, + input: string | { routeFile: string; viteChildCompiler: Vite.ViteDevServer } +): Promise | null> { + if (!ctx.reactRouterConfig.future.unstable_routeChunks) { + return null; + } + + let code = await resolveRouteFileCode(ctx, input); return getRouteChunks({ code }); } diff --git a/packages/react-router-dev/vite/route-chunks.ts b/packages/react-router-dev/vite/route-chunks.ts index 73807c8c0f..be1ce6689f 100644 --- a/packages/react-router-dev/vite/route-chunks.ts +++ b/packages/react-router-dev/vite/route-chunks.ts @@ -236,18 +236,13 @@ export function omitChunkedExports( return generate(astWithChunksOmitted, generateOptions); } -// TODO: Make this real export function detectRouteChunks({ code }: { code: string }): { hasClientActionChunk: boolean; hasClientLoaderChunk: boolean; hasRouteChunks: boolean; } { - let hasClientActionChunk = code.includes( - 'export { clientAction } from "./clientAction";' - ); - let hasClientLoaderChunk = code.includes( - 'export { clientLoader } from "./clientLoader";' - ); + let hasClientActionChunk = hasChunkableExport(code, "clientAction"); + let hasClientLoaderChunk = hasChunkableExport(code, "clientLoader"); let hasRouteChunks = hasClientActionChunk || hasClientLoaderChunk; return { @@ -257,21 +252,14 @@ export function detectRouteChunks({ code }: { code: string }): { }; } -// TODO: Make this real -export function getRouteChunks({ code }: { code: string }) { - let { hasClientActionChunk, hasClientLoaderChunk } = detectRouteChunks({ - code, - }); - +export function getRouteChunks({ code }: { code: string }): { + main: GeneratorResult | undefined; + clientAction: GeneratorResult | undefined; + clientLoader: GeneratorResult | undefined; +} { return { - main: code - .replace('export { clientAction } from "./clientAction";', "") - .replace('export { clientLoader } from "./clientLoader";', ""), - clientAction: hasClientActionChunk - ? `export { clientAction } from "./clientAction";` - : undefined, - clientLoader: hasClientLoaderChunk - ? `export { clientLoader } from "./clientLoader";` - : undefined, + main: omitChunkedExports(code, ["clientAction", "clientLoader"]), + clientAction: getChunkedExport(code, "clientAction"), + clientLoader: getChunkedExport(code, "clientLoader"), }; } diff --git a/playground/compiler/app/routes.ts b/playground/compiler/app/routes.ts index 3f99a8f0b0..6975c55326 100644 --- a/playground/compiler/app/routes.ts +++ b/playground/compiler/app/routes.ts @@ -2,6 +2,6 @@ import { type RoutesConfig, route, index } from "@react-router/dev/routes"; export const routes: RoutesConfig = [ index("routes/_index.tsx"), - route("/chunkable", "routes/chunkable/route.tsx"), + route("/chunkable", "routes/chunkable.tsx"), route("/unchunkable", "routes/unchunkable.tsx"), ]; diff --git a/playground/compiler/app/routes/chunkable/route.tsx b/playground/compiler/app/routes/chunkable.tsx similarity index 55% rename from playground/compiler/app/routes/chunkable/route.tsx rename to playground/compiler/app/routes/chunkable.tsx index 72a8a67aea..90cde8f2ec 100644 --- a/playground/compiler/app/routes/chunkable/route.tsx +++ b/playground/compiler/app/routes/chunkable.tsx @@ -1,6 +1,9 @@ import { useLoaderData } from "react-router"; -import { clientLoader } from "./clientLoader"; -export { clientLoader } from "./clientLoader"; + +export const clientLoader = async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return "hello from chunkable client loader!"; +}; export default function Hello() { const message = useLoaderData() as Awaited>; diff --git a/playground/compiler/app/routes/chunkable/clientLoader.ts b/playground/compiler/app/routes/chunkable/clientLoader.ts deleted file mode 100644 index e20a889184..0000000000 --- a/playground/compiler/app/routes/chunkable/clientLoader.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const clientLoader = async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - return "hello from chunkable client loader!"; -}; diff --git a/playground/compiler/app/routes/unchunkable.tsx b/playground/compiler/app/routes/unchunkable.tsx index 44a0617a80..7d88f90437 100644 --- a/playground/compiler/app/routes/unchunkable.tsx +++ b/playground/compiler/app/routes/unchunkable.tsx @@ -1,11 +1,14 @@ import { useLoaderData } from "react-router"; +// Dummy variable to prevent route exports from being chunked +let shared: null = null; + export const clientLoader = async () => { await new Promise((resolve) => setTimeout(resolve, 1000)); - return "hello from unchunkable client loader!"; + return shared ?? "hello from unchunkable client loader!"; }; export default function Hello() { const message = useLoaderData() as Awaited>; - return
{message}
; + return
{shared ?? message}
; } From e93ae9b0ecc318b10c6b531f6fe510c3439d898c Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 29 Aug 2024 09:36:42 +1000 Subject: [PATCH 07/63] Add initial route chunks HMR support --- packages/react-router-dev/vite/plugin.ts | 111 ++++++++++++++---- .../vite/static/refresh-utils.cjs | 43 ++++--- 2 files changed, 119 insertions(+), 35 deletions(-) diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 2b60204aa0..43f85396bf 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -145,6 +145,14 @@ const isRouteEntry = (id: string): boolean => { return id.endsWith(ROUTE_ENTRY_QUERY_STRING); }; +const isRouteVirtualModule = (id: string): boolean => { + return ( + isRouteEntry(id) || + id.endsWith(CLIENT_ACTION_CHUNK_QUERY_STRING) || + id.endsWith(CLIENT_LOADER_CHUNK_QUERY_STRING) + ); +}; + export type ServerBundleBuildConfig = { routes: RouteManifest; serverBundleId: string; @@ -625,7 +633,6 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { reactRouterServerManifest: ReactRouterManifest; }> => { invariant(viteConfig); - invariant(viteChildCompiler); let viteManifest = await loadViteManifest( getClientBuildDirectory(ctx.reactRouterConfig) @@ -742,8 +749,6 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { // In dev, the server and browser manifests are the same let getReactRouterManifestForDev = async (): Promise => { - invariant(viteChildCompiler); - let routes: ReactRouterManifest["routes"] = {}; let routeManifestExports = await getRouteManifestModuleExports( @@ -1654,7 +1659,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { let useFastRefresh = !ssr && (isJSX || code.includes(devRuntime)); if (!useFastRefresh) return; - if (isRouteEntry(id)) { + if (isRouteVirtualModule(id)) { return { code: addRefreshWrapper(ctx.reactRouterConfig, code, id) }; } @@ -1766,6 +1771,28 @@ function addRefreshWrapper( code: string, id: string ): string { + if ( + id.endsWith(CLIENT_ACTION_CHUNK_QUERY_STRING) || + id.endsWith(CLIENT_LOADER_CHUNK_QUERY_STRING) + ) { + let ownerRoute = getRoute(reactRouterConfig, id.split("?")[0]); + let moduleUpdatesGlobal = id.endsWith(CLIENT_ACTION_CHUNK_QUERY_STRING) + ? "window.__reactRouterClientActionModuleUpdates" + : "window.__reactRouterClientLoaderModuleUpdates"; + let acceptExport = id.endsWith(CLIENT_ACTION_CHUNK_QUERY_STRING) + ? "clientAction" + : "clientLoader"; + return ( + code + + REACT_REFRESH_ROUTE_CHUNK_FOOTER.replaceAll( + "__ACCEPT_EXPORT__", + JSON.stringify(acceptExport) + ) + .replaceAll("__MODULE_UPDATES_GLOBAL__", moduleUpdatesGlobal) + .replaceAll("__OWNER_ROUTE_ID__", JSON.stringify(ownerRoute?.id)) + ); + } + let route = getRoute(reactRouterConfig, id); let acceptExports = route || isRouteEntry(id) @@ -1824,6 +1851,22 @@ if (import.meta.hot && !inWebWorker) { }); }`; +const REACT_REFRESH_ROUTE_CHUNK_FOOTER = ` +import RefreshRuntime from "${hmrRuntimeId}"; +const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; +if (import.meta.hot && !inWebWorker) { + import.meta.hot.accept((nextExports) => { + if (!nextExports) return; + const exportKeys = Object.keys(nextExports); + if (exportKeys.length > 1 || exportKeys[0] !== __ACCEPT_EXPORT__) { + import.meta.hot.invalidate("Could not Fast Refresh. Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports"); + return; + } + __MODULE_UPDATES_GLOBAL__.set(__OWNER_ROUTE_ID__, nextExports); + RefreshRuntime.enqueueUpdate(); + }); +}`; + function getRoute( pluginConfig: ResolvedReactRouterConfig, file: string @@ -1844,6 +1887,7 @@ async function getRouteMetadata( route: RouteManifestEntry, readRouteFile?: () => string | Promise ): Promise { + let routeFile = route.file; let sourceExports = await getRouteModuleExports( viteChildCompiler, ctx, @@ -1851,6 +1895,21 @@ async function getRouteMetadata( readRouteFile ); + let { hasClientActionChunk, hasClientLoaderChunk } = + await detectRouteChunksIfEnabled(ctx, { + routeFile, + readRouteFile, + viteChildCompiler, + }); + + let moduleUrl = combineURLs( + ctx.publicPath, + `${resolveFileUrl( + ctx, + resolveRelativeRouteFilePath(route, ctx.reactRouterConfig) + )}` + ); + let info: ManifestRoute & { url: string } = { id: route.id, parentId: route.parentId, @@ -1865,16 +1924,13 @@ async function getRouteMetadata( resolveRelativeRouteFilePath(route, ctx.reactRouterConfig) ) ), - module: combineURLs( - ctx.publicPath, - `${resolveFileUrl( - ctx, - resolveRelativeRouteFilePath(route, ctx.reactRouterConfig) - )}?import` - ), // Ensure the Vite dev server responds with a JS module - // TODO: HMR support - clientActionModule: undefined, - clientLoaderModule: undefined, + module: `${moduleUrl}?import`, // Ensure the Vite dev server responds with a JS module + clientActionModule: hasClientActionChunk + ? `${moduleUrl}${CLIENT_ACTION_CHUNK_QUERY_STRING}` + : undefined, + clientLoaderModule: hasClientLoaderChunk + ? `${moduleUrl}${CLIENT_LOADER_CHUNK_QUERY_STRING}` + : undefined, hasAction: sourceExports.includes("action"), hasClientAction: sourceExports.includes("clientAction"), hasLoader: sourceExports.includes("loader"), @@ -2111,17 +2167,30 @@ function createPrerenderRoutes( }); } +type ResolveRouteFileCodeInput = + | string + | { + routeFile: string; + readRouteFile?: () => string | Promise; + viteChildCompiler: Vite.ViteDevServer | null; + }; const resolveRouteFileCode = async ( ctx: ReactRouterPluginContext, - input: string | { routeFile: string; viteChildCompiler: Vite.ViteDevServer } -): Promise => - typeof input === "string" - ? input - : await compileRouteFile(input.viteChildCompiler, ctx, input.routeFile); + input: ResolveRouteFileCodeInput +): Promise => { + if (typeof input === "string") return input; + invariant(input.viteChildCompiler); + return await compileRouteFile( + input.viteChildCompiler, + ctx, + input.routeFile, + input.readRouteFile + ); +}; async function detectRouteChunksIfEnabled( ctx: ReactRouterPluginContext, - input: string | { routeFile: string; viteChildCompiler: Vite.ViteDevServer } + input: ResolveRouteFileCodeInput ): Promise> { if (!ctx.reactRouterConfig.future.unstable_routeChunks) { return { @@ -2145,7 +2214,7 @@ async function detectRouteChunksIfEnabled( async function getRouteChunksIfEnabled( ctx: ReactRouterPluginContext, - input: string | { routeFile: string; viteChildCompiler: Vite.ViteDevServer } + input: ResolveRouteFileCodeInput ): Promise | null> { if (!ctx.reactRouterConfig.future.unstable_routeChunks) { return null; diff --git a/packages/react-router-dev/vite/static/refresh-utils.cjs b/packages/react-router-dev/vite/static/refresh-utils.cjs index ce6e600c46..edb571d376 100644 --- a/packages/react-router-dev/vite/static/refresh-utils.cjs +++ b/packages/react-router-dev/vite/static/refresh-utils.cjs @@ -17,27 +17,38 @@ const enqueueUpdate = debounce(async () => { for (let route of routeUpdates.values()) { manifest.routes[route.id] = route; - let imported = window.__reactRouterRouteModuleUpdates.get(route.id); - if (!imported) { + let previousModule = window.__remixRouteModules[route.id]; + let mainModule = window.__reactRouterRouteModuleUpdates.get(route.id); + let clientActionModule = + window.__reactRouterClientActionModuleUpdates.get(route.id); + let clientLoaderModule = + window.__reactRouterClientLoaderModuleUpdates.get(route.id); + + if (!mainModule && !clientActionModule && !clientLoaderModule) { throw Error( `[react-router:hmr] No module update found for route ${route.id}` ); } + + // If the main chunk is not updated, meaning only an action/loader chunk was + // modified, we should use the previous module as the base for the new module. + mainModule = mainModule ?? previousModule; + let routeModule = { - ...imported, + ...mainModule, + ...clientActionModule, + ...clientLoaderModule, // react-refresh takes care of updating these in-place, // if we don't preserve existing values we'll loose state. - default: imported.default - ? window.__remixRouteModules[route.id]?.default ?? imported.default - : imported.default, - ErrorBoundary: imported.ErrorBoundary - ? window.__remixRouteModules[route.id]?.ErrorBoundary ?? - imported.ErrorBoundary - : imported.ErrorBoundary, - HydrateFallback: imported.HydrateFallback - ? window.__remixRouteModules[route.id]?.HydrateFallback ?? - imported.HydrateFallback - : imported.HydrateFallback, + default: mainModule.default + ? previousModule?.default ?? mainModule.default + : mainModule.default, + ErrorBoundary: mainModule.ErrorBoundary + ? previousModule?.ErrorBoundary ?? mainModule.ErrorBoundary + : mainModule.ErrorBoundary, + HydrateFallback: mainModule.HydrateFallback + ? previousModule?.HydrateFallback ?? mainModule.HydrateFallback + : mainModule.HydrateFallback, }; window.__remixRouteModules[route.id] = routeModule; } @@ -58,6 +69,8 @@ const enqueueUpdate = debounce(async () => { __remixRouter._internalSetRoutes(routes); routeUpdates.clear(); window.__reactRouterRouteModuleUpdates.clear(); + window.__reactRouterClientActionModuleUpdates.clear(); + window.__reactRouterClientLoaderModuleUpdates.clear(); } await revalidate(); @@ -144,6 +157,8 @@ function __hmr_import(module) { const routeUpdates = new Map(); window.__reactRouterRouteModuleUpdates = new Map(); +window.__reactRouterClientActionModuleUpdates = new Map(); +window.__reactRouterClientLoaderModuleUpdates = new Map(); async function revalidate() { let { promise, resolve } = channel(); From 5b44c36c86540acefd61ac554959049ada94c615 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 29 Aug 2024 22:38:27 +1000 Subject: [PATCH 08/63] Handle new route chunks during HMR --- packages/react-router-dev/vite/plugin.ts | 89 ++++++++++++++---------- 1 file changed, 53 insertions(+), 36 deletions(-) diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 43f85396bf..0decf7ebb4 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -145,11 +145,24 @@ const isRouteEntry = (id: string): boolean => { return id.endsWith(ROUTE_ENTRY_QUERY_STRING); }; +const isMainRouteChunk = (id: string): boolean => { + return id.endsWith(MAIN_ROUTE_CHUNK_QUERY_STRING); +}; + +const isClientActionChunk = (id: string): boolean => { + return id.endsWith(CLIENT_ACTION_CHUNK_QUERY_STRING); +}; + +const isClientLoaderChunk = (id: string): boolean => { + return id.endsWith(CLIENT_LOADER_CHUNK_QUERY_STRING); +}; + const isRouteVirtualModule = (id: string): boolean => { return ( isRouteEntry(id) || - id.endsWith(CLIENT_ACTION_CHUNK_QUERY_STRING) || - id.endsWith(CLIENT_LOADER_CHUNK_QUERY_STRING) + isMainRouteChunk(id) || + isClientActionChunk(id) || + isClientLoaderChunk(id) ); }; @@ -1419,13 +1432,13 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { return "// Route chunks disabled"; } - if (id.endsWith(MAIN_ROUTE_CHUNK_QUERY_STRING)) { + if (isMainRouteChunk(id)) { return chunks.main ?? "// No main chunk"; } - if (id.endsWith(CLIENT_ACTION_CHUNK_QUERY_STRING)) { + if (isClientActionChunk(id)) { return chunks.clientAction ?? "// No client action chunk"; } - if (id.endsWith(CLIENT_LOADER_CHUNK_QUERY_STRING)) { + if (isClientLoaderChunk(id)) { return chunks.clientLoader ?? "// No client loader chunk"; } }, @@ -1714,8 +1727,10 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { [ "hasLoader", "hasClientLoader", + "clientLoaderModule", "hasAction", "hasClientAction", + "clientActionModule", "hasErrorBoundary", ] as const ).some((key) => oldRouteMetadata[key] !== newRouteMetadata[key]) @@ -1771,40 +1786,30 @@ function addRefreshWrapper( code: string, id: string ): string { - if ( - id.endsWith(CLIENT_ACTION_CHUNK_QUERY_STRING) || - id.endsWith(CLIENT_LOADER_CHUNK_QUERY_STRING) - ) { - let ownerRoute = getRoute(reactRouterConfig, id.split("?")[0]); - let moduleUpdatesGlobal = id.endsWith(CLIENT_ACTION_CHUNK_QUERY_STRING) + let route = getRoute(reactRouterConfig, id.split("?")[0]); + if (isClientActionChunk(id) || isClientLoaderChunk(id)) { + let moduleUpdatesGlobal = isClientActionChunk(id) ? "window.__reactRouterClientActionModuleUpdates" : "window.__reactRouterClientLoaderModuleUpdates"; - let acceptExport = id.endsWith(CLIENT_ACTION_CHUNK_QUERY_STRING) - ? "clientAction" - : "clientLoader"; return ( code + REACT_REFRESH_ROUTE_CHUNK_FOOTER.replaceAll( - "__ACCEPT_EXPORT__", - JSON.stringify(acceptExport) - ) - .replaceAll("__MODULE_UPDATES_GLOBAL__", moduleUpdatesGlobal) - .replaceAll("__OWNER_ROUTE_ID__", JSON.stringify(ownerRoute?.id)) + "__MODULE_UPDATES_GLOBAL__", + moduleUpdatesGlobal + ).replaceAll("__OWNER_ROUTE_ID__", JSON.stringify(route?.id)) ); } - let route = getRoute(reactRouterConfig, id); - let acceptExports = - route || isRouteEntry(id) - ? [ - "clientAction", - "clientLoader", - "handle", - "meta", - "links", - "shouldRevalidate", - ] - : []; + let acceptExports = route + ? [ + "clientAction", + "clientLoader", + "handle", + "meta", + "links", + "shouldRevalidate", + ] + : []; return ( REACT_REFRESH_HEADER.replaceAll("__SOURCE__", JSON.stringify(id)) + code + @@ -1836,10 +1841,25 @@ if (import.meta.hot && !inWebWorker) { window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform; }`.replace(/\n+/g, ""); +const IMPORT_CLIENT_ACTION_CHUNK_QUERY_STRING = + "?import&" + CLIENT_ACTION_CHUNK_QUERY_STRING.replace("?", ""); +const IMPORT_CLIENT_LOADER_CHUNK_QUERY_STRING = + "?import&" + CLIENT_LOADER_CHUNK_QUERY_STRING.replace("?", ""); + const REACT_REFRESH_FOOTER = ` if (import.meta.hot && !inWebWorker) { window.$RefreshReg$ = prevRefreshReg; window.$RefreshSig$ = prevRefreshSig; + ${ + // Ensure all route chunk modules are in memory to support HMR. This is to + // handle cases where route chunks don't exist on initial load but are + // subsequently created while editing the route module. + "" + }if (__ROUTE_ID__) { + const routeBase = import.meta.url.split("?")[0]; + RefreshRuntime.__hmr_import(routeBase + "${IMPORT_CLIENT_ACTION_CHUNK_QUERY_STRING}"); + RefreshRuntime.__hmr_import(routeBase + "${IMPORT_CLIENT_LOADER_CHUNK_QUERY_STRING}"); + } RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => { RefreshRuntime.registerExportsForReactRefresh(__SOURCE__, currentExports); import.meta.hot.accept((nextExports) => { @@ -1857,12 +1877,9 @@ const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof if (import.meta.hot && !inWebWorker) { import.meta.hot.accept((nextExports) => { if (!nextExports) return; - const exportKeys = Object.keys(nextExports); - if (exportKeys.length > 1 || exportKeys[0] !== __ACCEPT_EXPORT__) { - import.meta.hot.invalidate("Could not Fast Refresh. Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports"); - return; + if (Object.keys(nextExports).length) { + __MODULE_UPDATES_GLOBAL__.set(__OWNER_ROUTE_ID__, nextExports); } - __MODULE_UPDATES_GLOBAL__.set(__OWNER_ROUTE_ID__, nextExports); RefreshRuntime.enqueueUpdate(); }); }`; From c46b97aea3868f086c3fa639480145731d111099 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 2 Sep 2024 15:16:36 +1000 Subject: [PATCH 09/63] Fix clientAction chunk query string typo --- packages/react-router-dev/vite/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 0decf7ebb4..3408700d00 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -138,7 +138,7 @@ const CLIENT_ROUTE_EXPORTS = [ const ROUTE_ENTRY_QUERY_STRING = "?route-entry=1"; const ROUTE_CHUNK_QUERY_STRING = "?route-chunk="; const MAIN_ROUTE_CHUNK_QUERY_STRING = `${ROUTE_CHUNK_QUERY_STRING}main`; -const CLIENT_ACTION_CHUNK_QUERY_STRING = `${ROUTE_CHUNK_QUERY_STRING}action-loader`; +const CLIENT_ACTION_CHUNK_QUERY_STRING = `${ROUTE_CHUNK_QUERY_STRING}client-action`; const CLIENT_LOADER_CHUNK_QUERY_STRING = `${ROUTE_CHUNK_QUERY_STRING}client-loader`; const isRouteEntry = (id: string): boolean => { From 287a3bf984f44cf0dd3475bd9fad0ba385066137 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 3 Sep 2024 17:09:25 +1000 Subject: [PATCH 10/63] Add caching to route chunks logic --- packages/react-router-dev/vite/cache.ts | 25 ++ packages/react-router-dev/vite/plugin.ts | 64 +++- .../vite/route-chunks-test.ts | 133 +++++--- .../react-router-dev/vite/route-chunks.ts | 301 ++++++++++++------ 4 files changed, 352 insertions(+), 171 deletions(-) create mode 100644 packages/react-router-dev/vite/cache.ts diff --git a/packages/react-router-dev/vite/cache.ts b/packages/react-router-dev/vite/cache.ts new file mode 100644 index 0000000000..99a20767ab --- /dev/null +++ b/packages/react-router-dev/vite/cache.ts @@ -0,0 +1,25 @@ +type CacheEntry = { value: T; version: string }; + +export type Cache = Map>; + +export function getOrSetFromCache( + cache: Cache, + key: string, + version: string, + getValue: () => T +): T { + if (!cache) { + return getValue(); + } + + let entry = cache.get(key) as CacheEntry | undefined; + + if (entry?.version === version) { + return entry.value as T; + } + + let value = getValue(); + let newEntry: CacheEntry = { value, version }; + cache.set(key, newEntry); + return value; +} diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 3408700d00..b0ffde6e1e 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -32,6 +32,7 @@ import type { Manifest as ReactRouterManifest, } from "../manifest"; import invariant from "../invariant"; +import type { Cache } from "./cache"; import type { NodeRequestHandler } from "./node-adapter"; import { fromNodeRequest, toNodeRequest } from "./node-adapter"; import { getStylesForUrl, isCssModulesFile } from "./styles"; @@ -198,6 +199,17 @@ let browserManifestId = VirtualModule.id("browser-manifest"); let hmrRuntimeId = VirtualModule.id("hmr-runtime"); let injectHmrRuntimeId = VirtualModule.id("inject-hmr-runtime"); +const normalizeRelativeFilePath = ( + file: string, + reactRouterConfig: ResolvedReactRouterConfig +) => { + let vite = importViteEsmSync(); + let fullPath = path.resolve(reactRouterConfig.appDirectory, file); + let relativePath = path.relative(reactRouterConfig.appDirectory, fullPath); + + return vite.normalizePath(relativePath); +}; + const resolveRelativeRouteFilePath = ( route: RouteManifestEntry, reactRouterConfig: ResolvedReactRouterConfig @@ -476,6 +488,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { let viteChildCompiler: Vite.ViteDevServer | null = null; let routeConfigViteServer: Vite.ViteDevServer | null = null; let viteNodeRunner: ViteNodeRunner | null = null; + let cache: Cache = new Map(); let ssrExternals = isInReactRouterMonorepo() ? [ @@ -673,7 +686,10 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { let hasClientLoader = sourceExports.includes("clientLoader"); let { hasClientActionChunk, hasClientLoaderChunk } = - await detectRouteChunksIfEnabled(ctx, { routeFile, viteChildCompiler }); + await detectRouteChunksIfEnabled(cache, ctx, routeFile, { + routeFile, + viteChildCompiler, + }); let clientActionAssets = hasClientActionChunk ? getReactRouterManifestBuildAssets( @@ -783,7 +799,10 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { ); let { hasClientActionChunk, hasClientLoaderChunk } = - await detectRouteChunksIfEnabled(ctx, { routeFile, viteChildCompiler }); + await detectRouteChunksIfEnabled(cache, ctx, routeFile, { + routeFile, + viteChildCompiler, + }); routes[key] = { id: route.id, @@ -1390,8 +1409,13 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { routeModuleId ); - let { hasRouteChunks, hasClientActionChunk, hasClientLoaderChunk } = - await detectRouteChunksIfEnabled(ctx, code); + let { + hasRouteChunks = false, + hasClientActionChunk = false, + hasClientLoaderChunk = false, + } = options?.ssr + ? {} + : await detectRouteChunksIfEnabled(cache, ctx, id, code); let reexports = sourceExports .filter( @@ -1401,19 +1425,15 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { CLIENT_ROUTE_EXPORTS.includes(exportName) ) .filter((exportName) => - !options?.ssr && hasClientActionChunk - ? exportName !== "clientAction" - : true + hasClientActionChunk ? exportName !== "clientAction" : true ) .filter((exportName) => - !options?.ssr && hasClientLoaderChunk - ? exportName !== "clientLoader" - : true + hasClientLoaderChunk ? exportName !== "clientLoader" : true ) .join(", "); return `export { ${reexports} } from "./${routeFileName}${ - !options?.ssr && hasRouteChunks ? MAIN_ROUTE_CHUNK_QUERY_STRING : "" + hasRouteChunks ? MAIN_ROUTE_CHUNK_QUERY_STRING : "" }";`; }, }, @@ -1426,7 +1446,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { // Ignore anything that isn't marked as a route chunk if (!id.includes(ROUTE_CHUNK_QUERY_STRING)) return; - let chunks = await getRouteChunksIfEnabled(ctx, code); + let chunks = await getRouteChunksIfEnabled(cache, ctx, id, code); if (chunks === null) { return "// Route chunks disabled"; @@ -1713,6 +1733,7 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { let oldRouteMetadata = serverManifest.routes[route.id]; let newRouteMetadata = await getRouteMetadata( + cache, ctx, viteChildCompiler, route, @@ -1899,6 +1920,7 @@ function getRoute( } async function getRouteMetadata( + cache: Cache, ctx: ReactRouterPluginContext, viteChildCompiler: Vite.ViteDevServer | null, route: RouteManifestEntry, @@ -1913,7 +1935,7 @@ async function getRouteMetadata( ); let { hasClientActionChunk, hasClientLoaderChunk } = - await detectRouteChunksIfEnabled(ctx, { + await detectRouteChunksIfEnabled(cache, ctx, routeFile, { routeFile, readRouteFile, viteChildCompiler, @@ -2206,7 +2228,9 @@ const resolveRouteFileCode = async ( }; async function detectRouteChunksIfEnabled( + cache: Cache, ctx: ReactRouterPluginContext, + id: string, input: ResolveRouteFileCodeInput ): Promise> { if (!ctx.reactRouterConfig.future.unstable_routeChunks) { @@ -2226,11 +2250,17 @@ async function detectRouteChunksIfEnabled( }; } - return detectRouteChunks({ code }); + let cacheKey = + normalizeRelativeFilePath(id, ctx.reactRouterConfig) + + (typeof input === "string" ? "" : "?read"); + + return detectRouteChunks(code, cache, cacheKey); } async function getRouteChunksIfEnabled( + cache: Cache, ctx: ReactRouterPluginContext, + id: string, input: ResolveRouteFileCodeInput ): Promise | null> { if (!ctx.reactRouterConfig.future.unstable_routeChunks) { @@ -2239,5 +2269,9 @@ async function getRouteChunksIfEnabled( let code = await resolveRouteFileCode(ctx, input); - return getRouteChunks({ code }); + let cacheKey = + normalizeRelativeFilePath(id, ctx.reactRouterConfig) + + (typeof input === "string" ? "" : "?read"); + + return getRouteChunks(code, cache, cacheKey); } diff --git a/packages/react-router-dev/vite/route-chunks-test.ts b/packages/react-router-dev/vite/route-chunks-test.ts index c1a5b31c4f..19c959d11a 100644 --- a/packages/react-router-dev/vite/route-chunks-test.ts +++ b/packages/react-router-dev/vite/route-chunks-test.ts @@ -1,11 +1,14 @@ import dedent from "dedent"; +import type { Cache } from "./cache"; import { hasChunkableExport, getChunkedExport, omitChunkedExports, } from "./route-chunks"; +let cache: [Cache, string] = [new Map(), "cacheKey"]; + describe("route chunks", () => { describe("chunkable", () => { test("functions with no identifiers", () => { @@ -16,24 +19,32 @@ describe("route chunks", () => { export const target2 = () => null; export const other2 = () => null; `; - expect(hasChunkableExport(code, "default")).toBe(true); - expect(hasChunkableExport(code, "target1")).toBe(true); - expect(hasChunkableExport(code, "target2")).toBe(true); - expect(getChunkedExport(code, "default")?.code).toMatchInlineSnapshot(` + expect(hasChunkableExport(code, "default", ...cache)).toBe(true); + expect(hasChunkableExport(code, "target1", ...cache)).toBe(true); + expect(hasChunkableExport(code, "target2", ...cache)).toBe(true); + expect(getChunkedExport(code, "default", {}, ...cache)?.code) + .toMatchInlineSnapshot(` "export default function () { return null; }" `); - expect(getChunkedExport(code, "target1")?.code).toMatchInlineSnapshot(` + expect(getChunkedExport(code, "target1", {}, ...cache)?.code) + .toMatchInlineSnapshot(` "export function target1() { return null; }" `); - expect(getChunkedExport(code, "target2")?.code).toMatchInlineSnapshot( - `"export const target2 = () => null;"` - ); - expect(omitChunkedExports(code, ["default", "target1", "target2"])?.code) - .toMatchInlineSnapshot(` + expect( + getChunkedExport(code, "target2", {}, ...cache)?.code + ).toMatchInlineSnapshot(`"export const target2 = () => null;"`); + expect( + omitChunkedExports( + code, + ["default", "target1", "target2"], + {}, + ...cache + )?.code + ).toMatchInlineSnapshot(` "export function other1() { return null; } @@ -58,24 +69,27 @@ describe("route chunks", () => { export const target2 = () => getTargetMessage2(); export const other2 = () => getOtherMessage2(); `; - expect(hasChunkableExport(code, "target1")).toBe(true); - expect(hasChunkableExport(code, "target2")).toBe(true); - expect(getChunkedExport(code, "target1")?.code).toMatchInlineSnapshot(` + expect(hasChunkableExport(code, "target1", ...cache)).toBe(true); + expect(hasChunkableExport(code, "target2", ...cache)).toBe(true); + expect(getChunkedExport(code, "target1", {}, ...cache)?.code) + .toMatchInlineSnapshot(` "import { targetMessage1 } from "./targetMessage1"; const getTargetMessage1 = () => targetMessage1; export function target1() { return getTargetMessage1(); }" `); - expect(getChunkedExport(code, "target2")?.code).toMatchInlineSnapshot(` + expect(getChunkedExport(code, "target2", {}, ...cache)?.code) + .toMatchInlineSnapshot(` "import { targetMessage2 } from "./targetMessage2"; function getTargetMessage2() { return targetMessage2; } export const target2 = () => getTargetMessage2();" `); - expect(omitChunkedExports(code, ["target1", "target2"])?.code) - .toMatchInlineSnapshot(` + expect( + omitChunkedExports(code, ["target1", "target2"], {}, ...cache)?.code + ).toMatchInlineSnapshot(` "import { otherMessage1 } from "./otherMessage1"; import { otherMessage2 } from "./otherMessage2"; const getOtherMessage1 = () => otherMessage1; @@ -100,22 +114,30 @@ describe("route chunks", () => { export const other1 = () => sharedMessage; export const other2 = () => sharedMessage; `; - expect(hasChunkableExport(code, "default")).toBe(true); - expect(hasChunkableExport(code, "target1")).toBe(true); - expect(hasChunkableExport(code, "target2")).toBe(false); - expect(getChunkedExport(code, "default")?.code).toMatchInlineSnapshot(` + expect(hasChunkableExport(code, "default", ...cache)).toBe(true); + expect(hasChunkableExport(code, "target1", ...cache)).toBe(true); + expect(hasChunkableExport(code, "target2", ...cache)).toBe(false); + expect(getChunkedExport(code, "default", {}, ...cache)?.code) + .toMatchInlineSnapshot(` "export default function () { return null; }" `); - expect(getChunkedExport(code, "target1")?.code).toMatchInlineSnapshot(` + expect(getChunkedExport(code, "target1", {}, ...cache)?.code) + .toMatchInlineSnapshot(` "export function target1() { return null; }" `); - expect(getChunkedExport(code, "target2")).toBeUndefined(); - expect(omitChunkedExports(code, ["default", "target1", "target2"])?.code) - .toMatchInlineSnapshot(` + expect(getChunkedExport(code, "target2", {}, ...cache)).toBeUndefined(); + expect( + omitChunkedExports( + code, + ["default", "target1", "target2"], + {}, + ...cache + )?.code + ).toMatchInlineSnapshot(` "import { sharedMessage } from "./sharedMessage"; export const target2 = () => sharedMessage; export const other1 = () => sharedMessage; @@ -138,18 +160,20 @@ describe("route chunks", () => { export const target2 = () => getTargetMessage2(); export const other2 = () => getOtherMessage2(); `; - expect(hasChunkableExport(code, "target1")).toBe(true); - expect(hasChunkableExport(code, "target2")).toBe(false); - expect(getChunkedExport(code, "target1")?.code).toMatchInlineSnapshot(` + expect(hasChunkableExport(code, "target1", ...cache)).toBe(true); + expect(hasChunkableExport(code, "target2", ...cache)).toBe(false); + expect(getChunkedExport(code, "target1", {}, ...cache)?.code) + .toMatchInlineSnapshot(` "import { targetMessage1 } from "./targetMessage1"; const getTargetMessage1 = () => targetMessage1; export function target1() { return getTargetMessage1(); }" `); - expect(getChunkedExport(code, "target2")).toBeUndefined(); - expect(omitChunkedExports(code, ["target1", "target2"])?.code) - .toMatchInlineSnapshot(` + expect(getChunkedExport(code, "target2", {}, ...cache)).toBeUndefined(); + expect( + omitChunkedExports(code, ["target1", "target2"], {}, ...cache)?.code + ).toMatchInlineSnapshot(` "import { sharedMessage } from "./sharedMessage"; const getOtherMessage1 = () => sharedMessage; function getTargetMessage2() { @@ -172,12 +196,12 @@ describe("route chunks", () => { const code = dedent` export default function () {} `; - expect(hasChunkableExport(code, "target1")).toBe(false); - expect(getChunkedExport(code, "target1")).toBeUndefined(); - expect(hasChunkableExport(code, "target2")).toBe(false); - expect(getChunkedExport(code, "target2")).toBeUndefined(); + expect(hasChunkableExport(code, "target1", ...cache)).toBe(false); + expect(getChunkedExport(code, "target1", {}, ...cache)).toBeUndefined(); + expect(hasChunkableExport(code, "target2", ...cache)).toBe(false); + expect(getChunkedExport(code, "target2", {}, ...cache)).toBeUndefined(); expect( - omitChunkedExports(code, ["target1", "target2"])?.code + omitChunkedExports(code, ["target1", "target2"], {}, ...cache)?.code ).toMatchInlineSnapshot(`"export default function () {}"`); }); @@ -193,12 +217,13 @@ describe("route chunks", () => { export const other1 = () => getOtherMessage1(); export const other2 = () => getOtherMessage2(); `; - expect(hasChunkableExport(code, "target1")).toBe(false); - expect(getChunkedExport(code, "target1")).toBeUndefined(); - expect(hasChunkableExport(code, "target2")).toBe(false); - expect(getChunkedExport(code, "target2")).toBeUndefined(); - expect(omitChunkedExports(code, ["target1", "target2"])?.code) - .toMatchInlineSnapshot(` + expect(hasChunkableExport(code, "target1", ...cache)).toBe(false); + expect(getChunkedExport(code, "target1", {}, ...cache)).toBeUndefined(); + expect(hasChunkableExport(code, "target2", ...cache)).toBe(false); + expect(getChunkedExport(code, "target2", {}, ...cache)).toBeUndefined(); + expect( + omitChunkedExports(code, ["target1", "target2"], {}, ...cache)?.code + ).toMatchInlineSnapshot(` "const sharedMessage = "shared"; const getTargetMessage1 = () => sharedMessage; const getTargetMessage2 = () => sharedMessage; @@ -228,12 +253,13 @@ describe("route chunks", () => { export const other1 = () => getOtherMessage1(); export const other2 = () => getOtherMessage2(); `; - expect(hasChunkableExport(code, "target1")).toBe(false); - expect(getChunkedExport(code, "target1")).toBeUndefined(); - expect(hasChunkableExport(code, "target2")).toBe(false); - expect(getChunkedExport(code, "target2")).toBeUndefined(); - expect(omitChunkedExports(code, ["target1", "target2"])?.code) - .toMatchInlineSnapshot(` + expect(hasChunkableExport(code, "target1", ...cache)).toBe(false); + expect(getChunkedExport(code, "target1", {}, ...cache)).toBeUndefined(); + expect(hasChunkableExport(code, "target2", ...cache)).toBe(false); + expect(getChunkedExport(code, "target2", {}, ...cache)).toBeUndefined(); + expect( + omitChunkedExports(code, ["target1", "target2"], {}, ...cache)?.code + ).toMatchInlineSnapshot(` "import { targetMessage1, targetMessage2, otherMessage1, otherMessage2 } from "./messages"; const getTargetMessage1 = () => targetMessage1; const getTargetMessage2 = () => targetMessage2; @@ -258,12 +284,13 @@ describe("route chunks", () => { export const other1 = () => getOtherMessage1(); export const other2 = () => getOtherMessage2(); `; - expect(hasChunkableExport(code, "target1")).toBe(false); - expect(getChunkedExport(code, "target1")).toBeUndefined(); - expect(hasChunkableExport(code, "target2")).toBe(false); - expect(getChunkedExport(code, "target2")).toBeUndefined(); - expect(omitChunkedExports(code, ["target1", "target2"])?.code) - .toMatchInlineSnapshot(` + expect(hasChunkableExport(code, "target1", ...cache)).toBe(false); + expect(getChunkedExport(code, "target1", {}, ...cache)).toBeUndefined(); + expect(hasChunkableExport(code, "target2", ...cache)).toBe(false); + expect(getChunkedExport(code, "target2", {}, ...cache)).toBeUndefined(); + expect( + omitChunkedExports(code, ["target1", "target2"], {}, ...cache)?.code + ).toMatchInlineSnapshot(` "import * as messages from "./messages"; const getTargetMessage1 = () => messages.targetMessage1; const getTargetMessage2 = () => messages.targetMessage2; diff --git a/packages/react-router-dev/vite/route-chunks.ts b/packages/react-router-dev/vite/route-chunks.ts index be1ce6689f..e9e5d4dfa1 100644 --- a/packages/react-router-dev/vite/route-chunks.ts +++ b/packages/react-router-dev/vite/route-chunks.ts @@ -1,4 +1,6 @@ import type { GeneratorOptions, GeneratorResult } from "@babel/generator"; +import invariant from "../invariant"; +import { type Cache, getOrSetFromCache } from "./cache"; import { type BabelTypes, type NodePath, @@ -7,35 +9,53 @@ import { generate, t, } from "./babel"; -import invariant from "../invariant"; type Statement = BabelTypes.Statement; type Identifier = BabelTypes.Identifier; +function codeToAst( + code: string, + cache: Cache, + cacheKey: string +): BabelTypes.File { + return getOrSetFromCache(cache, `${cacheKey}::codeToAst`, code, () => + parse(code, { sourceType: "module" }) + ); +} + function getTopLevelStatementsByExportName( - code: string + code: string, + cache: Cache, + cacheKey: string ): Map> { - let ast = parse(code, { sourceType: "module" }); - let topLevelStatementsByExportName = new Map>(); - - traverse(ast, { - ExportDeclaration(exportPath) { - let visited = new Set(); - let identifiers = new Set>(); - - collectIdentifiers(visited, identifiers, exportPath); - - let topLevelStatements = new Set([ - exportPath.node, - ...getTopLevelStatementsForPaths(identifiers), - ]); - for (let exportName of getExportNames(exportPath)) { - topLevelStatementsByExportName.set(exportName, topLevelStatements); - } - }, - }); + return getOrSetFromCache( + cache, + `${cacheKey}::getTopLevelStatementsByExportName`, + code, + () => { + let ast = codeToAst(code, cache, cacheKey); + let topLevelStatementsByExportName = new Map>(); + + traverse(ast, { + ExportDeclaration(exportPath) { + let visited = new Set(); + let identifiers = new Set>(); + + collectIdentifiers(visited, identifiers, exportPath); + + let topLevelStatements = new Set([ + exportPath.node, + ...getTopLevelStatementsForPaths(identifiers), + ]); + for (let exportName of getExportNames(exportPath)) { + topLevelStatementsByExportName.set(exportName, topLevelStatements); + } + }, + }); - return topLevelStatementsByExportName; + return topLevelStatementsByExportName; + } + ); } function collectIdentifiers( @@ -127,38 +147,54 @@ function areSetsDisjoint(set1: Set, set2: Set): boolean { return true; } -export function hasChunkableExport(code: string, exportName: string): boolean { - let topLevelStatementsByExportName = getTopLevelStatementsByExportName(code); - let topLevelStatements = topLevelStatementsByExportName.get(exportName); +export function hasChunkableExport( + code: string, + exportName: string, + cache: Cache, + cacheKey: string +): boolean { + return getOrSetFromCache( + cache, + `${cacheKey}::hasChunkableExport::${exportName}`, + code, + () => { + let topLevelStatementsByExportName = getTopLevelStatementsByExportName( + code, + cache, + cacheKey + ); + let topLevelStatements = topLevelStatementsByExportName.get(exportName); + + // Export wasn't found in the file + if (!topLevelStatements) { + return false; + } - // Export wasn't found in the file - if (!topLevelStatements) { - return false; - } + // Export had no identifiers to collect, so it's isolated + // e.g. export default function () { return "string" } + if (topLevelStatements.size === 0) { + return true; + } - // Export had no identifiers to collect, so it's isolated - // e.g. export default function () { return "string" } - if (topLevelStatements.size === 0) { - return true; - } + // Loop through all other exports to see if they have any top level statements + // in common with the export we're trying to create a chunk for + for (let [ + currentExportName, + currentTopLevelStatements, + ] of topLevelStatementsByExportName) { + if (currentExportName === exportName) { + continue; + } + // As soon as we find any top level statements in common with another export, + // we know this export cannot be placed in its own chunk + if (!areSetsDisjoint(currentTopLevelStatements, topLevelStatements)) { + return false; + } + } - // Loop through all other exports to see if they have any top level statements - // in common with the export we're trying to create a chunk for - for (let [ - currentExportName, - currentTopLevelStatements, - ] of topLevelStatementsByExportName) { - if (currentExportName === exportName) { - continue; + return true; } - // As soon as we find any top level statements in common with another export, - // we know this export cannot be placed in its own chunk - if (!areSetsDisjoint(currentTopLevelStatements, topLevelStatements)) { - return false; - } - } - - return true; + ); } function replaceBody( @@ -177,72 +213,121 @@ function replaceBody( export function getChunkedExport( code: string, exportName: string, - generateOptions: GeneratorOptions = {} + generateOptions: GeneratorOptions = {}, + cache: Cache, + cacheKey: string ): GeneratorResult | undefined { - let ast = parse(code, { sourceType: "module" }); - let topLevelStatementsByExportName = getTopLevelStatementsByExportName(code); - - if (!hasChunkableExport(code, exportName)) { - return undefined; - } - - let topLevelStatements = topLevelStatementsByExportName.get(exportName); - invariant(topLevelStatements, "Expected export to have top level statements"); + return getOrSetFromCache( + cache, + `${cacheKey}::getChunkedExport::${exportName}::${JSON.stringify( + generateOptions + )}`, + code, + () => { + if (!hasChunkableExport(code, exportName, cache, cacheKey)) { + return undefined; + } - let topLevelStatementsArray = Array.from(topLevelStatements); - let chunkAst = replaceBody(ast, (body) => - body.filter((node) => - topLevelStatementsArray.some((statement) => - t.isNodesEquivalent(node, statement) - ) - ) + let ast = codeToAst(code, cache, cacheKey); + let topLevelStatementsByExportName = getTopLevelStatementsByExportName( + code, + cache, + cacheKey + ); + let topLevelStatements = topLevelStatementsByExportName.get(exportName); + invariant( + topLevelStatements, + "Expected export to have top level statements" + ); + + let topLevelStatementsArray = Array.from(topLevelStatements); + let chunkAst = replaceBody(ast, (body) => + body.filter((node) => + topLevelStatementsArray.some((statement) => + t.isNodesEquivalent(node, statement) + ) + ) + ); + + return generate(chunkAst, generateOptions); + } ); - - return generate(chunkAst, generateOptions); } export function omitChunkedExports( code: string, exportNames: string[], - generateOptions: GeneratorOptions = {} + generateOptions: GeneratorOptions = {}, + cache: Cache, + cacheKey: string ): GeneratorResult | undefined { - let ast = parse(code, { sourceType: "module" }); - let topLevelStatementsByExportName = getTopLevelStatementsByExportName(code); - let omittedStatements = new Set(); - - for (let exportName of exportNames) { - let topLevelStatements = topLevelStatementsByExportName.get(exportName); - if (!topLevelStatements || !hasChunkableExport(code, exportName)) { - continue; - } - for (let statement of topLevelStatements) { - omittedStatements.add(statement); - } - } - - let omittedStatementsArray = Array.from(omittedStatements); - let astWithChunksOmitted = replaceBody(ast, (body) => - body.filter((node) => - omittedStatementsArray.every( - (statement) => !t.isNodesEquivalent(node, statement) - ) - ) - ); + return getOrSetFromCache( + cache, + `${cacheKey}::omitChunkedExports::${exportNames.join( + "," + )}::${JSON.stringify(generateOptions)}`, + code, + () => { + let topLevelStatementsByExportName = getTopLevelStatementsByExportName( + code, + cache, + cacheKey + ); + let omittedStatements = new Set(); + + for (let exportName of exportNames) { + let topLevelStatements = topLevelStatementsByExportName.get(exportName); + if ( + !topLevelStatements || + !hasChunkableExport(code, exportName, cache, cacheKey) + ) { + continue; + } + for (let statement of topLevelStatements) { + omittedStatements.add(statement); + } + } - if (astWithChunksOmitted.program.body.length === 0) { - return undefined; - } + let omittedStatementsArray = Array.from(omittedStatements); + let ast = codeToAst(code, cache, cacheKey); + let astWithChunksOmitted = replaceBody(ast, (body) => + body.filter((node) => + omittedStatementsArray.every( + (statement) => !t.isNodesEquivalent(node, statement) + ) + ) + ); + + if (astWithChunksOmitted.program.body.length === 0) { + return undefined; + } - return generate(astWithChunksOmitted, generateOptions); + return generate(astWithChunksOmitted, generateOptions); + } + ); } -export function detectRouteChunks({ code }: { code: string }): { +export function detectRouteChunks( + code: string, + cache: Cache, + cacheKey: string +): { hasClientActionChunk: boolean; hasClientLoaderChunk: boolean; hasRouteChunks: boolean; } { - let hasClientActionChunk = hasChunkableExport(code, "clientAction"); - let hasClientLoaderChunk = hasChunkableExport(code, "clientLoader"); + let hasClientActionChunk = hasChunkableExport( + code, + "clientAction", + cache, + cacheKey + ); + let hasClientLoaderChunk = hasChunkableExport( + code, + "clientLoader", + cache, + cacheKey + ); let hasRouteChunks = hasClientActionChunk || hasClientLoaderChunk; return { @@ -252,14 +337,24 @@ export function detectRouteChunks({ code }: { code: string }): { }; } -export function getRouteChunks({ code }: { code: string }): { +export function getRouteChunks( + code: string, + cache: Cache, + cacheKey: string +): { main: GeneratorResult | undefined; clientAction: GeneratorResult | undefined; clientLoader: GeneratorResult | undefined; } { return { - main: omitChunkedExports(code, ["clientAction", "clientLoader"]), - clientAction: getChunkedExport(code, "clientAction"), - clientLoader: getChunkedExport(code, "clientLoader"), + main: omitChunkedExports( + code, + ["clientAction", "clientLoader"], + {}, + cache, + cacheKey + ), + clientAction: getChunkedExport(code, "clientAction", {}, cache, cacheKey), + clientLoader: getChunkedExport(code, "clientLoader", {}, cache, cacheKey), }; } From 359318eff47fa40f1ea03a6f67ddd9b3571c9f63 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 4 Sep 2024 14:37:03 +1000 Subject: [PATCH 11/63] Improve route chunk generation performance --- packages/react-router-dev/vite/plugin.ts | 52 ++++++++++++------- .../react-router-dev/vite/route-chunks.ts | 37 ++++++------- 2 files changed, 53 insertions(+), 36 deletions(-) diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index b0ffde6e1e..99286cbe63 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -40,7 +40,12 @@ import * as VirtualModule from "./vmod"; import { resolveFileUrl } from "./resolve-file-url"; import { combineURLs } from "./combine-urls"; import { removeExports } from "./remove-exports"; -import { detectRouteChunks, getRouteChunks } from "./route-chunks"; +import { + type RouteChunkName, + detectRouteChunks, + isRouteChunkName, + getRouteChunk, +} from "./route-chunks"; import { importViteEsmSync, preloadViteEsm } from "./import-vite-esm-sync"; import { type ReactRouterConfig, @@ -137,10 +142,16 @@ const CLIENT_ROUTE_EXPORTS = [ // Each route gets its own virtual module marked with an entry query string const ROUTE_ENTRY_QUERY_STRING = "?route-entry=1"; + +type RouteChunkQueryString = + `${typeof ROUTE_CHUNK_QUERY_STRING}${RouteChunkName}`; const ROUTE_CHUNK_QUERY_STRING = "?route-chunk="; -const MAIN_ROUTE_CHUNK_QUERY_STRING = `${ROUTE_CHUNK_QUERY_STRING}main`; -const CLIENT_ACTION_CHUNK_QUERY_STRING = `${ROUTE_CHUNK_QUERY_STRING}client-action`; -const CLIENT_LOADER_CHUNK_QUERY_STRING = `${ROUTE_CHUNK_QUERY_STRING}client-loader`; +const MAIN_ROUTE_CHUNK_QUERY_STRING = + `${ROUTE_CHUNK_QUERY_STRING}main` satisfies RouteChunkQueryString; +const CLIENT_ACTION_CHUNK_QUERY_STRING = + `${ROUTE_CHUNK_QUERY_STRING}clientAction` satisfies RouteChunkQueryString; +const CLIENT_LOADER_CHUNK_QUERY_STRING = + `${ROUTE_CHUNK_QUERY_STRING}clientLoader` satisfies RouteChunkQueryString; const isRouteEntry = (id: string): boolean => { return id.endsWith(ROUTE_ENTRY_QUERY_STRING); @@ -1446,21 +1457,25 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = (_config) => { // Ignore anything that isn't marked as a route chunk if (!id.includes(ROUTE_CHUNK_QUERY_STRING)) return; - let chunks = await getRouteChunksIfEnabled(cache, ctx, id, code); + let chunkName = id.split(ROUTE_CHUNK_QUERY_STRING)[1].split("&")[0]; - if (chunks === null) { - return "// Route chunks disabled"; + if (!isRouteChunkName(chunkName)) { + throw new Error(`Invalid route chunk name "${chunkName}" in "${id}"`); } - if (isMainRouteChunk(id)) { - return chunks.main ?? "// No main chunk"; - } - if (isClientActionChunk(id)) { - return chunks.clientAction ?? "// No client action chunk"; - } - if (isClientLoaderChunk(id)) { - return chunks.clientLoader ?? "// No client loader chunk"; + let chunk = await getRouteChunkIfEnabled( + cache, + ctx, + id, + chunkName, + code + ); + + if (chunk === null) { + return "// Route chunks disabled"; } + + return chunk ?? `// No ${chunkName} chunk`; }, }, { @@ -2257,12 +2272,13 @@ async function detectRouteChunksIfEnabled( return detectRouteChunks(code, cache, cacheKey); } -async function getRouteChunksIfEnabled( +async function getRouteChunkIfEnabled( cache: Cache, ctx: ReactRouterPluginContext, id: string, + chunkName: RouteChunkName, input: ResolveRouteFileCodeInput -): Promise | null> { +): Promise | null> { if (!ctx.reactRouterConfig.future.unstable_routeChunks) { return null; } @@ -2273,5 +2289,5 @@ async function getRouteChunksIfEnabled( normalizeRelativeFilePath(id, ctx.reactRouterConfig) + (typeof input === "string" ? "" : "?read"); - return getRouteChunks(code, cache, cacheKey); + return getRouteChunk(code, chunkName, cache, cacheKey); } diff --git a/packages/react-router-dev/vite/route-chunks.ts b/packages/react-router-dev/vite/route-chunks.ts index e9e5d4dfa1..c04afec4ae 100644 --- a/packages/react-router-dev/vite/route-chunks.ts +++ b/packages/react-router-dev/vite/route-chunks.ts @@ -256,7 +256,7 @@ export function getChunkedExport( export function omitChunkedExports( code: string, - exportNames: string[], + exportNames: readonly string[], generateOptions: GeneratorOptions = {}, cache: Cache, cacheKey: string @@ -337,24 +337,25 @@ export function detectRouteChunks( }; } -export function getRouteChunks( +const mainChunkName = "main" as const; +const chunkedExportNames = ["clientAction", "clientLoader"] as const; +export type RouteChunkName = + | typeof mainChunkName + | (typeof chunkedExportNames)[number]; + +export function isRouteChunkName(name: string): name is RouteChunkName { + return name === mainChunkName || chunkedExportNames.includes(name as any); +} + +export function getRouteChunk( code: string, + chunkName: RouteChunkName, cache: Cache, cacheKey: string -): { - main: GeneratorResult | undefined; - clientAction: GeneratorResult | undefined; - clientLoader: GeneratorResult | undefined; -} { - return { - main: omitChunkedExports( - code, - ["clientAction", "clientLoader"], - {}, - cache, - cacheKey - ), - clientAction: getChunkedExport(code, "clientAction", {}, cache, cacheKey), - clientLoader: getChunkedExport(code, "clientLoader", {}, cache, cacheKey), - }; +): GeneratorResult | undefined { + if (chunkName === mainChunkName) { + return omitChunkedExports(code, chunkedExportNames, {}, cache, cacheKey); + } + + return getChunkedExport(code, chunkName, {}, cache, cacheKey); } From c6fb80b14be57546f86d0dc27f99e6c31317da37 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 4 Sep 2024 16:00:06 +1000 Subject: [PATCH 12/63] Preload route chunks --- .../react-router/lib/dom/ssr/components.tsx | 11 ++----- packages/react-router/lib/dom/ssr/links.ts | 31 +++++-------------- 2 files changed, 10 insertions(+), 32 deletions(-) diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index b8c6d58a6f..ae1406be5d 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -715,14 +715,9 @@ import(${JSON.stringify(manifest.entry.module)});`; // eslint-disable-next-line }, []); - let routePreloads = matches - .map((match) => { - let route = manifest.routes[match.route.id]; - return (route.imports || []).concat([route.module]); - }) - .flat(1); - - let preloads = isHydrated ? [] : manifest.entry.imports.concat(routePreloads); + let preloads = isHydrated + ? [] + : manifest.entry.imports.concat(getModuleLinkHrefs(matches, manifest)); return isHydrated ? null : ( <> diff --git a/packages/react-router/lib/dom/ssr/links.ts b/packages/react-router/lib/dom/ssr/links.ts index 0e67c2ac04..5025eba8b0 100644 --- a/packages/react-router/lib/dom/ssr/links.ts +++ b/packages/react-router/lib/dom/ssr/links.ts @@ -222,7 +222,7 @@ export function getKeyedLinksForMatches( }) .flat(2); - let preloads = getCurrentPageModulePreloadHrefs(matches, manifest); + let preloads = getModuleLinkHrefs(matches, manifest); return dedupeLinkDescriptors(descriptors, preloads); } @@ -421,27 +421,6 @@ export function getNewMatchesForLinks( } export function getModuleLinkHrefs( - matches: AgnosticDataRouteMatch[], - manifestPatch: AssetsManifest -): string[] { - return dedupeHrefs( - matches - .map((match) => { - let route = manifestPatch.routes[match.route.id]; - let hrefs = [route.module]; - if (route.imports) { - hrefs = hrefs.concat(route.imports); - } - return hrefs; - }) - .flat(1) - ); -} - -// The `