fix(plex,channels): deep-review fixes — durable watch-sync + no shared-link clobber
Deep-review of the quickfix batch surfaced two data/preference-loss bugs plus smaller items; all fixed with tests. - watch_sync _pull_apply: don't let the Plex-authoritative pull overwrite a DIRTY local row (a change whose immediate push failed) — that stranded the value forever since _repush_dirty only retries dirty rows. Leave it dirty for re-push. - PlexProvider / ChannelsProvider: a partial shared link opened in a fresh tab persisted mount-time DEFAULTS over the account's stored scope/sort/filters (base read before the account was pinned). Persist/keep only URL-present dims; re-hydrate the omitted ones from storage. - playback resume write-back: guard against out-of-order delivery (a superseded older push now drops) and evict the throttle map so it can't grow unbounded. - PlexPlayer: announce mid-playback buffering to assistive tech (role=status + sr-only label); new plex.player.buffering (HU+EN). - columnSort: hoist the shared key:dir sort codec used by both URL-state modules. - Tests: dirty-guard + flag-settle + per-key throttle scoping + superseded-push (backend); hold-repeat/suppress-click/unmount + partial-link no-clobber + refetch-doesn't-clobber-undo (frontend). Backend pytest 475, FE vitest 519.
This commit is contained in:
@@ -73,14 +73,25 @@ function readStoredFilters(): FilterMap {
|
||||
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
|
||||
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
|
||||
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
|
||||
function readChannelsUrlSeed(): ChannelsUrlState | null {
|
||||
// The seed = the URL-merged state PLUS which dimensions the URL actually carried. `present` matters
|
||||
// because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns null),
|
||||
// so an OMITTED dimension's base is a default, not this account's stored value. The provider seeds and
|
||||
// persists only the PRESENT dimensions and lets the absent ones re-hydrate from storage normally —
|
||||
// otherwise a partial shared link silently resets the omitted stored sort / filter map.
|
||||
type ChannelsSeedPresent = { view: boolean; sort: boolean; filters: boolean };
|
||||
type ChannelsUrlSeed = { state: ChannelsUrlState; present: ChannelsSeedPresent };
|
||||
function readChannelsUrlSeed(): ChannelsUrlSeed | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!hasChannelsParams(params)) return null;
|
||||
return channelsParamsToState(params, {
|
||||
const state = channelsParamsToState(params, {
|
||||
view: clampView(getAccountRaw(LS.channelsView)),
|
||||
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
|
||||
filters: readStoredFilters(),
|
||||
});
|
||||
return {
|
||||
state,
|
||||
present: { view: params.has("ctab"), sort: params.has("csort"), filters: params.has("cf") },
|
||||
};
|
||||
}
|
||||
|
||||
type ChannelsState = {
|
||||
@@ -185,9 +196,13 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
useLayoutEffect(() => {
|
||||
if (!urlSeed || seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
if (urlSeed.view !== view) setViewRaw(urlSeed.view);
|
||||
setFilters(urlSeed.filters);
|
||||
setSort(urlSeed.sort);
|
||||
// Only apply the dimensions the URL actually carried. An omitted dimension must NOT go through its
|
||||
// setter (which would markDirty and block the account-pin re-hydrate from loading the stored value)
|
||||
// — leave it to hydrate from storage normally. Fixes a partial link wiping the stored sort/filters.
|
||||
const { state, present } = urlSeed;
|
||||
if (present.view && state.view !== view) setViewRaw(state.view);
|
||||
if (present.filters) setFilters(state.filters);
|
||||
if (present.sort) setSort(state.sort);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -198,9 +213,12 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!urlSeed || accountId == null || persistedRef.current) return;
|
||||
persistedRef.current = true;
|
||||
setAccountRaw(LS.channelsView, urlSeed.view);
|
||||
setAccountRaw(LS.channelsFilterMap, JSON.stringify(urlSeed.filters));
|
||||
setAccountRaw(LS.channelSort, JSON.stringify(urlSeed.sort));
|
||||
// Persist ONLY the URL-present dimensions — the omitted ones were seeded from mount-time defaults
|
||||
// (no account yet), so writing them here would overwrite the account's real stored sort/filter map.
|
||||
const { state, present } = urlSeed;
|
||||
if (present.view) setAccountRaw(LS.channelsView, state.view);
|
||||
if (present.filters) setAccountRaw(LS.channelsFilterMap, JSON.stringify(state.filters));
|
||||
if (present.sort) setAccountRaw(LS.channelSort, JSON.stringify(state.sort));
|
||||
}, [urlSeed, accountId]);
|
||||
|
||||
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
|
||||
|
||||
Reference in New Issue
Block a user