Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions e2e/focus-retention.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 2 additions & 2 deletions libs/backroad-components/src/lib/components/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-3">
<UICheckbox
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { HexColorPicker } from 'react-colorful';
import { useSyncedState } from '../hooks/use-synced-state';
import { setBackroadValue } from '../socket';
import { BackroadComponentRenderer } from '../types/components';

Expand Down Expand Up @@ -45,7 +46,7 @@ export const ColorPicker: BackroadComponentRenderer<'color_picker'> = (
props
) => {
const popover = useRef<HTMLDivElement>(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), []);
Expand Down
4 changes: 2 additions & 2 deletions libs/backroad-components/src/lib/components/date_input.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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';

// 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 (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor={props.id}>{props.args.label}</Label>
Expand Down
24 changes: 13 additions & 11 deletions libs/backroad-components/src/lib/components/multiselect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SelectOptionType[]>;
const [value, setValue] = useState(valueOptions);
selectedValues?.includes(option.value)
);
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor={props.id}>{props.args.label || props.id}</Label>
Expand All @@ -21,14 +25,12 @@ export const Multiselect: BackroadComponentRenderer<'multiselect'> = (
inputId={props.id}
unstyled
classNames={reactSelectClassNames}
defaultValue={value}
value={valueOptions}
isMulti
onChange={(newValue) => {
setValue(newValue);
setBackroadValue({
id: props.id,
value: newValue?.map((option) => option.value),
});
const values = newValue.map((option) => option.value);
setSelectedValues(values);
setBackroadValue({ id: props.id, value: values });
}}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { BackroadComponent } from '@backroad/core';
import { useState } from 'react';
import { useSyncedState } from '../hooks/use-synced-state';
import { setBackroadValue } from '../socket';
import { handleKeyUpBlur } from '../helpers/handleKeyUp';
import { Minus, Plus } from 'lucide-react';
import { Input, Label, Button } from 'backroad-ui';

export const NumberInput = (props: BackroadComponent<'number_input', true>) => {
const [inputValue, setInputValue] = useState(props.value);
const [inputValue, setInputValue] = useSyncedState(props.value);
const stepValue = props.args.step || 1;
const precisionValue = props.args.precision || 0;

Expand Down
4 changes: 2 additions & 2 deletions libs/backroad-components/src/lib/components/radio.tsx
Original file line number Diff line number Diff line change
@@ -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 { RadioGroup, RadioGroupItem, Label } from 'backroad-ui';

export const Radio: BackroadComponentRenderer<'radio'> = (props) => {
const [value, setValue] = useState(props.value);
const [value, setValue] = useSyncedState(props.value);
return (
<div className="flex w-full max-w-xs flex-col gap-3">
<span className="backroad-label">{props.args.label}</span>
Expand Down
16 changes: 13 additions & 3 deletions libs/backroad-components/src/lib/components/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,20 @@ import ReactSelect from 'react-select';
import { getFlattenedOptions, reactSelectClassNames } from '../helpers/select';
import { setBackroadValue } from '../socket';
import { BackroadComponentRenderer } from '../types/components';
import { useSyncedState } from '../hooks/use-synced-state';
import { Label } from 'backroad-ui';

export const Select: BackroadComponentRenderer<'select'> = (props) => {
// Controlled (was uncontrolled `defaultValue`): the renderer no longer
// remounts this on a value change, so the selection has to be driven by
// state. Seed + resync from the server value via useSyncedState, and update
// optimistically on change so the menu reflects the pick before the rerun
// round-trips.
const [value, setValue] = useSyncedState(
getFlattenedOptions(props.args.options).find(
(option) => option.value === props.value
) ?? null
);
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor={props.id}>{props.args.label || props.id}</Label>
Expand All @@ -13,10 +24,9 @@ export const Select: BackroadComponentRenderer<'select'> = (props) => {
inputId={props.id}
unstyled
classNames={reactSelectClassNames}
defaultValue={getFlattenedOptions(props.args.options).find(
(option) => option.value === props.value
)}
value={value}
onChange={(newValue) => {
setValue(newValue ?? null);
setBackroadValue({ id: props.id, value: newValue?.value });
}}
/>
Expand Down
4 changes: 2 additions & 2 deletions libs/backroad-components/src/lib/components/slider.tsx
Original file line number Diff line number Diff line change
@@ -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 { Label, Slider as SliderPrimitive } from 'backroad-ui';
Expand All @@ -10,7 +10,7 @@ import { Label, Slider as SliderPrimitive } from 'backroad-ui';
// the thumb for sighted users.
export const Slider: BackroadComponentRenderer<'slider'> = (props) => {
const { label, min = 0, max = 100, step = 1 } = props.args;
const [value, setValue] = useState(props.value);
const [value, setValue] = useSyncedState(props.value);

return (
<div className="flex w-full max-w-xs flex-col gap-2">
Expand Down
4 changes: 2 additions & 2 deletions libs/backroad-components/src/lib/components/text_area.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { useState } from 'react';
import { useSyncedState } from '../hooks/use-synced-state';
import { BackroadComponentRenderer } from '../types/components';
import { setBackroadValue } from '../socket';
import { Label, Textarea } from 'backroad-ui';

// Unlike text_input, Enter must insert a newline rather than commit, so there's
// no handleKeyUpBlur here — the value commits on blur only.
export const TextArea: BackroadComponentRenderer<'text_area'> = (props) => {
const [value, setValue] = useState(props.value);
const [value, setValue] = useSyncedState(props.value);
return (
<div className="flex w-full max-w-md flex-col gap-2">
<Label htmlFor={props.id}>{props.args.label}</Label>
Expand Down
4 changes: 2 additions & 2 deletions libs/backroad-components/src/lib/components/text_input.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useState } from 'react';
import { useSyncedState } from '../hooks/use-synced-state';
import { BackroadComponentRenderer } from '../types/components';
import { handleKeyUpBlur } from '../helpers/handleKeyUp';
import { setBackroadValue } from '../socket';
import { Input, Label } from 'backroad-ui';

export const TextInput: BackroadComponentRenderer<'text_input'> = (props) => {
const [value, setValue] = useState(props.value);
const [value, setValue] = useSyncedState(props.value);
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor={props.id}>{props.args.label}</Label>
Expand Down
4 changes: 2 additions & 2 deletions libs/backroad-components/src/lib/components/time_input.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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';

// Native time picker. Value is a 24-hour `HH:mm` string. `step` (seconds)
// switches the picker to second-level granularity when provided.
export const TimeInput: BackroadComponentRenderer<'time_input'> = (props) => {
const [value, setValue] = useState(props.value);
const [value, setValue] = useSyncedState(props.value);
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor={props.id}>{props.args.label}</Label>
Expand Down
4 changes: 2 additions & 2 deletions libs/backroad-components/src/lib/components/toggle.tsx
Original file line number Diff line number Diff line change
@@ -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 { Switch, Label } from 'backroad-ui';

export const Toggle: BackroadComponentRenderer<'toggle'> = (props) => {
const [value, setValue] = useState(props.value);
const [value, setValue] = useSyncedState(props.value);
return (
<div className="flex items-center gap-3">
<Switch
Expand Down
35 changes: 35 additions & 0 deletions libs/backroad-components/src/lib/hooks/use-synced-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useRef, useState, type Dispatch, type SetStateAction } from 'react';

/**
* Local component state seeded from a server-pushed value, kept in sync WITHOUT
* remounting.
*
* Backround: every value-returning widget keeps a local copy of its value so
* typing/dragging feels instant (the authoritative value only commits to the
* server on blur/commit). To make a server-driven change — `br.setValue(id,…)`,
* or the new value echoed back after a commit — actually show up, the renderer
* used to key each component by its value, forcing a full unmount+remount on any
* change. That remount is what dropped focus, the text caret, and selection
* mid-interaction (see TreeRender).
*
* Instead we adopt a new server value *during render* only when it actually
* differs from the last one we saw (React's documented "adjusting state when a
* prop changes" pattern), so the DOM node — and its focus — survives. While the
* server value holds steady, in-progress local edits are left untouched, because
* the guard never fires.
*/
export function useSyncedState<T>(
serverValue: T
): [T, Dispatch<SetStateAction<T>>] {
const [value, setValue] = useState<T>(serverValue);
const lastServerValue = useRef<T>(serverValue);
// `Object.is` guard: `serverValue` only gets a new identity when a render
// patch replaces this node (i.e. a genuine server-side change), not on the
// re-renders triggered by local `setValue`, so this neither loops nor clobbers
// local edits.
if (!Object.is(serverValue, lastServerValue.current)) {
lastServerValue.current = serverValue;
setValue(serverValue);
}
return [value, setValue];
}
9 changes: 8 additions & 1 deletion libs/backroad-components/src/lib/tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ export const TreeRender = (props: { tree: BackroadNode }) => {
if (isBackroadComponent(props.tree, true)) {
const ComponentRenderer = backroadClientComponents[props.tree.type];
// Suspense boundary for lazy renderers (e.g. `table`); inert for the rest.
// Key by the component's stable `id`, NOT its value: the parent already
// keys this whole subtree by `path`, so keying on value here only served to
// force a remount whenever the value changed — which dropped focus, the text
// caret, and selection mid-edit on every rerun. Value-driven UI updates are
// handled inside the components via `useSyncedState` instead, so the DOM
// node now survives a value change and only remounts when the component's
// identity actually changes.
return (
<Suspense fallback={null}>
{/* @ts-expect-error there are sufficient checks to ensure this is correct */}
<ComponentRenderer {...props.tree} key={props.tree.value} />
<ComponentRenderer {...props.tree} key={props.tree.id} />
</Suspense>
);
} else {
Expand Down
Loading