Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,17 @@ 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.
- **Detail:** not-compromised/t3code#22.
- **Retire when:** upstream lets mixed settings switches reverse partial saves.

### Fork pipeline

- **Commits:** `chore(fork): nightly rolling rebase and release pipeline`,
Expand Down
141 changes: 141 additions & 0 deletions apps/web/src/components/settings/ScopedSwitch.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof environment>[];
let scope: ReturnType<typeof resolveSettingsScope>;
let root: Root;
let container: HTMLDivElement;
let save: Promise<Awaited<ReturnType<typeof persistScopedSettingsPatch>>>;

function renderSwitch() {
root.render(
<ScopedSwitch
aria-label="Auto-resume limited threads"
settingKeys={["autoResumeLimitedThreads"]}
checked={environments[0]!.serverConfig.settings.autoResumeLimitedThreads}
onCheckedChange={(checked) => {
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<HTMLButtonElement>('[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));
},
);
});
20 changes: 16 additions & 4 deletions apps/web/src/components/settings/ScopedSwitch.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Switch> & { settingKeys: readonly (keyof ServerSettings)[] }) {
const mixed = useScopedSettingsMixed(settingKeys);
return <Switch {...props} mixed={mixed} checked={mixed ? false : checked} />;
const [requestedChecked, setRequestedChecked] = useState(checked ?? false);
return (
<Switch
{...props}
mixed={mixed}
checked={mixed ? requestedChecked : checked}
onCheckedChange={(nextChecked, details) => {
setRequestedChecked(nextChecked);
onCheckedChange?.(nextChecked, details);
}}
/>
);
}
Loading