314 lines · 9.8 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, 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();}