From 89874b9654bc69803b39f7b34b6b4aeb78088dad Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 5 May 2026 00:14:20 +0300 Subject: [PATCH 1/2] feat: add custom equality comparison for `update` method - Introduce `compare` option for `update()` to skip writes based on custom equality logic. - Extend `StorageUpdateOptions` with `compare` field for precise control over update logic. - Ensure no-op writes do not trigger unnecessary updates or storage writes. - Add comprehensive test coverage for `compare` usage. - Update documentation with examples to demonstrate custom `compare` usage. --- README.md | 23 +++++++- src/providers/AbstractStorage.ts | 16 +++++- src/providers/MonoStorage.test.ts | 42 ++++++++++++++ src/providers/MonoStorage.ts | 21 +++++-- src/providers/SecureStorage.test.ts | 17 ++++++ src/providers/Storage.test.ts | 88 ++++++++++++++++++++++++++++- src/types.ts | 17 +++++- 7 files changed, 215 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f89b3c0..c65d5ea 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,25 @@ await storage.update( ); ``` +### Custom compare + +`update()` skips writes when the returned value is equal to the previous value. +Pass `compare` when a specific update needs custom equality rules. + +```ts +await storage.update( + "settings", + prev => ({...prev, theme: "dark"}), + { + compare: (prev, next) => prev?.version === next?.version, + } +); +``` + +If `compare` returns `true`, the values are treated as equal and no write is made. +This also means no `watch()` callbacks are triggered for that update. Use +`compare: () => false` when you need to force a write and notification. + ### Important note Atomic operations rely on the Web Locks API. @@ -213,7 +232,9 @@ await popup.update("filters", prev => [...(prev ?? []), "pinned"]); const state = await popup.getAll(); ``` -This keeps related values grouped and easier to manage. +This keeps related values grouped and easier to manage. `MonoStorage.set()` updates +one field inside the grouped object, so it performs the same locked bucket update +as `MonoStorage.update()` instead of writing a separate top-level storage key. ## Watching changes diff --git a/src/providers/AbstractStorage.ts b/src/providers/AbstractStorage.ts index 7e7d962..b529588 100644 --- a/src/providers/AbstractStorage.ts +++ b/src/providers/AbstractStorage.ts @@ -1,5 +1,6 @@ import {browser} from "@addon-core/browser"; import {callWithPromise, handleListener} from "@addon-core/browser/utils"; +import {dequal as defaultCompare} from "dequal/lite"; import LockManager from "../LockManager"; import MonoStorage from "./MonoStorage"; import type { @@ -7,6 +8,7 @@ import type { StorageLockOptions, StorageProvider, StorageState, + StorageUpdateOptions, StorageUpdater, StorageWatchOptions, } from "../types"; @@ -152,8 +154,10 @@ export default abstract class AbstractStorage implements public async update( key: K, updater: StorageUpdater, - options?: StorageLockOptions + options?: StorageUpdateOptions ): Promise { + const {compare = defaultCompare, ...lockOptions} = options ?? {}; + return await this.locker.request( this.getLockKey(key), async () => { @@ -161,15 +165,23 @@ export default abstract class AbstractStorage implements const next = await updater(prev); if (next === undefined) { + if (prev === undefined) { + return undefined; + } + await this.removeUnlocked(key); return undefined; } + if (compare(prev, next)) { + return next; + } + await this.setUnlocked(key, next); return next; }, - options + lockOptions ); } diff --git a/src/providers/MonoStorage.test.ts b/src/providers/MonoStorage.test.ts index 6d3899e..4f033b7 100644 --- a/src/providers/MonoStorage.test.ts +++ b/src/providers/MonoStorage.test.ts @@ -81,6 +81,48 @@ test("update serializes concurrent bucket mutations", async () => { expect(await mono.get("a")).toBe(2); }); +test("update skips physical bucket write when inner value is equal", async () => { + const mono = new MonoStorage(key, base); + await mono.set("b", {x: 1}); + + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await mono.update("b", prev => (prev && typeof prev === "object" ? {...prev} : {x: 1})); + + expect(setSpy).not.toHaveBeenCalled(); + expect(await mono.getAll()).toEqual({b: {x: 1}}); +}); + +test("update skips physical bucket write when deleting an absent inner key", async () => { + const mono = new MonoStorage(key, base); + await mono.set("a", 1); + + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + setSpy.mockClear(); + removeSpy.mockClear(); + + await mono.update("c", () => undefined); + + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + expect(await mono.getAll()).toEqual({a: 1}); +}); + +test("update writes the physical bucket when inner value changes", async () => { + const mono = new MonoStorage(key, base); + await mono.set("a", 1); + + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await mono.update("a", prev => (prev ?? 0) + 1); + + expect(setSpy).toHaveBeenCalledTimes(1); + expect(await mono.getAll()).toEqual({a: 2}); +}); + test("getAll returns the whole bucket", async () => { const mono = new MonoStorage(key, base); await mono.set("a", 1); diff --git a/src/providers/MonoStorage.ts b/src/providers/MonoStorage.ts index 68285e8..022e493 100644 --- a/src/providers/MonoStorage.ts +++ b/src/providers/MonoStorage.ts @@ -1,5 +1,12 @@ import {dequal as isEqual} from "dequal/lite"; -import type {StorageLockOptions, StorageProvider, StorageState, StorageUpdater, StorageWatchOptions} from "../types"; +import type { + StorageLockOptions, + StorageProvider, + StorageState, + StorageUpdateOptions, + StorageUpdater, + StorageWatchOptions, +} from "../types"; export default class MonoStorage implements StorageProvider { constructor( @@ -30,8 +37,10 @@ export default class MonoStorage imple public async update( key: KP, updater: StorageUpdater, - options?: StorageLockOptions + options?: StorageUpdateOptions ): Promise { + const {compare = isEqual, ...lockOptions} = options ?? {}; + const nextBucket = await this.storage.update( this.key, async bucketValue => { @@ -41,7 +50,7 @@ export default class MonoStorage imple if (nextValue === undefined) { if (!(key in bucket)) { - return bucket; + return bucketValue; } const next = {...bucket}; @@ -50,9 +59,13 @@ export default class MonoStorage imple return Object.keys(next).length === 0 ? undefined : next; } + if (compare(prevValue, nextValue)) { + return bucketValue; + } + return {...bucket, [key]: nextValue}; }, - options + lockOptions ); return nextBucket?.[key] as T[KP] | undefined; diff --git a/src/providers/SecureStorage.test.ts b/src/providers/SecureStorage.test.ts index eddebd8..5f8867a 100644 --- a/src/providers/SecureStorage.test.ts +++ b/src/providers/SecureStorage.test.ts @@ -116,6 +116,23 @@ describe("set method", () => { }); }); +describe("update method", () => { + test("skips encryption and storage write when decrypted value is equal", async () => { + await securedStorage.set("settings", {theme: "dark"}); + + const encryptSpy = crypto.subtle.encrypt as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + encryptSpy.mockClear(); + setSpy.mockClear(); + + await securedStorage.update("settings", prev => ({...prev})); + + expect(encryptSpy).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + expect((await securedStorage.getAll())["settings"]).toEqual({theme: "dark"}); + }); +}); + describe("remove method", () => { test("deletes the key without namespace", async () => { await securedStorage.set("theme", "dark"); diff --git a/src/providers/Storage.test.ts b/src/providers/Storage.test.ts index 3bd42dc..ee4175b 100644 --- a/src/providers/Storage.test.ts +++ b/src/providers/Storage.test.ts @@ -141,8 +141,13 @@ test("update method - forwards lock options to custom storage locker", async () const isolatedStorage = new Storage({locker}); const controller = new AbortController(); + const compare = () => false; - await isolatedStorage.update("counter", prev => (prev ?? 0) + 1, {signal: controller.signal, timeout: 25}); + await isolatedStorage.update("counter", prev => (prev ?? 0) + 1, { + signal: controller.signal, + timeout: 25, + compare, + }); expect(requests).toEqual([ { @@ -154,6 +159,87 @@ test("update method - forwards lock options to custom storage locker", async () expect(await isolatedStorage.get("counter")).toBe(1); }); +describe("update method - no-op writes", () => { + test.each([ + ["primitive", "dark", () => "dark"], + ["object", {theme: "dark"}, (prev: {theme: string} | undefined) => ({...prev})], + ])("skips storage.set when the next %s value is equal", async (_, initialValue, updater) => { + await storage.set("settings", initialValue); + + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await storage.update("settings", updater as any); + + expect(setSpy).not.toHaveBeenCalled(); + expect(await storage.get("settings")).toEqual(initialValue); + }); + + test("writes when the next value changes", async () => { + await storage.set("settings", {theme: "light"}); + + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await storage.update("settings", prev => ({...prev, theme: "dark"})); + + expect(setSpy).toHaveBeenCalledTimes(1); + expect(await storage.get("settings")).toEqual({theme: "dark"}); + }); + + test("writes when creating a missing value", async () => { + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await storage.update("settings", () => ({theme: "dark"})); + + expect(setSpy).toHaveBeenCalledTimes(1); + expect(await storage.get("settings")).toEqual({theme: "dark"}); + }); + + test("skips storage.remove when deleting an already missing value", async () => { + const removeSpy = chrome.storage.local.remove as jest.Mock; + removeSpy.mockClear(); + + await storage.update("missing", () => undefined); + + expect(removeSpy).not.toHaveBeenCalled(); + }); + + test("custom compare can force a write for equal values", async () => { + const initialValue = {theme: "dark"}; + const nextValue = {theme: "dark"}; + const compare = jest.fn(() => false); + + await storage.set("settings", initialValue); + + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await storage.update("settings", () => nextValue, { + compare, + }); + + expect(compare).toHaveBeenCalledWith(initialValue, nextValue); + expect(setSpy).toHaveBeenCalledTimes(1); + expect(await storage.get("settings")).toEqual({theme: "dark"}); + }); + + test("custom compare can force a skip for unequal values", async () => { + await storage.set("settings", {theme: "light"}); + + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await storage.update("settings", () => ({theme: "dark"}), { + compare: () => true, + }); + + expect(setSpy).not.toHaveBeenCalled(); + expect(await storage.get("settings")).toEqual({theme: "light"}); + }); +}); + test("getAll method - returns all values from current namespace", async () => { await storage.set("a", 1); await storage.set("b", 2); diff --git a/src/types.ts b/src/types.ts index cc38922..842b48d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,21 @@ export interface StorageLocker { export type StorageUpdater = (prev: T | undefined) => T | undefined | Promise; +/** + * Equality check for `update()`. Return `true` to treat values as equal and skip the write. + * + * The comparer is not called when the updater returns `undefined`. + * For `SecureStorage`, values are decrypted before comparison. + */ +export type StorageUpdateComparer = (prev: T | undefined, next: T | undefined) => boolean; + +export interface StorageUpdateOptions extends StorageLockOptions { + /** + * Custom equality check for this update. Return `true` to skip the physical write. + */ + compare?: StorageUpdateComparer; +} + export type StorageWatchCallback = ( newValue: T[K] | undefined, oldValue: T[K] | undefined, @@ -36,7 +51,7 @@ export interface StorageProvider { update( key: K, updater: StorageUpdater, - options?: StorageLockOptions + options?: StorageUpdateOptions ): Promise; get(key: K): Promise; From f457ced9e0f06258bb221bb612a85a26bd1edf24 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 5 May 2026 00:20:39 +0300 Subject: [PATCH 2/2] docs: improve atomic updates section in README - Expand explanation of atomic updates for browser extensions. - Provide examples for extension state shared across multiple contexts. - Clarify use cases such as counters, retries, toggles, and queue metadata. --- README.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c65d5ea..3a977c7 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,9 @@ await settings.remove("language"); ## Atomic updates If the next value depends on the previous one, use `update()` instead of `get()` + `set()`. +This is especially useful in browser extensions, where the same storage value can +be updated from different contexts. Atomic updates keep each read-modify-write +operation consistent, so one context does not overwrite changes made by another. ```ts interface CounterState { @@ -109,13 +112,13 @@ const storage = Storage.Local(); await storage.update("installCount", prev => (prev ?? 0) + 1); ``` -This is useful for: +Use it for extension state that can be touched from more than one context: -- counters; -- retry state; -- toggles; -- queue metadata; -- any concurrent read-modify-write flow. +- install or usage counters; +- retry state shared by background and UI; +- popup or options toggles; +- queue metadata for background jobs; +- any read-modify-write flow shared across extension contexts. ### With timeout or abort signal