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(/
{
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);
diff --git a/stores/__tests__/email-selection.test.ts b/stores/__tests__/email-selection.test.ts
index 88cf269ef..6e995e200 100644
--- a/stores/__tests__/email-selection.test.ts
+++ b/stores/__tests__/email-selection.test.ts
@@ -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(() => {});
+ 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(() => {});
void useEmailStore.getState().searchEmails({ searchEmails: () => pending } as never, 'rollback');
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 74e798d90..96d37325e 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -71,7 +71,11 @@ type PendingUndoSend = {
submissionAccountId?: string;
};
-const NO_SELECTION = () => ({ selectedEmailKeys: new Set(), 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 }) => ({
+ selectedEmailKeys: state.selectedEmailKeys.size === 0 ? state.selectedEmailKeys : new Set(),
+ lastSelectedEmailKey: null,
+});
/** Drop the matching emails from a key-based selection. */
function dropFromSelection(
@@ -3620,7 +3624,7 @@ export const useEmailStore = create((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((set, get) => ({
},
clearSelection: () => {
- set({ selectedEmailKeys: new Set(), lastSelectedEmailKey: null });
+ set(NO_SELECTION(get()));
},
// JMAP operations
@@ -5378,7 +5382,7 @@ export const useEmailStore = create((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((set, get) => ({
setSearchFilters: (filters) => {
set((state) => ({
searchFilters: { ...state.searchFilters, ...filters },
- ...NO_SELECTION(),
+ ...NO_SELECTION(state),
}));
},
@@ -5625,7 +5629,7 @@ export const useEmailStore = create((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((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)),
}));
},