refactor(ui): every function compiles under the React Compiler but VirtualGrid, and react-hooks/refs is on

E32 S4 — the burn-down from 29 skipped functions to 1:

- try/finally (26 sites): the cleanup runs after a try/catch; steps that return early moved into
  their own function (push, the setup wizard's next, the share page's unlock).
- A conditional, loop or optional call inside a try skips the function too, and react-hooks/todo
  does not see it: the value is computed before the try, or the try lives in a module-level helper
  (config writes, clip queueing, clipboard, frame counts, seekable end, YouTube player times).
- The two exhaustive-deps suppressions are gone. useYtProgress takes its per-item snapshot through
  one effect event, read once when the player is built; PlexPlayer's unmount relies on the
  leaving-the-item cleanup for the generation bump it duplicated.
- What the skips hid: PlexPlayer's 17 render-phase ref writes (useLatest), its tracks popover alias,
  the loadSession self-reference (declared early, set after commit); PlayerModal's session-position
  read during render (now where the player starts), navRef/modeRef writes, and a description helper
  that ran a ref-carrying callback during render (now PlayerDescription); SavedViewsProvider's
  latest-cfg ref, and its setState in the seed effect (preferSticky now prefers the starred default
  among same-filter views, so the seed needs no sticky write — test seen red on the old helper).
- A store's hook was called `use()`, which the compiler does not know as a hook: inside the newly
  compiled usePlaylistActions it was memoized, ran on the first render alone, and the playlist rail
  threw "rendered fewer hooks" on its second render. Renamed to `useValue()` everywhere; the old
  contract test claimed the call was safe inside any `use…` function, which was false — replaced by
  a render test that is red under the old name.
- PlexPlayer's palette ref was written and never read since the Backspace guard moved to the layer
  stack: removed.

react-hooks/refs is back at error with no exception. The ratchet lists only VirtualGrid
(IncompatibleLibrary) and pins both players as compiled. No size or complexity ceiling was raised.
This commit is contained in:
2026-10-04 00:05:37 +02:00
parent a486c7521a
commit d7fb7075b8
42 changed files with 646 additions and 589 deletions
+31 -22
View File
@@ -45,6 +45,33 @@ function useSeededDraft(baseline: Record<string, string>, seedKey: string) {
return { draft, setDraft, secretReset, setSecretReset };
}
/** The dirty keys' writes, one after another. Apart from ConfigPanel's save() so the loop and its
* branches sit outside the try (the React Compiler cannot lower either inside one yet, and skipped
* the whole panel). */
async function writeConfigItems(
dirty: ConfigItem[],
draft: Record<string, string>,
secretReset: Record<string, boolean>,
) {
for (const it of dirty) {
const key = it.key;
const raw = draft[key] ?? "";
if (it.secret) {
if (secretReset[key] && !raw.trim()) await api.resetConfig(key);
else if (raw.trim()) await api.setConfig(key, raw);
} else if (it.type === "bool") {
await api.setConfig(key, raw === "true");
} else if (raw === "") {
// allow_empty keys treat a blank as an explicit empty value (e.g. disable smtp_user /
// youtube_api_proxy) rather than resetting to the env default (AB3).
if (it.allow_empty) await api.setConfig(key, "");
else if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
} else {
await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
}
}
}
export default function ConfigPanel() {
const { t } = useTranslation();
const panel = useTabPanel();
@@ -54,7 +81,6 @@ export default function ConfigPanel() {
const data = q.data;
const items = useMemo(() => (data ? Object.values(data.groups).flat() : []), [data]);
const byKey = useMemo(() => Object.fromEntries(items.map((i) => [i.key, i])), [items]);
// Baseline draft from the server: non-secrets show their effective value; secrets start empty
// (write-only — we never load them back).
const baseline = useMemo(() => {
@@ -85,30 +111,13 @@ export default function ConfigPanel() {
if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key];
return (draft[it.key] ?? "") !== (baseline[it.key] ?? "");
};
const dirtyKeys = items.filter(isDirty).map((i) => i.key);
const dirty = dirtyKeys.length > 0;
const dirtyItems = items.filter(isDirty);
const dirty = dirtyItems.length > 0;
async function save() {
setSaveState("saving");
try {
for (const key of dirtyKeys) {
const it = byKey[key];
if (!it) continue; // dirtyKeys are all config-item keys; skip if somehow not found
const raw = draft[key] ?? "";
if (it.secret) {
if (secretReset[key] && !raw.trim()) await api.resetConfig(key);
else if (raw.trim()) await api.setConfig(key, raw);
} else if (it.type === "bool") {
await api.setConfig(key, raw === "true");
} else if (raw === "") {
// allow_empty keys treat a blank as an explicit empty value (e.g. disable smtp_user /
// youtube_api_proxy) rather than resetting to the env default (AB3).
if (it.allow_empty) await api.setConfig(key, "");
else if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
} else {
await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
}
}
await writeConfigItems(dirtyItems, draft, secretReset);
await qc.invalidateQueries({ queryKey: qk.adminConfig() });
// The email-not-configured notice reads me.email_configured, which is otherwise only
// refetched on its own 60s interval — without this it keeps warning about fields the admin
@@ -190,7 +199,7 @@ export default function ConfigPanel() {
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
// never silently lost behind the global Save bar.
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
const dirtyByGroup = new Set(dirtyKeys.map((k) => byKey[k]?.group).filter(Boolean));
const dirtyByGroup = new Set(dirtyItems.map((i) => i.group).filter(Boolean));
const groupTabs = data.sections.flatMap((s) =>
s.groups.map((g) => ({
id: g,