Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions napi/angular-compiler/test/ssr-hmr.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<h1>Hello World</h1>',
})
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')
})
})
30 changes: 26 additions & 4 deletions napi/angular-compiler/vite-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -450,10 +465,17 @@ 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 HMR (client-side only)
// DON'T use addWatchFile - it creates modules in Vite's graph!
// Instead, use our custom watcher that doesn't create modules.
if (watchMode && viteServer) {
if (watchMode && viteServer && !isSSR) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep SSR resource watchers to invalidate template cache

Removing watcher registration for SSR transforms (!isSSR) means external templates/styles read during SSR are cached but never invalidated on file change, because resourceCache.delete(...) only runs inside the custom fs.watch callback and handleHotUpdate explicitly ignores resource files. In SSR-only code paths (or before any client transform registers watchers), editing templateUrl/styleUrls can leave SSR output stale until a restart.

Useful? React with 👍 / 👎.

const watchFn = (viteServer as any).__angularWatchTemplate
for (const dep of dependencies) {
const normalizedDep = normalizePath(dep)
Expand All @@ -470,7 +492,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,
}

Expand Down
Loading