Skip to content

Commit de91345

Browse files
committed
fix(@angular/build): reset component updates on dev-server SSR page request
When server-side rendering is enabled (either internal or external mode), page reload requests are handled by the SSR middleware before reaching the index HTML middleware. As a result, `resetComponentUpdates` was never invoked on page reloads, causing stale component HMR updates to persist in `templateUpdates` and be re-applied over freshly rendered pages. We now pass `resetComponentUpdates` to `createAngularSsrInternalMiddleware` and `createAngularSsrExternalMiddleware`, and trigger it when an SSR HTML transform occurs (`html:transform:pre`). Fixes #34163
1 parent 7ce093f commit de91345

4 files changed

Lines changed: 147 additions & 4 deletions

File tree

‎packages/angular/build/BUILD.bazel‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ ts_project(
148148
":node_modules/@babel/core",
149149
":node_modules/oxc-parser",
150150
"//:node_modules/@angular/compiler-cli",
151+
"//:node_modules/@angular/router",
151152
"//:node_modules/@types/jasmine",
152153
"//:node_modules/esbuild",
153154
"//:node_modules/prettier",

‎packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121

2222
export function createAngularSsrInternalMiddleware(
2323
server: ViteDevServer,
24+
resetComponentUpdates: () => void,
2425
indexHtmlTransformer?: (content: string) => Promise<string>,
2526
): Connect.NextHandleFunction {
2627
let cachedAngularServerApp: ReturnType<typeof getOrCreateAngularServerApp> | undefined;
@@ -53,6 +54,7 @@ export function createAngularSsrInternalMiddleware(
5354
// Only Add the transform hook only if it's a different instance.
5455
if (cachedAngularServerApp !== angularServerApp) {
5556
angularServerApp.hooks.on('html:transform:pre', async ({ html, url }) => {
57+
resetComponentUpdates();
5658
const processedHtml = await server.transformIndexHtml(url.pathname, html);
5759

5860
return indexHtmlTransformer?.(processedHtml) ?? processedHtml;
@@ -76,13 +78,13 @@ export function createAngularSsrInternalMiddleware(
7678

7779
export async function createAngularSsrExternalMiddleware(
7880
server: ViteDevServer,
81+
resetComponentUpdates: () => void,
7982
indexHtmlTransformer?: (content: string) => Promise<string>,
8083
): Promise<Connect.NextHandleFunction> {
8184
let fallbackWarningShown = false;
8285
let cachedAngularAppEngine: typeof SSRAngularAppEngine | undefined;
8386
let angularSsrInternalMiddleware:
84-
| ReturnType<typeof createAngularSsrInternalMiddleware>
85-
| undefined;
87+
ReturnType<typeof createAngularSsrInternalMiddleware> | undefined;
8688

8789
// Load the compiler because `@angular/ssr/node` depends on `@angular/` packages,
8890
// which must be processed by the runtime linker, even if they are not used.
@@ -120,6 +122,7 @@ export async function createAngularSsrExternalMiddleware(
120122

121123
angularSsrInternalMiddleware ??= createAngularSsrInternalMiddleware(
122124
server,
125+
resetComponentUpdates,
123126
indexHtmlTransformer,
124127
);
125128

@@ -132,6 +135,7 @@ export async function createAngularSsrExternalMiddleware(
132135
AngularAppEngine.ɵdisableAllowedHostsCheck = disableAllowedHostsCheck;
133136
AngularAppEngine.ɵallowStaticRouteRender = true;
134137
AngularAppEngine.ɵhooks.on('html:transform:pre', async ({ html, url }) => {
138+
resetComponentUpdates();
135139
const processedHtml = await server.transformIndexHtml(url.pathname, html);
136140

137141
return indexHtmlTransformer?.(processedHtml) ?? processedHtml;
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type { IncomingMessage, ServerResponse } from 'node:http';
10+
import {
11+
createAngularSsrExternalMiddleware,
12+
createAngularSsrInternalMiddleware,
13+
} from './ssr-middleware';
14+
15+
type ViteDevServer = Parameters<typeof createAngularSsrInternalMiddleware>[0];
16+
17+
describe('SSR dev-server middleware', () => {
18+
function createMockReqRes(): { req: IncomingMessage; res: ServerResponse } {
19+
const req = {
20+
url: '/',
21+
method: 'GET',
22+
headers: { host: 'localhost' },
23+
} as unknown as IncomingMessage;
24+
25+
const res = {
26+
statusCode: 200,
27+
setHeader: jasmine.createSpy('setHeader'),
28+
writeHead: jasmine.createSpy('writeHead'),
29+
end: jasmine.createSpy('end'),
30+
} as unknown as ServerResponse;
31+
32+
return { req, res };
33+
}
34+
35+
it('resets component updates when html:transform:pre executes in internal SSR middleware', async () => {
36+
let hookCallback: ((args: { html: string; url: URL }) => Promise<string>) | undefined;
37+
const resetComponentUpdates = jasmine.createSpy('resetComponentUpdates');
38+
let resolveHookRegistered!: () => void;
39+
const hookRegistered = new Promise<void>((resolve) => {
40+
resolveHookRegistered = resolve;
41+
});
42+
43+
const fakeAngularServerApp = {
44+
hooks: {
45+
on: (_name: string, cb: typeof hookCallback) => {
46+
hookCallback = cb;
47+
resolveHookRegistered();
48+
},
49+
},
50+
handle: jasmine.createSpy('handle').and.resolveTo(new Response('rendered')),
51+
};
52+
53+
const server = {
54+
config: { server: { allowedHosts: true }, base: '/' },
55+
ssrLoadModule: jasmine.createSpy('ssrLoadModule').and.resolveTo({
56+
ɵgetOrCreateAngularServerApp: () => fakeAngularServerApp,
57+
}),
58+
transformIndexHtml: jasmine
59+
.createSpy('transformIndexHtml')
60+
.and.callFake(async (_path: string, html: string) => html),
61+
} as unknown as ViteDevServer;
62+
63+
const middleware = createAngularSsrInternalMiddleware(
64+
server,
65+
resetComponentUpdates,
66+
async (html) => html + '<!-- custom -->',
67+
);
68+
69+
const { req, res } = createMockReqRes();
70+
middleware(req, res, () => {});
71+
72+
await hookRegistered;
73+
74+
expect(resetComponentUpdates).not.toHaveBeenCalled();
75+
expect(hookCallback).toBeDefined();
76+
const result = await hookCallback?.({
77+
html: '<div>test</div>',
78+
url: new URL('http://localhost/'),
79+
});
80+
expect(resetComponentUpdates).toHaveBeenCalledTimes(1);
81+
expect(result).toBe('<div>test</div><!-- custom -->');
82+
});
83+
84+
it('resets component updates when html:transform:pre executes in external SSR middleware', async () => {
85+
let hookCallback: ((args: { html: string; url: URL }) => Promise<string>) | undefined;
86+
const resetComponentUpdates = jasmine.createSpy('resetComponentUpdates');
87+
let resolveHookRegistered!: () => void;
88+
const hookRegistered = new Promise<void>((resolve) => {
89+
resolveHookRegistered = resolve;
90+
});
91+
92+
class FakeAngularAppEngine {
93+
static ɵhooks = {
94+
on: (_name: string, cb: typeof hookCallback) => {
95+
hookCallback = cb;
96+
resolveHookRegistered();
97+
},
98+
};
99+
static ɵdisableAllowedHostsCheck: boolean;
100+
static ɵallowStaticRouteRender: boolean;
101+
}
102+
103+
const server = {
104+
config: { server: { allowedHosts: true }, base: '/' },
105+
ssrLoadModule: jasmine.createSpy('ssrLoadModule').and.resolveTo({
106+
reqHandler: Object.assign(() => new Response('ok'), { __ng_request_handler__: true }),
107+
AngularAppEngine: FakeAngularAppEngine,
108+
}),
109+
transformIndexHtml: jasmine
110+
.createSpy('transformIndexHtml')
111+
.and.callFake(async (_path: string, html: string) => html + '<!-- transformed -->'),
112+
} as unknown as ViteDevServer;
113+
114+
const middleware = await createAngularSsrExternalMiddleware(server, resetComponentUpdates);
115+
116+
const { req, res } = createMockReqRes();
117+
middleware(req, res, () => {});
118+
119+
await hookRegistered;
120+
121+
expect(resetComponentUpdates).not.toHaveBeenCalled();
122+
expect(hookCallback).toBeDefined();
123+
const result = await hookCallback?.({
124+
html: '<div>test</div>',
125+
url: new URL('http://localhost/'),
126+
});
127+
expect(resetComponentUpdates).toHaveBeenCalledTimes(1);
128+
expect(result).toBe('<div>test</div><!-- transformed -->');
129+
});
130+
});

‎packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,21 @@ export function createAngularSetupMiddlewaresPlugin(
117117

118118
if (ssrMode === ServerSsrMode.ExternalSsrMiddleware) {
119119
patchBaseMiddleware(server.middlewares, server.config.base);
120-
middlewares.use(await createAngularSsrExternalMiddleware(server, indexHtmlTransformer));
120+
middlewares.use(
121+
await createAngularSsrExternalMiddleware(
122+
server,
123+
resetComponentUpdates,
124+
indexHtmlTransformer,
125+
),
126+
);
121127

122128
return;
123129
}
124130

125131
if (ssrMode === ServerSsrMode.InternalSsrMiddleware) {
126-
middlewares.use(createAngularSsrInternalMiddleware(server, indexHtmlTransformer));
132+
middlewares.use(
133+
createAngularSsrInternalMiddleware(server, resetComponentUpdates, indexHtmlTransformer),
134+
);
127135
}
128136

129137
middlewares.use(angularHtmlFallbackMiddleware);

0 commit comments

Comments
 (0)