index.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. "use client";
  2. import React, {
  3. createContext,
  4. useContext,
  5. useCallback,
  6. useEffect,
  7. useMemo,
  8. useRef,
  9. useSyncExternalStore,
  10. type ReactNode,
  11. } from "react";
  12. import {
  13. getByPath,
  14. createStateStore,
  15. type StateModel,
  16. type StateStore,
  17. } from "@json-render/core";
  18. import { flattenToPointers } from "@json-render/core/store-utils";
  19. /**
  20. * State context value
  21. */
  22. export interface StateContextValue {
  23. /** The current state model */
  24. state: StateModel;
  25. /** Get a value by path */
  26. get: (path: string) => unknown;
  27. /** Set a value by path */
  28. set: (path: string, value: unknown) => void;
  29. /** Update multiple values at once */
  30. update: (updates: Record<string, unknown>) => void;
  31. /** Return the live state snapshot from the underlying store (not the React render snapshot). */
  32. getSnapshot: () => StateModel;
  33. }
  34. const StateContext = createContext<StateContextValue | null>(null);
  35. /**
  36. * Props for StateProvider
  37. */
  38. export interface StateProviderProps {
  39. /**
  40. * External store that owns the state. When provided, the provider operates
  41. * in **controlled mode** — `initialState` and `onStateChange` are ignored
  42. * and the store is the single source of truth.
  43. */
  44. store?: StateStore;
  45. /** Initial state model (used only in uncontrolled mode) */
  46. initialState?: StateModel;
  47. /**
  48. * Callback when state changes (used only in uncontrolled mode).
  49. * Called once per `set` or `update` with all changed entries.
  50. */
  51. onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
  52. children: ReactNode;
  53. }
  54. function computeInitialFlat(
  55. isControlled: boolean,
  56. initialState: StateModel,
  57. ): Record<string, unknown> | null {
  58. if (isControlled) return null;
  59. if (Object.keys(initialState).length === 0) return {};
  60. return flattenToPointers(initialState);
  61. }
  62. /**
  63. * Provider for state model context.
  64. *
  65. * Supports two modes:
  66. * - **Controlled**: pass a `store` prop (e.g. backed by Redux / Zustand).
  67. * - **Uncontrolled** (default): omit `store` and optionally pass
  68. * `initialState` / `onStateChange`.
  69. */
  70. export function StateProvider({
  71. store: externalStore,
  72. initialState = {},
  73. onStateChange,
  74. children,
  75. }: StateProviderProps) {
  76. const internalStoreRef = useRef<StateStore | undefined>(undefined);
  77. if (!externalStore && !internalStoreRef.current) {
  78. internalStoreRef.current = createStateStore(initialState);
  79. }
  80. const store = externalStore ?? internalStoreRef.current!;
  81. // Refs for stable callback identity — callbacks never change regardless of
  82. // whether the consumer passes a new store / onStateChange reference.
  83. const storeRef = useRef(store);
  84. storeRef.current = store;
  85. const isControlledRef = useRef(!!externalStore);
  86. isControlledRef.current = !!externalStore;
  87. const initialModeRef = useRef(externalStore ? "controlled" : "uncontrolled");
  88. const modeWarnedRef = useRef(false);
  89. if (process.env.NODE_ENV !== "production") {
  90. const currentMode = externalStore ? "controlled" : "uncontrolled";
  91. if (currentMode !== initialModeRef.current && !modeWarnedRef.current) {
  92. modeWarnedRef.current = true;
  93. console.warn(
  94. `StateProvider: switching from ${initialModeRef.current} to ${currentMode} mode is not supported.`,
  95. );
  96. }
  97. }
  98. const prevInitialStateRef = useRef(initialState);
  99. const prevFlatRef = useRef<Record<string, unknown> | null>(
  100. computeInitialFlat(!!externalStore, initialState),
  101. );
  102. useEffect(() => {
  103. if (externalStore) return;
  104. if (initialState === prevInitialStateRef.current) return;
  105. prevInitialStateRef.current = initialState;
  106. const nextFlat =
  107. initialState && Object.keys(initialState).length > 0
  108. ? flattenToPointers(initialState)
  109. : {};
  110. const prevFlat = prevFlatRef.current ?? {};
  111. const allKeys = new Set([
  112. ...Object.keys(prevFlat),
  113. ...Object.keys(nextFlat),
  114. ]);
  115. const updates: Record<string, unknown> = {};
  116. for (const key of allKeys) {
  117. if (prevFlat[key] !== nextFlat[key]) {
  118. updates[key] = key in nextFlat ? nextFlat[key] : undefined;
  119. }
  120. }
  121. prevFlatRef.current = nextFlat;
  122. if (Object.keys(updates).length > 0) {
  123. store.update(updates);
  124. }
  125. }, [externalStore, initialState, store]);
  126. const state = useSyncExternalStore(
  127. store.subscribe,
  128. store.getSnapshot,
  129. store.getServerSnapshot ?? store.getSnapshot,
  130. );
  131. const onStateChangeRef = useRef(onStateChange);
  132. onStateChangeRef.current = onStateChange;
  133. const set = useCallback((path: string, value: unknown) => {
  134. const s = storeRef.current;
  135. const prev = s.getSnapshot();
  136. s.set(path, value);
  137. if (!isControlledRef.current && s.getSnapshot() !== prev) {
  138. onStateChangeRef.current?.([{ path, value }]);
  139. }
  140. }, []);
  141. const update = useCallback((updates: Record<string, unknown>) => {
  142. const s = storeRef.current;
  143. const prev = s.getSnapshot();
  144. s.update(updates);
  145. if (!isControlledRef.current && s.getSnapshot() !== prev) {
  146. const changes: Array<{ path: string; value: unknown }> = [];
  147. for (const [path, value] of Object.entries(updates)) {
  148. if (getByPath(prev, path) !== value) {
  149. changes.push({ path, value });
  150. }
  151. }
  152. if (changes.length > 0) {
  153. onStateChangeRef.current?.(changes);
  154. }
  155. }
  156. }, []);
  157. const get = useCallback((path: string) => storeRef.current.get(path), []);
  158. const getSnapshot = useCallback(() => storeRef.current.getSnapshot(), []);
  159. const value = useMemo<StateContextValue>(
  160. () => ({ state, get, set, update, getSnapshot }),
  161. [state, get, set, update, getSnapshot],
  162. );
  163. return (
  164. <StateContext.Provider value={value}>{children}</StateContext.Provider>
  165. );
  166. }
  167. /**
  168. * Hook to access the state context
  169. */
  170. export function useStateStore(): StateContextValue {
  171. const ctx = useContext(StateContext);
  172. if (!ctx) {
  173. throw new Error("useStateStore must be used within a StateProvider");
  174. }
  175. return ctx;
  176. }
  177. /**
  178. * Hook to get a value from the state model
  179. */
  180. export function useStateValue<T>(path: string): T | undefined {
  181. const { state } = useStateStore();
  182. return getByPath(state, path) as T | undefined;
  183. }
  184. /**
  185. * Hook to get and set a value from the state model (like useState).
  186. *
  187. * @deprecated Use {@link useBoundProp} with `$bindState` expressions instead.
  188. * `useStateBinding` takes a raw state path string, while `useBoundProp` works
  189. * with the renderer's `bindings` map and supports both `$bindState` and
  190. * `$bindItem` expressions.
  191. */
  192. export function useStateBinding<T>(
  193. path: string,
  194. ): [T | undefined, (value: T) => void] {
  195. const { state, set } = useStateStore();
  196. const value = getByPath(state, path) as T | undefined;
  197. const setValue = useCallback(
  198. (newValue: T) => set(path, newValue),
  199. [path, set],
  200. );
  201. return [value, setValue];
  202. }