Skip to content

fix(frontend): preserve input focus across reruns#49

Open
sudo-vaibhav wants to merge 1 commit into
mainfrom
fix/component-focus-loss-on-rerun
Open

fix(frontend): preserve input focus across reruns#49
sudo-vaibhav wants to merge 1 commit into
mainfrom
fix/component-focus-loss-on-rerun

Conversation

@sudo-vaibhav

@sudo-vaibhav sudo-vaibhav commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Problem

Every value-returning widget lost focus — and its text caret / selection — on every rerun. In Backroad-based apps (this surfaced while running code-bot), editing one field or nudging a slider yanked focus away mid-interaction.

Root cause: TreeRender keyed each component by its value:

<ComponentRenderer {...props.tree} key={props.tree.value} />

The parent (Frame) already keys each child subtree by stable path, so the inner value-key did nothing except force an unmount+remount whenever the committed value changed. That remount replaces the DOM node → focus, caret, and selection are gone.

Fix

  • Key components by their stable id instead of value (tree.tsx). The node now survives a value change and only remounts when its identity actually changes.
  • New useSyncedState hook — since we no longer remount to re-seed local state, components adopt a server-pushed value in place during render, only when it actually changes (React's documented "adjusting state on a prop change" pattern). Guarded with Object.is so it neither loops nor clobbers in-progress local edits.
  • Swapped useState(props.value)useSyncedState(props.value) across the controlled widgets (text_input, text_area, number_input, slider, checkbox, toggle, radio, date_input, time_input, color_picker).
  • select / multiselect: moved from uncontrolled defaultValue (which relied on the remount to refresh) to a controlled, synced value. multiselect tracks the selected values (stable identity) and derives react-select's option objects each render, so the sync guard doesn't re-fire every render.

Test

e2e/focus-retention.spec.ts — a slider must keep keyboard focus across each commit-driven rerun: focus once, then drive nudges through page.keyboard (targets whatever's focused; doesn't re-focus the way locator.press would and mask the bug). Each subsequent increment landing proves focus survived the prior rerun.

Verified the guard works: reverting the key back to props.tree.value makes this spec fail at the focus assertion; the fix makes it pass.

Verification

  • Full e2e suite: 20 passed
  • typecheck (backroad-components + backroad-frontend): clean
  • Frontend production build: clean

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added automated testing for keyboard focus retention during slider interactions.
  • Bug Fixes

    • Improved focus retention and state management across form components (inputs, sliders, checkboxes, toggles, date/time pickers, text areas, selectors, and radio buttons) to prevent loss of cursor position during user edits.
    • Fixed component remounting behavior to preserve focus and in-progress edits when values update.

The renderer keyed every component by its value (`key={props.tree.value}`
in TreeRender), so any committed value change unmounted and remounted the
widget — dropping DOM focus, the text caret, and selection mid-interaction
on every rerun. This was visible in Backroad-based apps (e.g. code-bot)
where editing one field or nudging a slider yanked focus away.

Key components by their stable `id` instead (the parent already keys the
subtree by `path`, so the value-key only ever forced remounts). To keep
server-driven value updates visible without a remount, components now
sync the server value in place via a new `useSyncedState` hook (React's
"adjust state on prop change" pattern, guarded so it neither loops nor
clobbers in-progress local edits). The two react-select widgets move from
uncontrolled `defaultValue` to a controlled, synced value for the same
reason.

Adds an e2e regression guard: a slider must keep keyboard focus across
each commit-driven rerun (repeated arrow nudges only register if focus
survives the remount). Verified it fails on the old value-keyed renderer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a6071751-e2bc-4547-86e6-2b344cfadb11

📥 Commits

Reviewing files that changed from the base of the PR and between 2131ef4 and 51a46d3.

📒 Files selected for processing (15)
  • e2e/focus-retention.spec.ts
  • libs/backroad-components/src/lib/components/checkbox.tsx
  • libs/backroad-components/src/lib/components/color_picker.tsx
  • libs/backroad-components/src/lib/components/date_input.tsx
  • libs/backroad-components/src/lib/components/multiselect.tsx
  • libs/backroad-components/src/lib/components/number_input.tsx
  • libs/backroad-components/src/lib/components/radio.tsx
  • libs/backroad-components/src/lib/components/select.tsx
  • libs/backroad-components/src/lib/components/slider.tsx
  • libs/backroad-components/src/lib/components/text_area.tsx
  • libs/backroad-components/src/lib/components/text_input.tsx
  • libs/backroad-components/src/lib/components/time_input.tsx
  • libs/backroad-components/src/lib/components/toggle.tsx
  • libs/backroad-components/src/lib/hooks/use-synced-state.ts
  • libs/backroad-components/src/lib/tree.tsx

📝 Walkthrough

Walkthrough

Introduces a useSyncedState hook that preserves DOM focus by syncing local component state with server-provided values only when reference identity changes. The ComponentRenderer tree key is switched from value to id to prevent remounting on value updates. All eleven input components are migrated from useState to useSyncedState, with Select and Multiselect also converted to controlled-value mode. A Playwright e2e test validates slider focus retention across rerenders.

Changes

Focus Retention via useSyncedState

Layer / File(s) Summary
useSyncedState hook and tree key-by-id fix
libs/backroad-components/src/lib/hooks/use-synced-state.ts, libs/backroad-components/src/lib/tree.tsx
useSyncedState introduced: seeds local state from serverValue and syncs only on Object.is identity change, leaving DOM focus/caret untouched during local edits. TreeRender keys ComponentRenderer by props.tree.id instead of props.tree.value to avoid remounting on value-only changes.
Simple component migrations to useSyncedState
libs/backroad-components/src/lib/components/checkbox.tsx, color_picker.tsx, date_input.tsx, number_input.tsx, radio.tsx, slider.tsx, text_area.tsx, text_input.tsx, time_input.tsx, toggle.tsx
Each component replaces useState(props.value) with useSyncedState(props.value) and updates imports; rendering, onChange, and setBackroadValue commit behavior are unchanged.
Select and Multiselect controlled-value conversion
libs/backroad-components/src/lib/components/select.tsx, libs/backroad-components/src/lib/components/multiselect.tsx
Select moves from uncontrolled defaultValue to a controlled value state seeded from flattened options. Multiselect replaces its useState-backed selection with useSyncedState(props.value), derives valueOptions by filtering, and switches to value={valueOptions} with an updated onChange that persists only selected .value entries.
E2e focus-retention regression test
e2e/focus-retention.spec.ts
Playwright suite focuses the "Volume" slider, sends ArrowRight keypresses, awaits each "Volume is N" text update, and asserts the slider remains the focused element after each commit-driven rerender.

Sequence Diagram(s)

sequenceDiagram
  participant User as User (keyboard)
  participant Slider as Slider component
  participant useSyncedState as useSyncedState
  participant Socket as Backroad socket (server)

  User->>Slider: ArrowRight keypress
  Slider->>useSyncedState: setValue(newLocalValue)
  Slider->>Socket: setBackroadValue({ id, value })
  Socket-->>Slider: rerender with updated props.value
  Slider->>useSyncedState: render — Object.is(serverValue, lastRef)?
  alt identity changed
    useSyncedState->>Slider: setValue(serverValue), update ref
  else same identity
    useSyncedState-->>Slider: no-op, local state preserved, focus retained
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 A slider I press, ArrowRight once, twice, thrice,
My focus stays put — oh, isn't that nice!
useSyncedState guards my caret with care,
No more remounts to jolt me mid-air.
The key is the id, not the value, you see —
Focus retention, at last, lives free! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(frontend): preserve input focus across reruns' accurately reflects the main objective of the PR—fixing focus loss in input widgets during reruns.
Description check ✅ Passed The PR description comprehensively covers the problem, root cause, fix approach, testing strategy, and verification results. It aligns with the template structure and provides adequate detail for understanding the changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/component-focus-loss-on-rerun

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Knip dead-code gate — no regressions

Category Baseline Current Δ
binaries 0 0 ±0
catalog 0 0 ±0
dependencies 28 28 ±0
devDependencies 8 8 ±0
duplicates 3 3 ±0
enumMembers 0 0 ±0
exports 5 5 ±0
files 6 6 ±0
namespaceMembers 0 0 ±0
optionalPeerDependencies 0 0 ±0
owners 18 18 ±0
types 2 2 ±0
unlisted 2 2 ±0
unresolved 1 1 ±0

All counts at or below baseline.

@github-actions

Copy link
Copy Markdown

size-limit report 📦

Path Size
backroad-frontend — main JS bundle 296.38 KB (+0.02% 🔺)
backroad-frontend — main CSS bundle 14 KB (0%)
backroad-frontend — /signin chunk (lazy) 106.96 KB (0%)

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