feat(library): render from locally-cached data when offline
The Library showed a blocking error with the backend unreachable. Now it composes a read-only library from everything already in the RTK Query cache (Tier-2 rehydrated last-seen data + anything fetched this session), so it keeps rendering offline instead of erroring. - selectors: `selectLocalTracks/Albums/Artists` — memoized, union + dedupe across getTracks/getAlbums/getArtists, the per-album/artist list endpoints, and single-entity fetches; skips pending/rejected entries - LibraryPage: when offline, fall back to the composed lists (live data still wins online), filter client-side for search, show an offline banner, and never show the retry-only ErrorState - i18n: `library.offline.*` (en + ru) - test: selector composition / dedup / status filtering (3 cases) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Offline library composition. When the active backend is unreachable, a single
|
||||
* `getTracks` query may be `rejected` (or never matched a rehydrated arg), so we
|
||||
* can't rely on it to render the library. Instead we compose the "locally
|
||||
* available" library from *every* fulfilled entry in the RTK Query cache —
|
||||
* last-seen lists rehydrated from localStorage (Tier 2) plus anything fetched
|
||||
* this session. This is read-only derived data, not a server-data slice copy:
|
||||
* it reads straight from the RTKQ cache the architecture already owns.
|
||||
*/
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { RootState } from '../index';
|
||||
import type { Album, Artist, PaginatedResponse, Track } from '../../api/types';
|
||||
|
||||
interface CacheEntry {
|
||||
status: string;
|
||||
endpointName?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
const selectQueries = (state: RootState): Record<string, unknown> =>
|
||||
state.api.queries;
|
||||
|
||||
function fulfilled(queries: Record<string, unknown>): CacheEntry[] {
|
||||
const out: CacheEntry[] = [];
|
||||
for (const entry of Object.values(queries)) {
|
||||
const e = entry as CacheEntry | undefined;
|
||||
if (e && e.status === 'fulfilled' && e.data != null) out.push(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every track known locally, deduped by id (last write wins). */
|
||||
export const selectLocalTracks = createSelector(
|
||||
selectQueries,
|
||||
(queries): Track[] => {
|
||||
const byId = new Map<string, Track>();
|
||||
for (const e of fulfilled(queries)) {
|
||||
switch (e.endpointName) {
|
||||
case 'getTracks':
|
||||
for (const t of (e.data as PaginatedResponse<Track>).items)
|
||||
byId.set(t.id, t);
|
||||
break;
|
||||
case 'getAlbumTracks':
|
||||
case 'getArtistTracks':
|
||||
for (const t of e.data as Track[]) byId.set(t.id, t);
|
||||
break;
|
||||
case 'getTrack':
|
||||
byId.set((e.data as Track).id, e.data as Track);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [...byId.values()];
|
||||
},
|
||||
);
|
||||
|
||||
/** Every album known locally, deduped by id. */
|
||||
export const selectLocalAlbums = createSelector(
|
||||
selectQueries,
|
||||
(queries): Album[] => {
|
||||
const byId = new Map<string, Album>();
|
||||
for (const e of fulfilled(queries)) {
|
||||
switch (e.endpointName) {
|
||||
case 'getAlbums':
|
||||
for (const a of (e.data as PaginatedResponse<Album>).items)
|
||||
byId.set(a.id, a);
|
||||
break;
|
||||
case 'getArtistAlbums':
|
||||
for (const a of e.data as Album[]) byId.set(a.id, a);
|
||||
break;
|
||||
case 'getAlbum':
|
||||
byId.set((e.data as Album).id, e.data as Album);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [...byId.values()];
|
||||
},
|
||||
);
|
||||
|
||||
/** Every artist known locally, deduped by id. */
|
||||
export const selectLocalArtists = createSelector(
|
||||
selectQueries,
|
||||
(queries): Artist[] => {
|
||||
const byId = new Map<string, Artist>();
|
||||
for (const e of fulfilled(queries)) {
|
||||
switch (e.endpointName) {
|
||||
case 'getArtists':
|
||||
for (const a of (e.data as PaginatedResponse<Artist>).items)
|
||||
byId.set(a.id, a);
|
||||
break;
|
||||
case 'getArtist':
|
||||
byId.set((e.data as Artist).id, e.data as Artist);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [...byId.values()];
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user