Skip to content

Commit 170436f

Browse files
Brooooooklynclaude
andauthored
fix: disable HMR for SSR transforms to prevent ERR_LOAD_URL (#110)
* fix: disable HMR for SSR transforms to prevent ERR_LOAD_URL When Nitro or other SSR frameworks process server-side code through Vite's module runner, the HMR initialization code would dynamically import @ng/component virtual modules that are only served via HTTP middleware, causing ERR_LOAD_URL failures. - Check options.ssr in transform hook and disable HMR for SSR bundles - Add resolveId/load hooks for @ng/component as safety net in SSR context - Skip file watcher registration for SSR transforms This matches Angular's official behavior where _enableHmr is only set for browser bundles, never for SSR bundles. - Close #109 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: keep SSR resource watchers for cache invalidation The fs.watch callback serves dual purpose: (1) invalidating resourceCache on file change, and (2) sending HMR WebSocket events. The HMR part is already independently gated by componentIds, which are only populated for client transforms. Skipping watcher registration for SSR would leave resourceCache stale when external templates/styles change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b43c439 commit 170436f

File tree

2 files changed

+121
-3
lines changed

2 files changed

+121
-3
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Tests for SSR + HMR interaction (Issue #109).
3+
*
4+
* When using @oxc-angular/vite with Nitro or other SSR frameworks, the server-side
5+
* bundle must NOT contain HMR initialization code that dynamically imports
6+
* `@ng/component?c=...` virtual modules, because those are only served via
7+
* HTTP middleware (not `resolveId`/`load` hooks), causing ERR_LOAD_URL.
8+
*
9+
* The fix:
10+
* 1. The transform hook checks `options.ssr` and disables HMR for SSR transforms.
11+
* 2. `resolveId`/`load` hooks handle `@ng/component` as a safety net, returning
12+
* an empty module so the module runner never crashes.
13+
*/
14+
import { describe, it, expect } from 'vitest'
15+
16+
import { transformAngularFile } from '../index.js'
17+
18+
const COMPONENT_SOURCE = `
19+
import { Component } from '@angular/core';
20+
21+
@Component({
22+
selector: 'app-root',
23+
template: '<h1>Hello World</h1>',
24+
})
25+
export class AppComponent {}
26+
`
27+
28+
describe('SSR + HMR (Issue #109)', () => {
29+
it('should inject HMR code when hmr is enabled (client-side)', async () => {
30+
const result = await transformAngularFile(COMPONENT_SOURCE, 'app.component.ts', {
31+
hmr: true,
32+
})
33+
34+
expect(result.errors).toHaveLength(0)
35+
// HMR initializer IIFE should be present
36+
expect(result.code).toContain('ɵɵreplaceMetadata')
37+
expect(result.code).toContain('import.meta.hot')
38+
expect(result.code).toContain('angular:component-update')
39+
})
40+
41+
it('should NOT inject HMR code when hmr is disabled (SSR-side)', async () => {
42+
const result = await transformAngularFile(COMPONENT_SOURCE, 'app.component.ts', {
43+
hmr: false,
44+
})
45+
46+
expect(result.errors).toHaveLength(0)
47+
// No HMR code should be present
48+
expect(result.code).not.toContain('ɵɵreplaceMetadata')
49+
expect(result.code).not.toContain('import.meta.hot')
50+
expect(result.code).not.toContain('angular:component-update')
51+
expect(result.code).not.toContain('@ng/component')
52+
// But the component should still be compiled correctly
53+
expect(result.code).toContain('ɵɵdefineComponent')
54+
expect(result.code).toContain('AppComponent')
55+
})
56+
57+
it('should produce no templateUpdates when hmr is disabled', async () => {
58+
const result = await transformAngularFile(COMPONENT_SOURCE, 'app.component.ts', {
59+
hmr: false,
60+
})
61+
62+
expect(result.errors).toHaveLength(0)
63+
expect(Object.keys(result.templateUpdates).length).toBe(0)
64+
})
65+
})
66+
67+
describe('Vite plugin SSR behavior (Issue #109)', () => {
68+
it('angular() plugin should pass ssr flag through to disable HMR', async () => {
69+
// This test validates the contract: when the Vite plugin receives
70+
// ssr=true in the transform options, it should set hmr=false
71+
// in the TransformOptions passed to transformAngularFile.
72+
//
73+
// The actual Vite plugin integration is tested via the e2e tests,
74+
// but this validates the underlying compiler respects hmr=false.
75+
const clientResult = await transformAngularFile(COMPONENT_SOURCE, 'app.component.ts', {
76+
hmr: true,
77+
})
78+
const ssrResult = await transformAngularFile(COMPONENT_SOURCE, 'app.component.ts', {
79+
hmr: false,
80+
})
81+
82+
// Client should have HMR
83+
expect(clientResult.code).toContain('ɵɵreplaceMetadata')
84+
85+
// SSR should NOT have HMR
86+
expect(ssrResult.code).not.toContain('ɵɵreplaceMetadata')
87+
88+
// Both should have the component definition
89+
expect(clientResult.code).toContain('ɵɵdefineComponent')
90+
expect(ssrResult.code).toContain('ɵɵdefineComponent')
91+
})
92+
})

napi/angular-compiler/vite-plugin/index.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,21 @@ export function angular(options: PluginOptions = {}): Plugin[] {
226226
configResolved(config) {
227227
resolvedConfig = config
228228
},
229+
// Safety net: resolve @ng/component virtual modules in SSR context.
230+
// The browser serves these via HTTP middleware, but Vite's module runner
231+
// (used by Nitro/SSR) resolves through plugin hooks instead.
232+
resolveId(source, _importer, options) {
233+
if (options?.ssr && source.includes(ANGULAR_COMPONENT_PREFIX)) {
234+
// Return as virtual module (with \0 prefix per Vite convention)
235+
return `\0${source}`
236+
}
237+
},
238+
load(id, options) {
239+
if (options?.ssr && id.startsWith('\0') && id.includes(ANGULAR_COMPONENT_PREFIX)) {
240+
// Return empty module — SSR doesn't need HMR update modules
241+
return 'export default undefined;'
242+
}
243+
},
229244
configureServer(server) {
230245
viteServer = server
231246

@@ -426,7 +441,7 @@ export function angular(options: PluginOptions = {}): Plugin[] {
426441
filter: {
427442
id: ANGULAR_TS_REGEX,
428443
},
429-
async handler(code, id) {
444+
async handler(code, id, options) {
430445
// Skip node_modules
431446
if (id.includes('node_modules')) {
432447
return
@@ -450,9 +465,20 @@ export function angular(options: PluginOptions = {}): Plugin[] {
450465
// Resolve external resources
451466
const { resources, dependencies } = await resolveResources(code, actualId)
452467

453-
// Track dependencies for HMR
468+
// Disable HMR for SSR transforms. SSR bundles must not contain HMR
469+
// initialization code that dynamically imports @ng/component virtual
470+
// modules, as those are served via HTTP middleware only. This matches
471+
// Angular's official behavior where _enableHmr is only set for browser
472+
// bundles (see @angular/build application-code-bundle.js).
473+
const isSSR = !!options?.ssr
474+
475+
// Track dependencies for resource cache invalidation and HMR.
454476
// DON'T use addWatchFile - it creates modules in Vite's graph!
455477
// Instead, use our custom watcher that doesn't create modules.
478+
// Note: watchers are registered for both client AND SSR transforms
479+
// because the fs.watch callback invalidates resourceCache (needed by
480+
// both). The HMR-specific behavior inside the callback is separately
481+
// gated by componentIds, which are only populated for client transforms.
456482
if (watchMode && viteServer) {
457483
const watchFn = (viteServer as any).__angularWatchTemplate
458484
for (const dep of dependencies) {
@@ -470,7 +496,7 @@ export function angular(options: PluginOptions = {}): Plugin[] {
470496
const transformOptions: TransformOptions = {
471497
sourcemap: pluginOptions.sourceMap,
472498
jit: pluginOptions.jit,
473-
hmr: pluginOptions.liveReload && watchMode,
499+
hmr: pluginOptions.liveReload && watchMode && !isSSR,
474500
angularVersion: pluginOptions.angularVersion,
475501
}
476502

0 commit comments

Comments
 (0)