-
Notifications
You must be signed in to change notification settings - Fork 120
Avoid reactivity bugs in how we track external state #3316
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
Please see LICENSE in the repository root for full details. | ||
*/ | ||
|
||
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 { | ||
const [, setNumUpdates] = useState(0); | ||
// Whenever the name changes, force an update | ||
useTypedEventEmitter(room, RoomEvent.Name, () => setNumUpdates((n) => n + 1)); | ||
return room.name; | ||
return useTypedEventEmitterState( | ||
room, | ||
RoomEvent.Name, | ||
useCallback(() => room.name, [room]), | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,25 +5,33 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial | |
Please see LICENSE in the repository root for full details. | ||
*/ | ||
|
||
import { useCallback, useMemo, useState } from "react"; | ||
import { type RoomState, RoomStateEvent, type Room } from "matrix-js-sdk"; | ||
import { useCallback } from "react"; | ||
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. | ||
* @param room The room. | ||
* @param f A mapping from the current room state to the computed value. | ||
* @returns The computed value. | ||
*/ | ||
export const useRoomState = <T>(room: Room, f: (state: RoomState) => T): T => { | ||
const [numUpdates, setNumUpdates] = useState(0); | ||
useTypedEventEmitter( | ||
export function useRoomState<T>(room: Room, f: (state: RoomState) => T): T { | ||
// TODO: matrix-js-sdk says that Room.currentState is deprecated, but it's not | ||
// clear how to reactively track the current state of the room without it | ||
const currentState = useTypedEventEmitterState( | ||
room, | ||
RoomEvent.CurrentStateUpdated, | ||
useCallback(() => room.currentState, [room]), | ||
); | ||
return useTypedEventEmitterState( | ||
currentState, | ||
RoomStateEvent.Update, | ||
useCallback(() => setNumUpdates((n) => n + 1), [setNumUpdates]), | ||
useCallback(() => f(currentState), [f, currentState]), | ||
); | ||
Comment on lines
+27
to
36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why id is it not enough, to listen to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, it's only called when the |
||
// We want any change to the update counter to trigger an update here | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
return useMemo(() => f(room.currentState), [room, f, numUpdates]); | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
/* | ||
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 { setLocalStorageItem, useLocalStorage } from "./useLocalStorage"; | ||
|
||
test("useLocalStorage reacts to changes made by an effect mounted on the same render", () => { | ||
localStorage.clear(); | ||
const Test: FC = () => { | ||
useEffect(() => setLocalStorageItem("my-value", "Hello!"), []); | ||
Check failure on line 18 in src/useLocalStorage.test.tsx
|
||
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"); | ||
}); |
Uh oh!
There was an error while loading. Please reload this page.