249 lines · 7.7 KB · TypeScript
RawDownload
/** * 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, useMemo, useSyncExternalStore } from "react";import { useAtomValue } from "@effect/atom-react";import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts";import {  type ClientSettingsPatch,  type ClientSettings,  DEFAULT_CLIENT_SETTINGS,  UnifiedSettings,} from "@t3tools/contracts/settings";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<void> | null = null;let clientSettingsHydrationGeneration = 0; 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<void> {  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`, 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`, 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 serverSettings = useAtomValue(primaryServerSettingsAtom);  const clientSettings = useSyncExternalStore(    subscribeClientSettings,    getClientSettingsSnapshot,    () => DEFAULT_CLIENT_SETTINGS,  );   const merged = useMemo<UnifiedSettings>(    () => ({      ...serverSettings,      ...clientSettings,    }),    [clientSettings, serverSettings],  );   return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, 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. */export function useUpdateSettings() {  const persistServerSettings = useAtomCommand(    serverEnvironment.updateSettings,    "server settings update",  );  const primaryEnvironment = usePrimaryEnvironment();  const updateSettings = useCallback(    (patch: Partial<UnifiedSettings>) => {      const { serverPatch, clientPatch } = splitPatch(patch);       if (Object.keys(serverPatch).length > 0) {        if (primaryEnvironment) {          void persistServerSettings({            environmentId: primaryEnvironment.environmentId,            input: { patch: serverPatch },          });        }      }       if (Object.keys(clientPatch).length > 0) {        persistClientSettings({          ...getClientSettingsSnapshot(),          ...clientPatch,        });      }    },    [persistServerSettings, primaryEnvironment],  );   return updateSettings;} 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;}