Skip to content

Conversation

@Yoshify
Copy link
Contributor

@Yoshify Yoshify commented Oct 6, 2025

Added a subscription pattern to SessionBase so you can listen for changes. You can now subscribe to storage updates and do something when things change. Also added some smart batching using microtasks so we don't spam listeners when multiple operations happen at once.

Tests have been updated to comprehensively test:

  • Listeners trigger on set/remove/destroy
  • Multiple listeners work fine
  • Async listeners are supported
  • Mix of sync and async listeners
  • Unsubscribe works as expected
  • Batching multiple sync operations
  • Can't accidentally subscribe twice (using Set)
  • Can unsubscribe specific listeners
  • Batch operations like setItems work
  • Microtask boundary behavior

Chrome Storage and Expo Secure Store tests have been omitted for the time being as they're skipped entirely.

How to Use It

Node/TS

const store = new LocalStorage();

const unsubscribe = store.subscribe(async () => {
  console.log('Store updated');
  // do whatever you need when the store changes
});

await store.setSessionItem(StorageKeys.accessToken, 'new-token');
// listener gets called here

unsubscribe(); // clean up when you're done

React

useEffect(() => {
  const unsubscribe = store.subscribe(async () => {
    // do things
  });
  // unsubscribe on unmount
  return unsubscribe;
}, [store]);

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 6, 2025

Walkthrough

Adds a publish-subscribe API to SessionBase/SessionManager (subscribe + notifyListeners with microtask batching) and updates store implementations (chrome, expoSecure, localStorage, memory) to call notifyListeners after mutations. New tests validate listener invocation, batching, deduplication, and unsubscribe behavior.

Changes

Cohort / File(s) Summary of changes
Core pub-sub API
lib/sessionManager/types.ts
Added StoreListener type, subscribe(listener): () => void, notifyListeners() and microtask-based batching (scheduleNotification) on SessionBase; extended SessionManager interface with subscribe and notifyListeners.
Store implementations emitting notifications
lib/sessionManager/stores/chromeStore.ts, lib/sessionManager/stores/expoSecureStore.ts, lib/sessionManager/stores/localStorage.ts, lib/sessionManager/stores/memory.ts
After state mutations (destroySession, setSessionItem flows, removeSessionItem), stores now call notifyListeners() in appropriate async paths (including using Promise.all for parallel ops in some stores). No public signature changes.
Subscription & behavior tests
lib/sessionManager/stores/localStorage.test.ts, lib/sessionManager/stores/memory.test.ts
Added tests for listener invocation on set/remove/destroy, multiple listeners, async listeners, unsubscribe behavior, batching across rapid operations and microtask boundaries, and deduplication of identical subscriptions.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Client
  participant Store as Store (chrome/expo/local/memory)
  participant Session as SessionBase / SessionManager
  participant L1 as Listener A
  participant L2 as Listener B

  Client->>Session: subscribe(listener)
  Session-->>Client: unsubscribe()

  rect rgba(200,230,255,0.12)
    Client->>Store: set/remove/destroy
    Store->>Session: notifyListeners()
    alt notification not yet scheduled
      Session->>Session: schedule microtask (coalesce)
    else already scheduled
      Session-->>Session: no-op (coalesced)
    end
  end

  Note over Session: microtask flush triggers batched notifications
  Session->>L1: invoke listener()
  par
    L1-->>Session: resolve (sync/async)
  and
    Session->>L2: invoke listener()
    L2-->>Session: resolve (sync/async)
  end
  Session->>Session: reset batching flag
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • feat: add expo-secure-store #5 — Extends store implementations (chromeStore, expoSecureStore, localStorage, memory) to call notifyListeners after mutations; directly related to the same stores and notify flow.
  • feat: add setItems to storage #16 — Modifies SessionBase/types and store implementations; likely touches the same core API and store behavior.

Suggested reviewers

  • DanielRivers
  • DaveOrDead

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The title clearly and concisely summarizes the primary change by indicating the addition of a store listener and subscription mechanism without extraneous details, matching the core functionality introduced in this PR.
Description Check ✅ Passed The description directly relates to the changeset by detailing the new subscription pattern, batching behavior, test coverage, and usage examples, all of which align with the modifications in SessionBase and associated tests.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/store-listeners

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 83ddf1f and 0371c5b.

📒 Files selected for processing (3)
  • lib/sessionManager/stores/chromeStore.ts (3 hunks)
  • lib/sessionManager/stores/expoSecureStore.ts (3 hunks)
  • lib/sessionManager/stores/localStorage.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • lib/sessionManager/stores/expoSecureStore.ts
  • lib/sessionManager/stores/localStorage.ts
  • lib/sessionManager/stores/chromeStore.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link

codecov bot commented Oct 6, 2025

Codecov Report

❌ Patch coverage is 61.66667% with 23 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/sessionManager/stores/expoSecureStore.ts 0.00% 13 Missing ⚠️
lib/sessionManager/stores/chromeStore.ts 0.00% 10 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
lib/sessionManager/stores/expoSecureStore.ts (2)

41-48: Fix async iteration in destroySession.

The forEach with an async callback doesn't await the removeSessionItem operations, so notifyListeners() is called before all items are removed. This creates a race condition where listeners are notified while deletion is still in progress.

Apply this diff to properly await all deletions:

 async destroySession(): Promise<void> {
   const keys = Object.values(StorageKeys);
-  keys.forEach(async (key) => {
-    await this.removeSessionItem(key);
-  });
+  await Promise.all(
+    keys.map((key) => this.removeSessionItem(key))
+  );

   this.notifyListeners();
 }

56-79: Fix unreachable notifyListeners call and missing notification for string values.

The notifyListeners() call on line 78 is unreachable because:

  • If itemValue is a string, the function returns at line 73
  • If not a string, an error is thrown at line 75

Additionally, string values (the common case) don't trigger listener notifications due to the early return. This is inconsistent with the subscription mechanism's purpose.

Apply this diff to fix both issues:

   if (typeof itemValue === "string") {
     splitString(itemValue, Math.min(storageSettings.maxLength, 2048)).forEach(
       async (splitValue, index) => {
         await expoSecureStore!.setItemAsync(
           `${storageSettings.keyPrefix}${itemKey}${index}`,
           splitValue,
         );
       },
     );
+    this.notifyListeners();
     return;
   } else {
     throw new Error("Item value must be a string");
   }
-
-  this.notifyListeners();
 }
lib/sessionManager/stores/memory.ts (1)

30-50: Missing notification for string values.

String values don't trigger listener notifications because the function returns early at line 44 before reaching notifyListeners() on line 49. This is inconsistent—string values are the primary use case for session storage.

Apply this diff to notify listeners for all value types:

   if (typeof itemValue === "string") {
     splitString(itemValue, storageSettings.maxLength).forEach(
       (splitValue, index) => {
         this.memCache[`${storageSettings.keyPrefix}${itemKey}${index}`] =
           splitValue;
       },
     );
+    this.notifyListeners();
     return;
   }
   this.memCache[`${storageSettings.keyPrefix}${String(itemKey)}0`] =
     itemValue;

   this.notifyListeners();
 }
lib/sessionManager/stores/chromeStore.ts (1)

41-63: Two critical issues: missing notification for string values and async forEach.

  1. String values don't trigger notifications due to the early return at line 56
  2. The forEach with async callback doesn't await the storage operations, so the function may return before all chunks are written

Apply this diff to fix both issues:

   if (typeof itemValue === "string") {
-    splitString(itemValue, storageSettings.maxLength).forEach(
-      async (splitValue, index) => {
-        await chrome.storage.local.set({
-          [`${storageSettings.keyPrefix}${itemKey}${index}`]: splitValue,
-        });
-      },
-    );
+    await Promise.all(
+      splitString(itemValue, storageSettings.maxLength).map(
+        async (splitValue, index) => {
+          await chrome.storage.local.set({
+            [`${storageSettings.keyPrefix}${itemKey}${index}`]: splitValue,
+          });
+        },
+      ),
+    );
+    this.notifyListeners();
     return;
   }
   await chrome.storage.local.set({
     [`${storageSettings.keyPrefix}${itemKey}0`]: itemValue,
   });

   this.notifyListeners();
 }
lib/sessionManager/stores/localStorage.ts (2)

26-32: Fix async iteration in destroySession.

The forEach doesn't await removeSessionItem (which returns a Promise), so notifyListeners() is called before deletions complete.

Apply this diff:

 async destroySession(): Promise<void> {
-  this.internalItems.forEach((key) => {
-    this.removeSessionItem(key);
-  });
+  await Promise.all(
+    Array.from(this.internalItems).map((key) => this.removeSessionItem(key))
+  );

   this.notifyListeners();
 }

40-65: Missing notification for string values.

String values don't trigger notifications because the function returns at line 57 before reaching notifyListeners() on line 64.

Apply this diff:

   if (typeof itemValue === "string") {
     splitString(itemValue, storageSettings.maxLength).forEach(
       (splitValue, index) => {
         localStorage.setItem(
           `${storageSettings.keyPrefix}${itemKey}${index}`,
           splitValue,
         );
       },
     );
+    this.notifyListeners();
     return;
   }
   localStorage.setItem(
     `${storageSettings.keyPrefix}${itemKey}0`,
     itemValue as string,
   );

   this.notifyListeners();
 }
🧹 Nitpick comments (2)
lib/sessionManager/stores/memory.test.ts (1)

332-335: Clarify batching expectations in comment.

The comment states "listener should be called more than once but less than 3 times due to batching" but this is vague about the exact expected behavior. The assertion expect(callCount).toBeLessThanOrEqual(3) allows for 3 calls (no batching).

Consider clarifying the batching semantics:

-    // Even though we made 3 changes, listener should be called more than once
-    // but less than 3 times due to batching
+    // Due to batching within the same microtask, the listener may be called
+    // fewer times than the number of operations (1-3 calls for 3 operations)
     expect(callCount).toBeGreaterThan(0);
     expect(callCount).toBeLessThanOrEqual(3);
lib/sessionManager/stores/localStorage.test.ts (1)

355-358: Clarify batching expectations in comment.

Same vague comment as in memory.test.ts. Consider using the same clarification suggested there for consistency.

-    // Even though we made 3 changes, listener should be called more than once
-    // but less than 3 times due to batching
+    // Due to batching within the same microtask, the listener may be called
+    // fewer times than the number of operations (1-3 calls for 3 operations)
     expect(callCount).toBeGreaterThan(0);
     expect(callCount).toBeLessThanOrEqual(3);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c195d5e and 5eff7fe.

📒 Files selected for processing (7)
  • lib/sessionManager/stores/chromeStore.ts (3 hunks)
  • lib/sessionManager/stores/expoSecureStore.ts (3 hunks)
  • lib/sessionManager/stores/localStorage.test.ts (1 hunks)
  • lib/sessionManager/stores/localStorage.ts (3 hunks)
  • lib/sessionManager/stores/memory.test.ts (1 hunks)
  • lib/sessionManager/stores/memory.ts (3 hunks)
  • lib/sessionManager/types.ts (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
lib/sessionManager/stores/localStorage.test.ts (1)
lib/sessionManager/stores/localStorage.ts (1)
  • LocalStorage (9-114)
lib/sessionManager/stores/memory.test.ts (1)
lib/sessionManager/stores/memory.ts (1)
  • MemoryStorage (9-92)
🪛 Biome (2.1.2)
lib/sessionManager/stores/expoSecureStore.ts

[error] 78-78: This code will never be reached ...

... because either this statement will return from the function ...

... or this statement will throw an exception beforehand

(lint/correctness/noUnreachable)

🔇 Additional comments (11)
lib/sessionManager/stores/expoSecureStore.ts (1)

113-134: LGTM!

The notifyListeners() call is correctly placed after the deletion loop completes.

lib/sessionManager/stores/memory.ts (2)

19-22: LGTM!

The synchronous cache clear followed by notifyListeners() is correct.


82-91: LGTM!

The notifyListeners() call is correctly placed after the deletion loop.

lib/sessionManager/stores/chromeStore.ts (2)

29-33: LGTM!

The await ensures all items are cleared before notifying listeners.


92-107: LGTM!

The deletion loop completes before notifyListeners() is called.

lib/sessionManager/stores/localStorage.ts (1)

96-113: LGTM!

The deletion loop completes before notifyListeners() is invoked.

lib/sessionManager/stores/memory.test.ts (1)

142-431: Comprehensive test coverage for subscription mechanism.

The test suite thoroughly validates:

  • Listener invocation on set/remove/destroy operations
  • Multiple listeners and async listener support
  • Unsubscribe behavior
  • Batching semantics and microtask boundaries
  • Deduplication of duplicate listener subscriptions

Note: These tests will fail until the missing notifyListeners() call for string values in memory.ts setSessionItem is added (flagged in separate comment).

lib/sessionManager/stores/localStorage.test.ts (1)

164-454: Comprehensive test coverage mirroring memory store tests.

The test suite validates the same subscription semantics as memory.test.ts, ensuring consistent behavior across storage implementations.

Note: These tests will fail until the missing notifyListeners() call for string values in localStorage.ts setSessionItem is added (flagged in separate comment).

lib/sessionManager/types.ts (3)

8-8: LGTM!

The StoreListener type correctly supports both synchronous and asynchronous listeners.


41-82: Well-designed batching and subscription mechanism.

The implementation provides:

  • Deduplication via Set<StoreListener> preventing accidental double-subscription
  • Batching via notificationScheduled flag to coalesce multiple rapid calls within the same execution context
  • Microtask-based notification scheduling for proper async handling
  • Promise.all to await all listeners (both sync and async)
  • Clean unsubscribe pattern returning a cleanup function

The void operator on line 55 makes notifyListeners() synchronous while still scheduling the async notification properly.


163-173: LGTM!

The public API additions to SessionManager are well-documented and provide a clear contract for the subscription mechanism.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
lib/sessionManager/stores/expoSecureStore.ts (1)

63-71: Critical: Async operations in forEach not awaited, causing race condition.

The forEach loop spawns multiple async operations without awaiting them, so notifyListeners() on line 71 executes before the storage writes complete. Listeners will be notified while chunks are still being written, potentially reading incomplete/stale data.

Apply this diff to await all write operations:

     if (typeof itemValue === "string") {
-      splitString(itemValue, Math.min(storageSettings.maxLength, 2048)).forEach(
-        async (splitValue, index) => {
-          await expoSecureStore!.setItemAsync(
-            `${storageSettings.keyPrefix}${itemKey}${index}`,
-            splitValue,
-          );
-        },
-      );
+      const chunks = splitString(itemValue, Math.min(storageSettings.maxLength, 2048));
+      await Promise.all(
+        chunks.map((splitValue, index) =>
+          expoSecureStore!.setItemAsync(
+            `${storageSettings.keyPrefix}${itemKey}${index}`,
+            splitValue,
+          )
+        )
+      );
       this.notifyListeners();
       return;
lib/sessionManager/stores/chromeStore.ts (1)

48-56: Critical: Async operations in forEach not awaited, causing race condition.

The forEach loop spawns multiple async chrome.storage.local.set operations without awaiting them, so notifyListeners() on line 56 executes before the writes complete. Listeners will be notified while chunks are still being written, potentially reading incomplete data.

Apply this diff to await all write operations:

     if (typeof itemValue === "string") {
-      splitString(itemValue, storageSettings.maxLength).forEach(
-        async (splitValue, index) => {
-          await chrome.storage.local.set({
-            [`${storageSettings.keyPrefix}${itemKey}${index}`]: splitValue,
-          });
-        },
-      );
+      const chunks = splitString(itemValue, storageSettings.maxLength);
+      await Promise.all(
+        chunks.map((splitValue, index) =>
+          chrome.storage.local.set({
+            [`${storageSettings.keyPrefix}${itemKey}${index}`]: splitValue,
+          })
+        )
+      );
       this.notifyListeners();
       return;
lib/sessionManager/stores/localStorage.ts (1)

48-64: Critical: Missing notifyListeners() call for string values.

The string code path (lines 48-57) returns early on line 57 without calling notifyListeners(), while the non-string path (lines 59-64) does call it. Since string values are the primary use case for session storage, listeners won't be notified for most write operations, breaking the subscription mechanism.

Apply this diff to notify listeners for string values:

     if (typeof itemValue === "string") {
       splitString(itemValue, storageSettings.maxLength).forEach(
         (splitValue, index) => {
           localStorage.setItem(
             `${storageSettings.keyPrefix}${itemKey}${index}`,
             splitValue,
           );
         },
       );
+      this.notifyListeners();
       return;
     }
🧹 Nitpick comments (1)
lib/sessionManager/stores/expoSecureStore.ts (1)

43-45: Remove redundant notifyListeners() in expoSecureStore.destroySession

SessionBase.notifyListeners() uses scheduleNotification() with a notificationScheduled guard, so multiple calls coalesce — the final this.notifyListeners() in lib/sessionManager/stores/expoSecureStore.ts (destroySession) is redundant; remove it for consistency with other stores.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5eff7fe and 83ddf1f.

📒 Files selected for processing (4)
  • lib/sessionManager/stores/chromeStore.ts (3 hunks)
  • lib/sessionManager/stores/expoSecureStore.ts (3 hunks)
  • lib/sessionManager/stores/localStorage.ts (3 hunks)
  • lib/sessionManager/stores/memory.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
lib/sessionManager/stores/chromeStore.ts (1)
lib/sessionManager/index.ts (1)
  • storageSettings (3-42)
lib/sessionManager/stores/memory.ts (1)
lib/sessionManager/index.ts (1)
  • storageSettings (3-42)
🔇 Additional comments (8)
lib/sessionManager/stores/memory.ts (3)

19-21: LGTM!

The destroySession implementation correctly clears the cache and notifies listeners synchronously.


30-51: LGTM!

Both code paths (string and non-string) correctly notify listeners after completing the write operations. The synchronous nature of memory storage ensures listeners see consistent state.


83-92: LGTM!

The removeSessionItem implementation correctly removes all matching keys and notifies listeners.

lib/sessionManager/stores/chromeStore.ts (3)

29-33: LGTM!

The destroySession implementation correctly clears storage and notifies listeners.


59-64: LGTM!

The non-string path correctly awaits the write operation before notifying listeners.


93-108: LGTM!

The removeSessionItem implementation correctly removes all chunks sequentially and notifies listeners.

lib/sessionManager/stores/localStorage.ts (2)

27-31: Verify if duplicate notifications in destroySession are intentional.

destroySession calls removeSessionItem on each key via Promise.all (line 27-29), and each removeSessionItem internally calls notifyListeners(). Then line 31 calls notifyListeners() again. This pattern differs from memory.ts and chromeStore.ts which avoid the duplication. The microtask batching might coalesce these, but the intent is unclear.

See the verification script in the review comment for expoSecureStore.ts at lines 43-45 to check SessionBase's batching implementation.


96-113: LGTM!

The removeSessionItem implementation correctly removes all chunks and notifies listeners.

Copy link
Member

@DanielRivers DanielRivers left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@DanielRivers DanielRivers merged commit 452abcb into main Oct 7, 2025
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants