refactor(cli): Reactive useSettingsStore hook (#14915)

This commit is contained in:
Pyush Sinha
2026-02-11 15:40:27 -08:00
committed by GitHub
parent 6c1773170e
commit b8008695db
4 changed files with 326 additions and 3 deletions

View File

@@ -0,0 +1,167 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Component, type ReactNode } from 'react';
import { renderHook, render } from '../../test-utils/render.js';
import { act } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SettingsContext, useSettingsStore } from './SettingsContext.js';
import {
type LoadedSettings,
SettingScope,
type LoadedSettingsSnapshot,
type SettingsFile,
createTestMergedSettings,
} from '../../config/settings.js';
const createMockSettingsFile = (path: string): SettingsFile => ({
path,
settings: {},
originalSettings: {},
});
const mockSnapshot: LoadedSettingsSnapshot = {
system: createMockSettingsFile('/system'),
systemDefaults: createMockSettingsFile('/defaults'),
user: createMockSettingsFile('/user'),
workspace: createMockSettingsFile('/workspace'),
isTrusted: true,
errors: [],
merged: createTestMergedSettings({
ui: { theme: 'default-theme' },
}),
};
class ErrorBoundary extends Component<
{ children: ReactNode; onError: (error: Error) => void },
{ hasError: boolean }
> {
constructor(props: { children: ReactNode; onError: (error: Error) => void }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(_error: Error) {
return { hasError: true };
}
override componentDidCatch(error: Error) {
this.props.onError(error);
}
override render() {
if (this.state.hasError) {
return null;
}
return this.props.children;
}
}
const TestHarness = () => {
useSettingsStore();
return null;
};
describe('SettingsContext', () => {
let mockLoadedSettings: LoadedSettings;
let listeners: Array<() => void> = [];
beforeEach(() => {
listeners = [];
mockLoadedSettings = {
subscribe: vi.fn((listener: () => void) => {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
}),
getSnapshot: vi.fn(() => mockSnapshot),
setValue: vi.fn(),
} as unknown as LoadedSettings;
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SettingsContext.Provider value={mockLoadedSettings}>
{children}
</SettingsContext.Provider>
);
it('should provide the correct initial state', () => {
const { result } = renderHook(() => useSettingsStore(), { wrapper });
expect(result.current.settings.merged).toEqual(mockSnapshot.merged);
expect(result.current.settings.isTrusted).toBe(true);
});
it('should allow accessing settings for a specific scope', () => {
const { result } = renderHook(() => useSettingsStore(), { wrapper });
const userSettings = result.current.settings.forScope(SettingScope.User);
expect(userSettings).toBe(mockSnapshot.user);
const workspaceSettings = result.current.settings.forScope(
SettingScope.Workspace,
);
expect(workspaceSettings).toBe(mockSnapshot.workspace);
});
it('should trigger re-renders when settings change (external event)', () => {
const { result } = renderHook(() => useSettingsStore(), { wrapper });
expect(result.current.settings.merged.ui?.theme).toBe('default-theme');
const newSnapshot = {
...mockSnapshot,
merged: { ui: { theme: 'new-theme' } },
};
(
mockLoadedSettings.getSnapshot as ReturnType<typeof vi.fn>
).mockReturnValue(newSnapshot);
// Trigger the listeners (simulate coreEvents emission)
act(() => {
listeners.forEach((l) => l());
});
expect(result.current.settings.merged.ui?.theme).toBe('new-theme');
});
it('should call store.setValue when setSetting is called', () => {
const { result } = renderHook(() => useSettingsStore(), { wrapper });
act(() => {
result.current.setSetting(SettingScope.User, 'ui.theme', 'dark');
});
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'ui.theme',
'dark',
);
});
it('should throw error if used outside provider', () => {
const onError = vi.fn();
// Suppress console.error (React logs error boundary info)
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary onError={onError}>
<TestHarness />
</ErrorBoundary>,
);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
message: 'useSettingsStore must be used within a SettingsProvider',
}),
);
consoleSpy.mockRestore();
});
});

View File

@@ -4,17 +4,81 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useContext } from 'react';
import type { LoadedSettings } from '../../config/settings.js';
import React, { useContext, useMemo, useSyncExternalStore } from 'react';
import type {
LoadableSettingScope,
LoadedSettings,
LoadedSettingsSnapshot,
SettingsFile,
} from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
export const SettingsContext = React.createContext<LoadedSettings | undefined>(
undefined,
);
export const useSettings = () => {
export const useSettings = (): LoadedSettings => {
const context = useContext(SettingsContext);
if (context === undefined) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
};
export interface SettingsState extends LoadedSettingsSnapshot {
forScope: (scope: LoadableSettingScope) => SettingsFile;
}
export interface SettingsStoreValue {
settings: SettingsState;
setSetting: (
scope: LoadableSettingScope,
key: string,
value: unknown,
) => void;
}
// Components that call this hook will re render when a settings change event is emitted
export const useSettingsStore = (): SettingsStoreValue => {
const store = useContext(SettingsContext);
if (store === undefined) {
throw new Error('useSettingsStore must be used within a SettingsProvider');
}
// React passes a listener fn into the subscribe function
// When the listener runs, it re renders the component if the snapshot changed
const snapshot = useSyncExternalStore(
(listener) => store.subscribe(listener),
() => store.getSnapshot(),
);
const settings: SettingsState = useMemo(
() => ({
...snapshot,
forScope: (scope: LoadableSettingScope) => {
switch (scope) {
case SettingScope.User:
return snapshot.user;
case SettingScope.Workspace:
return snapshot.workspace;
case SettingScope.System:
return snapshot.system;
case SettingScope.SystemDefaults:
return snapshot.systemDefaults;
default:
throw new Error(`Invalid scope: ${scope}`);
}
},
}),
[snapshot],
);
return useMemo(
() => ({
settings,
setSetting: (scope: LoadableSettingScope, key: string, value: unknown) =>
store.setValue(scope, key, value),
}),
[settings, store],
);
};