Files
Nitro-V3/CLAUDE.md
T
simoleo89 3459400ed7 docs(claude,architecture): refresh snapshot adoption status after 2026-05-19 fix
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.
2026-05-19 18:01:04 +02:00

21 KiB

Claude Code — project memory for Nitro-V3

This file is read automatically by Claude Code at session start. It captures the conventions and current state of this branch so a new session can hit the ground running.

TL;DR

This branch — feat/react19-modernization — is a long-running modernization of the Nitro V3 client: bump to React 19.2 idioms, add the supporting infrastructure (TanStack Query, Zustand, Vitest, React Compiler, error boundaries), split a few god-hooks, and audit logic bugs along the way. PR is #2 on simoleo89/Nitro-V3.

Upstream duckietm/Nitro-V3 (origin/Dev) is merged in through b2318b9 as of 2026-05-18 (merge commit 779a98c). That brings in JSON5 config support, user-settings (reset password / email / change username), wear-badge popup fix, login screen fix, About update, and the offer-selection refactor. When syncing the next batch of upstream commits, expect conflicts in App.tsx / bootstrap.ts / LoginView.tsx on React 19 imports — always keep the modernized local version.

Local-dev game assets are served by a small Vite plugin (sirv middleware mounted on /nitro-assets and /swf, reading from E:\Users\simol\Desktop\DEV\Nitro-Files) — NOT by symlinking inside public/. The symlink path triggers chokidar on ~177k files and the dev server hangs for minutes on Windows. See vite.config.mjs and the .gitignore note.

Detailed status, decisions, and next steps live in docs/ARCHITECTURE.md — read that before starting anything non-trivial.

Commands

Goal Command
Dev server yarn start
Production build yarn build
Serve production build yarn preview (defaults to http://localhost:4173)
Lint yarn eslint
Type-check (TS 7 native, fast) yarn typecheck
Test (Vitest, once) yarn test
Test (watch) yarn test:watch

Setup walkthrough

  1. Clone the renderer SDK as a sibling of this repo. vite.config.mjs resolves the @nitrots/* aliases against ../Nitro_Render_V3 (preferred) or ../renderer (legacy). If neither exists, the dev server and build now fail fast with a message pointing here.

    cd ..                            # parent of Nitro-V3
    git clone <renderer-repo> Nitro_Render_V3
    cd Nitro_Render_V3 && yarn install
    
  2. Install client deps.

    cd ../Nitro-V3
    yarn install
    
  3. Materialize the runtime configuration. public/configuration/ ships .example files. Copy the ones you need without the .example suffix and point them at your game server (websocket URL, asset base URL, UI texts, etc.). The dev server doesn't fail if these are missing but the client renders a blank/error screen at runtime.

  4. Run.

    • Dev: yarn start (Vite, HMR, includes the renderer source).
    • Production preview: yarn build && yarn preview.

The renderer SDK (@nitrots/nitro-renderer) is consumed via a filesystem link to a sibling working tree — ../Nitro_Render_V3 (preferred) or ../renderer (legacy). Without it, yarn typecheck reports TS2307 across the codebase — that's expected on a sandbox without the renderer, not a regression.

Stack snapshot

  • React 19.2.5, react-dom 19.2.5, @types/react 19.2.x.
  • TypeScript: TS 6 for build, TS 7 native preview (@typescript/native-preview, invoked via tsgo) for the typecheck script.
  • Vite 8 + @vitejs/plugin-react 6 + babel-plugin-react-compiler 1.0.
  • ESLint 10 + typescript-eslint 8 + eslint-plugin-react-hooks@7 + eslint-plugin-react-compiler.
  • TanStack Query 5 (@tanstack/react-query + devtools).
  • Zustand 5.
  • Vitest 3 + jsdom + @testing-library/react + @testing-library/jest-dom.
  • react-error-boundary 6.

Layout convention (DO NOT CHANGE)

Established by the team and recorded in docs/ARCHITECTURE.md proposal #3 (rejected the src/features/ alternative). Stay on this layout — every PR that violates it will need to be reworked.

src/components/<area>/<feature>/         → views (.tsx only)
  e.g. src/components/room/widgets/doorbell/DoorbellWidgetView.tsx

src/hooks/<area>/<feature?>/             → hooks, FLAT files, no per-feature subfolder
  e.g. src/hooks/rooms/widgets/useDoorbellState.ts
       src/hooks/rooms/widgets/useDoorbellActions.ts
       src/hooks/rooms/widgets/useDoorbellWidget.ts (deprecated shim)

src/api/                                 → cross-cutting helpers (LocalizeText, composers, formatters)
src/common/                              → reusable UI primitives + error boundary
src/state/                               → Zustand stores (cross-feature only)
src/**/*.test.{ts,tsx}                   → Vitest suites co-located next to their subject (e.g. `Foo.ts` + `Foo.test.ts`)
src/nitro-renderer.mock.ts               → hand-written renderer-SDK stub for tests (aliased over `@nitrots/nitro-renderer`)
src/test-setup.ts                        → Vitest setupFiles entry (jest-dom matchers, etc.)

When splitting a god-hook the convention is 3 files, all flat in the hooks barrel directory:

  • use<Feature>State.ts — state + event subscriptions + derived values
  • use<Feature>Actions.ts — pure imperative actions (no state writes)
  • use<Feature>Widget.ts — deprecated wrapper that composes the two and preserves the old return shape so existing consumers don't break

See useDoorbellState/useDoorbellActions/useDoorbellWidget as the canonical pattern.

Patterns to use

useSessionSnapshots (renderer snapshot pattern, React-side — OPT-IN)

For state that lives on a renderer Manager and is invalidated through NitroEventType.*_UPDATED, the file src/hooks/session/useSessionSnapshots.ts exposes eight consumer hooks backed by useSyncExternalStore:

const userData = useUserDataSnapshot();           // SessionData
const room    = useActiveRoomSessionSnapshot();   // RoomSession
const ignored = useIgnoredUsersSnapshot();        // ReadonlyArray<string>
const isIgn   = useIsUserIgnored(name);           // boolean, memoized
const badges  = useGroupBadgesSnapshot();         // ReadonlyMap<number,string>
const badge   = useGroupBadge(groupId);           // string, memoized
const vols    = useVolumesSnapshot();             // sound volumes
const users   = useRoomUserListSnapshot();        // ReadonlyArray<IRoomUserData>

Each hook has defensive typeof method === 'function' guards against a stale renderer bundle and degrades to a frozen default snapshot if the renderer doesn't expose the matching getter (kept module-level so React's bailout still works on the degraded path).

Adoption status: three pilot consumers shipped (commit d28819d, 2026-05-19). useSessionInfo reads userFigure / respectsLeft / respectsPetLeft from useUserDataSnapshot; useChatWidget.ownUserId reads from the snapshot directly; AvatarInfoWidgetAvatarView flips its Ignore/Unignore menu via useIsUserIgnored.

The original rollback (e142efd) was caused by a hard structural constraint, NOT a stale renderer or React Compiler quirk: snapshot hooks (useSyncExternalStore-based) must NOT be called inside a useBetween(stateFn) scope. use-between 1.x swaps ReactCurrentDispatcher.current with its own proxy (ownDispatcher at node_modules/use-between/release/index.esm.js:54-169) that re-implements only useState / useReducer / useEffect / useLayoutEffect / useCallback / useMemo / useRef / useImperativeHandle. useSyncExternalStore isn't on the list, so React resolves dispatcher.useSyncExternalStore to undefined and crashes on first paint — that's the original "(intermediate value)() is undefined" at ToolbarView.tsx:46. Chrome reports the same as dispatcher.useSyncExternalStore is not a function.

Fix pattern, applied to useSessionInfo: call the snapshot hook in the OUTER exported wrapper, after useBetween, so it runs in the real React dispatcher's scope. The inner state function (the one useBetween actually proxies) keeps only useState / useMessageEvent / plain actions.

const useSessionInfoState = () => {
    // ONLY use-between-safe hooks here.
    const [chatStyleId, setChatStyleId] = useState(0);
    // … useMessageEvent, actions …
    return { chatStyleId, /* actions */ };
};

export const useSessionInfo = () => {
    const shared = useBetween(useSessionInfoState);
    const userData = useUserDataSnapshot();        // outside useBetween → ok
    return { ...shared, userFigure: userData.figure, /* etc */ };
};

Regression guard: src/hooks/session/useSessionSnapshots.test.tsx asserts the negative case (snapshot inside useBetween crashes via ErrorBoundary) and the positive case (outside works). A CI gate (yarn lint:hooksreact-hooks/rules-of-hooks: error) blocks any future commit that reintroduces hook-order issues.

useNitroEventState / useMessageEventState

For "derived state from a single event" replace the two-step useState + useNitroEvent(e => setState(...)) with a single call:

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

The selector is held in a useLayoutEffect-refreshed ref so the listener stays registered across renders. Both hooks are exported from src/hooks/events.

useNitroQuery

For composer/parser request-response pairs:

const { data } = useNitroQuery<SomeParser, SomeData>({
    key: ['nitro', 'domain', 'request', ...args],
    request: () => new SomeComposer(args),
    parser: SomeParser,
    select: e => e.getParser()?.data,
    accept: e => e.getParser()?.correlationKey === args, // optional, for shared event bus
    staleTime: 60_000,
});

Already wired up; QueryClientProvider is mounted in src/index.tsx.

Companion useNitroEventInvalidator(eventType, queryKey, accept?) — import from src/api/nitro-query. Subscribes to the renderer event and invalidates the query slot on every push, so server-driven refresh paths work the same as the initial request/response (e.g. ClubGiftInfoEvent firing again after the user claims a gift).

Singleton-filter split for useBetween-based hooks

When a hook backs many consumers but most only need either state OR actions (not both), split it without breaking the shared-singleton guarantee:

// internal: state + actions in one closure
const useFooStore = () => {
    const [ data, setData ] = useState(...);
    // listeners, effects, actions ...
    return { data, doThing };
};

// public: read-only filter
export const useFooState = () => {
    const { data } = useBetween(useFooStore);
    return { data };
};

// public: imperative filter
export const useFooActions = () => {
    const { doThing } = useBetween(useFooStore);
    return { doThing };
};

// deprecated shim — keeps the historical return shape
export const useFoo = () => useBetween(useFooStore);

useBetween ensures all three entry points hit the same store instance, so listeners/effects register once. Used by useWiredTools, useTranslation, useNotification, useFriends.

Zustand stores

For cross-feature UI state (avoid module-level let):

import { createNitroStore } from '@/state/createNitroStore';

export const useFooStore = createNitroStore<FooState>()((set) => ({
    ...
}));

Components subscribe to slices, not the whole store:

const value = useFooStore(s => s.value);

Adoptions: src/components/navigator/views/navigatorRoomCreatorStore.ts (create-room lockout) and src/components/wired-tools/wiredCreatorToolsUiStore.ts (UI-only flags for the WiredCreatorTools panel — tab nav, modal/popover open, monitor + variable-manage filters).

WidgetErrorBoundary

Wrap any in-room widget tree so a crash degrades gracefully (logs to NitroLogger, falls back to null). Already applied at RoomWidgetsView as an umbrella; per-widget wrapping is a follow-up.

<WidgetErrorBoundary name="ChatWidget">
    <ChatWidgetView />
</WidgetErrorBoundary>

Form Actions

Login / Register / Forgot in src/components/login/LoginView.tsx use useActionState + useFormStatus. The legacy non-Action versions in src/components/login/components/{Register,Forgot}Dialog.tsx and shared.ts have been removed (dead code).

Configuration pre-init in bootstrap

src/bootstrap.ts calls await GetConfiguration().init() before importing ./index. Otherwise the first paint dumps a flood of "Missing configuration key" warnings while components synchronously read asset.url, login.endpoint, … against an empty store before prepare()'s deferred init lands.

Asset serving in dev

Game assets (bundled/, c_images/, gamedata/, swf/...) are NOT copied or symlinked under public/. They're served by a custom Vite plugin (nitroAssetsServer in vite.config.mjs) that mounts sirv on /nitro-assets and /swf, reading from E:\Users\simol\Desktop\DEV\Nitro-Files\. sirv is a connect-style middleware that bypasses chokidar entirely, so the ~177k asset files never enter the watch graph. The plugin also wires the same handler into configurePreviewServer so yarn preview keeps working.

What's wired up and what isn't

Adopted Pilot sites
Renderer snapshot consumer hooks (useSessionSnapshots) useSessionInfo (userFigure / userRespectRemaining / petRespectRemaining via useUserDataSnapshot in the outer wrapper, outside useBetween), useChatWidget.ownUserId (via useUserDataSnapshot), AvatarInfoWidgetAvatarView Ignore/Unignore (via useIsUserIgnored). The 8 hooks (userData / activeRoomSession / ignoredUsers / groupBadges / soundVolumes / roomUserList / isUserIgnored / groupBadge) keep their typeof-guard defensive fallbacks for stale-renderer paths.
useNitroEventState + companions (Reducer, ExternalSnapshot) OfferView, useAvatarInfoWidget (figure/badges/group reducer), useInventoryFurni (pure reducers + fragments useRef)
useNitroQuery + useNitroEventInvalidator OfferView, CatalogLayoutRoomAdsView, ModToolsChatlogView, CfhChatlogView, useGiftConfiguration, useUserGroups, useClubOffers(windowId), useSellablePetPalette(breed), useMarketplaceConfiguration, useClubGifts (with invalidator)
Zustand NavigatorRoomCreatorView (useRoomCreatorStore), WiredCreatorToolsView (useWiredCreatorToolsUiStore — every panel-lifecycle-relevant flag, snapshot, selection, highlight, inline editor, picker chain hoisted; what's left in the component as useState is genuinely transient: keepSelected, globalClock, roomEnteredAt, selectedMonitorErrorType, selectedMonitorLogDetails)
God-hook split (state + actions + shim) doorbell, poll, furni-chooser, user-chooser, friend-request, chat-input
God-hook split (useBetween singleton + state filter + actions filter + shim) wired-tools, translation, notification, friends, catalog (three-way: useCatalogData / useCatalogUiState / useCatalogActions — all 48 consumers migrated, deprecated useCatalog shim removed)
WidgetErrorBoundary RoomWidgetsView umbrella + per-widget wrap on all 13 room widgets and all 20 furniture widgets (so a crash in one widget no longer takes down its siblings)
Vitest 207/207 cases — pure helpers (incl. 4 new on getPetPackageNameError) + 2 Zustand store suites (navigatorRoomCreatorStore, wiredCreatorToolsUiStore with 45 cases including the picker-chain hoists) + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at src/nitro-renderer.mock.ts, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters. Tests are co-located under src/, alongside their subject.
Form Actions Login / Register / Forgot (LoginView.tsx)
Upstream origin/Dev absorbed (merge 779a98c) Through b2318b9 (2026-05-18): JSON5, user-settings reset password/email/username, wear-badge popup fix, login screen fix, About, offer-selection refactor
Not yet Notes
Split useChatWidget / useAvatarInfoWidget (data/actions) Both state-driven via events with no clean imperative actions to extract — split still skip-motivated, but useAvatarInfoWidget got a typed __nitroAvatarClickControl accessor + module-scope DEBOUNCE const in 2026-05-18 (commit 05ff7df). useChatWidget.ownUserId reactive migration re-applied 2026-05-19 in d28819d via useUserDataSnapshot (direct hook call — useChatWidget isn't wrapped in useBetween so the snapshot-outside-useBetween constraint doesn't apply).
Split usePetPackageWidget / useWordQuizWidget / useChatCommandSelector (data/actions) Data/actions split remains a bad fit, but all three got real modernization in 2026-05-18 instead: usePetPackageWidget → useReducer + extracted getPetPackageNameError pure helper + 4 tests; useWordQuizWidget → fixed stale-closure bug in setUserAnswers updater + useRef for the timeout handle; useChatCommandSelector → module-level let cache replaced with a Zustand store.
Migrate more consumers to renderer snapshot hooks Unblocked. Three pilot consumers shipped 2026-05-19 (d28819d), pattern documented above. Next candidates: any code reading from GetSessionDataManager().userId / userName / clubLevel / securityLevel, GetRoomSessionManager().getActiveSession(), or GetSoundManager().<volume> synchronously — those don't re-render today when the value changes. Rule: snapshot read MUST be outside any useBetween scope (CI gate yarn lint:hooks catches violations; regression test at src/hooks/session/useSessionSnapshots.test.tsx).
Widen the component / hook test coverage Mock layer is in place (src/nitro-renderer.mock.ts) and 3+ hook/component pilots pass. Good follow-up targets: LoginView Form Actions happy/error paths, OfferView with useNitroQuery. (Acceptable only as a side-effect of a real change — coverage growth on its own is deprioritized per session feedback.)

Known open logic bugs

None on this branch. The two previously-open races are closed:

  • MainView CREATED/ENDED race → fixed in 9d10e52 via a session-aware reducer pattern.
  • LayoutFurniImageView / LayoutAvatarImageView async fetch race → fixed in 97c9717 via requestIdRef guard on the async callback.

See docs/ARCHITECTURE.md "Recently fixed" for fix shapes.

House rules

  • Commit author: simoleo89 <simoleo89@users.noreply.github.com>. When committing, pass these via per-command overrides (git -c user.name=simoleo89 -c user.email=...) — do NOT modify the global git config.
  • No claude/... branch names — auto-generated names should be renamed before pushing. Prefer feat/<description>.
  • Never merge a branch that violates the layout convention above. The feat/react19-hooks-adapter branch (deleted) put hooks under src/components/...; that's wrong and a recurring temptation.
  • Skip-motivated god-hook splits are fine — when a hook's actions mutate internal state, document the reason in the commit message and move on rather than forcing a bad split.
  • yarn test must stay green on every commit. Currently 193/193. The GitHub Actions workflow at .github/workflows/ci.yml runs yarn typecheck + yarn test --run on every push to main / feat/** and on every PR — both must pass.
  • Lint baseline: don't regress. Some pre-existing errors (FC<{}>, IMessageEvent | undefined redundant union in the local sandbox where the renderer SDK isn't installed) are out of scope here.

Where everything lives

  • Architecture doc: docs/ARCHITECTURE.md
  • Test runner config: vitest.config.mts (separate from vite.config.mjs)
  • Test setup: src/test-setup.ts
  • Test convention: co-located under src/ next to the subject (src/<path>/Foo.tssrc/<path>/Foo.test.ts). No separate tests/ tree.
  • React Query adapter: src/api/nitro-query/createNitroQuery.ts
  • Zustand factory: src/state/createNitroStore.ts
  • Error boundary: src/common/error-boundary/WidgetErrorBoundary.tsx
  • Event hooks (useNitroEvent, useMessageEvent, useNitroEventState, useMessageEventState): src/hooks/events/
  • Wired-tools split (types/constants/helpers + 3 tab views): src/components/wired-tools/
  • User account settings (cherry-picked from upstream PR #126): src/components/user-settings/UserAccountSettingsView.tsx
  • Access-token persistence helper (used by login + remember + rotate): src/api/auth/accessToken.ts (persistAccessTokenFromPayload)
  • Asset middleware: nitroAssetsServer() in vite.config.mjs
  • Configuration pre-init: src/bootstrap.ts (await GetConfiguration().init() before import('./index'))
  • Catalog pure helpers: src/hooks/catalog/useCatalog.helpers.ts (buildCatalogNodeTree, findNodeById / findNodeByName, getNodesByOfferIdFromMap, getOfferProductKeys, normalizeCatalogType, resolveBuilderFurniPlaceableStatus)
  • Catalog three-way filter split: useCatalogData / useCatalogUiState / useCatalogActions in src/hooks/catalog/useCatalog.ts (all 48 consumers migrated; deprecated useCatalog shim removed)
  • Renderer-SDK mock for Vitest: src/nitro-renderer.mock.ts (aliased over @nitrots/nitro-renderer via vitest.config.mts). Hosts the explicit NitroLogger mock, the mockEventDispatcher / clearMockEventDispatcher helpers used by hook tests, the RoomSessionDoorbellEvent stub, and a long list of placeholder classes/enums kept around just so the src/api/* barrel cascade imports without throwing. Grow this file when a new test needs a symbol; prefer real deterministic stubs over vi.fn().