import { useSyncExternalStore } from "react"; import { readTokenRegistry, type TokenRegistry } from "./read-tokens"; // CSSOM은 React 바깥의 외부 시스템이라 `useSyncExternalStore`가 맞는 도구다 // (effect에서 setState 하면 렌더가 연쇄된다). // // 토큰 **값**은 테마와 뷰포트에 따라 달라지므로 둘 중 하나가 바뀌면 다시 읽어야 한다. // 반면 `useSyncExternalStore`는 매 렌더마다 getSnapshot을 호출하므로 매번 새 객체를 // 돌려주면 무한 루프가 된다 — 그래서 (테마, 뷰포트 구간)을 키로 캐시해 같은 조건 // 동안에는 동일한 참조를 반환한다. let cached: { key: string; registry: TokenRegistry } | null = null; /** `@fox/styles/_root.scss`가 읽는 수동 테마 선택 속성. */ export const THEME_ATTRIBUTE = "data-theme"; function snapshotKey(): string { const theme = document.documentElement.getAttribute(THEME_ATTRIBUTE) ?? "system"; // 폭 자체가 아니라 "반응형 토큰이 바뀌는 구간"만 키에 넣는다 — 창을 1px 줄일 때마다 // 전체 토큰을 다시 읽으면 낭비다. const wide = window.matchMedia("(min-width: 768px)").matches ? "pc" : "mobile"; return `${theme}:${wide}`; } function subscribe(onChange: () => void): () => void { const observer = new MutationObserver(onChange); observer.observe(document.documentElement, { attributes: true, attributeFilter: [THEME_ATTRIBUTE], }); const query = window.matchMedia("(min-width: 768px)"); query.addEventListener("change", onChange); return () => { observer.disconnect(); query.removeEventListener("change", onChange); }; } function getSnapshot(): TokenRegistry | null { const key = snapshotKey(); if (!cached || cached.key !== key) { cached = { key, registry: readTokenRegistry() }; } return cached.registry; } function getServerSnapshot(): TokenRegistry | null { return null; } /** * 서버 렌더와 하이드레이션 첫 렌더에서는 `null`, 그 이후 토큰 레지스트리를 반환한다. * 테마를 바꾸거나 768px 경계를 넘으면 그 조건에서 해석된 값으로 갱신된다. */ export function useTokenRegistry(): TokenRegistry | null { return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }