From 2a1ce36590e0836afc03bd332b00f96c6384adb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 8 Nov 2026 22:22:59 -0300 Subject: [PATCH 1/2] fix(vite): define ngServerMode per environment The build optimizer set `ngServerMode` once, from the top-level `build.ssr`, in the `define` and `oxc.define` every environment shares. With the Environment API (`vite build --app`, `builder.buildApp`), one config builds the client and the server, `build.ssr` is not set at the top level, and the server environment got `ngServerMode = false`. Angular bundled into it then kept a process-wide platform injector and ran `afterRender` hooks on the server, so concurrent requests failed with NG0404. Define `ngServerMode` in `configEnvironment` instead: `true` for server environments (which includes `vite build --ssr`), and `false` for a production client environment, like the Angular CLI. The SSR manifest plugin had the same per-run detection and never injected the manifests in these builds; it now checks the environment's `consumer`. --- .../test/build-optimizer.test.ts | 50 ++++++++++++++++++- .../test/ssr-manifest.test.ts | 16 ++++++ .../angular-build-optimizer-plugin.ts | 28 +++++------ .../angular-ssr-manifest-plugin.ts | 14 ++---- 4 files changed, 83 insertions(+), 25 deletions(-) diff --git a/napi/angular-compiler/test/build-optimizer.test.ts b/napi/angular-compiler/test/build-optimizer.test.ts index e73396e183..5934c26520 100644 --- a/napi/angular-compiler/test/build-optimizer.test.ts +++ b/napi/angular-compiler/test/build-optimizer.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { normalizePath } from 'vite' +import { normalizePath, resolveConfig, type InlineConfig } from 'vite' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { buildOptimizerPlugin } from '../vite-plugin/angular-build-optimizer-plugin.js' @@ -181,3 +181,51 @@ describe('build optimizer: package sideEffects', () => { expect(await optimize('@angular/build/fesm2022/build.mjs', plugin)).not.toContain('__PURE__') }) }) + +describe('build optimizer: ngServerMode', () => { + function resolveBuildConfig(config: InlineConfig, mode = 'production') { + return resolveConfig( + { + configFile: false, + logLevel: 'silent', + mode, + plugins: [ + buildOptimizerPlugin({ jit: false, sourcemap: false, thirdPartySourcemaps: false }), + ], + ...config, + }, + 'build', + ) + } + + // The `ngServerMode` each environment of the resolved build config defines. + async function ngServerModes(config: InlineConfig, mode?: string) { + const resolved = await resolveBuildConfig(config, mode) + return Object.fromEntries( + Object.entries(resolved.environments).map(([name, env]) => [name, env.define?.ngServerMode]), + ) + } + + it('defines it per environment when one config builds the client and the server', async () => { + expect( + await ngServerModes({ environments: { client: {}, ssr: {}, edge: { consumer: 'server' } } }), + ).toEqual({ client: 'false', ssr: 'true', edge: 'true' }) + }) + + it('defines it as true for a separate `vite build --ssr`', async () => { + expect(await ngServerModes({ build: { ssr: 'src/server.ts' } })).toMatchObject({ ssr: 'true' }) + }) + + it('only defines it in server environments outside production', async () => { + expect(await ngServerModes({ environments: { client: {}, ssr: {} } }, 'development')).toEqual({ + client: undefined, + ssr: 'true', + }) + }) + + it('leaves it out of the defines all environments share', async () => { + const resolved = await resolveBuildConfig({}) + expect(resolved.define).not.toHaveProperty('ngServerMode') + expect(resolved.oxc && resolved.oxc.define).not.toHaveProperty('ngServerMode') + }) +}) diff --git a/napi/angular-compiler/test/ssr-manifest.test.ts b/napi/angular-compiler/test/ssr-manifest.test.ts index e5b9df7ccc..1938e0ff84 100644 --- a/napi/angular-compiler/test/ssr-manifest.test.ts +++ b/napi/angular-compiler/test/ssr-manifest.test.ts @@ -109,5 +109,21 @@ describe('SSR Manifest Generation (Issue #60)', () => { const plugin = ssrManifestPlugin({}) expect(plugin.apply).toBe('build') }) + + it('should inject the manifests only in server environments', () => { + // One config can build the client and the server environments + // (`vite build --app`); `vite build --ssr` builds the `ssr` one. + const plugin = ssrManifestPlugin({}) + ;(plugin.configResolved as Function)({ root: '/app', base: '/' }) + const transform = (consumer: 'client' | 'server') => + (plugin.transform as Function).call( + { environment: { config: { consumer } } }, + 'const engine = new AngularNodeAppEngine();', + '/app/src/server.ts', + ) + + expect(transform('client')).toBeUndefined() + expect(transform('server')?.code).toContain('ɵsetAngularAppManifest') + }) }) }) diff --git a/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts b/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts index eff3cb2676..2d2665ff29 100644 --- a/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts @@ -87,8 +87,6 @@ export function buildOptimizerPlugin({ apply: 'build', config(userConfig) { isProd = userConfig.mode === 'production' || process.env['NODE_ENV'] === 'production' - const isSSR = !!userConfig.build?.ssr - const ngServerMode = `${isSSR}` if (isProd) { return { @@ -96,30 +94,32 @@ export function buildOptimizerPlugin({ ngJitMode: jit ? 'true' : 'false', ngI18nClosureMode: 'false', ngDevMode: 'false', - ngServerMode, }, oxc: { define: { ngDevMode: 'false', ngJitMode: jit ? 'true' : 'false', ngI18nClosureMode: 'false', - ngServerMode, }, }, } } - // In dev SSR mode, set ngServerMode even without the full production defines - if (isSSR) { - const defines: Record = { ngServerMode } - return { - define: defines, - oxc: { - define: defines, - }, - } + return undefined + }, + // `ngServerMode` is set per environment: one config can build the browser + // and the server bundles (`vite build --app`, `builder.buildApp`), and + // `vite build --ssr` builds the `ssr` environment. Like the Angular CLI, a + // server bundle gets `true` and a production browser bundle `false`. + configEnvironment(name, config) { + // Vite's default when `consumer` is not set. + const consumer = config.consumer ?? (name === 'client' ? 'client' : 'server') + if (consumer === 'server') { + return { define: { ngServerMode: 'true' } } + } + if (isProd) { + return { define: { ngServerMode: 'false' } } } - return undefined }, transform: { diff --git a/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts b/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts index 7aa17bbcd3..6dc0cd3d32 100644 --- a/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts @@ -6,7 +6,8 @@ * "Angular app engine manifest is not set." * * This plugin: - * 1. Detects SSR builds (when Vite's build.ssr is true) + * 1. Detects server builds (environments whose `consumer` is `server`, which + * includes the `ssr` environment `vite build --ssr` builds) * 2. Auto-injects manifest setup into files that use AngularNodeAppEngine/AngularAppEngine * 3. Provides the index.html as a server asset for SSR rendering * @@ -112,7 +113,6 @@ export interface SsrManifestPluginOptions { * Vite plugin that generates Angular SSR manifests for AngularNodeAppEngine. */ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { - let isSSR = false let resolvedConfig: ResolvedConfig let ssrEntryPath: string let indexHtmlContent: string | undefined @@ -121,15 +121,9 @@ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { name: '@oxc-angular/vite-ssr-manifest', apply: 'build', - config(userConfig) { - isSSR = !!userConfig.build?.ssr - }, - configResolved(config) { resolvedConfig = config - if (!isSSR) return - const workspaceRoot = config.root // Determine the SSR bootstrap entry (main.server.ts) @@ -139,7 +133,7 @@ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { }, async buildStart() { - if (!isSSR) return + if (this.environment.config.consumer !== 'server') return // Read index.html for the server asset const indexHtmlPath = resolve(resolvedConfig.root, 'index.html') @@ -153,7 +147,7 @@ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { }, transform(code, id) { - if (!isSSR) return + if (this.environment.config.consumer !== 'server') return // Inject manifest setup into files that use AngularNodeAppEngine or AngularAppEngine // These are the SSR entry points that need the manifest before constructing the engine From 01cde40354ee363ebb5ef0b16e782ed24ddd18b7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 9 Nov 2026 16:46:01 +0800 Subject: [PATCH 2/2] fix(vite): guard the SSR manifest injection outside Vite and when repeated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `this.environment` is only set by Vite. Under plain rolldown / tsdown (library builds) `buildStart` threw "Cannot read properties of undefined (reading 'config')"; before, the plugin did nothing there. - Every server environment now injects the manifests, so one that bundles another's output (Nitro builds the `ssr` service, then bundles it) injected them twice, with a second copy of the app. Skip code that already calls `ɵsetAngularAppManifest`. - The development-mode `ngServerMode` test stubs `NODE_ENV`, which the plugin also reads, so it passes under `NODE_ENV=production`. Co-Authored-By: Claude Opus 5.5 --- .../test/build-optimizer.test.ts | 8 +++++- .../test/ssr-manifest.test.ts | 28 +++++++++++++++++++ .../angular-ssr-manifest-plugin.ts | 12 +++++--- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/napi/angular-compiler/test/build-optimizer.test.ts b/napi/angular-compiler/test/build-optimizer.test.ts index 5934c26520..f2d86bcb1b 100644 --- a/napi/angular-compiler/test/build-optimizer.test.ts +++ b/napi/angular-compiler/test/build-optimizer.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { normalizePath, resolveConfig, type InlineConfig } from 'vite' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { buildOptimizerPlugin } from '../vite-plugin/angular-build-optimizer-plugin.js' @@ -216,7 +216,13 @@ describe('build optimizer: ngServerMode', () => { expect(await ngServerModes({ build: { ssr: 'src/server.ts' } })).toMatchObject({ ssr: 'true' }) }) + afterEach(() => { + vi.unstubAllEnvs() + }) + it('only defines it in server environments outside production', async () => { + // The plugin also treats `NODE_ENV=production` as production. + vi.stubEnv('NODE_ENV', 'development') expect(await ngServerModes({ environments: { client: {}, ssr: {} } }, 'development')).toEqual({ client: undefined, ssr: 'true', diff --git a/napi/angular-compiler/test/ssr-manifest.test.ts b/napi/angular-compiler/test/ssr-manifest.test.ts index 1938e0ff84..ea8629ce88 100644 --- a/napi/angular-compiler/test/ssr-manifest.test.ts +++ b/napi/angular-compiler/test/ssr-manifest.test.ts @@ -125,5 +125,33 @@ describe('SSR Manifest Generation (Issue #60)', () => { expect(transform('client')).toBeUndefined() expect(transform('server')?.code).toContain('ɵsetAngularAppManifest') }) + + it('should not inject the manifests twice', () => { + // A server environment can bundle another one's output (Nitro). + const plugin = ssrManifestPlugin({}) + ;(plugin.configResolved as Function)({ root: '/app', base: '/' }) + const transform = (code: string) => + (plugin.transform as Function).call( + { environment: { config: { consumer: 'server' } } }, + code, + '/app/.nitro/vite/services/ssr/index.mjs', + ) + + const once = transform('const engine = new AngularNodeAppEngine();').code + expect(transform(once)).toBeUndefined() + }) + + it('should do nothing outside Vite (plain rolldown has no environment)', async () => { + const plugin = ssrManifestPlugin({}) + ;(plugin.configResolved as Function)({ root: '/app', base: '/' }) + await (plugin.buildStart as Function).call({}) + expect( + (plugin.transform as Function).call( + {}, + 'const engine = new AngularNodeAppEngine();', + '/app/src/server.ts', + ), + ).toBeUndefined() + }) }) }) diff --git a/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts b/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts index 6dc0cd3d32..9959e2fbad 100644 --- a/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-ssr-manifest-plugin.ts @@ -133,7 +133,8 @@ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { }, async buildStart() { - if (this.environment.config.consumer !== 'server') return + // `environment` is only set by Vite, not by plain rolldown / tsdown. + if (this.environment?.config.consumer !== 'server') return // Read index.html for the server asset const indexHtmlPath = resolve(resolvedConfig.root, 'index.html') @@ -147,14 +148,17 @@ export function ssrManifestPlugin(options: SsrManifestPluginOptions): Plugin { }, transform(code, id) { - if (this.environment.config.consumer !== 'server') return + if (this.environment?.config.consumer !== 'server') return // Inject manifest setup into files that use AngularNodeAppEngine or AngularAppEngine - // These are the SSR entry points that need the manifest before constructing the engine + // These are the SSR entry points that need the manifest before constructing the engine. + // Code that already sets the manifest is skipped: a server environment can bundle + // the output of another one (Nitro builds the `ssr` service, then bundles it). if ( !id.includes('node_modules') && !id.startsWith('\0') && - (code.includes('AngularNodeAppEngine') || code.includes('AngularAppEngine')) + (code.includes('AngularNodeAppEngine') || code.includes('AngularAppEngine')) && + !code.includes('ɵsetAngularAppManifest') ) { const baseHref = resolvedConfig.base || '/'