The earlier "BLOCKED" / "rolled back" framing in CLAUDE.md +
ARCHITECTURE.md is stale: the three pilot snapshot-consumer migrations
shipped in d28819d on 2026-05-19 once the root cause was pinpointed
(`use-between` 1.x ships a dispatcher proxy that doesn't implement
`useSyncExternalStore`, so any snapshot hook called inside
useBetween(stateFn) crashes the first render).
Updated:
- CLAUDE.md → "Patterns to use → useSessionSnapshots": rewrote the
adoption-status paragraph to record the three live consumers, the
hard structural constraint (snapshot reads MUST be outside
useBetween scope, with the precise dispatcher line numbers + the
exact error fingerprint), and the fix template applied to
useSessionInfo (outer wrapper reads the snapshot, inner state
function keeps only use-between-safe hooks).
- CLAUDE.md → "What's wired up and what isn't" tables:
- Adopted row for "Renderer snapshot consumer hooks" lists the
three live consumers instead of the old "No in-tree consumers"
note.
- "Not yet" row renamed from "Blocked" to "Unblocked — migrate more
consumers", with concrete next candidates
(GetSessionDataManager().userId / userName / clubLevel /
securityLevel, GetRoomSessionManager().getActiveSession(),
GetSoundManager().<volume>) and a reminder of the constraint
+ the CI gate that enforces it.
- useChatWidget.ownUserId row notes the reactive migration via
useUserDataSnapshot landed (direct hook call — useChatWidget
isn't wrapped in useBetween, so the constraint doesn't apply).
- ARCHITECTURE.md → "useExternalSnapshot" subsection: replaced the
2026-05-18 rollback note with the structural constraint + the
2026-05-19 fix landing, including pointers to the regression test
and the new CI gate (eslint.hooks.config.mjs + yarn lint:hooks).
No code change in this commit — yarn typecheck clean, yarn
lint:hooks clean.
Two follow-ups to the CatalogPurchaseWidgetView fix (6bf3366):
1. CatalogItemGridWidgetView had the same shape — four useCallback
declarations (handleDragStart / handleDragOver / handleDrop /
handleDragEnd) sat below an `if(!currentPage) return null` early
return. When currentPage flipped from null to a real page the hook
count jumped by 4 and React would have thrown "Rendered more hooks
than during the previous render" the moment any consumer rendered
the grid in admin mode. Moved the four useCallback declarations
above the early-return; their bodies are safe pre-load (only
currentPage?.offers is accessed inside handleDrop, optional-chained
already).
2. CI gate — the existing GitHub Actions workflow runs `yarn
typecheck` and `yarn test`, but NOT `yarn eslint`. That's why this
pattern slipped through twice in a row: ESLint flags it locally
but no PR check enforces it. Full `yarn eslint` emits ~900
pre-existing baseline errors (brace-style, indentation,
recommended TS rules — out of scope for this branch), so a blanket
step would always fail. Instead added a focused
`eslint.hooks.config.mjs` + `yarn lint:hooks` script that runs
ESLint with ONLY `react-hooks/rules-of-hooks: error`. Wired into
ci.yml between `typecheck` and `test`. The local repo now has
zero violations of the rule.
3. useSessionSnapshots.test.tsx — added eslint-disable-next-line
comments on the three lines that intentionally violate the rule
(they're the assertions that the broken pattern crashes). Without
the comments the new CI gate would fail on the regression-guard
suite.
Verification: yarn lint:hooks green, yarn typecheck clean, yarn test
209/209.
React reported "Rendered more hooks than during the previous render"
when CatalogPurchaseWidgetView transitioned from currentOffer=null to
a real offer: hook count jumped from 22 to 23 because the
useMemo/useEffect block for the builders-club placement state sat
*below* the `if(!currentOffer) return null` early-return on line 140.
On the first render it never ran; on the next render (offer loaded)
it did, and React's hook-call tracker flagged the divergence and
unmounted the component via the error boundary.
Fix: move the three builders-club hooks (useMemo builderPlaceableStatus,
useMemo buildersClubPlaceOneButtonStyle, useEffect interval) above the
early return. They already short-circuit cleanly when
isBuildersClubPlaceable is false — added a defensive `!currentOffer`
guard on the first useMemo and an explicit `!!currentOffer` clause on
the derived isBuildersClubPlaceable so the .product access stays safe
when offer is null. Behavior unchanged for the loaded-offer path; the
early-render path now runs the hooks but their bodies no-op.
Verification: yarn typecheck clean, yarn test 209/209.
Root cause of last session's "(intermediate value)() is undefined" at
ToolbarView.tsx:46:
use-between 1.x ships its own React-dispatcher proxy (ownDispatcher
in node_modules/use-between/release/index.esm.js:54-169) that
re-implements only useState, useReducer, useEffect, useLayoutEffect,
useCallback, useMemo, useRef and useImperativeHandle. It does NOT
implement useSyncExternalStore. When the inner state function of
useBetween(stateFn) calls useSyncExternalStore (directly or via
useExternalSnapshot / useUserDataSnapshot), React resolves the
dispatcher to use-between's proxy, finds .useSyncExternalStore
missing, and calls undefined() — that's the exact production crash
in Firefox. Chrome reports the same as
"dispatcher.useSyncExternalStore is not a function".
Neither the vite alias (790ad2b) nor the defensive renderer-method
guards (c35a2d4) could fix it — both addressed downstream symptoms
(stale dist / missing manager methods) but the dispatcher is upstream
of both. That's why every retry kept reproducing the same error.
Fix is structural: snapshot hooks (useUserDataSnapshot,
useIsUserIgnored, etc.) MUST run outside any useBetween scope. Three
re-applied migrations:
- useSessionInfo: snapshot read moved into the outer wrapper. The
inner useSessionInfoState (useBetween-shared) now contains only
use-between-safe hooks: useState, useMessageEvent, plain actions.
userFigure / userRespectRemaining / petRespectRemaining come from
useUserDataSnapshot() OUTSIDE useBetween, so useSyncExternalStore
installs against the real React dispatcher.
- useChatWidget.ownUserId: direct snapshot read. useChatWidget is
exported as `useChatWidget = useChatWidgetState` (NOT wrapped in
useBetween), so this hook never sat inside the broken scope — the
precautionary rollback was unnecessary in retrospect. Gains
session-change reactivity (e.g. reconnect under a different user id).
- AvatarInfoWidgetAvatarView Ignore/Unignore: useIsUserIgnored(name)
read directly in the component body. Same reasoning as
useChatWidget — never inside useBetween. The menu auto-flips
Ignore <-> Unignore while the popup is open.
Added regression guard at src/hooks/session/useSessionSnapshots.test.tsx
with two cases: (1) useSyncExternalStore inside useBetween throws,
(2) useSyncExternalStore outside useBetween in the same render works.
Pins the constraint so future migrations cannot reintroduce the bad
shape silently.
Verification: yarn typecheck clean, yarn test 209/209 (207 baseline
+ 2 new regression cases), no consumer surface changes — every
destructured field (userFigure, userRespectRemaining, respectUser,
petRespectRemaining, respectPet, chatStyleId, updateChatStyleId) is
still returned with the same name and shape.
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.