From 943f21e23730d385ac7ca73372e48c8300e9ccbc Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:26:33 +0000 Subject: [PATCH] fix(@angular/build): use module.registerHooks() for in-memory ESM loader Node.js 26 runtime-deprecates `module.register()` (`DEP0205`) in favor of synchronous `module.registerHooks()`. Update the server-rendering in-memory ESM loader hooks to make `load` synchronous using `readFileSync` and register the hooks directly via `registerHooks({ resolve, load })`. Closes #34358 --- .../esm-in-memory-loader/loader-hooks.ts | 6 +- .../esm-in-memory-loader/loader-hooks_spec.ts | 69 +++++++++++++------ .../esm-in-memory-loader/register-hooks.ts | 7 +- 3 files changed, 56 insertions(+), 26 deletions(-) diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts index 6f21bfbc739b..6ff4c8f8191c 100644 --- a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts @@ -8,7 +8,7 @@ import assert from 'node:assert'; import { randomUUID } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -110,7 +110,7 @@ export function resolve( }); } -export async function load(url: string, context: { format?: string | null }, nextLoad: Function) { +export function load(url: string, context: { format?: string | null }, nextLoad: Function) { const { format } = context; // Load the file from memory if the URL is based in the virtual root @@ -137,7 +137,7 @@ export async function load(url: string, context: { format?: string | null }, nex } const filePath = fileURLToPath(url); - const fileBytes = await readFile(filePath); + const fileBytes = readFileSync(filePath); const source = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, fileBytes]); return { diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts index 2b7622145378..0b32c2248b10 100644 --- a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts @@ -6,6 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ +import assert from 'node:assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { initialize, load, resolve } from './loader-hooks'; import { createSharedServerFiles } from './utils'; @@ -124,12 +128,12 @@ describe('esm-in-memory-loader loader-hooks', () => { }); describe('load', () => { - it('should load in-memory file source from SharedArrayBuffer backed Uint8Array', async () => { + it('should load in-memory file source from SharedArrayBuffer backed Uint8Array', () => { const memoryUrl = new URL('./main.server.mjs', 'memory://').href; const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); const nextLoad = jasmine.createSpy('nextLoad'); - const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + const result = load(rootResolve.url, { format: 'module' }, nextLoad); expect(nextLoad).not.toHaveBeenCalled(); expect(result.format).toBe('module'); @@ -137,12 +141,12 @@ describe('esm-in-memory-loader loader-hooks', () => { expect(result.source).toBe('export const main = true;'); }); - it('should load in-memory file with multi-byte UTF-8 characters', async () => { + it('should load in-memory file with multi-byte UTF-8 characters', () => { const memoryUrl = new URL('./utf8.mjs', 'memory://').href; const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); const nextLoad = jasmine.createSpy('nextLoad'); - const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + const result = load(rootResolve.url, { format: 'module' }, nextLoad); expect(nextLoad).not.toHaveBeenCalled(); expect(result.format).toBe('module'); @@ -150,12 +154,12 @@ describe('esm-in-memory-loader loader-hooks', () => { expect(result.source).toBe('export const text = "🔥 UTF-8 🚀";'); }); - it('should load in-memory file with empty content', async () => { + it('should load in-memory file with empty content', () => { const memoryUrl = new URL('./empty.mjs', 'memory://').href; const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); const nextLoad = jasmine.createSpy('nextLoad'); - const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + const result = load(rootResolve.url, { format: 'module' }, nextLoad); expect(nextLoad).not.toHaveBeenCalled(); expect(result.format).toBe('module'); @@ -163,7 +167,7 @@ describe('esm-in-memory-loader loader-hooks', () => { expect(result.source).toBe(''); }); - it('should load in-memory file source with non-zero byteOffset in Uint8Array', async () => { + it('should load in-memory file source with non-zero byteOffset in Uint8Array', () => { const target = 'export const sliced = 42;'; const fullBuffer = Buffer.from(`__PADDING__${target}__MORE__`); const offset = Buffer.byteLength('__PADDING__', 'utf-8'); @@ -181,7 +185,7 @@ describe('esm-in-memory-loader loader-hooks', () => { const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); const nextLoad = jasmine.createSpy('nextLoad'); - const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + const result = load(rootResolve.url, { format: 'module' }, nextLoad); expect(nextLoad).not.toHaveBeenCalled(); expect(result.format).toBe('module'); @@ -189,7 +193,7 @@ describe('esm-in-memory-loader loader-hooks', () => { expect(result.source).toBe(target); }); - it('should load in-memory file source when outputFiles contain string values', async () => { + it('should load in-memory file source when outputFiles contain string values', () => { initialize({ workspaceRoot, outputFiles: { @@ -201,7 +205,7 @@ describe('esm-in-memory-loader loader-hooks', () => { const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); const nextLoad = jasmine.createSpy('nextLoad'); - const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + const result = load(rootResolve.url, { format: 'module' }, nextLoad); expect(nextLoad).not.toHaveBeenCalled(); expect(result.format).toBe('module'); @@ -209,21 +213,46 @@ describe('esm-in-memory-loader loader-hooks', () => { expect(result.source).toBe('export const fromString = 1;'); }); - it('should reject when in-memory file does not exist in outputFiles', async () => { + it('should throw when in-memory file does not exist in outputFiles', () => { const memoryUrl = new URL('./main.server.mjs', 'memory://').href; const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); const nonExistentVirtualUrl = rootResolve.url.replace('main.server.mjs', 'missing.mjs'); const nextLoad = jasmine.createSpy('nextLoad'); - await expectAsync( - load(nonExistentVirtualUrl, { format: 'module' }, nextLoad), - ).toBeRejectedWithError(/Resolved in-memory ESM file should always exist/); + expect(() => load(nonExistentVirtualUrl, { format: 'module' }, nextLoad)).toThrowError( + /Resolved in-memory ESM file should always exist/, + ); expect(nextLoad).not.toHaveBeenCalled(); }); - it('should delegate to nextLoad for non-angular file URLs', async () => { - const nextLoad = jasmine.createSpy('nextLoad').and.resolveTo({ format: 'module' }); - const result = await load( + it('should prepend ngServerMode initialization for @angular/ file URLs', async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + const tmpDir = await mkdtemp(join(baseTmpDir, 'loader-hooks-test-')); + + try { + const angularDir = join(tmpDir, '@angular/core'); + await mkdir(angularDir, { recursive: true }); + const filePath = join(angularDir, 'index.mjs'); + await writeFile(filePath, 'export const VERSION = "1.0.0";'); + + const nextLoad = jasmine.createSpy('nextLoad'); + const result = load(pathToFileURL(filePath).href, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(new TextDecoder().decode(result.source)).toBe( + 'var ngServerMode=true;export const VERSION = "1.0.0";', + ); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should delegate to nextLoad for non-angular file URLs', () => { + const nextLoad = jasmine.createSpy('nextLoad').and.returnValue({ format: 'module' }); + const result = load( 'file:///workspace/node_modules/rxjs/index.js', { format: 'module' }, nextLoad, @@ -235,9 +264,9 @@ describe('esm-in-memory-loader loader-hooks', () => { expect(result.format).toBe('module'); }); - it('should delegate to nextLoad for non-memory non-file URLs', async () => { - const nextLoad = jasmine.createSpy('nextLoad').and.resolveTo({ format: 'builtin' }); - const result = await load('node:fs', { format: 'builtin' }, nextLoad); + it('should delegate to nextLoad for non-memory non-file URLs', () => { + const nextLoad = jasmine.createSpy('nextLoad').and.returnValue({ format: 'builtin' }); + const result = load('node:fs', { format: 'builtin' }, nextLoad); expect(nextLoad).toHaveBeenCalledWith('node:fs'); expect(result.format).toBe('builtin'); diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts index b23fe297bc19..56747e17bd03 100644 --- a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts @@ -6,8 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -import { register } from 'node:module'; -import { pathToFileURL } from 'node:url'; +import { registerHooks } from 'node:module'; import { workerData } from 'node:worker_threads'; +import { initialize, load, resolve } from './loader-hooks'; -register('./loader-hooks.js', { parentURL: pathToFileURL(__filename), data: workerData }); +initialize(workerData); +registerHooks({ resolve, load });