From dfa37ee311c307801b71bd5d0f29fac51845022b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:29:03 +0100 Subject: [PATCH] fix: take the browser's templates into the account as soon as its synced settings have loaded --- .../account-template-sync-driver.test.tsx | 48 +++++++++++++++++++ .../templates/account-template-sync.tsx | 12 ++++- stores/settings-store.ts | 9 ++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 components/templates/__tests__/account-template-sync-driver.test.tsx diff --git a/components/templates/__tests__/account-template-sync-driver.test.tsx b/components/templates/__tests__/account-template-sync-driver.test.tsx new file mode 100644 index 000000000..9bcfb4cc4 --- /dev/null +++ b/components/templates/__tests__/account-template-sync-driver.test.tsx @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; + +// The driver syncs the active account's templates when its synced settings +// have loaded, also when the load changed nothing in the browser's list: +// only then is it known whose that list is. + +const sync = vi.hoisted(() => ({ schedule: vi.fn(), refresh: vi.fn() })); +vi.mock('@/lib/templates/personal-templates', () => ({ + scheduleAccountSync: sync.schedule, + refreshAccountTemplates: sync.refresh, +})); +vi.mock('@/lib/templates/team-templates', () => ({ refreshTeamTemplates: vi.fn() })); +vi.mock('@/lib/browser-navigation', async (importOriginal) => ({ + ...(await importOriginal>()), + apiFetch: vi.fn(async () => new Response(JSON.stringify({ settings: { theme: 'dark' } }), { status: 200 })), +})); + +import { AccountTemplateSync } from '../account-template-sync'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +const ACCOUNT = 'carol@example.org@mail.example.com'; +const original = useAuthStore.getState(); + +beforeEach(() => { + sync.schedule.mockClear(); + localStorage.removeItem('settings-sync-owner'); + useAuthStore.setState({ isAuthenticated: true, activeAccountId: ACCOUNT, client: {} as never }); +}); +afterEach(() => { + useAuthStore.setState({ isAuthenticated: original.isAuthenticated, activeAccountId: original.activeAccountId, client: original.client }); +}); + +describe('AccountTemplateSync', () => { + it('syncs the active account once its settings have loaded', async () => { + render(); + expect(sync.schedule).not.toHaveBeenCalled(); + await useSettingsStore.getState().loadFromServer('carol@example.org', 'https://mail.example.com'); + expect(sync.schedule).toHaveBeenCalledWith(ACCOUNT); + }); + + it('leaves another account\'s settings load alone', async () => { + render(); + await useSettingsStore.getState().loadFromServer('dave@example.org', 'https://mail.example.com'); + expect(sync.schedule).not.toHaveBeenCalled(); + }); +}); diff --git a/components/templates/account-template-sync.tsx b/components/templates/account-template-sync.tsx index 38c28c350..e2fb2167b 100644 --- a/components/templates/account-template-sync.tsx +++ b/components/templates/account-template-sync.tsx @@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth-store'; import { useAccountTemplatesStore } from '@/stores/account-templates-store'; import { refreshAccountTemplates, scheduleAccountSync } from '@/lib/templates/personal-templates'; import { useTemplateStore } from '@/stores/template-store'; +import { onSettingsLoadSettled } from '@/stores/settings-store'; import { refreshTeamTemplates } from '@/lib/templates/team-templates'; /** @@ -26,7 +27,16 @@ export function AccountTemplateSync() { // loaded, or a template changed there) is merged in at once. useEffect(() => { if (!isAuthenticated || !activeAccountId) return; - return useTemplateStore.subscribe(() => scheduleAccountSync(activeAccountId)); + const unsubscribe = useTemplateStore.subscribe(() => scheduleAccountSync(activeAccountId)); + // Once the account's synced settings have loaded, its browser list is + // known, even when the load changed nothing in it. + const unlisten = onSettingsLoadSettled((accountId) => { + if (accountId === activeAccountId) scheduleAccountSync(activeAccountId); + }); + return () => { + unsubscribe(); + unlisten(); + }; }, [activeAccountId, isAuthenticated]); useEffect(() => { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 7d672d459..216392860 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -65,6 +65,14 @@ export function isSettingsLoadSettled(accountId: string): boolean { return settledLoads.has(accountId); } +const settledListeners = new Set<(accountId: string) => void>(); + +/** Called whenever an account's settings load finishes (with or without a copy). */ +export function onSettingsLoadSettled(listener: (accountId: string) => void): () => void { + settledListeners.add(listener); + return () => settledListeners.delete(listener); +} + /** The account whose server copy the local settings (and template list) mirror; null if none. */ export function readSettingsOwner(): string | null { try { @@ -1819,6 +1827,7 @@ export const useSettingsStore = create()( return failWithoutForeignState(); } finally { settledLoads.add(accountId); + for (const listener of settledListeners) listener(accountId); } },