Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/ui-extensions-tester/src/point-of-sale/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
LineItem,
Storage,
SubscribableStorage,
CartApiContent,
} from '@shopify/ui-extensions/point-of-sale';

Expand Down Expand Up @@ -38,7 +39,23 @@ export function createStorage<
const store = new Map<string, unknown>(
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<T>, {
get(_target, prop) {
return createSubscribable(prop as string);
},
});

const storage: Storage<T> = {
current: currentProxy,
set: async (key, value) => {
store.set(key as string, value);
},
Expand Down
91 changes: 91 additions & 0 deletions packages/ui-extensions-tester/src/tests/pos-factories.test.ts
Original file line number Diff line number Diff line change
@@ -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?.();
});
});
});
1 change: 1 addition & 0 deletions packages/ui-extensions/src/surfaces/point-of-sale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
2 changes: 1 addition & 1 deletion packages/ui-extensions/src/surfaces/point-of-sale/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
44 changes: 44 additions & 0 deletions packages/ui-extensions/src/surfaces/point-of-sale/types/storage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type {ReadonlySignalLike} from '../../../shared';

/**
* @publicDocs
*/
Expand All @@ -17,6 +19,31 @@ export class StorageError extends Error {
export interface Storage<
BaseStorageTypes extends Record<string, any> = Record<string, unknown>,
> {
/**
* Reactive access to storage values as Subscribables.
*
* Each key is exposed as a `ReadonlySignalLike<T | undefined>`, 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<BaseStorageTypes>;

/**
* 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.
*
Expand Down Expand Up @@ -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<string, any> = Record<string, unknown>,
> = {
readonly [K in keyof BaseStorageTypes]: ReadonlySignalLike<
BaseStorageTypes[K] | undefined
>;
};
Loading