Skip to content

Commit 0728bb0

Browse files
committed
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 a1110af). 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.
1 parent 2f3e0b4 commit 0728bb0

12 files changed

+278
-133
lines changed

src/room/GroupCallView.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { useAudioContext } from "../useAudioContext";
2929
import { ActiveCall } from "./InCallView";
3030
import {
3131
flushPromises,
32+
mockEmitter,
3233
mockMatrixRoom,
3334
mockMatrixRoomMember,
3435
mockRtcMembership,
@@ -129,6 +130,7 @@ function createGroupCallView(
129130
getMxcAvatarUrl: () => null,
130131
getCanonicalAlias: () => null,
131132
currentState: {
133+
...mockEmitter(),
132134
getJoinRule: () => JoinRule.Invite,
133135
} as Partial<RoomState> as RoomState,
134136
});

src/room/useActiveFocus.ts

Lines changed: 17 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import {
99
type MatrixRTCSession,
1010
MatrixRTCSessionEvent,
1111
} from "matrix-js-sdk/lib/matrixrtc";
12-
import { useCallback, useEffect, useState } from "react";
12+
import { useCallback, useEffect, useRef } from "react";
1313
import { deepCompare } from "matrix-js-sdk/lib/utils";
1414
import { logger } from "matrix-js-sdk/lib/logger";
1515
import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
1616

17+
import { useTypedEventEmitterState } from "../useEvents";
18+
1719
/**
1820
* Gets the currently active (livekit) focus for a MatrixRTC session
1921
* 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";
2224
export function useActiveLivekitFocus(
2325
rtcSession: MatrixRTCSession,
2426
): LivekitFocus | undefined {
25-
const [activeFocus, setActiveFocus] = useState(() => {
26-
const f = rtcSession.getActiveFocus();
27-
// Only handle foci with type="livekit" for now.
28-
return !!f && isLivekitFocus(f) ? f : undefined;
29-
});
27+
const activeFocus = useTypedEventEmitterState(
28+
rtcSession,
29+
MatrixRTCSessionEvent.MembershipsChanged,
30+
useCallback(() => {
31+
const f = rtcSession.getActiveFocus();
32+
// Only handle foci with type="livekit" for now.
33+
return !!f && isLivekitFocus(f) ? f : undefined;
34+
}, [rtcSession]),
35+
);
3036

31-
const onMembershipsChanged = useCallback(() => {
32-
const newActiveFocus = rtcSession.getActiveFocus();
33-
if (!!newActiveFocus && !isLivekitFocus(newActiveFocus)) return;
34-
if (!deepCompare(activeFocus, newActiveFocus)) {
37+
const prevActiveFocus = useRef(activeFocus);
38+
useEffect(() => {
39+
if (!deepCompare(activeFocus, prevActiveFocus.current)) {
3540
const oldestMembership = rtcSession.getOldestMembership();
3641
logger.warn(
3742
`Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}.
38-
Updating focus (focus switch) from ${JSON.stringify(activeFocus)} to ${JSON.stringify(newActiveFocus)}`,
43+
Updated focus (focus switch) from ${JSON.stringify(prevActiveFocus.current)} to ${JSON.stringify(activeFocus)}`,
3944
);
40-
setActiveFocus(newActiveFocus);
45+
prevActiveFocus.current = activeFocus;
4146
}
4247
}, [activeFocus, rtcSession]);
4348

44-
useEffect(() => {
45-
rtcSession.on(
46-
MatrixRTCSessionEvent.MembershipsChanged,
47-
onMembershipsChanged,
48-
);
49-
50-
return (): void => {
51-
rtcSession.off(
52-
MatrixRTCSessionEvent.MembershipsChanged,
53-
onMembershipsChanged,
54-
);
55-
};
56-
});
57-
5849
return activeFocus;
5950
}

src/room/useRoomName.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
/*
2-
Copyright 2023, 2024 New Vector Ltd.
2+
Copyright 2025 New Vector Ltd.
33
44
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
55
Please see LICENSE in the repository root for full details.
66
*/
77

88
import { type Room, RoomEvent } from "matrix-js-sdk";
9-
import { useState } from "react";
9+
import { useCallback } from "react";
1010

11-
import { useTypedEventEmitter } from "../useEvents";
11+
import { useTypedEventEmitterState } from "../useEvents";
1212

1313
export function useRoomName(room: Room): string {
14-
const [, setNumUpdates] = useState(0);
15-
// Whenever the name changes, force an update
16-
useTypedEventEmitter(room, RoomEvent.Name, () => setNumUpdates((n) => n + 1));
17-
return room.name;
14+
return useTypedEventEmitterState(
15+
room,
16+
RoomEvent.Name,
17+
useCallback(() => room.name, [room]),
18+
);
1819
}

src/room/useRoomState.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,33 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
55
Please see LICENSE in the repository root for full details.
66
*/
77

8-
import { useCallback, useMemo, useState } from "react";
9-
import { type RoomState, RoomStateEvent, type Room } from "matrix-js-sdk";
8+
import { useCallback } from "react";
9+
import {
10+
type RoomState,
11+
RoomStateEvent,
12+
type Room,
13+
RoomEvent,
14+
} from "matrix-js-sdk";
1015

11-
import { useTypedEventEmitter } from "../useEvents";
16+
import { useTypedEventEmitterState } from "../useEvents";
1217

1318
/**
1419
* A React hook for values computed from room state.
1520
* @param room The room.
1621
* @param f A mapping from the current room state to the computed value.
1722
* @returns The computed value.
1823
*/
19-
export const useRoomState = <T>(room: Room, f: (state: RoomState) => T): T => {
20-
const [numUpdates, setNumUpdates] = useState(0);
21-
useTypedEventEmitter(
24+
export function useRoomState<T>(room: Room, f: (state: RoomState) => T): T {
25+
// TODO: matrix-js-sdk says that Room.currentState is deprecated, but it's not
26+
// clear how to reactively track the current state of the room without it
27+
const currentState = useTypedEventEmitterState(
2228
room,
29+
RoomEvent.CurrentStateUpdated,
30+
useCallback(() => room.currentState, [room]),
31+
);
32+
return useTypedEventEmitterState(
33+
currentState,
2334
RoomStateEvent.Update,
24-
useCallback(() => setNumUpdates((n) => n + 1), [setNumUpdates]),
35+
useCallback(() => f(currentState), [f, currentState]),
2536
);
26-
// We want any change to the update counter to trigger an update here
27-
// eslint-disable-next-line react-hooks/exhaustive-deps
28-
return useMemo(() => f(room.currentState), [room, f, numUpdates]);
29-
};
37+
}

src/useEvents.test.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
import { test } from "vitest";
9+
import { render, screen } from "@testing-library/react";
10+
import { type FC, useEffect, useState } from "react";
11+
import userEvent from "@testing-library/user-event";
12+
import { TypedEventEmitter } from "matrix-js-sdk";
13+
14+
import { useTypedEventEmitterState } from "./useEvents";
15+
16+
class TestEmitter extends TypedEventEmitter<"change", { change: () => void }> {
17+
private state = 1;
18+
public readonly getState = (): number => this.state;
19+
public readonly getNegativeState = (): number => -this.state;
20+
public readonly setState = (value: number): void => {
21+
this.state = value;
22+
this.emit("change");
23+
};
24+
}
25+
26+
test("useTypedEventEmitterState reacts to events", async () => {
27+
const user = userEvent.setup();
28+
const emitter = new TestEmitter();
29+
30+
const Test: FC = () => {
31+
const value = useTypedEventEmitterState(
32+
emitter,
33+
"change",
34+
emitter.getState,
35+
);
36+
return (
37+
<>
38+
<button onClick={() => emitter.setState(2)}>Change value</button>
39+
<div>Value is {value}</div>
40+
</>
41+
);
42+
};
43+
44+
render(<Test />);
45+
screen.getByText("Value is 1");
46+
await user.click(screen.getByText("Change value"));
47+
screen.getByText("Value is 2");
48+
});
49+
50+
test("useTypedEventEmitterState reacts to changes made by an effect mounted on the same render", () => {
51+
const emitter = new TestEmitter();
52+
53+
const Test: FC = () => {
54+
useEffect(() => emitter.setState(2), []);
55+
const value = useTypedEventEmitterState(
56+
emitter,
57+
"change",
58+
emitter.getState,
59+
);
60+
return `Value is ${value}`;
61+
};
62+
63+
render(<Test />);
64+
screen.getByText("Value is 2");
65+
});
66+
67+
test("useTypedEventEmitterState reacts to changes in getState", async () => {
68+
const user = userEvent.setup();
69+
const emitter = new TestEmitter();
70+
71+
const Test: FC = () => {
72+
const [fn, setFn] = useState(() => emitter.getState);
73+
const value = useTypedEventEmitterState(emitter, "change", fn);
74+
return (
75+
<>
76+
<button onClick={() => setFn(() => emitter.getNegativeState)}>
77+
Change getState
78+
</button>
79+
<div>Value is {value}</div>
80+
</>
81+
);
82+
};
83+
84+
render(<Test />);
85+
screen.getByText("Value is 1");
86+
await user.click(screen.getByText("Change getState"));
87+
screen.getByText("Value is -1");
88+
});

src/useEvents.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
55
Please see LICENSE in the repository root for full details.
66
*/
77

8-
import { useEffect } from "react";
8+
import { useCallback, useEffect, useSyncExternalStore } from "react";
99

1010
import type {
1111
Listener,
1212
ListenerMap,
1313
TypedEventEmitter,
1414
} from "matrix-js-sdk/lib/models/typed-event-emitter";
1515

16-
// Shortcut for registering a listener on an EventTarget
16+
/**
17+
* Shortcut for registering a listener on an EventTarget.
18+
*/
1719
export function useEventTarget<T extends Event>(
1820
target: EventTarget | null | undefined,
1921
eventType: string,
@@ -33,7 +35,9 @@ export function useEventTarget<T extends Event>(
3335
}, [target, eventType, listener, options]);
3436
}
3537

36-
// Shortcut for registering a listener on a TypedEventEmitter
38+
/**
39+
* Shortcut for registering a listener on a TypedEventEmitter.
40+
*/
3741
export function useTypedEventEmitter<
3842
Events extends string,
3943
Arguments extends ListenerMap<Events>,
@@ -50,3 +54,33 @@ export function useTypedEventEmitter<
5054
};
5155
}, [emitter, eventType, listener]);
5256
}
57+
58+
/**
59+
* Reactively tracks a value which is recalculated whenever the provided event
60+
* emitter emits an event. This is useful for bridging state from matrix-js-sdk
61+
* into React.
62+
*/
63+
export function useTypedEventEmitterState<
64+
Events extends string,
65+
Arguments extends ListenerMap<Events>,
66+
T extends Events,
67+
State,
68+
>(
69+
emitter: TypedEventEmitter<Events, Arguments>,
70+
eventType: T,
71+
getState: () => State,
72+
): State {
73+
const subscribe = useCallback(
74+
(onChange: () => void) => {
75+
emitter.on(eventType, onChange as Listener<Events, Arguments, T>);
76+
return (): void => {
77+
emitter.off(eventType, onChange as Listener<Events, Arguments, T>);
78+
};
79+
},
80+
[emitter, eventType],
81+
);
82+
// See the React docs for useSyncExternalStore; given that we're trying to
83+
// bridge state from an external source into React, using this hook is exactly
84+
// what React recommends.
85+
return useSyncExternalStore(subscribe, getState);
86+
}

src/useLocalStorage.test.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
import { test } from "vitest";
9+
import { render, screen } from "@testing-library/react";
10+
import { type FC, useEffect, useState } from "react";
11+
import userEvent from "@testing-library/user-event";
12+
13+
import { setLocalStorageItem, useLocalStorage } from "./useLocalStorage";
14+
15+
test("useLocalStorage reacts to changes made by an effect mounted on the same render", () => {
16+
localStorage.clear();
17+
const Test: FC = () => {
18+
useEffect(() => setLocalStorageItem("my-value", "Hello!"), []);
19+
const [myValue] = useLocalStorage("my-value");
20+
return myValue;
21+
};
22+
render(<Test />);
23+
screen.getByText("Hello!");
24+
});
25+
26+
test("useLocalStorage reacts to key changes", async () => {
27+
localStorage.clear();
28+
localStorage.setItem("value-1", "1");
29+
localStorage.setItem("value-2", "2");
30+
31+
const Test: FC = () => {
32+
const [key, setKey] = useState("value-1");
33+
const [value] = useLocalStorage(key);
34+
if (key !== `value-${value}`) throw new Error("Value is out of sync");
35+
return (
36+
<>
37+
<button onClick={() => setKey("value-2")}>Switch keys</button>
38+
<div>Value is: {value}</div>
39+
</>
40+
);
41+
};
42+
const user = userEvent.setup();
43+
render(<Test />);
44+
45+
screen.getByText("Value is: 1");
46+
await user.click(screen.getByRole("button", { name: "Switch keys" }));
47+
screen.getByText("Value is: 2");
48+
});

0 commit comments

Comments
 (0)