Avoid reactivity bugs in how we track external state (#3316)

* Avoid reactivity bugs in how we track external state

Many of our hooks which attempt to bridge external state from an EventEmitter or EventTarget into React had subtle bugs which could cause them to fail to react to certain updates. The conditions necessary for triggering these bugs are explained by the tests that I've included.

In the majority of cases, I don't think we were triggering these bugs in practice. They could've become problems if we refactored our components in certain ways. The one concrete case I'm aware of in which we actually triggered such a bug was the race condition with the useRoomEncryptionSystem shared secret logic (addressed by a1110af6d5).

But, particularly with all the weird reactivity issues we're debugging this week, I think we need to eliminate the possibility that any of the bugs in these hooks are the cause of our current headaches.

* Reuse useTypedEventEmitterState in useLocalStorage

* Fix type error
This commit is contained in:
Robin
2025-06-05 07:54:57 -04:00
committed by GitHub
parent a62b0be831
commit b0587fcfb3
12 changed files with 283 additions and 137 deletions

View File

@@ -29,6 +29,7 @@ import { useAudioContext } from "../useAudioContext";
import { ActiveCall } from "./InCallView"; import { ActiveCall } from "./InCallView";
import { import {
flushPromises, flushPromises,
mockEmitter,
mockMatrixRoom, mockMatrixRoom,
mockMatrixRoomMember, mockMatrixRoomMember,
mockRtcMembership, mockRtcMembership,
@@ -129,6 +130,7 @@ function createGroupCallView(
getMxcAvatarUrl: () => null, getMxcAvatarUrl: () => null,
getCanonicalAlias: () => null, getCanonicalAlias: () => null,
currentState: { currentState: {
...mockEmitter(),
getJoinRule: () => JoinRule.Invite, getJoinRule: () => JoinRule.Invite,
} as Partial<RoomState> as RoomState, } as Partial<RoomState> as RoomState,
}); });

View File

@@ -9,11 +9,13 @@ import {
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef } from "react";
import { deepCompare } from "matrix-js-sdk/lib/utils"; import { deepCompare } from "matrix-js-sdk/lib/utils";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
import { useTypedEventEmitterState } from "../useEvents";
/** /**
* Gets the currently active (livekit) focus for a MatrixRTC session * Gets the currently active (livekit) focus for a MatrixRTC session
* This logic is specific to livekit foci where the whole call must use one * This logic is specific to livekit foci where the whole call must use one
@@ -22,38 +24,27 @@ import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
export function useActiveLivekitFocus( export function useActiveLivekitFocus(
rtcSession: MatrixRTCSession, rtcSession: MatrixRTCSession,
): LivekitFocus | undefined { ): LivekitFocus | undefined {
const [activeFocus, setActiveFocus] = useState(() => { const activeFocus = useTypedEventEmitterState(
const f = rtcSession.getActiveFocus(); rtcSession,
// Only handle foci with type="livekit" for now. MatrixRTCSessionEvent.MembershipsChanged,
return !!f && isLivekitFocus(f) ? f : undefined; useCallback(() => {
}); const f = rtcSession.getActiveFocus();
// Only handle foci with type="livekit" for now.
return !!f && isLivekitFocus(f) ? f : undefined;
}, [rtcSession]),
);
const onMembershipsChanged = useCallback(() => { const prevActiveFocus = useRef(activeFocus);
const newActiveFocus = rtcSession.getActiveFocus(); useEffect(() => {
if (!!newActiveFocus && !isLivekitFocus(newActiveFocus)) return; if (!deepCompare(activeFocus, prevActiveFocus.current)) {
if (!deepCompare(activeFocus, newActiveFocus)) {
const oldestMembership = rtcSession.getOldestMembership(); const oldestMembership = rtcSession.getOldestMembership();
logger.warn( logger.warn(
`Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}. `Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}.
Updating focus (focus switch) from ${JSON.stringify(activeFocus)} to ${JSON.stringify(newActiveFocus)}`, Updated focus (focus switch) from ${JSON.stringify(prevActiveFocus.current)} to ${JSON.stringify(activeFocus)}`,
); );
setActiveFocus(newActiveFocus); prevActiveFocus.current = activeFocus;
} }
}, [activeFocus, rtcSession]); }, [activeFocus, rtcSession]);
useEffect(() => {
rtcSession.on(
MatrixRTCSessionEvent.MembershipsChanged,
onMembershipsChanged,
);
return (): void => {
rtcSession.off(
MatrixRTCSessionEvent.MembershipsChanged,
onMembershipsChanged,
);
};
});
return activeFocus; return activeFocus;
} }

View File

@@ -1,18 +1,19 @@
/* /*
Copyright 2023, 2024 New Vector Ltd. Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type Room, RoomEvent } from "matrix-js-sdk"; import { type Room, RoomEvent } from "matrix-js-sdk";
import { useState } from "react"; import { useCallback } from "react";
import { useTypedEventEmitter } from "../useEvents"; import { useTypedEventEmitterState } from "../useEvents";
export function useRoomName(room: Room): string { export function useRoomName(room: Room): string {
const [, setNumUpdates] = useState(0); return useTypedEventEmitterState(
// Whenever the name changes, force an update room,
useTypedEventEmitter(room, RoomEvent.Name, () => setNumUpdates((n) => n + 1)); RoomEvent.Name,
return room.name; useCallback(() => room.name, [room]),
);
} }

View File

@@ -5,10 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { useCallback, useMemo, useState } from "react"; import { useCallback } from "react";
import { type RoomState, RoomStateEvent, type Room } from "matrix-js-sdk"; import {
type RoomState,
RoomStateEvent,
type Room,
RoomEvent,
} from "matrix-js-sdk";
import { useTypedEventEmitter } from "../useEvents"; import { useTypedEventEmitterState } from "../useEvents";
/** /**
* A React hook for values computed from room state. * A React hook for values computed from room state.
@@ -16,14 +21,17 @@ import { useTypedEventEmitter } from "../useEvents";
* @param f A mapping from the current room state to the computed value. * @param f A mapping from the current room state to the computed value.
* @returns The computed value. * @returns The computed value.
*/ */
export const useRoomState = <T>(room: Room, f: (state: RoomState) => T): T => { export function useRoomState<T>(room: Room, f: (state: RoomState) => T): T {
const [numUpdates, setNumUpdates] = useState(0); // TODO: matrix-js-sdk says that Room.currentState is deprecated, but it's not
useTypedEventEmitter( // clear how to reactively track the current state of the room without it
const currentState = useTypedEventEmitterState(
room, room,
RoomStateEvent.Update, RoomEvent.CurrentStateUpdated,
useCallback(() => setNumUpdates((n) => n + 1), [setNumUpdates]), useCallback(() => room.currentState, [room]),
); );
// We want any change to the update counter to trigger an update here return useTypedEventEmitterState(
// eslint-disable-next-line react-hooks/exhaustive-deps currentState,
return useMemo(() => f(room.currentState), [room, f, numUpdates]); RoomStateEvent.Update,
}; useCallback(() => f(currentState), [f, currentState]),
);
}

88
src/useEvents.test.tsx Normal file
View File

@@ -0,0 +1,88 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { test } from "vitest";
import { render, screen } from "@testing-library/react";
import { type FC, useEffect, useState } from "react";
import userEvent from "@testing-library/user-event";
import { TypedEventEmitter } from "matrix-js-sdk";
import { useTypedEventEmitterState } from "./useEvents";
class TestEmitter extends TypedEventEmitter<"change", { change: () => void }> {
private state = 1;
public readonly getState = (): number => this.state;
public readonly getNegativeState = (): number => -this.state;
public readonly setState = (value: number): void => {
this.state = value;
this.emit("change");
};
}
test("useTypedEventEmitterState reacts to events", async () => {
const user = userEvent.setup();
const emitter = new TestEmitter();
const Test: FC = () => {
const value = useTypedEventEmitterState(
emitter,
"change",
emitter.getState,
);
return (
<>
<button onClick={() => emitter.setState(2)}>Change value</button>
<div>Value is {value}</div>
</>
);
};
render(<Test />);
screen.getByText("Value is 1");
await user.click(screen.getByText("Change value"));
screen.getByText("Value is 2");
});
test("useTypedEventEmitterState reacts to changes made by an effect mounted on the same render", () => {
const emitter = new TestEmitter();
const Test: FC = () => {
useEffect(() => emitter.setState(2), []);
const value = useTypedEventEmitterState(
emitter,
"change",
emitter.getState,
);
return `Value is ${value}`;
};
render(<Test />);
screen.getByText("Value is 2");
});
test("useTypedEventEmitterState reacts to changes in getState", async () => {
const user = userEvent.setup();
const emitter = new TestEmitter();
const Test: FC = () => {
const [fn, setFn] = useState(() => emitter.getState);
const value = useTypedEventEmitterState(emitter, "change", fn);
return (
<>
<button onClick={() => setFn(() => emitter.getNegativeState)}>
Change getState
</button>
<div>Value is {value}</div>
</>
);
};
render(<Test />);
screen.getByText("Value is 1");
await user.click(screen.getByText("Change getState"));
screen.getByText("Value is -1");
});

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { useEffect } from "react"; import { useCallback, useEffect, useSyncExternalStore } from "react";
import type { import type {
Listener, Listener,
@@ -13,7 +13,9 @@ import type {
TypedEventEmitter, TypedEventEmitter,
} from "matrix-js-sdk/lib/models/typed-event-emitter"; } from "matrix-js-sdk/lib/models/typed-event-emitter";
// Shortcut for registering a listener on an EventTarget /**
* Shortcut for registering a listener on an EventTarget.
*/
export function useEventTarget<T extends Event>( export function useEventTarget<T extends Event>(
target: EventTarget | null | undefined, target: EventTarget | null | undefined,
eventType: string, eventType: string,
@@ -33,7 +35,9 @@ export function useEventTarget<T extends Event>(
}, [target, eventType, listener, options]); }, [target, eventType, listener, options]);
} }
// Shortcut for registering a listener on a TypedEventEmitter /**
* Shortcut for registering a listener on a TypedEventEmitter.
*/
export function useTypedEventEmitter< export function useTypedEventEmitter<
Events extends string, Events extends string,
Arguments extends ListenerMap<Events>, Arguments extends ListenerMap<Events>,
@@ -50,3 +54,33 @@ export function useTypedEventEmitter<
}; };
}, [emitter, eventType, listener]); }, [emitter, eventType, listener]);
} }
/**
* Reactively tracks a value which is recalculated whenever the provided event
* emitter emits an event. This is useful for bridging state from matrix-js-sdk
* into React.
*/
export function useTypedEventEmitterState<
Events extends string,
Arguments extends ListenerMap<Events>,
T extends Events,
State,
>(
emitter: TypedEventEmitter<Events, Arguments>,
eventType: T,
getState: () => State,
): State {
const subscribe = useCallback(
(onChange: () => void) => {
emitter.on(eventType, onChange as Listener<Events, Arguments, T>);
return (): void => {
emitter.off(eventType, onChange as Listener<Events, Arguments, T>);
};
},
[emitter, eventType],
);
// See the React docs for useSyncExternalStore; given that we're trying to
// bridge state from an external source into React, using this hook is exactly
// what React recommends.
return useSyncExternalStore(subscribe, getState);
}

View File

@@ -0,0 +1,51 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { test } from "vitest";
import { render, screen } from "@testing-library/react";
import { type FC, useEffect, useState } from "react";
import userEvent from "@testing-library/user-event";
import {
setLocalStorageItemReactive,
useLocalStorage,
} from "./useLocalStorage";
test("useLocalStorage reacts to changes made by an effect mounted on the same render", () => {
localStorage.clear();
const Test: FC = () => {
useEffect(() => setLocalStorageItemReactive("my-value", "Hello!"), []);
const [myValue] = useLocalStorage("my-value");
return myValue;
};
render(<Test />);
screen.getByText("Hello!");
});
test("useLocalStorage reacts to key changes", async () => {
localStorage.clear();
localStorage.setItem("value-1", "1");
localStorage.setItem("value-2", "2");
const Test: FC = () => {
const [key, setKey] = useState("value-1");
const [value] = useLocalStorage(key);
if (key !== `value-${value}`) throw new Error("Value is out of sync");
return (
<>
<button onClick={() => setKey("value-2")}>Switch keys</button>
<div>Value is: {value}</div>
</>
);
};
const user = userEvent.setup();
render(<Test />);
screen.getByText("Value is: 1");
await user.click(screen.getByRole("button", { name: "Switch keys" }));
screen.getByText("Value is: 2");
});

View File

@@ -5,50 +5,44 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import EventEmitter from "events"; import { useCallback } from "react";
import { useCallback, useEffect, useState } from "react"; import { TypedEventEmitter } from "matrix-js-sdk";
import { useTypedEventEmitterState } from "./useEvents";
type LocalStorageItem = ReturnType<typeof localStorage.getItem>; type LocalStorageItem = ReturnType<typeof localStorage.getItem>;
// Bus to notify other useLocalStorage consumers when an item is changed // Bus to notify other useLocalStorage consumers when an item is changed
export const localStorageBus = new EventEmitter(); export const localStorageBus = new TypedEventEmitter<
string,
{ [key: string]: () => void }
>();
/** /**
* Like useState, but reads from and persists the value to localStorage * Like useState, but reads from and persists the value to localStorage
* This hook will not update when we write to localStorage.setItem(key, value) directly. * This hook will not update when we write to localStorage.setItem(key, value) directly.
* For the hook to react either use the returned setter or `setLocalStorageItemReactive`. * For the hook to react either use the returned setter or `setLocalStorageItemReactive`.
*/ */
export const useLocalStorage = ( export function useLocalStorage(
key: string, key: string,
): [LocalStorageItem, (value: string) => void] => { ): [LocalStorageItem, (value: string) => void] {
const [value, setValue] = useState<LocalStorageItem>(() => const value = useTypedEventEmitterState(
localStorage.getItem(key), localStorageBus,
key,
useCallback(() => localStorage.getItem(key), [key]),
);
const setValue = useCallback(
(newValue: string) => setLocalStorageItemReactive(key, newValue),
[key],
); );
useEffect(() => { return [value, setValue];
localStorageBus.on(key, setValue); }
return (): void => {
localStorageBus.off(key, setValue);
};
}, [key, setValue]);
return [
value,
useCallback(
(newValue: string) => {
setValue(newValue);
localStorage.setItem(key, newValue);
localStorageBus.emit(key, newValue);
},
[key, setValue],
),
];
};
export const setLocalStorageItemReactive = ( export const setLocalStorageItemReactive = (
key: string, key: string,
value: string, value: string,
): void => { ): void => {
localStorage.setItem(key, value); localStorage.setItem(key, value);
localStorageBus.emit(key, value); localStorageBus.emit(key);
}; };

View File

@@ -10,33 +10,31 @@ import {
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { useEffect, useState } from "react"; import { TypedEventEmitter } from "matrix-js-sdk";
import { useCallback, useEffect } from "react";
import { useTypedEventEmitterState } from "./useEvents";
const dummySession = new TypedEventEmitter();
export function useMatrixRTCSessionJoinState( export function useMatrixRTCSessionJoinState(
rtcSession: MatrixRTCSession | undefined, rtcSession: MatrixRTCSession | undefined,
): boolean { ): boolean {
const [, setNumUpdates] = useState(0); // React doesn't allow you to run a hook conditionally, so we have to plug in
// a dummy event emitter in case there is no rtcSession yet
const isJoined = useTypedEventEmitterState(
rtcSession ?? dummySession,
MatrixRTCSessionEvent.JoinStateChanged,
useCallback(() => rtcSession?.isJoined() ?? false, [rtcSession]),
);
useEffect(() => { useEffect(() => {
if (rtcSession !== undefined) { logger.info(
const onJoinStateChanged = (isJoined: boolean): void => { `Session in room ${rtcSession?.room.roomId} changed to ${
logger.info( isJoined ? "joined" : "left"
`Session in room ${rtcSession.room.roomId} changed to ${ }`,
isJoined ? "joined" : "left" );
}`, }, [rtcSession, isJoined]);
);
setNumUpdates((n) => n + 1); // Force an update
};
rtcSession.on(MatrixRTCSessionEvent.JoinStateChanged, onJoinStateChanged);
return (): void => { return isJoined;
rtcSession.off(
MatrixRTCSessionEvent.JoinStateChanged,
onJoinStateChanged,
);
};
}
}, [rtcSession]);
return rtcSession?.isJoined() ?? false;
} }

View File

@@ -5,39 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { logger } from "matrix-js-sdk/lib/logger";
import { import {
type CallMembership, type CallMembership,
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { useCallback, useEffect, useState } from "react"; import { useCallback } from "react";
import { useTypedEventEmitterState } from "./useEvents";
export function useMatrixRTCSessionMemberships( export function useMatrixRTCSessionMemberships(
rtcSession: MatrixRTCSession, rtcSession: MatrixRTCSession,
): CallMembership[] { ): CallMembership[] {
const [memberships, setMemberships] = useState(rtcSession.memberships); return useTypedEventEmitterState(
rtcSession,
const onMembershipsChanged = useCallback(() => { MatrixRTCSessionEvent.MembershipsChanged,
logger.info( useCallback(() => rtcSession.memberships, [rtcSession]),
`Memberships changed for call in room ${rtcSession.room.roomId} (${rtcSession.memberships.length} members)`, );
);
setMemberships(rtcSession.memberships);
}, [rtcSession]);
useEffect(() => {
rtcSession.on(
MatrixRTCSessionEvent.MembershipsChanged,
onMembershipsChanged,
);
return (): void => {
rtcSession.off(
MatrixRTCSessionEvent.MembershipsChanged,
onMembershipsChanged,
);
};
}, [rtcSession, onMembershipsChanged]);
return memberships;
} }

View File

@@ -1,13 +1,11 @@
/* /*
Copyright 2023, 2024 New Vector Ltd. Copyright 2023-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useEventTarget } from "./useEvents";
/** /**
* React hook that tracks whether the given media query matches. * React hook that tracks whether the given media query matches.
@@ -15,14 +13,13 @@ import { useEventTarget } from "./useEvents";
export function useMediaQuery(query: string): boolean { export function useMediaQuery(query: string): boolean {
const mediaQuery = useMemo(() => window.matchMedia(query), [query]); const mediaQuery = useMemo(() => window.matchMedia(query), [query]);
const [numChanges, setNumChanges] = useState(0); const subscribe = useCallback(
useEventTarget( (onChange: () => void) => {
mediaQuery, mediaQuery.addEventListener("change", onChange);
"change", return (): void => mediaQuery.removeEventListener("change", onChange);
useCallback(() => setNumChanges((n) => n + 1), [setNumChanges]), },
[mediaQuery],
); );
const getState = useCallback(() => mediaQuery.matches, [mediaQuery]);
// We want any change to the update counter to trigger an update here return useSyncExternalStore(subscribe, getState);
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => mediaQuery.matches, [mediaQuery, numChanges]);
} }

View File

@@ -108,7 +108,7 @@ interface EmitterMock<T> {
removeListener: () => T; removeListener: () => T;
} }
function mockEmitter<T>(): EmitterMock<T> { export function mockEmitter<T>(): EmitterMock<T> {
return { return {
on(): T { on(): T {
return this as T; return this as T;