refactor(gui): move the snapshot-store engine into the client runtime
The data layer no longer depends on the React glue package, and business plugins no longer depend on web-react at all: - The store engine (zustand vanilla + immer + persist + dev freeze), defineStore, and shallowEqual move to @deepseek-ai/dsh-client-runtime, exported from the ./client main entry — no ./store subpath survives on either package (the web-react one is deleted, none is opened on runtime). - Store products are bare snapshot sources: useSelector leaves SnapshotStore/StoreInstance and Session; every hook is composed at the binding site in web-react's renderer (per-source cached uSES binding). The SlotRendererHost sessions face carries bare observables only. - SessionProvider becomes a standard-kit seat: an entry whose children declare a session-scope slot receives the framework component as a prop, retiring the last value import of web-react from plugin packages. UseSession and the session-area types now live in ui-slots. - web-react shrinks to the shell-only React glue (renderer, providers, uSES bridge); zustand/immer belong to runtime alone; the module-table seed and tsdown externals drop the web-react/store seat. - NODE_ENV replacement is defined once in the shared tsdown client preset (browser bundles inline the engine and lost vite's define); the 3-line process.env typecheck shim moves to runtime with the engine. - Stray tsc artifacts (.js/.d.ts/.d.ts.map beside sources under src/) swept repo-wide; they shadow real sources under vitest resolution. Verified: both aggregate typecheck programs at zero; 604 client tests green; repo-wide grep for web-react/store at zero; real-host playwright run 7/7 including persist round-trip. ci: fix test/docs
This commit is contained in:
@@ -2,8 +2,15 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector, shallowEqual } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react/store'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
// Local one-level equality: the engine's shallowEqual moved to runtime with
|
||||
// the store relocation, and web-react tests must not import runtime (the
|
||||
// dependency direction is runtime → web-react). The eq PARAMETER contract is
|
||||
// what this suite asserts, not any specific equality implementation.
|
||||
const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean =>
|
||||
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => Object.is(a[k], b[k]))
|
||||
|
||||
interface Snap { a: number; b: number }
|
||||
|
||||
@@ -34,7 +41,7 @@ function Harness<S>({ useSelector, sel, eq, probe }: {
|
||||
useSelector: SnapshotSelectorHook<Snap>
|
||||
sel: (s: Snap) => S
|
||||
eq?: (a: S, b: S) => boolean
|
||||
probe: { renders: number; value?: S }
|
||||
probe: { renders: number; value?: S | undefined }
|
||||
}) {
|
||||
probe.renders += 1
|
||||
probe.value = useSelector(sel, eq)
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SlotEntryDef, SlotSpec, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
createSlotRenderer, defineStore, SessionProvider, SlotOwnershipError,
|
||||
createSlotRenderer, SessionProvider, SlotOwnershipError,
|
||||
type RenderOpts, type SessionCell,
|
||||
type SlotRendererHost, type StoreInstanceLike,
|
||||
} from '@deepseek-ai/dsh-client-web-react'
|
||||
@@ -25,6 +25,36 @@ type DeclaredSpec = SlotSpec<SlotEntryDef>
|
||||
const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }): StoredEntry =>
|
||||
({ options: {}, ...partial })
|
||||
|
||||
/**
|
||||
* Minimal store handle satisfying the StoreDecl contract shape (spec +
|
||||
* create(scopeKey?) + instance with clearPersisted): the machinery consumes
|
||||
* only the StoreInstanceLike face (bare snapshot source + baked actions),
|
||||
* but entry.store is typed to the full contract — the real defineStore lives
|
||||
* in runtime, which web-react tests must not import (dependency direction).
|
||||
*/
|
||||
function miniStore<T extends object>(init: () => T, mutators: Record<string, (state: T, ...params: never[]) => T>): StoreHandle<T, ActionsDecl<T>> {
|
||||
return {
|
||||
spec: { init, actions: {} },
|
||||
create: () => {
|
||||
let state = init()
|
||||
const listeners = new Set<() => void>()
|
||||
const actions: Record<string, (...params: never[]) => void> = {}
|
||||
for (const key of Object.keys(mutators)) {
|
||||
actions[key] = (...params: never[]) => {
|
||||
state = mutators[key]!(state, ...params)
|
||||
for (const fn of [...listeners]) fn()
|
||||
}
|
||||
}
|
||||
return {
|
||||
getSnapshot: () => state,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
actions,
|
||||
clearPersisted: () => {},
|
||||
} as StoreInstanceLike as ReturnType<StoreHandle<T, ActionsDecl<T>>['create']>
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function observable<T>(initial: T) {
|
||||
let value = initial
|
||||
const subs = new Set<() => void>()
|
||||
@@ -109,7 +139,11 @@ function makeHost() {
|
||||
}
|
||||
},
|
||||
addSession: (id: string): SessionCell => {
|
||||
const cell: SessionCell = { sessionId: id, useSession: { hookTag: id } }
|
||||
// Bare source per cell (identity-stable): the machinery binds useSession from it.
|
||||
const cell: SessionCell = {
|
||||
sessionId: id,
|
||||
session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} },
|
||||
}
|
||||
cells.set(id, cell)
|
||||
return cell
|
||||
},
|
||||
@@ -242,12 +276,17 @@ describe('standard-kit synthesis', () => {
|
||||
expect(view.container.textContent).toBe('2')
|
||||
})
|
||||
|
||||
it('delivers the session pair (useSession identity + sessionId) under SessionProvider', () => {
|
||||
it('delivers the session pair (bound useSession + sessionId) under SessionProvider', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.session', SINGLE_SESSION)
|
||||
const cell = h.addSession('s1')
|
||||
h.addSession('s1')
|
||||
const seen: AnyProps[] = []
|
||||
h.add('k.session', { component: (props: object) => { seen.push(props as AnyProps); return null } })
|
||||
h.add('k.session', {
|
||||
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
|
||||
seen.push({ ...props, read: props.useSession!((s) => s.sid) })
|
||||
return null
|
||||
},
|
||||
})
|
||||
mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot) => (
|
||||
<SessionProvider empty={() => <i>empty</i>}>
|
||||
{() => renderSlot('k.session', {})}
|
||||
@@ -255,10 +294,51 @@ describe('standard-kit synthesis', () => {
|
||||
))
|
||||
act(() => { h.current.set('s1') })
|
||||
const props = seen.at(-1)!
|
||||
expect(props['useSession']).toBe(cell.useSession)
|
||||
// The hook is BOUND by the machinery from the cell's bare source: it
|
||||
// reads the source's snapshot and stays identity-stable across renders
|
||||
// (per-source cache), which the switch-back cache tests cover.
|
||||
expect(props['read']).toBe('s1')
|
||||
expect(props['sessionId']).toBe('s1')
|
||||
})
|
||||
|
||||
it('hands the SessionProvider seat to entries declaring a session-scope child', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.session', SINGLE_SESSION)
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
h.addSession('s1')
|
||||
h.add('k.session', { component: ({ sessionId }: { sessionId?: string }) => <b>{sessionId}</b> })
|
||||
const rootSeen: AnyProps[] = []
|
||||
// Root entry uses its INJECTED provider seat (no value import of SessionProvider).
|
||||
h.add('root', {
|
||||
component: (props: AnyProps) => {
|
||||
rootSeen.push(props)
|
||||
const Provider = props['SessionProvider'] as typeof SessionProvider
|
||||
const renderSlot = props['renderSlot'] as RenderSlotFn
|
||||
return (
|
||||
<Provider empty={() => <i>empty</i>}>
|
||||
{() => renderSlot('k.session', {})}
|
||||
</Provider>
|
||||
)
|
||||
},
|
||||
children: { 'k.session': SINGLE_SESSION },
|
||||
})
|
||||
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||
expect(view.container.textContent).toBe('empty')
|
||||
act(() => { h.current.set('s1') })
|
||||
expect(view.container.textContent).toBe('s1')
|
||||
|
||||
// Entries whose children are all root-scope get no provider seat.
|
||||
const h2 = makeHost()
|
||||
h2.declare('k.single', SINGLE_ROOT)
|
||||
const seen2: AnyProps[] = []
|
||||
h2.add('root', {
|
||||
component: (props: AnyProps) => { seen2.push(props); return null },
|
||||
children: { 'k.single': SINGLE_ROOT },
|
||||
})
|
||||
render(<>{createSlotRenderer().renderRoot(h2.host, {})}</>)
|
||||
expect(seen2.at(-1)!['SessionProvider']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails loud when a session slot renders outside SessionProvider', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.session', SINGLE_SESSION)
|
||||
@@ -272,10 +352,7 @@ describe('standard-kit synthesis', () => {
|
||||
it('delivers the store pair for store-declaring entries and writes through baked actions', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
const handle = defineStore({
|
||||
init: () => ({ n: 0 }),
|
||||
actions: { inc: (d) => { d.n += 1 } },
|
||||
})
|
||||
const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) })
|
||||
let bump = () => {}
|
||||
h.add('k.single', {
|
||||
component: ({ useStore, actions }: {
|
||||
@@ -298,10 +375,7 @@ describe('standard-kit synthesis', () => {
|
||||
h.declare('k.session', SINGLE_SESSION)
|
||||
h.addSession('s1')
|
||||
h.addSession('s2')
|
||||
const handle = defineStore({
|
||||
init: () => ({ draft: '' }),
|
||||
actions: { setDraft: (d, text: string) => { d.draft = text } },
|
||||
})
|
||||
const handle = miniStore(() => ({ draft: '' }), { setDraft: (_s, text: string) => ({ draft: text }) })
|
||||
let setDraft: (text: string) => void = () => {}
|
||||
h.add('k.session', {
|
||||
component: ({ useStore, actions }: {
|
||||
@@ -369,7 +443,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
h.declare('k.session', SINGLE_SESSION)
|
||||
h.addSession('s1')
|
||||
const handle = defineStore({ init: () => ({ n: 0 }), actions: { inc: (d) => { d.n += 1 } } })
|
||||
const handle = miniStore(() => ({ n: 0 }), { inc: (s) => ({ n: s.n + 1 }) })
|
||||
const rootInject = vi.fn((actions: { inc: () => void }) => ({ viaRoot: actions }))
|
||||
const sessionInject = vi.fn((sessionId: string, actions: { inc: () => void }) => ({ sid: sessionId, viaSession: actions }))
|
||||
const seenRoot: AnyProps[] = []
|
||||
|
||||
@@ -57,7 +57,11 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
|
||||
host,
|
||||
current,
|
||||
addSession: (id: string) => {
|
||||
const cell: SessionCell = { sessionId: id, useSession: { hookTag: id } }
|
||||
// Bare source per cell (identity-stable): the machinery binds useSession from it.
|
||||
const cell: SessionCell = {
|
||||
sessionId: id,
|
||||
session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} },
|
||||
}
|
||||
cells.set(id, cell)
|
||||
return cell
|
||||
},
|
||||
@@ -121,15 +125,23 @@ describe('SessionProvider', () => {
|
||||
const h = makeHost({
|
||||
root: (renderSlot) => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
|
||||
})
|
||||
const s1 = h.addSession('s1')
|
||||
const s2 = h.addSession('s2')
|
||||
h.registerSession({ component: (props: object) => { seen.push(props as Record<string, unknown>); return null }, options: {} })
|
||||
h.addSession('s1')
|
||||
h.addSession('s2')
|
||||
h.registerSession({
|
||||
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
|
||||
// The bound hook reads the cell's bare source — asserting through it
|
||||
// proves the machinery wired THIS session's source, not another's.
|
||||
seen.push({ sessionId: props.sessionId, read: props.useSession!((s) => s.sid) })
|
||||
return null
|
||||
},
|
||||
options: {},
|
||||
})
|
||||
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||
act(() => { h.current.set('s1') })
|
||||
expect(seen.at(-1)!['useSession']).toBe(s1.useSession)
|
||||
expect(seen.at(-1)!['read']).toBe('s1')
|
||||
expect(seen.at(-1)!['sessionId']).toBe('s1')
|
||||
act(() => { h.current.set('s2') })
|
||||
expect(seen.at(-1)!['useSession']).toBe(s2.useSession)
|
||||
expect(seen.at(-1)!['read']).toBe('s2')
|
||||
expect(seen.at(-1)!['sessionId']).toBe('s2')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore, defineStore, shallowEqual } from '@deepseek-ai/dsh-client-web-react/store'
|
||||
|
||||
interface State {
|
||||
a: { n: number }
|
||||
b: { list: string[] }
|
||||
}
|
||||
|
||||
const init = (): State => ({ a: { n: 1 }, b: { list: ['x'] } })
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('createSnapshotStore', () => {
|
||||
it('applies update through a draft and preserves untouched branch references', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
const before = store.getSnapshot()
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
const after = store.getSnapshot()
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.a.n).toBe(2)
|
||||
expect(after.b).toBe(before.b)
|
||||
})
|
||||
|
||||
it('notifies synchronously per update by default', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
const seen: number[] = []
|
||||
store.subscribe(() => { seen.push(store.getSnapshot().a.n) })
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
store.update((d) => { d.a.n = 3 })
|
||||
expect(seen).toEqual([2, 3])
|
||||
})
|
||||
|
||||
it('coalesces a frame of updates into one notification in raf mode', () => {
|
||||
const frame: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
frame.push(cb)
|
||||
return frame.length
|
||||
})
|
||||
const store = createSnapshotStore(init(), { flush: 'raf' })
|
||||
const spy = vi.fn()
|
||||
store.subscribe(spy)
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
store.update((d) => { d.a.n = 3 })
|
||||
store.update((d) => { d.b.list.push('y') })
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
expect(frame).toHaveLength(1)
|
||||
frame.shift()!(0)
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
expect(store.getSnapshot().a.n).toBe(3)
|
||||
// Next frame batches independently.
|
||||
store.update((d) => { d.a.n = 4 })
|
||||
expect(frame).toHaveLength(1)
|
||||
frame.shift()!(0)
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('falls back to microtask batching in raf mode without requestAnimationFrame', async () => {
|
||||
const store = createSnapshotStore(init(), { flush: 'raf' })
|
||||
const spy = vi.fn()
|
||||
store.subscribe(spy)
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
store.update((d) => { d.a.n = 3 })
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
await Promise.resolve()
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('unsubscribes raf-mode listeners', () => {
|
||||
const frame: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
frame.push(cb)
|
||||
return frame.length
|
||||
})
|
||||
const store = createSnapshotStore(init(), { flush: 'raf' })
|
||||
const spy = vi.fn()
|
||||
const off = store.subscribe(spy)
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
off()
|
||||
frame.shift()!(0)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces state wholesale via set and freezes it outside production', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
const next = init()
|
||||
store.set(next)
|
||||
expect(store.getSnapshot()).toBe(next)
|
||||
expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
|
||||
})
|
||||
|
||||
it('freezes update produce output outside production (immer dev freeze)', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
|
||||
})
|
||||
|
||||
it('rehydrates primitive state whole, not spread into index keys', () => {
|
||||
const backing = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => backing.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { backing.set(k, v) },
|
||||
removeItem: (k: string) => { backing.delete(k) },
|
||||
})
|
||||
const store = createSnapshotStore<string>('', { persist: { name: 'spec-draft' } })
|
||||
store.set('hello')
|
||||
const revived = createSnapshotStore<string>('', { persist: { name: 'spec-draft' } })
|
||||
expect(revived.getSnapshot()).toBe('hello')
|
||||
})
|
||||
|
||||
it('persists to localStorage under the given name and rehydrates', () => {
|
||||
const backing = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => backing.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { backing.set(k, v) },
|
||||
removeItem: (k: string) => { backing.delete(k) },
|
||||
})
|
||||
const store = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
|
||||
store.update((d) => { d.a.n = 42 })
|
||||
expect(backing.has('spec-store')).toBe(true)
|
||||
const revived = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
|
||||
expect(revived.getSnapshot().a.n).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineStore', () => {
|
||||
const declare = () => defineStore({
|
||||
init: () => ({ selection: null as string | null, draft: '' }),
|
||||
actions: {
|
||||
select: (d, target: string) => { d.selection = target },
|
||||
setDraft: (d, text: string) => { d.draft = text },
|
||||
clearDraft: (d) => { d.draft = '' },
|
||||
},
|
||||
})
|
||||
|
||||
it('create() yields a live instance: fresh init state, selector-visible action writes', () => {
|
||||
const inst = declare().create()
|
||||
expect(inst.store.getSnapshot()).toEqual({ selection: null, draft: '' })
|
||||
inst.actions.setDraft('hello')
|
||||
inst.actions.select('m1')
|
||||
expect(inst.store.getSnapshot()).toEqual({ selection: 'm1', draft: 'hello' })
|
||||
inst.actions.clearDraft()
|
||||
expect(inst.store.getSnapshot().draft).toBe('')
|
||||
})
|
||||
|
||||
it('bakes draft-stripped actions that write through update (draft mutation, not replacement)', () => {
|
||||
const inst = declare().create()
|
||||
const before = inst.store.getSnapshot()
|
||||
inst.actions.setDraft('x')
|
||||
const after = inst.store.getSnapshot()
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.selection).toBe(before.selection) // untouched branch preserved (immer path)
|
||||
})
|
||||
|
||||
it('creates independent instances per create() call (the handle is a spec, not a singleton)', () => {
|
||||
const handle = declare()
|
||||
const a = handle.create()
|
||||
const b = handle.create()
|
||||
a.actions.setDraft('only-a')
|
||||
expect(b.store.getSnapshot().draft).toBe('')
|
||||
})
|
||||
|
||||
it('suffixes the persist key with the scope key: per-session persistence plus clearPersisted cleanup', () => {
|
||||
const backing = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => backing.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { backing.set(k, v) },
|
||||
removeItem: (k: string) => { backing.delete(k) },
|
||||
})
|
||||
const handle = defineStore({
|
||||
init: () => ({ draft: '' }),
|
||||
persist: 'spec.chat',
|
||||
actions: { setDraft: (d, text: string) => { d.draft = text } },
|
||||
})
|
||||
handle.create('s1').actions.setDraft('one')
|
||||
handle.create('s2').actions.setDraft('two')
|
||||
handle.create().actions.setDraft('root')
|
||||
expect(JSON.parse(backing.get('spec.chat.s1')!)).toEqual({ draft: 'one' })
|
||||
expect(JSON.parse(backing.get('spec.chat.s2')!)).toEqual({ draft: 'two' })
|
||||
expect(JSON.parse(backing.get('spec.chat')!)).toEqual({ draft: 'root' })
|
||||
// Rehydration honors the same suffixed key.
|
||||
expect(handle.create('s1').store.getSnapshot().draft).toBe('one')
|
||||
// Scope-death cleanup removes exactly the suffixed key.
|
||||
handle.create('s1').clearPersisted()
|
||||
expect(backing.has('spec.chat.s1')).toBe(false)
|
||||
expect(backing.has('spec.chat.s2')).toBe(true)
|
||||
expect(backing.has('spec.chat')).toBe(true)
|
||||
})
|
||||
|
||||
it('clearPersisted is a no-op without a persist declaration or without storage', () => {
|
||||
const inst = declare().create('s1') // no persist key declared
|
||||
expect(() => { inst.clearPersisted() }).not.toThrow()
|
||||
const persisting = defineStore({
|
||||
init: () => ({ n: 0 }),
|
||||
persist: 'spec.nostorage',
|
||||
actions: { inc: (d) => { d.n += 1 } },
|
||||
}).create()
|
||||
// jsdom-less lane: localStorage may exist here, so simulate its absence.
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
expect(() => { persisting.clearPersisted() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('swallows storage failures in clearPersisted (same non-fatal contract as persistence)', () => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => { throw new Error('quota / private mode') },
|
||||
})
|
||||
const inst = defineStore({
|
||||
init: () => ({ n: 0 }),
|
||||
persist: 'spec.throwing',
|
||||
actions: { inc: (d) => { d.n += 1 } },
|
||||
}).create()
|
||||
expect(() => { inst.clearPersisted() }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shallowEqual', () => {
|
||||
it('matches one-level-equal objects and rejects deeper drift', () => {
|
||||
const leaf = { deep: 1 }
|
||||
expect(shallowEqual({ x: 1, y: leaf }, { x: 1, y: leaf })).toBe(true)
|
||||
expect(shallowEqual({ x: 1, y: { deep: 1 } }, { x: 1, y: { deep: 1 } })).toBe(false)
|
||||
expect(shallowEqual([1, 2], [1, 2])).toBe(true)
|
||||
expect(shallowEqual([1, 2], [2, 1])).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user