Stop the dashboard answering a refresh with a reading it fetched earlier

The dashboard starts its snapshot request the moment the router commits to
"/", and parks the response in a slot outside the query cache until the
mounted cell asks for it. The cell only asks on its first fetch, so on every
return to the dashboard — where it already holds data and does not refetch on
mount — the parked response simply sat there unclaimed.

The next thing to call the query function was then whatever came along: the
two-minute poll, a focus refetch, or the invalidation that follows recording
something. All three exist to fetch data newer than what is on screen, and all
three were handed a response that had been requested before the entry was
written. So a value recorded within a minute of arriving on the dashboard
could keep reading as the old one until the following poll replaced it.

Only preload when no snapshot cell has produced data yet. That is the cold
first load the preload was built for, and it is the only moment the mounted
cell will claim the response promptly.
This commit is contained in:
Marc-André Bombeck committed 2026-07-31 07:47:00 +02:00
1 parent 340d1db6d9
commit d432842a73
2 files changed
+65 -8

No files matched your search

@@ -194,6 +194,53 @@ describe("prefetchDashboardSnapshot — hydration-safe promise handoff", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("a cell that already carries data never parks a response, so a write's refetch reads the server as it is AFTER the write", async () => {
// The read-after-write rule for the handoff. A parked response is a copy of
// the server from the moment it was requested; handing it to a later
// `queryFn` run serves pre-write data to a refetch whose entire purpose is
// to see the write. The dashboard reaches `queryFn` again only via the
// 120 s poll, a focus refetch, or the invalidation a mutation fires — never
// via a remount (`refetchOnMount: false`) — so every consumer of a parked
// response is one of those three.
let serverBody: Record<string, unknown> = {
layout: { widgets: [] },
tiles: {},
medsToday: { takenToday: 0 },
};
const fetchMock = vi
.fn()
.mockImplementation(() => Promise.resolve(envelopeResponse(serverBody)));
vi.stubGlobal("fetch", fetchMock);
getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] });
// The user comes back to the dashboard after a few minutes away. The cell
// still holds the pre-write body, so the page paints from it at once.
prefetchDashboardSnapshot(
fakeQueryClient({
data: { tiles: {} },
dataUpdatedAt: Date.now() - 5 * 60_000,
}),
);
expect(fetchMock).not.toHaveBeenCalled();
// They record a dose. The server now reports it taken.
serverBody = {
layout: { widgets: [] },
tiles: {},
medsToday: { takenToday: 1 },
};
// The write's invalidation refetches the mounted cell.
useDashboardSnapshot();
const opts = lastOptsOf(useQueryMock);
const snap = await (
opts.queryFn as () => Promise<{ medsToday: { takenToday: number } }>
)();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(snap.medsToday.takenToday).toBe(1);
});
it("falls back to a fresh fetch when the preload failed — the mounted cell owns error surfacing", async () => {
const layout = { widgets: [] };
const fetchMock = vi
+18 -8
View File
@@ -99,15 +99,25 @@ export function prefetchDashboardSnapshot(queryClient: QueryClient) {
}
// A fresh handoff is already in flight / parked.
if (preloadedSnapshot) return;
// Cache already warm (return-to-dashboard within staleTime) — the
// mounted cell serves it without ever calling `queryFn`; fetching here
// would be a wasted request parked in the slot.
// The handoff exists to cut the COLD first-load waterfall, so it is only
// ever warranted when no snapshot cell has produced data yet. Once a cell
// carries data the page paints from it immediately and there is no waterfall
// left to cut — but the parked response would still sit in the slot, because
// `refetchOnMount: false` means a remount never calls `queryFn` to consume
// it. The next `queryFn` run is then whatever comes along: the 120 s poll, a
// focus refetch, or the invalidation that follows the user's own write. All
// three exist to fetch data that is NEWER than what the cell holds, and all
// three would instead be handed a response whose request was issued BEFORE
// the write. That is how a just-recorded dose kept reading as still due
// until the following poll cleared it.
//
// v1.21.3 (b) — the live cell is locale-keyed (`["dashboard","snapshot",
// locale]`), but the preloader runs before the locale context is in scope.
// Probe ALL `["dashboard","snapshot"]` cells (prefix) and treat the freshest
// as the warm-slot signal: if any locale's cell is fresh, skip the preload;
// otherwise warm the request through the handoff and let the mounted cell
// commit it under whichever locale the user lands on.
// Probe ALL `["dashboard","snapshot"]` cells (prefix): if ANY locale's cell
// has data, skip the preload; otherwise warm the request through the handoff
// and let the mounted cell commit it under whichever locale the user lands
// on. A cell that exists but never resolved (an error slot) reports
// `dataUpdatedAt === 0` and correctly still warms.
const states = queryClient
.getQueriesData({ queryKey: queryKeys.dashboardSnapshot() })
.map(([key]) => queryClient.getQueryState(key));
@@ -115,7 +125,7 @@ export function prefetchDashboardSnapshot(queryClient: QueryClient) {
(max, s) => (s ? Math.max(max, s.dataUpdatedAt) : max),
0,
);
if (freshest > 0 && Date.now() - freshest < PRELOAD_MAX_AGE_MS) {
if (freshest > 0) {
return;
}
const promise = fetchDashboardSnapshot();