jotai: createJSONStorage does not properly subscribe to updates caused by RESET

Summary

When an item is removed from storage, a storage event fires with null as the incoming value. The storage event handler in createJSONStorage ignores null values from incoming storage events.

This is caused by a check for a non-falsy e.newValue here: https://github.com/pmndrs/jotai/blob/main/src/vanilla/utils/atomWithStorage.ts#L83

When setAtom(RESET) is called on an atomWithStorage, subscribers in other windows do not get the reset update.

Link to reproduction

I’ve created a minimal reproduction of the issue on codesandbox here:

https://codesandbox.io/s/jotai-storage-bug-reset-u9doo3?file=/src/App.tsx

Like with #1812 you might have to open the preview iframe itself (https://u9doo3.csb.app/) in separate windows to avoid permissions errors.

Fix discussion

I think that in order to fix this, the interface of the (Sync/Async)Storage.subscribe argument callback needs to be updated to accept NO_STORAGE_VALUE, so that the Storage can inform atomWithStorage that a reset occurred in another window.

--- a/src/vanilla/utils/atomWithStorage.ts
+++ b/src/vanilla/utils/atomWithStorage.ts
@@ -15,14 +15,14 @@ export interface AsyncStorage<Value> {
   getItem: (key: string) => Promise<Value | typeof NO_STORAGE_VALUE>
   setItem: (key: string, newValue: Value) => Promise<void>
   removeItem: (key: string) => Promise<void>
-  subscribe?: (key: string, callback: (value: Value) => void) => Unsubscribe
+  subscribe?: (key: string, callback: (value: Value | typeof NO_STORAGE_VALUE) => void) => Unsubscribe
 }
 
 export interface SyncStorage<Value> {
   getItem: (key: string) => Value | typeof NO_STORAGE_VALUE
   setItem: (key: string, newValue: Value) => void
   removeItem: (key: string) => void
-  subscribe?: (key: string, callback: (value: Value) => void) => Unsubscribe
+  subscribe?: (key: string, callback: (value: Value | typeof NO_STORAGE_VALUE) => void) => Unsubscribe
 }
 
 export interface AsyncStringStorage {
@@ -83,10 +83,13 @@ export function createJSONStorage<Value>(
       const storageEventCallback = (e: StorageEvent) => {
         if (
           e.storageArea === getStringStorage() &&
-          e.key === key &&
-          e.newValue
+          e.key === key
         ) {
-          callback(JSON.parse(e.newValue))
+          try {
+            callback(JSON.parse(e.newValue ?? ''))
+          } catch (e) {
+            callback(NO_STORAGE_VALUE)
+          }
         }
       }
       window.addEventListener('storage', storageEventCallback)
@@ -126,7 +129,9 @@ export function atomWithStorage<Value>(
     }
     let unsub: Unsubscribe | undefined
     if (storage.subscribe) {
-      unsub = storage.subscribe(key, setAtom)
+      unsub = storage.subscribe(key, (nextValue) => {
+        setAtom(nextValue === NO_STORAGE_VALUE ? initialValue : nextValue)
+      })
     }
     return unsub
   }

Currently, the only place the NO_STORAGE_VALUE symbol is exposed to consumers is with an unstable_ prefix.

That change would technically leak NO_STORAGE_VALUE as part of the exposed types for Sync/AsyncStorage.

It might make sense to remove the unstable prefix from the exported NO_STORAGE_VALUE symbol because of that?

Semi-related: Another reason it might make sense to expose NO_STORAGE_VALUE is because I don’t think you can create a custom storage (one that doesn’t use createJSONStorage, such as would be required to make a validated storage - #1198) without using that symbol either.

Check List

Please do not ask questions in issues.

  • I’ve already opened a discussion before opening this issue, or already discussed in other media.

Please include a minimal reproduction.

About this issue

  • Original URL
  • State: closed
  • Created a year ago
  • Comments: 15 (15 by maintainers)

Most upvoted comments

Yeah, this would only work:

storage.getItem(key, fallbackValue)
storage.subscribe(key, callback, fallbackValue)

@nderscore Would you like to draft a PR?

subscribers in other windows do not get the reset update.

oops, I missed that part.

Yeah, this would only work:

storage.getItem(key, fallbackValue)
storage.subscribe(key, callback, fallbackValue)