diff --git a/e2e/focus-retention.spec.ts b/e2e/focus-retention.spec.ts
new file mode 100644
index 0000000..d64cbb3
--- /dev/null
+++ b/e2e/focus-retention.spec.ts
@@ -0,0 +1,61 @@
+import { expect, test } from '@playwright/test';
+
+// Runs under the `chromium` project (already authenticated via the saved
+// storage state).
+//
+// Regression guard for focus survival across a rerun. The renderer used to key
+// every component by its value, so any committed value change unmounted and
+// remounted the widget — dropping DOM focus, the text caret, and selection
+// mid-interaction. Components are now keyed by their stable `id` and sync the
+// server value in place (useSyncedState), so the node survives a value change.
+//
+// The Volume slider is the sharpest probe: a keyboard nudge commits a new value
+// and reruns the script. If that rerun remounts the slider, it loses focus and
+// the NEXT arrow key goes nowhere. So we focus once and then drive every nudge
+// through `page.keyboard` (which targets whatever is currently focused, and does
+// NOT re-focus the element the way `locator.press` would — re-focusing would
+// mask the very bug under test). Each subsequent increment landing proves focus
+// was retained across the previous rerun.
+
+test.describe('focus survives a rerun', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/widgets');
+ await expect(page.getByRole('heading', { name: /^Widgets$/ })).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+
+ test('a slider keeps keyboard focus across each commit-driven rerun', async ({
+ page,
+ }) => {
+ const slider = page.getByRole('slider', { name: 'Volume' });
+ await expect(page.getByText('Volume is 30')).toBeVisible();
+
+ await slider.focus();
+ await expect(slider).toBeFocused();
+
+ // First nudge: commit + rerun. Under the old value-keyed renderer the slider
+ // remounts here and focus is lost.
+ await page.keyboard.press('ArrowRight');
+ await expect(page.getByText('Volume is 31')).toBeVisible({
+ timeout: 10_000,
+ });
+ // The node must still be the focused one for the rest of the test to work.
+ await expect(slider).toBeFocused();
+
+ // These two only register if focus survived the reruns above — each waits
+ // for the script echo, so the prior rerun has fully landed (and, under the
+ // old behaviour, would already have remounted) before the next key.
+ await page.keyboard.press('ArrowRight');
+ await expect(page.getByText('Volume is 32')).toBeVisible({
+ timeout: 10_000,
+ });
+
+ await page.keyboard.press('ArrowRight');
+ await expect(page.getByText('Volume is 33')).toBeVisible({
+ timeout: 10_000,
+ });
+
+ await expect(slider).toBeFocused();
+ });
+});
diff --git a/libs/backroad-components/src/lib/components/checkbox.tsx b/libs/backroad-components/src/lib/components/checkbox.tsx
index e0623b0..f65dc03 100644
--- a/libs/backroad-components/src/lib/components/checkbox.tsx
+++ b/libs/backroad-components/src/lib/components/checkbox.tsx
@@ -1,10 +1,10 @@
-import { useState } from 'react';
+import { useSyncedState } from '../hooks/use-synced-state';
import { BackroadComponentRenderer } from '../types/components';
import { setBackroadValue } from '../socket';
import { Checkbox as UICheckbox, Label } from 'backroad-ui';
export const Checkbox: BackroadComponentRenderer<'checkbox'> = (props) => {
- const [value, setValue] = useState(props.value);
+ const [value, setValue] = useSyncedState(props.value);
return (
= (
props
) => {
const popover = useRef(null);
- const [value, setValue] = useState(props.value || undefined);
+ const [value, setValue] = useSyncedState(props.value || undefined);
const [isOpen, setIsOpen] = useState(false);
const close = useCallback(() => setIsOpen(false), []);
diff --git a/libs/backroad-components/src/lib/components/date_input.tsx b/libs/backroad-components/src/lib/components/date_input.tsx
index c08e0ce..474bfb2 100644
--- a/libs/backroad-components/src/lib/components/date_input.tsx
+++ b/libs/backroad-components/src/lib/components/date_input.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useSyncedState } from '../hooks/use-synced-state';
import { BackroadComponentRenderer } from '../types/components';
import { setBackroadValue } from '../socket';
import { Input, Label } from 'backroad-ui';
@@ -6,7 +6,7 @@ import { Input, Label } from 'backroad-ui';
// Native date picker. `change` fires once per selection (not per keystroke),
// so committing directly is cheap. Value is an ISO `YYYY-MM-DD` string.
export const DateInput: BackroadComponentRenderer<'date_input'> = (props) => {
- const [value, setValue] = useState(props.value);
+ const [value, setValue] = useSyncedState(props.value);
return (
diff --git a/libs/backroad-components/src/lib/components/multiselect.tsx b/libs/backroad-components/src/lib/components/multiselect.tsx
index 20a6b7a..cd8f150 100644
--- a/libs/backroad-components/src/lib/components/multiselect.tsx
+++ b/libs/backroad-components/src/lib/components/multiselect.tsx
@@ -2,17 +2,21 @@ import ReactSelect from 'react-select';
import { getFlattenedOptions, reactSelectClassNames } from '../helpers/select';
import { setBackroadValue } from '../socket';
import { BackroadComponentRenderer } from '../types/components';
-import { useState } from 'react';
-import { SelectOptionType } from '@backroad/core';
+import { useSyncedState } from '../hooks/use-synced-state';
import { Label } from 'backroad-ui';
export const Multiselect: BackroadComponentRenderer<'multiselect'> = (
props
) => {
const flattenedOptions = getFlattenedOptions(props.args.options);
+ // Controlled (was uncontrolled `defaultValue`): the renderer no longer
+ // remounts this on a value change. Track the selected VALUES — which keep a
+ // stable identity across local re-renders — and derive the option objects
+ // react-select wants from them each render. Syncing on the freshly-filtered
+ // option array instead would make useSyncedState re-fire every render.
+ const [selectedValues, setSelectedValues] = useSyncedState(props.value);
const valueOptions = flattenedOptions.filter((option) =>
- props.value?.includes(option.value)
- ) as Readonly;
- const [value, setValue] = useState(valueOptions);
+ selectedValues?.includes(option.value)
+ );
return (