fix(e4): address code-review findings — usePref flush, ?pl guard, dedup (E4 review)
Applies the /code-review high findings on the E4 branch:
- usePref: flush the pending debounced server write on unmount instead of
clearing it, so the last change isn't lost (and can't later be overwritten by
the stale server value via the adopt effect). [correctness]
- PlaylistsProvider.readSelectedId: accept ?pl only when it's a positive integer
— "?pl=" gave Number("")===0 (finite) and "?pl=1.5" a fractional id, both of
which selected a non-existent playlist. [correctness]
- usePref: extract mergePrefsCache() so useSavePref and usePref.set share the
one me-cache merge shape instead of duplicating it. [reuse]
- New lib/useUrlMirror: the shared replaceState(urlWithParams(...)) mirror
effect, so FeedFiltersProvider and PlaylistsProvider stop hand-rolling the
identical block (keyed on the serialized delta so it only fires on change).
[reuse]
- plexCards: swapBadgeReveal() helper for the repeated
reveal-idle/group-hover:opacity-0 swap-badge ternary. [simplification]
Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): feed filter
mirrors to ?show and clears on leaving the feed / re-mirrors on return via
useUrlMirror; ?pl= (empty) and ?pl=abc no longer select a bogus playlist (fall
back to the stored selection); Plex reveal-idle badges still display:none on
touch after the helper refactor; 0 console errors.
This commit is contained in:
+30
-10
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api } from "./api";
|
||||
import type { Me } from "./api/account";
|
||||
@@ -37,6 +37,16 @@ function readServerPref(me: Me | null | undefined, key: string): unknown {
|
||||
return me?.preferences?.[key];
|
||||
}
|
||||
|
||||
// Optimistically merge a preferences patch into the `me` cache so readers see it without a refetch —
|
||||
// the shape mirrors the server's PUT /api/me/preferences merge. Shared by useSavePref and usePref.set
|
||||
// (their sole difference is WHEN the server write fires — immediately vs debounced), so the cache-merge
|
||||
// shape lives in ONE place.
|
||||
function mergePrefsCache(qc: QueryClient, patch: Record<string, unknown>): void {
|
||||
qc.setQueryData<Me | null>(qk.me(), (m) =>
|
||||
m ? { ...m, preferences: { ...m.preferences, ...patch } } : m,
|
||||
);
|
||||
}
|
||||
|
||||
/** The raw preference persister: save an arbitrary partial preferences patch to the server and merge
|
||||
* it into the `me` cache optimistically (so readers see it without a refetch). For dynamic or
|
||||
* multi-key prefs that don't map to a single stateful usePref call — e.g. a variable set of toggles
|
||||
@@ -46,9 +56,7 @@ export function useSavePref(): (patch: Record<string, unknown>) => void {
|
||||
return useCallback(
|
||||
(patch: Record<string, unknown>) => {
|
||||
api.savePrefs(patch).catch(() => {});
|
||||
qc.setQueryData<Me | null>(qk.me(), (m) =>
|
||||
m ? { ...m, preferences: { ...m.preferences, ...patch } } : m,
|
||||
);
|
||||
mergePrefsCache(qc, patch);
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
@@ -87,7 +95,18 @@ export function usePref<T>(
|
||||
const [value, setValue] = useState<T>(seed);
|
||||
|
||||
const saveTimer = useRef<number | undefined>(undefined);
|
||||
useEffect(() => () => window.clearTimeout(saveTimer.current), []);
|
||||
// The pending debounced server write, if any — flushed (NOT dropped) on unmount so the last change
|
||||
// isn't lost. Dropping it would leave the server stale while local/cache/localStorage hold the new
|
||||
// value, and a later adopt could then overwrite the newer local value with the stale server one.
|
||||
const pendingSave = useRef<(() => void) | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(saveTimer.current);
|
||||
pendingSave.current?.();
|
||||
pendingSave.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const set = useCallback(
|
||||
(next: T) => {
|
||||
@@ -95,19 +114,20 @@ export function usePref<T>(
|
||||
if (localKey) writeAccount(localKey, next);
|
||||
// Keep the `me` cache in step so other readers of this key (and this hook's own adopt effect)
|
||||
// see the new value without a refetch. Merges into preferences, mirroring the server PUT.
|
||||
qc.setQueryData<Me | null>(qk.me(), (m) =>
|
||||
m ? { ...m, preferences: { ...m.preferences, [serverKey]: next } } : m,
|
||||
);
|
||||
const payload = { [serverKey]: next };
|
||||
mergePrefsCache(qc, payload);
|
||||
// savePrefs is fire-and-forget: api.req() already surfaces a failure toast, and the local state +
|
||||
// cache already reflect the change (idempotent PUT retries a transient blip).
|
||||
const flush = () => api.savePrefs(payload).catch(() => {});
|
||||
if (debounceMs) {
|
||||
window.clearTimeout(saveTimer.current);
|
||||
pendingSave.current = flush;
|
||||
saveTimer.current = window.setTimeout(() => {
|
||||
api.savePrefs(payload).catch(() => {});
|
||||
pendingSave.current = null;
|
||||
flush();
|
||||
}, debounceMs);
|
||||
} else {
|
||||
api.savePrefs(payload).catch(() => {});
|
||||
flush();
|
||||
}
|
||||
},
|
||||
[qc, serverKey, localKey, debounceMs],
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from "react";
|
||||
import { urlWithParams } from "./urlState";
|
||||
|
||||
// Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new
|
||||
// entry) whenever they change. Each provider owns its own key(s) and passes the delta it wants; a null
|
||||
// value clears that key, and urlWithParams preserves every other param (so providers don't clobber each
|
||||
// other). Extracted from the identical replaceState/urlWithParams blocks FeedFiltersProvider and
|
||||
// PlaylistsProvider used to hand-roll, so the mirroring contract lives in one place.
|
||||
export function useUrlMirror(updates: Record<string, string | null>): void {
|
||||
// The caller typically passes an inline object literal (new identity every render); key on its
|
||||
// serialized value so the effect only fires when a param actually changes, not every render.
|
||||
const dep = JSON.stringify(updates);
|
||||
useEffect(() => {
|
||||
window.history.replaceState(window.history.state, "", urlWithParams(updates));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dep]);
|
||||
}
|
||||
Reference in New Issue
Block a user