Skip to content

feat(focus): recover focus when the active element is removed#41

Open
chiefcll wants to merge 1 commit into
mainfrom
feat/focus-loss-recovery
Open

feat(focus): recover focus when the active element is removed#41
chiefcll wants to merge 1 commit into
mainfrom
feat/focus-loss-recovery

Conversation

@chiefcll

@chiefcll chiefcll commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

What

Adds automatic focus-loss recovery plus dev-mode diagnostics for the most common focus failure mode in TV apps: the focused element unmounts and the remote goes dead.

Why

Nothing in the framework noticed when the focused element left the tree. removeChild and the delete-flush phase never checked whether the removed subtree contained activeElement(), so when a <Show> toggled, a list item was deleted, or a route swapped while its content was focused, the activeElement signal kept pointing at a destroyed node and key events walked a stale focus path. Every app had to defend against this manually.

How

  • Recovery (elementNode.ts): the unified post-mutation scheduler gets a phase 4 — after any pass that actually destroyed nodes (the only way the active element can leave the tree, so ordinary layout/focus passes pay nothing), if no focus change is pending, recoverActiveElement() runs.
  • recoverActiveElement() (focusManager.ts): if activeElement() is detached, walk the old focus path to the nearest still-attached, rendered ancestor and call its setFocus() — that ancestor's forwardFocus/selected logic then picks a sensible child. Detachment is verified by checking each node is present in its parent's children array, since removeChild doesn't clear node.parent.
  • Opt-out (config.ts): Config.focusLossRecovery, default true.
  • Dev diagnostics (all isDev-gated):
    • console.warn when recovery fires, naming the lost element and the recovery ancestor, with element refs attached and a pointer to printFocusHistory().
    • The Config.keyDebug "no event handler" log now prints the focus path leaf → root like a stack trace.
    • useFocusManager warns once if nothing is focused 1s after startup (missing autofocus).

Notes for reviewers

  • When recovery fires, the destroyed element still receives its normal onBlur/onFocusChanged(false) callbacks via updateFocusPath. Blur handlers that touch the node should tolerate it being destroyed — same exposure as any late callback, but worth knowing.
  • Deliberately out of scope: Row/Column selected reconciliation on child removal, focus traps, and a WebGL-drawn focusDebug overlay (the current overlay is DOM-renderer-only).

Testing

New tests/focusRecovery.test.tsx covers: recovery to parent (and that the dev warning fires), deep-subtree removal skipping detached ancestors, recovery re-running forwardFocus so a sibling gains focus, removal of a non-focused element not moving focus, and the focusLossRecovery = false opt-out. Full suite passes (139 tests), tsc clean, no new lint warnings.

🤖 Generated with Claude Code

When the focused element unmounted (Show toggle, list item removed,
route swap), nothing noticed: activeElement kept pointing at a
destroyed node and key events walked a stale focus path, dead-ending
the remote until the app manually refocused.

The post-mutation scheduler now runs a recovery phase after any pass
that destroyed nodes: if the active element is no longer attached,
focus is restored via the nearest still-attached ancestor on the old
focus path, letting that ancestor's forwardFocus/selected logic pick a
sensible child. Opt out with Config.focusLossRecovery = false.

Dev-mode diagnostics: a warning names the lost and recovering elements
when recovery fires, the keyDebug "no handler" log prints the focus
path leaf-to-root, and useFocusManager warns when nothing is focused
1s after startup (missing autofocus).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant