From 6e10f90441fdca5789dba77b41422de869c0806f Mon Sep 17 00:00:00 2001 From: not-compromised <94505409+not-compromised@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:02:27 -0500 Subject: [PATCH 1/2] fix(web): mixed settings switches can reverse partial saves --- FORK.md | 10 ++ .../components/settings/ScopedSwitch.test.tsx | 141 ++++++++++++++++++ .../src/components/settings/ScopedSwitch.tsx | 20 ++- 3 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/settings/ScopedSwitch.test.tsx diff --git a/FORK.md b/FORK.md index 159b51ad6c4aa..25ce6147270e0 100644 --- a/FORK.md +++ b/FORK.md @@ -124,6 +124,16 @@ otherwise. user guide in `docs/user/appearance.md`, Desktop colors. - **Retire when:** upstream merges pingdotgg/t3code#17883. +### Mixed settings recovery + +- **Commits:** `fix(web): mixed settings switches can reverse partial saves` +- **Must still be true:** + - After a setting saves on only some environments, the next switch click + can reverse that request while the selection remains mixed. + - Uniform values always reflect the servers' settings. Unsupported-server + warnings and the settings protocol stay unchanged. +- **Retire when:** upstream lets mixed settings switches reverse partial saves. + ### Fork pipeline - **Commits:** `chore(fork): nightly rolling rebase and release pipeline`, diff --git a/apps/web/src/components/settings/ScopedSwitch.test.tsx b/apps/web/src/components/settings/ScopedSwitch.test.tsx new file mode 100644 index 0000000000000..5fccb917e58aa --- /dev/null +++ b/apps/web/src/components/settings/ScopedSwitch.test.tsx @@ -0,0 +1,141 @@ +// @vitest-environment jsdom + +import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + persistScopedSettingsPatch, + planScopedSettingsPatch, + resolveScopedSettingsTargets, + scopedSettingsAreMixed, +} from "./scopedSettings"; +import { resolveSettingsScope } from "./settingsScope"; +import { ScopedSwitch } from "./ScopedSwitch"; + +vi.mock("./useScopedSettings", () => ({ + useScopedSettingsMixed: () => + scopedSettingsAreMixed(resolveScopedSettingsTargets(scope, environments), [ + "autoResumeLimitedThreads", + ]), +})); + +function environment(id: string, initialChecked: boolean) { + return { + environmentId: EnvironmentId.make(id), + label: id, + connection: { phase: "connected" as const }, + serverConfig: { + settings: { ...DEFAULT_SERVER_SETTINGS, autoResumeLimitedThreads: initialChecked }, + }, + }; +} + +let environments: ReturnType[]; +let scope: ReturnType; +let root: Root; +let container: HTMLDivElement; +let save: Promise>>; + +function renderSwitch() { + root.render( + { + save = persistScopedSettingsPatch( + planScopedSettingsPatch(scope, environments, { autoResumeLimitedThreads: checked }), + async ({ environmentId, input }) => { + const target = environments.find((entry) => entry.environmentId === environmentId)!; + // An older server acknowledges the patch but drops the unknown setting. + if (target.label !== "Old server") { + target.serverConfig.settings = applyServerSettingsPatch( + target.serverConfig.settings, + input.patch, + ); + } + return { _tag: "Success", value: target.serverConfig.settings }; + }, + () => {}, + ).then((result) => { + renderSwitch(); + return result; + }); + }} + />, + ); +} + +function switchElement() { + return container.querySelector('[role="switch"]')!; +} + +async function clickSwitch() { + await act(async () => { + switchElement().click(); + await save; + }); + return save; +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe("ScopedSwitch recovery", () => { + it.each([ + { initialChecked: false, oldServerFirst: false }, + { initialChecked: false, oldServerFirst: true }, + { initialChecked: true, oldServerFirst: false }, + { initialChecked: true, oldServerFirst: true }, + ])( + "reverses a partial save from $initialChecked with old server first: $oldServerFirst", + async ({ initialChecked, oldServerFirst }) => { + environments = ["Laptop", "Desktop", "Old server"].map((id) => + environment(id, initialChecked), + ); + if (oldServerFirst) environments.reverse(); + scope = resolveSettingsScope({}, [], environments); + await act(async () => renderSwitch()); + + const partialSave = await clickSwitch(); + expect(partialSave.failedEnvironments.map(({ label }) => label)).toEqual(["Old server"]); + expect(partialSave.savedEnvironmentCount).toBe(2); + expect(switchElement().getAttribute("aria-checked")).toBe("mixed"); + + const recovery = await clickSwitch(); + expect( + environments.map((entry) => entry.serverConfig.settings.autoResumeLimitedThreads), + ).toEqual([initialChecked, initialChecked, initialChecked]); + expect(recovery.failedEnvironments).toEqual([]); + expect(switchElement().getAttribute("aria-checked")).toBe(String(initialChecked)); + + // Selecting only supported environments restores ordinary toggling. + environments = environments.filter((entry) => entry.label !== "Old server"); + scope = resolveSettingsScope({}, [], environments); + await act(async () => renderSwitch()); + await clickSwitch(); + expect( + environments.map((entry) => entry.serverConfig.settings.autoResumeLimitedThreads), + ).toEqual([!initialChecked, !initialChecked]); + expect(switchElement().getAttribute("aria-checked")).toBe(String(!initialChecked)); + await clickSwitch(); + expect( + environments.map((entry) => entry.serverConfig.settings.autoResumeLimitedThreads), + ).toEqual([initialChecked, initialChecked]); + expect(switchElement().getAttribute("aria-checked")).toBe(String(initialChecked)); + }, + ); +}); diff --git a/apps/web/src/components/settings/ScopedSwitch.tsx b/apps/web/src/components/settings/ScopedSwitch.tsx index 06c798f89dd11..ba532d423d987 100644 --- a/apps/web/src/components/settings/ScopedSwitch.tsx +++ b/apps/web/src/components/settings/ScopedSwitch.tsx @@ -1,19 +1,31 @@ import type { ServerSettings } from "@t3tools/contracts"; -import type { ComponentProps } from "react"; +import { useState, type ComponentProps } from "react"; import { Switch } from "../ui/switch"; import { useScopedSettingsMixed } from "./useScopedSettings"; /** * A switch for a server setting that renders the mixed state when the - * selected targets disagree on `settingKeys`. Clicking a mixed switch turns - * it on everywhere, the macOS mixed-checkbox convention. + * selected targets disagree on `settingKeys`. Remember the requested value + * so a partial save can be reversed even while the targets remain mixed. */ export function ScopedSwitch({ settingKeys, checked, + onCheckedChange, ...props }: ComponentProps & { settingKeys: readonly (keyof ServerSettings)[] }) { const mixed = useScopedSettingsMixed(settingKeys); - return ; + const [requestedChecked, setRequestedChecked] = useState(checked ?? false); + return ( + { + setRequestedChecked(nextChecked); + onCheckedChange?.(nextChecked, details); + }} + /> + ); } From 1cb98853d0b2da045a4047ad0984245ea20874d0 Mon Sep 17 00:00:00 2001 From: not-compromised <94505409+not-compromised@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:06:15 -0500 Subject: [PATCH 2/2] docs(fork): track the mixed settings recovery PR --- FORK.md | 1 + 1 file changed, 1 insertion(+) diff --git a/FORK.md b/FORK.md index 25ce6147270e0..499f29d0d00ce 100644 --- a/FORK.md +++ b/FORK.md @@ -132,6 +132,7 @@ otherwise. can reverse that request while the selection remains mixed. - Uniform values always reflect the servers' settings. Unsupported-server warnings and the settings protocol stay unchanged. +- **Detail:** not-compromised/t3code#22. - **Retire when:** upstream lets mixed settings switches reverse partial saves. ### Fork pipeline