Merge branch 'main' into feat/desktop-offline

This commit is contained in:
Linus Rath
2026-11-03 21:37:32 +01:00
12 changed files with 479 additions and 39 deletions
@@ -1,5 +1,6 @@
import React from 'react';
import React, { act } from 'react';
import { render } from '@testing-library/react';
import { hydrateRoot } from 'react-dom/client';
import { renderToString } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
@@ -63,6 +64,29 @@ describe('login page while the config loads', () => {
expect(container.querySelector('.animate-spin')).toBeNull();
});
it('hydrates the server\'s skeleton when the config arrived first, and then draws the form', async () => {
// The page's Suspense boundary hydrates after the app around it, which fetches the config.
config.loading = true;
const html = renderToString(<LoginPage />);
config.loading = false;
const box = document.createElement('div');
box.innerHTML = html;
document.body.appendChild(box);
const fromServer = box.querySelector('[data-boot-skeleton="login"]');
expect(fromServer).not.toBeNull();
const recoverable: unknown[] = [];
const root = hydrateRoot(box, <LoginPage />, { onRecoverableError: (error) => { recoverable.push(error); } });
try {
await act(async () => {});
expect(recoverable).toEqual([]);
expect(box.querySelector('[data-boot-skeleton="login"]')).toBeNull();
expect(box.querySelector('#username')).not.toBeNull();
} finally {
await act(async () => root.unmount());
box.remove();
}
});
it('draws the sign-in button\'s place while the provider is looked up, not a spinner', () => {
config.loading = false;
config.oauthOnly = true;
+11 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { freshLoginRequired } from "@/lib/app-lock/sign-out";
import { Suspense, useState, useEffect, useRef, useCallback, useId, useMemo } from "react";
import { Suspense, useState, useEffect, useRef, useCallback, useId, useMemo, useSyncExternalStore } from "react";
import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag";
import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge";
import { customServerAllowedByDesktopPolicy, jmapServerAllowedByDesktopPolicy, useDesktopPolicy } from "@/lib/desktop/policy";
@@ -746,8 +746,12 @@ function LoginPageContent() {
}, [setTheme]);
// The server HTML, and every visit until /api/config answers: the card's
// shape, the same one signed-out visitors of the app pages get first.
if (configLoading) {
// shape, the same one signed-out visitors of the app pages get first. Also
// the pass that hydrates that HTML, however far the config has got: this
// page's Suspense boundary hydrates after the app around it, which may have
// fetched the config by then, and drawing the form would throw the HTML away.
const hydrating = useSyncExternalStore(neverChanges, notHydrating, isHydrating);
if (configLoading || hydrating) {
return <LoginSkeleton label={t("loading")} />;
}
@@ -2066,6 +2070,10 @@ function LoginPageContent() {
);
}
const neverChanges = () => () => {};
const notHydrating = () => false;
const isHydrating = () => true;
// useSearchParams() above must sit under a Suspense boundary for the route to
// prerender (the static Lite build); at runtime the boundary never suspends.
export default function LoginPage() {
@@ -0,0 +1,227 @@
import { act, useEffect, type ReactNode } from "react";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToPipeableStream } from "react-dom/server";
import { hydrateRoot, type Root } from "react-dom/client";
/**
* The gate against real React: the server's stream, then hydration. The controller is
* a stand-in, since what's tested here is what the gate does with a hold and a phase
* (lib/app-lock/__tests__/controller.test.ts tests the decisions themselves).
*/
const lock = vi.hoisted(() => {
const listeners = new Set<() => void>();
const state = {
phase: "open" as string,
hold: null as { promise: Promise<void>; release: () => void } | null,
setPhase(next: string) {
state.phase = next;
for (const listener of [...listeners]) listener();
},
takeHold() {
let release = () => {};
const promise = new Promise<void>((resolve) => {
release = resolve;
});
state.hold = { promise, release };
},
open() {
const held = state.hold;
state.hold = null;
state.setPhase("open");
held?.release();
},
listeners,
};
return state;
});
vi.mock("@/lib/app-lock/controller", () => ({
appLockHold: () => lock.hold?.promise ?? null,
bootAppLock: () => {},
getAppLockPhase: () => lock.phase,
recheckAppLockSetup: () => {},
startAppLock: () => () => {},
subscribeAppLockPhase: (listener: () => void) => {
lock.listeners.add(listener);
return () => lock.listeners.delete(listener);
},
takeDraftsSavedNotice: () => false,
}));
vi.mock("../lock-screen", () => ({
LockScreen: ({ phase }: { phase: string }) => <div data-testid="lock-screen">{phase}</div>,
}));
import { AppLockGate } from "../app-lock-gate";
/** Well over the size at which React moves a Suspense boundary out of the shell. */
const ROWS = Array.from({ length: 600 }, (_, i) => `row ${i} of a page skeleton, as long as a real one`);
let effects = 0;
function App() {
useEffect(() => {
effects += 1;
}, []);
return <main data-testid="app">{ROWS.map((row) => <p key={row}>{row}</p>)}</main>;
}
const page = (app: ReactNode = <App />) => <AppLockGate>{app}</AppLockGate>;
/** What the server sends, first byte to last, as the page's real stream does. */
async function stream(tree: ReactNode): Promise<string> {
vi.stubGlobal("window", undefined);
try {
return await new Promise<string>((resolve, reject) => {
let html = "";
const out = new PassThrough();
out.on("data", (chunk) => { html += chunk; });
out.on("end", () => resolve(html));
const { pipe } = renderToPipeableStream(tree, {
onShellReady: () => pipe(out),
onError: reject,
});
});
} finally {
vi.unstubAllGlobals();
}
}
/** The markup to hydrate: the gate alone, since a test can't hydrate a whole document. */
const serverHtml = () => stream(page());
/**
* A whole page, <html> and all, as the root layout renders it. It has to be: React only
* moves a boundary out of a shell that has a document around it.
*/
const serverDocument = () => stream(<html><head /><body>{page()}</body></html>);
let root: Root | null = null;
let recoverable: unknown[] = [];
/** Hydrates the server's markup and lets everything that can finish, finish. */
async function hydrate(html: string): Promise<HTMLElement> {
const container = document.createElement("div");
container.innerHTML = html;
document.body.appendChild(container);
await act(async () => {
root = hydrateRoot(container, page(), { onRecoverableError: (e) => { recoverable.push(e); } });
});
return container;
}
const lockScreen = () => document.querySelector('[data-testid="lock-screen"]');
beforeEach(() => {
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
lock.phase = "open";
lock.hold = null;
lock.listeners.clear();
effects = 0;
recoverable = [];
});
afterEach(async () => {
await act(async () => root?.unmount());
root = null;
document.body.innerHTML = "";
});
describe("what the server sends", () => {
it("has the app in the shell, not in a hidden segment a script reveals later", async () => {
const html = await serverDocument();
expect(html).toContain("row 0 of a page skeleton");
expect(html).not.toContain('<template id="B:');
expect(html).not.toMatch(/<div hidden id="S:/);
expect(html).not.toContain("$RC");
});
it("the page is on screen as soon as its bytes are, before any script ran", async () => {
const parsed = new DOMParser().parseFromString(await serverDocument(), "text/html");
const app = parsed.querySelector('[data-testid="app"]');
expect(app).not.toBeNull();
expect(app?.closest("[hidden]")).toBeNull();
});
});
describe("with no lock", () => {
it("hydrates the server's markup where it is, with no mismatch and the effects run", async () => {
const html = await serverHtml();
const container = document.createElement("div");
container.innerHTML = html;
document.body.appendChild(container);
const fromServer = container.querySelector('[data-testid="app"]');
await act(async () => {
root = hydrateRoot(container, page(), { onRecoverableError: (e) => { recoverable.push(e); } });
});
expect(recoverable).toEqual([]);
expect(container.querySelector('[data-testid="app"]')).toBe(fromServer);
expect(effects).toBe(1);
expect(lockScreen()).toBeNull();
});
});
describe("while the lock holds", () => {
it("nothing below hydrates: no effect runs, and the server's markup stays", async () => {
const html = await serverHtml();
lock.phase = "locked";
lock.takeHold();
const container = await hydrate(html);
expect(effects).toBe(0);
expect(container.querySelector('[data-testid="app"]')).not.toBeNull();
expect(lockScreen()?.textContent).toBe("locked");
expect(recoverable).toEqual([]);
});
it("an unlock hydrates that same markup in this document", async () => {
const html = await serverHtml();
lock.phase = "locked";
lock.takeHold();
const container = await hydrate(html);
const fromServer = container.querySelector('[data-testid="app"]');
await act(async () => lock.open());
expect(effects).toBe(1);
expect(container.querySelector('[data-testid="app"]')).toBe(fromServer);
expect(lockScreen()).toBeNull();
expect(recoverable).toEqual([]);
});
it("a phase change shows on the lock screen and leaves the held app alone", async () => {
const html = await serverHtml();
lock.phase = "checking";
lock.takeHold();
const container = await hydrate(html);
const fromServer = container.querySelector('[data-testid="app"]');
expect(lockScreen()).toBeNull();
await act(async () => lock.setPhase("locked"));
expect(lockScreen()?.textContent).toBe("locked");
expect(effects).toBe(0);
expect(container.querySelector('[data-testid="app"]')).toBe(fromServer);
await act(async () => lock.setPhase("locked-stay"));
expect(lockScreen()?.textContent).toBe("locked-stay");
expect(effects).toBe(0);
});
});
describe("once it has hydrated", () => {
it("a late hold takes the app away and the lock screen still comes up, and an unlock brings it back", async () => {
const container = await hydrate(await serverHtml());
expect(effects).toBe(1);
await act(async () => {
lock.takeHold();
lock.setPhase("locked");
});
expect(lockScreen()?.textContent).toBe("locked");
expect(container.querySelector('[data-testid="app"]')).toBeNull();
await act(async () => lock.open());
expect(lockScreen()).toBeNull();
expect(container.querySelector('[data-testid="app"]')).not.toBeNull();
});
it("a page that couldn't reload has the app gone from the screen", async () => {
const container = await hydrate(await serverHtml());
await act(async () => lock.setPhase("locked-stay"));
expect(container.querySelector('[data-testid="app"]')).toBeNull();
expect(lockScreen()?.textContent).toBe("locked-stay");
});
});
+52 -24
View File
@@ -1,6 +1,6 @@
"use client";
import { Suspense, use, useEffect, useState, useSyncExternalStore } from "react";
import { Activity, use, useEffect, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { LockScreen } from "./lock-screen";
import { appLockHold, bootAppLock, getAppLockPhase, recheckAppLockSetup, startAppLock, subscribeAppLockPhase, takeDraftsSavedNotice, type AppLockPhase } from "@/lib/app-lock/controller";
@@ -10,23 +10,61 @@ import { toast } from "@/stores/toast-store";
import { useTranslations } from "next-intl";
const serverPhase = (): AppLockPhase => "open";
const neverChanges = () => () => {};
const onClient = () => true;
const inServerMarkup = () => false;
/**
* Suspends the app below while the lock holds. In the first render of a page that is
* locked, that keeps the server's markup inert: nothing below hydrates, so no effect runs,
* no store fills and no plugin frame starts, until someone unlocks in this same document.
* The server's markup is the empty skeleton, never mail.
* The app below the gate. While the lock holds, the first render suspends, and that keeps
* the server's markup inert: nothing below hydrates, so no effect runs, no store fills and
* no plugin frame starts, until someone unlocks in this same document. The server's markup
* is the empty skeleton, never mail.
*
* Once it has hydrated there's no boundary left to suspend into, and suspending would stop
* the lock screen from showing too. A hold that comes that late takes the app away instead.
*/
function Hold() {
if (typeof window === "undefined") return null;
bootAppLock();
const hold = appLockHold();
if (hold) use(hold);
return null;
function Held({ children }: { children: React.ReactNode }) {
const phase = useSyncExternalStore(subscribeAppLockPhase, getAppLockPhase, serverPhase);
const hydrated = useSyncExternalStore(neverChanges, onClient, inServerMarkup);
if (typeof window !== "undefined") {
bootAppLock();
const hold = appLockHold();
if (hold) {
if (!hydrated) use(hold);
return null;
}
}
return phase === "locked-stay" ? null : children;
}
/** Above everything that touches mail, and in every window: main, pop-outs, composers. */
/**
* Above everything that touches mail, and in every window: main, pop-outs, composers.
*
* Activity and not Suspense around the app: both can stay dehydrated while something
* inside suspends, but React moves a big Suspense boundary out of the shell, even a
* finished one. The whole app would come as a hidden segment that a script reveals, a
* frame or up to 300 ms later, and the page has no skeleton until then. For everyone,
* lock or no lock. An Activity's markup stays where it is
* (scripts/boot-skeleton-guard checks the bytes).
*
* The phase lives in the two components below this one and not here: new props on a
* dehydrated boundary would make React give up on it and throw the server's markup away.
*/
export function AppLockGate({ children }: { children: React.ReactNode }) {
if (IS_LITE) return <>{children}</>;
return (
<>
<Activity mode="visible">
<Held>{children}</Held>
</Activity>
<LockLayer />
</>
);
}
/** The lock's life in this tab, and its screen. Hydrates with the page's shell, whatever Held does. */
function LockLayer() {
const phase = useSyncExternalStore(subscribeAppLockPhase, getAppLockPhase, serverPhase);
const t = useTranslations("appLock");
const [mounted, setMounted] = useState(false);
@@ -38,7 +76,6 @@ export function AppLockGate({ children }: { children: React.ReactNode }) {
// Signing in while an organisation requires the lock: it's due now.
useEffect(() => {
if (IS_LITE) return;
recheckAppLockSetup();
return useAccountStore.subscribe(recheckAppLockSetup);
}, []);
@@ -47,15 +84,6 @@ export function AppLockGate({ children }: { children: React.ReactNode }) {
if (phase === "open" && takeDraftsSavedNotice()) toast.info(t("draft_saved_notice"));
}, [phase, t]);
if (IS_LITE) return <>{children}</>;
return (
<>
<Suspense fallback={null}>
<Hold />
{phase === "locked-stay" ? null : children}
</Suspense>
{mounted && phase !== "open" && phase !== "checking" && createPortal(<LockScreen phase={phase} />, document.body)}
</>
);
if (!mounted || phase === "open" || phase === "checking") return null;
return createPortal(<LockScreen phase={phase} />, document.body);
}
+5 -1
View File
@@ -513,6 +513,8 @@ test('a forgotten PIN offline: the cookie that stays signs nobody in, then or wh
await enableOffline(page);
await enableLock(page);
await lockNow(page);
const sessionCookies = (await context.cookies()).filter((cookie) => SESSION_COOKIE.test(cookie.name));
expect(sessionCookies.length).toBeGreaterThan(0);
await context.setOffline(true);
await forgetPin(page);
// No network, so no way to delete an HttpOnly cookie, and the shell went with the rest:
@@ -521,7 +523,9 @@ test('a forgotten PIN offline: the cookie that stays signs nobody in, then or wh
.catch((error: Error) => expect(error.message).toContain('ERR_INTERNET_DISCONNECTED'));
// The old page is gone only once the error page is up: until then it could still reach the network.
await expect.poll(() => page.url(), { timeout: 30_000 }).toContain('chrome-error');
expect((await context.cookies()).some((cookie) => SESSION_COOKIE.test(cookie.name))).toBe(true);
// What a real outage leaves. Chromium's emulated one lets a keepalive request through when
// the page leaves right after it: the page hears it failed, and the server clears the cookies.
await context.addCookies(sessionCookies);
const stored = (await context.storageState()).origins.flatMap((origin) => origin.localStorage).map((entry) => entry.name);
expect(stored).toContain('bulwark-cookies-to-clear');
expect(stored).not.toContain('bulwark-app-lock');
+80 -1
View File
@@ -24,7 +24,7 @@ import {
} from '../controller';
import { LOCK_LAYER } from '../crypto';
import { appLockRequired, effectiveTimeout } from '../policy';
import { APP_LOCK_KEY, isAppLockedNow, readAppLockFile, setLockedHere } from '../state';
import { APP_LOCK_KEY, appLockBootScript, isAppLockedNow, readAppLockFile, setLockedHere } from '../state';
import { createVault, deleteVault, readVault, sealDraft, VaultExistsError } from '../vault';
const PIN = 'correct-pin';
@@ -637,6 +637,85 @@ describe('the desktop app', () => {
});
});
describe('a start with no lock file', () => {
/** Runs the head script of app/(main)/layout.tsx and waits until its look has answered. */
async function headScript(): Promise<void> {
const databases = vi.spyOn(indexedDB, 'databases');
new Function(appLockBootScript())();
await databases.mock.results[0]?.value;
await Promise.resolve();
databases.mockRestore();
}
afterEach(() => {
delete (window as unknown as Record<string, unknown>).__bulwarkNoAppLockVault;
});
it('holds nothing back once the head script found no vault: the app starts in the first render', async () => {
await headScript();
bootAppLock();
expect(appLockHold()).toBeNull();
expect(getAppLockPhase()).toBe('open');
expect(isAppLockedNow()).toBe(false);
});
it('still waits for the look when the head script has no answer yet', async () => {
bootAppLock();
expect(appLockHold()).toBeInstanceOf(Promise);
expect(getAppLockPhase()).toBe('checking');
await vi.waitFor(() => expect(appLockHold()).toBeNull());
expect(getAppLockPhase()).toBe('open');
});
it('a lost file with a vault still locks: the head script doesn\'t let that start through', async () => {
await enableAppLock(PIN);
localStorage.removeItem(APP_LOCK_KEY);
resetAppLockForTests();
await headScript();
bootAppLock();
expect(appLockHold()).toBeInstanceOf(Promise);
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked'));
expect(readAppLockFile().on).toBe(true);
expect(isAppLockedNow()).toBe(true);
});
it('a lock file on means no look at all: the first frame already knows', async () => {
localStorage.setItem(APP_LOCK_KEY, JSON.stringify({ on: true, locked: true }));
const databases = vi.spyOn(indexedDB, 'databases');
new Function(appLockBootScript())();
expect(databases).not.toHaveBeenCalled();
expect(document.documentElement.hasAttribute('data-app-locked')).toBe(true);
databases.mockRestore();
document.documentElement.removeAttribute('data-app-locked');
});
it('a look that fails says nothing, and the start waits for its own', async () => {
const databases = vi.spyOn(indexedDB, 'databases').mockRejectedValue(new Error('blocked'));
new Function(appLockBootScript())();
await databases.mock.results[0].value.catch(() => {});
databases.mockRestore();
bootAppLock();
expect(appLockHold()).toBeInstanceOf(Promise);
await vi.waitFor(() => expect(appLockHold()).toBeNull());
});
it('a required lock still waits for its PIN, vault or no vault', async () => {
await headScript();
bridge();
(window as unknown as { bulwarkDesktop: Record<string, unknown> }).bulwarkDesktop.policy = {
get: () => ({
AppLockRequired: { value: true, locked: true, source: 'forced-machine' },
AppLockTimeoutMinutes: { value: null, locked: false, source: 'default' },
}),
onChange: () => () => {},
};
localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a' }] } }));
bootAppLock();
expect(getAppLockPhase()).toBe('setup');
expect(appLockHold()).toBeInstanceOf(Promise);
});
});
describe('policy', () => {
function withPolicy(required: { value: boolean; locked: boolean }, minutes: { value: number | null; locked: boolean } = { value: null, locked: false }) {
const appLock = bridge();
+3
View File
@@ -40,6 +40,7 @@ import {
APP_LOCK_CHANNEL,
APP_LOCK_HOLDER,
APP_LOCK_KEY,
headScriptFoundNoVault,
isAppLockedNow,
postAppLockMessage,
readAppLockFile,
@@ -190,6 +191,8 @@ export function bootAppLock(): void {
return;
}
markDocument(false);
// The look already came back empty: nothing to hold back for.
if (headScriptFoundNoVault() && !setupNeeded()) return;
hold = newHold();
if (setupNeeded()) {
phase = 'setup';
+20 -1
View File
@@ -14,6 +14,10 @@ export const APP_LOCK_KEY = 'bulwark-app-lock';
/** When someone last used a window or tab. Shared by all of them. */
export const APP_LOCK_ACTIVITY_KEY = 'bulwark-app-lock-activity';
export const APP_LOCK_CHANNEL = 'bulwark:app-lock';
/** The IndexedDB database of lib/app-lock/vault.ts. */
export const VAULT_DB = 'bulwark-app-lock';
/** Set by the head script once it knows this browser has no vault. */
const NO_VAULT_GLOBAL = '__bulwarkNoAppLockVault';
/** Web Lock held by the tab that has the lock key, for as long as it lives. */
export const APP_LOCK_HOLDER = 'bulwark-app-lock-holder';
@@ -94,10 +98,25 @@ export function postAppLockMessage(message: AppLockMessage): void {
}
}
/** Runs in the head script, before the first paint. Self-contained: it is stringified. */
/**
* Runs in the head script, before the first paint. Self-contained: it is stringified.
*
* With no lock file it already asks whether there's a vault, which is what a start has to
* know before it may go on. The answer is usually in before the page's scripts have
* loaded, so someone with no lock never waits for the look. Only listing the databases:
* opening one would create it.
*/
export function appLockBootScript(): string {
return `try {
var appLock = JSON.parse(localStorage.getItem(${JSON.stringify(APP_LOCK_KEY)}) || 'null');
if (appLock && appLock.on === true) document.documentElement.setAttribute('data-app-locked', '');
else indexedDB.databases().then(function (dbs) {
if (!dbs.some(function (db) { return db.name === ${JSON.stringify(VAULT_DB)}; })) window[${JSON.stringify(NO_VAULT_GLOBAL)}] = true;
}, function () {});
} catch (e) {}`;
}
/** The head script's look has answered, and there is no vault. Anything else says nothing. */
export function headScriptFoundNoVault(): boolean {
return typeof window !== 'undefined' && (window as unknown as Record<string, unknown>)[NO_VAULT_GLOBAL] === true;
}
+2 -1
View File
@@ -15,11 +15,12 @@
* no secret service) says no, and the wrap stays as it is.
*/
import { VAULT_DB } from './state';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { sealFor, createSessionOpener, type SealedAnswer } from '@/lib/popout/sealed';
import { keyFromPin, keyFromPrf, ObviousPinError, open, PBKDF2_ITERATIONS, pinIsObvious, randomBytes, seal, type WrappedBytes } from './crypto';
export const VAULT_DB = 'bulwark-app-lock';
export { VAULT_DB };
const DB_VERSION = 1;
const RECORDS = 'records';
const SEALED = 'sealed';
@@ -116,6 +116,19 @@ test.describe("server HTML without JavaScript", () => {
});
}
// React moves a big finished Suspense boundary out of the shell, into a hidden
// <div> a script reveals later: a frame or up to 300 ms with no skeleton at all.
// The cases above only see that when the reveal loses the race with the load
// event, so this reads the bytes.
for (const path of ["/en", "/en/mail/folder/inbox", "/en/calendar/week/2026-11-04", "/en/contacts", "/en/files/Documents", "/en/settings", "/en/pro", "/en/login"]) {
test(`${path}: the skeleton is in the page, not in a segment a script reveals`, async ({ request }) => {
const html = await (await request.get(path)).text();
const skeleton = html.indexOf("data-boot-skeleton=");
expect(skeleton, "a skeleton in the HTML").toBeGreaterThan(-1);
expect(html.slice(0, skeleton), "a hidden segment before the skeleton").not.toMatch(/<div hidden id="S:/);
});
}
test("a visitor with nothing stored sees the login card's shape", async ({ browser }) => {
for (const path of ["/en/mail/folder/inbox", "/en/calendar/week/2026-11-04", "/en/settings"]) {
const context = await browser.newContext();
@@ -171,6 +184,25 @@ async function waitLoaded(page: Page) {
await page.waitForTimeout(1500);
}
// A failed hydration throws the skeleton away and draws the page again, which is what
// the skeleton is there to avoid. The login page's own boundary hydrates after the app
// around it, so a few loads, as it depends on what got there first.
test("the login page hydrates what the server sent", async ({ browser }) => {
const errors: string[] = [];
for (let i = 0; i < 4; i++) {
const context = await browser.newContext();
const page = await context.newPage();
const note = (text: string) => { if (/hydrat|#418|#423|#425/i.test(text)) errors.push(text.slice(0, 200)); };
page.on("pageerror", (e) => note(e.message));
page.on("console", (m) => { if (m.type() === "error") note(m.text()); });
await page.goto("/en/login");
await page.getByRole("button", { name: /^sign in$/i }).first().waitFor({ timeout: 60_000 });
await page.waitForTimeout(500);
await context.close();
}
expect(errors).toEqual([]);
});
test("layout shift from navigation to the loaded page stays at or under 0.01", async ({ browser }) => {
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await recordShifts(context);
+11
View File
@@ -51,6 +51,17 @@ describe('email-store selection', () => {
expect(ticked()).toBe(1);
});
it('leaves an empty selection the same object, so nothing reading it renders again', () => {
useEmailStore.setState({ selectedEmailKeys: new Set(), lastSelectedEmailKey: 'opened' });
const empty = useEmailStore.getState().selectedEmailKeys;
const pending = new Promise<never>(() => {});
useEmailStore.getState().setSearchQuery('rollback');
void useEmailStore.getState().searchEmails({ searchEmails: () => pending } as never, 'other');
useEmailStore.getState().clearSelection();
expect(useEmailStore.getState().selectedEmailKeys).toBe(empty);
expect(useEmailStore.getState().lastSelectedEmailKey).toBeNull();
});
it('drops them when a search runs for another query, before its results land', () => {
const pending = new Promise<never>(() => {});
void useEmailStore.getState().searchEmails({ searchEmails: () => pending } as never, 'rollback');
+11 -7
View File
@@ -71,7 +71,11 @@ type PendingUndoSend = {
submissionAccountId?: string;
};
const NO_SELECTION = () => ({ selectedEmailKeys: new Set<string>(), lastSelectedEmailKey: null });
// Nothing ticked stays the same object: a new empty set re-renders all that reads the selection.
const NO_SELECTION = (state: { selectedEmailKeys: Set<string> }) => ({
selectedEmailKeys: state.selectedEmailKeys.size === 0 ? state.selectedEmailKeys : new Set<string>(),
lastSelectedEmailKey: null,
});
/** Drop the matching emails from a key-based selection. */
function dropFromSelection(
@@ -3620,7 +3624,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// counted in the selection bar, with Delete acting on rows nobody can see.
setSearchQuery: (query) => set((state) => ({
searchQuery: query,
...(query === state.searchQuery ? {} : NO_SELECTION()),
...(query === state.searchQuery ? {} : NO_SELECTION(state)),
})),
setQuota: (quota) => set({ quota }),
@@ -3715,7 +3719,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
clearSelection: () => {
set({ selectedEmailKeys: new Set(), lastSelectedEmailKey: null });
set(NO_SELECTION(get()));
},
// JMAP operations
@@ -5378,7 +5382,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// trip later. What the mail view renders from is written only once the
// request is on its way (see afterRequestDispatch); the controller is
// recorded at once so a superseding search can still cancel this one.
set({ searchAbortController: controller, ...(typedQuery === get().searchQuery ? {} : NO_SELECTION()) });
set({ searchAbortController: controller, ...(typedQuery === get().searchQuery ? {} : NO_SELECTION(get())) });
afterRequestDispatch(() => {
if (get().searchAbortController === controller) set({ isLoading: true, error: null, searchQuery: typedQuery });
});
@@ -5616,7 +5620,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
setSearchFilters: (filters) => {
set((state) => ({
searchFilters: { ...state.searchFilters, ...filters },
...NO_SELECTION(),
...NO_SELECTION(state),
}));
},
@@ -5625,7 +5629,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set((state) => ({
searchMailboxId: mailboxId,
searchScopeFollowsFolder: false,
...(mailboxId !== state.searchMailboxId && (state.searchQuery.trim() || !isFilterEmpty(state.searchFilters)) ? NO_SELECTION() : {}),
...(mailboxId !== state.searchMailboxId && (state.searchQuery.trim() || !isFilterEmpty(state.searchFilters)) ? NO_SELECTION(state) : {}),
}));
},
@@ -5641,7 +5645,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
searchMailboxId: currentSearchScopeDefault(mailboxesInView(state), state.selectedMailbox),
searchScopeFollowsFolder: false,
...(isFilterEmpty(state.searchFilters) ? {} : NO_SELECTION()),
...(isFilterEmpty(state.searchFilters) ? {} : NO_SELECTION(state)),
}));
},