This commit drains the last field out of ICatalogOptions (clubGifts)
and deletes the interface — useCatalog no longer owns a catch-all
mutable object that downstream components stuff data into.
Two pieces:
1) New useNitroEventInvalidator(eventType, queryKey, accept?) — a
small companion to useNitroQuery for the case where the server
pushes the same event unprompted (e.g. ClubGiftInfoEvent fires
both as the response to GetClubGiftInfo and again after the user
claims a gift via SelectClubGiftComposer). It calls
queryClient.invalidateQueries() on each matching push so the
next render of any subscriber triggers a fresh queryFn.
2) New useClubGifts() — useNitroQuery on the ClubGiftInfoEvent
pair, paired with useNitroEventInvalidator so server-driven
pushes refresh the cache automatically. CatalogLayoutVipGiftsView
now consumes the query directly. The local optimistic
'giftsAvailable--' mutation (which side-effected the parser
object passed back to the catalog state!) is dropped — the
server's authoritative ClubGiftInfoEvent push is the single
source of truth via the invalidator.
useCatalog drops the matching listener + the GetClubGiftInfo dispatch
from the catalog-open effect. ICatalogOptions is now empty and
deleted; the catalogOptions / setCatalogOptions state + return-shape
field are removed from useCatalog along with the import.
MarketplacePostOfferView was both *the* fetcher and the listener for
MarketplaceConfigurationEvent — it dispatched
GetMarketplaceConfigurationMessageComposer from one effect when item
was set, then routed the response through setCatalogOptions.
useCatalog never touched the field; it was passing through catalogOptions
purely as a transport mechanism for this single component to talk to
itself. Replace with useMarketplaceConfiguration() — staleTime Infinity
(server-side constants for a session), enabled on item, single tidy
data path.
Drops marketplaceConfiguration from ICatalogOptions; with petPalettes
out too, ICatalogOptions is now just { clubGifts }. clubGifts is the
last one and needs invalidation (server pushes ClubGiftInfoEvent after
SelectClubGiftComposer) so it stays put until useNitroEventInvalidator
companion lands.
CatalogLayoutPetView previously read 'catalogOptions.petPalettes' (an
accumulating array of CatalogPetPalette objects keyed by breed) and,
on cache miss, dispatched GetSellablePetPalettesComposer(productData.type)
inline. useCatalog kept the matching SellablePetPalettesMessageEvent
listener that appended each new breed to the array (deduping by breed
identity).
Migrate the request/response pair to a TanStack query parameterized on
breed:
useSellablePetPalette(breed)
key: ['nitro', 'catalog', 'petPalette', breed]
request: () => new GetSellablePetPalettesComposer(breed)
parser: SellablePetPalettesMessageEvent
accept: event.getParser().productCode === breed
select: build a CatalogPetPalette from parser
enabled: !!breed (avoid spamming composers before currentOffer is set)
staleTime: Infinity
The view now derives breed from currentOffer.product.productData.type
and reads 'const { data: petPalette }'. The cache-miss-then-fetch
two-pass effect collapses into a single effect that runs once
petPalette resolves (or clears state when offer/petPalette aren't
ready).
Drops the matching listener from useCatalog, drops petPalettes from
ICatalogOptions, and removes the now-unused CatalogPetPalette /
SellablePetPalettesMessageEvent imports from useCatalog.
Two catalog layouts each fire 'new GetClubOffersMessageComposer(windowId)'
on mount and read parser.offers via HabboClubOffersMessageEvent:
- CatalogLayoutVipBuyView (windowId 1)
- CatalogLayoutBuildersClubBuyView (windowId 2 / 3, depending on
the addon variant)
Plus useCatalog used to also listen for HabboClubOffersMessageEvent and
stash the offers in 'catalogOptions.clubOffersByWindowId[windowId]' and
'catalogOptions.clubOffers' (the latter being a backward-compat alias
for windowId 1). Three listeners, three independent requests when all
mounted.
New useClubOffers(windowId) wraps the request/response pair as a
TanStack query keyed by '['nitro', 'catalog', 'clubOffers', windowId]'.
accept(): correlation-key filter (parser.windowId === windowId) so
the same multiplexed event doesn't satisfy the wrong query slot.
Both layouts now read 'const { data: offers = null } = useClubOffers(windowId)';
useCatalog drops the listener, ICatalogOptions drops the
clubOffers / clubOffersByWindowId fields and HabboClubOffersMessageEvent
no longer needs to be imported in useCatalog. The localization-refresh
effect that re-cloned both fields is also dropped — React Query owns
the cache now, and ClubOfferData has no localized strings anyway.
Four independent components used to send 'new CatalogGroupsComposer()'
on mount and listen for GuildMembershipsMessageEvent:
- useCatalog (writing into catalogOptions.groups)
- CatalogLayoutGuildForumView
- CatalogGuildSelectorWidgetView
- WiredSelectorUsersGroupView
- WiredConditionActorIsGroupMemberView
Each fired its own request and re-listened independently. With four
of them mounted in the wired-tools panel during a builder session,
the same packet went out four times.
New useUserGroups() hook wraps the request/response pair with
useNitroQuery (queryKey ['nitro', 'user', 'groups'], staleTime
Infinity — guild membership is session-stable). All four consumers
now read 'const { data: groups = [] } = useUserGroups()' and React
Query dedups: one composer at the first mount, all subsequent mounts
get the cached array.
Drops 'groups' from ICatalogOptions and the corresponding listener +
prev-state-merge from useCatalog — no remaining consumer reads it.
useCatalog's localization-refresh effect calls 'offer?.clone ? offer.clone() : offer'
to mint fresh references when locale strings change. Offer.ts implements
clone() but the interface didn't declare it, so the guarded call broke
tsgo. FurnitureOffer (the lazy wrapper) doesn't implement clone — and
the call site is guarded — so 'clone?(): IPurchasableOffer' (optional)
keeps the interface honest without forcing FurnitureOffer to grow a
no-op clone.
Net tsgo error count: -4.
The catalog's gift wrapping configuration was loaded by an effect in
useCatalog that fired GetGiftWrappingConfigurationComposer every time
the catalog opened, with the response stuffed into a catalogOptions
slice via setState-in-effect. Migrating to a TanStack query gives us
caching/dedup/loading-state for free on this one-shot session-stable
loader.
- New useGiftConfiguration() hook in src/hooks/catalog/ wraps the
composer/parser pair with useNitroQuery and staleTime: Infinity
(the wrapping config never changes within a session).
- CatalogGiftView now reads from the query directly instead of via
catalogOptions; the useCatalog() call in that component is also
dropped (no other field was used).
- useCatalog drops the GiftWrappingConfigurationEvent listener and the
unconditional composer dispatch.
- ICatalogOptions loses the giftConfiguration? field — no remaining
consumer.
First step toward the docs/ARCHITECTURE.md next-PR item 'Migrate
useCatalog read-only fetches to useNitroQuery'. The clubGifts loader
will follow once useNitroEventInvalidator lands (clubGifts can be
push-updated by the server after SelectClubGiftComposer, so it needs
cache invalidation, not just a one-shot fetch).
Run eslint --fix across src/ to clear ~1900 mechanical lint errors
surfaced by the @typescript-eslint v8 + react-hooks v7 + react-compiler
upgrade in the React 19 modernization PR.
Issues fixed automatically:
- brace-style (Allman): try/catch one-liners reformatted to multi-line
- indent: tab-vs-space and depth corrections
- semi: missing trailing semicolons
- no-trailing-spaces
No semantic changes. Remaining 701 errors are real-code issues
(set-state-in-effect, rules-of-hooks, no-unsafe-* type checks) that
need manual per-file review.
https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q