refactor(ui): the popover engine and every render-phase ref write compile under the React Compiler
E32 S3. The compiler skipped 93 functions; it now skips 29 (try/finally shapes, two suppressions,
VirtualGrid). react-hooks/refs hits 245 -> 31.
- Popover engine: `ref={pop.refs.setReference}` made the compiler treat the whole popover handle as
a ref, so every component holding one was skipped (21 call sites, the engine and its surface
among them). The setter is destructured now; the same for useScrollFade and useReturnFocus.
- Render-phase `ref.current = x` writes moved to `useEffectEvent` where only effects read them,
else to the new `useLatest` (written in a layout effect) / `useStableCallback`.
- FocusSafeButton (under every Button) read the focused element during render; it reads it through
useSyncExternalStore, which React asks on every render instead of a cached memo.
Bugs this surfaced, fixed:
- Combobox: a chip could keep showing its raw id. The label cache was a ref an effect filled after
the render that needed it; it is state merged during render now (test seen RED).
- useUndoable rendered from a ref plus a forced re-render; compiled, that memoizes a stale value.
The history is state now, mirrored in a ref for the stable callbacks.
- ConfigPanel and the saved-views editor re-seeded their drafts in an effect, painting the stale
draft for a frame; both adjust during render against the remembered key.
- Toaster's handover is written after commit instead of during render; a new test pins that the
inherited set is taken once, at the takeover (seen RED on a per-render re-read).
- IssueComposer mutated a ref prop as a one-shot focus token; it is plain state.
- PlexBrowse wrote its info-scroll memory from an effect declared before the ref, past its own
"one writer" rule; the writer comes first now and the effect goes through it.
The compiler ratchet drops to the measured 29 and now also requires FocusSafeButton and the popover
engine to compile.
This commit is contained in:
@@ -36,6 +36,8 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
fitHeight: true,
|
||||
});
|
||||
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
const { data: ch } = useQuery({
|
||||
queryKey: qk.channel(channelId),
|
||||
queryFn: () => api.channelDetail(channelId),
|
||||
@@ -48,7 +50,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
tabIndex={0}
|
||||
className="block max-w-88 truncate text-muted cursor-help rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
|
||||
|
||||
@@ -77,13 +77,14 @@ function OverflowPopover({
|
||||
mode: "menu",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
const name = ariaLabel ?? label;
|
||||
return (
|
||||
<>
|
||||
<Chip
|
||||
variant="token"
|
||||
size="md"
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={name}
|
||||
icon={icon ? <Filter className="size-3.5" aria-hidden="true" /> : undefined}
|
||||
|
||||
@@ -31,10 +31,11 @@ export default function AddToPlaylist({
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useMembershipPopover(open, setOpen);
|
||||
const { setReference } = pop.refs;
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
variant={variant}
|
||||
size={size}
|
||||
{...pop.getReferenceProps({
|
||||
@@ -93,7 +94,7 @@ function MembershipPanel({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const fade = useScrollFade();
|
||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
||||
const open = pop.open;
|
||||
const [newName, setNewName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -161,8 +162,8 @@ function MembershipPanel({
|
||||
{t("playlists.addToPlaylistTitle")}
|
||||
</div>
|
||||
<div
|
||||
ref={fade.ref}
|
||||
style={fade.style}
|
||||
ref={fadeRef}
|
||||
style={fadeStyle}
|
||||
aria-busy={busy}
|
||||
className="max-h-56 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffectEvent, useLayoutEffect, useState, type ReactNode } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
|
||||
@@ -89,12 +89,11 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
||||
// orders its lists and writes its mail in the stored one, else English, and a Hungarian screen
|
||||
// over English-ordered lists breaks "A→Z in the UI language's alphabet".
|
||||
const [readyFor, setReadyFor] = useState<number | null | undefined>(undefined);
|
||||
// `me` is read for the identity it produced, so it rides a ref: a background refetch of the same
|
||||
// `me` is read for the identity it produced, so it is an effect event: a background refetch of the same
|
||||
// account must not re-run the adoption over a language the user has just changed. That rule used
|
||||
// to live in a dep list that excluded `me`, which made the dep list load-bearing for correctness —
|
||||
// and the failure mode of getting it wrong was a page-reload loop, not a stale value.
|
||||
const meRef = useRef(me);
|
||||
meRef.current = me;
|
||||
const adoptedMe = useEffectEvent(() => me);
|
||||
useLayoutEffect(() => {
|
||||
if (identity === undefined) return;
|
||||
if (readyFor !== undefined) {
|
||||
@@ -107,7 +106,7 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
||||
if (readyFor !== identity) location.reload();
|
||||
return;
|
||||
}
|
||||
const account = meRef.current;
|
||||
const account = adoptedMe();
|
||||
if (account && getActiveAccount() !== account.id) setActiveAccount(account.id);
|
||||
const lang = account?.preferences?.language;
|
||||
if (typeof lang === "string" && isSupported(lang)) {
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
|
||||
placement: "bottom-end",
|
||||
offset: 8,
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
// Each row keeps the per-stat explanation the old inline stats carried (as a native `title` on the
|
||||
// row), so hovering a figure still tells the user what it counts — the guidance is not lost.
|
||||
@@ -84,7 +85,7 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
|
||||
variant="glass"
|
||||
size="sm"
|
||||
icon={<BarChart3 aria-hidden="true" />}
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
>
|
||||
{t("channels.stats.label")}
|
||||
|
||||
@@ -128,6 +128,7 @@ function SuggestedChipsLane({
|
||||
mode: "hover",
|
||||
placement: "bottom",
|
||||
});
|
||||
const { setReference: setOverflowAnchor } = overflowPop.refs;
|
||||
|
||||
return (
|
||||
<div className={`${chipLaneClass} flex items-center gap-1 min-w-0 flex-1`}>
|
||||
@@ -165,7 +166,7 @@ function SuggestedChipsLane({
|
||||
<Chip
|
||||
variant="suggest"
|
||||
size="xs"
|
||||
ref={overflowPop.refs.setReference}
|
||||
ref={setOverflowAnchor}
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
@@ -208,7 +209,7 @@ function TagEditorMenu({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState("");
|
||||
const fade = useScrollFade();
|
||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
||||
const attached = new Set(attachedIds);
|
||||
const submit = () => {
|
||||
const name = draft.trim();
|
||||
@@ -218,7 +219,7 @@ function TagEditorMenu({
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div ref={fade.ref} style={fade.style} className="max-h-60 overflow-y-auto no-scrollbar">
|
||||
<div ref={fadeRef} style={fadeStyle} className="max-h-60 overflow-y-auto no-scrollbar">
|
||||
{userTags.map((tg) => {
|
||||
const on = attached.has(tg.id);
|
||||
return (
|
||||
@@ -349,6 +350,7 @@ export function ChannelTagsControl({
|
||||
// of the roving list so neither steals the field's caret.
|
||||
hostsInput: true,
|
||||
});
|
||||
const { setReference: setEditAnchor } = editPop.refs;
|
||||
|
||||
// Derived for the same reason as the suggestion row above: if tags change so nothing is collapsed
|
||||
// anymore, the "+N" trigger unmounts, and the popover must already be shut in that same render —
|
||||
@@ -362,6 +364,7 @@ export function ChannelTagsControl({
|
||||
mode: "hover",
|
||||
placement: "bottom",
|
||||
});
|
||||
const { setReference: setOverflowAnchor } = overflowPop.refs;
|
||||
|
||||
// The "+N" is a filter-look chip (neutral): the tags themselves are the accent.
|
||||
const pillClass = chipClass("filter", "xs");
|
||||
@@ -381,7 +384,7 @@ export function ChannelTagsControl({
|
||||
<Chip
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
ref={editPop.refs.setReference}
|
||||
ref={setEditAnchor}
|
||||
title={t("channels.row.editTags")}
|
||||
aria-label={t("channels.row.editTags")}
|
||||
icon={<Plus className="size-3" />}
|
||||
@@ -440,7 +443,7 @@ export function ChannelTagsControl({
|
||||
{hidden > 0 && (
|
||||
<Chip
|
||||
size="xs"
|
||||
ref={overflowPop.refs.setReference}
|
||||
ref={setOverflowAnchor}
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { getAccountRaw, LS, useAccountPersistedState } from "../lib/storage";
|
||||
import { parseChannelLayout } from "../lib/channelLayout";
|
||||
import { hydrateSort } from "../lib/columnSort";
|
||||
import { useLatest } from "../lib/useLatest";
|
||||
import { useSortState } from "../lib/useSortState";
|
||||
import { useFilterState } from "../lib/useFilterState";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
@@ -135,9 +136,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
|
||||
// it (a dep would recreate the action on every switch and re-render every actions consumer).
|
||||
// Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`.
|
||||
const viewRef = useRef(view);
|
||||
viewRef.current = view;
|
||||
const viewRef = useLatest(view);
|
||||
|
||||
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||
// The active tab rides in history.state (`_chTab`), the same way the channel page (`_chan`) and the
|
||||
@@ -159,7 +158,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
|
||||
}
|
||||
},
|
||||
[setViewRaw],
|
||||
[setViewRaw, viewRef],
|
||||
);
|
||||
const focusChannel = useCallback(
|
||||
(name: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -33,7 +33,7 @@ export default function ChatThread({
|
||||
const [draft, setDraft] = useState("");
|
||||
const [plain, setPlain] = useState<Record<number, string>>({});
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const fade = useScrollFade();
|
||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
||||
const ready = useKeyState() === "ready";
|
||||
const needsKey = !isSystem && !ready;
|
||||
|
||||
@@ -43,10 +43,10 @@ export default function ChatThread({
|
||||
{ intervalMs: POLL_MS, enabled: !needsKey },
|
||||
);
|
||||
const items = q.data?.items ?? [];
|
||||
// The two effects below take the FETCH as their trigger and read the payload through this ref, so
|
||||
// their dep lists name everything they touch without making the identity of `q.data` a trigger.
|
||||
const threadRef = useRef(q.data);
|
||||
threadRef.current = q.data;
|
||||
// The two effects below take the FETCH as their trigger and read the payload through this effect
|
||||
// event, so their dep lists name everything they touch without making the identity of `q.data` a
|
||||
// trigger.
|
||||
const latestThread = useEffectEvent(() => q.data);
|
||||
|
||||
// Decrypt the thread when fresh server data arrives. Keyed on dataUpdatedAt (not `items`): the
|
||||
// `?? []` fallback is a new array reference every render, which would otherwise re-run this
|
||||
@@ -54,6 +54,7 @@ export default function ChatThread({
|
||||
useEffect(() => {
|
||||
if (isSystem || !ready) return;
|
||||
let cancelled = false;
|
||||
const thread = latestThread();
|
||||
// Every await inside is caught per message (a failure reads "can't decrypt"), so this never rejects.
|
||||
void (async () => {
|
||||
const out: Record<number, string> = {};
|
||||
@@ -62,7 +63,7 @@ export default function ChatThread({
|
||||
// hasn't re-set-up, so has no key) degrades to "can't decrypt" instead of rejecting the pass.
|
||||
let pub: string | null = null;
|
||||
let refreshed = false;
|
||||
for (const m of threadRef.current?.items ?? []) {
|
||||
for (const m of thread?.items ?? []) {
|
||||
if (!m.ciphertext || !m.iv) continue;
|
||||
try {
|
||||
if (!pub) pub = await partnerPub(partnerId);
|
||||
@@ -90,8 +91,8 @@ export default function ChatThread({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// `dataUpdatedAt` is the trigger and the payload is read through the ref above — see Messages,
|
||||
// where keying this family on `q.data` itself produced a render loop.
|
||||
// `dataUpdatedAt` is the trigger and the payload is read through the effect event above — see
|
||||
// Messages, where keying this family on `q.data` itself produced a render loop.
|
||||
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
|
||||
|
||||
// Opening the thread (or a new incoming message arriving) marks messages read server-side, so
|
||||
@@ -108,7 +109,7 @@ export default function ChatThread({
|
||||
lastPartner.current = partnerId;
|
||||
lastIncoming.current = -1;
|
||||
}
|
||||
const incoming = (threadRef.current?.items ?? []).filter((m) => m.recipient_id === meId).length;
|
||||
const incoming = (latestThread()?.items ?? []).filter((m) => m.recipient_id === meId).length;
|
||||
if (incoming !== lastIncoming.current) {
|
||||
lastIncoming.current = incoming;
|
||||
qc.invalidateQueries({ queryKey: qk.conversations() });
|
||||
@@ -167,8 +168,8 @@ export default function ChatThread({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={fade.ref}
|
||||
style={fade.style}
|
||||
ref={fadeRef}
|
||||
style={fadeStyle}
|
||||
className="flex-1 overflow-y-auto no-scrollbar p-3 flex flex-col gap-2"
|
||||
>
|
||||
{q.isError && !q.data ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -28,6 +28,23 @@ import { SURFACE } from "./ui/surfaces";
|
||||
// registry's own order (`sysconfig.SPECS`), so a new group lands where the backend puts it instead of
|
||||
// needing a second, hand-kept order list here.
|
||||
|
||||
/** The admin's draft, plus the secrets marked to reset-to-default on the next Save (that can't be
|
||||
* expressed by clearing the field: an empty secret field means "leave unchanged"). Re-seeded from
|
||||
* `baseline` DURING render when `seedKey` changes, never on every new baseline: an unchanged refetch
|
||||
* produces a new object, and re-seeding on that is exactly the clobbering of an in-progress draft
|
||||
* that CB2 was about. (It was an effect: the first frame after a load painted the empty draft.) */
|
||||
function useSeededDraft(baseline: Record<string, string>, seedKey: string) {
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
|
||||
const [seededFor, setSeededFor] = useState<string | null>(null);
|
||||
if (seededFor !== seedKey) {
|
||||
setSeededFor(seedKey);
|
||||
setDraft(baseline);
|
||||
setSecretReset({});
|
||||
}
|
||||
return { draft, setDraft, secretReset, setSecretReset };
|
||||
}
|
||||
|
||||
export default function ConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
const panel = useTabPanel();
|
||||
@@ -46,13 +63,9 @@ export default function ConfigPanel() {
|
||||
return m;
|
||||
}, [items]);
|
||||
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
// Secrets the admin marked to reset-to-default on the next Save (can't be expressed by
|
||||
// clearing the field, since an empty secret field means "leave unchanged").
|
||||
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
|
||||
const [saveState, setSaveState] = useState<SaveState>("idle");
|
||||
// Bumped after our own save so the draft re-seeds from fresh server values; a plain mid-edit
|
||||
// refetch does NOT (see the effect below).
|
||||
// refetch does NOT (see the seed below).
|
||||
const [reseedToken, setReseedToken] = useState(0);
|
||||
// One tab per config group (persisted). Called before the loading guard so hook order is
|
||||
// stable; the active id is clamped to a real group at render time once data has loaded.
|
||||
@@ -63,15 +76,10 @@ export default function ConfigPanel() {
|
||||
// load, registry change) or an explicit post-save bump — so a refetch that resolves mid-edit
|
||||
// (e.g. an invalidation) can no longer clobber the admin's in-progress draft (CB2).
|
||||
const dataVersion = useMemo(() => items.map((i) => i.key).join("|"), [items]);
|
||||
// The baseline is read through a ref, not named as a dependency: an unchanged refetch produces a
|
||||
// new object, and re-seeding on that is exactly the clobbering of an in-progress draft that CB2
|
||||
// was about. `dataVersion` (the config SHAPE) and the post-save token are the real triggers.
|
||||
const baselineRef = useRef(baseline);
|
||||
baselineRef.current = baseline;
|
||||
useEffect(() => {
|
||||
setDraft(baselineRef.current);
|
||||
setSecretReset({});
|
||||
}, [dataVersion, reseedToken]);
|
||||
const { draft, setDraft, secretReset, setSecretReset } = useSeededDraft(
|
||||
baseline,
|
||||
`${reseedToken}:${dataVersion}`,
|
||||
);
|
||||
|
||||
const isDirty = (it: ConfigItem): boolean => {
|
||||
if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key];
|
||||
|
||||
@@ -92,6 +92,8 @@ export default function DateFilterSelect({
|
||||
hostsInput: true,
|
||||
});
|
||||
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
const pick = (value: string) => {
|
||||
const change = selectDateChip(value, filters);
|
||||
setCustomOpen(change.customOpen);
|
||||
@@ -105,7 +107,7 @@ export default function DateFilterSelect({
|
||||
context="form"
|
||||
open={open}
|
||||
icon={<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />}
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
keepPreviousData,
|
||||
@@ -280,6 +280,8 @@ export default function Feed({
|
||||
// onState lets memo(VideoCard) skip re-rendering existing cards on page append.
|
||||
const loadedRef = useRef<Video[]>([]);
|
||||
|
||||
// The toast's Undo re-enters onState, a forward reference: through this ref (set below).
|
||||
const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
|
||||
const onState = useCallback(
|
||||
(id: string, status: VideoStatus) => {
|
||||
setOverrides((o) => ({ ...o, [id]: status }));
|
||||
@@ -349,11 +351,6 @@ export default function Feed({
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
// The toast's Undo action re-enters onState, which is a forward reference to the binding still
|
||||
// being defined — the same shape PlexPlayer's session reclaim has, and answered the same way.
|
||||
const onStateRef = useRef(onState);
|
||||
// eslint-disable-next-line react-hooks/immutability -- a latest-value ref for a forward reference: the Undo re-enters onState before its binding exists
|
||||
onStateRef.current = onState;
|
||||
|
||||
const onResetState = useCallback(
|
||||
(id: string) => {
|
||||
@@ -404,7 +401,12 @@ export default function Feed({
|
||||
);
|
||||
|
||||
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
||||
loadedRef.current = loaded;
|
||||
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
||||
// onState's refs follow each commit: itself, and what the cards show (live search results if on).
|
||||
useLayoutEffect(() => {
|
||||
onStateRef.current = onState;
|
||||
loadedRef.current = ytActive ? ytLoaded : loaded;
|
||||
});
|
||||
const withOverrides = (list: Video[]): Video[] =>
|
||||
list
|
||||
.map((v) => {
|
||||
@@ -435,8 +437,6 @@ export default function Feed({
|
||||
// A separate data source rendered in the same cards/player. No watch-state view filter (the
|
||||
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
|
||||
if (ytActive) {
|
||||
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
||||
loadedRef.current = ytLoaded;
|
||||
const ytItems: Video[] = sortYtResults(
|
||||
withOverrides(ytLoaded),
|
||||
ytSort.key as SortKey,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent, type RefObject } from "react";
|
||||
import { useEffect, useEffectEvent, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
|
||||
@@ -132,8 +132,9 @@ function ComposerBody({
|
||||
const [reviewing, setReviewing] = useState<Source | null>(null);
|
||||
const issueLink = useRef<HTMLAnchorElement>(null);
|
||||
const retryButton = useRef<HTMLButtonElement>(null);
|
||||
// The log whose review just closed: its Review button takes the focus back.
|
||||
const reviewed = useRef<Source | null>(null);
|
||||
// The log whose review just closed: its Review button takes the focus back when the attachments
|
||||
// mount again (they unmount while a review is open).
|
||||
const [reviewed, setReviewed] = useState<Source | null>(null);
|
||||
const sent = session.issue_url !== null;
|
||||
|
||||
const shotsQ = useQuery({
|
||||
@@ -231,19 +232,17 @@ function ComposerBody({
|
||||
});
|
||||
const locked = sent || send.isPending || sending;
|
||||
|
||||
const saveRef = useRef(save.mutate);
|
||||
saveRef.current = save.mutate;
|
||||
const saveNow = useEffectEvent(() => save.mutate());
|
||||
useEffect(() => {
|
||||
if (locked || !unsaved) return;
|
||||
const timer = window.setTimeout(() => saveRef.current(), SAVE_DELAY_MS);
|
||||
const timer = window.setTimeout(() => saveNow(), SAVE_DELAY_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [body, form.title, locked, unsaved]);
|
||||
// Closed before the typing paused: save what is still waiting, now.
|
||||
const flushRef = useRef(() => {});
|
||||
flushRef.current = () => {
|
||||
const flush = useEffectEvent(() => {
|
||||
if (unsaved && !locked) save.mutate();
|
||||
};
|
||||
useEffect(() => () => flushRef.current(), []);
|
||||
});
|
||||
useEffect(() => () => flush(), []);
|
||||
|
||||
// Screenshots are added here, not in the attachments column: an image pasted ANYWHERE in the
|
||||
// composer is taken — while typing the description is exactly when one is pasted.
|
||||
@@ -252,8 +251,7 @@ function ComposerBody({
|
||||
meta: { failTitle: "recorder.pill.shotFailed" },
|
||||
onSuccess: () => refreshShots(qc, session.id),
|
||||
});
|
||||
const addRef = useRef(addShot.mutate);
|
||||
addRef.current = addShot.mutate;
|
||||
const addImage = useEffectEvent((image: Blob) => addShot.mutate(image));
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (locked) return;
|
||||
@@ -265,7 +263,7 @@ function ComposerBody({
|
||||
const images = [...(e.clipboardData?.files ?? [])].filter(isImage);
|
||||
if (!images.length) return;
|
||||
e.preventDefault();
|
||||
for (const image of images) addRef.current(image);
|
||||
for (const image of images) addImage(image);
|
||||
};
|
||||
document.addEventListener("paste", onPaste);
|
||||
return () => document.removeEventListener("paste", onPaste);
|
||||
@@ -393,7 +391,7 @@ function ComposerBody({
|
||||
}))
|
||||
}
|
||||
onDone={() => {
|
||||
reviewed.current = reviewing;
|
||||
setReviewed(reviewing);
|
||||
setReviewing(null);
|
||||
}}
|
||||
/>
|
||||
@@ -606,7 +604,7 @@ function Attachments({
|
||||
adding: boolean;
|
||||
onAdd: (files: Iterable<File>) => void;
|
||||
onReview: (source: Source) => void;
|
||||
reviewed: RefObject<Source | null>;
|
||||
reviewed: Source | null;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
@@ -615,13 +613,10 @@ function Attachments({
|
||||
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
|
||||
const pick = useRef<HTMLInputElement>(null);
|
||||
const reviewButtons = useRef<Partial<Record<Source, HTMLButtonElement | null>>>({});
|
||||
// eslint-disable-next-line react-hooks/immutability -- the effect that consumes the caller's one-shot focus token (see the write below)
|
||||
// On mount after a review closed (this list unmounts while one is open), that log's Review button
|
||||
// takes the focus back. Only a mount or a new review moves it, so no later render steals it.
|
||||
useEffect(() => {
|
||||
const source = reviewed.current;
|
||||
if (!source) return;
|
||||
// eslint-disable-next-line react-hooks/immutability -- a ONE-SHOT token, consumed: the parent sets it to say "give this source's review button the focus back when the list re-renders", and clearing it here is what stops the focus being stolen on every later render. The ref is the caller's, which is why it is a dependency rather than a local.
|
||||
reviewed.current = null;
|
||||
reviewButtons.current[source]?.focus();
|
||||
if (reviewed) reviewButtons.current[reviewed]?.focus();
|
||||
}, [reviewed]);
|
||||
const remove = useMutation({
|
||||
mutationFn: (shotId: number) => api.removeDebugScreenshot(session.id, shotId),
|
||||
|
||||
@@ -44,10 +44,12 @@ export default function LanguageSwitcher({
|
||||
selectedIndex: currentIdx,
|
||||
});
|
||||
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
title={current.label}
|
||||
aria-label={current.label}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useEffectEvent, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -90,20 +90,20 @@ function ConversationList({
|
||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||
|
||||
// Decrypt conversation previews when fresh data arrives. The TRIGGER is `dataUpdatedAt` and the
|
||||
// payload is read through a ref, which is what makes the dep list below complete without making
|
||||
// the identity of `q.data` a trigger. That distinction is load-bearing: the `?? []` fallback is a
|
||||
// payload is read through an effect event, which is what makes the dep list below complete without
|
||||
// making the identity of `q.data` a trigger. That distinction is load-bearing: the `?? []` fallback is a
|
||||
// new array every render, and anything handing back a fresh object per render (a stub, a caller
|
||||
// that rebuilds it) turns "re-run when the data changes" into a render loop through setPreviews.
|
||||
// Measured the hard way — E27 S2 keyed this on `q.data`, and Messages.keyboard.dom.test hung.
|
||||
const conversationsRef = useRef(q.data);
|
||||
conversationsRef.current = q.data;
|
||||
const latestConversations = useEffectEvent(() => q.data);
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
let cancelled = false;
|
||||
const conversations = latestConversations();
|
||||
// Each decrypt is caught (a failure previews as "⚠"), so this never rejects.
|
||||
void (async () => {
|
||||
const out: Record<number, string> = {};
|
||||
for (const c of conversationsRef.current?.items ?? []) {
|
||||
for (const c of conversations?.items ?? []) {
|
||||
const m = c.last_message;
|
||||
if (m.kind === "user" && m.ciphertext && m.iv) {
|
||||
try {
|
||||
|
||||
@@ -74,6 +74,7 @@ export default function NavSidebar({
|
||||
placement: "right-end",
|
||||
offset: 8,
|
||||
});
|
||||
const { setReference: setAcctAnchor } = acctPop.refs;
|
||||
|
||||
async function logout() {
|
||||
// Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then
|
||||
@@ -145,7 +146,7 @@ export default function NavSidebar({
|
||||
|
||||
// Scroll-affordance for the module list: hidden scrollbar + top/bottom edge fade (shared hook,
|
||||
// also used by the side panels) so a high-zoom overflow scrolls without a scrollbar.
|
||||
const listFade = useScrollFade();
|
||||
const { ref: listFadeRef, style: listFadeStyle } = useScrollFade();
|
||||
|
||||
type NavItem = { page: Page; icon: LucideIcon; label: string; badge?: number };
|
||||
// Per-page presentation (icon/label/badge). WHICH pages appear and in what order — and each
|
||||
@@ -198,7 +199,7 @@ export default function NavSidebar({
|
||||
// against its right edge.
|
||||
<div className="relative shrink-0 w-15">
|
||||
<NavRail label={t("nav.primary")} className="absolute top-0 bottom-0 left-0 z-rail my-3">
|
||||
<NavRailList ref={listFade.ref} style={listFade.style}>
|
||||
<NavRailList ref={listFadeRef} style={listFadeStyle}>
|
||||
{userItems.map(renderItem)}
|
||||
{systemItems.length > 0 && (
|
||||
<>
|
||||
@@ -243,7 +244,7 @@ export default function NavSidebar({
|
||||
|
||||
<div>
|
||||
<Pressable
|
||||
ref={acctPop.refs.setReference}
|
||||
ref={setAcctAnchor}
|
||||
{...acctPop.getReferenceProps()}
|
||||
title={`${name} · ${roleLabel}`}
|
||||
aria-label={`${name} · ${roleLabel}`}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useLatest } from "../lib/useLatest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { urlWithParams, type Page } from "../lib/urlState";
|
||||
@@ -47,15 +48,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
channelViewFrom(window.history.state, window.location.search),
|
||||
);
|
||||
|
||||
// Refs so the STABLE action callbacks can read the latest state without depending on it — a dep
|
||||
// would recreate them on every navigation and re-render every actions consumer (e.g. VideoCard).
|
||||
// Written during render (the sanctioned "latest value" pattern): the ref is only READ in event
|
||||
// handlers after commit, and this provider never suspends or renders in a transition, so no
|
||||
// discarded render can leave it stale.
|
||||
const pageRef = useRef(page);
|
||||
pageRef.current = page;
|
||||
const channelViewRef = useRef(channelView);
|
||||
channelViewRef.current = channelView;
|
||||
// Latest-value refs so the STABLE action callbacks can read the latest state without depending on
|
||||
// it — a dep would recreate them on every navigation and re-render every actions consumer (e.g.
|
||||
// VideoCard). Only READ in event handlers, after commit.
|
||||
const pageRef = useLatest(page);
|
||||
const channelViewRef = useLatest(channelView);
|
||||
|
||||
const setPage = useCallback(
|
||||
(next: Page, then?: () => void) =>
|
||||
@@ -101,7 +98,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
notifyNav();
|
||||
then?.();
|
||||
}),
|
||||
[],
|
||||
[pageRef, channelViewRef],
|
||||
);
|
||||
|
||||
// A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands
|
||||
|
||||
@@ -149,13 +149,13 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
}, [layer]);
|
||||
|
||||
// Are we currently importing (or about to)? Show a progress screen instead of the
|
||||
// "you're connected" step until the first import settles.
|
||||
// "you're connected" step until the first import settles. Once triggered, the mutation itself says
|
||||
// which (pending / success / error) — the trigger ref is the effect's once-only latch, never read
|
||||
// here (a ref read in render made the React Compiler skip the wizard, E32).
|
||||
const importing =
|
||||
me.can_read &&
|
||||
!importSubs.isError &&
|
||||
(importSubs.isPending ||
|
||||
(importTriggered.current && !importSubs.isSuccess) ||
|
||||
(myStatus.data?.channels_total === 0 && !importSubs.isSuccess));
|
||||
(importSubs.isPending || (myStatus.data?.channels_total === 0 && !importSubs.isSuccess));
|
||||
|
||||
const step = !me.can_read ? "read" : !me.can_write ? "write" : "done";
|
||||
const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BookmarkPlus, Check, Play, Save, Star, Trash2 } from "lucide-react";
|
||||
import {
|
||||
@@ -80,6 +80,34 @@ function ViewRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** The SELECTED view's two drafts: its name (the rename field) and a local, undoable draft of its
|
||||
* captured filters. Removing a chip prunes the filter draft and — via onApply — writes the pruned set
|
||||
* straight back to the stored view; Undo/Redo re-persist the same way. The draft is the single
|
||||
* source of truth for the chip row, so a chip-prune refetch (which returns the value we just wrote)
|
||||
* never clobbers the undo history: both re-seed only when the SELECTION changes (and on Save-current,
|
||||
* by the caller). Adjusted DURING render against the remembered selection — an effect painted the
|
||||
* previous view's filters and name for a frame first. `selectedId` is the trigger, `selected` only
|
||||
* the data. */
|
||||
function useSelectedViewDrafts(
|
||||
selected: SavedView | null,
|
||||
selectedId: number | null,
|
||||
updateFilters: (id: number, filters: Record<string, unknown>) => unknown,
|
||||
) {
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const filtersDraft = useUndoable<Record<string, unknown>>(selected?.filters ?? {}, {
|
||||
onApply: (f) => {
|
||||
if (selected) void updateFilters(selected.id, f);
|
||||
},
|
||||
});
|
||||
const [seededFor, setSeededFor] = useState<number | null | undefined>(undefined);
|
||||
if (seededFor !== selectedId) {
|
||||
setSeededFor(selectedId);
|
||||
filtersDraft.reset(selected?.filters ?? {});
|
||||
setNameDraft(selected?.name ?? "");
|
||||
}
|
||||
return { filtersDraft, nameDraft, setNameDraft };
|
||||
}
|
||||
|
||||
export default function PersonalViewsModal() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -107,29 +135,11 @@ export default function PersonalViewsModal() {
|
||||
const selected = views.find((v) => v.id === selectedId) ?? null;
|
||||
// Two independent name fields: the left one names a NEW view; the right one renames the selected one.
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
|
||||
// A local, undoable draft of the SELECTED view's captured filters. Removing a chip prunes the draft
|
||||
// and — via onApply — writes the pruned set straight back to the stored view; Undo/Redo re-persist
|
||||
// the same way. The draft is the single source of truth for the chip row, so a chip-prune refetch
|
||||
// (which returns the value we just wrote) never clobbers the undo history. We only re-seed it on an
|
||||
// external change of the visible filters: switching the selection, or Save-current below.
|
||||
const filtersDraft = useUndoable<Record<string, unknown>>(selected?.filters ?? {}, {
|
||||
onApply: (f) => {
|
||||
if (selected) void updateFilters(selected.id, f);
|
||||
},
|
||||
});
|
||||
const resetDraft = filtersDraft.reset;
|
||||
// Re-seed only when the SELECTION changes, not on every same-view refetch — a refetch returns the
|
||||
// value we just wrote, and re-seeding on it would throw away the prune's undo history. The chosen
|
||||
// view is therefore read through a ref: `selectedId` is the trigger, `selected` is only the data.
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
useEffect(() => {
|
||||
const view = selectedRef.current;
|
||||
resetDraft(view?.filters ?? {});
|
||||
setNameDraft(view?.name ?? "");
|
||||
}, [selectedId, resetDraft]);
|
||||
const { filtersDraft, nameDraft, setNameDraft } = useSelectedViewDrafts(
|
||||
selected,
|
||||
selectedId,
|
||||
updateFilters,
|
||||
);
|
||||
|
||||
// The selected view's captured filters, as the same human labels its module's chip row shows, read
|
||||
// from the undoable draft. The feed hook runs unconditionally (fed empty filters off-feed); Plex
|
||||
@@ -322,7 +332,7 @@ export default function PersonalViewsModal() {
|
||||
const saved = await saveCurrent(selected.id);
|
||||
// Re-seed the draft to what we just stored (the live filters) so the chip row
|
||||
// reflects the save; clears the now-stale undo history.
|
||||
if (saved) resetDraft(saved);
|
||||
if (saved) filtersDraft.reset(saved);
|
||||
}}
|
||||
disabled={saveCurrentDisabled}
|
||||
title={
|
||||
|
||||
@@ -148,7 +148,7 @@ export default function PlayerModal({
|
||||
const lang = i18n.language;
|
||||
const { openChannel } = useNavigationActions();
|
||||
const qc = useQueryClient();
|
||||
const descFade = useScrollFade();
|
||||
const { ref: descFadeRef, style: descFadeStyle } = useScrollFade();
|
||||
// Browser/mouse Back closes the player instead of leaving the page.
|
||||
useBackToClose(onClose);
|
||||
// Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
|
||||
@@ -258,7 +258,6 @@ export default function PlayerModal({
|
||||
const {
|
||||
isFullscreen,
|
||||
maximized,
|
||||
maximizedRef,
|
||||
isFullscreenRef,
|
||||
toggleFullscreen,
|
||||
toggleTrueFullscreen,
|
||||
@@ -468,6 +467,7 @@ export default function PlayerModal({
|
||||
placement: "top-start",
|
||||
offset: 8,
|
||||
});
|
||||
const { setReference: setDescAnchor } = descPop.refs;
|
||||
const detail = useQuery({
|
||||
queryKey: qk.videoDetail(currentVideoId),
|
||||
queryFn: () => api.videoDetail(currentVideoId),
|
||||
@@ -514,7 +514,7 @@ export default function PlayerModal({
|
||||
// all. Both flags, not just fullscreen: since F stopped taking the browser's lock, maximise is the
|
||||
// common case and checking only `isFullscreen` made the keys dead exactly where they matter most.
|
||||
const volume = (e: KeyboardEvent, up: boolean) => {
|
||||
const covered = isFullscreenRef.current || maximizedRef.current;
|
||||
const covered = isFullscreenRef.current || maximized;
|
||||
if (!covered && canScrollBy(document.activeElement as HTMLElement | null, up ? -1 : 1))
|
||||
return;
|
||||
e.preventDefault();
|
||||
@@ -544,7 +544,7 @@ export default function PlayerModal({
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
if (fullscreenElement()) return;
|
||||
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
||||
if (maximizedRef.current) {
|
||||
if (maximized) {
|
||||
ev.preventDefault();
|
||||
setMaximized(false);
|
||||
return;
|
||||
@@ -889,7 +889,7 @@ export default function PlayerModal({
|
||||
</h2>
|
||||
{!wide && (
|
||||
<IconButton
|
||||
ref={descPop.refs.setReference}
|
||||
ref={setDescAnchor}
|
||||
{...descPop.getReferenceProps()}
|
||||
variant="ghost"
|
||||
aria-expanded={showDesc}
|
||||
@@ -918,8 +918,8 @@ export default function PlayerModal({
|
||||
</SectionLabel>
|
||||
{description((body) => (
|
||||
<div
|
||||
ref={descFade.ref}
|
||||
style={descFade.style}
|
||||
ref={descFadeRef}
|
||||
style={descFadeStyle}
|
||||
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||||
>
|
||||
{body}
|
||||
|
||||
@@ -22,16 +22,17 @@ export default function PlaylistLimitsHint() {
|
||||
placement: "right-end",
|
||||
offset: 8,
|
||||
});
|
||||
const { setPositionReference, setReference } = pop.refs;
|
||||
return (
|
||||
// The panel is placed off the whole row, not the "Why?" button: off the button it opened
|
||||
// over the right third of the rail and covered the lists (measured on localdev).
|
||||
<div
|
||||
ref={pop.refs.setPositionReference}
|
||||
ref={setPositionReference}
|
||||
className="mt-2 flex items-center gap-1.5 border-t border-border/60 px-1 pt-2 text-2xs text-muted"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>{t("playlists.limits.hint")}</span>
|
||||
<Button variant="link" ref={pop.refs.setReference} {...pop.getReferenceProps()}>
|
||||
<Button variant="link" ref={setReference} {...pop.getReferenceProps()}>
|
||||
{t("playlists.limits.why")}
|
||||
</Button>
|
||||
<PopoverSurface
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLatest, useStableCallback } from "../lib/useLatest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -167,7 +168,7 @@ export default function Playlists() {
|
||||
const qc = useQueryClient();
|
||||
const plActions = usePlaylistActions();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const renameFocus = useReturnFocus(renaming);
|
||||
const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each
|
||||
// is reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server.
|
||||
@@ -215,8 +216,7 @@ export default function Playlists() {
|
||||
// the actual set of items changes (different playlist, or an add/remove).
|
||||
const lastSetRef = useRef<string>("");
|
||||
// The selection as of NOW, for code that runs after an await or on behalf of the open player.
|
||||
const selectedRef = useRef(selectedId);
|
||||
selectedRef.current = selectedId;
|
||||
const selectedRef = useLatest(selectedId);
|
||||
// Put the rows back to what the CACHED detail says — the server's list as last fetched. A failed
|
||||
// reorder or move must not wait for the resync to do it: the server's list is unchanged, so the
|
||||
// refetch comes back deep-equal, react-query keeps the same `data` object, and the effect below
|
||||
@@ -330,12 +330,15 @@ export default function Playlists() {
|
||||
// push/delete-on-YouTube paths (usePlaylistActions). Folding it in here would have taken drag and remove away from
|
||||
// every account without the scope, which they have always had.
|
||||
const canEdit = editable;
|
||||
const canEditRef = useRef(canEdit);
|
||||
canEditRef.current = canEdit;
|
||||
const canEditRef = useLatest(canEdit);
|
||||
// Forward refs to `shown` and `removeItem` (defined further down) for the stable callbacks here,
|
||||
// pointed at the latest values after each commit (below).
|
||||
const shownRef = useRef<Video[]>([]);
|
||||
const removeRef = useRef<typeof removeItem>(async () => false);
|
||||
|
||||
// Stable (the memoized row callbacks depend on it), so it reads `shown` through the ref rather
|
||||
// than closing over it.
|
||||
const openPlayer = useCallback((id: string) => {
|
||||
const openPlayer = useStableCallback((id: string) => {
|
||||
const queue = shownRef.current;
|
||||
const index = queue.findIndex((v) => v.id === id);
|
||||
if (index < 0) return;
|
||||
@@ -346,18 +349,14 @@ export default function Playlists() {
|
||||
listId: selectedRef.current,
|
||||
editable: canEditRef.current,
|
||||
});
|
||||
}, []);
|
||||
const onPlay = useCallback((v: Video) => openPlayer(v.id), [openPlayer]);
|
||||
});
|
||||
// The player's "remove from this playlist" goes through the SAME removeItem the row menu and the
|
||||
// Delete key use — one optimistic update, one undo toast, one undo-stack rebase, no second copy to
|
||||
// drift. It answers whether the removal actually happened, because the player has to decide
|
||||
// afterwards whether to step to the next item or close. It removes from the list the player was
|
||||
// launched from (see `playing.listId`), not from whatever is selected by now.
|
||||
const launchListId = playing?.listId ?? null;
|
||||
const removeFromPlayer = useCallback(
|
||||
(v: Video) => removeRef.current(v, launchListId),
|
||||
[launchListId],
|
||||
);
|
||||
const removeFromPlayer = (v: Video) => removeRef.current(v, launchListId);
|
||||
|
||||
const storedIndex = useMemo(() => {
|
||||
const m = new Map(items.map((v, i) => [v.id, i]));
|
||||
@@ -379,7 +378,7 @@ export default function Playlists() {
|
||||
playlistColumns({
|
||||
t,
|
||||
indexOf: storedIndex,
|
||||
onPlay,
|
||||
onPlay: (v) => openPlayer(v.id),
|
||||
more: (v) => (
|
||||
<MoreButton
|
||||
label={t("playlists.rowMenu")}
|
||||
@@ -389,7 +388,7 @@ export default function Playlists() {
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[t, storedIndex, onPlay, rowMenuButton, rowMenuOpen, rowMenuTarget],
|
||||
[t, storedIndex, openPlayer, rowMenuButton, rowMenuOpen, rowMenuTarget],
|
||||
);
|
||||
|
||||
const filterActive = Object.values(filters).some(isActive);
|
||||
@@ -397,11 +396,6 @@ export default function Playlists() {
|
||||
() => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy, lang),
|
||||
[items, columns, filters, viewSort, groupBy, lang],
|
||||
);
|
||||
// Read by the drag announcements, which must report the CURRENT positions without making the
|
||||
// announcement closures a render dependency of DndContext.
|
||||
const shownRef = useRef(shown);
|
||||
// eslint-disable-next-line react-hooks/immutability -- the house latest-value ref: the announcement closures must read the CURRENT list without the list becoming a render dependency of DndContext. A render-phase ref write is never part of the rendered output, which is what makes it safe; the checker is conservative about refs an effect also reads.
|
||||
shownRef.current = shown;
|
||||
|
||||
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
|
||||
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
|
||||
@@ -535,10 +529,12 @@ export default function Playlists() {
|
||||
});
|
||||
return true;
|
||||
}
|
||||
// Kept in a ref so the memoized column callbacks don't rebuild on every state change.
|
||||
const removeRef = useRef(removeItem);
|
||||
// eslint-disable-next-line react-hooks/immutability -- same latest-value ref as shownRef above — it keeps the memoised column callbacks from rebuilding on every state change.
|
||||
removeRef.current = removeItem;
|
||||
// The drag announcements read `shownRef` too: the CURRENT positions, without making their closures
|
||||
// a render dependency of DndContext; `removeRef` keeps the memoized column callbacks stable.
|
||||
useLayoutEffect(() => {
|
||||
shownRef.current = shown;
|
||||
removeRef.current = removeItem;
|
||||
});
|
||||
|
||||
async function moveItemTo(video: Video, target: Playlist) {
|
||||
if (selectedId == null) return;
|
||||
@@ -610,7 +606,7 @@ export default function Playlists() {
|
||||
|
||||
// Stable identity: PlayerModal's keydown effect depends on it, so a fresh closure
|
||||
// every render re-subscribed those listeners and re-ran focusModal() under the open player.
|
||||
const closePlayer = useCallback(() => setPlaying(null), []);
|
||||
const closePlayer = () => setPlaying(null);
|
||||
|
||||
// Name the current view in the user's terms: "Title ↑", "grouped by channel", or both.
|
||||
const sortLabel =
|
||||
@@ -866,11 +862,11 @@ export default function Playlists() {
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
renameFocus.arm(e);
|
||||
armRenameReturn(e);
|
||||
void saveRename(); // plActions.rename reports its own failure
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
renameFocus.arm(e);
|
||||
armRenameReturn(e);
|
||||
setRenaming(false);
|
||||
}
|
||||
}}
|
||||
@@ -902,7 +898,7 @@ export default function Playlists() {
|
||||
{/* Rename, the pin, YouTube (open / push / revert) and Delete: the list's ONE
|
||||
menu (E28 C1), the same the rail row opens — the header keeps Play all. */}
|
||||
<MoreButton
|
||||
ref={renameFocus.target}
|
||||
ref={renameTarget}
|
||||
variant="outline"
|
||||
label={t("playlists.railMenu")}
|
||||
expanded={headerMenu.open}
|
||||
|
||||
@@ -143,12 +143,22 @@ export default function PlexBrowse() {
|
||||
}
|
||||
}, [openPlaylist, sub, setPlaylistOpen]);
|
||||
|
||||
// Like the grid's scroll memory (below), for the info page: "Browse collection" (onFilter) leaves
|
||||
// the info view for a filtered grid, and browser Back returns to this same info page — restore
|
||||
// where the user had scrolled it (down to a collection strip) instead of snapping to the top. Reset
|
||||
// on a fresh info open so a different title's info starts at the top.
|
||||
const infoScrollRef = useRef(0);
|
||||
// ONE writer for that memory, so "stash on the way out of an info page, reset on a fresh one" is a
|
||||
// rule the code holds rather than one spread over the call sites. Declared above the effects that
|
||||
// use it: an effect closing over the ref before its declaration made the compiler skip PlexBrowse.
|
||||
const rememberInfoScroll = (px: number) => void (infoScrollRef.current = px);
|
||||
|
||||
// The header search typeahead picked a title → open it the same way a card click would (a movie's
|
||||
// info page like the card menu's "Media info", a show's drill-down), then clear the hand-off so it
|
||||
// doesn't re-open.
|
||||
useEffect(() => {
|
||||
if (jumpTo != null) {
|
||||
if (jumpTo.type !== "show") infoScrollRef.current = 0; // a jumped-to info page starts at the top
|
||||
if (jumpTo.type !== "show") rememberInfoScroll(0); // a jumped-to info page starts at the top
|
||||
sub.open(
|
||||
jumpTo.type === "show"
|
||||
? { kind: "show", id: jumpTo.id }
|
||||
@@ -187,22 +197,6 @@ export default function PlexBrowse() {
|
||||
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
||||
// instead of the top of the library. The scroller is App's <main> (the page's overflow-y-auto).
|
||||
const scrollRef = useRef(0);
|
||||
// Same idea for the info page: "Browse collection" (onFilter) leaves the info view for a filtered
|
||||
// grid, and browser Back returns to this same info page — restore where the user had scrolled it
|
||||
// (down to a collection strip) instead of snapping to the top. Reset on a fresh info open so a
|
||||
// different title's info starts at the top.
|
||||
const infoScrollRef = useRef(0);
|
||||
// ONE writer for that memory, so "stash on the way out of an info page, reset on a fresh one" is a
|
||||
// rule the code holds rather than one spread over four call sites — and so the compiler's
|
||||
// complaint about it is answered once, here, instead of at each of them.
|
||||
//
|
||||
// The complaint is a false positive of a conservative analysis, not a hazard: the rule objects to a
|
||||
// ref being written outside the effect that reads it, but that IS the arrangement a scroll memory
|
||||
// needs — the navigation handlers record where the page was, and the restore layout effect above
|
||||
// reads it on the way back. React's own guarantee (a ref write is never part of rendering) is what
|
||||
// makes it safe, and nothing here reads the value during render.
|
||||
// eslint-disable-next-line react-hooks/immutability -- a scroll memory: handlers record, the restore layout effect reads, nothing reads it in render
|
||||
const rememberInfoScroll = (px: number) => void (infoScrollRef.current = px);
|
||||
// One menu for the titles grid and one for the search's episodes (see ui/ContextMenu).
|
||||
const menu = useContextMenu<PlexCard>();
|
||||
const epMenu = useContextMenu<PlexCard>();
|
||||
|
||||
@@ -34,8 +34,8 @@ export default function PlexCollectionEditor({
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const editableFade = useScrollFade();
|
||||
const othersFade = useScrollFade();
|
||||
const { ref: editableFadeRef, style: editableFadeStyle } = useScrollFade();
|
||||
const { ref: othersFadeRef, style: othersFadeStyle } = useScrollFade();
|
||||
const [members, setMembers] = useState<Set<string>>(() => new Set(memberOf));
|
||||
const [q, setQ] = useState("");
|
||||
const [newName, setNewName] = useState("");
|
||||
@@ -186,8 +186,8 @@ export default function PlexCollectionEditor({
|
||||
|
||||
{/* Editable collections with in/out toggle */}
|
||||
<div
|
||||
ref={editableFade.ref}
|
||||
style={editableFade.style}
|
||||
ref={editableFadeRef}
|
||||
style={editableFadeStyle}
|
||||
className="max-h-80 space-y-1 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{listQ.isLoading ? (
|
||||
@@ -238,8 +238,8 @@ export default function PlexCollectionEditor({
|
||||
{t("plex.collEditor.others", { count: eligible.length })}
|
||||
</summary>
|
||||
<div
|
||||
ref={othersFade.ref}
|
||||
style={othersFade.style}
|
||||
ref={othersFadeRef}
|
||||
style={othersFadeStyle}
|
||||
className="mt-2 max-h-40 space-y-1 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{eligible.map((c) => (
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function PlexCollectionView({
|
||||
const d = q.data;
|
||||
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const renameFocus = useReturnFocus(renaming);
|
||||
const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
|
||||
const [name, setName] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -305,11 +305,11 @@ export default function PlexCollectionView({
|
||||
onBlur={doRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
renameFocus.arm(e);
|
||||
armRenameReturn(e);
|
||||
void doRename(); // reports its own failure
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
renameFocus.arm(e);
|
||||
armRenameReturn(e);
|
||||
setRenaming(false);
|
||||
}
|
||||
}}
|
||||
@@ -371,7 +371,7 @@ export default function PlexCollectionView({
|
||||
{canEdit && (
|
||||
<>
|
||||
<ActionBtn
|
||||
ref={renameFocus.target}
|
||||
ref={renameTarget}
|
||||
onClick={() => {
|
||||
setName(d.title);
|
||||
setRenaming(true);
|
||||
|
||||
@@ -325,7 +325,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
fitHeight: true,
|
||||
openOnArrowKey: false,
|
||||
});
|
||||
const tracksFade = useScrollFade();
|
||||
const { ref: tracksFadeRef, style: tracksFadeStyle } = useScrollFade();
|
||||
// The ⋯ is both the menu's trigger and the tuning panel's dismiss exception and focus return.
|
||||
const setMoreReference = morePop.refs.setReference;
|
||||
const moreTriggerRef = useCallback(
|
||||
@@ -1783,10 +1783,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
{/* Portaled, but a React child of the player: a click or a wheel here must not
|
||||
reach the stage's play-toggle and volume handlers. */}
|
||||
<div
|
||||
ref={tracksFade.ref}
|
||||
ref={tracksFadeRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
style={{ ...tracksFade.style, maxHeight: "min(60dvh, var(--fit-max-h))" }}
|
||||
style={{ ...tracksFadeStyle, maxHeight: "min(60dvh, var(--fit-max-h))" }}
|
||||
className="overflow-auto no-scrollbar p-2"
|
||||
>
|
||||
{detail.audio_streams.length > 1 && (
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function PlexPlaylistAdd({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const fade = useScrollFade();
|
||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
||||
const qc = useQueryClient();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -141,8 +141,8 @@ export default function PlexPlaylistAdd({
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={fade.ref}
|
||||
style={fade.style}
|
||||
ref={fadeRef}
|
||||
style={fadeStyle}
|
||||
className="max-h-80 space-y-1 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{listQ.isLoading ? (
|
||||
|
||||
@@ -416,7 +416,7 @@ export default function PlexPlaylistView({
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const renameFocus = useReturnFocus(renaming);
|
||||
const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
|
||||
const [name, setName] = useState("");
|
||||
const [layout, setLayout] = useState<Layout>(() =>
|
||||
getAccountRaw(LS.plexPlaylistLayout) === "tree" ? "tree" : "accordion",
|
||||
@@ -741,12 +741,12 @@ export default function PlexPlaylistView({
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
renameFocus.arm(e);
|
||||
armRenameReturn(e);
|
||||
void rename(); // reports its own failure
|
||||
}
|
||||
// Unmounting the field fires no blur, so Escape really does cancel.
|
||||
if (e.key === "Escape") {
|
||||
renameFocus.arm(e);
|
||||
armRenameReturn(e);
|
||||
setRenaming(false);
|
||||
}
|
||||
}}
|
||||
@@ -783,7 +783,7 @@ export default function PlexPlaylistView({
|
||||
{/* Rename and delete — the playlist's own, rarer actions — sit in its "…" (E28 C1); a
|
||||
rename opened from there hands the focus back to it. */}
|
||||
<MoreButton
|
||||
ref={renameFocus.target}
|
||||
ref={renameTarget}
|
||||
variant="glass"
|
||||
size="md"
|
||||
label={t("plex.playlist.menu")}
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function SavedViewsPopover({
|
||||
const { views, activeViewId, apply, openModal } = useSavedViews();
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement });
|
||||
const { setReference } = pop.refs;
|
||||
const label = ariaLabel ?? t("views.saved");
|
||||
return (
|
||||
<>
|
||||
@@ -42,7 +43,7 @@ export default function SavedViewsPopover({
|
||||
open={open}
|
||||
icon={icon}
|
||||
trailing={trailing}
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={label}
|
||||
>
|
||||
@@ -51,7 +52,7 @@ export default function SavedViewsPopover({
|
||||
) : (
|
||||
<IconButton
|
||||
variant="ghost-accent"
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
label={label}
|
||||
>
|
||||
|
||||
@@ -38,6 +38,7 @@ export default function SyncStatus({
|
||||
placement: "bottom-end",
|
||||
offset: 8,
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
const { data } = useQuery({
|
||||
queryKey: qk.myStatus(),
|
||||
queryFn: api.myStatus,
|
||||
@@ -122,7 +123,7 @@ export default function SyncStatus({
|
||||
return (
|
||||
<div className="pointer-events-auto shrink-0">
|
||||
<Pressable
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// Short label only: the popover already explains the numbers, and the long one overflowed
|
||||
// the viewport edge from here.
|
||||
|
||||
@@ -82,6 +82,7 @@ export default function TagFilterChips({
|
||||
mode: "menu",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
const moreLabel = t("channels.tags.moreCount", { count: hidden });
|
||||
|
||||
return (
|
||||
@@ -113,7 +114,7 @@ export default function TagFilterChips({
|
||||
<TagChip key={c.key} chip={c} />
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<Chip ref={pop.refs.setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
|
||||
<Chip ref={setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
|
||||
+{hidden}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
@@ -38,7 +38,7 @@ function TagRow({
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(tag.name);
|
||||
const [open, setOpen] = useState(false);
|
||||
const popFade = useScrollFade();
|
||||
const { ref: popFadeRef, style: popFadeStyle } = useScrollFade();
|
||||
const commit = () => {
|
||||
const v = name.trim();
|
||||
if (v && v !== tag.name) onRename(v);
|
||||
@@ -52,6 +52,7 @@ function TagRow({
|
||||
offset: 8,
|
||||
hoverDelay: { open: 0, close: 150 },
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
@@ -62,7 +63,7 @@ function TagRow({
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||
/>
|
||||
<span
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
className={`text-xs text-muted tabular-nums shrink-0 ${
|
||||
channels.length
|
||||
@@ -81,8 +82,8 @@ function TagRow({
|
||||
</div>
|
||||
{/* Inner scroller so the scroll-fade mask rides the scrolling box, not the glass panel. */}
|
||||
<div
|
||||
ref={popFade.ref}
|
||||
style={popFade.style}
|
||||
ref={popFadeRef}
|
||||
style={popFadeStyle}
|
||||
className="max-h-72 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{channels.map((ch) => (
|
||||
@@ -111,7 +112,7 @@ export default function TagManager({
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const listFade = useScrollFade();
|
||||
const { ref: listFadeRef, style: listFadeStyle } = useScrollFade();
|
||||
const [newName, setNewName] = useState("");
|
||||
const tagsQuery = useTags();
|
||||
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
|
||||
@@ -168,8 +169,8 @@ export default function TagManager({
|
||||
) : (
|
||||
// Cap at ~8 rows tall, then scroll — the list can grow long.
|
||||
<div
|
||||
ref={listFade.ref}
|
||||
style={listFade.style}
|
||||
ref={listFadeRef}
|
||||
style={listFadeStyle}
|
||||
className="flex flex-col gap-2 max-h-88 overflow-y-auto no-scrollbar pr-1"
|
||||
>
|
||||
{userTags.map((tag) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useEffectEvent, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Play, X } from "lucide-react";
|
||||
@@ -32,10 +32,9 @@ export function UpNextCard({
|
||||
});
|
||||
const next = nextQ.data;
|
||||
|
||||
// Countdown. `onAdvance` is read through a ref so the 100ms interval never restarts on re-render.
|
||||
// Countdown. `onAdvance` is an effect event so the 100ms interval never restarts on re-render.
|
||||
const [remaining, setRemaining] = useState(COUNTDOWN_S);
|
||||
const advanceRef = useRef(onAdvance);
|
||||
advanceRef.current = onAdvance;
|
||||
const advance = useEffectEvent(onAdvance);
|
||||
useEffect(() => {
|
||||
const started = performance.now();
|
||||
const iv = window.setInterval(() => {
|
||||
@@ -43,7 +42,7 @@ export function UpNextCard({
|
||||
if (left <= 0) {
|
||||
window.clearInterval(iv);
|
||||
setRemaining(0);
|
||||
advanceRef.current();
|
||||
advance();
|
||||
} else {
|
||||
setRemaining(left);
|
||||
}
|
||||
|
||||
@@ -189,7 +189,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
|
||||
// --- timeline drag (seek / boundary) + hover preview ---
|
||||
const [drag, setDrag] = useState<Drag | null>(null);
|
||||
const [hoverX, setHoverX] = useState<number | null>(null);
|
||||
// The hover point, measured in the pointer handler (track-relative x and its time), so render never
|
||||
// reads the track's geometry (a DOM read in render made the React Compiler skip the editor, E32).
|
||||
const [hover, setHover] = useState<{ x: number; time: number } | null>(null);
|
||||
const timeAt = useCallback(
|
||||
(clientX: number) => {
|
||||
const el = trackRef.current;
|
||||
@@ -273,19 +275,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
};
|
||||
};
|
||||
const stripCells = strip && trackW ? Math.max(1, Math.floor(trackW / (44 * aspect))) : 0;
|
||||
const hoverTime = hoverX != null ? timeAt(hoverX) : null;
|
||||
const hoverTime = hover?.time ?? null;
|
||||
// Clamp the hover-scrub popover inside the track so it never overflows the modal (which would
|
||||
// add a horizontal scrollbar): center it on the cursor, but keep both edges within [0, trackW].
|
||||
const HOVER_W = 148;
|
||||
const hoverLeft =
|
||||
hoverX != null
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Math.max(0, (trackW || HOVER_W) - HOVER_W),
|
||||
hoverX - (trackRef.current?.getBoundingClientRect().left ?? 0) - HOVER_W / 2,
|
||||
),
|
||||
)
|
||||
hover != null
|
||||
? Math.max(0, Math.min(Math.max(0, (trackW || HOVER_W) - HOVER_W), hover.x - HOVER_W / 2))
|
||||
: 0;
|
||||
|
||||
// --- create ---
|
||||
@@ -465,8 +461,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
setDrag({ type: "seek" });
|
||||
seek(timeAt(e.clientX));
|
||||
}}
|
||||
onPointerMove={(e) => setHoverX(e.clientX)}
|
||||
onPointerLeave={() => setHoverX(null)}
|
||||
onPointerMove={(e) =>
|
||||
setHover({
|
||||
x: e.clientX - e.currentTarget.getBoundingClientRect().left,
|
||||
time: timeAt(e.clientX),
|
||||
})
|
||||
}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
>
|
||||
{/* aspect-correct filmstrip */}
|
||||
{stripCells > 0 && (
|
||||
|
||||
@@ -56,6 +56,8 @@ export default function ViewSwitcher<T extends string>({
|
||||
selectedIndex: currentIdx,
|
||||
});
|
||||
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
const current = options[currentIdx];
|
||||
if (!current) return null;
|
||||
const CurrentIcon = current.icon;
|
||||
@@ -66,7 +68,7 @@ export default function ViewSwitcher<T extends string>({
|
||||
context="toolbar"
|
||||
open={open}
|
||||
icon={<CurrentIcon className="w-4 h-4 shrink-0" />}
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// The aria-label carries the mode's name at every size — the visible text below is a
|
||||
// nicety for wide windows, not the accessible name.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -270,9 +270,8 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
||||
// `useDialogChrome` and its one counted scroll lock.
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialogChrome(panelRef);
|
||||
// Keep a stable handler across renders so the stack id is assigned once per mount (as in ui/Modal).
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
// An effect event, so the stack id is assigned once per mount (as in ui/Modal).
|
||||
const close = useEffectEvent(onClose);
|
||||
useEffect(() => {
|
||||
// Round 6 gave this shell the trap and the restore but not the LAYER MEMBERSHIP the other three
|
||||
// have, so its Escape and Tab fired from a raw window listener whatever was on top of it. That
|
||||
@@ -285,7 +284,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (takeEscape(e, id)) {
|
||||
e.stopPropagation();
|
||||
onCloseRef.current();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
// The trap defers only to a layer that owns the keyboard, not to a hover card under the pointer.
|
||||
|
||||
@@ -90,6 +90,8 @@ export default function YearFilter({
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
// Honest states: a first-load / errored fetch must not read as "no years" (an empty chip row).
|
||||
if (years.length === 0) {
|
||||
if (yearsQuery.isLoading) {
|
||||
@@ -124,7 +126,7 @@ export default function YearFilter({
|
||||
{years.length > quick.length && (
|
||||
<>
|
||||
<Chip
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={t("sidebar.year.moreLabel")}
|
||||
>
|
||||
|
||||
@@ -13,94 +13,38 @@ import { compileReport, shippedSources, type CompileReport } from "./compilerSki
|
||||
// • a NEW skip (or a known one changing category) — fix the function; if it truly cannot be
|
||||
// compiled, adding it here is a decision for the owner, with the reason in docs/gates.md;
|
||||
// • a skip that is GONE — delete its entry, so the list only ever shrinks.
|
||||
// E32 S3+ burns it down: the popover engine (Refs: `getReferenceProps()` / `refs.setReference`
|
||||
// passed during render), render-phase ref writes, try/finally (Todo), the two exhaustive-deps
|
||||
// suppressions. The end state is IncompatibleLibrary alone (VirtualGrid on TanStack Virtual).
|
||||
// E32 burns it down: S3 took it from 93 to 29 (the popover engine's `ref={pop.refs.setReference}`
|
||||
// — read through destructuring now — and every render-phase ref write, moved to `useEffectEvent` /
|
||||
// `useLatest`). Left: try/finally and other Todo shapes, and the two exhaustive-deps suppressions.
|
||||
// The end state is IncompatibleLibrary alone (VirtualGrid on TanStack Virtual).
|
||||
const KNOWN_SKIPS: CompileReport["skipped"] = {
|
||||
"components/AboutCell.tsx": { AboutCell: "Refs" },
|
||||
"components/ActiveFilterChips.tsx": { OverflowPopover: "Refs" },
|
||||
"components/AddToPlaylist.tsx": { AddToPlaylist: "Refs", MembershipPanel: "Todo" },
|
||||
"components/BootGate.tsx": { BootGate: "Refs" },
|
||||
"components/ChannelStatsPopover.tsx": { ChannelStatsPopover: "Refs" },
|
||||
"components/ChannelTags.tsx": {
|
||||
ChannelTagsControl: "Refs",
|
||||
SuggestedChipsLane: "Refs",
|
||||
TagEditorMenu: "Refs",
|
||||
},
|
||||
"components/ChannelsProvider.tsx": { ChannelsProvider: "Refs" },
|
||||
"components/ChatThread.tsx": { ChatThread: "Refs" },
|
||||
"components/ConfigPanel.tsx": { ConfigPanel: "Refs" },
|
||||
"components/DateFilterSelect.tsx": { DateFilterSelect: "Refs" },
|
||||
"components/AddToPlaylist.tsx": { MembershipPanel: "Todo" },
|
||||
"components/ConfigPanel.tsx": { ConfigPanel: "Todo" },
|
||||
"components/DownloadCenter.tsx": { DownloadCenter: "Todo" },
|
||||
"components/DownloadDialog.tsx": { DownloadDialog: "Todo" },
|
||||
"components/Feed.tsx": { Feed: "Immutability" },
|
||||
"components/IssueComposer.tsx": { Attachments: "Immutability", ComposerBody: "Refs" },
|
||||
"components/KeyGate.tsx": { KeyGate: "Todo" },
|
||||
"components/LanguageSwitcher.tsx": { LanguageSwitcher: "Refs" },
|
||||
"components/Messages.tsx": { ConversationList: "Refs" },
|
||||
"components/NavSidebar.tsx": { NavSidebar: "Refs" },
|
||||
"components/NavigationProvider.tsx": { NavigationProvider: "Refs" },
|
||||
"components/OnboardingWizard.tsx": { OnboardingWizard: "Refs" },
|
||||
"components/PersonalViewsModal.tsx": { PersonalViewsModal: "Refs" },
|
||||
"components/PlayerModal.tsx": { PlayerModal: "Todo" },
|
||||
"components/PlaylistLimitsHint.tsx": { PlaylistLimitsHint: "Refs" },
|
||||
"components/Playlists.tsx": { Playlists: "Immutability" },
|
||||
"components/PlaylistsRail.tsx": { PlaylistsRail: "Todo" },
|
||||
"components/PlexBrowse.tsx": { PlexBrowse: "Immutability" },
|
||||
"components/PlexCollectionEditor.tsx": { PlexCollectionEditor: "Todo" },
|
||||
"components/PlexCollectionView.tsx": { PlexCollectionView: "Todo" },
|
||||
"components/PlexPlayer.tsx": { PlexPlayer: "Suppression" },
|
||||
"components/PlexPlaylistAdd.tsx": { PlexPlaylistAdd: "Todo" },
|
||||
"components/PlexPlaylistView.tsx": { PlexPlaylistView: "Refs" },
|
||||
"components/PlexStatsOverlay.tsx": { PlexStatsOverlay: "Todo" },
|
||||
"components/SavedViewsPopover.tsx": { SavedViewsPopover: "Refs" },
|
||||
"components/SavedViewsProvider.tsx": { SavedViewsProvider: "Todo" },
|
||||
"components/SettingsPanel.tsx": { SignInMethods: "Todo" },
|
||||
"components/SetupWizard.tsx": { SetupWizard: "Todo" },
|
||||
"components/SyncStatus.tsx": { SyncStatus: "Refs" },
|
||||
"components/TagFilterChips.tsx": { TagFilterChips: "Refs" },
|
||||
"components/TagManager.tsx": { TagManager: "Refs", TagRow: "Refs" },
|
||||
"components/UpNextCard.tsx": { UpNextCard: "Refs" },
|
||||
"components/UploadDialog.tsx": { UploadDialog: "Todo" },
|
||||
"components/VideoEditor.tsx": { VideoEditor: "Refs" },
|
||||
"components/ViewSwitcher.tsx": { ViewSwitcher: "Refs" },
|
||||
"components/VideoEditor.tsx": { VideoEditor: "Todo" },
|
||||
"components/WatchPage.tsx": { WatchPage: "Todo", useMediaSession: "Todo" },
|
||||
"components/Welcome.tsx": { AuthCard: "Todo", Lightbox: "Refs" },
|
||||
"components/YearFilter.tsx": { YearFilter: "Refs" },
|
||||
"components/Welcome.tsx": { AuthCard: "Todo" },
|
||||
"components/plexViews.tsx": { PlexSeasonView: "Todo", PlexShowView: "Todo" },
|
||||
"components/ui/ActionMenu.tsx": { ActionMenu: "Refs" },
|
||||
"components/ui/AnchoredPopover.tsx": { PopoverSurfaceInner: "Refs" },
|
||||
"components/ui/Combobox.tsx": { Combobox: "Refs" },
|
||||
"components/ui/FilterSelect.tsx": { FilterSelect: "Refs" },
|
||||
"components/ui/FocusSafeButton.tsx": { FocusSafeButton: "Refs" },
|
||||
"components/ui/Modal.tsx": { Modal: "Refs" },
|
||||
"components/ui/NavRail.tsx": { NavRailGroup: "Refs" },
|
||||
"components/ui/PageScroller.tsx": { PageScroller: "Refs" },
|
||||
"components/ui/PriorityStepper.tsx": { PriorityStepper: "Refs" },
|
||||
"components/ui/SidePanel.tsx": { SidePanel: "Refs" },
|
||||
"components/ui/SortControl.tsx": { SortControl: "Refs" },
|
||||
"components/ui/Toaster.tsx": { Toaster: "Globals" },
|
||||
"components/ui/Tooltip.tsx": { Tooltip: "Refs" },
|
||||
"components/ui/VirtualGrid.tsx": { VirtualGrid: "IncompatibleLibrary" },
|
||||
"components/ui/anchoredPopoverHooks.ts": { useAnchoredPopover: "Refs" },
|
||||
"components/ui/sortableA11y.ts": { useSortableA11y: "Immutability" },
|
||||
"components/ui/tableHeader.tsx": { FilterFunnel: "Refs", FilterOptionList: "Refs" },
|
||||
"components/useDebugRecording.ts": { useDebugRecording: "Todo" },
|
||||
"components/useDownloadActions.tsx": { useDownloadActions: "Todo" },
|
||||
"components/usePlaylistActions.tsx": { usePlaylistActions: "Todo" },
|
||||
"components/usePlexPlaylistEdits.ts": { usePlexPlaylistEdits: "Todo" },
|
||||
"lib/history.ts": { useBackToClose: "Refs", useBackToExit: "Refs", useHistorySubview: "Refs" },
|
||||
"lib/keymap.ts": { useGlobalKeymap: "Refs" },
|
||||
"lib/layerStack.ts": { useLayer: "Refs" },
|
||||
"lib/listNav.ts": { useListNav: "Refs" },
|
||||
"lib/playerModalHooks.ts": { useYtBigView: "Refs", useYtProgress: "Suppression" },
|
||||
"lib/plexPlayerHooks.ts": { useAutoSkip: "Refs", usePlexTracks: "Todo" },
|
||||
"lib/useChannelPriority.ts": { useChannelPriority: "Refs" },
|
||||
"lib/useChipOverflowFit.ts": { useChipOverflowFit: "Refs" },
|
||||
"lib/useDismiss.ts": { useDismiss: "Refs" },
|
||||
"lib/usePref.ts": { usePref: "Refs" },
|
||||
"lib/useUndoable.ts": { useUndoable: "Refs" },
|
||||
"lib/useUrlMirror.ts": { useUrlMirror: "Refs" },
|
||||
"lib/playerModalHooks.ts": { useYtProgress: "Suppression" },
|
||||
"lib/plexPlayerHooks.ts": { usePlexTracks: "Todo" },
|
||||
};
|
||||
|
||||
// The files a regression hurt most: compiled, and they must stay so (a positive check too, so a
|
||||
@@ -108,6 +52,10 @@ const KNOWN_SKIPS: CompileReport["skipped"] = {
|
||||
const MUST_COMPILE: Record<string, string[]> = {
|
||||
"components/Channels.tsx": ["Channels"],
|
||||
"components/ui/DataTable.tsx": ["DataTable"],
|
||||
// Under every button and every popover in the app (E32 S3).
|
||||
"components/ui/FocusSafeButton.tsx": ["FocusSafeButton"],
|
||||
"components/ui/anchoredPopoverHooks.ts": ["useAnchoredPopover"],
|
||||
"components/ui/AnchoredPopover.tsx": ["PopoverSurfaceInner"],
|
||||
};
|
||||
|
||||
type Row = [file: string, fn: string, category: string];
|
||||
|
||||
@@ -164,10 +164,11 @@ function SubMenu({ item, onClose }: { item: SubmenuItem; onClose: () => void })
|
||||
// running off the bottom at 768px.
|
||||
fitHeight: true,
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
return (
|
||||
<>
|
||||
<PopoverItem
|
||||
elementRef={pop.refs.setReference}
|
||||
elementRef={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
@@ -235,12 +236,13 @@ export default function ActionMenu({
|
||||
// PopoverItem renders it as a native `disabled` button, which useListNavigation reads and never
|
||||
// focuses. No explicit disabledIndices needed. See ActionMenu.dom.test.tsx for the guard.
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
|
||||
const { setReference } = pop.refs;
|
||||
return (
|
||||
<>
|
||||
{/* A caller-shaped trigger (a labelled pill) is a surface; the default "…" is a glyph. */}
|
||||
{className ? (
|
||||
<Pressable
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
@@ -252,7 +254,7 @@ export default function ActionMenu({
|
||||
<IconButton
|
||||
variant="glass"
|
||||
size="md"
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
label={ariaLabel}
|
||||
>
|
||||
|
||||
@@ -43,6 +43,10 @@ import type { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
// free-text input (the tag creator) must stopPropagation non-Escape keys on that input so ↑/↓ and
|
||||
// typeahead act on the text, not the list — see ChannelTags.
|
||||
//
|
||||
// The trigger takes its ref DESTRUCTURED: `const { setReference } = pop.refs;` then
|
||||
// `ref={setReference} {...pop.getReferenceProps()}`. Written `ref={pop.refs.setReference}`, the React
|
||||
// Compiler treats the whole `pop` as a ref and skips the component (E32 S3 — 21 call sites did).
|
||||
//
|
||||
// S? adds a "combobox" mode — the ONE right way to build a typeable combobox (a search/filter input
|
||||
// with a listbox of options below), so a hand-rolled floating-ui engine (the old MultiSelectChips /
|
||||
// PlexSearchBox pattern) is no longer needed. It differs from menu/listbox in exactly the focus model:
|
||||
@@ -294,6 +298,7 @@ function PopoverSurfaceInner({
|
||||
elementsRef,
|
||||
labelsRef,
|
||||
} = popover;
|
||||
const { setFloating } = refs;
|
||||
|
||||
// A node portaled to <body> is painted UNDERNEATH whatever holds the fullscreen lock, so a popover
|
||||
// opened from a player's control bar in fullscreen would be invisible; it must portal INTO the
|
||||
@@ -369,7 +374,7 @@ function PopoverSurfaceInner({
|
||||
|
||||
const surface = (
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
ref={setFloating}
|
||||
{...floatingProps}
|
||||
aria-label={listProps ? undefined : ariaLabel}
|
||||
className={zClass}
|
||||
|
||||
@@ -206,6 +206,30 @@ describe("Combobox", () => {
|
||||
expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces
|
||||
});
|
||||
|
||||
it("names a chip by a label that arrives with later options, in that same render", () => {
|
||||
// E32 S3: the label cache was a ref an effect filled AFTER the render that needed it, and nothing
|
||||
// re-rendered afterwards — the chip kept showing the raw id until something unrelated did.
|
||||
const view = (options: { value: string; label?: string }[]) => (
|
||||
<FloatingTree>
|
||||
<Combobox
|
||||
label="Collection"
|
||||
clientFilter={false}
|
||||
options={options}
|
||||
selected={["id1"]}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
</FloatingTree>
|
||||
);
|
||||
const { rerender } = render(view([]));
|
||||
expect(screen.getByText("id1")).toBeTruthy();
|
||||
rerender(view([{ value: "id1", label: "Harry Potter" }]));
|
||||
expect(screen.queryByText("id1")).toBeNull();
|
||||
expect(screen.getByText("Harry Potter")).toBeTruthy();
|
||||
// …and it outlives the option: a narrower list later still names the chip.
|
||||
rerender(view([]));
|
||||
expect(screen.getByText("Harry Potter")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("single-select: picking replaces the value and closes the list", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
@@ -78,6 +78,23 @@ export interface ComboboxProps {
|
||||
labelsPending?: boolean;
|
||||
}
|
||||
|
||||
/** Remember each value's display label as options flow through, so a selected chip can name itself
|
||||
* even after the option leaves the query-narrowed list (async single-select). State, merged DURING
|
||||
* render: a ref written in an effect was read by the render before it, so a chip could name itself
|
||||
* by its raw value until something else re-rendered (E32). */
|
||||
function useLabelCache(options: readonly ComboboxOption[]): Record<string, string> {
|
||||
const [labelCache, setLabelCache] = useState<Record<string, string>>({});
|
||||
const newLabels = options.filter(
|
||||
(o): o is typeof o & { label: string } => o.label != null && labelCache[o.value] !== o.label,
|
||||
);
|
||||
if (newLabels.length)
|
||||
setLabelCache((c) => ({
|
||||
...c,
|
||||
...Object.fromEntries(newLabels.map((o) => [o.value, o.label])),
|
||||
}));
|
||||
return labelCache;
|
||||
}
|
||||
|
||||
export default function Combobox({
|
||||
label,
|
||||
options,
|
||||
@@ -108,17 +125,15 @@ export default function Combobox({
|
||||
if (queryProp === undefined) setQueryState(q);
|
||||
onQueryChange?.(q);
|
||||
};
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const fade = useScrollFade();
|
||||
// The input node as STATE, not a ref: the handlers below are handed to getReferenceProps during
|
||||
// render, and a ref inside them made the React Compiler skip the whole component (E32).
|
||||
const [inputEl, setInputEl] = useState<HTMLInputElement | null>(null);
|
||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
||||
|
||||
// Remember each value's display label as options flow through, so a selected chip can name itself
|
||||
// even after the option leaves the query-narrowed list (async single-select). Caller-supplied
|
||||
// `selectedLabels` win (they survive a page reload where nothing has been fetched yet).
|
||||
const labelCache = useRef<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
for (const o of options) if (o.label != null) labelCache.current[o.value] = o.label;
|
||||
}, [options]);
|
||||
const knownLabel = (v: string) => selectedLabels?.[v] ?? labelCache.current[v];
|
||||
// Caller-supplied `selectedLabels` win over the cache (they survive a page reload where nothing
|
||||
// has been fetched yet).
|
||||
const labelCache = useLabelCache(options);
|
||||
const knownLabel = (v: string) => selectedLabels?.[v] ?? labelCache[v];
|
||||
const labelFor = (v: string) => knownLabel(v) ?? v;
|
||||
|
||||
const setOpenAnd = (next: boolean) => {
|
||||
@@ -134,18 +149,19 @@ export default function Combobox({
|
||||
if (!next) setQuery("");
|
||||
};
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" });
|
||||
const { setReference, setPositionReference } = pop.refs;
|
||||
// The input is the combobox reference for floating-ui AND our own focus target (the field's
|
||||
// click-to-focus handler, and commit() re-focusing after a pick). A merged callback ref feeds the
|
||||
// node to BOTH — without it `inputRef` stays null, so the field's onMouseDown guard (`e.target !==
|
||||
// inputRef.current`) is always true and preventDefault() cancels the input's own click-focus, making
|
||||
// node to BOTH — without it the node stays null, so the field's onMouseDown guard (`e.target !==
|
||||
// inputEl`) is always true and preventDefault() cancels the input's own click-focus, making
|
||||
// the field impossible to type into with the mouse. Memoised so floating-ui's ref isn't re-attached
|
||||
// every render.
|
||||
const setInputRef = useCallback(
|
||||
(node: HTMLInputElement | null) => {
|
||||
pop.refs.setReference(node);
|
||||
inputRef.current = node;
|
||||
setReference(node);
|
||||
setInputEl(node);
|
||||
},
|
||||
[pop.refs],
|
||||
[setReference],
|
||||
);
|
||||
// `highlightIndex` = the row the user actually SEES highlighted (pointer hover, else keyboard); Enter
|
||||
// commits THAT, not the bare keyboard `activeIndex` — otherwise arrow-then-hover-then-Enter would toggle
|
||||
@@ -173,7 +189,7 @@ export default function Combobox({
|
||||
selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value],
|
||||
);
|
||||
// Keep the field focused so the user can keep picking / typing; the list stays open.
|
||||
inputRef.current?.focus();
|
||||
inputEl?.focus();
|
||||
} else {
|
||||
// Single-select is final: close and DON'T re-focus — a refocus fires onFocus, which would
|
||||
// immediately re-open the list (now that the merged ref makes focus() actually work).
|
||||
@@ -187,7 +203,7 @@ export default function Combobox({
|
||||
{/* The field: removable chips + a free-text input + a chevron, styled as one input box. The whole
|
||||
box is the POSITION anchor (the panel matches its width); the input is the combobox reference. */}
|
||||
<div
|
||||
ref={pop.refs.setPositionReference}
|
||||
ref={setPositionReference}
|
||||
onMouseDown={(e) => {
|
||||
// A press anywhere but the input (padding / a chip gap) focuses the field and opens the list —
|
||||
// focus drives "open", so a caret click never toggles it shut. A press on a chip's × is its
|
||||
@@ -197,9 +213,9 @@ export default function Combobox({
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.target !== inputRef.current) {
|
||||
if (e.target !== inputEl) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
inputEl?.focus();
|
||||
setOpenAnd(true);
|
||||
}
|
||||
}}
|
||||
@@ -277,7 +293,7 @@ export default function Combobox({
|
||||
className={`${SURFACE.menu} w-full animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
|
||||
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
|
||||
<div ref={fadeRef} style={fadeStyle} className="max-h-64 overflow-y-auto no-scrollbar">
|
||||
{/* The listbox holds the options alone; the lines about the list sit beside it (F4). */}
|
||||
<PopoverList>
|
||||
{!showError &&
|
||||
|
||||
@@ -64,6 +64,7 @@ export default function FilterSelect({
|
||||
options.findIndex((o) => o.value === selected[0]),
|
||||
),
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? v;
|
||||
// Summarise the trigger value. Multi lists the first chosen (in options order, so it's stable) + "+N".
|
||||
@@ -96,7 +97,7 @@ export default function FilterSelect({
|
||||
context="toolbar"
|
||||
open={open}
|
||||
label={hideLabel ? undefined : label}
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
data-testid={testId}
|
||||
// Carry the current value in the accessible name (not just the group label), matching
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { forwardRef, useCallback, useRef, useState, type ButtonHTMLAttributes } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type ButtonHTMLAttributes,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { endsTextSelection, movedPastSlop } from "../../lib/textSelection";
|
||||
|
||||
@@ -23,6 +30,8 @@ import { endsTextSelection, movedPastSlop } from "../../lib/textSelection";
|
||||
// <button> unless it is `user-select: text`, and then the mouseup that ends the drag still clicks the
|
||||
// button (both measured, 2026-09-26), so the flag sets both halves: the text selects, and the click
|
||||
// that ends a selection inside the button, or a press dragged past the slop, does nothing.
|
||||
const noSubscription = () => () => {};
|
||||
|
||||
export const FocusSafeButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
@@ -58,9 +67,15 @@ export const FocusSafeButton = forwardRef<
|
||||
);
|
||||
// Focused by the event OR by the document's own answer at render time: when the window itself is
|
||||
// not focused (another app in front) `.focus()` moves `activeElement` but fires no focus event
|
||||
// (measured in Chrome), and the button disabled in that state lost the focus all the same.
|
||||
const focused =
|
||||
focusEvent || (!!node.current && node.current.ownerDocument.activeElement === node.current);
|
||||
// (measured in Chrome), and the button disabled in that state lost the focus all the same. Read
|
||||
// through useSyncExternalStore, which React asks on EVERY render, never from a memo: a bare
|
||||
// render-time DOM read made the React Compiler skip the component (E32), and compiled it would be
|
||||
// cached. Nothing to subscribe to — the focus events above already re-render it.
|
||||
const activeNow = useSyncExternalStore(
|
||||
noSubscription,
|
||||
() => node.current !== null && node.current.ownerDocument.activeElement === node.current,
|
||||
);
|
||||
const focused = focusEvent || activeNow;
|
||||
const soft = busy || (!!disabled && focused);
|
||||
// A caller's own aria-disabled is the "unavailable, and here is why" state (DESIGN.md: the tooltip or
|
||||
// label carries the reason, so the button stays focusable): it is kept, never overwritten by ours, and
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -124,15 +125,14 @@ export default function Modal({
|
||||
// the backdrop wrongly dismisses — the click event bubbles to the common ancestor (the backdrop).
|
||||
const pressedOnBackdrop = useRef(false);
|
||||
|
||||
// Keep a stable handler across renders so the stack id is assigned once per mount.
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
// An effect event, so a new handler each render never re-registers: the stack id is assigned once per mount.
|
||||
const close = useEffectEvent(onClose);
|
||||
useEffect(() => {
|
||||
const id = pushLayer();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (takeEscape(e, id)) {
|
||||
e.stopPropagation();
|
||||
onCloseRef.current();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
// Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert
|
||||
|
||||
@@ -216,12 +216,13 @@ export function NavRailGroup({
|
||||
placement: "right-start",
|
||||
offset: 8,
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
const active = pages.some((p) => p.active);
|
||||
return (
|
||||
<>
|
||||
<Tooltip hint={label} side="right" always openDelay={350}>
|
||||
<Pressable
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
data-testid={testId}
|
||||
aria-label={showLabel ? shortLabel : label}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffectEvent,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { FOCUS_REST_ATTRS, useFocusKeeper } from "../../lib/useFocusKeeper";
|
||||
import { StickyTopProvider } from "./stickyTop";
|
||||
@@ -130,12 +137,11 @@ export default function PageScroller({
|
||||
setStickyTops((n) => n + 1);
|
||||
return () => setStickyTops((n) => n - 1);
|
||||
}, []);
|
||||
const fade = useScrollFade(20, stickyTops === 0);
|
||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade(20, stickyTops === 0);
|
||||
const fabUp = useBackToTopShown();
|
||||
const fadeRef = fade.ref;
|
||||
const elRef = useRef<HTMLElement | null>(null);
|
||||
// Stable identity: an inline ref callback changes every render, which makes React detach and
|
||||
// re-attach the ref each commit — and since fade.ref is a state setter, that re-attach loops it
|
||||
// re-attach the ref each commit — and since fadeRef is a state setter, that re-attach loops it
|
||||
// into "Maximum update depth exceeded". useScrollFade's ref setter is itself stable, so binding
|
||||
// once here is safe.
|
||||
const setRef = useCallback(
|
||||
@@ -150,29 +156,31 @@ export default function PageScroller({
|
||||
// all) leaves the focus in its slot rather than on <body> — see useFocusKeeper.
|
||||
useFocusKeeper(elRef);
|
||||
|
||||
// Live scrollKey for the event/nav-bus callbacks (which are bound once, not per scrollKey).
|
||||
const scrollKeyRef = useRef(scrollKey);
|
||||
scrollKeyRef.current = scrollKey;
|
||||
const lastComposite = useRef<string | null>(null);
|
||||
const cancelRef = useRef<() => void>(() => {});
|
||||
|
||||
// Restore (or reset to top) whenever the (history entry + content) identity changes — a page/sub-view
|
||||
// navigation, a Back/Forward, or a feed filter change. Idempotent: a no-op when the composite is
|
||||
// unchanged, so it's safe to call from several triggers.
|
||||
const resolve = useCallback(() => {
|
||||
// unchanged, so it's safe to call from several triggers. An effect event: the listeners below are
|
||||
// bound once, not per scrollKey, and still read the live one.
|
||||
const resolve = useEffectEvent(() => {
|
||||
const el = elRef.current;
|
||||
if (!el || scrollKeyRef.current == null) return;
|
||||
const key = `${ensureNavId()}:${scrollKeyRef.current}`;
|
||||
if (!el || scrollKey == null) return;
|
||||
const key = `${ensureNavId()}:${scrollKey}`;
|
||||
if (key === lastComposite.current) return;
|
||||
lastComposite.current = key;
|
||||
cancelRef.current();
|
||||
cancelRef.current = restoreScroll(el, savedScroll.get(key) ?? 0);
|
||||
}, []);
|
||||
});
|
||||
const remember = useEffectEvent((el: HTMLElement) => {
|
||||
if (scrollKey == null) return;
|
||||
rememberScroll(`${peekNavId()}:${scrollKey}`, el.scrollTop);
|
||||
});
|
||||
|
||||
// Trigger 1: mount + a content-key change (page switch, feed filter) — both re-render this component.
|
||||
useLayoutEffect(() => {
|
||||
resolve();
|
||||
}, [scrollKey, resolve]);
|
||||
}, [scrollKey]);
|
||||
|
||||
// Trigger 2: Back/Forward (popstate) and a Plex sub-view open (nav bus) — neither necessarily
|
||||
// re-renders this component. Also records the position continuously (not at unmount): the scroller's
|
||||
@@ -180,20 +188,18 @@ export default function PageScroller({
|
||||
useLayoutEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
if (scrollKeyRef.current == null) return;
|
||||
rememberScroll(`${peekNavId()}:${scrollKeyRef.current}`, el.scrollTop);
|
||||
};
|
||||
const onScroll = () => remember(el);
|
||||
const onNav = () => resolve();
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("popstate", resolve);
|
||||
const unsubNav = subscribeNav(resolve);
|
||||
window.addEventListener("popstate", onNav);
|
||||
const unsubNav = subscribeNav(onNav);
|
||||
return () => {
|
||||
cancelRef.current();
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("popstate", resolve);
|
||||
window.removeEventListener("popstate", onNav);
|
||||
unsubNav();
|
||||
};
|
||||
}, [resolve]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
// `id` + `tabIndex={-1}`: the skip link's target (ui/SkipLink) takes the focus, so the next Tab
|
||||
@@ -204,7 +210,7 @@ export default function PageScroller({
|
||||
id={CONTENT_ID}
|
||||
tabIndex={-1}
|
||||
{...FOCUS_REST_ATTRS}
|
||||
style={fade.style}
|
||||
style={fadeStyle}
|
||||
className="flex-1 min-w-0 overflow-y-auto scrollbar-gutter-stable focus:outline-none"
|
||||
>
|
||||
<StickyTopProvider value={registerStickyTop}>{children}</StickyTopProvider>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function PriorityStepper({
|
||||
onPriority(delta);
|
||||
};
|
||||
|
||||
const editFocus = useReturnFocus(editing);
|
||||
const { target: editTarget, arm: armEditReturn } = useReturnFocus(editing);
|
||||
const commitEdit = () => {
|
||||
const parsed = parseInt(draft, 10);
|
||||
if (!Number.isNaN(parsed) && parsed !== priority) onSet(parsed);
|
||||
@@ -162,7 +162,7 @@ export function PriorityStepper({
|
||||
} ${cls.value}`}
|
||||
>
|
||||
<button
|
||||
ref={editFocus.target}
|
||||
ref={editTarget}
|
||||
onClick={beginEdit}
|
||||
aria-label={t("common.priority.edit")}
|
||||
title={t("common.priority.edit")}
|
||||
@@ -180,10 +180,10 @@ export function PriorityStepper({
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
editFocus.arm(e);
|
||||
armEditReturn(e);
|
||||
commitEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
editFocus.arm(e);
|
||||
armEditReturn(e);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function SidePanel({
|
||||
const { t } = useTranslation();
|
||||
const cardRef = useRef<HTMLElement>(null);
|
||||
const tabRef = useRef<HTMLButtonElement>(null);
|
||||
const fade = useScrollFade();
|
||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
||||
const peek = useHoverFocusWithin();
|
||||
const drawerMode = useMediaQuery(DRAWER_QUERY, false);
|
||||
const pinRoom = useMediaQuery(PIN_ROOM_QUERY, true);
|
||||
@@ -234,8 +234,8 @@ export default function SidePanel({
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={fade.ref}
|
||||
style={fade.style}
|
||||
ref={fadeRef}
|
||||
style={fadeStyle}
|
||||
className="flex-1 min-h-0 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
<div className="p-3 space-y-3">{body}</div>
|
||||
|
||||
@@ -48,6 +48,7 @@ export default function SortControl({
|
||||
mode: "menu",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
// A stored sort can name an option this surface no longer offers; show it as unsorted rather than
|
||||
// letting an arrow claim a sort that `sortRows`/the server won't actually apply.
|
||||
@@ -71,7 +72,7 @@ export default function SortControl({
|
||||
context="toolbar"
|
||||
open={open}
|
||||
label={label}
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// Carry the active sort key AND direction in the accessible name (the visible arrow is
|
||||
// aria-hidden), matching ViewSwitcher — a screen reader announces "Sort: Date, Descending",
|
||||
|
||||
@@ -94,6 +94,18 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert");
|
||||
});
|
||||
|
||||
it("takes the inherited set ONCE, at the takeover: a toast of its own stays an alert", () => {
|
||||
// E32: the handover is a snapshot made when the host becomes top, not a read of what the top
|
||||
// host last showed — re-read on every render, this host's own "first" turned silent the moment
|
||||
// "second" arrived (its own screen had become the handover by then).
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "error", message: "first" }));
|
||||
act(() => void notify({ level: "error", message: "second" }));
|
||||
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
|
||||
expect(card("first").getAttribute("role")).toBe("alert");
|
||||
expect(card("second").getAttribute("role")).toBe("alert");
|
||||
});
|
||||
|
||||
it("keeps its live region off while the inherited toasts go in, and arms it a task later", async () => {
|
||||
// The other half of "silently": a region that is already polite when the inherited nodes are
|
||||
// inserted announces them anyway. React flushes effects in the SAME task as that render, so the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications";
|
||||
@@ -70,30 +70,29 @@ export default function Toaster({
|
||||
// the takeover render. Reading the store live also silenced a toast RAISED in the same commit as
|
||||
// the takeover: a dialog that notifies and closes in one handler (UploadDialog) put its success
|
||||
// toast up in the very batch that handed the app host the top spot, so it was classified as
|
||||
// inherited and announced by neither path (deep-review round 5). Written during render on purpose
|
||||
// — the handover must be readable by the taking-over host in the SAME commit, and an effect
|
||||
// cleanup runs one phase too late; only the host that IS top writes, so the value is the losing
|
||||
// host's last screen either way.
|
||||
// inherited and announced by neither path (deep-review round 5). The top host records its screen
|
||||
// after each commit (the layout effect below); a host taking over reads it in a LATER render, after
|
||||
// that commit, so it always sees the outgoing host's last committed screen. (It was written during
|
||||
// render, which made the React Compiler skip the Toaster — E32 — and let a discarded render
|
||||
// overwrite it.)
|
||||
//
|
||||
// Snapshotted through a ref keyed on `isTop`, not a `useMemo`: React re-invokes a component body
|
||||
// — and every `useMemo` factory in it — on a StrictMode double render and on a restarted one, and
|
||||
// the LAST invocation is what commits. With a memo the second pass re-read `lastTopIds` after the
|
||||
// first pass had already overwritten it on the line below, so the takeover host classified its
|
||||
// OWN new toast as inherited and the dev build behaved exactly like the code this replaced
|
||||
// (measured in jsdom: role "status" without StrictMode, null with it — round 6). The ref only
|
||||
// recomputes when `isTop` actually flips, so a repeated invocation of one render is a no-op.
|
||||
const handover = useRef<{ top: boolean; ids: ReadonlySet<number> }>({
|
||||
// Snapshotted into state when `isTop` flips, adjusted DURING render — not a `useMemo`, which React
|
||||
// may recompute (and the compiler is free to): a recompute while top would re-read this host's OWN
|
||||
// screen and silence its next toast. Under StrictMode the double invocation reads the same,
|
||||
// unchanged global, so both passes agree (the round-6 failure was a render-phase WRITE racing it).
|
||||
const [handover, setHandover] = useState<{ top: boolean; ids: ReadonlySet<number> }>({
|
||||
top: false,
|
||||
ids: EMPTY_IDS,
|
||||
});
|
||||
if (handover.current.top !== isTop)
|
||||
handover.current = { top: isTop, ids: new Set(isTop ? lastTopIds : []) };
|
||||
const inherited = handover.current.ids;
|
||||
// AFTER the snapshot above has read the outgoing host's screen — this host's own first render as
|
||||
// top would otherwise overwrite the handover with the toasts it is about to classify. Writing the
|
||||
// same value twice is harmless; that is the point of the ref.
|
||||
// eslint-disable-next-line react-hooks/globals -- the handover between toast hosts is module-level BY DESIGN and must be written during render — see the 12 lines above, which record why a useMemo produced the wrong answer under StrictMode. The write is idempotent, which is what makes a repeated render invocation safe.
|
||||
if (isTop) lastTopIds = toasts.map((n) => n.id);
|
||||
let inherited = handover.ids;
|
||||
if (handover.top !== isTop) {
|
||||
const next = { top: isTop, ids: new Set(isTop ? lastTopIds : []) };
|
||||
setHandover(next);
|
||||
inherited = next.ids;
|
||||
}
|
||||
useLayoutEffect(() => {
|
||||
if (isTop) lastTopIds = toasts.map((n) => n.id);
|
||||
});
|
||||
const [armed, setArmed] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isTop) {
|
||||
|
||||
@@ -38,12 +38,13 @@ export default function Tooltip({
|
||||
// rest-before-show for labels that shouldn't flash on a fast sweep across an icon row.
|
||||
hoverDelay: { open: openDelay, close: 0 },
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
if (!enabled || !hint) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
|
||||
<span ref={setReference} {...pop.getReferenceProps()} className="inline-flex">
|
||||
{children}
|
||||
</span>
|
||||
{/* Passive caption: it must not swallow a click aimed at a neighbouring control that it
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
useFloating,
|
||||
autoUpdate,
|
||||
@@ -256,12 +256,9 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
setActiveIndex(to);
|
||||
setHoverIndex(null);
|
||||
};
|
||||
const pageKeyRef = useRef(onPageKey);
|
||||
pageKeyRef.current = onPageKey;
|
||||
const paging = useMemo(() => {
|
||||
const onKeyDown = (e: React.KeyboardEvent) => pageKeyRef.current(e);
|
||||
return { reference: { onKeyDown }, floating: { onKeyDown } };
|
||||
}, []);
|
||||
// The compiler memoizes this on what onPageKey reads (the active row), so the prop getters below
|
||||
// change only when that does.
|
||||
const paging = { reference: { onKeyDown: onPageKey }, floating: { onKeyDown: onPageKey } };
|
||||
|
||||
const interactions = useInteractions([
|
||||
click,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useLatest } from "../../lib/useLatest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { closestCenter } from "@dnd-kit/core";
|
||||
import type {
|
||||
@@ -38,8 +39,7 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
|
||||
announcements: Announcements;
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
const latest = useRef(describe);
|
||||
latest.current = describe;
|
||||
const latest = useLatest(describe);
|
||||
return useMemo(() => {
|
||||
// Spread into a fresh object: i18next's options type is an index signature, which a named
|
||||
// interface does not satisfy.
|
||||
@@ -51,22 +51,21 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
|
||||
// dnd-kit reports the item over ITSELF right after the pick-up, in the same tick — announced,
|
||||
// "moved to position 1" replaced "picked up … use the arrow keys" in the live region before a
|
||||
// screen reader read it (measured in Chrome). Only a real move is announced as one.
|
||||
let moved = false;
|
||||
// Per-DRAG state the announcement closures share (a component-lifetime ref would be the wrong
|
||||
// lifetime): an object owned by this memo, so the closures mutate a property, never a binding.
|
||||
const drag = { moved: false };
|
||||
return {
|
||||
screenReaderInstructions: { draggable: t("common.dnd.instructions") },
|
||||
announcements: {
|
||||
onDragStart: ({ active }) => {
|
||||
moved = false;
|
||||
drag.moved = false;
|
||||
return t("common.dnd.pickedUp", at(active.id));
|
||||
},
|
||||
onDragOver: ({ active, over }) => {
|
||||
if (!over || (!moved && over.id === active.id)) return undefined;
|
||||
// `moved` is per-DRAG state, and the announcement closures capturing it is the point:
|
||||
if (!over || (!drag.moved && over.id === active.id)) return undefined;
|
||||
// dnd-kit reports the item over ITSELF in the pick-up tick (see above), and this flag is
|
||||
// the only thing separating that from a real move. A ref would be the wrong lifetime —
|
||||
// one drag, not the component.
|
||||
// eslint-disable-next-line react-hooks/immutability -- per-DRAG state the announcement closures share; a ref would live as long as the component
|
||||
moved = true;
|
||||
// the only thing separating that from a real move.
|
||||
drag.moved = true;
|
||||
return t("common.dnd.movedOver", landing(active.id, over.id));
|
||||
},
|
||||
onDragEnd: ({ active, over }) =>
|
||||
@@ -76,7 +75,7 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
|
||||
onDragCancel: ({ active }) => t("common.dnd.cancelled", at(active.id)),
|
||||
},
|
||||
};
|
||||
}, [t]);
|
||||
}, [t, latest]);
|
||||
}
|
||||
|
||||
// A context that nests sortable lists (a Plex playlist: its blocks, and each show's episodes) must
|
||||
|
||||
@@ -29,11 +29,11 @@ import { SURFACE } from "./surfaces";
|
||||
// The multi-select filter's option list, capped and scrollable. Its own component because it owns a
|
||||
// hook — see the FilterPopover note below for why nesting either of these would remount them.
|
||||
function FilterOptionList({ children }: { children: ReactNode }) {
|
||||
const fade = useScrollFade();
|
||||
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
||||
return (
|
||||
<div
|
||||
ref={fade.ref}
|
||||
style={fade.style}
|
||||
ref={fadeRef}
|
||||
style={fadeStyle}
|
||||
className="flex flex-col gap-0.5 max-h-60 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{children}
|
||||
@@ -77,13 +77,14 @@ function FilterFunnel<T>({
|
||||
mode: isText ? "dialog" : "menu",
|
||||
role: isText ? "dialog" : "menu",
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
const filterOn = isActive(value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
variant={filterOn ? "ghost-accent" : "ghost"}
|
||||
ref={pop.refs.setReference}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
label={t("datatable.filter")}
|
||||
// The icon alone was a 14x14 hit target, the smallest in the app; the sm rung is 28.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// its own button pops exactly its entry without tripping the modals underneath.
|
||||
//
|
||||
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useState } from "react";
|
||||
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
|
||||
import { urlWithParams } from "./urlState";
|
||||
|
||||
@@ -137,12 +137,11 @@ export function useHistorySubview<T>(
|
||||
}
|
||||
return root;
|
||||
};
|
||||
const readViewRef = useRef(readView);
|
||||
readViewRef.current = readView;
|
||||
const [view, setView] = useState<T>(readView);
|
||||
const readLandedView = useEffectEvent(readView);
|
||||
useEffect(() => {
|
||||
// Back/Forward restores the sub-view from the landed entry (state OR its restored ?<key> URL).
|
||||
const onPop = () => setView(readViewRef.current());
|
||||
const onPop = () => setView(readLandedView());
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
@@ -370,11 +369,10 @@ export function closeTopOverlayThenRun(close: () => void, run: () => void): void
|
||||
/** While the calling component is mounted, Back (or the browser/mouse back button) closes it via
|
||||
* `onClose` instead of navigating away. Mount the component only while the overlay is open. */
|
||||
export function useBackToClose(onClose: () => void): void {
|
||||
const cb = useRef(onClose);
|
||||
cb.current = onClose;
|
||||
const cb = useEffectEvent(onClose);
|
||||
useEffect(() => {
|
||||
ensureOverlayListener();
|
||||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb.current() };
|
||||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() };
|
||||
overlayStack.push(entry);
|
||||
scheduleReconcile();
|
||||
return () => {
|
||||
@@ -394,12 +392,11 @@ export function useBackToClose(onClose: () => void): void {
|
||||
* `active` false, whose cleanup drops the entry and its history state on the next tick — exactly the
|
||||
* useBackToClose lifecycle, just gated on a flag. `onExit` runs on a genuine Back while active. */
|
||||
export function useBackToExit(active: boolean, onExit: () => void): void {
|
||||
const cb = useRef(onExit);
|
||||
cb.current = onExit;
|
||||
const cb = useEffectEvent(onExit);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
ensureOverlayListener();
|
||||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb.current() };
|
||||
const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() };
|
||||
overlayStack.push(entry);
|
||||
scheduleReconcile();
|
||||
return () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import { useEffect, useEffectEvent, type RefObject } from "react";
|
||||
import { anyBlockingLayer, escapeHandled } from "./layerStack";
|
||||
import { createStore } from "./store";
|
||||
import type { Page } from "./urlState";
|
||||
@@ -483,10 +483,9 @@ export function useGlobalKeymap(
|
||||
pages: readonly Page[],
|
||||
): void {
|
||||
// The page jumps are the global keys whose action lives OUTSIDE this file — navigation is a
|
||||
// provider and `lib/` may not import a component — so App hands it in. Kept in a ref so a fresh
|
||||
// provider and `lib/` may not import a component — so App hands it in. An effect event, so a fresh
|
||||
// closure each render doesn't rebind the window listener (and can't leave a stale `setPage`).
|
||||
const navRef = useRef(onNavigate);
|
||||
navRef.current = onNavigate;
|
||||
const navigate = useEffectEvent(onNavigate);
|
||||
useKeyScope("page", enabled);
|
||||
const pagesKey = pages.join(",");
|
||||
useEffect(() => {
|
||||
@@ -506,7 +505,7 @@ export function useGlobalKeymap(
|
||||
(ev: KeyboardEvent) => {
|
||||
if (!s.page || !navigablePages.get().includes(s.page)) return;
|
||||
ev.preventDefault();
|
||||
navRef.current(s.page);
|
||||
navigate(s.page);
|
||||
},
|
||||
]),
|
||||
) as { readonly [K in ShortcutId<"page">]: (e: KeyboardEvent) => void };
|
||||
@@ -529,7 +528,7 @@ export function useGlobalKeymap(
|
||||
},
|
||||
home: (ev) => {
|
||||
ev.preventDefault();
|
||||
navRef.current("home");
|
||||
navigate("home");
|
||||
},
|
||||
});
|
||||
if (!handled) dispatch(e, "page", pageHandlers);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useEffectEvent, useRef } from "react";
|
||||
|
||||
// R8 S3 — one shared registry of open "dismissable layers" (modals, popovers, players), ordered by
|
||||
// open time. It replaces the three mutually-unaware Escape systems that each guessed at ordering with
|
||||
@@ -107,24 +107,21 @@ export function useLayer(
|
||||
opts?: { passive?: boolean; trapsTab?: boolean },
|
||||
): { isTopmost: () => boolean; anyAbove: () => boolean; blockingAbove: () => boolean } {
|
||||
const idRef = useRef<number | null>(null);
|
||||
const escRef = useRef(onEscape);
|
||||
escRef.current = onEscape;
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
const escape = useEffectEvent(() => onEscape?.());
|
||||
const hasEscape = onEscape !== undefined;
|
||||
const passive = !!opts?.passive;
|
||||
const trapsTab = opts?.trapsTab;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const id = pushLayer({
|
||||
passive: !!optsRef.current?.passive,
|
||||
trapsTab: optsRef.current?.trapsTab,
|
||||
});
|
||||
const id = pushLayer({ passive, trapsTab });
|
||||
idRef.current = id;
|
||||
let onKey: ((e: KeyboardEvent) => void) | undefined;
|
||||
if (escRef.current) {
|
||||
if (hasEscape) {
|
||||
onKey = (e: KeyboardEvent) => {
|
||||
// takeEscape stamps BEFORE closing, so a `window` listener below (fires after this `document`
|
||||
// one) sees the Escape as consumed even though closing us pops this layer off the stack.
|
||||
if (takeEscape(e, id)) escRef.current?.();
|
||||
if (takeEscape(e, id)) escape();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
}
|
||||
@@ -133,8 +130,9 @@ export function useLayer(
|
||||
removeLayer(id);
|
||||
idRef.current = null;
|
||||
};
|
||||
// Re-register only when open/closed toggles; onEscape is read fresh via escRef.
|
||||
}, [active]);
|
||||
// Re-register when open/closed toggles (the shape flags are constant per caller); the Escape
|
||||
// handler is an effect event, so a new `onEscape` each render never re-registers the layer.
|
||||
}, [active, hasEscape, passive, trapsTab]);
|
||||
|
||||
return {
|
||||
isTopmost: () => idRef.current != null && isTopmost(idRef.current),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { useEffect, useEffectEvent, useState, type RefObject } from "react";
|
||||
import { dispatch, useKeyScope } from "./keymap";
|
||||
import { scrollParent } from "./listPaging";
|
||||
import { focusIsUnclaimed } from "./useFocusKeeper";
|
||||
@@ -490,8 +490,8 @@ export function useListNav(
|
||||
onDelete?: (index: number) => void;
|
||||
},
|
||||
): void {
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
// Read when a key is pressed, so the registration below never re-runs for a new `opts` object.
|
||||
const latestOpts = useEffectEvent(() => opts);
|
||||
// The `list` keys do something only while there is an item to land on. An empty queue, a list still
|
||||
// loading or one that failed mounts its container all the same, and the cheat sheet listed j/k, the
|
||||
// arrows and Enter over nothing (keymap: a scope is pushed only while its keys really do something).
|
||||
@@ -503,10 +503,10 @@ export function useListNav(
|
||||
if (!opts.enabled || !el) return;
|
||||
lists.set(el, {
|
||||
el,
|
||||
count: () => optsRef.current.count ?? ownItems(el).length,
|
||||
cols: () => optsRef.current.cols ?? measuredCols(el),
|
||||
reveal: (i) => optsRef.current.reveal?.(i),
|
||||
remove: () => optsRef.current.onDelete ?? null,
|
||||
count: () => latestOpts().count ?? ownItems(el).length,
|
||||
cols: () => latestOpts().cols ?? measuredCols(el),
|
||||
reveal: (i) => latestOpts().reveal?.(i),
|
||||
remove: () => latestOpts().onDelete ?? null,
|
||||
});
|
||||
el.setAttribute(LIST, "");
|
||||
const check = () => setHasItems(el.querySelector(`[${INDEX}]`) !== null);
|
||||
|
||||
@@ -256,7 +256,6 @@ export function useYtBigView({
|
||||
}): {
|
||||
isFullscreen: boolean;
|
||||
maximized: boolean;
|
||||
maximizedRef: RefObject<boolean>;
|
||||
isFullscreenRef: RefObject<boolean>;
|
||||
toggleFullscreen: () => void;
|
||||
toggleTrueFullscreen: () => void;
|
||||
@@ -267,8 +266,6 @@ export function useYtBigView({
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
// Our own "fills the viewport" mode (the F key). Not the Fullscreen API — see toggleFullscreen.
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
const maximizedRef = useRef(false);
|
||||
maximizedRef.current = maximized;
|
||||
// Mirrored into a ref because the keydown handler is bound once and would otherwise read false forever.
|
||||
const isFullscreenRef = useRef(false);
|
||||
// Nested Back: peel fullscreen → maximise → close, one Back at a time (each a history layer only while
|
||||
@@ -310,7 +307,6 @@ export function useYtBigView({
|
||||
return {
|
||||
isFullscreen,
|
||||
maximized,
|
||||
maximizedRef,
|
||||
isFullscreenRef,
|
||||
toggleFullscreen,
|
||||
toggleTrueFullscreen,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "./fullscreen";
|
||||
import { api, type PlexItemDetail, type PlexMarker } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import { useLatest } from "./useLatest";
|
||||
import i18n, { plexLang } from "../i18n";
|
||||
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
||||
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
|
||||
@@ -458,17 +459,13 @@ export function useAutoSkip(opts: {
|
||||
seekTo,
|
||||
videoRef,
|
||||
} = opts;
|
||||
const skipProgressRef = useRef<number | null>(null);
|
||||
const cancelAutoSkipRef = useRef<() => void>(() => {});
|
||||
|
||||
const activeMarker: PlexMarker | undefined = markers?.find(
|
||||
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
||||
);
|
||||
const activeMarkerRef = useRef(activeMarker);
|
||||
activeMarkerRef.current = activeMarker;
|
||||
const activeMarkerRef = useLatest(activeMarker);
|
||||
|
||||
const [skipProgress, setSkipProgress] = useState<number | null>(null);
|
||||
skipProgressRef.current = skipProgress;
|
||||
const skipProgressRef = useLatest(skipProgress);
|
||||
const cancelledMarkerRef = useRef<string | null>(null);
|
||||
// Forget a cancelled-skip when the item changes: the ref holds a `${type}-${start_s}` key, and a
|
||||
// marker at the same offset on the NEXT episode would otherwise be suppressed by a cancel made on
|
||||
@@ -479,8 +476,7 @@ export function useAutoSkip(opts: {
|
||||
// The running countdown interval, so cancelAutoSkip can actually STOP it — clearing skipProgress
|
||||
// alone left the interval ticking, which re-set the progress ~100ms later and skipped anyway.
|
||||
const skipTimerRef = useRef<number | null>(null);
|
||||
const reportWatchedErrorRef = useRef(opts.reportWatchedError);
|
||||
reportWatchedErrorRef.current = opts.reportWatchedError;
|
||||
const reportWatchedErrorRef = useLatest(opts.reportWatchedError);
|
||||
const doSkip = useCallback(
|
||||
(m: PlexMarker) => {
|
||||
setSkipProgress(null);
|
||||
@@ -495,7 +491,7 @@ export function useAutoSkip(opts: {
|
||||
go(nextId, watchedWrite);
|
||||
} else seekTo(m.end_s);
|
||||
},
|
||||
[id, go, nextId, seekTo],
|
||||
[id, go, nextId, seekTo, reportWatchedErrorRef],
|
||||
);
|
||||
const cancelAutoSkip = useCallback(() => {
|
||||
const m = activeMarkerRef.current;
|
||||
@@ -505,8 +501,8 @@ export function useAutoSkip(opts: {
|
||||
skipTimerRef.current = null;
|
||||
}
|
||||
setSkipProgress(null);
|
||||
}, []);
|
||||
cancelAutoSkipRef.current = cancelAutoSkip;
|
||||
}, [activeMarkerRef]);
|
||||
const cancelAutoSkipRef = useLatest(cancelAutoSkip);
|
||||
// The marker is identified by type+offset rather than by object identity: `markers?.find` returns
|
||||
// the same element while the playhead stays inside it, but a refetch of the markers list would
|
||||
// hand back an equal-but-new object and restart a countdown already under way. The marker itself
|
||||
@@ -548,6 +544,7 @@ export function useAutoSkip(opts: {
|
||||
autoSkipDelay,
|
||||
doSkip,
|
||||
videoRef,
|
||||
activeMarkerRef,
|
||||
]);
|
||||
|
||||
return { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
import { api } from "./api";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
import { useLatest } from "./useLatest";
|
||||
|
||||
/** The shared feed-priority flow used by both the channel manager and the channel detail page:
|
||||
* an optimistic bump/set with a debounced server flush (~400ms after the last notch) so a
|
||||
@@ -12,7 +13,7 @@ import i18n from "../i18n";
|
||||
*
|
||||
* Timers are keyed by channel id, so a pending flush survives the caller switching channels: it
|
||||
* fires later and reads the right cache via `read(id)`, rather than needing the caller to flush on
|
||||
* every id change. `read`/`optimistic`/`onSuccess`/`onError` are read through a ref so the returned
|
||||
* every id change. `read`/`optimistic`/`onSuccess`/`onError` are read through `useLatest` so the returned
|
||||
* callbacks stay stable — the unmount flush then runs only on unmount, never on every render. */
|
||||
export function useChannelPriority(opts: {
|
||||
/** Current priority for this channel from whichever cache the caller owns (undefined if absent). */
|
||||
@@ -27,22 +28,24 @@ export function useChannelPriority(opts: {
|
||||
delay?: number;
|
||||
}) {
|
||||
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
const optsRef = useLatest(opts);
|
||||
|
||||
const flush = useCallback((channelId: string) => {
|
||||
const latest = optsRef.current.read(channelId);
|
||||
if (latest == null) return;
|
||||
api
|
||||
.updateChannel(channelId, { priority: latest })
|
||||
.then(() => optsRef.current.onSuccess?.(channelId))
|
||||
.catch((err: unknown) => {
|
||||
// Said here, once, for both callers: their `onError` only refetches, so a refused priority
|
||||
// used to just snap back — often after the stepper had already been let go (E11 S2b).
|
||||
notifyActionError(err, i18n.t("errors.writeFailed.channelPriority"));
|
||||
optsRef.current.onError(channelId);
|
||||
});
|
||||
}, []);
|
||||
const flush = useCallback(
|
||||
(channelId: string) => {
|
||||
const latest = optsRef.current.read(channelId);
|
||||
if (latest == null) return;
|
||||
api
|
||||
.updateChannel(channelId, { priority: latest })
|
||||
.then(() => optsRef.current.onSuccess?.(channelId))
|
||||
.catch((err: unknown) => {
|
||||
// Said here, once, for both callers: their `onError` only refetches, so a refused priority
|
||||
// used to just snap back — often after the stepper had already been let go (E11 S2b).
|
||||
notifyActionError(err, i18n.t("errors.writeFailed.channelPriority"));
|
||||
optsRef.current.onError(channelId);
|
||||
});
|
||||
},
|
||||
[optsRef],
|
||||
);
|
||||
|
||||
// Set an absolute priority (used by both the arrows — via bumpPriority — and the inline edit),
|
||||
// optimistically + a debounced flush.
|
||||
@@ -59,7 +62,7 @@ export function useChannelPriority(opts: {
|
||||
}, optsRef.current.delay ?? 400),
|
||||
);
|
||||
},
|
||||
[flush],
|
||||
[flush, optsRef],
|
||||
);
|
||||
|
||||
// Read the LATEST value from the cache each step (not a render closure) so a hold-repeat
|
||||
@@ -68,7 +71,7 @@ export function useChannelPriority(opts: {
|
||||
(channelId: string, delta: number) => {
|
||||
setPriority(channelId, (optsRef.current.read(channelId) ?? 0) + delta);
|
||||
},
|
||||
[setPriority],
|
||||
[setPriority, optsRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||
import { useEffectEvent, useLayoutEffect, useState, type RefObject } from "react";
|
||||
|
||||
// The shared DOM-measure-and-fit core for a single-line chip lane that collapses its overflow into a
|
||||
// trailing "+N" pill. Owns the layout-effect + ResizeObserver + initial-measure wiring and reads the
|
||||
@@ -69,11 +69,10 @@ export function useChipOverflowFit<R>(args: {
|
||||
}): R {
|
||||
const { rowRef, chipsRef, pillRef, deps, decide, initial } = args;
|
||||
const [result, setResult] = useState<R>(initial);
|
||||
// Read `decide` fresh each run without making it an effect dependency: the effect re-runs on `deps`
|
||||
// Read `decide` fresh each run (an effect event) without making it an effect dependency: the effect re-runs on `deps`
|
||||
// (and resize), and a caller's decide closure is rebuilt every render — depending on it would
|
||||
// resubscribe the ResizeObserver on every render.
|
||||
const decideRef = useRef(decide);
|
||||
decideRef.current = decide;
|
||||
const decideNow = useEffectEvent(decide);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current;
|
||||
@@ -85,7 +84,7 @@ export function useChipOverflowFit<R>(args: {
|
||||
const chips = (chipsRef.current ?? []).filter((c): c is HTMLElement => c != null);
|
||||
const lastChip = chips[chips.length - 1];
|
||||
setResult(
|
||||
decideRef.current({
|
||||
decideNow({
|
||||
avail: row.clientWidth,
|
||||
chipRights: chips.map(rightOf),
|
||||
pillW: pill.offsetWidth,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import { useEffect, useEffectEvent, type RefObject } from "react";
|
||||
import { useLayer } from "./layerStack";
|
||||
|
||||
/** While `active`, close (call `onClose`) on Escape or a mousedown outside ALL of the given
|
||||
@@ -26,26 +26,22 @@ export function useDismiss(
|
||||
|
||||
// Outside-press stays per-instance (the refs/positioning are the caller's): close on a mousedown
|
||||
// that lands outside every registered element.
|
||||
// Both arguments are read through refs so the listener can stay subscribed once per open/close —
|
||||
// Both arguments are read through an effect event so the listener can stay subscribed once per open/close —
|
||||
// which is the point of the `[active]` dep list — without going STALE. The disabled dep list this
|
||||
// replaces asserted that onClose was "read fresh from this run's closure"; it was not. The closure
|
||||
// is built when `active` last flipped, so a caller that re-rendered with a different handler while
|
||||
// the popover stayed open kept getting the old one called (regression test in useDismiss.dom.test).
|
||||
// `refs` is the weaker case — the ref OBJECTS are stable, so a late-mounting node is seen either
|
||||
// way — but a caller that changes WHICH refs it passes while open was equally ignored.
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
const refsRef = useRef(refs);
|
||||
refsRef.current = refs;
|
||||
const onPress = useEffectEvent((e: MouseEvent) => {
|
||||
const list = Array.isArray(refs) ? refs : [refs];
|
||||
const target = e.target as Node;
|
||||
if (!list.some((r) => r.current?.contains(target))) onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const current = refsRef.current;
|
||||
const list = Array.isArray(current) ? current : [current];
|
||||
const target = e.target as Node;
|
||||
if (!list.some((r) => r.current?.contains(target))) onCloseRef.current();
|
||||
};
|
||||
const onDown = (e: MouseEvent) => onPress(e);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useLayoutEffect, useRef, type RefObject } from "react";
|
||||
|
||||
/** A ref that holds the latest COMMITTED `value`, for a stable callback that must still read fresh
|
||||
* props: an event handler, a timer, an API that takes its callbacks once. When only effects read the
|
||||
* value, use `useEffectEvent` instead.
|
||||
*
|
||||
* Written in a layout effect, never during render. A render-phase `ref.current = value` makes the
|
||||
* React Compiler skip the whole component (E32), and it is wrong under concurrent rendering anyway:
|
||||
* a render React throws away would still have overwritten the value. Read it outside render only. */
|
||||
export function useLatest<T>(value: T): RefObject<T> {
|
||||
const ref = useRef(value);
|
||||
useLayoutEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref;
|
||||
}
|
||||
|
||||
/** A callback with a STABLE identity that always runs the latest `fn` — for a handler handed to
|
||||
* something memoized (a column set, a menu model) that must not rebuild whenever the handler's
|
||||
* inputs change. Call it from events only, never during render: it runs the last COMMITTED `fn`. */
|
||||
export function useStableCallback<A extends unknown[], R>(
|
||||
fn: (...args: A) => R,
|
||||
): (...args: A) => R {
|
||||
const latest = useLatest(fn);
|
||||
return useCallback((...args: A) => latest.current(...args), [latest]);
|
||||
}
|
||||
@@ -74,20 +74,13 @@ export function usePref<T>(
|
||||
const { localKey, debounceMs, parse } = options;
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Latest parse in a ref so the seed and the setter don't depend on a fresh closure each render (which
|
||||
// would churn the memoized setter). parse must be idempotent anyway.
|
||||
const parseRef = useRef(parse);
|
||||
parseRef.current = parse;
|
||||
const norm = useCallback((raw: unknown): T => {
|
||||
const p = parseRef.current;
|
||||
return p ? p(raw) : (raw as T);
|
||||
}, []);
|
||||
|
||||
// Seeded inline rather than through a memoized `seed()`: useState calls a lazy initializer exactly
|
||||
// once, at mount, so there was nothing for the useCallback to save — and its dep list was the one
|
||||
// place in this file that had to be excused from the checker (it read `defaultValue` without
|
||||
// naming it). Server preference wins, then the per-account localStorage mirror, then the default.
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
// `parse` is read here only, once: the setter stores what it is given.
|
||||
const norm = (raw: unknown): T => (parse ? parse(raw) : (raw as T));
|
||||
const server = readServerPref(qc.getQueryData<Me | null>(qk.me()), serverKey);
|
||||
if (server !== undefined) return norm(server);
|
||||
if (localKey) return norm(readAccount(localKey, defaultValue));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useReducer, useRef } from "react";
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useLatest } from "./useLatest";
|
||||
|
||||
// A generic, reusable undo/redo container for a single piece of state.
|
||||
//
|
||||
@@ -9,8 +10,9 @@ import { useCallback, useReducer, useRef } from "react";
|
||||
// onApply (use it when the underlying data is reloaded from the source of truth).
|
||||
//
|
||||
// Not playlist-specific: any component with an undoable value (sort order, a draft, a set
|
||||
// of filters…) can use it. State lives in a ref so the callbacks are stable and never read
|
||||
// a stale snapshot; a reducer-based force-render keeps the view in sync.
|
||||
// of filters…) can use it. The history is state (what render reads) mirrored in a ref (what the
|
||||
// stable callbacks read, so two calls in one tick chain on each other, never on a stale snapshot).
|
||||
// Render never reads the ref: a compiled component would otherwise memoize a stale value (E32).
|
||||
export interface Undoable<T> {
|
||||
value: T;
|
||||
set: (next: T) => void;
|
||||
@@ -32,10 +34,18 @@ export function useUndoable<T>(
|
||||
initial: T,
|
||||
opts?: { onApply?: (value: T) => void; limit?: number },
|
||||
): Undoable<T> {
|
||||
const [, force] = useReducer((n: number) => n + 1, 0);
|
||||
const ref = useRef<History<T>>({ past: [], present: initial, future: [] });
|
||||
const onApplyRef = useRef(opts?.onApply);
|
||||
onApplyRef.current = opts?.onApply;
|
||||
const [h, setH] = useState<History<T>>(() => ({ past: [], present: initial, future: [] }));
|
||||
const ref = useRef(h);
|
||||
// The mirror follows every commit, so a `reset` made during render (a caller re-seeding on a new
|
||||
// selection) reaches the callbacks without a render-phase ref write.
|
||||
useLayoutEffect(() => {
|
||||
ref.current = h;
|
||||
}, [h]);
|
||||
const commit = useCallback((next: History<T>) => {
|
||||
ref.current = next;
|
||||
setH(next);
|
||||
}, []);
|
||||
const onApplyRef = useLatest(opts?.onApply);
|
||||
const limit = opts?.limit ?? 100;
|
||||
|
||||
const set = useCallback(
|
||||
@@ -44,39 +54,36 @@ export function useUndoable<T>(
|
||||
if (Object.is(next, s.present)) return;
|
||||
const past = [...s.past, s.present];
|
||||
if (past.length > limit) past.shift();
|
||||
ref.current = { past, present: next, future: [] };
|
||||
force();
|
||||
commit({ past, present: next, future: [] });
|
||||
onApplyRef.current?.(next);
|
||||
},
|
||||
[limit],
|
||||
[limit, commit, onApplyRef],
|
||||
);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
const s = ref.current;
|
||||
if (!s.past.length) return;
|
||||
const prev = s.past[s.past.length - 1]!; // past is non-empty (guarded above)
|
||||
ref.current = {
|
||||
commit({
|
||||
past: s.past.slice(0, -1),
|
||||
present: prev,
|
||||
future: [s.present, ...s.future],
|
||||
};
|
||||
force();
|
||||
});
|
||||
onApplyRef.current?.(prev);
|
||||
}, []);
|
||||
}, [commit, onApplyRef]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
const s = ref.current;
|
||||
if (!s.future.length) return;
|
||||
const next = s.future[0]!; // future is non-empty (guarded above)
|
||||
const rest = s.future.slice(1);
|
||||
ref.current = { past: [...s.past, s.present], present: next, future: rest };
|
||||
force();
|
||||
commit({ past: [...s.past, s.present], present: next, future: rest });
|
||||
onApplyRef.current?.(next);
|
||||
}, []);
|
||||
}, [commit, onApplyRef]);
|
||||
|
||||
// State only: safe to call during render. The other actions see it from the next commit on.
|
||||
const reset = useCallback((value: T) => {
|
||||
ref.current = { past: [], present: value, future: [] };
|
||||
force();
|
||||
setH({ past: [], present: value, future: [] });
|
||||
}, []);
|
||||
|
||||
// Apply the same transformation to EVERY snapshot — past, present and future — and keep the
|
||||
@@ -84,17 +91,18 @@ export function useUndoable<T>(
|
||||
// removing a playlist item is not itself undoable, yet dropping it from each stored order keeps
|
||||
// the user's reorder history valid and usable instead of throwing it away (which is what a
|
||||
// `reset` does). No `onApply`: the caller is already persisting the change by its own route.
|
||||
const rebase = useCallback((map: (value: T) => T) => {
|
||||
const s = ref.current;
|
||||
ref.current = {
|
||||
past: s.past.map(map),
|
||||
present: map(s.present),
|
||||
future: s.future.map(map),
|
||||
};
|
||||
force();
|
||||
}, []);
|
||||
const rebase = useCallback(
|
||||
(map: (value: T) => T) => {
|
||||
const s = ref.current;
|
||||
commit({
|
||||
past: s.past.map(map),
|
||||
present: map(s.present),
|
||||
future: s.future.map(map),
|
||||
});
|
||||
},
|
||||
[commit],
|
||||
);
|
||||
|
||||
const h = ref.current;
|
||||
return {
|
||||
value: h.present,
|
||||
set,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useEffectEvent } from "react";
|
||||
import { urlWithParams } from "./urlState";
|
||||
|
||||
// Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new
|
||||
@@ -10,12 +10,13 @@ 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);
|
||||
// The object itself is read through a ref, so the effect's one reactive input is the serialized
|
||||
// value — which is exactly what the dep list now says. The house pattern (see useChipOverflowFit's
|
||||
// decideRef, usePref's parseRef): a ref for the identity that churns, a primitive for the trigger.
|
||||
const updatesRef = useRef(updates);
|
||||
updatesRef.current = updates;
|
||||
// The object itself is read through an effect event, so the effect's one reactive input is the
|
||||
// serialized value — which is exactly what the dep list says: an effect event for the identity that
|
||||
// churns, a primitive for the trigger.
|
||||
const mirror = useEffectEvent(() => {
|
||||
window.history.replaceState(window.history.state, "", urlWithParams(updates));
|
||||
});
|
||||
useEffect(() => {
|
||||
window.history.replaceState(window.history.state, "", urlWithParams(updatesRef.current));
|
||||
mirror();
|
||||
}, [dep]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user