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');