diff --git a/src/api/rehydrate.ts b/src/api/rehydrate.ts index fd8e706..fa7f7ed 100644 --- a/src/api/rehydrate.ts +++ b/src/api/rehydrate.ts @@ -15,6 +15,11 @@ export const REHYDRATE_API = 'api/rehydrate'; export interface RehydrateApiPayload { queries: Record; mutations: Record; + // 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; keys: Record }; } export const rehydrateApi = createAction(REHYDRATE_API); diff --git a/src/store/rtkqPersist.ts b/src/store/rtkqPersist.ts index fcd451b..a88bc0e 100644 --- a/src/store/rtkqPersist.ts +++ b/src/store/rtkqPersist.ts @@ -22,13 +22,17 @@ type QueryEntry = ApiState['queries'][string]; * carry no usable data and subscriptions are rebuilt by components on mount. * Mutation results are never restored. */ +const EMPTY_PROVIDED = { tags: {}, keys: {} }; + function snapshot(apiState: ApiState): RehydrateApiPayload { const queries: Record = {}; for (const [key, entry] of Object.entries(apiState.queries)) { const q = entry as QueryEntry | undefined; 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 { @@ -37,7 +41,13 @@ function load(): RehydrateApiPayload | null { if (!raw) return null; const parsed = JSON.parse(raw) as Partial; 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 { return null; } diff --git a/tests/rtkqPersist.test.ts b/tests/rtkqPersist.test.ts index 1c11933..6f8807a 100644 --- a/tests/rtkqPersist.test.ts +++ b/tests/rtkqPersist.test.ts @@ -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', () => { rstest.useFakeTimers(); let state = apiStateWith({});