fix(offline): include provided in RTKQ rehydration payload
Docker Build & Publish / build (push) Failing after 1m42s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped

RTK Query 2.12's invalidation slice reads `provided.tags` during cache
rehydration (`Object.entries(provided.tags ?? {})`). Our persisted
snapshot only carried `{ queries, mutations }`, so `provided` was
undefined and `.tags` threw on every startup with a cached snapshot —
crashing the app inside the rehydrate reducer / immer produce.

Snapshot now carries the real `provided` (so invalidation tags
rehydrate), and `load()` defaults it to `{ tags: {}, keys: {} }` so
snapshots persisted before this field existed recover without a manual
localStorage clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Senko-san
2026-06-10 14:29:42 +03:00
parent 538cfb9c5b
commit 1228118027
3 changed files with 34 additions and 2 deletions
+5
View File
@@ -15,6 +15,11 @@ export const REHYDRATE_API = 'api/rehydrate';
export interface RehydrateApiPayload { export interface RehydrateApiPayload {
queries: Record<string, unknown>; queries: Record<string, unknown>;
mutations: Record<string, unknown>; mutations: Record<string, unknown>;
// RTKQ's invalidation slice reads `provided.tags`/`provided.keys` during
// rehydration (it does `Object.entries(provided.tags ?? {})`), so `provided`
// must be an object — a bare `{ queries, mutations }` makes it crash on
// `provided.tags` of undefined. Always present; empty objects are valid.
provided: { tags: Record<string, unknown>; keys: Record<string, unknown> };
} }
export const rehydrateApi = createAction<RehydrateApiPayload>(REHYDRATE_API); export const rehydrateApi = createAction<RehydrateApiPayload>(REHYDRATE_API);
+12 -2
View File
@@ -22,13 +22,17 @@ type QueryEntry = ApiState['queries'][string];
* carry no usable data and subscriptions are rebuilt by components on mount. * carry no usable data and subscriptions are rebuilt by components on mount.
* Mutation results are never restored. * Mutation results are never restored.
*/ */
const EMPTY_PROVIDED = { tags: {}, keys: {} };
function snapshot(apiState: ApiState): RehydrateApiPayload { function snapshot(apiState: ApiState): RehydrateApiPayload {
const queries: Record<string, unknown> = {}; const queries: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(apiState.queries)) { for (const [key, entry] of Object.entries(apiState.queries)) {
const q = entry as QueryEntry | undefined; const q = entry as QueryEntry | undefined;
if (q && q.status === 'fulfilled') queries[key] = q; if (q && q.status === 'fulfilled') queries[key] = q;
} }
return { queries, mutations: {} }; // Carry `provided` along so RTKQ can re-register invalidation tags for the
// restored entries; it is also required structurally (see RehydrateApiPayload).
return { queries, mutations: {}, provided: apiState.provided ?? EMPTY_PROVIDED };
} }
function load(): RehydrateApiPayload | null { function load(): RehydrateApiPayload | null {
@@ -37,7 +41,13 @@ function load(): RehydrateApiPayload | null {
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<RehydrateApiPayload>; const parsed = JSON.parse(raw) as Partial<RehydrateApiPayload>;
if (!parsed.queries) return null; if (!parsed.queries) return null;
return { queries: parsed.queries, mutations: {} }; // `provided` may be absent in snapshots written before this field existed —
// default it so the invalidation slice doesn't crash on `provided.tags`.
return {
queries: parsed.queries,
mutations: {},
provided: parsed.provided ?? EMPTY_PROVIDED,
};
} catch { } catch {
return null; return null;
} }
+17
View File
@@ -49,6 +49,23 @@ test('rehydrateApiCache replays a stored cache as a rehydrate action', () => {
}); });
}); });
test('rehydrate payload always carries `provided` (regression: RTKQ reads provided.tags)', () => {
// A snapshot persisted before `provided` existed must not crash RTKQ's
// invalidation slice, which does `Object.entries(provided.tags ?? {})`.
instanceStorage.set(
'rtkq',
JSON.stringify({
queries: { 'getLibrary(undefined)': { status: 'fulfilled', data: [] } },
mutations: {},
}),
);
const dispatched: Array<{ payload: { provided?: unknown } }> = [];
rehydrateApiCache((a) =>
dispatched.push(a as { payload: { provided?: unknown } }),
);
expect(dispatched[0].payload.provided).toEqual({ tags: {}, keys: {} });
});
test('startApiPersistence saves only fulfilled queries after throttle', () => { test('startApiPersistence saves only fulfilled queries after throttle', () => {
rstest.useFakeTimers(); rstest.useFakeTimers();
let state = apiStateWith({}); let state = apiStateWith({});