From 5cc857e080b7b0a14fc1977c106f31a6a33734a6 Mon Sep 17 00:00:00 2001 From: erKam <88717125+erkamyaman@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:50:49 +0300 Subject: [PATCH] fix(store): resend the NgRx action log after the server forgets the page The page sends only log entries newer than the seq the server last confirmed. When the server forgot the page (pagehide or a bfcache restore, the 15 second expiry while the socket was down, a hub restart), the next push carried only the newest entries. The server stored them as the whole log, marked every earlier seq as dropped, and answered the newest seq, so the page never resent the history it still held. Reports now carry the seq they continue from. When the server has no record of that page session, it answers 0, and the page pushes its full held log again. --- .../src/__tests__/ngrx-collector.test.ts | 34 +++++++++++++++++++ packages/devtools/src/ngrx-overlay.ts | 7 ++++ packages/devtools/src/ngrx-shared.ts | 2 ++ packages/devtools/src/rpc/ngrx-tools.ts | 9 +++-- 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/devtools/src/__tests__/ngrx-collector.test.ts b/packages/devtools/src/__tests__/ngrx-collector.test.ts index 8f7ef290..5947b55f 100644 --- a/packages/devtools/src/__tests__/ngrx-collector.test.ts +++ b/packages/devtools/src/__tests__/ngrx-collector.test.ts @@ -1175,6 +1175,40 @@ describe('ngrx collector with Store DevTools', () => { } }); + it('resends the held log when the server forgot the page', async () => { + const { store, ng } = setupDevtools(); + const pages: NgrxPages = new Map(); + const my = { + rpc: { + call: async (name: string, report: unknown) => { + if (name === 'forget-ngrx-page') return void pages.delete(report as string); + if (name !== 'push-ngrx-state') return undefined; + return { seq: mergeNgrxReport(pages, report as NgrxPageReport, []) }; + }, + register: () => {}, + }, + }; + const overlay = attachNgrx(my, 'p1', () => ng as any); + const seqs = () => ngrxStateOf(pages).pages[0]?.log.map((e) => e.seq); + try { + await overlay.push(); + store.dispatch({ type: 'inc' }); + store.dispatch({ type: 'inc' }); + await overlay.push(); + expect(seqs()).toEqual([1, 2]); + overlay.leave(); + await Promise.resolve(); + expect(pages.size).toBe(0); + store.dispatch({ type: 'inc' }); + await overlay.push(); + await overlay.push(); + expect(seqs()).toEqual([1, 2, 3]); + expect(ngrxStateOf(pages).pages[0].dropped).toBe(0); + } finally { + overlay.stop(); + } + }); + it('does not call a restore to the newest action paused', () => { const { store, collector } = setupDevtools(); store.dispatch({ type: 'inc' }); diff --git a/packages/devtools/src/ngrx-overlay.ts b/packages/devtools/src/ngrx-overlay.ts index 273e2305..0c099f54 100644 --- a/packages/devtools/src/ngrx-overlay.ts +++ b/packages/devtools/src/ngrx-overlay.ts @@ -80,12 +80,19 @@ export function attachNgrx( stores, classic, log, + since: sentSeq, ...(lost.updates.length ? { unrestorable: lost.updates } : {}), }; const answer = (await my.rpc.call('push-ngrx-state', report)) as { seq?: unknown } | undefined; + const previous = sentSeq; sentSeq = typeof answer?.seq === 'number' ? answer.seq : collector.lastSeq(); sentLost = lost.last; + // A lower seq means the server lost this page: resend the held log. + if (sentSeq < previous) { + sentLost = 0; + schedule(); + } } catch { return; } finally { diff --git a/packages/devtools/src/ngrx-shared.ts b/packages/devtools/src/ngrx-shared.ts index b80c6e38..8ee233e1 100644 --- a/packages/devtools/src/ngrx-shared.ts +++ b/packages/devtools/src/ngrx-shared.ts @@ -118,6 +118,8 @@ export interface NgrxPageReport { stores: NgrxSignalStoreInfo[]; classic: NgrxClassicStoreInfo | null; log: NgrxLogEntry[]; + /** The newest seq the server confirmed before this report; `log` holds only later entries. */ + since?: number; /** Entries sent earlier that Store DevTools can no longer restore. */ unrestorable?: NgrxUnrestorableUpdate[]; } diff --git a/packages/devtools/src/rpc/ngrx-tools.ts b/packages/devtools/src/rpc/ngrx-tools.ts index e1bb8ac5..cc9f697d 100644 --- a/packages/devtools/src/rpc/ngrx-tools.ts +++ b/packages/devtools/src/rpc/ngrx-tools.ts @@ -90,9 +90,14 @@ export function mergeNgrxReport( maxLog = MAX_LOG, ): number { const previous = pages.get(report.pageId); - const keep = previous && previous.session === report.session ? previous.log : []; + const known = previous?.session === report.session; + const keep = known ? previous.log : []; const lastSeq = keep.at(-1)?.seq ?? 0; - const fresh = report.log.filter((entry: NgrxLogEntry) => entry.seq > lastSeq); + // The page only sends entries after `since`; if this server forgot the page + // (forget-ngrx-page, expiry, restart), drop the partial log and answer 0 so + // the page resends everything it still holds. + const resync = !known && typeof report.since === 'number' && report.since > 0; + const fresh = resync ? [] : report.log.filter((entry: NgrxLogEntry) => entry.seq > lastSeq); const lost = new Map(); for (const update of Array.isArray(report.unrestorable) ? report.unrestorable : []) { if (typeof update?.seq === 'number' && UNRESTORABLE.has(update.reason)) {