Until now the test suite was authoritative only when run locally;
nothing stopped a commit landing with `yarn test` red. Wire up a
GitHub Actions workflow that gates push + pull_request on both
`yarn typecheck` and `yarn test --run`.
The setup mirrors CLAUDE.md's "Setup walkthrough":
- Check the client into `<workspace>/Nitro-V3`.
- Check `duckietm/Nitro_Render_V3` as a sibling at
`<workspace>/Nitro_Render_V3`, since the build / typecheck wire
the renderer in via filesystem aliases that expect that layout.
- Symlink `Nitro-V3/node_modules/@nitrots/nitro-renderer` →
`../../../Nitro_Render_V3` so `tsconfig.json`'s `include`
entry pointing at `node_modules/@nitrots/nitro-renderer/src/**/*.ts`
actually resolves.
- `yarn install --frozen-lockfile` in both repos, then run
`yarn typecheck` and `yarn test --run` in the client.
Node 22 (matches the local toolchain). Yarn classic, with the
workflow's `setup-node` caching the `yarn.lock` of both repos so
subsequent runs don't reinstall from scratch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
First half of the proposed `useCatalog` decomposition. The 1036-line
god-hook still owns the singleton-via-useBetween, but the pure logic
it used to define inline now lives in a dependency-free module so it
can be tested in isolation and reused by future split-out hooks
(`useCatalogData` / `useCatalogUiState` / `useCatalogActions` when
those land).
New module: `src/hooks/catalog/useCatalog.helpers.ts` (222 LOC).
- `normalizeCatalogType(type?)` — coerce the optional catalog type to
`NORMAL` / `BUILDER`. Was a 5-line `useCallback` with an empty
dependency array.
- `getOfferProductKeys(offer)` — produces the canonical
`productType:id:classId` and `productType:class:className` keys
for the resolved-offer cache.
- `findNodeById` / `findNodeByName` — DFS over the catalog tree,
root explicitly excluded so callers can't select the synthetic
root by mistake.
- `getNodesByOfferIdFromMap(offerId, map, onlyVisible)` — extracted
from the closed-over `getNodesByOfferId`. The `onlyVisible`
fallback to the full bucket when nothing visible remains is
preserved.
- `buildCatalogNodeTree(NodeData)` — pulled out of the
`CatalogPagesListEvent` reducer. Builds the tree and the offerId
index in one pass; the caller now does `const { rootNode,
offersToNodes } = buildCatalogNodeTree(parser.root)` instead of
carrying an inline recursive walker + a local map.
- `resolveBuilderFurniPlaceableStatus(input)` — the placement
decision tree as a pure function. The hook keeps the
`GetRoomEngine` / `GetSessionDataManager` reads that count
non-self, non-moderator visitors (only when the subscription has
expired) and forwards the resulting `visitorCount` into the
helper, so the previous early-exit semantics are preserved.
`useCatalog.ts` now imports these and removes ~140 lines of inline
copies. Net hook size: 1036 → 961 LOC. Behavior unchanged.
Tests: `tests/useCatalog.helpers.test.ts` (34 cases).
- `normalizeCatalogType` (4) — BUILDER pass-through, NORMAL
pass-through, undefined/empty fallback, unknown string fallback.
- `getOfferProductKeys` (5) — both keys, id-only when classId<0,
class-only when className empty, no-product short-circuit,
empty productType short-circuit.
- `findNodeById` (5) — null input, root exclusion, immediate child,
grandchild, miss returns null.
- `findNodeByName` (2) — match by name + root exclusion, miss.
- `getNodesByOfferIdFromMap` (5) — empty map, raw bucket pass-through,
visible-only filter, fallback when no visible remain, miss.
- `buildCatalogNodeTree` (3) — root depth=0 + empty offer map for a
leaf-only root, DFS traversal tracks offer→nodes across branch
and leaf, child.parent === root.
- `resolveBuilderFurniPlaceableStatus` (10) — missing offer,
not-in-room, owner happy path, non-owner without fallback,
guild admin with time, furni limit reached, shared-pool override
ignoring the limit, expired+blocked-by-visitors flag,
expired+visitor count > 0, expired+empty room is okay.
To support the placement-status test the renderer mock gains real
numeric values for `RoomControllerLevel` (NONE..MODERATOR) and
`RoomObjectCategory` (MINIMUM..MAXIMUM); the previous string-keyed
Proxy stubs made `controllerLevel >= GUILD_ADMIN` evaluate to NaN.
Suite: 158/158 (was 124/124). `yarn typecheck` green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundations for widening Vitest coverage past the pure-helper subset.
The real `@nitrots/nitro-renderer` eagerly loads Pixi v8 and the full
Habbo message parser/composer registry at module-import time, which
jsdom cannot host: any `tests/**` file that transitively pulled a
renderer symbol would throw before a single assertion ran. That's
why the existing 8 suites all stuck to pure modules imported by
concrete path and used `import type` for renderer-side names.
Add a stub at `tests/mocks/renderer-mock.ts`, aliased over the package
via `vitest.config.mts`. It exports:
- Explicit behavioral stubs for the symbols tests actually exercise:
`NitroLogger`, `GetEventDispatcher`, the `mockEventDispatcher`
helper with `addEventListener` / `removeEventListener` /
`dispatchEvent` / `hasListeners`, and `RoomSessionDoorbellEvent`
(signature matches the real `(type, session, userName)` to keep
tsgo happy).
- String-keyed Proxy enums for `NitroEventType`, `RoomObjectCategory`,
`AvatarFigurePartType`, etc. — each access returns a stable unique
string so dispatch and listener agree.
- Lightweight `class StubClass {}` placeholders for the ~30 Pixi and
gameplay classes the `src/api/*` barrel touches at import time
(`NitroAlphaFilter`, `NitroContainer`, `EventDispatcher`, …).
Keeps the cascade from throwing without simulating behavior tests
don't care about.
- Singleton getters (`GetAssetManager`, `GetCommunication`,
`GetSessionDataManager`, …) returning a chainable Proxy so deeply
nested `GetX().y.z(…)` access evaluates to no-op proxies.
Pilots on top of that layer (each one designed to catch a different
class of regression):
- `tests/WidgetErrorBoundary.test.tsx` (4 cases) — happy path,
default silent fallback + `NitroLogger.error` call, custom
fallback node, default `unknown` widget name.
- `tests/useDoorbellState.test.tsx` (7 cases) — initial empty state,
append on `RSDE_DOORBELL`, dedup duplicate names, remove on
`RSDE_ACCEPTED` / `RSDE_REJECTED`, ignore stale events for
never-pending users, full unsubscribe on unmount.
Suite count now 124/124 across 10 files (was 113/113 across 8).
`yarn typecheck` still green.
Docs: CLAUDE.md's Vitest row and "Where everything lives" pointer
updated; `docs/ARCHITECTURE.md` Tests section now lists the new
suites + a description of what the mock layer covers, and the
"Wider Vitest coverage" entry in the next-steps list is reframed
from "needs a renderer mock" to "pick the next adopter".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CLAUDE.md
- TL;DR mentions the duckietm PR #126 cherry-pick (UserAccountSettings,
wear-badge popup fix) and the sirv-based dev asset serving so a fresh
session knows what's living on top of upstream main.
- New patterns section for the bootstrap.ts configuration pre-init
and the nitroAssetsServer Vite plugin, with a pointer to the
.gitignore note explaining why public/{nitro-assets,swf} symlinks
are a trap.
- "What's wired up" table gets two rows: Form Actions, and the PR #126
pickup.
- "Where everything lives" gets entries for UserAccountSettingsView,
the persistAccessTokenFromPayload helper, the asset middleware, and
the bootstrap pre-init call.
docs/ARCHITECTURE.md
- Recently fixed: adds the useAvatarEditor PETS/MISC paletteID
null-pointer that surfaced when the editor was opened.
- New Bonus subsections describing the boot-time orchestration in
bootstrap.ts, the dev asset serving via sirv (and why symlinking
under public/ is the wrong move on Windows), and the upstream
feature catch-up via PR #126.
No code changes in this commit — pure documentation refresh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`buildCategory` was reading `set.paletteID` on the line directly above
the `if(!set || !palette) return null` guard. For categories where
`getSetType()` legitimately returns null (PETS, MISC with no figure
data on the server), this threw "can't access property paletteID, set
is null" and triggered the WidgetErrorBoundary when the user opened
the avatar editor.
Split the guard: bail out as soon as `set` is null, then resolve the
palette, then bail again if that's null too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two typecheck regressions surfaced after pulling duckietm PR #126 onto
this branch:
- NotificationBadgeReceivedBubbleView lost its `useEffectEvent` wrapper
during conflict resolution. The PR rewrote the effect to use a plain
`useEffect(..., [activeBadgeCodes.length])`; reintroduce the
`requestBadgesIfEmpty = useEffectEvent(...)` shape that the rest of
this branch uses, applied to the renamed activeBadgeCodes selector.
- `src/bootstrap.ts` was importing `GetConfiguration` from the package
alias `@nitrots/configuration`, which Vite resolves via filesystem
alias but tsgo does not. Swap to the monolithic
`@nitrots/nitro-renderer` re-export, matching how App.tsx already
imports the same symbol.
Both fixes get `yarn typecheck` green again and all 113 Vitest cases
still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restoring `yarn start` from "takes forever" back to seconds.
A previous session had symlinked `public/nitro-assets` and `public/swf`
to a sibling `Nitro-Files/` tree (~177k files) so Vite could serve them
through `publicDir`. The cost was massive: chokidar tried to install a
watcher on every file at startup and the dev server hung for minutes
on Windows. Upstream `duckietm/Nitro-V3` never does this — assets live
on a separate HTTP server referenced by URL in the JSON configs.
Changes:
- Remove the two symlinks under `public/` and add a .gitignore entry
with a note explaining why they must not come back.
- Add a small Vite plugin (`nitroAssetsServer`) that mounts `sirv` on
`/nitro-assets/*` and `/swf/*`, reading from
`../Nitro-Files/{nitro-assets,swf}`. sirv is a connect-style
middleware that bypasses chokidar entirely, so 177k files no longer
cost anything at startup. The plugin also wires the same handler
into `configurePreviewServer` so `yarn preview` keeps working.
- Drop the matching `/nitro-assets` and `/swf` entries from
`server.proxy` — they had been pointed at the auth proxy on :2096
which does not expose those paths.
- Disable `login.turnstile.enabled` in `renderer-config.json`. The
configured sitekey is Cloudflare's "always-passes" test key but the
widget still requires user interaction and blocks the login flow
in local dev.
Login flow fixes that fell out of debugging:
- `prepare()` in App.tsx ran twice under React Strict Mode (mount →
cleanup → mount). The first pass set `setShowLogin(true)`, the
second raced ahead and fell through to `onSessionExpired()`,
clobbering the login UI. Guard the effect with
`lastPrepareTriggerRef` so duplicate runs at the same trigger value
are skipped while intentional re-runs (after a successful login,
which bumps `prepareTrigger`) still go through.
- Call `GetConfiguration().init()` from `bootstrap.ts` before
importing `./index`. The renderer's ConfigurationManager logs
"Missing configuration key" the first time any key is read against
an uninitialised store, and components mounted in the first paint
(login screen, hooks, the renderer warmup) were all hitting that
path before prepare()'s deferred init landed. Pre-loading the
config means the store is already populated when React mounts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The existing manualChunks rule had a bug: every renderer-related check
sat INSIDE `if(id.includes('node_modules'))`, but the renderer source
is consumed via filesystem alias to ../Nitro_Render_V3/packages/*/src
— it is not under node_modules. So the `Nitro_Render_V3` branch never
fired, and the entire renderer (~1MB+) ended up merged into the main
app chunk instead of its own nitro-renderer-*.js.
Move the renderer-path check BEFORE the node_modules guard and base
it on either the literal "Nitro_Render_V3" segment or the resolved
rendererRoot (whichever is in use). The node_modules branch still
handles the unlikely case that someone publishes the renderer as a
real npm package later.
Result expected on yarn build:
- one nitro-renderer-*.js chunk for renderer + pixi (pixi is aliased
to rendererRoot/node_modules/pixi.js, so its id will include
rendererRoot too)
- one vendor-*.js chunk for third-party deps from node_modules
- one src-*.js (or similar) chunk for the app
This makes the first paint of the app faster (browser can parallel-
download chunks) and lets the CDN cache the renderer between deploys
where only the client code changed.
No behavior change at runtime — just a different on-disk layout.
Today if you clone Nitro-V3 without cloning Nitro_Render_V3 next to it,
yarn start / yarn build fail deep inside Rolldown with:
Failed to resolve import "@nitrots/nitro-renderer"
from "/path/to/Nitro-V3/src/App.tsx"
which doesn't tell you what to do. Move the check up to
vite.config.mjs: when neither ../Nitro_Render_V3 nor ../renderer
exists, throw with the explicit clone-and-install steps and a pointer
to CLAUDE.md.
Also update CLAUDE.md "Commands" section:
- Add `yarn preview` (production build server, http://localhost:4173).
- Add a 4-step "Setup walkthrough" covering: clone the renderer
sibling, yarn install on both, copy public/configuration/*.example
to *.json, then run.
Net effect: a fresh checkout of this branch shows you exactly which
prerequisite is missing instead of a Rolldown stack trace.
Vite already ships a preview server but it wasn't exposed in
package.json. Now: yarn build && yarn preview serves dist/ on
http://localhost:4173 with --host so it's reachable from the LAN.
Three top-level files brought in sync with the work landed on
feat/react19-modernization:
- CHANGELOG.md gets a 'React 19 Modernization Phase 2 (2026-05-12)'
section spanning all four pattern groups (event-state companions,
TanStack queries on the catalog layer, god-hook splits in the
doorbell + singleton-filter styles, Pixi v8 / TS 5.7+ alignment),
the Vitest growth 65 -> 113, and the in-scope logic bug fixes.
- ARCHITECTURE.md bumps the test ledger 99 -> 113 (adds the
avatar-info reducer suite), documents the new pure-module test
convention (concrete file paths + 'import type' for renderer
event types), and lists the two new singleton-filter splits
(notification, friends).
- CLAUDE.md mirrors the same updates plus a 'Singleton-filter split'
recipe alongside the doorbell-style one; useNitroEventInvalidator
is documented next to useNitroQuery; the 'What's wired up' table
enumerates all 10 split hooks. Test count bumped 99 -> 113 in
both the 'Vitest' row and the green-bar house rule.
The three reducers that drive the InfoStand pilot
(applyUserBadgesUpdate / applyUserFigureUpdate /
applyFavouriteGroupUpdate, in src/hooks/rooms/widgets/avatarInfo.reducers.ts)
have been live for ~10 commits without coverage. They encode
non-trivial branches: 'state not AvatarInfoUser' bail-out,
'event for different user / roomIndex' bail-out, dedup-equality
bail-out, and the clearGroup logic (status === -1 || habboGroupId <= 0).
Add tests pinning every branch.
Two import-tightening tweaks made the reducer module itself
testable in jsdom without dragging the renderer SDK in:
- Renderer event types are now type-only imports — they're erased
at compile time, so the runtime module load of @nitrots/nitro-renderer
is skipped. The reducer body only reads plain event fields (no
) so this is safe.
- AvatarInfoUser / dedupeBadges / IAvatarInfo come from concrete file
paths instead of '../../../api' (the barrel pulls in Pixi-bound
modules via the renderer side-imports).
Tests cover each branch by constructing AvatarInfoUser via the
actual class (so the instanceof guard hits) and casting plain event
objects through for the typed parameter.
Net Vitest count: 99 -> 113 (8 test files).
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.
- Pattern #2 (useNitroQuery): list the eight catalog-layer queries
carved out of useCatalog this session (gift / groups / club offers /
pet palette / marketplace / club gifts), plus the new
useNitroEventInvalidator companion for server-push refresh.
- Note ICatalogOptions deleted — the legacy 'catalogOptions' bag has
no remaining fields after the migrations, useCatalog no longer
exposes it.
- New 'useCatalog decomposition (in progress)' table — what's been
lifted to TanStack and what's deferred (page tree + Builders Club
status, both core state slices that move with the data/actions
split).
- Pattern #4 (god-hook split): add the three new splits done this
session (chat-input doorbell-style, wired-tools singleton-filter,
translation singleton-filter inline).
- Bump Vitest count 83 → 99 (added 16 cases on the
useCatalogFavorites helpers).
- Note the pure-module test convention: import from concrete file
paths rather than the api barrel to avoid jsdom pulling in Pixi.
- Typecheck baseline: client now reports 0 too (was 57 at last
doc-write); the section's enumerated sweeps all landed.
- CLAUDE.md: bump '77/77' references to '99/99' (both places).
The 5 pure functions inside useCatalogFavorites
(normalizeCatalogType, getOffersStorageKey, getPagesStorageKey,
parseOffers, parsePages) handle the v2 -> v3 storage-key migration
that runs once per user the first time they open the v3 client. The
parseOffers branch in particular silently morphs the legacy number[]
shape into IFavoriteOffer[] — exactly the kind of one-shot migration
code that should have coverage so a refactor doesn't break old saves.
Move them into useCatalogFavorites.helpers.ts (sibling file, matching
the WiredCreatorTools / useInventoryFurni.reducers / avatarInfo.reducers
convention). useCatalogFavorites imports them back, plus re-exports
the IFavoriteOffer type from the helper module for the public API.
Both helpers import CatalogType from the concrete file path
('../../api/catalog/CatalogType') rather than the api barrel, so the
test file doesn't drag in the renderer SDK and run aground in jsdom.
Tests cover:
- normalizeCatalogType fallback to NORMAL on undefined/garbage/explicit
- storage-key routing for NORMAL / BUILDER / missing arg
- parseOffers: invalid JSON, non-array, empty array, v2 number[] migration,
v3 IFavoriteOffer[] passthrough, mixed-array passthrough
- parsePages: invalid JSON, non-array, normal array
Net Vitest count: 83 -> 99 (7 test files).
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.
Same pattern as the wired-tools split: 600-line useTranslation backs
6 consumers with a wide state + action surface. Split along the
read/write seam:
- useTranslationStore (internal, was the inner useTranslationState) —
the previous singleton body, untouched except for the rename and a
doc-comment.
- useTranslationState (public, read-only) — useBetween filter exposing
settings, the supported-languages list, the loading/loaded flags,
the detected-language tags, lastError, and the pure getLanguageName
helper.
- useTranslationActions (public, imperative) — same singleton filter
exposing updateSettings, ensureSupportedLanguagesLoaded, the four
translate/queue helpers. Also re-exposes 'settings' because most
call sites need 'if(settings.enabled)' before dispatching.
- useTranslation (deprecated shim) — composes the singleton via
useBetween, preserving the historical full-shape return.
applyTextTranslationLocale stays exported from the same module path
so LoginView's import keeps working.
Updates docs/ARCHITECTURE.md proposal #4 section to list the three
new splits (chat-input + wired-tools + translation) alongside the
previous five.
useWiredTools backs 20 consumers with a 618-line wide state + actions
surface; split it along the read/write seam so it's clear at the
import site whether a view is rendering Wired data or mutating it.
Because the actions need access to setters (setUserVariableAssignments,
setFurniVariableAssignments, ...), this isn't the same pure-action
shape as doorbell/friend-request. Used the useBetween singleton
indirection instead:
- useWiredToolsStore (internal) — the entire previous useWiredToolsState
body, untouched. State + listeners + effects + actions in one
closure.
- useWiredToolsState (public, read-only) — useBetween(useWiredToolsStore)
filtered to the 12 state fields (accountPreferences, roomSettings,
showInspect/Toolbar booleans, variable definitions+assignments,
areUserVariablesLoaded).
- useWiredToolsActions (public, imperative) — same singleton filtered
to the 13 actions (updateAccountPreferences, saveRoomSettings,
requestUserVariables, assignXxx/removeXxx/updateXxx variable
helpers, openMonitor / openInspectionForFurni / openInspectionForUser).
- useWiredTools (deprecated shim) — composes both, preserves the
full historical shape so the 20 existing consumers keep working.
useBetween ensures all four entry points hit the same instance, so the
state + dispatch loop stays a single source of truth. This is also the
shape that a future migration to a Zustand slice would inherit
cleanly — each public hook becomes a slice subscription.
Continues the proposal #4 split pattern (doorbell, poll, furni-chooser,
user-chooser, friend-request) for the chat-input widget. Splits the
334-line useChatInputWidget along the natural seam:
- useChatInputState — selectedUsername / floodBlocked / floodBlockedSeconds
/ isTyping / isIdle state plus the three event listeners
(FLOOD_EVENT, ObjectSelected, ObjectDeselected) and the three lifecycle
effects (flood-countdown, idle-auto-clear, typing-indicator sync).
- useChatInputActions — sendChat(text, chatType, recipientName, styleId).
Carries the slash-command handler (":shake", ":rotate", ":zoom",
":screenshot", ":pickall", etc.) and the chat-vs-shout-vs-whisper
dispatch path, with the optional outgoing-translation hook.
- useChatInputWidget — deprecated shim that composes both into the
historical { selectedUsername, floodBlocked, floodBlockedSeconds,
setIsTyping, setIsIdle, sendChat } shape so ChatInputView keeps
working unchanged.
Bonus while in here:
- Guarded all roomSession reads in actions with optional chaining
(the hook can be called during the brief no-room window between
enter and leave).
- Dropped the useless 'if(isIdle)' inside the idle effect body — the
early return guard above it already covers that branch.
- GuideToolOngoingView classNames clause: classNames(..., 'chat.roomId'
&& 'cursor-pointer') — the property name was quoted so the literal
string 'chat.roomId' was always-truthy. Unquote to read the actual
chat.roomId field.
- NavigatorRoomSettingsModTabView: UserProfileIconView userName={ user.userId }
put a number into the string-typed userName prop; the right prop for
a numeric id is userId.
- WiredExtraVariableEchoView resolvedVariableEntries: the inline
fallback-entry literal at the bottom of the useMemo got its kind
field widened to string (instead of the 'custom' literal needed by
IWiredVariablePickerEntry). Lift it into a typed const + rename to
namedFallback to avoid the shadowing of the upstream
createFallbackVariableEntry result.
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).
- ChooserSelectionVisualizer: sprite.blendMode is BLEND_MODES (string
enum in Pixi v8: 'normal' | 'add' | 'multiply' | ...). The legacy
Pixi numeric enum compared against '=== 1' (ADD); switch to '=== "add"'.
- MannequinUtilities.MANNEQUIN_FIGURE was inferred as
(string | number | number[])[]: the 'hd' / 99999 / [99998] triple
needs to be a typed tuple [string, number, number[]] so the
figureContainer.updatePart(string, number, number[]) call resolves.
InterfaceColorTabView already imports react-colorful's HexColorPicker
but the dep was never installed — tsgo flagged TS2307. Adding it via
yarn (5.7.0, the current stable line).
- ProductImageUtility: 'CatalogPageMessageProductData.I' was clearly a
placeholder/typo in the WALL branch — getProductCategory's first
param is FurnitureType, so use the enclosing productType.
- YouTubePlayerView: IRoomUserData has webID, not userId. Two
spectator/watcher-list sites used the wrong field.
- AvatarInfoWidgetView REQUEST_MANIPULATION handler: avatarInfo is
IAvatarInfo (union); .category / .id only exist on AvatarInfoFurni.
Type-guard before reading.
- InfoStandWidgetPetView: deleted the duplicate local 'interface
AvatarInfoPet' — was shadowing the imported one. Drop AvatarInfoPet
from the import (local interface stands alone).
- FurnitureExternalImageView: missing GetSessionDataManager import (the
reportedUserId field reads it inline). Added.
- GroupCreatorView setGroupData call: null values for groupName /
groupDescription / groupColors / groupBadgeParts where IGroupData
expects string / number[] / GroupBadgePart[]. Empty defaults. Also
added the previously-omitted groupHasForum field.
- ContextMenuView + WiredCreatorToolsView: 'return () =>
ticker.remove(updateOverlays)' — Pixi Ticker.remove() returns the
ticker, leaking the value to React's EffectCallback cleanup which
expects 'void | (() => void)'. Wrap in block body.
- Deleted src/components/room/widgets/chat/ChatWidgetWindowView_old.tsx
— dead code (zero references in the codebase), tripping the
NitroCardHeaderView onCloseClick prop change.
Net tsgo error count: -11.
UiSettingsContext referenced UiSettingsLoadComposer /
UiSettingsSaveComposer / UiSettingsDataEvent — none of which exist on
the renderer, and the corresponding Arcturus packet handlers don't
exist either (grep across the emulator turns up zero matches for
'UiSettings'). The feature is real (theme color/image stored in
localStorage works) but the cross-device sync was wired against a
non-existent server endpoint.
Strip the server-bound code path: settings keep persisting to
localStorage as before. The full sync becomes a follow-up that will
need both renderer composer classes AND the Arcturus packet handler
landing together.
Also re-export src/api/ui-settings/ from src/api/index so
InterfaceImageTabView / InterfaceColorTabView can import useUiSettings
+ PRESET_COLORS / THEME_PRESETS via the root barrel as the rest of the
codebase does.
Net tsgo error count: -7 (3 from UiSettingsContext imports + 4 from
InterfaceColor/ImageTabView consumers).
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.
IGetImageListener.imageReady(result: IImageResult) takes a single
IImageResult object (with .id, .data, .image), but three call sites in
the client still used the old 3-arg destructure '(id, texture, image)
=> ...'. The renderer's RoomEngine.ts already passes
'new ImageResult(...)' to the listener, so the runtime payload matches
the new contract; the old call-site shape just type-errored.
Migrated:
- LayoutPetImageView (pet thumbnail loader)
- LayoutRoomObjectImageView (furniture thumbnail loader)
- useFurniturePresentWidget (gift box image generator)
Also tightened imageFailed handlers from 'imageFailed: null' to a
proper no-op arrow — the interface requires a callback.
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 two helper functions buildWallItem and buildFloorItem took
roomObject as 'any', so 'model.getValue<number>(...)' became an
untyped-function-with-type-args error under tsgo (six hits). Typing
the param as IRoomObject (the renderer's public interface — model is
already typed there) fixes them all at once.
The fallback chain for ownerName was guarded by
'sessionDataManager.getUserData ? sessionDataManager.getUserData(ownerId)?.name : null'
— SessionDataManager.getUserData() does NOT exist on the renderer
(documented in Nitro_Render_V3/CLAUDE.md), so that branch was always
dead. Dropping it removes the four tsgo errors and the misleading
condition.
Net tsgo error count: 90 -> 80.
- ColorVariantType missed the 5 outline-* bootstrap variants
GroupForumThreadView and GroupForumThreadListView already use; adding
them clears 4 errors.
- React 19 moved the JSX namespace out of the global scope into the
react module; WiredNeighborhoodSelectorView referenced JSX.Element
without importing it.
- showConfirm() takes 7 args; the chat link confirm in useOnClickChat
passed an 8th 'link' icon arg left over from an older signature.
- LocalizeText placeholder array is string[]; UserContainerView passed
userProfile.friendsCount (number) — call .toString().
Net tsgo error count: 97 -> 90.
- Import path for SendMessageComposer pointed at ../SendMessageComposer
(non-existent); the actual module lives at ../nitro/SendMessageComposer.
Worked at runtime via Vite alias, broke at tsgo.
- request factory was typed as () => unknown so passing the return into
SendMessageComposer (which expects IMessageComposer<unknown[]>)
failed the cast.
- The Pick<NitroQueryConfig, ...> bundle handed to awaitNitroResponse
included 'key', which isn't part of that subset.
- When no select is provided, resolve(event) leaked TParser through the
TData channel; cast to TData (the default TData=TParser fallback is
fine for typed callers, but the explicit-generic case needed it).
Net tsgo error count: 100 -> 97.
ToolbarView and FriendsBarView declared their motion variant objects
without a type annotation, so tsgo widened transition.type to 'string'
where framer-motion's Variants narrows it to a literal union (spring /
tween / inertia / etc). Every <motion.div variants={...} /> site flagged
the mismatch.
Annotating the constants as Variants makes the literal inference work
('spring' stays 'spring'); also drops the redundant 'as const' on
staggerDirection now that the parent type pins it.
Net tsgo error count: 133 -> 100.
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).
Updates the proposal #1 section to reflect the four companion hooks now
in src/hooks/events/ (useNitroEventReducer, useMessageEventReducer,
useExternalSnapshot, on top of the existing *State hooks) and marks
the InfoStand + Inventory pilots from the original Fase 2 plan as
adopted. Adds the convention note for state owned outside the
listener: keep useState + useMessageEvent and extract the reducer as
a pure function, citing the two new reducer modules as reference.
The badge-deduplication helper was extracted from
InfoStandWidgetUserView in the prior commit; it's a pure (badges[]) =>
badges[] function that keeps slot indices stable by replacing duplicate
codes with empty strings. Coverage:
- empty input
- unique-only passthrough
- duplicate-replaced-with-empty
- falsy entries (null / undefined / '') normalized to ''
- first-occurrence-wins semantics
- order sensitivity (same multiset, different order -> different output)
The four useMessageEvent handlers in useInventoryFurniState (furniture
list add/update, list, removed, plus the dead post-it-placed listener)
were inlined as ~250 LOC of merge logic inside setGroupItems callbacks.
Three things change:
- The three meaningful reducers move to useInventoryFurni.reducers.ts
as applyFurnitureListAddOrUpdate / applyFurnitureList /
applyFurnitureListRemoved, plus two helpers clearUnseenFlags and
refreshGroupItemsLocalization for the existing effect-driven mutations.
Side effects (CreateLinkEvent, attemptItemPlacement, dispatchAdded)
are passed in via a ctx object so the reducers stay easy to test.
- The module-level furniMsgFragments buffer becomes a useRef, removing
a latent bug where two simultaneous client instances would have
trampled each other's fragments.
- The empty FurniturePostItPlacedEvent handler is dropped (dead code).
useInventoryFurni still owns groupItems via useState so the existing
effect-driven setters (unseen flag reset, localization refresh) keep
working; the message handlers now call setGroupItems(prev =>
applyX(prev, event, ctx)) with the extracted reducers.
InfoStandWidgetUserView previously subscribed to three room-session
events (RSUBE_BADGES, USER_FIGURE, FAVOURITE_GROUP_UPDATE) and pushed
the result back to its parent via a setAvatarInfo prop, with each
handler running CloneObject(prev) before patching one field. Three
issues with that shape:
- CloneObject was deep-cloning the whole AvatarInfoUser shape blindly
with no class-prototype awareness;
- the three listeners raced on shallow merges across the same prev
reference in StrictMode dev;
- the subscriptions lived outside the state owner, forcing a prop
callback barrier per event.
The subscriptions are now in useAvatarInfoWidget — the actual owner of
avatarInfo — and call three pure reducers extracted to
src/hooks/rooms/widgets/avatarInfo.reducers.ts (applyUserBadgesUpdate,
applyUserFigureUpdate, applyFavouriteGroupUpdate). Each reducer returns
the same reference when the event doesn't apply so React bail-outs work.
The clone now constructs a fresh AvatarInfoUser preserving prototype.
dedupeBadges is extracted to its own pure module under src/api/avatar/
so Vitest can cover it without pulling in the renderer.
InfoStandWidgetUserView loses the setAvatarInfo prop (parent updated)
and the CloneObject import.
The three companions promised in docs/ARCHITECTURE.md proposal #1
('Companion to add later') are now in src/hooks/events/:
- useExternalSnapshot wraps useSyncExternalStore for the renderer's
EventDispatcher.subscribe() + getXxxSnapshot() pairing introduced in
Nitro_Render_V3 2.1.0.
- useNitroEventReducer and useMessageEventReducer mirror the existing
*State hooks but collapse multiple event types into a single owned
state slice. The message variant accepts either a single event type
or an array; subscription is wired through a single useEffect to keep
the rules-of-hooks happy.
Two doc changes so a fresh local Claude Code session can pick up the
branch without re-discovering the conventions and the work-in-progress.
CLAUDE.md (new, repo root)
- Onboarding file Claude Code reads automatically at session start.
- TL;DR with branch name + PR number, points at docs/ARCHITECTURE.md.
- Stack snapshot (React 19, TS 7 native, Vite 8 + Compiler, Zustand 5,
TanStack Query 5, Vitest 3).
- Layout convention spelled out — `src/components/<area>/<feature>/`
for views, `src/hooks/<area>/<feature?>/` flat for hooks. The
rejected-feature-folders decision is the most stepped-on rake, so
it lives here at the top.
- The canonical 3-file god-hook split shape with doorbell as the
reference.
- Patterns to use with copy-pasteable signatures: useNitroEventState,
useMessageEventState, useNitroQuery (with the accept() filter),
Zustand stores via createNitroStore, WidgetErrorBoundary.
- "Wired up vs not yet" matrix: what each pattern is adopted on and
what the next reasonable target is.
- Pointer to the two still-open logic bugs (MainView CREATED/ENDED
race; LayoutFurniImageView async fetch race) with fix shapes.
- House rules: commit author override, no claude/... branch names,
never merge a layout-violating branch, skip-motivated splits are
fine if explained in the commit message.
docs/ARCHITECTURE.md (refresh)
- "What's already in place" rewritten to reflect the full state of
the feat/react19-modernization branch:
* stale references to the old claude/update-react-typescript-He2rs
branch removed
* the three additional god-hook splits done since the last edit
(furni-chooser, user-chooser, friend-request) added
* the 4 useNitroQuery migration sites listed (OfferView,
CatalogLayoutRoomAdsView, ModToolsChatlogView, CfhChatlogView)
* the three additional WiredCreatorToolsView tab extractions
(Monitor, Inspection, Variables) with the 4493 -> 3544 line
counter
* dead-code removal of the legacy login dialogs documented
* the Vitest count updated from 22 to 77 across 6 test files
* usePollSubscriptions hoist to RoomWidgetsView noted
- "How to pick the next refactor PR" rewritten:
* completed items removed (the previous list still had
"hoist usePollSubscriptions" as todo even though it's done,
and "per-tab WiredCreatorTools split" same)
* remaining priorities re-ordered: useCatalog migration (1),
useCatalog split (2), per-widget error boundaries (3),
wired-tools shared-state Zustand slice (4), the two open
logic bugs (5), wider Vitest coverage (6).
* "skipped intentionally" subsection added for the god-hook
splits that need design work first (pet-package, word-quiz,
chat-input, chat-widget, avatar-info).
Verification
- yarn test: 77/77 still passing.
- grep claude/update-react-typescript-He2rs docs/ARCHITECTURE.md: 0
(no stale branch refs).
Now a fresh `claude` session in this repo can read CLAUDE.md, follow
the link to ARCHITECTURE.md, and start contributing without re-asking
the conventions.
Two unrelated cleanups grouped because they're both small and safe.
Dead code removal
- src/components/login/components/RegisterDialog.tsx
- src/components/login/components/ForgotDialog.tsx
- src/components/login/components/shared.ts (only consumed by the two
dialogs above)
These were the older non-Form-Actions versions of the register and
forgot-password dialogs. LoginView.tsx defines its own inline versions
that use `useActionState` + `useFormStatus` (Phase 3 of the React 19
modernization), which are the ones actually rendered. The legacy
files were already documented as dead in docs/ARCHITECTURE.md.
NewsWindow.tsx and the `components/` directory itself stay — NewsWindow
is still imported by LoginView at the bottom of the login flow.
Vitest coverage on FriendlyTime (+12 cases)
- 65 -> 77 passing tests, 5 -> 6 test files.
- LocalizeText is mocked with a deterministic stub
(`${ key }|${ amount }`) so each assertion can verify both the bucket
chosen and the rounded amount. The mock also short-circuits the
transitive renderer-SDK import, which keeps the test runner
decoupled from the renderer install state.
- Buckets covered: seconds / minutes / hours / days / months / years
for both `format` and `shortFormat`. Plus: threshold override,
key-suffix concatenation, half-hour rounding, the raw
`getLocalization` helper.
Verification
- yarn test: 6 files / 77 cases / ~2s.
- yarn eslint on the new test file: 0 errors / 0 warnings.
- yarn tsc: clean on touched files.
49 -> 65 passing tests, 4 -> 5 test files.
New file: tests/api-utils-extra.test.ts (16 cases)
- LocalizeFormattedNumber (3): zero/NaN/null guard, sub-1000 stays,
>=1000 inserts thin-space group separators.
- ColorUtils (8): makeColorHex, makeColorNumberHex (with zero-pad),
convertFromHex (with/without #), int_to_8BitVals/eight_bitVals_to_int
roundtrip, int2rgb pure-RGB output, zero-input edge cases.
- FixedSizeStack (4): grow then overwrite oldest (ring-buffer
semantics), reset clears state, partial-fill behavior of getMax,
empty-stack returns Number.MIN_VALUE. The "partial-fill" case
documents a subtle quirk: getMax iterates the whole maxSize window
including undefined slots, but `undefined > X` is false in JS so
the inserted value wins — the test pins that behavior.
Note on `usePetPackageWidget` and `useWordQuizWidget`
- They were both considered for a state/actions split this turn but
their actions mutate internal state (`onClose` resets 5 useState,
`vote` reads pollId/question/answerSent). A clean split would
require either passing args to the action or hoisting the state
to a shared store first. Deferred as follow-up.
Verification
- yarn test: 5 files / 65 cases / ~1.9s.
- yarn eslint on the new test file: 0 errors / 0 warnings.
Stesso pattern di doorbell / poll / furni-chooser / user-chooser:
flat split sotto src/hooks/rooms/widgets/, no co-location dentro
src/components/.
Split
- src/hooks/rooms/widgets/useFriendRequestState.ts (new):
activeRequests state + displayedRequests derived (filter su
dismissedRequestIds) + due bridge events (user added/removed) +
un useEffect che riallinea activeRequests quando cambia il set
di requests dal friends-store. Esporta anche il tipo
ActiveFriendRequest per consumi futuri.
Plus: ?. su roomSession e userDataManager per evitare il bug
pattern "session è null in transition" (vedi PetTrainingPanel,
precedentemente fixato).
- src/hooks/rooms/widgets/useFriendRequestActions.ts (new):
hideFriendRequest. Thin adapter sul friends-store
(setDismissedRequestIds), nessuna subscription.
- src/hooks/rooms/widgets/useFriendRequestWidget.ts: deprecated
shim che compone i due e preserva
{ displayedRequests, hideFriendRequest } per il consumer
FriendRequestWidgetView.
Verifica
- yarn eslint sui 4 file toccati: 1 errore pre-esistente
(set-state-in-effect sul useEffect che ri-derive activeRequests
da requests — già nel file originale, baseline invariata).
- yarn test: 49/49 passing.
- yarn tsc: clean.
Sequence widget split adesso a 5 (doorbell, poll, furni-chooser,
user-chooser, friend-request). Rimangono: usePetPackageWidget,
useWordQuizWidget, useChatInputWidget, useChatWidget,
useAvatarInfoWidget, useFilterWordsWidget.