/** * Environment-scoped settings hooks. * * 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). * * Live server settings always require an environment id. Primary-environment * access is intentionally named as such so environment-sensitive consumers * cannot silently read the wrong server's settings. */ import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useAtomValue } from "@effect/atom-react"; import { DEFAULT_SERVER_SETTINGS, type EnvironmentId, ServerSettings, type ServerSettingsPatch, } from "@t3tools/contracts"; import { type ClientSettingsPatch, type ClientSettings, DEFAULT_CLIENT_SETTINGS, type UnifiedSettings, } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { ensureLocalApi } from "~/localApi"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; import { usePrimaryEnvironment } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; 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 | 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(); } } function emitClientSettingsHydrationChange() { for (const listener of clientSettingsHydrationListeners) { listener(); } } function getClientSettingsSnapshot(): ClientSettings { return clientSettingsSnapshot; } 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 getClientSettingsHydratedSnapshot(): boolean { return clientSettingsHydrated; } function subscribeClientSettingsHydration(listener: () => void): () => void { clientSettingsHydrationListeners.add(listener); void hydrateClientSettings(); return () => { clientSettingsHydrationListeners.delete(listener); }; } async function hydrateClientSettings(): Promise { if (clientSettingsHydrated) { return; } if (clientSettingsHydrationPromise) { return clientSettingsHydrationPromise; } const hydrationGeneration = clientSettingsHydrationGeneration; const nextHydration = (async () => { try { const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); if (hydrationGeneration !== clientSettingsHydrationGeneration) { return; } if (persistedSettings) { replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); } } catch (error) { console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, { operation: "hydrate", ...safeErrorLogAttributes(error), }); } finally { if (hydrationGeneration === clientSettingsHydrationGeneration) { 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`, { operation: "persist", ...safeErrorLogAttributes(error), }); }); } // ── Key sets for routing patches ───────────────────────────────────── const SERVER_SETTINGS_KEYS = new Set(Struct.keys(ServerSettings.fields)); function splitPatch(patch: Partial): { serverPatch: ServerSettingsPatch; clientPatch: ClientSettingsPatch; } { const serverPatch: Record = {}; const clientPatch: Record = {}; 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 ──────────────────────────────────────────────────────────── /** * 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, ); } function useSelectedClientSettings(selector: SettingsSelector): T { const getSelectedSnapshot = useMemo( () => createSettingsSelectorSnapshotReader(getClientSettingsSnapshot, selector), [selector], ); const getSelectedServerSnapshot = useMemo( () => createSettingsSelectorSnapshotReader(() => DEFAULT_CLIENT_SETTINGS, selector), [selector], ); return useSyncExternalStore( subscribeClientSettings, getSelectedSnapshot, getSelectedServerSnapshot, ); } export function mergeEnvironmentSettings( serverSettings: ServerSettings, clientSettings: ClientSettings, ): UnifiedSettings { return { ...serverSettings, ...clientSettings }; } function useMergedSettings( serverSettings: ServerSettings, selector: ((settings: UnifiedSettings) => T) | undefined, ): T { const selectMergedSettings = useCallback( (clientSettings: ClientSettings) => { const merged = mergeEnvironmentSettings(serverSettings, clientSettings); return selector ? selector(merged) : (merged as T); }, [selector, serverSettings], ); return useSelectedClientSettings(selectMergedSettings); } export function useClientSettings( selector?: (settings: ClientSettings) => T, ): T { return useSelectedClientSettings( selector ?? (selectClientSettingsIdentity as unknown as SettingsSelector), ); } /** Read current settings for one environment, merged with client-local preferences. */ export function useEnvironmentSettings( environmentId: EnvironmentId, selector?: (settings: UnifiedSettings) => T, ): T { const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector); } /** Primary-only settings access for the settings UI and other explicitly global surfaces. */ export function usePrimarySettings( selector?: (settings: UnifiedSettings) => T, ): T { return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); } /** * 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. */ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { const persistServerSettings = useAtomCommand( serverEnvironment.updateSettings, "server settings update", ); const updateSettings = useCallback( (patch: Partial) => { const { serverPatch, clientPatch } = splitPatch(patch); if (Object.keys(serverPatch).length > 0) { if (environmentId) { void persistServerSettings({ environmentId, input: { patch: serverPatch }, }); } } if (Object.keys(clientPatch).length > 0) { persistClientSettings({ ...getClientSettingsSnapshot(), ...clientPatch, }); } }, [environmentId, persistServerSettings], ); return updateSettings; } export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { return useUpdateSettingsTarget(environmentId); } export function useUpdatePrimarySettings() { return useUpdateSettingsTarget(usePrimaryEnvironment()?.environmentId ?? null); } export function useUpdateClientSettings() { return useCallback((patch: ClientSettingsPatch) => { persistClientSettings({ ...getClientSettingsSnapshot(), ...patch, }); }, []); } export function __resetClientSettingsPersistenceForTests(): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; clientSettingsHydrated = false; clientSettingsHydrationPromise = null; clientSettingsListeners.clear(); clientSettingsHydrationListeners.clear(); } export function __setClientSettingsForTests(settings: ClientSettings): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = settings; clientSettingsHydrated = true; clientSettingsHydrationPromise = null; }