catalog: three-way singleton-filter split + first 3 consumer migrations

Completes the useCatalog decomposition. After the previous commit
extracted the pure helpers, this one splits the singleton-via-useBetween
store into three slice-specific entry points and migrates a handful of
consumers as proof.

`src/hooks/catalog/useCatalog.ts`

- Internal `useCatalogState` → renamed to `useCatalogStore` and is no
  longer exported. The full return shape is unchanged so callers that
  still go through the shim see the exact same object.
- Three new exports built on top of the same `useBetween` instance:
    - `useCatalogData()` — server-driven read-only slice (rootNode,
      offersToNodes, currentPage, currentOffer, frontPageItems,
      searchResult, roomPreviewer, isBusy, catalog localization
      version, Builders Club counters + timers).
    - `useCatalogUiState()` — UI ephemeral state + writers
      (isVisible, pageId, previousPageId, currentType, activeNodes,
      navigationHidden, purchaseOptions, catalogPlaceMultipleObjects,
      plus every `set*` writer including the ones that mutate the
      data slice on user-driven selection).
    - `useCatalogActions()` — imperative operations only
      (openCatalogByType, toggleCatalogByType, activateNode,
      openPageBy{Id,Name,OfferId}, requestOfferToMover,
      selectCatalogOffer, getNodeBy{Id,Name},
      getBuilderFurniPlaceableStatus).
- `useCatalog` is kept as a deprecated shim that returns the full
  historical surface, so the 48 existing consumers compile and run
  unchanged.

Pilot consumer migrations (3 of 48):

- `CatalogBuildersClubStatusView` — Data (furni counters, seconds
  timers) + UiState (currentType).
- `CatalogBreadcrumbView` — UiState (activeNodes) + Actions
  (activateNode).
- `CatalogNavigationItemView` — UiState (currentType) + Actions
  (activateNode).

Tests: `tests/useCatalog.filters.test.tsx` (5 cases).

`useBetween` is mocked via `vi.hoisted` so the four hooks share one
deterministic fake store — rendering the real `useCatalogStore`
would mount ~30 useState calls + open a fresh RoomPreviewer +
subscribe to a dozen renderer events, which is more than these
contract tests need.

- `useCatalogData` exposes exactly its read-only keys.
- `useCatalogUiState` exposes exactly its UI keys + setters.
- `useCatalogActions` exposes exactly its imperative ops (and
  explicitly NOT data fields — proves no leak across slices).
- Singleton identity: callbacks read through the shim are `===` to
  the ones read through the slices.
- Shim surface: the historical key set is still present so
  un-migrated consumers don't silently break.

Suite: 163/163 (was 158/158). `yarn typecheck` green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
simoleo89
2026-05-13 21:50:56 +02:00
parent fd3ef7875d
commit 59d6c4cab3
7 changed files with 372 additions and 15 deletions
+111 -2
View File
@@ -12,7 +12,15 @@ import { useCatalogSkipPurchaseConfirmation } from './useCatalogSkipPurchaseConf
const DUMMY_PAGE_ID_FOR_OFFER_SEARCH = -12345678;
const DRAG_AND_DROP_ENABLED = true;
const useCatalogState = () =>
// Internal singleton store — held together by `useBetween` so every
// public filter below sees the same listeners + state. Do NOT export
// this directly; consumers must go through the filters or the
// deprecated `useCatalog` shim. The previous 1100-line monolith
// exposed everything via `useCatalog`; the three filters below
// (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`)
// shrink the surface each consumer subscribes to, which lets the
// React Compiler memoize and avoids unrelated re-renders.
const useCatalogStore = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ isBusy, setIsBusy ] = useState(false);
@@ -958,4 +966,105 @@ const useCatalogState = () =>
return { isVisible, setIsVisible, isBusy, pageId, previousPageId, currentType, rootNode, offersToNodes, currentPage, setCurrentPage, currentOffer, setCurrentOffer, activeNodes, searchResult, setSearchResult, frontPageItems, roomPreviewer, navigationHidden, setNavigationHidden, purchaseOptions, setPurchaseOptions, catalogLocalizationVersion, getNodeById, getNodeByName, activateNode, openPageById, openPageByName, openPageByOfferId, requestOfferToMover, openCatalogByType, toggleCatalogByType, furniCount, furniLimit, maxFurniLimit, secondsLeft, secondsLeftWithGrace, updateTime, catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects, getBuilderFurniPlaceableStatus, selectCatalogOffer };
};
export const useCatalog = () => useBetween(useCatalogState);
/**
* Read-only slice of server-driven catalog state. Anything a consumer
* needs to *display* (page tree, current page, offers, Builders Club
* counters) lives here.
*
* `roomPreviewer` and the busy flag are kept here too because they
* are observed (not mutated) by every consumer that renders a preview.
*/
export const useCatalogData = () =>
{
const {
isBusy,
rootNode, offersToNodes,
currentPage, currentOffer,
frontPageItems, searchResult,
roomPreviewer,
catalogLocalizationVersion,
furniCount, furniLimit, maxFurniLimit,
secondsLeft, secondsLeftWithGrace, updateTime
} = useBetween(useCatalogStore);
return {
isBusy,
rootNode, offersToNodes,
currentPage, currentOffer,
frontPageItems, searchResult,
roomPreviewer,
catalogLocalizationVersion,
furniCount, furniLimit, maxFurniLimit,
secondsLeft, secondsLeftWithGrace, updateTime
};
};
/**
* UI-side state owned by the catalog overlay itself: visibility, the
* currently-rendered page id and breadcrumb, search query result,
* purchase options, multi-place toggle. Includes the setters that
* mutate the data slice when the user picks a page / offer / search
* result — those don't trigger server traffic so they belong to the
* UI layer.
*/
export const useCatalogUiState = () =>
{
const {
isVisible, setIsVisible,
pageId, previousPageId,
currentType,
activeNodes,
navigationHidden, setNavigationHidden,
purchaseOptions, setPurchaseOptions,
catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects,
setCurrentPage, setCurrentOffer, setSearchResult
} = useBetween(useCatalogStore);
return {
isVisible, setIsVisible,
pageId, previousPageId,
currentType,
activeNodes,
navigationHidden, setNavigationHidden,
purchaseOptions, setPurchaseOptions,
catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects,
setCurrentPage, setCurrentOffer, setSearchResult
};
};
/**
* Imperative actions: open / toggle the catalog, navigate the page
* tree, request a furni to the mover, look up nodes by id/name, run
* the Builders Club placement check. These all either send a
* composer to the server, dispatch a UI event, or run synchronous
* tree queries — none of them are React state by themselves.
*/
export const useCatalogActions = () =>
{
const {
openCatalogByType, toggleCatalogByType,
activateNode,
openPageById, openPageByName, openPageByOfferId,
requestOfferToMover, selectCatalogOffer,
getNodeById, getNodeByName,
getBuilderFurniPlaceableStatus
} = useBetween(useCatalogStore);
return {
openCatalogByType, toggleCatalogByType,
activateNode,
openPageById, openPageByName, openPageByOfferId,
requestOfferToMover, selectCatalogOffer,
getNodeById, getNodeByName,
getBuilderFurniPlaceableStatus
};
};
/**
* Deprecated. Kept so the 48 existing consumers compile unchanged —
* incrementally migrate them to `useCatalogData` / `useCatalogUiState`
* / `useCatalogActions` and remove this shim once the call sites are
* gone. Mirrors the same `useBetween` singleton, so behavior is
* identical.
*/
export const useCatalog = () => useBetween(useCatalogStore);