The `[App] prepare() start` console.warn was including the full SSO
ticket from `window.location.search`. SSO tickets are one-shot bearer
credentials — any leak (copied logs in a bug report, screen share,
malicious browser extension reading console output) grants
single-use access to the user's session. Replace the actual ticket
with a boolean.
The vendor chunk was a single ~1MB blob (react + tanstack-query +
framer-motion + jodit + emoji-mart + react-icons + howler + zustand +
json5 all merged), forcing every cold load to wait on the slowest of
those modules before the page could interactivate. Split it into
domain-specific chunks so HTTP/2 multiplexing can pull them in
parallel and CF can cache each independently:
- vendor-pixi (pixi.js + pixi-filters — when rollup actually splits;
currently inlined into the umbrella renderer chunk
because nitro-renderer is its sole importer)
- vendor-audio (howler)
- vendor-emoji (@emoji-mart — heaviest at ~430KB, only used in chat
so a longer-term win is making it lazy)
- vendor-editor (jodit + @react-page — admin-only news editor)
- vendor-react (react / react-dom / scheduler / error-boundary)
- vendor-motion / vendor-query / vendor-icons / vendor-state /
vendor-json5
- nitro-renderer-{avatar,communication,room,assets} — heaviest
renderer packages get their own chunks when imported directly
(the umbrella @nitrots/nitro-renderer still hosts the rest)
Also add a `<link rel=preconnect>` for challenges.cloudflare.com so
the Turnstile JS handshake doesn't pay an extra TLS round-trip on
the first paint.
Net effect: roughly the same total bytes shipped on a cold load, but
they fetch in parallel instead of sequentially, and a warm second
visitor only re-downloads the chunks whose code actually changed.
The CMS Inertia /client page now passes `&token=<uuid>&token_exp=<unix>`
on the iframe src so Nitro can persist the token to localStorage on first
boot. `App.tsx::prepare()` reads them from `window.location.search` and
calls `SetRememberLogin({ token, expiresAt })` when no remember-login is
already stored.
This wires up the existing reconnect flow: when the WS drops, the loop
in `tryRememberLogin()` (already in this file) POSTs the saved token to
`login.remember.endpoint` (defaults to `${api.url}/api/auth/remember`)
and uses the returned fresh SSO ticket to reconnect. Without this step
the localStorage stayed empty and the reconnect always fell through to
"Session expired" after a few retries because Arcturus clears
`auth_ticket` on first consume.
Server side: the CMS counterpart is in medievalshell/InertiaCMS
commit on djoohotel — adds the /api/auth/remember endpoint backed by
`users_remember_families` (UUID family + 30-day expiry + revoked flag).
`useCatalogActions` filter (useCatalog.ts:1042-1043) destructures and
returns `getNodesByOfferId` from the store along with the other action
methods. The filter contract test was stale — it asserted 11 keys while
the actual filter returns 12.
Add `getNodesByOfferId` to the expected keys list and to the fakeStore
mock so the assertion matches the live hook output.
Two pre-existing tsgo failures surfaced by the loading-screen redesign PR:
1. AvatarEffectsView: import loadGamedata via the umbrella
`@nitrots/nitro-renderer` instead of `@nitrots/utils`. The deep
sub-package alias only exists in vite.config.mjs; tsgo resolves
against node_modules, where only the umbrella is symlinked. Same
symbol — index.ts re-exports `* from '@nitrots/utils'`.
2. DraggableWindow: `useRef<HTMLDivElement>()` -> `useRef<HTMLDivElement>(null)`.
React 19 typings now require an initial value. Fixed once in
a39aa37, re-introduced by the merge in 03bebe4.
Loading screen overhaul:
- LoadingView: Nitro V3 logo flush top-left, loading.gif at viewport
centre, large progress bar (max 900px / 90vw, h-8, gradient + glow)
anchored bottom-centre with the percentage rendered inside the bar in
Poppins, plus a friendly stage label underneath. Logo + background +
progress bar colour overridable via renderer-config keys
(loading.logo.url, loading.background, loading.progress.color).
- App.tsx: wired a real loadingProgress (0->100) + loadingTask driven by
the boot pipeline: config init (10), renderer (20), per-warmup-task
bumps for AssetManager/Localization/AvatarRender/SoundManager (25->70),
session managers (78/85/92), Communication (98), ready (100). Each bump
carries a task label looked up via a new taskLabel(key, fallback)
helper so the Italian baseline ("Sto caricando il guardaroba",
"Connessione al server", ...) can be translated by editing
renderer-config; fallback keeps current strings if the key is missing.
- AvatarEffectsView: replace raw fetch(url).json() with
loadGamedata(url) so the effectmap root manifest (JSON5 with
// comments) parses correctly and supports the core/custom/seasonal
tier merge.
- fallbackToLogin: respect login.screen.enabled=false. When login is
disabled (SSO-only deployments), init failures now route to
showSessionExpired() (home + diagnostic) instead of rendering an empty
LoginView placeholder.
- scripts/write-asset-loader.mjs: the pre-React shell rendered into
#root before the JS bundle takes over was a light-blue login skeleton
(linear gradient + two grey rectangles) producing a visible flash
before the real loader appeared. Replaced with the same
radial-gradient the LoadingView paints — the handoff is now invisible.
- renderer-config.example: document the 13 loader keys so operators can
copy & translate.
Extends the "Adopted" snapshot pilot-sites table with ModToolsView
(useRoomUserListSnapshot driving the selected-user presence dot) and
adds a new row documenting the companion pattern: event-driven local
state for cases where there's no manager-snapshot to read from yet —
the AvatarInfoWidgetAvatarView Give/Remove Rights flow uses local
useState + useMessageEvent(FlatControllerAdded/Removed) + optimistic
bump. Same reactive shape as useIsUserIgnored but sourced from the
renderer event bus instead of a snapshot getter, so the next contributor
knows when to reach for which pattern.
ModToolsView
- Subscribe to useRoomUserListSnapshot so the selected user's
"still in room" state is reactive — green dot when the user is
in the current room, gray dot when they've left. Previously the
selection was a static capture at click time.
- Add an inline X to clear the selected-user slot without having
to click a different avatar.
- Report Tool button shows a count badge for OPEN tickets
(IssueMessageData.STATE_OPEN) so a new ticket arriving while
the panel is open is visible immediately. Caps display at 99+.
- Tooltip on the room-bound buttons explains why they're disabled
("Enter a room first") instead of showing a silent disabled state.
- Buttons grow their labels with `flex-grow text-start` so the
trailing dot / badge / clear-X sits flush right.
useModTools
- Fix splice(index) → splice(index, 1) in close{Room,RoomChatlog,
UserInfo,UserChatlog} — the omitted second argument was
silently deleting EVERY subsequent open panel, not just the one
being closed. Visible whenever a moderator had two or more panels
of the same kind open.
- Fix toggleUserChatlog reading from openRoomChatlogs instead of
openUserChatlogs — copy-paste typo made the toggle inconsistent
with the underlying state.
Replaced the cached `avatarInfo.targetRoomControllerLevel` derivation
with a local `controllerLevel` state that:
- starts from the popup-open snapshot
- listens to FlatControllerAddedEvent / FlatControllerRemovedEvent
filtered by avatarInfo.webID
- is optimistically bumped on `give_rights` / `remove_rights` clicks
so the moderate submenu flips immediately without waiting for the
server roundtrip
Same shape as the recent useIsUserIgnored migration: the popup now
auto-flips the button without forcing the user to close+reopen it.
The permission map shipped over the wire carries both
PermissionSetting.ALLOWED (value 1) and PermissionSetting.ROOM_OWNER
(value 2). Server-side, `Habbo.hasPermission(key)` calls
`Rank.hasPermission(key, isRoomOwner=false)`, whose implementation
at Rank.java:120 is:
setting == ALLOWED || (setting == ROOM_OWNER && isRoomOwner)
So a permission whose rank value is ROOM_OWNER is only granted when
the caller is the active room owner — Habbo.hasPermission(key) with
the default `false` therefore returns false for ROOM_OWNER entries.
The previous useHasPermission implementation (`> 0`) treated
ROOM_OWNER as unconditionally true, which would let a UI gate light
up even when the server would refuse the action. Real example from
the default seed: `acc_closedice_room` is ROOM_OWNER for rank_1..6
and ALLOWED only for rank_7 — under `> 0` the predicate was true for
every rank, diverging from the server behaviour.
Tighten useHasPermission to `=== 1` (ALLOWED only). For the genuine
"this is a ROOM_OWNER permission, combine with room session"
scenarios, code reaches for usePermissionValue(key) and checks
`=== 2 && roomSession.isRoomOwner` explicitly.
None of the 11 migrated consumers are affected by the tightening:
the keys they use (acc_supporttool / acc_anyroomowner /
acc_catalogfurni / acc_calendar_force / acc_staff_pick /
acc_ambassador) are all ALLOWED-only in the default seed.
Test refresh:
- useHasPermission('acc_supporttool') (value 1) stays true.
- useHasPermission('acc_anyroomowner') with value 2 in the mock
flips from true to false — the new contract.
- Other cases unchanged.
Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
214/214.
Replace the rank-level family (useHasRankLevel + STAFF_LEVELS
constants + useIsRank) with a permission-driven family that reads
straight from the deployment's `permission_definitions` table — no
more hardcoded SecurityLevel/rank-id thresholds on the client. A new
rank in permission_ranks or a re-shuffling of permission_definitions
rank columns now propagates through the UI automatically.
Renderer-side wire shipped in companion commit
feat/react19-event-bus@159c5eb (UserPermissionsMapParser + Event,
SessionDataManager.getPermissionsSnapshot + USER_PERMISSIONS_UPDATED).
New public API in `useSessionSnapshots.ts`:
- useUserPermissions(): ReadonlyMap<string, number> — full map
- useHasPermission(key): boolean — > 0 ⇒ true
- usePermissionValue(key): number — raw 1/2 or 0
- useIsAmbassador() now aliases useHasPermission('acc_ambassador')
- useUserRank() kept for PRESENTATIONAL use only (badge, prefix,
prefix color) — documented as such in JSDoc; gating must use
useHasPermission.
Dropped:
- src/api/nitro/session/RankLevels.ts (STAFF_LEVELS constants)
- useHasRankLevel / useIsRank exports (rank-based gating)
11 consumer migrations, each mapped to the right
`permission_definitions.permission_key`:
- ToolbarView (mod-only chat-input button) → acc_supporttool
- ChooserWidgetView (room-object id column) → acc_supporttool
- CatalogClassicView (admin toggles) → acc_catalogfurni
- CatalogModernView (admin toggles) → acc_catalogfurni
- FurniEditorView (panel access) → acc_catalogfurni
- CalendarView (force-open day) → acc_calendar_force
- InfoStandWidgetFurniView (mod buildtools btn) → acc_anyroomowner
- AvatarInfoWidgetPetView (canPickUp) → acc_anyroomowner
- FurnitureMannequinView (controller mode) → acc_anyroomowner
- YouTubePlayerView (isMyRoom) → acc_anyroomowner
- NavigatorRoomInfoView 'settings' → acc_anyroomowner
- NavigatorRoomInfoView 'staff_pick' → acc_staff_pick
Test refresh:
- useUserRank still tested for the presentational shape.
- useHasPermission: true for non-zero, false for absent/zero.
- usePermissionValue: raw 1 / 2 / 0 (default).
- useUserPermissions: full map exposure.
- Runtime promote test: mutate the permissions map + dispatch
USER_PERMISSIONS_UPDATED, assert useHasPermission flips false→true.
Locks in the new reactive contract.
Mock unchanged (the test sets getPermissionsSnapshot via vi.mocked).
Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
214/214 (213 prior + 1 net new for useUserPermissions). Backward
compatible: older Arcturus deployments don't ship the map → empty
snapshot → every gate is false → mod UI hidden (safe default).
Drop the SecurityLevel-named family (useIsModerator / useIsAdmin /
useIsCommunity / useIsPlayerSupport / useHasSecurityLevel /
useUserSecurityLevel) in favour of a rank-based family tied to the
operator's actual `permission_ranks` rows in the Arcturus DB:
- `useUserRank()` returns `{ id, name, level, badge, prefix,
prefixColor }` derived from the snapshot. Powered by the renderer's
extended IUserDataSnapshot (companion commit 87e67d5 on
feat/react19-event-bus).
- `useHasRankLevel(min)` replaces useHasSecurityLevel; consumers
pass a `permission_ranks.level` threshold from the deployment.
- `useIsRank(name)` matches `permission_ranks.rank_name` exactly.
To avoid bare integers in widget bodies, added a deployment-scoped
constants file at `src/api/nitro/session/RankLevels.ts`:
export const STAFF_LEVELS = {
MEMBER: 1, SUPPORT: 4, MOD: 5, SUPER_MOD: 6, ADMIN: 7
};
A deployment that re-numbers `permission_ranks` only edits this file.
Migrated all 11 consumer reads (same set as the earlier session's
useIsModerator migration plus the audit catch): ToolbarView,
CatalogClassicView, CatalogModernView, ChooserWidgetView,
CalendarView, YouTubePlayerView, FurniEditorView,
InfoStandWidgetFurniView, AvatarInfoWidgetPetView,
FurnitureMannequinView, NavigatorRoomInfoView. The
NavigatorRoomInfoView `staff_pick` permission was previously
`securityLevel >= COMMUNITY (7)` via the renderer-enum wrapper —
ported to `useHasRankLevel(STAFF_LEVELS.ADMIN)` because in the
default seed level 7 is Administrator, which is the actual rank that
gets the `acc_anyroomowner`-style permissions for staff-picking.
Tests refreshed under `useSessionSnapshots.test.tsx`:
- useUserRank surfaces the full metadata block;
- useHasRankLevel does `>=` against the threshold;
- useIsRank exact-matches against rank_name;
- a runtime promote (snapshot mutation + SESSION_DATA_UPDATED
dispatch) flips the result, locking in the reactive contract.
Mock extended only minimally — kept the SecurityLevel enum class for
any consumer outside the dropped family that still imports it.
Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
213/213. The Arcturus-side composer change (UserPermissionsComposer
appending the 5 extra fields) is staged but UNCOMMITTED on Arcturus
main (which has unrelated WIP); the wire is backward-compatible so
the React client works against both pre- and post-extension
emulators.
Build on the useIsModerator landing (532cb28c) along three axes:
1. Family. Extract `useHasSecurityLevel(min)` as the primitive,
backed by a fresh `useUserSecurityLevel()` raw-level reader. The
six SecurityLevel constants (1..9) deserve named wrappers so the
"show this only to X-and-up" pattern doesn't get re-derived ad-hoc
each time: shipped `useIsModerator` / `useIsPlayerSupport` /
`useIsCommunity` / `useIsAdmin` as one-line shims. Also added
`useIsAmbassador()` as a sibling — not derived from security level,
reads the boolean field on the snapshot directly.
2. Audit. The 532cb28c migration covered 6 React-render reads but
missed 5 more discovered by a follow-up grep:
- FurniEditorView (top-level `const isMod`)
- InfoStandWidgetFurniView (inline JSX, mod-only build-tools button)
- NavigatorRoomInfoView (3 reads in hasPermission(): isModerator
and securityLevel >= COMMUNITY for the staff-pick gate. The
userId read stays imperative — userId doesn't flip at runtime in
practice, no reactivity gain.)
- AvatarInfoWidgetPetView (inside useMemo with [roomSession] deps;
migrated and isModerator added to the deps so a runtime
promote/demote re-derives canPickUp without remount)
- FurnitureMannequinView (inside useEffect; same treatment — added
isModerator to the deps so the mode re-resolves on flip)
The remaining ~17 reads (CanManipulateFurniture,
AvatarInfoUtilities.populate*, useChatInputActions,
useFurnitureDimmerWidget / useFurniturePlaylistEditorWidget /
useFurnitureStickieWidget canModify checks, useCatalog admin
filter, useNavigator door-mode guard) are click-time / event-time
imperative — they read at the moment a user action fires, so a
reactive value would be cached at hook execution and stale by the
time the action runs. Leaving them on the synchronous manager read
is correct.
3. Test. Added four cases pinning the contract:
- useUserSecurityLevel returns the raw level.
- useHasSecurityLevel does `>=` against the threshold.
- Named wrappers map to the right constants (MODERATOR=5,
COMMUNITY=7, ADMINISTRATOR=8).
- **Reactive flip** — mutate the snapshot, dispatch the
SESSION_DATA_UPDATED event on the mock dispatcher, assert the
hook re-derives. Locks in the whole point of the snapshot
pattern (a static read would pass cases 1-3 but fail case 4).
Mock changes:
- Added SecurityLevel class (mirrors the renderer enum 0..9) so the
family wrappers resolve to actual numbers in jsdom — without it
`useIsModerator()` would call `useHasSecurityLevel(undefined)` and
the test would silently pass false-positives.
Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
213/213 (209 baseline + 4 new family/reactivity cases).
Adds a reactive `useIsModerator()` derived from
`useUserDataSnapshot().securityLevel >= SecurityLevel.MODERATOR`
(mirrors the renderer-side getter at SessionDataManager.ts:684), and
migrates the six React component-body reads of
`GetSessionDataManager().isModerator`:
- ToolbarView (mod-only chat-input button)
- CatalogClassicView, CatalogModernView (admin toggles in catalog
header)
- ChooserWidgetView (room-object id column visibility)
- YouTubePlayerView (room-control affordance — hook moved above the
`if (!isOpen) return null` early return so the hook order stays
stable when the player opens/closes)
- CalendarView (mod-only "open all" affordance)
UX impact: any future promote/demote that flips
SESSION_DATA_UPDATED now re-renders the mod-only UI live, instead of
requiring an F5. Imperative call sites
(AvatarInfoUtilities.populate*, CanManipulateFurniture,
RoomChatHandler) still read the manager directly — they run at click
time, not in a React render, so reactivity has no upside there.
Five of the six call sites are top-level component-body reads (no
early-return interaction). YouTubePlayerView has an
`if (!isOpen) return null` below the hook list, so the hook had to
move ABOVE it; same shape as the recent CatalogPurchaseWidgetView and
CatalogItemGridWidgetView fixes.
Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
209/209.
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.