diff --git a/.github/workflows/code-analysis.yml b/.github/workflows/code-analysis.yml index 4359ca87c..45f681b44 100644 --- a/.github/workflows/code-analysis.yml +++ b/.github/workflows/code-analysis.yml @@ -61,6 +61,20 @@ jobs: - name: Main ESlint check run: pnpm lint + vite-optimize-deps: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name + name: Vite optimizeDeps audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Node.js environment + uses: ./.github/actions/node_env_setup + with: + node-version: ${{ env.node-version }} + + - name: Check optimizeDeps declarations are up to date + run: pnpm check:vite-optimize-deps + stylelint: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name name: Stylelint ${{ matrix.name }} diff --git a/apps/aurora/news/+vite-optimize-deps-ssr-exclude.internal b/apps/aurora/news/+vite-optimize-deps-ssr-exclude.internal new file mode 100644 index 000000000..fadf55777 --- /dev/null +++ b/apps/aurora/news/+vite-optimize-deps-ssr-exclude.internal @@ -0,0 +1 @@ +Excluded `i18next-fs-backend` and `remix-i18next/server` from the client `optimizeDeps` bundle — they were still being picked up by Vite's client-side dependency scanner despite only being declared in `ssr.optimizeDeps.include`. @sneridagh diff --git a/apps/aurora/news/+vite-optimize-deps.internal b/apps/aurora/news/+vite-optimize-deps.internal new file mode 100644 index 000000000..ae4a04fa5 --- /dev/null +++ b/apps/aurora/news/+vite-optimize-deps.internal @@ -0,0 +1 @@ +Added `optimizeDeps.include` entries for non-addon workspace packages and app-level deps to reduce lazy dependency discovery reloads on dev server startup. @arybakov05 diff --git a/apps/aurora/vite.config.ts b/apps/aurora/vite.config.ts index c7e9478f6..0f602f4d3 100644 --- a/apps/aurora/vite.config.ts +++ b/apps/aurora/vite.config.ts @@ -46,6 +46,52 @@ export default defineConfig(({ command, mode, isSsrBuild }) => { ] : []), ] as PluginOption[], + optimizeDeps: { + // Server-only deps that Vite would otherwise still pick up for the + // client bundle — keep in sync with ssr.optimizeDeps.include below + exclude: [ + 'i18next-fs-backend', + 'i18next-fs-backend/cjs', + 'remix-i18next/server', + ], + include: [ + // App-level deps (in apps/aurora/package.json) + 'i18next', + 'i18next-browser-languagedetector', + 'i18next-http-backend', + 'react-i18next', + // Injected by babel-plugin-react-compiler, not in any package.json + 'react/compiler-runtime', + 'remix-i18next/client', + 'remix-i18next/react', + // @plone/components and @plone/helpers are not registered add-ons, so + // their deps can't be declared in vite.extend.js — list them here + '@plone/components > @internationalized/date', + '@plone/components > @react-aria/utils', + '@plone/components > @react-spectrum/utils', + '@plone/components > clsx', + '@plone/components > react-aria', + '@plone/components > react-aria-components', + '@plone/components > react-aria-components/DropZone', + '@plone/components > react-aria-components/Form', + '@plone/components > react-aria-components/Group', + '@plone/components > react-aria-components/Modal', + '@plone/components > react-aria-components/Table', + '@plone/components > react-aria-components/Tooltip', + '@plone/components > react-aria-components/composeRenderProps', + '@plone/components > react-stately', + '@plone/components > tailwind-merge', + '@plone/components > tailwind-variants', + '@plone/helpers > jotai', + '@plone/helpers > jotai/utils', + '@plone/helpers > jotai-optics', + ], + }, + ssr: { + optimizeDeps: { + include: ['i18next-fs-backend/cjs', 'isbot', 'remix-i18next/server'], + }, + }, resolve: { tsconfigPaths: true, }, diff --git a/docs/development/index.md b/docs/development/index.md index 708b4ae6a..e794fdfcd 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -20,4 +20,5 @@ i18n editor-slash-menu block-anatomy configure-editor-block-widths +vite-optimize-deps ``` diff --git a/docs/development/vite-optimize-deps.md b/docs/development/vite-optimize-deps.md new file mode 100644 index 000000000..e529675ea --- /dev/null +++ b/docs/development/vite-optimize-deps.md @@ -0,0 +1,128 @@ +--- +myst: + html_meta: + "description": "How to declare Vite optimizeDeps for add-ons in Aurora" + "property=og:description": "How to declare Vite optimizeDeps for add-ons in Aurora" + "property=og:title": "Vite dependency pre-bundling" + "keywords": "Plone Aurora, Vite, optimizeDeps, pnpm, add-ons" +--- + +# Vite dependency pre-bundling + +This guide shows you how to declare third-party dependencies for pre-bundling so Vite resolves more of them at startup instead of discovering them lazily during dev. + +Aurora runs in a pnpm monorepo. +Vite treats workspace packages as source files and does not scan them upfront for their third-party dependencies. +Instead, it discovers them the first time a page imports them, triggering a re-bundle and a browser reload. +In a large project, this causes several reload cycles and noticeably slows down the first page load in dev mode. + +The fix is to declare those third-party dependencies in `optimizeDeps.include`. +Aurora uses two places for these declarations depending on whether the package is a registered add-on. + +```{important} +Only declare a package as a registered add-on if it configures the application — registering slots, routes, or components into the Aurora framework. +Pure libraries such as component kits and utility packages must not be add-ons. +Registering them as add-ons causes the addon system to generate unnecessary loaders and blurs the boundary between framework participants and libraries. +If your package is a library, add its entries to `vite.config.ts` in the app that uses it. +``` + +## Registered add-ons: `vite.extend.js` + +Any package that is a registered Aurora add-on can ship a `vite.extend.js` file in its root. +`PloneRegistryVitePlugin` picks these up automatically and merges them into the Vite config at startup — no changes to the app are needed. + +The file must export a default function that receives the current config and returns a modified copy: + +```js +// packages/my-addon/vite.extend.js +export default function (config) { + return { + ...config, + optimizeDeps: { + ...config.optimizeDeps, + include: [ + ...(config.optimizeDeps?.include ?? []), + // Use "pkg > dep" syntax for pnpm — resolves the dep through the + // workspace package that owns it + '@plone/my-addon > some-library', + '@plone/my-addon > some-library/subpath', + ], + }, + }; +} +``` + +### When to add an entry + +Add a `"pkg > dep"` entry for every direct dependency of your add-on that is not itself a workspace package. +You do not need entries for: + +- Other Aurora add-ons or workspace packages — Vite excludes them from + optimization automatically because it treats them as source files. +- Dev dependencies. +- Peer dependencies that the host app provides. + +If a dependency exports subpaths you use (for example `some-lib/react` or `some-lib/client`), add a separate entry for each subpath. +Vite does not discover subpath exports automatically from the main entry. + +If a dependency is only used in server-side code (for example `*.server.*` files), add it to `ssr.optimizeDeps.include` instead of `optimizeDeps.include` so it isn't pre-bundled for the browser. +Declaring it in `ssr.optimizeDeps.include` alone is not always enough — Vite's client-side dependency scanner can still pick it up through re-exports. +If that happens, also add it to the top-level `optimizeDeps.exclude` in `apps/aurora/vite.config.ts` so it's explicitly kept out of the client bundle. + +To keep the lists from drifting, run: + +```sh +pnpm check:vite-optimize-deps +``` + +The script inspects runtime imports in the workspace packages that require explicit declarations and reports missing or stale `pkg > dep` entries. + +Run it when: + +- you add or remove a runtime import in `@plone/components`, `@plone/helpers`, + `@plone/cmsui`, `@plone/layout`, or `@plone/plate` +- you switch an import to a different package subpath such as + `some-lib/react` +- you move an import between browser-only and server-only code and may need to + switch between `optimizeDeps.include` and `ssr.optimizeDeps.include` +- you touch one of the existing optimize-deps config files and want to verify + the list before opening a PR + +Recommended workflow: + +1. Make the code change that introduces or removes runtime imports. +2. Run `pnpm check:vite-optimize-deps`. +3. Copy the reported missing entries into the relevant `vite.extend.js` file or + `apps/aurora/vite.config.ts`. +4. Remove reported stale entries unless you have a concrete reason to keep + them. +5. Re-run the check until it passes. + +### Existing core add-on files + +| Add-on | File | +|--------|------| +| `@plone/plate` | `packages/plate/vite.extend.js` | +| `@plone/layout` | `packages/layout/vite.extend.js` | +| `@plone/cmsui` | `packages/cmsui/vite.extend.js` | + +## Non-add-on workspace packages and app deps: `vite.config.ts` + +Packages that are not registered add-ons — currently `@plone/components` and `@plone/helpers` — cannot use `vite.extend.js` because `PloneRegistryVitePlugin` never loads their files. +List their dependencies directly in `apps/aurora/vite.config.ts` under `optimizeDeps.include`. + +The same rule applies to packages that are direct dependencies of the app itself (listed in `apps/aurora/package.json`). +Those use plain names without the `>` syntax because Vite resolves them directly from the app root. + +```ts +// apps/aurora/vite.config.ts +optimizeDeps: { + include: [ + // App-level dep — plain name works + 'some-app-dep', + // Non-add-on workspace package dep — requires "pkg > dep" syntax + '@plone/components > react-aria-components', + '@plone/helpers > jotai', + ] +} +``` diff --git a/package.json b/package.json index 52e9ed0cb..c3f1f2426 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test": "pnpm --filter @plone/aurora run test", "test:all": "pnpm test:ci", "test:ci": "pnpm --filter @plone/* --filter \"!@plone/scripts\" --filter \"!@plone/theming\" run test --run", + "check:vite-optimize-deps": "node packages/scripts/check-vite-optimize-deps.js", "prettier": "prettier --check '{apps,packages}/**/*.{js,jsx,ts,tsx}'", "prettier:fix": "prettier --write '{apps,packages}/**/*.{js,jsx,ts,tsx}'", "stylelint": "stylelint '{apps,packages}/**/*.{css,scss,less}'", diff --git a/packages/cmsui/news/+vite-optimize-deps.internal b/packages/cmsui/news/+vite-optimize-deps.internal new file mode 100644 index 000000000..25a7501c4 --- /dev/null +++ b/packages/cmsui/news/+vite-optimize-deps.internal @@ -0,0 +1 @@ +Added `vite.extend.js` and a dependency audit path to pre-bundle CMS UI runtime dependencies, reducing dev server startup reloads. @arybakov05 diff --git a/packages/cmsui/vite.extend.js b/packages/cmsui/vite.extend.js new file mode 100644 index 000000000..e7f9fb629 --- /dev/null +++ b/packages/cmsui/vite.extend.js @@ -0,0 +1,31 @@ +export default function (config) { + return { + ...config, + optimizeDeps: { + ...config.optimizeDeps, + include: [ + ...(config.optimizeDeps?.include ?? []), + '@plone/cmsui > @platejs/floating', + '@plone/cmsui > @platejs/link/react', + '@plone/cmsui > @tanstack/react-form', + '@plone/cmsui > class-variance-authority', + '@plone/cmsui > clsx', + '@plone/cmsui > jotai', + '@plone/cmsui > jotai-optics', + '@plone/cmsui > jotai/utils', + '@plone/cmsui > jwt-decode', + '@plone/cmsui > lucide-react', + '@plone/cmsui > platejs', + '@plone/cmsui > platejs/react', + '@plone/cmsui > react-aria', + '@plone/cmsui > react-aria-components', + '@plone/cmsui > react-i18next', + '@plone/cmsui > react-router', + '@plone/cmsui > rrule', + '@plone/cmsui > tailwind-merge', + '@plone/cmsui > tailwind-variants', + '@plone/cmsui > usehooks-ts', + ], + }, + }; +} diff --git a/packages/layout/news/+vite-optimize-deps.internal b/packages/layout/news/+vite-optimize-deps.internal new file mode 100644 index 000000000..3a5112b9e --- /dev/null +++ b/packages/layout/news/+vite-optimize-deps.internal @@ -0,0 +1 @@ +Added `vite.extend.js` and a dependency audit path to pre-bundle layout runtime dependencies, reducing dev server startup reloads. @arybakov05 diff --git a/packages/layout/vite.extend.js b/packages/layout/vite.extend.js new file mode 100644 index 000000000..57d59c575 --- /dev/null +++ b/packages/layout/vite.extend.js @@ -0,0 +1,19 @@ +export default function (config) { + return { + ...config, + optimizeDeps: { + ...config.optimizeDeps, + include: [ + ...(config.optimizeDeps?.include ?? []), + '@plone/layout > clsx', + '@plone/layout > lodash.sortby', + '@plone/layout > pretty-bytes', + '@plone/layout > react-aria', + '@plone/layout > react-aria-components', + '@plone/layout > react-i18next', + '@plone/layout > react-router', + '@plone/layout > rrule', + ], + }, + }; +} diff --git a/packages/plate/news/+vite-optimize-deps.internal b/packages/plate/news/+vite-optimize-deps.internal new file mode 100644 index 000000000..590e9793c --- /dev/null +++ b/packages/plate/news/+vite-optimize-deps.internal @@ -0,0 +1 @@ +Added `vite.extend.js` and a dependency audit path to pre-bundle Plate runtime dependencies, reducing dev server startup reloads. @arybakov05 diff --git a/packages/plate/vite.extend.js b/packages/plate/vite.extend.js new file mode 100644 index 000000000..0ea56d5a6 --- /dev/null +++ b/packages/plate/vite.extend.js @@ -0,0 +1,91 @@ +export default function (config) { + return { + ...config, + optimizeDeps: { + ...config.optimizeDeps, + include: [ + ...(config.optimizeDeps?.include ?? []), + '@plone/plate > @ai-sdk/react', + '@plone/plate > @ariakit/react', + '@plone/plate > @platejs/ai', + '@plone/plate > @platejs/ai/react', + '@plone/plate > @platejs/autoformat', + '@plone/plate > @platejs/basic-nodes', + '@plone/plate > @platejs/basic-nodes/react', + '@plone/plate > @platejs/basic-styles', + '@plone/plate > @platejs/basic-styles/react', + '@plone/plate > @platejs/callout', + '@plone/plate > @platejs/callout/react', + '@plone/plate > @platejs/caption', + '@plone/plate > @platejs/caption/react', + '@plone/plate > @platejs/code-block', + '@plone/plate > @platejs/code-block/react', + '@plone/plate > @platejs/combobox', + '@plone/plate > @platejs/combobox/react', + '@plone/plate > @platejs/comment', + '@plone/plate > @platejs/comment/react', + '@plone/plate > @platejs/dnd', + '@plone/plate > @platejs/docx', + '@plone/plate > @platejs/emoji/react', + '@plone/plate > @platejs/floating', + '@plone/plate > @platejs/indent', + '@plone/plate > @platejs/indent/react', + '@plone/plate > @platejs/juice', + '@plone/plate > @platejs/layout', + '@plone/plate > @platejs/layout/react', + '@plone/plate > @platejs/link', + '@plone/plate > @platejs/link/react', + '@plone/plate > @platejs/list', + '@plone/plate > @platejs/list/react', + '@plone/plate > @platejs/markdown', + '@plone/plate > @platejs/media', + '@plone/plate > @platejs/media/react', + '@plone/plate > @platejs/mention', + '@plone/plate > @platejs/mention/react', + '@plone/plate > @platejs/playwright', + '@plone/plate > @platejs/resizable', + '@plone/plate > @platejs/selection/react', + '@plone/plate > @platejs/slash-command/react', + '@plone/plate > @platejs/suggestion', + '@plone/plate > @platejs/table', + '@plone/plate > @platejs/table/react', + '@plone/plate > @platejs/toc', + '@plone/plate > @platejs/toc/react', + '@plone/plate > @platejs/toggle', + '@plone/plate > @platejs/toggle/react', + '@plone/plate > @radix-ui/react-alert-dialog', + '@plone/plate > @radix-ui/react-avatar', + '@plone/plate > @radix-ui/react-checkbox', + '@plone/plate > @radix-ui/react-context-menu', + '@plone/plate > @radix-ui/react-dialog', + '@plone/plate > @radix-ui/react-dropdown-menu', + '@plone/plate > @radix-ui/react-popover', + '@plone/plate > @radix-ui/react-separator', + '@plone/plate > @radix-ui/react-slot', + '@plone/plate > @radix-ui/react-toolbar', + '@plone/plate > @radix-ui/react-tooltip', + '@plone/plate > @udecode/cn', + '@plone/plate > ai', + '@plone/plate > class-variance-authority', + '@plone/plate > clsx', + '@plone/plate > cmdk', + '@plone/plate > html2canvas-pro', + '@plone/plate > jotai', + '@plone/plate > lodash.debounce', + '@plone/plate > lowlight', + '@plone/plate > lucide-react', + '@plone/plate > pdf-lib', + '@plone/plate > platejs', + '@plone/plate > platejs/react', + '@plone/plate > react-dnd', + '@plone/plate > react-dnd-html5-backend', + '@plone/plate > react-lite-youtube-embed', + '@plone/plate > remark-gfm', + '@plone/plate > sonner', + '@plone/plate > tailwind-merge', + '@plone/plate > use-file-picker', + '@plone/plate > zod', + ], + }, + }; +} diff --git a/packages/registry/news/+vite-extend-support.internal b/packages/registry/news/+vite-extend-support.internal new file mode 100644 index 000000000..039f17f01 --- /dev/null +++ b/packages/registry/news/+vite-extend-support.internal @@ -0,0 +1 @@ +Check whether `.plone/vite.loader.js` content has changed before writing it in `PloneRegistryVitePlugin`. @arybakov05 \ No newline at end of file diff --git a/packages/registry/vite-plugin.js b/packages/registry/vite-plugin.js index c8c8dcd8f..1bbf0d7d2 100644 --- a/packages/registry/vite-plugin.js +++ b/packages/registry/vite-plugin.js @@ -79,7 +79,14 @@ const load = (config, context = {}) => { export default load; `; - fs.writeFileSync(viteLoaderPath, code); + // Only write if content changed — vite.loader.js is imported by vite.config.ts + // so any write triggers a Vite server restart, causing an infinite loop + const existing = fs.existsSync(viteLoaderPath) + ? fs.readFileSync(viteLoaderPath, 'utf-8') + : null; + if (existing !== code) { + fs.writeFileSync(viteLoaderPath, code); + } return viteLoaderPath; } diff --git a/packages/scripts/README.md b/packages/scripts/README.md index 554ea77f6..b9744ee0e 100644 --- a/packages/scripts/README.md +++ b/packages/scripts/README.md @@ -9,3 +9,31 @@ It scans and detects i18n messages from the code and adds them to the i18n machi See https://6.docs.plone.org/volto/development/i18n.html for more information. This script is installed in the `node_modules/.bin` directory and can be called via `yarn i18n` or directly in the `scripts` `package.json` part. + +## Aurora optimize-deps audit + +Aurora includes a repository-level audit script at +`packages/scripts/check-vite-optimize-deps.js`. + +Run it from the repo root with: + +```sh +pnpm check:vite-optimize-deps +``` + +Use it when you change runtime imports in Aurora workspace packages that feed +the app through Vite: + +- `@plone/components` +- `@plone/helpers` +- `@plone/cmsui` +- `@plone/layout` +- `@plone/plate` + +The script scans those packages for runtime imports of direct third-party +dependencies and compares them with the explicit entries declared in +`apps/aurora/vite.config.ts` and `packages/*/vite.extend.js`. + +If it reports missing entries, add them to the relevant optimize-deps config. +If it reports extra entries, remove them unless they are intentionally kept for +some unresolved import path. diff --git a/packages/scripts/check-vite-optimize-deps.js b/packages/scripts/check-vite-optimize-deps.js new file mode 100644 index 000000000..05c7ab5a1 --- /dev/null +++ b/packages/scripts/check-vite-optimize-deps.js @@ -0,0 +1,504 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const repoRoot = process.cwd(); + +const TARGETS = [ + { + id: 'components', + owner: '@plone/components', + packageDir: 'packages/components', + configFile: 'apps/aurora/vite.config.ts', + sourceRoots: ['src'], + }, + { + id: 'helpers', + owner: '@plone/helpers', + packageDir: 'packages/helpers', + configFile: 'apps/aurora/vite.config.ts', + sourceRoots: ['src'], + }, + { + id: 'cmsui', + owner: '@plone/cmsui', + packageDir: 'packages/cmsui', + configFile: 'packages/cmsui/vite.extend.js', + }, + { + id: 'layout', + owner: '@plone/layout', + packageDir: 'packages/layout', + configFile: 'packages/layout/vite.extend.js', + }, + { + id: 'plate', + owner: '@plone/plate', + packageDir: 'packages/plate', + configFile: 'packages/plate/vite.extend.js', + }, +]; + +const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']); + +function toPosix(filePath) { + return filePath.split(path.sep).join('/'); +} + +function shouldIgnoreFile(relativePath) { + const normalized = toPosix(relativePath); + + return ( + normalized.includes('/dist/') || + normalized.includes('/.storybook/') || + normalized.includes('/coverage/') || + normalized.includes('/news/') || + normalized.includes('/node_modules/') || + normalized.includes('/acceptance/') || + normalized.includes('/__tests__/') || + normalized.endsWith('.test.js') || + normalized.endsWith('.test.jsx') || + normalized.endsWith('.test.ts') || + normalized.endsWith('.test.tsx') || + normalized.endsWith('.spec.js') || + normalized.endsWith('.spec.jsx') || + normalized.endsWith('.spec.ts') || + normalized.endsWith('.spec.tsx') || + normalized.endsWith('.stories.js') || + normalized.endsWith('.stories.jsx') || + normalized.endsWith('.stories.ts') || + normalized.endsWith('.stories.tsx') || + normalized.endsWith('.story.js') || + normalized.endsWith('.story.jsx') || + normalized.endsWith('.story.ts') || + normalized.endsWith('.story.tsx') || + normalized.endsWith('/vite.config.js') || + normalized.endsWith('/vite.config.ts') || + normalized.endsWith('/vitest.config.js') || + normalized.endsWith('/vitest.config.ts') || + normalized.endsWith('/tsup.config.js') || + normalized.endsWith('/tsup.config.ts') || + normalized.endsWith('/vite.extend.js') || + normalized.endsWith('/vite.extend.ts') || + normalized.endsWith('/setupTesting.js') || + normalized.endsWith('/setupTesting.ts') + ); +} + +function listFiles(rootDir, sourceRoots = ['']) { + const results = []; + const stack = sourceRoots.length > 0 ? [...sourceRoots] : ['']; + + while (stack.length) { + const current = stack.pop(); + const absoluteCurrent = path.join(rootDir, current); + const entries = fs.readdirSync(absoluteCurrent, { withFileTypes: true }); + + for (const entry of entries) { + const relativePath = current + ? path.join(current, entry.name) + : entry.name; + + if (entry.isDirectory()) { + if (shouldIgnoreFile(relativePath)) { + continue; + } + stack.push(relativePath); + continue; + } + + if (!SOURCE_EXTENSIONS.has(path.extname(entry.name))) { + continue; + } + + if (shouldIgnoreFile(relativePath)) { + continue; + } + + results.push(relativePath); + } + } + + return results; +} + +function loadJson(relativePath) { + return JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')); +} + +function getWorkspacePackages() { + const packageFiles = []; + for (const rootName of ['apps', 'packages']) { + const rootDir = path.join(repoRoot, rootName); + for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const packageFile = path.join(rootName, entry.name, 'package.json'); + if (fs.existsSync(path.join(repoRoot, packageFile))) { + packageFiles.push(packageFile); + } + } + } + + return new Set( + packageFiles.map((file) => loadJson(file).name).filter(Boolean), + ); +} + +function getPackageBase(specifier) { + if (specifier.startsWith('@')) { + const [scope, name] = specifier.split('/'); + return name ? `${scope}/${name}` : specifier; + } + const [name] = specifier.split('/'); + return name; +} + +function isRelative(specifier) { + return specifier.startsWith('.') || specifier.startsWith('/'); +} + +function isRuntimeImport(node) { + const clause = node.importClause; + if (!clause) { + return true; + } + if (clause.isTypeOnly) { + return false; + } + if (clause.name) { + return true; + } + if (!clause.namedBindings) { + return false; + } + if (ts.isNamespaceImport(clause.namedBindings)) { + return true; + } + return clause.namedBindings.elements.some((element) => !element.isTypeOnly); +} + +function isRuntimeExport(node) { + if (node.isTypeOnly) { + return false; + } + const clause = node.exportClause; + if (!clause) { + // `export * from 'pkg'` + return true; + } + if (ts.isNamespaceExport(clause)) { + // `export * as ns from 'pkg'` + return true; + } + // `export { a, type b } from 'pkg'` — only a type-only re-export list + // (every element marked `type`) should be treated as a non-runtime export. + return clause.elements.some((element) => !element.isTypeOnly); +} + +function isServerOnlyFile(relativeFile) { + return /\.server\.(js|jsx|ts|tsx)$/.test(toPosix(relativeFile)); +} + +function normalizeSpecifier(specifier) { + return specifier.replace(/\?.*$/, ''); +} + +function shouldIncludeSpecifier( + specifier, + directDependencies, + workspacePackages, +) { + if (!specifier || isRelative(specifier) || specifier.startsWith('node:')) { + return false; + } + + const base = getPackageBase(specifier); + if (workspacePackages.has(base)) { + return false; + } + + return directDependencies.has(base); +} + +function collectSpecifiers(packageDir, directDependencies, workspacePackages) { + const target = TARGETS.find((item) => item.packageDir === packageDir); + const files = listFiles( + path.join(repoRoot, packageDir), + target?.sourceRoots ?? [''], + ); + + // Track, per specifier, whether it was seen from a client-reachable file + // and/or from a `*.server.*` file — only specifiers seen exclusively from + // `*.server.*` files belong in ssr.optimizeDeps.include; anything else + // (client-only, or used from both) belongs in optimizeDeps.include. + const seenFrom = new Map(); + + const record = (specifier, isServerFile) => { + const entry = seenFrom.get(specifier) ?? { client: false, server: false }; + if (isServerFile) { + entry.server = true; + } else { + entry.client = true; + } + seenFrom.set(specifier, entry); + }; + + for (const relativeFile of files) { + const absoluteFile = path.join(repoRoot, packageDir, relativeFile); + if (!SOURCE_EXTENSIONS.has(path.extname(absoluteFile))) { + continue; + } + + const isServerFile = isServerOnlyFile(relativeFile); + const sourceText = fs.readFileSync(absoluteFile, 'utf8'); + const sourceFile = ts.createSourceFile( + absoluteFile, + sourceText, + ts.ScriptTarget.Latest, + true, + absoluteFile.endsWith('.tsx') || absoluteFile.endsWith('.jsx') + ? ts.ScriptKind.TSX + : ts.ScriptKind.TS, + ); + + const visit = (node) => { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + isRuntimeImport(node) + ) { + const specifier = normalizeSpecifier(node.moduleSpecifier.text); + if ( + shouldIncludeSpecifier( + specifier, + directDependencies, + workspacePackages, + ) + ) { + record(specifier, isServerFile); + } + } + + if ( + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) && + isRuntimeExport(node) + ) { + const specifier = normalizeSpecifier(node.moduleSpecifier.text); + if ( + shouldIncludeSpecifier( + specifier, + directDependencies, + workspacePackages, + ) + ) { + record(specifier, isServerFile); + } + } + + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length === 1 && + ts.isStringLiteral(node.arguments[0]) + ) { + const specifier = normalizeSpecifier(node.arguments[0].text); + if ( + shouldIncludeSpecifier( + specifier, + directDependencies, + workspacePackages, + ) + ) { + record(specifier, isServerFile); + } + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + } + + const client = []; + const serverOnly = []; + for (const [specifier, seen] of seenFrom) { + (seen.client ? client : serverOnly).push(specifier); + } + + return { + client: client.sort((a, b) => a.localeCompare(b)), + serverOnly: serverOnly.sort((a, b) => a.localeCompare(b)), + }; +} + +function isPropertyNamed(node, name) { + return ( + (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) && + ts.isIdentifier(node.name) && + node.name.text === name + ); +} + +// Only entries actually declared in an `include` array count as declared — +// entries in a sibling `exclude` array must not be treated as covered. +function collectDeclaredEntries(configFile, owner) { + const contents = fs.readFileSync(path.join(repoRoot, configFile), 'utf8'); + const sourceFile = ts.createSourceFile( + configFile, + contents, + ts.ScriptTarget.Latest, + true, + configFile.endsWith('.ts') || configFile.endsWith('.tsx') + ? ts.ScriptKind.TS + : ts.ScriptKind.JS, + ); + + const prefix = `${owner} > `; + const client = new Set(); + const ssr = new Set(); + + const visit = (node, ancestorNames) => { + if ( + isPropertyNamed(node, 'include') && + node.initializer && + ts.isArrayLiteralExpression(node.initializer) + ) { + const target = ancestorNames.includes('ssr') ? ssr : client; + for (const element of node.initializer.elements) { + if ( + ts.isStringLiteralLike(element) && + element.text.startsWith(prefix) + ) { + target.add(element.text.slice(prefix.length)); + } + } + } + + const nextAncestors = + (ts.isPropertyAssignment(node) || + ts.isShorthandPropertyAssignment(node)) && + ts.isIdentifier(node.name) + ? [...ancestorNames, node.name.text] + : ancestorNames; + + ts.forEachChild(node, (child) => visit(child, nextAncestors)); + }; + + visit(sourceFile, []); + + return { + client: [...client].sort((a, b) => a.localeCompare(b)), + ssr: [...ssr].sort((a, b) => a.localeCompare(b)), + }; +} + +function diffEntries(expected, declared) { + const expectedSet = new Set(expected); + const declaredSet = new Set(declared); + + return { + missing: expected.filter((entry) => !declaredSet.has(entry)), + extra: declared.filter((entry) => !expectedSet.has(entry)), + }; +} + +function formatEntries(owner, entries) { + return entries.map((entry) => ` '${owner} > ${entry}',`).join('\n'); +} + +const workspacePackages = getWorkspacePackages(); + +const results = TARGETS.map((target) => { + const packageJson = loadJson(path.join(target.packageDir, 'package.json')); + const directDependencies = new Set( + Object.keys(packageJson.dependencies ?? {}), + ); + + const expected = collectSpecifiers( + target.packageDir, + directDependencies, + workspacePackages, + ); + const declared = collectDeclaredEntries(target.configFile, target.owner); + const clientDiff = diffEntries(expected.client, declared.client); + const ssrDiff = diffEntries(expected.serverOnly, declared.ssr); + + return { + ...target, + expected, + declared, + clientDiff, + ssrDiff, + }; +}); + +let hasDiff = false; +const reportedOk = new Set(); + +function reportSection(label, owner, diff, suggested) { + let printed = false; + + if (diff.missing.length) { + console.log(` Missing ${label} entries:`); + for (const entry of diff.missing) { + console.log(` ${owner} > ${entry}`); + } + printed = true; + } + + if (diff.extra.length) { + console.log(` Extra ${label} entries:`); + for (const entry of diff.extra) { + console.log(` ${owner} > ${entry}`); + } + printed = true; + } + + if (printed && suggested.length) { + console.log(` Suggested ${label} block:`); + console.log(formatEntries(owner, suggested)); + } +} + +for (const result of results) { + const clientOk = + !result.clientDiff.missing.length && !result.clientDiff.extra.length; + const ssrOk = !result.ssrDiff.missing.length && !result.ssrDiff.extra.length; + + if (clientOk && ssrOk) { + if (!reportedOk.has(result.configFile)) { + console.log(`OK ${result.configFile}`); + reportedOk.add(result.configFile); + } + continue; + } + + hasDiff = true; + console.log(`CHECK ${result.configFile}`); + + if (!clientOk) { + reportSection( + 'optimizeDeps.include', + result.owner, + result.clientDiff, + result.expected.client, + ); + } + + if (!ssrOk) { + reportSection( + 'ssr.optimizeDeps.include', + result.owner, + result.ssrDiff, + result.expected.serverOnly, + ); + } +} + +if (hasDiff) { + process.exitCode = 1; +} diff --git a/packages/scripts/news/+vite-optimize-deps-audit-fixes.internal b/packages/scripts/news/+vite-optimize-deps-audit-fixes.internal new file mode 100644 index 000000000..4e59a7366 --- /dev/null +++ b/packages/scripts/news/+vite-optimize-deps-audit-fixes.internal @@ -0,0 +1 @@ +Fixed the `check-vite-optimize-deps` audit script to ignore entries declared only in `optimizeDeps.exclude`, to correctly skip type-only named re-exports, and to check `ssr.optimizeDeps.include` separately for dependencies used only in `*.server.*` files. @sneridagh diff --git a/packages/scripts/news/+vite-optimize-deps-audit.internal b/packages/scripts/news/+vite-optimize-deps-audit.internal new file mode 100644 index 000000000..b5513c5f8 --- /dev/null +++ b/packages/scripts/news/+vite-optimize-deps-audit.internal @@ -0,0 +1 @@ +Added a workspace dependency audit script for Aurora Vite `optimizeDeps` declarations. @sneridagh diff --git a/packages/scripts/package.json b/packages/scripts/package.json index eed12d7e3..b589d912f 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -50,6 +50,7 @@ "wait-on": "^8.0.0" }, "devDependencies": { - "release-it": "catalog:" + "release-it": "catalog:", + "typescript": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80189c5ed..0323d896c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1559,6 +1559,9 @@ importers: release-it: specifier: 'catalog:' version: 19.0.5(@types/node@24.10.1)(magicast@0.3.5) + typescript: + specifier: 'catalog:' + version: 5.9.2 packages/theming: dependencies: @@ -6417,6 +6420,7 @@ packages: html2canvas-pro@1.5.12: resolution: {integrity: sha512-egtJIe6YXMKSLX/ls400OJD6tzEVtATJOE++mnXmxMWyqcu9HDXDoLiWeXnGv45QW2ZaIiDlXw46Gxqrqw6SEw==} engines: {node: '>=16.0.0'} + deprecated: All 1.5.x series are deprecated. Known rendering bugs exist and no further maintenance. Please upgrade to the latest 2.x release. htmlparser2@5.0.1: resolution: {integrity: sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==} @@ -6933,7 +6937,6 @@ packages: resolution: {integrity: sha512-Quz3MvAwHxVYNXsOByL7xI5EB2WYOeFswqaHIA3qOK3isRWTxiplBEocmmru6XmxDB2L7jDNYtYA4FyimoAFEw==} engines: {node: '>=8.17.0'} hasBin: true - bundledDependencies: [] jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}