Files
siftlode/frontend/src/components/ui/FocusSafeButton.tsx
T
peter 76358a0d77 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.
2026-10-03 23:18:26 +02:00

116 lines
4.8 KiB
TypeScript

import {
forwardRef,
useCallback,
useRef,
useState,
useSyncExternalStore,
type ButtonHTMLAttributes,
} from "react";
import clsx from "clsx";
import { endsTextSelection, movedPastSlop } from "../../lib/textSelection";
// A `<button>` whose `disabled` never throws the focus out of the page.
//
// A focused button that turns natively disabled loses the focus — Chrome moves it to <body>
// (measured, E11 S2) — and the app disables the very button that was just pressed on ~60 sites (a
// mutation pending, a form gone clean after its save), so a keyboard user was dropped out of the page
// on every submit. While THIS button holds the focus, `disabled` (and `busy`) is expressed as
// `aria-disabled` plus a click guard instead: still focusable, announced as unavailable, and not
// activatable — its preventDefault also cancels a form's implicit submission, which is a click on the
// default button. The native attribute applies again once the focus is elsewhere (at the blur; in
// a window without focus, which fires no blur, at the next render).
//
// Unstyled on purpose: `ui/Button` is built on it, and a hand-rolled `<button>` swaps its tag for
// this one keeping its classes. The dimmed look of the soft state is one rule in index.css
// (`[data-focus-safe][aria-disabled="true"]`), since a caller's `disabled:*` utilities match only the
// native attribute.
//
// `selectable` is for a button whose text is a title or a name (docs/DESIGN.md "Text stays
// selectable"): a row, a card, a name that opens its object. Chrome selects no text inside a
// <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> & {
/** Working: unavailable and announced as busy, whether focused or not. */
busy?: boolean;
/** Its text is a title or a name: selectable, and the click that ends a selection is ignored. */
selectable?: boolean;
}
>(function FocusSafeButton(
{
disabled,
busy = false,
selectable = false,
className,
onClick,
onMouseDown,
onFocus,
onBlur,
...rest
},
ref,
) {
const [focusEvent, setFocusEvent] = useState(false);
const node = useRef<HTMLButtonElement | null>(null);
const pressedAt = useRef<{ x: number; y: number } | null>(null);
const setNode = useCallback(
(el: HTMLButtonElement | null) => {
node.current = el;
if (typeof ref === "function") ref(el);
else if (ref) ref.current = el;
},
[ref],
);
// 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. 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
// it is inert to a click like ours, so no caller has to guard its own handler.
const callerSoft = rest["aria-disabled"] === true || rest["aria-disabled"] === "true";
return (
<button
ref={setNode}
{...rest}
className={clsx(selectable && "select-text", className) || undefined}
data-focus-safe=""
disabled={!!disabled && !focused}
aria-disabled={soft || callerSoft || undefined}
aria-busy={busy || undefined}
onClick={(e) => {
if (soft || callerSoft) {
e.preventDefault();
return;
}
if (selectable && (endsTextSelection(e) || movedPastSlop(pressedAt.current, e))) return;
onClick?.(e);
}}
onMouseDown={(e) => {
pressedAt.current = { x: e.clientX, y: e.clientY };
onMouseDown?.(e);
}}
onFocus={(e) => {
setFocusEvent(true);
onFocus?.(e);
}}
onBlur={(e) => {
setFocusEvent(false);
onBlur?.(e);
}}
/>
);
});