diff --git a/packages/ui-extensions-tester/src/point-of-sale/index.ts b/packages/ui-extensions-tester/src/point-of-sale/index.ts index 404bfa9ca7..121193dd04 100644 --- a/packages/ui-extensions-tester/src/point-of-sale/index.ts +++ b/packages/ui-extensions-tester/src/point-of-sale/index.ts @@ -1,6 +1,7 @@ import type { LineItem, Storage, + SubscribableStorage, CartApiContent, } from '@shopify/ui-extensions/point-of-sale'; @@ -38,7 +39,23 @@ export function createStorage< const store = new Map( initialValues ? Object.entries(initialValues) : [], ); + const createSubscribable = (key: string) => ({ + get value() { + return store.get(key) as never; + }, + subscribe(_fn: (value: unknown) => void) { + return () => {}; + }, + }); + + const currentProxy = new Proxy({} as SubscribableStorage, { + get(_target, prop) { + return createSubscribable(prop as string); + }, + }); + const storage: Storage = { + current: currentProxy, set: async (key, value) => { store.set(key as string, value); }, diff --git a/packages/ui-extensions-tester/src/tests/pos-factories.test.ts b/packages/ui-extensions-tester/src/tests/pos-factories.test.ts new file mode 100644 index 0000000000..d4375bb21b --- /dev/null +++ b/packages/ui-extensions-tester/src/tests/pos-factories.test.ts @@ -0,0 +1,91 @@ +import {createStorage} from '../point-of-sale'; + +describe('createStorage', () => { + it('set and get round-trips a value', async () => { + const storage = createStorage(); + await storage.set('key1', 'hello'); + expect(await storage.get('key1')).toBe('hello'); + }); + + it('get returns undefined for missing keys', async () => { + const storage = createStorage(); + expect(await storage.get('missing')).toBeUndefined(); + }); + + it('initializes with provided values', async () => { + const storage = createStorage({theme: 'dark', count: 42}); + expect(await storage.get('theme')).toBe('dark'); + expect(await storage.get('count')).toBe(42); + }); + + it('delete removes a key and returns true', async () => { + const storage = createStorage({key1: 'val'}); + expect(await storage.delete('key1')).toBe(true); + expect(await storage.get('key1')).toBeUndefined(); + }); + + it('delete returns false for non-existent key', async () => { + const storage = createStorage(); + expect(await storage.delete('nope')).toBe(false); + }); + + it('clear removes all entries', async () => { + const storage = createStorage({alpha: 1, beta: 2}); + await storage.clear(); + expect(await storage.get('alpha')).toBeUndefined(); + expect(await storage.get('beta')).toBeUndefined(); + }); + + it('entries returns all key-value pairs', async () => { + const storage = createStorage({width: 10, height: 20}); + const entries = await storage.entries(); + const arr = [...entries]; + expect(arr).toContainEqual(['width', 10]); + expect(arr).toContainEqual(['height', 20]); + }); + + describe('current', () => { + it('is defined when created by the factory', () => { + const storage = createStorage({syncStatus: 'pending'}); + expect(storage.current).toBeDefined(); + }); + + it('provides subscribable access to stored values', () => { + const storage = createStorage({syncStatus: 'pending'}); + expect(storage.current?.syncStatus.value).toBe('pending'); + }); + + it('reflects updates made via set', async () => { + const storage = createStorage({syncStatus: 'pending'}); + await storage.set('syncStatus', 'complete'); + expect(storage.current?.syncStatus.value).toBe('complete'); + }); + + it('returns undefined for keys that do not exist', () => { + const storage = createStorage<{missing: string}>(); + expect(storage.current?.missing.value).toBeUndefined(); + }); + + it('returns undefined after key is deleted', async () => { + const storage = createStorage({syncStatus: 'pending'}); + expect(storage.current?.syncStatus.value).toBe('pending'); + await storage.delete('syncStatus'); + expect(storage.current?.syncStatus.value).toBeUndefined(); + }); + + it('returns undefined after clear', async () => { + const storage = createStorage({alpha: 1, beta: 2}); + expect(storage.current?.alpha.value).toBe(1); + await storage.clear(); + expect(storage.current?.alpha.value).toBeUndefined(); + expect(storage.current?.beta.value).toBeUndefined(); + }); + + it('subscribe returns an unsubscribe function', () => { + const storage = createStorage({key: 'val'}); + const unsubscribe = storage.current?.key.subscribe(() => {}); + expect(typeof unsubscribe).toBe('function'); + unsubscribe?.(); + }); + }); +}); diff --git a/packages/ui-extensions/src/surfaces/point-of-sale.ts b/packages/ui-extensions/src/surfaces/point-of-sale.ts index 6cfc50f190..e0d8dfa0bd 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale.ts @@ -3,3 +3,4 @@ export * from './point-of-sale/events'; export * from './point-of-sale/extension-targets'; export * from './point-of-sale/event/data'; export * from './point-of-sale/event/output'; +export type {ReadonlySignalLike} from '../shared'; diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/api.ts b/packages/ui-extensions/src/surfaces/point-of-sale/api.ts index 3ded99c263..b1d730e95c 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/api.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/api.ts @@ -135,7 +135,7 @@ export type { export type {CountryCode} from './types/country-code'; export type {Session} from './types/session'; -export type {Storage} from './types/storage'; +export type {Storage, SubscribableStorage} from './types/storage'; export {StorageError} from './types/storage'; export type {PinPadApiContent, PinPadApi} from './api/pin-pad-api'; diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/types/storage.ts b/packages/ui-extensions/src/surfaces/point-of-sale/types/storage.ts index f8db9f5460..58d93c1116 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/types/storage.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/types/storage.ts @@ -1,3 +1,5 @@ +import type {ReadonlySignalLike} from '../../../shared'; + /** * @publicDocs */ @@ -17,6 +19,31 @@ export class StorageError extends Error { export interface Storage< BaseStorageTypes extends Record = Record, > { + /** + * Reactive access to storage values as Subscribables. + * + * Each key is exposed as a `ReadonlySignalLike`, enabling cross-target + * reactivity. One extension target can subscribe to a key and react when + * another target updates it via `set()` or `delete()`. + * + * The `value` property provides synchronous access to the current stored value. + * The `subscribe()` method registers a callback that fires whenever the value + * changes, including changes made by other extension targets within the same app. + * + * @example Cross-target reactivity + * ```typescript + * // In one extension target: + * await shopify.storage.set('syncStatus', 'complete'); + * + * // In another target: + * const status = shopify.storage.current.syncStatus.value; // 'complete' + * const unsubscribe = shopify.storage.current.syncStatus.subscribe((value) => { + * // Reacts when another target updates this key + * }); + * ``` + */ + current?: SubscribableStorage; + /** * Stores a value under the specified key, overwriting any existing value. Values must be JSON-serializable and return `StorageError` when storage limits are exceeded. Commonly used for storing user preferences, caching API responses, or passing contextual data from tiles to modals. * @@ -77,3 +104,20 @@ export interface Storage< Keys extends keyof StorageTypes = keyof StorageTypes, >(): Promise<[Keys, StorageTypes[Keys]][]>; } + +/** + * Provides reactive, subscribable access to individual storage keys. + * + * Each property is a `ReadonlySignalLike` that reflects the current value of the + * corresponding storage key. Values are `undefined` when the key does not exist. + * + * Mutations are performed through the existing `Storage` methods (`set`, + * `delete`, `clear`, etc.) — `SubscribableStorage` is read-only and reactive. + */ +export type SubscribableStorage< + BaseStorageTypes extends Record = Record, +> = { + readonly [K in keyof BaseStorageTypes]: ReadonlySignalLike< + BaseStorageTypes[K] | undefined + >; +};