Compare commits

...

2 Commits

Author SHA1 Message Date
Robin
ccf168cadd Make doubly sure that useLocalStorage reacts to key changes
I think my previous commit technically made sure that the value would converge to the right thing eventually, but it could become temporarily out of sync with the key passed to the hook, at least. The test demonstrates how.

We haven't yet triggered this failure mode in practice, I think; this is more of a theoretical correctness thing.
2025-05-28 12:47:56 -04:00
Robin
6258bcec54 Fix a possible race in useLocalStorage
This race could cause useLocalStorage to entirely fail to react to an update to the item in question.

It seems we are hitting this race condition in practice with the useRoomEncryptionSystem shared secret logic. a1110af6d5, I think, causes us to depend on the existence of this race. But really we ought to fix the race and also fix useRoomEncryptionSystem to not depend on it (PR from Timo forthcoming).
2025-05-28 12:46:10 -04:00
2 changed files with 65 additions and 4 deletions

View File

@@ -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!"), []);
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

@@ -6,7 +6,10 @@ Please see LICENSE in the repository root for full details.
*/
import EventEmitter from "events";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect } from "react";
import { useLatest } from "./useLatest";
import { useReactiveState } from "./useReactiveState";
type LocalStorageItem = ReturnType<typeof localStorage.getItem>;
@@ -17,16 +20,26 @@ export const localStorageBus = new EventEmitter();
export const useLocalStorage = (
key: string,
): [LocalStorageItem, (value: string) => void] => {
const [value, setValue] = useState<LocalStorageItem>(() =>
localStorage.getItem(key),
const [value, setValue] = useReactiveState<LocalStorageItem>(
() => localStorage.getItem(key),
[key],
);
const latestValue = useLatest(value);
useEffect(() => {
// We're about to set up the bus listener that will enable us to react to
// any future updates to the localStorage item. However, it's possible that
// we already missed an update if there was an effect which modified the
// item in the time *between* the render phase of useLocalStorage and the
// execution of this effect. Let's update the state if that happened.
const stored = localStorage.getItem(key);
if (latestValue.current !== stored) setValue(stored);
localStorageBus.on(key, setValue);
return (): void => {
localStorageBus.off(key, setValue);
};
}, [key, setValue]);
}, [key, latestValue, setValue]);
return [
value,