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,
}