/** * Unified settings hook. * * Abstracts the split between server-authoritative settings (persisted in * `settings.json` on the server, fetched via `server.getConfig`) and * client-only settings (persisted in localStorage). * * Consumers use `useSettings(selector)` to read, and `useUpdateSettings()` to * write. The hook transparently routes reads/writes to the correct backing * store. */import { useCallback, useRef, useSyncExternalStore } from "react";import { DEFAULT_SERVER_SETTINGS, ServerSettings, type ServerSettingsPatch,} from "@t3tools/contracts";import { type ClientSettingsPatch, type ClientSettings, DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS, UnifiedSettings,} from "@t3tools/contracts/settings";import { ensureLocalApi } from "~/localApi";import * as Struct from "effect/Struct";import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";import { applySettingsUpdated, getServerConfig, onServerConfigUpdated } from "~/rpc/serverState"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; const clientSettingsListeners = new Set<() => void>();const clientSettingsHydrationListeners = new Set<() => void>();let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;let clientSettingsHydrated = false;let clientSettingsHydrationPromise: Promise<void> | null = null;let unifiedServerSettingsSnapshot = DEFAULT_SERVER_SETTINGS;let unifiedClientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;let unifiedSettingsSnapshot = DEFAULT_UNIFIED_SETTINGS; function emitClientSettingsChange() { for (const listener of clientSettingsListeners) { listener(); }} function emitClientSettingsHydrationChange() { for (const listener of clientSettingsHydrationListeners) { listener(); }} function getClientSettingsSnapshot(): ClientSettings { return clientSettingsSnapshot;} function getServerSettingsSnapshot(): ServerSettings { return getServerConfig()?.settings ?? DEFAULT_SERVER_SETTINGS;} function getUnifiedSettingsSnapshot(): UnifiedSettings { const serverSettings = getServerSettingsSnapshot(); const clientSettings = getClientSettingsSnapshot(); if ( serverSettings === unifiedServerSettingsSnapshot && clientSettings === unifiedClientSettingsSnapshot ) { return unifiedSettingsSnapshot; } unifiedServerSettingsSnapshot = serverSettings; unifiedClientSettingsSnapshot = clientSettings; unifiedSettingsSnapshot = { ...serverSettings, ...clientSettings, }; return unifiedSettingsSnapshot;} function replaceClientSettingsSnapshot(settings: ClientSettings): void { clientSettingsSnapshot = settings; emitClientSettingsChange();} function setClientSettingsHydrated(nextHydrated: boolean): void { if (clientSettingsHydrated === nextHydrated) { return; } clientSettingsHydrated = nextHydrated; emitClientSettingsHydrationChange();} function subscribeClientSettings(listener: () => void): () => void { clientSettingsListeners.add(listener); void hydrateClientSettings(); return () => { clientSettingsListeners.delete(listener); };} function subscribeUnifiedSettings(listener: () => void): () => void { const unsubscribeClient = subscribeClientSettings(listener); const unsubscribeServer = onServerConfigUpdated(() => { listener(); }); return () => { unsubscribeClient(); unsubscribeServer(); };} function getClientSettingsHydratedSnapshot(): boolean { return clientSettingsHydrated;} function subscribeClientSettingsHydration(listener: () => void): () => void { clientSettingsHydrationListeners.add(listener); void hydrateClientSettings(); return () => { clientSettingsHydrationListeners.delete(listener); };} async function hydrateClientSettings(): Promise<void> { if (clientSettingsHydrated) { return; } if (clientSettingsHydrationPromise) { return clientSettingsHydrationPromise; } const nextHydration = (async () => { try { const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); if (persistedSettings) { replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); } } catch (error) { console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); } finally { setClientSettingsHydrated(true); } })(); const hydrationPromise = nextHydration.finally(() => { if (clientSettingsHydrationPromise === hydrationPromise) { clientSettingsHydrationPromise = null; } }); clientSettingsHydrationPromise = hydrationPromise; return clientSettingsHydrationPromise;} function persistClientSettings(settings: ClientSettings): void { replaceClientSettingsSnapshot(settings); void ensureLocalApi() .persistence.setClientSettings(settings) .catch((error) => { console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, error); });} // ── Key sets for routing patches ───────────────────────────────────── const SERVER_SETTINGS_KEYS = new Set<string>(Struct.keys(ServerSettings.fields)); function splitPatch(patch: Partial<UnifiedSettings>): { serverPatch: ServerSettingsPatch; clientPatch: ClientSettingsPatch;} { const serverPatch: Record<string, unknown> = {}; const clientPatch: Record<string, unknown> = {}; for (const [key, value] of Object.entries(patch)) { if (SERVER_SETTINGS_KEYS.has(key)) { serverPatch[key] = value; } else { clientPatch[key] = value; } } return { serverPatch: serverPatch as ServerSettingsPatch, clientPatch: clientPatch as ClientSettingsPatch, };} // ── Hooks ──────────────────────────────────────────────────────────── /** * Read merged settings. Selector narrows the subscription so components * only re-render when the slice they care about changes. */ /** * Non-hook accessor for the current merged client settings snapshot. * Used by non-React code paths (e.g. runtime services) that need the latest * settings without subscribing. */export function getClientSettings(): ClientSettings { return getClientSettingsSnapshot();} export function useClientSettingsHydrated(): boolean { return useSyncExternalStore( subscribeClientSettingsHydration, getClientSettingsHydratedSnapshot, () => false, );} export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T { const selectorRef = useRef(selector); selectorRef.current = selector; const lastSourceRef = useRef<UnifiedSettings | null>(null); const lastSelectorRef = useRef<typeof selector | undefined>(undefined); const lastSelectedRef = useRef<T | null>(null); const hasSelectedRef = useRef(false); const selectSnapshot = useCallback((settings: UnifiedSettings): T => { const currentSelector = selectorRef.current; if ( hasSelectedRef.current && lastSourceRef.current === settings && lastSelectorRef.current === currentSelector ) { return lastSelectedRef.current as T; } const selected = currentSelector ? currentSelector(settings) : (settings as T); lastSourceRef.current = settings; lastSelectorRef.current = currentSelector; if (hasSelectedRef.current && Object.is(lastSelectedRef.current, selected)) { return lastSelectedRef.current as T; } hasSelectedRef.current = true; lastSelectedRef.current = selected; return selected; }, []); const getSelectedSnapshot = useCallback((): T => { return selectSnapshot(getUnifiedSettingsSnapshot()); }, [selectSnapshot]); const getServerSelectedSnapshot = useCallback((): T => { return selectSnapshot(DEFAULT_UNIFIED_SETTINGS); }, [selectSnapshot]); const subscribeSelectedSettings = useCallback( (listener: () => void): (() => void) => subscribeUnifiedSettings(() => { const hadSelected = hasSelectedRef.current; const previousSelected = lastSelectedRef.current; const nextSelected = selectSnapshot(getUnifiedSettingsSnapshot()); if (!hadSelected || !Object.is(previousSelected, nextSelected)) { listener(); } }), [selectSnapshot], ); return useSyncExternalStore( subscribeSelectedSettings, getSelectedSnapshot, getServerSelectedSnapshot, );} /** * Returns an updater that routes each key to the correct backing store. * * Server keys are optimistically patched in atom-backed server state, then * persisted via RPC. Client keys go through client persistence. */export function useUpdateSettings() { const updateSettings = useCallback((patch: Partial<UnifiedSettings>) => { const { serverPatch, clientPatch } = splitPatch(patch); if (Object.keys(serverPatch).length > 0) { const currentServerConfig = getServerConfig(); if (currentServerConfig) { applySettingsUpdated(applyServerSettingsPatch(currentServerConfig.settings, serverPatch)); } // Fire-and-forget RPC — push will reconcile on success void ensureLocalApi().server.updateSettings(serverPatch); } if (Object.keys(clientPatch).length > 0) { persistClientSettings({ ...getClientSettingsSnapshot(), ...clientPatch, }); } }, []); const resetSettings = useCallback(() => { updateSettings(DEFAULT_UNIFIED_SETTINGS); }, [updateSettings]); return { updateSettings, resetSettings, };} export function __resetClientSettingsPersistenceForTests(): void { clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; clientSettingsHydrated = false; clientSettingsHydrationPromise = null; unifiedServerSettingsSnapshot = DEFAULT_SERVER_SETTINGS; unifiedClientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; unifiedSettingsSnapshot = DEFAULT_UNIFIED_SETTINGS; clientSettingsListeners.clear(); clientSettingsHydrationListeners.clear();}
/**
* Unified settings hook.
*
* Abstracts the split between server-authoritative settings (persisted in
* `settings.json` on the server, fetched via `server.getConfig`) and
* client-only settings (persisted in localStorage).
*
* Consumers use `useSettings(selector)` to read, and `useUpdateSettings()` to
* write. The hook transparently routes reads/writes to the correct backing
* store.
*/
import { useCallback, useRef, useSyncExternalStore } from "react";
import {
DEFAULT_SERVER_SETTINGS,
ServerSettings,
type ServerSettingsPatch,
} from "@t3tools/contracts";
import {
type ClientSettingsPatch,
type ClientSettings,
DEFAULT_CLIENT_SETTINGS,
DEFAULT_UNIFIED_SETTINGS,
UnifiedSettings,
} from "@t3tools/contracts/settings";
import { ensureLocalApi } from "~/localApi";
import * as Struct from "effect/Struct";
import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
import { applySettingsUpdated, getServerConfig, onServerConfigUpdated } from "~/rpc/serverState";
const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
const clientSettingsListeners = new Set<() => void>();
const clientSettingsHydrationListeners = new Set<() => void>();
let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
let clientSettingsHydrated = false;
let clientSettingsHydrationPromise: Promise<void> | null = null;
let unifiedServerSettingsSnapshot = DEFAULT_SERVER_SETTINGS;
let unifiedClientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
let unifiedSettingsSnapshot = DEFAULT_UNIFIED_SETTINGS;
function emitClientSettingsChange() {
for (const listener of clientSettingsListeners) {
listener();
}
}
function emitClientSettingsHydrationChange() {
for (const listener of clientSettingsHydrationListeners) {
listener();
}
}
function getClientSettingsSnapshot(): ClientSettings {
return clientSettingsSnapshot;
}
function getServerSettingsSnapshot(): ServerSettings {
return getServerConfig()?.settings ?? DEFAULT_SERVER_SETTINGS;
}
function getUnifiedSettingsSnapshot(): UnifiedSettings {
const serverSettings = getServerSettingsSnapshot();
const clientSettings = getClientSettingsSnapshot();
if (
serverSettings === unifiedServerSettingsSnapshot &&
clientSettings === unifiedClientSettingsSnapshot
) {
return unifiedSettingsSnapshot;
}
unifiedServerSettingsSnapshot = serverSettings;
unifiedClientSettingsSnapshot = clientSettings;
unifiedSettingsSnapshot = {
...serverSettings,
...clientSettings,
};
return unifiedSettingsSnapshot;
}
function replaceClientSettingsSnapshot(settings: ClientSettings): void {
clientSettingsSnapshot = settings;
emitClientSettingsChange();
}
function setClientSettingsHydrated(nextHydrated: boolean): void {
if (clientSettingsHydrated === nextHydrated) {
return;
}
clientSettingsHydrated = nextHydrated;
emitClientSettingsHydrationChange();
}
function subscribeClientSettings(listener: () => void): () => void {
clientSettingsListeners.add(listener);
void hydrateClientSettings();
return () => {
clientSettingsListeners.delete(listener);
};
}
function subscribeUnifiedSettings(listener: () => void): () => void {
const unsubscribeClient = subscribeClientSettings(listener);
const unsubscribeServer = onServerConfigUpdated(() => {
listener();
});
return () => {
unsubscribeClient();
unsubscribeServer();
};
}
function getClientSettingsHydratedSnapshot(): boolean {
return clientSettingsHydrated;
}
function subscribeClientSettingsHydration(listener: () => void): () => void {
clientSettingsHydrationListeners.add(listener);
void hydrateClientSettings();
return () => {
clientSettingsHydrationListeners.delete(listener);
};
}
async function hydrateClientSettings(): Promise<void> {
if (clientSettingsHydrated) {
return;
}
if (clientSettingsHydrationPromise) {
return clientSettingsHydrationPromise;
}
const nextHydration = (async () => {
try {
const persistedSettings = await ensureLocalApi().persistence.getClientSettings();
if (persistedSettings) {
replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });
}
} catch (error) {
console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error);
} finally {
setClientSettingsHydrated(true);
}
})();
const hydrationPromise = nextHydration.finally(() => {
if (clientSettingsHydrationPromise === hydrationPromise) {
clientSettingsHydrationPromise = null;
}
});
clientSettingsHydrationPromise = hydrationPromise;
return clientSettingsHydrationPromise;
}
function persistClientSettings(settings: ClientSettings): void {
replaceClientSettingsSnapshot(settings);
void ensureLocalApi()
.persistence.setClientSettings(settings)
.catch((error) => {
console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, error);
});
}
// ── Key sets for routing patches ─────────────────────────────────────
const SERVER_SETTINGS_KEYS = new Set<string>(Struct.keys(ServerSettings.fields));
function splitPatch(patch: Partial<UnifiedSettings>): {
serverPatch: ServerSettingsPatch;
clientPatch: ClientSettingsPatch;
} {
const serverPatch: Record<string, unknown> = {};
const clientPatch: Record<string, unknown> = {};
for (const [key, value] of Object.entries(patch)) {
if (SERVER_SETTINGS_KEYS.has(key)) {
serverPatch[key] = value;
} else {
clientPatch[key] = value;
}
}
return {
serverPatch: serverPatch as ServerSettingsPatch,
clientPatch: clientPatch as ClientSettingsPatch,
};
}
// ── Hooks ────────────────────────────────────────────────────────────
/**
* Read merged settings. Selector narrows the subscription so components
* only re-render when the slice they care about changes.
*/
/**
* Non-hook accessor for the current merged client settings snapshot.
* Used by non-React code paths (e.g. runtime services) that need the latest
* settings without subscribing.
*/
export function getClientSettings(): ClientSettings {
return getClientSettingsSnapshot();
}
export function useClientSettingsHydrated(): boolean {
return useSyncExternalStore(
subscribeClientSettingsHydration,
getClientSettingsHydratedSnapshot,
() => false,
);
}
export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {
const selectorRef = useRef(selector);
selectorRef.current = selector;
const lastSourceRef = useRef<UnifiedSettings | null>(null);
const lastSelectorRef = useRef<typeof selector | undefined>(undefined);
const lastSelectedRef = useRef<T | null>(null);
const hasSelectedRef = useRef(false);
const selectSnapshot = useCallback((settings: UnifiedSettings): T => {
const currentSelector = selectorRef.current;
if (
hasSelectedRef.current &&
lastSourceRef.current === settings &&
lastSelectorRef.current === currentSelector
) {
return lastSelectedRef.current as T;
}
const selected = currentSelector ? currentSelector(settings) : (settings as T);
lastSourceRef.current = settings;
lastSelectorRef.current = currentSelector;
if (hasSelectedRef.current && Object.is(lastSelectedRef.current, selected)) {
return lastSelectedRef.current as T;
}
hasSelectedRef.current = true;
lastSelectedRef.current = selected;
return selected;
}, []);
const getSelectedSnapshot = useCallback((): T => {
return selectSnapshot(getUnifiedSettingsSnapshot());
}, [selectSnapshot]);
const getServerSelectedSnapshot = useCallback((): T => {
return selectSnapshot(DEFAULT_UNIFIED_SETTINGS);
}, [selectSnapshot]);
const subscribeSelectedSettings = useCallback(
(listener: () => void): (() => void) =>
subscribeUnifiedSettings(() => {
const hadSelected = hasSelectedRef.current;
const previousSelected = lastSelectedRef.current;
const nextSelected = selectSnapshot(getUnifiedSettingsSnapshot());
if (!hadSelected || !Object.is(previousSelected, nextSelected)) {
listener();
}
}),
[selectSnapshot],
);
return useSyncExternalStore(
subscribeSelectedSettings,
getSelectedSnapshot,
getServerSelectedSnapshot,
);
}
/**
* Returns an updater that routes each key to the correct backing store.
*
* Server keys are optimistically patched in atom-backed server state, then
* persisted via RPC. Client keys go through client persistence.
*/
export function useUpdateSettings() {
const updateSettings = useCallback((patch: Partial<UnifiedSettings>) => {
const { serverPatch, clientPatch } = splitPatch(patch);
if (Object.keys(serverPatch).length > 0) {
const currentServerConfig = getServerConfig();
if (currentServerConfig) {
applySettingsUpdated(applyServerSettingsPatch(currentServerConfig.settings, serverPatch));
}
// Fire-and-forget RPC — push will reconcile on success
void ensureLocalApi().server.updateSettings(serverPatch);
}
if (Object.keys(clientPatch).length > 0) {
persistClientSettings({
...getClientSettingsSnapshot(),
...clientPatch,
});
}
}, []);
const resetSettings = useCallback(() => {
updateSettings(DEFAULT_UNIFIED_SETTINGS);
}, [updateSettings]);
return {
updateSettings,
resetSettings,
};
}
export function __resetClientSettingsPersistenceForTests(): void {
clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
clientSettingsHydrated = false;
clientSettingsHydrationPromise = null;
unifiedServerSettingsSnapshot = DEFAULT_SERVER_SETTINGS;
unifiedClientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
unifiedSettingsSnapshot = DEFAULT_UNIFIED_SETTINGS;
clientSettingsListeners.clear();
clientSettingsHydrationListeners.clear();
}