Replace the outer AnimatePresence wrapper around the four toolbar rows
(desktop backplate, left-nav, right-nav, mobile-nav) with always-mounted
motion.div elements driven by an isVisible-derived variant string
('visible' or 'hidden'). This eliminates the spam-toggle bug: rapid
clicks on the show/hide chevron previously left motion children in
inconsistent intermediate states (stuck opacity 0, phantom scale 0.8)
because AnimatePresence + Fragment + multiple keyed children breaks
when enter/exit cycles overlap. With variants, framer-motion's spring
solver picks up from the current animated value on each retarget, so
spam-clicking just settles smoothly toward whichever target is current.
Refactor details:
- containerVariants drops its 'exit' state (now lives in 'hidden').
- itemVariants drops 'exit' as well — animation target is the same as
hidden, and exit doesn't apply without AnimatePresence.
- New shellVariants for the backplate.
- pointer-events is animated per-variant ('auto' visible / 'none'
hidden) instead of pinned via a Tailwind class, so the hidden rows
don't intercept clicks.
- Wrapper variants are computed inside the component because
leftNavVariants.hidden depends on isInRoom (the nav slides in from
the side in-room, from the bottom otherwise).
- Variant inheritance: outer wrapper drives 'visible'/'hidden';
inner container (containerVariants) and items (itemVariants) inherit
via framer's variant propagation, so stagger runs in both directions
without needing AnimatePresence.
- Inner AnimatePresence around the Me popover stays — it has a single
keyed child with a clean conditional and doesn't suffer from the
Fragment-wrapping issue.
Cleanups while here:
- Dropped hasDesktopUnifiedShell: always equal to isToolbarOpen inside
the isInRoom-gated block, so the ternary was always picking one
branch. Inlined.
- Dropped showDesktopShell: same redundancy inside the (now removed)
AnimatePresence. The 'else' branch of its ternary was dead code.
- Extracted spring transition constants (SHELL_TRANSITION,
NAV_TRANSITION, ME_POPOVER_TRANSITION) so they're declared once.
- Removed pointer-events-auto from wrapper className strings where
the variant now owns it (mobile-nav, left-nav, right-nav).
Behaviour: identical to before for a single click cycle (open → close
animates with the same spring). The previously broken spam-click path
now settles cleanly. Tests still 193/193, typecheck 0 errors, prod
build unchanged.
Move the four inline-editor useStates out of WiredCreatorToolsView and
into useWiredCreatorToolsUiStore:
- editingVariable / editingValue — Inspection-tab variables-table
inline edit (current key being edited + in-flight input text).
- editingManagedHolderVariableId / editingManagedHolderValue — same
pair for the Variable Manage panel's holder rows (id 0 = none).
WiredInspectionTabView drops three more props (editingVariable,
editingValue, onEditingValueChange) and consumes the store directly
for the read sides + the per-keystroke setEditingValue. The cancel /
keydown / begin handlers stay in the parent because they wrap
shouldPauseVariableSnapshotRefresh-aware logic plus selection
bookkeeping that doesn't belong to a pure tab body.
The shouldPauseVariableSnapshotRefresh derived flag still reads from
the same store now-backed values; no behaviour change on the polling
suppression path.
Tests: three new cases (set+read pair, null-clear, managed-holder
0-as-sentinel reset). 193/193 passing.
Move the highlight feature pair into useWiredCreatorToolsUiStore:
isVariableHighlightActive (toggle UI flag) and variableHighlightOverlays
(computed screen-space overlay positions). The two screen-coords effects
in WiredCreatorToolsView stay where they are (they need React's
lifecycle to install / tear down WiredSelectionVisualizer highlights on
the active room objects) but now write to setVariableHighlightOverlays.
WiredVariablesTabView drops the isVariableHighlightActive +
onToggleVariableHighlight props and consumes the store directly — same
shape as the previous tab-prop reductions on this branch. The toggle
button keeps the same UX (Highlight ↔ Undo) but no longer crosses the
prop boundary.
Direct benefit: closing and reopening the panel while a variable
highlight is active no longer flickers the overlays off and back on —
the active flag + the last-computed overlay set both persist in
zustand and the effect re-runs from the same starting point.
Tests: three new cases on the store (toggle via direct + updater,
overlay replace + clear, close/reopen persistence). 190/190 passing.
variableHighlightObjectsRef stays a useRef inside the component: it
tracks the live PIXI objects WiredSelectionVisualizer drew onto, used
only for the cleanup pass — refs don't trigger renders and don't need
to live in the store.
Five more useStates leave WiredCreatorToolsView: selectedFurni,
selectedFurniLiveState, selectedUser, selectedUserLiveState, and the
monotonic selectedUserActionVersion counter. All five now live in
useWiredCreatorToolsUiStore; the room-event listeners
(useObjectSelectedEvent, the per-kind useMessageEvent + useNitroEvent
handlers, the per-action effects that bump the version counter) stay
in the component because they need React's subscription lifecycle —
they just call the store actions instead of setState.
Same persistence benefit as the previous monitorSnapshot pass: the
currently-inspected target survives a panel close/reopen instead of
being dropped to null on remount. Live-state setters and the action
version counter accept Updater<T> so the many `previousValue => ...`
call sites stayed verbatim.
Tests: six new cases (setSelectedFurni + null clear, functional
updater on FurniLiveState, paired setSelectedUser + LiveState,
monotonic ActionVersion via updater, close/reopen persistence). The
test fixtures use the real interface shapes — InspectionFurniSelection
includes a renderer-typed `info: AvatarInfoFurni` that is cast
through `as never` so the test doesn't have to construct the full
avatar info shape. 187/187 passing.
Move the monitor snapshot off WiredCreatorToolsView's useState into
useWiredCreatorToolsUiStore. The WiredMonitorDataEvent listener still
lives in the component (it can't move alongside without dragging
useMessageEvent into the store), but it now writes to setMonitorSnapshot
and the room-change reset calls resetMonitorSnapshot() instead of
re-instantiating the default in the component.
Direct benefit: the snapshot now survives closing and reopening the
panel between two server pushes. Before this commit, the parent
remounted on every visibility flip (parent renders null while
`!isVisible`) which dropped the snapshot back to the empty default;
the user would briefly see zeroed stats until the next `monitor:fetch`
roundtrip landed. Holding the snapshot in zustand decouples the data
from the component's mount lifecycle.
Tests: three new cases on the store cover setMonitorSnapshot,
resetMonitorSnapshot returning a fresh empty instance, and the
"close/reopen panel preserves snapshot" lifecycle. Total 181/181.
The Jest-style __mocks__/ folder added one indirection for a single
file. Move the stub to src/nitro-renderer.mock.ts at src/ root next to
test-setup.ts, drop the folder, repoint the vitest alias, and update
the lone test that imports the helpers directly (useDoorbellState).
Same behaviour, one fewer directory.
Eliminate the parallel `tests/` tree. Each `*.test.ts` / `*.test.tsx`
now sits in the same directory as the module it covers, mirroring its
filename (`Foo.ts` ↔ `Foo.test.ts`). The renderer-SDK mock used by
component / hook tests moves to `src/__mocks__/nitro-renderer.ts` and
the Vitest setup file becomes `src/test-setup.ts` — both still wired
through `vitest.config.mts` exactly as before, only the paths changed.
All 13 suites + 178/178 cases still pass. The production build is
unaffected: rollup only follows imports from `src/index.tsx` and never
crosses into `.test.ts` files, so test code is naturally tree-shaken
out of the bundle. `yarn build` output is byte-for-byte the same on
the user-facing chunks.
tsconfig drops the now-redundant `tests` include entry. CLAUDE.md
'Layout convention' replaces the old `tests/` row with three rows
documenting the new co-located convention, the `__mocks__/` directory
and the `test-setup.ts` entry; ARCHITECTURE.md picks up the same
update. The 'DO NOT CHANGE' qualifier on the layout is preserved —
this rewrite IS the change, decided deliberately to make tests a
first-class part of the source tree rather than a sibling project.
Zustand row in 'Adopted' now lists both store adoptions; the 'Not yet'
row reframes the Wired Creator Tools follow-up as 'hoist the *derived*
event-driven state' since the UI flags are now done. Vitest count
bumped to 178/178 and the second store suite is mentioned.
Move 14 pure UI flags off useState in WiredCreatorToolsView and into a
new feature-local Zustand store (useWiredCreatorToolsUiStore): tab
navigation (isVisible, activeTab, inspectionType, variablesType), modal
open flags (monitor history/info, inspection give, variable manage,
managed give), and the variable-manage / monitor-history filter +
sort + page selectors. The setters accept either a value or a (prev =>
next) updater to preserve the toggle/pagination call sites.
WiredInspectionTabView and WiredVariablesTabView now consume the store
directly for inspectionType / variablesType / isInspectionGiveOpen,
dropping six props from their interfaces. Behaviour is unchanged: every
listener and memo in the parent still reads the same values through
selectors, and the new tests pin the defaults and setter semantics
across the 14 flags.
Derived selection state (selectedFurni, monitorSnapshot, variable
highlight overlays, etc.) intentionally stays in the parent for this
pass — moving those requires moving their listener effects too.
Always call applySelectedOffer(offer) and consolidate activation into a single conditional. Removed the separate non-lazy branch and now only call offer.activate() when offer.isLazy && offer.offerId > -1, reducing duplicated logic and simplifying the flow.
The umbrella boundary on RoomWidgetsView caught any widget crash but
unmounted every sibling along with the failing widget — a single bad
parser in ChatWidget would dark out the avatar info, chat input,
doorbell and all furniture overlays until the next remount.
Wraps each of the 13 direct children of RoomWidgetsView (AvatarInfo,
Chat, ChatInput, Doorbell, RoomTools, RoomFilterWords, RoomThumbnail,
FurniChooser, PetPackage, UserChooser, WordQuiz, FriendRequest, plus
the FurnitureWidgets umbrella) and each of the 20 sub-widgets inside
FurnitureWidgetsView in its own named WidgetErrorBoundary. A crash
now silently logs through NitroLogger with the widget name and
renders null for that one widget; every sibling keeps rendering.
The outer umbrella stays as defense-in-depth for the wrapper div and
the listener setup in RoomWidgetsView itself.
Closes the "Per-widget WidgetErrorBoundary wrapping" roadmap item;
updates CLAUDE.md and docs/ARCHITECTURE.md accordingly.
LayoutFurniImageView and LayoutAvatarImageView both fired async image
generation (TextureUtils.generateImage / SDK resetFigure callback) and
wrote the result back through setImageElement / setAvatarUrl with only
an isMounted / isDisposed component-level guard. If props changed
twice in rapid succession the older request could resolve last and
overwrite the newer image with a stale one, visible on slow
connections or fast scroll over grids of unique items.
Each effect now captures `const requestId = ++requestIdRef.current`
and threads it into every async callback (TextureUtils.generateImage,
the SDK's resetFigure listener, the cache write). When a callback
fires it bails if `requestIdRef.current !== requestId` — only the
latest effect's callbacks make it past the gate. A stale ENDED for
the previous figure now leaves the cache and the rendered url
unchanged.
Moves both bugs from "Open" to "Recently fixed" in
docs/ARCHITECTURE.md.
Two independent useNitroEvent listeners updated landingViewVisible from
RoomSessionEvent.CREATED and ENDED with no notion of which session was
active. Under flaky websocket reconnects the events can land out of
order: a stale ENDED for the previous room arrives after CREATED for
the new one, flips landingViewVisible back to true, and the user is
left at the hotel view inside a room (or vice versa) until the next
room change.
Folds both events into one useNitroEventReducer that carries the
tracked sessionId. CREATED sets the id and closes the landing view;
ENDED is applied only when its event.session.roomId matches the
tracked id (or no session is active) — otherwise it's a stale ENDED
for a previous session and is ignored. The reducer companion is the
existing useNitroEventReducer from src/hooks/events, so no new
infrastructure.
Moves the entry in docs/ARCHITECTURE.md from "Open" to "Recently
fixed".
Replaces every direct call to the deprecated useCatalog() shim with the
targeted filter(s) (useCatalogData / useCatalogUiState / useCatalogActions).
Each consumer now subscribes only to the slice it actually reads, which
restores React Compiler memoization and stops catalog-wide re-renders
whenever an unrelated key changes.
Removes the now-unused useCatalog shim from useCatalog.ts and the
shim-specific case in tests/useCatalog.filters.test.tsx. The "all four
hooks observe the same singleton" test becomes "all three filters", since
there is no shim left to compare against. useCatalogFavorites swaps its
internal useCatalog() call for useCatalogUiState() (currentType lives in
the UI slice).
Updates CLAUDE.md and docs/ARCHITECTURE.md to reflect that all 48
historical consumers are migrated and the shim is gone.
Vitest: 162/162 (was 163 — minus the deprecated-shim contract case).
Node 20 is being removed from GitHub-hosted runners in Sept 2026 and
the actions/checkout@v4 / setup-node@v4 steps were warning on every
run. Set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true at the workflow
level so they pick up the Node 24 runtime now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-up fixes after the first CI run failed with TS2307 across
~250 files:
1. The relative symlink target `../../../Nitro_Render_V3` resolves
from inside `Nitro-V3/node_modules/@nitrots/nitro-renderer` to
`Nitro-V3/Nitro_Render_V3` (one too few `..`) — that path
doesn't exist, so tsgo couldn't find the renderer SDK and bailed
on every `@nitrots/nitro-renderer` import. Switch to an absolute
target via ${{ github.workspace }}.
2. The client depends on renderer API additions (`allowUnderpass` on
RoomSettingsData, `sendBackgroundMessage` on IRoomSession, the
NitroConfig Window declaration alignment) that live on
`feat/react19-event-bus` of `simoleo89/Nitro_Render_V3` and not
on `duckietm/Nitro_Render_V3:main`. Point the checkout at the
fork + that branch so tsgo sees what the local working tree sees.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Yarn cleans up anything in node_modules that's not declared in
package.json, so the previous order (symlink → yarn install) wiped
the link and tsgo could not resolve @nitrots/nitro-renderer.
Move the symlink step after both installs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.