state-store.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import {
  2. getByPath,
  3. parseJsonPointer,
  4. type StateModel,
  5. type StateStore,
  6. } from "./types";
  7. /**
  8. * Immutably set a value at a JSON Pointer path using structural sharing.
  9. * Only objects along the path are shallow-cloned; untouched branches keep
  10. * their original references.
  11. */
  12. export function immutableSetByPath(
  13. root: StateModel,
  14. path: string,
  15. value: unknown,
  16. ): StateModel {
  17. const segments = parseJsonPointer(path);
  18. if (segments.length === 0) return root;
  19. const result = { ...root };
  20. let current: Record<string, unknown> = result;
  21. for (let i = 0; i < segments.length - 1; i++) {
  22. const seg = segments[i]!;
  23. const child = current[seg];
  24. if (Array.isArray(child)) {
  25. current[seg] = [...child];
  26. } else if (child !== null && typeof child === "object") {
  27. current[seg] = { ...(child as Record<string, unknown>) };
  28. } else {
  29. const nextSeg = segments[i + 1];
  30. current[seg] = nextSeg !== undefined && /^\d+$/.test(nextSeg) ? [] : {};
  31. }
  32. current = current[seg] as Record<string, unknown>;
  33. }
  34. const lastSeg = segments[segments.length - 1]!;
  35. if (Array.isArray(current)) {
  36. if (lastSeg === "-") {
  37. (current as unknown[]).push(value);
  38. } else {
  39. (current as unknown[])[parseInt(lastSeg, 10)] = value;
  40. }
  41. } else {
  42. current[lastSeg] = value;
  43. }
  44. return result;
  45. }
  46. /**
  47. * Create a simple in-memory {@link StateStore}.
  48. *
  49. * This is the default store used by `StateProvider` when no external store is
  50. * provided. It mirrors the previous `useState`-based behaviour but is
  51. * framework-agnostic so it can also be used in tests or non-React contexts.
  52. */
  53. export function createStateStore(initialState: StateModel = {}): StateStore {
  54. let state: StateModel = { ...initialState };
  55. const listeners = new Set<() => void>();
  56. function notify() {
  57. for (const listener of listeners) {
  58. listener();
  59. }
  60. }
  61. return {
  62. get(path: string): unknown {
  63. return getByPath(state, path);
  64. },
  65. set(path: string, value: unknown): void {
  66. if (getByPath(state, path) === value) return;
  67. state = immutableSetByPath(state, path, value);
  68. notify();
  69. },
  70. update(updates: Record<string, unknown>): void {
  71. let changed = false;
  72. let next = state;
  73. for (const [path, value] of Object.entries(updates)) {
  74. if (getByPath(next, path) !== value) {
  75. next = immutableSetByPath(next, path, value);
  76. changed = true;
  77. }
  78. }
  79. if (!changed) return;
  80. state = next;
  81. notify();
  82. },
  83. getSnapshot(): StateModel {
  84. return state;
  85. },
  86. getServerSnapshot(): StateModel {
  87. return state;
  88. },
  89. subscribe(listener: () => void): () => void {
  90. listeners.add(listener);
  91. return () => {
  92. listeners.delete(listener);
  93. };
  94. },
  95. };
  96. }
  97. /**
  98. * Configuration for {@link createStoreAdapter}. Adapter authors supply these
  99. * three callbacks; everything else (get, set, update, no-op detection,
  100. * getServerSnapshot) is handled by the returned {@link StateStore}.
  101. */
  102. export interface StoreAdapterConfig {
  103. /** Return the current state snapshot from the underlying store. */
  104. getSnapshot: () => StateModel;
  105. /** Write a new state snapshot to the underlying store. */
  106. setSnapshot: (next: StateModel) => void;
  107. /** Subscribe to changes in the underlying store. Return an unsubscribe fn. */
  108. subscribe: (listener: () => void) => () => void;
  109. }
  110. /**
  111. * Build a full {@link StateStore} from a minimal adapter config.
  112. *
  113. * Handles `get`, `set` (with no-op detection), `update` (batched, with no-op
  114. * detection), `getSnapshot`, `getServerSnapshot`, and `subscribe` -- so each
  115. * adapter only needs to wire its snapshot source, write API, and subscribe
  116. * mechanism.
  117. */
  118. export function createStoreAdapter(config: StoreAdapterConfig): StateStore {
  119. return {
  120. get(path: string): unknown {
  121. return getByPath(config.getSnapshot(), path);
  122. },
  123. set(path: string, value: unknown): void {
  124. const current = config.getSnapshot();
  125. if (getByPath(current, path) === value) return;
  126. config.setSnapshot(immutableSetByPath(current, path, value));
  127. },
  128. update(updates: Record<string, unknown>): void {
  129. let next = config.getSnapshot();
  130. let changed = false;
  131. for (const [path, value] of Object.entries(updates)) {
  132. if (getByPath(next, path) !== value) {
  133. next = immutableSetByPath(next, path, value);
  134. changed = true;
  135. }
  136. }
  137. if (!changed) return;
  138. config.setSnapshot(next);
  139. },
  140. getSnapshot: config.getSnapshot,
  141. getServerSnapshot: config.getSnapshot,
  142. subscribe: config.subscribe,
  143. };
  144. }
  145. const MAX_FLATTEN_DEPTH = 20;
  146. /**
  147. * Recursively flatten a plain object into a `Record<string, unknown>` keyed by
  148. * JSON Pointer paths. Only leaf values (non-plain-object) appear in the output.
  149. *
  150. * Includes circular reference protection and a depth cap to prevent stack
  151. * overflow on pathological inputs.
  152. *
  153. * ```ts
  154. * flattenToPointers({ user: { name: "Alice" }, count: 1 })
  155. * // => { "/user/name": "Alice", "/count": 1 }
  156. * ```
  157. */
  158. export function flattenToPointers(
  159. obj: Record<string, unknown>,
  160. prefix = "",
  161. _depth = 0,
  162. _seen?: Set<object>,
  163. _warned?: { current: boolean },
  164. ): Record<string, unknown> {
  165. const seen = _seen ?? new Set<object>();
  166. const warned = _warned ?? { current: false };
  167. const result: Record<string, unknown> = {};
  168. for (const [key, value] of Object.entries(obj)) {
  169. const pointer = `${prefix}/${key}`;
  170. if (
  171. _depth < MAX_FLATTEN_DEPTH &&
  172. value !== null &&
  173. typeof value === "object" &&
  174. !Array.isArray(value) &&
  175. Object.getPrototypeOf(value) === Object.prototype &&
  176. !seen.has(value)
  177. ) {
  178. seen.add(value);
  179. Object.assign(
  180. result,
  181. flattenToPointers(
  182. value as Record<string, unknown>,
  183. pointer,
  184. _depth + 1,
  185. seen,
  186. warned,
  187. ),
  188. );
  189. } else {
  190. if (
  191. process.env.NODE_ENV !== "production" &&
  192. !warned.current &&
  193. _depth >= MAX_FLATTEN_DEPTH &&
  194. value !== null &&
  195. typeof value === "object" &&
  196. !Array.isArray(value) &&
  197. Object.getPrototypeOf(value) === Object.prototype &&
  198. !seen.has(value as object)
  199. ) {
  200. warned.current = true;
  201. console.warn(
  202. `flattenToPointers: depth limit (${MAX_FLATTEN_DEPTH}) reached. Nested state beyond this depth will be treated as a leaf value.`,
  203. );
  204. }
  205. result[pointer] = value;
  206. }
  207. }
  208. return result;
  209. }