state.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import {
  2. computed,
  3. defineComponent,
  4. inject,
  5. onUnmounted,
  6. provide,
  7. ref,
  8. shallowRef,
  9. watch,
  10. type ComputedRef,
  11. type PropType,
  12. type ShallowRef,
  13. } from "vue";
  14. import {
  15. createStateStore,
  16. getByPath,
  17. type StateModel,
  18. type StateStore,
  19. } from "@json-render/core";
  20. import { flattenToPointers } from "@json-render/core/store-utils";
  21. /**
  22. * State context value
  23. */
  24. export interface StateContextValue {
  25. /** Reactive state snapshot — use state.value in reactive contexts */
  26. state: ShallowRef<StateModel>;
  27. /** Get a value by path */
  28. get: (path: string) => unknown;
  29. /** Set a value by path */
  30. set: (path: string, value: unknown) => void;
  31. /** Update multiple values at once */
  32. update: (updates: Record<string, unknown>) => void;
  33. /** Return the live state snapshot from the underlying store (not through Vue reactivity). */
  34. getSnapshot: () => StateModel;
  35. }
  36. const STATE_KEY = Symbol("json-render:state");
  37. /**
  38. * Props for StateProvider
  39. */
  40. export interface StateProviderProps {
  41. /**
  42. * External store that owns the state. When provided, the provider operates
  43. * in **controlled mode** — `initialState` and `onStateChange` are ignored
  44. * and the store is the single source of truth.
  45. */
  46. store?: StateStore;
  47. /** Initial state model (used only in uncontrolled mode) */
  48. initialState?: StateModel;
  49. /**
  50. * Callback when state changes (used only in uncontrolled mode).
  51. * Called once per `set` or `update` with all changed entries.
  52. */
  53. onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
  54. }
  55. /**
  56. * Provider for state model context.
  57. *
  58. * Supports two modes:
  59. * - **Controlled**: pass a `store` prop (e.g. backed by Redux / Zustand).
  60. * - **Uncontrolled** (default): omit `store` and optionally pass
  61. * `initialState` / `onStateChange`.
  62. */
  63. export const StateProvider = defineComponent({
  64. name: "StateProvider",
  65. props: {
  66. store: {
  67. type: Object as PropType<StateStore>,
  68. default: undefined,
  69. },
  70. initialState: {
  71. type: Object as PropType<StateModel>,
  72. default: undefined,
  73. },
  74. onStateChange: {
  75. type: Function as PropType<
  76. (changes: Array<{ path: string; value: unknown }>) => void
  77. >,
  78. default: undefined,
  79. },
  80. },
  81. setup(props, { slots }) {
  82. const isControlled = !!props.store;
  83. // Use external store (controlled) or create internal store (uncontrolled)
  84. const internalStore = !isControlled
  85. ? createStateStore(props.initialState ?? {})
  86. : null;
  87. const store: StateStore = props.store ?? internalStore!;
  88. const state = shallowRef<StateModel>(store.getSnapshot());
  89. const unsubscribe = store.subscribe(() => {
  90. state.value = store.getSnapshot();
  91. });
  92. onUnmounted(unsubscribe);
  93. // Sync external initialState changes (uncontrolled mode only)
  94. if (!isControlled) {
  95. let prevFlat: Record<string, unknown> =
  96. props.initialState && Object.keys(props.initialState).length > 0
  97. ? flattenToPointers(props.initialState)
  98. : {};
  99. watch(
  100. () => props.initialState,
  101. (newInitialState) => {
  102. if (!newInitialState) return;
  103. const nextFlat =
  104. Object.keys(newInitialState).length > 0
  105. ? flattenToPointers(newInitialState)
  106. : {};
  107. const allKeys = new Set([
  108. ...Object.keys(prevFlat),
  109. ...Object.keys(nextFlat),
  110. ]);
  111. const updates: Record<string, unknown> = {};
  112. for (const key of allKeys) {
  113. if (prevFlat[key] !== nextFlat[key]) {
  114. updates[key] = key in nextFlat ? nextFlat[key] : undefined;
  115. }
  116. }
  117. prevFlat = nextFlat;
  118. if (Object.keys(updates).length > 0) {
  119. store.update(updates);
  120. }
  121. },
  122. );
  123. }
  124. // Keep onStateChange in a ref so it always reads the latest callback
  125. const onStateChangeRef = ref(props.onStateChange);
  126. watch(
  127. () => props.onStateChange,
  128. (fn) => {
  129. onStateChangeRef.value = fn;
  130. },
  131. );
  132. const get = (path: string) => store.get(path);
  133. const getSnapshot = () => store.getSnapshot();
  134. const set = (path: string, value: unknown) => {
  135. const prev = store.getSnapshot();
  136. store.set(path, value);
  137. if (!isControlled && store.getSnapshot() !== prev) {
  138. onStateChangeRef.value?.([{ path, value }]);
  139. }
  140. };
  141. const update = (updates: Record<string, unknown>) => {
  142. const prev = store.getSnapshot();
  143. store.update(updates);
  144. if (!isControlled && store.getSnapshot() !== prev) {
  145. const changes: Array<{ path: string; value: unknown }> = [];
  146. for (const [path, value] of Object.entries(updates)) {
  147. if (getByPath(prev, path) !== value) {
  148. changes.push({ path, value });
  149. }
  150. }
  151. if (changes.length > 0) {
  152. onStateChangeRef.value?.(changes);
  153. }
  154. }
  155. };
  156. provide<StateContextValue>(STATE_KEY, {
  157. state,
  158. get,
  159. set,
  160. update,
  161. getSnapshot,
  162. });
  163. return () => slots.default?.();
  164. },
  165. });
  166. /**
  167. * Composable to access the state context
  168. */
  169. export function useStateStore(): StateContextValue {
  170. const ctx = inject<StateContextValue>(STATE_KEY);
  171. if (!ctx) {
  172. throw new Error("useStateStore must be used within a StateProvider");
  173. }
  174. return ctx;
  175. }
  176. /**
  177. * Composable to get a value from the state model (reactive)
  178. */
  179. export function useStateValue<T>(path: string): ComputedRef<T | undefined> {
  180. const { state } = useStateStore();
  181. return computed(() => getByPath(state.value, path) as T | undefined);
  182. }
  183. /**
  184. * Composable to get and set a value from the state model by path.
  185. *
  186. * This is the path-based variant for use in arbitrary composables. For
  187. * registry components that receive `bindings` from the renderer, prefer
  188. * `useBoundProp` which reads the already-resolved prop value and writes back
  189. * to the bound path.
  190. */
  191. export function useStateBinding<T>(
  192. path: string,
  193. ): [ComputedRef<T | undefined>, (value: T) => void] {
  194. const { state, set } = useStateStore();
  195. const value = computed(() => getByPath(state.value, path) as T | undefined);
  196. const setValue = (newValue: T) => set(path, newValue);
  197. return [value, setValue];
  198. }