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
+6 -3
View File
@@ -259,15 +259,15 @@ into `configurePreviewServer` so `yarn preview` keeps working.
| `useNitroQuery` + `useNitroEventInvalidator` | `OfferView`, `CatalogLayoutRoomAdsView`, `ModToolsChatlogView`, `CfhChatlogView`, `useGiftConfiguration`, `useUserGroups`, `useClubOffers(windowId)`, `useSellablePetPalette(breed)`, `useMarketplaceConfiguration`, `useClubGifts` (with invalidator) |
| Zustand | `NavigatorRoomCreatorView` (`useRoomCreatorStore`) |
| 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`, `notification`, `friends` |
| God-hook split (`useBetween` singleton + state filter + actions filter + shim) | `wired-tools`, `translation`, `notification`, `friends`, `catalog` (three-way: `useCatalogData` / `useCatalogUiState` / `useCatalogActions`, plus the `useCatalog` shim) |
| `WidgetErrorBoundary` | `RoomWidgetsView` umbrella |
| Vitest | 158/158 cases — pure helpers + Zustand store + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `tests/mocks/renderer-mock.ts`, plus 34 cases on the freshly extracted catalog helpers |
| Vitest | 163/163 cases — pure helpers + Zustand store + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `tests/mocks/renderer-mock.ts`, 34 cases on the catalog pure helpers, 5 contract cases on the catalog filters |
| Form Actions | Login / Register / Forgot (LoginView.tsx) |
| Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating |
| Not yet | Notes |
|---|---|
| Singleton-filter split of `useCatalog` | Pure helpers extracted to `useCatalog.helpers.ts` and consumed in the hook (`buildCatalogNodeTree`, `findNodeById`, `findNodeByName`, `getNodesByOfferIdFromMap`, `getOfferProductKeys`, `normalizeCatalogType`, `resolveBuilderFurniPlaceableStatus`). What still remains: split the singleton state into `useCatalogData` / `useCatalogUiState` / `useCatalogActions` filters via `useBetween`, mirroring the wired-tools / translation / notification / friends pattern. The 48 consumers can stay on the shim during the transition. |
| Migrate the 48 `useCatalog()` consumers to the new filters | The split is done: pure helpers in `useCatalog.helpers.ts`, three filters (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`) plus the deprecated `useCatalog` shim. Three pilot consumers already migrated (`CatalogBuildersClubStatusView`, `CatalogBreadcrumbView`, `CatalogNavigationItemView`). The remaining 45 still hit the shim — incremental work, each migration is mechanical: split the destructure into 2-3 filter calls based on which keys are read. |
| 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` / `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. (Wired-tools split done as singleton-filter; Zustand slice is the next step.) |
@@ -327,6 +327,9 @@ Fix shapes documented; both are reasonable PRs on their own.
(`buildCatalogNodeTree`, `findNodeById` / `findNodeByName`,
`getNodesByOfferIdFromMap`, `getOfferProductKeys`,
`normalizeCatalogType`, `resolveBuilderFurniPlaceableStatus`)
- Catalog three-way filter split: `useCatalogData` /
`useCatalogUiState` / `useCatalogActions` (with the deprecated
`useCatalog` shim) in `src/hooks/catalog/useCatalog.ts`
- Renderer-SDK mock for Vitest: `tests/mocks/renderer-mock.ts`
(aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`).
Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` /
+40 -4
View File
@@ -480,9 +480,39 @@ Status after this round of work:
| CatalogPagesList / CatalogPage | **deferred** — core state slice (rootNode / offersToNodes / currentPage), needs its own split-out store |
| BuildersClubFurniCount / SubscriptionStatus | **deferred** — read by the internal `getBuilderFurniPlaceableStatus` logic, moves with the data/actions split |
Pure-helper extraction landed before the singleton split:
`src/hooks/catalog/useCatalog.helpers.ts` hosts the dependency-free
pieces previously inlined in the hook —
**Helper extraction + filter split both landed.** The 1100-line hook
now has its dependency-free logic in
`src/hooks/catalog/useCatalog.helpers.ts` and exposes three public
filters built on top of the same `useBetween` singleton:
- `useCatalogData()` — server-driven read-only slice (`rootNode`,
`offersToNodes`, `currentPage`, `currentOffer`, `frontPageItems`,
`searchResult`, `roomPreviewer`, `isBusy`,
`catalogLocalizationVersion`, Builders Club counters + timers).
- `useCatalogUiState()` — UI ephemeral state + writers
(`isVisible`, `pageId`, `previousPageId`, `currentType`,
`activeNodes`, `navigationHidden`, `purchaseOptions`,
`catalogPlaceMultipleObjects`, plus all the `set*` writers,
including the ones that mutate the data slice on page / offer /
search-result selection).
- `useCatalogActions()` — imperative operations
(`openCatalogByType`, `toggleCatalogByType`, `activateNode`,
`openPageBy{Id,Name,OfferId}`, `requestOfferToMover`,
`selectCatalogOffer`, `getNodeBy{Id,Name}`,
`getBuilderFurniPlaceableStatus`).
The internal store is named `useCatalogStore` and is **not exported**;
the four public entry points (`useCatalogData` / `useCatalogUiState`
/ `useCatalogActions` / `useCatalog`) all funnel into the same
`useBetween` instance, so listeners + state register once. The
deprecated `useCatalog` shim continues to expose the full historical
return shape so the 48 existing consumers compile unchanged; they
should be incrementally migrated to the specific filters as PRs
touch them. Three pilot migrations already landed in
`CatalogBuildersClubStatusView`, `CatalogBreadcrumbView`, and
`CatalogNavigationItemView`.
Pure helpers in `useCatalog.helpers.ts`:
- `normalizeCatalogType(type?)` — coerce the optional catalog type
back to `NORMAL` / `BUILDER`.
@@ -512,7 +542,7 @@ empty-map / partial-bucket branches of the offer lookup).
- Vitest 3 + jsdom + `@testing-library/react` + `@testing-library/jest-dom`
configured. Separate `vitest.config.mts` so the runner doesn't drag in
the renderer SDK aliases from `vite.config.mjs`.
- **158 cases passing** across 11 test files. Pure-module suites:
- **163 cases passing** across 12 test files. Pure-module suites:
- `WiredCreatorTools.helpers.test.ts` (18) — formatters + snapshot
factory.
- `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants
@@ -539,6 +569,12 @@ empty-map / partial-bucket branches of the offer lookup).
the partial-visible fallback), `buildCatalogNodeTree` (tree
depth + offerId index), and the full decision tree of
`resolveBuilderFurniPlaceableStatus`.
- `useCatalog.filters.test.tsx` (5) — contract tests for the
three-way singleton-filter split. Stubs `use-between` so the
filters share one fake store, asserts each filter exposes
exactly the keys it owns (no leak across slices), and pins
down `===` identity of callbacks between the shim and each
slice so the migration of the 48 consumers stays safe.
Component-/hook-level suites (on the new renderer-SDK mock):
- `WidgetErrorBoundary.test.tsx` (4) — happy path + caught render
@@ -2,11 +2,12 @@ import { GetTickerTime } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { CatalogType, FriendlyTime, LocalizeText } from '../../../../api';
import buildersClubIcon from '../../../../assets/images/toolbar/icons/buildersclub.png';
import { useCatalog } from '../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../hooks';
export const CatalogBuildersClubStatusView: FC = () =>
{
const { currentType = CatalogType.NORMAL, furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalog();
const { furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalogData();
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
const [ ticker, setTicker ] = useState(() => GetTickerTime());
useEffect(() =>
@@ -1,11 +1,12 @@
import { FC } from 'react';
import { FaChevronRight, FaHome } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { useCatalog } from '../../../../hooks';
import { useCatalogActions, useCatalogUiState } from '../../../../hooks';
export const CatalogBreadcrumbView: FC<{}> = () =>
{
const { activeNodes = [], activateNode } = useCatalog();
const { activeNodes = [] } = useCatalogUiState();
const { activateNode } = useCatalogActions();
if(!activeNodes || activeNodes.length === 0)
{
@@ -1,7 +1,7 @@
import { FC, useCallback, useRef, useState } from 'react';
import { FaArrowsAlt, FaCaretDown, FaCaretUp, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
import { CatalogType, ICatalogNode, LocalizeText } from '../../../../api';
import { useCatalog, useCatalogFavorites } from '../../../../hooks';
import { useCatalogActions, useCatalogFavorites, useCatalogUiState } from '../../../../hooks';
import { useCatalogAdmin } from '../../CatalogAdminContext';
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
@@ -15,7 +15,8 @@ export interface CatalogNavigationItemViewProps
export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = props =>
{
const { node = null, child = false } = props;
const { activateNode = null, currentType = CatalogType.NORMAL } = useCatalog();
const { activateNode = null } = useCatalogActions();
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const { isFavoritePage, toggleFavoritePage } = useCatalogFavorites();
+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);
+206
View File
@@ -0,0 +1,206 @@
/* @vitest-environment jsdom */
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
// `useCatalogStore` mounts ~30 useState calls, opens a fresh
// RoomPreviewer, subscribes to a dozen renderer message events, and
// reaches into `useNotification()` for the alert helpers — too much
// surface to render under jsdom and not what these tests are about.
//
// We just want to lock down the *contract* of the three filters
// (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`) and
// the shim: each one must read its specific subset of keys from the
// same `useBetween` singleton.
//
// Stub `use-between` so all four hooks share one deterministic store
// object. `vi.hoisted` lets us reference the fake from the mock
// factory (which is itself hoisted).
const { fakeStore } = vi.hoisted(() =>
{
const fakeStore = {
// Data slice
isBusy: false,
rootNode: null,
offersToNodes: null,
currentPage: null,
currentOffer: null,
frontPageItems: [],
searchResult: null,
roomPreviewer: null,
catalogLocalizationVersion: 0,
furniCount: 0,
furniLimit: 0,
maxFurniLimit: 0,
secondsLeft: 0,
secondsLeftWithGrace: 0,
updateTime: 0,
// UiState slice
isVisible: false,
setIsVisible: vi.fn(),
pageId: -1,
previousPageId: -1,
currentType: 'NORMAL',
activeNodes: [] as any[],
navigationHidden: false,
setNavigationHidden: vi.fn(),
purchaseOptions: { quantity: 1 },
setPurchaseOptions: vi.fn(),
catalogPlaceMultipleObjects: false,
setCatalogPlaceMultipleObjects: vi.fn(),
setCurrentPage: vi.fn(),
setCurrentOffer: vi.fn(),
setSearchResult: vi.fn(),
// Actions slice
openCatalogByType: vi.fn(),
toggleCatalogByType: vi.fn(),
activateNode: vi.fn(),
openPageById: vi.fn(),
openPageByName: vi.fn(),
openPageByOfferId: vi.fn(),
requestOfferToMover: vi.fn(),
selectCatalogOffer: vi.fn(),
getNodeById: vi.fn(),
getNodeByName: vi.fn(),
getBuilderFurniPlaceableStatus: vi.fn()
};
return { fakeStore };
});
vi.mock('use-between', () => ({
useBetween: () => fakeStore
}));
// Import AFTER the mock is set up. The hooks resolve `useBetween` at
// import time via the module graph, so the order matters.
import { useCatalog, useCatalogActions, useCatalogData, useCatalogUiState } from '../src/hooks/catalog/useCatalog';
describe('useCatalog filter contract', () =>
{
it('useCatalogData returns the read-only data slice', () =>
{
const { result } = renderHook(() => useCatalogData());
expect(Object.keys(result.current).sort()).toEqual([
'catalogLocalizationVersion',
'currentOffer',
'currentPage',
'frontPageItems',
'furniCount',
'furniLimit',
'isBusy',
'maxFurniLimit',
'offersToNodes',
'roomPreviewer',
'rootNode',
'searchResult',
'secondsLeft',
'secondsLeftWithGrace',
'updateTime'
]);
// Reads point at the same underlying values.
expect(result.current.rootNode).toBe(fakeStore.rootNode);
expect(result.current.furniCount).toBe(fakeStore.furniCount);
expect(result.current.frontPageItems).toBe(fakeStore.frontPageItems);
});
it('useCatalogUiState returns the UI fields plus their setters', () =>
{
const { result } = renderHook(() => useCatalogUiState());
expect(Object.keys(result.current).sort()).toEqual([
'activeNodes',
'catalogPlaceMultipleObjects',
'currentType',
'isVisible',
'navigationHidden',
'pageId',
'previousPageId',
'purchaseOptions',
'setCatalogPlaceMultipleObjects',
'setCurrentOffer',
'setCurrentPage',
'setIsVisible',
'setNavigationHidden',
'setPurchaseOptions',
'setSearchResult'
]);
expect(result.current.setIsVisible).toBe(fakeStore.setIsVisible);
expect(result.current.setCurrentPage).toBe(fakeStore.setCurrentPage);
});
it('useCatalogActions returns only imperative operations', () =>
{
const { result } = renderHook(() => useCatalogActions());
expect(Object.keys(result.current).sort()).toEqual([
'activateNode',
'getBuilderFurniPlaceableStatus',
'getNodeById',
'getNodeByName',
'openCatalogByType',
'openPageById',
'openPageByName',
'openPageByOfferId',
'requestOfferToMover',
'selectCatalogOffer',
'toggleCatalogByType'
]);
// No data fields leak through.
expect(result.current).not.toHaveProperty('rootNode');
expect(result.current).not.toHaveProperty('isVisible');
expect(result.current).not.toHaveProperty('currentPage');
expect(result.current.activateNode).toBe(fakeStore.activateNode);
expect(result.current.openCatalogByType).toBe(fakeStore.openCatalogByType);
});
it('all four hooks observe the same singleton — refs are ===', () =>
{
const { result } = renderHook(() =>
({
data: useCatalogData(),
ui: useCatalogUiState(),
actions: useCatalogActions(),
full: useCatalog()
}));
// The shim and the slices reach the same fakeStore. Any
// accidental copy would break this `===` check.
expect(result.current.full.activateNode).toBe(result.current.actions.activateNode);
expect(result.current.full.openCatalogByType).toBe(result.current.actions.openCatalogByType);
expect(result.current.full.setIsVisible).toBe(result.current.ui.setIsVisible);
expect(result.current.full.setCurrentPage).toBe(result.current.ui.setCurrentPage);
expect(result.current.full.rootNode).toBe(result.current.data.rootNode);
expect(result.current.full.furniCount).toBe(result.current.data.furniCount);
expect(result.current.full.roomPreviewer).toBe(result.current.data.roomPreviewer);
});
it('useCatalog (deprecated shim) preserves the full historical surface', () =>
{
const { result } = renderHook(() => useCatalog());
// Sample one field from each slice, including the setters
// that the 48 existing consumers still destructure straight
// out of `useCatalog()`. If a setter or callback ever stops
// being forwarded, the shim breaks and those consumers
// silently fail.
const required = [
'rootNode', 'offersToNodes', 'currentPage', 'currentOffer', 'frontPageItems',
'isVisible', 'setIsVisible', 'pageId', 'previousPageId', 'currentType',
'setCurrentPage', 'setCurrentOffer', 'setSearchResult',
'openCatalogByType', 'toggleCatalogByType', 'activateNode',
'openPageById', 'openPageByName', 'openPageByOfferId',
'requestOfferToMover', 'selectCatalogOffer',
'getNodeById', 'getNodeByName', 'getBuilderFurniPlaceableStatus',
'furniCount', 'furniLimit', 'secondsLeft', 'updateTime'
];
for(const key of required) expect(result.current).toHaveProperty(key);
});
});