Files
Nitro-V3/docs/ARCHITECTURE.md
T
simoleo89 7218285583 Split usePollWidget into subscriptions + actions (proposal #4) + doc update
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
2026-05-11 16:31:53 +00:00

19 KiB
Raw Blame History

Architecture & Refactor Plan

Status: living document, last updated 2026-05-10. This file describes the structural direction the codebase is moving in. Read it before starting a non-trivial refactor — half the value comes from staying consistent, not from each individual change.

Table of contents

  1. Where the project stands today
  2. Five structural improvements
    1. Event subscriptions as derived state
    2. Server requests as queries
    3. Feature folders
    4. Splitting god-hooks
    5. Unified UI store
  3. Bonus: error boundaries
  4. What's already in place
  5. How to pick the next refactor PR

Where the project stands today

The codebase is a React 19.2 client for the Nitro renderer (Habbo-style hotel client). Most of the architectural pressure comes from the renderer's event-bus + composer/parser model: the UI talks to the server by sending composers and listening to incoming message events. Almost every piece of state in this app is "the latest value seen on a given event".

That model creates two kinds of friction with modern React:

  1. useEffect everywherereact-hooks/set-state-in-effect reports ~328 violations across ~280 files. Most are legitimate event-driven updates, but the pattern hides the intent (it reads as "imperative setState on mount/effect" rather than "subscribe to a stream").
  2. God-hooksuseCatalog (~1100 lines), useChat, useWiredTools, useInventoryFurni all bundle data fetching, UI state, side effects, and computed values into a single export. Components import the whole thing for one field; the React Compiler skips memoization.

Two big files (WiredCreatorToolsView.tsx 4493→3901 lines, LoginView.tsx 1700) further compound the problem: the Compiler logs "Compilation Skipped: Existing memoization could not be preserved", which means manual useMemo/useCallback are not even helping.

The improvements below are ordered so that each one makes the next one easier.


Five structural improvements

1. Event subscriptions as derived state

Problem. Pattern repeated hundreds of times:

const [foo, setFoo] = useState(initial);
useNitroEvent(SomeEvent, e => setFoo(e.payload));

or with the message channel:

const [data, setData] = useState(null);
useMessageEvent(SomeParser, e => {
    const parser = e.getParser();
    if (!parser) return;
    setData(parser.field);
});

The shape of the code obscures the intent ("foo IS the latest event payload") and makes the lint think we're doing imperative setState in an effect.

Solution. Two thin hooks (src/hooks/events/useNitroEventState.ts and useMessageEventState.ts):

const foo = useNitroEventState(SomeEvent, e => e.payload, initial);
const data = useMessageEventState(SomeParser, e => e.getParser()?.field ?? null, null);

Internally the selector closure is held in a ref refreshed in commit phase (useLayoutEffect), so a new selector identity per render does not force re-subscription. The listener is registered once.

Status. Implemented + 1 pilot adoption (OfferView.tsx).

Adoption. Organic: when a contributor sees a clean "derive-from-single-event" case, they convert it. Do not sweep-replace. The majority of existing subscriptions have side effects, multi-state updates, conditional filters, or state-machine semantics that lose information when forced into a single selector.

Companion to add later. A useNitroEventReducer<S, T>(events, reducer, initial) for the cases where multiple events affect one state slice (see useDoorbellWidget — three events, one users array).


2. Server requests as queries

Problem. A request/response pair against the server today looks like:

useEffect(() => {
    SendMessageComposer(new GetXComposer());
}, []);

useMessageEvent(YParser, e => {
    setData(e.getParser().data);
});

There is no caching, no deduplication, no retry, no loading or error state, no devtools. Every consumer rolls its own. The same request fires multiple times if multiple components mount it.

Solution. Wrap composer/parser pairs in a TanStack Query adapter (@tanstack/react-query is in the same family as @tanstack/react-virtual which is already a dependency):

const { data, isLoading } = useNitroQuery({
    request: () => new GetXComposer(),
    parser: YParser,
    select: e => e.getParser().data,
});

Status. Adapter prototype written (src/api/nitro-query/createNitroQuery.ts). Not wired up because @tanstack/react-query is not yet installed — deliberately left as a yarn add step the team can approve.

To enable.

yarn add @tanstack/react-query @tanstack/react-query-devtools

Then mount the provider in src/index.tsx:

<QueryClientProvider client={queryClient}>
    <App />
    <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

Migration order suggested.

  1. Read-only catalog data (useCatalog page fetches) — biggest win, lowest risk because it's mostly read.
  2. Inventory tabs.
  3. Navigator search results.
  4. Marketplace listings.

Push messages (events the server emits without the client asking) keep using useMessageEventState — they're not requests.


3. Feature folders (adopted)rejected, keep the current layout

Update: an earlier version of this document proposed a src/features/<feature>/ layout (vertical slices). The pilot on the doorbell widget showed that the existing src/components/<area>/ + src/hooks/<area>/ split is the convention the team wants to keep. The pilot has been rolled back; this section is left as a record of the decision.

Current convention (the one to follow):

  • Views live under src/components/<area>/<feature>/*.tsx (e.g. src/components/room/widgets/doorbell/DoorbellWidgetView.tsx).
  • Hooks live under src/hooks/<area>/<feature?>/*.ts (e.g. src/hooks/rooms/widgets/useDoorbellState.ts). Multiple hooks for the same widget go in the same folder as siblings, not in a per-widget subfolder.
  • Pure helpers / constants / types that are specific to one view go in sibling files next to the view (see src/components/wired-tools/WiredCreatorTools.{types,constants,helpers}.ts for the established pattern).
  • Cross-cutting utilities continue to live under src/api/ and src/common/.

Discoverability is acceptable as long as the naming is consistent — useDoorbellState / useDoorbellActions / DoorbellWidgetView are greppable in seconds even though they live in three separate directory trees.


4. Splitting god-hooks

Problem. useCatalog.ts is ~1100 lines. It owns:

  • Server fetch lifecycle (request/parser pairs)
  • UI state (selected page, current product, filters)
  • Side effects (purchases, gift composer dispatch)
  • Computed values (pricing display, page tree)
  • Cross-cutting helpers (currency lookup, club level checks)

Every component that imports useCatalog() for one field re-runs the whole thing. The Compiler can't memoize it (too large). Tests can't be written against a single concern.

Solution. Split by responsibility, not by entity:

useCatalogData()      // server data, returns { pages, currentPage, isLoading }
useCatalogUiState()   // ui state, returns { selectedNode, setSelectedNode, filters, ... }
useCatalogActions()   // imperative actions, returns { purchase, gift, openOffer }

Inside, useCatalogData uses useNitroQuery (#2). useCatalogUiState uses a Zustand slice (#5). useCatalogActions is a stateless export — just functions that compose composers.

Status. Pilot done on useDoorbellWidget:

  • src/hooks/rooms/widgets/useDoorbellState.ts — the users list, derived from three events using a useNitroEventReducer-like pattern.
  • src/hooks/rooms/widgets/useDoorbellActions.tsanswer(name, flag).
  • src/hooks/rooms/widgets/useDoorbellWidget.ts kept as a deprecated shim that composes the two so existing consumers don't break.

It's a small hook so the split looks almost theatrical, but the shape is the same one we want to apply to useCatalog.

Migration order suggested. Largest pain first, moving down:

  1. useCatalog (~1100 LOC) — but only after #2 is enabled (server fetches collapse to a few useNitroQuery calls, removing 60% of the file).
  2. useChatInputWidget (~500 LOC)
  3. useWiredTools (~600 LOC)
  4. useInventoryFurni (~300 LOC)

5. Unified UI store

Problem. Cross-feature UI state lives in:

  • React Context (e.g. UiSettingsContext)
  • Custom hooks with module-level singletons (useNavigator's implicit cache)
  • let foo = ... module-level mutable variables — flagged by the React Compiler as "Writing to a variable defined outside a component or hook is not allowed" (currently 5+ violations)
  • localStorage reads in effects

There is no single source of truth, no devtools, no time-travel.

Solution. Adopt Zustand for cross-feature UI state. Each feature owns one slice:

// src/state/wired-tools.ts (or src/components/wired-tools/wiredToolsStore.ts)
export const useWiredToolsStore = create<WiredToolsState>()((set) => ({
    activeTab: 'monitor',
    setActiveTab: (tab) => set({ activeTab: tab }),
    // ...
}));

Components subscribe to specific keys (Zustand re-renders only the subscribers whose selected slice changed):

const activeTab = useWiredToolsStore(s => s.activeTab);

This eliminates the let isCreatingRoom = false module-level pattern and makes the state ispezionable in dev tools.

Status. Skeleton written (src/state/createNitroStore.ts), not yet adopted — zustand is not yet installed. Same reason as #2: deliberately a follow-up yarn add step.

To enable.

yarn add zustand

Then convert the smallest singleton first (suggestion: the isCreatingRoom/createRoomTimeout pair in NavigatorRoomCreatorView.tsx — it's a clean 5-line conversion).

Do not wholesale-replace Context. Some Contexts (theming, i18n) are fine as-is. Zustand is for application state, not configuration state.


Bonus: error boundaries

react-error-boundary is already a dependency. A widget crashing in a room (e.g. malformed pet data in InfoStandWidgetFurniView) currently takes down the whole UI.

Solution. Wrap each widget root in <ErrorBoundary fallback={null} onError={NitroLogger.error}>. Implementation lives at src/common/error-boundary/WidgetErrorBoundary.tsx.

Status. Implemented + applied to RoomWidgetsView as the umbrella for all in-room widgets. A widget crash now degrades gracefully (the offending widget disappears) instead of unmounting the room.

A more granular pass could wrap each individual widget for finer-grained fallbacks, but the umbrella alone already prevents the worst class of failures.


What's already in place

The current branch (claude/update-react-typescript-He2rs) has applied:

  • React 19.2 / TypeScript 7 (Native preview) / ESLint 10 / React Hooks v7 / React Compiler 1.0 — toolchain bump, all warnings audited.
  • Form Actions<form action={...}> + useActionState adopted in LoginView.tsx (login, register, forgot dialogs).
  • useEffectEvent — adopted in App.tsx, FurniEditorSearchView, NotificationBadgeReceivedBubbleView, NavigatorRoomSettingsRightsTabView, UiSettingsContext to clear all react-hooks/exhaustive-deps warnings.
  • Targeted set-state-in-effect cleanupCatalogHeaderView (pure derive), NavigatorRoomCreatorView (lazy state init), LoginView (track-previous-prop reset), ChooserWidgetView (callback in useEffectEvent).
  • WiredCreatorToolsView split — types/constants/helpers extracted to sibling files; main view 4493 → 3901 lines.
  • Pattern #1 (useNitroEventState) — implemented + 1 pilot.
  • Pattern #3 (feature folder)rejected; the existing src/components/<area>/ + src/hooks/<area>/ layout is kept.
  • Pattern #4 (split god-hook) — applied to:
    • doorbell: useDoorbellState (data) + useDoorbellActions;
    • poll: usePollSubscriptions (3 listeners) + usePollActions (3 imperative actions). useWordQuizWidget migrated to import usePollActions directly (it doesn't need the subscriptions).
  • Pattern #2 (useNitroQuery)enabled: @tanstack/react-query installed, QueryClientProvider mounted, real adapter in src/api/nitro-query/, first migration on OfferView.
  • Pattern #5 (Zustand store)enabled: zustand installed, createNitroStore is now a real re-export, first migration converts the let isCreatingRoom / createRoomTimeout singleton in NavigatorRoomCreatorView to useRoomCreatorStore.
  • Test infrastructure — Vitest 3 + jsdom + @testing-library set up. 22 smoke tests passing on the pure helpers (WiredCreatorTools.helpers.ts) and the new Zustand store.
  • Bonus (error boundaries)WidgetErrorBoundary applied at RoomWidgetsView.

How to pick the next refactor PR

Foundations #1#3 (React Query, Zustand, Vitest) are done. Order of value/risk for the next contributor:

  1. Migrate useCatalog's read-only fetches to useNitroQuery. Biggest expected payoff (cache + dedup + loading state for free). Move the page-tree fetch first; the imperative purchase/gift flows stay where they are. Adds tests against the new hooks as you go.
  2. Mount usePollSubscriptions once at room-session level instead of inside useWordQuizWidget. The shim in usePollWidget works for now but is wrong design: subscriptions don't belong inside an actions hook. Right place is probably RoomWidgetsView or wherever poll state should be observable.
  3. Split useCatalog along the doorbell/poll lines (useCatalogData / useCatalogUiState / useCatalogActions, siblings under src/hooks/catalog/). Only after #1 — React Query removes ~60% of the file's responsibility, Zustand absorbs the UI state slice.
  4. Wider Vitest coverage: add cases for useDoorbellState (event reducer), useNitroQuery (timeout + cleanup), the smaller pure formatters in src/api/. ~20 cases gets us to a meaningful smoke baseline.
  5. Per-tab split of WiredCreatorToolsView (Monitor / Inspection / Variables / Settings panels). Needs a tiny Zustand slice for the shared state, then each tab moves to its own file. Unblocks the React Compiler memoization on the parent module.

Anything else (the LoginView dialog split, the react-compiler/react-compiler warnings on the remaining big files, the set-state-in-effect sweep) is a downstream consequence of the above — easier and safer once the foundations are in place.


Known logic bugs (independent of structural refactor)

These are runtime bugs spotted while doing the structural work. They are not fixed by the patterns above — they need their own PRs with manual QA. Listing them here because there is currently no GitHub Issues board on this repo.

Open

MainView — race between RoomSessionEvent.CREATED and ENDED

src/components/MainView.tsx:47-48 writes the same landingViewVisible state from two independent listeners with no session-token guard:

useNitroEvent(RoomSessionEvent.CREATED, () => setLandingViewVisible(false));
useNitroEvent(RoomSessionEvent.ENDED, e => setLandingViewVisible(e.openLandingView));

If the events arrive out of order (fast reconnect, network reordering), the final state contradicts the actual session state — landing view stuck open inside a room, or stuck closed at the hotel view. Resolves on next room change.

Fix shape (deferred until useNitroEventReducer companion lands — see proposal #1):

// One reducer owns both events + the active session token
const { sessionId, landingViewVisible } = useNitroEventReducer<...>(
    [RoomSessionEvent.CREATED, RoomSessionEvent.ENDED],
    (state, e) => {
        if (e.type === RoomSessionEvent.CREATED) {
            return { sessionId: e.session.roomId, landingViewVisible: false };
        }
        if (state.sessionId !== null && e.session.roomId !== state.sessionId) {
            return state; // stale ENDED for old session, ignore
        }
        return { sessionId: null, landingViewVisible: e.openLandingView };
    },
    { sessionId: null, landingViewVisible: true }
);

Severity: edge case, observed only after unstable websocket reconnects. UX-degrading, not data-corrupting.

LayoutFurniImageView / LayoutAvatarImageView — async fetch race

In both files an effect kicks off an async processAsImageUrl / generateImage and writes the result via setImageElement. If props change twice in quick succession, the first fetch can resolve after the second one and overwrite the newer image with the older one.

Fix shape: capture a request-id ref at the start of the effect, only write the result if the ref hasn't been bumped meanwhile. Or — better — once React Query (#2) is enabled, model the image fetch as a query keyed on the props tuple; React Query handles cancellation and ordering for free.

Severity: visible only on slow connections / rapid prop changes. Not data-corrupting.

Recently fixed (in this branch)

  • Doorbell close button didn't close while users were pending (useEffect(() => setIsVisible(!!users.length)) overrode the close). Fixed by src/components/room/widgets/doorbell/DoorbellWidgetView.tsx (separate dismissed state, visibility computed in render).
  • Doorbell optimistic remove without rollback — the original answer() removed the user from the local list before the server confirmed via RSDE_ACCEPTED/RSDE_REJECTED, leaving client and server desynced if the network dropped. Fixed by removing the local removeUser call: the server-driven events now own the list. Note: a "pending" indicator (so users see their answer is in flight) is desirable — separate small PR.
  • localStorage room history wiped on every tab close (RoomToolsWidgetView.tsx, useEffect on beforeunload removing nitro.room.history). Fixed by removing the beforeunload handler; history now persists across sessions, which is the only sensible meaning of localStorage. If "session-only" was the intent, the right primitive is sessionStorage — file an issue if that's actually desired.
  • AvatarInfoPetTrainingPanelView null-pointerroomSession.userDataManager.getPetData(parser.petId) could throw if roomSession was null at the moment the event arrived (between rooms). Fixed with ?. chain.