Drop observable-hooks in favor of useBehavior

With this change, views now read every reactive value through `useBehavior`, as
`architecture.md` already prescribes. Where a view reads a plain stream, the view
model now exposes a `Behavior`.
This commit is contained in:
Johannes Marbach
2026-09-28 17:54:50 +02:00
parent addc1b10a0
commit 3f63a6fc3c
22 changed files with 459 additions and 113 deletions
+82
View File
@@ -0,0 +1,82 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Form } from "@vector-im/compound-web";
import { describe, expect, it, vi } from "vitest";
import { DeviceSelection } from "./DeviceSelection";
import { constant } from "../state/Behavior";
import {
type AudioOutputDeviceLabel,
type DeviceLabel,
type MediaDevice,
type SelectedDevice,
} from "../state/MediaDevices";
type Device = MediaDevice<DeviceLabel | AudioOutputDeviceLabel, SelectedDevice>;
function mockDevice(
available: [string, DeviceLabel][],
selectedId: string | undefined,
): Device & { select: ReturnType<typeof vi.fn> } {
return {
available$: constant(new Map(available)),
selected$: constant(
selectedId === undefined ? undefined : { id: selectedId },
),
select: vi.fn(),
} as unknown as Device & { select: ReturnType<typeof vi.fn> };
}
const twoDevices: [string, DeviceLabel][] = [
["a", { type: "name", name: "Mic A" }],
["b", { type: "number", number: 2 }],
];
describe("DeviceSelection", () => {
it("marks the selected device and selects another on click", async () => {
const device = mockDevice(twoDevices, "a");
render(
<Form.Root>
<DeviceSelection
device={device}
title="Microphone"
numberedLabel={(n) => `Microphone ${n}`}
/>
</Form.Root>,
);
const first = screen.getByRole<HTMLInputElement>("radio", {
name: "Mic A",
});
const second = screen.getByRole<HTMLInputElement>("radio", {
name: "Microphone 2",
});
expect(first.checked).toBe(true);
expect(second.checked).toBe(false);
await userEvent.click(second);
expect(device.select).toHaveBeenCalledWith("b");
});
it("shows nothing when there is no choice to make", () => {
const device = mockDevice([twoDevices[0]], "a");
render(
<Form.Root>
<DeviceSelection
device={device}
title="Microphone"
numberedLabel={(n) => `Microphone ${n}`}
/>
</Form.Root>,
);
expect(screen.queryByRole("radio")).toBeNull();
expect(screen.queryByText("Microphone")).toBeNull();
});
});
+3 -3
View File
@@ -21,7 +21,6 @@ import {
Separator,
} from "@vector-im/compound-web";
import { Trans, useTranslation } from "react-i18next";
import { useObservableEagerState } from "observable-hooks";
import {
type AudioOutputDeviceLabel,
@@ -30,6 +29,7 @@ import {
type MediaDevice,
} from "../state/MediaDevices";
import styles from "./DeviceSelection.module.css";
import { useBehavior } from "../useBehavior";
interface Props {
device: MediaDevice<DeviceLabel | AudioOutputDeviceLabel, SelectedDevice>;
@@ -44,8 +44,8 @@ export const DeviceSelection: FC<Props> = ({
}) => {
const { t } = useTranslation();
const groupId = useId();
const available = useObservableEagerState(device.available$);
const selectedId = useObservableEagerState(device.selected$)?.id;
const available = useBehavior(device.available$);
const selectedId = useBehavior(device.selected$)?.id;
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
device.select(e.target.value);