Review an optimistic cart across tabs

src/cart-store.tsTypeScript
@@ -0,0 +1,82 @@
1+export interface Cart {
2+ id: string;
3+ revision: number;
4+ lines: Array<{ itemId: string; quantity: number }>;
5+}
6+
7+export interface CartSnapshotMessage {
8+ sourceTabId: string;
9+ cart: Cart;
10+}
11+
12+export interface CartClient {
13+ replaceCart(request: {
14+ requestId: string;
15+ cart: Cart;
16+ }): Promise<{ cart: Cart }>;
17+}
18+
19+function cloneCart(cart: Cart): Cart {
20+ return { ...cart, lines: cart.lines.map((line) => ({ ...line })) };
21+}
22+
23+type Listener = () => void;
24+
25+export class CartStore {
26+ #cart: Cart;
27+ #pending = false;
28+ #listeners = new Set<Listener>();
29+
30+ constructor(initialCart: Cart) {
31+ this.#cart = cloneCart(initialCart);
32+ }
33+
34+ subscribe(listener: Listener) {
35+ this.#listeners.add(listener);
36+ return () => {
37+ this.#listeners.delete(listener);
38+ };
39+ }
40+
41+ snapshot() {
42+ return {
43+ cart: cloneCart(this.#cart),
44+ pending: this.#pending,
45+ };
46+ }
47+
48+ beginMutation() {
49+ this.#pending = true;
50+ this.#emit();
51+ }
52+
53+ finishMutation() {
54+ this.#pending = false;
55+ this.#emit();
56+ }
57+
58+ setQuantity(itemId: string, quantity: number) {
59+ const nextLines = this.#cart.lines.filter((line) => line.itemId !== itemId);
60+ if (quantity > 0) {
61+ nextLines.push({ itemId, quantity });
62+ }
63+
64+ this.#cart = {
65+ ...this.#cart,
66+ revision: this.#cart.revision + 1,
67+ lines: nextLines,
68+ };
69+ this.#emit();
70+ }
71+
72+ replace(cart: Cart) {
73+ this.#cart = cloneCart(cart);
74+ this.#emit();
75+ }
76+
77+ #emit() {
78+ for (const listener of this.#listeners) {
79+ listener();
80+ }
81+ }
82+}