Architecture refactor: docs + 5 pilot implementations + error boundary

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
This commit is contained in:
simoleo89
2026-05-11 16:31:52 +00:00
parent 22a44d18b0
commit 48d62c5c6b
13 changed files with 648 additions and 93 deletions
+84
View File
@@ -0,0 +1,84 @@
/**
* Adapter prototype for proposal #2 (server requests as queries).
*
* NOT YET ENABLED — `@tanstack/react-query` is not in package.json.
* To activate:
*
* yarn add @tanstack/react-query @tanstack/react-query-devtools
*
* Then mount the provider once in `src/index.tsx`:
*
* import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
* const queryClient = new QueryClient({
* defaultOptions: { queries: { staleTime: 30_000, retry: 1 } }
* });
* <QueryClientProvider client={queryClient}><App /></QueryClientProvider>
*
* Then this file becomes:
*
* import { useQuery } from '@tanstack/react-query';
* ...
*
* The interface below shows the intended API. Once enabled, replace the
* placeholder bodies with the real `useQuery` calls.
*/
import { IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer';
import { SendMessageComposer } from '../SendMessageComposer';
export interface NitroQueryConfig<TParser extends IMessageEvent, TData>
{
/**
* Stable key used for caching/deduping (TanStack Query queryKey).
* Convention: ['nitro', '<domain>', '<request>', ...args].
*/
key: readonly unknown[];
/**
* Factory for the request composer. Called once per query execution.
*/
request: () => any;
/**
* The parser class to listen for as the response.
*/
parser: typeof MessageEvent;
/**
* Maps the parser event to the data the component cares about.
*/
select?: (event: TParser) => TData;
/**
* Optional max time to wait for the response before failing.
*/
timeoutMs?: number;
}
/**
* Build a one-shot Promise that sends a composer and resolves with the
* matching parser event. To be passed into TanStack Query's queryFn:
*
* useQuery({
* queryKey: cfg.key,
* queryFn: () => awaitNitroResponse(cfg),
* });
*
* Implementation outline (filled in once react-query is added):
*
* return new Promise<TData>((resolve, reject) => {
* const event = new cfg.parser((e: TParser) => {
* GetCommunication().removeMessageEvent(event);
* resolve(cfg.select ? cfg.select(e) : (e as unknown as TData));
* });
* GetCommunication().registerMessageEvent(event);
* SendMessageComposer(cfg.request());
* if (cfg.timeoutMs) setTimeout(() => {
* GetCommunication().removeMessageEvent(event);
* reject(new Error('NitroQuery timeout'));
* }, cfg.timeoutMs);
* });
*/
export const awaitNitroResponse = <TParser extends IMessageEvent, TData>(
_cfg: NitroQueryConfig<TParser, TData>
): Promise<TData> =>
{
void SendMessageComposer;
throw new Error('useNitroQuery is not enabled. See docs/ARCHITECTURE.md proposal #2.');
};
+1
View File
@@ -0,0 +1 @@
export * from './createNitroQuery';
@@ -0,0 +1,28 @@
import { NitroLogger } from '@nitrots/nitro-renderer';
import { FC, ReactNode } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
interface WidgetErrorBoundaryProps
{
name?: string;
fallback?: ReactNode;
children: ReactNode;
}
const SilentFallback = (_props: FallbackProps) => null;
/**
* Wraps a (room) widget so a runtime error inside it degrades gracefully
* instead of unmounting the whole UI. Errors are logged to NitroLogger
* with the widget name.
*
* Bonus addition from docs/ARCHITECTURE.md.
*/
export const WidgetErrorBoundary: FC<WidgetErrorBoundaryProps> = ({ name = 'unknown', fallback, children }) =>
(
<ErrorBoundary
FallbackComponent={ fallback ? () => <>{ fallback }</> : SilentFallback }
onError={ (err) => NitroLogger.error(`[Widget:${ name }] crashed`, err) }>
{ children }
</ErrorBoundary>
);
+1
View File
@@ -16,6 +16,7 @@ export * from './card';
export * from './card/accordion';
export * from './card/tabs';
export * from './draggable-window';
export * from './error-boundary/WidgetErrorBoundary';
export * from './layout';
export * from './layout/limited-edition';
export * from './types';
@@ -1,6 +1,7 @@
import { GetRoomEngine, RoomEngineObjectEvent, RoomEngineRoomAdEvent, RoomEngineTriggerWidgetEvent, RoomEngineUseProductEvent, RoomId, RoomSessionErrorMessageEvent, RoomZoomEvent } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { DispatchUiEvent, LocalizeText, NotificationAlertType, RoomWidgetUpdateRoomObjectEvent } from '../../../api';
import { WidgetErrorBoundary } from '../../../common';
import { useNitroEvent, useNotification, useRoom } from '../../../hooks';
import { AvatarInfoWidgetView } from './avatar-info/AvatarInfoWidgetView';
import { ChatInputView } from './chat-input/ChatInputView';
@@ -153,7 +154,7 @@ export const RoomWidgetsView: FC<{}> = props =>
});
return (
<>
<WidgetErrorBoundary name="RoomWidgets">
<div className="absolute top-0 left-0 pointer-events-none size-full">
<FurnitureWidgetsView />
</div>
@@ -169,6 +170,6 @@ export const RoomWidgetsView: FC<{}> = props =>
<UserChooserWidgetView />
<WordQuizWidgetView />
<FriendRequestWidgetView />
</>
</WidgetErrorBoundary>
);
};
@@ -1,51 +1 @@
import { FC, useEffect, useState } from 'react';
import { LocalizeText } from '../../../../api';
import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useDoorbellWidget } from '../../../../hooks';
export const DoorbellWidgetView: FC<{}> = props =>
{
const [ isVisible, setIsVisible ] = useState(false);
const { users = [], answer = null } = useDoorbellWidget();
useEffect(() =>
{
setIsVisible(!!users.length);
}, [ users ]);
if(!isVisible) return null;
return (
<NitroCardView className="nitro-widget-doorbell" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText('navigator.doorbell.title') } onCloseClick={ event => setIsVisible(false) } />
<NitroCardContentView gap={ 0 } overflow="hidden">
<Column gap={ 2 }>
<Grid className="text-black font-bold border-bottom px-1 pb-1" gap={ 1 }>
<div className="col-span-6">{ LocalizeText('generic.username') }</div>
<div className="col-span-6" />
</Grid>
</Column>
<Column className="striped-children" gap={ 0 } overflow="auto">
{ users && (users.length > 0) && users.map(userName =>
{
return (
<Grid key={ userName } alignItems="center" className="text-black border-bottom p-1" gap={ 1 }>
<div className="col-span-6">{ userName }</div>
<div className="col-span-6">
<div className="flex items-center gap-1 justify-end">
<Button variant="success" onClick={ () => answer(userName, true) }>
{ LocalizeText('generic.accept') }
</Button>
<Button variant="danger" onClick={ () => answer(userName, false) }>
{ LocalizeText('generic.deny') }
</Button>
</div>
</div>
</Grid>
);
}) }
</Column>
</NitroCardContentView>
</NitroCardView>
);
};
export { DoorbellWidgetView } from '../../../../features/doorbell';
@@ -0,0 +1,13 @@
import { GetRoomSession } from '../../../api';
/**
* Imperative actions for the doorbell. Stateless on purpose — split from
* useDoorbellState (proposal #4) so components that only need to dispatch
* an answer don't subscribe to the events.
*/
export const useDoorbellActions = () => ({
answer: (userName: string, flag: boolean): void =>
{
GetRoomSession()?.sendDoorbellApprovalMessage(userName, flag);
}
});
@@ -0,0 +1,46 @@
import { RoomSessionDoorbellEvent } from '@nitrots/nitro-renderer';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useNitroEvent } from '../../../hooks';
/**
* Reduces the three doorbell events (DOORBELL, RSDE_ACCEPTED, RSDE_REJECTED)
* into a single users array.
*
* This is the proposal #4 split: data-only hook. Actions are in
* useDoorbellActions.
*/
export const useDoorbellState = (): readonly string[] =>
{
const [ users, setUsers ] = useState<string[]>([]);
const usersRef = useRef(users);
useLayoutEffect(() =>
{
usersRef.current = users;
});
const handleAdd = useCallback((event: RoomSessionDoorbellEvent) =>
{
if(usersRef.current.indexOf(event.userName) >= 0) return;
setUsers([ ...usersRef.current, event.userName ]);
}, []);
const handleRemove = useCallback((event: RoomSessionDoorbellEvent) =>
{
const index = usersRef.current.indexOf(event.userName);
if(index === -1) return;
const next = [ ...usersRef.current ];
next.splice(index, 1);
setUsers(next);
}, []);
useNitroEvent<RoomSessionDoorbellEvent>(RoomSessionDoorbellEvent.DOORBELL, handleAdd);
useNitroEvent<RoomSessionDoorbellEvent>(RoomSessionDoorbellEvent.RSDE_ACCEPTED, handleRemove);
useNitroEvent<RoomSessionDoorbellEvent>(RoomSessionDoorbellEvent.RSDE_REJECTED, handleRemove);
return users;
};
+3
View File
@@ -0,0 +1,3 @@
export { useDoorbellActions } from './hooks/useDoorbellActions';
export { useDoorbellState } from './hooks/useDoorbellState';
export { DoorbellWidgetView } from './views/DoorbellWidgetView';
@@ -0,0 +1,47 @@
import { FC, useState } from 'react';
import { LocalizeText } from '../../../api';
import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../common';
import { useDoorbellActions } from '../hooks/useDoorbellActions';
import { useDoorbellState } from '../hooks/useDoorbellState';
export const DoorbellWidgetView: FC = () =>
{
const users = useDoorbellState();
const { answer } = useDoorbellActions();
const [ dismissed, setDismissed ] = useState(false);
const isVisible = !dismissed && users.length > 0;
if(!isVisible) return null;
return (
<NitroCardView className="nitro-widget-doorbell" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText('navigator.doorbell.title') } onCloseClick={ () => setDismissed(true) } />
<NitroCardContentView gap={ 0 } overflow="hidden">
<Column gap={ 2 }>
<Grid className="text-black font-bold border-bottom px-1 pb-1" gap={ 1 }>
<div className="col-span-6">{ LocalizeText('generic.username') }</div>
<div className="col-span-6" />
</Grid>
</Column>
<Column className="striped-children" gap={ 0 } overflow="auto">
{ users.map(userName => (
<Grid key={ userName } alignItems="center" className="text-black border-bottom p-1" gap={ 1 }>
<div className="col-span-6">{ userName }</div>
<div className="col-span-6">
<div className="flex items-center gap-1 justify-end">
<Button variant="success" onClick={ () => answer(userName, true) }>
{ LocalizeText('generic.accept') }
</Button>
<Button variant="danger" onClick={ () => answer(userName, false) }>
{ LocalizeText('generic.deny') }
</Button>
</div>
</div>
</Grid>
)) }
</Column>
</NitroCardContentView>
</NitroCardView>
);
};
+10 -40
View File
@@ -1,44 +1,14 @@
import { RoomSessionDoorbellEvent } from '@nitrots/nitro-renderer';
import { useState } from 'react';
import { GetRoomSession } from '../../../api';
import { useNitroEvent } from '../../events';
import { useDoorbellActions, useDoorbellState } from '../../../features/doorbell';
const useDoorbellWidgetState = () =>
/**
* @deprecated Use `useDoorbellState` and `useDoorbellActions` from
* `src/features/doorbell` directly. This shim is kept so existing
* imports via the `hooks` barrel keep working.
*/
export const useDoorbellWidget = () =>
{
const [ users, setUsers ] = useState<string[]>([]);
const users = useDoorbellState();
const { answer } = useDoorbellActions();
const addUser = (userName: string) =>
{
if(users.indexOf(userName) >= 0) return;
setUsers([ ...users, userName ]);
};
const removeUser = (userName: string) =>
{
const index = users.indexOf(userName);
if(index === -1) return;
const newUsers = [ ...users ];
newUsers.splice(index, 1);
setUsers(newUsers);
};
const answer = (userName: string, flag: boolean) =>
{
GetRoomSession().sendDoorbellApprovalMessage(userName, flag);
removeUser(userName);
};
useNitroEvent<RoomSessionDoorbellEvent>(RoomSessionDoorbellEvent.DOORBELL, event => addUser(event.userName));
useNitroEvent<RoomSessionDoorbellEvent>(RoomSessionDoorbellEvent.RSDE_REJECTED, event => removeUser(event.userName));
useNitroEvent<RoomSessionDoorbellEvent>(RoomSessionDoorbellEvent.RSDE_ACCEPTED, event => removeUser(event.userName));
return { users, addUser, removeUser, answer };
return { users, answer };
};
export const useDoorbellWidget = useDoorbellWidgetState;
+42
View File
@@ -0,0 +1,42 @@
/**
* Skeleton for proposal #5 (unified UI store).
*
* NOT YET ENABLED — `zustand` is not in package.json.
* To activate:
*
* yarn add zustand
*
* Then this file becomes:
*
* import { create } from 'zustand';
* export const createNitroStore = create;
*
* The naming convention below documents the intended structure: each
* feature owns one slice file under `src/features/<feature>/state/`,
* importing `createNitroStore` from here.
*
* Example slice (to be created when zustand is installed):
*
* // src/features/wired-tools/state/wiredToolsSlice.ts
* import { createNitroStore } from '../../../state/createNitroStore';
*
* type WiredToolsState = {
* activeTab: 'monitor' | 'variables' | 'inspection' | 'chests' | 'settings';
* setActiveTab: (tab: WiredToolsState['activeTab']) => void;
* };
*
* export const useWiredToolsStore = createNitroStore<WiredToolsState>()((set) => ({
* activeTab: 'monitor',
* setActiveTab: (tab) => set({ activeTab: tab }),
* }));
*
* First migration target suggested in docs/ARCHITECTURE.md is the
* `let isCreatingRoom = false` / `createRoomTimeout` singleton pair in
* NavigatorRoomCreatorView.tsx — a ~5-line conversion that removes a
* react-compiler/react-compiler "writing outside component" violation.
*/
export const createNitroStore = (): never =>
{
throw new Error('createNitroStore is not enabled. See docs/ARCHITECTURE.md proposal #5.');
};