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
47 changes: 46 additions & 1 deletion apps/web/src/hooks/useSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
87 changes: 76 additions & 11 deletions apps/web/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,60 @@ let clientSettingsHydrated = false;
let clientSettingsHydrationPromise: Promise<void> | null = null;
let clientSettingsHydrationGeneration = 0;

type SettingsSelector<S, T> = (settings: S) => T;

const selectClientSettingsIdentity: SettingsSelector<ClientSettings, ClientSettings> = (settings) =>
settings;

function isObjectLike(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

export function areSettingsSelectionsEqual<T>(left: T, right: T): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium hooks/useSettings.ts:51

areSettingsSelectionsEqual uses Object.keys to compare non-primitive selections, so Set, Map, Date, and class instances with no enumerable own keys all compare equal to each other. When a selector returns one of these (e.g. useClientSettings((s) => new Set(s.favorites.map(...)))), createSettingsSelectorSnapshotReader treats every new selection as equal to the stale cached one and keeps returning the previous value, so the component stops updating after the first render. Consider falling back to Object.is for non-plain objects so distinct object identities still produce updates.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/hooks/useSettings.ts around line 51:

`areSettingsSelectionsEqual` uses `Object.keys` to compare non-primitive selections, so `Set`, `Map`, `Date`, and class instances with no enumerable own keys all compare equal to each other. When a selector returns one of these (e.g. `useClientSettings((s) => new Set(s.favorites.map(...)))`), `createSettingsSelectorSnapshotReader` treats every new selection as equal to the stale cached one and keeps returning the previous value, so the component stops updating after the first render. Consider falling back to `Object.is` for non-plain objects so distinct object identities still produce updates.

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<S, T>(
getSnapshot: () => S,
selector: SettingsSelector<S, T>,
): () => 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();
Expand Down Expand Up @@ -182,11 +236,20 @@ export function useClientSettingsHydrated(): boolean {
);
}

function useClientSettingsValue(): ClientSettings {
function useSelectedClientSettings<T>(selector: SettingsSelector<ClientSettings, T>): 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,
);
}

Expand All @@ -201,21 +264,23 @@ function useMergedSettings<T>(
serverSettings: ServerSettings,
selector: ((settings: UnifiedSettings) => T) | undefined,
): T {
const clientSettings = useClientSettingsValue();

const merged = useMemo<UnifiedSettings>(
() => 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<T = ClientSettings>(
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<ClientSettings, T>),
);
}

/** Read current settings for one environment, merged with client-local preferences. */
Expand Down
Loading