diff --git a/napi/angular-compiler/test/ssr-hmr.test.ts b/napi/angular-compiler/test/ssr-hmr.test.ts new file mode 100644 index 000000000..4e8e4b7a6 --- /dev/null +++ b/napi/angular-compiler/test/ssr-hmr.test.ts @@ -0,0 +1,92 @@ +/** + * Tests for SSR + HMR interaction (Issue #109). + * + * When using @oxc-angular/vite with Nitro or other SSR frameworks, the server-side + * bundle must NOT contain HMR initialization code that dynamically imports + * `@ng/component?c=...` virtual modules, because those are only served via + * HTTP middleware (not `resolveId`/`load` hooks), causing ERR_LOAD_URL. + * + * The fix: + * 1. The transform hook checks `options.ssr` and disables HMR for SSR transforms. + * 2. `resolveId`/`load` hooks handle `@ng/component` as a safety net, returning + * an empty module so the module runner never crashes. + */ +import { describe, it, expect } from 'vitest' + +import { transformAngularFile } from '../index.js' + +const COMPONENT_SOURCE = ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + template: '

Hello World

', + }) + export class AppComponent {} +` + +describe('SSR + HMR (Issue #109)', () => { + it('should inject HMR code when hmr is enabled (client-side)', async () => { + const result = await transformAngularFile(COMPONENT_SOURCE, 'app.component.ts', { + hmr: true, + }) + + expect(result.errors).toHaveLength(0) + // HMR initializer IIFE should be present + expect(result.code).toContain('ɵɵreplaceMetadata') + expect(result.code).toContain('import.meta.hot') + expect(result.code).toContain('angular:component-update') + }) + + it('should NOT inject HMR code when hmr is disabled (SSR-side)', async () => { + const result = await transformAngularFile(COMPONENT_SOURCE, 'app.component.ts', { + hmr: false, + }) + + expect(result.errors).toHaveLength(0) + // No HMR code should be present + expect(result.code).not.toContain('ɵɵreplaceMetadata') + expect(result.code).not.toContain('import.meta.hot') + expect(result.code).not.toContain('angular:component-update') + expect(result.code).not.toContain('@ng/component') + // But the component should still be compiled correctly + expect(result.code).toContain('ɵɵdefineComponent') + expect(result.code).toContain('AppComponent') + }) + + it('should produce no templateUpdates when hmr is disabled', async () => { + const result = await transformAngularFile(COMPONENT_SOURCE, 'app.component.ts', { + hmr: false, + }) + + expect(result.errors).toHaveLength(0) + expect(Object.keys(result.templateUpdates).length).toBe(0) + }) +}) + +describe('Vite plugin SSR behavior (Issue #109)', () => { + it('angular() plugin should pass ssr flag through to disable HMR', async () => { + // This test validates the contract: when the Vite plugin receives + // ssr=true in the transform options, it should set hmr=false + // in the TransformOptions passed to transformAngularFile. + // + // The actual Vite plugin integration is tested via the e2e tests, + // but this validates the underlying compiler respects hmr=false. + const clientResult = await transformAngularFile(COMPONENT_SOURCE, 'app.component.ts', { + hmr: true, + }) + const ssrResult = await transformAngularFile(COMPONENT_SOURCE, 'app.component.ts', { + hmr: false, + }) + + // Client should have HMR + expect(clientResult.code).toContain('ɵɵreplaceMetadata') + + // SSR should NOT have HMR + expect(ssrResult.code).not.toContain('ɵɵreplaceMetadata') + + // Both should have the component definition + expect(clientResult.code).toContain('ɵɵdefineComponent') + expect(ssrResult.code).toContain('ɵɵdefineComponent') + }) +}) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 881f71161..34c8aee52 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -226,6 +226,21 @@ export function angular(options: PluginOptions = {}): Plugin[] { configResolved(config) { resolvedConfig = config }, + // Safety net: resolve @ng/component virtual modules in SSR context. + // The browser serves these via HTTP middleware, but Vite's module runner + // (used by Nitro/SSR) resolves through plugin hooks instead. + resolveId(source, _importer, options) { + if (options?.ssr && source.includes(ANGULAR_COMPONENT_PREFIX)) { + // Return as virtual module (with \0 prefix per Vite convention) + return `\0${source}` + } + }, + load(id, options) { + if (options?.ssr && id.startsWith('\0') && id.includes(ANGULAR_COMPONENT_PREFIX)) { + // Return empty module — SSR doesn't need HMR update modules + return 'export default undefined;' + } + }, configureServer(server) { viteServer = server @@ -426,7 +441,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { filter: { id: ANGULAR_TS_REGEX, }, - async handler(code, id) { + async handler(code, id, options) { // Skip node_modules if (id.includes('node_modules')) { return @@ -450,9 +465,20 @@ export function angular(options: PluginOptions = {}): Plugin[] { // Resolve external resources const { resources, dependencies } = await resolveResources(code, actualId) - // Track dependencies for HMR + // Disable HMR for SSR transforms. SSR bundles must not contain HMR + // initialization code that dynamically imports @ng/component virtual + // modules, as those are served via HTTP middleware only. This matches + // Angular's official behavior where _enableHmr is only set for browser + // bundles (see @angular/build application-code-bundle.js). + const isSSR = !!options?.ssr + + // Track dependencies for resource cache invalidation and HMR. // DON'T use addWatchFile - it creates modules in Vite's graph! // Instead, use our custom watcher that doesn't create modules. + // Note: watchers are registered for both client AND SSR transforms + // because the fs.watch callback invalidates resourceCache (needed by + // both). The HMR-specific behavior inside the callback is separately + // gated by componentIds, which are only populated for client transforms. if (watchMode && viteServer) { const watchFn = (viteServer as any).__angularWatchTemplate for (const dep of dependencies) { @@ -470,7 +496,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { const transformOptions: TransformOptions = { sourcemap: pluginOptions.sourceMap, jit: pluginOptions.jit, - hmr: pluginOptions.liveReload && watchMode, + hmr: pluginOptions.liveReload && watchMode && !isSSR, angularVersion: pluginOptions.angularVersion, }