|
| 1 | +# Fix ThemeSettingsStore Max Listeners Exceeded (Issue #431) |
| 2 | + |
| 3 | +**Date:** 2025-07-15 |
| 4 | +**Status:** Approved |
| 5 | +**Scope:** SDK fix + Pilot template optimization + tests |
| 6 | + |
| 7 | +## Problem |
| 8 | + |
| 9 | +Console warning appears on all Weaverse Hydrogen themes: |
| 10 | +``` |
| 11 | +ThemeSettingsStore: Maximum listeners (100) exceeded. Possible memory leak detected. |
| 12 | +``` |
| 13 | + |
| 14 | +The `ThemeSettingsStore` class in `packages/hydrogen/src/utils/use-theme-settings-store.ts` has a hard cap of 100 listeners. When exceeded, new subscriptions are silently dropped (returns a no-op cleanup function), causing components to stop receiving theme setting updates. |
| 15 | + |
| 16 | +### Why 100 Is Too Low |
| 17 | + |
| 18 | +In the Pilot template, `useThemeSettings()` is called from 17+ files with 19+ call sites. A product grid with 10 products generates ~60 listeners from badges alone (each `ProductBadges` triggers 6 `useThemeSettings()` calls). Adding header, footer, logo, style, animation, newsletter, slideshow, and link components easily pushes past 100. |
| 19 | + |
| 20 | +### Root Cause |
| 21 | + |
| 22 | +1. **100 listener cap is too low** for real-world usage |
| 23 | +2. **Silent subscription drops** when cap is hit — components stop updating |
| 24 | +3. **Pilot badges.tsx** calls `useThemeSettings()` 6 times per product card (once in each badge component plus the shared `Badge` component) |
| 25 | + |
| 26 | +React's `useSyncExternalStore` handles cleanup properly on unmount. The `Set` prevents duplicate listeners. The cap only breaks legitimate subscriptions. |
| 27 | + |
| 28 | +## Approach: Remove Cap, Optimize Template |
| 29 | + |
| 30 | +Chose over two alternatives: |
| 31 | +- **Nanostores migration**: Too large a change surface, breaks API, higher regression risk |
| 32 | +- **Selector pattern**: YAGNI — solving an unmeasured performance problem with massive scope creep |
| 33 | + |
| 34 | +## Changes |
| 35 | + |
| 36 | +### 1. ThemeSettingsStore — Remove Listener Cap |
| 37 | + |
| 38 | +**File:** `packages/hydrogen/src/utils/use-theme-settings-store.ts` |
| 39 | + |
| 40 | +- Remove `MAX_LISTENERS` constant |
| 41 | +- Remove the cap check that silently drops subscriptions |
| 42 | +- Add a dev-only warning that fires once when listener count exceeds 500 (advisory, never blocks) |
| 43 | +- Keep everything else: `Set<() => void>`, `isDestroyed` guards, `emit()` with snapshot copy, `destroy()`, singleton via `window.__weaverseThemeSettingsStore` |
| 44 | + |
| 45 | +```typescript |
| 46 | +private hasWarnedListenerCount = false |
| 47 | + |
| 48 | +subscribe = (callback: () => void) => { |
| 49 | + if (this.isDestroyed) { |
| 50 | + console.warn('ThemeSettingsStore: Cannot subscribe to destroyed store') |
| 51 | + return () => {} |
| 52 | + } |
| 53 | + |
| 54 | + this.listeners.add(callback) |
| 55 | + |
| 56 | + if (!this.hasWarnedListenerCount && this.listeners.size > 500) { |
| 57 | + this.hasWarnedListenerCount = true |
| 58 | + console.warn( |
| 59 | + `ThemeSettingsStore: ${this.listeners.size} listeners detected. ` + |
| 60 | + 'This may indicate a performance issue. Consider using fewer useThemeSettings() calls.' |
| 61 | + ) |
| 62 | + } |
| 63 | + |
| 64 | + return () => { |
| 65 | + this.listeners.delete(callback) |
| 66 | + } |
| 67 | +} |
| 68 | +``` |
| 69 | + |
| 70 | +### 2. Pilot Template — Consolidate Badge useThemeSettings Calls |
| 71 | + |
| 72 | +**File:** `templates/pilot/app/components/product/badges.tsx` |
| 73 | + |
| 74 | +Move the single `useThemeSettings()` call to `ProductBadges` and pass settings as props to child badge components. This reduces 6 subscriptions per product card to 1. |
| 75 | + |
| 76 | +- `ProductBadges` calls `useThemeSettings()` once, destructures all badge-related settings |
| 77 | +- Each badge component (`NewBadge`, `SaleBadge`, `BestSellerBadge`, `SoldOutBadge`, `BundleBadge`) receives settings via props |
| 78 | +- `Badge` becomes a pure presentational component (already receives style values as props) |
| 79 | + |
| 80 | +### 3. Tests |
| 81 | + |
| 82 | +**File:** `packages/hydrogen/src/utils/__tests__/use-theme-settings-store.test.ts` |
| 83 | + |
| 84 | +Test cases: |
| 85 | +- Subscribe adds listener, unsubscribe removes it |
| 86 | +- No listener limit: 200+ listeners all receive updates |
| 87 | +- Destroyed store: subscribe returns no-op |
| 88 | +- Emit notifies all listeners |
| 89 | +- Cleanup: unsubscribed listener not called on update |
| 90 | + |
| 91 | +## What's NOT Changing |
| 92 | + |
| 93 | +- `WeaverseHydrogenRoot.tsx` — `useThemeSettings()` and `withWeaverse()` are correct |
| 94 | +- `@weaverse/react` or `@weaverse/core` — no changes needed |
| 95 | +- Other templates (Aspen, Naturelle) — benefit from SDK fix automatically |
| 96 | +- No selector pattern or store library migration |
0 commit comments