diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index 9a4de3b5aa64..d648331b70de 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -148,6 +148,7 @@ ts_project( ":node_modules/@babel/core", ":node_modules/oxc-parser", "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/router", "//:node_modules/@types/jasmine", "//:node_modules/esbuild", "//:node_modules/prettier", diff --git a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts index 78458624c4db..c75c63e992bb 100644 --- a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts @@ -21,6 +21,7 @@ import { export function createAngularSsrInternalMiddleware( server: ViteDevServer, + resetComponentUpdates: () => void, indexHtmlTransformer?: (content: string) => Promise, ): Connect.NextHandleFunction { let cachedAngularServerApp: ReturnType | undefined; @@ -53,6 +54,7 @@ export function createAngularSsrInternalMiddleware( // Only Add the transform hook only if it's a different instance. if (cachedAngularServerApp !== angularServerApp) { angularServerApp.hooks.on('html:transform:pre', async ({ html, url }) => { + resetComponentUpdates(); const processedHtml = await server.transformIndexHtml(url.pathname, html); return indexHtmlTransformer?.(processedHtml) ?? processedHtml; @@ -76,13 +78,13 @@ export function createAngularSsrInternalMiddleware( export async function createAngularSsrExternalMiddleware( server: ViteDevServer, + resetComponentUpdates: () => void, indexHtmlTransformer?: (content: string) => Promise, ): Promise { let fallbackWarningShown = false; let cachedAngularAppEngine: typeof SSRAngularAppEngine | undefined; let angularSsrInternalMiddleware: - | ReturnType - | undefined; + ReturnType | undefined; // Load the compiler because `@angular/ssr/node` depends on `@angular/` packages, // which must be processed by the runtime linker, even if they are not used. @@ -120,6 +122,7 @@ export async function createAngularSsrExternalMiddleware( angularSsrInternalMiddleware ??= createAngularSsrInternalMiddleware( server, + resetComponentUpdates, indexHtmlTransformer, ); @@ -132,6 +135,7 @@ export async function createAngularSsrExternalMiddleware( AngularAppEngine.ɵdisableAllowedHostsCheck = disableAllowedHostsCheck; AngularAppEngine.ɵallowStaticRouteRender = true; AngularAppEngine.ɵhooks.on('html:transform:pre', async ({ html, url }) => { + resetComponentUpdates(); const processedHtml = await server.transformIndexHtml(url.pathname, html); return indexHtmlTransformer?.(processedHtml) ?? processedHtml; diff --git a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware_spec.ts b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware_spec.ts new file mode 100644 index 000000000000..a61011043a43 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware_spec.ts @@ -0,0 +1,130 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { + createAngularSsrExternalMiddleware, + createAngularSsrInternalMiddleware, +} from './ssr-middleware'; + +type ViteDevServer = Parameters[0]; + +describe('SSR dev-server middleware', () => { + function createMockReqRes(): { req: IncomingMessage; res: ServerResponse } { + const req = { + url: '/', + method: 'GET', + headers: { host: 'localhost' }, + } as unknown as IncomingMessage; + + const res = { + statusCode: 200, + setHeader: jasmine.createSpy('setHeader'), + writeHead: jasmine.createSpy('writeHead'), + end: jasmine.createSpy('end'), + } as unknown as ServerResponse; + + return { req, res }; + } + + it('resets component updates when html:transform:pre executes in internal SSR middleware', async () => { + let hookCallback: ((args: { html: string; url: URL }) => Promise) | undefined; + const resetComponentUpdates = jasmine.createSpy('resetComponentUpdates'); + let resolveHookRegistered!: () => void; + const hookRegistered = new Promise((resolve) => { + resolveHookRegistered = resolve; + }); + + const fakeAngularServerApp = { + hooks: { + on: (_name: string, cb: typeof hookCallback) => { + hookCallback = cb; + resolveHookRegistered(); + }, + }, + handle: jasmine.createSpy('handle').and.resolveTo(new Response('rendered')), + }; + + const server = { + config: { server: { allowedHosts: true }, base: '/' }, + ssrLoadModule: jasmine.createSpy('ssrLoadModule').and.resolveTo({ + ɵgetOrCreateAngularServerApp: () => fakeAngularServerApp, + }), + transformIndexHtml: jasmine + .createSpy('transformIndexHtml') + .and.callFake(async (_path: string, html: string) => html), + } as unknown as ViteDevServer; + + const middleware = createAngularSsrInternalMiddleware( + server, + resetComponentUpdates, + async (html) => html + '', + ); + + const { req, res } = createMockReqRes(); + middleware(req, res, () => {}); + + await hookRegistered; + + expect(resetComponentUpdates).not.toHaveBeenCalled(); + expect(hookCallback).toBeDefined(); + const result = await hookCallback?.({ + html: '
test
', + url: new URL('http://localhost/'), + }); + expect(resetComponentUpdates).toHaveBeenCalledTimes(1); + expect(result).toBe('
test
'); + }); + + it('resets component updates when html:transform:pre executes in external SSR middleware', async () => { + let hookCallback: ((args: { html: string; url: URL }) => Promise) | undefined; + const resetComponentUpdates = jasmine.createSpy('resetComponentUpdates'); + let resolveHookRegistered!: () => void; + const hookRegistered = new Promise((resolve) => { + resolveHookRegistered = resolve; + }); + + class FakeAngularAppEngine { + static ɵhooks = { + on: (_name: string, cb: typeof hookCallback) => { + hookCallback = cb; + resolveHookRegistered(); + }, + }; + static ɵdisableAllowedHostsCheck: boolean; + static ɵallowStaticRouteRender: boolean; + } + + const server = { + config: { server: { allowedHosts: true }, base: '/' }, + ssrLoadModule: jasmine.createSpy('ssrLoadModule').and.resolveTo({ + reqHandler: Object.assign(() => new Response('ok'), { __ng_request_handler__: true }), + AngularAppEngine: FakeAngularAppEngine, + }), + transformIndexHtml: jasmine + .createSpy('transformIndexHtml') + .and.callFake(async (_path: string, html: string) => html + ''), + } as unknown as ViteDevServer; + + const middleware = await createAngularSsrExternalMiddleware(server, resetComponentUpdates); + + const { req, res } = createMockReqRes(); + middleware(req, res, () => {}); + + await hookRegistered; + + expect(resetComponentUpdates).not.toHaveBeenCalled(); + expect(hookCallback).toBeDefined(); + const result = await hookCallback?.({ + html: '
test
', + url: new URL('http://localhost/'), + }); + expect(resetComponentUpdates).toHaveBeenCalledTimes(1); + expect(result).toBe('
test
'); + }); +}); diff --git a/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts index 78e16304f01d..34b0704f4091 100644 --- a/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts +++ b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts @@ -117,13 +117,21 @@ export function createAngularSetupMiddlewaresPlugin( if (ssrMode === ServerSsrMode.ExternalSsrMiddleware) { patchBaseMiddleware(server.middlewares, server.config.base); - middlewares.use(await createAngularSsrExternalMiddleware(server, indexHtmlTransformer)); + middlewares.use( + await createAngularSsrExternalMiddleware( + server, + resetComponentUpdates, + indexHtmlTransformer, + ), + ); return; } if (ssrMode === ServerSsrMode.InternalSsrMiddleware) { - middlewares.use(createAngularSsrInternalMiddleware(server, indexHtmlTransformer)); + middlewares.use( + createAngularSsrInternalMiddleware(server, resetComponentUpdates, indexHtmlTransformer), + ); } middlewares.use(angularHtmlFallbackMiddleware);