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.
|
||||
|
||||
Reference in New Issue
Block a user