Files
Nitro-V3/docs/ARCHITECTURE.md
T
simoleo89 0755285708 Revert feature-folder migration; keep classic src/components + src/hooks layout
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
2026-05-11 16:31:53 +00:00

442 lines
18 KiB
Markdown

# 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](#where-the-project-stands-today)
2. [Five structural improvements](#five-structural-improvements)
1. [Event subscriptions as derived state](#1-event-subscriptions-as-derived-state)
2. [Server requests as queries](#2-server-requests-as-queries)
3. [Feature folders](#3-feature-folders)
4. [Splitting god-hooks](#4-splitting-god-hooks)
5. [Unified UI store](#5-unified-ui-store)
3. [Bonus: error boundaries](#bonus-error-boundaries)
4. [What's already in place](#whats-already-in-place)
5. [How to pick the next refactor PR](#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` everywhere** — `react-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-hooks**`useCatalog` (~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:
```ts
const [foo, setFoo] = useState(initial);
useNitroEvent(SomeEvent, e => setFoo(e.payload));
```
or with the message channel:
```ts
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`):
```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:
```ts
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):
```ts
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.**
```sh
yarn add @tanstack/react-query @tanstack/react-query-devtools
```
Then mount the provider in `src/index.tsx`:
```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:
```ts
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.ts``answer(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:
```ts
// 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):
```ts
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.**
```sh
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` cleanup** — `CatalogHeaderView` (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 the doorbell hook:
`src/hooks/rooms/widgets/useDoorbellState.ts` (data) +
`src/hooks/rooms/widgets/useDoorbellActions.ts` (actions).
- **Pattern #2 (`useNitroQuery`)** — adapter prototype written, not yet
enabled (needs `yarn add @tanstack/react-query`).
- **Pattern #5 (Zustand store)** — skeleton written, not yet enabled
(needs `yarn add zustand`).
- **Bonus (error boundaries)** — `WidgetErrorBoundary` applied at
`RoomWidgetsView`.
---
## How to pick the next refactor PR
Order of value/risk for the next contributor:
1. **Enable React Query** (`yarn add @tanstack/react-query`) and migrate
one read-only `useCatalog` fetch as a second pilot. Highest impact, low
risk.
2. **Enable Zustand** and migrate the `let isCreatingRoom` /
`createRoomTimeout` singleton in `NavigatorRoomCreatorView`. Trivial,
makes the Compiler stop complaining about cross-component variable
writes.
3. **Add tests** (still the #1 thing missing — see "What I'd fix" notes).
Vitest + jsdom + a tiny mock layer for the renderer would unblock every
refactor below.
4. **Split `useCatalog`** — the biggest god-hook, following the doorbell
pattern (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`
as siblings under `src/hooks/catalog/`). Only do this *after* #1 and
#2 in this list (React Query removes 60% of the file's responsibility,
Zustand handles its UI state).
Anything else (the per-tab `WiredCreatorTools` split, the
`react-compiler/react-compiler` warnings, the `set-state-in-effect`
sweep, the `LoginView` dialog split) is a downstream consequence of these
five — 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:
```ts
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):
```ts
// 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-pointer** —
`roomSession.userDataManager.getPetData(parser.petId)` could throw if
`roomSession` was null at the moment the event arrived (between rooms).
Fixed with `?.` chain.