CLAUDE.md updates:
- Patterns to use: "useSessionSnapshots" section retitled "(OPT-IN)";
the documented pilot adopters (useSessionInfo,
AvatarInfoWidgetAvatarView) are removed. Adds explicit warning about
the suspected useBetween + useSyncExternalStore + React Compiler
interaction and the rollback in e142efd.
- Adopted table: snapshot-consumer row changed to "No in-tree
consumers" with note about defensive fallbacks remaining.
- Not yet table: the useChatWidget reactive-ownUserId line corrected
to reflect the rollback; the "migrate session-data mirrors" row
marked BLOCKED with a retry hint (try a non-useBetween consumer
first to isolate the cause).
ARCHITECTURE.md update:
- useExternalSnapshot bullet in the "Solution" section gains a note
pointing at the 8 pre-built consumers in useSessionSnapshots.ts and
the 2026-05-18 rollback caveat with the suspected interaction and
retry guidance.
Pure documentation refresh; no code change. The useSessionSnapshots.ts
file and the vite alias remain in place — they're not what got rolled
back, only the consumer-side migrations were.
The migrations of useSessionInfo, useChatWidget.ownUserId and the
AvatarInfo Ignore/Unignore menu to the new useSessionSnapshots hooks
were correct in code but produce a persistent runtime error in the
user's deployment:
TypeError: (intermediate value)() is undefined
ToolbarView ToolbarView.tsx:46
The error fires from React's render loop on the first paint —
ToolbarView is the first mounted consumer of useSessionInfo, which is
why it carries the boundary message. Two attempted fixes did not
resolve it on the user's side:
- 790ad2b: vite alias forcing @nitrots/nitro-renderer to source index.ts
- c35a2d4: defensive typeof guards on every Manager method call inside
useSessionSnapshots (so a missing method degrades to a frozen default
rather than calling undefined)
Both are correct defenses but the error persists, meaning the failure
mode is upstream of those guards. Rather than burn more cycles
remote-debugging, roll back the three consumer migrations:
- useSessionInfo: restored to the pre-71a0eee shape — 5 useState
fields driven by useMessageEvent<UserInfoEvent, FigureUpdateEvent,
UserSettingsEvent>. The five consumers (ToolbarView, HcCenterView,
ChatInputView, AvatarInfoPetTrainingPanelView, InfoStandWidgetPetView,
AvatarInfo{Avatar,Pet,OwnPet}View) get the same destructured shape
they had before this session.
- useChatWidget.ownUserId: restored to `GetSessionDataManager()?.userId`
(synchronous, captured at mount). Loses the session-change reactivity
but matches the previous, working behaviour.
- AvatarInfoWidgetAvatarView Ignore/Unignore: restored to
`avatarInfo.isIgnored` (captured by AvatarInfoUtilities at click
time, not reactive). Loses the live-toggle if the user is
ignored/unignored while the popup is open — known small regression,
worth it for stability.
Kept intact:
- The useSessionSnapshots.ts hook file itself, with defensive guards,
so the API stays available for any future opt-in consumer.
- 790ad2b vite alias for the umbrella, still useful as defence in
depth for future migrations.
- All the other non-snapshot modernizations from this session
(usePetPackageWidget reducer, useWordQuizWidget bug fix,
useChatCommandSelector Zustand store, useAvatarInfoWidget typed
globalThis accessor).
Verification: yarn typecheck clean, yarn test 207/207, yarn build green.
The toolbar should boot without the error now — the call chain on the
first paint no longer touches the new useExternalSnapshot / snapshot
getter path.
The snapshot hooks were chained against renderer Manager methods
(getUserDataSnapshot, getIgnoredUsersSnapshot, subscribe, …) under the
assumption that the resolved \`@nitrots/nitro-renderer\` bundle always
includes the v2.1.0+ snapshot API.
That assumption fails in two real scenarios:
1. A stale \`dist/index.js\` shadows the source umbrella at resolution
time (the vite alias commit 790ad2b mitigates this in dev, but it
only takes effect after a server restart).
2. A consumer bundles the client against an older renderer release
(e.g. NitroV3-Housekeeping's embedded copy in \`public/nitro3\`).
In both cases the snapshot hook calls \`undefined()\` and React shows
the error-boundary fallback "(intermediate value)() is undefined".
Wrap every renderer-side call with a typeof guard:
const manager = GetSessionDataManager();
if(!manager || typeof manager.getUserDataSnapshot !== 'function')
return DEFAULT_USER_DATA;
return manager.getUserDataSnapshot();
Module-level frozen defaults (DEFAULT_USER_DATA, EMPTY_IGNORED_LIST,
EMPTY_GROUP_BADGES, EMPTY_USER_LIST, DEFAULT_VOLUMES, NOOP_UNSUBSCRIBE)
keep the snapshot reference stable across fallback calls, so
useSyncExternalStore's bailout still works and we don't trigger render
loops on the degraded path.
Once the renderer is upgraded (or the alias kicks in after restart),
the hooks transparently switch to the real getters — no code change
needed at any consumer.
Verification: yarn typecheck clean, yarn test 207/207, yarn build green.
The fix is defense-in-depth on top of 790ad2b (vite alias) — both can
coexist, neither alone is sufficient for every deployment surface.
Without an explicit alias for the umbrella package
@nitrots/nitro-renderer, vite's resolver follows the node_modules
symlink (@nitrots/nitro-renderer -> Nitro_Render_V3) and the
\`"main": "./index"\` field, which can land on a stale built
\`dist/index.js\` when one exists in the renderer working tree.
When that happens the bundle ships pre-snapshot-pattern stubs of
SessionDataManager / IgnoredUsersManager / etc. — and the new
useSessionInfo / useUserDataSnapshot code calling getUserDataSnapshot()
explodes at runtime with the Firefox error
TypeError: (intermediate value)() is undefined
(which is Firefox's way of reporting a chain like
\`GetSessionDataManager().getUserDataSnapshot()\` where the second
method is undefined). The reported call site is ToolbarView line 46
because that's the first consumer of useSessionInfo that mounts.
Two fixes together:
1. This commit: explicit alias \`@nitrots/nitro-renderer\` ->
\`<renderer>/index.ts\`. Subsequent transitive imports
(export * from '@nitrots/api', '@nitrots/events', ...) still go
through the existing per-package aliases, so all renderer code is
guaranteed-source even when a stale dist exists.
2. Rebuild the renderer's dist (yarn build in Nitro_Render_V3) so that
any other consumer that bypasses vite's alias resolution (e.g. an
ad-hoc Node script) also sees the current state. Done separately.
No source code change to any consumer. The client production build
\`yarn build\` now produces a bundle containing getUserDataSnapshot,
getIgnoredUsersSnapshot, SESSION_DATA_UPDATED, IGNORED_USERS_UPDATED
and the other new symbols — verified via grep on dist/assets/*.js.
The Not-yet table previously framed useChatWidget, useAvatarInfoWidget,
usePetPackageWidget, useWordQuizWidget, useChatCommandSelector as
purely skip-motivated. The data/actions split is still a bad fit for
all five (the original reason holds), but each got a real,
non-split modernization in the 2026-05-18 session:
- usePetPackageWidget: useReducer + getPetPackageNameError pure helper
+ 4 Vitest cases (c3a76b6)
- useWordQuizWidget: closure-captured stale-state bug fix + useRef
for the question-clear timeout (5259c89)
- useChatCommandSelector: module-level `let` cache → Zustand store
(19b4851)
- useChatWidget: reactive ownUserId via useUserDataSnapshot (05ff7df)
- useAvatarInfoWidget: typed __nitroAvatarClickControl accessor +
module-scope DEBOUNCE const (05ff7df)
Updated the two Not-yet rows to reflect what landed and what
remains "skip-motivated" (data/actions split specifically). Vitest
count bumped 203 → 207.
Two small modernization wins on the previously skip-motivated god-hooks.
Neither hook lends itself to the data/actions split, but both had
concrete imperative-style residue worth tidying:
== useChatWidget
Replace `const ownUserId = GetSessionDataManager()?.userId || -1;` with
`useUserDataSnapshot().userId`. The previous read happened at hook mount
and stayed pinned to whatever userId the manager held at that point —
a session change (re-login without page reload) would silently corrupt
the outgoing-translation owner check below. With the snapshot hook,
the value updates reactively via SESSION_DATA_UPDATED and the
useNitroEvent re-registration picks up the fresh ownUserId for every
incoming chat event.
== useAvatarInfoWidget
Two tidy points:
- CLICK_USER_DEBOUNCE_MS (the 120ms window during which a directional
click suppresses the context menu) lifted from inside the hook body
to a module-level const. It's never going to change at runtime and
doesn't depend on hook state — keeping it inside meant it was
redeclared on every render.
- The `(globalThis as any).__nitroAvatarClickControl` read replaced by
a typed `getAvatarClickControl()` helper backed by a proper
`NitroAvatarClickControl` interface. Same runtime behaviour; type
channel no longer goes through `any`, and the symbol is documented
in one place above the hook.
Public APIs of both hooks unchanged. Suite: 207/207.
Two module-level `let` declarations (cachedServerCommands +
globalListenerRegistered) were tracking the AvailableCommandsEvent
listener state outside React. The pattern was a React Compiler
violation flagged elsewhere in the codebase (the navigatorRoomCreator
fix was the canonical precedent — see commit fd1835c).
Move both into a per-hook Zustand store
(`useChatCommandStore`) following the same convention as
`useWiredCreatorToolsUiStore` and `useRoomCreatorStore`. The store
keeps the cached server-pushed CommandDefinitions plus a
single-shot isListenerRegistered flag that prevents the in-hook
useMessageEvent and the module-level pre-mount listener from
double-registering.
`CLIENT_COMMANDS` stays at module scope — it's a const array,
React Compiler is fine with constant data.
Behavioural change: zero. The pre-mount registration still tries
once at module load (covering the case where the server's
AvailableCommands lands before any React widget mounts). The in-hook
useMessageEvent still covers later mounts and rank-change refreshes.
Every push goes through `setServerCommands`, so all consumers see
the same data.
Side benefit: a future test can now `useChatCommandStore.setState({
serverCommands: [...], isListenerRegistered: true })` to seed a
deterministic fixture without monkey-patching the module.
Public API of useChatCommandSelector unchanged; the one consumer
(ChatInputView) reads the same destructured fields. Verified via grep.
Suite: 207/207.
Two bugs and one tidy in the word-quiz widget hook.
== Bug 1: stale-closure read in setUserAnswers updater
setUserAnswers(prevValue => {
if(!prevValue.has(userData.roomIndex)) {
const newValue = new Map(userAnswers); // <- WRONG: reads
// ^^^^^^^ the closed-over
// state, not prevValue
newValue.set(userData.roomIndex, ...);
return newValue;
}
return prevValue;
});
The functional updater is supposed to read the *latest* state (its
`prevValue` argument), not the closed-over `userAnswers` from the
render that registered this listener. The old code mixed both:
`prevValue.has(...)` for the check but `new Map(userAnswers)` for the
copy. Under rapid successive ANSWERED events for different users
within the same tick, the second update would copy a stale map and
drop the first user's entry. Fixed: use prevValue throughout.
== Bug 2: questionClearTimeout stored in useState
The timeout handle is a side-channel value, not display state. Storing
it in useState meant every (re)schedule triggered a re-render even
though no widget reads it. It also let the cleanup effect close over
a stale handle if the unmount fired between the schedule and the
state commit. Moved to useRef + a small `scheduleQuestionClear(delay)`
helper that consolidates the clear-then-set pattern (was duplicated
across FINISHED and QUESTION handlers).
== Tidy
- The duration-zero branch of QUESTION now explicitly clears any
pending timeout instead of falling through to a `setTimeout(..., null)`
no-op path.
- Cleanup effect rewritten as a single arrow-return for brevity.
Public API of useWordQuizWidget unchanged. Suite: 207/207.
The hook tracked five related useState fields driving the pet-package
naming dialog (isVisible / objectId / objectType / petName / errorResult).
They transitioned in lockstep on the two RoomSessionPetPackageEvent
types and the inline change handler — textbook state-machine territory.
Collapse into a single useReducer with four explicit transitions:
- 'open' → REQUESTED event lands; flips visible, records target
- 'close' → REQUESTED-result success OR user dismiss; resets to INITIAL
- 'set-name' → input change; updates petName AND clears any error
(the previous code had this side effect inlined in
onChangePetName as `if(errorResult.length) setErrorResult('')`,
now it's part of the reducer contract)
- 'set-error' → REQUESTED-result with validation failure; sets the label
Plus extract `getPetPackageNameError(code)` to a top-level exported
pure function (was an inline closure named getErrorResultForCode).
The mapping is server-protocol contract, not UI state — moving it out
of the hook means it's testable, reusable, and won't be recreated on
every render.
Public API of usePetPackageWidget is unchanged — the one consumer
(PetPackageWidgetView) reads the same destructured fields. Verified
via grep.
Tests: 4 new cases on getPetPackageNameError covering code 0 / 1-4 /
falsy / unknown-fallback. Suite: 207/207 (was 203/203).
Extend SESSION_2026-05-18_changelog.md with the five-commit Phase 12
batch landed after the original changelog (e7e8bcc) was written:
- b2a86da: feat(hooks/session) — eight consumer hooks for the renderer
snapshot pattern (useUserDataSnapshot, useActiveRoomSessionSnapshot,
useIgnoredUsersSnapshot + useIsUserIgnored, useGroupBadgesSnapshot +
useGroupBadge, useVolumesSnapshot, useRoomUserListSnapshot).
- 71a0eee: refactor(hooks/session) — migrate useSessionInfo's userFigure
/ respects mirrors to useUserDataSnapshot; drop 3 useState + 2
useMessageEvent.
- 36addbe: fix(avatar-info) — reactive Ignore/Unignore menu entry; the
previously-stale captured boolean now flips in real time via
useIsUserIgnored.
- 02a396d: docs(CLAUDE.md) refresh — bump Vitest 193→203, drop obsolete
rows, document the new snapshot-consumer pattern, mark the two old
open logic bugs as closed.
Also bumps the headline commit count 109 → 114 and updates the
end-of-session HEAD references.
- Adopted table: add new row for the useSessionSnapshots consumer hooks
(pilots on useSessionInfo + AvatarInfoWidgetAvatarView); bump Vitest
count from 193/193 to 203/203; expand the Zustand row to note that
the WiredCreatorTools panel-lifecycle hoist roadmap is closed (every
remaining useState in that component is genuinely transient).
- Not yet table: drop the obsolete "hoist Wired Creator Tools derived
state" row (done in monitorSnapshot/selection/highlight/inline-editor
hoists + today's three picker commits). Add a new row for migrating
remaining session-data mirrors to the snapshot pattern.
- Patterns section: new "useSessionSnapshots" entry at the top
documenting the 8 hook menu and which pilots already use them.
- Known open logic bugs: both previously-open races are closed
(9d10e52 + 97c9717). Replace the section with a "no open bugs" entry
pointing readers to docs/ARCHITECTURE.md "Recently fixed".
No code changes — pure doc refresh aligning CLAUDE.md with the
current state of the branch.
The Ignore <-> Unignore context-menu entry was driven by
avatarInfo.isIgnored — a boolean captured by AvatarInfoUtilities once,
at the time the avatar was clicked. If the user got ignored / unignored
*while the popup was already open* (e.g. via the friends panel, or
because a server push flipped the state), the menu kept showing the
stale option and clicking it would no-op (or worse, double-ignore).
Switch the menu items to read useIsUserIgnored(avatarInfo.name) — the
reactive hook backed by IgnoredUsersManager.getIgnoredUsersSnapshot()
+ NitroEventType.IGNORED_USERS_UPDATED. Now the menu flips automatically
the moment the ignore list changes, without re-opening.
avatarInfo.isIgnored stays on the data object (other code paths still
consume it) — only the user-facing menu toggle is now reactive.
Replace the local useState mirror of userFigure / userRespectRemaining /
petRespectRemaining (driven by useMessageEvent<UserInfoEvent> +
useMessageEvent<FigureUpdateEvent> + manual setUser after giveRespect)
with a single useUserDataSnapshot() read.
Why this works: SessionDataManager already invalidates its snapshot
on every state change that mattered to the old hook — UserInfoEvent
handler (line 142), FigureUpdateEvent listener (line 117),
giveRespect / givePetRespect (lines 540/551). The snapshot's
respectsLeft / respectsPetLeft map directly to the parser fields
respectsRemaining / respectsPetRemaining the old code mirrored.
Net result: 3 useState declarations + 2 useMessageEvent subscriptions
removed; respectUser / respectPet become trivial pass-throughs (no
post-call setState because the manager's invalidate dispatches the
event for us). UserSettingsEvent stays on useMessageEvent —
chatStyleId is not in the snapshot.
Also drops the deprecated `userInfo: UserInfoDataParser` field from
the return shape — no in-tree consumer reads it (verified via grep
across src/), it was carried as legacy clutter.
Consumers unchanged: ToolbarView, HcCenterView, ChatInputView,
AvatarInfoPetTrainingPanelView, InfoStandWidgetPetView, AvatarInfoWidget
{Avatar,Pet,OwnPet}View. All destructure individual fields, not the
deprecated userInfo.
Verification: yarn typecheck clean, yarn test 203/203.
The renderer exposes six referentially-stable snapshot getters under the
v2.1.0 React-friendly pattern (SessionData / RoomSession / IgnoredUsers /
GroupBadges / RoomUserList / SoundVolumes), each invalidated by a
dedicated NitroEventType.*_UPDATED dispatch. Until now nothing on the
client consumed them — useExternalSnapshot existed as a useSyncExternalStore
wrapper but no widget was wired up to a snapshot.
Add thin consumer hooks under src/hooks/session/useSessionSnapshots.ts,
each a useExternalSnapshot wrapper around the matching subscribe+getter
pair:
- useUserDataSnapshot() → Readonly<IUserDataSnapshot>
- useActiveRoomSessionSnapshot() → Readonly<IRoomSessionSnapshot> | null
- useIgnoredUsersSnapshot() → ReadonlyArray<string>
- useIsUserIgnored(name) → boolean (useMemo over the array)
- useGroupBadgesSnapshot() → ReadonlyMap<number, string>
- useGroupBadge(groupId) → string (useMemo over the map)
- useVolumesSnapshot() → Readonly<ISoundVolumesSnapshot>
- useRoomUserListSnapshot() → ReadonlyArray<IRoomUserData>
Two design details worth noting:
- useRoomUserListSnapshot subscribes to BOTH ROOM_USER_LIST_UPDATED (for
join/leave/update inside a session) AND ROOM_SESSION_UPDATED (because
the underlying userDataManager reference flips when the active room
session changes). A single module-level frozen EMPTY_USER_LIST is the
fallback when no session is active, keeping reference stability across
reads in the no-room state.
- useIsUserIgnored / useGroupBadge memoize the scalar derivation so a
re-render only happens when the underlying snapshot reference flips,
not on unrelated useExternalSnapshot wake-ups.
These hooks unlock per-component snapshot consumption — widgets that
previously juggled addEventListener + useState pairs (or worse, read
GetSessionDataManager().userId directly and never re-rendered) can now
go through one of these and get reactivity for free. Migration of
existing consumers (useSessionInfo, AvatarInfoUtilities, etc.) is the
next pass.
Verification: yarn typecheck clean, yarn test 203/203, yarn build green.
Companion tool for the split-aware gamedata loader added in
@nitrots/utils. Takes a legacy single-file gamedata JSON/JSON5 and
produces the directory layout the loader expects:
<out>/
manifest.json5 (root manifest, tier order)
core/
manifest.json5 (file list in load order)
<part>.json5 ...
The tool auto-detects the gamedata type from top-level keys and applies
the right split strategy:
- EffectMap -> one file per effect type (dance, fx, ...)
- FigureData -> palettes + one file per setType
- FigureMap -> chunked libraries (default 500/file)
- FurnitureData -> floor/wall, chunks of furnitype (default 300)
- HabboAvatarActions -> grouped by state
- ProductData -> chunked products (default 500)
- ExternalTexts/UITexts-> grouped by key prefix
Only the core/ tier is generated; custom/ and seasonal/ are operator-
owned and the loader auto-discovers them when their manifest.json5
exists.
Flags: --input, --output, --type, --chunk-size, --json (legacy emit),
--force, --help.
README extended with a 'Splitting gamedata' section covering the layout,
the strategy table, CLI usage and the renderer-config migration step.
End-to-end documentation of every modification since the two branches
were opened:
- 109 commits on feat/react19-modernization (baseline ae17619)
- 22 commits on feat/react19-event-bus (baseline 98b03aa)
- the 2026-05-18 Arcturus FF pull to v4.1.16
Organized into 11 phases on the client (React 19 baseline → infra
pillars → god-hook splits → WiredCreatorTools extraction + Zustand
hoists → typecheck cleanup → error boundaries → test infrastructure →
CI → PR #126 cherry-picks + asset middleware → toolbar spam-toggle fix
(PR #130 upstream) → full upstream sync + final picker hoists) and 9
phases on the renderer (v2.1.0 React-friendly API → TS 6/tsgo → API
interface alignments → ArrayBuffer drift → Pixi v8 → composer/parser
alignment with Arcturus → dead code → upstream sync → snapshot
extensions).
Includes the full commit index per branch, the public-API additions
table, the bugs-fixed table with severity, the Vitest test-count
evolution (0 -> 203 client, 0 -> 127 renderer), and the local
rollback-tag list.
Adds install.bat / install.sh / install.mjs at the project root for a
one-shot setup: prereqs check, renderer clone & link, dependency install,
config copy, JSON parser mode selection, URL prompt with validation, and
the production build.
- install.bat / install.sh: thin OS-specific wrappers around install.mjs
- install.mjs: 9-step installer with --help, --non-interactive,
--skip-build/clone/link and per-URL override flags
- new step 6 'Choose JSON parsing mode': prompts the operator (json5
recommended) or accepts --json-mode=json5|legacy|auto in CI; writes
.nitro-build.json so the final 'yarn build' picks it up directly
- summary now reports the selected JSON mode and its source
- .gitattributes: force LF on install.sh / install.mjs so the shebang
stays valid on Linux/macOS after a Windows checkout
- install.sh marked executable in the index (100755)
- README: new 'Quick install' section with interactive and CI usage,
plus a complete --non-interactive example
Move the Variable-Manage panel's four-step picker cascade into the
Zustand store. Closes the WiredCreatorTools "fragmented picker state"
roadmap item — every remaining useState in the panel is now either a
store-backed UI flag or a transient component-only value (keepSelected,
globalClock, roomEnteredAt, monitor error/log details).
All writers were already direct assignments (no updater shape), so the
setters are plain typed setters. Sentinels remain `null` / `0` / `0` /
`'0'`; the cascade reset effects at WiredCreatorToolsView.tsx:2265-2307
keep the chain self-consistent. Panel close/reopen now preserves the
managed picker state, matching the lifecycle guarantee already provided
for selection, monitor snapshot, variable highlight, and inline editor.
Tests: 4 new cases (entry select/clear, chain write, post-action reset
to sentinels, panel-lifecycle persistence). Suite: 203/203.
Lets the operator pick between strict JSON (legacy) and JSON5 for every
configuration file consumed by Nitro and the renderer.
- scripts/configure-json.mjs: interactive prompt (JSON5 recommended),
with --if-missing and --non-interactive flags for CI use
- package.json: yarn configure / prestart / prebuild hooks
- vite.config.mjs: reads .nitro-build.json (or NITRO_JSON_MODE env) and
injects the compile-time constant __NITRO_JSON_MODE__ via define
- src/bootstrap.ts: routes client-mode.json parsing through the
selected mode
- .gitignore: ignore the per-deployment .nitro-build.json
- README: full usage and override section
- public/configuration assets regenerated by the updated prebuild flow
The renderer side (@nitrots/utils JsonParser) is updated in the
companion Nitro_Render_V3 commit on the dev branch.
Move the Inspection-tab Give-variable popover picker pair into the
Zustand store. Both writers use direct assignments (no updater shape),
so the store setters are plain `(next: number) => void` /
`(next: string) => void`. Defaults `0` / `'0'` match the existing
"sentinel = not selected" convention used by the reset effects at
WiredCreatorToolsView.tsx:3026-3042.
Tests: 2 new cases (set+read pair, sentinel-reset). Suite: 199/199.
Move the last two `Record<...Type, string>` useStates out of
WiredCreatorToolsView into useWiredCreatorToolsUiStore. Both writers
were already using the `prev => ({ ...prev, [key]: value })` updater
shape, so the new store setters expose `Updater<Record<...>>` to keep
existing call sites verbatim.
Initial values default to empty strings; the existing
`variableDefinitionsByType` sync effect at WiredCreatorToolsView.tsx
:1543-1574 already populates valid keys on first render and reconciles
whenever the server-side definitions change. Closing/reopening the panel
now preserves the active picker key instead of resetting it.
Tests: 4 new cases on the store (updater shape, single-key patch
preserving siblings, direct-record write path, panel-lifecycle
persistence). Suite: 197/197 (was 193/193).
Replace the stale 'PR #126 cherry-picks' framing with the new state:
origin/Dev is fully merged through b2318b9 (merge commit 779a98c). Note
the recurring conflict surface (App.tsx / bootstrap.ts / LoginView.tsx
React 19 imports) for future upstream syncs.
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).