Skip to content
Merged
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')
})
})
32 changes: 29 additions & 3 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,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) {
Expand All @@ -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,
}

Expand Down
Loading