diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 7132c84c9d3..e0825bc22e1 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -6,7 +6,12 @@ import { import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; -import { mergeEnvironmentSettings } from "./useSettings"; +import { selectProjectGroupingSettings } from "../logicalProject"; +import { + areSettingsSelectionsEqual, + createSettingsSelectorSnapshotReader, + mergeEnvironmentSettings, +} from "./useSettings"; describe("mergeEnvironmentSettings", () => { it("combines the selected environment's server settings with client preferences", () => { @@ -35,3 +40,43 @@ describe("mergeEnvironmentSettings", () => { expect(settings.favorites).toBe(clientSettings.favorites); }); }); + +describe("createSettingsSelectorSnapshotReader", () => { + it("reuses the selected reference when unrelated settings change", () => { + let settings = DEFAULT_CLIENT_SETTINGS; + const readProjectGroupingSettings = createSettingsSelectorSnapshotReader( + () => settings, + selectProjectGroupingSettings, + ); + + const initialSelection = readProjectGroupingSettings(); + settings = { + ...settings, + favorites: [ + { + provider: ProviderInstanceId.make("codex_remote"), + model: "gpt-5.4", + }, + ], + }; + const unrelatedUpdateSelection = readProjectGroupingSettings(); + + expect(unrelatedUpdateSelection).toBe(initialSelection); + + settings = { + ...settings, + sidebarProjectGroupingMode: "repository_path", + }; + const relatedUpdateSelection = readProjectGroupingSettings(); + + expect(relatedUpdateSelection).not.toBe(initialSelection); + expect(relatedUpdateSelection.sidebarProjectGroupingMode).toBe("repository_path"); + }); +}); + +describe("areSettingsSelectionsEqual", () => { + it("compares selected objects shallowly", () => { + expect(areSettingsSelectionsEqual({ a: 1, b: "same" }, { a: 1, b: "same" })).toBe(true); + expect(areSettingsSelectionsEqual({ a: 1 }, { a: 2 })).toBe(false); + }); +}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 514484d896a..0f5045fd10b 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -39,6 +39,60 @@ let clientSettingsHydrated = false; let clientSettingsHydrationPromise: Promise | null = null; let clientSettingsHydrationGeneration = 0; +type SettingsSelector = (settings: S) => T; + +const selectClientSettingsIdentity: SettingsSelector = (settings) => + settings; + +function isObjectLike(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function areSettingsSelectionsEqual(left: T, right: T): boolean { + if (Object.is(left, right)) { + return true; + } + if (!isObjectLike(left) || !isObjectLike(right)) { + return false; + } + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + for (const key of leftKeys) { + if (!Object.prototype.hasOwnProperty.call(right, key)) { + return false; + } + if (!Object.is(left[key], right[key])) { + return false; + } + } + + return true; +} + +export function createSettingsSelectorSnapshotReader( + getSnapshot: () => S, + selector: SettingsSelector, +): () => T { + let hasPreviousSelection = false; + let previousSelection: T; + + return () => { + const nextSelection = selector(getSnapshot()); + if (hasPreviousSelection && areSettingsSelectionsEqual(previousSelection, nextSelection)) { + return previousSelection; + } + + hasPreviousSelection = true; + previousSelection = nextSelection; + return nextSelection; + }; +} + function emitClientSettingsChange() { for (const listener of clientSettingsListeners) { listener(); @@ -182,11 +236,20 @@ export function useClientSettingsHydrated(): boolean { ); } -function useClientSettingsValue(): ClientSettings { +function useSelectedClientSettings(selector: SettingsSelector): T { + const getSelectedSnapshot = useMemo( + () => createSettingsSelectorSnapshotReader(getClientSettingsSnapshot, selector), + [selector], + ); + const getSelectedServerSnapshot = useMemo( + () => createSettingsSelectorSnapshotReader(() => DEFAULT_CLIENT_SETTINGS, selector), + [selector], + ); + return useSyncExternalStore( subscribeClientSettings, - getClientSettingsSnapshot, - () => DEFAULT_CLIENT_SETTINGS, + getSelectedSnapshot, + getSelectedServerSnapshot, ); } @@ -201,21 +264,23 @@ function useMergedSettings( serverSettings: ServerSettings, selector: ((settings: UnifiedSettings) => T) | undefined, ): T { - const clientSettings = useClientSettingsValue(); - - const merged = useMemo( - () => mergeEnvironmentSettings(serverSettings, clientSettings), - [clientSettings, serverSettings], + const selectMergedSettings = useCallback( + (clientSettings: ClientSettings) => { + const merged = mergeEnvironmentSettings(serverSettings, clientSettings); + return selector ? selector(merged) : (merged as T); + }, + [selector, serverSettings], ); - return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); + return useSelectedClientSettings(selectMergedSettings); } export function useClientSettings( selector?: (settings: ClientSettings) => T, ): T { - const settings = useClientSettingsValue(); - return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]); + return useSelectedClientSettings( + selector ?? (selectClientSettingsIdentity as unknown as SettingsSelector), + ); } /** Read current settings for one environment, merged with client-local preferences. */