mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(pwa): restore persisted query cache as gap-fill, never overwrite live data
restorePersistedQueryCache hydrated the dehydrated snapshot unconditionally, so a restore that resolves in an effect AFTER the page has already fetched a fresh result overwrote that live query with the stale (often empty) on-disk copy — a read opened straight after a mutation then showed pre-mutation data. Filter the dehydrated state to keys the live cache does not already hold before hydrate, so restore only fills gaps; a query already present is by definition fresher than disk. Scope the post-restore invalidation to the keys actually hydrated so a live query is never disturbed. Adds an invariant test covering the clobber case.
This commit is contained in:
1 parent
33dcf39adf
commit
9633d66eea
2 files changed
+114
-1
No files matched your search
@@ -134,8 +134,90 @@ describe("persister round-trips through a fake IndexedDB", () => {
|
||||
weightKg: 81,
|
||||
});
|
||||
|
||||
// Restored data must paint instantly BUT be marked stale so the first
|
||||
// observer background-refetches — otherwise the client's 5-minute
|
||||
// staleTime treats a minutes-old snapshot as fresh and a list opened
|
||||
// right after a mutation serves the stale pre-mutation copy.
|
||||
const restored = matchingBuild
|
||||
.getQueryCache()
|
||||
.find({ queryKey: ["dashboard", "snapshot"] });
|
||||
expect(restored?.isStale()).toBe(true);
|
||||
|
||||
const foreignBuild = new QueryClient();
|
||||
await restorePersistedQueryCache(foreignBuild, "v9.9.9");
|
||||
expect(foreignBuild.getQueryData(["dashboard", "snapshot"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never clobbers a query the live cache already holds", async () => {
|
||||
// The read-after-write invariant: restore runs in an effect, possibly
|
||||
// AFTER the page already fetched a fresh (post-mutation) list. A
|
||||
// persisted STALE copy under the same key must not overwrite it — a
|
||||
// present query is always fresher than disk.
|
||||
const store = new Map<string, unknown>();
|
||||
const makeReq = (run: () => void) => {
|
||||
const req: Record<string, unknown> = {};
|
||||
queueMicrotask(() => {
|
||||
run();
|
||||
(req.onsuccess as (() => void) | undefined)?.();
|
||||
});
|
||||
return req;
|
||||
};
|
||||
const fakeDb = {
|
||||
close() {},
|
||||
transaction() {
|
||||
return {
|
||||
objectStore() {
|
||||
return {
|
||||
put(value: unknown, key: string) {
|
||||
store.set(key, value);
|
||||
return makeReq(() => {});
|
||||
},
|
||||
get(key: string) {
|
||||
const req = makeReq(() => {});
|
||||
(req as { result?: unknown }).result = store.get(key);
|
||||
return req;
|
||||
},
|
||||
delete(key: string) {
|
||||
store.delete(key);
|
||||
return makeReq(() => {});
|
||||
},
|
||||
};
|
||||
},
|
||||
set oncomplete(fn: () => void) {
|
||||
queueMicrotask(fn);
|
||||
},
|
||||
set onerror(_fn: () => void) {},
|
||||
};
|
||||
},
|
||||
};
|
||||
(globalThis as { indexedDB?: unknown }).indexedDB = {
|
||||
open() {
|
||||
const req: Record<string, unknown> = { result: fakeDb };
|
||||
queueMicrotask(() => (req.onsuccess as () => void)?.());
|
||||
return req;
|
||||
},
|
||||
};
|
||||
|
||||
// Persist a STALE (empty) measurements list.
|
||||
const source = new QueryClient();
|
||||
source.setQueryData(["measurements", "list"], []);
|
||||
const stop = startPersistingQueryCache(source, "v1.18.6");
|
||||
vi.useFakeTimers();
|
||||
source.setQueryData(["measurements", "list"], []);
|
||||
vi.advanceTimersByTime(1100);
|
||||
vi.useRealTimers();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
stop();
|
||||
|
||||
// The live client already fetched the FRESH (post-mutation) row.
|
||||
const live = new QueryClient();
|
||||
live.setQueryData(["measurements", "list"], [{ id: "fresh-row" }]);
|
||||
|
||||
await restorePersistedQueryCache(live, "v1.18.6");
|
||||
|
||||
// The fresh row survived — the empty persisted snapshot did not win.
|
||||
expect(live.getQueryData(["measurements", "list"])).toEqual([
|
||||
{ id: "fresh-row" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -106,7 +106,38 @@ export async function restorePersistedQueryCache(
|
||||
await clearPersistedQueryCache();
|
||||
return;
|
||||
}
|
||||
hydrate(queryClient, payload.state);
|
||||
|
||||
// Never clobber a query the live client already holds. `hydrate` calls
|
||||
// `setData` per dehydrated query unconditionally, so a snapshot restored
|
||||
// in an effect AFTER the first paint would overwrite a query the page
|
||||
// already fetched/prefetched — replacing the fresh (post-mutation) list
|
||||
// with the stale persisted (often empty) copy. That was the read-after-
|
||||
// write break across dashboard/measurements/medications: the restore won
|
||||
// the race against the in-flight fetch. Drop every dehydrated query whose
|
||||
// key the cache already carries so restore only ever FILLS gaps, never
|
||||
// overwrites; a query already present is by definition fresher than disk.
|
||||
const cache = queryClient.getQueryCache();
|
||||
const filtered: DehydratedState = {
|
||||
...payload.state,
|
||||
queries: payload.state.queries.filter(
|
||||
(q) => cache.get(q.queryHash) === undefined,
|
||||
),
|
||||
};
|
||||
if (filtered.queries.length === 0) return;
|
||||
|
||||
hydrate(queryClient, filtered);
|
||||
// Restored queries carry their original `dataUpdatedAt`, so with the
|
||||
// client's 5-minute `staleTime` a snapshot saved minutes ago hydrates as
|
||||
// *fresh* and the first observer never refetches — a list opened straight
|
||||
// after a create/update then serves the stale pre-mutation copy. Marking
|
||||
// every restored query invalidated makes it stale-but-shown: the persisted
|
||||
// data still paints instantly, and the first mount triggers a background
|
||||
// refetch. Online that lands fresh data within ms; offline the refetch
|
||||
// fails and the hydrated copy stays on screen — the offline value holds.
|
||||
// Scoped to the keys we just hydrated so we never disturb a live query.
|
||||
for (const dq of filtered.queries) {
|
||||
cache.get(dq.queryHash)?.invalidate();
|
||||
}
|
||||
} catch {
|
||||
// Persistence is best-effort; never block boot on a cache restore.
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user