Compare commits

...
Author SHA1 Message Date
Timo K.andClaude Fable 5.1 8eaffa5b04 Cover the standalone client's initialisation and session restore
Neither had a test before the move, so the moved lines showed up as
uncovered. The client initialisation is tested through a fake client
and mocked stores: which store it syncs into with and without
IndexedDB, with and without the worker, the fallback to memory when
the IndexedDB store cannot start, clearing the stores only when not
restoring, carrying on without crypto, and counting as ready only
after a sync from the network rather than the one restored from the
store. The session restore is tested for no stored session, a
restored one, one the homeserver no longer knows, and any other
failure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 15:05:11 +01:00
Timo K.andClaude Fable 5.1 495be71ddc Keep the store worker out of the component package
The package carried the standalone app's IndexedDB store worker: 11 MB
with a copy of the Matrix SDK and its crypto wasm, referenced by an
absolute path no host could serve, and never used by the component,
which takes the host's client. The bundler emits the worker file as
soon as it loads the `?worker` import, used or not, and the import sat
in the Matrix utilities the component reaches for the room URLs.

The client initialisation moves into a module of its own, with the
worker import, which only the standalone login, registration and
session restore paths import. ClientProvider takes the session loader
through a `loadClient` prop instead of importing it itself, since even
a dynamic import keeps a module in the package: the standalone app
passes a lazy `initSPA` from App.tsx, the component passes nothing,
and given neither a client nor a loader no one is signed in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 14:42:56 +01:00
10 changed files with 548 additions and 175 deletions
+12 -2
View File
@@ -30,7 +30,7 @@ import { HomePage } from "./home/HomePage";
import { LoginPage } from "./auth/LoginPage";
import { RegisterPage } from "./auth/RegisterPage";
import { RoomPage } from "./room/RoomPage";
import { ClientProvider } from "./ClientContext";
import { type ClientLoader, ClientProvider } from "./ClientContext";
import { ErrorPage, LoadingPage } from "./FullScreenView";
import { Initializer } from "./initializer";
import { type WidgetHelpers } from "./widget";
@@ -57,6 +57,16 @@ import { LeaveToHomeProvider } from "./LeaveToHomeContext";
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
// Element Call's own session, for when no host supplies a client.
// - Passed in here, not imported by ClientProvider: this module is only in the
// app builds, so the client setup and the store worker behind it stay out of
// the component package (see the `loadClient` prop).
// - Imported on demand, as it was before, so that a widget, which gets its
// client from the host, never downloads it.
// - Module-level, so that the provider's effect sees one stable loader.
const loadStandaloneClient: ClientLoader = async (...args) =>
(await import("./utils/spa")).initSPA(...args);
interface SimpleProviderProps {
children: JSX.Element;
}
@@ -146,7 +156,7 @@ export const App: FC<Props> = ({ vm, widget }) => {
const content =
loaded && clientReady ? (
<ClientProvider client={widgetClient}>
<ClientProvider client={widgetClient} loadClient={loadStandaloneClient}>
<MediaDevicesContext value={vm.mediaDevices}>
<BackgroundEffectsProvider effects={vm.backgroundEffects}>
<Sentry.ErrorBoundary
+25 -6
View File
@@ -11,7 +11,11 @@ import { BrowserRouter } from "react-router-dom";
import { type MatrixClient } from "matrix-js-sdk";
import { type FC } from "react";
import { ClientProvider, useClientState } from "./ClientContext";
import {
type ClientLoader,
ClientProvider,
useClientState,
} from "./ClientContext";
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
({
@@ -95,13 +99,13 @@ test("follows the client when the host swaps it", () => {
test("finds a client of its own when the host supplies none", async () => {
const client = mockClient();
vi.doMock("./utils/spa", () => ({
initSPA: vi.fn().mockResolvedValue({ client, passwordlessUser: true }),
}));
const loadClient = vi
.fn<ClientLoader>()
.mockResolvedValue({ client, passwordlessUser: true });
const { container } = render(
<BrowserRouter>
<ClientProvider>
<ClientProvider loadClient={loadClient}>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
@@ -110,6 +114,21 @@ test("finds a client of its own when the host supplies none", async () => {
// Nothing to show until a session has been restored or created
expect(container.textContent).toBe("loading");
await waitFor(() => expect(container.textContent).toBe("@alice:example.org"));
// Given the session store to restore from and to clear on a stale session
expect(loadClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
);
});
vi.doUnmock("./utils/spa");
test("has no one signed in when given neither a client nor a way to load one", async () => {
const { container } = render(
<BrowserRouter>
<ClientProvider>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
);
await waitFor(() => expect(container.textContent).toBe("unauthenticated"));
});
+32 -12
View File
@@ -143,9 +143,34 @@ interface Props {
* own.
*/
client?: MatrixClient;
/**
* How Element Call finds a client when none is supplied: the standalone app
* restores or creates a session of its own. Stable, as a hook dependency.
* Left out along with the client, no one is signed in.
*
* A prop, rather than this module importing `utils/spa` itself, because:
* - This module is in the component build, and so is everything it imports,
* dynamically or not: a dynamic import becomes a chunk in the package,
* not a module left out of it.
* - Behind `utils/spa` sits `utils/initClient` and the client store worker,
* which the bundler emits as soon as it loads the import (see there).
* - `App.tsx`, which the component never imports, is where the standalone
* app passes the loader in; the component passes nothing, having the
* host's client.
*/
loadClient?: ClientLoader;
}
export const ClientProvider: FC<Props> = ({ children, client }) => {
/**
* Restores or creates a session of Element Call's own, kept where the given
* functions keep it. Resolves to null when no one is signed in.
*/
export type ClientLoader = (
loadSession: () => Session | undefined,
clearSession: () => void,
) => Promise<InitResult | null>;
export const ClientProvider: FC<Props> = ({ children, client, loadClient }) => {
const leaveToHome = useLeaveToHome();
const hostBridge = useHostBridge();
@@ -179,9 +204,13 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
// React does in strict mode), we need to make sure not to doubly initialize
// the client.
if (initializing.current) return;
if (loadClient === undefined) {
setInitClientState(null);
return;
}
initializing.current = true;
loadClient()
loadClient(loadSession, clearSession)
.then((initResult) => {
setInitClientState(initResult);
if (PosthogAnalytics.instance.isEnabled())
@@ -189,7 +218,7 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
})
.catch((err) => logger.error(err))
.finally(() => (initializing.current = false));
}, [client]);
}, [client, loadClient]);
const changePassword = useCallback(
async (password: string) => {
@@ -360,15 +389,6 @@ export type InitResult = {
passwordlessUser: boolean;
};
/**
* Restores or creates a session of Element Call's own. Only reached when no
* client was supplied for it to use.
*/
async function loadClient(): Promise<InitResult | null> {
const { initSPA } = await import("./utils/spa");
return initSPA(loadSession, clearSession);
}
export interface Session {
user_id: string;
device_id: string;
+1 -1
View File
@@ -13,7 +13,7 @@ import {
type MatrixClient,
} from "matrix-js-sdk";
import { initClient } from "../utils/matrix";
import { initClient } from "../utils/initClient";
import { type Session } from "../ClientContext";
/**
* This provides the login method to login using user credentials.
+1 -1
View File
@@ -14,7 +14,7 @@ import {
} from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { initClient } from "../utils/matrix";
import { initClient } from "../utils/initClient";
import { type Session } from "../ClientContext";
import { Config } from "../config/Config";
import { useUrlParams } from "../UrlParams";
+214
View File
@@ -0,0 +1,214 @@
/*
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 { EventEmitter } from "events";
import {
afterEach,
beforeEach,
describe,
expect,
it,
type Mock,
vi,
} from "vitest";
import {
ClientEvent,
createClient,
type ICreateClientOpts,
IndexedDBStore,
MemoryStore,
} from "matrix-js-sdk";
import type * as MatrixSdk from "matrix-js-sdk";
import { initClient } from "./initClient";
import { flushPromises } from "./test";
interface Store {
startup: Mock<() => Promise<void>>;
getSavedSyncToken: Mock<() => Promise<string | null>>;
}
const store = (): Store => ({
startup: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
getSavedSyncToken: vi
.fn<() => Promise<string | null>>()
.mockResolvedValue(null),
});
// The client is the SDK's business; what these tests are about is how it is
// set up: its stores, its crypto, and when it counts as ready.
vi.mock("matrix-js-sdk", async (importOriginal) => ({
...(await importOriginal<typeof MatrixSdk>()),
createClient: vi.fn(),
IndexedDBStore: vi.fn(function (this: Store) {
Object.assign(this, store());
}),
MemoryStore: vi.fn(function (this: Store) {
Object.assign(this, store());
}),
}));
interface FakeClient extends EventEmitter {
store: Store;
clearStores: Mock<() => Promise<void>>;
initRustCrypto: Mock<() => Promise<void>>;
startClient: Mock<() => Promise<void>>;
}
/**
* A client that, unless told otherwise, reports its first sync a tick after
* being started, as the real one does once the network answers.
*/
function fakeClient(opts: ICreateClientOpts, syncs = true): FakeClient {
const client = Object.assign(new EventEmitter(), {
store: opts.store as unknown as Store,
clearStores: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
initRustCrypto: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
startClient: vi.fn(async () => {
if (!syncs) return;
await new Promise((resolve) => setTimeout(resolve, 0));
client.emit(ClientEvent.Sync, "PREPARED", null);
}),
});
return client;
}
const options: ICreateClientOpts = { baseUrl: "https://hs.example.org" };
const clients = vi.mocked(createClient) as unknown as Mock<
(opts: ICreateClientOpts) => FakeClient
>;
const indexedDBStores = vi.mocked(IndexedDBStore);
const memoryStores = vi.mocked(MemoryStore);
describe("initClient", () => {
beforeEach(() => {
clients.mockImplementation((opts) => fakeClient(opts));
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it("hands back the client once its first sync is done", async () => {
const client = await initClient(options, true);
expect(client).toBe(clients.mock.results[0].value);
expect(client.startClient).toHaveBeenCalledWith({
clientWellKnownPollPeriod: 600,
});
expect(clients).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: "https://hs.example.org",
useAuthorizationHeader: true,
localTimeoutMs: 5000,
}),
);
});
it("keeps the stores when restoring a session", async () => {
const client = (await initClient(options, true)) as unknown as FakeClient;
expect(client.clearStores).not.toHaveBeenCalled();
});
it("clears the stores before starting them when not restoring", async () => {
const client = (await initClient(options, false)) as unknown as FakeClient;
expect(client.clearStores).toHaveBeenCalled();
expect(client.clearStores.mock.invocationCallOrder[0]).toBeLessThan(
client.store.startup.mock.invocationCallOrder[0],
);
});
it("syncs into memory where there is no IndexedDB", async () => {
// jsdom has no indexedDB
const client = await initClient(options, true);
expect(client.store).toBe(memoryStores.mock.instances[0]);
expect(indexedDBStores).not.toHaveBeenCalled();
});
it("syncs into IndexedDB through a worker, where it was bundled", async () => {
vi.stubGlobal("indexedDB", {});
vi.stubEnv("DEV", false);
const client = await initClient(options, true);
expect(client.store).toBe(indexedDBStores.mock.instances[0]);
const [storeOptions] = indexedDBStores.mock.calls[0];
expect(storeOptions.dbName).toBe("element-call-sync");
expect(storeOptions.workerFactory).toBeTypeOf("function");
});
it("syncs into IndexedDB without a worker in development", async () => {
vi.stubGlobal("indexedDB", {});
vi.stubEnv("DEV", true);
await initClient(options, true);
// The dev server does not bundle the worker
expect(indexedDBStores.mock.calls[0][0].workerFactory).toBeUndefined();
});
it("falls back to memory when the IndexedDB store cannot start", async () => {
vi.stubGlobal("indexedDB", {});
const failing = store();
failing.startup.mockRejectedValue(new Error("quota exceeded"));
indexedDBStores.mockImplementationOnce(function (this: IndexedDBStore) {
Object.assign(this, failing);
});
const client = await initClient(options, true);
expect(client.store).toBe(memoryStores.mock.instances[0]);
expect(client.store.startup).toHaveBeenCalled();
});
it("carries on without crypto when it cannot be initialised", async () => {
clients.mockImplementation((opts) => {
const client = fakeClient(opts);
client.initRustCrypto.mockRejectedValue(new Error("stores in use"));
return client;
});
await expect(initClient(options, true)).resolves.toBeDefined();
});
it("waits for a sync from the network, past one restored from the store", async () => {
let client!: FakeClient;
clients.mockImplementation((opts) => {
client = fakeClient(opts, false);
client.store.getSavedSyncToken.mockResolvedValue("saved");
return client;
});
let ready = false;
const result = initClient(options, true).then(() => (ready = true));
await flushPromises();
// The first sync the client reports is the one restored from the store
client.emit(ClientEvent.Sync, "PREPARED", null);
await flushPromises();
expect(ready).toBe(false);
client.emit(ClientEvent.Sync, "PREPARED", null);
await result;
expect(ready).toBe(true);
});
it("fails when the first sync does", async () => {
let client!: FakeClient;
clients.mockImplementation((opts) => (client = fakeClient(opts, false)));
const result = initClient(options, true);
await flushPromises();
client.emit(ClientEvent.Sync, "ERROR", null, {
error: new Error("offline"),
});
await expect(result).rejects.toThrow("offline");
});
});
+170
View File
@@ -0,0 +1,170 @@
/*
Copyright 2022-2024 New Vector Ltd.
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 {
ClientEvent,
createClient,
type ICreateClientOpts,
IndexedDBStore,
type MatrixClient,
MemoryStore,
} from "matrix-js-sdk";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
import { logger } from "matrix-js-sdk/lib/logger";
// This import is why `initClient` has a module of its own:
// - The bundler emits the worker file (11 MB: the Matrix SDK and its crypto
// wasm) the moment it loads this import — whether or not anything ever calls
// `new IndexedDBWorker()`. Tree shaking drops the call, not the file.
// - So it may only sit in a module the component build never loads. It used
// to be in `./matrix`, which the component reaches for the room URL helpers,
// and the worker shipped in the package, unused and unusable by any host.
import IndexedDBWorker from "../IndexedDBWorker?worker";
import { getUrlParams } from "../UrlParams";
import { Config } from "../config/Config";
import { fallbackICEServerAllowed } from "./matrix";
const SYNC_STORE_NAME = "element-call-sync";
/**
* Initialises and returns a new standalone Matrix Client.
* This can only be called safely if no other client is running
* otherwise rust crypto will throw since it is not ready to initialize a new session.
* If another client is running make sure `.logout()` is called before executing this function.
* @param clientOptions Object of options passed through to the client
* @param restore If the rust crypto should be reset before the client initialization or
* if the initialization should try to restore the crypto state from the indexDB.
* @returns The MatrixClient instance
*/
export async function initClient(
clientOptions: ICreateClientOpts,
restore: boolean,
): Promise<MatrixClient> {
let indexedDB: IDBFactory | undefined;
try {
indexedDB = window.indexedDB;
} catch (e) {
logger.warn("Could not get indexDB from window.", e);
}
// options we always pass to the client (stuff that we need in order to work)
const baseOpts = {
fallbackICEServerAllowed: fallbackICEServerAllowed,
isVoipWithNoMediaAllowed:
Config.get().features?.feature_group_calls_without_video_and_audio,
} as ICreateClientOpts;
if (indexedDB && localStorage) {
baseOpts.store = new IndexedDBStore({
indexedDB: window.indexedDB,
localStorage,
dbName: SYNC_STORE_NAME,
// We can't use the worker in dev mode because Vite simply doesn't bundle workers
// in dev mode: it expects them to use native modules. Ours don't, and even then only
// Chrome supports it. (It bundles them fine in production mode.)
workerFactory: import.meta.env.DEV
? undefined
: (): Worker => new IndexedDBWorker(),
});
} else if (localStorage) {
baseOpts.store = new MemoryStore({ localStorage });
}
// XXX: we read from the URL params in RoomPage too:
// it would be much better to read them in one place and pass
// the values around, but we initialise the matrix client in
// many different places so we'd have to pass it into all of
// them.
const { e2eEnabled } = getUrlParams();
if (!e2eEnabled) {
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
}
const client = createClient({
...baseOpts,
...clientOptions,
useAuthorizationHeader: true,
// Use a relatively low timeout for API calls: this is a realtime app
// so we don't want API calls taking ages, we'd rather they just fail.
localTimeoutMs: 5000,
useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: fallbackICEServerAllowed,
});
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
// - We lost the auth tokens and cannot restore the client resulting in registering a new user.
// - We start the sign in flow but are registered with a guest user. (It should additionally log out the guest before)
// - A new account is created because of missing LocalStorage: "matrix-auth-store", but the crypto IndexDB is still available.
if (!restore) {
await client.clearStores();
}
// Start client store.
// Note: The `client.store` is used to store things like sync results. It's independent of
// the cryptostore, and uses a separate indexeddb database.
try {
await client.store.startup();
} catch (error) {
logger.error(
"Error starting matrix client indexDB store. Falling back to memory store.",
error,
);
client.store = new MemoryStore({ localStorage });
await client.store.startup();
}
// Also creates and starts any crypto related stores.
try {
await client.initRustCrypto();
} catch (err) {
logger.warn(
err,
"Make sure to clear client stores before initializing the rust crypto.",
);
}
// Once startClient is called, syncs are run asynchronously.
// Also, sync completion is communicated only via events.
// So, apply the event listener *before* starting the client.
// Otherwise, a sync may complete before the listener gets applied,
// and we will miss it.
const syncPromise = waitForSync(client);
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
await syncPromise;
return client;
}
async function waitForSync(client: MatrixClient): Promise<void> {
// If there is a saved sync, the client will fire an additional sync event
// for restoring it before it runs the first network sync.
// However, the sync we want to wait for is the network sync,
// as the saved sync may be missing some state.
// Thus, don't resolve on the first sync when we know it's for the saved sync.
let waitForSavedSync = !!(await client.store.getSavedSyncToken());
return new Promise<void>((resolve, reject) => {
const onSync = (
state: SyncState,
_old: SyncState | null,
data?: ISyncStateData,
): void => {
if (state === "PREPARED") {
if (waitForSavedSync) {
waitForSavedSync = false;
} else {
client.removeListener(ClientEvent.Sync, onSync);
resolve();
}
} else if (state === "ERROR") {
client.removeListener(ClientEvent.Sync, onSync);
reject(data?.error);
}
};
client.on(ClientEvent.Sync, onSync);
});
}
+8 -152
View File
@@ -5,21 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
ClientEvent,
createClient,
IndexedDBStore,
MemoryStore,
Preset,
Visibility,
} from "matrix-js-sdk";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
import { ClientEvent, Preset, Visibility } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { secureRandomBase64Url } from "matrix-js-sdk/lib/randomstring";
import type { ICreateClientOpts, MatrixClient, Room } from "matrix-js-sdk";
import IndexedDBWorker from "../IndexedDBWorker?worker";
import { generateUrlSearchParams, getUrlParams } from "../UrlParams";
import type { MatrixClient, Room } from "matrix-js-sdk";
import { generateUrlSearchParams } from "../UrlParams";
import { Config } from "../config/Config";
import { E2eeType } from "../e2ee/e2eeType";
import {
@@ -27,149 +18,14 @@ import {
saveKeyForRoom,
} from "../e2ee/sharedKeyManagement";
// Shared with the component, which reaches this module for the room URLs. So
// nothing in here may import the client store worker: the bundler emits the
// worker file on loading the import, used or not. The one function that needs
// it, `initClient`, lives in `./initClient` for that reason.
export const fallbackICEServerAllowed =
import.meta.env.VITE_FALLBACK_STUN_ALLOWED === "true";
const SYNC_STORE_NAME = "element-call-sync";
async function waitForSync(client: MatrixClient): Promise<void> {
// If there is a saved sync, the client will fire an additional sync event
// for restoring it before it runs the first network sync.
// However, the sync we want to wait for is the network sync,
// as the saved sync may be missing some state.
// Thus, don't resolve on the first sync when we know it's for the saved sync.
let waitForSavedSync = !!(await client.store.getSavedSyncToken());
return new Promise<void>((resolve, reject) => {
const onSync = (
state: SyncState,
_old: SyncState | null,
data?: ISyncStateData,
): void => {
if (state === "PREPARED") {
if (waitForSavedSync) {
waitForSavedSync = false;
} else {
client.removeListener(ClientEvent.Sync, onSync);
resolve();
}
} else if (state === "ERROR") {
client.removeListener(ClientEvent.Sync, onSync);
reject(data?.error);
}
};
client.on(ClientEvent.Sync, onSync);
});
}
/**
* Initialises and returns a new standalone Matrix Client.
* This can only be called safely if no other client is running
* otherwise rust crypto will throw since it is not ready to initialize a new session.
* If another client is running make sure `.logout()` is called before executing this function.
* @param clientOptions Object of options passed through to the client
* @param restore If the rust crypto should be reset before the client initialization or
* if the initialization should try to restore the crypto state from the indexDB.
* @returns The MatrixClient instance
*/
export async function initClient(
clientOptions: ICreateClientOpts,
restore: boolean,
): Promise<MatrixClient> {
let indexedDB: IDBFactory | undefined;
try {
indexedDB = window.indexedDB;
} catch (e) {
logger.warn("Could not get indexDB from window.", e);
}
// options we always pass to the client (stuff that we need in order to work)
const baseOpts = {
fallbackICEServerAllowed: fallbackICEServerAllowed,
isVoipWithNoMediaAllowed:
Config.get().features?.feature_group_calls_without_video_and_audio,
} as ICreateClientOpts;
if (indexedDB && localStorage) {
baseOpts.store = new IndexedDBStore({
indexedDB: window.indexedDB,
localStorage,
dbName: SYNC_STORE_NAME,
// We can't use the worker in dev mode because Vite simply doesn't bundle workers
// in dev mode: it expects them to use native modules. Ours don't, and even then only
// Chrome supports it. (It bundles them fine in production mode.)
workerFactory: import.meta.env.DEV
? undefined
: (): Worker => new IndexedDBWorker(),
});
} else if (localStorage) {
baseOpts.store = new MemoryStore({ localStorage });
}
// XXX: we read from the URL params in RoomPage too:
// it would be much better to read them in one place and pass
// the values around, but we initialise the matrix client in
// many different places so we'd have to pass it into all of
// them.
const { e2eEnabled } = getUrlParams();
if (!e2eEnabled) {
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
}
const client = createClient({
...baseOpts,
...clientOptions,
useAuthorizationHeader: true,
// Use a relatively low timeout for API calls: this is a realtime app
// so we don't want API calls taking ages, we'd rather they just fail.
localTimeoutMs: 5000,
useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: fallbackICEServerAllowed,
});
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
// - We lost the auth tokens and cannot restore the client resulting in registering a new user.
// - We start the sign in flow but are registered with a guest user. (It should additionally log out the guest before)
// - A new account is created because of missing LocalStorage: "matrix-auth-store", but the crypto IndexDB is still available.
if (!restore) {
await client.clearStores();
}
// Start client store.
// Note: The `client.store` is used to store things like sync results. It's independent of
// the cryptostore, and uses a separate indexeddb database.
try {
await client.store.startup();
} catch (error) {
logger.error(
"Error starting matrix client indexDB store. Falling back to memory store.",
error,
);
client.store = new MemoryStore({ localStorage });
await client.store.startup();
}
// Also creates and starts any crypto related stores.
try {
await client.initRustCrypto();
} catch (err) {
logger.warn(
err,
"Make sure to clear client stores before initializing the rust crypto.",
);
}
// Once startClient is called, syncs are run asynchronously.
// Also, sync completion is communicated only via events.
// So, apply the event listener *before* starting the client.
// Otherwise, a sync may complete before the listener gets applied,
// and we will miss it.
const syncPromise = waitForSync(client);
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
await syncPromise;
return client;
}
export function roomAliasLocalpartFromRoomName(roomName: string): string {
return roomName
.trim()
+83
View File
@@ -0,0 +1,83 @@
/*
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type MatrixClient, MatrixError } from "matrix-js-sdk";
import { initClient } from "./initClient";
import { initSPA } from "./spa";
import { Config } from "../config/Config";
import { type Session } from "../ClientContext";
vi.mock("./initClient", () => ({ initClient: vi.fn() }));
const session: Session = {
user_id: "@alice:example.org",
device_id: "AAAA",
access_token: "secret",
passwordlessUser: true,
};
const client = {} as MatrixClient;
describe("initSPA", () => {
const clearSession = vi.fn();
beforeEach(() => {
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
"https://hs.example.org",
);
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
it("continues without a client when no session is stored", async () => {
await expect(initSPA(() => undefined, clearSession)).resolves.toBeNull();
expect(initClient).not.toHaveBeenCalled();
expect(clearSession).not.toHaveBeenCalled();
});
it("restores the stored session into a client", async () => {
vi.mocked(initClient).mockResolvedValue(client);
await expect(initSPA(() => session, clearSession)).resolves.toEqual({
client,
passwordlessUser: true,
});
expect(initClient).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: "https://hs.example.org",
accessToken: "secret",
userId: "@alice:example.org",
deviceId: "AAAA",
}),
// Restoring: the crypto store is the session's, not to be wiped
true,
);
});
it("drops a session the homeserver no longer knows", async () => {
vi.mocked(initClient).mockRejectedValue(
new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Unknown token" }),
);
await expect(initSPA(() => session, clearSession)).resolves.toBeNull();
expect(clearSession).toHaveBeenCalled();
});
it("drops the session and fails on any other error", async () => {
vi.mocked(initClient).mockRejectedValue(new Error("offline"));
await expect(initSPA(() => session, clearSession)).rejects.toThrow(
"offline",
);
expect(clearSession).toHaveBeenCalled();
});
});
+2 -1
View File
@@ -9,7 +9,8 @@ import { type ICreateClientOpts, MatrixError } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { Config } from "../config/Config";
import { fallbackICEServerAllowed, initClient } from "./matrix";
import { fallbackICEServerAllowed } from "./matrix";
import { initClient } from "./initClient";
import type { InitResult, Session } from "../ClientContext";
export async function initSPA(