From 436bb83f182fa31077f59c361f203205bdfcb2df Mon Sep 17 00:00:00 2001 From: erKam <88717125+erkamyaman@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:01:25 +0300 Subject: [PATCH] fix(http): serve the edited page when a prerendered route is revalidated A TransferState edit override had no effect on a prerendered page the browser already had: the reload sent If-None-Match, Angular's engine answered 304 from the prerendered file's ETag, and there was no HTML to edit. When an edit did go through, the response kept the unedited file's ETag, so after the override was removed the browser revalidated its edited copy with a 304 and kept showing the edit. While a state edit matches, the conditional request headers are now dropped, and an edited response drops its ETag and Last-Modified. --- .../src/__tests__/ssr-overrides.test.ts | 26 +++++++++++++++++++ packages/devtools/src/ssr-middleware.ts | 10 +++++++ 2 files changed, 36 insertions(+) diff --git a/packages/devtools/src/__tests__/ssr-overrides.test.ts b/packages/devtools/src/__tests__/ssr-overrides.test.ts index 5f6a2fe2..f8701bd8 100644 --- a/packages/devtools/src/__tests__/ssr-overrides.test.ts +++ b/packages/devtools/src/__tests__/ssr-overrides.test.ts @@ -269,6 +269,32 @@ describe('ssrMiddleware overrides', () => { } }); + it('edits a prerendered page on reload instead of letting its ETag answer 304', () => { + ssrRegistry().overrides = [override({ kind: 'state-edit', key: 'a', value: '{"b":2}' })]; + // What Angular's engine does for a prerendered page: 304 when the browser's ETag matches. + const etag = '"abc123"'; + const serve = (req: IncomingMessage, res: FakeResponse) => () => { + if (req.headers['if-none-match'] === etag) { + res.statusCode = 304; + res.end(); + return; + } + res.setHeader('content-type', 'text/html;charset=UTF-8'); + res.setHeader('etag', etag); + res.end(STATE_HTML); + }; + // A reload of a page the browser already has sends its ETag. + const reload = request(); + reload.headers['if-none-match'] = etag; + const res = new FakeResponse(); + createSsrMiddleware()(reload, res as unknown as ServerResponse, serve(reload, res)); + expect(res.statusCode).toBe(200); + expect(res.body).toContain('{"a":{"b":2},"keep":2}'); + // The edited copy must not be cached under the file's ETag, or a reload after the override + // is removed answers 304 and the browser keeps showing the edit. + expect(res.sentHeaders).not.toHaveProperty('etag'); + }); + it('does not report an edit on a page without TransferState, such as an error page', () => { ssrRegistry().overrides = [override({ kind: 'state-edit', key: 'a', value: '1' })]; const res = new FakeResponse(); diff --git a/packages/devtools/src/ssr-middleware.ts b/packages/devtools/src/ssr-middleware.ts index ae81cebf..67f804e7 100644 --- a/packages/devtools/src/ssr-middleware.ts +++ b/packages/devtools/src/ssr-middleware.ts @@ -181,6 +181,10 @@ export function createSsrMiddleware(options: SsrMiddlewareOptions = {}) { // Angular's Node adapter flushes headers before the body, which would fix the old content-length. res.flushHeaders = function () {} as typeof res.flushHeaders; res.setHeader('x-pangular-override', 'state-edit'); + // A browser that has the page sends its ETag, and a prerendered page then answers 304, which + // would leave the cached copy without the edit. + delete req.headers['if-none-match']; + delete req.headers['if-modified-since']; // Callbacks wait until the held HTML is really sent. const callbacks: ((error?: Error | null) => void)[] = []; res.write = function (this: ServerResponse, chunk: unknown, ...rest: unknown[]) { @@ -221,6 +225,12 @@ export function createSsrMiddleware(options: SsrMiddlewareOptions = {}) { if (!res.headersSent) { res.removeHeader('content-length'); res.setHeader('content-length', Buffer.byteLength(out)); + // The validators describe the unedited page. Kept, they would let the browser revalidate + // its edited copy with a 304 after the override is removed. + if (edited) { + res.removeHeader('etag'); + res.removeHeader('last-modified'); + } } res.flushHeaders = flushHeaders; const last = [chunk, ...rest].find((arg) => typeof arg === 'function');