Two ephemeral packets: ConsoleTyping (client->server, 4087) relayed by
the emulator to the peer as FriendTyping (server->client, 4088). Client
sends typing-start once per burst + stop on idle/send/empty; shows
"X is typing..." with a 6s auto-expire. 1:1 only, no DB.
2-state read receipts (sent / read) via two custom packets:
MarkConsoleRead (client->server, header 4085) sent on thread focus,
relayed by the emulator to the peer as ConsoleReadReceipt (server->
client, header 4086). Client marks own messages READ and renders
checkmarks. Live-relay only (no DB table / login batch) since the
client doesn't persist message history across sessions.
Store messages to offline friends in messenger_offline (capped inbox),
replay on login via the existing FriendChatMessageComposer with an
"offline" extraData marker, and render a "sent while offline" tag in
the client thread. No new packets; emulator + small client touch.
Bite-sized TDD plan across all three codebases for the friend-groups
feature: 4 client->server category packets (renderer composers +
Arcturus handlers), DB persistence (reusing existing
messenger_categories + messenger_friendships.category), server-
authoritative re-push via existing MessengerInit/UpdateFriend
composers, and client store actions + chip-filter UI + manager modal +
per-friend assign control + pure filter helper with tests.
Design doc for upgrading the room-settings Base tab from the cramped
horizontal two-column rows to a stacked-label layout matching the
sibling Access tab. Also gitignore the .superpowers/ brainstorm dir.
Combines spec + 5-task plan into a single doc for faster execution.
Branch: feat/navigator-p2-query (forked from feat/navigator-modernization
P1 tip). Migrates search from event-driven imperative state to
useNitroQuery with cache per [tabCode, filter], invalidator on
FlatCreatedEvent + RoomSettingsUpdatedEvent, accept-filter that rejects
mismatched-tab server pushes.
Key API changes: useNavigatorActions DELETED (sendSearch +
reloadCurrentSearch gone); useNavigatorData no longer returns
searchResult; navigatorUiStore adds currentTabCode + currentFilter +
setTab + setFilter; new useNavigatorSearch hook returns the
{ searchResult, isFetching, refetch } triple.
Bite-sized tasks with exact code blocks:
- Task 1: navigatorUiStore (TDD, 14 cases)
- Task 2: useDoorState extraction (TDD, 11 cases incl. dual-subscription filters)
- Task 3: useNavigatorStore internal closure (move all non-door listeners + new actions)
- Task 4: 3 filters + barrel rewrite + smoke test
- Tasks 5-8: 13 consumer migrations (atomic commit)
- Task 9: delete useNavigator.ts + final verification (typecheck/test/lint/manual)
Each commit is a green stopping point except Task 4 step 8 (intentional
intermediate-broken commit while consumers still import the removed
useNavigator export from the barrel). Tasks 5-8 land atomically to close
that gap in the next commit.
First of four planned phases reworking the Navigator on a clean
origin/Dev base. P1 is pure refactor (zero visible change): split
the 492-line useNavigator god-hook into wired-tools-style filters
(useNavigatorData / useNavigatorUiState / useNavigatorActions),
extract door lifecycle to useDoorState under src/hooks/rooms/widgets,
hoist the 9 local useState in NavigatorView into a Zustand
navigatorUiStore, migrate all 13 active consumers, and delete the
shim.
The Zustand UI store uses per-key selectors in useNavigatorUiState
to match createNitroStore's documented convention ("subscribe to
specific slices only").
Spec also anchors the visual rework (P4) target so architecture
decisions in P1 align with where we are heading: rich empty states,
card hover-reveal, saved-search chip row, filter intent chips,
sticky section headers, skeleton loaders.
Out of scope for P1 (each gets its own future spec): TanStack Query
migration of search (P2), reactive favourites/snapshot pattern (P3),
virtualization + empty states + persistence + chips (P4), Form
Action on search input (P6), WidgetErrorBoundary wrap (P5,
parallel-eligible).
Standalone performance guide for the Nitro V3 client, covering both
sides of the cold-load story so a deployer doesn't have to cross-
reference two repos to get from 60-90 s down to 4 s.
Sections:
1. Three Nitro-side changes that matter (code split, LoadingView with
real progress, remember-token URL capture)
2. Vite manualChunks — why vendor-first ordering matters, why pixi
stays inlined, expected chunk sizes after yarn build
3. LoadingView state model + the 12-stage progress table + the
pre-React shell template in scripts/write-asset-loader.mjs
4. Remember-token capture from URL → SetRememberLogin, with DevTools
verification of nitro.auth.remember localStorage entry
5. nginx gzip (the single biggest win at ~17x for JSON5) + 30-day
cache headers on gamedata + try_files manifest fallback
6. Windows + IIS equivalent — URL Rewrite + ARR reverse proxy to
Node, Dynamic Compression toggle, web.config snippets,
trade-offs (CPU cost, JDBC quirks, shared hosting caveat)
7. End-to-end verification probes — chunks present, gzip on, dir
fallback works, progress bar renders, remember-token persisted
Cross-references medievalshell/InertiaCMS:docs/PERFORMANCE.md for the
matching CMS-side application config (SSO TTL, /api/auth/remember
endpoint, migrations).
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.
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.
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.
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.
The Jest-style __mocks__/ folder added one indirection for a single
file. Move the stub to src/nitro-renderer.mock.ts at src/ root next to
test-setup.ts, drop the folder, repoint the vitest alias, and update
the lone test that imports the helpers directly (useDoorbellState).
Same behaviour, one fewer directory.
Eliminate the parallel `tests/` tree. Each `*.test.ts` / `*.test.tsx`
now sits in the same directory as the module it covers, mirroring its
filename (`Foo.ts` ↔ `Foo.test.ts`). The renderer-SDK mock used by
component / hook tests moves to `src/__mocks__/nitro-renderer.ts` and
the Vitest setup file becomes `src/test-setup.ts` — both still wired
through `vitest.config.mts` exactly as before, only the paths changed.
All 13 suites + 178/178 cases still pass. The production build is
unaffected: rollup only follows imports from `src/index.tsx` and never
crosses into `.test.ts` files, so test code is naturally tree-shaken
out of the bundle. `yarn build` output is byte-for-byte the same on
the user-facing chunks.
tsconfig drops the now-redundant `tests` include entry. CLAUDE.md
'Layout convention' replaces the old `tests/` row with three rows
documenting the new co-located convention, the `__mocks__/` directory
and the `test-setup.ts` entry; ARCHITECTURE.md picks up the same
update. The 'DO NOT CHANGE' qualifier on the layout is preserved —
this rewrite IS the change, decided deliberately to make tests a
first-class part of the source tree rather than a sibling project.
The umbrella boundary on RoomWidgetsView caught any widget crash but
unmounted every sibling along with the failing widget — a single bad
parser in ChatWidget would dark out the avatar info, chat input,
doorbell and all furniture overlays until the next remount.
Wraps each of the 13 direct children of RoomWidgetsView (AvatarInfo,
Chat, ChatInput, Doorbell, RoomTools, RoomFilterWords, RoomThumbnail,
FurniChooser, PetPackage, UserChooser, WordQuiz, FriendRequest, plus
the FurnitureWidgets umbrella) and each of the 20 sub-widgets inside
FurnitureWidgetsView in its own named WidgetErrorBoundary. A crash
now silently logs through NitroLogger with the widget name and
renders null for that one widget; every sibling keeps rendering.
The outer umbrella stays as defense-in-depth for the wrapper div and
the listener setup in RoomWidgetsView itself.
Closes the "Per-widget WidgetErrorBoundary wrapping" roadmap item;
updates CLAUDE.md and docs/ARCHITECTURE.md accordingly.
LayoutFurniImageView and LayoutAvatarImageView both fired async image
generation (TextureUtils.generateImage / SDK resetFigure callback) and
wrote the result back through setImageElement / setAvatarUrl with only
an isMounted / isDisposed component-level guard. If props changed
twice in rapid succession the older request could resolve last and
overwrite the newer image with a stale one, visible on slow
connections or fast scroll over grids of unique items.
Each effect now captures `const requestId = ++requestIdRef.current`
and threads it into every async callback (TextureUtils.generateImage,
the SDK's resetFigure listener, the cache write). When a callback
fires it bails if `requestIdRef.current !== requestId` — only the
latest effect's callbacks make it past the gate. A stale ENDED for
the previous figure now leaves the cache and the rendered url
unchanged.
Moves both bugs from "Open" to "Recently fixed" in
docs/ARCHITECTURE.md.
Two independent useNitroEvent listeners updated landingViewVisible from
RoomSessionEvent.CREATED and ENDED with no notion of which session was
active. Under flaky websocket reconnects the events can land out of
order: a stale ENDED for the previous room arrives after CREATED for
the new one, flips landingViewVisible back to true, and the user is
left at the hotel view inside a room (or vice versa) until the next
room change.
Folds both events into one useNitroEventReducer that carries the
tracked sessionId. CREATED sets the id and closes the landing view;
ENDED is applied only when its event.session.roomId matches the
tracked id (or no session is active) — otherwise it's a stale ENDED
for a previous session and is ignored. The reducer companion is the
existing useNitroEventReducer from src/hooks/events, so no new
infrastructure.
Moves the entry in docs/ARCHITECTURE.md from "Open" to "Recently
fixed".
Replaces every direct call to the deprecated useCatalog() shim with the
targeted filter(s) (useCatalogData / useCatalogUiState / useCatalogActions).
Each consumer now subscribes only to the slice it actually reads, which
restores React Compiler memoization and stops catalog-wide re-renders
whenever an unrelated key changes.
Removes the now-unused useCatalog shim from useCatalog.ts and the
shim-specific case in tests/useCatalog.filters.test.tsx. The "all four
hooks observe the same singleton" test becomes "all three filters", since
there is no shim left to compare against. useCatalogFavorites swaps its
internal useCatalog() call for useCatalogUiState() (currentType lives in
the UI slice).
Updates CLAUDE.md and docs/ARCHITECTURE.md to reflect that all 48
historical consumers are migrated and the shim is gone.
Vitest: 162/162 (was 163 — minus the deprecated-shim contract case).
Completes the useCatalog decomposition. After the previous commit
extracted the pure helpers, this one splits the singleton-via-useBetween
store into three slice-specific entry points and migrates a handful of
consumers as proof.
`src/hooks/catalog/useCatalog.ts`
- Internal `useCatalogState` → renamed to `useCatalogStore` and is no
longer exported. The full return shape is unchanged so callers that
still go through the shim see the exact same object.
- Three new exports built on top of the same `useBetween` instance:
- `useCatalogData()` — server-driven read-only slice (rootNode,
offersToNodes, currentPage, currentOffer, frontPageItems,
searchResult, roomPreviewer, isBusy, catalog localization
version, Builders Club counters + timers).
- `useCatalogUiState()` — UI ephemeral state + writers
(isVisible, pageId, previousPageId, currentType, activeNodes,
navigationHidden, purchaseOptions, catalogPlaceMultipleObjects,
plus every `set*` writer including the ones that mutate the
data slice on user-driven selection).
- `useCatalogActions()` — imperative operations only
(openCatalogByType, toggleCatalogByType, activateNode,
openPageBy{Id,Name,OfferId}, requestOfferToMover,
selectCatalogOffer, getNodeBy{Id,Name},
getBuilderFurniPlaceableStatus).
- `useCatalog` is kept as a deprecated shim that returns the full
historical surface, so the 48 existing consumers compile and run
unchanged.
Pilot consumer migrations (3 of 48):
- `CatalogBuildersClubStatusView` — Data (furni counters, seconds
timers) + UiState (currentType).
- `CatalogBreadcrumbView` — UiState (activeNodes) + Actions
(activateNode).
- `CatalogNavigationItemView` — UiState (currentType) + Actions
(activateNode).
Tests: `tests/useCatalog.filters.test.tsx` (5 cases).
`useBetween` is mocked via `vi.hoisted` so the four hooks share one
deterministic fake store — rendering the real `useCatalogStore`
would mount ~30 useState calls + open a fresh RoomPreviewer +
subscribe to a dozen renderer events, which is more than these
contract tests need.
- `useCatalogData` exposes exactly its read-only keys.
- `useCatalogUiState` exposes exactly its UI keys + setters.
- `useCatalogActions` exposes exactly its imperative ops (and
explicitly NOT data fields — proves no leak across slices).
- Singleton identity: callbacks read through the shim are `===` to
the ones read through the slices.
- Shim surface: the historical key set is still present so
un-migrated consumers don't silently break.
Suite: 163/163 (was 158/158). `yarn typecheck` green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First half of the proposed `useCatalog` decomposition. The 1036-line
god-hook still owns the singleton-via-useBetween, but the pure logic
it used to define inline now lives in a dependency-free module so it
can be tested in isolation and reused by future split-out hooks
(`useCatalogData` / `useCatalogUiState` / `useCatalogActions` when
those land).
New module: `src/hooks/catalog/useCatalog.helpers.ts` (222 LOC).
- `normalizeCatalogType(type?)` — coerce the optional catalog type to
`NORMAL` / `BUILDER`. Was a 5-line `useCallback` with an empty
dependency array.
- `getOfferProductKeys(offer)` — produces the canonical
`productType:id:classId` and `productType:class:className` keys
for the resolved-offer cache.
- `findNodeById` / `findNodeByName` — DFS over the catalog tree,
root explicitly excluded so callers can't select the synthetic
root by mistake.
- `getNodesByOfferIdFromMap(offerId, map, onlyVisible)` — extracted
from the closed-over `getNodesByOfferId`. The `onlyVisible`
fallback to the full bucket when nothing visible remains is
preserved.
- `buildCatalogNodeTree(NodeData)` — pulled out of the
`CatalogPagesListEvent` reducer. Builds the tree and the offerId
index in one pass; the caller now does `const { rootNode,
offersToNodes } = buildCatalogNodeTree(parser.root)` instead of
carrying an inline recursive walker + a local map.
- `resolveBuilderFurniPlaceableStatus(input)` — the placement
decision tree as a pure function. The hook keeps the
`GetRoomEngine` / `GetSessionDataManager` reads that count
non-self, non-moderator visitors (only when the subscription has
expired) and forwards the resulting `visitorCount` into the
helper, so the previous early-exit semantics are preserved.
`useCatalog.ts` now imports these and removes ~140 lines of inline
copies. Net hook size: 1036 → 961 LOC. Behavior unchanged.
Tests: `tests/useCatalog.helpers.test.ts` (34 cases).
- `normalizeCatalogType` (4) — BUILDER pass-through, NORMAL
pass-through, undefined/empty fallback, unknown string fallback.
- `getOfferProductKeys` (5) — both keys, id-only when classId<0,
class-only when className empty, no-product short-circuit,
empty productType short-circuit.
- `findNodeById` (5) — null input, root exclusion, immediate child,
grandchild, miss returns null.
- `findNodeByName` (2) — match by name + root exclusion, miss.
- `getNodesByOfferIdFromMap` (5) — empty map, raw bucket pass-through,
visible-only filter, fallback when no visible remain, miss.
- `buildCatalogNodeTree` (3) — root depth=0 + empty offer map for a
leaf-only root, DFS traversal tracks offer→nodes across branch
and leaf, child.parent === root.
- `resolveBuilderFurniPlaceableStatus` (10) — missing offer,
not-in-room, owner happy path, non-owner without fallback,
guild admin with time, furni limit reached, shared-pool override
ignoring the limit, expired+blocked-by-visitors flag,
expired+visitor count > 0, expired+empty room is okay.
To support the placement-status test the renderer mock gains real
numeric values for `RoomControllerLevel` (NONE..MODERATOR) and
`RoomObjectCategory` (MINIMUM..MAXIMUM); the previous string-keyed
Proxy stubs made `controllerLevel >= GUILD_ADMIN` evaluate to NaN.
Suite: 158/158 (was 124/124). `yarn typecheck` green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundations for widening Vitest coverage past the pure-helper subset.
The real `@nitrots/nitro-renderer` eagerly loads Pixi v8 and the full
Habbo message parser/composer registry at module-import time, which
jsdom cannot host: any `tests/**` file that transitively pulled a
renderer symbol would throw before a single assertion ran. That's
why the existing 8 suites all stuck to pure modules imported by
concrete path and used `import type` for renderer-side names.
Add a stub at `tests/mocks/renderer-mock.ts`, aliased over the package
via `vitest.config.mts`. It exports:
- Explicit behavioral stubs for the symbols tests actually exercise:
`NitroLogger`, `GetEventDispatcher`, the `mockEventDispatcher`
helper with `addEventListener` / `removeEventListener` /
`dispatchEvent` / `hasListeners`, and `RoomSessionDoorbellEvent`
(signature matches the real `(type, session, userName)` to keep
tsgo happy).
- String-keyed Proxy enums for `NitroEventType`, `RoomObjectCategory`,
`AvatarFigurePartType`, etc. — each access returns a stable unique
string so dispatch and listener agree.
- Lightweight `class StubClass {}` placeholders for the ~30 Pixi and
gameplay classes the `src/api/*` barrel touches at import time
(`NitroAlphaFilter`, `NitroContainer`, `EventDispatcher`, …).
Keeps the cascade from throwing without simulating behavior tests
don't care about.
- Singleton getters (`GetAssetManager`, `GetCommunication`,
`GetSessionDataManager`, …) returning a chainable Proxy so deeply
nested `GetX().y.z(…)` access evaluates to no-op proxies.
Pilots on top of that layer (each one designed to catch a different
class of regression):
- `tests/WidgetErrorBoundary.test.tsx` (4 cases) — happy path,
default silent fallback + `NitroLogger.error` call, custom
fallback node, default `unknown` widget name.
- `tests/useDoorbellState.test.tsx` (7 cases) — initial empty state,
append on `RSDE_DOORBELL`, dedup duplicate names, remove on
`RSDE_ACCEPTED` / `RSDE_REJECTED`, ignore stale events for
never-pending users, full unsubscribe on unmount.
Suite count now 124/124 across 10 files (was 113/113 across 8).
`yarn typecheck` still green.
Docs: CLAUDE.md's Vitest row and "Where everything lives" pointer
updated; `docs/ARCHITECTURE.md` Tests section now lists the new
suites + a description of what the mock layer covers, and the
"Wider Vitest coverage" entry in the next-steps list is reframed
from "needs a renderer mock" to "pick the next adopter".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CLAUDE.md
- TL;DR mentions the duckietm PR #126 cherry-pick (UserAccountSettings,
wear-badge popup fix) and the sirv-based dev asset serving so a fresh
session knows what's living on top of upstream main.
- New patterns section for the bootstrap.ts configuration pre-init
and the nitroAssetsServer Vite plugin, with a pointer to the
.gitignore note explaining why public/{nitro-assets,swf} symlinks
are a trap.
- "What's wired up" table gets two rows: Form Actions, and the PR #126
pickup.
- "Where everything lives" gets entries for UserAccountSettingsView,
the persistAccessTokenFromPayload helper, the asset middleware, and
the bootstrap pre-init call.
docs/ARCHITECTURE.md
- Recently fixed: adds the useAvatarEditor PETS/MISC paletteID
null-pointer that surfaced when the editor was opened.
- New Bonus subsections describing the boot-time orchestration in
bootstrap.ts, the dev asset serving via sirv (and why symlinking
under public/ is the wrong move on Windows), and the upstream
feature catch-up via PR #126.
No code changes in this commit — pure documentation refresh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three top-level files brought in sync with the work landed on
feat/react19-modernization:
- CHANGELOG.md gets a 'React 19 Modernization Phase 2 (2026-05-12)'
section spanning all four pattern groups (event-state companions,
TanStack queries on the catalog layer, god-hook splits in the
doorbell + singleton-filter styles, Pixi v8 / TS 5.7+ alignment),
the Vitest growth 65 -> 113, and the in-scope logic bug fixes.
- ARCHITECTURE.md bumps the test ledger 99 -> 113 (adds the
avatar-info reducer suite), documents the new pure-module test
convention (concrete file paths + 'import type' for renderer
event types), and lists the two new singleton-filter splits
(notification, friends).
- CLAUDE.md mirrors the same updates plus a 'Singleton-filter split'
recipe alongside the doorbell-style one; useNitroEventInvalidator
is documented next to useNitroQuery; the 'What's wired up' table
enumerates all 10 split hooks. Test count bumped 99 -> 113 in
both the 'Vitest' row and the green-bar house rule.
- Pattern #2 (useNitroQuery): list the eight catalog-layer queries
carved out of useCatalog this session (gift / groups / club offers /
pet palette / marketplace / club gifts), plus the new
useNitroEventInvalidator companion for server-push refresh.
- Note ICatalogOptions deleted — the legacy 'catalogOptions' bag has
no remaining fields after the migrations, useCatalog no longer
exposes it.
- New 'useCatalog decomposition (in progress)' table — what's been
lifted to TanStack and what's deferred (page tree + Builders Club
status, both core state slices that move with the data/actions
split).
- Pattern #4 (god-hook split): add the three new splits done this
session (chat-input doorbell-style, wired-tools singleton-filter,
translation singleton-filter inline).
- Bump Vitest count 83 → 99 (added 16 cases on the
useCatalogFavorites helpers).
- Note the pure-module test convention: import from concrete file
paths rather than the api barrel to avoid jsdom pulling in Pixi.
- Typecheck baseline: client now reports 0 too (was 57 at last
doc-write); the section's enumerated sweeps all landed.
- CLAUDE.md: bump '77/77' references to '99/99' (both places).
Same pattern as the wired-tools split: 600-line useTranslation backs
6 consumers with a wide state + action surface. Split along the
read/write seam:
- useTranslationStore (internal, was the inner useTranslationState) —
the previous singleton body, untouched except for the rename and a
doc-comment.
- useTranslationState (public, read-only) — useBetween filter exposing
settings, the supported-languages list, the loading/loaded flags,
the detected-language tags, lastError, and the pure getLanguageName
helper.
- useTranslationActions (public, imperative) — same singleton filter
exposing updateSettings, ensureSupportedLanguagesLoaded, the four
translate/queue helpers. Also re-exposes 'settings' because most
call sites need 'if(settings.enabled)' before dispatching.
- useTranslation (deprecated shim) — composes the singleton via
useBetween, preserving the historical full-shape return.
applyTextTranslationLocale stays exported from the same module path
so LoginView's import keeps working.
Updates docs/ARCHITECTURE.md proposal #4 section to list the three
new splits (chat-input + wired-tools + translation) alongside the
previous five.
Updates the proposal #1 section to reflect the four companion hooks now
in src/hooks/events/ (useNitroEventReducer, useMessageEventReducer,
useExternalSnapshot, on top of the existing *State hooks) and marks
the InfoStand + Inventory pilots from the original Fase 2 plan as
adopted. Adds the convention note for state owned outside the
listener: keep useState + useMessageEvent and extract the reducer as
a pure function, citing the two new reducer modules as reference.
Two doc changes so a fresh local Claude Code session can pick up the
branch without re-discovering the conventions and the work-in-progress.
CLAUDE.md (new, repo root)
- Onboarding file Claude Code reads automatically at session start.
- TL;DR with branch name + PR number, points at docs/ARCHITECTURE.md.
- Stack snapshot (React 19, TS 7 native, Vite 8 + Compiler, Zustand 5,
TanStack Query 5, Vitest 3).
- Layout convention spelled out — `src/components/<area>/<feature>/`
for views, `src/hooks/<area>/<feature?>/` flat for hooks. The
rejected-feature-folders decision is the most stepped-on rake, so
it lives here at the top.
- The canonical 3-file god-hook split shape with doorbell as the
reference.
- Patterns to use with copy-pasteable signatures: useNitroEventState,
useMessageEventState, useNitroQuery (with the accept() filter),
Zustand stores via createNitroStore, WidgetErrorBoundary.
- "Wired up vs not yet" matrix: what each pattern is adopted on and
what the next reasonable target is.
- Pointer to the two still-open logic bugs (MainView CREATED/ENDED
race; LayoutFurniImageView async fetch race) with fix shapes.
- House rules: commit author override, no claude/... branch names,
never merge a layout-violating branch, skip-motivated splits are
fine if explained in the commit message.
docs/ARCHITECTURE.md (refresh)
- "What's already in place" rewritten to reflect the full state of
the feat/react19-modernization branch:
* stale references to the old claude/update-react-typescript-He2rs
branch removed
* the three additional god-hook splits done since the last edit
(furni-chooser, user-chooser, friend-request) added
* the 4 useNitroQuery migration sites listed (OfferView,
CatalogLayoutRoomAdsView, ModToolsChatlogView, CfhChatlogView)
* the three additional WiredCreatorToolsView tab extractions
(Monitor, Inspection, Variables) with the 4493 -> 3544 line
counter
* dead-code removal of the legacy login dialogs documented
* the Vitest count updated from 22 to 77 across 6 test files
* usePollSubscriptions hoist to RoomWidgetsView noted
- "How to pick the next refactor PR" rewritten:
* completed items removed (the previous list still had
"hoist usePollSubscriptions" as todo even though it's done,
and "per-tab WiredCreatorTools split" same)
* remaining priorities re-ordered: useCatalog migration (1),
useCatalog split (2), per-widget error boundaries (3),
wired-tools shared-state Zustand slice (4), the two open
logic bugs (5), wider Vitest coverage (6).
* "skipped intentionally" subsection added for the god-hook
splits that need design work first (pet-package, word-quiz,
chat-input, chat-widget, avatar-info).
Verification
- yarn test: 77/77 still passing.
- grep claude/update-react-typescript-He2rs docs/ARCHITECTURE.md: 0
(no stale branch refs).
Now a fresh `claude` session in this repo can read CLAUDE.md, follow
the link to ARCHITECTURE.md, and start contributing without re-asking
the conventions.
usePollWidget bundled two unrelated responsibilities:
- three useNitroEvent listeners that bridge RoomSessionPollEvent
(OFFER / ERROR / CONTENT) onto the UI event bus via DispatchUiEvent
— pure side-effects, zero local state, should mount once;
- three imperative actions (startPoll, rejectPoll, answerPoll) that
every consumer wants, but which shouldn't re-register the listeners.
In practice the only consumer of usePollWidget was useWordQuizWidget,
which needed only `answerPoll` — but pulled in the three subscriptions
as a side effect every time the word-quiz widget rendered. That's the
classic god-hook anti-pattern this proposal targets.
Split (mirrors the doorbell pattern already in place):
- src/hooks/rooms/widgets/usePollSubscriptions.ts (new): the three
bridge listeners, returns void. Should be mounted ONCE at the
highest stable level above poll-aware UI (room widgets root). For
now still mounted by the shim — follow-up PR can move it.
- src/hooks/rooms/widgets/usePollActions.ts (new): the three
imperative actions. Defensive `?.` on roomSession so a poll action
during a room transition no longer crashes.
- src/hooks/rooms/widgets/usePollWidget.ts: kept as a deprecated shim
that composes both — preserves the old `{ startPoll, rejectPoll,
answerPoll }` shape so existing consumers don't break.
- src/hooks/rooms/widgets/useWordQuizWidget.ts: migrated to import
usePollActions directly. The word-quiz widget no longer registers
poll subscriptions transitively — its render no longer has the side
effect of subscribing to three renderer events.
Doc
- docs/ARCHITECTURE.md "What's already in place": records both god-hook
splits (doorbell + poll), the now-enabled React Query and Zustand,
and the test infrastructure. Removes the "not yet enabled" markers
for #2 and #5.
- "How to pick the next refactor PR": rewritten to reflect that the
foundations are done. New priority order:
1. migrate useCatalog's read-only fetches to useNitroQuery,
2. hoist usePollSubscriptions to room-session level,
3. split useCatalog along the doorbell/poll lines,
4. broaden Vitest coverage,
5. per-tab WiredCreatorToolsView split.
Verification
- yarn eslint on the touched files: 0 errors / 0 warnings.
- yarn test: 22/22 passing, 2 files, ~1.0s.
- Existing useWordQuizWidget consumers (RoomWidgetsView ->
WordQuizWidgetView) unaffected — they import from the barrel which
still re-exports the same shape.
https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
Decision: the src/features/<feature>/ layout introduced as proposal #3
(pilot on doorbell in 8ec9d27) is not the convention the team wants. The
existing src/components/<area>/ + src/hooks/<area>/ split is the one
that stays.
What's reverted
- src/features/doorbell/ is removed entirely. The doorbell view and the
two hooks move back under the classic paths:
src/features/doorbell/views/DoorbellWidgetView.tsx
-> src/components/room/widgets/doorbell/DoorbellWidgetView.tsx
src/features/doorbell/hooks/useDoorbellState.ts
-> src/hooks/rooms/widgets/useDoorbellState.ts
src/features/doorbell/hooks/useDoorbellActions.ts
-> src/hooks/rooms/widgets/useDoorbellActions.ts
- The compat shims that lived in those classic paths are dropped now
that the real files are back.
- src/hooks/rooms/widgets/index.ts adds the two new hooks alongside the
existing useDoorbellWidget shim (kept as a deprecated wrapper so any
external consumer importing the old shape via the barrel keeps
working).
What's preserved
- The split between data and actions (proposal #4) — useDoorbellState
and useDoorbellActions remain two separate hooks. This was the actual
improvement, and it's independent of where the files sit.
- The bug fixes from 8ec9d27 (close button race, optimistic-remove
rollback) — both still present, just in the new path.
- src/state/createNitroStore.ts and src/api/nitro-query/createNitroQuery.ts
are left where they are. They aren't feature folders; they're
cross-cutting framework code (Zustand skeleton, React Query adapter
prototype) that any feature can consume.
Doc
- docs/ARCHITECTURE.md section #3 is rewritten to record the decision
rather than recommend the layout. It now describes the convention to
follow:
* views under src/components/<area>/<feature>/
* hooks under src/hooks/<area>/<feature?>/ (siblings, not subfolders
per widget)
* sibling .types/.constants/.helpers files for view-specific code
(e.g. WiredCreatorTools.*.ts)
- "What's already in place" and "Recently fixed" sections updated to
point at the new paths.
- "How to pick the next refactor PR" no longer mentions feature-folder
migration as an option.
Note: the five extra feature folders started this session (reconnect,
nitropedia, ads, hc-center, campaign) were never committed; they only
existed in the working tree and have been restored from HEAD.
Verification
- find src/features -type f -> 0 (directory removed).
- npx tsc --noEmit on all touched files: clean (only the project-wide
pre-existing TS2307 about @nitrots/nitro-renderer not installed
locally remains, same as before).
- npx eslint on all touched files: 0 errors, 0 warnings.
https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
These are the bugs surfaced during the structural work that are simple
enough to fix in isolation. Larger ones (race conditions that need
session-token tracking, async-fetch ordering) are deferred and documented
in docs/ARCHITECTURE.md "Known logic bugs" — the repo has Issues
disabled, so the doc is the issue board.
== Fix: room history wiped on every tab close
src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx had a
useEffect that registered a `beforeunload` handler calling
`window.localStorage.removeItem('nitro.room.history')`. The whole point
of localStorage is to persist across sessions; wiping it on tab close is
either a leftover debug call or a misunderstanding of the API.
Removed the handler. History now persists across browser sessions, which
matches user expectations. If "session-only" was the intent, the right
primitive is `sessionStorage` (not localStorage + cleanup) — left as a
note in the doc.
== Fix: AvatarInfoPetTrainingPanelView null-pointer on session change
src/components/room/widgets/avatar-info/AvatarInfoPetTrainingPanelView.tsx
read `roomSession.userDataManager.getPetData(parser.petId)` without
guarding for `roomSession` being null. The PetTrainingPanelMessageEvent
can arrive during a room transition when `roomSession` is briefly null,
crashing the widget. Added `?.` chain on both `roomSession` and
`userDataManager`.
== Doc: known logic bugs section
Two open issues documented for follow-up:
- MainView.tsx CREATED/ENDED race — needs session-token tracking, fits
cleanly into the future useNitroEventReducer companion to proposal #1.
- LayoutFurniImageView / LayoutAvatarImageView async fetch ordering —
needs request-id refs, or solves itself once React Query (proposal #2)
is enabled and the image fetch becomes a query keyed on props.
Plus a "recently fixed" subsection that records the four bugs already
addressed in this branch (doorbell close button, doorbell optimistic
remove, room history wipe, pet panel null-pointer) so the next reader
knows what changed and why.
== Verification
- yarn eslint on the two modified files: same error count before and
after (5 pre-existing set-state-in-effect on RoomToolsWidgetView,
none introduced).
- yarn tsc on the two modified files: clean.
https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
This is the structural plan promised in the previous session, with concrete
pilots for all five proposals + the bonus error-boundary work.
== docs/ARCHITECTURE.md (new, ~370 lines)
Living document describing:
- where the project stands today (event-bus pattern friction with React 19,
god-hooks, oversized files);
- the five proposed structural improvements with the why/how/status of each;
- what's already in place across this branch;
- recommended order for the next refactor PRs.
This is the deliverable the rest of this commit references.
== Proposal #3 + #4 pilots: src/features/doorbell/ (new)
Concrete feature-folder migration on the doorbell widget (chosen because
it's small enough to migrate end-to-end in one commit).
src/features/doorbell/
index.ts public API
views/DoorbellWidgetView.tsx
hooks/useDoorbellState.ts reduces 3 events into a users array (data only)
hooks/useDoorbellActions.ts answer(name, flag) (imperative actions only)
The split (data vs actions) is the pattern proposal #4 wants applied to
useCatalog/useChat/useWiredTools later. The original useDoorbellWidget had
both concerns + a buggy `useEffect(() => setIsVisible(!!users.length), [users])`
derive-state-in-effect. The new view computes visibility in render.
Compat shims kept so existing imports keep working:
- src/components/room/widgets/doorbell/DoorbellWidgetView.tsx -> 1-line re-export
- src/hooks/rooms/widgets/useDoorbellWidget.ts -> deprecated wrapper around
the two new hooks, returning the same { users, answer } shape.
== Proposal #2 prototype: src/api/nitro-query/ (new)
Adapter outline for wrapping composer/parser request-response pairs in
TanStack Query. Not yet enabled because @tanstack/react-query is not in
package.json. The file documents the activation steps:
yarn add @tanstack/react-query @tanstack/react-query-devtools
+ mount QueryClientProvider in src/index.tsx
awaitNitroResponse() throws with a helpful pointer to the doc section if
called before activation, so accidental adoption fails loudly.
== Proposal #5 skeleton: src/state/createNitroStore.ts (new)
Same pattern: skeleton + activation instructions. Not yet enabled because
zustand is not in package.json.
yarn add zustand
+ replace the throw with `import { create } from 'zustand'; export const createNitroStore = create;`
The doc inside the file shows the recommended slice shape and points to
the suggested first migration target (the let isCreatingRoom singleton in
NavigatorRoomCreatorView).
== Bonus: WidgetErrorBoundary
src/common/error-boundary/WidgetErrorBoundary.tsx wraps react-error-boundary
with a sensible default (silent fallback, NitroLogger.error). Re-exported
from src/common/index.ts.
Applied as the umbrella around RoomWidgetsView's children — a widget
crash in a room (e.g. malformed pet data) now degrades gracefully
instead of unmounting the whole UI.
== Verification
- yarn eslint on all new + modified files: 0 errors / 0 warnings introduced.
RoomWidgetsView still has its 1 pre-existing FC<{}> error (1 before, 1 after).
- yarn tsc on all new files: clean (only project-wide pre-existing
TS2307 about @nitrots/nitro-renderer not installed locally remains).
- No regressions: existing imports of DoorbellWidgetView and
useDoorbellWidget keep resolving via the compat shims.
== What's NOT in this commit (intentionally)
- Mass adoption of the new patterns elsewhere — left as follow-up PRs in
the order documented in ARCHITECTURE.md "How to pick the next refactor PR".
- Installation of @tanstack/react-query / zustand — explicit team decision,
not the LLM's to make.
- Test infrastructure (Vitest setup) — listed as the #1 missing piece in
the doc, but a separate PR.
https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q