refactor(useChatCommandSelector): move module-level mutable cache into a Zustand store

Two module-level `let` declarations (cachedServerCommands +
globalListenerRegistered) were tracking the AvailableCommandsEvent
listener state outside React. The pattern was a React Compiler
violation flagged elsewhere in the codebase (the navigatorRoomCreator
fix was the canonical precedent — see commit fd1835c).

Move both into a per-hook Zustand store
(`useChatCommandStore`) following the same convention as
`useWiredCreatorToolsUiStore` and `useRoomCreatorStore`. The store
keeps the cached server-pushed CommandDefinitions plus a
single-shot isListenerRegistered flag that prevents the in-hook
useMessageEvent and the module-level pre-mount listener from
double-registering.

`CLIENT_COMMANDS` stays at module scope — it's a const array,
React Compiler is fine with constant data.

Behavioural change: zero. The pre-mount registration still tries
once at module load (covering the case where the server's
AvailableCommands lands before any React widget mounts). The in-hook
useMessageEvent still covers later mounts and rank-change refreshes.
Every push goes through `setServerCommands`, so all consumers see
the same data.

Side benefit: a future test can now `useChatCommandStore.setState({
  serverCommands: [...], isListenerRegistered: true })` to seed a
deterministic fixture without monkey-patching the module.

Public API of useChatCommandSelector unchanged; the one consumer
(ChatInputView) reads the same destructured fields. Verified via grep.

Suite: 207/207.
This commit is contained in:
simoleo89
2026-05-18 21:44:59 +02:00
parent 5259c8930f
commit 19b48513d8
@@ -1,8 +1,10 @@
import { AvailableCommandsEvent, GetCommunication } from '@nitrots/nitro-renderer'; import { AvailableCommandsEvent, GetCommunication } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { CommandDefinition } from '../../../api'; import { CommandDefinition } from '../../../api';
import { createNitroStore } from '../../../state/createNitroStore';
import { useMessageEvent } from '../../events'; import { useMessageEvent } from '../../events';
// Client-only commands are static; safe to keep at module scope.
const CLIENT_COMMANDS: CommandDefinition[] = [ const CLIENT_COMMANDS: CommandDefinition[] = [
// Effetti stanza // Effetti stanza
{ key: 'shake', description: 'Scuoti la stanza' }, { key: 'shake', description: 'Scuoti la stanza' },
@@ -31,60 +33,79 @@ const CLIENT_COMMANDS: CommandDefinition[] = [
{ key: 'nitro', description: 'Info client' }, { key: 'nitro', description: 'Info client' },
]; ];
// Module-level cache: cattura i comandi dal server anche prima che React monti /**
let cachedServerCommands: CommandDefinition[] = []; * Server-pushed command cache. Lives in a Zustand store (instead of
let globalListenerRegistered = false; * module-level `let` variables) so the React Compiler can analyze the
* surrounding hook cleanly, and so a future test can `setState({…})`
function ensureGlobalListener(): void * a deterministic fixture without monkey-patching the module.
*
* The `isListenerRegistered` flag prevents the renderer from getting
* two AvailableCommandsEvent listeners — one from the module-level
* pre-mount registration (which captures the server's reply that lands
* during login, BEFORE any React widget mounts) and one from the
* in-hook `useMessageEvent` (which covers later rank-change refreshes).
*/
interface ChatCommandStore
{ {
if(globalListenerRegistered) return; serverCommands: CommandDefinition[];
globalListenerRegistered = true; isListenerRegistered: boolean;
setServerCommands: (commands: CommandDefinition[]) => void;
markListenerRegistered: () => void;
}
const useChatCommandStore = createNitroStore<ChatCommandStore>()((set) => ({
serverCommands: [],
isListenerRegistered: false,
setServerCommands: (commands) => set({ serverCommands: commands }),
markListenerRegistered: () => set({ isListenerRegistered: true })
}));
const ensureGlobalListener = (): void =>
{
if(useChatCommandStore.getState().isListenerRegistered) return;
try try
{ {
const event = new AvailableCommandsEvent((event: AvailableCommandsEvent) => const event = new AvailableCommandsEvent((event: AvailableCommandsEvent) =>
{ {
const parser = event.getParser(); const parser = event.getParser();
cachedServerCommands = parser.commands.map(cmd => ({ key: cmd.key, description: cmd.description })); useChatCommandStore.getState().setServerCommands(parser.commands.map(cmd => ({ key: cmd.key, description: cmd.description })));
}); });
GetCommunication().registerMessageEvent(event); GetCommunication().registerMessageEvent(event);
useChatCommandStore.getState().markListenerRegistered();
} }
catch(e) catch
{ {
// Communication not ready yet, will retry on hook mount // Communication not ready yet — the in-hook useMessageEvent
globalListenerRegistered = false; // below covers later mounts.
} }
} };
// Try to register immediately at module load // Try once at module load so the server's response landing before any
// React mount still hits the cache.
ensureGlobalListener(); ensureGlobalListener();
export const useChatCommandSelector = (chatValue: string) => export const useChatCommandSelector = (chatValue: string) =>
{ {
const [ serverCommands, setServerCommands ] = useState<CommandDefinition[]>(cachedServerCommands); const serverCommands = useChatCommandStore(s => s.serverCommands);
const setServerCommands = useChatCommandStore(s => s.setServerCommands);
const [ selectedIndex, setSelectedIndex ] = useState(0); const [ selectedIndex, setSelectedIndex ] = useState(0);
const [ dismissed, setDismissed ] = useState(false); const [ dismissed, setDismissed ] = useState(false);
// Ensure global listener is registered
useEffect(() => useEffect(() =>
{ {
// Cover the case where the module-level registration failed
// because GetCommunication() wasn't ready at import time.
ensureGlobalListener(); ensureGlobalListener();
// If cache already has data (from login), use it
if(cachedServerCommands.length > 0 && serverCommands.length === 0)
{
setServerCommands(cachedServerCommands);
}
}, []); }, []);
// Also listen via React hook for any future updates (e.g. rank change) // Late updates (rank change, etc.) — go through the store so all
// consumers see the same data.
useMessageEvent<AvailableCommandsEvent>(AvailableCommandsEvent, event => useMessageEvent<AvailableCommandsEvent>(AvailableCommandsEvent, event =>
{ {
const parser = event.getParser(); const parser = event.getParser();
const cmds = parser.commands.map(cmd => ({ key: cmd.key, description: cmd.description })); setServerCommands(parser.commands.map(cmd => ({ key: cmd.key, description: cmd.description })));
cachedServerCommands = cmds;
setServerCommands(cmds);
}); });
const allCommands = useMemo(() => const allCommands = useMemo(() =>
@@ -93,10 +114,7 @@ export const useChatCommandSelector = (chatValue: string) =>
for(const clientCmd of CLIENT_COMMANDS) for(const clientCmd of CLIENT_COMMANDS)
{ {
if(!merged.some(cmd => cmd.key === clientCmd.key)) if(!merged.some(cmd => cmd.key === clientCmd.key)) merged.push(clientCmd);
{
merged.push(clientCmd);
}
} }
return merged.sort((a, b) => a.key.localeCompare(b.key)); return merged.sort((a, b) => a.key.localeCompare(b.key));