From 284ba1431a7a685f0494a06362b830b94234c369 Mon Sep 17 00:00:00 2001 From: erKam <88717125+erkamyaman@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:01:31 +0300 Subject: [PATCH] fix(store): log a pending patchState before a method or restore in the same tick Without watchState, writes outside a store method are grouped per microtask into one patchState entry, starting from a snapshot taken at the first write. A method call or a signal restore in the same tick logged its own entry without settling that snapshot first, so when the microtask ran its diff also contained the method's change. The change was logged twice, out of order, and restoring the patchState entry put back the state after the method. The pending entry is now logged before an outermost method call or a restore takes its own snapshot. --- .../devtools/src/__tests__/ngrx-collector.test.ts | 15 +++++++++++++++ packages/devtools/src/ngrx-collector.ts | 14 ++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/devtools/src/__tests__/ngrx-collector.test.ts b/packages/devtools/src/__tests__/ngrx-collector.test.ts index 3ee51e91..baaaaae8 100644 --- a/packages/devtools/src/__tests__/ngrx-collector.test.ts +++ b/packages/devtools/src/__tests__/ngrx-collector.test.ts @@ -171,6 +171,21 @@ describe('ngrx collector', () => { expect(entry.durationMs).toBeUndefined(); }); + it('keeps a method change out of a patchState entry from earlier in the same tick', async () => { + const { store, collector } = setup(); + collector.collect(); + patchState(store, { saved: ['x'] }); + store.setQuery('rome'); + await Promise.resolve(); + expect(collector.logSince(0).map((e) => [e.type, e.diff])).toEqual([ + ['patchState', [{ path: 'saved[0]', op: 'add', after: 'x' }]], + ['setQuery', [{ path: 'query', op: 'change', before: '', after: 'rome' }]], + ]); + expect(collector.run({ type: 'restore', seq: 1 }).ok).toBe(true); + expect((store as any)[STATE_SOURCE].query()).toBe(''); + expect((store as any)[STATE_SOURCE].saved()).toEqual(['x']); + }); + it('measures a method call duration and updates rolling stats per method', () => { const { store, collector } = setup(); collector.collect(); diff --git a/packages/devtools/src/ngrx-collector.ts b/packages/devtools/src/ngrx-collector.ts index 8a552089..ee2dd307 100644 --- a/packages/devtools/src/ngrx-collector.ts +++ b/packages/devtools/src/ngrx-collector.ts @@ -353,6 +353,18 @@ export function createNgrxCollector( }); }; + /** + * Logs writes still waiting for the microtask above right away, so a method + * call or restore that follows in the same tick gets its own entry and its + * change does not also land in that `patchState` entry. + */ + const flushPending = (t: Tracked) => { + const before = t.pendingBefore; + if (!before) return; + t.pendingBefore = null; + finish(t, 'patchState', undefined, before); + }; + /** * Appends one `signal-store` entry per patchState call, picked up through * `watchState()`. Dedicated to that path (not reused through `finish()`) so @@ -463,6 +475,7 @@ export function createNgrxCollector( apply(target, thisArg, args) { info.calls++; const outer = t.depth === 0; + if (outer) flushPending(t); const before = outer ? snapshot(t) : null; const frame = { name, args, startedAt: now(), fired: 0 }; t.methodStack.push(frame); @@ -1249,6 +1262,7 @@ export function createNgrxCollector( const t = entry.tracked; if (!tracked.has(t.instance)) return { error: 'This store no longer exists on the page.' }; if (!t.writable) return { error: 'This store state is read-only.' }; + flushPending(t); const before = snapshot(t); const patch: Record = {}; for (const key of t.stateKeys) {