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.
CatalogAdminContext exposes savePage / deletePage / saveOffer /
createOffer / deleteOffer as void-returning fire-and-forget composer
dispatches — they just call SendMessageComposer and let the server
push back later. The Offer/Page edit views were 'await action(data);
if(success) closeForm()' as if the actions returned Promise<boolean>,
but they don't return anything. tsgo flagged the truthiness check on
void.
Drop the await + truthiness — call the action, then close the form
unconditionally. This matches the actual behaviour: closeForm() ran
synchronously after the void anyway. A future PR that wants real
'wait for server confirmation' UX should refactor the context to
return Promise<boolean> (correlated to the response packet via the
pendingActionRef machinery already in place).
React 19 dropped the no-arg useRef overload — the type-only useRef<T>()
form (no initial value) is gone, every call must pass an initial value.
The codebase had 15 occurrences of useRef<HTMLDivElement>() (DOM ref
pattern) all flagged by tsgo as 'Expected 1 arguments, but got 0'.
Mechanical sweep to useRef<HTMLDivElement>(null) — no behavior change,
React still hands out a ref object with .current set to null at mount.
Net tsgo error count: 57 -> 42.
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).
Second concrete adoption of proposal #2 (first was OfferView).
Before
- A useState<RoomEntryData[]>([]) for availableRooms.
- A useMessageEvent<RoomAdPurchaseInfoEvent> handler that
set the state on each parser event.
- A useEffect on mount that dispatched two composers, one of which
was GetRoomAdPurchaseInfoComposer paired with the parser above.
After
- A single useNitroQuery call wires the request and parser as one
read-only query. The select extracts parser.rooms with a default
empty array.
- staleTime is 60s — opening the same panel within a minute reuses
the cached value; the composer is not re-dispatched. Useful here
because the user navigates between catalog tabs.
- The mount-only useEffect no longer dispatches the room-ad composer;
the second composer (GetUserEventCatsMessageComposer) stays where
it was — that one feeds useNavigator state and isn't a
request-response pair this component owns.
Why this file
- It was the cleanest pattern in the catalog tree: no correlation
keys, no conditional filter on the parser, no other writes to
availableRooms. The pure derive-from-event case useNitroQuery is
built for.
- The big god-hook useCatalog (1100 LOC) still owns most of the
catalog data layer; migrating that needs the data/uiState/actions
split first.
Verification
- yarn test: 49/49 still passing.
- yarn eslint on the touched file: 1 error (the pre-existing
set-state-in-effect on line 36, unchanged — baseline matches).
- The previous useMessageEvent import was removed cleanly.
Phase 1 of the refactor plan in docs/ARCHITECTURE.md.
Install
- yarn add @tanstack/react-query@5 @tanstack/react-query-devtools@5
- Both pinned to ^5 (matches React 19 peer requirement).
Wiring
- src/index.tsx: mounts QueryClientProvider above ErrorBoundary +
Suspense. Default config: staleTime=30s, retry=1,
refetchOnWindowFocus=false (chat client, not a data dashboard).
Adapter
- src/api/nitro-query/createNitroQuery.ts: replaces the previous
prototype that just threw. Exposes:
* useNitroQuery({ key, request, parser, select, timeoutMs })
— wraps TanStack's useQuery; queryFn awaits the parser response.
* awaitNitroResponse(...) — lower-level helper for imperative use
via queryClient.fetchQuery.
The Promise:
1. registers the parser via GetCommunication().registerMessageEvent
2. dispatches the composer via SendMessageComposer
3. resolves with select(event) on the first matching parser
4. rejects after timeoutMs (default 15s)
5. always cleans up the listener + timeout (cancel-safe).
Pilot
- src/components/catalog/views/targeted-offer/OfferView.tsx:
the previous useMessageEventState + manual useEffect-send pattern
becomes a single useNitroQuery call. staleTime:Infinity because the
targeted offer doesn't change during a session. Subsequent OfferView
remounts (e.g. opening/closing the dialog) now reuse the cached
payload — the GetTargetedOfferComposer is no longer re-sent each
time.
Verification
- yarn eslint on the four touched files: 1 pre-existing
no-redundant-type-constituents error (IMessageEvent resolves as `any`
in the local sandbox without the renderer SDK installed; matches the
12 other pre-existing instances of the same false positive).
- yarn tsc on the four touched files: clean (modulo the
project-wide TS2307 about @nitrots/nitro-renderer).
- The original prototype's "throw" guard is gone — useNitroQuery is now
callable.
Migration path (per docs/ARCHITECTURE.md)
- Next adoption targets (read-only fetches first): useCatalog's page
data, useInventoryFurni's bot listing, Navigator search results,
Marketplace listings.
- Push messages (server-pushed events the client doesn't request)
keep using useNitroEventState / useMessageEventState — they're
subscriptions, not requests.
https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
Introduce the building block for reducing the state-from-event
boilerplate that pervades the codebase:
// Before
const [foo, setFoo] = useState(initial);
useNitroEvent(SOME_EVENT, e => setFoo(e.payload));
// After
const foo = useNitroEventState(SOME_EVENT, e => e.payload, initial);
Implementation notes:
- src/hooks/events/useNitroEventState.ts wraps useNitroEvent so the
selector closure can use up-to-date surrounding values (captured in
a ref refreshed in commit via useLayoutEffect) without forcing a
re-subscription on every render. Listener is registered once and
always reads the latest selector.
- src/hooks/events/useMessageEventState.ts is the mirror for
useMessageEvent (server message channel — request/response composers
and push parsers).
- Both pass the new react-hooks v7 rules cleanly (in particular the
strict react-hooks/refs that forbids ref mutation during render).
- Re-exported from src/hooks/events/index.ts so callers reach them
via the existing `from '../../hooks'` import path.
Pilot adoption (1 site) to demonstrate the pattern:
- src/components/catalog/views/targeted-offer/OfferView.tsx:
the offer state was a clean derive-from-event case
(setOffer(parser.data) on TargetedOfferEvent, no other writes).
Replaced with a single useMessageEventState call using the optional
chain `evt.getParser()?.data ?? null` as selector. Removes the
useState pair and the explicit subscription block.
Honest scope note:
A broader sweep is intentionally NOT done. Most existing event
subscriptions in this codebase are multi-state updates, state
machines, conditional filters ("skip if not my id"), or have side
effects mixed in (notifications, redirects). Forcing those into
useNitroEventState would lose information and risk regressions in
behavior the lint won't catch. Adoption should happen organically
when contributors see a clean derive-from-event case, not as a
mechanical replace-all.
https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
Fix only the cases that are unambiguous anti-patterns; leave the
event-driven setState patterns (useNitroEvent / useMessageEvent
subscriptions, async fetches with cleanup) alone since they're
legitimate in this architecture.
- src/components/catalog/views/catalog-header/CatalogHeaderView.tsx:
displayImageUrl was pure-derived from imageUrl. Drop the useState +
useEffect entirely; compute in render.
- src/components/navigator/views/NavigatorRoomCreatorView.tsx:
the maxVisitors list (10..100 step 10) and roomModels/selectedModel
came from static config; convert to module-level MAX_VISITORS_LIST
constant + useState lazy initializers. Removes 2 init effects.
setCategory(categories[0].id) is left as-is because categories
arrives async from a hook.
- src/components/login/LoginView.tsx:
Replace useEffect(() => setLocalError(null), [step]) with the
React-recommended "track previous prop" render-time reset:
if(prevStep !== step) { setPrevStep(step); setLocalError(null); }
Same observable behavior, no extra render.
- src/components/room/widgets/choosers/ChooserWidgetView.tsx:
Wrap the selectItem callback prop call in useEffectEvent so a
parent re-render that changes selectItem identity doesn't
re-fire the visualizer side-effects.
Net: 4 fewer set-state-in-effect violations; behavior preserved.
The remaining ~328 violations across the codebase are predominantly
legitimate event-bus / async-fetch patterns and need per-case
review with running app, not a sweep.
https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
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
Adopt React 19 idioms across the codebase. The runtime was already on
react@19.2.5 but no React 19 APIs were in use.
- forwardRef -> ref-as-prop in 7 layout/component files
(NitroInput/Button/ItemCountBadge/Card×5/InfiniteGridItem,
ToolbarItemView, AvatarEditorIcon)
- <Ctx.Provider> -> <Ctx> in 6 contexts (CatalogAdmin, FloorplanEditor,
UiSettings, GridContext, NitroCardContext, NitroCardAccordionContext)
- Native <script> hoisting for Turnstile, ExternalPluginLoader, GoogleAdsView
(React 19 dedupes by src; removes manual document.head.appendChild +
module-level promise caches)
- React Compiler enabled at build time via babel-plugin-react-compiler
in vite.config.mjs (target: '19'), plus eslint-plugin-react-compiler
in lint mode
- Global <ErrorBoundary> + <Suspense> in src/index.tsx using
react-error-boundary, with LoadingView as fallback
- BackgroundsView migrated to use(promise) as a demonstrator pattern
for Suspense-driven config loading
- ESLint react setting bumped 18.3.1 -> 19.2; legacy
@typescript-eslint/ban-types replaced with no-restricted-types
(the old rule was removed in @typescript-eslint v8)
- Refresh public/configuration/{asset-loader,bootstrap}.js to match
current write-asset-loader.mjs output
Phase 3 (login forms -> useActionState/useFormStatus) deferred:
LoginView is 1623 lines with lockout + Turnstile + heartbeat
interleaving; safer as its own PR.
https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
- XSS fix: Created SanitizeHtml.ts utility using DOMPurify (already in package.json but never used). Wrapped all 21 dangerouslySetInnerHTML calls in catalog views with SanitizeHtml() — only allows safe tags (b, i, u, br, span, div, p, a, strong, em, img)
- Race condition fix: Added 10-second timeout fallbacks on purchase flags in CatalogPurchaseWidgetView and CatalogGiftView so the flag auto-resets even if the server never responds
- Modern card-based layout with vertical icon rail, breadcrumb nav, inline search
- Admin mode: edit/create/delete pages and offers, drag & drop reorder via HK API
- Favorites system: heart on furni, star on pages, localStorage persistence
- Redesigned product card with price pills, dynamic quantity spinner
- Upgraded trophies (filter tabs, parchment textarea), pets (breed/color flow),
custom prefix (dynamic color boxes)
- Font fix: Ubuntu Regular, proper @font-face declarations
- New Tailwind design tokens and CatalogTexts.json for localization
- Catalog page for creating custom prefixes with text, per-letter colors, emoji icon and visual effects
- Emoji picker via @emoji-mart/react with createPortal + Shadow DOM blur fix
- Inventory prefix management (activate/deactivate/delete)
- Chat bubble rendering with multi-color prefix and effect support
- Prefix utilities (getPrefixEffectStyle, parsePrefixColors, PREFIX_EFFECT_KEYFRAMES)
- All UI text in English