Split useNotification into state + actions via useBetween singleton

useNotification is consumed by ~44 sites in the codebase but most of
them only need a single imperative entry point (typically simpleAlert
or showConfirm). The hook also runs ~24 useMessageEvent listeners
internally to translate server events into queued notifications.

Same singleton-filter pattern as useWiredTools / useTranslation:

- useNotificationStore (internal, was useNotificationState) — the
  previous body unchanged. ~30 listeners + 5 state slices + 8 actions
  in one closure.
- useNotificationState (public, read-only) — useBetween filter
  exposing only the three queue arrays (alerts, bubbleAlerts,
  confirms). Used by the global NotificationView renderer.
- useNotificationActions (public, imperative) — useBetween filter
  exposing the 8 entry points: simpleAlert / showNitroAlert /
  showTradeAlert / showConfirm / showSingleBubble +
  closeAlert / closeBubbleAlert / closeConfirm.
- useNotification (deprecated shim) — composes the singleton via
  useBetween, preserving the historical return shape so the 44
  existing call sites keep working.

Also brings CLAUDE.md's 'What's wired up' table up to date with the
splits done this session (chat-input doorbell-style, wired-tools +
translation singleton-filter, plus this notification one) and the
8 useCatalog fetch migrations to TanStack queries.
This commit is contained in:
simoleo89
2026-05-11 22:56:32 +02:00
parent 7cf01b0947
commit 5344eaf5c0
2 changed files with 76 additions and 9 deletions
+8 -7
View File
@@ -155,19 +155,20 @@ Login / Register / Forgot in `src/components/login/LoginView.tsx` use
| Adopted | Pilot sites | | Adopted | Pilot sites |
|---|---| |---|---|
| `useNitroEventState` | `OfferView` | | `useNitroEventState` + companions (Reducer, ExternalSnapshot) | `OfferView`, `useAvatarInfoWidget` (figure/badges/group reducer), `useInventoryFurni` (pure reducers + fragments useRef) |
| `useNitroQuery` | `OfferView`, `CatalogLayoutRoomAdsView`, `ModToolsChatlogView`, `CfhChatlogView` | | `useNitroQuery` + `useNitroEventInvalidator` | `OfferView`, `CatalogLayoutRoomAdsView`, `ModToolsChatlogView`, `CfhChatlogView`, `useGiftConfiguration`, `useUserGroups`, `useClubOffers(windowId)`, `useSellablePetPalette(breed)`, `useMarketplaceConfiguration`, `useClubGifts` (with invalidator) |
| Zustand | `NavigatorRoomCreatorView` (`useRoomCreatorStore`) | | Zustand | `NavigatorRoomCreatorView` (`useRoomCreatorStore`) |
| God-hook split | `doorbell`, `poll`, `furni-chooser`, `user-chooser`, `friend-request` | | God-hook split (state + actions + shim) | `doorbell`, `poll`, `furni-chooser`, `user-chooser`, `friend-request`, `chat-input` |
| God-hook split (`useBetween` singleton + state filter + actions filter + shim) | `wired-tools`, `translation` |
| `WidgetErrorBoundary` | `RoomWidgetsView` umbrella | | `WidgetErrorBoundary` | `RoomWidgetsView` umbrella |
| Vitest | 99/99 cases on pure helpers + the Zustand store | | Vitest | 99/99 cases on pure helpers + the Zustand store |
| Not yet | Notes | | Not yet | Notes |
|---|---| |---|---|
| Split `useCatalog` (~1100 LOC) | Migrate read-only fetches to `useNitroQuery` first, then split into `useCatalogData` / `useCatalogUiState` / `useCatalogActions`. | | Core `useCatalog` split | Session-stable secondary fetches all migrated to TanStack queries (see ARCHITECTURE.md). What's left: core `rootNode`/`offersToNodes`/`currentPage` slice + Builders Club status. Needs a dedicated `useCatalogData`/`useCatalogUiState`/`useCatalogActions` split. |
| Split `useChatInputWidget` / `useChatWidget` / `useAvatarInfoWidget` | Large state machines; needs careful per-file design before mechanical split. | | Split `useChatWidget` / `useAvatarInfoWidget` | Both state-driven via events with no clean imperative actions to extract — skip-motivated. Already touched today for the InfoStand listener move. |
| Split `usePetPackageWidget` / `useWordQuizWidget` | Their "actions" mutate internal state; need to either pass args or hoist state to a store first. Documented in commit messages, skipped intentionally. | | Split `usePetPackageWidget` / `useWordQuizWidget` / `useChatCommandSelector` | Their "actions" mutate internal state or are tightly interdependent — skip-motivated. |
| Hoist Wired Creator Tools shared state to a Zustand slice | Would remove ~25 props passed to the 3 tab sub-components. | | Hoist Wired Creator Tools shared state to a Zustand slice | Would remove ~25 props passed to the 3 tab sub-components. (Wired-tools split done as singleton-filter; Zustand slice is the next step.) |
| Wider Vitest coverage (React components) | `@testing-library/*` is installed; needs a small renderer-SDK mock layer first. | | Wider Vitest coverage (React components) | `@testing-library/*` is installed; needs a small renderer-SDK mock layer first. |
## Known open logic bugs ## Known open logic bugs
+68 -2
View File
@@ -16,7 +16,19 @@ const getTimeZeroPadded = (time: number) =>
let modDisclaimerTimeout: ReturnType<typeof setTimeout> = null; let modDisclaimerTimeout: ReturnType<typeof setTimeout> = null;
const recentBadgeNotifications = new Set<string>(); const recentBadgeNotifications = new Set<string>();
const useNotificationState = () => /**
* Internal singleton state + actions for the notification subsystem.
* Public consumers should reach for useNotificationState (read-only —
* the queue arrays for the renderer) or useNotificationActions (the
* imperative simpleAlert / showConfirm / showSingleBubble / etc.).
* useNotification is the legacy shim that composes both.
*
* Wrapped in useBetween at each public-hook layer so all consumers see
* the same instance, matching the previous useBetween(useNotificationState)
* behavior — required because ~30 useMessageEvent listeners live inside
* this hook and need to register exactly once across the tree.
*/
const useNotificationStore = () =>
{ {
const [ alerts, setAlerts ] = useState<NotificationAlertItem[]>([]); const [ alerts, setAlerts ] = useState<NotificationAlertItem[]>([]);
const [ bubbleAlerts, setBubbleAlerts ] = useState<NotificationBubbleItem[]>([]); const [ bubbleAlerts, setBubbleAlerts ] = useState<NotificationBubbleItem[]>([]);
@@ -490,4 +502,58 @@ const useNotificationState = () =>
return { alerts, bubbleAlerts, confirms, simpleAlert, showNitroAlert, showTradeAlert, showConfirm, showSingleBubble, closeAlert, closeBubbleAlert, closeConfirm }; return { alerts, bubbleAlerts, confirms, simpleAlert, showNitroAlert, showTradeAlert, showConfirm, showSingleBubble, closeAlert, closeBubbleAlert, closeConfirm };
}; };
export const useNotification = () => useBetween(useNotificationState); /**
* Read-only slice of the notification store: the three queue arrays
* (alerts, bubbleAlerts, confirms) that the renderer view layer drains.
*
* Consumers that only need to *show* a notification should use
* useNotificationActions instead — the queues are an implementation
* detail of the global NotificationView component.
*/
export const useNotificationState = () =>
{
const { alerts, bubbleAlerts, confirms } = useBetween(useNotificationStore);
return { alerts, bubbleAlerts, confirms };
};
/**
* Imperative slice of the notification store: 8 entry points covering
* the alert / bubble / confirm / trade-alert flows plus the matching
* close handlers. ~40 consumers across the codebase only use one or
* two of these — splitting the slice off keeps their dependency
* surface honest and makes it greppable which call sites
* dismiss-vs-show.
*/
export const useNotificationActions = () =>
{
const {
simpleAlert,
showNitroAlert,
showTradeAlert,
showConfirm,
showSingleBubble,
closeAlert,
closeBubbleAlert,
closeConfirm
} = useBetween(useNotificationStore);
return {
simpleAlert,
showNitroAlert,
showTradeAlert,
showConfirm,
showSingleBubble,
closeAlert,
closeBubbleAlert,
closeConfirm
};
};
/**
* @deprecated Prefer `useNotificationState` (queue arrays) and
* `useNotificationActions` (imperative show/close helpers) directly.
* This shim composes both into the historical `useNotification()`
* shape so the existing 40+ consumers keep working unchanged.
*/
export const useNotification = () => useBetween(useNotificationStore);