Finish the settings load when the tree remounts mid-load (ihasmail #29)
(cherry picked from commit 5ec06f40125cc320fba64edaa4ee1a1fb23d22d2)
This commit is contained in:
1 parent
c558693db5
commit
2e7f30c64e
3 files changed
+96
-30
No files matched your search
+13
-12
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user