diff --git a/.changeset/bright-routes-run.md b/.changeset/bright-routes-run.md new file mode 100644 index 0000000..3c52877 --- /dev/null +++ b/.changeset/bright-routes-run.md @@ -0,0 +1,5 @@ +--- +"rsbuild-plugin-react-router": patch +--- + +Improve route analysis and route chunking performance for larger applications, with benchmark tooling to track build overhead. diff --git a/README.md b/README.md index 65a0a18..33b4587 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ A Rsbuild plugin that provides seamless integration with React Router, supportin ## Features - - 🚀 Zero-config setup with sensible defaults - 🔄 Automatic route generation from file system - 🖥️ Server-Side Rendering (SSR) support @@ -58,11 +57,11 @@ export default defineConfig(() => { // Optional: Enable custom server mode customServer: false, // Optional: Specify server output format - serverOutput: "commonjs", + serverOutput: 'commonjs', // Optional: enable experimental support for module federation - federation: false - }), - pluginReact() + federation: false, + }), + pluginReact(), ], }; }); @@ -73,6 +72,7 @@ export default defineConfig(() => { The plugin uses a two-part configuration system: 1. **Plugin Options** (in `rsbuild.config.ts`): + ```ts pluginReactRouter({ /** @@ -87,7 +87,27 @@ pluginReactRouter({ * Options: "commonjs" | "module" * @default "module" */ - serverOutput?: "commonjs" | "module" + serverOutput?: "commonjs" | "module", + + /** + * Rsbuild dev-only lazy compilation behavior. + * @default false + */ + lazyCompilation?: boolean | Rspack.LazyCompilationOptions, + + /** + * Emit structured React Router plugin timing logs. + * @default false + */ + logPerformance?: boolean, + + /** + * Run route transforms in a worker-thread pool. + * Pass `false` to disable or `{ maxWorkers }` to override the default worker count. + * @default true, using `available CPUs - 2` workers. + */ + parallelTransforms?: boolean | { maxWorkers?: number }, + /** * Enable experimental support for module federation * @default false @@ -106,6 +126,7 @@ passing the build to React Router's request handler. ``` 2. **React Router Configuration** (in `react-router.config.*`): + ```ts import type { Config } from '@react-router/dev/config'; @@ -120,19 +141,19 @@ export default { * The file name for the server build output. * @default "index.js" */ - serverBuildFile: "index.js", + serverBuildFile: 'index.js', /** * The output format for the server build. * Options: "esm" | "cjs" * @default "esm" */ - serverModuleFormat: "esm", + serverModuleFormat: 'esm', /** * Split server bundles by route branch (advanced). */ - serverBundles: async ({ branch }) => branch[0]?.id ?? "main", + serverBundles: async ({ branch }) => branch[0]?.id ?? 'main', /** * Hook called after the build completes. @@ -256,13 +277,14 @@ export default { } satisfies Config; ``` -For large sites, you can tune prerender concurrency: +For large sites, prerendering defaults to `availableParallelism - 2` concurrent +paths. You can tune prerender concurrency: ```ts export default { ssr: false, prerender: { - paths: ['/','/about'], + paths: ['/', '/about'], unstable_concurrency: 4, }, } satisfies Config; @@ -275,7 +297,12 @@ If no configuration is provided, the following defaults will be used: ```ts // Plugin defaults (rsbuild.config.ts) { - customServer: false + customServer: false, + serverOutput: 'module', + federation: false, + lazyCompilation: false, + logPerformance: false, + parallelTransforms: true // adaptive worker pool } // Router defaults (react-router.config.ts) @@ -287,6 +314,18 @@ If no configuration is provided, the following defaults will be used: } ``` +`parallelTransforms: true` uses worker threads for route builds. The default +worker count is `availableParallelism - 2`. Pass `{ maxWorkers }` to override +that count, or `false` to run route transforms inline. + +For builds with 256+ routes, detailed file-size reporting is compacted to totals +by default to avoid gzipping and printing thousands of assets. Set +`performance.printFileSize` to an object to customize that output. + +Route transform source maps are generated in development only. If you enable +Rsbuild source maps for faster local debugging, prefer a cheap JS map: +`output.sourceMap: { js: 'cheap-module-source-map', css: false }`. + ### Route Configuration Routes can be defined in `app/routes.ts` using the helper functions from `@react-router/dev/routes`: @@ -326,6 +365,7 @@ export default [ ``` The plugin provides several helper functions for defining routes: + - `index()` - Creates an index route - `route()` - Creates a regular route with a path - `layout()` - Creates a layout route with nested children @@ -336,6 +376,7 @@ The plugin provides several helper functions for defining routes: Route components support the following exports: #### Client-side Exports + - `default` - The route component - `ErrorBoundary` - Error boundary component - `HydrateFallback` - Loading component during hydration @@ -349,6 +390,7 @@ Route components support the following exports: - `shouldRevalidate` - Revalidation control #### Server-side Exports + - `loader` - Server-side data loading - `action` - Server-side form actions - `middleware` - Server-side middleware @@ -387,9 +429,9 @@ export default defineConfig(() => { return { plugins: [ pluginReactRouter({ - customServer: true - }), - pluginReact() + customServer: true, + }), + pluginReact(), ], }; }); @@ -398,6 +440,7 @@ export default defineConfig(() => { When using a custom server, you'll need to: 1. Create a server handler (`server/index.ts`): + ```ts import { createRequestHandler } from '@react-router/express'; @@ -413,6 +456,7 @@ export const app = createRequestHandler({ ``` 2. Set up your server entry point (`server.js`): + ```js import { createRsbuild, loadConfig } from '@rsbuild/core'; import express from 'express'; @@ -451,9 +495,11 @@ async function startServer() { devServer.connectWebSocket({ server }); } else { // Production mode - app.use(express.static(path.join(__dirname, 'build/client'), { - index: false - })); + app.use( + express.static(path.join(__dirname, 'build/client'), { + index: false, + }) + ); // Load the server bundle const serverBundle = await import('./build/server/static/js/app.js'); @@ -477,6 +523,7 @@ startServer().catch(console.error); ``` 3. Update your `package.json` scripts: + ```json { "scripts": { @@ -488,6 +535,7 @@ startServer().catch(console.error); ``` The custom server setup allows you to: + - Add custom middleware - Handle API routes - Integrate with databases @@ -500,6 +548,7 @@ The custom server setup allows you to: To deploy your React Router app to Cloudflare Workers: 1. **Configure Rsbuild** (`rsbuild.config.ts`): + ```ts import { defineConfig } from '@rsbuild/core'; import { pluginReact } from '@rsbuild/plugin-react'; @@ -524,17 +573,24 @@ export default defineConfig({ module: true, }, resolve: { - conditionNames: ['workerd', 'worker', 'browser', 'import', 'require'], + conditionNames: [ + 'workerd', + 'worker', + 'browser', + 'import', + 'require', + ], }, }, }, }, }, - plugins: [pluginReactRouter({customServer: true}), pluginReact()], + plugins: [pluginReactRouter({ customServer: true }), pluginReact()], }); ``` 2. **Configure Wrangler** (`wrangler.toml`): + ```toml workers_dev = true name = "my-react-router-worker" @@ -552,6 +608,7 @@ VALUE_FROM_CLOUDFLARE = "Hello from Cloudflare" ``` 3. **Create Worker Entry** (`server/index.ts`): + ```ts import { createRequestHandler } from 'react-router'; @@ -588,6 +645,7 @@ export default { ``` 4. **Update Package Dependencies**: + ```json { "dependencies": { @@ -605,6 +663,7 @@ export default { ``` 5. **Setup Deployment Scripts** (`package.json`): + ```json { "scripts": { @@ -630,6 +689,7 @@ export default { ### Development Workflow: 1. Local Development: + ```bash # Start local development server npm run dev @@ -646,6 +706,7 @@ export default { ## Development The plugin automatically: + - Runs type generation during development and build - Sets up development server with live reload - Handles route-based code splitting @@ -667,17 +728,17 @@ CSS endpoint) are not supported 1:1. The repository includes several examples demonstrating different use cases: -| Example | Description | Port | Command | -|---------|-------------|------|---------| -| [default-template](./examples/default-template) | Standard SSR setup with React Router | 3000 | `pnpm dev` | -| [spa-mode](./examples/spa-mode) | Single Page Application (`ssr: false`) | 3001 | `pnpm dev` | -| [prerender](./examples/prerender) | Static prerendering for multiple routes | 3002 | `pnpm dev` | -| [custom-node-server](./examples/custom-node-server) | Custom Express server with SSR | 3003 | `pnpm dev` | -| [cloudflare](./examples/cloudflare) | Cloudflare Workers deployment | 3004 | `pnpm dev` | -| [client-only](./examples/client-only) | `.client` modules with SSR hydration | 3010 | `pnpm dev` | -| [epic-stack](./examples/epic-stack) | Full-featured Epic Stack example | 3005 | `pnpm dev` | -| [federation/epic-stack](./examples/federation/epic-stack) | Module Federation host | 3006 | `pnpm dev` | -| [federation/epic-stack-remote](./examples/federation/epic-stack-remote) | Module Federation remote | 3007 | `pnpm dev` | +| Example | Description | Port | Command | +| ----------------------------------------------------------------------- | --------------------------------------- | ---- | ---------- | +| [default-template](./examples/default-template) | Standard SSR setup with React Router | 3000 | `pnpm dev` | +| [spa-mode](./examples/spa-mode) | Single Page Application (`ssr: false`) | 3001 | `pnpm dev` | +| [prerender](./examples/prerender) | Static prerendering for multiple routes | 3002 | `pnpm dev` | +| [custom-node-server](./examples/custom-node-server) | Custom Express server with SSR | 3003 | `pnpm dev` | +| [cloudflare](./examples/cloudflare) | Cloudflare Workers deployment | 3004 | `pnpm dev` | +| [client-only](./examples/client-only) | `.client` modules with SSR hydration | 3010 | `pnpm dev` | +| [epic-stack](./examples/epic-stack) | Full-featured Epic Stack example | 3005 | `pnpm dev` | +| [federation/epic-stack](./examples/federation/epic-stack) | Module Federation host | 3006 | `pnpm dev` | +| [federation/epic-stack-remote](./examples/federation/epic-stack-remote) | Module Federation remote | 3007 | `pnpm dev` | Each example has unique ports configured to allow running multiple examples simultaneously. diff --git a/benchmarks/README.md b/benchmarks/README.md index f0bee00..8cb14a0 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -18,7 +18,7 @@ counts for stress testing. All benchmark profiles generate deterministic synthetic React Router apps under `.benchmark/fixtures/`, build the current plugin package once, then run Rsbuild -production builds against those fixtures. +builds with `pluginReactRouter({ logPerformance: true })`. To capture Rspack tracing output for a benchmark, pass `--rspack-profile`: @@ -58,8 +58,9 @@ Each run writes: - `.benchmark/results//baseline.md` The JSON includes wall time, optional GNU `/usr/bin/time -v` user/sys/RSS data, -per-run exit status, and Rspack trace artifact paths when tracing is enabled. -The markdown report summarizes the same benchmark-level timing and memory data +parsed `[react-router:performance]` reports from the plugin, and an aggregated +`pluginOperations` table per fixture. The markdown report includes the same +operation breakdown so route transforms and manifest work can be compared without opening the raw JSON. ## Hygiene diff --git a/benchmarks/manifest-performance-methodology.md b/benchmarks/manifest-performance-methodology.md new file mode 100644 index 0000000..6535646 --- /dev/null +++ b/benchmarks/manifest-performance-methodology.md @@ -0,0 +1,273 @@ +# Manifest-generation performance benchmark recipe + +This document defines the reproducible commands and metric checklist for +measuring manifest-generation performance before and after the route-analysis / +manifest cache deduplication work. + +## Environment notes + +Use the same machine, branch, package manager, and Node version for both halves +of an A/B comparison. + +Record environment details for each run: + +- Branch and commit +- Node and pnpm versions +- Platform +- Rsbuild and Rspack versions +- React Router package versions +- Benchmark fixture size + +Fixture export-shape cycle from `scripts/benchmark/fixture.mjs`: + +```text +plain, ssr-data, split-client, split-client, ssr-data, client-server-imports +``` + +For 256 generated routes this yields: + +| Profile | Count | +| ------------------------------------------------------------ | ----: | +| plain | 42 | +| ssr-data | 86 | +| split-client | 86 | +| client-server-imports | 42 | +| splittable routes (`split-client` + `client-server-imports`) | 128 | + +## Existing benchmark harness + +The benchmark harness is `scripts/bench-builds.mjs`; package scripts are defined +in `package.json`: + +```sh +pnpm bench:smoke # 48-route smoke, 1 measured iteration +pnpm bench:baseline # 256-route default profile, 5 measured iterations +pnpm bench:full # 48/256/1024 route stress profile +``` + +The harness: + +1. builds the plugin package (`pnpm build`) unless `--skip-root-build` is passed; +2. generates deterministic fixtures under `.benchmark/fixtures/`; +3. runs `node node_modules/@rsbuild/core/bin/rsbuild.js build --config rsbuild.config.mjs`; +4. sets `REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE=1`, enabling structured + `[react-router:performance]` plugin logs; +5. wraps builds in `/usr/bin/time -v` when available and records user/sys/RSS; +6. writes `.benchmark/results//baseline.json` and `baseline.md`. + +`rsbuild build --help` in this repo exposes `--log-level`, `--environment`, +`--mode`, and `--config`, but no dedicated benchmark/stats/profiling CLI flag. +Use the plugin `logPerformance` reports as the primary plugin-level source of +truth. If low-level Rspack stats are needed later, add them through fixture +`rsbuild.config.mjs`; do not depend on a non-existent CLI flag. + +## Pre-flight commands + +Run from the repo root: + +```sh +git status --short +git rev-parse HEAD +node --version +pnpm --version +pnpm install +pnpm build +``` + +Keep benchmark output under `.benchmark/`; it is gitignored. Do not use broad +`git clean -fdX` because it may delete `node_modules/` and TraceDecay indexes. + +## Primary benchmark commands + +Use the default 256-route profile for the canonical before/after comparison. It +includes the split fixture that exercises route-chunk/manifest analysis and the +non-split controls. + +Baseline/current behavior: + +```sh +node scripts/bench-builds.mjs \ + --profile default \ + --iterations 5 \ + --warmup 1 \ + --clean build \ + --format both \ + --out .benchmark/results/manifest-baseline +``` + +Post-refactor behavior on the same branch/machine: + +```sh +node scripts/bench-builds.mjs \ + --profile default \ + --iterations 5 \ + --warmup 1 \ + --clean build \ + --format both \ + --out .benchmark/results/manifest-after-cache-dedup +``` + +If the refactor is gated behind an environment flag, run both toggles on the +same commit instead: + +```sh +ROUTE_MANIFEST_CACHE_DEDUP=0 node scripts/bench-builds.mjs \ + --profile default --iterations 5 --warmup 1 --clean build --format both \ + --out .benchmark/results/manifest-dedup-off + +ROUTE_MANIFEST_CACHE_DEDUP=1 node scripts/bench-builds.mjs \ + --profile default --iterations 5 --warmup 1 --clean build --format both \ + --out .benchmark/results/manifest-dedup-on +``` + +For a quicker focused loop, isolate the split fixture: + +```sh +node scripts/bench-builds.mjs \ + --profile default \ + --filter split \ + --iterations 3 \ + --warmup 1 \ + --clean build \ + --format both \ + --out .benchmark/results/manifest-split-smoke +``` + +For scaling validation after the refactor, use the full profile split fixtures: + +```sh +node scripts/bench-builds.mjs \ + --profile full \ + --filter split \ + --iterations 5 \ + --warmup 1 \ + --clean build \ + --format both \ + --out .benchmark/results/manifest-scale +``` + +## Single-fixture command for manual debugging + +The harness command for each fixture build is: + +```sh +cd .benchmark/fixtures/synthetic-256-ssr-esm-split +REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE=1 NODE_ENV=production \ + /usr/bin/time -v \ + node node_modules/@rsbuild/core/bin/rsbuild.js \ + build --config rsbuild.config.mjs --log-level info +``` + +Use this only for debugging logs. Use `scripts/bench-builds.mjs` for numbers +because it controls warmup, cleaning, aggregation, and output format. + +## Metric checklist + +### Already observable from `baseline.json` + +| Metric | Source | Why it matters | +| --------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Build wall time | `benchmarks[].summary.wallMs` | End-to-end user-visible build time. | +| CPU time | `summary.userMs` + `summary.sysMs` | Less noisy than wall time when the machine has minor scheduling variance. | +| Peak RSS | `summary.maxRssKb` | Ensures cache dedup does not regress memory. | +| Compiler lifecycle | each plugin report's `compilerLifecycleMs` | Plugin setup/build lifecycle timing per compiler environment. | +| Transform invocation counts | `pluginOperations[].count` | Counts route/manifest hook invocations. Counts should usually stay stable after dedup; timings should drop. | +| Transform cumulative time | `pluginOperations[].totalMs` | Primary signal for expensive plugin work moving out of duplicate paths. | +| Slowest transform | `pluginOperations[].maxMs` and `operations.*.slowest` in JSON | Catches per-route outliers hidden by totals. | + +Relevant existing operation buckets: + +- `manifest:transform`: virtual server/browser manifest module transform. +- `manifest:stage`: browser manifest staging callback in `modifyBrowserManifest`. +- `route:client-entry`: route client-entry transform; currently calls + `transformToEsm`, `getExportNames`, and, for web split builds, + `detectRouteChunksIfEnabled`. +- `route:split-exports`: route source rewrite for split-route modules; currently + calls `transformToEsm`, `detectRouteChunksIfEnabled`, and `getExportNames`. +- `route:chunk`: per-`?route-chunk=` transform; currently calls + `transformToEsm`, `getRouteChunkIfEnabled`, and, for enforce mode on `main`, + `getExportNames`. +- `route:module`: `?react-router-route` transform. +- `module:client-only-stub` and `module:server-only-guard`: import guard/stub + overhead, useful controls for unrelated plugin transform cost. + +### Add or instrument for the cache-dedup refactor + +The existing profiler is transform-bucket level. To prove manifest-generation +cache deduplication specifically, add direct counters around the lower-level +operations below, either as new `performanceProfiler.record*` operation names or +as a `counters` object in `ReactRouterPerformanceReport`. + +| Counter / metric | Suggested operation name | Expected baseline for 256-route default split build | Notes | +| --------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------: | ------------------------------------------------------------------------------------------------------------------------ | +| Route-file stat calls | `manifest:route-stat` | 257 per build | `getRouteModuleAnalysis(resourcePath)` calls `stat` before cache lookup. Root + 256 routes. | +| Route-file reads | `manifest:route-read` | 257 per build on a cold build | Count the `readFile(resourcePath, 'utf8')` inside `getRouteModuleAnalysis` cache misses. | +| Route source transforms for manifest analysis | `manifest:route-transform-to-esm` | 257 per build on a cold build | Same cache-miss path as route reads. | +| Export extractions for manifest analysis | `manifest:route-export-extract` | 257 per build on a cold build | `getRouteModuleAnalysis` calls `getExportNames(code)` once per route-module analysis miss. | +| Manifest route analysis wall time | `manifest:route-analysis` | 257 samples; report total/mean/p95 | Wrap one route's `getRouteModuleAnalysis` + split detection inside `getReactRouterManifestForDev`. | +| Total manifest route-map wall time | `manifest:route-map` | 1 per manifest generation | Wrap the `Promise.all(Object.entries(routes).map(...))` block in `manifest.ts`. | +| Split-route detection calls from manifest | `manifest:route-chunk-detect` | 257 per split build | Only when `isBuild && routeChunkConfig`. Must drop duplicated work after dedup if manifest reuses cached route analysis. | +| Babel route-chunk parse calls | `route-chunk:parse` | currently at most 1 per `(route, code)` cache key, but direct count needed | Current code caches parse but still clones AST on each access; count parse separately from clone. | +| Babel route-chunk traverse calls | `route-chunk:traverse` | currently at most 1 per `(route, code)` cache key, but direct count needed | Wrap `getExportDependencies`. | +| AST structured clones | `route-chunk:structured-clone` | roughly 1 for dependency analysis + 1 per generated chunk for splittable modules | This is the expected direct win for RouteChunkAnalysis-style dedup. | +| Chunk code generations | `route-chunk:generate` | up to 5 per fully splittable route | Count `generate()` in `getChunkedExport` and `omitChunkedExports`. | +| Per-route analysis time | `manifest:route-analysis` / `route-chunk:analyze` slowest list | one resource entry per route | Keep `resource` as the route file path so `slowest` pinpoints outliers. | + +Acceptance rule: the refactor should reduce direct manifest/read/export-analysis +work or route-chunk analysis work without changing the externally visible route +transform invocation counts for the same fixture. If `pluginOperations[].count` +changes, explain why the module graph changed; otherwise compare `totalMs`, +`maxMs`, and direct counters. + +## Baseline expectations + +For the split fixture after cache dedup: + +- `route:client-entry`, `route:module`, `route:split-exports`, and + `route:chunk` invocation counts should remain approximately the same because + the module graph and virtual modules are unchanged. +- `route:client-entry.totalMs` and `route:chunk.totalMs` are the hot buckets to + reduce. On head they dominate the split fixture: ~363.8s and ~409.9s summed + across five measured builds. +- Direct `manifest:route-read`, `manifest:route-export-extract`, and + `manifest:route-analysis` counters should show 257 route analyses per cold + build before dedup. If a new shared cache lets transform hooks and manifest + generation reuse one analysis result, the duplicated lower-level counters + should fall while the transform-level counts stay stable. +- Direct `route-chunk:structured-clone` should fall materially if the refactor + removes per-query AST cloning. + +Use `synthetic-256-ssr-esm` as the non-split control. It should not materially +change when the split-route cache path changes. + +## Comparison procedure + +1. Run the baseline and post-refactor commands back-to-back on the same machine. +2. Compare `synthetic-256-ssr-esm-split` first: + - wall median and p95; + - CPU median (`userMs + sysMs`); + - p95 RSS; + - `route:client-entry.totalMs`; + - `route:chunk.totalMs`; + - direct manifest/route-analysis counters added for the refactor. +3. Check `synthetic-256-ssr-esm` and `synthetic-256-sourcemaps` as controls. + Their route-chunk-specific direct counters should remain zero or unchanged. +4. Use `operations.*.slowest` in `baseline.json` to inspect outlier route files + if medians improve but max transform time regresses. +5. For a final report, include both absolute values and percentage deltas. + +Suggested report table: + +```text +| Metric (256 split fixture) | Before | After | Delta | +|---|---:|---:|---:| +| Wall median | 2.07s | ... | ... | +| CPU median (user+sys) | ... | ... | ... | +| Peak RSS p95 | 704 MB | ... | ... | +| route:client-entry totalMs | 363767.2ms | ... | ... | +| route:chunk totalMs | 409899.2ms | ... | ... | +| manifest route reads / build | 257 expected | ... | ... | +| manifest export extractions / build | 257 expected | ... | ... | +| route-chunk structuredClone calls / build | instrument | ... | ... | +| per-route analysis p95 | instrument | ... | ... | +``` diff --git a/config/rslib.config.ts b/config/rslib.config.ts index b005996..9b76aba 100644 --- a/config/rslib.config.ts +++ b/config/rslib.config.ts @@ -13,7 +13,6 @@ export const nodeMinifyConfig: Minify = { css: false, jsOptions: { minimizerOptions: { - // preserve variable name and disable minify for easier debugging mangle: false, minify: false, compress: true, @@ -21,14 +20,13 @@ export const nodeMinifyConfig: Minify = { }, }; -// Clean tsc cache to ensure the dts files can be generated correctly export const pluginCleanTscCache: RsbuildPlugin = { name: 'plugin-clean-tsc-cache', setup(api) { api.onBeforeBuild(() => { const tsbuildinfo = path.join( - api.context.rootPath, - 'tsconfig.tsbuildinfo', + api.context.rootPath, + 'tsconfig.tsbuildinfo' ); if (fs.existsSync(tsbuildinfo)) { fs.rmSync(tsbuildinfo); @@ -42,8 +40,8 @@ export const esmConfig: LibConfig = { syntax: 'es2021', shims: { esm: { - __dirname: true - } + __dirname: true, + }, }, dts: { build: true, @@ -51,10 +49,6 @@ export const esmConfig: LibConfig = { plugins: [pluginCleanTscCache], output: { minify: nodeMinifyConfig, - externals: { - '@babel/traverse': 'commonjs @babel/traverse', - '@babel/generator': 'commonjs @babel/generator', - } }, }; diff --git a/examples/default-template/playwright.config.ts b/examples/default-template/playwright.config.ts index 66c43d8..d3c3690 100644 --- a/examples/default-template/playwright.config.ts +++ b/examples/default-template/playwright.config.ts @@ -7,8 +7,10 @@ export default defineConfig({ expect: { timeout: 5000 }, - // Run tests in files in parallel + // Keep this example serial because dev-route-watch mutates routes.ts and + // restarts the shared dev server. fullyParallel: false, + workers: 1, // Fail the build on CI if you accidentally left test.only in the source code forbidOnly: !!process.env.CI, // Retry on CI only @@ -47,4 +49,4 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, timeout: 120000, }, -}); \ No newline at end of file +}); diff --git a/examples/default-template/tests/e2e/dev-route-watch.test.ts b/examples/default-template/tests/e2e/dev-route-watch.test.ts new file mode 100644 index 0000000..32035c7 --- /dev/null +++ b/examples/default-template/tests/e2e/dev-route-watch.test.ts @@ -0,0 +1,151 @@ +import { expect, test, type Page } from '@playwright/test'; +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const appDirectory = join(__dirname, '../../app'); +const restartMarkerPath = join( + __dirname, + '../../build/client/.react-router/route-watch' +); +const routesConfigPath = join(appDirectory, 'routes.ts'); +const addedRoutePath = join(appDirectory, 'routes/dev-added-route.tsx'); +const addedRouteUrl = '/dev-added-route'; +const addedRouteText = 'Route added while dev server is running'; +const editedAddedRouteText = 'Route edited without dev server restart'; +const addedRouteConfigEntry = ` route('dev-added-route', 'routes/dev-added-route.tsx'),`; + +const removeAddedRouteConfig = (): boolean => { + const routesConfig = readFileSync(routesConfigPath, 'utf8'); + if (routesConfig.includes(addedRouteConfigEntry)) { + writeFileSync( + routesConfigPath, + routesConfig.replace(`${addedRouteConfigEntry}\n\n`, '') + ); + return true; + } + return false; +}; + +const removeAddedRouteFile = (): boolean => { + if (existsSync(addedRoutePath)) { + rmSync(addedRoutePath, { force: true }); + return true; + } + return false; +}; + +const readRestartMarker = (): string | null => + existsSync(restartMarkerPath) + ? readFileSync(restartMarkerPath, 'utf8') + : null; + +const expectRestartMarkerStable = async ( + expectedMarker: string | null, + quietMs = 750 +) => { + const startedAt = Date.now(); + await expect + .poll( + () => { + const marker = readRestartMarker(); + if (marker !== expectedMarker) { + return `changed:${marker ?? 'missing'}`; + } + return Date.now() - startedAt >= quietMs ? 'stable' : 'waiting'; + }, + { intervals: [100], timeout: quietMs + 1000 } + ) + .toBe('stable'); +}; + +const waitForRouteText = async ( + page: Page, + url: string, + text: string +) => { + await expect + .poll( + async () => { + try { + const response = await page.request.get(url, { + timeout: 2000, + }); + if (!response.ok()) { + return `status:${response.status()}`; + } + const body = await response.text(); + return body.includes(text) ? 'ready' : 'missing-text'; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + { timeout: 60000 } + ) + .toBe('ready'); +}; + +test.describe('dev route watch', () => { + test.setTimeout(90000); + + test.beforeEach(async ({ page }) => { + if (removeAddedRouteConfig()) { + await waitForRouteText(page, '/', 'Welcome to React Router'); + } + if (removeAddedRouteFile()) { + await waitForRouteText(page, '/', 'Welcome to React Router'); + } + }); + + test.afterEach(async ({ page }) => { + if (removeAddedRouteConfig()) { + await waitForRouteText(page, '/', 'Welcome to React Router'); + } + if (removeAddedRouteFile()) { + await waitForRouteText(page, '/', 'Welcome to React Router'); + } + }); + + test('serves a route added after the dev server starts without restarting on later edits', async ({ + page, + }) => { + await page.goto('/'); + await expect(page.locator('h1')).toContainText('Welcome to React Router'); + + writeFileSync( + addedRoutePath, + `export default function DevAddedRoute() { + return

${addedRouteText}

; +} +` + ); + + const routesConfig = readFileSync(routesConfigPath, 'utf8'); + writeFileSync( + routesConfigPath, + routesConfig.replace( + ' // Docs section with nested routes', + `${addedRouteConfigEntry}\n\n // Docs section with nested routes` + ) + ); + + await waitForRouteText(page, addedRouteUrl, addedRouteText); + + await page.goto(addedRouteUrl); + await expect(page.locator('h1')).toHaveText(addedRouteText); + + await expect.poll(readRestartMarker, { timeout: 10000 }).not.toBe(null); + const restartMarkerBefore = readRestartMarker(); + writeFileSync( + addedRoutePath, + `export default function DevAddedRoute() { + return

${editedAddedRouteText}

; +} +` + ); + + await waitForRouteText(page, addedRouteUrl, editedAddedRouteText); + await expectRestartMarkerStable(restartMarkerBefore); + }); +}); diff --git a/package.json b/package.json index f964c0d..c572d66 100644 --- a/package.json +++ b/package.json @@ -70,16 +70,9 @@ "release:local": "pnpm build && changeset version && changeset publish && git add . && git commit -m \"chore: version packages\" && git push && git push --tags" }, "dependencies": { - "@babel/core": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", "@react-router/node": "^7.13.0", "@remix-run/node-fetch-server": "^0.13.0", "@rspack/plugin-react-refresh": "^2.0.2", - "babel-dead-code-elimination": "^1.0.12", - "esbuild": "^0.27.2", "execa": "^9.6.1", "fs-extra": "11.3.3", "isbot": "5.1.34", @@ -87,7 +80,9 @@ "jsesc": "^3.1.0", "pathe": "^2.0.3", "react-refresh": "^0.18.0", - "rspack-plugin-virtual-module": "^1.0.1" + "yuku-analyzer": "0.5.39", + "yuku-codegen": "0.5.39", + "yuku-parser": "0.5.39" }, "devDependencies": { "@changesets/cli": "^2.29.8", @@ -97,12 +92,9 @@ "@rsbuild/plugin-react": "2.0.1", "@rslib/core": "^0.22.1", "@rspack/core": "2.0.8", - "@swc/helpers": "^0.5.23", "@rstest/core": "^0.8.1", "@rstest/coverage-istanbul": "^0.2.0", - "@types/babel__core": "^7.20.5", - "@types/babel__generator": "^7.27.0", - "@types/babel__traverse": "^7.28.0", + "@swc/helpers": "^0.5.23", "@types/fs-extra": "11.0.4", "@types/jsesc": "^3.0.3", "@types/node": "^25.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 994cb95..cd4093a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,21 +11,6 @@ importers: .: dependencies: - '@babel/core': - specifier: ^7.28.6 - version: 7.28.6 - '@babel/generator': - specifier: ^7.28.6 - version: 7.28.6 - '@babel/parser': - specifier: ^7.28.6 - version: 7.28.6 - '@babel/traverse': - specifier: ^7.28.6 - version: 7.28.6 - '@babel/types': - specifier: ^7.28.6 - version: 7.28.6 '@react-router/node': specifier: ^7.13.0 version: 7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) @@ -35,12 +20,6 @@ importers: '@rspack/plugin-react-refresh': specifier: ^2.0.2 version: 2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(react-refresh@0.18.0) - babel-dead-code-elimination: - specifier: ^1.0.12 - version: 1.0.12 - esbuild: - specifier: ^0.27.2 - version: 0.27.2 execa: specifier: ^9.6.1 version: 9.6.1 @@ -62,16 +41,22 @@ importers: react-refresh: specifier: ^0.18.0 version: 0.18.0 - rspack-plugin-virtual-module: - specifier: ^1.0.1 - version: 1.0.1 + yuku-analyzer: + specifier: 0.5.39 + version: 0.5.39 + yuku-codegen: + specifier: 0.5.39 + version: 0.5.39 + yuku-parser: + specifier: 0.5.39 + version: 0.5.39 devDependencies: '@changesets/cli': specifier: ^2.29.8 version: 2.29.8(@types/node@25.0.10) '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@rsbuild/config': specifier: workspace:* version: link:config @@ -96,15 +81,6 @@ importers: '@swc/helpers': specifier: ^0.5.23 version: 0.5.23 - '@types/babel__core': - specifier: ^7.20.5 - version: 7.20.5 - '@types/babel__generator': - specifier: ^7.27.0 - version: 7.27.0 - '@types/babel__traverse': - specifier: ^7.28.0 - version: 7.28.0 '@types/fs-extra': specifier: 11.0.4 version: 11.0.4 @@ -195,7 +171,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@rsbuild/core': specifier: 2.0.15 version: 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) @@ -219,7 +195,7 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) examples/cloudflare: dependencies: @@ -253,7 +229,7 @@ importers: version: 7.13.0(@cloudflare/workers-types@4.20260127.0)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@rsbuild/core': specifier: 2.0.15 version: 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) @@ -314,7 +290,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.1.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.9.4)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@rsbuild/core': specifier: 2.0.15 version: 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) @@ -323,7 +299,7 @@ importers: version: 2.0.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) '@rsdoctor/rspack-plugin': specifier: ^1.5.13 - version: 1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) + version: 1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 @@ -378,13 +354,13 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@rsbuild/core': specifier: 2.0.15 version: 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) '@rsbuild/plugin-less': specifier: ^1.6.4 - version: 1.6.4(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) + version: 1.6.4(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) '@rsbuild/plugin-react': specifier: ^2.0.1 version: 2.0.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23)) @@ -408,13 +384,13 @@ importers: version: 10.1.0 react-router-devtools: specifier: ^6.2.0 - version: 6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) rsbuild-plugin-react-router: specifier: workspace:* version: link:../.. string-replace-loader: specifier: ^3.3.0 - version: 3.3.0(webpack@5.97.1(esbuild@0.27.2)) + version: 3.3.0(webpack@5.97.1(lightningcss@1.30.2)) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -426,10 +402,10 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) vite-tsconfig-paths: specifier: ^6.0.5 - version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) examples/epic-stack: dependencies: @@ -504,7 +480,7 @@ importers: version: 7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) '@react-router/remix-routes-option-adapter': specifier: 7.13.0 - version: 7.13.0(@react-router/dev@7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3) + version: 7.13.0(@react-router/dev@7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3) '@remix-run/server-runtime': specifier: 2.17.4 version: 2.17.4(typescript@5.9.3) @@ -651,14 +627,14 @@ importers: version: 4.0.2(tailwindcss@4.1.18) vite-env-only: specifier: 3.0.3 - version: 3.0.3(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 3.0.3(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) zod: specifier: 3.25.76 version: 3.25.76 devDependencies: '@epic-web/config': specifier: 1.21.3 - version: 1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) '@faker-js/faker': specifier: 10.2.0 version: 10.2.0 @@ -667,7 +643,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@rstest/core': specifier: 0.8.1 version: 0.8.1(jsdom@27.4.0(@noble/hashes@2.0.1)) @@ -745,7 +721,7 @@ importers: version: 0.5.10 '@vitejs/plugin-react': specifier: 5.1.2 - version: 5.1.2(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 5.1.2(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) autoprefixer: specifier: 10.4.23 version: 10.4.23(postcss@8.5.15) @@ -793,7 +769,7 @@ importers: version: 5.9.3 vite: specifier: 7.3.1 - version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) examples/federation: devDependencies: @@ -832,13 +808,13 @@ importers: version: 0.6.1 '@module-federation/enhanced': specifier: 2.5.1 - version: 2.5.1(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + version: 2.5.1(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/node': specifier: 2.7.44 - version: 2.7.44(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + version: 2.7.44(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/rsbuild-plugin': specifier: 2.5.1 - version: 2.5.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + version: 2.5.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@nasa-gcn/remix-seo': specifier: 2.0.1 version: 2.0.1(@remix-run/react@2.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@remix-run/server-runtime@2.17.4(typescript@5.9.3)) @@ -886,7 +862,7 @@ importers: version: 7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) '@react-router/remix-routes-option-adapter': specifier: 7.13.0 - version: 7.13.0(@react-router/dev@7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3) + version: 7.13.0(@react-router/dev@7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3) '@remix-run/server-runtime': specifier: 2.17.4 version: 2.17.4(typescript@5.9.3) @@ -1037,7 +1013,7 @@ importers: devDependencies: '@epic-web/config': specifier: 1.21.3 - version: 1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) '@faker-js/faker': specifier: 10.2.0 version: 10.2.0 @@ -1046,7 +1022,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@rstest/core': specifier: 0.8.1 version: 0.8.1(jsdom@27.4.0(@noble/hashes@2.0.1)) @@ -1196,13 +1172,13 @@ importers: version: 0.6.1 '@module-federation/enhanced': specifier: 2.5.1 - version: 2.5.1(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + version: 2.5.1(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/node': specifier: 2.7.44 - version: 2.7.44(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + version: 2.7.44(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/rsbuild-plugin': specifier: 2.5.1 - version: 2.5.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + version: 2.5.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@nasa-gcn/remix-seo': specifier: 2.0.1 version: 2.0.1(@remix-run/react@2.15.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(@remix-run/server-runtime@2.17.4(typescript@5.9.3)) @@ -1250,7 +1226,7 @@ importers: version: 7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) '@react-router/remix-routes-option-adapter': specifier: 7.13.0 - version: 7.13.0(@react-router/dev@7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3) + version: 7.13.0(@react-router/dev@7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3) '@remix-run/server-runtime': specifier: 2.17.4 version: 2.17.4(typescript@5.9.3) @@ -1401,7 +1377,7 @@ importers: devDependencies: '@epic-web/config': specifier: 1.21.3 - version: 1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) '@faker-js/faker': specifier: 10.2.0 version: 10.2.0 @@ -1410,7 +1386,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@rstest/core': specifier: 0.8.1 version: 0.8.1(jsdom@27.4.0(@noble/hashes@2.0.1)) @@ -1558,7 +1534,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@rsbuild/core': specifier: 2.0.15 version: 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) @@ -1582,7 +1558,7 @@ importers: version: 10.1.0 react-router-devtools: specifier: ^6.2.0 - version: 6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) rsbuild-plugin-react-router: specifier: workspace:* version: link:../.. @@ -1591,7 +1567,7 @@ importers: version: 14.2.5 string-replace-loader: specifier: ^3.3.0 - version: 3.3.0(webpack@5.97.1(esbuild@0.27.2)) + version: 3.3.0(webpack@5.97.1(lightningcss@1.30.2)) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -1603,10 +1579,10 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) vite-tsconfig-paths: specifier: ^6.0.5 - version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) examples/spa-mode: dependencies: @@ -1637,7 +1613,7 @@ importers: version: 1.58.0 '@react-router/dev': specifier: ^7.13.0 - version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) '@rsbuild/core': specifier: 2.0.15 version: 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) @@ -1661,7 +1637,7 @@ importers: version: 10.1.0 react-router-devtools: specifier: ^6.2.0 - version: 6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) rsbuild-plugin-react-router: specifier: workspace:* version: link:../.. @@ -1670,7 +1646,7 @@ importers: version: 14.2.5 string-replace-loader: specifier: ^3.3.0 - version: 3.3.0(webpack@5.97.1(esbuild@0.27.2)) + version: 3.3.0(webpack@5.97.1(lightningcss@1.30.2)) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -1682,10 +1658,10 @@ importers: version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + version: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) vite-tsconfig-paths: specifier: ^6.0.5 - version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) packages: @@ -1909,6 +1885,10 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -4818,6 +4798,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@5.1.1': resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} @@ -4855,8 +4838,8 @@ packages: '@types/node@25.0.10': resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==} - '@types/node@25.1.0': - resolution: {integrity: sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==} + '@types/node@25.9.4': + resolution: {integrity: sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==} '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -5167,6 +5150,174 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@yuku-analyzer/binding-darwin-arm64@0.5.39': + resolution: {integrity: sha512-+C9QJxMZ1ujKhhBf//NQTDYZ+es94VgekoULPBV4mHSUDNfmuVW2XB7FmV6RJv4NBrdZRQQ/WcbNiuz7M54VpA==} + cpu: [arm64] + os: [darwin] + + '@yuku-analyzer/binding-darwin-x64@0.5.39': + resolution: {integrity: sha512-GHO4pYMTm2W+tNxwM9bforEggO3qQ2V8qQ/+CxVLLJSFk2UwtwjDUmAzoCmydC2sxX6kS4B2C+UxR3IiGkvbIg==} + cpu: [x64] + os: [darwin] + + '@yuku-analyzer/binding-freebsd-x64@0.5.39': + resolution: {integrity: sha512-LWMPzuXL7QlSeiw43wgBZsBTEkcRs3mAshisQVztYCpeAhh9USznXmlo201rcE47X1V7hc8gc7FXRsBD9gs60Q==} + cpu: [x64] + os: [freebsd] + + '@yuku-analyzer/binding-linux-arm-gnu@0.5.39': + resolution: {integrity: sha512-W5jhcSUUf3iirM5qeZCQVHhnDfUUUSM2jqLvWhuWfnEbXlVECmr7PUZOX6k92FlKINrLpgkUgF+Qa3TJ7UV6pg==} + cpu: [arm] + os: [linux] + + '@yuku-analyzer/binding-linux-arm-musl@0.5.39': + resolution: {integrity: sha512-m0JlZRU9UMmHhgHJSAHzruc7/5RuxahLo1U9hrTEeePe3UtQfC6uNsJS+Oktd7K/HWhE5vJDfe2Jn8nn4Tc0PA==} + cpu: [arm] + os: [linux] + + '@yuku-analyzer/binding-linux-arm64-gnu@0.5.39': + resolution: {integrity: sha512-18tLRh0LUcwHqrXB31/xvxiiOZBCKQ4uTvAsltAfB9p+l1WDVYwogI3vkbvP/xkqqgWFlPYeV09wPmttdrelUw==} + cpu: [arm64] + os: [linux] + + '@yuku-analyzer/binding-linux-arm64-musl@0.5.39': + resolution: {integrity: sha512-v7bikc1uuy+Wqd7EHV9hAgQacsUMgLYmfm+QArs0RfsotNHOe7HRlEhQ1SUw6qgnOg2WU6QZDndoUio8CVr+uQ==} + cpu: [arm64] + os: [linux] + + '@yuku-analyzer/binding-linux-x64-gnu@0.5.39': + resolution: {integrity: sha512-jV97sJa+Hf7EcGAbj3NSvYP7VX0LlhpDkAO9vNVHjro0Aj9suwW20o4+fRPuzYHxZKUOJf0Hf3mmW7MUoCgb0A==} + cpu: [x64] + os: [linux] + + '@yuku-analyzer/binding-linux-x64-musl@0.5.39': + resolution: {integrity: sha512-r1PCUYBtcrnX5xwsEK1LY7QXty65Vh8y6JprItNqpMssRiOHCyLj8RW9omrV//9jGsEHco3PACXkZqAE94pvmw==} + cpu: [x64] + os: [linux] + + '@yuku-analyzer/binding-win32-arm64@0.5.39': + resolution: {integrity: sha512-WdFhMS8j8w5r85hD0hcieXF3le0EOMAv/iJYOVvvoVgupn2k+T//jyJjkuuzbAVIfq6sVv/IJPWa0vobhk3pfQ==} + cpu: [arm64] + os: [win32] + + '@yuku-analyzer/binding-win32-x64@0.5.39': + resolution: {integrity: sha512-qFH6vAxClP8BNpztHFt3r/Qkf5O/bMVlSPd9T+jyv+dSHlVgnbXgz5cbI3Ozd28FPbjmsxbGM9VCZq8GsMywHA==} + cpu: [x64] + os: [win32] + + '@yuku-codegen/binding-darwin-arm64@0.5.39': + resolution: {integrity: sha512-zBgmr0X0IQ5jr+lUsm0pK9EVcea4xOr/gTw/sFAZBkl5PrK6k+jhXCLcYTFnRZ7XCV+7XjZZeEBI3bOcTTb/Ug==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.5.39': + resolution: {integrity: sha512-DKKCXwLyRXquicGQODwt3oHUAl10Kcc9wFsuw9mqyR+vqLeHNsIPgO37zfzHGiTvDehwBcy6YfNIyS+bKxQhpA==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.5.39': + resolution: {integrity: sha512-IN+r1J1wrq8llBvvCx9O4/hZq6uMtYufDY1DyclnnVexuZ6oxTO+4Owg2PvHX1oeJWa0eWbvXyF75uscuX6FXw==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.5.39': + resolution: {integrity: sha512-CBmdviCmf/mw+AgU54OgA+oxgHiGgowYdz/AIPyQICKOo+F/bgeAGJcOdPvsGxAyLoylp8IdPXAqMzcmsaXdkA==} + cpu: [arm] + os: [linux] + + '@yuku-codegen/binding-linux-arm-musl@0.5.39': + resolution: {integrity: sha512-g34mpy2JG+rl5PaQmPARtf7BHHvHYhk3jMlwlfshUO+2frLLDCaSVTGzEb8jw5bqqiwHe/pMNveq9PYY1w/R+w==} + cpu: [arm] + os: [linux] + + '@yuku-codegen/binding-linux-arm64-gnu@0.5.39': + resolution: {integrity: sha512-EG6bv5an/4hx6Sy/ikDg7raRjtJgVoDLPfoDFULF9dJKlb7bVcGdhyAJUXy20HF5uQUMCtwtw+Zygduhvsajdw==} + cpu: [arm64] + os: [linux] + + '@yuku-codegen/binding-linux-arm64-musl@0.5.39': + resolution: {integrity: sha512-OTD73F2RMyxY9LTANkJZg+nFGq4VILqDQ/vtp8OdXxDls/cBIFGUHKHtWyZhXOBCtmtgVeJlWtiXsodmA2ZC9Q==} + cpu: [arm64] + os: [linux] + + '@yuku-codegen/binding-linux-x64-gnu@0.5.39': + resolution: {integrity: sha512-mZLLkl9wa+4V4poXBCjjMB4Qu2UXVvRQSgP1rLuzi7FUCkK5+Cy16Eawy8NgmWEtd82tpfe9Jmqt7rhl55LzJg==} + cpu: [x64] + os: [linux] + + '@yuku-codegen/binding-linux-x64-musl@0.5.39': + resolution: {integrity: sha512-KFkWzyUQaa7tRy5tmX5w//WAO58YiOh/GFsePeu7nuPfXueQFbKEa+9/5Jy0GoLCmaacg4quYwr3drGmY7PTew==} + cpu: [x64] + os: [linux] + + '@yuku-codegen/binding-win32-arm64@0.5.39': + resolution: {integrity: sha512-GAnElqWLv2tbmL/k0wYRrozqMozrEWhiWUSM6NpPOdQkEg4l9zfC+SqGJirNZQj7i+Oy+j3rIN0oMdz5hBsAow==} + cpu: [arm64] + os: [win32] + + '@yuku-codegen/binding-win32-x64@0.5.39': + resolution: {integrity: sha512-uaF7BD+MkXj+9RFVih2Kv6UOg/Q0+jytSVYP5YRJfTrXcEFyEqNLMrZZav6dPGj9xOTSQUGgxvHNQS0XPf1R5Q==} + cpu: [x64] + os: [win32] + + '@yuku-parser/binding-darwin-arm64@0.5.39': + resolution: {integrity: sha512-MYd/uTmmKRc9dyefCwprRYwqrXFbL97oW2+ANGqDpc5E/wezfsyF/aD+Xxb3pNrc/0bjhIPM76EO2a3ne7f0oQ==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.5.39': + resolution: {integrity: sha512-8qyPmKObZ9ro9gJWIhoUz3WZCDzf3OC1K9arLHb6zjvJWQPcQ64hxkuZXscGRX8p0elleZLx7sTy9HSbcuVS/A==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.5.39': + resolution: {integrity: sha512-ZP4Iksc54bBmVPVXg/8b9i+KzkQo+vGQE9BElnc+WRBQRos+xrH6pWLjWdm70AhJS+QLDDqEWpDValG22Mb4BA==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.5.39': + resolution: {integrity: sha512-T9T/QrDM4c7BKoJcLu2fDLF2ZaXIn+ZBPhXOVKM/EJvjvVe3TluIla1xXLlDdXjxU5M1G5q4ngcXz65qz1tvEA==} + cpu: [arm] + os: [linux] + + '@yuku-parser/binding-linux-arm-musl@0.5.39': + resolution: {integrity: sha512-/5FrXOu/M/m/91kdVxcZJWO8CYsvA93a9ihhRYlPEeTJ3iu8HWeZhRhlbCAVVulGpvpSifak/1UdIPjZDsumvg==} + cpu: [arm] + os: [linux] + + '@yuku-parser/binding-linux-arm64-gnu@0.5.39': + resolution: {integrity: sha512-hl6zf9VT6krdaM7kTjYmXElf9fbEtgjecLUJn/YmTc10IQYvxolwgSETKWNDouaq2mYPl3YG9xg4m/atZJXJYA==} + cpu: [arm64] + os: [linux] + + '@yuku-parser/binding-linux-arm64-musl@0.5.39': + resolution: {integrity: sha512-PVCU2JLondIFkTIuuZAaqnGIfL2H1AvugTQHMim1F+EWXqVvDQRMzlPXkvahLtB/qy5iegYmRSbfr5J8LUY45w==} + cpu: [arm64] + os: [linux] + + '@yuku-parser/binding-linux-x64-gnu@0.5.39': + resolution: {integrity: sha512-gVqZR/I4fKotvK3Wcg17Mvvm75wzB6ySW2UbIIbUKU9CPvEE66wGzu/Q3uHv08EKnM/k4KhB0NrmQR5/d37GYQ==} + cpu: [x64] + os: [linux] + + '@yuku-parser/binding-linux-x64-musl@0.5.39': + resolution: {integrity: sha512-quABxCeXY8Yu6Dljp08kNogcRQz3glGx4/voQ9Ee87kCZge9bzEo3Vfr+H2B/Z5xazR3Rsz65LEUTZNiuf6mcA==} + cpu: [x64] + os: [linux] + + '@yuku-parser/binding-win32-arm64@0.5.39': + resolution: {integrity: sha512-h9qTGty+oxY/4rb3R18IgNfOwh6D8MLKfoIyQ2UJU35AVKksqKjMGGPlenG9eqFm0ohvsxqgs6AMHEdfSyrxxg==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.5.39': + resolution: {integrity: sha512-geGQZsNoBnjJ3EGUphmy/39Ajm3jBkPqjY0qvQVlgpE8V5yUrDc7dVWdcJ5Pkv/UhOMS3FzSgJFQtRFgajbZsA==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.5.37': + resolution: {integrity: sha512-yaGadzsSgTqKXUFef9iUBP7tFXdkN+DWcZqU+MvixYajB3luC8HHCDfJZk/Dy/Hb8haAwJ3z0G9g7bjAG4nGJg==} + '@zeit/schemas@2.36.0': resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==} @@ -5197,6 +5348,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + address@2.0.3: resolution: {integrity: sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==} engines: {node: '>= 16.0.0'} @@ -5234,6 +5390,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.12.0: resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} @@ -5379,6 +5538,11 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} + baseline-browser-mapping@2.10.38: + resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} + engines: {node: '>=6.0.0'} + hasBin: true + baseline-browser-mapping@2.9.18: resolution: {integrity: sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==} hasBin: true @@ -5465,6 +5629,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -5513,6 +5682,9 @@ packages: caniuse-lite@1.0.30001766: resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -5996,6 +6168,9 @@ packages: electron-to-chromium@1.5.279: resolution: {integrity: sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==} + electron-to-chromium@1.5.376: + resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -6031,6 +6206,10 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + enhanced-resolve@5.24.0: + resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} + engines: {node: '>=10.13.0'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -6597,6 +6776,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -6769,6 +6952,10 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-data-view@1.0.2: resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} engines: {node: '>= 0.4'} @@ -7165,8 +7352,8 @@ packages: resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} engines: {node: '>=4'} - loader-runner@4.3.1: - resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} + loader-runner@4.3.2: + resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} engines: {node: '>=6.11.5'} loader-utils@2.0.4: @@ -7461,6 +7648,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-releases@2.0.48: + resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} + engines: {node: '>=18'} + node-schedule@2.1.1: resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==} engines: {node: '>=6'} @@ -7524,8 +7715,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} @@ -7724,6 +7916,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pidtree@0.3.1: resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==} engines: {node: '>=0.10'} @@ -8008,9 +8204,6 @@ packages: resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} engines: {node: '>=0.12'} - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - range-parser@1.2.0: resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} engines: {node: '>= 0.6'} @@ -8281,6 +8474,11 @@ packages: engines: {node: '>= 0.4'} hasBin: true + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true @@ -8337,9 +8535,6 @@ packages: resolution: {integrity: sha512-DCUkRKUBR1lSpHKRcxNvHaYwGrUVf9MsoE1u6gd0CF37I8vwwtWc4b+FA9OwYZ4QA/shslzAYorD3MMfd+Rs/Q==} engines: {node: ^20.19.0 || >=22.12.0} - rspack-plugin-virtual-module@1.0.1: - resolution: {integrity: sha512-NQJ3fXa1v0WayvfHMWbyqLUA3JIqgCkhIcIOnZscuisinxorQyIAo+bqcU5pCusMKSyPqVIWO3caQyl0s9VDAg==} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -8538,17 +8733,14 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - serialize-javascript@6.0.2: - resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} - - seroval-plugins@1.5.0: - resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 - seroval@1.5.0: - resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} engines: {node: '>=10'} serve-handler@6.1.6: @@ -8901,24 +9093,51 @@ packages: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} - terser-webpack-plugin@5.3.16: - resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} + terser-webpack-plugin@5.6.1: + resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} engines: {node: '>= 10.13.0'} peerDependencies: + '@minify-html/node': '*' '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' uglify-js: '*' webpack: ^5.1.0 peerDependenciesMeta: + '@minify-html/node': + optional: true '@swc/core': optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true esbuild: optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true uglify-js: optional: true - terser@5.46.0: - resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==} + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} engines: {node: '>=10'} hasBin: true @@ -8928,20 +9147,24 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} tldts-core@7.0.19: @@ -9074,6 +9297,9 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.18.2: resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} engines: {node: '>=20.18.1'} @@ -9272,8 +9498,8 @@ packages: warning@3.0.0: resolution: {integrity: sha512-jMBt6pUrKn5I+OGgtQ4YZLdhIeJmObddh6CsibPxyQ5yPZm1XExSyzC1LCNX7BzhxWgiHmizBWJTHJIjMjTQYQ==} - watchpack@2.5.1: - resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + watchpack@2.5.2: + resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} wcwidth@1.0.1: @@ -9290,6 +9516,10 @@ packages: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} + webpack-sources@3.5.0: + resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} + engines: {node: '>=10.13.0'} + webpack-virtual-modules@0.5.0: resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} @@ -9458,8 +9688,8 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} yaml@2.7.0: @@ -9501,6 +9731,15 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + yuku-analyzer@0.5.39: + resolution: {integrity: sha512-ifxMHDDo3OM2LVdgN18yMurZYMbZkM51MBjR59rBLd/BOowqkAU3zvlLKWZoH2xW0shVWRjXXlDnfHPvhxG2oA==} + + yuku-codegen@0.5.39: + resolution: {integrity: sha512-SGvKDXn0I7MyQEYpDASCPGXIA6+hv6mhxoABicLa1EPhyKFp9vrfgKiXtrR0QHhlTYwlWPtCa1W2hHHTfysoDQ==} + + yuku-parser@0.5.39: + resolution: {integrity: sha512-uOddu+b5QhhH5+7gOb4mkNG8O4ExOyrG5q0UPuugy7I495eN4fuQlu20fp0F2lx6My/Th01XxtHjpHscIH9UFw==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -9760,6 +9999,9 @@ snapshots: '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.7': + optional: true + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.28.6 @@ -10042,10 +10284,10 @@ snapshots: '@epic-web/client-hints@1.3.8': {} - '@epic-web/config@1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))': + '@epic-web/config@1.21.3(@testing-library/dom@10.4.1)(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))': dependencies: '@total-typescript/ts-reset': 0.6.1 - '@vitest/eslint-plugin': 1.6.6(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + '@vitest/eslint-plugin': 1.6.6(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jest-dom: 5.5.0(@testing-library/dom@10.4.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-playwright: 2.5.1(eslint@9.39.2(jiti@2.6.1)) @@ -10413,7 +10655,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.10.0 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -10575,7 +10817,7 @@ snapshots: - node-fetch - utf-8-validate - '@module-federation/enhanced@2.5.1(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2))': + '@module-federation/enhanced@2.5.1(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15))': dependencies: '@module-federation/bridge-react-webpack-plugin': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/cli': 2.5.1(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3) @@ -10593,7 +10835,7 @@ snapshots: upath: 2.0.1 optionalDependencies: typescript: 5.9.3 - webpack: 5.97.1(esbuild@0.27.2) + webpack: 5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15) transitivePeerDependencies: - '@rspack/core' - bufferutil @@ -10628,16 +10870,16 @@ snapshots: - utf-8-validate - vue-tsc - '@module-federation/node@2.7.44(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2))': + '@module-federation/node@2.7.44(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15))': dependencies: - '@module-federation/enhanced': 2.5.1(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + '@module-federation/enhanced': 2.5.1(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/runtime': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) '@module-federation/sdk': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) encoding: 0.1.13 node-fetch: 2.7.0(encoding@0.1.13) tapable: 2.3.0 optionalDependencies: - webpack: 5.97.1(esbuild@0.27.2) + webpack: 5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15) transitivePeerDependencies: - '@rspack/core' - bufferutil @@ -10645,10 +10887,10 @@ snapshots: - utf-8-validate - vue-tsc - '@module-federation/rsbuild-plugin@2.5.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2))': + '@module-federation/rsbuild-plugin@2.5.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15))': dependencies: - '@module-federation/enhanced': 2.5.1(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) - '@module-federation/node': 2.7.44(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)) + '@module-federation/enhanced': 2.5.1(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(node-fetch@2.7.0(encoding@0.1.13))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) + '@module-federation/node': 2.7.44(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(typescript@5.9.3)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) '@module-federation/sdk': 2.5.1(node-fetch@2.7.0(encoding@0.1.13)) optionalDependencies: '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) @@ -11707,7 +11949,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@react-router/dev@7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0)': + '@react-router/dev@7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0)': dependencies: '@babel/core': 7.28.6 '@babel/generator': 7.28.6 @@ -11737,8 +11979,8 @@ snapshots: semver: 7.7.3 tinyglobby: 0.2.15 valibot: 1.2.0(typescript@5.9.3) - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) - vite-node: 3.2.4(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) + vite-node: 3.2.4(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) optionalDependencies: '@react-router/serve': 7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) typescript: 5.9.3 @@ -11758,7 +12000,7 @@ snapshots: - tsx - yaml - '@react-router/dev@7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.1.0)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0)': + '@react-router/dev@7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.9.4)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0)': dependencies: '@babel/core': 7.28.6 '@babel/generator': 7.28.6 @@ -11788,8 +12030,8 @@ snapshots: semver: 7.7.3 tinyglobby: 0.2.15 valibot: 1.2.0(typescript@5.9.3) - vite: 7.3.1(@types/node@25.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) - vite-node: 3.2.4(@types/node@25.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) + vite-node: 3.2.4(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) optionalDependencies: '@react-router/serve': 7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) typescript: 5.9.3 @@ -11832,9 +12074,9 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@react-router/remix-routes-option-adapter@7.13.0(@react-router/dev@7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3)': + '@react-router/remix-routes-option-adapter@7.13.0(@react-router/dev@7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0))(typescript@5.9.3)': dependencies: - '@react-router/dev': 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) + '@react-router/dev': 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3))(@types/node@25.0.10)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))(wrangler@4.61.0(@cloudflare/workers-types@4.20260127.0))(yaml@2.7.0) optionalDependencies: typescript: 5.9.3 @@ -11999,11 +12241,11 @@ snapshots: optionalDependencies: '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) - '@rsbuild/plugin-less@1.6.4(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2))': + '@rsbuild/plugin-less@1.6.4(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: deepmerge: 4.3.1 less: 4.6.6 - less-loader: 12.3.3(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(less@4.6.6)(webpack@5.97.1(esbuild@0.27.2)) + less-loader: 12.3.3(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(less@4.6.6)(webpack@5.97.1(lightningcss@1.30.2)) reduce-configs: 1.1.2 optionalDependencies: '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) @@ -12032,13 +12274,13 @@ snapshots: '@rsdoctor/client@1.5.13': {} - '@rsdoctor/core@1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2))': + '@rsdoctor/core@1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: '@rsbuild/plugin-check-syntax': 1.6.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)) - '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/sdk': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) + '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/sdk': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) '@rspack/resolver': 0.2.8(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) browserslist-load-config: 1.0.3 es-toolkit: 1.47.1 @@ -12056,10 +12298,10 @@ snapshots: - utf-8-validate - webpack - '@rsdoctor/graph@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2))': + '@rsdoctor/graph@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) es-toolkit: 1.47.1 path-browserify: 1.0.1 source-map: 0.7.6 @@ -12067,13 +12309,13 @@ snapshots: - '@rspack/core' - webpack - '@rsdoctor/rspack-plugin@1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2))': + '@rsdoctor/rspack-plugin@1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: - '@rsdoctor/core': 1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/sdk': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) + '@rsdoctor/core': 1.5.13(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/sdk': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) optionalDependencies: '@rspack/core': 2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) transitivePeerDependencies: @@ -12085,12 +12327,12 @@ snapshots: - utf-8-validate - webpack - '@rsdoctor/sdk@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2))': + '@rsdoctor/sdk@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: '@rsdoctor/client': 1.5.13 - '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) - '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) + '@rsdoctor/graph': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) + '@rsdoctor/utils': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) launch-editor: 2.14.1 safer-buffer: 2.1.2 socket.io: 4.8.1 @@ -12102,7 +12344,7 @@ snapshots: - utf-8-validate - webpack - '@rsdoctor/types@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2))': + '@rsdoctor/types@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: '@types/connect': 3.4.38 '@types/estree': 1.0.5 @@ -12110,12 +12352,12 @@ snapshots: source-map: 0.7.6 optionalDependencies: '@rspack/core': 2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) - webpack: 5.97.1(esbuild@0.27.2) + webpack: 5.97.1(lightningcss@1.30.2) - '@rsdoctor/utils@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2))': + '@rsdoctor/utils@1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2))': dependencies: '@babel/code-frame': 7.26.2 - '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(esbuild@0.27.2)) + '@rsdoctor/types': 1.5.13(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(webpack@5.97.1(lightningcss@1.30.2)) '@types/estree': 1.0.5 acorn: 8.15.0 acorn-import-attributes: 1.9.5(acorn@8.15.0) @@ -12680,7 +12922,7 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-vite@0.4.1(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))': + '@tanstack/devtools-vite@0.4.1(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))': dependencies: '@babel/core': 7.28.6 '@babel/generator': 7.28.6 @@ -12692,7 +12934,7 @@ snapshots: chalk: 5.6.2 launch-editor: 2.12.0 picomatch: 4.0.3 - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) transitivePeerDependencies: - bufferutil - supports-color @@ -12838,7 +13080,7 @@ snapshots: '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/eslint@9.6.1': dependencies: @@ -12849,6 +13091,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/express-serve-static-core@5.1.1': dependencies: '@types/node': 25.0.10 @@ -12895,10 +13139,9 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@25.1.0': + '@types/node@25.9.4': dependencies: - undici-types: 7.16.0 - optional: true + undici-types: 7.24.6 '@types/parse-json@4.0.2': optional: true @@ -13110,7 +13353,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-react@5.1.2(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))': + '@vitejs/plugin-react@5.1.2(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))': dependencies: '@babel/core': 7.28.6 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) @@ -13118,18 +13361,18 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.53 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) transitivePeerDependencies: - supports-color - '@vitest/eslint-plugin@1.6.6(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))': + '@vitest/eslint-plugin@1.6.6(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))': dependencies: '@typescript-eslint/scope-manager': 8.54.0 '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) transitivePeerDependencies: - supports-color @@ -13140,22 +13383,22 @@ snapshots: '@vitest/spy': 4.0.18 '@vitest/utils': 4.0.18 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 optional: true - '@vitest/mocker@4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0))': + '@vitest/mocker@4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.7(@types/node@25.0.10)(typescript@5.9.3) - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) optional: true '@vitest/pretty-format@4.0.18': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 optional: true '@vitest/runner@4.0.18': @@ -13177,7 +13420,7 @@ snapshots: '@vitest/utils@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 optional: true '@web3-storage/multipart-parser@1.0.0': {} @@ -13262,6 +13505,107 @@ snapshots: '@xtuc/long@4.2.2': {} + '@yuku-analyzer/binding-darwin-arm64@0.5.39': + optional: true + + '@yuku-analyzer/binding-darwin-x64@0.5.39': + optional: true + + '@yuku-analyzer/binding-freebsd-x64@0.5.39': + optional: true + + '@yuku-analyzer/binding-linux-arm-gnu@0.5.39': + optional: true + + '@yuku-analyzer/binding-linux-arm-musl@0.5.39': + optional: true + + '@yuku-analyzer/binding-linux-arm64-gnu@0.5.39': + optional: true + + '@yuku-analyzer/binding-linux-arm64-musl@0.5.39': + optional: true + + '@yuku-analyzer/binding-linux-x64-gnu@0.5.39': + optional: true + + '@yuku-analyzer/binding-linux-x64-musl@0.5.39': + optional: true + + '@yuku-analyzer/binding-win32-arm64@0.5.39': + optional: true + + '@yuku-analyzer/binding-win32-x64@0.5.39': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.5.39': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.5.39': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.5.39': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.5.39': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.5.39': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.5.39': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.5.39': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.5.39': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.5.39': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.5.39': + optional: true + + '@yuku-codegen/binding-win32-x64@0.5.39': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.5.39': + optional: true + + '@yuku-parser/binding-darwin-x64@0.5.39': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.5.39': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.5.39': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.5.39': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.5.39': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.5.39': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.5.39': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.5.39': + optional: true + + '@yuku-parser/binding-win32-arm64@0.5.39': + optional: true + + '@yuku-parser/binding-win32-x64@0.5.39': + optional: true + + '@yuku-toolchain/types@0.5.37': {} + '@zeit/schemas@2.36.0': {} accepts@1.3.8: @@ -13288,6 +13632,8 @@ snapshots: acorn@8.15.0: {} + acorn@8.17.0: {} + address@2.0.3: {} adm-zip@0.5.10: {} @@ -13304,9 +13650,9 @@ snapshots: optionalDependencies: ajv: 8.17.1 - ajv-keywords@3.5.2(ajv@6.12.6): + ajv-keywords@3.5.2(ajv@6.15.0): dependencies: - ajv: 6.12.6 + ajv: 6.15.0 ajv-keywords@5.1.0(ajv@8.17.1): dependencies: @@ -13320,6 +13666,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ajv@8.12.0: dependencies: fast-deep-equal: 3.1.3 @@ -13476,9 +13829,9 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 cosmiconfig: 7.1.0 - resolve: 1.22.11 + resolve: 1.22.12 optional: true balanced-match@1.0.2: {} @@ -13493,6 +13846,8 @@ snapshots: base64id@2.0.0: {} + baseline-browser-mapping@2.10.38: {} + baseline-browser-mapping@2.9.18: {} basic-auth@2.0.1: @@ -13611,6 +13966,14 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.38 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.376 + node-releases: 2.0.48 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-from@1.1.2: {} buffer@5.7.1: @@ -13654,6 +14017,8 @@ snapshots: caniuse-lite@1.0.30001766: {} + caniuse-lite@1.0.30001799: {} + chai@6.2.2: optional: true @@ -13841,7 +14206,7 @@ snapshots: import-fresh: 3.3.1 parse-json: 5.2.0 path-type: 4.0.0 - yaml: 1.10.2 + yaml: 1.10.3 optional: true cosmiconfig@8.3.6(typescript@5.9.3): @@ -14094,6 +14459,8 @@ snapshots: electron-to-chromium@1.5.279: {} + electron-to-chromium@1.5.376: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -14135,6 +14502,11 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + enhanced-resolve@5.24.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -14340,8 +14712,8 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 1.22.11 + is-core-module: 2.16.2 + resolve: 1.22.12 transitivePeerDependencies: - supports-color optional: true @@ -14489,7 +14861,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 optional: true esutils@2.0.3: {} @@ -14650,6 +15022,11 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + optional: true + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -14925,6 +15302,11 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + optional: true + he@1.2.0: {} headers-polyfill@4.0.3: {} @@ -15099,6 +15481,11 @@ snapshots: dependencies: hasown: 2.0.2 + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + optional: true + is-data-view@1.0.2: dependencies: call-bound: 1.0.4 @@ -15264,7 +15651,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 25.0.10 + '@types/node': 25.9.4 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -15303,7 +15690,7 @@ snapshots: webidl-conversions: 8.0.1 whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 - ws: 8.19.0 + ws: 8.21.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -15377,12 +15764,12 @@ snapshots: leac@0.6.0: {} - less-loader@12.3.3(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(less@4.6.6)(webpack@5.97.1(esbuild@0.27.2)): + less-loader@12.3.3(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(less@4.6.6)(webpack@5.97.1(lightningcss@1.30.2)): dependencies: less: 4.6.6 optionalDependencies: '@rspack/core': 2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) - webpack: 5.97.1(esbuild@0.27.2) + webpack: 5.97.1(lightningcss@1.30.2) less@4.6.6: dependencies: @@ -15466,7 +15853,7 @@ snapshots: pify: 3.0.0 strip-bom: 3.0.0 - loader-runner@4.3.1: {} + loader-runner@4.3.2: {} loader-utils@2.0.4: dependencies: @@ -15717,6 +16104,8 @@ snapshots: node-releases@2.0.27: {} + node-releases@2.0.48: {} + node-schedule@2.1.1: dependencies: cron-parser: 4.9.0 @@ -15802,7 +16191,7 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - obug@2.1.1: + obug@2.1.3: optional: true on-finished@2.3.0: @@ -15985,6 +16374,9 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: + optional: true + pidtree@0.3.1: {} pify@3.0.0: {} @@ -16151,10 +16543,6 @@ snapshots: discontinuous-range: 1.0.0 ret: 0.1.15 - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 - range-parser@1.2.0: {} range-parser@1.2.1: {} @@ -16233,7 +16621,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 - react-router-devtools@6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)): + react-router-devtools@6.2.0(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)): dependencies: '@babel/core': 7.28.6 '@babel/generator': 7.28.6 @@ -16243,7 +16631,7 @@ snapshots: '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/devtools-client': 0.0.5 '@tanstack/devtools-event-client': 0.4.0 - '@tanstack/devtools-vite': 0.4.1(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + '@tanstack/devtools-vite': 0.4.1(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) '@tanstack/react-devtools': 0.9.3(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11) '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) @@ -16257,7 +16645,7 @@ snapshots: react-hotkeys-hook: 5.2.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-router: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-tooltip: 5.30.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) optionalDependencies: '@biomejs/cli-darwin-arm64': 2.3.13 '@rollup/rollup-darwin-arm64': 4.57.0 @@ -16436,6 +16824,14 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + optional: true + resolve@1.22.8: dependencies: is-core-module: 2.16.1 @@ -16513,10 +16909,6 @@ snapshots: rslog@2.1.3: {} - rspack-plugin-virtual-module@1.0.1: - dependencies: - fs-extra: 11.3.3 - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -16658,8 +17050,8 @@ snapshots: schema-utils@3.3.0: dependencies: '@types/json-schema': 7.0.15 - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) + ajv: 6.15.0 + ajv-keywords: 3.5.2(ajv@6.15.0) schema-utils@4.3.0: dependencies: @@ -16723,15 +17115,11 @@ snapshots: transitivePeerDependencies: - supports-color - serialize-javascript@6.0.2: - dependencies: - randombytes: 2.1.0 - - seroval-plugins@1.5.0(seroval@1.5.0): + seroval-plugins@1.5.4(seroval@1.5.4): dependencies: - seroval: 1.5.0 + seroval: 1.5.4 - seroval@1.5.0: {} + seroval@1.5.4: {} serve-handler@6.1.6: dependencies: @@ -16811,7 +17199,7 @@ snapshots: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 - semver: 7.7.3 + semver: 7.8.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -16936,8 +17324,8 @@ snapshots: solid-js@1.9.11: dependencies: csstype: 3.2.3 - seroval: 1.5.0 - seroval-plugins: 1.5.0(seroval@1.5.0) + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: @@ -17008,10 +17396,10 @@ snapshots: strict-event-emitter@0.5.1: {} - string-replace-loader@3.3.0(webpack@5.97.1(esbuild@0.27.2)): + string-replace-loader@3.3.0(webpack@5.97.1(lightningcss@1.30.2)): dependencies: schema-utils: 4.3.3 - webpack: 5.97.1(esbuild@0.27.2) + webpack: 5.97.1(lightningcss@1.30.2) string-width@4.2.3: dependencies: @@ -17165,21 +17553,33 @@ snapshots: term-size@2.2.1: {} - terser-webpack-plugin@5.3.16(esbuild@0.27.2)(webpack@5.97.1(esbuild@0.27.2)): + terser-webpack-plugin@5.6.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 - serialize-javascript: 6.0.2 - terser: 5.46.0 - webpack: 5.97.1(esbuild@0.27.2) + terser: 5.48.0 + webpack: 5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15) optionalDependencies: esbuild: 0.27.2 + lightningcss: 1.30.2 + postcss: 8.5.15 + optional: true - terser@5.46.0: + terser-webpack-plugin@5.6.1(lightningcss@1.30.2)(webpack@5.97.1(lightningcss@1.30.2)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.48.0 + webpack: 5.97.1(lightningcss@1.30.2) + optionalDependencies: + lightningcss: 1.30.2 + + terser@5.48.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.17.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -17188,7 +17588,7 @@ snapshots: tinybench@2.9.0: optional: true - tinyexec@1.0.2: + tinyexec@1.2.4: optional: true tinyglobby@0.2.15: @@ -17196,9 +17596,15 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + optional: true + tinypool@1.1.1: {} - tinyrainbow@3.0.3: + tinyrainbow@3.1.0: optional: true tldts-core@7.0.19: {} @@ -17332,6 +17738,8 @@ snapshots: undici-types@7.16.0: {} + undici-types@7.24.6: {} + undici@7.18.2: {} undici@7.24.7: {} @@ -17389,6 +17797,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + update-check@1.5.4: dependencies: registry-auth-token: 3.3.2 @@ -17432,7 +17846,7 @@ snapshots: vary@1.1.2: {} - vite-env-only@3.0.3(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)): + vite-env-only@3.0.3(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)): dependencies: '@babel/core': 7.28.6 '@babel/generator': 7.28.6 @@ -17441,17 +17855,17 @@ snapshots: '@babel/types': 7.28.6 babel-dead-code-elimination: 1.0.12 micromatch: 4.0.8 - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) transitivePeerDependencies: - supports-color - vite-node@3.2.4(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0): + vite-node@3.2.4(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) transitivePeerDependencies: - '@types/node' - jiti @@ -17466,13 +17880,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@25.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0): + vite-node@3.2.4(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) transitivePeerDependencies: - '@types/node' - jiti @@ -17487,17 +17901,17 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)): + vite-tsconfig-paths@6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) transitivePeerDependencies: - supports-color - typescript - vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0): + vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -17513,11 +17927,11 @@ snapshots: lightningcss: 1.30.2 sass: 1.100.0 sass-embedded: 1.100.0 - terser: 5.46.0 + terser: 5.48.0 tsx: 4.21.0 yaml: 2.7.0 - vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0): + vite@7.3.1(@types/node@25.9.4)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -17526,21 +17940,21 @@ snapshots: rollup: 4.57.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.1.0 + '@types/node': 25.9.4 fsevents: 2.3.3 jiti: 2.6.1 less: 4.6.6 lightningcss: 1.30.2 sass: 1.100.0 sass-embedded: 1.100.0 - terser: 5.46.0 + terser: 5.48.0 tsx: 4.21.0 yaml: 2.7.0 - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.6.6)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0)) + '@vitest/mocker': 4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -17549,15 +17963,15 @@ snapshots: es-module-lexer: 1.7.0 expect-type: 1.3.0 magic-string: 0.30.21 - obug: 2.1.1 + obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.7.0) + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.21.0)(yaml@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -17585,9 +17999,8 @@ snapshots: dependencies: loose-envify: 1.4.0 - watchpack@2.5.1: + watchpack@2.5.2: dependencies: - glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 wcwidth@1.0.1: @@ -17600,36 +18013,87 @@ snapshots: webpack-sources@3.3.3: {} + webpack-sources@3.5.0: {} + webpack-virtual-modules@0.5.0: {} - webpack@5.97.1(esbuild@0.27.2): + webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15): dependencies: '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - browserslist: 4.28.1 + acorn: 8.17.0 + browserslist: 4.28.2 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.4 + enhanced-resolve: 5.24.0 es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.1 + loader-runner: 4.3.2 mime-types: 2.1.35 neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.3.3 - terser-webpack-plugin: 5.3.16(esbuild@0.27.2)(webpack@5.97.1(esbuild@0.27.2)) - watchpack: 2.5.1 - webpack-sources: 3.3.3 + terser-webpack-plugin: 5.6.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)(webpack@5.97.1(esbuild@0.27.2)(lightningcss@1.30.2)(postcss@8.5.15)) + watchpack: 2.5.2 + webpack-sources: 3.5.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + optional: true + + webpack@5.97.1(lightningcss@1.30.2): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.9 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.17.0 + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.24.0 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.2 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.1(lightningcss@1.30.2)(webpack@5.97.1(lightningcss@1.30.2)) + watchpack: 2.5.2 + webpack-sources: 3.5.0 transitivePeerDependencies: + - '@minify-html/node' - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso - esbuild + - html-minifier-terser + - lightningcss + - postcss - uglify-js whatwg-mimetype@4.0.0: {} @@ -17774,7 +18238,7 @@ snapshots: yallist@3.1.1: {} - yaml@1.10.2: + yaml@1.10.3: optional: true yaml@2.7.0: @@ -17830,4 +18294,52 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 + yuku-analyzer@0.5.39: + dependencies: + '@yuku-toolchain/types': 0.5.37 + optionalDependencies: + '@yuku-analyzer/binding-darwin-arm64': 0.5.39 + '@yuku-analyzer/binding-darwin-x64': 0.5.39 + '@yuku-analyzer/binding-freebsd-x64': 0.5.39 + '@yuku-analyzer/binding-linux-arm-gnu': 0.5.39 + '@yuku-analyzer/binding-linux-arm-musl': 0.5.39 + '@yuku-analyzer/binding-linux-arm64-gnu': 0.5.39 + '@yuku-analyzer/binding-linux-arm64-musl': 0.5.39 + '@yuku-analyzer/binding-linux-x64-gnu': 0.5.39 + '@yuku-analyzer/binding-linux-x64-musl': 0.5.39 + '@yuku-analyzer/binding-win32-arm64': 0.5.39 + '@yuku-analyzer/binding-win32-x64': 0.5.39 + + yuku-codegen@0.5.39: + dependencies: + '@yuku-toolchain/types': 0.5.37 + optionalDependencies: + '@yuku-codegen/binding-darwin-arm64': 0.5.39 + '@yuku-codegen/binding-darwin-x64': 0.5.39 + '@yuku-codegen/binding-freebsd-x64': 0.5.39 + '@yuku-codegen/binding-linux-arm-gnu': 0.5.39 + '@yuku-codegen/binding-linux-arm-musl': 0.5.39 + '@yuku-codegen/binding-linux-arm64-gnu': 0.5.39 + '@yuku-codegen/binding-linux-arm64-musl': 0.5.39 + '@yuku-codegen/binding-linux-x64-gnu': 0.5.39 + '@yuku-codegen/binding-linux-x64-musl': 0.5.39 + '@yuku-codegen/binding-win32-arm64': 0.5.39 + '@yuku-codegen/binding-win32-x64': 0.5.39 + + yuku-parser@0.5.39: + dependencies: + '@yuku-toolchain/types': 0.5.37 + optionalDependencies: + '@yuku-parser/binding-darwin-arm64': 0.5.39 + '@yuku-parser/binding-darwin-x64': 0.5.39 + '@yuku-parser/binding-freebsd-x64': 0.5.39 + '@yuku-parser/binding-linux-arm-gnu': 0.5.39 + '@yuku-parser/binding-linux-arm-musl': 0.5.39 + '@yuku-parser/binding-linux-arm64-gnu': 0.5.39 + '@yuku-parser/binding-linux-arm64-musl': 0.5.39 + '@yuku-parser/binding-linux-x64-gnu': 0.5.39 + '@yuku-parser/binding-linux-x64-musl': 0.5.39 + '@yuku-parser/binding-win32-arm64': 0.5.39 + '@yuku-parser/binding-win32-x64': 0.5.39 + zod@3.25.76: {} diff --git a/rslib.config.ts b/rslib.config.ts index 2966d22..f00f09c 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -8,6 +8,8 @@ const config = defineConfig({ source: { entry: { index: './src/index.ts', + 'parallel-route-transform-worker': + './src/parallel-route-transform-worker.ts', 'templates/entry.server': './src/templates/entry.server.tsx', 'templates/entry.client': './src/templates/entry.client.tsx', }, diff --git a/scripts/bench-builds.mjs b/scripts/bench-builds.mjs index 173e40a..265d76b 100644 --- a/scripts/bench-builds.mjs +++ b/scripts/bench-builds.mjs @@ -201,6 +201,26 @@ const parseTimeStats = stderr => { }; }; +const parsePluginReports = output => { + const reports = []; + for (const line of output.split(/\r?\n/)) { + const markerIndex = line.indexOf('[react-router:performance]'); + if (markerIndex === -1) { + continue; + } + const jsonStart = line.indexOf('{', markerIndex); + if (jsonStart === -1) { + continue; + } + try { + reports.push(JSON.parse(line.slice(jsonStart))); + } catch { + // Keep raw build output useful even if one line is malformed. + } + } + return reports; +}; + const summarizeMetric = values => { const sorted = values .filter(value => typeof value === 'number') @@ -231,8 +251,49 @@ const summarizeRuns = runs => ({ maxRssKb: summarizeMetric(runs.map(run => run.maxRssKb)), }); +const summarizePluginOperations = runs => { + const operations = new Map(); + + for (const run of runs) { + for (const report of run.pluginReports) { + for (const [operation, metrics] of Object.entries( + report.operations ?? {} + )) { + const key = `${report.environment}:${operation}`; + const current = operations.get(key) ?? { + environment: report.environment, + operation, + count: 0, + totalMs: 0, + wallMs: null, + maxMs: 0, + reports: 0, + }; + current.count += metrics.count ?? 0; + current.totalMs += metrics.totalMs ?? 0; + if (typeof metrics.wallMs === 'number') { + current.wallMs = (current.wallMs ?? 0) + metrics.wallMs; + } + current.maxMs = Math.max(current.maxMs, metrics.maxMs ?? 0); + current.reports += 1; + operations.set(key, current); + } + } + } + + return [...operations.values()].sort((a, b) => { + if (b.totalMs !== a.totalMs) { + return b.totalMs - a.totalMs; + } + return `${a.environment}:${a.operation}`.localeCompare( + `${b.environment}:${b.operation}` + ); + }); +}; + const formatMs = value => value == null ? '-' : `${(value / 1000).toFixed(2)}s`; +const formatReportMs = value => (value == null ? '-' : `${value.toFixed(1)}ms`); const formatRss = value => value == null ? '-' : `${Math.round(value / 1024)} MB`; @@ -254,8 +315,8 @@ const renderMarkdown = result => { ? [`- Rspack trace output: ${result.rspackTraceOutput}`] : []), '', - '| Benchmark | Routes | Variant | Median wall | Mean wall | p95 wall | Max RSS |', - '|---|---:|---|---:|---:|---:|---:|', + '| Benchmark | Routes | Variant | Median wall | Mean wall | p95 wall | Max RSS | Plugin reports |', + '|---|---:|---|---:|---:|---:|---:|---:|', ]; for (const benchmark of result.benchmarks) { @@ -268,6 +329,7 @@ const renderMarkdown = result => { formatMs(benchmark.summary.wallMs.mean), formatMs(benchmark.summary.wallMs.p95), formatRss(benchmark.summary.maxRssKb.p95), + benchmark.runs.reduce((sum, run) => sum + run.pluginReports.length, 0), ] .join(' | ') .replace(/^/, '| ') @@ -275,6 +337,37 @@ const renderMarkdown = result => { ); } + for (const benchmark of result.benchmarks) { + if (benchmark.pluginOperations.length === 0) { + continue; + } + lines.push( + '', + `## ${benchmark.id} Plugin Operations`, + '', + 'Total is the sum of all measured operation durations. Wall merges overlapping intervals to approximate elapsed plugin time. Max is the slowest single operation call.', + '', + '| Environment | Operation | Count | Total | Wall | Max | Reports |', + '|---|---|---:|---:|---:|---:|---:|' + ); + for (const operation of benchmark.pluginOperations.slice(0, 12)) { + lines.push( + [ + operation.environment, + operation.operation, + operation.count, + formatReportMs(operation.totalMs), + formatReportMs(operation.wallMs), + formatReportMs(operation.maxMs), + operation.reports, + ] + .join(' | ') + .replace(/^/, '| ') + .replace(/$/, ' |') + ); + } + } + lines.push(''); return `${lines.join('\n')}\n`; }; @@ -443,9 +536,6 @@ const main = async () => { const pluginImportPath = pathToFileURL( path.join(rootDir, 'dist/index.js') ).href; - const pluginReactImportPath = - process.env.REACT_ROUTER_BENCHMARK_PLUGIN_REACT_IMPORT ?? - '@rsbuild/plugin-react'; const selectedBenchmarks = profiles[args.profile].filter(benchmark => args.filter ? benchmark.id.includes(args.filter) : true ); @@ -475,7 +565,6 @@ const main = async () => { variant: benchmark.variant, sourceMap: benchmark.sourceMap ?? false, pluginImportPath, - pluginReactImportPath, parallelTransforms: args.parallelTransforms, }); @@ -514,6 +603,7 @@ const main = async () => { cwd: fixtureRoot, env: { NODE_ENV: 'production', + REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE: '1', ...(args.rspackProfile ? { RSPACK_PROFILE: args.rspackProfile } : {}), ...(rspackTraceOutput ? { RSPACK_TRACE_OUTPUT: rspackTraceOutput } @@ -534,6 +624,10 @@ const main = async () => { }) : []; const timeStats = useTime ? parseTimeStats(commandResult.stderr) : {}; + const pluginReports = parsePluginReports( + `${commandResult.stdout}\n${commandResult.stderr}` + ); + if (commandResult.status !== 0 && args.failFast) { process.exit(commandResult.status ?? 1); } @@ -545,6 +639,7 @@ const main = async () => { userMs: timeStats.userMs ?? null, sysMs: timeStats.sysMs ?? null, maxRssKb: timeStats.maxRssKb ?? null, + pluginReports, rspackProfiles, rspackTraceOutput: rspackTraceOutput && !isTraceOutputStream(rspackTraceOutput) @@ -562,6 +657,7 @@ const main = async () => { 'node /node_modules/@rsbuild/core/bin/rsbuild.js build --config rsbuild.config.mjs', runs, summary: summarizeRuns(runs), + pluginOperations: summarizePluginOperations(runs), }); } diff --git a/scripts/benchmark/fixture.mjs b/scripts/benchmark/fixture.mjs index b132449..d3c94e3 100644 --- a/scripts/benchmark/fixture.mjs +++ b/scripts/benchmark/fixture.mjs @@ -237,7 +237,6 @@ const createRsbuildConfig = ({ variant, sourceMap, pluginImportPath, - pluginReactImportPath, parallelTransforms, }) => { const ssr = variant !== 'spa'; @@ -249,7 +248,7 @@ const createRsbuildConfig = ({ return [ `import { defineConfig } from '@rsbuild/core';`, - `import { pluginReact } from '${pluginReactImportPath}';`, + `import { pluginReact } from '@rsbuild/plugin-react';`, `import { pluginReactRouter } from '${pluginImportPath}';`, '', 'export default defineConfig({', @@ -259,6 +258,7 @@ const createRsbuildConfig = ({ ...(ssr ? [` serverOutput: 'module',`] : []), ...renderParallelTransformsOption(parallelTransforms), lazyCompilationOption, + ` logPerformance: process.env.REACT_ROUTER_BENCHMARK_LOG_PERFORMANCE === '1',`, ' }),', ' ],', ' output: {', @@ -361,7 +361,6 @@ export async function generateSyntheticFixture({ variant, sourceMap = false, pluginImportPath = 'rsbuild-plugin-react-router', - pluginReactImportPath = '@rsbuild/plugin-react', fixture = 'default', parallelTransforms, }) { @@ -386,7 +385,6 @@ export async function generateSyntheticFixture({ variant, sourceMap, pluginImportPath, - pluginReactImportPath, parallelTransforms, }) ); diff --git a/scripts/compare-benchmarks.mjs b/scripts/compare-benchmarks.mjs index 89e663f..31192ac 100644 --- a/scripts/compare-benchmarks.mjs +++ b/scripts/compare-benchmarks.mjs @@ -9,18 +9,23 @@ const { values } = parseArgs({ before: { type: 'string' }, after: { type: 'string' }, benchmark: { type: 'string', default: 'synthetic-256-ssr-esm-split' }, + operations: { + type: 'string', + default: 'route:chunk,route:client-entry,route:split-exports', + }, }, }); if (!values.before || !values.after) { throw new Error( - 'Usage: node scripts/compare-benchmarks.mjs --before --after [--benchmark ]' + 'Usage: node scripts/compare-benchmarks.mjs --before --after [--benchmark ] [--operations op,op]' ); } const readJson = async file => JSON.parse(await readFile(file, 'utf8')); const before = await readJson(values.before); const after = await readJson(values.after); +const operations = new Set(values.operations.split(',').filter(Boolean)); const findBenchmark = (result, id) => { const benchmark = result.benchmarks?.find(item => item.id === id); @@ -35,6 +40,19 @@ const findBenchmark = (result, id) => { const metric = (benchmark, path) => path.split('.').reduce((value, key) => value?.[key], benchmark); +const operationMetric = (benchmark, operation, key) => { + const matches = + benchmark.pluginOperations?.filter(item => item.operation === operation) ?? + []; + const values = matches + .map(item => item[key]) + .filter(value => typeof value === 'number'); + if (values.length === 0) { + return null; + } + return values.reduce((sum, value) => sum + value, 0); +}; + const percentDelta = (beforeValue, afterValue) => { if (beforeValue == null || afterValue == null || beforeValue === 0) { return '-'; @@ -42,6 +60,7 @@ const percentDelta = (beforeValue, afterValue) => { return `${(((afterValue - beforeValue) / beforeValue) * 100).toFixed(1)}%`; }; +const formatNumber = value => (value == null ? '-' : value.toFixed(1)); const formatMs = value => value == null ? '-' : `${(value / 1000).toFixed(2)}s`; const formatKb = value => @@ -81,6 +100,23 @@ const rows = [ }, ]; +for (const operation of operations) { + rows.push( + { + label: `${operation} totalMs`, + before: operationMetric(beforeBenchmark, operation, 'totalMs'), + after: operationMetric(afterBenchmark, operation, 'totalMs'), + format: formatNumber, + }, + { + label: `${operation} wallMs`, + before: operationMetric(beforeBenchmark, operation, 'wallMs'), + after: operationMetric(afterBenchmark, operation, 'wallMs'), + format: formatNumber, + } + ); +} + console.log(`Benchmark comparison: ${values.benchmark}`); console.log(''); console.log('| Metric | Before | After | Delta |'); diff --git a/src/babel.ts b/src/babel.ts index 4c52d9c..d14a254 100644 --- a/src/babel.ts +++ b/src/babel.ts @@ -1,18 +1,63 @@ -import type { types as Babel } from '@babel/core'; -import generatorPkg from '@babel/generator'; -import { type ParseResult, parse } from '@babel/parser'; -/* eslint-disable @typescript-eslint/consistent-type-imports */ -import type { NodePath } from '@babel/traverse'; -import traversePkg from '@babel/traverse'; -import * as t from '@babel/types'; +import { + parse as yukuParse, + walk, + type ParseOptions, + type ParseResult, +} from 'yuku-parser'; +import type { Rspack } from '@rsbuild/core'; +import { strip } from 'yuku-codegen'; -// Babel packages are CommonJS. Depending on the bundler/runtime interop mode, -// their "default" may either be the exported function or a module namespace. -// We normalize to always get the callable function. -const traverse: typeof import('@babel/traverse').default = - (traversePkg as any).default ?? (traversePkg as any); -const generate: typeof import('@babel/generator').default = - (generatorPkg as any).default ?? (generatorPkg as any); +export const parse = ( + code: string, + options: ParseOptions = {} +): ParseResult => { + const result = yukuParse(code, { + sourceType: options.sourceType ?? 'module', + lang: options.lang ?? 'tsx', + }); + const errors = result.diagnostics.filter( + diagnostic => diagnostic.severity === 'error' + ); + if (errors.length > 0) { + throw new Error(errors.map(error => error.message).join('\n')); + } + return result; +}; -export { traverse, generate, parse, t }; -export type { Babel, NodePath, ParseResult }; +export const traverse: typeof walk = walk; + +export const generate = ( + ast: ParseResult | { type: 'Program' }, + options: { + sourceMaps?: boolean; + filename?: string; + sourceFileName?: string; + } = {} +): { code: string; map: Rspack.RawSourceMap | null } => { + const result = 'program' in ast ? ast : { program: ast, lineStarts: [] }; + const generated = strip(result.program as Parameters[0], { + comments: 'some', + sourceMaps: options.sourceMaps + ? { + lineStarts: result.lineStarts, + file: options.filename, + sourceFileName: options.sourceFileName, + } + : undefined, + }); + const map = generated.map + ? { + ...generated.map, + file: generated.map.file ?? options.filename ?? '', + sourceRoot: generated.map.sourceRoot ?? undefined, + sourcesContent: + generated.map.sourcesContent?.map(source => source ?? '') ?? + undefined, + } + : null; + + return { code: generated.code, map }; +}; + +export const t = {}; +export type { ParseResult }; diff --git a/src/bounded-cache.ts b/src/bounded-cache.ts new file mode 100644 index 0000000..b7d6df0 --- /dev/null +++ b/src/bounded-cache.ts @@ -0,0 +1,14 @@ +export const setBoundedCacheEntry = ( + cache: Map, + key: Key, + value: Value, + maxEntries: number +): void => { + if (!cache.has(key) && cache.size >= maxEntries) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } + } + cache.set(key, value); +}; diff --git a/src/concurrency.ts b/src/concurrency.ts new file mode 100644 index 0000000..a6cb619 --- /dev/null +++ b/src/concurrency.ts @@ -0,0 +1,12 @@ +import { availableParallelism, cpus } from 'node:os'; + +const DEFAULT_RESERVED_CORES = 2; + +const getAvailableCpuCount = (): number => + typeof availableParallelism === 'function' + ? availableParallelism() + : cpus().length; + +export const getDefaultConcurrency = ( + cpuCount: number = getAvailableCpuCount() +): number => Math.max(0, Math.floor(cpuCount) - DEFAULT_RESERVED_CORES); diff --git a/src/constants.ts b/src/constants.ts index 12c1732..7af426d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -28,6 +28,10 @@ export const SERVER_ONLY_ROUTE_EXPORTS = [ 'headers', ] as const; +export const SERVER_ONLY_ROUTE_EXPORTS_SET: ReadonlySet = new Set( + SERVER_ONLY_ROUTE_EXPORTS +); + // Client route exports are split into non-component exports and component exports. // This mirrors upstream React Router Vite plugin intent and is used for export filtering. export const CLIENT_NON_COMPONENT_EXPORTS = [ @@ -52,11 +56,19 @@ export const CLIENT_ROUTE_EXPORTS: readonly ( | (typeof CLIENT_COMPONENT_EXPORTS)[number] )[] = [...CLIENT_NON_COMPONENT_EXPORTS, ...CLIENT_COMPONENT_EXPORTS]; +export const CLIENT_ROUTE_EXPORTS_SET: ReadonlySet = new Set( + CLIENT_ROUTE_EXPORTS +); + export const NAMED_COMPONENT_EXPORTS = [ 'HydrateFallback', 'ErrorBoundary', ] as const; +export const NAMED_COMPONENT_EXPORTS_SET: ReadonlySet = new Set( + NAMED_COMPONENT_EXPORTS +); + export const SERVER_EXPORTS = { loader: 'loader', action: 'action', diff --git a/src/export-utils.ts b/src/export-utils.ts index d5f67d4..7dac8e9 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -1,64 +1,411 @@ -import { readFile } from 'node:fs/promises'; -import { extname } from 'pathe'; -import * as esbuild from 'esbuild'; -import { init, parse as parseExports } from 'es-module-lexer'; -import { JS_LOADERS } from './constants.js'; +import { readFile, stat } from 'node:fs/promises'; +import { strip } from 'yuku-codegen'; +import { langFromPath, parse } from 'yuku-parser'; +import { setBoundedCacheEntry } from './bounded-cache.js'; +import { + detectRouteChunksIfEnabled, + type RouteChunkCache, + type RouteChunkConfig, + type RouteChunkInfo, +} from './route-chunks.js'; -const getEsbuildLoader = (resourcePath: string): esbuild.Loader => { - const ext = extname(resourcePath) as keyof typeof JS_LOADERS; - return JS_LOADERS[ext] ?? 'js'; +type TransformCacheEntry = { + source: string; + transformed: Promise; }; -export const transformToEsm = async ( - code: string, - resourcePath: string -): Promise => { - return ( - await esbuild.transform(code, { - jsx: 'automatic', - format: 'esm', - platform: 'neutral', - loader: getEsbuildLoader(resourcePath), - }) - ).code; -}; - -export const getExportNames = async (code: string): Promise => { - await init; - const [, exportSpecifiers] = await parseExports(code); - return Array.from( - new Set(exportSpecifiers.map(specifier => specifier.n).filter(Boolean)) +type ExportInfo = { + readonly exportNames: readonly string[]; + readonly exportAllModules: readonly string[]; +}; + +type TransformedModule = ExportInfo & { + readonly code: string; +}; + +export type BundlerRouteAnalysis = TransformedModule & { + getRouteChunkInfo: ( + cache: RouteChunkCache | undefined, + config: RouteChunkConfig + ) => Promise; +}; + +type BundlerRouteAnalysisCacheEntry = { + source: string; + analysis: Promise; +}; + +type RouteModuleAnalysis = { + readonly code: string; + readonly exports: readonly string[]; + readonly exportAllModules: readonly string[]; +}; + +type RouteModuleAnalysisCacheEntry = { + mtimeMs: number; + size: number; + analysis: Promise; +}; + +const transformCache = new Map(); +const exportInfoCache = new Map>(); +const bundlerRouteAnalysisCache = new Map< + string, + BundlerRouteAnalysisCacheEntry +>(); +const routeModuleAnalysisCache = new Map< + string, + RouteModuleAnalysisCacheEntry +>(); + +const MAX_EXPORT_UTILS_CACHE_ENTRIES = 2048; + +type AnyNode = Record; + +const cachePromiseOnReject = ( + promise: Promise, + invalidate: () => void +): Promise => + promise.catch(error => { + invalidate(); + throw error; + }); + +const getRouteChunkConfigCacheKey = (config: RouteChunkConfig) => + `${String(config.splitRouteModules ?? false)}\0${config.appDirectory}\0${config.rootRouteFile}`; + +const parseProgram = (code: string, resourcePath?: string) => { + const result = parse(code, { + sourceType: 'module', + lang: resourcePath ? langFromPath(resourcePath) : 'tsx', + }); + const errors = result.diagnostics.filter( + diagnostic => diagnostic.severity === 'error' ); + if (errors.length > 0) { + throw new Error(errors.map(error => error.message).join('\n')); + } + return result.program; }; -export const getExportNamesAndExportAll = async ( - code: string -): Promise<{ exportNames: string[]; exportAllModules: string[] }> => { - await init; - const [imports, exportSpecifiers] = await parseExports(code); +const getIdentifierNamesFromPattern = ( + pattern: AnyNode | null | undefined, + names: string[] = [] +): string[] => { + if (!pattern) { + return names; + } + if (pattern.type === 'Identifier') { + names.push(pattern.name); + return names; + } + if (pattern.type === 'RestElement') { + return getIdentifierNamesFromPattern(pattern.argument, names); + } + if (pattern.type === 'AssignmentPattern') { + return getIdentifierNamesFromPattern(pattern.left, names); + } + if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements ?? []) { + getIdentifierNamesFromPattern(element, names); + } + return names; + } + if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties ?? []) { + if (property.type === 'RestElement') { + getIdentifierNamesFromPattern(property.argument, names); + } else { + getIdentifierNamesFromPattern(property.value, names); + } + } + } + return names; +}; + +const getExportedName = (node: AnyNode): string | null => { + if (!node) { + return null; + } + if (node.type === 'Identifier') { + return node.name; + } + if (node.type === 'Literal' || node.type === 'StringLiteral') { + return String(node.value); + } + return null; +}; + +const isTypeOnlyExport = (node: AnyNode): boolean => + node.exportKind === 'type' || + node.type === 'TSExportAssignment' || + (node.type === 'ExportDefaultDeclaration' && + node.declaration?.type === 'TSInterfaceDeclaration'); + +export const collectProgramExportNames = (program: AnyNode): string[] => { const exportNames = new Set(); - for (const specifier of exportSpecifiers) { - if (specifier.n) { - exportNames.add(specifier.n); + for (const statement of program.body ?? []) { + if (isTypeOnlyExport(statement)) { + continue; + } + + if (statement.type === 'ExportAllDeclaration') { + const exported = getExportedName(statement.exported); + if (exported) { + exportNames.add(exported); + } + continue; + } + + if (statement.type === 'ExportDefaultDeclaration') { + exportNames.add('default'); + continue; + } + + if (statement.type !== 'ExportNamedDeclaration') { + continue; + } + const declaration = statement.declaration; + if (declaration) { + if (declaration.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations ?? []) { + for (const name of getIdentifierNamesFromPattern(declarator.id)) { + exportNames.add(name); + } + } + } else if ( + (declaration.type === 'FunctionDeclaration' || + declaration.type === 'ClassDeclaration' || + declaration.type === 'TSEnumDeclaration') && + declaration.id?.name + ) { + exportNames.add(declaration.id.name); + } + continue; + } + + for (const specifier of statement.specifiers ?? []) { + if (specifier.exportKind === 'type') { + continue; + } + const exported = getExportedName(specifier.exported); + if (exported) { + exportNames.add(exported); + } } } - const exportAllModules: string[] = []; - for (const entry of imports) { - if (!entry.n) { + return Array.from(exportNames); +}; + +const collectExportAllModules = (program: AnyNode): string[] => { + const modules: string[] = []; + for (const statement of program.body ?? []) { + if ( + statement.type !== 'ExportAllDeclaration' || + isTypeOnlyExport(statement) + ) { continue; } - const statement = code.slice(entry.ss, entry.se); - if (/^\s*export\s*\*\s*from\s*['"]/.test(statement)) { - exportAllModules.push(entry.n); + if (statement.exported) { + continue; + } + const source = statement.source?.value; + if (typeof source === 'string') { + modules.push(source); } } - return { exportNames: Array.from(exportNames), exportAllModules }; + return modules; +}; + +const getTransformedModule = async ( + code: string, + resourcePath: string +): Promise => { + const cached = transformCache.get(resourcePath); + if (cached?.source === code) { + return cached.transformed; + } + + let transformed: Promise; + transformed = cachePromiseOnReject( + (async () => { + const program = parseProgram(code, resourcePath); + const stripped = strip(program, { comments: 'some' }); + if (stripped.errors.length > 0) { + throw new Error(stripped.errors.map(error => error.message).join('\n')); + } + return { + code: stripped.code, + exportNames: collectProgramExportNames(program), + exportAllModules: collectExportAllModules(program), + }; + })(), + () => { + if (transformCache.get(resourcePath)?.transformed === transformed) { + transformCache.delete(resourcePath); + } + } + ); + + setBoundedCacheEntry( + transformCache, + resourcePath, + { + source: code, + transformed, + }, + MAX_EXPORT_UTILS_CACHE_ENTRIES + ); + return transformed; }; -export const getRouteModuleExports = async ( +export const transformToEsm = async ( + code: string, resourcePath: string -): Promise => { - const source = await readFile(resourcePath, 'utf8'); - const code = await transformToEsm(source, resourcePath); - return getExportNames(code); +): Promise => (await getTransformedModule(code, resourcePath)).code; + +export const getExportNames = async ( + code: string +): Promise => { + return (await getExportNamesAndExportAll(code)).exportNames; +}; + +export const getBundlerRouteAnalysis = async ( + source: string, + resourcePath: string +): Promise => { + const cached = bundlerRouteAnalysisCache.get(resourcePath); + if (cached?.source === source) { + return cached.analysis; + } + + const analysis = (async () => { + const program = parseProgram(source, resourcePath); + const sourceInfo: TransformedModule = { + code: source, + exportNames: collectProgramExportNames(program), + exportAllModules: collectExportAllModules(program), + }; + const routeChunkInfoCache = new Map>(); + + return { + ...sourceInfo, + getRouteChunkInfo: ( + cache: RouteChunkCache | undefined, + config: RouteChunkConfig + ) => { + const cacheKey = getRouteChunkConfigCacheKey(config); + const cachedRouteChunkInfo = routeChunkInfoCache.get(cacheKey); + if (cachedRouteChunkInfo) { + return cachedRouteChunkInfo; + } + + let routeChunkInfo: Promise; + routeChunkInfo = cachePromiseOnReject( + detectRouteChunksIfEnabled(cache, config, resourcePath, source), + () => { + if (routeChunkInfoCache.get(cacheKey) === routeChunkInfo) { + routeChunkInfoCache.delete(cacheKey); + } + } + ); + + routeChunkInfoCache.set(cacheKey, routeChunkInfo); + return routeChunkInfo; + }, + }; + })(); + + let trackedAnalysis: Promise; + trackedAnalysis = cachePromiseOnReject(analysis, () => { + if ( + bundlerRouteAnalysisCache.get(resourcePath)?.analysis === trackedAnalysis + ) { + bundlerRouteAnalysisCache.delete(resourcePath); + } + }); + + setBoundedCacheEntry( + bundlerRouteAnalysisCache, + resourcePath, + { + source, + analysis: trackedAnalysis, + }, + MAX_EXPORT_UTILS_CACHE_ENTRIES + ); + return trackedAnalysis; +}; + +export const getExportNamesAndExportAll = async ( + code: string +): Promise => { + const cached = exportInfoCache.get(code); + if (cached) { + return cached; + } + + const exportInfo = (async () => { + const program = parseProgram(code); + return { + exportNames: collectProgramExportNames(program), + exportAllModules: collectExportAllModules(program), + }; + })(); + + let trackedExportInfo: Promise; + trackedExportInfo = cachePromiseOnReject(exportInfo, () => { + if (exportInfoCache.get(code) === trackedExportInfo) { + exportInfoCache.delete(code); + } + }); + + setBoundedCacheEntry( + exportInfoCache, + code, + trackedExportInfo, + MAX_EXPORT_UTILS_CACHE_ENTRIES + ); + return trackedExportInfo; +}; + +export const getRouteModuleAnalysis = async ( + resourcePath: string +): Promise => { + const stats = await stat(resourcePath); + const cached = routeModuleAnalysisCache.get(resourcePath); + if (cached?.mtimeMs === stats.mtimeMs && cached.size === stats.size) { + return cached.analysis; + } + + const analysis = (async () => { + const source = await readFile(resourcePath, 'utf8'); + const transformed = await getTransformedModule(source, resourcePath); + return { + code: transformed.code, + exports: transformed.exportNames, + exportAllModules: transformed.exportAllModules, + }; + })(); + + let trackedAnalysis: Promise; + trackedAnalysis = cachePromiseOnReject(analysis, () => { + if ( + routeModuleAnalysisCache.get(resourcePath)?.analysis === trackedAnalysis + ) { + routeModuleAnalysisCache.delete(resourcePath); + } + }); + + setBoundedCacheEntry( + routeModuleAnalysisCache, + resourcePath, + { + mtimeMs: stats.mtimeMs, + size: stats.size, + analysis: trackedAnalysis, + }, + MAX_EXPORT_UTILS_CACHE_ENTRIES + ); + return trackedAnalysis; }; diff --git a/src/index.ts b/src/index.ts index 651343a..fcd7560 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,31 +1,25 @@ -import { existsSync, readFileSync, statSync } from 'node:fs'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; +import { existsSync, readFileSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; import fsExtra from 'fs-extra'; import type { Config } from './react-router-config.js'; import type { RouteConfigEntry } from '@react-router/dev/routes'; -import type { RsbuildPlugin, Rspack } from '@rsbuild/core'; +import { + rspack, + type RsbuildEntryDescription, + type RsbuildPlugin, + type Rspack, +} from '@rsbuild/core'; import { createJiti } from 'jiti'; import jsesc from 'jsesc'; -import { basename as pathBasename, dirname, relative, resolve } from 'pathe'; -import { RspackVirtualModulePlugin } from 'rspack-plugin-virtual-module'; -import { generate, parse } from './babel.js'; -import { - BUILD_CLIENT_ROUTE_QUERY_STRING, - CLIENT_ROUTE_EXPORTS, - JS_EXTENSIONS, - PLUGIN_NAME, - SERVER_ONLY_ROUTE_EXPORTS, -} from './constants.js'; +import { dirname, relative, resolve } from 'pathe'; + +import { BUILD_CLIENT_ROUTE_QUERY_STRING, PLUGIN_NAME } from './constants.js'; import { createDevServerMiddleware } from './dev-server.js'; import { generateWithProps, - removeExports, - transformRoute, findEntryFile, normalizeAssetPrefix, - removeUnusedImports, } from './plugin-utils.js'; import type { PluginOptions } from './types.js'; import { @@ -44,27 +38,30 @@ import { } from './react-router-config.js'; import { getReactRouterManifestForDev, + getRouteManifestModuleExports, configRoutesToRouteManifest, + createReactRouterManifestStats, + type ReactRouterManifestStats, } from './manifest.js'; import { createModifyBrowserManifestPlugin } from './modify-browser-manifest.js'; import { createRequestHandler, matchRoutes } from 'react-router'; import { - getExportNames, - getExportNamesAndExportAll, - getRouteModuleExports, - transformToEsm, -} from './export-utils.js'; -import { - detectRouteChunksIfEnabled, getRouteChunkEntryName, - getRouteChunkIfEnabled, getRouteChunkModuleId, - getRouteChunkNameFromModuleId, routeChunkExportNames, - validateRouteChunks, type RouteChunkCache, type RouteChunkConfig, } from './route-chunks.js'; +import { createRouteTransformExecutor } from './parallel-route-transforms.js'; +import { + createRouteTopologyWatcher, + createRouteManifestSnapshot, + emitRouteRestartMarkerAsset, + ensureDevRestartMarker, + getRouteRestartMarkerPath, + mergeWatchFiles, + type WatchFileConfig, +} from './route-watch.js'; import { validateRouteConfig } from './route-config.js'; import { getBuildManifest, @@ -73,6 +70,11 @@ import { import { warnOnClientSourceMaps } from './warnings/warn-on-client-source-maps.js'; import { validatePluginOrderFromConfig } from './validation/validate-plugin-order.js'; import { getSsrExternals } from './ssr-externals.js'; +import { + createReactRouterPerformanceProfiler, + roundMs, +} from './performance.js'; +import { mapVirtualModules } from './virtual-modules.js'; const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); @@ -127,6 +129,12 @@ export const pluginReactRouter = ( ...defaultOptions, ...options, }; + const logPerformance = pluginOptions.logPerformance === true; + const setupStartMs = logPerformance ? performance.now() : 0; + const performanceProfiler = createReactRouterPerformanceProfiler({ + enabled: logPerformance, + log: message => api.logger.info(message), + }); const nodeExternals = Array.from( new Set(['express', ...getSsrExternals(process.cwd())]) @@ -186,7 +194,9 @@ export const pluginReactRouter = ( }); }); - const jiti = createJiti(process.cwd()); + const jiti = createJiti(process.cwd(), { + moduleCache: false, + }); // Read the react-router.config file first (supports .ts, .js, .mjs, etc.) const configPath = findEntryFile(resolve('react-router.config')); @@ -314,21 +324,24 @@ export const pluginReactRouter = ( ); } - const routeConfigExport = await jiti.import( - routesPath, - { - default: true, + const loadRouteConfig = async (): Promise => { + const routeConfigExport = await jiti.import( + routesPath, + { + default: true, + } + ); + const routeConfigValue = await routeConfigExport; + const validation = validateRouteConfig({ + routeConfigFile: relative(process.cwd(), routesPath), + routeConfig: routeConfigValue, + }); + if (!validation.valid) { + throw new Error(validation.message); } - ); - const routeConfigValue = await routeConfigExport; - const validation = validateRouteConfig({ - routeConfigFile: relative(process.cwd(), routesPath), - routeConfig: routeConfigValue, - }); - if (!validation.valid) { - throw new Error(validation.message); - } - const routeConfig = validation.routeConfig; + return validation.routeConfig; + }; + const routeConfig = await loadRouteConfig(); const entryClientPath = findEntryFile( resolve(appDirectory, 'entry.client') @@ -360,6 +373,14 @@ export const pluginReactRouter = ( // React Router's server build expects route files relative to `appDirectory` // so it can resolve them correctly during compilation. const rootRouteFile = relative(appDirectory, rootRoutePath); + const getWatchedRouteTopology = async (): Promise> => { + const latestRouteConfig = await loadRouteConfig(); + const latestRoutes = { + root: { path: '', id: 'root', file: rootRouteFile }, + ...configRoutesToRouteManifest(appDirectory, latestRouteConfig), + }; + return createRouteManifestSnapshot(latestRoutes); + }; const routes = { root: { path: '', id: 'root', file: rootRouteFile }, @@ -387,23 +408,72 @@ export const pluginReactRouter = ( const isBuild = api.context.action === 'build'; const splitRouteModules = future?.v8_splitRouteModules ?? false; - const enforceSplitRouteModules = splitRouteModules === 'enforce'; + const isPrerenderEnabled = + prerenderConfig !== undefined && prerenderConfig !== false; + const isSpaMode = !ssr && !isPrerenderEnabled; + const routeCount = Object.keys(routes).length; const routeChunkConfig: RouteChunkConfig = { splitRouteModules, appDirectory, rootRouteFile, }; const routeChunkCache: RouteChunkCache = new Map(); + const routeTransformExecutor = createRouteTransformExecutor({ + parallelTransforms: pluginOptions.parallelTransforms, + routeChunkCache, + splitRouteModules: Boolean(splitRouteModules), + }); const routeChunkOptions = { splitRouteModules, rootRouteFile, isBuild, cache: routeChunkCache, }; + const outputClientPath = resolve(buildDirectory, 'client'); + const assetsBuildDirectory = relative(process.cwd(), outputClientPath); + const watchDirectory = resolve(appDirectory); + const routeRestartMarkerPath = getRouteRestartMarkerPath(outputClientPath); + const routeWatchFiles: WatchFileConfig[] = [ + { + paths: routesPath, + type: 'reload-server', + }, + { + paths: routeRestartMarkerPath, + type: 'reload-server', + }, + ]; + let closeRouteTopologyWatcher: (() => void) | undefined; + + api.onBeforeStartDevServer(async () => { + await ensureDevRestartMarker(routeRestartMarkerPath); + closeRouteTopologyWatcher = await createRouteTopologyWatcher({ + watchDirectory, + getRouteTopology: getWatchedRouteTopology, + restartMarkerPath: routeRestartMarkerPath, + onError: error => { + api.logger.warn( + `[${PLUGIN_NAME}] Failed to watch route topology changes: ${error}` + ); + }, + }); + }); + + api.onCloseDevServer(() => { + closeRouteTopologyWatcher?.(); + closeRouteTopologyWatcher = undefined; + }); + api.onCloseBuild(async () => { + await routeTransformExecutor.close(); + }); + api.onCloseDevServer(async () => { + await routeTransformExecutor.close(); + }); type ReactRouterManifest = Awaited< ReturnType >; + let latestBrowserManifest: ReactRouterManifest | null = null; let latestServerManifest: ReactRouterManifest | null = null; const latestServerManifestsByBundleId: Record = {}; @@ -414,48 +484,37 @@ export const pluginReactRouter = ( route, ]) ); - const routeExportsCache = new Map(); - const getCachedRouteExports = async (filePath: string) => { - if (routeExportsCache.has(filePath)) { - return routeExportsCache.get(filePath)!; - } - const exports = await getRouteModuleExports(filePath); - routeExportsCache.set(filePath, exports); - return exports; - }; + const manifestChunkNames = new Set(['entry.client']); const webRouteEntries = Object.values(routes).reduce( (acc, route) => { const entryName = route.file.slice(0, route.file.lastIndexOf('.')); const routeFilePath = resolve(appDirectory, route.file); + manifestChunkNames.add(entryName); acc[entryName] = { import: `${routeFilePath}${BUILD_CLIENT_ROUTE_QUERY_STRING}`, + html: false, }; if (isBuild && splitRouteModules && route.id !== 'root') { - let source = ''; - try { - source = readFileSync(routeFilePath, 'utf8'); - } catch { - source = ''; - } - if (source) { - for (const exportName of routeChunkExportNames) { - if (!source.includes(exportName)) { - continue; - } - acc[getRouteChunkEntryName(route.id, exportName)] = { - import: getRouteChunkModuleId(routeFilePath, exportName), - }; + const source = readFileSync(routeFilePath, 'utf8'); + for (const exportName of routeChunkExportNames) { + if (!source.includes(exportName)) { + continue; } + const chunkEntryName = getRouteChunkEntryName(route.id, exportName); + manifestChunkNames.add(chunkEntryName); + acc[chunkEntryName] = { + import: getRouteChunkModuleId(routeFilePath, exportName), + html: false, + }; } } return acc; }, - {} as Record + {} as Record ); - const buildManifest = await getBuildManifest({ reactRouterConfig: resolvedConfigWithRoutes, routes, @@ -463,13 +522,13 @@ export const pluginReactRouter = ( }); const routesByServerBundleId = getRoutesByServerBundleId(buildManifest); - const outputClientPath = resolve(buildDirectory, 'client'); - const assetsBuildDirectory = relative(process.cwd(), outputClientPath); - - let clientStats: Rspack.StatsCompilation | undefined; + let clientStats: ReactRouterManifestStats | undefined; api.onAfterEnvironmentCompile(({ stats, environment }) => { if (environment.name === 'web') { - clientStats = stats?.toJson(); + clientStats = createReactRouterManifestStats( + stats?.compilation, + manifestChunkNames + ); } if (pluginOptions.federation && ssr) { const serverBuildDir = resolve(buildDirectory, 'server'); @@ -479,6 +538,11 @@ export const pluginReactRouter = ( fsExtra.copySync(serverBuildDir, ssrDir); } } + if (logPerformance) { + performanceProfiler.flush(environment.name, { + compilerLifecycleMs: roundMs(performance.now() - setupStartMs), + }); + } }); // Determine prerender paths from config @@ -491,10 +555,6 @@ export const pluginReactRouter = ( warn: message => api.logger.warn(message), } ); - const isPrerenderEnabled = - prerenderConfig !== undefined && prerenderConfig !== false; - const isSpaMode = !ssr && !isPrerenderEnabled; - const groupRoutesByParentId = (manifest: Record) => { const grouped: Record = {}; Object.values(manifest).forEach(route => { @@ -531,6 +591,24 @@ export const pluginReactRouter = ( const normalizePrerenderMatchPath = (path: string) => `/${path}/`.replace(/^\/\/+/, '/'); + const withBuildRequest = async ( + input: string | URL, + init: RequestInit | undefined, + handle: (request: Request) => Promise + ): Promise => { + const controller = new AbortController(); + try { + return await handle( + new Request(input, { + ...init, + signal: controller.signal, + }) + ); + } finally { + controller.abort(); + } + }; + const prerenderData = async ( handler: (request: Request) => Promise, prerenderPath: string, @@ -560,28 +638,32 @@ export const pluginReactRouter = ( if (onlyRoutes?.length) { url.searchParams.set('_routes', onlyRoutes.join(',')); } - const request = new Request(url, requestInit); - const response = await handler(request); - const data = await response.text(); + return withBuildRequest(url, requestInit, async request => { + const response = await handler(request); + const data = await response.text(); - if (response.status !== 200 && response.status !== 202) { - throw new Error( - `Prerender (data): Received a ${response.status} status code from ` + - `\`entry.server.tsx\` while prerendering the \`${prerenderPath}\` path.\n` + - `${normalizedPath}` - ); - } + if (response.status !== 200 && response.status !== 202) { + throw new Error( + `Prerender (data): Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering the \`${prerenderPath}\` path.\n` + + `${normalizedPath}` + ); + } - const outputPath = resolve(clientBuildDir, ...normalizedPath.split('/')); - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, data); - api.logger.info( - `Prerender (data): ${prerenderPath} -> ${relative( - process.cwd(), - outputPath - )}` - ); - return data; + const outputPath = resolve( + clientBuildDir, + ...normalizedPath.split('/') + ); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, data); + api.logger.info( + `Prerender (data): ${prerenderPath} -> ${relative( + process.cwd(), + outputPath + )}` + ); + return data; + }); }; const prerenderRoute = async ( @@ -594,17 +676,17 @@ export const pluginReactRouter = ( /\/\/+/g, '/' ); - const request = new Request( + await withBuildRequest( `http://localhost${normalizedPath}`, - requestInit - ); - const response = await handler(request); - let html = await response.text(); - - if (redirectStatusCodes.has(response.status)) { - const location = response.headers.get('Location'); - const delay = response.status === 302 ? 2 : 0; - html = ` + requestInit, + async request => { + const response = await handler(request); + let html = await response.text(); + + if (redirectStatusCodes.has(response.status)) { + const location = response.headers.get('Location'); + const delay = response.status === 302 ? 2 : 0; + html = ` Redirecting to: ${location} @@ -616,26 +698,28 @@ export const pluginReactRouter = ( `; - } else if (response.status !== 200) { - throw new Error( - `Prerender (html): Received a ${response.status} status code from ` + - `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` path.\n` + - html - ); - } + } else if (response.status !== 200) { + throw new Error( + `Prerender (html): Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` path.\n` + + html + ); + } - const outputPath = resolve( - clientBuildDir, - ...normalizedPath.split('/'), - 'index.html' - ); - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, html); - api.logger.info( - `Prerender (html): ${prerenderPath} -> ${relative( - process.cwd(), - outputPath - )}` + const outputPath = resolve( + clientBuildDir, + ...normalizedPath.split('/'), + 'index.html' + ); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, html); + api.logger.info( + `Prerender (html): ${prerenderPath} -> ${relative( + process.cwd(), + outputPath + )}` + ); + } ); }; @@ -648,29 +732,34 @@ export const pluginReactRouter = ( const normalizedPath = `${basename}${prerenderPath}/` .replace(/\/\/+/g, '/') .replace(/\/$/g, ''); - const request = new Request( + await withBuildRequest( `http://localhost${normalizedPath}`, - requestInit - ); - const response = await handler(request); - const content = Buffer.from(await response.arrayBuffer()); + requestInit, + async request => { + const response = await handler(request); + const content = Buffer.from(await response.arrayBuffer()); - if (response.status !== 200) { - throw new Error( - `Prerender (resource): Received a ${response.status} status code from ` + - `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` path.\n` + - content.toString('utf8') - ); - } + if (response.status !== 200) { + throw new Error( + `Prerender (resource): Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering the \`${normalizedPath}\` path.\n` + + content.toString('utf8') + ); + } - const outputPath = resolve(clientBuildDir, ...normalizedPath.split('/')); - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, content); - api.logger.info( - `Prerender (resource): ${prerenderPath} -> ${relative( - process.cwd(), - outputPath - )}` + const outputPath = resolve( + clientBuildDir, + ...normalizedPath.split('/') + ); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, content); + api.logger.info( + `Prerender (resource): ${prerenderPath} -> ${relative( + process.cwd(), + outputPath + )}` + ); + } ); }; @@ -679,51 +768,56 @@ export const pluginReactRouter = ( build: any, clientBuildDir: string ): Promise => { - const request = new Request(`http://localhost${basename}`, { - headers: { - 'X-React-Router-SPA-Mode': 'yes', + await withBuildRequest( + `http://localhost${basename}`, + { + headers: { + 'X-React-Router-SPA-Mode': 'yes', + }, }, - }); - const response = await handler(request); - const html = await response.text(); - const isPrerenderSpaFallback = build.prerender?.includes('/'); - const filename = isPrerenderSpaFallback - ? '__spa-fallback.html' - : 'index.html'; - - if (response.status !== 200) { - if (isPrerenderSpaFallback) { - throw new Error( - `Prerender: Received a ${response.status} status code from ` + - `\`entry.server.tsx\` while prerendering your \`${filename}\` file.\n` + - html - ); - } - throw new Error( - `SPA Mode: Received a ${response.status} status code from ` + - `\`entry.server.tsx\` while prerendering your \`${filename}\` file.\n` + - html - ); - } + async request => { + const response = await handler(request); + const html = await response.text(); + const isPrerenderSpaFallback = build.prerender?.includes('/'); + const filename = isPrerenderSpaFallback + ? '__spa-fallback.html' + : 'index.html'; + + if (response.status !== 200) { + if (isPrerenderSpaFallback) { + throw new Error( + `Prerender: Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering your \`${filename}\` file.\n` + + html + ); + } + throw new Error( + `SPA Mode: Received a ${response.status} status code from ` + + `\`entry.server.tsx\` while prerendering your \`${filename}\` file.\n` + + html + ); + } - if ( - !html.includes('window.__reactRouterContext =') || - !html.includes('window.__reactRouterRouteModules =') - ) { - throw new Error( - 'SPA Mode: Did you forget to include `` in your root route? ' + - 'Your pre-rendered HTML cannot hydrate without ``.' - ); - } + if ( + !html.includes('window.__reactRouterContext =') || + !html.includes('window.__reactRouterRouteModules =') + ) { + throw new Error( + 'SPA Mode: Did you forget to include `` in your root route? ' + + 'Your pre-rendered HTML cannot hydrate without ``.' + ); + } - const outputPath = resolve(clientBuildDir, filename); - await writeFile(outputPath, html); - const prettyPath = relative(process.cwd(), outputPath); - if (build.prerender?.length) { - api.logger.info(`Prerender (html): SPA Fallback -> ${prettyPath}`); - } else { - api.logger.info(`SPA Mode: Generated ${prettyPath}`); - } + const outputPath = resolve(clientBuildDir, filename); + await writeFile(outputPath, html); + const prettyPath = relative(process.cwd(), outputPath); + if (build.prerender?.length) { + api.logger.info(`Prerender (html): SPA Fallback -> ${prettyPath}`); + } else { + api.logger.info(`SPA Mode: Generated ${prettyPath}`); + } + } + ); }; const validateSsrFalsePrerenderExports = async ( @@ -751,11 +845,7 @@ export const pluginReactRouter = ( ); } - const routeExports: Record = {}; - for (const route of Object.values(routes)) { - const filePath = resolve(appDirectory, route.file); - routeExports[route.id] = await getRouteModuleExports(filePath); - } + const routeExports = getRouteManifestModuleExports(manifest); const errors: string[] = []; for (const [routeId, route] of Object.entries(manifest.routes)) { @@ -811,7 +901,6 @@ export const pluginReactRouter = ( } }; - // Handle SPA mode and prerendering after build api.onAfterBuild(async ({ environments }) => { const webEnv = environments.web; if (!webEnv) { @@ -862,15 +951,17 @@ export const pluginReactRouter = ( const requestHandler = createRequestHandler(build, 'production'); if (isPrerenderEnabled) { - const manifest = await getReactRouterManifestForDev( - routes, - pluginOptions, - clientStats, - appDirectory, - assetPrefix, - routeChunkOptions - ); if (!ssr) { + const manifest = + latestBrowserManifest ?? + (await getReactRouterManifestForDev( + routes, + pluginOptions, + clientStats, + appDirectory, + assetPrefix, + routeChunkOptions + )); await validateSsrFalsePrerenderExports(manifest, prerenderPaths); } @@ -988,11 +1079,6 @@ export const pluginReactRouter = ( } if (buildEnd) { - const buildManifest = await getBuildManifest({ - reactRouterConfig: resolvedConfigWithRoutes, - routes, - rootDirectory: process.cwd(), - }); await buildEnd({ buildManifest, reactRouterConfig: resolvedConfigWithRoutes, @@ -1004,10 +1090,7 @@ export const pluginReactRouter = ( const allowedActionOriginsForBuild = allowedActionOrigins === false ? undefined : allowedActionOrigins; - // Create virtual modules for React Router - const vmodTempDir = `rspack-virtual-module-${process.pid}-${Math.random() - .toString(16) - .slice(2)}`; + // Public requests stay bare while Rspack resolves seeded virtual files. const createVirtualModulePlugin = (publicPath: string) => { const bundleVirtualModules = Object.fromEntries( Object.entries(routesByServerBundleId).map( @@ -1042,8 +1125,8 @@ export const pluginReactRouter = ( ]) ); - return new RspackVirtualModulePlugin( - { + return new rspack.experiments.VirtualModulesPlugin( + mapVirtualModules({ 'virtual/react-router/browser-manifest': 'export default {};', 'virtual/react-router/server-manifest': 'export default {};', 'virtual/react-router/server-build': generateServerBuild(routes, { @@ -1062,8 +1145,7 @@ export const pluginReactRouter = ( ...bundleVirtualModules, ...bundleManifestModules, 'virtual/react-router/with-props': generateWithProps(), - }, - vmodTempDir + }) ); }; @@ -1104,12 +1186,35 @@ export const pluginReactRouter = ( `virtual/react-router/server-build-${bundleId}`; } + const lazyCompilation = + pluginOptions.lazyCompilation === undefined + ? {} + : { lazyCompilation: pluginOptions.lazyCompilation }; + const shouldCompactFileSizeReport = + isBuild && + routeCount >= 256 && + (config.performance?.printFileSize === undefined || + config.performance.printFileSize === true); + return mergeRsbuildConfig(config, { + ...(shouldCompactFileSizeReport + ? { + performance: { + printFileSize: { + total: true, + detail: false, + compressed: false, + }, + }, + } + : {}), output: { assetPrefix: config.output?.assetPrefix || '/', }, dev: { writeToDisk: true, + ...lazyCompilation, + watchFiles: mergeWatchFiles(config.dev?.watchFiles, routeWatchFiles), // Only add SSR middleware if SSR is enabled and not using a custom server // In SPA mode (ssr: false), we just serve static files from the client build setupMiddlewares: @@ -1132,8 +1237,10 @@ export const pluginReactRouter = ( entry: { // no query needed when federation is disabled 'entry.client': finalEntryClientPath, - 'virtual/react-router/browser-manifest': - 'virtual/react-router/browser-manifest', + 'virtual/react-router/browser-manifest': { + import: 'virtual/react-router/browser-manifest', + html: false, + }, ...webRouteEntries, }, }, @@ -1165,6 +1272,7 @@ export const pluginReactRouter = ( module: true, }, optimization: { + avoidEntryIife: true, runtimeChunk: 'single', }, }, @@ -1173,42 +1281,35 @@ export const pluginReactRouter = ( // Always include node environment, even for SPA mode (`ssr:false`), // because React Router still needs a server build to prerender the // root route into a hydratable `index.html` at build time. - ...(true - ? { - node: { - source: { - entry: nodeEntries, - }, - output: { - distPath: { - root: resolve(buildDirectory, 'server'), - }, - target: config.environments?.node?.output?.target || 'node', - filename: { - js: '[name].js', - }, - }, - tools: { - rspack: { - target: options.federation ? 'async-node' : 'node', - externals: nodeExternals, - dependencies: ['web'], - externalsType: resolvedServerOutput, - output: { - chunkFormat: resolvedServerOutput, - chunkLoading: nodeChunkLoading, - workerChunkLoading: nodeChunkLoading, - wasmLoading: 'fetch', - module: resolvedServerOutput === 'module', - }, - // optimization: { - // runtimeChunk: 'single', - // }, - }, - }, + node: { + source: { + entry: nodeEntries, + }, + output: { + distPath: { + root: resolve(buildDirectory, 'server'), + }, + target: config.environments?.node?.output?.target || 'node', + filename: { + js: '[name].js', + }, + }, + tools: { + rspack: { + target: options.federation ? 'async-node' : 'node', + externals: nodeExternals, + dependencies: ['web'], + externalsType: resolvedServerOutput, + output: { + chunkFormat: resolvedServerOutput, + chunkLoading: nodeChunkLoading, + workerChunkLoading: nodeChunkLoading, + wasmLoading: 'fetch', + module: resolvedServerOutput === 'module', }, - } - : {}), + }, + }, + }, }, }); }); @@ -1255,29 +1356,41 @@ export const pluginReactRouter = ( routeChunkOptions, { future, + manifestChunkNames, onManifest: (manifest, sri) => { - const baseServerManifest = { - ...manifest, - sri, - }; - latestServerManifest = baseServerManifest; - for (const [bundleId, bundleRoutes] of Object.entries( - routesByServerBundleId - )) { - if (!bundleRoutes) { - continue; + performanceProfiler.recordSync( + 'web', + 'manifest:stage', + 'virtual/react-router/browser-manifest', + () => { + latestBrowserManifest = manifest; + const baseServerManifest = { + ...manifest, + sri, + }; + latestServerManifest = baseServerManifest; + for (const [ + bundleId, + bundleRoutes, + ] of Object.entries(routesByServerBundleId)) { + if (!bundleRoutes) { + continue; + } + const routeIds = new Set( + Object.keys(bundleRoutes) + ); + const filteredRoutes = Object.fromEntries( + Object.entries(manifest.routes).filter( + ([routeId]) => routeIds.has(routeId) + ) + ); + latestServerManifestsByBundleId[bundleId] = { + ...baseServerManifest, + routes: filteredRoutes, + }; + } } - const routeIds = new Set(Object.keys(bundleRoutes)); - const filteredRoutes = Object.fromEntries( - Object.entries(manifest.routes).filter( - ([routeId]) => routeIds.has(routeId) - ) - ); - latestServerManifestsByBundleId[bundleId] = { - ...baseServerManifest, - routes: filteredRoutes, - }; - } + ); }, } ) @@ -1290,6 +1403,19 @@ export const pluginReactRouter = ( } ); + if (isBuild) { + api.processAssets( + { stage: 'additional', targets: ['web'] }, + ({ sources, compilation }) => { + emitRouteRestartMarkerAsset({ + restartMarkerPath: routeRestartMarkerPath, + sources, + compilation, + }); + } + ); + } + api.processAssets( { stage: 'additional', targets: ['node'] }, ({ sources, compilation }) => { @@ -1306,450 +1432,182 @@ export const pluginReactRouter = ( } ); - // Add manifest transformations api.transform( { test: /virtual\/react-router\/(browser|server)-manifest/, }, - async args => { - // For browser manifest, return a placeholder that will be modified by the plugin - if (args.environment.name === 'web') { - return { - code: `window.__reactRouterManifest = "PLACEHOLDER";`, - }; - } + async args => + performanceProfiler.record( + args.environment?.name, + 'manifest:transform', + args.resource, + async () => { + if (args.environment.name === 'web') { + return { + code: `window.__reactRouterManifest = "PLACEHOLDER";`, + }; + } - const bundleMatch = args.resource.match( - /virtual\/react-router\/server-manifest(?:-([^?]+))?/ - ); - const bundleId = bundleMatch?.[1]?.replace(/\\.js$/, ''); - - const manifest = - (isBuild && latestServerManifest - ? bundleId && latestServerManifestsByBundleId[bundleId] - ? latestServerManifestsByBundleId[bundleId] - : latestServerManifest - : null) ?? - (await getReactRouterManifestForDev( - routes, - pluginOptions, - clientStats, - appDirectory, - assetPrefix, - routeChunkOptions - )); - return { - code: `export default ${jsesc(manifest, { es6: true })};`, - }; - } + const bundleMatch = args.resource.match( + /virtual\/react-router\/server-manifest(?:-([^?]+))?/ + ); + const bundleId = bundleMatch?.[1]?.replace(/\.js$/, ''); + + const manifest = + (isBuild && latestServerManifest + ? bundleId && latestServerManifestsByBundleId[bundleId] + ? latestServerManifestsByBundleId[bundleId] + : latestServerManifest + : null) ?? + (await getReactRouterManifestForDev( + routes, + pluginOptions, + clientStats, + appDirectory, + assetPrefix, + routeChunkOptions + )); + return { + code: `export default ${jsesc(manifest, { es6: true })};`, + }; + } + ) ); api.transform( { resourceQuery: /__react-router-build-client-route/, }, - async args => { - const code = await transformToEsm(args.code, args.resourcePath); - const exportNames = await getExportNames(code); - const isServer = args.environment?.name === 'node'; - const chunkedExports = - !isServer && isBuild && splitRouteModules - ? ( - await detectRouteChunksIfEnabled( - routeChunkCache, - routeChunkConfig, - args.resourcePath, - code - ) - ).chunkedExports - : []; - const chunkedExportSet = new Set(chunkedExports); - const reexports = exportNames.filter(exp => { - if (chunkedExportSet.has(exp)) { - return false; - } - return ( - (CLIENT_ROUTE_EXPORTS as readonly string[]).includes(exp) || - (isServer && - (SERVER_ONLY_ROUTE_EXPORTS as readonly string[]).includes(exp)) - ); - }); - const target = `${args.resourcePath}?react-router-route`; - return { - code: `export { ${reexports.join(', ')} } from ${JSON.stringify( - target - )};`, - }; - } + async args => + performanceProfiler.record( + args.environment?.name, + 'route:client-entry', + args.resource, + async () => + routeTransformExecutor.run({ + kind: 'routeClientEntry', + code: args.code, + resourcePath: args.resourcePath, + environmentName: args.environment?.name, + isBuild, + routeChunkConfig, + }) + ) ); api.transform( { resourceQuery: /route-chunk=/, + environments: ['web'], }, - async args => { - if (args.environment?.name !== 'web') { - return { code: args.code, map: null }; - } - const preventEmptyChunkSnippet = (reason: string) => - `Math.random()<0&&console.log(${JSON.stringify(reason)});`; - - if (!isBuild || !splitRouteModules) { - return { - code: preventEmptyChunkSnippet('Split route modules disabled'), - map: null, - }; - } - - const chunkName = getRouteChunkNameFromModuleId(args.resource); - if (!chunkName) { - throw new Error(`Invalid route chunk name in "${args.resource}"`); - } - - const transformed = await transformToEsm(args.code, args.resourcePath); - const chunk = await getRouteChunkIfEnabled( - routeChunkCache, - routeChunkConfig, - args.resourcePath, - chunkName, - transformed - ); - - if (enforceSplitRouteModules && chunkName === 'main' && chunk) { - const exportNames = await getExportNames(chunk); - validateRouteChunks({ - config: routeChunkConfig, - id: args.resourcePath, - valid: { - clientAction: !exportNames.includes('clientAction'), - clientLoader: !exportNames.includes('clientLoader'), - clientMiddleware: !exportNames.includes('clientMiddleware'), - HydrateFallback: !exportNames.includes('HydrateFallback'), - }, - }); - } - - return { - code: chunk ?? preventEmptyChunkSnippet(`No ${chunkName} chunk`), - map: null, - }; - } + async args => + performanceProfiler.record( + args.environment?.name, + 'route:chunk', + args.resource, + async () => + routeTransformExecutor.run({ + kind: 'routeChunk', + code: args.code, + resource: args.resource, + resourcePath: args.resourcePath, + isBuild, + routeChunkConfig, + }) + ) ); - api.transform( - { - test: /\.[cm]?[jt]sx?$/, - }, - async args => { - if (args.environment?.name !== 'web') { - return { code: args.code, map: null }; - } - if (!isBuild || !splitRouteModules) { - return { code: args.code, map: null }; - } - if ( - args.resource.includes(BUILD_CLIENT_ROUTE_QUERY_STRING) || - args.resource.includes('?react-router-route') || - args.resource.includes('route-chunk=') - ) { - return { code: args.code, map: null }; - } - const route = routeByFilePath.get(args.resourcePath); - if (!route) { - return { code: args.code, map: null }; - } - - const transformed = await transformToEsm(args.code, args.resourcePath); - const { hasRouteChunks, chunkedExports } = - await detectRouteChunksIfEnabled( - routeChunkCache, - routeChunkConfig, - args.resourcePath, - transformed - ); - if (!hasRouteChunks) { - return { code: args.code, map: null }; - } - - const sourceExports = await getCachedRouteExports(args.resourcePath); - const chunkedExportSet = new Set(chunkedExports); - const isMainChunkExport = (name: string) => !chunkedExportSet.has(name); - const mainChunkReexports = sourceExports - .filter(isMainChunkExport) - .join(', '); - const chunkBasePath = `./${pathBasename(args.resourcePath)}`; + if (isBuild && splitRouteModules) { + api.transform( + { + test: path => routeByFilePath.has(path), + resourceQuery: { + not: /__react-router-build-client-route|react-router-route|route-chunk=/, + }, + environments: ['web'], + }, + async args => + performanceProfiler.record( + args.environment?.name, + 'route:split-exports', + args.resource, + async () => { + const route = routeByFilePath.get(args.resourcePath); + if (!route) { + return { code: args.code, map: null }; + } - return { - code: [ - mainChunkReexports - ? `export { ${mainChunkReexports} } from "${getRouteChunkModuleId( - chunkBasePath, - 'main' - )}";` - : null, - ...chunkedExports.map( - exportName => - `export { ${exportName} } from "${getRouteChunkModuleId( - chunkBasePath, - exportName - )}";` - ), - ] - .filter(Boolean) - .join('\n'), - map: null, - }; - } - ); + return routeTransformExecutor.run({ + kind: 'splitRouteExports', + code: args.code, + resourcePath: args.resourcePath, + routeChunkConfig, + }); + } + ) + ); + } api.transform( { test: /[\\/]\.server[\\/]|\.server(\.[cm]?[jt]sx?)?$/, + environments: ['web'], }, - async args => { - if (args.environment?.name !== 'web') { - return { code: args.code, map: null }; - } - - const relativePath = relative(process.cwd(), args.resourcePath); - throw new Error( - `[${PLUGIN_NAME}] Server-only module referenced by client: ${relativePath}` - ); - } + async args => + performanceProfiler.record( + args.environment?.name, + 'module:server-only-guard', + args.resource, + async () => { + const relativePath = relative(process.cwd(), args.resourcePath); + throw new Error( + `[${PLUGIN_NAME}] Server-only module referenced by client: ${relativePath}` + ); + } + ) ); api.transform( { test: /[\\/]\.client[\\/]|\.client(\.[cm]?[jt]sx?)?$/, + environments: ['node'], }, - async args => { - if (args.environment?.name !== 'node') { - return { code: args.code, map: null }; - } - - const code = await transformToEsm(args.code, args.resourcePath); - const { exportNames: directExportNames, exportAllModules } = - await getExportNamesAndExportAll(code); - const exportNames = new Set(directExportNames); - const unresolvedExportAll = new Set(); - const visitedModules = new Set(); - - const resolveIndexFile = (dirPath: string): string | null => { - for (const ext of JS_EXTENSIONS) { - const candidate = resolve(dirPath, `index${ext}`); - if (!existsSync(candidate)) { - continue; - } - try { - if (statSync(candidate).isFile()) { - return candidate; - } - } catch { - continue; - } - } - return null; - }; - - const resolvePathWithExtensions = (basePath: string): string | null => { - if (existsSync(basePath)) { - try { - const stats = statSync(basePath); - if (stats.isFile()) { - return basePath; - } - if (stats.isDirectory()) { - return resolveIndexFile(basePath); - } - } catch { - // Ignore invalid paths and fall back to extension probing. - } - } - - for (const ext of JS_EXTENSIONS) { - const candidate = `${basePath}${ext}`; - if (!existsSync(candidate)) { - continue; - } - try { - if (statSync(candidate).isFile()) { - return candidate; - } - } catch { - continue; - } - } - - return resolveIndexFile(basePath); - }; - - const resolveExportAllModule = ( - specifier: string, - importerPath: string - ): string | null => { - if (specifier.startsWith('.') || specifier.startsWith('/')) { - const basePath = specifier.startsWith('/') - ? specifier - : resolve(dirname(importerPath), specifier); - const resolvedPath = resolvePathWithExtensions(basePath); - if (resolvedPath) { - return resolvedPath; - } - } - - try { - const resolver = createRequire(pathToFileURL(importerPath).href); - return resolver.resolve(specifier); - } catch { - return null; - } - }; - - const collectExportNamesFromModule = async ( - modulePath: string - ): Promise => { - if (visitedModules.has(modulePath)) { - return; - } - visitedModules.add(modulePath); - const source = await readFile(modulePath, 'utf8'); - const moduleCode = await transformToEsm(source, modulePath); - const { - exportNames: moduleExportNames, - exportAllModules: moduleExportAll, - } = await getExportNamesAndExportAll(moduleCode); - for (const name of moduleExportNames) { - if (name !== 'default') { - exportNames.add(name); - } - } - for (const nestedSpecifier of moduleExportAll) { - const nestedPath = resolveExportAllModule( - nestedSpecifier, - modulePath - ); - if (!nestedPath) { - unresolvedExportAll.add(nestedSpecifier); - continue; - } - await collectExportNamesFromModule(nestedPath); - } - }; - - for (const specifier of exportAllModules) { - const resolvedPath = resolveExportAllModule( - specifier, - args.resourcePath - ); - if (!resolvedPath) { - unresolvedExportAll.add(specifier); - continue; - } - await collectExportNamesFromModule(resolvedPath); - } - - if (unresolvedExportAll.size > 0) { - throw new Error( - `[${PLUGIN_NAME}] Client-only module uses \`export * from\` with ` + - `unresolvable specifier(s): ${Array.from(unresolvedExportAll) - .map(spec => `\`${spec}\``) - .join(', ')}. ` + - `Please explicitly re-export named bindings in ` + - `\`${relative(process.cwd(), args.resourcePath)}\`.` - ); - } - return { - code: Array.from(exportNames) - .map(name => - name === 'default' - ? 'export default undefined;' - : `export const ${name} = undefined;` - ) - .join('\n'), - map: null, - }; - } + async args => + performanceProfiler.record( + args.environment?.name, + 'module:client-only-stub', + args.resource, + async () => + routeTransformExecutor.run({ + kind: 'clientOnlyStub', + code: args.code, + resourcePath: args.resourcePath, + }) + ) ); api.transform( { resourceQuery: /\?react-router-route/, }, - async args => { - let code: string; - try { - code = await transformToEsm(args.code, args.resourcePath); - } catch (error) { - console.error(args.resourcePath); - throw error; - } - - // Match React Router Vite behavior: - // In SPA mode, server-only route exports are invalid (except root `loader`), - // and `HydrateFallback` is only allowed on the root route. - // - // Important: `es-module-lexer` can't parse TS/TSX directly, so we scan - // the ESBuild-transformed JS output. - if (args.environment.name === 'web' && !ssr && isSpaMode) { - const exportNames = await getExportNames(code); - - const isRootRoute = args.resourcePath === rootRoutePath; - - const invalidServerOnly = exportNames.filter(exp => { - if (isRootRoute && exp === 'loader') return false; - return (SERVER_ONLY_ROUTE_EXPORTS as readonly string[]).includes( - exp - ); - }); - - if (invalidServerOnly.length > 0) { - const list = invalidServerOnly.map(e => `\`${e}\``).join(', '); - throw new Error( - `SPA Mode: ${invalidServerOnly.length} invalid route export(s) in ` + - `\`${relative(process.cwd(), args.resourcePath)}\`: ${list}. ` + - `See https://reactrouter.com/how-to/spa for more information.` - ); - } - - if (!isRootRoute && exportNames.includes('HydrateFallback')) { - throw new Error( - `SPA Mode: Invalid \`HydrateFallback\` export found in ` + - `\`${relative(process.cwd(), args.resourcePath)}\`. ` + - `\`HydrateFallback\` is only permitted on the root route in SPA Mode. ` + - `See https://reactrouter.com/how-to/spa for more information.` - ); - } - } - - const defaultExportMatch = code.match( - /\n\s{0,}([\w\d_]+)\sas default,?/ - ); - if ( - defaultExportMatch && - typeof defaultExportMatch.index === 'number' - ) { - code = - code.slice(0, defaultExportMatch.index) + - code.slice(defaultExportMatch.index + defaultExportMatch[0].length); - code += `\nexport default ${defaultExportMatch[1]};`; - } - - const ast = parse(code, { sourceType: 'module' }); - if (args.environment.name === 'web') { - const mutableServerOnlyRouteExports = [...SERVER_ONLY_ROUTE_EXPORTS]; - removeExports(ast, mutableServerOnlyRouteExports); - } - transformRoute(ast); - if (args.environment.name === 'web') { - removeUnusedImports(ast); - } - - return generate(ast, { - sourceMaps: true, - filename: args.resource, - sourceFileName: args.resourcePath, - }); - } + async args => + performanceProfiler.record( + args.environment?.name, + 'route:module', + args.resource, + async () => + routeTransformExecutor.run({ + kind: 'routeModule', + code: args.code, + resource: args.resource, + resourcePath: args.resourcePath, + environmentName: args.environment.name, + ssr, + isBuild, + isSpaMode, + rootRoutePath, + }) + ) ); }, }); diff --git a/src/manifest.ts b/src/manifest.ts index 961757a..72463c9 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -1,21 +1,21 @@ import { createHash } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; import { dirname, isAbsolute, relative, resolve } from 'pathe'; import type { Route, PluginOptions, RouteManifestItem } from './types.js'; import type { RouteConfigEntry } from '@react-router/dev/routes'; -import type { Rspack } from '@rsbuild/core'; import { combineURLs, createRouteId } from './plugin-utils.js'; import { SERVER_EXPORTS, CLIENT_EXPORTS } from './constants.js'; import { + buildManifestChunkValidity, + createEmptyRouteChunkByExportName, detectRouteChunksIfEnabled, getRouteChunkEntryName, + routeChunkExportNames, validateRouteChunks, type RouteChunkCache, type RouteChunkConfig, } from './route-chunks.js'; -import { getExportNames, transformToEsm } from './export-utils.js'; +import { getRouteModuleAnalysis } from './export-utils.js'; -// Helper functions export function configRoutesToRouteManifest( appDirectory: string, routes: RouteConfigEntry[], @@ -64,6 +64,97 @@ type RouteChunkManifestOptions = { cache?: RouteChunkCache; }; +export type ReactRouterManifestForDev = { + version: string; + url: string; + hmr?: { + runtime: string; + }; + entry: { + module: string; + imports: string[]; + css: string[]; + }; + sri?: Record; + routes: Record; +}; + +export type ReactRouterManifestStats = { + assetsByChunkName?: Record; +}; + +type ReactRouterManifestStatsChunk = { + files?: Iterable; +}; + +type ReactRouterManifestStatsCompilation = { + namedChunks: Iterable<[string, ReactRouterManifestStatsChunk]>; +}; + +type ReactRouterManifestStatsNamedChunks = + ReactRouterManifestStatsCompilation['namedChunks'] & { + get?: (chunkName: string) => ReactRouterManifestStatsChunk | undefined; + }; + +const orderChunkFiles = (chunkName: string, files: string[]): string[] => { + const ownChunkAsset = `${chunkName}.js`; + const ownFileIndex = files.findIndex(file => file.endsWith(ownChunkAsset)); + if (ownFileIndex <= 0) { + return files; + } + + return [ + files[ownFileIndex], + ...files.slice(0, ownFileIndex), + ...files.slice(ownFileIndex + 1), + ]; +}; + +export const createReactRouterManifestStats = ( + compilation: ReactRouterManifestStatsCompilation | undefined, + chunkNames?: ReadonlySet +): ReactRouterManifestStats | undefined => { + if (!compilation) { + return undefined; + } + + const assetsByChunkName: Record = {}; + const namedChunks = + compilation.namedChunks as ReactRouterManifestStatsNamedChunks; + + if (chunkNames && typeof namedChunks.get === 'function') { + for (const chunkName of chunkNames) { + const chunk = namedChunks.get(chunkName); + if (!chunk) { + continue; + } + const files = Array.from(chunk.files ?? []); + assetsByChunkName[chunkName] = orderChunkFiles(chunkName, files); + } + } else { + for (const [chunkName, chunk] of namedChunks) { + if (chunkNames && !chunkNames.has(chunkName)) { + continue; + } + const files = Array.from(chunk.files ?? []); + assetsByChunkName[chunkName] = orderChunkFiles(chunkName, files); + } + } + + return { assetsByChunkName }; +}; + +export type RouteManifestModuleExports = Record; + +const routeManifestModuleExports = new WeakMap< + ReactRouterManifestForDev, + RouteManifestModuleExports +>(); + +export const getRouteManifestModuleExports = ( + manifest: ReactRouterManifestForDev +): RouteManifestModuleExports => routeManifestModuleExports.get(manifest) ?? {}; + const DEFAULT_MANIFEST_DIR = 'static/js'; const getManifestDirFromEntryAsset = (entryModulePath?: string): string => { @@ -108,28 +199,32 @@ const getRouteEntryName = (route: Route): string => { return extensionIndex >= 0 ? route.file.slice(0, extensionIndex) : route.file; }; +export const getReactRouterManifestChunkNames = ( + routes: Record, + splitRouteModules: boolean | 'enforce' = false +): Set => { + const chunkNames = new Set(['entry.client']); + for (const route of Object.values(routes)) { + chunkNames.add(getRouteEntryName(route)); + if (!splitRouteModules || route.id === 'root') { + continue; + } + for (const exportName of routeChunkExportNames) { + chunkNames.add(getRouteChunkEntryName(route.id, exportName)); + } + } + return chunkNames; +}; + export async function getReactRouterManifestForDev( routes: Record, //@ts-ignore options: PluginOptions, - clientStats: Rspack.StatsCompilation | undefined, + clientStats: ReactRouterManifestStats | undefined, context: string, assetPrefix = '/', routeChunkOptions?: RouteChunkManifestOptions -): Promise<{ - version: string; - url: string; - hmr?: { - runtime: string; - }; - entry: { - module: string; - imports: string[]; - css: string[]; - }; - sri?: Record; - routes: Record; -}> { +): Promise { const result: Record = {}; const splitRouteModules = routeChunkOptions?.splitRouteModules ?? false; const enforceSplitRouteModules = splitRouteModules === 'enforce'; @@ -148,11 +243,10 @@ export async function getReactRouterManifestForDev( if (!assets) { return [`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`]; } - const normalizedAssets = Array.isArray(assets) ? assets : [assets]; - if (!normalizedAssets.some(asset => asset.endsWith('.js'))) { - return [`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`, ...normalizedAssets]; + if (!assets.some(asset => asset.endsWith('.js'))) { + return [`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`, ...assets]; } - return normalizedAssets; + return assets; }; const getModulePathForChunk = (chunkName: string): string | undefined => { @@ -161,116 +255,116 @@ export async function getReactRouterManifestForDev( return jsAssets[0] ? combineURLs(assetPrefix, jsAssets[0]) : undefined; }; - for (const [key, route] of Object.entries(routes)) { - const routeEntryName = getRouteEntryName(route); - const assets = getAssetsForChunk(routeEntryName); - const jsAssets = assets.filter(asset => asset.endsWith('.js')) || []; - let cssAssets = assets.filter(asset => asset.endsWith('.css')) || []; - // Read and analyze the route file to check for exports - const routeFilePath = resolve(context, route.file); - let exports = new Set(); - let hasRouteChunkByExportName: Record< - 'clientAction' | 'clientLoader' | 'clientMiddleware' | 'HydrateFallback', - boolean - > = { - clientAction: false, - clientLoader: false, - clientMiddleware: false, - HydrateFallback: false, - }; + const manifestEntries = await Promise.all( + Object.entries(routes).map(async ([key, route]) => { + const routeEntryName = getRouteEntryName(route); + const assets = getAssetsForChunk(routeEntryName); + const jsAssets = assets.filter(asset => asset.endsWith('.js')); + let cssAssets = assets.filter(asset => asset.endsWith('.css')); + const routeFilePath = resolve(context, route.file); + let exports = new Set(); + let routeModuleExports: readonly string[] = []; + let hasRouteChunkByExportName: ReturnType< + typeof createEmptyRouteChunkByExportName + > | null = null; - try { - const source = await readFile(routeFilePath, 'utf8'); - if ( - !isBuild && - cssAssets.length === 0 && - /\.(?:css|less|sass|scss)(?:\?[^'"`]+)?['"`]/.test(source) - ) { - cssAssets = [ - `${DEFAULT_MANIFEST_DIR.replace('/js', '/css')}/${routeEntryName}.css`, - ]; + try { + const { code, exports: exportNames } = + await getRouteModuleAnalysis(routeFilePath); + if ( + !isBuild && + cssAssets.length === 0 && + /\.(?:css|less|sass|scss)(?:\?[^'"`]+)?['"`]/.test(code) + ) { + cssAssets = [ + `${DEFAULT_MANIFEST_DIR.replace('/js', '/css')}/${routeEntryName}.css`, + ]; + } + routeModuleExports = exportNames; + exports = new Set(exportNames); + + if (isBuild && routeChunkConfig) { + const { hasRouteChunkByExportName: chunkInfo } = + await detectRouteChunksIfEnabled( + routeChunkOptions?.cache, + routeChunkConfig, + routeFilePath, + code + ); + hasRouteChunkByExportName = chunkInfo; + } + } catch (error) { + if (isBuild) { + throw error; + } + console.error(`Failed to analyze route file ${routeFilePath}:`, error); } - const code = await transformToEsm(source, routeFilePath); - exports = new Set(await getExportNames(code)); - - if (isBuild && routeChunkConfig) { - const { hasRouteChunkByExportName: chunkInfo } = - await detectRouteChunksIfEnabled( - routeChunkOptions?.cache, - routeChunkConfig, - routeFilePath, - code - ); - hasRouteChunkByExportName = chunkInfo; + + const hasClientAction = exports.has(CLIENT_EXPORTS.clientAction); + const hasClientLoader = exports.has(CLIENT_EXPORTS.clientLoader); + const hasClientMiddleware = exports.has(CLIENT_EXPORTS.clientMiddleware); + const hasDefaultExport = exports.has('default'); + const routeChunkMap = hasRouteChunkByExportName; + + if (isBuild && enforceSplitRouteModules && routeChunkConfig) { + validateRouteChunks({ + config: routeChunkConfig, + id: routeFilePath, + valid: buildManifestChunkValidity( + exports, + routeChunkMap ?? createEmptyRouteChunkByExportName() + ), + }); } - } catch (error) { - console.error(`Failed to analyze route file ${routeFilePath}:`, error); - } - const hasClientAction = exports.has(CLIENT_EXPORTS.clientAction); - const hasClientLoader = exports.has(CLIENT_EXPORTS.clientLoader); - const hasClientMiddleware = exports.has(CLIENT_EXPORTS.clientMiddleware); - const hasHydrateFallback = exports.has(CLIENT_EXPORTS.HydrateFallback); - const hasDefaultExport = exports.has('default'); - - if (isBuild && enforceSplitRouteModules && routeChunkConfig) { - validateRouteChunks({ - config: routeChunkConfig, - id: routeFilePath, - valid: { - clientAction: - !hasClientAction || hasRouteChunkByExportName.clientAction, - clientLoader: - !hasClientLoader || hasRouteChunkByExportName.clientLoader, - clientMiddleware: - !hasClientMiddleware || hasRouteChunkByExportName.clientMiddleware, - HydrateFallback: - !hasHydrateFallback || hasRouteChunkByExportName.HydrateFallback, + return [ + key, + { + id: route.id, + parentId: route.parentId, + path: route.path, + index: route.index, + caseSensitive: route.caseSensitive, + module: combineURLs(assetPrefix, jsAssets[0] || ''), + clientActionModule: routeChunkMap?.clientAction + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'clientAction') + ) + : undefined, + clientLoaderModule: routeChunkMap?.clientLoader + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'clientLoader') + ) + : undefined, + clientMiddlewareModule: routeChunkMap?.clientMiddleware + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'clientMiddleware') + ) + : undefined, + hydrateFallbackModule: routeChunkMap?.HydrateFallback + ? getModulePathForChunk( + getRouteChunkEntryName(route.id, 'HydrateFallback') + ) + : undefined, + hasAction: exports.has(SERVER_EXPORTS.action), + hasLoader: exports.has(SERVER_EXPORTS.loader), + hasClientAction, + hasClientLoader, + hasClientMiddleware, + hasDefaultExport, + hasErrorBoundary: exports.has(CLIENT_EXPORTS.ErrorBoundary), + imports: jsAssets.map(asset => combineURLs(assetPrefix, asset)), + css: cssAssets.map(asset => combineURLs(assetPrefix, asset)), }, - }); - } + routeModuleExports, + ] as const; + }) + ); - result[key] = { - id: route.id, - parentId: route.parentId, - path: route.path, - index: route.index, - caseSensitive: route.caseSensitive, - module: combineURLs(assetPrefix, jsAssets[0] || ''), - clientActionModule: - isBuild && hasRouteChunkByExportName.clientAction - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'clientAction') - ) - : undefined, - clientLoaderModule: - isBuild && hasRouteChunkByExportName.clientLoader - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'clientLoader') - ) - : undefined, - clientMiddlewareModule: - isBuild && hasRouteChunkByExportName.clientMiddleware - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'clientMiddleware') - ) - : undefined, - hydrateFallbackModule: - isBuild && hasRouteChunkByExportName.HydrateFallback - ? getModulePathForChunk( - getRouteChunkEntryName(route.id, 'HydrateFallback') - ) - : undefined, - hasAction: exports.has(SERVER_EXPORTS.action), - hasLoader: exports.has(SERVER_EXPORTS.loader), - hasClientAction, - hasClientLoader, - hasClientMiddleware, - hasDefaultExport, - hasErrorBoundary: exports.has(CLIENT_EXPORTS.ErrorBoundary), - imports: jsAssets.map(asset => combineURLs(assetPrefix, asset)), - css: cssAssets.map(asset => combineURLs(assetPrefix, asset)), - }; + const routeModuleExportsByRouteId: RouteManifestModuleExports = {}; + for (const [key, routeManifestItem, routeModuleExports] of manifestEntries) { + result[key] = routeManifestItem; + routeModuleExportsByRouteId[key] = routeModuleExports; } const entryAssets = getAssetsForChunk('entry.client'); @@ -292,7 +386,7 @@ export async function getReactRouterManifestForDev( entryModulePath: entryJsAssets[0], }); - return { + const manifest = { version, url: combineURLs(assetPrefix, manifestPath), hmr: undefined, @@ -300,4 +394,7 @@ export async function getReactRouterManifestForDev( sri: undefined, routes: result, }; + + routeManifestModuleExports.set(manifest, routeModuleExportsByRouteId); + return manifest; } diff --git a/src/modify-browser-manifest.ts b/src/modify-browser-manifest.ts index 50dff2e..b172eac 100644 --- a/src/modify-browser-manifest.ts +++ b/src/modify-browser-manifest.ts @@ -3,6 +3,8 @@ import type { Route, PluginOptions } from './types.js'; import { rspack } from '@rsbuild/core'; import type { Rspack } from '@rsbuild/core'; import { + createReactRouterManifestStats, + getReactRouterManifestChunkNames, getReactRouterManifestForDev, getReactRouterManifestPath, } from './manifest.js'; @@ -24,18 +26,29 @@ export function createModifyBrowserManifestPlugin( routeChunkOptions?: Parameters[5], options?: { future?: { unstable_subResourceIntegrity?: boolean }; + manifestChunkNames?: ReadonlySet; onManifest?: ( manifest: Awaited>, sri: Record | undefined ) => void; } ) { + const manifestChunkNames = + options?.manifestChunkNames ?? + getReactRouterManifestChunkNames( + routes, + routeChunkOptions?.splitRouteModules + ); + return { apply(compiler: Rspack.Compiler): void { compiler.hooks.emit.tapAsync( 'ModifyBrowserManifest', async (compilation: Rspack.Compilation, callback) => { - const stats = compilation.getStats().toJson(); + const stats = createReactRouterManifestStats( + compilation, + manifestChunkNames + ); const manifest = await getReactRouterManifestForDev( routes, pluginOptions, diff --git a/src/parallel-route-transform-worker.ts b/src/parallel-route-transform-worker.ts new file mode 100644 index 0000000..e9c0ad3 --- /dev/null +++ b/src/parallel-route-transform-worker.ts @@ -0,0 +1,106 @@ +import { parentPort } from 'node:worker_threads'; +import { setBoundedCacheEntry } from './bounded-cache.js'; +import { + executeRouteTransformTask, + type RouteTransformResult, + type RouteTransformTask, +} from './route-transform-tasks.js'; + +type CachedRouteTransformTask = Omit & { + code?: string; +}; + +type WorkerRequest = { + id: number; + task: RouteTransformTask | CachedRouteTransformTask; + sourceCacheKey?: string; +}; + +type WorkerErrorPayload = { + name?: string; + message: string; + stack?: string; +}; + +type WorkerResponse = + | { + id: number; + ok: true; + result: RouteTransformResult; + } + | { + id: number; + ok: false; + error: WorkerErrorPayload; + }; + +const serializeError = (error: unknown): WorkerErrorPayload => { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + return { + message: String(error), + }; +}; + +if (!parentPort) { + throw new Error('parallel route transform worker requires parentPort'); +} + +const MAX_SOURCE_CACHE_ENTRIES = 2048; +const sourceCache = new Map(); + +const hydrateTaskSource = ({ + task, + sourceCacheKey, +}: Pick): RouteTransformTask => { + if (!sourceCacheKey) { + return task as RouteTransformTask; + } + + if (typeof task.code === 'string') { + setBoundedCacheEntry( + sourceCache, + sourceCacheKey, + task.code, + MAX_SOURCE_CACHE_ENTRIES + ); + return task as RouteTransformTask; + } + + const code = sourceCache.get(sourceCacheKey); + if (code === undefined) { + throw new Error( + `Missing cached route transform source for ${sourceCacheKey}.` + ); + } + return { + ...task, + code, + } as RouteTransformTask; +}; + +parentPort.on( + 'message', + async ({ id, task, sourceCacheKey }: WorkerRequest) => { + try { + const hydratedTask = hydrateTaskSource({ task, sourceCacheKey }); + const result = await executeRouteTransformTask(hydratedTask); + parentPort?.postMessage({ + id, + ok: true, + result, + } satisfies WorkerResponse); + } catch (error) { + parentPort?.postMessage({ + id, + ok: false, + error: serializeError(error), + } satisfies WorkerResponse); + } + } +); diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts new file mode 100644 index 0000000..6eddf6d --- /dev/null +++ b/src/parallel-route-transforms.ts @@ -0,0 +1,378 @@ +import { Worker } from 'node:worker_threads'; +import { setBoundedCacheEntry } from './bounded-cache.js'; +import { SERVER_ONLY_ROUTE_EXPORTS } from './constants.js'; +import { getDefaultConcurrency } from './concurrency.js'; +import { + executeRouteTransformTask, + type RouteTransformResult, + type RouteTransformTask, + type RouteTransformTaskOptions, +} from './route-transform-tasks.js'; +import type { PluginOptions } from './types.js'; + +export type ParallelTransformsConfig = + NonNullable extends infer Config + ? Exclude + : never; + +export type RouteTransformExecutorOptions = RouteTransformTaskOptions & { + parallelTransforms?: PluginOptions['parallelTransforms']; + splitRouteModules?: boolean; +}; + +export type RouteTransformExecutor = { + run: (task: RouteTransformTask) => Promise; + close: () => Promise; +}; + +type WorkerResponse = + | { + id: number; + ok: true; + result: RouteTransformResult; + } + | { + id: number; + ok: false; + error: WorkerErrorPayload; + }; + +type WorkerRequest = { + id: number; + task: + | RouteTransformTask + | (Omit & { code?: string }); + sourceCacheKey?: string; +}; + +type WorkerErrorPayload = { + name?: string; + message: string; + stack?: string; +}; + +type PendingTask = { + resolve: (result: RouteTransformResult) => void; + reject: (error: Error) => void; +}; + +type WorkerState = { + worker: Worker; + pending: Map; + sourceCache: Map; + startupError?: WorkerStartupError; +}; + +type RouteModuleResultCacheEntry = { + source: string; + result: Promise; +}; + +class WorkerStartupError extends Error { + constructor(message: string) { + super(message); + this.name = 'WorkerStartupError'; + } +} + +const MAX_WORKER_SOURCE_CACHE_ENTRIES = 2048; +const MAX_ROUTE_MODULE_RESULT_CACHE_ENTRIES = 2048; + +export const getDefaultWorkerCount = (cpuCount?: number): number => + getDefaultConcurrency(cpuCount); + +const getConfiguredWorkerCount = ( + parallelTransforms: ParallelTransformsConfig +): number => { + if (parallelTransforms === true) { + return getDefaultWorkerCount(); + } + + const configured = parallelTransforms.maxWorkers; + if (configured === undefined) { + return getDefaultWorkerCount(); + } + if (!Number.isFinite(configured) || configured < 1) { + throw new Error( + '[react-router] parallelTransforms.maxWorkers must be at least 1.' + ); + } + return Math.floor(configured); +}; + +const hashString = (value: string): number => { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + return hash; +}; + +const deserializeWorkerError = (error: WorkerErrorPayload): Error => { + const result = new Error(error.message); + result.name = error.name ?? 'Error'; + if (error.stack) { + result.stack = error.stack; + } + return result; +}; + +const createWorkerUrl = (): URL => + new URL('./parallel-route-transform-worker.js', import.meta.url); + +const isWorkerStartupError = (error: unknown): error is WorkerStartupError => + error instanceof WorkerStartupError; + +const canShareRouteModuleBuildResult = (task: RouteTransformTask): boolean => + task.kind === 'routeModule' && + task.isBuild && + task.ssr && + !task.isSpaMode && + !SERVER_ONLY_ROUTE_EXPORTS.some(exportName => task.code.includes(exportName)); + +class ParallelRouteTransformExecutor implements RouteTransformExecutor { + #closed = false; + #nextId = 1; + #nextRouteModuleWorkerIndex = 0; + #nextSplitRouteAnalysisWorkerIndex = 0; + #routeModuleResultCache = new Map(); + #splitRouteAnalysisWorkers = new Map(); + #workers: WorkerState[]; + + constructor( + workerCount: number, + private readonly options: RouteTransformTaskOptions, + private readonly balanceRouteModuleTransforms: boolean, + private readonly shareRouteModuleBuildResults: boolean + ) { + this.#workers = Array.from({ length: workerCount }, () => + this.#createWorkerState() + ); + } + + async run(task: RouteTransformTask): Promise { + if (this.#closed) { + return executeRouteTransformTask(task, this.options); + } + + if ( + this.shareRouteModuleBuildResults && + canShareRouteModuleBuildResult(task) + ) { + return this.#runCachedRouteModuleBuildTask(task); + } + + try { + return await this.#runInWorker(task); + } catch (error) { + if (isWorkerStartupError(error)) { + return executeRouteTransformTask(task, this.options); + } + throw error; + } + } + + async close(): Promise { + if (this.#closed) { + return; + } + this.#closed = true; + const workers = this.#workers; + this.#workers = []; + await Promise.all( + workers.map(async state => { + for (const pending of state.pending.values()) { + pending.reject(new Error('Route transform worker closed.')); + } + state.pending.clear(); + await state.worker.terminate(); + }) + ); + } + + #createWorkerState(): WorkerState { + const worker = new Worker(createWorkerUrl()); + const state: WorkerState = { + worker, + pending: new Map(), + sourceCache: new Map(), + }; + + worker.on('message', (response: WorkerResponse) => { + const pending = state.pending.get(response.id); + if (!pending) { + return; + } + state.pending.delete(response.id); + if (response.ok) { + pending.resolve(response.result); + } else { + pending.reject(deserializeWorkerError(response.error)); + } + }); + + worker.on('error', (error: Error) => { + const startupError = new WorkerStartupError(error.message); + startupError.stack = error.stack; + state.startupError = startupError; + for (const pending of state.pending.values()) { + pending.reject(startupError); + } + state.pending.clear(); + }); + + worker.on('exit', code => { + if (this.#closed || code === 0) { + return; + } + const startupError = new WorkerStartupError( + `Route transform worker exited with code ${code}.` + ); + state.startupError = startupError; + for (const pending of state.pending.values()) { + pending.reject(startupError); + } + state.pending.clear(); + }); + + return state; + } + + #runCachedRouteModuleBuildTask( + task: RouteTransformTask + ): Promise { + const cacheKey = task.resourcePath; + const cached = this.#routeModuleResultCache.get(cacheKey); + if (cached?.source === task.code) { + return cached.result; + } + + const result = this.#runInWorker(task).catch(error => { + if (this.#routeModuleResultCache.get(cacheKey)?.result === result) { + this.#routeModuleResultCache.delete(cacheKey); + } + if (isWorkerStartupError(error)) { + return executeRouteTransformTask(task, this.options); + } + throw error; + }); + setBoundedCacheEntry( + this.#routeModuleResultCache, + cacheKey, + { + source: task.code, + result, + }, + MAX_ROUTE_MODULE_RESULT_CACHE_ENTRIES + ); + return result; + } + + #runInWorker(task: RouteTransformTask): Promise { + const workerIndex = this.#getWorkerIndex(task); + const state = this.#workers[workerIndex]; + if (!state) { + return executeRouteTransformTask(task, this.options); + } + if (state.startupError) { + return Promise.reject(state.startupError); + } + + const id = this.#nextId++; + const sourceCacheKey = task.resourcePath; + const requestTask = this.#createWorkerRequestTask( + state, + task, + sourceCacheKey + ); + return new Promise((resolve, reject) => { + state.pending.set(id, { resolve, reject }); + state.worker.postMessage({ + id, + task: requestTask, + sourceCacheKey, + } satisfies WorkerRequest); + }); + } + + #createWorkerRequestTask( + state: WorkerState, + task: RouteTransformTask, + sourceCacheKey: string + ): WorkerRequest['task'] { + const cachedSource = state.sourceCache.get(sourceCacheKey); + if (cachedSource === task.code) { + const { code: _code, ...cachedTask } = task; + return cachedTask; + } + + setBoundedCacheEntry( + state.sourceCache, + sourceCacheKey, + task.code, + MAX_WORKER_SOURCE_CACHE_ENTRIES + ); + return task; + } + + #getWorkerIndex(task: RouteTransformTask): number { + const workerCount = Math.max(1, this.#workers.length); + if ( + this.balanceRouteModuleTransforms && + (task.kind === 'routeClientEntry' || + task.kind === 'routeChunk' || + task.kind === 'splitRouteExports') + ) { + const existingWorkerIndex = this.#splitRouteAnalysisWorkers.get( + task.resourcePath + ); + if (existingWorkerIndex !== undefined) { + return existingWorkerIndex % workerCount; + } + const workerIndex = this.#nextSplitRouteAnalysisWorkerIndex % workerCount; + this.#nextSplitRouteAnalysisWorkerIndex += 1; + this.#splitRouteAnalysisWorkers.set(task.resourcePath, workerIndex); + return workerIndex; + } + if ( + this.balanceRouteModuleTransforms && + task.kind === 'routeModule' && + !(task.environmentName === 'web' && !task.ssr && task.isSpaMode) + ) { + const workerIndex = this.#nextRouteModuleWorkerIndex % workerCount; + this.#nextRouteModuleWorkerIndex += 1; + return workerIndex; + } + return hashString(task.resourcePath) % workerCount; + } +} + +export const createRouteTransformExecutor = ({ + parallelTransforms, + routeChunkCache, + splitRouteModules, +}: RouteTransformExecutorOptions = {}): RouteTransformExecutor => { + const options = { routeChunkCache }; + const effectiveParallelTransforms = parallelTransforms ?? true; + if (!effectiveParallelTransforms) { + return { + run: task => executeRouteTransformTask(task, options), + close: async () => {}, + }; + } + + const workerCount = getConfiguredWorkerCount(effectiveParallelTransforms); + if (workerCount < 1) { + return { + run: task => executeRouteTransformTask(task, options), + close: async () => {}, + }; + } + + return new ParallelRouteTransformExecutor( + workerCount, + options, + Boolean(splitRouteModules), + Boolean(splitRouteModules) + ); +}; diff --git a/src/performance.ts b/src/performance.ts new file mode 100644 index 0000000..fb9adf6 --- /dev/null +++ b/src/performance.ts @@ -0,0 +1,237 @@ +type OperationTiming = { + count: number; + // Total sums every recorded duration, so parallel work can make it larger + // than elapsed wall-clock time. Use wallMs for non-overlapping elapsed time. + totalMs: number; + wallMs: number; + maxMs: number; + slowest: Array<{ + durationMs: number; + resource: string; + }>; +}; + +type OperationInterval = { startMs: number; endMs: number }; + +type MutableOperationTiming = Omit & { + slowest: Array<{ + durationMs: number; + resource: string; + }>; + intervals: OperationInterval[]; +}; + +type EnvironmentTimings = Map; + +const MAX_SLOWEST_ENTRIES = 5; + +const insertSlowestEntry = ( + slowest: MutableOperationTiming['slowest'], + entry: MutableOperationTiming['slowest'][number] +) => { + if ( + slowest.length === MAX_SLOWEST_ENTRIES && + entry.durationMs <= slowest[slowest.length - 1].durationMs + ) { + return; + } + + let insertIndex = slowest.length; + while ( + insertIndex > 0 && + entry.durationMs > slowest[insertIndex - 1].durationMs + ) { + insertIndex -= 1; + } + slowest.splice(insertIndex, 0, entry); + if (slowest.length > MAX_SLOWEST_ENTRIES) { + slowest.pop(); + } +}; + +export const roundMs = (value: number): number => Math.round(value * 10) / 10; + +export type ReactRouterPerformanceReport = { + environment: string; + compilerLifecycleMs?: number; + operations: Record; +}; + +export type ReactRouterPerformanceProfiler = { + record( + environment: string | undefined, + operation: string, + resource: string, + callback: () => Promise + ): Promise; + recordSync( + environment: string | undefined, + operation: string, + resource: string, + callback: () => T + ): T; + flush( + environment: string, + details?: Pick + ): void; +}; + +export const createReactRouterPerformanceProfiler = ({ + enabled, + log, +}: { + enabled: boolean; + log: (message: string) => void; +}): ReactRouterPerformanceProfiler => { + const timingsByEnvironment = new Map(); + + const getOperationTiming = ( + environment: string, + operation: string + ): MutableOperationTiming => { + let timings = timingsByEnvironment.get(environment); + if (!timings) { + timings = new Map(); + timingsByEnvironment.set(environment, timings); + } + + let timing = timings.get(operation); + if (!timing) { + timing = { + count: 0, + totalMs: 0, + maxMs: 0, + slowest: [], + intervals: [], + }; + timings.set(operation, timing); + } + return timing; + }; + + const computeWallMs = (intervals: OperationInterval[]) => { + if (intervals.length === 0) { + return 0; + } + + const sortedIntervals = [...intervals].sort( + (a, b) => a.startMs - b.startMs || a.endMs - b.endMs + ); + let mergedStart = sortedIntervals[0].startMs; + let mergedEnd = sortedIntervals[0].endMs; + let wallMs = 0; + + for (const interval of sortedIntervals.slice(1)) { + if (interval.startMs <= mergedEnd) { + mergedEnd = Math.max(mergedEnd, interval.endMs); + continue; + } + + wallMs += mergedEnd - mergedStart; + mergedStart = interval.startMs; + mergedEnd = interval.endMs; + } + + wallMs += mergedEnd - mergedStart; + return roundMs(wallMs); + }; + + const toOperationTiming = ( + timing: MutableOperationTiming + ): OperationTiming => ({ + count: timing.count, + totalMs: roundMs(timing.totalMs), + wallMs: computeWallMs(timing.intervals), + maxMs: roundMs(timing.maxMs), + slowest: timing.slowest.map(entry => ({ + durationMs: roundMs(entry.durationMs), + resource: entry.resource, + })), + }); + + const recordDuration = ( + environment: string, + operation: string, + resource: string, + startMs: number, + endMs: number + ) => { + const duration = endMs - startMs; + const timing = getOperationTiming(environment, operation); + timing.count += 1; + timing.totalMs += duration; + timing.maxMs = Math.max(timing.maxMs, duration); + timing.intervals.push({ startMs, endMs }); + insertSlowestEntry(timing.slowest, { + durationMs: duration, + resource, + }); + }; + + return { + record(environment, operation, resource, callback) { + if (!enabled) { + return callback(); + } + + const resolvedEnvironment = environment ?? 'unknown'; + const start = performance.now(); + try { + return callback().then( + result => { + const end = performance.now(); + recordDuration(resolvedEnvironment, operation, resource, start, end); + return result; + }, + error => { + const end = performance.now(); + recordDuration(resolvedEnvironment, operation, resource, start, end); + throw error; + } + ); + } catch (error) { + const end = performance.now(); + recordDuration(resolvedEnvironment, operation, resource, start, end); + return Promise.reject(error); + } + }, + recordSync(environment, operation, resource, callback) { + if (!enabled) { + return callback(); + } + + const resolvedEnvironment = environment ?? 'unknown'; + const start = performance.now(); + try { + return callback(); + } finally { + const end = performance.now(); + recordDuration(resolvedEnvironment, operation, resource, start, end); + } + }, + flush(environment, details = {}) { + if (!enabled) { + return; + } + + const timings = timingsByEnvironment.get(environment); + if (!timings || timings.size === 0) { + return; + } + + const operations = Object.fromEntries( + [...timings.entries()].map(([operation, timing]) => [ + operation, + toOperationTiming(timing), + ]) + ); + const report: ReactRouterPerformanceReport = { + environment, + ...details, + operations, + }; + log(`[react-router:performance] ${JSON.stringify(report)}`); + timingsByEnvironment.delete(environment); + }, + }; +}; diff --git a/src/plugin-utils.ts b/src/plugin-utils.ts index 519c18a..1ba9166 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -1,24 +1,44 @@ -import { - deadCodeElimination, - findReferencedIdentifiers, -} from 'babel-dead-code-elimination'; import { normalize } from 'pathe'; import { existsSync } from 'node:fs'; -import type { Babel, NodePath, ParseResult } from './babel.js'; -import { t, traverse } from './babel.js'; -import { NAMED_COMPONENT_EXPORTS, JS_EXTENSIONS } from './constants.js'; +import { walk, type ParseResult } from 'yuku-parser'; +import { + NAMED_COMPONENT_EXPORTS, + NAMED_COMPONENT_EXPORTS_SET, + JS_EXTENSIONS, +} from './constants.js'; + +type AnyNode = Record; + +const getProgram = (ast: ParseResult | AnyNode): AnyNode => + (ast as ParseResult).program ?? ast; export function validateDestructuredExports( - id: Babel.ArrayPattern | Babel.ObjectPattern, - exportsToRemove: string[] + id: AnyNode, + exportsToRemove: readonly string[] ): void { + if (id.type === 'Identifier') { + if (exportsToRemove.includes(id.name)) { + throw invalidDestructureError(id.name); + } + return; + } + + if (id.type === 'AssignmentPattern') { + validateDestructuredExports(id.left, exportsToRemove); + return; + } + if (id.type === 'ArrayPattern') { - for (const element of id.elements) { + for (const element of id.elements ?? []) { if (!element) { continue; } - // [ foo ] + if (element.type === 'AssignmentPattern') { + validateDestructuredExports(element, exportsToRemove); + continue; + } + if ( element.type === 'Identifier' && exportsToRemove.includes(element.name) @@ -26,7 +46,6 @@ export function validateDestructuredExports( throw invalidDestructureError(element.name); } - // [ ...foo ] if ( element.type === 'RestElement' && element.argument.type === 'Identifier' && @@ -35,8 +54,6 @@ export function validateDestructuredExports( throw invalidDestructureError(element.argument.name); } - // [ [...] ] - // [ {...} ] if (element.type === 'ArrayPattern' || element.type === 'ObjectPattern') { validateDestructuredExports(element, exportsToRemove); } @@ -44,16 +61,12 @@ export function validateDestructuredExports( } if (id.type === 'ObjectPattern') { - for (const property of id.properties) { + for (const property of id.properties ?? []) { if (!property) { continue; } - if ( - property.type === 'ObjectProperty' && - property.key.type === 'Identifier' - ) { - // { foo } + if (property.type === 'Property') { if ( property.value.type === 'Identifier' && exportsToRemove.includes(property.value.name) @@ -61,9 +74,8 @@ export function validateDestructuredExports( throw invalidDestructureError(property.value.name); } - // { foo: [...] } - // { foo: {...} } if ( + property.value.type === 'AssignmentPattern' || property.value.type === 'ArrayPattern' || property.value.type === 'ObjectPattern' ) { @@ -71,7 +83,6 @@ export function validateDestructuredExports( } } - // { ...foo } if ( property.type === 'RestElement' && property.argument.type === 'Identifier' && @@ -87,14 +98,20 @@ export function invalidDestructureError(name: string): Error { return new Error(`Cannot remove destructured export "${name}"`); } -export function toFunctionExpression(decl: Babel.FunctionDeclaration): any { - return t.functionExpression( - decl.id, - decl.params, - decl.body, - decl.generator, - decl.async - ); +export function toFunctionExpression(decl: AnyNode): AnyNode { + return { + ...decl, + type: 'FunctionExpression', + declare: undefined, + }; +} + +export function toClassExpression(decl: AnyNode): AnyNode { + return { + ...decl, + type: 'ClassExpression', + declare: undefined, + }; } export function combineURLs(baseURL: string, relativeURL: string): string { @@ -118,11 +135,6 @@ export function createRouteId(file: string): string { return normalize(stripFileExtension(file)); } -/** - * Find a file with any of the supported JavaScript extensions - * @param basePath - The base path without extension - * @returns The file path with extension if found, or a default path - */ export function findEntryFile(basePath: string): string { for (const ext of JS_EXTENSIONS) { const filePath = `${basePath}${ext}`; @@ -130,7 +142,7 @@ export function findEntryFile(basePath: string): string { return filePath; } } - return `${basePath}.tsx`; // Default to .tsx if no file exists + return `${basePath}.tsx`; } export function generateWithProps() { @@ -173,278 +185,876 @@ export function generateWithProps() { `; } -export const removeExports = ( - ast: ParseResult, - exportsToRemove: string[] -): void => { - const previouslyReferencedIdentifiers = findReferencedIdentifiers(ast); - let exportsFiltered = false; - const markedForRemoval = new Set>(); - // Keep track of identifiers referenced by removed exports, - // e.g. export { localName as exportName }, export default function localName - const removedExportLocalNames = new Set(); +const removeFromArray = (array: T[], value: T): void => { + const index = array.indexOf(value); + if (index >= 0) { + array.splice(index, 1); + } +}; - traverse(ast, { - ExportDeclaration(path: NodePath) { - // export { foo }; - // export { bar } from "./module"; - if (path.node.type === 'ExportNamedDeclaration') { - if (path.node.specifiers.length) { - //@ts-ignore - path.node.specifiers = path.node.specifiers.filter( - ( - specifier: - | Babel.ExportSpecifier - | Babel.ExportDefaultSpecifier - | Babel.ExportNamespaceSpecifier - ) => { - // Filter out individual specifiers - if ( - specifier.type === 'ExportSpecifier' && - specifier.exported.type === 'Identifier' - ) { - if (exportsToRemove.includes(specifier.exported.name)) { - exportsFiltered = true; - // Track the local identifier if it's different from the exported name - if ( - specifier.local && - specifier.local.type === 'Identifier' && - specifier.local.name !== specifier.exported.name - ) { - removedExportLocalNames.add(specifier.local.name); - } - return false; - } - } - return true; - } - ); - // Remove the entire export statement if all specifiers were removed - if (path.node.specifiers.length === 0) { - markedForRemoval.add(path); - } - } +const getPatternIdentifierNames = ( + pattern: AnyNode | null | undefined, + names = new Set() +): Set => { + if (!pattern) { + return names; + } + if (pattern.type === 'Identifier') { + names.add(pattern.name); + return names; + } + if (pattern.type === 'RestElement') { + return getPatternIdentifierNames(pattern.argument, names); + } + if (pattern.type === 'AssignmentPattern') { + return getPatternIdentifierNames(pattern.left, names); + } + if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements ?? []) { + getPatternIdentifierNames(element, names); + } + return names; + } + if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties ?? []) { + if (property.type === 'RestElement') { + getPatternIdentifierNames(property.argument, names); + } else { + getPatternIdentifierNames(property.value, names); + } + } + } + return names; +}; - // export const foo = ...; - // export const [ foo ] = ...; - if (path.node.declaration?.type === 'VariableDeclaration') { - const declaration = path.node.declaration; - declaration.declarations = declaration.declarations.filter( - (declaration: Babel.VariableDeclarator) => { - // export const foo = ...; - // export const foo = ..., bar = ...; - if ( - declaration.id.type === 'Identifier' && - exportsToRemove.includes(declaration.id.name) - ) { - // Filter out individual variables - exportsFiltered = true; - return false; - } +const getDeclaredNames = (node: AnyNode): Set => { + const names = new Set(); + if (node.type === 'VariableDeclaration') { + for (const declarator of node.declarations ?? []) { + getPatternIdentifierNames(declarator.id, names); + } + } else if ( + (node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration') && + node.id?.name + ) { + names.add(node.id.name); + } else if (node.type === 'ImportDeclaration') { + for (const specifier of node.specifiers ?? []) { + if (specifier.local?.name) { + names.add(specifier.local.name); + } + } + } + return names; +}; - // export const [ foo ] = ...; - // export const { foo } = ...; - if ( - declaration.id.type === 'ArrayPattern' || - declaration.id.type === 'ObjectPattern' - ) { - // NOTE: These exports cannot be safely removed, so instead we - // validate them to ensure that any exports that are intended to - // be removed are not present - validateDestructuredExports(declaration.id, exportsToRemove); - } +const isIdentifierDeclaration = (node: AnyNode, parent: AnyNode | null) => { + if (!parent || node.type !== 'Identifier') { + return false; + } + if ( + (parent.type === 'FunctionDeclaration' || + parent.type === 'FunctionExpression' || + parent.type === 'ClassDeclaration' || + parent.type === 'ClassExpression') && + parent.id === node + ) { + return true; + } + if (parent.type === 'VariableDeclarator') { + return getPatternIdentifierNames(parent.id).has(node.name); + } + if ( + (parent.type === 'ImportSpecifier' || + parent.type === 'ImportDefaultSpecifier' || + parent.type === 'ImportNamespaceSpecifier') && + parent.local === node + ) { + return true; + } + if ( + (parent.type === 'FunctionDeclaration' || + parent.type === 'FunctionExpression' || + parent.type === 'ArrowFunctionExpression') && + (parent.params ?? []).some((param: AnyNode) => + getPatternIdentifierNames(param).has(node.name) + ) + ) { + return true; + } + return false; +}; - return true; - } - ); - // Remove the entire export statement if all variables were removed - if (declaration.declarations.length === 0) { - markedForRemoval.add(path); - } - } +const isNonReferenceIdentifier = (node: AnyNode, parent: AnyNode | null) => { + if (!parent || node.type !== 'Identifier') { + return false; + } + if (isIdentifierDeclaration(node, parent)) { + return true; + } + if ( + parent.type === 'MemberExpression' && + parent.property === node && + !parent.computed + ) { + return true; + } + if ( + parent.type === 'Property' && + parent.key === node && + !parent.computed && + !parent.shorthand + ) { + return true; + } + if ( + parent.type === 'MethodDefinition' && + parent.key === node && + !parent.computed + ) { + return true; + } + if ( + parent.type === 'ExportSpecifier' || + parent.type === 'ExportDefaultSpecifier' || + parent.type === 'ExportNamespaceSpecifier' + ) { + return true; + } + if (parent.type === 'ImportSpecifier' && parent.imported === node) { + return true; + } + if ( + parent.type === 'LabeledStatement' || + parent.type === 'BreakStatement' || + parent.type === 'ContinueStatement' + ) { + return true; + } + return false; +}; - // export function foo() {} - if (path.node.declaration?.type === 'FunctionDeclaration') { - const id = path.node.declaration.id; - if (id && exportsToRemove.includes(id.name)) { - markedForRemoval.add(path); - } - } +const isUppercaseName = (name: string): boolean => /^[A-Z]/.test(name); - // export class Foo() {} - if (path.node.declaration?.type === 'ClassDeclaration') { - const id = path.node.declaration.id; - if (id && exportsToRemove.includes(id.name)) { - markedForRemoval.add(path); - } - } +const collectReferencedNames = (node: AnyNode): Set => { + const referenced = new Set(); + walk(node as any, { + Identifier(node: AnyNode, ctx: any) { + const parent = ctx.parent as AnyNode | null; + if (!isNonReferenceIdentifier(node, parent)) { + referenced.add(node.name); + } + }, + JSXIdentifier(node: AnyNode, ctx: any) { + const parent = ctx.parent as AnyNode | null; + if (!parent || !isUppercaseName(node.name)) { + return; } - - // export default ...; if ( - path.node.type === 'ExportDefaultDeclaration' && - exportsToRemove.includes('default') + (parent.type === 'JSXOpeningElement' || + parent.type === 'JSXClosingElement') && + parent.name === node ) { - markedForRemoval.add(path); - // Track the identifier being exported as default - if (path.node.declaration) { - if (path.node.declaration.type === 'Identifier') { - removedExportLocalNames.add(path.node.declaration.name); - } else if ( - (path.node.declaration.type === 'FunctionDeclaration' || - path.node.declaration.type === 'ClassDeclaration') && - path.node.declaration.id - ) { - removedExportLocalNames.add(path.node.declaration.id.name); - } - } + referenced.add(node.name); + return; + } + if (parent.type === 'JSXMemberExpression' && parent.object === node) { + referenced.add(node.name); + } + }, + ExportSpecifier(node: AnyNode, ctx: any) { + const declaration = ctx.parent as AnyNode | null; + if ( + !declaration?.source && + declaration?.exportKind !== 'type' && + node.local?.name && + node.exportKind !== 'type' + ) { + referenced.add(node.local.name); } }, }); + return referenced; +}; - // Remove top-level property assignments to removed exports. Handles - // `clientLoader.hydrate = true`, `Component.displayName = "..."`, etc. - traverse(ast, { - ExpressionStatement(path: NodePath) { - // Only handle top-level statements - if (!path.parentPath.isProgram()) { - return; +const getExportedName = (specifier: AnyNode): string | null => { + const exported = specifier.exported; + if (!exported) { + return null; + } + if (exported.type === 'Identifier') { + return exported.name; + } + if (exported.type === 'Literal') { + return String(exported.value); + } + return null; +}; + +type TopLevelDeclaration = { + referencedNames: Set; +}; + +type TopLevelDeclarationGraph = { + declarationsByNode: Map; + declarationsByName: Map>; +}; + +const createTopLevelDeclarationGraph = ( + program: AnyNode +): TopLevelDeclarationGraph => { + const declarationsByNode = new Map(); + const declarationsByName = new Map>(); + + const registerDeclaration = ( + node: AnyNode, + declarationNode: AnyNode, + declaredNames: Set + ) => { + const declaration: TopLevelDeclaration = { + referencedNames: collectReferencedNames(declarationNode), + }; + declarationsByNode.set(node, declaration); + for (const name of declaredNames) { + const namedDeclarations = declarationsByName.get(name) ?? new Set(); + namedDeclarations.add(declaration); + declarationsByName.set(name, namedDeclarations); + } + }; + + for (const statement of program.body ?? []) { + if (statement.type === 'VariableDeclaration') { + for (const declarator of statement.declarations) { + registerDeclaration( + declarator, + declarator, + getPatternIdentifierNames(declarator.id) + ); } + continue; + } + if ( + statement.type === 'FunctionDeclaration' || + statement.type === 'ClassDeclaration' + ) { + registerDeclaration(statement, statement, getDeclaredNames(statement)); + } + } - const expr = path.node.expression; - if (expr.type !== 'AssignmentExpression') { - return; + return { declarationsByNode, declarationsByName }; +}; + +const collectLiveTopLevelDeclarations = ( + program: AnyNode, + graph: TopLevelDeclarationGraph +): Set => { + const pendingNames: string[] = []; + + for (const statement of program.body ?? []) { + if (statement.type === 'VariableDeclaration') { + continue; + } + if (graph.declarationsByNode.has(statement)) { + continue; + } + for (const name of collectReferencedNames(statement)) { + pendingNames.push(name); + } + } + + // This is intentionally name-based and conservative: shadowing may retain a + // declaration, but it must never make a live declaration removable. + const visitedNames = new Set(); + const liveDeclarations = new Set(); + while (pendingNames.length > 0) { + const name = pendingNames.pop(); + if (!name || visitedNames.has(name)) { + continue; + } + visitedNames.add(name); + for (const declaration of graph.declarationsByName.get(name) ?? []) { + if (!liveDeclarations.has(declaration)) { + liveDeclarations.add(declaration); + for (const referencedName of declaration.referencedNames) { + pendingNames.push(referencedName); + } } + } + } + + return liveDeclarations; +}; - const left = expr.left; +const declarationReferencesName = ( + declaration: TopLevelDeclaration, + names: ReadonlySet, + graph: TopLevelDeclarationGraph, + cache: Map, + visitedNames = new Set() +): boolean => { + const cached = cache.get(declaration); + if (cached !== undefined) { + return cached; + } + + for (const referencedName of declaration.referencedNames) { + if (names.has(referencedName)) { + cache.set(declaration, true); + return true; + } + if (visitedNames.has(referencedName)) { + continue; + } + visitedNames.add(referencedName); + for (const referencedDeclaration of graph.declarationsByName.get( + referencedName + ) ?? []) { if ( - left.type === 'MemberExpression' && - left.object.type === 'Identifier' && - (exportsToRemove.includes(left.object.name) || - removedExportLocalNames.has(left.object.name)) + declarationReferencesName( + referencedDeclaration, + names, + graph, + cache, + visitedNames + ) ) { - markedForRemoval.add(path as any); + cache.set(declaration, true); + return true; } - }, + } + } + cache.set(declaration, false); + return false; +}; + +const removeNewlyDeadTopLevelDeclarations = ( + program: AnyNode, + graph: TopLevelDeclarationGraph, + previouslyLive: ReadonlySet, + removedExportReferencedNames: ReadonlySet +): void => { + const currentlyLive = collectLiveTopLevelDeclarations(program, graph); + const removedReferenceCache = new Map(); + const isRemovableDeadDeclaration = (node: AnyNode) => { + const declaration = graph.declarationsByNode.get(node); + if (!declaration || currentlyLive.has(declaration)) { + return false; + } + return ( + previouslyLive.has(declaration) || + declarationReferencesName( + declaration, + removedExportReferencedNames, + graph, + removedReferenceCache + ) + ); + }; + + program.body = program.body.filter((statement: AnyNode) => { + if (statement.type === 'VariableDeclaration') { + statement.declarations = statement.declarations.filter( + (declarator: AnyNode) => !isRemovableDeadDeclaration(declarator) + ); + return statement.declarations.length > 0; + } + return !isRemovableDeadDeclaration(statement); }); +}; - if (markedForRemoval.size > 0 || exportsFiltered) { - for (const path of markedForRemoval) { - path.remove(); +const hasRemovableExport = ( + program: AnyNode, + exportsToRemove: ReadonlySet +): boolean => { + for (const statement of program.body ?? []) { + if (statement.type === 'ExportAllDeclaration') { + const exportedName = statement.exported + ? getExportedName({ exported: statement.exported }) + : null; + if (!exportedName || exportsToRemove.has(exportedName)) { + return true; + } + continue; } - // Run dead code elimination on any newly unreferenced identifiers - deadCodeElimination(ast, previouslyReferencedIdentifiers); + if (statement.type === 'ExportDefaultDeclaration') { + if (exportsToRemove.has('default')) { + return true; + } + continue; + } + + if (statement.type !== 'ExportNamedDeclaration') { + continue; + } + + for (const specifier of statement.specifiers ?? []) { + if (specifier.type !== 'ExportSpecifier') { + continue; + } + const exportedName = getExportedName(specifier); + if (exportedName && exportsToRemove.has(exportedName)) { + return true; + } + } + + const declaration = statement.declaration; + if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations ?? []) { + for (const name of getPatternIdentifierNames(declarator.id)) { + if (exportsToRemove.has(name)) { + return true; + } + } + } + continue; + } + + if ( + (declaration?.type === 'FunctionDeclaration' || + declaration?.type === 'ClassDeclaration') && + declaration.id?.name && + exportsToRemove.has(declaration.id.name) + ) { + return true; + } } + return false; }; -export const removeUnusedImports = (ast: ParseResult): void => { - let scopeCrawled = false; - traverse(ast, { - Program(path: NodePath) { - if (!scopeCrawled) { - path.scope.crawl(); - scopeCrawled = true; +export const removeExports = ( + ast: ParseResult | AnyNode, + exportsToRemove: readonly string[], + exportsToRemoveSet: ReadonlySet = new Set(exportsToRemove) +): boolean => { + const program = getProgram(ast); + if (!hasRemovableExport(program, exportsToRemoveSet)) { + return false; + } + + const declarationGraph = createTopLevelDeclarationGraph(program); + const previouslyLive = collectLiveTopLevelDeclarations( + program, + declarationGraph + ); + let exportsChanged = false; + const removedExportLocalNames = new Set(); + const removedExportReferencedNames = new Set(); + const trackRemovedExportReferences = (node: AnyNode | null | undefined) => { + if (!node) { + return; + } + const declaration = declarationGraph.declarationsByNode.get(node); + for (const name of declaration?.referencedNames ?? + collectReferencedNames(node)) { + removedExportReferencedNames.add(name); + } + }; + + for (const statement of [...program.body]) { + if (statement.type === 'ExportAllDeclaration') { + const exportedName = statement.exported + ? getExportedName({ exported: statement.exported }) + : null; + if (!exportedName || exportsToRemoveSet.has(exportedName)) { + exportsChanged = true; + removeFromArray(program.body, statement); } - }, - ImportDeclaration(path: NodePath) { - if (path.node.specifiers.length === 0) { - return; + continue; + } + + if (statement.type === 'ExportNamedDeclaration') { + if (statement.specifiers?.length) { + statement.specifiers = statement.specifiers.filter( + (specifier: AnyNode) => { + if (specifier.type !== 'ExportSpecifier') { + return true; + } + const exportedName = getExportedName(specifier); + if (exportedName && exportsToRemoveSet.has(exportedName)) { + exportsChanged = true; + if (specifier.local?.name) { + removedExportLocalNames.add(specifier.local.name); + removedExportReferencedNames.add(specifier.local.name); + } + return false; + } + return true; + } + ); + if (statement.specifiers.length === 0 && !statement.declaration) { + removeFromArray(program.body, statement); + } } - const specifierPaths = path.get('specifiers') as NodePath< - | Babel.ImportSpecifier - | Babel.ImportDefaultSpecifier - | Babel.ImportNamespaceSpecifier - >[]; + const declaration = statement.declaration; + if (declaration?.type === 'VariableDeclaration') { + declaration.declarations = declaration.declarations.filter( + (declarator: AnyNode) => { + if (declarator.id.type === 'Identifier') { + if (exportsToRemoveSet.has(declarator.id.name)) { + exportsChanged = true; + removedExportLocalNames.add(declarator.id.name); + removedExportReferencedNames.add(declarator.id.name); + trackRemovedExportReferences(declarator); + return false; + } + return true; + } - for (const specifierPath of specifierPaths) { - const local = specifierPath.node.local; - const binding = local ? path.scope.getBinding(local.name) : null; - if (!binding || !binding.referenced) { - specifierPath.remove(); + validateDestructuredExports(declarator.id, exportsToRemove); + return true; + } + ); + if (declaration.declarations.length === 0) { + removeFromArray(program.body, statement); } } - if (path.node.specifiers.length === 0) { - path.remove(); + if ( + (declaration?.type === 'FunctionDeclaration' || + declaration?.type === 'ClassDeclaration') && + declaration.id?.name && + exportsToRemoveSet.has(declaration.id.name) + ) { + exportsChanged = true; + removedExportLocalNames.add(declaration.id.name); + removedExportReferencedNames.add(declaration.id.name); + trackRemovedExportReferences(statement); + removeFromArray(program.body, statement); + } + } + + if ( + statement.type === 'ExportDefaultDeclaration' && + exportsToRemoveSet.has('default') + ) { + exportsChanged = true; + const declaration = statement.declaration; + if (declaration?.type === 'Identifier') { + removedExportLocalNames.add(declaration.name); + removedExportReferencedNames.add(declaration.name); + } else if (declaration?.id?.name) { + removedExportLocalNames.add(declaration.id.name); + removedExportReferencedNames.add(declaration.id.name); + } + trackRemovedExportReferences(statement); + removeFromArray(program.body, statement); + } + } + + for (const statement of [...program.body]) { + const expression = + statement.type === 'ExpressionStatement' ? statement.expression : null; + const left = + expression?.type === 'AssignmentExpression' ? expression.left : null; + if ( + left?.type === 'MemberExpression' && + left.object?.type === 'Identifier' && + removedExportLocalNames.has(left.object.name) + ) { + removeFromArray(program.body, statement); + } + } + + if (exportsChanged) { + removeNewlyDeadTopLevelDeclarations( + program, + declarationGraph, + previouslyLive, + removedExportReferencedNames + ); + } + + return exportsChanged; +}; + +export const removeUnusedImports = (ast: ParseResult | AnyNode): void => { + const program = getProgram(ast); + const referenced = collectReferencedNames(program); + for (const statement of [...program.body]) { + if (statement.type !== 'ImportDeclaration') { + continue; + } + if ((statement.specifiers ?? []).length === 0) { + continue; + } + statement.specifiers = (statement.specifiers ?? []).filter( + (specifier: AnyNode) => { + if (specifier.importKind === 'type') { + return false; + } + return !specifier.local?.name || referenced.has(specifier.local.name); } + ); + if (statement.specifiers.length === 0) { + removeFromArray(program.body, statement); + } + } +}; + +const identifier = (name: string): AnyNode => ({ + type: 'Identifier', + start: 0, + end: 0, + name, + decorators: [], + optional: false, + typeAnnotation: null, +}); + +const literal = (value: string): AnyNode => ({ + type: 'Literal', + start: 0, + end: 0, + value, + raw: JSON.stringify(value), +}); + +const callExpression = (callee: AnyNode, args: AnyNode[]): AnyNode => ({ + type: 'CallExpression', + start: 0, + end: 0, + callee, + arguments: args, + optional: false, +}); + +const importDeclaration = ( + specifiers: Array<{ local: string; imported: string }>, + source: string +): AnyNode => ({ + type: 'ImportDeclaration', + start: 0, + end: 0, + specifiers: specifiers.map(specifier => ({ + type: 'ImportSpecifier', + start: 0, + end: 0, + imported: identifier(specifier.imported), + local: identifier(specifier.local), + importKind: 'value', + })), + source: literal(source), + attributes: [], + phase: null, + importKind: 'value', +}); + +const variableDeclaration = (name: string, init: AnyNode): AnyNode => ({ + type: 'VariableDeclaration', + start: 0, + end: 0, + kind: 'const', + declare: false, + declarations: [ + { + type: 'VariableDeclarator', + start: 0, + end: 0, + id: identifier(name), + init, + definite: false, }, - }); + ], +}); + +const patternIncludesName = ( + pattern: AnyNode | null | undefined, + name: string +): boolean => { + if (!pattern) { + return false; + } + if (pattern.type === 'Identifier') { + return pattern.name === name; + } + if (pattern.type === 'RestElement') { + return patternIncludesName(pattern.argument, name); + } + if (pattern.type === 'AssignmentPattern') { + return patternIncludesName(pattern.left, name); + } + if (pattern.type === 'ArrayPattern') { + return (pattern.elements ?? []).some((element: AnyNode | null) => + patternIncludesName(element, name) + ); + } + if (pattern.type === 'ObjectPattern') { + return (pattern.properties ?? []).some((property: AnyNode) => + property.type === 'RestElement' + ? patternIncludesName(property.argument, name) + : patternIncludesName(property.value, name) + ); + } + return false; }; -export const transformRoute = (ast: ParseResult): void => { - const hocs: Array<[string, Babel.Identifier]> = []; - function getHocUid(path: NodePath, hocName: string) { - const uid = path.scope.generateUidIdentifier(hocName); - hocs.push([hocName, uid]); +const declarationIncludesName = ( + declaration: AnyNode, + name: string +): boolean => { + if (declaration.type === 'VariableDeclaration') { + return (declaration.declarations ?? []).some((declarator: AnyNode) => + patternIncludesName(declarator.id, name) + ); + } + if ( + (declaration.type === 'FunctionDeclaration' || + declaration.type === 'ClassDeclaration') && + declaration.id?.name + ) { + return declaration.id.name === name; + } + if (declaration.type === 'ImportDeclaration') { + return (declaration.specifiers ?? []).some( + (specifier: AnyNode) => specifier.local?.name === name + ); + } + return false; +}; + +const hasTopLevelBindingName = (program: AnyNode, name: string): boolean => { + for (const statement of program.body ?? []) { + if (statement.type === 'ImportDeclaration') { + if (declarationIncludesName(statement, name)) { + return true; + } + continue; + } + + if (statement.type === 'ExportDefaultDeclaration') { + if (statement.declaration?.id?.name === name) { + return true; + } + continue; + } + + const declaration = + statement.type === 'ExportNamedDeclaration' + ? statement.declaration + : statement; + if (declaration && declarationIncludesName(declaration, name)) { + return true; + } + } + return false; +}; + +export const transformRoute = (ast: ParseResult | AnyNode): void => { + const program = getProgram(ast); + const usedNames = new Set(); + const hocs: Array<[string, string]> = []; + const componentWrapperDeclarations: AnyNode[] = []; + + function getUid(name: string) { + let uid = `_${name}`; + let index = 2; + while (usedNames.has(uid) || hasTopLevelBindingName(program, uid)) { + uid = `_${name}${index++}`; + } + usedNames.add(uid); return uid; } - traverse(ast, { - ExportDeclaration(path: NodePath) { - if (path.isExportDefaultDeclaration()) { - const declaration = path.get('declaration'); - // prettier-ignore - const expr = - declaration.isExpression() ? declaration.node : - declaration.isFunctionDeclaration() ? toFunctionExpression(declaration.node) : - undefined - if (expr) { - const uid = getHocUid(path, 'withComponentProps'); - declaration.replaceWith(t.callExpression(uid, [expr]) as any); - } - return; + function getHocUid(hocName: string) { + const uid = getUid(hocName); + hocs.push([hocName, uid]); + return identifier(uid); + } + + function wrapNamedComponentDeclaration(name: string, declaration: AnyNode) { + const uid = getHocUid(`with${name}Props`); + const expression = + declaration.type === 'FunctionDeclaration' + ? toFunctionExpression(declaration) + : declaration.type === 'ClassDeclaration' + ? toClassExpression(declaration) + : declaration; + return variableDeclaration(name, callExpression(uid, [expression])); + } + + for (const statement of program.body ?? []) { + if (statement.type === 'ExportDefaultDeclaration') { + const declaration = statement.declaration; + const expr = + declaration?.type === 'FunctionDeclaration' + ? toFunctionExpression(declaration) + : declaration?.type === 'ClassDeclaration' + ? toClassExpression(declaration) + : declaration; + if (expr) { + const uid = getHocUid('withComponentProps'); + statement.declaration = callExpression(uid, [expr]); } + continue; + } - if (path.isExportNamedDeclaration()) { - const decl = path.get('declaration'); - - if (decl.isVariableDeclaration()) { - // biome-ignore lint/complexity/noForEach: - decl.get('declarations').forEach((varDeclarator: NodePath) => { - const id = varDeclarator.get('id') as any; - const init = varDeclarator.get('init') as any; - const expr = init.node as any; - if (!expr) return; - if (!id.isIdentifier()) return; - const { name } = id.node; - if (!isNamedComponentExport(name)) return; - - const uid = getHocUid(path, `with${name}Props`); - init.replaceWith(t.callExpression(uid, [expr])); - }); - return; + if (statement.type !== 'ExportNamedDeclaration') { + continue; + } + const declaration = statement.declaration; + if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations ?? []) { + if ( + declarator.id?.type !== 'Identifier' || + !declarator.init || + !isNamedComponentExport(declarator.id.name) + ) { + continue; } + const uid = getHocUid(`with${declarator.id.name}Props`); + declarator.init = callExpression(uid, [declarator.init]); + } + continue; + } - if (decl.isFunctionDeclaration()) { - const { id } = decl.node; - if (!id) return; - const { name } = id; - if (!isNamedComponentExport(name)) return; - - const uid = getHocUid(path, `with${name}Props`); - decl.replaceWith( - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(name), - t.callExpression(uid, [toFunctionExpression(decl.node)]) - ), - ]) as any - ); - } + if ( + (declaration?.type === 'FunctionDeclaration' || + declaration?.type === 'ClassDeclaration') && + declaration.id?.name && + isNamedComponentExport(declaration.id.name) + ) { + const name = declaration.id.name; + statement.declaration = wrapNamedComponentDeclaration(name, declaration); + continue; + } + + for (const specifier of statement.specifiers ?? []) { + if ( + specifier.type !== 'ExportSpecifier' || + specifier.exportKind === 'type' + ) { + continue; } - }, - }); + const exportedName = getExportedName(specifier); + if (!exportedName || !isNamedComponentExport(exportedName)) { + continue; + } + const localName = specifier.local?.name; + if (!localName) { + continue; + } + const wrappedLocalName = getUid(exportedName); + const uid = getHocUid(`with${exportedName}Props`); + componentWrapperDeclarations.push( + variableDeclaration( + wrappedLocalName, + callExpression(uid, [identifier(localName)]) + ) + ); + specifier.local = identifier(wrappedLocalName); + } + } + + program.body.push(...componentWrapperDeclarations); if (hocs.length > 0) { - ast.program.body.unshift( - t.importDeclaration( - hocs.map(([name, identifier]) => - t.importSpecifier(identifier, t.identifier(name)) - ), - t.stringLiteral('virtual/react-router/with-props') - ) as any + program.body.unshift( + importDeclaration( + hocs.map(([name, local]) => ({ imported: name, local })), + 'virtual/react-router/with-props' + ) ); } }; @@ -452,5 +1062,5 @@ export const transformRoute = (ast: ParseResult): void => { function isNamedComponentExport( name: string ): name is (typeof NAMED_COMPONENT_EXPORTS)[number] { - return (NAMED_COMPONENT_EXPORTS as readonly string[]).includes(name); + return NAMED_COMPONENT_EXPORTS_SET.has(name); } diff --git a/src/prerender.ts b/src/prerender.ts index b0bf5d9..9fbde67 100644 --- a/src/prerender.ts +++ b/src/prerender.ts @@ -1,3 +1,4 @@ +import { getDefaultConcurrency } from './concurrency.js'; import type { Config } from './react-router-config.js'; import type { RouteConfigEntry } from '@react-router/dev/routes'; @@ -134,7 +135,10 @@ export const resolvePrerenderPaths = async ( return pathsConfig ?? []; }; -export const getPrerenderConcurrency = (prerender: PrerenderConfig): number => { +export const getPrerenderConcurrency = ( + prerender: PrerenderConfig, + cpuCount?: number +): number => { if ( typeof prerender === 'object' && prerender !== null && @@ -145,7 +149,7 @@ export const getPrerenderConcurrency = (prerender: PrerenderConfig): number => { return value; } } - return 1; + return getDefaultConcurrency(cpuCount); }; const isValidPrerenderPathsConfig = ( diff --git a/src/route-artifacts.ts b/src/route-artifacts.ts new file mode 100644 index 0000000..d577a63 --- /dev/null +++ b/src/route-artifacts.ts @@ -0,0 +1,158 @@ +import { + CLIENT_ROUTE_EXPORTS_SET, + SERVER_ONLY_ROUTE_EXPORTS_SET, +} from './constants.js'; +import { getExportNames } from './export-utils.js'; +import { + buildEnforceChunkValidity, + detectRouteChunksIfEnabled, + emptyRouteChunkSnippet, + getRouteChunkIfEnabled, + getRouteChunkNameFromModuleId, + shouldAnalyzeRouteChunks, + validateRouteChunks, + type RouteChunkCache, + type RouteChunkConfig, +} from './route-chunks.js'; + +export type RouteClientEntryArtifactOptions = { + code: string; + resourcePath: string; + environmentName?: string; + isBuild: boolean; + routeChunkCache?: RouteChunkCache; + routeChunkConfig: RouteChunkConfig; +}; + +type RouteClientEntryArtifact = { + code: string; +}; + +export type RouteChunkArtifactOptions = { + code: string; + resource: string; + resourcePath: string; + isBuild: boolean; + routeChunkCache?: RouteChunkCache; + routeChunkConfig: RouteChunkConfig; +}; + +type RouteChunkArtifact = { + code: string; + map: null; +}; + +export const buildRouteClientEntryCode = ({ + exportNames, + chunkedExports, + isServer, + resourcePath, +}: { + exportNames: readonly string[]; + chunkedExports: readonly string[]; + isServer: boolean; + resourcePath: string; +}): { code: string; reexports: string[] } => { + const chunkedExportSet = + chunkedExports.length > 0 ? new Set(chunkedExports) : undefined; + const reexports = exportNames + .filter(exp => { + if (chunkedExportSet?.has(exp)) { + return false; + } + return ( + CLIENT_ROUTE_EXPORTS_SET.has(exp) || + (isServer && SERVER_ONLY_ROUTE_EXPORTS_SET.has(exp)) + ); + }) + .sort(); + const target = `${resourcePath}?react-router-route`; + return { + code: `export { ${reexports.join(', ')} } from ${JSON.stringify(target)};`, + reexports, + }; +}; + +export const createRouteClientEntryArtifact = async ({ + code, + resourcePath, + environmentName, + isBuild, + routeChunkCache, + routeChunkConfig, +}: RouteClientEntryArtifactOptions): Promise => { + const isServer = environmentName === 'node'; + const mightHaveRouteChunks = + !isServer && + isBuild && + shouldAnalyzeRouteChunks(routeChunkConfig, resourcePath, code); + const routeChunkInfo = mightHaveRouteChunks + ? await detectRouteChunksIfEnabled( + routeChunkCache, + routeChunkConfig, + resourcePath, + code + ) + : null; + const exportNames = + routeChunkInfo?.exportNames ?? (await getExportNames(code)); + const chunkedExports = routeChunkInfo?.chunkedExports ?? []; + return { + code: buildRouteClientEntryCode({ + exportNames, + chunkedExports, + isServer, + resourcePath, + }).code, + }; +}; + +export const createRouteChunkArtifact = async ({ + code, + resource, + resourcePath, + isBuild, + routeChunkCache, + routeChunkConfig, +}: RouteChunkArtifactOptions): Promise => { + const splitRouteModules = routeChunkConfig.splitRouteModules; + if (!isBuild || !splitRouteModules) { + return { + code: emptyRouteChunkSnippet('Split route modules disabled'), + map: null, + }; + } + + const chunkName = getRouteChunkNameFromModuleId(resource); + if (!chunkName) { + throw new Error(`Invalid route chunk name in "${resource}"`); + } + if (chunkName !== 'main' && !code.includes(chunkName)) { + return { + code: emptyRouteChunkSnippet(`No ${chunkName} chunk`), + map: null, + }; + } + + const chunk = await getRouteChunkIfEnabled( + routeChunkCache, + routeChunkConfig, + resourcePath, + chunkName, + code + ); + + if (splitRouteModules === 'enforce' && chunkName === 'main' && chunk) { + const exportNames = await getExportNames(chunk); + validateRouteChunks({ + config: routeChunkConfig, + id: resourcePath, + valid: buildEnforceChunkValidity(exportNames), + }); + } + + return { + code: chunk ?? emptyRouteChunkSnippet(`No ${chunkName} chunk`), + map: null, + }; +}; diff --git a/src/route-chunks.ts b/src/route-chunks.ts index 2339729..59f1b4c 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -1,7 +1,14 @@ -import type { NodePath } from './babel.js'; -import { generate, parse, t, traverse } from './babel.js'; +import { + Analyzer, + type Module, + type Symbol as YukuSymbol, +} from 'yuku-analyzer'; +import { strip } from 'yuku-codegen'; +import { walk } from 'yuku-parser'; import { normalize, relative, resolve } from 'pathe'; +type AnyNode = Record; + export type RouteChunkExportName = | 'clientAction' | 'clientLoader' @@ -16,7 +23,7 @@ export type RouteChunkConfig = { rootRouteFile: string; }; -export type RouteChunkCacheEntry = { +type RouteChunkCacheEntry = { value: T; version: string; }; @@ -24,6 +31,7 @@ export type RouteChunkCacheEntry = { export type RouteChunkCache = Map>; export type RouteChunkInfo = { + exportNames: string[]; hasRouteChunks: boolean; hasRouteChunkByExportName: Record; chunkedExports: RouteChunkExportName[]; @@ -41,6 +49,18 @@ export const routeChunkNames: RouteChunkName[] = [ ...routeChunkExportNames, ]; +export const mightContainRouteChunkExportName = (source: string): boolean => + routeChunkExportNames.some(exportName => source.includes(exportName)); + +const createRouteChunkExportMap = ( + getValue: (exportName: RouteChunkExportName) => boolean +): Record => + Object.fromEntries( + routeChunkExportNames.map(exportName => [exportName, getValue(exportName)]) + ) as Record; + +export const emptyRouteChunkSnippet = (_reason: string): string => 'export {};'; + const routeChunkQueryStringPrefix = '?route-chunk='; const routeChunkQueryStrings: Record = { @@ -50,6 +70,7 @@ const routeChunkQueryStrings: Record = { clientMiddleware: `${routeChunkQueryStringPrefix}clientMiddleware`, HydrateFallback: `${routeChunkQueryStringPrefix}HydrateFallback`, }; +const routeChunkQueryStringValues = Object.values(routeChunkQueryStrings); const routeChunkEntrySuffix: Record = { clientAction: 'client-action', @@ -68,14 +89,11 @@ const invariant: (value: unknown, message: string) => asserts value = ( }; const getOrSetFromCache = ( - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, key: string, version: string, getValue: () => T ): T => { - if (!cache) { - return getValue(); - } const entry = cache.get(key) as RouteChunkCacheEntry | undefined; if (entry?.version === version) { return entry.value; @@ -85,80 +103,100 @@ const getOrSetFromCache = ( return value; }; -const codeToAst = ( +const hasCachedValue = ( + cache: RouteChunkCache, + key: string, + version: string +): boolean => cache.get(key)?.version === version; + +type AnalyzedModule = { + module: Module; + // Dependency sets use these node identities. Consumers must shallow-copy + // any node whose children they narrow instead of mutating this cached AST. + program: AnyNode; +}; + +const analyzeCode = ( code: string, - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, cacheKey: string -) => { - return structuredClone( - getOrSetFromCache(cache, `${cacheKey}::codeToAst`, code, () => - parse(code, { sourceType: 'module' }) - ) - ); +): AnalyzedModule => { + return getOrSetFromCache(cache, `${cacheKey}::analyzeCode`, code, () => { + const analyzer = new Analyzer(); + const module = analyzer.addFile(cacheKey, code, { + lang: 'tsx', + sourceType: 'module', + }); + const errors = module.diagnostics.filter( + diagnostic => diagnostic.severity === 'error' + ); + if (errors.length > 0) { + throw new Error(errors.map(error => error.message).join('\n')); + } + return { module, program: module.ast as AnyNode }; + }); }; -const assertNodePath: ( - path: NodePath | NodePath[] | null | undefined -) => asserts path is NodePath = path => { - invariant( - path && !Array.isArray(path), - `Expected a Path, but got ${Array.isArray(path) ? 'an array' : path}` - ); +type ExportDependencies = { + topLevelStatements: Set; + topLevelNonModuleStatements: Set; + importedIdentifierNames: Set; + exportedVariableDeclarators: Set; }; -const isNodePathWithNode = (path: unknown): path is NodePath => { - if (!path || typeof path !== 'object' || Array.isArray(path)) { - return false; - } - if (!('node' in path)) { - return false; +const getTopLevelStatementForNode = ( + module: Module, + node: AnyNode +): AnyNode => { + let current: AnyNode = node; + let parent = module.parentOf(current as never) as AnyNode | null; + while (parent && parent.type !== 'Program') { + current = parent; + parent = module.parentOf(current as never) as AnyNode | null; } - return Boolean((path as { node?: unknown }).node); -}; - -const assertNodePathIsStatement: ( - path: NodePath | NodePath[] | null | undefined -) => asserts path is NodePath = path => { - invariant( - path && !Array.isArray(path) && t.isStatement(path.node), - `Expected a Statement path, but got ${ - Array.isArray(path) ? 'an array' : path?.node?.type - }` - ); + invariant(parent?.type === 'Program', 'Expected node to be within Program'); + return current; }; -const assertNodePathIsVariableDeclarator: ( - path: NodePath | NodePath[] | null | undefined -) => asserts path is NodePath = path => { - invariant( - path && !Array.isArray(path) && t.isVariableDeclarator(path.node), - `Expected a VariableDeclarator path, but got ${ - Array.isArray(path) ? 'an array' : path?.node?.type - }` - ); +const getVariableDeclaratorForNode = ( + module: Module, + node: AnyNode +): AnyNode | null => { + let current: AnyNode | null = node; + while (current) { + if (current.type === 'VariableDeclarator') { + return current; + } + current = module.parentOf(current as never) as AnyNode | null; + } + return null; }; -const assertNodePathIsPattern: ( - path: NodePath | NodePath[] | null | undefined -) => asserts path is NodePath = path => { - invariant( - path && !Array.isArray(path) && t.isPattern(path.node), - `Expected a Pattern path, but got ${ - Array.isArray(path) ? 'an array' : path?.node?.type - }` - ); +const getExportedName = (exported: AnyNode): string => { + if (exported.type === 'Identifier') { + return exported.name; + } + return String(exported.value); }; -type ExportDependencies = { - topLevelStatements: Set; - topLevelNonModuleStatements: Set; - importedIdentifierNames: Set; - exportedVariableDeclarators: Set; +const setsIntersect = (set1: Set, set2: Set) => { + let smallerSet = set1; + let largerSet = set2; + if (set1.size > set2.size) { + smallerSet = set2; + largerSet = set1; + } + for (const element of smallerSet) { + if (largerSet.has(element)) { + return true; + } + } + return false; }; const getExportDependencies = ( code: string, - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, cacheKey: string ): Map => { return getOrSetFromCache( @@ -166,368 +204,236 @@ const getExportDependencies = ( `${cacheKey}::getExportDependencies`, code, () => { + const { module } = analyzeCode(code, cache, cacheKey); const exportDependencies = new Map(); - const ast = codeToAst(code, cache, cacheKey); + const topLevelStatementCache = new Map(); + const variableDeclaratorCache = new Map(); - function handleExport( - exportName: string, - exportPath: NodePath, - identifiersPath: NodePath = exportPath - ) { - const identifiers = getDependentIdentifiersForPath(identifiersPath); - const topLevelStatements = new Set([ - exportPath.node as t.Statement, - ...getTopLevelStatementsForPaths(identifiers), - ]); - const topLevelNonModuleStatements = new Set( - Array.from(topLevelStatements).filter( - statement => - !t.isImportDeclaration(statement) && - !t.isExportDeclaration(statement) - ) - ); - const importedIdentifierNames = new Set(); - for (const identifier of identifiers) { - if ( - t.isIdentifier(identifier.node) && - identifier.parentPath?.parentPath?.isImportDeclaration() - ) { - importedIdentifierNames.add(identifier.node.name); - } + const getCachedTopLevelStatementForNode = (node: AnyNode): AnyNode => { + const cached = topLevelStatementCache.get(node); + if (cached) { + return cached; } - const exportedVariableDeclarators = new Set(); - for (const identifier of identifiers) { - if (identifier.parentPath?.isVariableDeclarator()) { - const parentPath = identifier.parentPath; - if (parentPath.parentPath?.parentPath?.isExportNamedDeclaration()) { - exportedVariableDeclarators.add( - parentPath.node as t.VariableDeclarator - ); - continue; - } - } - const isWithinExportDestructuring = Boolean( - identifier.findParent(path => - Boolean( - path.isPattern() && - path.parentPath?.isVariableDeclarator() && - path.parentPath.parentPath?.parentPath?.isExportNamedDeclaration() - ) - ) - ); - if (isWithinExportDestructuring) { - let currentPath: NodePath | null = identifier; - while (currentPath) { - if ( - currentPath.parentPath?.isVariableDeclarator() && - currentPath.parentKey === 'id' - ) { - exportedVariableDeclarators.add( - currentPath.parentPath.node as t.VariableDeclarator - ); - break; - } - currentPath = currentPath.parentPath; - } - } + const statement = getTopLevelStatementForNode(module, node); + topLevelStatementCache.set(node, statement); + return statement; + }; + + const getCachedVariableDeclaratorForNode = ( + node: AnyNode + ): AnyNode | null => { + if (variableDeclaratorCache.has(node)) { + return variableDeclaratorCache.get(node) ?? null; } - exportDependencies.set(exportName, { - topLevelStatements, - topLevelNonModuleStatements, - importedIdentifierNames, - exportedVariableDeclarators, - }); - } + const declarator = getVariableDeclaratorForNode(module, node); + variableDeclaratorCache.set(node, declarator); + return declarator; + }; + + const addCachedTopLevelStatement = ( + dependencies: ExportDependencies, + node: AnyNode + ) => { + const statement = getCachedTopLevelStatementForNode(node); + dependencies.topLevelStatements.add(statement); + if ( + statement.type !== 'ImportDeclaration' && + !statement.type.startsWith('Export') + ) { + dependencies.topLevelNonModuleStatements.add(statement); + } + return statement; + }; - traverse(ast, { - ExportDeclaration(exportPath) { - const { node } = exportPath; - if (t.isExportAllDeclaration(node)) { - return; - } - if (t.isExportDefaultDeclaration(node)) { - handleExport('default', exportPath); + const handleExport = ( + exportName: string, + exportNode: AnyNode, + localSymbol: YukuSymbol | null + ) => { + const dependencies: ExportDependencies = { + topLevelStatements: new Set(), + topLevelNonModuleStatements: new Set(), + importedIdentifierNames: new Set(), + exportedVariableDeclarators: new Set(), + }; + const visitedSymbols = new Set(); + const scannedNodes = new Set(); + + const scanNode = (node: AnyNode) => { + if (scannedNodes.has(node)) { return; } - const { declaration } = node; - if (t.isVariableDeclaration(declaration)) { - const { declarations } = declaration; - for (let i = 0; i < declarations.length; i++) { - const declarator = declarations[i]; - if (t.isIdentifier(declarator.id)) { - const declaratorPath = exportPath.get( - `declaration.declarations.${i}` - ); - assertNodePathIsVariableDeclarator(declaratorPath); - handleExport(declarator.id.name, exportPath, declaratorPath); - continue; + scannedNodes.add(node); + walk(node as any, { + Identifier(node: AnyNode) { + const reference = module.referenceOf(node as never); + if (reference?.symbol) { + visitSymbol(reference.symbol); } - if (t.isPattern(declarator.id)) { - const exportedPatternPath = exportPath.get( - `declaration.declarations.${i}.id` - ); - assertNodePathIsPattern(exportedPatternPath); - const identifiers = - getIdentifiersForPatternPath(exportedPatternPath); - for (const identifier of identifiers) { - if (!t.isIdentifier(identifier.node)) { - continue; - } - handleExport(identifier.node.name, exportPath, identifier); - } - } - } + }, + }); + }; + + const visitSymbol = (symbol: YukuSymbol) => { + if (visitedSymbols.has(symbol)) { return; } - if ( - t.isFunctionDeclaration(declaration) || - t.isClassDeclaration(declaration) - ) { - invariant( - declaration.id, - 'Expected exported function or class declaration to have a name when not the default export' + visitedSymbols.add(symbol); + + for (const declaration of symbol.declarations as AnyNode[]) { + const statement = addCachedTopLevelStatement( + dependencies, + declaration ); - handleExport(declaration.id.name, exportPath); - return; - } - if (t.isExportNamedDeclaration(node)) { - for (const specifier of node.specifiers) { - if (t.isIdentifier(specifier.exported)) { - const name = specifier.exported.name; - const specifierPath = exportPath - .get('specifiers') - .find(path => path.node === specifier); - invariant( - specifierPath, - `Expected to find specifier path for ${name}` - ); - handleExport(name, exportPath, specifierPath); - } + if (statement.type === 'ImportDeclaration') { + dependencies.importedIdentifierNames.add(symbol.name); } - return; + const declarator = getCachedVariableDeclaratorForNode(declaration); + if ( + declarator && + getCachedTopLevelStatementForNode(declarator).type === + 'ExportNamedDeclaration' + ) { + dependencies.exportedVariableDeclarators.add(declarator); + } + scanNode(declarator ?? statement); } - throw new Error('Unknown export node type'); - }, - }); - return exportDependencies; - } - ); -}; + for (const reference of symbol.references as any[]) { + const statement = addCachedTopLevelStatement( + dependencies, + reference.node + ); + const declarator = getCachedVariableDeclaratorForNode( + reference.node + ); + scanNode(declarator ?? statement); + } + }; -const getDependentIdentifiersForPath = ( - path: NodePath, - state?: { visited: Set; identifiers: Set } -): Set => { - const { visited, identifiers } = state ?? { - visited: new Set(), - identifiers: new Set(), - }; - if (visited.has(path)) { - return identifiers; - } - visited.add(path); - path.traverse({ - Identifier(pathInner) { - if (identifiers.has(pathInner)) { - return; - } - identifiers.add(pathInner); - const binding = pathInner.scope.getBinding(pathInner.node.name); - if (!binding) { - return; - } - getDependentIdentifiersForPath(binding.path, { visited, identifiers }); - for (const reference of binding.referencePaths) { - if (reference.isExportNamedDeclaration()) { + addCachedTopLevelStatement(dependencies, exportNode); + + if (localSymbol) { + visitSymbol(localSymbol); + } else { + const statement = getCachedTopLevelStatementForNode(exportNode); + scanNode(statement); + } + + exportDependencies.set(exportName, dependencies); + }; + + for (const exp of module.exports as any[]) { + if (exp.typeOnly || exp.isStar || exp.isExportEquals) { continue; } - getDependentIdentifiersForPath(reference, { visited, identifiers }); + handleExport(exp.name, exp.node as AnyNode, exp.local ?? null); } - for (const constantViolation of binding.constantViolations) { - getDependentIdentifiersForPath(constantViolation, { - visited, - identifiers, - }); - } - }, - }); - const topLevelStatement = getTopLevelStatementPathForPath(path); - const withinImportStatement = topLevelStatement.isImportDeclaration(); - const withinExportStatement = topLevelStatement.isExportDeclaration(); - if (!withinImportStatement && !withinExportStatement) { - getDependentIdentifiersForPath(topLevelStatement, { visited, identifiers }); - } - if ( - withinExportStatement && - path.isIdentifier() && - (t.isPattern(path.parentPath.node) || - t.isPattern(path.parentPath.parentPath?.node)) - ) { - const variableDeclarator = path.findParent(p => p.isVariableDeclarator()); - if (variableDeclarator) { - assertNodePath(variableDeclarator); - getDependentIdentifiersForPath(variableDeclarator, { - visited, - identifiers, - }); - } - } - return identifiers; -}; -const getTopLevelStatementPathForPath = (path: NodePath) => { - const ancestry = path.getAncestry(); - const topLevelStatement = ancestry[ancestry.length - 2]; - assertNodePathIsStatement(topLevelStatement); - return topLevelStatement; -}; - -const getTopLevelStatementsForPaths = (paths: Set) => { - const topLevelStatements = new Set(); - for (const path of paths) { - const topLevelStatement = getTopLevelStatementPathForPath(path); - topLevelStatements.add(topLevelStatement.node as t.Statement); - } - return topLevelStatements; + return exportDependencies; + } + ); }; -const getIdentifiersForPatternPath = ( - patternPath: NodePath, - identifiers: Set = new Set() +const isExportChunkable = ( + exportName: string, + exportDependencies: Map ) => { - function walk(currentPath: NodePath) { - if (currentPath.isIdentifier()) { - identifiers.add(currentPath); - return; + const dependencies = exportDependencies.get(exportName); + if (!dependencies) { + return false; + } + for (const [currentExportName, currentDependencies] of exportDependencies) { + if (currentExportName === exportName) { + continue; } - if (currentPath.isObjectPattern()) { - const { properties } = currentPath.node; - for (let i = 0; i < properties.length; i++) { - const property = properties[i]; - if (t.isObjectProperty(property)) { - const valuePath = currentPath.get(`properties.${i}.value`); - if (isNodePathWithNode(valuePath)) { - walk(valuePath); - } - } else if (t.isRestElement(property)) { - const argumentPath = currentPath.get(`properties.${i}.argument`); - if (isNodePathWithNode(argumentPath)) { - walk(argumentPath); - } - } - } - } else if (currentPath.isArrayPattern()) { - const { elements } = currentPath.node; - for (let i = 0; i < elements.length; i++) { - const element = elements[i]; - if (element) { - const elementPath = currentPath.get(`elements.${i}`); - if (isNodePathWithNode(elementPath)) { - walk(elementPath); - } - } - } - } else if (currentPath.isRestElement()) { - const argumentPath = currentPath.get('argument'); - if (isNodePathWithNode(argumentPath)) { - walk(argumentPath); - } + if ( + setsIntersect( + currentDependencies.topLevelNonModuleStatements, + dependencies.topLevelNonModuleStatements + ) + ) { + return false; } } - walk(patternPath); - return identifiers; -}; - -const getExportedName = (exported: t.Identifier | t.StringLiteral) => { - return t.isIdentifier(exported) ? exported.name : exported.value; -}; - -const setsIntersect = (set1: Set, set2: Set) => { - let smallerSet = set1; - let largerSet = set2; - if (set1.size > set2.size) { - smallerSet = set2; - largerSet = set1; + if (dependencies.exportedVariableDeclarators.size > 1) { + return false; } - for (const element of smallerSet) { - if (largerSet.has(element)) { - return true; + if (dependencies.exportedVariableDeclarators.size > 0) { + for (const [currentExportName, currentDependencies] of exportDependencies) { + if (currentExportName === exportName) { + continue; + } + if ( + setsIntersect( + currentDependencies.exportedVariableDeclarators, + dependencies.exportedVariableDeclarators + ) + ) { + return false; + } } } - return false; + return true; }; +const getChunkableExportMap = ( + code: string, + cache: RouteChunkCache, + cacheKey: string +): Record => + getOrSetFromCache(cache, `${cacheKey}::getChunkableExportMap`, code, () => { + const exportDependencies = getExportDependencies(code, cache, cacheKey); + return createRouteChunkExportMap(exportName => + isExportChunkable(exportName, exportDependencies) + ); + }); + const hasChunkableExport = ( code: string, exportName: string, - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, cacheKey: string +) => + (routeChunkExportNames as string[]).includes(exportName) + ? getChunkableExportMap(code, cache, cacheKey)[ + exportName as RouteChunkExportName + ] + : false; + +const generateCode = (program: AnyNode): string | undefined => { + if (program.body.length === 0) { + return undefined; + } + const result = strip(program as any, { comments: 'some' }); + if (result.errors.length > 0) { + throw new Error(result.errors.map(error => error.message).join('\n')); + } + return result.code; +}; + +const filterImportSpecifiers = ( + node: AnyNode, + shouldKeep: (importedName: string) => boolean ) => { - return getOrSetFromCache( - cache, - `${cacheKey}::hasChunkableExport::${exportName}`, - code, - () => { - const exportDependencies = getExportDependencies(code, cache, cacheKey); - const dependencies = exportDependencies.get(exportName); - if (!dependencies) { - return false; - } - for (const [ - currentExportName, - currentDependencies, - ] of exportDependencies) { - if (currentExportName === exportName) { - continue; - } - if ( - setsIntersect( - currentDependencies.topLevelNonModuleStatements, - dependencies.topLevelNonModuleStatements - ) - ) { - return false; - } - } - if (dependencies.exportedVariableDeclarators.size > 1) { - return false; - } - if (dependencies.exportedVariableDeclarators.size > 0) { - for (const [ - currentExportName, - currentDependencies, - ] of exportDependencies) { - if (currentExportName === exportName) { - continue; - } - if ( - setsIntersect( - currentDependencies.exportedVariableDeclarators, - dependencies.exportedVariableDeclarators - ) - ) { - return false; - } - } - } - return true; - } + if (node.specifiers.length === 0) { + return node; + } + const specifiers = node.specifiers.filter((specifier: AnyNode) => + shouldKeep(specifier.local.name) ); + return specifiers.length > 0 ? { ...node, specifiers } : null; }; const getChunkedExport = ( code: string, exportName: string, - generateOptions: Record = {}, - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, cacheKey: string ): string | undefined => { return getOrSetFromCache( cache, - `${cacheKey}::getChunkedExport::${exportName}::${JSON.stringify( - generateOptions - )}`, + `${cacheKey}::getChunkedExport::${exportName}`, code, () => { if (!hasChunkableExport(code, exportName, cache, cacheKey)) { @@ -537,104 +443,94 @@ const getChunkedExport = ( const dependencies = exportDependencies.get(exportName); invariant(dependencies, 'Expected export to have dependencies'); - const topLevelStatementsArray = Array.from( - dependencies.topLevelStatements - ); - const exportedVariableDeclaratorsArray = Array.from( - dependencies.exportedVariableDeclarators - ); - - const ast = codeToAst(code, cache, cacheKey); - ast.program.body = ast.program.body - .filter(node => - topLevelStatementsArray.some(statement => - t.isNodesEquivalent(node, statement) - ) - ) - .map(node => { - if (!t.isImportDeclaration(node)) { + const program = analyzeCode(code, cache, cacheKey).program; + const body = program.body + .filter((node: AnyNode) => dependencies.topLevelStatements.has(node)) + .map((node: AnyNode) => { + if (node.type !== 'ImportDeclaration') { return node; } if (dependencies.importedIdentifierNames.size === 0) { return null; } - node.specifiers = node.specifiers.filter(specifier => - dependencies.importedIdentifierNames.has(specifier.local.name) - ); - invariant( - node.specifiers.length > 0, - 'Expected import statement to have used specifiers' + return filterImportSpecifiers(node, importedName => + dependencies.importedIdentifierNames.has(importedName) ); - return node; }) - .map(node => { - if (!t.isExportDeclaration(node)) { + .map((node: AnyNode | null) => { + if (!node || !node.type.startsWith('Export')) { return node; } - if (t.isExportAllDeclaration(node)) { + if (node.type === 'ExportAllDeclaration') { return null; } - if (t.isExportDefaultDeclaration(node)) { + if (node.type === 'ExportDefaultDeclaration') { return exportName === 'default' ? node : null; } const { declaration } = node; - if (t.isVariableDeclaration(declaration)) { - declaration.declarations = declaration.declarations.filter( - declarationNode => - exportedVariableDeclaratorsArray.some(declarator => - t.isNodesEquivalent(declarationNode, declarator) - ) + if (declaration?.type === 'VariableDeclaration') { + const declarations = declaration.declarations.filter( + (declarationNode: AnyNode) => + dependencies.exportedVariableDeclarators.has(declarationNode) ); - if (declaration.declarations.length === 0) { - return null; - } - return node; + return declarations.length > 0 + ? { + ...node, + declaration: { ...declaration, declarations }, + } + : null; } if ( - t.isFunctionDeclaration(node.declaration) || - t.isClassDeclaration(node.declaration) + declaration?.type === 'FunctionDeclaration' || + declaration?.type === 'ClassDeclaration' ) { - return node.declaration.id?.name === exportName ? node : null; + return declaration.id?.name === exportName ? node : null; } - if (t.isExportNamedDeclaration(node)) { - if (node.specifiers.length === 0) { - return null; - } - node.specifiers = node.specifiers.filter( - specifier => getExportedName(specifier.exported) === exportName + if (node.type === 'ExportNamedDeclaration') { + const specifiers = node.specifiers.filter( + (specifier: AnyNode) => + getExportedName(specifier.exported) === exportName ); - if (node.specifiers.length === 0) { - return null; - } - return node; + return specifiers.length > 0 ? { ...node, specifiers } : null; } throw new Error('Unknown export node type'); }) - .filter(Boolean) as t.Statement[]; + .filter(Boolean) as AnyNode[]; - return generate(ast, generateOptions).code; + return generateCode({ ...program, body }); } ); }; +const getChunkedExportCacheKey = ( + cacheKey: string, + exportName: RouteChunkExportName +) => `${cacheKey}::getChunkedExport::${exportName}`; + +const hasCachedChunkedExport = ( + code: string, + exportName: RouteChunkExportName, + cache: RouteChunkCache, + cacheKey: string +): boolean => + hasCachedValue(cache, getChunkedExportCacheKey(cacheKey, exportName), code); + const omitChunkedExports = ( code: string, exportNames: string[], - generateOptions: Record = {}, - cache: RouteChunkCache | undefined, + cache: RouteChunkCache, cacheKey: string ): string | undefined => { return getOrSetFromCache( cache, - `${cacheKey}::omitChunkedExports::${exportNames.join(',')}::${JSON.stringify( - generateOptions - )}`, + `${cacheKey}::omitChunkedExports::${exportNames.join(',')}`, code, () => { - const isChunkable = (exportName: string) => - hasChunkableExport(code, exportName, cache, cacheKey); + const chunkableExportMap = getChunkableExportMap(code, cache, cacheKey); + const exportNameSet = new Set(exportNames); const isOmitted = (exportName: string) => - exportNames.includes(exportName) && isChunkable(exportName); + exportNameSet.has(exportName) && + Boolean(chunkableExportMap[exportName as RouteChunkExportName]); const isRetained = (exportName: string) => !isOmitted(exportName); const exportDependencies = getExportDependencies(code, cache, cacheKey); @@ -642,9 +538,10 @@ const omitChunkedExports = ( const omittedExportNames = allExportNames.filter(isOmitted); const retainedExportNames = allExportNames.filter(isRetained); - const omittedStatements = new Set(); - const omittedExportedVariableDeclarators = - new Set(); + const omittedStatements = new Set(); + const omittedExportedVariableDeclarators = new Set(); + const retainedImportedIdentifierNames = new Set(); + const omittedImportedIdentifierNames = new Set(); for (const omittedExportName of omittedExportNames) { const dependencies = exportDependencies.get(omittedExportName); @@ -658,122 +555,118 @@ const omitChunkedExports = ( for (const declarator of dependencies.exportedVariableDeclarators) { omittedExportedVariableDeclarators.add(declarator); } + for (const importedName of dependencies.importedIdentifierNames) { + omittedImportedIdentifierNames.add(importedName); + } } - const ast = codeToAst(code, cache, cacheKey); - const omittedStatementsArray = Array.from(omittedStatements); - const omittedExportedVariableDeclaratorsArray = Array.from( - omittedExportedVariableDeclarators - ); - ast.program.body = ast.program.body - .filter(node => - omittedStatementsArray.every( - statement => !t.isNodesEquivalent(node, statement) - ) - ) - .map(node => { - if (!t.isImportDeclaration(node)) { - return node; - } - if (node.specifiers.length === 0) { + for (const retainedExportName of retainedExportNames) { + const dependencies = exportDependencies.get(retainedExportName); + if (!dependencies) { + continue; + } + for (const importedName of dependencies.importedIdentifierNames) { + retainedImportedIdentifierNames.add(importedName); + } + } + + const program = analyzeCode(code, cache, cacheKey).program; + const body = program.body + .filter((node: AnyNode) => !omittedStatements.has(node)) + .map((node: AnyNode) => { + if (node.type !== 'ImportDeclaration') { return node; } - node.specifiers = node.specifiers.filter(specifier => { - const importedName = specifier.local.name; - for (const retainedExportName of retainedExportNames) { - const dependencies = exportDependencies.get(retainedExportName); - if (dependencies?.importedIdentifierNames?.has(importedName)) { - return true; - } - } - for (const omittedExportName of omittedExportNames) { - const dependencies = exportDependencies.get(omittedExportName); - if (dependencies?.importedIdentifierNames?.has(importedName)) { - return false; - } - } + return filterImportSpecifiers(node, importedName => { + if (retainedImportedIdentifierNames.has(importedName)) return true; + if (omittedImportedIdentifierNames.has(importedName)) return false; return true; }); - if (node.specifiers.length === 0) { - return null; - } - return node; }) - .map(node => { - if (!t.isExportDeclaration(node)) { + .map((node: AnyNode | null) => { + if (!node || !node.type.startsWith('Export')) { return node; } - if (t.isExportAllDeclaration(node)) { + if (node.type === 'ExportAllDeclaration') { return node; } - if (t.isExportDefaultDeclaration(node)) { + if (node.type === 'ExportDefaultDeclaration') { return isOmitted('default') ? null : node; } - if (t.isVariableDeclaration(node.declaration)) { - node.declaration.declarations = - node.declaration.declarations.filter(declarationNode => - omittedExportedVariableDeclaratorsArray.every( - declarator => - !t.isNodesEquivalent(declarationNode, declarator) - ) - ); - if (node.declaration.declarations.length === 0) { - return null; - } - return node; + if (node.declaration?.type === 'VariableDeclaration') { + const declarations = node.declaration.declarations.filter( + (declarationNode: AnyNode) => + !omittedExportedVariableDeclarators.has(declarationNode) + ); + return declarations.length > 0 + ? { + ...node, + declaration: { ...node.declaration, declarations }, + } + : null; } if ( - t.isFunctionDeclaration(node.declaration) || - t.isClassDeclaration(node.declaration) + node.declaration?.type === 'FunctionDeclaration' || + node.declaration?.type === 'ClassDeclaration' ) { - const declarationId = node.declaration.id; - invariant( - declarationId, - 'Expected exported function or class declaration to have a name when not the default export' - ); - return isOmitted(declarationId.name) ? null : node; + return isOmitted(node.declaration.id.name) ? null : node; } - if (t.isExportNamedDeclaration(node)) { - if (node.specifiers.length === 0) { - return node; - } - node.specifiers = node.specifiers.filter(specifier => { + if (node.type === 'ExportNamedDeclaration') { + const specifiers = node.specifiers.filter((specifier: AnyNode) => { const exportedName = getExportedName(specifier.exported); return !isOmitted(exportedName); }); - if (node.specifiers.length === 0) { - return null; - } - return node; + return specifiers.length > 0 || node.declaration + ? { ...node, specifiers } + : null; } throw new Error('Unknown node type'); }) - .filter(Boolean) as t.Statement[]; + .filter(Boolean) as AnyNode[]; - if (ast.program.body.length === 0) { - return undefined; - } - return generate(ast, generateOptions).code; + return generateCode({ ...program, body }); } ); }; +const precomputeChunkedExports = ( + code: string, + cache: RouteChunkCache, + cacheKey: string +) => { + const chunkableExportMap = getChunkableExportMap(code, cache, cacheKey); + for (const exportName of routeChunkExportNames) { + if (!chunkableExportMap[exportName]) { + continue; + } + if (!hasCachedChunkedExport(code, exportName, cache, cacheKey)) { + getChunkedExport(code, exportName, cache, cacheKey); + } + } +}; + export const detectRouteChunks = ( code: string, cache: RouteChunkCache | undefined, cacheKey: string ): RouteChunkInfo => { - const hasRouteChunkByExportName = Object.fromEntries( - routeChunkExportNames.map(exportName => [ - exportName, - hasChunkableExport(code, exportName, cache, cacheKey), - ]) - ) as Record; + const analysisCache = cache ?? new Map(); + const exportDependencies = getExportDependencies( + code, + analysisCache, + cacheKey + ); + const hasRouteChunkByExportName = getChunkableExportMap( + code, + analysisCache, + cacheKey + ); const chunkedExports = Object.entries(hasRouteChunkByExportName) .filter(([, isChunked]) => isChunked) .map(([exportName]) => exportName as RouteChunkExportName); const hasRouteChunks = chunkedExports.length > 0; return { + exportNames: Array.from(exportDependencies.keys()), hasRouteChunks, hasRouteChunkByExportName, chunkedExports, @@ -791,10 +684,19 @@ export const getRouteChunkCode: ( cache: RouteChunkCache | undefined, cacheKey: string ) => { + const analysisCache = cache ?? new Map(); if (chunkName === 'main') { - return omitChunkedExports(code, routeChunkExportNames, {}, cache, cacheKey); + return omitChunkedExports( + code, + routeChunkExportNames, + analysisCache, + cacheKey + ); + } + if (!hasCachedChunkedExport(code, chunkName, analysisCache, cacheKey)) { + precomputeChunkedExports(code, analysisCache, cacheKey); } - return getChunkedExport(code, chunkName, {}, cache, cacheKey); + return getChunkedExport(code, chunkName, analysisCache, cacheKey); }; export const getRouteChunkModuleId = ( @@ -803,9 +705,7 @@ export const getRouteChunkModuleId = ( ) => `${filePath}${routeChunkQueryStrings[chunkName]}`; export const isRouteChunkModuleId: (id: string) => boolean = (id: string) => - Object.values(routeChunkQueryStrings).some(queryString => - id.endsWith(queryString) - ); + routeChunkQueryStringValues.some(queryString => id.endsWith(queryString)); const isRouteChunkName = (name: string): name is RouteChunkName => name === 'main' || (routeChunkExportNames as string[]).includes(name); @@ -813,10 +713,16 @@ const isRouteChunkName = (name: string): name is RouteChunkName => export const getRouteChunkNameFromModuleId = ( id: string ): RouteChunkName | null => { - if (!id.includes(routeChunkQueryStringPrefix)) { + const queryIndex = id.indexOf(routeChunkQueryStringPrefix); + if (queryIndex === -1) { return null; } - const chunkName = id.split(routeChunkQueryStringPrefix)[1].split('&')[0]; + const chunkNameStart = queryIndex + routeChunkQueryStringPrefix.length; + const chunkNameEnd = id.indexOf('&', chunkNameStart); + const chunkName = id.slice( + chunkNameStart, + chunkNameEnd === -1 ? undefined : chunkNameEnd + ); if (!isRouteChunkName(chunkName)) { return null; } @@ -832,6 +738,38 @@ const normalizeRelativeFilePath = (file: string, appDirectory: string) => { const isRootRouteModuleId = (config: RouteChunkConfig, id: string) => normalizeRelativeFilePath(id, config.appDirectory) === config.rootRouteFile; +export const shouldAnalyzeRouteChunks = ( + config: RouteChunkConfig, + id: string, + code: string +): boolean => + Boolean(config.splitRouteModules) && + mightContainRouteChunkExportName(code) && + !isRootRouteModuleId(config, id); + +export const createEmptyRouteChunkByExportName = (): Record< + RouteChunkExportName, + boolean +> => createRouteChunkExportMap(() => false); + +export const buildEnforceChunkValidity = ( + exportNames: readonly string[] +): Record => { + const exportNameSet = new Set(exportNames); + return createRouteChunkExportMap( + exportName => !exportNameSet.has(exportName) + ); +}; + +export const buildManifestChunkValidity = ( + exportNames: ReadonlySet, + hasRouteChunkByExportName: Readonly> +): Record => + createRouteChunkExportMap( + exportName => + !exportNames.has(exportName) || hasRouteChunkByExportName[exportName] + ); + export const detectRouteChunksIfEnabled: ( cache: RouteChunkCache | undefined, config: RouteChunkConfig, @@ -844,23 +782,13 @@ export const detectRouteChunksIfEnabled: ( code: string ) => { const noRouteChunks = (): RouteChunkInfo => ({ + exportNames: [], chunkedExports: [] as RouteChunkExportName[], hasRouteChunks: false, - hasRouteChunkByExportName: { - clientAction: false, - clientLoader: false, - clientMiddleware: false, - HydrateFallback: false, - } as Record, + hasRouteChunkByExportName: createEmptyRouteChunkByExportName(), }); - if (!config.splitRouteModules) { - return noRouteChunks(); - } - if (isRootRouteModuleId(config, id)) { - return noRouteChunks(); - } - if (!routeChunkExportNames.some(exportName => code.includes(exportName))) { + if (!shouldAnalyzeRouteChunks(config, id, code)) { return noRouteChunks(); } @@ -884,6 +812,13 @@ export const getRouteChunkIfEnabled: ( if (!config.splitRouteModules) { return null; } + if (chunkName === 'main') { + if (!mightContainRouteChunkExportName(code)) { + return code; + } + } else if (!code.includes(chunkName)) { + return null; + } const cacheKey = normalizeRelativeFilePath(id, config.appDirectory); return getRouteChunkCode(code, chunkName, cache, cacheKey) ?? null; }; diff --git a/src/route-transform-tasks.ts b/src/route-transform-tasks.ts new file mode 100644 index 0000000..7375304 --- /dev/null +++ b/src/route-transform-tasks.ts @@ -0,0 +1,351 @@ +import { statSync, type Stats } from 'node:fs'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { basename as pathBasename, dirname, relative, resolve } from 'pathe'; +import { generate, parse } from './babel.js'; +import { + JS_EXTENSIONS, + PLUGIN_NAME, + SERVER_ONLY_ROUTE_EXPORTS, + SERVER_ONLY_ROUTE_EXPORTS_SET, +} from './constants.js'; +import { + collectProgramExportNames, + getExportNamesAndExportAll, + getRouteModuleAnalysis, +} from './export-utils.js'; +import { + removeExports, + removeUnusedImports, + transformRoute, +} from './plugin-utils.js'; +import { + createRouteChunkArtifact, + createRouteClientEntryArtifact, +} from './route-artifacts.js'; +import { + detectRouteChunksIfEnabled, + getRouteChunkModuleId, + type RouteChunkCache, + type RouteChunkConfig, +} from './route-chunks.js'; + +export type RouteTransformResult = { + code: string; + map?: ReturnType['map']; +}; + +type BaseRouteTransformTask = { + code: string; + resourcePath: string; +}; + +export type RouteClientEntryTransformTask = BaseRouteTransformTask & { + kind: 'routeClientEntry'; + environmentName?: string; + isBuild: boolean; + routeChunkConfig: RouteChunkConfig; +}; + +export type RouteChunkTransformTask = BaseRouteTransformTask & { + kind: 'routeChunk'; + resource: string; + isBuild: boolean; + routeChunkConfig: RouteChunkConfig; +}; + +export type SplitRouteExportsTransformTask = BaseRouteTransformTask & { + kind: 'splitRouteExports'; + routeChunkConfig: RouteChunkConfig; +}; + +export type ClientOnlyStubTransformTask = BaseRouteTransformTask & { + kind: 'clientOnlyStub'; +}; + +export type RouteModuleTransformTask = BaseRouteTransformTask & { + kind: 'routeModule'; + resource: string; + environmentName: string; + ssr: boolean; + isBuild: boolean; + isSpaMode: boolean; + rootRoutePath: string | null; +}; + +export type RouteTransformTask = + | RouteClientEntryTransformTask + | RouteChunkTransformTask + | SplitRouteExportsTransformTask + | ClientOnlyStubTransformTask + | RouteModuleTransformTask; + +export type RouteTransformTaskOptions = { + routeChunkCache?: RouteChunkCache; +}; + +const defaultRouteChunkCache: RouteChunkCache = new Map(); + +const getRouteChunkCache = (options?: RouteTransformTaskOptions) => + options?.routeChunkCache ?? defaultRouteChunkCache; + +const tryStat = (path: string): Stats | null => + statSync(path, { throwIfNoEntry: false }) ?? null; + +const splitRouteExports = async ( + task: SplitRouteExportsTransformTask, + options?: RouteTransformTaskOptions +): Promise => { + const { exportNames, hasRouteChunks, chunkedExports } = + await detectRouteChunksIfEnabled( + getRouteChunkCache(options), + task.routeChunkConfig, + task.resourcePath, + task.code + ); + if (!hasRouteChunks) { + return { code: task.code, map: null }; + } + + const chunkedExportSet = new Set(chunkedExports); + const mainChunkReexports = exportNames + .filter(name => !chunkedExportSet.has(name)) + .join(', '); + const chunkBasePath = `./${pathBasename(task.resourcePath)}`; + + return { + code: [ + mainChunkReexports + ? `export { ${mainChunkReexports} } from "${getRouteChunkModuleId( + chunkBasePath, + 'main' + )}";` + : null, + ...chunkedExports.map( + exportName => + `export { ${exportName} } from "${getRouteChunkModuleId( + chunkBasePath, + exportName + )}";` + ), + ] + .filter(Boolean) + .join('\n'), + map: null, + }; +}; + +const resolveIndexFile = (dirPath: string): string | null => { + for (const ext of JS_EXTENSIONS) { + const candidate = resolve(dirPath, `index${ext}`); + const stats = tryStat(candidate); + if (!stats?.isFile()) { + continue; + } + return candidate; + } + return null; +}; + +const resolvePathWithExtensions = (basePath: string): string | null => { + const stats = tryStat(basePath); + if (stats?.isFile()) { + return basePath; + } + if (stats?.isDirectory()) { + return resolveIndexFile(basePath); + } + + for (const ext of JS_EXTENSIONS) { + const candidate = `${basePath}${ext}`; + const candidateStats = tryStat(candidate); + if (!candidateStats?.isFile()) { + continue; + } + return candidate; + } + + return resolveIndexFile(basePath); +}; + +const resolveExportAllModule = ( + specifier: string, + importerPath: string +): string | null => { + if (specifier.startsWith('.') || specifier.startsWith('/')) { + const basePath = specifier.startsWith('/') + ? specifier + : resolve(dirname(importerPath), specifier); + const resolvedPath = resolvePathWithExtensions(basePath); + if (resolvedPath) { + return resolvedPath; + } + } + + try { + const resolver = createRequire(pathToFileURL(importerPath).href); + return resolver.resolve(specifier); + } catch { + return null; + } +}; + +const createClientOnlyStub = async ( + task: ClientOnlyStubTransformTask +): Promise => { + const { exportNames: directExportNames, exportAllModules } = + await getExportNamesAndExportAll(task.code); + const exportNames = new Set(directExportNames); + const unresolvedExportAll = new Set(); + const visitedModules = new Set(); + + const collectExportNamesFromModule = async ( + modulePath: string + ): Promise => { + if (visitedModules.has(modulePath)) { + return; + } + visitedModules.add(modulePath); + const { exports: moduleExportNames, exportAllModules: moduleExportAll } = + await getRouteModuleAnalysis(modulePath); + for (const name of moduleExportNames) { + if (name !== 'default') { + exportNames.add(name); + } + } + for (const nestedSpecifier of moduleExportAll) { + const nestedPath = resolveExportAllModule(nestedSpecifier, modulePath); + if (!nestedPath) { + unresolvedExportAll.add(nestedSpecifier); + continue; + } + await collectExportNamesFromModule(nestedPath); + } + }; + + for (const specifier of exportAllModules) { + const resolvedPath = resolveExportAllModule(specifier, task.resourcePath); + if (!resolvedPath) { + unresolvedExportAll.add(specifier); + continue; + } + await collectExportNamesFromModule(resolvedPath); + } + + if (unresolvedExportAll.size > 0) { + throw new Error( + `[${PLUGIN_NAME}] Client-only module uses \`export * from\` with ` + + `unresolvable specifier(s): ${Array.from(unresolvedExportAll) + .map(spec => `\`${spec}\``) + .join(', ')}. ` + + `Please explicitly re-export named bindings in ` + + `\`${relative(process.cwd(), task.resourcePath)}\`.` + ); + } + + return { + code: Array.from(exportNames) + .map(name => + name === 'default' + ? 'export default undefined;' + : `export const ${name} = undefined;` + ) + .join('\n'), + map: null, + }; +}; + +const transformRouteModule = async ( + task: RouteModuleTransformTask +): Promise => { + let code = task.code; + + const defaultExportMatch = code.match(/\n\s{0,}([\w\d_]+)\sas default,?/); + if (defaultExportMatch && typeof defaultExportMatch.index === 'number') { + code = + code.slice(0, defaultExportMatch.index) + + code.slice(defaultExportMatch.index + defaultExportMatch[0].length); + code += `\nexport default ${defaultExportMatch[1]};`; + } + + const ast = parse(code, { sourceType: 'module' }); + if (task.environmentName === 'web' && !task.ssr && task.isSpaMode) { + const resolvedExportNames = collectProgramExportNames(ast.program); + const isRootRoute = task.resourcePath === task.rootRoutePath; + const relativePath = relative(process.cwd(), task.resourcePath); + + const invalidServerOnly = resolvedExportNames.filter(exp => { + if (isRootRoute && exp === 'loader') return false; + return SERVER_ONLY_ROUTE_EXPORTS_SET.has(exp); + }); + + if (invalidServerOnly.length > 0) { + const list = invalidServerOnly.map(e => `\`${e}\``).join(', '); + throw new Error( + `SPA Mode: ${invalidServerOnly.length} invalid route export(s) in ` + + `\`${relativePath}\`: ${list}. ` + + `See https://reactrouter.com/how-to/spa for more information.` + ); + } + + if (!isRootRoute && resolvedExportNames.includes('HydrateFallback')) { + throw new Error( + `SPA Mode: Invalid \`HydrateFallback\` export found in ` + + `\`${relativePath}\`. ` + + `\`HydrateFallback\` is only permitted on the root route in SPA Mode. ` + + `See https://reactrouter.com/how-to/spa for more information.` + ); + } + } + + const removedServerOnlyExports = + task.environmentName === 'web' + ? removeExports( + ast, + SERVER_ONLY_ROUTE_EXPORTS, + SERVER_ONLY_ROUTE_EXPORTS_SET + ) + : false; + transformRoute(ast); + if (removedServerOnlyExports) { + removeUnusedImports(ast); + } + + return generate(ast, { + sourceMaps: !task.isBuild, + filename: task.resource, + sourceFileName: task.resourcePath, + }); +}; + +export const executeRouteTransformTask = async ( + task: RouteTransformTask, + options?: RouteTransformTaskOptions +): Promise => { + switch (task.kind) { + case 'routeClientEntry': + return createRouteClientEntryArtifact({ + code: task.code, + resourcePath: task.resourcePath, + environmentName: task.environmentName, + isBuild: task.isBuild, + routeChunkCache: getRouteChunkCache(options), + routeChunkConfig: task.routeChunkConfig, + }); + case 'routeChunk': + return createRouteChunkArtifact({ + code: task.code, + resource: task.resource, + resourcePath: task.resourcePath, + isBuild: task.isBuild, + routeChunkCache: getRouteChunkCache(options), + routeChunkConfig: task.routeChunkConfig, + }); + case 'splitRouteExports': + return splitRouteExports(task, options); + case 'clientOnlyStub': + return createClientOnlyStub(task); + case 'routeModule': + return transformRouteModule(task); + } +}; diff --git a/src/route-watch.ts b/src/route-watch.ts new file mode 100644 index 0000000..d57cc81 --- /dev/null +++ b/src/route-watch.ts @@ -0,0 +1,260 @@ +import { readFileSync, watch, type FSWatcher } from 'node:fs'; +import { access, mkdir, readdir, writeFile } from 'node:fs/promises'; +import type { ProcessAssetsHandler, RsbuildConfig } from '@rsbuild/core'; +import { dirname, resolve } from 'pathe'; +import type { Route } from './types.js'; + +export const ROUTE_RESTART_MARKER_ASSET = '.react-router/route-watch'; +const INITIAL_RESTART_MARKER_CONTENT = 'react-router-route-watch'; + +type RouteManifestSnapshotEntry = Pick< + Route, + 'caseSensitive' | 'file' | 'id' | 'index' | 'parentId' | 'path' +>; + +type WatchFilesConfig = NonNullable< + NonNullable['watchFiles'] +>; +export type WatchFileConfig = + | Exclude + | Extract[number]; + +type RouteDirectoryState = { + directories: Set; + routeTopology: Set; +}; + +type ProcessAssetsContext = Parameters[0]; +type RouteRestartMarkerAssetOptions = Pick< + ProcessAssetsContext, + 'compilation' | 'sources' +> & { + restartMarkerPath: string; +}; + +export const mergeWatchFiles = ( + existing: WatchFilesConfig | undefined, + additions: WatchFileConfig[] +): WatchFilesConfig => { + if (!existing) { + return additions as WatchFilesConfig; + } + return [ + ...(Array.isArray(existing) ? existing : [existing]), + ...additions, + ] as WatchFilesConfig; +}; + +export const getRouteRestartMarkerPath = (outputClientPath: string): string => + resolve(outputClientPath, ROUTE_RESTART_MARKER_ASSET); + +const readRestartMarkerContent = (restartMarkerPath: string): string => { + try { + const content = readFileSync(restartMarkerPath, 'utf8'); + return content || INITIAL_RESTART_MARKER_CONTENT; + } catch { + return INITIAL_RESTART_MARKER_CONTENT; + } +}; + +export const emitRouteRestartMarkerAsset = ({ + restartMarkerPath, + sources, + compilation, +}: RouteRestartMarkerAssetOptions): void => { + const source = new sources.RawSource( + readRestartMarkerContent(restartMarkerPath) + ); + if (compilation.getAsset(ROUTE_RESTART_MARKER_ASSET)) { + compilation.updateAsset(ROUTE_RESTART_MARKER_ASSET, source); + return; + } + compilation.emitAsset(ROUTE_RESTART_MARKER_ASSET, source); +}; + +export const createRouteManifestSnapshot = ( + routes: Record +): Set => + new Set( + Object.entries(routes) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([routeId, route]) => + JSON.stringify([ + routeId, + route.id, + route.parentId ?? null, + route.path ?? null, + route.index ?? null, + route.caseSensitive ?? null, + route.file, + ]) + ) + ); + +export const ensureDevRestartMarker = async ( + restartMarkerPath: string +): Promise => { + // Build emits this marker through processAssets. Dev owns the watched file + // directly so ordinary rebuilds do not rewrite it and trigger reload loops. + await mkdir(dirname(restartMarkerPath), { recursive: true }); + try { + await access(restartMarkerPath); + } catch { + await writeFile(restartMarkerPath, INITIAL_RESTART_MARKER_CONTENT); + } +}; + +const areSetsEqual = (left: Set, right: Set): boolean => { + if (left.size !== right.size) { + return false; + } + for (const value of left) { + if (!right.has(value)) { + return false; + } + } + return true; +}; + +const readRouteDirectoryState = async ({ + watchDirectory, + getRouteTopology, +}: { + watchDirectory: string; + getRouteTopology: () => Promise>; +}): Promise => { + const directories = new Set(); + + const walkDirectory = async (directory: string): Promise => { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + + directories.add(directory); + await Promise.all( + entries.map(async entry => { + const entryPath = resolve(directory, entry.name); + if (entry.isDirectory()) { + await walkDirectory(entryPath); + } + }) + ); + }; + + await walkDirectory(watchDirectory); + return { + directories, + routeTopology: await getRouteTopology(), + }; +}; + +export const createRouteTopologyWatcher = async ({ + watchDirectory, + getRouteTopology, + restartMarkerPath, + onError, +}: { + watchDirectory: string; + getRouteTopology: () => Promise>; + restartMarkerPath: string; + onError: (error: unknown) => void; +}): Promise<() => void> => { + let state = await readRouteDirectoryState({ + watchDirectory, + getRouteTopology, + }); + let closed = false; + let rescanTimer: ReturnType | undefined; + let rescanQueue = Promise.resolve(); + const directoryWatchers = new Map(); + + const touchRestartMarker = async (): Promise => { + await mkdir(dirname(restartMarkerPath), { recursive: true }); + await writeFile(restartMarkerPath, String(Date.now())); + }; + + const closeRemovedDirectoryWatchers = ( + nextDirectories: Set + ): void => { + for (const [directory, watcher] of directoryWatchers) { + if (!nextDirectories.has(directory)) { + watcher.close(); + directoryWatchers.delete(directory); + } + } + }; + + const watchNewDirectories = (nextDirectories: Set): void => { + for (const directory of nextDirectories) { + if (directoryWatchers.has(directory)) { + continue; + } + try { + const watcher = watch(directory, () => { + scheduleRescan(); + }); + watcher.on('error', onError); + directoryWatchers.set(directory, watcher); + } catch (error) { + onError(error); + } + } + }; + + const syncDirectoryWatchers = (nextDirectories: Set): void => { + closeRemovedDirectoryWatchers(nextDirectories); + watchNewDirectories(nextDirectories); + }; + + const runRescan = async (): Promise => { + if (closed) { + return; + } + try { + const nextState = await readRouteDirectoryState({ + watchDirectory, + getRouteTopology, + }); + syncDirectoryWatchers(nextState.directories); + if (!areSetsEqual(state.routeTopology, nextState.routeTopology)) { + state = nextState; + await touchRestartMarker(); + return; + } + state = nextState; + } catch (error) { + onError(error); + } + }; + + const rescan = (): Promise => { + rescanQueue = rescanQueue.then(runRescan, runRescan); + return rescanQueue; + }; + + const scheduleRescan = (): void => { + if (rescanTimer) { + clearTimeout(rescanTimer); + } + rescanTimer = setTimeout(() => { + rescanTimer = undefined; + void rescan(); + }, 100); + }; + + syncDirectoryWatchers(state.directories); + + return () => { + closed = true; + if (rescanTimer) { + clearTimeout(rescanTimer); + } + for (const watcher of directoryWatchers.values()) { + watcher.close(); + } + directoryWatchers.clear(); + }; +}; diff --git a/src/templates/entry.server.tsx b/src/templates/entry.server.tsx index c0e202c..aecef83 100644 --- a/src/templates/entry.server.tsx +++ b/src/templates/entry.server.tsx @@ -30,11 +30,17 @@ export default function handleRequest( (userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady'; + let abortDelay: ReturnType | undefined; + let ready = false; const { pipe, abort } = renderToPipeableStream( , { [readyOption]() { + ready = true; + if (readyOption === 'onAllReady' && abortDelay) { + clearTimeout(abortDelay); + } shellRendered = true; const body = new PassThrough(); const stream = createReadableStreamFromReadable(body); @@ -62,6 +68,9 @@ export default function handleRequest( } ); - setTimeout(abort, ABORT_DELAY); + abortDelay = setTimeout(abort, ABORT_DELAY); + if (readyOption === 'onAllReady' && ready) { + clearTimeout(abortDelay); + } }); } diff --git a/src/types.ts b/src/types.ts index a8d3fca..ba40843 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { RsbuildConfig } from '@rsbuild/core'; + export type Route = { id: string; parentId?: string; @@ -27,19 +29,29 @@ export type PluginOptions = { * Federation mode configuration */ federation?: boolean; -}; -/** - * Arguments passed to transform functions - */ -export type TransformArgs = { - code: string; - resource: string; - resourcePath: string; - context?: string | null; - environment?: { - name: string; - }; + /** + * Rsbuild dev-only lazy compilation behavior. + * @default false + */ + lazyCompilation?: NonNullable['lazyCompilation']; + + /** + * Emit structured React Router plugin timing logs. + * @default false + */ + logPerformance?: boolean; + + /** + * Run route transforms in a worker-thread pool. + * Pass `false` to disable or `{ maxWorkers }` to override the default worker count. + * @default true, using `available CPUs - 2` workers. + */ + parallelTransforms?: + | boolean + | { + maxWorkers?: number; + }; }; export type RouteManifestItem = Omit & { diff --git a/src/virtual-modules.ts b/src/virtual-modules.ts new file mode 100644 index 0000000..9fe3a78 --- /dev/null +++ b/src/virtual-modules.ts @@ -0,0 +1,30 @@ +const VIRTUAL_MODULE_PREFIX = 'virtual/react-router/'; + +export const getVirtualModuleFilePath = (moduleId: string): string => { + if (!moduleId.startsWith(VIRTUAL_MODULE_PREFIX)) { + throw new Error( + `Virtual module id must start with ${JSON.stringify(VIRTUAL_MODULE_PREFIX)}: ${moduleId}` + ); + } + + const relativeId = moduleId.slice(VIRTUAL_MODULE_PREFIX.length); + const segments = relativeId.split('/'); + if ( + !relativeId || + segments.some(segment => !segment || segment === '.' || segment === '..') + ) { + throw new Error(`Invalid virtual module id: ${moduleId}`); + } + + return `node_modules/${moduleId}.js`; +}; + +export const mapVirtualModules = ( + modules: Record +): Record => + Object.fromEntries( + Object.entries(modules).map(([moduleId, contents]) => [ + getVirtualModuleFilePath(moduleId), + contents, + ]) + ); diff --git a/tests/benchmark-fixture.test.ts b/tests/benchmark-fixture.test.ts new file mode 100644 index 0000000..d20bd40 --- /dev/null +++ b/tests/benchmark-fixture.test.ts @@ -0,0 +1,264 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { describe, expect, it } from '@rstest/core'; + +describe('benchmark fixture generator', () => { + it('creates a deterministic synthetic React Router app', async () => { + const { generateSyntheticFixture } = await import( + '../scripts/benchmark/fixture.mjs' + ); + const root = mkdtempSync(join(tmpdir(), 'rr-benchmark-fixture-')); + + try { + const result = await generateSyntheticFixture({ + root, + routeCount: 8, + variant: 'ssr-esm-split', + sourceMap: true, + }); + + expect(result.routeCount).toBe(8); + expect(result.variant).toBe('ssr-esm-split'); + expect(existsSync(join(root, 'app/routes.ts'))).toBe(true); + expect(existsSync(join(root, 'rsbuild.config.mjs'))).toBe(true); + + const routeConfig = readFileSync(join(root, 'app/routes.ts'), 'utf8'); + expect(routeConfig).toContain("id: 'route-0001'"); + expect(routeConfig).toContain("file: 'routes/route-0001.tsx'"); + expect(routeConfig).toContain("id: 'route-0008'"); + expect(existsSync(join(root, 'app/routes/route-0008.tsx'))).toBe(true); + + const routeModule = readFileSync( + join(root, 'app/routes/route-0003.tsx'), + 'utf8' + ); + expect(routeModule).toContain('export async function clientLoader'); + expect(routeModule).toContain('export default function Route0003'); + + const rsbuildConfig = readFileSync(join(root, 'rsbuild.config.mjs'), 'utf8'); + expect(rsbuildConfig).toContain( + "import { pluginReact } from '@rsbuild/plugin-react';" + ); + expect(rsbuildConfig).toContain('pluginReact(),'); + expect(rsbuildConfig.indexOf('pluginReact(),')).toBeLessThan( + rsbuildConfig.indexOf('pluginReactRouter({') + ); + expect(rsbuildConfig).toContain('logPerformance'); + expect(rsbuildConfig).toContain( + "sourceMap: { js: 'cheap-module-source-map', css: false }" + ); + expect(rsbuildConfig).not.toContain('parallelTransforms:'); + + const reactRouterConfig = readFileSync( + join(root, 'react-router.config.ts'), + 'utf8' + ); + expect(reactRouterConfig).toContain('v8_splitRouteModules'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('can point the benchmark config at an explicit built plugin import', async () => { + const { generateSyntheticFixture } = await import( + '../scripts/benchmark/fixture.mjs' + ); + const root = mkdtempSync(join(tmpdir(), 'rr-benchmark-fixture-')); + + try { + await generateSyntheticFixture({ + root, + routeCount: 1, + variant: 'ssr-esm', + pluginImportPath: 'file:///repo/dist/index.js', + }); + + const rsbuildConfig = readFileSync(join(root, 'rsbuild.config.mjs'), 'utf8'); + expect(rsbuildConfig).toContain( + "import { pluginReactRouter } from 'file:///repo/dist/index.js';" + ); + expect(rsbuildConfig).toContain("serverOutput: 'module'"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('can enable parallel route transforms in benchmark config', async () => { + const { generateSyntheticFixture } = await import( + '../scripts/benchmark/fixture.mjs' + ); + const root = mkdtempSync(join(tmpdir(), 'rr-benchmark-fixture-')); + + try { + const result = await generateSyntheticFixture({ + root, + routeCount: 1, + variant: 'ssr-esm', + parallelTransforms: { maxWorkers: 3 }, + }); + + const rsbuildConfig = readFileSync(join(root, 'rsbuild.config.mjs'), 'utf8'); + expect(result.parallelTransforms).toEqual({ maxWorkers: 3 }); + expect(rsbuildConfig).toContain( + 'parallelTransforms: { maxWorkers: 3 },' + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('can explicitly disable parallel route transforms in benchmark config', async () => { + const { generateSyntheticFixture } = await import( + '../scripts/benchmark/fixture.mjs' + ); + const root = mkdtempSync(join(tmpdir(), 'rr-benchmark-fixture-')); + + try { + const result = await generateSyntheticFixture({ + root, + routeCount: 1, + variant: 'ssr-esm', + parallelTransforms: false, + }); + + const rsbuildConfig = readFileSync(join(root, 'rsbuild.config.mjs'), 'utf8'); + expect(result.parallelTransforms).toBe(false); + expect(rsbuildConfig).toContain('parallelTransforms: false,'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('omits server-only route exports from SPA benchmark fixtures', async () => { + const { generateSyntheticFixture } = await import( + '../scripts/benchmark/fixture.mjs' + ); + const root = mkdtempSync(join(tmpdir(), 'rr-benchmark-fixture-')); + + try { + await generateSyntheticFixture({ + root, + routeCount: 8, + variant: 'spa', + }); + + const rootModule = readFileSync(join(root, 'app/root.tsx'), 'utf8'); + expect(rootModule).toContain('Scripts'); + + for (let index = 1; index <= 8; index += 1) { + const routeModule = readFileSync( + join(root, `app/routes/route-${String(index).padStart(4, '0')}.tsx`), + 'utf8' + ); + expect(routeModule).not.toContain('function loader'); + expect(routeModule).not.toContain('function action'); + expect(routeModule).not.toContain('function headers'); + expect(routeModule).not.toContain('HydrateFallback'); + expect(routeModule).not.toContain('server-data.server'); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('generates deterministic named stress fixture shapes', async () => { + const { benchmarkFixtureNames, generateSyntheticFixture } = await import( + '../scripts/benchmark/fixture.mjs' + ); + expect(benchmarkFixtureNames).toEqual([ + 'default', + 'export-heavy', + 'reexports', + 'import-fanout', + 'chunk-saturated', + ]); + + const expectations = [ + { + fixture: 'export-heavy', + routeFile: 'app/routes/route-0001.tsx', + snippets: ['unusedExport0001_31', 'export async function clientLoader'], + }, + { + fixture: 'reexports', + routeFile: 'app/routes/route-0001.tsx', + snippets: [ + "export * from '../route-reexports/reexport-all-0001'", + 'app/route-reexports/reexport-0001.ts', + ], + }, + { + fixture: 'import-fanout', + routeFile: 'app/routes/route-0001.tsx', + snippets: ["from '../fanout/fanout-15'", 'fanoutValues'], + }, + { + fixture: 'chunk-saturated', + routeFile: 'app/routes/route-0001.tsx', + snippets: ['export async function clientAction', 'HydrateFallback'], + }, + ]; + + for (const { fixture, routeFile, snippets } of expectations) { + const rootA = mkdtempSync(join(tmpdir(), 'rr-benchmark-fixture-a-')); + const rootB = mkdtempSync(join(tmpdir(), 'rr-benchmark-fixture-b-')); + + try { + const result = await generateSyntheticFixture({ + root: rootA, + routeCount: 4, + variant: 'ssr-esm-split', + fixture, + }); + await generateSyntheticFixture({ + root: rootB, + routeCount: 4, + variant: 'ssr-esm-split', + fixture, + }); + + expect(result.fixture).toBe(fixture); + const routeModuleA = readFileSync(join(rootA, routeFile), 'utf8'); + const routeModuleB = readFileSync(join(rootB, routeFile), 'utf8'); + expect(routeModuleA).toBe(routeModuleB); + + for (const snippet of snippets) { + if (snippet.startsWith('app/')) { + expect(existsSync(join(rootA, snippet))).toBe(true); + } else { + expect(routeModuleA).toContain(snippet); + } + } + } finally { + rmSync(rootA, { recursive: true, force: true }); + rmSync(rootB, { recursive: true, force: true }); + } + } + }); + + it('accepts equals-form CLI options before benchmark selection', () => { + const result = spawnSync( + process.execPath, + [ + 'scripts/bench-builds.mjs', + '--profile=smoke', + '--iterations=1', + '--warmup=0', + '--filter=missing', + '--rspack-profile=ALL', + '--rspack-trace-output=rspack.log', + '--skip-root-build', + ], + { + cwd: process.cwd(), + encoding: 'utf8', + } + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('No benchmarks matched filter "missing".'); + expect(result.stderr).not.toContain('Unknown benchmark argument'); + }); +}); diff --git a/tests/bounded-cache.test.ts b/tests/bounded-cache.test.ts new file mode 100644 index 0000000..b26eeb2 --- /dev/null +++ b/tests/bounded-cache.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from '@rstest/core'; +import { setBoundedCacheEntry } from '../src/bounded-cache'; + +describe('bounded cache helpers', () => { + it('evicts the oldest entry only when inserting past the maximum size', () => { + const cache = new Map([ + ['first', 1], + ['second', 2], + ]); + + setBoundedCacheEntry(cache, 'second', 22, 2); + expect([...cache.entries()]).toEqual([ + ['first', 1], + ['second', 22], + ]); + + setBoundedCacheEntry(cache, 'third', 3, 2); + expect([...cache.entries()]).toEqual([ + ['second', 22], + ['third', 3], + ]); + }); +}); diff --git a/tests/export-utils.test.ts b/tests/export-utils.test.ts new file mode 100644 index 0000000..921d1d6 --- /dev/null +++ b/tests/export-utils.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from '@rstest/core'; +import { parse } from '../src/babel'; +import { + getBundlerRouteAnalysis, + getExportNamesAndExportAll, + transformToEsm, +} from '../src/export-utils'; + +const routeChunkConfig = { + splitRouteModules: true as const, + appDirectory: '/app', + rootRouteFile: 'root.tsx', +}; + +describe('getBundlerRouteAnalysis', () => { + it('reuses source code, export names, and chunk info for the same source', async () => { + const source = ` + export const clientAction = async () => {}; + export default function Route() { return null; } + `; + const resourcePath = '/app/routes/demo.tsx'; + + const first = await getBundlerRouteAnalysis(source, resourcePath); + const second = await getBundlerRouteAnalysis(source, resourcePath); + + expect(second).toBe(first); + expect(second.code).toBe(first.code); + expect(second.exportNames).toBe(first.exportNames); + expect(second.getRouteChunkInfo(undefined, routeChunkConfig)).toBe( + first.getRouteChunkInfo(undefined, routeChunkConfig) + ); + + expect(first.code).toBe(source); + expect(first.exportNames).toEqual(['clientAction', 'default']); + await expect( + first.getRouteChunkInfo(undefined, routeChunkConfig) + ).resolves.toMatchObject({ + hasRouteChunks: true, + chunkedExports: ['clientAction'], + }); + }); + + it('replaces the cached analysis when the source changes for the same resource', async () => { + const resourcePath = '/app/routes/demo.tsx'; + + const initial = await getBundlerRouteAnalysis( + `export const clientAction = async () => {};`, + resourcePath + ); + const updated = await getBundlerRouteAnalysis( + `export const clientLoader = async () => {};`, + resourcePath + ); + + expect(updated).not.toBe(initial); + expect(updated.exportNames).toEqual(['clientLoader']); + }); + + it('collects runtime exports and export-all modules from the initial parse', async () => { + const analysis = await getBundlerRouteAnalysis( + ` + export type LoaderData = { value: string }; + export interface RouteHandle { title: string } + export type * from './types'; + export type * as typeHelpers from './type-helpers'; + export * from './shared'; + export * as helpers from './helpers'; + export const loader = () => null; + export default function Route() { return null; } + `, + '/app/routes/runtime-exports.tsx' + ); + + const exportInfo = { + exportNames: analysis.exportNames, + exportAllModules: analysis.exportAllModules, + }; + expect(exportInfo).toEqual({ + exportNames: ['helpers', 'loader', 'default'], + exportAllModules: ['./shared'], + }); + await expect(getExportNamesAndExportAll(analysis.code)).resolves.toEqual( + exportInfo + ); + }); + + it('collects exported TypeScript enum names as runtime exports', async () => { + await expect( + getExportNamesAndExportAll( + `export enum Status { Active = 'active' }` + ) + ).resolves.toEqual({ + exportNames: ['Status'], + exportAllModules: [], + }); + }); + + it('does not report an erased default interface as a runtime export', async () => { + const analysis = await getBundlerRouteAnalysis( + `export default interface RouteType { value: string }`, + '/app/routes/type-only-default.tsx' + ); + const exportInfo = { + exportNames: analysis.exportNames, + exportAllModules: analysis.exportAllModules, + }; + + expect(exportInfo).toEqual({ exportNames: [], exportAllModules: [] }); + await expect(getExportNamesAndExportAll(analysis.code)).resolves.toEqual( + exportInfo + ); + }); + + it('does not report erased ambient declarations as runtime exports', async () => { + const analysis = await getBundlerRouteAnalysis( + ` + export declare function loader(): void; + export declare const action: () => void; + export declare class ServerOnly {} + export const clientLoader = () => null; + `, + '/app/routes/ambient-exports.tsx' + ); + const exportInfo = { + exportNames: analysis.exportNames, + exportAllModules: analysis.exportAllModules, + }; + + expect(exportInfo).toEqual({ + exportNames: ['clientLoader'], + exportAllModules: [], + }); + await expect(getExportNamesAndExportAll(analysis.code)).resolves.toEqual( + exportInfo + ); + }); +}); + +describe('transformToEsm', () => { + it('preserves arrow function object return parentheses', async () => { + const code = ` + const items = [{ pathname: '/', data: 'Home' }]; + export const labels = items.map((item) => ({ + to: item.pathname, + label: item.data, + })); + `; + + const transformed = await transformToEsm(code, 'route.tsx'); + + expect(transformed).toContain('=> ({'); + expect(() => parse(transformed, { sourceType: 'module' })).not.toThrow(); + }); +}); diff --git a/tests/features.test.ts b/tests/features.test.ts index ad1e3bf..9f122c0 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -1,6 +1,10 @@ import { createStubRsbuild } from '@scripts/test-helper'; import { describe, expect, it, rstest } from '@rstest/core'; +import { rspack } from '@rsbuild/core'; +import * as fs from 'node:fs'; +import path from 'node:path'; import { pluginReactRouter } from '../src'; +import { getVirtualModuleFilePath } from '../src/virtual-modules'; describe('pluginReactRouter', () => { describe('basic configuration', () => { @@ -85,10 +89,57 @@ describe('pluginReactRouter', () => { const plugins = config.tools?.rspack?.plugins || []; const virtualModulePlugin = plugins.find( - (p) => p.constructor.name === 'RspackVirtualModulePlugin' + (p: any) => p.constructor.name === 'VirtualModulesPlugin' ); expect(virtualModulePlugin).toBeDefined(); + + const compiler = { + context: '/virtual/project', + hooks: { + afterEnvironment: { + tap: (_name: string, handler: () => void) => handler(), + }, + }, + } as any; + virtualModulePlugin.apply(compiler); + + const virtualFiles = + rspack.experiments.VirtualModulesPlugin.__internal__take_virtual_files( + compiler + ); + const virtualFilePaths = virtualFiles?.map(file => file.path) || []; + const virtualModulePath = (id: string) => + path.join(compiler.context, getVirtualModuleFilePath(id)); + + expect(virtualFilePaths).toContain( + virtualModulePath('virtual/react-router/browser-manifest') + ); + expect(virtualFilePaths).toContain( + virtualModulePath('virtual/react-router/server-build') + ); + expect(virtualFilePaths).toContain( + virtualModulePath('virtual/react-router/with-props') + ); + expect(virtualFilePaths).not.toContain( + '/virtual/project/virtual/react-router/browser-manifest' + ); + }); + + it('should map bare React Router virtual module ids to resolvable files', () => { + expect( + getVirtualModuleFilePath('virtual/react-router/browser-manifest') + ).toBe('node_modules/virtual/react-router/browser-manifest.js'); + expect( + getVirtualModuleFilePath('virtual/react-router/server-build-edge') + ).toBe('node_modules/virtual/react-router/server-build-edge.js'); + + expect(() => + getVirtualModuleFilePath('virtual/react-router/../server-build') + ).toThrow('Invalid virtual module id'); + expect(() => + getVirtualModuleFilePath('virtual/other/server-build') + ).toThrow('Virtual module id must start'); }); }); @@ -110,12 +161,22 @@ describe('pluginReactRouter', () => { }); it('should register build and dot file transforms', async () => { + process.env.RR_TEST_SPLIT_ROUTE_MODULES = 'true'; + const readFileSync = rstest + .spyOn(fs, 'readFileSync') + .mockReturnValue('export default function Route() { return null; }'); const rsbuild = await createStubRsbuild({ + action: 'build', rsbuildConfig: {}, }); - const plugin = pluginReactRouter(); - await plugin.setup(rsbuild as any); + try { + const plugin = pluginReactRouter(); + await plugin.setup(rsbuild as any); + } finally { + delete process.env.RR_TEST_SPLIT_ROUTE_MODULES; + readFileSync.mockRestore(); + } const calls = (rsbuild.transform as any).mock.calls.map( (call: any[]) => call[0] @@ -129,11 +190,41 @@ describe('pluginReactRouter', () => { ).toBe(true); expect( - calls.some((call: any) => call.test?.toString().includes('\\.server')) + calls.some( + (call: any) => + call.resourceQuery?.toString().includes('route-chunk=') && + call.environments?.includes('web') + ) + ).toBe(true); + + const splitRouteExportsTransform = calls.find( + (call: any) => + typeof call.test === 'function' && + call.resourceQuery?.not?.toString().includes('route-chunk=') && + call.environments?.includes('web') + ); + expect(splitRouteExportsTransform).toBeDefined(); + expect( + splitRouteExportsTransform.test(path.resolve('app/routes/index.tsx')) ).toBe(true); + expect(splitRouteExportsTransform.test(path.resolve('app/other.tsx'))).toBe( + false + ); expect( - calls.some((call: any) => call.test?.toString().includes('\\.client')) + calls.some( + (call: any) => + call.test?.toString().includes('\\.server') && + call.environments?.includes('web') + ) + ).toBe(true); + + expect( + calls.some( + (call: any) => + call.test?.toString().includes('\\.client') && + call.environments?.includes('node') + ) ).toBe(true); }); }); diff --git a/tests/index.test.ts b/tests/index.test.ts index 47c3e4b..85db3fe 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,5 +1,6 @@ import { createStubRsbuild } from '@scripts/test-helper'; -import { describe, expect, it } from '@rstest/core'; +import { describe, expect, it, rstest } from '@rstest/core'; +import * as fs from 'node:fs'; import { pluginReactRouter } from '../src'; describe('pluginReactRouter', () => { @@ -15,6 +16,87 @@ describe('pluginReactRouter', () => { expect(config.dev.hmr).toBe(true); expect(config.dev.liveReload).toBe(true); expect(config.dev.writeToDisk).toBe(true); + expect(config.dev.lazyCompilation).toBeUndefined(); + }); + + it('should restart the dev server when route entries are added', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: { + dev: { + watchFiles: { + paths: 'custom.config.ts', + type: 'reload-server', + }, + }, + }, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.watchFiles).toEqual( + expect.arrayContaining([ + { + paths: 'custom.config.ts', + type: 'reload-server', + }, + { + paths: expect.stringMatching(/app\/routes\.[cm]?[jt]sx?$/), + type: 'reload-server', + }, + { + paths: expect.stringMatching( + /build\/client\/\.react-router\/route-watch$/ + ), + type: 'reload-server', + }, + ]) + ); + }); + + it('emits the route restart marker as a web build asset', async () => { + const rsbuild = await createStubRsbuild({ + action: 'build', + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + await rsbuild.unwrapConfig(); + + const processAssetsCall = rsbuild.processAssets.mock.calls.find( + ([options]) => + options.stage === 'additional' && options.targets?.includes('web') + ); + expect(processAssetsCall).toBeDefined(); + + const handler = processAssetsCall?.[1]; + const emitAsset = rstest.fn(); + const updateAsset = rstest.fn(); + const RawSource = class { + constructor(private readonly content: string) {} + source() { + return this.content; + } + size() { + return this.content.length; + } + }; + + handler({ + sources: { RawSource }, + compilation: { + getAsset: rstest.fn().mockReturnValue(undefined), + emitAsset, + updateAsset, + }, + }); + + expect(emitAsset).toHaveBeenCalledWith( + '.react-router/route-watch', + expect.any(RawSource) + ); + expect(emitAsset.mock.calls[0][1].source()).not.toBe(''); + expect(updateAsset).not.toHaveBeenCalled(); }); it('should respect server output format', async () => { @@ -31,6 +113,147 @@ describe('pluginReactRouter', () => { expect(nodeConfig.output.module).toBe(false); }); + it('configures web entries to avoid unnecessary entry IIFEs', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect( + config.environments?.web?.tools?.rspack?.optimization?.avoidEntryIife + ).toBe(true); + }); + + it('reduces file size reporting overhead for medium split route builds by default', async () => { + process.env.RR_TEST_SPLIT_ROUTE_MODULES = 'true'; + process.env.RR_TEST_ROUTE_COUNT = '256'; + const readFileSync = rstest + .spyOn(fs, 'readFileSync') + .mockReturnValue('export default function Route() { return null; }'); + try { + const rsbuild = await createStubRsbuild({ + action: 'build', + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.performance?.printFileSize).toEqual({ + total: true, + detail: false, + compressed: false, + }); + } finally { + readFileSync.mockRestore(); + delete process.env.RR_TEST_SPLIT_ROUTE_MODULES; + delete process.env.RR_TEST_ROUTE_COUNT; + } + }); + + it('reduces file size reporting overhead for medium route builds by default', async () => { + process.env.RR_TEST_ROUTE_COUNT = '256'; + const readFileSync = rstest + .spyOn(fs, 'readFileSync') + .mockReturnValue('export default function Route() { return null; }'); + try { + const rsbuild = await createStubRsbuild({ + action: 'build', + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.performance?.printFileSize).toEqual({ + total: true, + detail: false, + compressed: false, + }); + } finally { + readFileSync.mockRestore(); + delete process.env.RR_TEST_ROUTE_COUNT; + } + }); + + it('keeps explicit object file size reporting config for large split route builds', async () => { + process.env.RR_TEST_SPLIT_ROUTE_MODULES = 'true'; + process.env.RR_TEST_ROUTE_COUNT = '1024'; + const readFileSync = rstest + .spyOn(fs, 'readFileSync') + .mockReturnValue('export default function Route() { return null; }'); + try { + const rsbuild = await createStubRsbuild({ + action: 'build', + rsbuildConfig: { + performance: { + printFileSize: { + detail: true, + compressed: true, + }, + }, + }, + }); + + rsbuild.addPlugins([pluginReactRouter()]); + const config = await rsbuild.unwrapConfig(); + + expect(config.performance?.printFileSize).toEqual({ + detail: true, + compressed: true, + }); + } finally { + readFileSync.mockRestore(); + delete process.env.RR_TEST_SPLIT_ROUTE_MODULES; + delete process.env.RR_TEST_ROUTE_COUNT; + } + }); + + it('should forward lazy compilation when explicitly configured', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([ + pluginReactRouter({ + lazyCompilation: { + entries: true, + imports: true, + }, + }), + ]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.lazyCompilation).toEqual({ + entries: true, + imports: true, + }); + }); + + it('should allow lazy compilation to be enabled with a boolean', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter({ lazyCompilation: true })]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.lazyCompilation).toBe(true); + }); + + it('should allow lazy compilation to be disabled', async () => { + const rsbuild = await createStubRsbuild({ + rsbuildConfig: {}, + }); + + rsbuild.addPlugins([pluginReactRouter({ lazyCompilation: false })]); + const config = await rsbuild.unwrapConfig(); + + expect(config.dev.lazyCompilation).toBe(false); + }); + it('should configure web environment correctly', async () => { const rsbuild = await createStubRsbuild({ rsbuildConfig: {}, @@ -44,6 +267,18 @@ describe('pluginReactRouter', () => { expect(webConfig.externalsType).toBe('module'); expect(webConfig.output.chunkFormat).toBe('module'); expect(webConfig.output.module).toBe(true); + + const webEntries = config.environments?.web?.source?.entry; + expect(webEntries['entry.client']).toEqual( + expect.stringMatching(/entry\.client/) + ); + expect(webEntries['virtual/react-router/browser-manifest']).toEqual({ + import: 'virtual/react-router/browser-manifest', + html: false, + }); + expect(webEntries['routes/index']).toMatchObject({ + html: false, + }); }); it('should configure node environment correctly', async () => { diff --git a/tests/manifest-split-route-modules.test.ts b/tests/manifest-split-route-modules.test.ts index 8c0579e..1e2107b 100644 --- a/tests/manifest-split-route-modules.test.ts +++ b/tests/manifest-split-route-modules.test.ts @@ -3,9 +3,36 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from '@rstest/core'; import { getReactRouterManifestForDev } from '../src/manifest'; -import { getRouteChunkEntryName } from '../src/route-chunks'; +import { + getRouteChunkEntryName, + routeChunkExportNames, + type RouteChunkExportName, +} from '../src/route-chunks'; -const createTempApp = () => { +const clientExportFixtures: Record = { + clientAction: `export async function clientAction() { return {}; }`, + clientLoader: `export async function clientLoader() { return {}; }`, + clientMiddleware: `export async function clientMiddleware() { return null; }`, + HydrateFallback: `export function HydrateFallback() { return null; }`, +}; + +type ManifestModuleField = + | 'clientActionModule' + | 'clientLoaderModule' + | 'clientMiddlewareModule' + | 'hydrateFallbackModule'; + +const moduleFieldByExportName: Record< + RouteChunkExportName, + ManifestModuleField +> = { + clientAction: 'clientActionModule', + clientLoader: 'clientLoaderModule', + clientMiddleware: 'clientMiddlewareModule', + HydrateFallback: 'hydrateFallbackModule', +}; + +const createTempApp = (routeCode?: string, rootCode?: string) => { const root = mkdtempSync(join(tmpdir(), 'rr-manifest-')); const appDir = join(root, 'app'); const routesDir = join(appDir, 'routes'); @@ -13,102 +40,156 @@ const createTempApp = () => { writeFileSync( join(appDir, 'root.tsx'), - `export default function Root() { return null; }` + rootCode ?? `export default function Root() { return null; }` ); writeFileSync( join(routesDir, 'clients.tsx'), - `export async function clientAction() { return {}; } - export async function clientLoader() { return {}; } - export default function Clients() { return null; }` + routeCode ?? + `export async function clientAction() { return {}; } + export async function clientLoader() { return {}; } + export default function Clients() { return null; }` ); - return { root, appDir, routesDir }; + return { root, appDir }; +}; + +const routes = { + root: { id: 'root', file: 'root.tsx', path: '' }, + 'routes/clients': { + id: 'routes/clients', + parentId: 'root', + file: 'routes/clients.tsx', + path: 'clients', + }, +}; + +const createClientStats = (routeId = 'routes/clients') => { + const assetsByChunkName: Record = { + 'entry.client': ['static/js/entry.client.js'], + [routeId]: [`static/js/${routeId}.js`], + }; + for (const exportName of routeChunkExportNames) { + assetsByChunkName[getRouteChunkEntryName(routeId, exportName)] = [ + `static/js/${getRouteChunkEntryName(routeId, exportName)}.js`, + ]; + } + return { assetsByChunkName }; }; +const getManifest = async ( + appDir: string, + splitRouteModules: boolean | 'enforce', + isBuild = true +) => + getReactRouterManifestForDev(routes, {}, createClientStats(), appDir, '/', { + splitRouteModules, + rootRouteFile: 'root.tsx', + isBuild, + cache: new Map(), + }); + describe('manifest split route modules', () => { - it('includes clientActionModule when split route modules are enabled for build', async () => { + it.each(routeChunkExportNames)( + 'includes %sModule when the export is splittable in build mode', + async (exportName: RouteChunkExportName) => { + const { root, appDir } = createTempApp(` + ${clientExportFixtures[exportName]} + export default function Clients() { return null; } + `); + try { + const manifest = await getManifest(appDir, true); + const field = moduleFieldByExportName[exportName]; + + expect(manifest.routes['routes/clients'][field]).toBe( + `/static/js/${getRouteChunkEntryName('routes/clients', exportName)}.js` + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + } + ); + + it('omits split route module fields in dev mode', async () => { const { root, appDir } = createTempApp(); try { - const routes = { - root: { id: 'root', file: 'root.tsx', path: '' }, - 'routes/clients': { - id: 'routes/clients', - parentId: 'root', - file: 'routes/clients.tsx', - path: 'clients', - }, - }; - - const clientActionEntry = getRouteChunkEntryName( - 'routes/clients', - 'clientAction' - ); + const manifest = await getManifest(appDir, true, false); - const clientStats: { assetsByChunkName: Record } = { - assetsByChunkName: { - 'routes/clients': ['static/js/routes/clients.js'], - [clientActionEntry]: ['static/js/routes/clients-client-action.js'], - }, - }; - - const manifest = await getReactRouterManifestForDev( - routes, - {}, - clientStats, - appDir, - '/', - { - splitRouteModules: true, - rootRouteFile: 'root.tsx', - isBuild: true, - } - ); + expect(manifest.routes['routes/clients'].clientActionModule).toBeUndefined(); + expect(manifest.routes['routes/clients'].clientLoaderModule).toBeUndefined(); + expect( + manifest.routes['routes/clients'].clientMiddlewareModule + ).toBeUndefined(); + expect( + manifest.routes['routes/clients'].hydrateFallbackModule + ).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('omits a module field for a client export that is present but not splittable', async () => { + const { root, appDir } = createTempApp(` + const shared = () => null; + export default function Clients() { return shared(); } + export async function clientAction() { return shared(); } + `); + try { + const manifest = await getManifest(appDir, true); expect(manifest.routes['routes/clients'].hasClientAction).toBe(true); - expect(manifest.routes['routes/clients'].clientActionModule).toBe( - '/static/js/routes/clients-client-action.js' - ); + expect(manifest.routes['routes/clients'].clientActionModule).toBeUndefined(); } finally { rmSync(root, { recursive: true, force: true }); } }); - it('omits split route module fields in dev mode', async () => { - const { root, appDir } = createTempApp(); + it('throws in enforce mode when a present client export is not splittable', async () => { + const { root, appDir } = createTempApp(` + const shared = () => null; + export default function Clients() { return shared(); } + export async function clientAction() { return shared(); } + `); try { - const routes = { - root: { id: 'root', file: 'root.tsx', path: '' }, - 'routes/clients': { - id: 'routes/clients', - parentId: 'root', - file: 'routes/clients.tsx', - path: 'clients', - }, - }; - - const clientStats: { assetsByChunkName: Record } = { - assetsByChunkName: { - 'routes/clients': ['static/js/routes/clients.js'], - }, - }; - - const manifest = await getReactRouterManifestForDev( - routes, - {}, - clientStats, - appDir, - '/', - { - splitRouteModules: true, - rootRouteFile: 'root.tsx', - isBuild: false, - } + await expect(getManifest(appDir, 'enforce')).rejects.toThrowError( + /Error splitting route module[\s\S]*clientAction/ ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('does not throw outside enforce mode when a present client export is not splittable', async () => { + const { root, appDir } = createTempApp(` + const shared = () => null; + export default function Clients() { return shared(); } + export async function clientAction() { return shared(); } + `); + try { + const manifest = await getManifest(appDir, true); + + expect(manifest.routes['routes/clients'].hasClientAction).toBe(true); expect(manifest.routes['routes/clients'].clientActionModule).toBeUndefined(); - expect(manifest.routes['routes/clients'].clientLoaderModule).toBeUndefined(); } finally { rmSync(root, { recursive: true, force: true }); } }); -}); + + it('does not add route chunk module fields for the root route', async () => { + const { root, appDir } = createTempApp( + `export default function Clients() { return null; }`, + `export async function clientAction() { return {}; } + export default function Root() { return null; }` + ); + try { + const manifest = await getManifest(appDir, true); + + expect(manifest.routes.root.hasClientAction).toBe(true); + expect(manifest.routes.root.clientActionModule).toBeUndefined(); + expect(manifest.routes.root.clientLoaderModule).toBeUndefined(); + expect(manifest.routes.root.clientMiddlewareModule).toBeUndefined(); + expect(manifest.routes.root.hydrateFallbackModule).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); \ No newline at end of file diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index a17204e..2d77c1e 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -1,7 +1,158 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from '@rstest/core'; -import { configRoutesToRouteManifest } from '../src/manifest'; +import { + createReactRouterManifestStats, + configRoutesToRouteManifest, + getReactRouterManifestForDev, + getReactRouterManifestChunkNames, + getRouteManifestModuleExports, +} from '../src/manifest'; + +const createTempApp = (routeCode: string) => { + const root = mkdtempSync(join(tmpdir(), 'rr-manifest-')); + const appDir = join(root, 'app'); + const routesDir = join(appDir, 'routes'); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + join(appDir, 'root.tsx'), + `export default function Root() { return null; }` + ); + writeFileSync(join(routesDir, 'page.tsx'), routeCode); + + return { root, appDir }; +}; + +const routes = { + root: { id: 'root', file: 'root.tsx', path: '' }, + 'routes/page': { + id: 'routes/page', + parentId: 'root', + file: 'routes/page.tsx', + path: 'page', + }, +}; + +const clientStats = { + assetsByChunkName: { + 'entry.client': ['static/js/entry.client.js'], + root: ['static/js/root.js'], + 'routes/page': ['static/js/routes/page.js'], + }, +}; describe('manifest', () => { + it('creates manifest stats from named chunks without stats JSON', () => { + const compilation = { + namedChunks: new Map([ + [ + 'runtime', + { + files: new Set(['static/js/runtime.js']), + }, + ], + [ + 'entry.client', + { + files: new Set([ + 'static/js/entry.client.js', + 'static/css/entry.client.css', + ]), + }, + ], + [ + 'routes/page', + { + files: new Set(['static/js/routes/page.js']), + }, + ], + ]), + }; + + expect(createReactRouterManifestStats(compilation)).toEqual({ + assetsByChunkName: { + runtime: ['static/js/runtime.js'], + 'entry.client': [ + 'static/js/entry.client.js', + 'static/css/entry.client.css', + ], + 'routes/page': ['static/js/routes/page.js'], + }, + }); + }); + + it('filters manifest stats to requested chunk names', () => { + const compilation = { + namedChunks: new Map([ + ['runtime', { files: new Set(['static/js/runtime.js']) }], + ['entry.client', { files: new Set(['static/js/entry.client.js']) }], + ['routes/page', { files: new Set(['static/js/routes/page.js']) }], + ['vendor', { files: new Set(['static/js/vendor.js']) }], + ]), + }; + + expect( + createReactRouterManifestStats( + compilation, + new Set(['entry.client', 'routes/page']) + ) + ).toEqual({ + assetsByChunkName: { + 'entry.client': ['static/js/entry.client.js'], + 'routes/page': ['static/js/routes/page.js'], + }, + }); + }); + + it('uses direct named chunk lookup for filtered manifest stats when available', () => { + const chunks = new Map([ + ['entry.client', { files: new Set(['static/js/entry.client.js']) }], + ['routes/page', { files: new Set(['static/js/routes/page.js']) }], + ]); + const compilation = { + namedChunks: { + get: (chunkName: string) => chunks.get(chunkName), + *[Symbol.iterator](): IterableIterator< + [string, { files: Set }] + > { + throw new Error('filtered manifest stats should not scan all chunks'); + }, + }, + }; + + expect( + createReactRouterManifestStats( + compilation, + new Set(['entry.client', 'routes/page']) + ) + ).toEqual({ + assetsByChunkName: { + 'entry.client': ['static/js/entry.client.js'], + 'routes/page': ['static/js/routes/page.js'], + }, + }); + }); + + it('collects only manifest-readable chunk names', () => { + expect(Array.from(getReactRouterManifestChunkNames(routes, false))).toEqual( + ['entry.client', 'root', 'routes/page'] + ); + + expect(getReactRouterManifestChunkNames(routes, true)).toEqual( + new Set([ + 'entry.client', + 'root', + 'routes/page', + 'routes/page-client-action', + 'routes/page-client-loader', + 'routes/page-client-middleware', + 'routes/page-hydrate-fallback', + ]) + ); + }); + describe('configRoutesToRouteManifest', () => { it('should convert simple route config to manifest', () => { const routeConfig = [ @@ -172,4 +323,66 @@ describe('manifest', () => { expect(item).toHaveProperty('hasClientMiddleware', false); expect(item).toHaveProperty('hasDefaultExport', false); }); + + it('tracks route exports outside the manifest payload', async () => { + const { root, appDir } = createTempApp(` + export function headers() { return {}; } + export async function action() { return null; } + export async function loader() { return null; } + export default function Page() { return null; } + `); + try { + const manifest = await getReactRouterManifestForDev( + routes, + {}, + clientStats, + appDir, + '/', + { + isBuild: true, + rootRouteFile: 'root.tsx', + splitRouteModules: false, + } + ); + + const routeManifest = manifest.routes['routes/page']; + expect(routeManifest).toMatchObject({ + hasAction: true, + hasLoader: true, + }); + expect(getRouteManifestModuleExports(manifest)['routes/page']).toEqual( + expect.arrayContaining(['headers', 'action', 'loader', 'default']) + ); + expect(routeManifest).not.toHaveProperty('headers'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('preserves dev css fallback when route analysis uses transformed code', async () => { + const { root, appDir } = createTempApp(` + import './page.css'; + export default function Page() { return

Page

; } + `); + try { + const manifest = await getReactRouterManifestForDev( + routes, + {}, + clientStats, + appDir, + '/', + { + isBuild: false, + rootRouteFile: 'root.tsx', + splitRouteModules: false, + } + ); + + expect(manifest.routes['routes/page'].css).toEqual([ + '/static/css/routes/page.css', + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/tests/modify-browser-manifest.test.ts b/tests/modify-browser-manifest.test.ts new file mode 100644 index 0000000..4141abf --- /dev/null +++ b/tests/modify-browser-manifest.test.ts @@ -0,0 +1,184 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from '@rstest/core'; +import { createModifyBrowserManifestPlugin } from '../src/modify-browser-manifest'; + +const createTempApp = () => { + const root = mkdtempSync(join(tmpdir(), 'rr-modify-manifest-')); + const appDir = join(root, 'app'); + mkdirSync(join(appDir, 'routes'), { recursive: true }); + writeFileSync( + join(appDir, 'root.tsx'), + `export default function Root() { return null; }` + ); + writeFileSync( + join(appDir, 'routes/page.tsx'), + `export default function Page() { return null; }` + ); + return { root, appDir }; +}; + +const createAsset = (source: string) => ({ + source: () => source, + size: () => source.length, +}); + +describe('modify browser manifest plugin', () => { + it('does not read ignored chunk files while creating manifest stats', async () => { + const { root, appDir } = createTempApp(); + const routes = { + root: { id: 'root', file: 'root.tsx', path: '' }, + 'routes/page': { + id: 'routes/page', + parentId: 'root', + file: 'routes/page.tsx', + path: 'page', + }, + }; + let emit: + | ((compilation: unknown, callback: (error?: Error) => void) => void) + | undefined; + const compiler = { + hooks: { + emit: { + tapAsync(_name: string, callback: typeof emit) { + emit = callback; + }, + }, + }, + }; + + try { + createModifyBrowserManifestPlugin(routes, {}, appDir).apply( + compiler as never + ); + + const ignoredChunk = {}; + Object.defineProperty(ignoredChunk, 'files', { + get() { + throw new Error('ignored chunk files should not be read'); + }, + }); + + await new Promise((resolve, reject) => { + emit?.( + { + namedChunks: new Map([ + [ + 'entry.client', + { files: new Set(['static/js/entry.client.js']) }, + ], + ['root', { files: new Set(['static/js/root.js']) }], + [ + 'routes/page', + { files: new Set(['static/js/routes/page.js']) }, + ], + ['vendor', ignoredChunk], + ]), + assets: { + 'static/js/virtual/react-router/browser-manifest.js': createAsset( + 'window.__reactRouterManifest="PLACEHOLDER";' + ), + }, + }, + error => { + if (error) { + reject(error); + return; + } + resolve(); + } + ); + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('uses actual manifest chunk names instead of theoretical split route chunks', async () => { + const { root, appDir } = createTempApp(); + const routes = { + root: { id: 'root', file: 'root.tsx', path: '' }, + 'routes/page': { + id: 'routes/page', + parentId: 'root', + file: 'routes/page.tsx', + path: 'page', + }, + }; + let emit: + | ((compilation: unknown, callback: (error?: Error) => void) => void) + | undefined; + const compiler = { + hooks: { + emit: { + tapAsync(_name: string, callback: typeof emit) { + emit = callback; + }, + }, + }, + }; + + try { + createModifyBrowserManifestPlugin( + routes, + {}, + appDir, + '/', + { + splitRouteModules: true, + rootRouteFile: 'root.tsx', + isBuild: true, + }, + { + manifestChunkNames: new Set([ + 'entry.client', + 'root', + 'routes/page', + ]), + } + ).apply(compiler as never); + + const theoreticalSplitChunk = {}; + Object.defineProperty(theoreticalSplitChunk, 'files', { + get() { + throw new Error('theoretical split chunk files should not be read'); + }, + }); + + await new Promise((resolve, reject) => { + emit?.( + { + namedChunks: new Map([ + [ + 'entry.client', + { files: new Set(['static/js/entry.client.js']) }, + ], + ['root', { files: new Set(['static/js/root.js']) }], + [ + 'routes/page', + { files: new Set(['static/js/routes/page.js']) }, + ], + ['routes/page-client-loader', theoreticalSplitChunk], + ]), + assets: { + 'static/js/virtual/react-router/browser-manifest.js': createAsset( + 'window.__reactRouterManifest="PLACEHOLDER";' + ), + }, + }, + error => { + if (error) { + reject(error); + return; + } + resolve(); + } + ); + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/parallel-route-transforms.test.ts b/tests/parallel-route-transforms.test.ts new file mode 100644 index 0000000..6120941 --- /dev/null +++ b/tests/parallel-route-transforms.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it, rstest } from '@rstest/core'; +import * as exportUtils from '../src/export-utils'; +import { + executeRouteTransformTask, + type RouteModuleTransformTask, +} from '../src/route-transform-tasks'; +import { + createRouteTransformExecutor, + getDefaultWorkerCount, +} from '../src/parallel-route-transforms'; +import type { RouteChunkConfig } from '../src/route-chunks'; + +const routeChunkConfig: RouteChunkConfig = { + splitRouteModules: true, + appDirectory: '/app', + rootRouteFile: 'root.tsx', +}; + +const disabledRouteChunkConfig: RouteChunkConfig = { + ...routeChunkConfig, + splitRouteModules: false, +}; + +const resourcePath = '/app/routes/demo.tsx'; +const createRouteModuleTask = ( + overrides: Partial> = {} +): RouteModuleTransformTask => ({ + kind: 'routeModule' as const, + code: ` + import { serverValue } from '../server-data.server'; + export async function loader() { return serverValue; } + export default function Route() { return null; } + `, + resource: `${resourcePath}?react-router-route`, + resourcePath, + environmentName: 'web', + ssr: true, + isBuild: false, + isSpaMode: false, + rootRoutePath: '/app/root.tsx', + ...overrides, +}); + +describe('parallel route transforms', () => { + it.each([ + [1, 0], + [2, 0], + [3, 1], + [4, 2], + [6, 4], + [8, 6], + [10, 8], + [12, 10], + [24, 22], + ])('defaults to cpu count minus two workers', (cpus, workers) => { + expect(getDefaultWorkerCount(cpus)).toBe(workers); + }); + + it('honors explicit maxWorkers', async () => { + const executor = createRouteTransformExecutor({ + parallelTransforms: { maxWorkers: 2 }, + }); + + try { + const result = await executor.run(createRouteModuleTask()); + + expect(result.code).toContain('export default _withComponentProps'); + expect(result.code).not.toContain('loader'); + } finally { + await executor.close(); + } + }); + + it('runs route builds inline when parallel transforms are disabled', async () => { + const executor = createRouteTransformExecutor({ + parallelTransforms: false, + }); + + try { + const result = await executor.run(createRouteModuleTask()); + + expect(result.code).toContain('export default _withComponentProps'); + expect(result.code).not.toContain('loader'); + } finally { + await executor.close(); + } + }); + + it('executes route client entry tasks through the shared task executor', async () => { + await expect( + executeRouteTransformTask({ + kind: 'routeClientEntry', + code: ` + export async function loader() { return null; } + export async function clientLoader() { return null; } + export default function Route() { return null; } + `, + resourcePath, + environmentName: 'web', + isBuild: false, + routeChunkConfig: disabledRouteChunkConfig, + }) + ).resolves.toEqual({ + code: `export { clientLoader, default } from "${resourcePath}?react-router-route";`, + }); + }); + + it('does not run bundler route analysis for client entries without split route chunks', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); + + try { + await executeRouteTransformTask({ + kind: 'routeClientEntry', + code: ` + export async function loader() { return null; } + export async function clientLoader() { return null; } + export default function Route() { return null; } + `, + resourcePath, + environmentName: 'web', + isBuild: false, + routeChunkConfig: disabledRouteChunkConfig, + }); + + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + + it('does not run bundler route analysis for split client entries without split export names', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); + + try { + const result = await executeRouteTransformTask({ + kind: 'routeClientEntry', + code: ` + export async function loader() { return null; } + export default function Route() { return null; } + `, + resourcePath, + environmentName: 'web', + isBuild: true, + routeChunkConfig, + }); + + expect(result.code).toBe( + `export { default } from "${resourcePath}?react-router-route";` + ); + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + + it('does not run bundler route analysis for split route export modules without split export names', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); + const code = ` + export async function loader() { return null; } + export default function Route() { return null; } + `; + + try { + const result = await executeRouteTransformTask({ + kind: 'splitRouteExports', + code, + resourcePath, + routeChunkConfig, + }); + + expect(result).toEqual({ code, map: null }); + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + + it('does not run bundler route analysis for client-only stubs', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); + + try { + await executeRouteTransformTask({ + kind: 'clientOnlyStub', + code: ` + export const clientValue = 'client'; + export default function ClientOnly() { return null; } + `, + resourcePath: '/app/client-data.client.ts', + }); + + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + + it('can execute route module tasks through worker-backed parallelism', async () => { + const executor = createRouteTransformExecutor({ + parallelTransforms: { maxWorkers: 2 }, + }); + + try { + const result = await executor.run(createRouteModuleTask()); + + expect(result.code).toContain('export default _withComponentProps'); + expect(result.code).not.toContain('loader'); + } finally { + await executor.close(); + } + }); + + it('shares build route module results across environments when output is identical', async () => { + const executor = createRouteTransformExecutor({ + parallelTransforms: { maxWorkers: 2 }, + splitRouteModules: true, + }); + const task = createRouteModuleTask({ + code: ` + export async function clientLoader() { return null; } + export default function Route() { return null; } + `, + environmentName: 'node', + isBuild: true, + }); + + try { + const nodeResult = await executor.run(task); + const webResult = await executor.run({ + ...task, + environmentName: 'web', + }); + + expect(webResult).toEqual(nodeResult); + } finally { + await executor.close(); + } + }); + + it('does not share build route module results when web removes server-only exports', async () => { + const executor = createRouteTransformExecutor({ + parallelTransforms: { maxWorkers: 2 }, + splitRouteModules: true, + }); + const task = createRouteModuleTask({ + environmentName: 'node', + isBuild: true, + }); + + try { + const nodeResult = await executor.run(task); + const webResult = await executor.run({ + ...task, + environmentName: 'web', + }); + + expect(nodeResult.code).toContain('loader'); + expect(webResult.code).not.toContain('loader'); + } finally { + await executor.close(); + } + }); + + it('preserves value imports when web route modules have no server-only exports', async () => { + const result = await executeRouteTransformTask( + createRouteModuleTask({ + code: ` + import { setup } from './side-effect'; + export default function Route() { return null; } + `, + environmentName: 'web', + ssr: false, + isBuild: true, + }) + ); + + expect(result.code).toContain(`import { setup } from './side-effect';`); + }); + + it('does not run bundler route analysis for non-SPA route module transforms', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); + + try { + await executeRouteTransformTask(createRouteModuleTask()); + + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + + it('validates SPA route modules without bundler route analysis', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); + + try { + const result = await executeRouteTransformTask( + createRouteModuleTask({ + code: ` + export async function clientLoader() { return null; } + export default function Route() { return null; } + `, + ssr: false, + isSpaMode: true, + }) + ); + + expect(result.code).toContain('clientLoader'); + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + + it('rejects invalid SPA route module exports from the route transform AST', async () => { + await expect( + executeRouteTransformTask( + createRouteModuleTask({ + code: ` + export async function action() { return null; } + export default function Route() { return null; } + `, + ssr: false, + isSpaMode: true, + }) + ) + ).rejects.toThrow('SPA Mode: 1 invalid route export'); + }); + + it('generates route module source maps only outside build mode', async () => { + const task = createRouteModuleTask({ + code: ` + export async function loader() { return null; } + export default function Route() { return null; } + `, + }); + + await expect( + executeRouteTransformTask({ + ...task, + isBuild: true, + }) + ).resolves.toMatchObject({ map: null }); + + const devResult = await executeRouteTransformTask({ + ...task, + isBuild: false, + }); + + expect(devResult.map).not.toBeNull(); + }); +}); diff --git a/tests/performance.test.ts b/tests/performance.test.ts new file mode 100644 index 0000000..9738cef --- /dev/null +++ b/tests/performance.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from '@rstest/core'; +import { createReactRouterPerformanceProfiler } from '../src/performance'; + +const parsePerformanceReport = (message: string) => { + const prefix = '[react-router:performance] '; + expect(message.startsWith(prefix)).toBe(true); + return JSON.parse(message.slice(prefix.length)); +}; + +describe('React Router performance profiler', () => { + it('aggregates operation timings by environment and logs structured JSON', async () => { + const logs: string[] = []; + const profiler = createReactRouterPerformanceProfiler({ + enabled: true, + log: message => logs.push(message), + }); + + await profiler.record('web', 'route:client-entry', 'app/routes/a.tsx', async () => { + return 'client-entry'; + }); + await profiler.record('web', 'route:client-entry', 'app/routes/b.tsx', async () => { + return 'client-entry'; + }); + await profiler.record('node', 'route:module', 'app/routes/a.tsx', async () => { + return 'route-module'; + }); + profiler.recordSync('web', 'manifest:stage', 'virtual/react-router/browser-manifest', () => { + return 'manifest'; + }); + + profiler.flush('web', { compilerLifecycleMs: 123.4 }); + + expect(logs).toHaveLength(1); + expect(logs[0]).toContain('[react-router:performance]'); + + const report = parsePerformanceReport(logs[0]); + expect(report.environment).toBe('web'); + expect(report.compilerLifecycleMs).toBe(123.4); + expect(report.operations['route:client-entry'].count).toBe(2); + expect(report.operations['route:client-entry'].slowest).toHaveLength(2); + expect(report.operations['manifest:stage'].count).toBe(1); + expect(report.operations['route:module']).toBeUndefined(); + + await profiler.record('web', 'route:client-entry', 'app/routes/c.tsx', async () => { + return 'client-entry'; + }); + profiler.flush('web'); + + expect(logs).toHaveLength(2); + const secondReport = parsePerformanceReport(logs[1]); + expect(secondReport.operations['route:client-entry'].count).toBe(1); + expect(secondReport.operations['manifest:stage']).toBeUndefined(); + }); + + it('reports interval-union wall time without changing summed timing fields', async () => { + const logs: string[] = []; + const originalNow = performance.now; + let now = 0; + let resolveFirst: (value: string) => void = () => {}; + let resolveSecond: (value: string) => void = () => {}; + const profiler = createReactRouterPerformanceProfiler({ + enabled: true, + log: message => logs.push(message), + }); + + try { + performance.now = () => now; + + const first = profiler.record('web', 'route:module', 'app/routes/a.tsx', () => { + return new Promise(resolve => { + resolveFirst = resolve; + }); + }); + + now = 10; + const second = profiler.record('web', 'route:module', 'app/routes/b.tsx', () => { + return new Promise(resolve => { + resolveSecond = resolve; + }); + }); + + now = 25; + resolveSecond('second'); + await second; + + now = 40; + resolveFirst('first'); + await first; + + profiler.flush('web'); + + const report = parsePerformanceReport(logs[0]); + expect(report.operations['route:module']).toMatchObject({ + count: 2, + totalMs: 55, + wallMs: 40, + maxMs: 40, + }); + expect(report.operations['route:module'].slowest).toEqual([ + { durationMs: 40, resource: 'app/routes/a.tsx' }, + { durationMs: 15, resource: 'app/routes/b.tsx' }, + ]); + } finally { + performance.now = originalNow; + } + }); + + it('keeps only the five slowest operation entries in descending order', () => { + const logs: string[] = []; + const originalNow = performance.now; + const times = [ + 0, 3, 3, 12, 12, 14, 14, 20, 20, 21, 21, 29, 29, 33, + ]; + const profiler = createReactRouterPerformanceProfiler({ + enabled: true, + log: message => logs.push(message), + }); + + try { + performance.now = () => { + const time = times.shift(); + if (time === undefined) { + throw new Error('unexpected timer read'); + } + return time; + }; + + for (const resource of ['a', 'b', 'c', 'd', 'e', 'f', 'g']) { + profiler.recordSync('web', 'route:module', resource, () => resource); + } + profiler.flush('web'); + + const report = parsePerformanceReport(logs[0]); + expect(report.operations['route:module'].slowest).toEqual([ + { durationMs: 9, resource: 'b' }, + { durationMs: 8, resource: 'f' }, + { durationMs: 6, resource: 'd' }, + { durationMs: 4, resource: 'g' }, + { durationMs: 3, resource: 'a' }, + ]); + } finally { + performance.now = originalNow; + } + }); + + it('rounds reported operation timings when flushing', () => { + const logs: string[] = []; + const originalNow = performance.now; + const times = [0, 1.04, 1.04, 1.16]; + const profiler = createReactRouterPerformanceProfiler({ + enabled: true, + log: message => logs.push(message), + }); + + try { + performance.now = () => { + const time = times.shift(); + if (time === undefined) { + throw new Error('unexpected timer read'); + } + return time; + }; + + profiler.recordSync('web', 'route:module', 'app/routes/a.tsx', () => {}); + profiler.recordSync('web', 'route:module', 'app/routes/b.tsx', () => {}); + profiler.flush('web'); + + const report = parsePerformanceReport(logs[0]); + expect(report.operations['route:module']).toMatchObject({ + totalMs: 1.2, + wallMs: 1.2, + maxMs: 1, + }); + expect(report.operations['route:module'].slowest).toEqual([ + { durationMs: 1, resource: 'app/routes/a.tsx' }, + { durationMs: 0.1, resource: 'app/routes/b.tsx' }, + ]); + } finally { + performance.now = originalNow; + } + }); + + it('records async operations without Promise finally overhead', async () => { + const logs: string[] = []; + const profiler = createReactRouterPerformanceProfiler({ + enabled: true, + log: message => logs.push(message), + }); + const operation = Promise.resolve('route-module'); + operation.finally = () => { + throw new Error('profiler should avoid Promise.prototype.finally'); + }; + + await expect( + profiler.record('web', 'route:module', 'app/routes/a.tsx', () => { + return operation; + }) + ).resolves.toBe('route-module'); + profiler.flush('web'); + + const report = parsePerformanceReport(logs[0]); + expect(report.operations['route:module'].count).toBe(1); + }); + + it('does not evaluate timers or log output when disabled', async () => { + const logs: string[] = []; + const originalNow = performance.now; + const nowCalls: string[] = []; + const profiler = createReactRouterPerformanceProfiler({ + enabled: false, + log: message => logs.push(message), + }); + + try { + performance.now = () => { + nowCalls.push('now'); + throw new Error('disabled profiler should not read timers'); + }; + + const asyncResult = await profiler.record( + 'web', + 'route:module', + 'app/routes/a.tsx', + async () => 'unchanged' + ); + const syncResult = profiler.recordSync( + 'web', + 'manifest:stage', + 'virtual/react-router/browser-manifest', + () => 'sync-unchanged' + ); + profiler.flush('web'); + + expect(asyncResult).toBe('unchanged'); + expect(syncResult).toBe('sync-unchanged'); + expect(nowCalls).toEqual([]); + expect(logs).toEqual([]); + } finally { + performance.now = originalNow; + } + }); +}); diff --git a/tests/plugin-utils.test.ts b/tests/plugin-utils.test.ts index 8c85258..ca85de0 100644 --- a/tests/plugin-utils.test.ts +++ b/tests/plugin-utils.test.ts @@ -1,12 +1,20 @@ import { describe, expect, it } from '@rstest/core'; +import { generate, parse } from '../src/babel'; import { combineURLs, stripFileExtension, createRouteId, generateWithProps, normalizeAssetPrefix, + transformRoute, } from '../src/plugin-utils'; +const transformRouteCode = (code: string) => { + const ast = parse(code, { sourceType: 'module' }); + transformRoute(ast); + return generate(ast).code; +}; + describe('plugin-utils', () => { describe('combineURLs', () => { it('should combine base and relative URLs', () => { @@ -121,4 +129,76 @@ describe('plugin-utils', () => { expect(normalizeAssetPrefix('/assets/')).toBe('/assets/'); }); }); + + describe('transformRoute', () => { + it('wraps default class exports with component props', () => { + const result = transformRouteCode(` + export default class Route {} + `); + + expect(result).toContain('withComponentProps'); + expect(result).toMatch(/export default _withComponentProps\(class Route/); + }); + + it('wraps named class component exports', () => { + const result = transformRouteCode(` + export class ErrorBoundary {} + `); + + expect(result).toContain('withErrorBoundaryProps'); + expect(result).toMatch( + /export const ErrorBoundary = _withErrorBoundaryProps\(class ErrorBoundary/ + ); + }); + + it('wraps component exports declared through export specifiers', () => { + const result = transformRouteCode(` + function Boundary() { + return null; + } + export { Boundary as ErrorBoundary }; + `); + + expect(result).toContain('withErrorBoundaryProps'); + expect(result).toMatch( + /const _ErrorBoundary = _withErrorBoundaryProps\(Boundary\)/ + ); + expect(result).toMatch(/export \{ _ErrorBoundary as ErrorBoundary \}/); + }); + + it('avoids top-level generated helper name collisions', () => { + const result = transformRouteCode(` + const _withComponentProps = 'reserved'; + export default function Route() { return null; } + `); + + expect(result).toContain('withComponentProps as _withComponentProps2'); + expect(result).toContain('export default _withComponentProps2'); + }); + + it('does not reserve generated helper names used only in local scopes', () => { + const result = transformRouteCode(` + export default function Route() { + const _withComponentProps = 'local'; + return _withComponentProps; + } + `); + + expect(result).toContain('withComponentProps as _withComponentProps'); + expect(result).toContain('export default _withComponentProps(function Route'); + expect(result).not.toContain('_withComponentProps2'); + }); + + it('does not reserve generated helper names from re-export specifiers', () => { + const result = transformRouteCode(` + export { foo as _withComponentProps } from './foo'; + export default function Route() { return null; } + `); + + expect(result).toContain('withComponentProps as _withComponentProps'); + expect(result).toContain('export default _withComponentProps'); + expect(result).not.toContain('_withComponentProps2'); + }); + }); + }); diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index c2cdcc6..2f3171a 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -87,6 +87,8 @@ describe('prerender helpers', () => { expect( getPrerenderConcurrency({ paths: ['/'], unstable_concurrency: 3 }) ).toBe(3); - expect(getPrerenderConcurrency({ paths: ['/'] })).toBe(1); + expect(getPrerenderConcurrency({ paths: ['/'] }, 24)).toBe(22); + expect(getPrerenderConcurrency({ paths: ['/'] }, 3)).toBe(1); + expect(getPrerenderConcurrency({ paths: ['/'] }, 2)).toBe(0); }); }); diff --git a/tests/remove-exports.test.ts b/tests/remove-exports.test.ts index e907ca1..144a597 100644 --- a/tests/remove-exports.test.ts +++ b/tests/remove-exports.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from '@rstest/core'; -import { parse, traverse } from '../src/babel'; +import { generate, parse, traverse } from '../src/babel'; import { removeExports, removeUnusedImports } from '../src/plugin-utils'; function hasTopLevelAssignment(ast: any, textIncludes: string): boolean { @@ -19,6 +19,38 @@ function hasTopLevelAssignment(ast: any, textIncludes: string): boolean { } describe('removeExports', () => { + it('returns false when no matching export can be removed', () => { + const code = ` + export const clientLoader = async () => null; + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + const removed = removeExports(ast, ['loader', 'action']); + + expect(removed).toBe(false); + expect(generate(ast).code).toContain('clientLoader'); + }); + + it('returns true when a matching export is removed', () => { + const code = ` + export async function loader() { + return null; + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + const removed = removeExports(ast, ['loader']); + + expect(removed).toBe(true); + expect(generate(ast).code).not.toContain('loader'); + }); + it('removes top-level property assignment when removed export is referenced by local name', () => { const code = ` const local = () => {}; @@ -73,4 +105,267 @@ describe('removeExports', () => { expect(hasThemeImport).toBe(false); }); + + it('removes export-all declarations when removing server-only exports', () => { + const code = ` + export * from './data.server'; + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + expect(result).not.toContain("export * from './data.server'"); + expect(result).toContain('Route'); + }); + + it('does not treat imported names as local references', () => { + const code = ` + import { + loaderDependency as dependency, + unrelated as loaderDependency, + } from './data.server'; + export function loader() { + return dependency(); + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + removeUnusedImports(ast); + + expect(generate(ast).code).not.toContain('./data.server'); + }); + + it('keeps top-level declarations referenced from JSX after removing exports', () => { + const code = ` + export function loader() { + return null; + } + + function ProgressBar() { + return null; + } + + export default function Route() { + return ; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + + expect(result).toContain('function ProgressBar'); + expect(result).toContain(' { + const code = ` + const leaf = 1, middle = leaf; + export function loader() { + return middle; + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + expect(result).not.toContain('leaf'); + expect(result).not.toContain('middle'); + expect(result).not.toContain('loader'); + expect(result).toContain('Route'); + }); + + it('rejects destructured defaults for removed server-only exports', () => { + const code = ` + const route = { loader: async () => null }; + export const { loader = async () => null } = route; + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + + expect(() => removeExports(ast, ['loader'])).toThrowError( + 'Cannot remove destructured export "loader"' + ); + }); + + it('removes every declaration in a deep dead dependency chain', () => { + const helperCount = 64; + const helpers = Array.from({ length: helperCount }, (_, index) => { + const value = + index === helperCount - 1 ? '1' : `helper${index + 1}()`; + return `const helper${index} = () => ${value};`; + }).join('\n'); + const code = ` + ${helpers} + export function loader() { + return helper0(); + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + expect(result).not.toMatch(/\bhelper\d+\b/); + expect(result).toContain('Route'); + }); + + it('preserves declarations that were already unused before export removal', () => { + const code = ` + import { register } from './registry'; + const registration = register(); + export function loader() { + return null; + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + removeUnusedImports(ast); + + const result = generate(ast).code; + expect(result).toContain("import { register } from './registry'"); + expect(result).toContain('const registration = register()'); + expect(result).not.toContain('loader'); + }); + + it('removes pre-existing unused declarations that reference removed export locals', () => { + const code = ` + const leaked = loader; + export function loader() { + return null; + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + expect(result).not.toContain('leaked'); + expect(result).not.toContain('loader'); + expect(result).toContain('Route'); + }); + + it('removes pre-existing unused declarations that retain server-only imports', () => { + const code = ` + import { readSecret } from './data.server'; + const leaked = readSecret(); + export function loader() { + return readSecret(); + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + removeUnusedImports(ast); + + const result = generate(ast).code; + expect(result).not.toContain('./data.server'); + expect(result).not.toContain('leaked'); + expect(result).not.toContain('readSecret'); + expect(result).toContain('Route'); + }); + + it('removes multiple pre-existing unused declarations through shared removed export dependencies', () => { + const code = ` + const shared = () => loader(); + const first = () => shared(); + const second = () => shared(); + export function loader() { + return null; + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + expect(result).not.toContain('shared'); + expect(result).not.toContain('first'); + expect(result).not.toContain('second'); + expect(result).not.toContain('loader'); + expect(result).toContain('Route'); + }); + + it('does not treat an exported alias as a reference to its exported name', () => { + const code = ` + const loader = register(); + const implementation = () => null; + export { implementation as loader }; + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + expect(result).toContain('const loader = register()'); + expect(result).not.toContain('implementation'); + }); + + it('removes a dead declaration cycle reached only by a removed export', () => { + const code = ` + const first = () => second(); + const second = () => first(); + export function loader() { + return first(); + } + export default function Route() { + return null; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + expect(result).not.toContain('first'); + expect(result).not.toContain('second'); + expect(result).toContain('Route'); + }); + + it('removes dependencies of an anonymous default export', () => { + const code = ` + const render = () => null; + export default () => render(); + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['default']); + + expect(generate(ast).code).not.toContain('render'); + }); }); diff --git a/tests/route-artifacts.test.ts b/tests/route-artifacts.test.ts new file mode 100644 index 0000000..8200d6b --- /dev/null +++ b/tests/route-artifacts.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it, rstest } from '@rstest/core'; +import * as exportUtils from '../src/export-utils'; +import { + createRouteChunkArtifact, + createRouteClientEntryArtifact, +} from '../src/route-artifacts'; +import { + emptyRouteChunkSnippet, + getRouteChunkIfEnabled, + getRouteChunkModuleId, + type RouteChunkCache, + type RouteChunkConfig, + type RouteChunkName, +} from '../src/route-chunks'; + +const routeChunkConfig: RouteChunkConfig = { + splitRouteModules: true, + appDirectory: '/app', + rootRouteFile: 'root.tsx', +}; + +const disabledRouteChunkConfig: RouteChunkConfig = { + ...routeChunkConfig, + splitRouteModules: false, +}; + +const enforceRouteChunkConfig: RouteChunkConfig = { + ...routeChunkConfig, + splitRouteModules: 'enforce', +}; + +const resourcePath = '/app/routes/demo.tsx'; +const routeRequest = `${resourcePath}?react-router-route`; + +const createRouteChunk = async ( + source: string, + chunkName: RouteChunkName, + options: { + config?: RouteChunkConfig; + cache?: RouteChunkCache; + isBuild?: boolean; + } = {} +) => + createRouteChunkArtifact({ + code: source, + resource: getRouteChunkModuleId(resourcePath, chunkName), + resourcePath, + routeChunkConfig: options.config ?? routeChunkConfig, + routeChunkCache: options.cache, + isBuild: options.isBuild ?? true, + }); + +describe('route artifact helpers', () => { + describe('createRouteClientEntryArtifact', () => { + it('generates web route reexports that filter server-only exports', async () => { + const result = await createRouteClientEntryArtifact({ + code: ` + export async function loader() { return null; } + export async function clientLoader() { return null; } + export { meta as meta }; + const meta = () => []; + export default function Route() { return null; } + `, + resourcePath, + environmentName: 'web', + isBuild: false, + routeChunkConfig: disabledRouteChunkConfig, + }); + + expect(result).toEqual({ + code: `export { clientLoader, default, meta } from ${JSON.stringify( + routeRequest + )};`, + }); + }); + + it('includes server-only route exports for node route entries', async () => { + const result = await createRouteClientEntryArtifact({ + code: ` + export async function loader() { return null; } + export async function action() { return null; } + export async function clientLoader() { return null; } + export default function Route() { return null; } + `, + resourcePath, + environmentName: 'node', + isBuild: true, + routeChunkConfig, + }); + + expect(result).toEqual({ + code: `export { action, clientLoader, default, loader } from ${JSON.stringify( + routeRequest + )};`, + }); + }); + + it('excludes split client exports from web build route entries', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); + + try { + const result = await createRouteClientEntryArtifact({ + code: ` + export const clientAction = async () => {}; + export async function clientLoader() { return null; } + export default function Route() { return null; } + `, + resourcePath, + environmentName: 'web', + isBuild: true, + routeChunkConfig, + }); + + expect(result).toEqual({ + code: `export { default } from ${JSON.stringify(routeRequest)};`, + }); + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + + it('does not run split analysis for root route client entries', async () => { + const getBundlerRouteAnalysis = rstest.spyOn( + exportUtils, + 'getBundlerRouteAnalysis' + ); + const rootResourcePath = '/app/root.tsx'; + + try { + const result = await createRouteClientEntryArtifact({ + code: ` + export async function clientLoader() { return null; } + export function HydrateFallback() { return null; } + export default function Root() { return null; } + `, + resourcePath: rootResourcePath, + environmentName: 'web', + isBuild: true, + routeChunkConfig, + }); + + expect(result).toEqual({ + code: `export { HydrateFallback, clientLoader, default } from ${JSON.stringify( + `${rootResourcePath}?react-router-route` + )};`, + }); + expect(getBundlerRouteAnalysis).not.toHaveBeenCalled(); + } finally { + getBundlerRouteAnalysis.mockRestore(); + } + }); + }); + + describe('createRouteChunkArtifact', () => { + it('returns the disabled split-route empty snippet with a null map', async () => { + await expect( + createRouteChunk(`export const clientLoader = async () => {};`, 'clientLoader', { + config: disabledRouteChunkConfig, + isBuild: true, + }) + ).resolves.toEqual({ + code: emptyRouteChunkSnippet('Split route modules disabled'), + map: null, + }); + }); + + it('rejects invalid route chunk names before generating code', async () => { + await expect( + createRouteChunkArtifact({ + code: `export const clientLoader = async () => {};`, + resource: `${resourcePath}?route-chunk=invalid`, + resourcePath, + routeChunkConfig, + isBuild: true, + }) + ).rejects.toThrow(`Invalid route chunk name in "${resourcePath}?route-chunk=invalid"`); + }); + + it('generates the same route chunk code as the existing transformed ESM path', async () => { + const source = ` + export const clientAction = async () => {}; + export default function Route() { return null; } + `; + const cache: RouteChunkCache = new Map(); + const analysis = await exportUtils.getBundlerRouteAnalysis( + source, + resourcePath + ); + const expectedCode = await getRouteChunkIfEnabled( + cache, + routeChunkConfig, + resourcePath, + 'clientAction', + analysis.code + ); + + const result = await createRouteChunk(source, 'clientAction', { cache }); + + expect(result).toEqual({ code: expectedCode, map: null }); + }); + + it('skips ESM transforms for named chunks when no route chunk exports exist', async () => { + await expect( + createRouteChunkArtifact({ + code: `export default function Route() { return null; }`, + resource: getRouteChunkModuleId(resourcePath, 'clientLoader'), + resourcePath: '/app/routes/demo.cts', + routeChunkConfig, + isBuild: true, + }) + ).resolves.toEqual({ + code: emptyRouteChunkSnippet('No clientLoader chunk'), + map: null, + }); + }); + + it('validates enforce-mode main chunks against generated chunk exports', async () => { + await expect( + createRouteChunk( + ` + const shared = () => null; + export const clientAction = async () => shared(); + export default function Route() { return shared(); } + `, + 'main', + { + config: enforceRouteChunkConfig, + } + ) + ).rejects.toThrow('Error splitting route module: routes/demo.tsx'); + }); + }); +}); diff --git a/tests/route-chunks-cache.test.ts b/tests/route-chunks-cache.test.ts new file mode 100644 index 0000000..a84b4f0 --- /dev/null +++ b/tests/route-chunks-cache.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from '@rstest/core'; +import { + detectRouteChunksIfEnabled, + getRouteChunkIfEnabled, + routeChunkNames, + type RouteChunkCache, + type RouteChunkConfig, + type RouteChunkInfo, + type RouteChunkName, +} from '../src/route-chunks'; + +const config: RouteChunkConfig = { + splitRouteModules: true, + appDirectory: '/app', + rootRouteFile: 'root.tsx', +}; + +const routeId = '/app/routes/demo.tsx'; + +const chunkableCode = ` + const actionHelper = () => null; + const loaderHelper = () => null; + const middlewareHelper = () => null; + const fallbackHelper = () => null; + export const clientAction = async () => actionHelper(); + export const clientLoader = async () => loaderHelper(); + export const clientMiddleware = async () => middlewareHelper(); + export function HydrateFallback() { return fallbackHelper(); } + export async function action() { return null; } + export default function Route() { return null; } +`; + +const nonChunkableCode = ` + const shared = () => null; + export default function Route() { return shared(); } + export const clientAction = async () => shared(); +`; + +const collectRouteChunkOracle = async ( + cache: RouteChunkCache | undefined, + code = chunkableCode +) => { + const info = await detectRouteChunksIfEnabled(cache, config, routeId, code); + const chunks = Object.fromEntries( + await Promise.all( + routeChunkNames.map(async chunkName => [ + chunkName, + await getRouteChunkIfEnabled(cache, config, routeId, chunkName, code), + ]) + ) + ) as Record; + + return { info, chunks }; +}; + +const expectAllRouteChunks = (info: RouteChunkInfo) => { + expect(info.hasRouteChunks).toBe(true); + expect(info.chunkedExports).toEqual([ + 'clientAction', + 'clientLoader', + 'clientMiddleware', + 'HydrateFallback', + ]); + expect(info.hasRouteChunkByExportName).toEqual({ + clientAction: true, + clientLoader: true, + clientMiddleware: true, + HydrateFallback: true, + }); +}; + +describe('route chunk cache', () => { + it('invalidates cached detection when the same route id receives changed code', async () => { + const cache = new Map(); + + const first = await detectRouteChunksIfEnabled( + cache, + config, + routeId, + chunkableCode + ); + const second = await detectRouteChunksIfEnabled( + cache, + config, + routeId, + nonChunkableCode + ); + + expectAllRouteChunks(first); + expect(second.hasRouteChunks).toBe(false); + expect(second.hasRouteChunkByExportName.clientAction).toBe(false); + }); + + it('returns identical route chunk info and generated chunks across repeated cached calls', async () => { + const cache = new Map(); + + const first = await collectRouteChunkOracle(cache); + const second = await collectRouteChunkOracle(cache); + + expect(second).toEqual(first); + expectAllRouteChunks(first.info); + expect(first.chunks.main).not.toContain('clientAction'); + expect(first.chunks.clientAction).toContain('clientAction'); + expect(first.chunks.clientLoader).toContain('clientLoader'); + expect(first.chunks.clientMiddleware).toContain('clientMiddleware'); + expect(first.chunks.HydrateFallback).toContain('HydrateFallback'); + }); + + it('computes the same route chunk oracle with and without an explicit cache', async () => { + const cached = await collectRouteChunkOracle(new Map()); + const uncached = await collectRouteChunkOracle(undefined); + + expect(uncached).toEqual(cached); + }); + + it('stores the Yuku route chunk analysis entries for repeated chunk generation', async () => { + const cache = new Map(); + + await collectRouteChunkOracle(cache); + + expect(Array.from(cache.keys()).sort()).toEqual([ + 'routes/demo.tsx::analyzeCode', + 'routes/demo.tsx::getChunkableExportMap', + 'routes/demo.tsx::getChunkedExport::HydrateFallback', + 'routes/demo.tsx::getChunkedExport::clientAction', + 'routes/demo.tsx::getChunkedExport::clientLoader', + 'routes/demo.tsx::getChunkedExport::clientMiddleware', + 'routes/demo.tsx::getExportDependencies', + 'routes/demo.tsx::omitChunkedExports::clientAction,clientLoader,clientMiddleware,HydrateFallback', + ]); + }); + + it('precomputes sibling named chunk entries for repeated chunk generation', async () => { + const cache = new Map(); + + await getRouteChunkIfEnabled( + cache, + config, + routeId, + 'clientAction', + chunkableCode + ); + + expect(Array.from(cache.keys()).sort()).toEqual([ + 'routes/demo.tsx::analyzeCode', + 'routes/demo.tsx::getChunkableExportMap', + 'routes/demo.tsx::getChunkedExport::HydrateFallback', + 'routes/demo.tsx::getChunkedExport::clientAction', + 'routes/demo.tsx::getChunkedExport::clientLoader', + 'routes/demo.tsx::getChunkedExport::clientMiddleware', + 'routes/demo.tsx::getExportDependencies', + ]); + }); +}); diff --git a/tests/route-chunks.test.ts b/tests/route-chunks.test.ts index f6a7799..643edca 100644 --- a/tests/route-chunks.test.ts +++ b/tests/route-chunks.test.ts @@ -1,87 +1,572 @@ import { describe, expect, it } from '@rstest/core'; +import { getExportNames } from '../src/export-utils'; import { detectRouteChunksIfEnabled, + getRouteChunkCode, + getRouteChunkEntryName, + getRouteChunkIfEnabled, + getRouteChunkModuleId, + getRouteChunkNameFromModuleId, + isRouteChunkModuleId, + routeChunkExportNames, + type RouteChunkConfig, + type RouteChunkExportName, + type RouteChunkInfo, validateRouteChunks, } from '../src/route-chunks'; -const config = { - splitRouteModules: true as const, +const config: RouteChunkConfig = { + splitRouteModules: true, appDirectory: '/app', rootRouteFile: 'root.tsx', }; -const enforceConfig = { - splitRouteModules: 'enforce' as const, - appDirectory: '/app', - rootRouteFile: 'root.tsx', +const disabledConfig: RouteChunkConfig = { + ...config, + splitRouteModules: false, +}; + +const enforceConfig: RouteChunkConfig = { + ...config, + splitRouteModules: 'enforce', +}; + +const routeId = '/app/routes/demo.tsx'; +const rootRouteId = '/app/root.tsx'; + +const emptyChunkInfo: RouteChunkInfo = { + exportNames: [], + chunkedExports: [], + hasRouteChunks: false, + hasRouteChunkByExportName: { + clientAction: false, + clientLoader: false, + clientMiddleware: false, + HydrateFallback: false, + }, +}; + +const clientExportFixtures: Record = { + clientAction: `export const clientAction = async () => {};`, + clientLoader: `export const clientLoader = async () => {};`, + clientMiddleware: `export const clientMiddleware = async () => {};`, + HydrateFallback: `export function HydrateFallback() { return null; }`, +}; + +const codeWithClientAction = ` + export const clientAction = async () => {}; + export default function Route() { return null; } +`; + +const codeWithClientActionSharedWithDefault = ` + const helper = () => null; + export default function Route() { return helper(); } + export const clientAction = async () => helper(); +`; + +const codeWithActionAndDefault = ` + import { json } from 'react-router'; + export async function action() { return json({}); } + export default function Route() { return null; } +`; + +const detect = (code: string, id = routeId) => + detectRouteChunksIfEnabled(new Map(), config, id, code); + +const expectOnlyChunkedExport = ( + result: RouteChunkInfo, + exportName: RouteChunkExportName +) => { + expect(result.hasRouteChunks).toBe(true); + expect(result.chunkedExports).toEqual([exportName]); + for (const name of routeChunkExportNames) { + expect(result.hasRouteChunkByExportName[name]).toBe(name === exportName); + } +}; + +const expectNoRouteChunks = ( + result: RouteChunkInfo, + exportNames: string[] = [] +) => { + expect(result).toEqual({ ...emptyChunkInfo, exportNames }); +}; + +const expectExports = async ( + code: string | null, + expectedExports: string[], + unexpectedExports: string[] = [] +) => { + expect(code).not.toBeNull(); + const exports = await getExportNames(code ?? ''); + for (const exportName of expectedExports) { + expect(exports).toContain(exportName); + } + for (const exportName of unexpectedExports) { + expect(exports).not.toContain(exportName); + } }; describe('route chunks', () => { - it('detects chunkable client exports', async () => { - const code = ` - export const clientAction = async () => {}; - export const clientLoader = async () => {}; - export const clientMiddleware = async () => {}; - export function HydrateFallback() { return null; } - export default function Route() { return null; } - `; - - const result = await detectRouteChunksIfEnabled( - undefined, - config, - '/app/routes/demo.tsx', - code + describe('detect route chunks', () => { + it.each(routeChunkExportNames)( + 'detects a splittable %s export independently', + async exportName => { + const code = ` + ${clientExportFixtures[exportName]} + export default function Route() { return null; } + `; + + const result = await detect(code); + + expectOnlyChunkedExport(result, exportName); + } ); - expect(result.hasRouteChunks).toBe(true); - expect(result.hasRouteChunkByExportName.clientAction).toBe(true); - expect(result.hasRouteChunkByExportName.clientLoader).toBe(true); - expect(result.hasRouteChunkByExportName.clientMiddleware).toBe(true); - expect(result.hasRouteChunkByExportName.HydrateFallback).toBe(true); + it('detects all four client exports as independently splittable', async () => { + const code = ` + const actionHelper = () => null; + const loaderHelper = () => null; + const middlewareHelper = () => null; + const fallbackHelper = () => null; + export const clientAction = async () => actionHelper(); + export const clientLoader = async () => loaderHelper(); + export const clientMiddleware = async () => middlewareHelper(); + export function HydrateFallback() { return fallbackHelper(); } + export default function Route() { return null; } + `; + + const result = await detect(code); + + expect(result.hasRouteChunks).toBe(true); + expect(result.hasRouteChunkByExportName).toEqual({ + clientAction: true, + clientLoader: true, + clientMiddleware: true, + HydrateFallback: true, + }); + expect(result.chunkedExports).toEqual(routeChunkExportNames); + }); + + it('returns runtime export names from route chunk analysis', async () => { + const result = await detectRouteChunksIfEnabled( + new Map(), + config, + routeId, + ` + export type LoaderData = { value: string }; + export type * from './types'; + export * from './shared'; + export * as helpers from './helpers'; + export const clientAction = async () => {}; + export async function loader() { return null; } + export default function Route() { return null; } + ` + ); + + expect(result.exportNames).toEqual([ + 'helpers', + 'clientAction', + 'loader', + 'default', + ]); + }); + + it('allows client exports to depend on imports', async () => { + const code = ` + import { json } from 'react-router'; + export const clientLoader = async () => json({}); + export default function Route() { return null; } + `; + + const result = await detect(code); + + expectOnlyChunkedExport(result, 'clientLoader'); + }); + + it('does not split two client exports that share a top-level helper', async () => { + const code = ` + const shared = () => {}; + export const clientAction = async () => shared(); + export const clientLoader = async () => shared(); + `; + + const result = await detect(code); + + expectNoRouteChunks(result, ['clientAction', 'clientLoader']); + }); + + it('does not split a client export that shares top-level code with the default export', async () => { + const result = await detect(codeWithClientActionSharedWithDefault); + + expectNoRouteChunks(result, ['default', 'clientAction']); + }); + + it('splits a single-binding destructured client export', async () => { + const code = ` + function make() { return { clientAction: async () => {} }; } + export const { clientAction } = make(); + export default function Route() { return null; } + `; + + const result = await detect(code); + + expectOnlyChunkedExport(result, 'clientAction'); + }); + + it('does not split a multi-binding destructured client export sharing a declarator', async () => { + const code = ` + function make() { return { clientAction: async () => {}, foo: 1 }; } + export const { clientAction, foo } = make(); + export default function Route() { return null; } + `; + + const result = await detect(code); + + expectNoRouteChunks(result, ['clientAction', 'foo', 'default']); + }); + + it('splits an isolated client export while leaving a non-splittable sibling unsplit', async () => { + const code = ` + const actionHelper = () => null; + const shared = () => null; + export const clientAction = async () => actionHelper(); + export const clientLoader = async () => shared(); + export default function Route() { return shared(); } + `; + + const result = await detect(code); + + expect(result.hasRouteChunks).toBe(true); + expect(result.chunkedExports).toEqual(['clientAction']); + expect(result.hasRouteChunkByExportName.clientAction).toBe(true); + expect(result.hasRouteChunkByExportName.clientLoader).toBe(false); + }); + + it('does not scan sibling declarators from shared export statements as dependencies', async () => { + const code = ` + const serverOnly = () => null; + export const clientAction = async () => null, helper = serverOnly(); + export default function Route() { return helper; } + `; + + const result = await detect(code); + + expectOnlyChunkedExport(result, 'clientAction'); + }); + + it('orders chunkedExports by routeChunkExportNames, not source order', async () => { + const code = ` + export function HydrateFallback() { return null; } + export const clientLoader = async () => {}; + export const clientAction = async () => {}; + export default function Route() { return null; } + `; + + const result = await detect(code); + + expect(result.chunkedExports).toEqual([ + 'clientAction', + 'clientLoader', + 'HydrateFallback', + ]); + }); }); - it('skips splitting for the root route', async () => { - const code = `export const clientAction = async () => {};`; + describe('generate route chunk code', () => { + it('omits chunkable client exports from the main chunk while retaining default and server exports', async () => { + const code = ` + import { json } from 'react-router'; + export async function action() { return json({}); } + export const clientAction = async () => {}; + export default function Route() { return null; } + `; - const result = await detectRouteChunksIfEnabled( - undefined, - config, - '/app/root.tsx', - code - ); + const chunk = await getRouteChunkIfEnabled( + new Map(), + config, + routeId, + 'main', + code + ); + + await expectExports(chunk, ['default', 'action'], ['clientAction']); + }); + + it('returns main chunk code without analysis when no route chunk exports exist', async () => { + const cache = new Map(); + const code = `export default function Route() { return null; }`; + + const chunk = await getRouteChunkIfEnabled( + cache, + config, + routeId, + 'main', + code + ); + + expect(chunk).toBe(code); + expect(cache.size).toBe(0); + }); + + it('generates an individual client chunk with only that client export', async () => { + const code = ` + import { json } from 'react-router'; + export async function action() { return json({}); } + export const clientAction = async () => {}; + export default function Route() { return null; } + `; + + const chunk = await getRouteChunkIfEnabled( + new Map(), + config, + routeId, + 'clientAction', + code + ); + + await expectExports(chunk, ['clientAction'], ['default', 'action']); + }); + + it('keeps only import specifiers used by an individual client chunk', async () => { + const code = ` + import { json, useFetcher } from 'react-router'; + export const clientLoader = async () => json({}); + export default function Route() { return useFetcher(); } + `; + + const chunk = await getRouteChunkIfEnabled( + new Map(), + config, + routeId, + 'clientLoader', + code + ); + + expect(chunk).toMatch(/import\s*\{\s*json\s*\}\s*from/); + expect(chunk).not.toContain('useFetcher'); + await expectExports(chunk, ['clientLoader'], ['default']); + }); + + it('returns null for the main chunk when only client exports exist', async () => { + const code = ` + export const clientAction = async () => {}; + export const clientLoader = async () => {}; + `; + + const chunk = await getRouteChunkIfEnabled( + new Map(), + config, + routeId, + 'main', + code + ); + + expect(chunk).toBeNull(); + }); + + it('returns null for a non-chunkable individual client export', async () => { + const chunk = await getRouteChunkIfEnabled( + new Map(), + config, + routeId, + 'clientAction', + codeWithClientActionSharedWithDefault + ); + + expect(chunk).toBeNull(); + }); - expect(result.hasRouteChunks).toBe(false); - expect(result.hasRouteChunkByExportName.clientAction).toBe(false); + it('returns the full main chunk when a module has no chunkable exports', async () => { + const chunk = await getRouteChunkIfEnabled( + new Map(), + config, + routeId, + 'main', + codeWithActionAndDefault + ); + + await expectExports(chunk, ['default', 'action'], ['clientAction']); + }); + + it('dispatches main and named chunk generation through getRouteChunkCode', async () => { + const cache = new Map(); + const mainChunk = getRouteChunkCode( + codeWithClientAction, + 'main', + cache, + 'routes/demo.tsx' + ); + const clientActionChunk = getRouteChunkCode( + codeWithClientAction, + 'clientAction', + cache, + 'routes/demo.tsx' + ); + + await expectExports(mainChunk ?? null, ['default'], ['clientAction']); + await expectExports(clientActionChunk ?? null, ['clientAction'], ['default']); + }); + + it('round-trips route chunk module ids and entry names', () => { + const moduleId = getRouteChunkModuleId( + '/app/routes/r.tsx', + 'clientAction' + ); + + expect(moduleId).toBe('/app/routes/r.tsx?route-chunk=clientAction'); + expect(isRouteChunkModuleId(moduleId)).toBe(true); + expect(getRouteChunkNameFromModuleId(moduleId)).toBe('clientAction'); + expect(getRouteChunkNameFromModuleId('/app/routes/r.tsx?route-chunk=main')).toBe( + 'main' + ); + expect(getRouteChunkNameFromModuleId('/app/routes/r.tsx')).toBeNull(); + expect( + getRouteChunkNameFromModuleId('/app/routes/r.tsx?route-chunk=bogus') + ).toBeNull(); + expect(getRouteChunkEntryName('routes/clients', 'clientAction')).toBe( + 'routes/clients-client-action' + ); + }); }); - it('throws when enforce is enabled and chunks cannot be split', async () => { - const code = ` - const shared = () => {}; - export const clientAction = async () => shared(); - export const clientLoader = async () => shared(); - `; - - const result = await detectRouteChunksIfEnabled( - undefined, - enforceConfig, - '/app/routes/shared.tsx', - code + describe('mode + early-exit', () => { + it('returns no route chunks without parsing when splitRouteModules is disabled or absent', async () => { + const invalidCode = `export const clientAction = ;`; + const absentConfig: RouteChunkConfig = { + ...config, + splitRouteModules: undefined, + }; + + await expect( + detectRouteChunksIfEnabled(new Map(), disabledConfig, routeId, invalidCode) + ).resolves.toEqual(emptyChunkInfo); + await expect( + detectRouteChunksIfEnabled(new Map(), absentConfig, routeId, invalidCode) + ).resolves.toEqual(emptyChunkInfo); + }); + + it('early-exits when no client export name substring appears', async () => { + const result = await detect(codeWithActionAndDefault); + + expectNoRouteChunks(result); + }); + + it('does not create a chunk from a client export name mentioned only in a comment', async () => { + const code = ` + // clientAction is mentioned here, but no such export exists. + export default function Route() { return null; } + `; + + const result = await detect(code); + + expectNoRouteChunks(result, ['default']); + }); + + it('returns null when route chunk generation is disabled', async () => { + await expect( + getRouteChunkIfEnabled( + new Map(), + disabledConfig, + routeId, + 'main', + codeWithClientAction + ) + ).resolves.toBeNull(); + }); + }); + + describe('root route', () => { + it.each([ + ['/app/root.tsx', true], + ['/app/./root.tsx', true], + ['/app/root.tsx?react-router-route', true], + ['/app/routes/root.tsx', false], + ])( + 'detects root route identity for %s', + async (id, isRootRoute) => { + const result = await detect(codeWithClientAction, id); + + expect(result.hasRouteChunks).toBe(!isRootRoute); + expect(result.hasRouteChunkByExportName.clientAction).toBe(!isRootRoute); + } ); - expect(result.hasRouteChunkByExportName.clientAction).toBe(false); - expect(result.hasRouteChunkByExportName.clientLoader).toBe(false); - - expect(() => - validateRouteChunks({ - config: enforceConfig, - id: '/app/routes/shared.tsx', - valid: { - clientAction: false, - clientLoader: false, - clientMiddleware: true, - HydrateFallback: true, - }, - }) - ).toThrowError(/Error splitting route module/); + it('generates a named chunk for the root route because generation has no root guard', async () => { + const chunk = await getRouteChunkIfEnabled( + new Map(), + config, + rootRouteId, + 'clientAction', + codeWithClientAction + ); + + await expectExports(chunk, ['clientAction'], ['default']); + }); + + it('does not enforce route chunk validity for the root route', () => { + expect(() => + validateRouteChunks({ + config: enforceConfig, + id: rootRouteId, + valid: { + clientAction: false, + clientLoader: false, + clientMiddleware: false, + HydrateFallback: false, + }, + }) + ).not.toThrow(); + }); + }); + + describe('enforce mode', () => { + it('allows all valid route chunks', () => { + expect(() => + validateRouteChunks({ + config: enforceConfig, + id: routeId, + valid: { + clientAction: true, + clientLoader: true, + clientMiddleware: true, + HydrateFallback: true, + }, + }) + ).not.toThrow(); + }); + + it('throws a singular guidance message for one invalid route chunk', () => { + expect(() => + validateRouteChunks({ + config: enforceConfig, + id: routeId, + valid: { + clientAction: false, + clientLoader: true, + clientMiddleware: true, + HydrateFallback: true, + }, + }) + ).toThrowError( + /Error splitting route module:[\s\S]*clientAction[\s\S]*This export[\s\S]*its own chunk[\s\S]*it shares/ + ); + }); + + it('throws a plural guidance message listing every invalid route chunk', () => { + expect(() => + validateRouteChunks({ + config: enforceConfig, + id: routeId, + valid: { + clientAction: false, + clientLoader: false, + clientMiddleware: true, + HydrateFallback: true, + }, + }) + ).toThrowError( + /Error splitting route module:[\s\S]*clientAction[\s\S]*clientLoader[\s\S]*These exports[\s\S]*their own chunks[\s\S]*they share/ + ); + }); }); }); diff --git a/tests/route-watch.test.ts b/tests/route-watch.test.ts new file mode 100644 index 0000000..0784ef3 --- /dev/null +++ b/tests/route-watch.test.ts @@ -0,0 +1,102 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from '@rstest/core'; +import { + createRouteManifestSnapshot, + ensureDevRestartMarker, + getRouteRestartMarkerPath, +} from '../src/route-watch'; + +describe('route watch restart marker', () => { + it('places the restart marker in the client build output', () => { + expect(getRouteRestartMarkerPath('/project/build/client')).toBe( + '/project/build/client/.react-router/route-watch' + ); + }); + + it('creates the restart marker when missing', async () => { + const root = mkdtempSync(join(tmpdir(), 'rr-route-watch-')); + try { + const markerPath = join(root, 'build/.react-router-route-watch'); + + await ensureDevRestartMarker(markerPath); + + expect(readFileSync(markerPath, 'utf8')).not.toBe(''); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('does not rewrite an existing restart marker on dev server startup', async () => { + const root = mkdtempSync(join(tmpdir(), 'rr-route-watch-')); + try { + const markerPath = join(root, 'build/.react-router-route-watch'); + mkdirSync(join(root, 'build'), { recursive: true }); + writeFileSync(markerPath, 'existing'); + + await ensureDevRestartMarker(markerPath); + + expect(readFileSync(markerPath, 'utf8')).toBe('existing'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('route watch topology snapshot', () => { + it('changes when route topology changes but route files stay the same', () => { + const baseRoutes = { + root: { id: 'root', path: '', file: 'root.tsx' }, + 'routes/demo': { + id: 'routes/demo', + parentId: 'root', + path: 'demo', + file: 'routes/demo.tsx', + }, + }; + + const changedRoutes = { + ...baseRoutes, + 'routes/demo': { + ...baseRoutes['routes/demo'], + path: 'renamed-demo', + }, + }; + + expect(createRouteManifestSnapshot(baseRoutes)).not.toEqual( + createRouteManifestSnapshot(changedRoutes) + ); + }); + + it('is stable for equivalent route manifests with different object insertion order', () => { + const first = createRouteManifestSnapshot({ + root: { id: 'root', path: '', file: 'root.tsx' }, + 'routes/demo': { + id: 'routes/demo', + parentId: 'root', + path: 'demo', + file: 'routes/demo.tsx', + }, + }); + + const second = createRouteManifestSnapshot({ + 'routes/demo': { + id: 'routes/demo', + parentId: 'root', + path: 'demo', + file: 'routes/demo.tsx', + }, + root: { id: 'root', path: '', file: 'root.tsx' }, + }); + + expect(second).toEqual(first); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index a860a14..8df5a63 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -10,6 +10,16 @@ rstest.mock('jiti', () => ({ createJiti: () => ({ import: rstest.fn().mockImplementation((path) => { if (path.includes('routes.ts')) { + const routeCount = Number(process.env.RR_TEST_ROUTE_COUNT ?? 0); + if (routeCount > 0) { + return Promise.resolve( + Array.from({ length: routeCount }, (_, index) => ({ + id: `routes/route-${index}`, + file: `routes/route-${index}.tsx`, + index: index === 0, + })) + ); + } return Promise.resolve([ { id: 'routes/index', @@ -18,6 +28,13 @@ rstest.mock('jiti', () => ({ }, ]); } + if (process.env.RR_TEST_SPLIT_ROUTE_MODULES === 'true') { + return Promise.resolve({ + future: { + v8_splitRouteModules: true, + }, + }); + } return Promise.resolve({}); }), }), @@ -51,7 +68,7 @@ const deepMerge = (base: any, overrides: any): any => { // Mock the @scripts/test-helper module rstest.mock('@scripts/test-helper', () => ({ - createStubRsbuild: rstest.fn().mockImplementation(async ({ rsbuildConfig = {} } = {}) => { + createStubRsbuild: rstest.fn().mockImplementation(async ({ action = 'dev', rsbuildConfig = {} } = {}) => { const baseConfig = { dev: { // Match Rsbuild defaults so plugin changes are observable in tests. @@ -91,7 +108,7 @@ rstest.mock('@scripts/test-helper', () => ({ tools: { rspack: { plugins: [ - { constructor: { name: 'RspackVirtualModulePlugin' } }, + { constructor: { name: 'VirtualModulesPlugin' } }, ], }, }, @@ -110,6 +127,8 @@ rstest.mock('@scripts/test-helper', () => ({ unwrapConfig: rstest.fn(), processAssets: rstest.fn(), onBeforeStartDevServer: rstest.fn(), + onCloseDevServer: rstest.fn(), + onCloseBuild: rstest.fn(), onBeforeBuild: rstest.fn(), onAfterBuild: rstest.fn(), getNormalizedConfig: rstest.fn().mockImplementation(() => mergedConfig), @@ -127,7 +146,7 @@ rstest.mock('@scripts/test-helper', () => ({ }, context: { rootPath: '/Users/bytedance/dev/rsbuild-plugin-react-router', - action: 'dev', + action, }, compiler: { webpack: {