From 5277af9f7547e95cc4cebaec9cb05085a23e8647 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Sun, 6 Sep 2026 23:27:37 +0000 Subject: [PATCH 1/9] test(vite-plugin-angular): reproduce compiler roots and lifecycle regressions Add focused regressions for roots, compilation handoffs, HMR and emitted artifacts. Co-Authored-By: OpenAI Codex --- .../src/lib/analog-plugin-interop.spec.ts | 29 ++- .../compilation-api-plugin.spec.ts | 218 ++++++++++++++++-- .../src/lib/compiler-roots.spec.ts | 110 +++++++++ .../src/lib/oxc-dts-plugin.spec.ts | 78 +++++++ 4 files changed, 404 insertions(+), 31 deletions(-) create mode 100644 packages/vite-plugin-angular/src/lib/compiler-roots.spec.ts create mode 100644 packages/vite-plugin-angular/src/lib/oxc-dts-plugin.spec.ts diff --git a/packages/vite-plugin-angular/src/lib/analog-plugin-interop.spec.ts b/packages/vite-plugin-angular/src/lib/analog-plugin-interop.spec.ts index fa4de0e2b6..f3a9c2c2cc 100644 --- a/packages/vite-plugin-angular/src/lib/analog-plugin-interop.spec.ts +++ b/packages/vite-plugin-angular/src/lib/analog-plugin-interop.spec.ts @@ -136,13 +136,14 @@ describe('analog plugin interop', () => { }); it('names the failing plugin when a registry configurator throws', async () => { + const cause = new Error('boom'); const { configureStylesheetRegistry } = await runAnalogSetupHooks([ { name: 'vite-plugin-xyz', analog: { setup(ctx) { ctx.configureStylesheetRegistry(() => { - throw new Error('boom'); + throw cause; }); }, }, @@ -152,7 +153,11 @@ describe('analog plugin interop', () => { expect(() => configureStylesheetRegistry?.({} as any, { workspaceRoot: '/workspace' }), ).toThrow( - '[analog] Stylesheet registry configurator from plugin "vite-plugin-xyz" failed: boom', + expect.objectContaining({ + message: + '[analog] Stylesheet registry configurator from plugin "vite-plugin-xyz" failed: boom', + cause, + }), ); }); @@ -209,13 +214,14 @@ describe('analog plugin interop', () => { }); it('names the failing plugin and stylesheet when a preprocessor throws', async () => { + const cause = new Error('boom'); const { stylePreprocessor } = await runAnalogSetupHooks([ { name: 'vite-plugin-xyz', analog: { setup(ctx) { ctx.registerStylePreprocessor(() => { - throw new Error('boom'); + throw cause; }); }, }, @@ -225,25 +231,32 @@ describe('analog plugin interop', () => { expect(() => stylePreprocessor?.('.demo {}', 'app.component.scss', context), ).toThrow( - '[analog] Style preprocessor from plugin "vite-plugin-xyz" failed for "app.component.scss": boom', + expect.objectContaining({ + message: + '[analog] Style preprocessor from plugin "vite-plugin-xyz" failed for "app.component.scss": boom', + cause, + }), ); }); it('names the failing plugin when analog.setup throws', async () => { + const cause = new Error('bad config'); await expect( runAnalogSetupHooks([ { name: 'vite-plugin-xyz', analog: { setup() { - throw new Error('bad config'); + throw cause; }, }, } as AnalogIntegrationPlugin, ]), - ).rejects.toThrow( - '[analog] analog.setup() from plugin "vite-plugin-xyz" failed: bad config', - ); + ).rejects.toMatchObject({ + message: + '[analog] analog.setup() from plugin "vite-plugin-xyz" failed: bad config', + cause, + }); }); it('runs setup hooks once per resolved config', async () => { diff --git a/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.spec.ts b/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.spec.ts index 3e2a5ae9fc..123790f490 100644 --- a/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.spec.ts +++ b/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -113,7 +113,68 @@ describe('compilationAPIPlugin', () => { expect(plugin.closeBundle).toBeTypeOf('function'); }); - it('config hook disables esbuild/oxc', async () => { + it.each([ + { test: false, astro: false }, + { test: false, astro: true }, + { test: true, astro: false }, + { test: true, astro: true }, + ])( + 'separates test mode from transformer ownership (%j)', + async ({ test, astro }) => { + process.env['NODE_ENV'] = test ? 'test' : 'development'; + if (test) process.env['VITEST'] = 'true'; + const { compilationAPIPlugin } = + await import('./compilation-api-plugin.js'); + const plugin = compilationAPIPlugin({ + tsconfigGetter: () => join(tempRoot, 'tsconfig.json'), + workspaceRoot: tempRoot, + inlineStylesExtension: 'css', + jit: false, + liveReload: true, + disableTypeChecking: true, + supportedBrowsers: ['safari 15'], + fileReplacements: [], + isTest: test, + isAstroIntegration: astro, + include: [], + }); + + const result = await (plugin.config as any)( + { root: tempRoot, mode: 'development' }, + { command: 'serve', mode: 'development' }, + ); + + expect(result.esbuild).toBeUndefined(); + expect(result.oxc).toBeUndefined(); + const dependencyPlugin = result.optimizeDeps.rolldownOptions.plugins[0]; + expect(dependencyPlugin.load !== undefined).toBe(!test); + expect(typeof dependencyPlugin.buildEnd).toBe( + astro ? 'undefined' : 'function', + ); + }, + ); + + it('waits for buildStart compilation before transforming an emitted module', async () => { + const initializeMock = vi.fn().mockResolvedValue({ + externalStylesheets: new Map(), + templateUpdates: new Map(), + }); + const diagnoseFilesMock = vi + .fn() + .mockResolvedValue({ errors: [], warnings: [] }); + const emitted = + Promise.withResolvers<{ filename: string; contents: string }[]>(); + const emitAffectedFilesMock = vi + .fn() + .mockImplementation(() => emitted.promise); + + createAngularCompilationMock.mockResolvedValue({ + initialize: initializeMock, + update: vi.fn(), + diagnoseFiles: diagnoseFilesMock, + emitAffectedFiles: emitAffectedFilesMock, + }); + const { compilationAPIPlugin } = await import('./compilation-api-plugin.js'); const plugin = compilationAPIPlugin({ @@ -121,7 +182,7 @@ describe('compilationAPIPlugin', () => { workspaceRoot: tempRoot, inlineStylesExtension: 'css', jit: false, - liveReload: true, + liveReload: false, disableTypeChecking: true, supportedBrowsers: ['safari 15'], fileReplacements: [], @@ -130,32 +191,120 @@ describe('compilationAPIPlugin', () => { include: [], }); - const result = await (plugin.config as any)( + await (plugin.config as any)( { root: tempRoot, mode: 'development' }, { command: 'serve', mode: 'development' }, ); + await (plugin.configResolved as any)({ + cacheDir: join(tempRoot, '.vite'), + root: tempRoot, + mode: 'development', + build: {}, + server: {}, + plugins: [], + }); + const building = (plugin.buildStart as any).call({ + addWatchFile: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }); - expect(result.esbuild).toBeUndefined(); - expect(result.oxc).toBeUndefined(); + await vi.waitFor(() => + expect(emitAffectedFilesMock).toHaveBeenCalledOnce(), + ); + const filename = join(tempRoot, 'main.ts'); + const transformed = (plugin.transform as any).handler.call( + { warn: vi.fn(), error: vi.fn() }, + 'export const value = 1;', + filename, + ); + emitted.resolve([{ filename, contents: 'export const value = 1;' }]); + await building; + await expect(transformed).resolves.toMatchObject({ + code: 'export const value = 1;', + }); + + expect(createAngularCompilationMock).toHaveBeenCalledOnce(); + expect(initializeMock).toHaveBeenCalledOnce(); + expect(emitAffectedFilesMock).toHaveBeenCalledOnce(); }); - it('initializes compilation on buildStart', async () => { - const initializeMock = vi.fn().mockResolvedValue({ + it('includes integration-provided files outside the configured TypeScript roots', async () => { + const main = join(tempRoot, 'main.ts'); + const extra = join(tempRoot, 'integration.ts'); + writeFileSync(main, 'export const main = 1;'); + writeFileSync(extra, 'export const integration = 2;'); + writeFileSync( + join(tempRoot, 'tsconfig.json'), + JSON.stringify({ + files: ['main.ts'], + compilerOptions: { target: 'es2022' }, + }), + ); + const initialize = vi.fn(async (_tsconfig: string) => ({ externalStylesheets: new Map(), templateUpdates: new Map(), + })); + createAngularCompilationMock.mockResolvedValue({ + initialize, + diagnoseFiles: vi.fn().mockResolvedValue({ errors: [], warnings: [] }), + emitAffectedFiles: vi.fn().mockResolvedValue([]), }); - const diagnoseFilesMock = vi - .fn() - .mockResolvedValue({ errors: [], warnings: [] }); - const emitAffectedFilesMock = vi.fn().mockResolvedValue([]); + const { compilationAPIPlugin } = + await import('./compilation-api-plugin.js'); + const plugin = compilationAPIPlugin({ + tsconfigGetter: () => join(tempRoot, 'tsconfig.json'), + workspaceRoot: tempRoot, + inlineStylesExtension: 'css', + jit: false, + liveReload: false, + disableTypeChecking: true, + supportedBrowsers: ['safari 15'], + fileReplacements: [], + isTest: false, + isAstroIntegration: false, + include: [extra], + }); + await (plugin.config as any)( + { root: tempRoot, mode: 'development' }, + { command: 'serve', mode: 'development' }, + ); + await (plugin.configResolved as any)({ + cacheDir: join(tempRoot, '.vite'), + root: tempRoot, + mode: 'development', + build: {}, + server: {}, + plugins: [], + }); + await (plugin.buildStart as any).call({ + addWatchFile: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }); + const wrapper = JSON.parse( + readFileSync(initialize.mock.calls[0][0], 'utf8'), + ); + expect(wrapper.files).toEqual( + expect.arrayContaining([ + cachedViteActual.normalizePath(main), + cachedViteActual.normalizePath(extra), + ]), + ); + }); + it('consumes an iterable compilation result without requiring an array', async () => { + const filename = join(tempRoot, 'main.ts'); createAngularCompilationMock.mockResolvedValue({ - initialize: initializeMock, - update: vi.fn(), - diagnoseFiles: diagnoseFilesMock, - emitAffectedFiles: emitAffectedFilesMock, + initialize: vi.fn().mockResolvedValue({ + externalStylesheets: new Map(), + templateUpdates: new Map(), + }), + diagnoseFiles: vi.fn().mockResolvedValue({ errors: [], warnings: [] }), + emitAffectedFiles: vi.fn(function* () { + yield { filename, contents: 'export const fromCompiler = true;' }; + }), }); - const { compilationAPIPlugin } = await import('./compilation-api-plugin.js'); const plugin = compilationAPIPlugin({ @@ -171,7 +320,6 @@ describe('compilationAPIPlugin', () => { isAstroIntegration: false, include: [], }); - await (plugin.config as any)( { root: tempRoot, mode: 'development' }, { command: 'serve', mode: 'development' }, @@ -189,10 +337,12 @@ describe('compilationAPIPlugin', () => { error: vi.fn(), warn: vi.fn(), }); - - expect(createAngularCompilationMock).toHaveBeenCalledOnce(); - expect(initializeMock).toHaveBeenCalledOnce(); - expect(emitAffectedFilesMock).toHaveBeenCalledOnce(); + const result = await (plugin.transform as any).handler.call( + { warn: vi.fn(), error: vi.fn() }, + 'export const raw = true;', + filename, + ); + expect(result.code).toBe('export const fromCompiler = true;'); }); it('hands the stylesheet registry to analog.setup configurators', async () => { @@ -327,7 +477,7 @@ describe('compilationAPIPlugin', () => { externalStylesheets: new Map(), templateUpdates: new Map([ [ - encodeURIComponent(`src/app.component.ts@AppComponent`), + encodeURIComponent(`${testFile}@AppComponent`), '/* hmr update code */', ], ]), @@ -394,6 +544,28 @@ describe('compilationAPIPlugin', () => { expect(result).toBeDefined(); expect(result.code).toBe('compiled output'); + + const clientModule = { isSelfAccepting: false }; + const mixedModule = { + id: testFile, + get isSelfAccepting() { + return clientModule.isSelfAccepting; + }, + }; + const send = vi.fn(); + const modules = await (plugin.handleHotUpdate as any)({ + file: testFile, + modules: [mixedModule], + server: { + ws: { send }, + environments: { + client: { moduleGraph: { getModuleById: () => clientModule } }, + }, + }, + }); + expect(modules).toEqual([mixedModule]); + expect(clientModule.isSelfAccepting).toBe(true); + expect(send).toHaveBeenCalled(); }); it('serves emitted output for TypeScript files without Angular decorators', async () => { diff --git a/packages/vite-plugin-angular/src/lib/compiler-roots.spec.ts b/packages/vite-plugin-angular/src/lib/compiler-roots.spec.ts new file mode 100644 index 0000000000..d08da89551 --- /dev/null +++ b/packages/vite-plugin-angular/src/lib/compiler-roots.spec.ts @@ -0,0 +1,110 @@ +import { + mkdtemp, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { build } from 'vite'; +import angular from '../index'; + +const roots: string[] = []; + +beforeEach(() => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('VITEST', undefined); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +async function createFixture() { + const root = await mkdtemp(join(tmpdir(), 'analog-compiler-roots-')); + roots.push(root); + await mkdir(join(root, 'src')); + await symlink( + fileURLToPath(new URL('../../../../node_modules', import.meta.url)), + join(root, 'node_modules'), + 'junction', + ); + await writeFile(join(root, 'src/main.ts'), 'export const main = true;'); + await writeFile( + join(root, 'src/original.ts'), + 'export const replaced = true;', + ); + await writeFile( + join(root, 'src/replacement.ts'), + ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-extra', standalone: true, template: '

additional root

' }) + export class AdditionalComponent {} + `, + ); + await writeFile( + join(root, 'tsconfig.json'), + JSON.stringify({ + files: ['src/main.ts'], + compilerOptions: { + target: 'es2022', + module: 'esnext', + moduleResolution: 'bundler', + experimentalDecorators: true, + skipLibCheck: true, + types: [], + }, + }), + ); + return root; +} + +describe('compiler roots in a Vite library build', () => { + it.each([false, true])( + 'AOT-compiles an additional library root (Compilation API: %s)', + async (useCompilationAPI) => { + const root = await createFixture(); + await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + angular({ + workspaceRoot: root, + tsconfig: join(root, 'tsconfig.json'), + jit: false, + liveReload: false, + fileReplacements: [ + { replace: 'src/original.ts', with: 'src/replacement.ts' }, + ], + include: useCompilationAPI + ? [join(root, 'src/replacement.ts')] + : [], + experimental: { useAngularCompilationAPI: useCompilationAPI }, + }), + ], + build: { + minify: false, + lib: { + entry: join(root, 'src/original.ts'), + formats: ['es'], + fileName: 'component', + }, + rollupOptions: { external: ['@angular/core'] }, + }, + }); + const output = await readFile(join(root, 'dist/component.mjs'), 'utf8'); + expect(output).toContain('defineComponent'); + expect(output).toContain('additional root'); + expect(output).not.toContain('__decorate'); + }, + 30000, + ); +}); diff --git a/packages/vite-plugin-angular/src/lib/oxc-dts-plugin.spec.ts b/packages/vite-plugin-angular/src/lib/oxc-dts-plugin.spec.ts new file mode 100644 index 0000000000..4f43906c73 --- /dev/null +++ b/packages/vite-plugin-angular/src/lib/oxc-dts-plugin.spec.ts @@ -0,0 +1,78 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { build, normalizePath, type InlineConfig } from 'vite'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { oxcDtsPlugin } from '../../../../tools/build/shared-plugins'; + +const directories: string[] = []; + +afterEach(async () => { + for (const directory of directories.splice(0)) { + await rm(directory, { recursive: true, force: true }); + } +}); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'analog-dts-rebuild-')); + directories.push(root); + await mkdir(join(root, 'src')); + await writeFile( + join(root, 'src/index.ts'), + "export type { Options } from './options'; export const value: number = 1;", + ); + await writeFile( + join(root, 'src/options.ts'), + 'export interface Options { first?: boolean }', + ); + const config: InlineConfig = { + root, + configFile: false, + logLevel: 'silent', + plugins: [oxcDtsPlugin(root)], + build: { + outDir: join(root, 'dist'), + emptyOutDir: false, + lib: { + entry: { 'src/index': join(root, 'src/index.ts') }, + formats: ['es'], + }, + rollupOptions: { + output: { + preserveModules: true, + preserveModulesRoot: normalizePath(root), + entryFileNames: '[name].js', + }, + }, + }, + }; + return { root, config }; +} + +describe('OXC declaration rebuilds', () => { + it('refreshes type-only declarations while retaining nested build output', async () => { + const { root, config } = await fixture(); + await build(config); + const declaration = join(root, 'dist/src/options.d.ts'); + expect(await readFile(declaration, 'utf8')).toContain('first?: boolean'); + await mkdir(join(root, 'dist/nested-plugin')); + await writeFile( + join(root, 'dist/nested-plugin/keep.js'), + 'export const keep = true;', + ); + await writeFile( + join(root, 'src/options.ts'), + 'export interface Options { second?: string }', + ); + + await build(config); + + const updated = await readFile(declaration, 'utf8'); + expect(updated).toContain('second?: string'); + expect(updated).not.toContain('first'); + expect( + await readFile(join(root, 'dist/nested-plugin/keep.js'), 'utf8'), + ).toBe('export const keep = true;'); + }); +}); From 47ca5f2ac44a54c16f98f810d395357e5b6832e5 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 00:11:56 +0000 Subject: [PATCH 2/9] fix(vite-plugin-angular): preserve compiler roots and lifecycle across Vite versions Restore compiler handoffs and qualify installed packages across Vite 6-8. Fixes analogjs/analog#2518. Co-Authored-By: OpenAI Codex --- .github/workflows/compiler-compat.yml | 62 +++- .github/workflows/conformance.yml | 8 +- .../src/content/guides/angular-compilation.md | 63 +++- .../update-3-0-0/migrate-setup-vitest.ts | 5 +- packages/vite-plugin-angular/package.json | 8 + packages/vite-plugin-angular/project.json | 15 +- .../scripts/compiler-vite-fixture.mjs | 328 ++++++++++++++++++ .../scripts/compiler-vite-smoke.mjs | 154 ++++++++ .../src/lib/analog-plugin-interop.ts | 3 + .../lib/angular-vite-plugin-transform.spec.ts | 10 +- .../src/lib/angular-vite-plugin.spec.ts | 3 + .../src/lib/angular-vite-plugin.ts | 74 ++-- .../compilation-api-plugin.spec.ts | 40 ++- .../compilation-api/compilation-api-plugin.ts | 119 +++++-- .../src/lib/compiler/compile.ts | 2 +- .../src/lib/compiler/constants.ts | 10 +- .../src/lib/compiler/debug.ts | 10 +- .../src/lib/compiler/defer.ts | 2 +- .../src/lib/compiler/js-emitter.ts | 2 +- .../src/lib/compiler/metadata.ts | 22 +- .../src/lib/compiler/test-helpers.ts | 2 +- .../src/lib/compiler/utils.ts | 2 +- .../src/lib/encapsulation-plugin.ts | 1 + packages/vite-plugin-angular/src/lib/host.ts | 2 +- .../src/lib/router-plugin.spec.ts | 50 +++ .../src/lib/router-plugin.ts | 7 +- .../src/lib/utils/compilation-shared.ts | 4 +- .../src/lib/utils/debug-log-file.ts | 9 +- .../src/lib/utils/debug.ts | 26 +- .../src/lib/utils/devkit.ts | 8 +- .../src/lib/utils/plugin-config.ts | 13 +- .../src/lib/utils/tsconfig-resolver.ts | 4 +- pnpm-lock.yaml | 276 ++++++++------- pnpm-workspace.yaml | 1 + tools/build/shared-plugins.ts | 11 +- 35 files changed, 1082 insertions(+), 274 deletions(-) create mode 100644 packages/vite-plugin-angular/scripts/compiler-vite-fixture.mjs create mode 100644 packages/vite-plugin-angular/scripts/compiler-vite-smoke.mjs create mode 100644 packages/vite-plugin-angular/src/lib/router-plugin.spec.ts diff --git a/.github/workflows/compiler-compat.yml b/.github/workflows/compiler-compat.yml index fe99d663ac..87e0c23bea 100644 --- a/.github/workflows/compiler-compat.yml +++ b/.github/workflows/compiler-compat.yml @@ -37,14 +37,20 @@ on: workflow_dispatch: pull_request: paths: - - 'packages/vite-plugin-angular/src/lib/compiler/**' + - 'packages/vite-plugin-angular/**' + - 'tools/build/**' - 'package.json' + - 'pnpm-workspace.yaml' + - 'pnpm-lock.yaml' - '.github/workflows/compiler-compat.yml' push: branches: [beta] paths: - - 'packages/vite-plugin-angular/src/lib/compiler/**' + - 'packages/vite-plugin-angular/**' + - 'tools/build/**' - 'package.json' + - 'pnpm-workspace.yaml' + - 'pnpm-lock.yaml' - '.github/workflows/compiler-compat.yml' env: @@ -70,7 +76,7 @@ jobs: # signal-input array shape and aren't worth supporting). v19/v20 # use the floor of the major as a deterministic CI signal. angular-version: - ['17.3.12', '18.2.14', '19.0.0', '20.0.0', '21.0.0', 'next'] + ['17.3.12', '18.2.14', '19.0.0', '20.0.0', '21.0.0', '22.0.0', 'next'] name: Angular ${{ matrix.angular-version }} @@ -96,6 +102,10 @@ jobs: run: | node -e " const fs = require('fs'); + // Alternate compiler versions qualify compiler APIs, not the entire workspace. + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')); + delete pkg.scripts.prepare; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); const f = 'pnpm-workspace.yaml'; const v = '${{ matrix.angular-version }}'; const pinned = ['@angular/compiler', '@angular/compiler-cli']; @@ -217,3 +227,49 @@ jobs: --label "bug,vite-plugin-angular" \ --body "$BODY" fi + + consumer-compat: + name: Angular ${{ matrix.angular }} / Vite ${{ matrix.vite }} consumer + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { angular: '17.3.12', vite: '6.0.0' } + - { angular: '18.2.14', vite: '6.0.0' } + - { angular: '19.0.0', vite: '6.0.0' } + - { angular: '20.0.0', vite: '6.0.0' } + - { angular: '20.1.0', vite: '6.0.0' } + - { angular: '21.0.0', vite: '7.0.0' } + - { angular: '22.0.0', vite: '6.0.0' } + - { angular: '22.0.0', vite: '7.0.0' } + - { angular: '22.0.0', vite: '8.0.8' } + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '24.15.0' + - run: npm install --global corepack@0.31.0 + - run: corepack enable + - name: Install workspace without unrelated package prepare builds + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Test the installed package in an isolated Angular/Vite consumer + env: + NX_DAEMON: 'false' + NX_NO_CLOUD: 'true' + run: pnpm exec nx run vite-plugin-angular:compat-smoke --vite=${{ matrix.vite }} --angular=${{ matrix.angular }} + - name: Check compiler package tests, declarations, and lint + if: matrix.angular == '22.0.0' && matrix.vite == '8.0.8' + env: + NX_DAEMON: 'false' + NX_NO_CLOUD: 'true' + run: pnpm exec nx run-many --targets=test,typecheck,eslint:lint --projects=vite-plugin-angular --parallel=1 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: compiler-angular-${{ matrix.angular }}-vite-${{ matrix.vite }} + path: | + dist/compiler-vite-smoke/**/result.json + dist/compiler-vite-smoke/**/*-failure.json + dist/compiler-vite-smoke/*/pnpm-lock.yaml + if-no-files-found: warn diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index f4807fc028..0aaae54d64 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -4,14 +4,18 @@ on: workflow_dispatch: pull_request: paths: - - 'packages/vite-plugin-angular/src/lib/compiler/**' + - 'packages/vite-plugin-angular/**' - 'package.json' + - 'pnpm-workspace.yaml' + - 'pnpm-lock.yaml' - '.github/workflows/conformance.yml' push: branches: [beta] paths: - - 'packages/vite-plugin-angular/src/lib/compiler/**' + - 'packages/vite-plugin-angular/**' - 'package.json' + - 'pnpm-workspace.yaml' + - 'pnpm-lock.yaml' - '.github/workflows/conformance.yml' env: diff --git a/apps/docs-analog/src/content/guides/angular-compilation.md b/apps/docs-analog/src/content/guides/angular-compilation.md index 62f5890847..a206b80a9a 100644 --- a/apps/docs-analog/src/content/guides/angular-compilation.md +++ b/apps/docs-analog/src/content/guides/angular-compilation.md @@ -1,9 +1,10 @@ # Angular Compilation -The AnalogJS Vite Plugin (`@analogjs/vite-plugin-angular`) compiles your Angular components, directives, and pipes to Ivy definitions during the Vite build. It provides two compilation paths: +The AnalogJS Vite Plugin (`@analogjs/vite-plugin-angular`) compiles your Angular components, directives, and pipes to Ivy definitions during the Vite build. It provides these compilation paths: -- **Default compilation**: Angular's own compiler (`ngtsc`), with full compile-time type checking. +- **Default compilation**: Angular's own compiler (`NgtscProgram`), with optional compile-time type checking. - **Fast compile**: an in-tree single-pass compiler that produces the same Ivy output much faster. +- **Experimental Compilation API**: Angular's build compilation API, enabled explicitly with a compatible `@angular/build` installation. Both emit identical Ivy code and identical runtime behavior, because both ultimately call the same `@angular/compiler` lowering APIs. @@ -25,7 +26,31 @@ export default defineConfig({ }); ``` -The default path runs Angular's compiler through the Angular Compilation API. It performs **full TypeScript and template type checking** (including `strictTemplates`), emits declaration files, and supports i18n message extraction. Use it when you want compile-time safety: wrong template bindings, missing inputs, and type mismatches fail the build. +The default path uses `NgtscProgram`; `experimental.useAngularCompilationAPI` defaults to `false`. Set `disableTypeChecking: false` and enable `strictTemplates` in your Angular compiler options when TypeScript and template errors must fail the build. Type checking is disabled by default for build throughput. + +```ts +angular({ disableTypeChecking: false }); +``` + +## Experimental Compilation API + +With an `@angular/build` version that exposes `createAngularCompilation` through `@angular/build/private`, opt into Angular's build compilation API: + +```ts +angular({ + experimental: { useAngularCompilationAPI: true }, + disableTypeChecking: false, + include: ['src/integrations/**/*.ts'], +}); +``` + +Configured TypeScript roots, integration-provided includes, and expanded project roots are combined before compilation. The normal compiler also includes file-replacement targets, so replacement components outside the original tsconfig are AOT-compiled instead of falling through to a generic TypeScript transform. + +The Angular version alone does not establish availability: for example, `@angular/build@20.1.0` does not expose this factory. Unsupported installations fail during configuration with an actionable diagnostic. Leave the option disabled to use normal compilation on those versions. + +For Vite library builds, the experimental API emits JavaScript only. Generate `.d.ts` files in a separate declaration step. Angular's compilation API returns one result per source file, so enabling declaration emit there would overwrite the JavaScript result with declaration text. The repository's package builds use their own declaration-emission step. + +Both paths wait for active compilation before transforming emitted modules. Experimental compilation shares its emitted modules with the HMR middleware; template-only changes invalidate the affected component and deliver Angular's replacement metadata. Compiler shutdown waits for active work before disposing its worker. Plugin setup and stylesheet errors preserve the original exception as `cause` together with the plugin/file context. ## Fast compile @@ -50,13 +75,15 @@ export default defineConfig({ ``` :::note -In an Analog application, configure the plugin through `@analogjs/platform` instead. Its `analog()` plugin forwards the same `fastCompile` option: +In an Analog v3 application, configure compilation on `angular()` alongside the platform plugin: ```ts import analog from '@analogjs/platform'; +import angular from '@analogjs/vite-plugin-angular'; +import { nitro } from 'nitro/vite'; export default defineConfig({ - plugins: [analog({ fastCompile: true })], + plugins: [analog(), angular({ fastCompile: true }), nitro()], }); ``` @@ -157,15 +184,27 @@ Fast compile is validated against Angular `17`, `18`, `19`, `20`, `21`, `22`, an For overall Angular/Analog/Vite version support, see [Version Compatibility](/docs/guides/compatibility). +### Compiler contributor checks + +The compiler compatibility workflow separates compiler API tests from installed-package consumer builds. The consumer matrix uses Angular 17.3, 18.2, 19.0, 20.0, and 20.1 with Vite 6; Angular 21 with Vite 7; and Angular 22 with Vite 6, 7, and 8. It is a representative matrix, not every possible cross-product. The experimental path is checked when the installed build package exposes it; other tuples verify the explicit rejection as well as normal and fast compilation. + +Run one tuple from the repository root: + +```bash +pnpm exec nx run vite-plugin-angular:compat-smoke --vite=8.0.8 --angular=22.0.0 +``` + +The target builds and packs the package, installs it into an isolated consumer, and checks normal compilation, additional roots/replacements, and fast full/partial compilation. It also checks experimental Compilation API output on supported Angular versions. Angular 22 tuples additionally use Chromium to verify template HMR preserves component state in both compiler modes, and that boot/manual reload works with Angular HMR disabled. Each run retains its exact installed versions, lockfile, and result under `dist/compiler-vite-smoke/`. + ### Default vs fast compile -| | Default | Fast compile | -| ----------------------------------- | ----------------------- | ------------------------------------- | -| Ivy output | Full | Identical | -| Cold build / hot rebuild | Standard | Significantly faster | -| Compile-time template type checking | Yes (`strictTemplates`) | No (use the Angular Language Service) | -| i18n message extraction | Yes | `$localize` emitted | -| Library / partial builds | Yes | Yes (`fastCompileMode: 'partial'`) | +| | Default | Fast compile | +| ----------------------------------- | -------------------------------------------------------------- | ------------------------------------- | +| Ivy output | Full | Identical | +| Cold build / hot rebuild | Standard | Significantly faster | +| Compile-time template type checking | Opt in with `disableTypeChecking: false` and `strictTemplates` | No (use the Angular Language Service) | +| i18n message extraction | Yes | `$localize` emitted | +| Library / partial builds | Yes | Yes (`fastCompileMode: 'partial'`) | ## Trade-offs diff --git a/packages/vite-plugin-angular/migrations/update-3-0-0/migrate-setup-vitest.ts b/packages/vite-plugin-angular/migrations/update-3-0-0/migrate-setup-vitest.ts index bb935e44e7..fc1bc93224 100644 --- a/packages/vite-plugin-angular/migrations/update-3-0-0/migrate-setup-vitest.ts +++ b/packages/vite-plugin-angular/migrations/update-3-0-0/migrate-setup-vitest.ts @@ -3,6 +3,7 @@ import { readJson, runTasksInSerial, Tree, + type GeneratorCallback, visitNotIgnoredFiles, } from '@nx/devkit'; @@ -36,7 +37,9 @@ function getAnalogVersion(tree: Tree): string { ); } -export default async function migrateSetupVitest(tree: Tree) { +export default async function migrateSetupVitest( + tree: Tree, +): Promise { const filesToUpdate: string[] = []; visitNotIgnoredFiles(tree, '', (filePath) => { diff --git a/packages/vite-plugin-angular/package.json b/packages/vite-plugin-angular/package.json index 3b4c4ec438..f8c43a9843 100644 --- a/packages/vite-plugin-angular/package.json +++ b/packages/vite-plugin-angular/package.json @@ -60,6 +60,14 @@ "rolldown": "catalog:", "tinyglobby": "catalog:" }, + "devDependencies": { + "esbuild": "catalog:", + "jiti": "catalog:", + "less": "catalog:", + "sass": "catalog:", + "sass-embedded": "catalog:", + "vite": "catalog:" + }, "builders": "./dist/src/lib/tools/builders.json", "nx-migrations": { "packageGroup": [ diff --git a/packages/vite-plugin-angular/project.json b/packages/vite-plugin-angular/project.json index e1624690c1..da7345b3b9 100644 --- a/packages/vite-plugin-angular/project.json +++ b/packages/vite-plugin-angular/project.json @@ -23,7 +23,20 @@ } }, "test": { - "executor": "@nx/vitest:test" + "executor": "@nx/vitest:test", + "inputs": [ + "default", + "^production", + "{workspaceRoot}/tools/build/**/*.ts" + ] + }, + "compat-smoke": { + "executor": "nx:run-commands", + "cache": false, + "dependsOn": ["build"], + "options": { + "command": "node packages/vite-plugin-angular/scripts/compiler-vite-smoke.mjs" + } }, "version": { "executor": "@jscutlery/semver:version", diff --git a/packages/vite-plugin-angular/scripts/compiler-vite-fixture.mjs b/packages/vite-plugin-angular/scripts/compiler-vite-fixture.mjs new file mode 100644 index 0000000000..d86ee60081 --- /dev/null +++ b/packages/vite-plugin-angular/scripts/compiler-vite-fixture.mjs @@ -0,0 +1,328 @@ +import assert from 'node:assert/strict'; +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; +import { build, createServer, version as viteVersion } from 'vite'; +import angular from '@analogjs/vite-plugin-angular'; + +const require = createRequire(import.meta.url); +const angularVersion = require('@angular/core/package.json').version; +const [major] = angularVersion.split('.').map(Number); +const root = process.cwd(); +const results = []; +await mkdir(join(root, 'src')); +await writeFile( + join(root, 'src/configured.ts'), + 'export const configured = true;', +); +await writeFile(join(root, 'src/original.ts'), 'export const original = true;'); +await writeFile( + join(root, 'src/component.ts'), + ` + import { Component } from '@angular/core'; + @Component({selector: 'app-example', standalone: true, template: '

compiler compatibility

'}) + export class ExampleComponent {} +`, +); +await writeFile( + join(root, 'tsconfig.json'), + JSON.stringify({ + files: ['src/configured.ts'], + compilerOptions: { + target: 'es2022', + module: 'esnext', + moduleResolution: 'bundler', + experimentalDecorators: true, + skipLibCheck: true, + types: [], + }, + }), +); + +async function buildCase(name, options, expected) { + const outDir = join(root, 'output', name); + await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + angular({ + workspaceRoot: root, + tsconfig: join(root, 'tsconfig.json'), + jit: false, + liveReload: false, + ...options, + }), + ], + build: { + outDir, + minify: false, + lib: { + entry: join(root, 'src/original.ts'), + formats: ['es'], + fileName: 'component', + }, + rollupOptions: { external: ['@angular/core'] }, + }, + }); + const output = (await readdir(outDir)).find( + (name) => name.endsWith('.js') || name.endsWith('.mjs'), + ); + assert.ok(output, `${name}: JavaScript emitted`); + const code = await readFile(join(outDir, output), 'utf8'); + assert.ok( + code.includes(expected), + `${name}: expected ${expected} in compiler output`, + ); + assert.ok( + code.includes('compiler compatibility'), + `${name}: template retained`, + ); + if (expected === 'defineComponent') + assert.ok( + !code.includes('__decorate'), + `${name}: AOT compilation did not fall through`, + ); + results.push({ name, passed: true }); +} + +await buildCase( + 'normal-replacement', + { + fileReplacements: [ + { replace: 'src/original.ts', with: 'src/component.ts' }, + ], + experimental: { useAngularCompilationAPI: false }, + }, + 'defineComponent', +); + +// Integration includes exercise files that are outside the original tsconfig. +await writeFile( + join(root, 'src/original.ts'), + await readFile(join(root, 'src/component.ts'), 'utf8'), +); +await buildCase( + 'normal-include', + { + include: [join(root, 'src/original.ts')], + experimental: { useAngularCompilationAPI: false }, + }, + 'defineComponent', +); +await buildCase( + 'fast-full', + { fastCompile: true, fastCompileMode: 'full' }, + 'defineComponent', +); +await buildCase( + 'fast-partial', + { fastCompile: true, fastCompileMode: 'partial' }, + 'ngDeclareComponent', +); + +const compilationApiAvailable = + major >= 18 && + typeof require('@angular/build/private').createAngularCompilation === + 'function'; +if (compilationApiAvailable) { + await buildCase( + 'compilation-api-include', + { + include: [join(root, 'src/original.ts')], + experimental: { useAngularCompilationAPI: true }, + }, + 'defineComponent', + ); +} else { + await assert.rejects( + () => + buildCase( + 'compilation-api-unavailable', + { experimental: { useAngularCompilationAPI: true } }, + 'defineComponent', + ), + /requires @angular\/build\/private to export createAngularCompilation/, + ); + results.push({ + name: 'compilation-api-include', + supported: false, + unavailableOptionRejected: true, + reason: 'Installed @angular/build does not expose createAngularCompilation', + }); +} + +async function browserHmrCase(browser, compilationApi, liveReload) { + const name = `browser-api-${compilationApi}-live-reload-${liveReload}`; + await writeFile( + join(root, 'src/view.html'), + '

before

', + ); + process.env.NODE_ENV = 'development'; + const compiledModules = []; + const plugins = angular({ + workspaceRoot: root, + tsconfig: join(root, 'tsconfig.browser.json'), + jit: false, + liveReload, + experimental: { useAngularCompilationAPI: compilationApi }, + }); + const compiler = plugins.find( + (plugin) => + plugin.name === + (compilationApi + ? '@analogjs/vite-plugin-angular-compilation-api' + : '@analogjs/vite-plugin-angular'), + ); + const transform = compiler.transform; + const transformHandler = + typeof transform === 'function' ? transform : transform.handler; + compiler.transform = { + ...(typeof transform === 'object' ? transform : {}), + async handler(...args) { + const result = await transformHandler.apply(this, args); + if (String(args[1]).includes('app.component.ts')) + compiledModules.push({ id: args[1], result }); + return result; + }, + }; + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins, + server: { host: '127.0.0.1', port: 0, strictPort: true }, + }); + const page = await browser.newPage(); + const errors = []; + const consoleMessages = []; + const socketMessages = []; + const fileEvents = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => consoleMessages.push(message.text())); + page.on('websocket', (socket) => + socket.on('framereceived', (frame) => + socketMessages.push(String(frame.payload)), + ), + ); + server.watcher.on('all', (event, path) => fileEvents.push({ event, path })); + try { + await server.listen(); + const address = server.httpServer.address(); + assert.ok(address && typeof address !== 'string'); + await page.goto(`http://127.0.0.1:${address.port}`); + await page.waitForFunction( + () => + document.querySelector('[data-testid="message"]')?.textContent === + 'before', + ); + await page.locator('[data-testid="count"]').click(); + await page.waitForFunction( + () => + document.querySelector('[data-testid="count"]')?.textContent === '1', + ); + if (!liveReload) { + await page.reload(); + await page.waitForFunction( + () => + document.querySelector('[data-testid="message"]')?.textContent === + 'before', + ); + assert.equal( + await page.locator('[data-testid="count"]').textContent(), + '0', + ); + assert.deepEqual(errors, []); + results.push({ + name, + passed: true, + behavior: 'boot and manual reload with Angular HMR disabled', + }); + return; + } + await writeFile( + join(root, 'src/view.html'), + '

after

', + ); + await page.waitForFunction( + () => + document.querySelector('[data-testid="message"]')?.textContent === + 'after', + null, + { timeout: 15000 }, + ); + assert.equal( + await page.locator('[data-testid="count"]').textContent(), + liveReload ? '1' : '0', + `${name}: expected HMR state preservation or full reload`, + ); + assert.deepEqual(errors, [], `${name}: no browser runtime errors`); + results.push({ name, passed: true }); + } catch (error) { + await writeFile( + join(root, `${name}-failure.json`), + JSON.stringify( + { + name, + errors, + consoleMessages, + socketMessages, + fileEvents, + compiledModules, + html: await page.content(), + component: (await server.transformRequest('/src/app.component.ts')) + ?.code, + }, + null, + 2, + ), + ); + throw error; + } finally { + await page.close(); + await server.close(); + process.env.NODE_ENV = 'production'; + } +} + +if (major === 22) { + await writeFile( + join(root, 'index.html'), + '', + ); + await writeFile( + join(root, 'src/app.component.ts'), + `import { Component } from '@angular/core'; @Component({ selector: 'app-root', standalone: true, templateUrl: './view.html' }) export class AppComponent { count = 0; }`, + ); + await writeFile( + join(root, 'src/browser.ts'), + `import 'zone.js'; import { provideZoneChangeDetection } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; import { AppComponent } from './app.component'; bootstrapApplication(AppComponent, { providers: [provideZoneChangeDetection()] });`, + ); + await writeFile( + join(root, 'tsconfig.browser.json'), + JSON.stringify({ extends: './tsconfig.json', files: ['src/browser.ts'] }), + ); + const { chromium } = await import('playwright'); + const browser = await chromium.launch(); + try { + await browserHmrCase(browser, false, true); + await browserHmrCase(browser, true, true); + await browserHmrCase(browser, false, false); + await browserHmrCase(browser, true, false); + } finally { + await browser.close(); + } +} + +const report = { + angular: angularVersion, + vite: viteVersion, + node: process.version, + typescript: require('typescript/package.json').version, + results, +}; +await writeFile( + join(root, 'result.json'), + JSON.stringify(report, null, 2) + '\n', +); +console.log(JSON.stringify(report, null, 2)); diff --git a/packages/vite-plugin-angular/scripts/compiler-vite-smoke.mjs b/packages/vite-plugin-angular/scripts/compiler-vite-smoke.mjs new file mode 100644 index 0000000000..f2bc844de0 --- /dev/null +++ b/packages/vite-plugin-angular/scripts/compiler-vite-smoke.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +// Build the package first: pnpm nx run vite-plugin-angular:build +// Usage: node packages/vite-plugin-angular/scripts/compiler-vite-smoke.mjs --vite=8.0.8 --angular=22.0.0 +import { execaSync } from 'execa'; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +const { values } = parseArgs({ + options: { vite: { type: 'string' }, angular: { type: 'string' } }, +}); +const supportedVite = ['6.0.0', '7.0.0', '8.0.8']; +const angularTuples = { + '17.3.12': { + typescript: '5.4.5', + builder: '17.3.17', + package: '@angular-devkit/build-angular', + }, + '18.2.14': { + typescript: '5.5.4', + builder: '18.2.14', + package: '@angular/build', + }, + '19.0.0': { + typescript: '5.6.3', + builder: '19.0.0', + package: '@angular/build', + }, + '20.0.0': { + typescript: '5.8.3', + builder: '20.0.0', + package: '@angular/build', + }, + '20.1.0': { + typescript: '5.8.3', + builder: '20.1.0', + package: '@angular/build', + }, + '21.0.0': { + typescript: '5.9.3', + builder: '21.0.0', + package: '@angular/build', + }, + '22.0.0': { + typescript: '6.0.2', + builder: '22.0.0', + package: '@angular/build', + }, +}; +const tuple = Object.hasOwn(angularTuples, values.angular ?? '') + ? angularTuples[values.angular] + : undefined; +if (!tuple || !supportedVite.includes(values.vite)) { + throw new Error( + `Select --vite=${supportedVite.join('|')} and --angular=${Object.keys(angularTuples).join('|')}`, + ); +} + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const workspace = resolve(scriptDirectory, '../../..'); +const outputRoot = join(workspace, 'dist/compiler-vite-smoke'); +mkdirSync(outputRoot, { recursive: true }); +const root = mkdtempSync( + join(outputRoot, `angular-${values.angular}-vite-${values.vite}-`), +); +const packed = JSON.parse( + execaSync( + 'npm', + ['pack', '--json', '--ignore-scripts', '--pack-destination', root], + { + cwd: join(workspace, 'packages/vite-plugin-angular/dist'), + }, + ).stdout, +); +const packageName = JSON.parse( + readFileSync( + join(workspace, 'packages/vite-plugin-angular/package.json'), + 'utf8', + ), +).name; +const dependencies = { + [packageName]: `file:./${packed[0].filename}`, + '@angular/core': values.angular, + '@angular/common': values.angular, + '@angular/platform-browser': values.angular, + '@angular/compiler': values.angular, + '@angular/compiler-cli': values.angular, + [tuple.package]: tuple.builder, + typescript: tuple.typescript, + vite: values.vite, + rxjs: '7.8.2', + tslib: '2.8.1', + ...(values.angular === '22.0.0' + ? { playwright: '1.59.1', 'zone.js': '0.16.1' } + : {}), +}; +writeFileSync( + join(root, 'package.json'), + JSON.stringify( + { + name: 'analog-compiler-compat-fixture', + private: true, + type: 'module', + dependencies, + }, + null, + 2, + ), +); +copyFileSync( + join(scriptDirectory, 'compiler-vite-fixture.mjs'), + join(root, 'fixture.mjs'), +); +const env = { ...process.env, NODE_ENV: 'production' }; +env.PLAYWRIGHT_SKIP_BROWSER_GC = '1'; +delete env.VITEST; +execaSync( + 'pnpm', + [ + 'install', + '--ignore-workspace', + '--ignore-scripts', + '--no-frozen-lockfile', + '--prefer-offline', + ], + { cwd: root, env, stdio: 'inherit' }, +); +if (values.angular === '22.0.0') { + execaSync( + 'pnpm', + [ + 'exec', + 'playwright', + 'install', + 'chromium', + ...(process.env.CI ? ['--with-deps'] : []), + ], + { cwd: root, env, stdio: 'inherit' }, + ); +} +execaSync(process.execPath, ['fixture.mjs'], { + cwd: root, + env, + stdio: 'inherit', +}); +console.log(`Installed-package evidence retained at ${root}`); diff --git a/packages/vite-plugin-angular/src/lib/analog-plugin-interop.ts b/packages/vite-plugin-angular/src/lib/analog-plugin-interop.ts index 1a286d4fc3..aaa18ac687 100644 --- a/packages/vite-plugin-angular/src/lib/analog-plugin-interop.ts +++ b/packages/vite-plugin-angular/src/lib/analog-plugin-interop.ts @@ -149,6 +149,7 @@ export async function runAnalogSetupHooks( } catch (error) { throw new Error( `[analog] analog.setup() from plugin "${plugin.name}" failed: ${describeError(error)}`, + { cause: error }, ); } @@ -191,6 +192,7 @@ function wrapStylePreprocessor( } catch (error) { throw new Error( `[analog] Style preprocessor from plugin "${pluginName}" failed for "${filename}": ${describeError(error)}`, + { cause: error }, ); } }; @@ -206,6 +208,7 @@ function wrapRegistryConfigurator( } catch (error) { throw new Error( `[analog] Stylesheet registry configurator from plugin "${pluginName}" failed: ${describeError(error)}`, + { cause: error }, ); } }; diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin-transform.spec.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin-transform.spec.ts index f3bad6f43d..83836a693f 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin-transform.spec.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin-transform.spec.ts @@ -27,7 +27,7 @@ async function setupLegacyTransformPlugin() { getSourceFile: vi.fn().mockReturnValue(undefined), }; - vi.doMock('typescript', () => ({ + const mockTypeScript = { sys: { readFile: vi.fn(), }, @@ -40,6 +40,10 @@ async function setupLegacyTransformPlugin() { createIncrementalCompilerHost: vi.fn().mockReturnValue({}), createPrinter: vi.fn().mockReturnValue({ printNode: vi.fn() }), createSourceFile: vi.fn().mockReturnValue({}), + }; + vi.doMock('typescript', () => ({ + ...mockTypeScript, + default: mockTypeScript, })); vi.doMock('@angular/compiler-cli', () => ({ @@ -157,7 +161,7 @@ describe('legacy Angular transform', () => { it('returns undefined when Angular did not emit the requested TS file', async () => { const { plugin, workspaceRoot } = await setupLegacyTransformPlugin(); const transformHook = plugin.transform.handler; - const id = `${workspaceRoot}/enterpriseOS/schema/src/gdm/identifiers.ts`; + const id = `${workspaceRoot}/libs/shared/src/identifiers.ts`; const result = await transformHook.call( { @@ -165,7 +169,7 @@ describe('legacy Angular transform', () => { error: vi.fn(), warn: vi.fn(), }, - 'export const DvsEntityUuidSchema = {};', + 'export const identifier = "example";', id, ); diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts index f167f0249e..27db9e26ce 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.spec.ts @@ -106,6 +106,9 @@ describe('liveReload option', () => { const names = angular().map((plugin) => plugin.name); expect(names).toEqual(expect.arrayContaining(hmrPluginNames)); + expect( + names.filter((name) => name === 'analogjs-live-reload-plugin'), + ).toHaveLength(1); }); }); diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts index cb5914e6a3..ffa4b1987d 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts @@ -19,7 +19,7 @@ import * as vite from 'vite'; import * as compilerCli from '@angular/compiler-cli'; import { createRequire } from 'node:module'; -import * as ts from 'typescript'; +import ts from 'typescript'; import { ModuleNode, normalizePath, @@ -93,11 +93,7 @@ import { toJitInlineStyleId } from './utils/jit-inline-styles.js'; import { TsconfigResolver } from './utils/tsconfig-resolver.js'; import { cssExtensionStyleResolverPlugin } from './utils/css-extension-resolver.js'; import { getJsTransformConfigKey, isRolldown } from './utils/rolldown.js'; -import { - toVirtualRawId, - toVirtualStyleId, - VIRTUAL_RAW_PREFIX, -} from './utils/virtual-ids.js'; +import { toVirtualRawId, VIRTUAL_RAW_PREFIX } from './utils/virtual-ids.js'; import { type SourceFileCache as SourceFileCacheType } from './utils/source-file-cache.js'; const require = createRequire(import.meta.url); @@ -445,6 +441,7 @@ export function angular(options?: PluginOptions): Plugin[] { jit, incremental: watchMode, }, + isTest, // Astro manages the transformer lifecycle externally. !isAstroIntegration, ), @@ -931,7 +928,9 @@ export function angular(options?: PluginOptions): Plugin[] { }); pendingCompilation = performCompilation(resolvedConfig, [ - ...mods.map((mod) => mod.id).filter(Boolean), + ...mods + .map((mod) => mod.id) + .filter((id): id is string => Boolean(id)), ...updates, ]); @@ -1100,7 +1099,6 @@ export function angular(options?: PluginOptions): Plugin[] { }); debugStylesV('load: served inline component stylesheet', { filename, - length: componentStyles.length, requestId: id, ...describeStylesheetContent(componentStyles), }); @@ -1327,20 +1325,23 @@ export function angular(options?: PluginOptions): Plugin[] { } const compilationPlugin = pluginOptions.useAngularCompilationAPI - ? compilationAPIPlugin({ - tsconfigGetter: pluginOptions.tsconfigGetter, - workspaceRoot: pluginOptions.workspaceRoot, - inlineStylesExtension: pluginOptions.inlineStylesExtension, - jit, - liveReload: pluginOptions.liveReload, - disableTypeChecking: pluginOptions.disableTypeChecking, - supportedBrowsers: pluginOptions.supportedBrowsers, - fileReplacements: pluginOptions.fileReplacements, - isTest, - isAstroIntegration, - include: pluginOptions.include, - debug: options?.debug, - }) + ? compilationAPIPlugin( + { + tsconfigGetter: pluginOptions.tsconfigGetter, + workspaceRoot: pluginOptions.workspaceRoot, + inlineStylesExtension: pluginOptions.inlineStylesExtension, + jit, + liveReload: pluginOptions.liveReload, + disableTypeChecking: pluginOptions.disableTypeChecking, + supportedBrowsers: pluginOptions.supportedBrowsers, + fileReplacements: pluginOptions.fileReplacements, + isTest, + isAstroIntegration, + include: pluginOptions.include, + debug: options?.debug, + }, + { classNames, outputFiles }, + ) : pluginOptions.fastCompile ? fastCompilePlugin({ tsconfigGetter: pluginOptions.tsconfigGetter, @@ -1366,16 +1367,8 @@ export function angular(options?: PluginOptions): Plugin[] { replaceFiles(pluginOptions.fileReplacements, pluginOptions.workspaceRoot), virtualModulesPlugin({ jit }), pluginOptions.liveReload && liveReloadPlugin({ classNames, fileEmitter }), - // `compilationPlugin` is either `angularPlugin()` or `fastCompilePlugin()` - // depending on `pluginOptions.fastCompile`. When fastCompile is off the - // array used to also include an unconditional `angularPlugin()` right - // before this line — invoking the same plugin twice and double- - // registering its hooks. Removed: `compilationPlugin` already covers both - // branches. + // Register the selected compiler and its shared HMR middleware once. compilationPlugin, - !pluginOptions.fastCompile && - pluginOptions.liveReload && - liveReloadPlugin({ classNames, fileEmitter }), ...(isTest && !isStackBlitz ? angularVitestPlugins((id) => outputFiles.get(normalizePath(id))?.map) : []), @@ -1494,11 +1487,10 @@ export function angular(options?: PluginOptions): Plugin[] { ), ); // Merge + dedupe root names - rootNames = union( - rootNames, - tsconfigResolver.ensureIncludeCache(), - replacements, - ); + rootNames = union(rootNames, [ + ...tsconfigResolver.ensureIncludeCache(), + ...replacements, + ]); const hostKey = JSON.stringify(tsCompilerOptions); let host: ts.CompilerHost; @@ -1835,7 +1827,7 @@ export async function getModulesForChangedFile( requestId: string; candidate: string; via: 'url' | 'id'; - moduleId?: string; + moduleId?: string | null; }> = []; for (const requestId of stylesheetRequestIds) { const candidates = [ @@ -1927,7 +1919,7 @@ function diagnoseComponentStylesheetPipeline( dependencies: StylesheetDependency[]; diagnostics: ReturnType; tags: string[]; - directModuleId?: string; + directModuleId?: string | null; directModuleUrl?: string; trackedRequestIds: string[]; wrapperCount: number; @@ -2121,7 +2113,7 @@ export async function findComponentStylesheetWrapperModules( const lookupHits: Array<{ candidate: string; via?: 'url' | 'id'; - moduleId?: string; + moduleId?: string | null; moduleType?: string; }> = []; @@ -2224,8 +2216,8 @@ function logComponentStylesheetHmrOutcome(details: { encapsulation: string; diagnosis: ReturnType; outcome: ComponentStylesheetHmrOutcome; - directModuleId?: string; - wrapperIds?: string[]; + directModuleId?: string | null; + wrapperIds?: Array; }) { const pitfalls: string[] = []; const rejectedPreferredPaths: string[] = []; diff --git a/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.spec.ts b/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.spec.ts index 123790f490..b7f9e77878 100644 --- a/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.spec.ts +++ b/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.spec.ts @@ -164,6 +164,7 @@ describe('compilationAPIPlugin', () => { .mockResolvedValue({ errors: [], warnings: [] }); const emitted = Promise.withResolvers<{ filename: string; contents: string }[]>(); + const close = vi.fn(); const emitAffectedFilesMock = vi .fn() .mockImplementation(() => emitted.promise); @@ -173,6 +174,7 @@ describe('compilationAPIPlugin', () => { update: vi.fn(), diagnoseFiles: diagnoseFilesMock, emitAffectedFiles: emitAffectedFilesMock, + close, }); const { compilationAPIPlugin } = @@ -218,8 +220,12 @@ describe('compilationAPIPlugin', () => { 'export const value = 1;', filename, ); + const closing = (plugin.closeBundle as any)(); + await Promise.resolve(); + expect(close).not.toHaveBeenCalled(); emitted.resolve([{ filename, contents: 'export const value = 1;' }]); await building; + await closing; await expect(transformed).resolves.toMatchObject({ code: 'export const value = 1;', }); @@ -227,6 +233,7 @@ describe('compilationAPIPlugin', () => { expect(createAngularCompilationMock).toHaveBeenCalledOnce(); expect(initializeMock).toHaveBeenCalledOnce(); expect(emitAffectedFilesMock).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); }); it('includes integration-provided files outside the configured TypeScript roots', async () => { @@ -473,6 +480,7 @@ describe('compilationAPIPlugin', () => { it('maps templateUpdates to HMR metadata', async () => { const testFile = join(tempRoot, 'src/app.component.ts'); + const update = vi.fn(); const initializeMock = vi.fn().mockResolvedValue({ externalStylesheets: new Map(), templateUpdates: new Map([ @@ -491,7 +499,7 @@ describe('compilationAPIPlugin', () => { createAngularCompilationMock.mockResolvedValue({ initialize: initializeMock, - update: vi.fn(), + update, diagnoseFiles: vi.fn().mockResolvedValue({ errors: [], warnings: [] }), emitAffectedFiles: emitAffectedFilesMock, }); @@ -566,6 +574,36 @@ describe('compilationAPIPlugin', () => { expect(modules).toEqual([mixedModule]); expect(clientModule.isSelfAccepting).toBe(true); expect(send).toHaveBeenCalled(); + + send.mockClear(); + emitAffectedFilesMock.mockResolvedValue([]); + const templateFile = join(tempRoot, 'src/app.component.html'); + const invalidateModule = vi.fn(); + await (plugin.handleHotUpdate as any)({ + file: templateFile, + modules: [], + server: { + ws: { send }, + environments: { + client: { + moduleGraph: { + getModuleById: () => clientModule, + invalidateModule, + }, + }, + }, + }, + }); + expect(update).toHaveBeenLastCalledWith(new Set([templateFile])); + expect(invalidateModule).toHaveBeenCalledWith(clientModule); + expect(send).toHaveBeenCalled(); + await expect( + transformHandler.call( + { warn: vi.fn(), error: vi.fn() }, + 'source text', + testFile, + ), + ).resolves.toMatchObject({ code: 'compiled output' }); }); it('serves emitted output for TypeScript files without Angular decorators', async () => { diff --git a/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.ts b/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.ts index 0bc20a23d0..ca604f32c0 100644 --- a/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.ts @@ -83,8 +83,17 @@ export interface CompilationAPIPluginOptions { debug?: DebugOption; } +interface CompilationAPIState { + outputFiles: Map; + classNames: Map; +} + export function compilationAPIPlugin( pluginOptions: CompilationAPIPluginOptions, + state: CompilationAPIState = { + outputFiles: new Map(), + classNames: new Map(), + }, ): Plugin { let resolvedConfig: ResolvedConfig; let tsConfigResolutionContext: TsConfigResolutionContext | null = null; @@ -99,13 +108,13 @@ export function compilationAPIPlugin( | Awaited> | undefined; const sourceFileCache: SourceFileCacheType = new SourceFileCache(); - const outputFiles = new Map(); - const classNames = new Map(); + const { outputFiles, classNames } = state; let stylesheetRegistry: AnalogStylesheetRegistry | undefined; let compilationLock = Promise.resolve(); - let pendingCompilation: Promise | null = null; + let pendingCompilation: Promise | null = null; let initialCompilation = false; let viteServer: ViteDevServer | undefined; + let removeWatcherListeners: (() => void) | undefined; const isTest = process.env['NODE_ENV'] === 'test' || !!process.env['VITEST']; const tsconfigResolver = new TsconfigResolver({ @@ -160,11 +169,10 @@ export function compilationAPIPlugin( resolvedTsConfigPath, config, ); - const mergedRootNames = union( - cached.rootNames, - expandedGraphRoots, - includedFiles, - ).map((file) => normalizePath(file)); + const mergedRootNames = union(cached.rootNames, [ + ...expandedGraphRoots, + ...includedFiles, + ]).map((file) => normalizePath(file)); if (mergedRootNames.length === cached.rootNames.length) { return resolvedTsConfigPath; @@ -272,9 +280,9 @@ export function compilationAPIPlugin( async transformStylesheet( data: string, containingFile: string, - resourceFile: string | null, - order: number, - className: string | null, + resourceFile?: string, + order?: number, + className?: string, ) { const filename = resourceFile ?? @@ -380,9 +388,10 @@ export function compilationAPIPlugin( } if (!isTest && resolvedConfig.build?.lib) { - tsCompilerOptions['declaration'] = true; - tsCompilerOptions['declarationMap'] = watchMode; - tsCompilerOptions['inlineSources'] = true; + // This API returns one output per source file. Declaration output + // would replace its JavaScript; library declarations need a separate build. + tsCompilerOptions['declaration'] = false; + tsCompilerOptions['declarationMap'] = false; } if (isTest) { @@ -465,13 +474,26 @@ export function compilationAPIPlugin( }); } - const affectedFiles = await compilation.emitAffectedFiles(); + const affectedFiles = [...(await compilation.emitAffectedFiles())]; debugEmit('emitAffectedFiles summary', { count: affectedFiles.length, templateUpdateCount: templateUpdates.size, knownOutputCountBefore: outputFiles.size, }); + // A template-only HMR update may not emit the component's full JavaScript. + for (const [filename, update] of templateUpdates) { + classNames.set(filename, update.className); + const previous = outputFiles.get(filename); + if (previous) { + outputFiles.set(filename, { + ...previous, + hmrUpdateCode: update.code, + hmrEligible: true, + }); + } + } + for (const file of affectedFiles) { const normalizedFilename = normalizePath(file.filename); const templateUpdate = templateUpdates.get(normalizedFilename); @@ -491,6 +513,7 @@ export function compilationAPIPlugin( hmrEligible: !!templateUpdate?.code, }); } + return [...templateUpdates.keys()]; } async function performCompilation(config: ResolvedConfig, ids?: string[]) { @@ -506,12 +529,27 @@ export function compilationAPIPlugin( transformFilter = integrations.transformFilter; tsconfigResolver.setIntegrationIncludes(integrations.include); externalizeStylesRequested = integrations.externalizeStyles; - await performAngularCompilation(config, ids); + return await performAngularCompilation(config, ids); } finally { resolve!(); } } + function notifyComponentUpdates(server: ViteDevServer, files: string[]) { + for (const file of files) { + const className = classNames.get(file); + if (!className) continue; + const relativeFileId = `${normalizePath(relative(process.cwd(), file))}@${className}`; + const clientGraph = server.environments.client?.moduleGraph; + const clientModule = clientGraph?.getModuleById(file); + if (clientModule) { + clientGraph?.invalidateModule(clientModule); + clientModule.isSelfAccepting = true; + } + sendHMRComponentUpdate(server, relativeFileId); + } + } + function isComponentStyleSheet(id: string): boolean { return id.includes('ngcomp='); } @@ -542,6 +580,11 @@ export function compilationAPIPlugin( name: '@analogjs/vite-plugin-angular-compilation-api', enforce: 'pre' as const, async config(config, { command }) { + if (typeof createAngularCompilation !== 'function') { + throw new Error( + '[@analogjs/vite-plugin-angular]: The experimental Compilation API requires @angular/build/private to export createAngularCompilation. Use a compatible @angular/build version or set experimental.useAngularCompilationAPI to false.', + ); + } activateDeferredDebug(command); watchMode = command === 'serve'; const isProd = @@ -602,6 +645,7 @@ export function compilationAPIPlugin( plugins: [ createRolldownCompilerPlugin( compilerPluginOptions, + isTest, !pluginOptions.isAstroIntegration, ), ], @@ -645,13 +689,19 @@ export function compilationAPIPlugin( tsconfigResolver.invalidateAll(); await performCompilation(resolvedConfig); }; - server.watcher.on('add', invalidateCompilation); - server.watcher.on('unlink', invalidateCompilation); - server.watcher.on('change', (file) => { + const invalidateTsconfig = (file: string) => { if (file.includes('tsconfig')) { tsconfigResolver.invalidateTsconfigCaches(); } - }); + }; + server.watcher.on('add', invalidateCompilation); + server.watcher.on('unlink', invalidateCompilation); + server.watcher.on('change', invalidateTsconfig); + removeWatcherListeners = () => { + server.watcher.off('add', invalidateCompilation); + server.watcher.off('unlink', invalidateCompilation); + server.watcher.off('change', invalidateTsconfig); + }; }, async buildStart() { if (!isVitestVscode) { @@ -697,19 +747,22 @@ export function compilationAPIPlugin( debugHmr('sending component update', { relativeFileId }); sendHMRComponentUpdate(ctx.server, relativeFileId); - return ctx.modules.map((mod) => { - if (mod.id === ctx.file) { - mod.isSelfAccepting = true; - } - return mod; - }); + const clientModule = + ctx.server.environments.client?.moduleGraph.getModuleById(ctx.file); + if (clientModule) clientModule.isSelfAccepting = true; + return ctx.modules; } } if (/\.(html|htm)$/.test(ctx.file)) { debugHmr('template file changed', { file: ctx.file }); - // Recompile to pick up template changes - pendingCompilation = performCompilation(resolvedConfig); + pendingCompilation = performCompilation(resolvedConfig, [ctx.file]); + const updatedComponents = await pendingCompilation; + pendingCompilation = null; + if (shouldEnableLiveReload()) { + notifyComponentUpdates(ctx.server, updatedComponents); + return []; + } } if (/\.(css|less|sass|scss)$/.test(ctx.file)) { @@ -815,6 +868,8 @@ export function compilationAPIPlugin( pendingCompilation = null; } + await compilationLock; + const typescriptResult = fileEmitter(id); if (!typescriptResult) { debugCompilationApi('transform skip (file not emitted)', { id }); @@ -851,9 +906,13 @@ export function compilationAPIPlugin( }; }, }, - closeBundle() { - angularCompilation?.close?.(); + async closeBundle() { + removeWatcherListeners?.(); + removeWatcherListeners = undefined; + await compilationLock; + const compilation = angularCompilation; angularCompilation = undefined; + await compilation?.close?.(); }, }; } diff --git a/packages/vite-plugin-angular/src/lib/compiler/compile.ts b/packages/vite-plugin-angular/src/lib/compiler/compile.ts index e0afca4b20..41582f5b6b 100644 --- a/packages/vite-plugin-angular/src/lib/compiler/compile.ts +++ b/packages/vite-plugin-angular/src/lib/compiler/compile.ts @@ -1,4 +1,4 @@ -import * as ts from 'typescript'; +import ts from 'typescript'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as o from '@angular/compiler'; diff --git a/packages/vite-plugin-angular/src/lib/compiler/constants.ts b/packages/vite-plugin-angular/src/lib/compiler/constants.ts index adc4423eb9..9d486f9c8f 100644 --- a/packages/vite-plugin-angular/src/lib/compiler/constants.ts +++ b/packages/vite-plugin-angular/src/lib/compiler/constants.ts @@ -6,7 +6,7 @@ */ /** All six Angular class decorators. */ -export const ANGULAR_DECORATORS = new Set([ +export const ANGULAR_DECORATORS: Set = new Set([ 'Component', 'Directive', 'Pipe', @@ -20,7 +20,7 @@ export const ANGULAR_DECORATORS = new Set([ * Derived from {@link ANGULAR_DECORATORS} so the fast-path "is this an Angular file?" * checks stay in sync with the canonical decorator set and don't drift. */ -export const ANGULAR_DECORATOR_CALL_RE = new RegExp( +export const ANGULAR_DECORATOR_CALL_RE: RegExp = new RegExp( `@(${[...ANGULAR_DECORATORS].join('|')})\\(`, ); @@ -29,7 +29,7 @@ export const ANGULAR_DECORATOR_CALL_RE = new RegExp( * pipe name, or module exports). Excludes `@Injectable` and `@Service`, which * self-register via ɵprov and don't need Ivy compilation. */ -export const COMPILABLE_DECORATORS = new Set([ +export const COMPILABLE_DECORATORS: Set = new Set([ 'Component', 'Directive', 'Pipe', @@ -37,7 +37,7 @@ export const COMPILABLE_DECORATORS = new Set([ ]); /** Decorator names used on class fields and host bindings. */ -export const FIELD_DECORATORS = new Set([ +export const FIELD_DECORATORS: Set = new Set([ 'Input', 'Output', 'ViewChild', @@ -49,7 +49,7 @@ export const FIELD_DECORATORS = new Set([ ]); /** Signal-based reactive APIs that need downleveling or metadata extraction. */ -export const SIGNAL_APIS = new Set([ +export const SIGNAL_APIS: Set = new Set([ 'input', 'model', 'output', diff --git a/packages/vite-plugin-angular/src/lib/compiler/debug.ts b/packages/vite-plugin-angular/src/lib/compiler/debug.ts index 812cb2ba41..e3c8c6c903 100644 --- a/packages/vite-plugin-angular/src/lib/compiler/debug.ts +++ b/packages/vite-plugin-angular/src/lib/compiler/debug.ts @@ -1,4 +1,4 @@ -import { createDebug } from 'obug'; +import { createDebug, type Debugger } from 'obug'; // Structured debug output for the fast-compile path. Activated via the // `DEBUG` environment variable, e.g.: @@ -17,24 +17,24 @@ const root = createDebug('analog-fast-compile'); * Top-level compile/transform events: per-file start, end, timing, * fatal errors that get rethrown after logging. */ -export const debugCompile = root; +export const debugCompile: Debugger = root; /** * Registry scanning of `.ts` and `.d.ts` files. Useful when a directive * or pipe is not being recognized as a dependency — turning this on * shows what the registry actually saw. */ -export const debugRegistry = root.extend('registry'); +export const debugRegistry: Debugger = root.extend('registry'); /** * Cross-file dependency resolution decisions: how an `imports: [...]` * entry was resolved to underlying directives, NgModule export * expansion, tuple barrel expansion. */ -export const debugResolve = root.extend('resolve'); +export const debugResolve: Debugger = root.extend('resolve'); /** * Code emission and helper hoisting: which Ivy instructions were * emitted, where helpers were inserted, type-only import elision. */ -export const debugEmit = root.extend('emit'); +export const debugEmit: Debugger = root.extend('emit'); diff --git a/packages/vite-plugin-angular/src/lib/compiler/defer.ts b/packages/vite-plugin-angular/src/lib/compiler/defer.ts index c3d2bc392c..14fe2208fd 100644 --- a/packages/vite-plugin-angular/src/lib/compiler/defer.ts +++ b/packages/vite-plugin-angular/src/lib/compiler/defer.ts @@ -1,4 +1,4 @@ -import * as ts from 'typescript'; +import ts from 'typescript'; import * as o from '@angular/compiler'; import { ComponentRegistry } from './registry.js'; diff --git a/packages/vite-plugin-angular/src/lib/compiler/js-emitter.ts b/packages/vite-plugin-angular/src/lib/compiler/js-emitter.ts index 56fb689cab..008ea7dd0d 100644 --- a/packages/vite-plugin-angular/src/lib/compiler/js-emitter.ts +++ b/packages/vite-plugin-angular/src/lib/compiler/js-emitter.ts @@ -1,4 +1,4 @@ -import * as ts from 'typescript'; +import ts from 'typescript'; import * as o from '@angular/compiler'; /** Shared printer — only used as fallback for complex WrappedNodeExpr (e.g. decorator args). */ diff --git a/packages/vite-plugin-angular/src/lib/compiler/metadata.ts b/packages/vite-plugin-angular/src/lib/compiler/metadata.ts index 10aef2f813..bfb47bea9b 100644 --- a/packages/vite-plugin-angular/src/lib/compiler/metadata.ts +++ b/packages/vite-plugin-angular/src/lib/compiler/metadata.ts @@ -2,6 +2,18 @@ import * as o from '@angular/compiler'; import { unwrapForwardRefOxc } from './utils.js'; import { FIELD_DECORATORS, SIGNAL_APIS } from './constants.js'; +interface DetectedFields { + inputs: o.R3DirectiveMetadata['inputs']; + outputs: o.R3DirectiveMetadata['outputs']; + viewQueries: o.R3QueryMetadata[]; + contentQueries: o.R3QueryMetadata[]; +} + +interface DetectedDecoratedFields extends DetectedFields { + hostProperties: Record; + hostListeners: Record; +} + function getCallApi(call: any): { api: string; required: boolean } | null { const callee = call.callee; if (!callee) return null; @@ -453,7 +465,10 @@ export function extractMetadata( * Detect signal-based APIs on class members: input(), model(), output(), * viewChild(), contentChild(), viewChildren(), contentChildren(). */ -export function detectSignals(classNode: any, sourceCode: string) { +export function detectSignals( + classNode: any, + sourceCode: string, +): DetectedFields { const inputs: any = {}, outputs: any = {}, viewQueries: any[] = [], @@ -684,7 +699,10 @@ export function detectSignals(classNode: any, sourceCode: string) { * Detect decorator-based field metadata: @Input, @Output, @ViewChild, * @ContentChild, @ViewChildren, @ContentChildren, @HostBinding, @HostListener. */ -export function detectFieldDecorators(classNode: any, sourceCode: string) { +export function detectFieldDecorators( + classNode: any, + sourceCode: string, +): DetectedDecoratedFields { const inputs: any = {}; const outputs: any = {}; const viewQueries: any[] = []; diff --git a/packages/vite-plugin-angular/src/lib/compiler/test-helpers.ts b/packages/vite-plugin-angular/src/lib/compiler/test-helpers.ts index 01d77fdac0..86b59c75ff 100644 --- a/packages/vite-plugin-angular/src/lib/compiler/test-helpers.ts +++ b/packages/vite-plugin-angular/src/lib/compiler/test-helpers.ts @@ -35,7 +35,7 @@ export function compilePartialCode( }).code; } -export function expectCompiles(result: string) { +export function expectCompiles(result: string): void { expect(result).toBeTruthy(); expect(result).not.toMatch(/^Error:/m); } diff --git a/packages/vite-plugin-angular/src/lib/compiler/utils.ts b/packages/vite-plugin-angular/src/lib/compiler/utils.ts index 70d3c592c8..fac4af8333 100644 --- a/packages/vite-plugin-angular/src/lib/compiler/utils.ts +++ b/packages/vite-plugin-angular/src/lib/compiler/utils.ts @@ -1,4 +1,4 @@ -import * as ts from 'typescript'; +import ts from 'typescript'; import * as path from 'node:path'; /** Collect type-only imported names: `import type { X }` and `import { type X }`. */ diff --git a/packages/vite-plugin-angular/src/lib/encapsulation-plugin.ts b/packages/vite-plugin-angular/src/lib/encapsulation-plugin.ts index 6ef034bece..102f0e12dc 100644 --- a/packages/vite-plugin-angular/src/lib/encapsulation-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/encapsulation-plugin.ts @@ -54,6 +54,7 @@ export function encapsulationPlugin(): Plugin { }; } } + return; }, }; } diff --git a/packages/vite-plugin-angular/src/lib/host.ts b/packages/vite-plugin-angular/src/lib/host.ts index 92fd41b3d6..2790637473 100644 --- a/packages/vite-plugin-angular/src/lib/host.ts +++ b/packages/vite-plugin-angular/src/lib/host.ts @@ -1,7 +1,7 @@ import type { CompilerHost } from '@angular/compiler-cli'; import { normalizePath } from 'vite'; -import * as ts from 'typescript'; +import ts from 'typescript'; import { createHash } from 'node:crypto'; import path from 'node:path'; diff --git a/packages/vite-plugin-angular/src/lib/router-plugin.spec.ts b/packages/vite-plugin-angular/src/lib/router-plugin.spec.ts new file mode 100644 index 0000000000..a1963331ea --- /dev/null +++ b/packages/vite-plugin-angular/src/lib/router-plugin.spec.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { transformFile } = vi.hoisted(() => ({ transformFile: vi.fn() })); +vi.mock('./utils/devkit.js', () => ({ + JavaScriptTransformer: class { + transformFile = transformFile; + close = vi.fn(); + }, +})); + +import { routerPlugin } from './router-plugin.js'; + +function transform(code: string, id: string) { + const hook = routerPlugin().transform as { + handler: ( + code: string, + id: string, + ) => Promise<{ code: string } | undefined>; + }; + return hook.handler(code, id); +} + +beforeEach(() => { + transformFile.mockReset(); + transformFile.mockResolvedValue(Buffer.from('export const linked = true;')); +}); + +describe('dependency optimization without native Vite hook filters', () => { + it.each(['/src/app.component.ts', '/src/main.js', '/@vite/client'])( + 'leaves %s to its source compiler', + async (id) => { + await expect( + transform('already compiled Angular output', id), + ).resolves.toBeUndefined(); + expect(transformFile).not.toHaveBeenCalled(); + }, + ); + + it('links an Angular FESM dependency and strips its query before reading', async () => { + await expect( + transform( + 'partial declarations', + '/node_modules/@angular/core/fesm2022/core.mjs?v=123', + ), + ).resolves.toEqual({ code: 'export const linked = true;' }); + expect(transformFile).toHaveBeenCalledWith( + '/node_modules/@angular/core/fesm2022/core.mjs', + ); + }); +}); diff --git a/packages/vite-plugin-angular/src/lib/router-plugin.ts b/packages/vite-plugin-angular/src/lib/router-plugin.ts index 776c5d6f7d..164bc54de9 100644 --- a/packages/vite-plugin-angular/src/lib/router-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/router-plugin.ts @@ -7,6 +7,8 @@ import { type TransformCacheStore, } from './utils/transform-cache.js'; +const FESM_MODULE_ID = /fesm(.*?)\.mjs/; + export function routerPlugin(): Plugin { const memoryCache = new Map(); const memoryOnly: TransformCacheStore = { @@ -36,9 +38,12 @@ export function routerPlugin(): Plugin { }, transform: { filter: { - id: /fesm(.*?)\.mjs/, + id: FESM_MODULE_ID, }, async handler(_code: string, id: string) { + // Early Vite 6 does not apply hook filters. Never overwrite a compiled + // application module by reading its original TypeScript from disk. + if (!FESM_MODULE_ID.test(id)) return; const path = id.split('?')[0]; const contents = await javascriptTransformer.transformFile(path); diff --git a/packages/vite-plugin-angular/src/lib/utils/compilation-shared.ts b/packages/vite-plugin-angular/src/lib/utils/compilation-shared.ts index 486a445cb2..93a52dac78 100644 --- a/packages/vite-plugin-angular/src/lib/utils/compilation-shared.ts +++ b/packages/vite-plugin-angular/src/lib/utils/compilation-shared.ts @@ -206,7 +206,9 @@ export function refreshStylesheetRegistryForFile( * Checks for vitest run from the command line * @returns boolean */ -export function isTestWatchMode(args = process.argv): boolean { +export function isTestWatchMode( + args: readonly string[] = process.argv, +): boolean { // vitest --run const hasRun = args.find((arg) => arg.includes('--run')); if (hasRun) { diff --git a/packages/vite-plugin-angular/src/lib/utils/debug-log-file.ts b/packages/vite-plugin-angular/src/lib/utils/debug-log-file.ts index 54d2300247..f9a855d3ca 100644 --- a/packages/vite-plugin-angular/src/lib/utils/debug-log-file.ts +++ b/packages/vite-plugin-angular/src/lib/utils/debug-log-file.ts @@ -10,6 +10,11 @@ import type { Debugger } from 'obug'; const TRUNCATED_KEY = '__analogDebugLogTruncated'; const WRAPPED_KEY = '__analogFileLogWrapped'; + +interface FileLogDebugger extends Debugger { + __analogFileLogWrapped?: string; + __analogOriginalLog?: Debugger['log']; +} // eslint-disable-next-line no-control-regex const ANSI_RE = /\x1B\[[0-9;]*[A-Za-z]|\x1B\].*?\x07/g; @@ -31,12 +36,12 @@ function ensureTruncated(filePath: string): void { } function wrapLog(dbg: Debugger, filePath: string): void { - const rec = dbg as Record; + const rec: FileLogDebugger = dbg; if (rec[WRAPPED_KEY] === filePath) return; const originalLog = rec[WRAPPED_KEY] && rec['__analogOriginalLog'] - ? (rec['__analogOriginalLog'] as Debugger['log']) + ? rec['__analogOriginalLog'] : dbg.log; rec['__analogOriginalLog'] = originalLog; diff --git a/packages/vite-plugin-angular/src/lib/utils/debug.ts b/packages/vite-plugin-angular/src/lib/utils/debug.ts index 3e9e4427ce..749da36d95 100644 --- a/packages/vite-plugin-angular/src/lib/utils/debug.ts +++ b/packages/vite-plugin-angular/src/lib/utils/debug.ts @@ -1,21 +1,25 @@ -import { createDebug } from 'obug'; +import { createDebug, type Debugger } from 'obug'; import { createDebugHarness } from './debug-harness.js'; // Normal — key decisions, once per startup or per component -export const debugHmr = createDebug('analog:angular:hmr'); -export const debugStyles = createDebug('analog:angular:styles'); -export const debugCompiler = createDebug('analog:angular:compiler'); -export const debugCompilationApi = createDebug( +export const debugHmr: Debugger = createDebug('analog:angular:hmr'); +export const debugStyles: Debugger = createDebug('analog:angular:styles'); +export const debugCompiler: Debugger = createDebug('analog:angular:compiler'); +export const debugCompilationApi: Debugger = createDebug( 'analog:angular:compilation-api', ); -export const debugEmit = createDebug('analog:angular:emit'); -export const debugStylePipeline = createDebug('analog:angular:style-pipeline'); +export const debugEmit: Debugger = createDebug('analog:angular:emit'); +export const debugStylePipeline: Debugger = createDebug( + 'analog:angular:style-pipeline', +); // Verbose — per-file detail, enable with :v suffix or parent:* -export const debugHmrV = createDebug('analog:angular:hmr:v'); -export const debugStylesV = createDebug('analog:angular:styles:v'); -export const debugCompilerV = createDebug('analog:angular:compiler:v'); -export const debugEmitV = createDebug('analog:angular:emit:v'); +export const debugHmrV: Debugger = createDebug('analog:angular:hmr:v'); +export const debugStylesV: Debugger = createDebug('analog:angular:styles:v'); +export const debugCompilerV: Debugger = createDebug( + 'analog:angular:compiler:v', +); +export const debugEmitV: Debugger = createDebug('analog:angular:emit:v'); const angularDebugInstances = [ debugHmr, diff --git a/packages/vite-plugin-angular/src/lib/utils/devkit.ts b/packages/vite-plugin-angular/src/lib/utils/devkit.ts index e0af9cb2c4..c622dbae70 100644 --- a/packages/vite-plugin-angular/src/lib/utils/devkit.ts +++ b/packages/vite-plugin-angular/src/lib/utils/devkit.ts @@ -5,11 +5,11 @@ import * as sfc from './source-file-cache.js'; const require = createRequire(import.meta.url); -const angularMajor = Number(VERSION.major); -const angularMinor = Number(VERSION.minor); -const angularPatch = Number(VERSION.patch); +const angularMajor: number = Number(VERSION.major); +const angularMinor: number = Number(VERSION.minor); +const angularPatch: number = Number(VERSION.patch); const padVersion = (version: number) => String(version).padStart(2, '0'); -const angularFullVersion = Number( +const angularFullVersion: number = Number( `${angularMajor}${padVersion(angularMinor)}${padVersion(angularPatch)}`, ); let sourceFileCache: any; diff --git a/packages/vite-plugin-angular/src/lib/utils/plugin-config.ts b/packages/vite-plugin-angular/src/lib/utils/plugin-config.ts index 9fc82b372f..0e63addedd 100644 --- a/packages/vite-plugin-angular/src/lib/utils/plugin-config.ts +++ b/packages/vite-plugin-angular/src/lib/utils/plugin-config.ts @@ -23,7 +23,7 @@ import { * zero characters, and any non-`x` letter was admitted — so `.tsrx` * and similar extensions matched by accident. */ -export const TS_EXT_REGEX = /\.[cm]?ts(?![a-z])/; +export const TS_EXT_REGEX: RegExp = /\.[cm]?ts(?![a-z])/; /** * Resolves whether Angular should be compiled for production. An explicit @@ -52,7 +52,7 @@ export function getTsConfigPath( isTest: boolean, isLib: boolean, workspaceRoot?: string, -) { +): string { if (tsconfig && isAbsolute(tsconfig)) { if (!existsSync(tsconfig)) { console.error( @@ -111,7 +111,7 @@ export function getTsConfigPath( export function createTsConfigGetter( tsconfigOrGetter?: string | (() => string), -) { +): () => string { if (typeof tsconfigOrGetter === 'function') { return tsconfigOrGetter; } @@ -128,7 +128,12 @@ export interface DepOptimizerOptions { isAstroIntegration: boolean; } -export function createDepOptimizerConfig(opts: DepOptimizerOptions) { +export function createDepOptimizerConfig(opts: DepOptimizerOptions): { + optimizeDeps: vite.DepOptimizationOptions & { + include: string[]; + exclude: string[]; + }; +} { const defineOptions = { ngJitMode: 'false', ngI18nClosureMode: 'false', diff --git a/packages/vite-plugin-angular/src/lib/utils/tsconfig-resolver.ts b/packages/vite-plugin-angular/src/lib/utils/tsconfig-resolver.ts index 6ad7148525..463ada9237 100644 --- a/packages/vite-plugin-angular/src/lib/utils/tsconfig-resolver.ts +++ b/packages/vite-plugin-angular/src/lib/utils/tsconfig-resolver.ts @@ -80,7 +80,7 @@ export class TsconfigResolver { readAngularTsconfigConfiguration( resolvedTsConfigPath: string, config: ResolvedConfig, - ) { + ): ReturnType { const isProd = config.mode === 'production'; return compilerCli.readConfiguration(resolvedTsConfigPath, { suppressOutputPathCheck: true, @@ -131,7 +131,7 @@ export class TsconfigResolver { collectExpandedTsconfigRoots( resolvedTsConfigPath: string, config: ResolvedConfig, - visited = new Set(), + visited: Set = new Set(), ): string[] { const normalizedTsConfigPath = normalizePath(resolvedTsConfigPath); if (visited.has(normalizedTsConfigPath)) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bffbdb4b5..3443b3aaa1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,6 +240,9 @@ catalogs: es-toolkit: specifier: ^1.45.1 version: 1.45.1 + esbuild: + specifier: 0.28.0 + version: 0.28.0 eslint: specifier: ^10.1.0 version: 10.2.0 @@ -1605,10 +1608,10 @@ importers: dependencies: '@angular-devkit/build-angular': specifier: catalog:peerAngularBuilders - version: 21.2.7(26dfc4a4fcbabc49e4b7c4050b4fef07) + version: 21.2.7(d96fbb687f660aa1e858eb476c0e6dfd) '@angular/build': specifier: catalog:peerAngularBuilders - version: 21.2.7(3b9b3dd3ee6a41b42e9225ae6aa2f8ec) + version: 21.2.7(25ba112725613e64b931c2253542b465) es-toolkit: specifier: 'catalog:' version: 1.45.1 @@ -1630,6 +1633,25 @@ importers: tinyglobby: specifier: 'catalog:' version: 0.2.16 + devDependencies: + esbuild: + specifier: 'catalog:' + version: 0.28.0 + jiti: + specifier: 'catalog:' + version: 2.6.1 + less: + specifier: 'catalog:' + version: 4.6.4 + sass: + specifier: 'catalog:' + version: 1.99.0 + sass-embedded: + specifier: 'catalog:' + version: 1.99.0 + vite: + specifier: 'catalog:' + version: 8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.9.0) packages/vite-plugin-angular-tools: {} @@ -15900,11 +15922,11 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular-devkit/build-angular@21.2.7(26dfc4a4fcbabc49e4b7c4050b4fef07)': + '@angular-devkit/build-angular@21.2.7(5202cc10b59ccd363402323333923b82)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2102.7(chokidar@5.0.0) - '@angular-devkit/build-webpack': 0.2102.7(chokidar@5.0.0)(webpack-dev-server@5.2.3(tslib@2.8.1)(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)))(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)) + '@angular-devkit/build-webpack': 0.2102.7(chokidar@5.0.0)(webpack-dev-server@5.2.3(tslib@2.8.1)(webpack@5.106.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.28.0)))(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)) '@angular-devkit/core': 21.2.7(chokidar@5.0.0) '@angular/build': 21.2.7(405b5e00feb1af6265b8d6181fa89fca) '@angular/compiler-cli': 22.0.0(@angular/compiler@22.0.0)(typescript@6.0.3) @@ -15953,11 +15975,11 @@ snapshots: tree-kill: 1.2.2 tslib: 2.8.1 typescript: 6.0.3 - webpack: 5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.28.0) + webpack: 5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3) webpack-dev-middleware: 7.4.5(tslib@2.8.1)(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)) webpack-dev-server: 5.2.3(tslib@2.8.1)(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)) webpack-merge: 6.0.1 - webpack-subresource-integrity: 5.1.0(html-webpack-plugin@5.6.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)))(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)) + webpack-subresource-integrity: 5.1.0(html-webpack-plugin@5.6.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.106.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.28.0)))(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)) optionalDependencies: '@angular/core': 22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1) '@angular/localize': 21.2.8(@angular/compiler-cli@22.0.0(@angular/compiler@22.0.0)(typescript@6.0.3))(@angular/compiler@22.0.0) @@ -15991,14 +16013,15 @@ snapshots: - vitest - webpack-cli - yaml + optional: true - '@angular-devkit/build-angular@21.2.7(5202cc10b59ccd363402323333923b82)': + '@angular-devkit/build-angular@21.2.7(d96fbb687f660aa1e858eb476c0e6dfd)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2102.7(chokidar@5.0.0) - '@angular-devkit/build-webpack': 0.2102.7(chokidar@5.0.0)(webpack-dev-server@5.2.3(tslib@2.8.1)(webpack@5.106.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.28.0)))(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)) + '@angular-devkit/build-webpack': 0.2102.7(chokidar@5.0.0)(webpack-dev-server@5.2.3(tslib@2.8.1)(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)))(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)) '@angular-devkit/core': 21.2.7(chokidar@5.0.0) - '@angular/build': 21.2.7(405b5e00feb1af6265b8d6181fa89fca) + '@angular/build': 21.2.7(460f9a170101f7b74a9e1242f5065a43) '@angular/compiler-cli': 22.0.0(@angular/compiler@22.0.0)(typescript@6.0.3) '@babel/core': 7.29.0 '@babel/generator': 7.29.1 @@ -16045,11 +16068,11 @@ snapshots: tree-kill: 1.2.2 tslib: 2.8.1 typescript: 6.0.3 - webpack: 5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3) + webpack: 5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.28.0) webpack-dev-middleware: 7.4.5(tslib@2.8.1)(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)) webpack-dev-server: 5.2.3(tslib@2.8.1)(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)) webpack-merge: 6.0.1 - webpack-subresource-integrity: 5.1.0(html-webpack-plugin@5.6.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.106.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.28.0)))(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)) + webpack-subresource-integrity: 5.1.0(html-webpack-plugin@5.6.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)))(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)) optionalDependencies: '@angular/core': 22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1) '@angular/localize': 21.2.8(@angular/compiler-cli@22.0.0(@angular/compiler@22.0.0)(typescript@6.0.3))(@angular/compiler@22.0.0) @@ -16083,7 +16106,6 @@ snapshots: - vitest - webpack-cli - yaml - optional: true '@angular-devkit/build-angular@21.2.7(e594cc69022ce4033cd069b841cd7a0c)': dependencies: @@ -16578,7 +16600,7 @@ snapshots: - yaml optional: true - '@angular/build@21.2.7(3b9b3dd3ee6a41b42e9225ae6aa2f8ec)': + '@angular/build@21.2.7(25ba112725613e64b931c2253542b465)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2102.7(chokidar@5.0.0) @@ -16588,7 +16610,7 @@ snapshots: '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-split-export-declaration': 7.24.7 '@inquirer/confirm': 5.1.21(@types/node@25.6.0) - '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0)) + '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0)) beasties: 0.4.1 browserslist: 4.28.2 esbuild: 0.27.3 @@ -16609,7 +16631,7 @@ snapshots: tslib: 2.8.1 typescript: 6.0.3 undici: 7.24.4 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0) watchpack: 2.5.1 optionalDependencies: '@angular/core': 22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1) @@ -16617,12 +16639,12 @@ snapshots: '@angular/platform-browser': 22.0.0(@angular/animations@22.0.0(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1)))(@angular/common@22.0.0(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1)) '@angular/platform-server': 22.0.0(@angular/common@22.0.0(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/compiler@22.0.0)(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1))(@angular/platform-browser@22.0.0(@angular/animations@22.0.0(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1)))(@angular/common@22.0.0(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1)))(rxjs@7.8.2) '@angular/ssr': 21.2.7(6fc05730774a8276e69579bed1c76f88) - less: 4.4.2 + less: 4.6.4 lmdb: 3.5.1 ng-packagr: 21.2.2(@angular/compiler-cli@22.0.0(@angular/compiler@22.0.0)(typescript@6.0.3))(tailwindcss@4.2.2)(tslib@2.8.1)(typescript@6.0.3) postcss: 8.5.6 tailwindcss: 4.2.2 - vitest: 4.1.4(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.8.9)(jsdom@29.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0)) + vitest: 4.1.4(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.8.9)(jsdom@29.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.9.0)) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -16697,6 +16719,67 @@ snapshots: - terser - tsx - yaml + optional: true + + '@angular/build@21.2.7(460f9a170101f7b74a9e1242f5065a43)': + dependencies: + '@ampproject/remapping': 2.3.0 + '@angular-devkit/architect': 0.2102.7(chokidar@5.0.0) + '@angular/compiler': 22.0.0 + '@angular/compiler-cli': 22.0.0(@angular/compiler@22.0.0)(typescript@6.0.3) + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-split-export-declaration': 7.24.7 + '@inquirer/confirm': 5.1.21(@types/node@25.6.0) + '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0)) + beasties: 0.4.1 + browserslist: 4.28.2 + esbuild: 0.27.3 + https-proxy-agent: 7.0.6 + istanbul-lib-instrument: 6.0.3 + jsonc-parser: 3.3.1 + listr2: 9.0.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + parse5-html-rewriting-stream: 8.0.0 + picomatch: 4.0.4 + piscina: 5.1.4 + rolldown: 1.0.0-rc.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + sass: 1.97.3 + semver: 7.7.4 + source-map-support: 0.5.21 + tinyglobby: 0.2.15 + tslib: 2.8.1 + typescript: 6.0.3 + undici: 7.24.4 + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0) + watchpack: 2.5.1 + optionalDependencies: + '@angular/core': 22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1) + '@angular/localize': 21.2.8(@angular/compiler-cli@22.0.0(@angular/compiler@22.0.0)(typescript@6.0.3))(@angular/compiler@22.0.0) + '@angular/platform-browser': 22.0.0(@angular/animations@22.0.0(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1)))(@angular/common@22.0.0(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1)) + '@angular/platform-server': 22.0.0(@angular/common@22.0.0(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/compiler@22.0.0)(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1))(@angular/platform-browser@22.0.0(@angular/animations@22.0.0(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1)))(@angular/common@22.0.0(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1))(rxjs@7.8.2))(@angular/core@22.0.0(@angular/compiler@22.0.0)(rxjs@7.8.2)(zone.js@0.16.1)))(rxjs@7.8.2) + '@angular/ssr': 21.2.7(6fc05730774a8276e69579bed1c76f88) + less: 4.4.2 + lmdb: 3.5.1 + ng-packagr: 21.2.2(@angular/compiler-cli@22.0.0(@angular/compiler@22.0.0)(typescript@6.0.3))(tailwindcss@4.2.2)(tslib@2.8.1)(typescript@6.0.3) + postcss: 8.5.6 + tailwindcss: 4.2.2 + vitest: 4.1.4(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.8.9)(jsdom@29.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.9.0)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@types/node' + - chokidar + - jiti + - lightningcss + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml '@angular/build@21.2.7(48eff4a31a89559d77cb149cc537e7e0)': dependencies: @@ -23693,7 +23776,7 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.20.1 - jiti: 2.6.1 + jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 @@ -24536,13 +24619,18 @@ snapshots: transitivePeerDependencies: - typescript - '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0))': + '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0))': dependencies: - vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0) - '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0))': + '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0))': dependencies: - vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0) + + '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0))': + dependencies: + vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0) + optional: true '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0))': dependencies: @@ -24593,20 +24681,6 @@ snapshots: - utf-8-validate - vite - '@vitest/browser-playwright@4.1.4(playwright@1.59.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0))(vitest@4.1.4)': - dependencies: - '@vitest/browser': 4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0))(vitest@4.1.4) - '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0)) - playwright: 1.59.1 - tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.8.9)(jsdom@29.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0)) - transitivePeerDependencies: - - bufferutil - - msw - - utf-8-validate - - vite - optional: true - '@vitest/browser-playwright@4.1.4(playwright@1.59.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0))(vitest@4.1.4)': dependencies: '@vitest/browser': 4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0))(vitest@4.1.4) @@ -24652,24 +24726,6 @@ snapshots: - utf-8-validate - vite - '@vitest/browser@4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0))(vitest@4.1.4)': - dependencies: - '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0)) - '@vitest/utils': 4.1.4 - magic-string: 0.30.21 - pngjs: 7.0.0 - sirv: 3.0.2 - tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.8.9)(jsdom@29.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0)) - ws: 8.20.0 - transitivePeerDependencies: - - bufferutil - - msw - - utf-8-validate - - vite - optional: true - '@vitest/browser@4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0))(vitest@4.1.4)': dependencies: '@blazediff/core': 1.9.1 @@ -24747,15 +24803,6 @@ snapshots: optionalDependencies: vite: 8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.9.0) - '@vitest/mocker@4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0))': - dependencies: - '@vitest/spy': 4.1.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0) - optional: true - '@vitest/mocker@4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.4 @@ -28953,8 +29000,7 @@ snapshots: jiti@2.6.1: {} - jiti@2.7.0: - optional: true + jiti@2.7.0: {} joi@18.1.2: dependencies: @@ -31497,7 +31543,7 @@ snapshots: postcss-loader@8.2.0(@rspack/core@1.6.8(@swc/helpers@0.5.21))(postcss@8.5.6)(typescript@6.0.3)(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)): dependencies: cosmiconfig: 9.0.1(typescript@6.0.3) - jiti: 2.6.1 + jiti: 2.7.0 postcss: 8.5.6 semver: 7.8.0 optionalDependencies: @@ -31510,7 +31556,7 @@ snapshots: postcss-loader@8.2.0(@rspack/core@1.7.11(@swc/helpers@0.5.21))(postcss@8.5.6)(typescript@6.0.3)(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.27.3)): dependencies: cosmiconfig: 9.0.1(typescript@6.0.3) - jiti: 2.6.1 + jiti: 2.7.0 postcss: 8.5.6 semver: 7.8.0 optionalDependencies: @@ -31522,7 +31568,7 @@ snapshots: postcss-loader@8.2.1(@rspack/core@1.6.8(@swc/helpers@0.5.21))(postcss@8.5.13)(typescript@6.0.3)(webpack@5.106.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.28.0)): dependencies: cosmiconfig: 9.0.1(typescript@6.0.3) - jiti: 2.6.1 + jiti: 2.7.0 postcss: 8.5.13 semver: 7.8.0 optionalDependencies: @@ -31534,7 +31580,7 @@ snapshots: postcss-loader@8.2.1(@rspack/core@1.7.11(@swc/helpers@0.5.21))(postcss@8.5.13)(typescript@6.0.2)(webpack@5.105.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.28.0)): dependencies: cosmiconfig: 9.0.1(typescript@6.0.2) - jiti: 2.6.1 + jiti: 2.7.0 postcss: 8.5.13 semver: 7.8.0 optionalDependencies: @@ -31546,7 +31592,7 @@ snapshots: postcss-loader@8.2.1(@rspack/core@1.7.11(@swc/helpers@0.5.21))(postcss@8.5.13)(typescript@6.0.2)(webpack@5.106.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.28.0)): dependencies: cosmiconfig: 9.0.1(typescript@6.0.2) - jiti: 2.6.1 + jiti: 2.7.0 postcss: 8.5.13 semver: 7.8.0 optionalDependencies: @@ -31558,7 +31604,7 @@ snapshots: postcss-loader@8.2.1(@rspack/core@1.7.11(@swc/helpers@0.5.21))(postcss@8.5.13)(typescript@6.0.3)(webpack@5.106.2(@swc/core@1.15.24(@swc/helpers@0.5.21))(esbuild@0.28.0)): dependencies: cosmiconfig: 9.0.1(typescript@6.0.3) - jiti: 2.6.1 + jiti: 2.7.0 postcss: 8.5.13 semver: 7.8.0 optionalDependencies: @@ -34006,7 +34052,7 @@ snapshots: - supports-color - typescript - vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.9.0): + vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -34018,14 +34064,14 @@ snapshots: '@types/node': 25.6.0 fsevents: 2.3.3 jiti: 2.6.1 - less: 4.6.4 + less: 4.4.2 lightningcss: 1.32.0 - sass: 1.99.0 + sass: 1.97.3 sass-embedded: 1.99.0 - terser: 5.46.2 + terser: 5.46.0 yaml: 2.9.0 - vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0): + vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -34036,15 +34082,15 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 fsevents: 2.3.3 - jiti: 2.7.0 - less: 4.4.2 + jiti: 2.6.1 + less: 4.6.4 lightningcss: 1.32.0 sass: 1.97.3 sass-embedded: 1.99.0 - terser: 5.46.0 + terser: 5.46.2 yaml: 2.9.0 - vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0): + vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -34055,15 +34101,15 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 fsevents: 2.3.3 - jiti: 2.7.0 - less: 4.4.2 + jiti: 2.6.1 + less: 4.6.4 lightningcss: 1.32.0 - sass: 1.97.3 + sass: 1.99.0 sass-embedded: 1.99.0 terser: 5.46.2 yaml: 2.9.0 - vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0): + vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.4.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -34075,14 +34121,15 @@ snapshots: '@types/node': 25.6.0 fsevents: 2.3.3 jiti: 2.7.0 - less: 4.6.4 + less: 4.4.2 lightningcss: 1.32.0 sass: 1.97.3 sass-embedded: 1.99.0 - terser: 5.46.2 + terser: 5.46.0 yaml: 2.9.0 + optional: true - vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.9.0): + vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -34096,30 +34143,31 @@ snapshots: jiti: 2.7.0 less: 4.6.4 lightningcss: 1.32.0 - sass: 1.99.0 + sass: 1.97.3 sass-embedded: 1.99.0 terser: 5.46.2 yaml: 2.9.0 - vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.9.0): + vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(less@4.6.4)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.9.0): dependencies: - lightningcss: 1.32.0 + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.9 - rolldown: 1.0.0-rc.15 + rollup: 4.60.1 tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.6.0 - esbuild: 0.28.0 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 less: 4.6.4 + lightningcss: 1.32.0 sass: 1.99.0 sass-embedded: 1.99.0 terser: 5.46.2 yaml: 2.9.0 - vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0): + vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -34130,13 +34178,12 @@ snapshots: '@types/node': 25.6.0 esbuild: 0.28.0 fsevents: 2.3.3 - jiti: 2.7.0 - less: 4.4.2 - sass: 1.97.3 + jiti: 2.6.1 + less: 4.6.4 + sass: 1.99.0 sass-embedded: 1.99.0 terser: 5.46.2 yaml: 2.9.0 - optional: true vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0): dependencies: @@ -34222,39 +34269,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.4(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.8.9)(jsdom@29.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.4 - '@vitest/runner': 4.1.4 - '@vitest/snapshot': 4.1.4 - '@vitest/spy': 4.1.4 - '@vitest/utils': 4.1.4 - es-module-lexer: 2.0.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.0.0 - tinybench: 2.9.0 - tinyexec: 1.1.1 - tinyglobby: 0.2.16 - tinyrainbow: 3.1.0 - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 25.6.0 - '@vitest/browser-playwright': 4.1.4(playwright@1.59.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.4.2)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0))(vitest@4.1.4) - '@vitest/coverage-v8': 4.1.4(@vitest/browser@4.1.4)(vitest@4.1.4) - '@vitest/ui': 4.1.4(vitest@4.1.4) - happy-dom: 20.8.9 - jsdom: 29.0.2 - transitivePeerDependencies: - - msw - optional: true - vitest@4.1.4(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.8.9)(jsdom@29.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.97.3)(terser@5.46.2)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 66af73cd59..1057cc19c8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -116,6 +116,7 @@ catalog: eslint-plugin-storybook: 10.3.5 front-matter: ^4.0.2 es-toolkit: ^1.45.1 + esbuild: 0.28.0 execa: ^9.6.1 fast-glob: ^3.3.3 fd-package-json: ^2.0.0 diff --git a/tools/build/shared-plugins.ts b/tools/build/shared-plugins.ts index 192c456356..ae6583d269 100644 --- a/tools/build/shared-plugins.ts +++ b/tools/build/shared-plugins.ts @@ -39,6 +39,7 @@ export function oxcDtsPlugin(pkgDir: string): Plugin { async writeBundle(options, bundle) { const { isolatedDeclarationSync } = await import('oxc-transform'); const outDir = options.dir!; + const emittedDeclarations = new Set(); for (const [fileName, chunk] of Object.entries(bundle)) { if (chunk.type !== 'chunk' || !chunk.facadeModuleId) continue; @@ -68,6 +69,7 @@ export function oxcDtsPlugin(pkgDir: string): Plugin { const dtsPath = join(outDir, fileName.replace(/\.js$/, '.d.ts')); mkdirSync(dirname(dtsPath), { recursive: true }); writeFileSync(dtsPath, result.code); + emittedDeclarations.add(resolve(dtsPath)); } } catch (error) { this.error( @@ -86,12 +88,9 @@ export function oxcDtsPlugin(pkgDir: string): Plugin { const relPath = relative(pkgDir, tsFile); const dtsOut = join(outDir, relPath.replace(/\.ts$/, '.d.ts')); - try { - readFileSync(dtsOut); - continue; - } catch { - // not yet generated - } + // Only skip declarations emitted by this build. Existing output may + // describe an older version of a type-only source file. + if (emittedDeclarations.has(resolve(dtsOut))) continue; try { const source = readFileSync(tsFile, 'utf-8'); From b3482e5e06b474869cdc21e01ffb3267da35f267 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 00:40:44 +0000 Subject: [PATCH 3/9] ci: repair Nx Agent bootstrap prerequisites Use the shared Node pin through nvm and install only the Playwright browsers in this workspace. --- .github/workflows/compiler-compat.yml | 2 +- .node-version | 2 +- .nvmrc | 1 + .nx/workflows/agents.yaml | 12 +++--------- CONTRIBUTING.md | 3 +++ 5 files changed, 9 insertions(+), 11 deletions(-) create mode 120000 .nvmrc diff --git a/.github/workflows/compiler-compat.yml b/.github/workflows/compiler-compat.yml index 87e0c23bea..2023c1989c 100644 --- a/.github/workflows/compiler-compat.yml +++ b/.github/workflows/compiler-compat.yml @@ -248,7 +248,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '24.15.0' + node-version-file: .node-version - run: npm install --global corepack@0.31.0 - run: corepack enable - name: Install workspace without unrelated package prepare builds diff --git a/.node-version b/.node-version index 045200741c..5bf4400f22 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -24.14.1 \ No newline at end of file +24.15.0 diff --git a/.nvmrc b/.nvmrc new file mode 120000 index 0000000000..070266a268 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +.node-version \ No newline at end of file diff --git a/.nx/workflows/agents.yaml b/.nx/workflows/agents.yaml index 4007a327a2..14ccb307f5 100644 --- a/.nx/workflows/agents.yaml +++ b/.nx/workflows/agents.yaml @@ -23,10 +23,8 @@ launch-templates: uses: 'nrwl/nx-cloud-workflows/v5/workflow-steps/checkout/main.yaml' - name: Install Node - # Reads `.node-version` (24.11.0). The base image ships Node 24.14, so we - # install the pinned version to preserve the old `node-version-file` - # behavior. Verify this reusable step picks up `.node-version`; if not, - # pass the version explicitly via its inputs. + # This step reads .nvmrc, which points to the shared .node-version pin. + # Install Node 24.15.0 instead of falling back to the Node 24.14 image. uses: 'nrwl/nx-cloud-workflows/v5/workflow-steps/install-node/main.yaml' - name: Enable Corepack and pnpm @@ -53,10 +51,6 @@ launch-templates: pnpm install --frozen-lockfile --prefer-offline - name: Install browsers - # Agents run both `test` (Vitest + Chromium shell) and `e2e` (Playwright - # + Cypress) tasks, so the full browser superset is installed once. This - # is heavier than either old job alone but is required because any agent - # may receive either task family. + # Vitest browser tests and Playwright E2E targets share these binaries. script: | pnpm playwright install --with-deps - pnpm cypress install diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6fc23a601..fce99bc3f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,9 @@ Source code for the Analog framework exists under the `packages/` folder. To con Analog uses [pnpm](https://pnpm.io/) to manage its dependencies. +Use the Node.js version pinned in `.node-version`. The `.nvmrc` symlink shares +that pin with nvm and the Nx Agents installer. + Before opening a pull request, run the following command from the root folder to make sure your development dependencies are up-to-date: ```shell From 3e0d04784ed1fe7b529f3cf621274289487b5961 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 06:02:37 +0000 Subject: [PATCH 4/9] fix(vite-plugin-angular): preserve server mode in environment builds Let Vite environment defines control ngServerMode instead of shared transforms. Co-Authored-By: OpenAI Codex --- .../scripts/compiler-vite-fixture.mjs | 56 ++++++++++++++++++- .../src/lib/angular-build-optimizer-plugin.ts | 4 +- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/vite-plugin-angular/scripts/compiler-vite-fixture.mjs b/packages/vite-plugin-angular/scripts/compiler-vite-fixture.mjs index d86ee60081..30105e12f8 100644 --- a/packages/vite-plugin-angular/scripts/compiler-vite-fixture.mjs +++ b/packages/vite-plugin-angular/scripts/compiler-vite-fixture.mjs @@ -2,7 +2,12 @@ import assert from 'node:assert/strict'; import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { createRequire } from 'node:module'; -import { build, createServer, version as viteVersion } from 'vite'; +import { + build, + createBuilder, + createServer, + version as viteVersion, +} from 'vite'; import angular from '@analogjs/vite-plugin-angular'; const require = createRequire(import.meta.url); @@ -86,6 +91,55 @@ async function buildCase(name, options, expected) { results.push({ name, passed: true }); } +async function environmentDefinesCase() { + const entry = join(root, 'src/environment.ts'); + await writeFile( + entry, + 'declare const ngServerMode: boolean; export const server = typeof ngServerMode !== "undefined" && ngServerMode;', + ); + const builder = await createBuilder({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + angular({ + workspaceRoot: root, + tsconfig: join(root, 'tsconfig.json'), + include: [entry], + jit: false, + liveReload: false, + }), + ], + build: { write: false, minify: false, lib: { entry, formats: ['es'] } }, + environments: { + client: { consumer: 'client' }, + ssr: { consumer: 'server', build: { ssr: true } }, + }, + }); + for (const [name, expected] of [ + ['client', false], + ['ssr', true], + ]) { + const result = await builder.build(builder.environments[name]); + const bundles = Array.isArray(result) ? result : [result]; + const chunk = bundles + .flatMap((bundle) => bundle.output ?? []) + .find((file) => file.type === 'chunk' && file.isEntry); + assert.ok(chunk, `${name}: environment entry emitted`); + const output = await import( + `data:text/javascript;base64,${Buffer.from(chunk.code).toString('base64')}` + ); + assert.equal( + output.server, + expected, + `${name}: environment-specific Angular server mode`, + ); + } + results.push({ name: 'environment-server-mode', passed: true }); +} + +await environmentDefinesCase(); + await buildCase( 'normal-replacement', { diff --git a/packages/vite-plugin-angular/src/lib/angular-build-optimizer-plugin.ts b/packages/vite-plugin-angular/src/lib/angular-build-optimizer-plugin.ts index 67a3f8b746..88fa653b9f 100644 --- a/packages/vite-plugin-angular/src/lib/angular-build-optimizer-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/angular-build-optimizer-plugin.ts @@ -58,7 +58,9 @@ export function buildOptimizerPlugin({ ngDevMode: 'false', ngJitMode: 'false', ngI18nClosureMode: 'false', - ngServerMode: `${!!userConfig.build?.ssr}`, + // The environment-aware Vite define owns ngServerMode. A + // shared transform define would fold server code to false + // before that environment's override can be applied. } : undefined, }, From 52ec53a2d31b7deca4ce887aaa7b78e899aed005 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 06:27:53 +0000 Subject: [PATCH 5/9] fix(vite-plugin-angular): retain incremental state across environment builds Keep the real incremental builder separate from the production diagnostics wrapper. Co-Authored-By: OpenAI Codex --- .../src/lib/angular-vite-plugin.ts | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts index ffa4b1987d..ddad976a03 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts @@ -252,6 +252,9 @@ export function angular(options?: PluginOptions): Plugin[] { const ts = require('typescript'); let builder: ts.BuilderProgram | ts.EmitAndSemanticDiagnosticsBuilderProgram; + let incrementalBuilder: + | ts.EmitAndSemanticDiagnosticsBuilderProgram + | undefined; let nextProgram: NgtscProgram | undefined; let cachedHost: ts.CompilerHost | undefined; let cachedHostKey: string | undefined; @@ -1551,8 +1554,9 @@ export function angular(options?: PluginOptions): Plugin[] { */ let typeScriptProgram: ts.Program; let angularCompiler: NgtscProgram['compiler']; + let createdBuilder: ts.EmitAndSemanticDiagnosticsBuilderProgram; const oldBuilder = - builder ?? ts.readBuilderProgram(tsCompilerOptions, host); + incrementalBuilder ?? ts.readBuilderProgram(tsCompilerOptions, host); if (!jit) { // Create the Angular specific program that contains the Angular compiler @@ -1566,7 +1570,7 @@ export function angular(options?: PluginOptions): Plugin[] { typeScriptProgram = angularProgram.compiler.getCurrentProgram(); augmentProgramWithVersioning(typeScriptProgram); - builder = ts.createEmitAndSemanticDiagnosticsBuilderProgram( + createdBuilder = ts.createEmitAndSemanticDiagnosticsBuilderProgram( typeScriptProgram, host, oldBuilder as ts.EmitAndSemanticDiagnosticsBuilderProgram, @@ -1574,21 +1578,22 @@ export function angular(options?: PluginOptions): Plugin[] { nextProgram = angularProgram; } else { - builder = ts.createEmitAndSemanticDiagnosticsBuilderProgram( + createdBuilder = ts.createEmitAndSemanticDiagnosticsBuilderProgram( rootNames, tsCompilerOptions, host, oldBuilder as ts.EmitAndSemanticDiagnosticsBuilderProgram, ); - typeScriptProgram = builder.getProgram(); + typeScriptProgram = createdBuilder.getProgram(); } - if (!watchMode) { - // When not in watch mode, the startup cost of the incremental analysis can be avoided by - // using an abstract builder that only wraps a TypeScript program. - builder = ts.createAbstractBuilder(typeScriptProgram, host, oldBuilder); - } + // Abstract builders have no incremental state in older TypeScript versions. + // Retain the real builder for the next environment or compilation pass. + incrementalBuilder = createdBuilder; + builder = watchMode + ? createdBuilder + : ts.createAbstractBuilder(typeScriptProgram, host, oldBuilder); if (angularCompiler!) { await angularCompiler.analyzeAsync(); From 1a3c4000f4fde9e868fa8626e71dd6ef72823672 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 14:03:11 +0000 Subject: [PATCH 6/9] fix(platform): apply shared SSR route-policy prerequisite Include the tested route-policy fix required by the existing no-SSR E2E assertion. Co-Authored-By: OpenAI Codex --- .../features/server/server-side-rendering.md | 26 +++++-- .../src/lib/nitro/analog-nitro-plugin.spec.ts | 8 +++ .../src/lib/nitro/analog-nitro-plugin.ts | 21 +++--- .../src/lib/nitro/ssr-renderer.spec.ts | 71 +++++++++++++++++++ 4 files changed, 110 insertions(+), 16 deletions(-) create mode 100644 packages/platform/src/lib/nitro/ssr-renderer.spec.ts diff --git a/apps/docs-analog/src/content/features/server/server-side-rendering.md b/apps/docs-analog/src/content/features/server/server-side-rendering.md index a8864f824f..1971ddc9b5 100644 --- a/apps/docs-analog/src/content/features/server/server-side-rendering.md +++ b/apps/docs-analog/src/content/features/server/server-side-rendering.md @@ -28,8 +28,16 @@ For more information about externals with SSR, check out the [Vite documentation SSR is enabled by default. For a hybrid approach, you can specify some routes to only be rendered client-side, and not be server side rendered. This is done through the `routeRules` configuration object by specifying an `ssr` option. +In Analog v3, configure these rules on the separate Nitro plugin. A more specific +`ssr: true` rule enables server rendering under a parent rule that disables it. + ```ts // https://vitejs.dev/config/ +import { defineConfig } from 'vite'; +import analog from '@analogjs/platform'; +import angular from '@analogjs/vite-plugin-angular'; +import { nitro } from 'nitro/vite'; + export default defineConfig(({ mode }) => ({ // ...other config plugins: [ @@ -37,14 +45,18 @@ export default defineConfig(({ mode }) => ({ prerender: { routes: ['/', '/404.html'], }, - nitro: { - routeRules: { - // All admin URLs are only rendered on the client - '/admin/**': { ssr: false }, + }), + angular(), + nitro({ + routeRules: { + // All admin URLs are only rendered on the client + '/admin/**': { ssr: false }, - // Render a 404 page as a fallback page - '/404.html': { ssr: false }, - }, + // A specific child can opt back into server rendering + '/admin/help': { ssr: true }, + + // Render a 404 page as a fallback page + '/404.html': { ssr: false }, }, }), ], diff --git a/packages/platform/src/lib/nitro/analog-nitro-plugin.spec.ts b/packages/platform/src/lib/nitro/analog-nitro-plugin.spec.ts index b01d874839..af13bbdfa0 100644 --- a/packages/platform/src/lib/nitro/analog-nitro-plugin.spec.ts +++ b/packages/platform/src/lib/nitro/analog-nitro-plugin.spec.ts @@ -242,6 +242,10 @@ describe('analogNitroPlugin', () => { routeRules: { '/buffered': { streaming: false }, '/no-ssr': { ssr: false }, + '/no-ssr/help': { + ssr: true, + headers: { 'x-analog-no-ssr': 'true', 'x-example': 'retained' }, + }, '/default': {}, }, }, @@ -255,6 +259,10 @@ describe('analogNitroPlugin', () => { expect(nitroMock.options.routeRules['/no-ssr'].headers).toEqual({ 'x-analog-no-ssr': 'true', }); + expect(nitroMock.options.routeRules['/no-ssr/help'].headers).toEqual({ + 'x-analog-no-ssr': 'false', + 'x-example': 'retained', + }); expect(nitroMock.options.routeRules['/default'].headers).toBeUndefined(); }); }); diff --git a/packages/platform/src/lib/nitro/analog-nitro-plugin.ts b/packages/platform/src/lib/nitro/analog-nitro-plugin.ts index 87bfeadaf2..4e428e18f9 100644 --- a/packages/platform/src/lib/nitro/analog-nitro-plugin.ts +++ b/packages/platform/src/lib/nitro/analog-nitro-plugin.ts @@ -591,7 +591,7 @@ export function analogNitroPlugin(options: Options = {}): Plugin { * the env-runner and in prod via the `__nitro_vite_envs__` global set up by * nitro/vite's `prodSetup`). */ -function generateSsrRendererVirtual(template: string): string { +export function generateSsrRendererVirtual(template: string): string { return ` import { defineHandler } from 'nitro/h3'; import ssr from '#analog/ssr'; @@ -600,11 +600,10 @@ const TEMPLATE = ${JSON.stringify(template)}; export default defineHandler(async (event) => { event.res.headers.set('content-type', 'text/html; charset=utf-8'); - // 'x-analog-no-ssr' is stamped on response headers by - // injectAnalogRouteRuleHeaders for routeRules with \`ssr: false\`. Nitro - // applies routeRule headers to the response before the renderer fires, - // so we can short-circuit by reading them here. - if (event.res.headers.get('x-analog-no-ssr') === 'true') { + // Matched header rules are available before response middleware applies them. + const noSsr = event.context.routeRules?.headers?.['x-analog-no-ssr'] + ?? event.res.headers.get('x-analog-no-ssr'); + if (noSsr === 'true') { return TEMPLATE; } const service = ssr.default ?? ssr; @@ -748,7 +747,8 @@ function sanitizeNitroBundlerConfig(rollupConfig: { output?: unknown }): void { /** * Walks Nitro's resolved routeRules and stamps `x-analog-no-ssr: true` onto - * any rule with `ssr: false`, and `x-analog-no-streaming: true` onto any rule + * any rule with `ssr: false`, resetting it for explicit `ssr: true`, and + * `x-analog-no-streaming: true` onto any rule * with `streaming: false`. Kept as response-header hints for downstream * consumers (CDN, edge logic); the actual SSR short-circuit happens inside * the SSR renderer virtual above, and the router falls back to a buffered @@ -768,8 +768,11 @@ export function injectAnalogRouteRuleHeaders(nitro: Nitro): void { if (!routeRules) return; for (const rule of Object.values(routeRules)) { - if (rule?.ssr === false) { - rule.headers = { ...rule.headers, 'x-analog-no-ssr': 'true' }; + if (typeof rule?.ssr === 'boolean') { + rule.headers = { + ...rule.headers, + 'x-analog-no-ssr': String(!rule.ssr), + }; } if (rule?.streaming === false) { rule.headers = { ...rule.headers, 'x-analog-no-streaming': 'true' }; diff --git a/packages/platform/src/lib/nitro/ssr-renderer.spec.ts b/packages/platform/src/lib/nitro/ssr-renderer.spec.ts new file mode 100644 index 0000000000..5698e7231b --- /dev/null +++ b/packages/platform/src/lib/nitro/ssr-renderer.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { generateSsrRendererVirtual } from './analog-nitro-plugin'; + +const template = ''; + +function createRenderer(fetch: (request: Request) => Promise) { + const source = generateSsrRendererVirtual(template) + .replace("import { defineHandler } from 'nitro/h3';", '') + .replace("import ssr from '#analog/ssr';", '') + .replace('export default', 'return'); + const evaluate = new Function('defineHandler', 'ssr', source); + return evaluate((handler: unknown) => handler, { fetch }); +} + +function createEvent(ssr?: boolean) { + return { + req: new Request('http://localhost/client'), + res: { headers: new Headers() }, + context: { + routeRules: { + headers: ssr === undefined ? {} : { 'x-analog-no-ssr': String(!ssr) }, + }, + }, + }; +} + +describe('SSR renderer route rules', () => { + it('returns the client template before response headers are applied', async () => { + const fetch = vi.fn(async () => new Response('server-rendered content')); + const render = createRenderer(fetch); + const event = createEvent(false); + + expect(await render(event)).toBe(template); + expect(fetch).not.toHaveBeenCalled(); + expect(event.res.headers.get('content-type')).toBe( + 'text/html; charset=utf-8', + ); + }); + + it('honors explicit SSR over an inherited no-SSR header', async () => { + const response = new Response('server-rendered content'); + const fetch = vi.fn(async () => response); + const render = createRenderer(fetch); + const event = createEvent(true); + event.res.headers.set('x-analog-no-ssr', 'true'); + + expect(await render(event)).toBe(response); + expect(fetch).toHaveBeenCalledWith(event.req); + }); + + it('retains the header fallback when no SSR rule is resolved', async () => { + const fetch = vi.fn(async () => new Response('server-rendered content')); + const render = createRenderer(fetch); + const event = createEvent(); + event.res.headers.set('x-analog-no-ssr', 'true'); + + expect(await render(event)).toBe(template); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('renders normally when no rule disables SSR', async () => { + const response = new Response('server-rendered content'); + const fetch = vi.fn(async () => response); + const render = createRenderer(fetch); + const event = createEvent(); + + expect(await render(event)).toBe(response); + expect(fetch).toHaveBeenCalledWith(event.req); + }); +}); From cf975762f3b98fe4268f783f26f9d68f007efce4 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 16:01:08 +0000 Subject: [PATCH 7/9] fix(platform): filter caller SSR hints before renderer dispatch Restore caller-header filtering from eeda7af8d5c5123e09a0f5da9896ccdbafa86791. Preserve Request adapter fields, runtime context and cancellation; verify configured SSR rules over built HTTP. The route-policy contribution owns this shared prerequisite for independent renderer copies in the split. Refs: analogjs/analog#2523, analogjs/analog#2516 --- apps/analog-app-e2e/tests/ssr-policy.spec.ts | 23 +++++++ apps/analog-app/vite.config.ts | 1 + .../src/lib/nitro/analog-nitro-plugin.ts | 13 ++++ .../src/lib/nitro/ssr-renderer.spec.ts | 60 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 apps/analog-app-e2e/tests/ssr-policy.spec.ts diff --git a/apps/analog-app-e2e/tests/ssr-policy.spec.ts b/apps/analog-app-e2e/tests/ssr-policy.spec.ts new file mode 100644 index 0000000000..cea868d6bc --- /dev/null +++ b/apps/analog-app-e2e/tests/ssr-policy.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; + +test('caller hints cannot disable SSR on an enabled route', async ({ + request, +}) => { + for (const headers of [{}, { 'x-analog-no-ssr': 'true' }]) { + const response = await request.get('/products/1?ssr-policy=1', { headers }); + expect(response.status()).toBe(200); + const html = await response.text(); + expect(html).toContain('Product Details'); + expect(html).toContain('ng-server-context'); + } +}); + +test('caller hints cannot enable SSR on a configured client route', async ({ + request, +}) => { + const response = await request.get('/cart', { + headers: { 'x-analog-no-ssr': 'false' }, + }); + expect(response.status()).toBe(200); + expect(await response.text()).not.toContain('ng-server-context'); +}); diff --git a/apps/analog-app/vite.config.ts b/apps/analog-app/vite.config.ts index 1d24c4d51c..afa10a0491 100644 --- a/apps/analog-app/vite.config.ts +++ b/apps/analog-app/vite.config.ts @@ -94,6 +94,7 @@ export default defineConfig(async ({ mode, command }) => { }), nitro({ routeRules: { + '/products/**': { ssr: true }, '/client': { ssr: false }, '/cart/**': { ssr: false }, '/404.html': { ssr: false }, diff --git a/packages/platform/src/lib/nitro/analog-nitro-plugin.ts b/packages/platform/src/lib/nitro/analog-nitro-plugin.ts index 4e428e18f9..426ed46c15 100644 --- a/packages/platform/src/lib/nitro/analog-nitro-plugin.ts +++ b/packages/platform/src/lib/nitro/analog-nitro-plugin.ts @@ -607,6 +607,19 @@ export default defineHandler(async (event) => { return TEMPLATE; } const service = ssr.default ?? ssr; + if (event.req.headers.has('x-analog-no-ssr')) { + const headers = new Headers(event.req.headers); + headers.delete('x-analog-no-ssr'); + // Copy public fields because server Request adapters lack native constructor state. + const request = new Request(event.req.url, { + method: event.req.method, + headers, + signal: event.req.signal, + ...(event.req.method === 'GET' || event.req.method === 'HEAD' ? {} : { body: event.req.body, duplex: 'half' }), + }); + if (event.req.runtime) Object.defineProperty(request, 'runtime', { value: event.req.runtime }); + return service.fetch(request); + } return service.fetch(event.req); }); `; diff --git a/packages/platform/src/lib/nitro/ssr-renderer.spec.ts b/packages/platform/src/lib/nitro/ssr-renderer.spec.ts index 5698e7231b..8f2821c130 100644 --- a/packages/platform/src/lib/nitro/ssr-renderer.spec.ts +++ b/packages/platform/src/lib/nitro/ssr-renderer.spec.ts @@ -26,10 +26,70 @@ function createEvent(ssr?: boolean) { } describe('SSR renderer route rules', () => { + it.each([undefined, true])( + 'strips caller SSR hints with rule %s', + async (ssr) => { + const fetch = vi.fn( + async (_request: Request) => new Response('rendered'), + ); + const event = createEvent(ssr); + event.req.headers.set('X-Analog-No-SSR', 'true'); + + await createRenderer(fetch)(event); + + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0][0].headers.has('x-analog-no-ssr')).toBe(false); + expect(event.req.headers.get('x-analog-no-ssr')).toBe('true'); + }, + ); + + it.each(['GET', 'HEAD', 'POST'])( + 'preserves a %s request adapter when filtering', + async (method) => { + const fetch = vi.fn( + async (_request: Request) => new Response('rendered'), + ); + const abort = new AbortController(); + const runtime = { node: { req: {}, res: {} } }; + const event = { + ...createEvent(true), + req: { + url: 'http://localhost/page?query=1', + method, + headers: new Headers({ + 'x-analog-no-ssr': 'true', + 'x-custom': 'retained', + }), + body: method === 'POST' ? new Response('request payload').body : null, + signal: abort.signal, + runtime, + }, + }; + + await createRenderer(fetch)(event); + + const forwarded = fetch.mock.calls[0][0]; + expect(forwarded).toBeInstanceOf(Request); + expect(forwarded.url).toBe(event.req.url); + expect(forwarded.method).toBe(method); + expect(forwarded.headers.get('x-custom')).toBe('retained'); + expect(forwarded.headers.has('x-analog-no-ssr')).toBe(false); + expect(Reflect.get(forwarded, 'runtime')).toBe(runtime); + expect(await forwarded.text()).toBe( + method === 'POST' ? 'request payload' : '', + ); + const reason = new Error('disconnected'); + abort.abort(reason); + expect(forwarded.signal.aborted).toBe(true); + expect(forwarded.signal.reason).toBe(reason); + }, + ); + it('returns the client template before response headers are applied', async () => { const fetch = vi.fn(async () => new Response('server-rendered content')); const render = createRenderer(fetch); const event = createEvent(false); + event.req.headers.set('x-analog-no-ssr', 'false'); expect(await render(event)).toBe(template); expect(fetch).not.toHaveBeenCalled(); From e767273de6e3b8589b39c815ccdae4db1a6b7e62 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 16:05:08 +0000 Subject: [PATCH 8/9] test(platform): type HTTP SSR policy header cases --- apps/analog-app-e2e/tests/ssr-policy.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/analog-app-e2e/tests/ssr-policy.spec.ts b/apps/analog-app-e2e/tests/ssr-policy.spec.ts index cea868d6bc..2c1b5aa86b 100644 --- a/apps/analog-app-e2e/tests/ssr-policy.spec.ts +++ b/apps/analog-app-e2e/tests/ssr-policy.spec.ts @@ -3,8 +3,10 @@ import { expect, test } from '@playwright/test'; test('caller hints cannot disable SSR on an enabled route', async ({ request, }) => { - for (const headers of [{}, { 'x-analog-no-ssr': 'true' }]) { - const response = await request.get('/products/1?ssr-policy=1', { headers }); + for (const supplied of [false, true]) { + const response = await request.get('/products/1?ssr-policy=1', { + headers: supplied ? { 'x-analog-no-ssr': 'true' } : {}, + }); expect(response.status()).toBe(200); const html = await response.text(); expect(html).toContain('Product Details'); From fe128cbf9492af7eac3c7f93009a453edc6e280c Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 17:18:08 +0000 Subject: [PATCH 9/9] fix(vite-plugin-angular): recompile changed tsconfig roots Recompile on tsconfig changes and verify root replacement while integration includes remain. Use Node 20 for legacy consumer fixtures and trigger compatibility checks for Node pin changes. Co-Authored-By: OpenAI Codex --- .github/workflows/compiler-compat.yml | 18 ++++++++++-- .github/workflows/conformance.yml | 2 ++ .../scripts/compiler-vite-smoke.mjs | 4 ++- .../compilation-api-plugin.spec.ts | 28 ++++++++++++++++++- .../compilation-api/compilation-api-plugin.ts | 4 +-- 5 files changed, 49 insertions(+), 7 deletions(-) diff --git a/.github/workflows/compiler-compat.yml b/.github/workflows/compiler-compat.yml index 2023c1989c..fbc8f51a1f 100644 --- a/.github/workflows/compiler-compat.yml +++ b/.github/workflows/compiler-compat.yml @@ -42,6 +42,7 @@ on: - 'package.json' - 'pnpm-workspace.yaml' - 'pnpm-lock.yaml' + - '.node-version' - '.github/workflows/compiler-compat.yml' push: branches: [beta] @@ -51,6 +52,7 @@ on: - 'package.json' - 'pnpm-workspace.yaml' - 'pnpm-lock.yaml' + - '.node-version' - '.github/workflows/compiler-compat.yml' env: @@ -235,9 +237,9 @@ jobs: fail-fast: false matrix: include: - - { angular: '17.3.12', vite: '6.0.0' } - - { angular: '18.2.14', vite: '6.0.0' } - - { angular: '19.0.0', vite: '6.0.0' } + - { angular: '17.3.12', vite: '6.0.0', node: '20.19.5' } + - { angular: '18.2.14', vite: '6.0.0', node: '20.19.5' } + - { angular: '19.0.0', vite: '6.0.0', node: '20.19.5' } - { angular: '20.0.0', vite: '6.0.0' } - { angular: '20.1.0', vite: '6.0.0' } - { angular: '21.0.0', vite: '7.0.0' } @@ -246,6 +248,15 @@ jobs: - { angular: '22.0.0', vite: '8.0.8' } steps: - uses: actions/checkout@v3 + - name: Install supported Node for legacy Angular consumers + if: matrix.node != '' + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + - name: Record the consumer Node executable + if: matrix.node != '' + id: consumer-node + run: echo "path=$(node -p 'process.execPath')" >> "$GITHUB_OUTPUT" - uses: actions/setup-node@v3 with: node-version-file: .node-version @@ -255,6 +266,7 @@ jobs: run: pnpm install --frozen-lockfile --ignore-scripts - name: Test the installed package in an isolated Angular/Vite consumer env: + ANALOG_CONSUMER_NODE: ${{ steps.consumer-node.outputs.path }} NX_DAEMON: 'false' NX_NO_CLOUD: 'true' run: pnpm exec nx run vite-plugin-angular:compat-smoke --vite=${{ matrix.vite }} --angular=${{ matrix.angular }} diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 0aaae54d64..388af69e68 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -8,6 +8,7 @@ on: - 'package.json' - 'pnpm-workspace.yaml' - 'pnpm-lock.yaml' + - '.node-version' - '.github/workflows/conformance.yml' push: branches: [beta] @@ -16,6 +17,7 @@ on: - 'package.json' - 'pnpm-workspace.yaml' - 'pnpm-lock.yaml' + - '.node-version' - '.github/workflows/conformance.yml' env: diff --git a/packages/vite-plugin-angular/scripts/compiler-vite-smoke.mjs b/packages/vite-plugin-angular/scripts/compiler-vite-smoke.mjs index f2bc844de0..73de2549cc 100644 --- a/packages/vite-plugin-angular/scripts/compiler-vite-smoke.mjs +++ b/packages/vite-plugin-angular/scripts/compiler-vite-smoke.mjs @@ -146,7 +146,9 @@ if (values.angular === '22.0.0') { { cwd: root, env, stdio: 'inherit' }, ); } -execaSync(process.execPath, ['fixture.mjs'], { +const consumerNode = process.env.ANALOG_CONSUMER_NODE || process.execPath; +console.log(`Consumer Node: ${execaSync(consumerNode, ['--version']).stdout}`); +execaSync(consumerNode, ['fixture.mjs'], { cwd: root, env, stdio: 'inherit', diff --git a/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.spec.ts b/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.spec.ts index b7f9e77878..98a1483a90 100644 --- a/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.spec.ts +++ b/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.spec.ts @@ -236,7 +236,7 @@ describe('compilationAPIPlugin', () => { expect(close).toHaveBeenCalledOnce(); }); - it('includes integration-provided files outside the configured TypeScript roots', async () => { + it('refreshes TypeScript roots and preserves integration includes after tsconfig changes', async () => { const main = join(tempRoot, 'main.ts'); const extra = join(tempRoot, 'integration.ts'); writeFileSync(main, 'export const main = 1;'); @@ -298,6 +298,32 @@ describe('compilationAPIPlugin', () => { cachedViteActual.normalizePath(extra), ]), ); + const replacement = join(tempRoot, 'replacement.ts'); + writeFileSync(replacement, 'export const replacement = 3;'); + const watcher = { on: vi.fn(), off: vi.fn() }; + (plugin.configureServer as any)({ watcher, ws: { send: vi.fn() } }); + writeFileSync( + join(tempRoot, 'tsconfig.json'), + JSON.stringify({ + files: ['replacement.ts'], + compilerOptions: { target: 'es2022' }, + }), + ); + const onChange = watcher.on.mock.calls.find( + ([event]) => event === 'change', + )![1]; + await onChange(join(tempRoot, 'tsconfig.json')); + expect(initialize).toHaveBeenCalledTimes(2); + const updated = JSON.parse( + readFileSync(initialize.mock.calls[1][0], 'utf8'), + ); + expect(updated.files).toEqual( + expect.arrayContaining([ + cachedViteActual.normalizePath(replacement), + cachedViteActual.normalizePath(extra), + ]), + ); + expect(updated.files).not.toContain(cachedViteActual.normalizePath(main)); }); it('consumes an iterable compilation result without requiring an array', async () => { diff --git a/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.ts b/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.ts index ca604f32c0..d9ccbe71cc 100644 --- a/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/compilation-api/compilation-api-plugin.ts @@ -689,9 +689,9 @@ export function compilationAPIPlugin( tsconfigResolver.invalidateAll(); await performCompilation(resolvedConfig); }; - const invalidateTsconfig = (file: string) => { + const invalidateTsconfig = async (file: string) => { if (file.includes('tsconfig')) { - tsconfigResolver.invalidateTsconfigCaches(); + await invalidateCompilation(); } }; server.watcher.on('add', invalidateCompilation);