Finish the settings load when the tree remounts mid-load (ihasmail #29)

(cherry picked from commit 5ec06f40125cc320fba64edaa4ee1a1fb23d22d2)
This commit is contained in:
jcoffey-dev authored and John Coffey committed 2026-10-05 10:33:08 -07:00
1 parent c558693db5
commit 2e7f30c64e
3 files changed
+96 -30

No files matched your search

+13 -12
View File
@@ -19,7 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock";
import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify"; import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
import { publishWorkerFacts } from "@/lib/sw/swFacts"; import { publishWorkerFacts } from "@/lib/sw/swFacts";
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings"; import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync"; import { armSettingsSync, loadRemoteSettings, loadSettingsOnce, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
import { loadSettingsPolicy } from "@/lib/settingsPolicy"; import { loadSettingsPolicy } from "@/lib/settingsPolicy";
import { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable"; import { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable";
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n"; import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
@@ -141,22 +141,22 @@ function AuthedApp() {
* Once per account, not once per mount: this subtree is keyed on the * Once per account, not once per mount: this subtree is keyed on the
* language version, so picking a language throws it away and builds it * language version, so picking a language throws it away and builds it
* again. Re-reading the settings file there would apply a copy written * again. Re-reading the settings file there would apply a copy written
* before the change and undo it. * before the change and undo it. And the load is not this mount's to
* cancel: the settings file choosing a language remounts the tree midway
* through it, and a load cut off there never armed the pushes, so nothing
* changed afterwards was saved (Gitea issue #23). `loadSettingsOnce` runs
* it to the end and lets every mount wait on it.
*/ */
const [ready, setReady] = useState(PAINTED_FROM_CACHE); const [ready, setReady] = useState(PAINTED_FROM_CACHE);
useEffect(() => { useEffect(() => {
if (settingsAlreadyLoadedFor(accountId)) {
setReady(true);
return;
}
let canceled = false; let canceled = false;
void (async () => { void loadSettingsOnce(accountId, async (isCurrent) => {
/* Before the account's own settings, so both the seeding below and the /* Before the account's own settings, so both the seeding below and the
enforcement inside `hydrate` have something to apply. */ enforcement inside `hydrate` have something to apply. */
await loadSettingsPolicy(); await loadSettingsPolicy();
if (canceled) return; if (!isCurrent()) return;
const remote = await loadRemoteSettings(); const remote = await loadRemoteSettings();
if (canceled) return; if (!isCurrent()) return;
if (remote) useSettings.getState().hydrate(remote); if (remote) useSettings.getState().hydrate(remote);
// No settings file: this account has never had settings of its own, so // No settings file: this account has never had settings of its own, so
// the installation's defaults are what it starts on rather than // the installation's defaults are what it starts on rather than
@@ -178,15 +178,16 @@ function AuthedApp() {
// The catalog for whatever language that turned out to be. Hydrating // The catalog for whatever language that turned out to be. Hydrating
// asks for it; this is waiting for the answer. // asks for it; this is waiting for the answer.
await whenLanguageReady(); await whenLanguageReady();
if (canceled) return; if (!isCurrent()) return;
setReady(true);
// Pushes were held back until now so they could not race the load. A // Pushes were held back until now so they could not race the load. A
// change made while it was in flight was kept, and goes out here. // change made while it was in flight was kept, and goes out here.
armSettingsSync(); armSettingsSync();
// No file yet — seed one from what this browser has, so the next device // No file yet — seed one from what this browser has, so the next device
// to sign in starts from these rather than from the defaults. // to sign in starts from these rather than from the defaults.
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings)); if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
})(); }).then(() => {
if (!canceled) setReady(true);
});
return () => { return () => {
canceled = true; canceled = true;
}; };
+55 -8
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, mergeRemote, syncedPart, type Settings } from "@/store/settings"; import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, mergeRemote, syncedPart, type Settings } from "@/store/settings";
import { isAppFolder } from "../appFolder"; import { isAppFolder } from "../appFolder";
import { settingsAlreadyLoadedFor, stopSettingsSync } from "../settingsSync"; import { loadSettingsOnce, stopSettingsSync } from "../settingsSync";
/** /**
* Settings used to live only in localStorage, so nothing followed the user * Settings used to live only in localStorage, so nothing followed the user
@@ -116,20 +116,67 @@ describe("a change made but not yet written up", () => {
expect(merged.uiLanguage).toBe("en"); expect(merged.uiLanguage).toBe("en");
}); });
it("reads the file again for an account after a sign-out", () => { it("reads the file again for an account after a sign-out", async () => {
stopSettingsSync(); stopSettingsSync();
expect(settingsAlreadyLoadedFor("a1")).toBe(false); let loads = 0;
const load = async () => { loads++; };
await loadSettingsOnce("a1", load);
// The remount that a language change causes must not read it a second time. // The remount that a language change causes must not read it a second time.
expect(settingsAlreadyLoadedFor("a1")).toBe(true); await loadSettingsOnce("a1", load);
expect(loads).toBe(1);
// Signing out drops the claim, so signing back in reads the file rather // Signing out drops the claim, so signing back in reads the file rather
// than trusting whatever the previous session left behind. // than trusting whatever the previous session left behind.
stopSettingsSync(); stopSettingsSync();
expect(settingsAlreadyLoadedFor("a1")).toBe(false); await loadSettingsOnce("a1", load);
expect(loads).toBe(2);
stopSettingsSync(); stopSettingsSync();
}); });
it("treats a missing account as already loaded, so nothing is fetched", () => { it("finishes a load that a remount interrupts, so changes are saved (Gitea #23)", async () => {
expect(settingsAlreadyLoadedFor(null)).toBe(true); stopSettingsSync();
expect(settingsAlreadyLoadedFor(undefined)).toBe(true); let release!: () => void;
const gate = new Promise<void>((r) => { release = r; });
let loads = 0;
let armed = false;
const load = async (isCurrent: () => boolean) => {
loads++;
await gate;
if (!isCurrent()) return;
armed = true;
};
// First mount starts the load; the settings file picks a language and the
// tree remounts before the load has finished.
const first = loadSettingsOnce("a1", load);
const second = loadSettingsOnce("a1", load);
expect(second).toBe(first);
release();
await second;
expect(loads).toBe(1);
// This is what used to be skipped: the remounted tree took the
// already-loaded path and nothing armed the pushes.
expect(armed).toBe(true);
stopSettingsSync();
});
it("stops a load that a sign-out overtakes", async () => {
stopSettingsSync();
let release!: () => void;
const gate = new Promise<void>((r) => { release = r; });
let applied = false;
const pending = loadSettingsOnce("a1", async (isCurrent) => {
await gate;
if (isCurrent()) applied = true;
});
stopSettingsSync();
release();
await pending;
expect(applied).toBe(false);
});
it("treats a missing account as already loaded, so nothing is fetched", async () => {
let loads = 0;
await loadSettingsOnce(null, async () => { loads++; });
await loadSettingsOnce(undefined, async () => { loads++; });
expect(loads).toBe(0);
}); });
}); });
+28 -10
View File
@@ -34,6 +34,9 @@ let inFlight: Promise<void> | null = null;
/** Nothing is pushed before the first load has settled, or we would race it. */ /** Nothing is pushed before the first load has settled, or we would race it. */
let armed = false; let armed = false;
let loadedFor: string | null = null; let loadedFor: string | null = null;
let loading: Promise<void> | null = null;
/** Bumped by every new load and every sign-out; a load checks it is still the latest. */
let generation = 0;
let listenersBound = false; let listenersBound = false;
export function settingsSyncAvailable(): boolean { export function settingsSyncAvailable(): boolean {
@@ -64,21 +67,34 @@ export async function loadRemoteSettings(): Promise<Record<string, unknown> | nu
} }
/** /**
* Has this account's settings file already been read on this page load? * Load this account's settings, once per page load, however many times the
* caller mounts.
* *
* Claims the account as a side effect, so two callers cannot both start a * The subtree that does the reading is keyed on the language version, so it
* read. The subtree that does the reading is keyed on the language version * is remounted whenever the language changes -- including when the settings
* and so is deliberately remounted whenever somebody picks a language; * file itself picks one. Tying the load to a mount meant that remount canceled
* without this the remount re-reads a file written before the change and * it halfway: the new mount saw the account already claimed and skipped the
* applies it, putting the old language back. * load, and nothing armed the pushes, so every later change was silently
* dropped (Gitea issue #23). Now the load belongs to the account, not the
* mount: every mount waits on the same promise, and the load runs to the end.
* *
* Re-reading on a remount is still wrong -- it would apply a file written
* before the change and undo it -- which is why it is shared, not repeated.
*
* `isCurrent` turns false once the session that started the load signs out,
* so a load overtaken by a sign-out stops instead of applying stale settings.
* Cleared by `stopSettingsSync`, so signing out and back in reads again. * Cleared by `stopSettingsSync`, so signing out and back in reads again.
*/ */
export function settingsAlreadyLoadedFor(accountId: string | null | undefined): boolean { export function loadSettingsOnce(
if (!accountId) return true; accountId: string | null | undefined,
if (loadedFor === accountId) return true; load: (isCurrent: () => boolean) => Promise<void>,
): Promise<void> {
if (!accountId) return Promise.resolve();
if (loadedFor === accountId && loading) return loading;
loadedFor = accountId; loadedFor = accountId;
return false; const mine = ++generation;
loading = load(() => generation === mine);
return loading;
} }
/** Allow pushes. Called once the first load has settled, either way. */ /** Allow pushes. Called once the first load has settled, either way. */
@@ -93,6 +109,8 @@ export function armSettingsSync(): void {
export function stopSettingsSync(): void { export function stopSettingsSync(): void {
armed = false; armed = false;
loadedFor = null; loadedFor = null;
loading = null;
generation++;
pending = null; pending = null;
if (timer !== null) { if (timer !== null) {
window.clearTimeout(timer); window.clearTimeout(timer);