diff --git a/src/ClientContext.test.tsx b/src/ClientContext.test.tsx new file mode 100644 index 000000000..909635d89 --- /dev/null +++ b/src/ClientContext.test.tsx @@ -0,0 +1,69 @@ +/* +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 { expect, test, vi } from "vitest"; +import { render } from "@testing-library/react"; +import { BrowserRouter } from "react-router-dom"; +import { type MatrixClient } from "matrix-js-sdk"; +import { type FC } from "react"; + +import { ClientProvider, useClientState } from "./ClientContext"; + +const mockClient = (): MatrixClient => + ({ + on: vi.fn(), + removeListener: vi.fn(), + getUserId: () => "@alice:example.org", + getDeviceId: () => "AAAA", + stopClient: vi.fn(), + }) as Partial as MatrixClient; + +/** Reports what the context says, so a test can assert on it. */ +const ShowClientState: FC = () => { + const state = useClientState(); + if (state === undefined) return loading; + if (state.state === "error") return error; + return ( + {state.authenticated?.client.getUserId() ?? "unauthenticated"} + ); +}; + +test("uses a client supplied by the host without waiting", () => { + const client = mockClient(); + + const { container } = render( + + + + + , + ); + + // Available on the very first render: a supplied client needs no session + // restoring, so there is no loading state to pass through. + expect(container.textContent).toBe("@alice:example.org"); +}); + +test("does not claim exclusive use of storage when given a client", () => { + // The channel is created when the module loads, so spy on the prototype + // rather than trying to replace the global. + const postMessage = vi.spyOn(BroadcastChannel.prototype, "postMessage"); + + render( + + + + + , + ); + + // The broadcast shuts down other instances to protect Element Call's own + // stores. A host's client brings its own, so there is nothing to protect. + expect(postMessage).not.toHaveBeenCalled(); + + postMessage.mockRestore(); +}); diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index a0d65e64f..6ad017f15 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -134,19 +134,36 @@ const loadChannel = interface Props { children: JSX.Element; + /** + * The client Element Call should use. + * + * When a host embeds Element Call it already has a client, and owns the + * user's session; supplying it here means Element Call neither authenticates + * anyone nor manages their session. Left out, Element Call finds a client + * itself — from the widget API, or by restoring or creating a session of its + * own. + */ + client?: MatrixClient; } -export const ClientProvider: FC = ({ children }) => { +export const ClientProvider: FC = ({ children, client }) => { const navigate = useNavigate(); const hostBridge = useHostBridge(); // null = signed out, undefined = loading const [initClientState, setInitClientState] = useState< InitResult | null | undefined - >(undefined); + >( + client === undefined + ? undefined + : // A supplied client belongs to the host, so there is no session of ours + // to restore and nothing to wait for. + { client, passwordlessUser: false }, + ); const initializing = useRef(false); useEffect(() => { + if (client !== undefined) return; // In case the component is mounted, unmounted, and remounted quickly (as // React does in strict mode), we need to make sure not to doubly initialize // the client. @@ -161,7 +178,7 @@ export const ClientProvider: FC = ({ children }) => { }) .catch((err) => logger.error(err)) .finally(() => (initializing.current = false)); - }, []); + }, [client]); const changePassword = useCallback( async (password: string) => { @@ -228,11 +245,13 @@ export const ClientProvider: FC = ({ children }) => { // To protect against multiple sessions writing to the same storage // simultaneously, we send a broadcast message that shuts down all other - // running instances of the app. This isn't necessary if the app is running in - // a widget though, since then it'll be mostly stateless. + // running instances of the app. Element Call only has storage of its own to + // protect when it created the session itself; given a client, or running as a + // widget, it is mostly stateless. + const ownsSession = client === undefined && widget === null; useEffect(() => { - if (!widget) loadChannel?.postMessage({}); - }, []); + if (ownsSession) loadChannel?.postMessage({}); + }, [ownsSession]); const [alreadyOpenedErr, setAlreadyOpenedErr] = useState( undefined,