store.client.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { createSnapshotStore, defineStore, shallowEqual } from '../src/index.ts'
  3. interface State {
  4. a: { n: number }
  5. b: { list: string[] }
  6. }
  7. const init = (): State => ({ a: { n: 1 }, b: { list: ['x'] } })
  8. afterEach(() => {
  9. vi.unstubAllGlobals()
  10. vi.unstubAllEnvs()
  11. vi.restoreAllMocks()
  12. })
  13. describe('createSnapshotStore', () => {
  14. it('applies update through a draft and preserves untouched branch references', () => {
  15. const store = createSnapshotStore(init())
  16. const before = store.getSnapshot()
  17. store.update((d) => { d.a.n = 2 })
  18. const after = store.getSnapshot()
  19. expect(after).not.toBe(before)
  20. expect(after.a.n).toBe(2)
  21. expect(after.b).toBe(before.b)
  22. })
  23. it('notifies synchronously per update by default', () => {
  24. const store = createSnapshotStore(init())
  25. const seen: number[] = []
  26. store.subscribe(() => { seen.push(store.getSnapshot().a.n) })
  27. store.update((d) => { d.a.n = 2 })
  28. store.update((d) => { d.a.n = 3 })
  29. expect(seen).toEqual([2, 3])
  30. })
  31. it('coalesces a frame of updates into one notification in raf mode', () => {
  32. const frame: FrameRequestCallback[] = []
  33. vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
  34. frame.push(cb)
  35. return frame.length
  36. })
  37. const store = createSnapshotStore(init(), { flush: 'raf' })
  38. const spy = vi.fn()
  39. store.subscribe(spy)
  40. store.update((d) => { d.a.n = 2 })
  41. store.update((d) => { d.a.n = 3 })
  42. store.update((d) => { d.b.list.push('y') })
  43. expect(spy).not.toHaveBeenCalled()
  44. expect(frame).toHaveLength(1)
  45. frame.shift()!(0)
  46. expect(spy).toHaveBeenCalledTimes(1)
  47. expect(store.getSnapshot().a.n).toBe(3)
  48. // Next frame batches independently.
  49. store.update((d) => { d.a.n = 4 })
  50. expect(frame).toHaveLength(1)
  51. frame.shift()!(0)
  52. expect(spy).toHaveBeenCalledTimes(2)
  53. })
  54. it('falls back to microtask batching in raf mode without requestAnimationFrame', async () => {
  55. const store = createSnapshotStore(init(), { flush: 'raf' })
  56. const spy = vi.fn()
  57. store.subscribe(spy)
  58. store.update((d) => { d.a.n = 2 })
  59. store.update((d) => { d.a.n = 3 })
  60. expect(spy).not.toHaveBeenCalled()
  61. await Promise.resolve()
  62. expect(spy).toHaveBeenCalledTimes(1)
  63. })
  64. it('unsubscribes raf-mode listeners', () => {
  65. const frame: FrameRequestCallback[] = []
  66. vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
  67. frame.push(cb)
  68. return frame.length
  69. })
  70. const store = createSnapshotStore(init(), { flush: 'raf' })
  71. const spy = vi.fn()
  72. const off = store.subscribe(spy)
  73. store.update((d) => { d.a.n = 2 })
  74. off()
  75. frame.shift()!(0)
  76. expect(spy).not.toHaveBeenCalled()
  77. })
  78. it('replaces state wholesale via set and freezes it outside production', () => {
  79. const store = createSnapshotStore(init())
  80. const next = init()
  81. store.set(next)
  82. expect(store.getSnapshot()).toBe(next)
  83. expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
  84. })
  85. it('freezes the owned envelope without traversing opaque object handles', () => {
  86. class OpaqueHandle {
  87. readonly value = { n: 1 }
  88. }
  89. const handle = new OpaqueHandle()
  90. const store = createSnapshotStore({ handle, values: new Set(['x']) })
  91. store.set({ handle, values: new Set(['y']) })
  92. const snapshot = store.getSnapshot()
  93. expect(Object.isFrozen(snapshot)).toBe(true)
  94. expect(Object.isFrozen(snapshot.handle)).toBe(false)
  95. expect(() => { snapshot.values.add('z') }).toThrow()
  96. })
  97. it('freezes update produce output outside production (immer dev freeze)', () => {
  98. const store = createSnapshotStore(init())
  99. store.update((d) => { d.a.n = 2 })
  100. expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
  101. })
  102. it('does not deep-freeze wholesale state in production', () => {
  103. vi.stubEnv('NODE_ENV', 'production')
  104. const store = createSnapshotStore(init())
  105. const next = init()
  106. store.set(next)
  107. next.a.n = 9
  108. expect(store.getSnapshot().a.n).toBe(9)
  109. })
  110. it('rehydrates primitive state whole, not spread into index keys', () => {
  111. const backing = new Map<string, string>()
  112. vi.stubGlobal('localStorage', {
  113. getItem: (k: string) => backing.get(k) ?? null,
  114. setItem: (k: string, v: string) => { backing.set(k, v) },
  115. removeItem: (k: string) => { backing.delete(k) },
  116. })
  117. const store = createSnapshotStore<string>('', { persist: { name: 'spec-draft' } })
  118. store.set('hello')
  119. const revived = createSnapshotStore<string>('', { persist: { name: 'spec-draft' } })
  120. expect(revived.getSnapshot()).toBe('hello')
  121. })
  122. it('persists to localStorage under the given name and rehydrates', () => {
  123. const backing = new Map<string, string>()
  124. vi.stubGlobal('localStorage', {
  125. getItem: (k: string) => backing.get(k) ?? null,
  126. setItem: (k: string, v: string) => { backing.set(k, v) },
  127. removeItem: (k: string) => { backing.delete(k) },
  128. })
  129. const store = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
  130. store.update((d) => { d.a.n = 42 })
  131. expect(backing.has('spec-store')).toBe(true)
  132. const revived = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
  133. expect(revived.getSnapshot().a.n).toBe(42)
  134. })
  135. it('reports rehydration failures without preventing store creation', () => {
  136. const failure = new Error('storage read failed')
  137. vi.stubGlobal('localStorage', {
  138. getItem: () => { throw failure },
  139. setItem: () => {},
  140. removeItem: () => {},
  141. })
  142. const report = vi.spyOn(console, 'error').mockImplementation(() => {})
  143. const store = createSnapshotStore(init(), { persist: { name: 'spec-broken-read' } })
  144. expect(store.getSnapshot()).toEqual(init())
  145. expect(report).toHaveBeenCalledWith(
  146. "snapshot store 'spec-broken-read' rehydration failed:",
  147. failure,
  148. )
  149. })
  150. it('reports persistence failures without rejecting the write', () => {
  151. const failure = new Error('storage write failed')
  152. vi.stubGlobal('localStorage', {
  153. getItem: () => null,
  154. setItem: () => { throw failure },
  155. removeItem: () => {},
  156. })
  157. const report = vi.spyOn(console, 'error').mockImplementation(() => {})
  158. const store = createSnapshotStore(init(), { persist: { name: 'spec-broken-write' } })
  159. expect(() => { store.update((draft) => { draft.a.n = 7 }) }).not.toThrow()
  160. expect(store.getSnapshot().a.n).toBe(7)
  161. expect(report).toHaveBeenCalledWith(
  162. "snapshot store 'spec-broken-write' persistence failed:",
  163. failure,
  164. )
  165. })
  166. })
  167. describe('defineStore', () => {
  168. const declare = () => defineStore({
  169. init: () => ({ selection: null as string | null, draft: '' }),
  170. actions: {
  171. select: (d, target: string) => { d.selection = target },
  172. setDraft: (d, text: string) => { d.draft = text },
  173. clearDraft: (d) => { d.draft = '' },
  174. },
  175. })
  176. it('create() yields a live instance: fresh init state, selector-visible action writes', () => {
  177. const inst = declare().create()
  178. expect(inst.getSnapshot()).toEqual({ selection: null, draft: '' })
  179. const listener = vi.fn()
  180. const unsubscribe = inst.subscribe(listener)
  181. inst.actions.setDraft('hello')
  182. inst.actions.select('m1')
  183. expect(inst.store.getSnapshot()).toEqual({ selection: 'm1', draft: 'hello' })
  184. expect(listener).toHaveBeenCalledTimes(2)
  185. unsubscribe()
  186. inst.actions.clearDraft()
  187. expect(inst.store.getSnapshot().draft).toBe('')
  188. expect(listener).toHaveBeenCalledTimes(2)
  189. })
  190. it('bakes draft-stripped actions that write through update (draft mutation, not replacement)', () => {
  191. const inst = declare().create()
  192. const before = inst.store.getSnapshot()
  193. inst.actions.setDraft('x')
  194. const after = inst.store.getSnapshot()
  195. expect(after).not.toBe(before)
  196. expect(after.selection).toBe(before.selection) // untouched branch preserved (immer path)
  197. })
  198. it('creates independent instances per create() call (the handle is a spec, not a singleton)', () => {
  199. const handle = declare()
  200. const a = handle.create()
  201. const b = handle.create()
  202. a.actions.setDraft('only-a')
  203. expect(b.store.getSnapshot().draft).toBe('')
  204. })
  205. it('suffixes the persist key with the scope key: per-session persistence plus clearPersisted cleanup', () => {
  206. const backing = new Map<string, string>()
  207. vi.stubGlobal('localStorage', {
  208. getItem: (k: string) => backing.get(k) ?? null,
  209. setItem: (k: string, v: string) => { backing.set(k, v) },
  210. removeItem: (k: string) => { backing.delete(k) },
  211. })
  212. const handle = defineStore({
  213. init: () => ({ draft: '' }),
  214. persist: 'spec.chat',
  215. actions: { setDraft: (d, text: string) => { d.draft = text } },
  216. })
  217. handle.create('s1').actions.setDraft('one')
  218. handle.create('s2').actions.setDraft('two')
  219. handle.create().actions.setDraft('root')
  220. expect(JSON.parse(backing.get('spec.chat.s1')!)).toEqual({ draft: 'one' })
  221. expect(JSON.parse(backing.get('spec.chat.s2')!)).toEqual({ draft: 'two' })
  222. expect(JSON.parse(backing.get('spec.chat')!)).toEqual({ draft: 'root' })
  223. // Rehydration honors the same suffixed key.
  224. expect(handle.create('s1').store.getSnapshot().draft).toBe('one')
  225. // Scope-death cleanup removes exactly the suffixed key.
  226. handle.create('s1').clearPersisted()
  227. expect(backing.has('spec.chat.s1')).toBe(false)
  228. expect(backing.has('spec.chat.s2')).toBe(true)
  229. expect(backing.has('spec.chat')).toBe(true)
  230. })
  231. it('clearPersisted is a no-op without a persist declaration or without storage', () => {
  232. const inst = declare().create('s1') // no persist key declared
  233. expect(() => { inst.clearPersisted() }).not.toThrow()
  234. const persisting = defineStore({
  235. init: () => ({ n: 0 }),
  236. persist: 'spec.nostorage',
  237. actions: { inc: (d) => { d.n += 1 } },
  238. }).create()
  239. // jsdom-less lane: localStorage may exist here, so simulate its absence.
  240. vi.stubGlobal('localStorage', undefined)
  241. expect(() => { persisting.clearPersisted() }).not.toThrow()
  242. })
  243. it('swallows storage failures in clearPersisted (same non-fatal contract as persistence)', () => {
  244. vi.stubGlobal('localStorage', {
  245. getItem: () => null,
  246. setItem: () => {},
  247. removeItem: () => { throw new Error('quota / private mode') },
  248. })
  249. const inst = defineStore({
  250. init: () => ({ n: 0 }),
  251. persist: 'spec.throwing',
  252. actions: { inc: (d) => { d.n += 1 } },
  253. }).create()
  254. expect(() => { inst.clearPersisted() }).not.toThrow()
  255. })
  256. })
  257. describe('shallowEqual', () => {
  258. it('matches one-level-equal objects and rejects deeper drift', () => {
  259. const leaf = { deep: 1 }
  260. expect(shallowEqual({ x: 1, y: leaf }, { x: 1, y: leaf })).toBe(true)
  261. expect(shallowEqual({ x: 1, y: { deep: 1 } }, { x: 1, y: { deep: 1 } })).toBe(false)
  262. expect(shallowEqual([1, 2], [1, 2])).toBe(true)
  263. expect(shallowEqual([1, 2], [2, 1])).toBe(false)
  264. })
  265. })