mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
fix: show the page skeleton from the first frame again, with or without an app lock
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user