From a9a0214b24fb65d1dcb55629636a0d9cc501dfc2 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 17:59:21 -0700 Subject: [PATCH 1/4] fix(web): keep rapid settings edits from reverting each other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server settings only change locally once the server echoes `settingsUpdated` back over the websocket, and `useUpdateSettingsTarget` applied nothing locally in the meantime. Any edit issued inside that round trip was therefore computed from pre-edit settings, and because keys such as `providerInstances` are whole-value replacements rather than deep merges, the second write silently reverted the first: toggle one provider off, toggle a second off a moment later, and the first provider's switch snaps back on and never reaches settings.json. Retain in-flight patches per environment and replay them over the server value with the same `applyServerSettingsPatch` the server runs, so reads and follow-up patches build on the edit the user just made. Patches are dropped once no write is outstanding — including after a failed write, so the server value stays authoritative. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/hooks/pendingServerSettings.test.ts | 118 ++++++++++++++++++ apps/web/src/hooks/pendingServerSettings.ts | 107 ++++++++++++++++ apps/web/src/hooks/useSettings.ts | 40 +++++- 3 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/hooks/pendingServerSettings.test.ts create mode 100644 apps/web/src/hooks/pendingServerSettings.ts diff --git a/apps/web/src/hooks/pendingServerSettings.test.ts b/apps/web/src/hooks/pendingServerSettings.test.ts new file mode 100644 index 00000000000..0d8f99d3892 --- /dev/null +++ b/apps/web/src/hooks/pendingServerSettings.test.ts @@ -0,0 +1,118 @@ +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + __resetPendingServerPatchesForTests, + applyPendingServerPatches, + getPendingServerPatches, + releasePendingServerPatch, + retainPendingServerPatch, + subscribePendingServerPatches, +} from "./pendingServerSettings"; + +const environmentId = EnvironmentId.make("environment-1"); +const codexId = ProviderInstanceId.make("codex"); +const claudeId = ProviderInstanceId.make("claudeAgent"); + +const providerInstance = (driver: string, enabled: boolean) => ({ + driver: ProviderDriverKind.make(driver), + enabled, +}); + +afterEach(() => { + __resetPendingServerPatchesForTests(); +}); + +describe("pendingServerSettings", () => { + it("has no overlay before a write is dispatched", () => { + expect(getPendingServerPatches(environmentId)).toEqual([]); + expect(getPendingServerPatches(null)).toEqual([]); + expect(applyPendingServerPatches(DEFAULT_SERVER_SETTINGS, [])).toBe(DEFAULT_SERVER_SETTINGS); + }); + + it("keeps a second provider toggle from reverting the first one", () => { + // Disabling codex is dispatched from the server's own (empty) map. + const disableCodex = { + providerInstances: { [codexId]: providerInstance("codex", false) }, + }; + retainPendingServerPatch(environmentId, disableCodex); + + // Before the echo lands, the panel must already see codex disabled so the + // next whole-map replacement it builds carries that edit forward. + const optimistic = applyPendingServerPatches( + DEFAULT_SERVER_SETTINGS, + getPendingServerPatches(environmentId), + ); + expect(optimistic.providerInstances[codexId]?.enabled).toBe(false); + + const disableClaude = { + providerInstances: { + ...optimistic.providerInstances, + [claudeId]: providerInstance("claudeAgent", false), + }, + }; + retainPendingServerPatch(environmentId, disableClaude); + + const both = applyPendingServerPatches( + DEFAULT_SERVER_SETTINGS, + getPendingServerPatches(environmentId), + ); + expect(both.providerInstances[codexId]?.enabled).toBe(false); + expect(both.providerInstances[claudeId]?.enabled).toBe(false); + }); + + it("retains the overlay until the last outstanding write settles", () => { + const patch = { providerInstances: { [codexId]: providerInstance("codex", false) } }; + retainPendingServerPatch(environmentId, patch); + retainPendingServerPatch(environmentId, patch); + + releasePendingServerPatch(environmentId); + expect(getPendingServerPatches(environmentId)).toHaveLength(2); + + releasePendingServerPatch(environmentId); + expect(getPendingServerPatches(environmentId)).toEqual([]); + }); + + it("drops the overlay for a failed write so the server value wins again", () => { + retainPendingServerPatch(environmentId, { + providerInstances: { [codexId]: providerInstance("codex", false) }, + }); + releasePendingServerPatch(environmentId); + + const settings = applyPendingServerPatches( + DEFAULT_SERVER_SETTINGS, + getPendingServerPatches(environmentId), + ); + expect(settings).toBe(DEFAULT_SERVER_SETTINGS); + }); + + it("scopes pending writes to their own environment", () => { + const otherEnvironmentId = EnvironmentId.make("environment-2"); + retainPendingServerPatch(environmentId, { + providerInstances: { [codexId]: providerInstance("codex", false) }, + }); + + expect(getPendingServerPatches(otherEnvironmentId)).toEqual([]); + releasePendingServerPatch(otherEnvironmentId); + expect(getPendingServerPatches(environmentId)).toHaveLength(1); + }); + + it("notifies subscribers when the pending set changes", () => { + let notifications = 0; + const unsubscribe = subscribePendingServerPatches(() => { + notifications += 1; + }); + + retainPendingServerPatch(environmentId, { enableAssistantStreaming: false }); + releasePendingServerPatch(environmentId); + unsubscribe(); + retainPendingServerPatch(environmentId, { enableAssistantStreaming: false }); + + expect(notifications).toBe(2); + }); +}); diff --git a/apps/web/src/hooks/pendingServerSettings.ts b/apps/web/src/hooks/pendingServerSettings.ts new file mode 100644 index 00000000000..04197cf620c --- /dev/null +++ b/apps/web/src/hooks/pendingServerSettings.ts @@ -0,0 +1,107 @@ +/** + * Optimistic overlay for server settings writes that are still in flight. + * + * Server settings only change locally once the server echoes a + * `settingsUpdated` broadcast back over the websocket. Until that round trip + * lands, readers still see pre-edit settings, so a second edit issued inside + * the window is computed from stale state — and because keys such as + * `providerInstances` are whole-value replacements rather than deep merges, + * that second write silently reverts the first one. (Toggle one provider off, + * toggle a second off a moment later, and the first provider's switch snaps + * back on once its own write is overwritten.) + * + * Each in-flight patch is retained here and replayed on top of the server value + * through the same `applyServerSettingsPatch` the server runs, so both reads and + * follow-up patches are built from the edit the user just made. Retained patches + * are dropped once no write is outstanding for that environment, at which point + * the server value is authoritative again — including when a write failed. + * + * @module hooks/pendingServerSettings + */ +import type { EnvironmentId, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; + +interface PendingServerPatches { + readonly patches: ReadonlyArray; + /** Writes dispatched for this environment that have not settled yet. */ + readonly inFlight: number; +} + +export const NO_PENDING_SERVER_PATCHES: ReadonlyArray = []; + +const pendingByEnvironment = new Map(); +const listeners = new Set<() => void>(); + +function emitChange(): void { + for (const listener of listeners) { + listener(); + } +} + +export function subscribePendingServerPatches(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Snapshot accessor for `useSyncExternalStore`: the returned array is stable + * until the environment's pending set actually changes. + */ +export function getPendingServerPatches( + environmentId: EnvironmentId | null, +): ReadonlyArray { + if (environmentId === null) return NO_PENDING_SERVER_PATCHES; + return pendingByEnvironment.get(environmentId)?.patches ?? NO_PENDING_SERVER_PATCHES; +} + +/** Record a patch that has just been dispatched to the server. */ +export function retainPendingServerPatch( + environmentId: EnvironmentId, + patch: ServerSettingsPatch, +): void { + const pending = pendingByEnvironment.get(environmentId); + pendingByEnvironment.set(environmentId, { + patches: [...(pending?.patches ?? NO_PENDING_SERVER_PATCHES), patch], + inFlight: (pending?.inFlight ?? 0) + 1, + }); + emitChange(); +} + +/** + * Mark one dispatched write as settled. Patches are only forgotten when the + * last outstanding write settles: releasing them one at a time would expose the + * server value before its later echo arrived, flickering the UI back to a state + * the user has already edited away from. + */ +export function releasePendingServerPatch(environmentId: EnvironmentId): void { + const pending = pendingByEnvironment.get(environmentId); + if (pending === undefined) return; + if (pending.inFlight <= 1) { + pendingByEnvironment.delete(environmentId); + } else { + pendingByEnvironment.set(environmentId, { + patches: pending.patches, + inFlight: pending.inFlight - 1, + }); + } + emitChange(); +} + +/** Replay in-flight patches over an authoritative server settings value. */ +export function applyPendingServerPatches( + settings: ServerSettings, + patches: ReadonlyArray, +): ServerSettings { + if (patches.length === 0) return settings; + return patches.reduce( + (current, patch) => applyServerSettingsPatch(current, patch), + settings, + ); +} + +export function __resetPendingServerPatchesForTests(): void { + pendingByEnvironment.clear(); + listeners.clear(); +} diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index fc0563b4ac6..206e2daaa38 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -24,6 +24,14 @@ import { type UnifiedSettings, } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + applyPendingServerPatches, + getPendingServerPatches, + NO_PENDING_SERVER_PATCHES, + releasePendingServerPatch, + retainPendingServerPatch, + subscribePendingServerPatches, +} from "./pendingServerSettings"; import { ensureLocalApi } from "~/localApi"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; @@ -142,6 +150,17 @@ function persistClientSettings(settings: ClientSettings): void { }); } +function usePendingServerPatches( + environmentId: EnvironmentId | null, +): ReadonlyArray { + const getSnapshot = useCallback(() => getPendingServerPatches(environmentId), [environmentId]); + return useSyncExternalStore( + subscribePendingServerPatches, + getSnapshot, + () => NO_PENDING_SERVER_PATCHES, + ); +} + // ── Key sets for routing patches ───────────────────────────────────── const SERVER_SETTINGS_KEYS = new Set(Struct.keys(ServerSettings.fields)); @@ -200,14 +219,21 @@ export function mergeEnvironmentSettings( } function useMergedSettings( + environmentId: EnvironmentId | null, serverSettings: ServerSettings, selector: ((settings: UnifiedSettings) => T) | undefined, ): T { const clientSettings = useClientSettingsValue(); + const pendingPatches = usePendingServerPatches(environmentId); + + const optimisticServerSettings = useMemo( + () => applyPendingServerPatches(serverSettings, pendingPatches), + [pendingPatches, serverSettings], + ); const merged = useMemo( - () => mergeEnvironmentSettings(serverSettings, clientSettings), - [clientSettings, serverSettings], + () => mergeEnvironmentSettings(optimisticServerSettings, clientSettings), + [clientSettings, optimisticServerSettings], ); return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); @@ -226,20 +252,21 @@ export function useEnvironmentSettings( selector?: (settings: UnifiedSettings) => T, ): T { const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); - return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector); + return useMergedSettings(environmentId, serverSettings ?? DEFAULT_SERVER_SETTINGS, selector); } /** Primary-only settings access for the settings UI and other explicitly global surfaces. */ export function usePrimarySettings( selector?: (settings: UnifiedSettings) => T, ): T { - return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); + const environmentId = usePrimaryEnvironment()?.environmentId ?? null; + return useMergedSettings(environmentId, useAtomValue(primaryServerSettingsAtom), selector); } /** * Returns an updater that routes each key to the correct backing store. * - * Server keys are optimistically patched in atom-backed server state, then + * Server keys are applied optimistically through `./pendingServerSettings` and * persisted via RPC. Client keys go through client persistence. */ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { @@ -253,9 +280,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { if (Object.keys(serverPatch).length > 0) { if (environmentId) { + retainPendingServerPatch(environmentId, serverPatch); void persistServerSettings({ environmentId, input: { patch: serverPatch }, + }).finally(() => { + releasePendingServerPatch(environmentId); }); } } From f5c6b2baa1f12f53030fac6f5a1d1c78aebbc3e3 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 27 Jul 2026 18:47:58 -0700 Subject: [PATCH 2/4] fix(web): rebase queued settings updates --- .../src/hooks/pendingServerSettings.test.ts | 42 ++-- apps/web/src/hooks/pendingServerSettings.ts | 214 +++++++++++++----- apps/web/src/hooks/useSettings.ts | 70 +++++- 3 files changed, 238 insertions(+), 88 deletions(-) diff --git a/apps/web/src/hooks/pendingServerSettings.test.ts b/apps/web/src/hooks/pendingServerSettings.test.ts index 0d8f99d3892..66a17f70813 100644 --- a/apps/web/src/hooks/pendingServerSettings.test.ts +++ b/apps/web/src/hooks/pendingServerSettings.test.ts @@ -5,13 +5,15 @@ import { ProviderInstanceId, } from "@t3tools/contracts"; import { afterEach, describe, expect, it } from "vite-plus/test"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { __resetPendingServerPatchesForTests, + acknowledgePendingServerSettings, applyPendingServerPatches, getPendingServerPatches, - releasePendingServerPatch, retainPendingServerPatch, + settlePendingServerPatch, subscribePendingServerPatches, } from "./pendingServerSettings"; @@ -23,6 +25,13 @@ const providerInstance = (driver: string, enabled: boolean) => ({ driver: ProviderDriverKind.make(driver), enabled, }); +const retain = (patch: Parameters[1]) => + retainPendingServerPatch( + environmentId, + patch, + applyPendingServerPatches(DEFAULT_SERVER_SETTINGS, getPendingServerPatches(environmentId)), + DEFAULT_SERVER_SETTINGS, + ); afterEach(() => { __resetPendingServerPatchesForTests(); @@ -40,7 +49,7 @@ describe("pendingServerSettings", () => { const disableCodex = { providerInstances: { [codexId]: providerInstance("codex", false) }, }; - retainPendingServerPatch(environmentId, disableCodex); + retain(disableCodex); // Before the echo lands, the panel must already see codex disabled so the // next whole-map replacement it builds carries that edit forward. @@ -56,7 +65,7 @@ describe("pendingServerSettings", () => { [claudeId]: providerInstance("claudeAgent", false), }, }; - retainPendingServerPatch(environmentId, disableClaude); + retain(disableClaude); const both = applyPendingServerPatches( DEFAULT_SERVER_SETTINGS, @@ -66,23 +75,23 @@ describe("pendingServerSettings", () => { expect(both.providerInstances[claudeId]?.enabled).toBe(false); }); - it("retains the overlay until the last outstanding write settles", () => { + it("retains a successful overlay until its settings echo arrives", () => { const patch = { providerInstances: { [codexId]: providerInstance("codex", false) } }; - retainPendingServerPatch(environmentId, patch); - retainPendingServerPatch(environmentId, patch); + const id = retain(patch); + const settledSettings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, patch); - releasePendingServerPatch(environmentId); - expect(getPendingServerPatches(environmentId)).toHaveLength(2); + settlePendingServerPatch(environmentId, id, settledSettings); + expect(getPendingServerPatches(environmentId)).toHaveLength(1); - releasePendingServerPatch(environmentId); + acknowledgePendingServerSettings(environmentId, settledSettings); expect(getPendingServerPatches(environmentId)).toEqual([]); }); it("drops the overlay for a failed write so the server value wins again", () => { - retainPendingServerPatch(environmentId, { + const id = retain({ providerInstances: { [codexId]: providerInstance("codex", false) }, }); - releasePendingServerPatch(environmentId); + settlePendingServerPatch(environmentId, id, null); const settings = applyPendingServerPatches( DEFAULT_SERVER_SETTINGS, @@ -93,12 +102,9 @@ describe("pendingServerSettings", () => { it("scopes pending writes to their own environment", () => { const otherEnvironmentId = EnvironmentId.make("environment-2"); - retainPendingServerPatch(environmentId, { - providerInstances: { [codexId]: providerInstance("codex", false) }, - }); + retain({ providerInstances: { [codexId]: providerInstance("codex", false) } }); expect(getPendingServerPatches(otherEnvironmentId)).toEqual([]); - releasePendingServerPatch(otherEnvironmentId); expect(getPendingServerPatches(environmentId)).toHaveLength(1); }); @@ -108,10 +114,10 @@ describe("pendingServerSettings", () => { notifications += 1; }); - retainPendingServerPatch(environmentId, { enableAssistantStreaming: false }); - releasePendingServerPatch(environmentId); + const id = retain({ enableAssistantStreaming: false }); + settlePendingServerPatch(environmentId, id, null); unsubscribe(); - retainPendingServerPatch(environmentId, { enableAssistantStreaming: false }); + retain({ enableAssistantStreaming: false }); expect(notifications).toBe(2); }); diff --git a/apps/web/src/hooks/pendingServerSettings.ts b/apps/web/src/hooks/pendingServerSettings.ts index 04197cf620c..39ff090925a 100644 --- a/apps/web/src/hooks/pendingServerSettings.ts +++ b/apps/web/src/hooks/pendingServerSettings.ts @@ -1,41 +1,84 @@ -/** - * Optimistic overlay for server settings writes that are still in flight. - * - * Server settings only change locally once the server echoes a - * `settingsUpdated` broadcast back over the websocket. Until that round trip - * lands, readers still see pre-edit settings, so a second edit issued inside - * the window is computed from stale state — and because keys such as - * `providerInstances` are whole-value replacements rather than deep merges, - * that second write silently reverts the first one. (Toggle one provider off, - * toggle a second off a moment later, and the first provider's switch snaps - * back on once its own write is overwritten.) - * - * Each in-flight patch is retained here and replayed on top of the server value - * through the same `applyServerSettingsPatch` the server runs, so both reads and - * follow-up patches are built from the edit the user just made. Retained patches - * are dropped once no write is outstanding for that environment, at which point - * the server value is authoritative again — including when a write failed. - * - * @module hooks/pendingServerSettings - */ import type { EnvironmentId, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; -interface PendingServerPatches { - readonly patches: ReadonlyArray; - /** Writes dispatched for this environment that have not settled yet. */ - readonly inFlight: number; +export interface PendingServerPatch { + readonly id: number; + readonly patch: ServerSettingsPatch; + readonly baseSettings: ServerSettings; + readonly settledSettings?: ServerSettings; +} + +interface PendingServerState { + readonly patches: ReadonlyArray; + readonly authoritativeSettings: ServerSettings; } -export const NO_PENDING_SERVER_PATCHES: ReadonlyArray = []; +export const NO_PENDING_SERVER_PATCHES: ReadonlyArray = []; -const pendingByEnvironment = new Map(); +const pendingByEnvironment = new Map(); const listeners = new Set<() => void>(); +let nextPendingPatchId = 1; function emitChange(): void { - for (const listener of listeners) { - listener(); + for (const listener of listeners) listener(); +} + +function structurallyEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) { + return false; } + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => structurallyEqual(value, right[index])) + ); + } + const leftRecord = left as Readonly>; + const rightRecord = right as Readonly>; + const leftKeys = Object.keys(leftRecord); + const rightKeys = Object.keys(rightRecord); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(rightRecord, key) && + structurallyEqual(leftRecord[key], rightRecord[key]), + ) + ); +} + +/** + * Preserve only the provider-instance edits made by this operation when its + * original optimistic base no longer matches the server after an earlier + * write failed. + */ +export function rebaseServerSettingsPatch( + patch: ServerSettingsPatch, + originalBase: ServerSettings, + currentBase: ServerSettings, +): ServerSettingsPatch { + if (patch.providerInstances === undefined) return patch; + + const rebasedProviderInstances = { ...currentBase.providerInstances }; + const keys = new Set([ + ...Object.keys(originalBase.providerInstances), + ...Object.keys(patch.providerInstances), + ]); + for (const key of keys) { + const instanceId = key as keyof typeof patch.providerInstances; + const previous = originalBase.providerInstances[instanceId]; + const next = patch.providerInstances[instanceId]; + if (structurallyEqual(previous, next)) continue; + if (next === undefined) { + delete rebasedProviderInstances[instanceId]; + } else { + rebasedProviderInstances[instanceId] = next; + } + } + return { ...patch, providerInstances: rebasedProviderInstances }; } export function subscribePendingServerPatches(listener: () => void): () => void { @@ -45,63 +88,118 @@ export function subscribePendingServerPatches(listener: () => void): () => void }; } -/** - * Snapshot accessor for `useSyncExternalStore`: the returned array is stable - * until the environment's pending set actually changes. - */ export function getPendingServerPatches( environmentId: EnvironmentId | null, -): ReadonlyArray { +): ReadonlyArray { if (environmentId === null) return NO_PENDING_SERVER_PATCHES; return pendingByEnvironment.get(environmentId)?.patches ?? NO_PENDING_SERVER_PATCHES; } -/** Record a patch that has just been dispatched to the server. */ +export function applyPendingServerPatches( + settings: ServerSettings, + patches: ReadonlyArray, +): ServerSettings { + return patches.reduce((current, pending) => { + const rebasedPatch = rebaseServerSettingsPatch( + pending.patch, + pending.baseSettings, + current, + ); + return applyServerSettingsPatch(current, rebasedPatch); + }, settings); +} + export function retainPendingServerPatch( environmentId: EnvironmentId, patch: ServerSettingsPatch, -): void { - const pending = pendingByEnvironment.get(environmentId); + baseSettings: ServerSettings, + authoritativeSettings: ServerSettings, +): number { + const existing = pendingByEnvironment.get(environmentId); + const id = nextPendingPatchId++; pendingByEnvironment.set(environmentId, { - patches: [...(pending?.patches ?? NO_PENDING_SERVER_PATCHES), patch], - inFlight: (pending?.inFlight ?? 0) + 1, + patches: [...(existing?.patches ?? NO_PENDING_SERVER_PATCHES), { id, patch, baseSettings }], + authoritativeSettings: existing?.authoritativeSettings ?? authoritativeSettings, }); emitChange(); + return id; } -/** - * Mark one dispatched write as settled. Patches are only forgotten when the - * last outstanding write settles: releasing them one at a time would expose the - * server value before its later echo arrived, flickering the UI back to a state - * the user has already edited away from. - */ -export function releasePendingServerPatch(environmentId: EnvironmentId): void { - const pending = pendingByEnvironment.get(environmentId); - if (pending === undefined) return; - if (pending.inFlight <= 1) { +export function getPendingServerPatchForDispatch( + environmentId: EnvironmentId, + id: number, +): ServerSettingsPatch | null { + const state = pendingByEnvironment.get(environmentId); + const pending = state?.patches.find((entry) => entry.id === id); + if (!state || !pending) return null; + return rebaseServerSettingsPatch( + pending.patch, + pending.baseSettings, + state.authoritativeSettings, + ); +} + +export function settlePendingServerPatch( + environmentId: EnvironmentId, + id: number, + settings: ServerSettings | null, +): void { + const state = pendingByEnvironment.get(environmentId); + if (!state) return; + const patches = + settings === null + ? state.patches.filter((entry) => entry.id !== id) + : state.patches.map((entry) => + entry.id === id ? { ...entry, settledSettings: settings } : entry, + ); + if (patches.length === 0 && settings === null) { pendingByEnvironment.delete(environmentId); } else { pendingByEnvironment.set(environmentId, { - patches: pending.patches, - inFlight: pending.inFlight - 1, + patches, + authoritativeSettings: settings ?? state.authoritativeSettings, }); } emitChange(); } -/** Replay in-flight patches over an authoritative server settings value. */ -export function applyPendingServerPatches( +/** + * Retire successful overlays only when their actual settingsUpdated payload is + * observed. An initial config snapshot cannot acknowledge a write accidentally. + */ +export function acknowledgePendingServerSettings( + environmentId: EnvironmentId, settings: ServerSettings, - patches: ReadonlyArray, -): ServerSettings { - if (patches.length === 0) return settings; - return patches.reduce( - (current, patch) => applyServerSettingsPatch(current, patch), - settings, +): void { + const state = pendingByEnvironment.get(environmentId); + if (!state) return; + const acknowledgedIndex = state.patches.findLastIndex( + (entry) => + entry.settledSettings !== undefined && structurallyEqual(entry.settledSettings, settings), ); + if (acknowledgedIndex >= 0) { + const patches = state.patches.slice(acknowledgedIndex + 1); + if (patches.length === 0) { + pendingByEnvironment.delete(environmentId); + } else { + pendingByEnvironment.set(environmentId, { + patches, + authoritativeSettings: settings, + }); + } + emitChange(); + return; + } + if (!state.patches.some((entry) => entry.settledSettings !== undefined)) { + pendingByEnvironment.set(environmentId, { + ...state, + authoritativeSettings: settings, + }); + } } export function __resetPendingServerPatchesForTests(): void { pendingByEnvironment.clear(); listeners.clear(); + nextPendingPatchId = 1; } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 206e2daaa38..adeb022e8cb 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -9,7 +9,7 @@ * access is intentionally named as such so environment-sensitive consumers * cannot silently read the wrong server's settings. */ -import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; import { useAtomValue } from "@effect/atom-react"; import { DEFAULT_SERVER_SETTINGS, @@ -25,11 +25,14 @@ import { } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { + acknowledgePendingServerSettings, applyPendingServerPatches, getPendingServerPatches, + getPendingServerPatchForDispatch, NO_PENDING_SERVER_PATCHES, - releasePendingServerPatch, + type PendingServerPatch, retainPendingServerPatch, + settlePendingServerPatch, subscribePendingServerPatches, } from "./pendingServerSettings"; import { ensureLocalApi } from "~/localApi"; @@ -48,6 +51,7 @@ let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; let clientSettingsHydrated = false; let clientSettingsHydrationPromise: Promise | null = null; let clientSettingsHydrationGeneration = 0; +const serverSettingsWriteQueueByEnvironment = new Map>(); function emitClientSettingsChange() { for (const listener of clientSettingsListeners) { @@ -152,7 +156,7 @@ function persistClientSettings(settings: ClientSettings): void { function usePendingServerPatches( environmentId: EnvironmentId | null, -): ReadonlyArray { +): ReadonlyArray { const getSnapshot = useCallback(() => getPendingServerPatches(environmentId), [environmentId]); return useSyncExternalStore( subscribePendingServerPatches, @@ -225,6 +229,11 @@ function useMergedSettings( ): T { const clientSettings = useClientSettingsValue(); const pendingPatches = usePendingServerPatches(environmentId); + useEffect(() => { + if (environmentId) { + acknowledgePendingServerSettings(environmentId, serverSettings); + } + }, [environmentId, serverSettings]); const optimisticServerSettings = useMemo( () => applyPendingServerPatches(serverSettings, pendingPatches), @@ -269,7 +278,10 @@ export function usePrimarySettings( * Server keys are applied optimistically through `./pendingServerSettings` and * persisted via RPC. Client keys go through client persistence. */ -function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { +function useUpdateSettingsTarget( + environmentId: EnvironmentId | null, + serverSettings: ServerSettings, +) { const persistServerSettings = useAtomCommand( serverEnvironment.updateSettings, "server settings update", @@ -280,12 +292,40 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { if (Object.keys(serverPatch).length > 0) { if (environmentId) { - retainPendingServerPatch(environmentId, serverPatch); - void persistServerSettings({ + const optimisticBase = applyPendingServerPatches( + serverSettings, + getPendingServerPatches(environmentId), + ); + const pendingId = retainPendingServerPatch( environmentId, - input: { patch: serverPatch }, - }).finally(() => { - releasePendingServerPatch(environmentId); + serverPatch, + optimisticBase, + serverSettings, + ); + const previous = + serverSettingsWriteQueueByEnvironment.get(environmentId) ?? Promise.resolve(); + const current = previous + .then(async () => { + const pendingPatch = getPendingServerPatchForDispatch(environmentId, pendingId); + if (!pendingPatch) return; + const result = await persistServerSettings({ + environmentId, + input: { patch: pendingPatch }, + }); + settlePendingServerPatch( + environmentId, + pendingId, + result._tag === "Success" ? result.value : null, + ); + }) + .catch(() => { + settlePendingServerPatch(environmentId, pendingId, null); + }); + serverSettingsWriteQueueByEnvironment.set(environmentId, current); + void current.finally(() => { + if (serverSettingsWriteQueueByEnvironment.get(environmentId) === current) { + serverSettingsWriteQueueByEnvironment.delete(environmentId); + } }); } } @@ -297,18 +337,24 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { }); } }, - [environmentId, persistServerSettings], + [environmentId, persistServerSettings, serverSettings], ); return updateSettings; } export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { - return useUpdateSettingsTarget(environmentId); + const serverSettings = + useAtomValue(serverEnvironment.settingsValueAtom(environmentId)) ?? DEFAULT_SERVER_SETTINGS; + return useUpdateSettingsTarget(environmentId, serverSettings); } export function useUpdatePrimarySettings() { - return useUpdateSettingsTarget(usePrimaryEnvironment()?.environmentId ?? null); + const environmentId = usePrimaryEnvironment()?.environmentId ?? null; + return useUpdateSettingsTarget( + environmentId, + useAtomValue(primaryServerSettingsAtom), + ); } export function useUpdateClientSettings() { From 28517a08c991bbe03eec46cdca8f384afc5813c8 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 27 Jul 2026 19:14:55 -0700 Subject: [PATCH 3/4] fix(web): retire settings patches after early echo --- .../src/hooks/pendingServerSettings.test.ts | 27 +++++++++++++++ apps/web/src/hooks/pendingServerSettings.ts | 34 +++++++++++++------ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/apps/web/src/hooks/pendingServerSettings.test.ts b/apps/web/src/hooks/pendingServerSettings.test.ts index 66a17f70813..afc9f1ffc17 100644 --- a/apps/web/src/hooks/pendingServerSettings.test.ts +++ b/apps/web/src/hooks/pendingServerSettings.test.ts @@ -87,6 +87,33 @@ describe("pendingServerSettings", () => { expect(getPendingServerPatches(environmentId)).toEqual([]); }); + it("retires a successful overlay when its settings echo arrives before the RPC settles", () => { + const patch = { providerInstances: { [codexId]: providerInstance("codex", false) } }; + const id = retain(patch); + const settledSettings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, patch); + + acknowledgePendingServerSettings(environmentId, settledSettings); + expect(getPendingServerPatches(environmentId)).toHaveLength(1); + + settlePendingServerPatch(environmentId, id, settledSettings); + expect(getPendingServerPatches(environmentId)).toEqual([]); + }); + + it("records an early echo while an older successful patch is still awaiting its echo", () => { + const firstPatch = { enableAssistantStreaming: false }; + const firstId = retain(firstPatch); + const secondPatch = { enableProviderUpdateChecks: false }; + const secondId = retain(secondPatch); + const firstSettings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, firstPatch); + const secondSettings = applyServerSettingsPatch(firstSettings, secondPatch); + + settlePendingServerPatch(environmentId, firstId, firstSettings); + acknowledgePendingServerSettings(environmentId, secondSettings); + settlePendingServerPatch(environmentId, secondId, secondSettings); + + expect(getPendingServerPatches(environmentId)).toEqual([]); + }); + it("drops the overlay for a failed write so the server value wins again", () => { const id = retain({ providerInstances: { [codexId]: providerInstance("codex", false) }, diff --git a/apps/web/src/hooks/pendingServerSettings.ts b/apps/web/src/hooks/pendingServerSettings.ts index 39ff090925a..fe04ec92c03 100644 --- a/apps/web/src/hooks/pendingServerSettings.ts +++ b/apps/web/src/hooks/pendingServerSettings.ts @@ -10,7 +10,10 @@ export interface PendingServerPatch { interface PendingServerState { readonly patches: ReadonlyArray; + /** Latest successful RPC result, used to rebase the next queued write. */ readonly authoritativeSettings: ServerSettings; + /** Latest settings snapshot published to React, which may precede its RPC result. */ + readonly observedSettings: ServerSettings; } export const NO_PENDING_SERVER_PATCHES: ReadonlyArray = []; @@ -120,6 +123,7 @@ export function retainPendingServerPatch( pendingByEnvironment.set(environmentId, { patches: [...(existing?.patches ?? NO_PENDING_SERVER_PATCHES), { id, patch, baseSettings }], authoritativeSettings: existing?.authoritativeSettings ?? authoritativeSettings, + observedSettings: existing?.observedSettings ?? authoritativeSettings, }); emitChange(); return id; @@ -146,18 +150,26 @@ export function settlePendingServerPatch( ): void { const state = pendingByEnvironment.get(environmentId); if (!state) return; + const settledIndex = state.patches.findIndex((entry) => entry.id === id); + const settingsWereAlreadyObserved = + settings !== null && + settledIndex >= 0 && + structurallyEqual(state.observedSettings, settings); const patches = settings === null ? state.patches.filter((entry) => entry.id !== id) - : state.patches.map((entry) => - entry.id === id ? { ...entry, settledSettings: settings } : entry, - ); - if (patches.length === 0 && settings === null) { + : settingsWereAlreadyObserved + ? state.patches.slice(settledIndex + 1) + : state.patches.map((entry) => + entry.id === id ? { ...entry, settledSettings: settings } : entry, + ); + if (patches.length === 0) { pendingByEnvironment.delete(environmentId); } else { pendingByEnvironment.set(environmentId, { patches, authoritativeSettings: settings ?? state.authoritativeSettings, + observedSettings: state.observedSettings, }); } emitChange(); @@ -185,17 +197,19 @@ export function acknowledgePendingServerSettings( pendingByEnvironment.set(environmentId, { patches, authoritativeSettings: settings, + observedSettings: settings, }); } emitChange(); return; } - if (!state.patches.some((entry) => entry.settledSettings !== undefined)) { - pendingByEnvironment.set(environmentId, { - ...state, - authoritativeSettings: settings, - }); - } + pendingByEnvironment.set(environmentId, { + ...state, + ...(state.patches.some((entry) => entry.settledSettings !== undefined) + ? {} + : { authoritativeSettings: settings }), + observedSettings: settings, + }); } export function __resetPendingServerPatchesForTests(): void { From ca30f0be4b34821b8d813d73a8a2cbab474ced7e Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 27 Jul 2026 19:32:22 -0700 Subject: [PATCH 4/4] fix(web): preserve latest settled settings base --- apps/web/src/hooks/pendingServerSettings.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/pendingServerSettings.ts b/apps/web/src/hooks/pendingServerSettings.ts index fe04ec92c03..af7935d0e26 100644 --- a/apps/web/src/hooks/pendingServerSettings.ts +++ b/apps/web/src/hooks/pendingServerSettings.ts @@ -196,7 +196,10 @@ export function acknowledgePendingServerSettings( } else { pendingByEnvironment.set(environmentId, { patches, - authoritativeSettings: settings, + authoritativeSettings: patches.reduce( + (latest, entry) => entry.settledSettings ?? latest, + settings, + ), observedSettings: settings, }); }