Skip to content

Commit

Permalink
Merge branch 'main' into bug-2565
Browse files Browse the repository at this point in the history
  • Loading branch information
dai-shi authored May 27, 2024
2 parents 3ac27db + 88362ed commit 78289f9
Show file tree
Hide file tree
Showing 4 changed files with 224 additions and 25 deletions.
6 changes: 3 additions & 3 deletions src/vanilla/store2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export const createStore = (): Store => {
if (pendingPromise) {
if (pendingPromise !== valueOrPromise) {
pendingPromise[CONTINUE_PROMISE](valueOrPromise, abortPromise)
++atomState.n
}
} else {
const continuablePromise = createContinuablePromise(
Expand Down Expand Up @@ -499,8 +500,7 @@ export const createStore = (): Store => {
for (let i = topsortedAtoms.length - 1; i >= 0; --i) {
const a = topsortedAtoms[i]!
const aState = getAtomState(a)
const hasPrevValue = 'v' in aState
const prevValue = aState.v
const prevEpochNumber = aState.n
let hasChangedDeps = false
for (const dep of aState.d.keys()) {
if (dep !== a && changedAtoms.has(dep)) {
Expand All @@ -511,7 +511,7 @@ export const createStore = (): Store => {
if (hasChangedDeps) {
readAtomState(pending, a, isMarked)
mountDependencies(pending, a, aState)
if (!hasPrevValue || !Object.is(prevValue, aState.v)) {
if (prevEpochNumber !== aState.n) {
addPendingAtom(pending, a, aState)
changedAtoms.add(a)
}
Expand Down
62 changes: 40 additions & 22 deletions src/vanilla/utils/atomWithStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ const isPromiseLike = (x: unknown): x is PromiseLike<unknown> =>

type Unsubscribe = () => void

type Subscribe<Value> = (
key: string,
callback: (value: Value) => void,
initialValue: Value,
) => Unsubscribe

type StringSubscribe = (
key: string,
callback: (value: string | null) => void,
) => Unsubscribe

type SetStateActionWithReset<Value> =
| Value
| typeof RESET
Expand All @@ -16,34 +27,28 @@ export interface AsyncStorage<Value> {
getItem: (key: string, initialValue: Value) => PromiseLike<Value>
setItem: (key: string, newValue: Value) => PromiseLike<void>
removeItem: (key: string) => PromiseLike<void>
subscribe?: (
key: string,
callback: (value: Value) => void,
initialValue: Value,
) => Unsubscribe
subscribe?: Subscribe<Value>
}

export interface SyncStorage<Value> {
getItem: (key: string, initialValue: Value) => Value
setItem: (key: string, newValue: Value) => void
removeItem: (key: string) => void
subscribe?: (
key: string,
callback: (value: Value) => void,
initialValue: Value,
) => Unsubscribe
subscribe?: Subscribe<Value>
}

export interface AsyncStringStorage {
getItem: (key: string) => PromiseLike<string | null>
setItem: (key: string, newValue: string) => PromiseLike<void>
removeItem: (key: string) => PromiseLike<void>
subscribe?: StringSubscribe
}

export interface SyncStringStorage {
getItem: (key: string) => string | null
setItem: (key: string, newValue: string) => void
removeItem: (key: string) => void
subscribe?: StringSubscribe
}

export function withStorageValidator<Value>(
Expand Down Expand Up @@ -114,6 +119,7 @@ export function createJSONStorage<Value>(
): AsyncStorage<Value> | SyncStorage<Value> {
let lastStr: string | undefined
let lastValue: Value

const storage: AsyncStorage<Value> | SyncStorage<Value> = {
getItem: (key, initialValue) => {
const parse = (str: string | null) => {
Expand Down Expand Up @@ -141,24 +147,32 @@ export function createJSONStorage<Value>(
),
removeItem: (key) => getStringStorage()?.removeItem(key),
}

const createHandleSubscribe =
(subscriber: StringSubscribe): Subscribe<Value> =>
(key, callback, initialValue) =>
subscriber(key, (v) => {
let newValue: Value
try {
newValue = JSON.parse(v || '')
} catch {
newValue = initialValue
}
callback(newValue)
})

let subscriber = getStringStorage()?.subscribe
if (
!subscriber &&
typeof window !== 'undefined' &&
typeof window.addEventListener === 'function' &&
window.Storage
window.Storage &&
getStringStorage() instanceof window.Storage
) {
storage.subscribe = (key, callback, initialValue) => {
if (!(getStringStorage() instanceof window.Storage)) {
return () => {}
}
subscriber = (key, callback) => {
const storageEventCallback = (e: StorageEvent) => {
if (e.storageArea === getStringStorage() && e.key === key) {
let newValue: Value
try {
newValue = JSON.parse(e.newValue || '')
} catch {
newValue = initialValue
}
callback(newValue)
callback(e.newValue)
}
}
window.addEventListener('storage', storageEventCallback)
Expand All @@ -167,6 +181,10 @@ export function createJSONStorage<Value>(
}
}
}

if (subscriber) {
storage.subscribe = createHandleSubscribe(subscriber)
}
return storage
}

Expand Down
99 changes: 99 additions & 0 deletions tests/react/vanilla-utils/atomWithStorage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createJSONStorage,
unstable_withStorageValidator as withStorageValidator,
} from 'jotai/vanilla/utils'
import type { SyncStringStorage } from 'jotai/vanilla/utils/atomWithStorage'

const resolve: (() => void)[] = []

Expand Down Expand Up @@ -601,3 +602,101 @@ describe('withStorageValidator', () => {
atomWithStorage('my-number', 0, withStorageValidator(isNumber)(storage))
})
})

describe('with subscribe method in string storage', () => {
it('createJSONStorage subscriber is called correctly', async () => {
const store = createStore()

const subscribe = vi.fn()
const stringStorage = {
getItem: () => {
return null
},
setItem: () => {},
removeItem: () => {},
subscribe,
}

const dummyStorage = createJSONStorage<number>(() => stringStorage)

const dummyAtom = atomWithStorage<number>('dummy', 1, dummyStorage)

const DummyComponent = () => {
const [value] = useAtom(dummyAtom, { store })
return (
<>
<div>{value}</div>
</>
)
}

render(
<StrictMode>
<DummyComponent />
</StrictMode>,
)

expect(subscribe).toHaveBeenCalledWith('dummy', expect.any(Function))
})

it('createJSONStorage subscriber responds to events correctly', async () => {
const storageData: Record<string, string> = {
count: '10',
}

const stringStorage = {
getItem: (key: string) => {
return storageData[key] || null
},
setItem: (key: string, newValue: string) => {
storageData[key] = newValue
},
removeItem: (key: string) => {
delete storageData[key]
},
subscribe(key, callback) {
function handler(event: CustomEvent<string>) {
callback(event.detail)
}

window.addEventListener('dummystoragechange', handler as EventListener)
return () =>
window.removeEventListener(
'dummystoragechange',
handler as EventListener,
)
},
} as SyncStringStorage

const dummyStorage = createJSONStorage<number>(() => stringStorage)

const countAtom = atomWithStorage('count', 1, dummyStorage)

const Counter = () => {
const [count] = useAtom(countAtom)
return (
<>
<div>count: {count}</div>
</>
)
}

const { findByText } = render(
<StrictMode>
<Counter />
</StrictMode>,
)

await findByText('count: 10')

storageData.count = '12'
fireEvent(
window,
new CustomEvent('dummystoragechange', {
detail: '12',
}),
)
await findByText('count: 12')
// expect(storageData.count).toBe('11')
})
})
82 changes: 82 additions & 0 deletions tests/vanilla/dependency.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,85 @@ it('should not provide stale values to conditional dependents', () => {
store.set(hasFilterAtom, true)
expect(store.get(stageAtom), 'should update').toBe('is-empty')
})

it('settles never resolving async derivations with deps picked up sync', async () => {
const resolve: ((value: number) => void)[] = []

const syncAtom = atom({
promise: new Promise<number>((r) => resolve.push(r)),
})

const asyncAtom = atom(async (get) => {
return await get(syncAtom).promise
})

const store = createStore()

let sub = 0
const values: unknown[] = []
store.get(asyncAtom).then((value) => values.push(value))

store.sub(asyncAtom, () => {
sub++
store.get(asyncAtom).then((value) => values.push(value))
})

await new Promise((r) => setTimeout(r))

store.set(syncAtom, {
promise: new Promise<number>((r) => resolve.push(r)),
})

await new Promise((r) => setTimeout(r))

resolve[1]?.(1)

await new Promise((r) => setTimeout(r))

expect(values).toEqual([1, 1])
expect(sub).toBe(1)
})

it.skipIf(!import.meta.env?.USE_STORE2)(
'settles never resolving async derivations with deps picked up async',
async () => {
const resolve: ((value: number) => void)[] = []

const syncAtom = atom({
promise: new Promise<number>((r) => resolve.push(r)),
})

const asyncAtom = atom(async (get) => {
// we want to pick up `syncAtom` as an async dep
await Promise.resolve()

return await get(syncAtom).promise
})

const store = createStore()

let sub = 0
const values: unknown[] = []
store.get(asyncAtom).then((value) => values.push(value))

store.sub(asyncAtom, () => {
sub++
store.get(asyncAtom).then((value) => values.push(value))
})

await new Promise((r) => setTimeout(r))

store.set(syncAtom, {
promise: new Promise<number>((r) => resolve.push(r)),
})

await new Promise((r) => setTimeout(r))

resolve[1]?.(1)

await new Promise((r) => setTimeout(r))

expect(values).toEqual([1, 1])
expect(sub).toBe(1)
},
)

0 comments on commit 78289f9

Please sign in to comment.