From fe93bae3ebba06e0b3219932c7a0abc95e5248ac Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 13 Mar 2026 23:23:29 +0800 Subject: [PATCH 1/2] 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 https://github.com/voidzero-dev/oxc-angular-compiler/issues/109 Co-Authored-By: Claude Opus 4.6 --- napi/angular-compiler/test/ssr-hmr.test.ts | 92 ++++++++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 30 ++++++- 2 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 napi/angular-compiler/test/ssr-hmr.test.ts 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..078f4a5fe 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,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) { const watchFn = (viteServer as any).__angularWatchTemplate for (const dep of dependencies) { const normalizedDep = normalizePath(dep) @@ -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, } From 153de34dab80bbb06946a0dd916e526ec7e43590 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Sat, 14 Mar 2026 12:27:36 +0800 Subject: [PATCH 2/2] 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 --- napi/angular-compiler/vite-plugin/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 078f4a5fe..34c8aee52 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -472,10 +472,14 @@ export function angular(options: PluginOptions = {}): Plugin[] { // bundles (see @angular/build application-code-bundle.js). const isSSR = !!options?.ssr - // Track dependencies for HMR (client-side only) + // 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. - if (watchMode && viteServer && !isSSR) { + // 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) { const normalizedDep = normalizePath(dep)