feat(loading): redesigned loader with progress bar, task labels, configurable assets + perf(build): granular code-split + preconnect hint for cold-load speed + docs: PERFORMANCE.md — client + server recipe for the 4s cold load
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 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.
When a new CFH ticket arrives the moderator currently only finds out
by opening the ModTools launcher and looking at the Report Tool
counter. If the launcher is closed they have no signal — same
treatment friend requests already get on the People button next door.
Match the existing pattern: read `tickets` from `useModTools()`
(useBetween-shared, no extra subscription cost), filter to
state===1 (OPEN), and render a <LayoutItemCountView> over the
ToolbarItemView in absolute-positioned relative wrapper. Same
positioning as the friend-requests badge (-right-1 -top-1 z-10
pointer-events-none).
Gated on `isMod` so non-mods don't compute the filter or render
the wrapper — and since useModTools is a useBetween singleton its
event listeners only register once across the whole app regardless
of consumer count.
Applied to both toolbar layouts (desktop and mobile, lines ~272 and
~382) so the badge follows the user across breakpoints.
The Button base class string declared `inline-block`, even though the
component renders through <Flex center> which passes display="flex".
Both `inline-block` (from the Button base class) and `flex` (from
Flex) ended up as classes on the same element. Tailwind v4's emitted
stylesheet orders display utilities in source order — the
unfortunate result on this build was that the icon kept rendering at
the baseline (top-left of the line box) while the text settled
centered via text-center, i.e. inline-block layout was winning.
Resolve the ambiguity by passing display="inline-flex" to Flex
explicitly. Now there's only ONE display utility on the element
(inline-flex), and Flex's center=true still adds items-center +
justify-center. Strip the now-conflicting `inline-block` /
`align-middle` from the Button base class string — flex's
items-center already handles vertical alignment.
text-center is kept so multi-line label buttons that relied on it
still render centered (it's a no-op for flex-row layout otherwise).
No call-site changes needed — pure CSS-equivalence fix on a single
common component.
The Context strip at the top of the launcher showed which room the
mod is currently observing — green pill + door icon when in a room,
zinc strip when not. In practice it's noise: the Room Tool / Chatlog
Tool buttons right under it already gate on the same in-room state
(disabled when not in a room) and carry their own tooltip explaining
that. The strip duplicated the signal without adding actionable info.
Remove the section, the now-unused FaDoorOpen / FaDoorClosed imports,
and the matching `modtools.window.section.context` /
`modtools.window.context.room` locale keys (from both the runtime
UITexts.json and the versioned UITexts.example template).
User reported empty fields (Email, Last Purchase, Lock Expires, Banned
Accs, Abusive CFHs) showing what looks like a music-note glyph next to
the label. They aren't censored — they're genuinely empty (a rank-7
Administrator account has none of that data populated). The em-dash
"—" (U+2014) used as the placeholder doesn't have a glyph in the
Habbo pixel font (Volter / Volter-Goldfish), so the engine falls
through to a placeholder glyph that on some font stacks looks like a
music note.
Two-part fix in ModToolsUserView (Field), ModToolsIssueInfoView
(Field) and ModToolsRoomView (owner fallback):
1. Replace the U+2014 em-dash with a plain ASCII `-`. Hyphen-minus is
safely in Volter, so the placeholder renders correctly across the
whole client.
2. The `value || placeholder` guard is now `(value || value === 0)`.
Stat fields whose value is the literal number 0 — a clean account
with cfhCount=0, banCount=0, cautionCount=0 — were rendering the
placeholder because 0 is falsy. Treat 0 as a real value.
Also dropped the `italic` class on the placeholder span — the
hyphen does the job on its own and italic on a single-character
glyph in a pixel font was making it look like a tilted line.
The launcher panel was a flat stack of four buttons (Room Tool, Chatlog
Tool, selected-user + presence dot inline, Report Tool) with no visual
hierarchy. The selected-user row was particularly cramped — name, the
2px dot and the 4×4 close-X all crammed into a single button row, easy
to misclick.
Reorganize into four logical groups, each with a small uppercase
section label:
Context — gradient strip (emerald when in a room, zinc when not)
showing "Room #<id>" or "Enter a room first" with a
matching door icon. Source of truth for "what is the
mod observing right now"; both Room Tool and Chatlog
Tool feed from the same currentRoomId.
Room — Room Tool + Chatlog Tool stacked. Both still gate on
isInRoom; the disabled state now reads from a single
flag instead of repeating `currentRoomId <= 0`.
User — When a user is selected: a card with the presence dot
(emerald = still in room, zinc = left), the username at
a real legible size, a bigger close button, plus a
dedicated "Open Info" button to toggle ModToolsUserView.
Splitting the click target from the close action removes
the misclick footgun.
When no user is selected: a dashed-border empty state
with a FaUserSlash icon and the "Select a user" hint —
reads as a clear "no selection" instead of an active
button you can't press.
Reports — Report Tool with the open-ticket badge. Badge gets a 2px
rose halo box-shadow so a new ticket pulses into view
instead of competing with the button background.
Locale keys added under modtools.window.section.* and
modtools.window.context.room / modtools.window.user.open_info, in both
the runtime UITexts.json and the versioned UITexts.example template.
The "Open Info" button label is a fix in flight — the old layout
overloaded the username row to also open user info, with no separate
label. The new explicit button gets its own key so the action is
unambiguous (the previous version mislabelled the button as "Mod
Action", which is actually a different sub-panel).
typecheck + vitest 214/214 + JSON validation all clean.
ModToolsChatlogView and CfhChatlogView were on the useNitroQuery
pattern. Symptom: the card opens, the spinner spins, but the data
never arrives — even when the server is correctly answering with
ModToolRoomChatlogComposer (header 3434) and GetCfhChatlogComposer
(607). Both header IDs match the renderer's Incoming map, both server
handlers gate only on ACC_SUPPORTTOOL and reply unconditionally when
the room/issue lookup succeeds. So the request DOES go out and the
response DOES come back — but useNitroQuery's listener (registered
via `new (ParserCtor)(callback)` + `registerMessageEvent`) isn't
delivering the event to the React side here.
ModToolsUserChatlogView already uses the plain `useMessageEvent` +
`useEffect(sendComposer)` pattern and works on this same setup, so
align the two broken views with it. Keep the loading-spinner empty
state introduced yesterday so the user still gets visible feedback
while the response is in flight.
This sidesteps useNitroQuery for these two cases rather than fixing
it in place — the underlying createNitroQuery + listener registration
plumbing still works for OfferView, useUserGroups, useClubOffers,
useSellablePetPalette, useMarketplaceConfiguration, useClubGifts,
CatalogLayoutRoomAdsView, so the regression is specific to these two
parsers and worth investigating separately. Filed as a follow-up.
ModToolsChatlogView returned null whenever roomChatlog was undefined
— including the entire window between click and server response (up
to a 15-second NitroQuery timeout). Result: clicking the Chatlog
button in the launcher or in Room Info appeared to do nothing at all
on any session where the server reply was slow or the accept-filter
correlation didn't match.
The other two chatlog wrappers (ModToolsUserChatlogView,
CfhChatlogView) already render a spinner while data is loading after
yesterday's redesign — this view was the one I missed.
Apply the same fix: always render the NitroCardView, and show the
FaSpinner loading state inside until useNitroQuery resolves.
The ModTools template refresh introduced ~80 hardcoded English strings
(labels, placeholders, tooltips, empty-state copy, button text). Move
every one of them onto the modtools.* namespace and read via
LocalizeText so the panels translate alongside the rest of the client.
UITexts.example (versioned template) extended with the full set:
modtools.window.* Launcher box (toolbar item, tools,
selected-user state, ticket count)
modtools.userinfo.* User info card — already had the
legacy modtools.userinfo.{userName,
cfhCount, …} keys from before; added
refresh tooltip, presence pill labels
(in_room / online / offline with
matching .title tooltips), section
headings, action button labels, stat
card labels
modtools.roominfo.* Room info card — title, refresh, loading,
owner pill (here/away + tooltips), stat
labels, action buttons, moderate panel
heading + checkboxes + textarea
placeholder + caution/alert CTAs
modtools.user.message.* Send-message dialog (recipient label,
body label, placeholder, char counter,
empty state, send button)
modtools.user.modaction.* Mod Action form — header, sanctioning
label, 3-step section titles, select
placeholders, message label + optional
note, message placeholder, preview
heading, default/apply buttons, every
sendAlert error message
modtools.user.visits.* Room visits — title, header strip
heading, entry count (singular/plural),
empty state, column headers, visit
button + tooltip
modtools.user.chatlog.* User chatlog — title (with username
variant), loading state
modtools.room.chatlog.* Room chatlog title
modtools.chatlog.* Shared ChatlogView — column headers,
empty state, room-separator Visit/Tools
buttons
modtools.tickets.* Tickets window — title, tab labels
(open/mine/picked), column headers,
empty states, action buttons (pick/
handle/release), issue resolution
window (title, label, details heading,
field labels, chatlog toggle, resolve-as
heading, resolution buttons, release
back to queue), CFH chatlog title
The same 130 entries land in Nitro-Files/.../UITexts.json (runtime).
Both files validate as JSON. The runtime additions take effect on
next client reload; the template additions ship the strings to any
fresh deploy.
Notes:
- The MOD_ACTION_DEFINITIONS sanction names ("Alert", "Mute 1h",
"Ban 18h" …) stay hardcoded for now since they're keyed off
server-side action IDs that don't have an existing locale key
convention. Worth a follow-up if needed.
- help.cfh.topic.* keys (CFH topic display names) are already in
ExternalTexts.json and were already read via LocalizeText, so
they didn't need changes.
typecheck + vitest 214/214 + lint:hooks all clean.
Applies the visual language introduced in ModToolsUserView yesterday
to every other ModTools window. The design tokens used consistently:
emerald — present in current room / positive state
sky — online / informational / current selection
zinc — neutral / disabled
amber — warn-level (CFH, alerts, cautions)
rose — danger (bans, releases, abusive)
Files redesigned:
ModToolsRoomView
Identity header with FaDoorOpen, room name + ID, owner-present pill
(emerald/zinc), manual refresh button. Stat strip: user count (sky)
+ clickable owner name (zinc) opening user info. Quick actions
(Visit / Chatlog) in a 2-col grid. Moderate panel collapsed into an
amber-tinted card with the 3 toggles + textarea + two CTAs (Send
Caution=danger, Send Alert=warning). CTAs disabled until a message
is typed AND the room info has loaded.
ModToolsUserModActionView
Numbered 3-step form (CFH topic → sanction → optional message).
Live preview row showing the chosen topic + sanction as tone-coded
pills (amber/sky/rose/orange/fuchsia/zinc by action type). Primary
CTA = Default Sanction, success CTA = Apply Sanction, both
disabled until the required selections are made.
ModToolsUserSendMessageView
Recipient header with FaEnvelope and the username, autofocused
textarea, char counter, single full-width Send button gated on
non-empty message.
ModToolsUserRoomVisitsView
Header strip with entry count badge, three-column grid (time / room
name / visit button), monospace timestamps, hover row highlight,
empty state with FaDoorOpen icon.
ModToolsUserChatlogView / ModToolsChatlogView / CfhChatlogView
Loading state with spinner instead of returning null. Cards grow to
min-w-[460px] max-w-[520px] max-h-[500px] for usable chatlog area.
ChatlogView
Replace Bootstrap-ish striped table with a CSS grid (60px / 120px /
1fr). Room-info separator rendered as a sky card with Visit/Tools
pill buttons. Per-row hover + even-row tint; highlighted rows
(hasHighlighting) get an amber wash. Username is a button opening
user info via existing link event. Empty state with FaCommentDots.
ModToolsTicketsView
Tabs get icons (FaListUl / FaUserCheck / FaCheckSquare) and inline
count badges (amber/sky/zinc) so the moderator sees the queue size
at a glance. ticket bucket filtering memoized off the tickets array.
ModToolsOpenIssuesTabView / MyIssuesTabView / PickedIssuesTabView
Same CSS grid table style. Category renders as a tone-coded pill
(Open=amber, Mine=sky, All picked=zinc). Action buttons get icons
(FaHandPointer Pick, FaTools Handle, FaSignOutAlt Release). Empty
state with FaInbox.
ModToolsIssueInfoView
Card header with category + topic pills. Details rendered as a dl
grid instead of a striped table. Caller / Reported names as inline
link buttons with external-link icon. Chatlog toggle is full-width
secondary. Resolution buttons in a 3-col grid with intent colours
(success=Resolved, dark=Useless, danger=Abusive) + a separate
Release-to-queue button on its own row so it isn't confused with
the resolutions.
No behaviour changes — all composers, message events, parent state
hookups, and sanction validation paths are unchanged. This is purely
a presentation pass. typecheck + vitest 214/214 + lint:hooks all
clean.
Replace the flat striped table with a structured layout that surfaces
the moderation signal at a glance:
Identity header
Username + ID + classification, presence pill (In room / Online /
Offline) with colour coding (emerald / sky / zinc) and a matching
dot, plus a manual refresh button. The pill source-of-truth is
useRoomUserListSnapshot for the "in room" case (reactive) falling
back to userInfo.online — tooltip discloses which path produced
the value.
Stat strip
Four counter cards in a single row — CFH, Cautions, Bans, Trade
locks — tinted warn (amber) or danger (rose) when value > 0, neutral
(zinc) when zero. Big tabular-nums numbers so the moderator sees a
problem account immediately without parsing rows.
Sectioned body
Account / Activity / Sanctions / Trading as labelled dl groups
(grid-cols-[auto_1fr]) replacing the 14-row striped table. Missing
values render as a dim em-dash instead of an empty cell.
Action bar
2×2 button grid with react-icons/fa glyphs (FaCommentDots,
FaEnvelope, FaDoorOpen, FaGavel). Mod Action keeps variant="danger"
so the destructive action stands out from the three info actions
(variant="secondary").
No behaviour changes — the same composer / event listeners /
sub-views are wired up; this is a presentation rewrite. Card grows
to min-w-[420px] max-w-[480px] to fit the new layout without
horizontal scroll on mod laptops.
ModToolsUserView used a one-shot ModeratorUserInfoData snapshot taken at
panel-open time. Two consequences:
- The online/offline icon (rendered next to userName) was frozen on the
value at open. If the target user joined/left while the panel stayed
open, the icon kept lying.
- After the moderator applied a sanction via ModToolsUserModActionView
the user info window stayed open with stale cfhCount / banCount /
cautionCount / lastSanctionTime; you had to close and reopen to see
the bump.
Fix shape mirrors the ModToolsView selected-user dot from yesterday:
- Read useRoomUserListSnapshot in the component (outside any useBetween
scope — useSyncExternalStore constraint). If the target user is in
the current room they're online; fall back to userInfo.online
otherwise. Tooltip surfaces which path produced the value.
- Subscribe to ModeratorActionResultMessageEvent (parser carries
userId + success). On a successful action targeting THIS userId,
re-send GetModeratorUserInfoMessageComposer so the table re-fetches.
Upstream Buy/Search fix added getNodesByOfferId to the
useCatalogActions filter but didn't refresh the actions-shape contract
test in useCatalog.filters.test.tsx. Add the key so the test reflects
the current public surface.
Upstream V3.5.0 introduced a useRef<HTMLDivElement>() call with no
initial value. TS6 (and the tsgo preview compiler) now require an
explicit initial value for useRef typed against a DOM element. Pass
null to match the React 19 RefObject<HTMLDivElement | null> shape.
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.
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.
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.
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).
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.