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.
20 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
-
Clone the renderer SDK as a sibling of this repo.
vite.config.mjsresolves 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 -
Install client deps.
cd ../Nitro-V3 yarn install -
Materialize the runtime configuration.
public/configuration/ships.examplefiles. Copy the ones you need without the.examplesuffix 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. -
Run.
- Dev:
yarn start(Vite, HMR, includes the renderer source). - Production preview:
yarn build && yarn preview.
- Dev:
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-dom19.2.5,@types/react19.2.x. - TypeScript: TS 6 for build, TS 7 native preview (
@typescript/native-preview, invoked viatsgo) for thetypecheckscript. - Vite 8 +
@vitejs/plugin-react6 +babel-plugin-react-compiler1.0. - ESLint 10 +
typescript-eslint8 +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-boundary6.
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 valuesuse<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: zero in-tree consumers. The first three pilot
migrations (useSessionInfo, useChatWidget.ownUserId,
AvatarInfoWidgetAvatarView Ignore/Unignore) were rolled back in
e142efd after a persistent runtime error
(intermediate value)() is undefined at ToolbarView.tsx:46 that the
vite-alias fix (790ad2b) and the defensive guards (c35a2d4) could
not eliminate. The hooks remain available for any future opt-in
consumer, but do not migrate useBetween-shared consumers to them
without isolated testing first — that combination is the suspected
cause of the bug.
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) |
No in-tree consumers — three pilot migrations rolled back in e142efd due to a runtime (intermediate value)() is undefined at ToolbarView.tsx:46 that survived both the vite-alias fix and defensive guards. The 8 hooks (userData / activeRoomSession / ignoredUsers / groupBadges / soundVolumes / roomUserList / isUserIgnored / groupBadge) remain available as opt-in API with frozen-default fallbacks. |
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). The useChatWidget reactive-ownUserId migration in the same commit was rolled back in e142efd; the hook is back on GetSessionDataManager()?.userId (static at mount). |
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 any consumer to renderer snapshot hooks | Blocked. Three pilot migrations were rolled back in e142efd after the useSessionInfo migration triggered a persistent runtime error at ToolbarView.tsx:46. The defensive guards in useSessionSnapshots.ts and the umbrella vite alias (790ad2b) are still in place. Before retrying, isolate the cause: the suspected interaction is useBetween + useSyncExternalStore + React Compiler. Try a NON-useBetween consumer first (e.g. a fresh per-component hook usage in a low-blast-radius widget). |
| 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:
MainViewCREATED/ENDED race → fixed in9d10e52via a session-aware reducer pattern.LayoutFurniImageView/LayoutAvatarImageViewasync fetch race → fixed in97c9717viarequestIdRefguard 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. Preferfeat/<description>. - Never merge a branch that violates the layout convention above.
The
feat/react19-hooks-adapterbranch (deleted) put hooks undersrc/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 testmust stay green on every commit. Currently 193/193. The GitHub Actions workflow at.github/workflows/ci.ymlrunsyarn typecheck+yarn test --runon every push tomain/feat/**and on every PR — both must pass.- Lint baseline: don't regress. Some pre-existing errors (
FC<{}>,IMessageEvent | undefinedredundant 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 fromvite.config.mjs) - Test setup:
src/test-setup.ts - Test convention: co-located under
src/next to the subject (src/<path>/Foo.ts↔src/<path>/Foo.test.ts). No separatetests/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()invite.config.mjs - Configuration pre-init:
src/bootstrap.ts(await GetConfiguration().init()beforeimport('./index')) - Catalog pure helpers:
src/hooks/catalog/useCatalog.helpers.ts(buildCatalogNodeTree,findNodeById/findNodeByName,getNodesByOfferIdFromMap,getOfferProductKeys,normalizeCatalogType,resolveBuilderFurniPlaceableStatus) - Catalog three-way filter split:
useCatalogData/useCatalogUiState/useCatalogActionsinsrc/hooks/catalog/useCatalog.ts(all 48 consumers migrated; deprecateduseCatalogshim removed) - Renderer-SDK mock for Vitest:
src/nitro-renderer.mock.ts(aliased over@nitrots/nitro-rendererviavitest.config.mts). Hosts the explicitNitroLoggermock, themockEventDispatcher/clearMockEventDispatcherhelpers used by hook tests, theRoomSessionDoorbellEventstub, and a long list of placeholder classes/enums kept around just so thesrc/api/*barrel cascade imports without throwing. Grow this file when a new test needs a symbol; prefer real deterministic stubs overvi.fn().