From 19b48513d8e7f2d620e0219b01ba19827c79ec4d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 18 May 2026 21:44:59 +0200 Subject: [PATCH] refactor(useChatCommandSelector): move module-level mutable cache into a Zustand store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../rooms/widgets/useChatCommandSelector.ts | 76 ++++++++++++------- 1 file changed, 47 insertions(+), 29 deletions(-) diff --git a/src/hooks/rooms/widgets/useChatCommandSelector.ts b/src/hooks/rooms/widgets/useChatCommandSelector.ts index eb31b68..9584e92 100644 --- a/src/hooks/rooms/widgets/useChatCommandSelector.ts +++ b/src/hooks/rooms/widgets/useChatCommandSelector.ts @@ -1,8 +1,10 @@ import { AvailableCommandsEvent, GetCommunication } from '@nitrots/nitro-renderer'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { CommandDefinition } from '../../../api'; +import { createNitroStore } from '../../../state/createNitroStore'; import { useMessageEvent } from '../../events'; +// Client-only commands are static; safe to keep at module scope. const CLIENT_COMMANDS: CommandDefinition[] = [ // Effetti stanza { key: 'shake', description: 'Scuoti la stanza' }, @@ -31,60 +33,79 @@ const CLIENT_COMMANDS: CommandDefinition[] = [ { key: 'nitro', description: 'Info client' }, ]; -// Module-level cache: cattura i comandi dal server anche prima che React monti -let cachedServerCommands: CommandDefinition[] = []; -let globalListenerRegistered = false; - -function ensureGlobalListener(): void +/** + * Server-pushed command cache. Lives in a Zustand store (instead of + * module-level `let` variables) so the React Compiler can analyze the + * surrounding hook cleanly, and so a future test can `setState({…})` + * 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; - globalListenerRegistered = true; + serverCommands: CommandDefinition[]; + isListenerRegistered: boolean; + setServerCommands: (commands: CommandDefinition[]) => void; + markListenerRegistered: () => void; +} + +const useChatCommandStore = createNitroStore()((set) => ({ + serverCommands: [], + isListenerRegistered: false, + setServerCommands: (commands) => set({ serverCommands: commands }), + markListenerRegistered: () => set({ isListenerRegistered: true }) +})); + +const ensureGlobalListener = (): void => +{ + if(useChatCommandStore.getState().isListenerRegistered) return; try { const event = new AvailableCommandsEvent((event: AvailableCommandsEvent) => { 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); + useChatCommandStore.getState().markListenerRegistered(); } - catch(e) + catch { - // Communication not ready yet, will retry on hook mount - globalListenerRegistered = false; + // Communication not ready yet — the in-hook useMessageEvent + // 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(); export const useChatCommandSelector = (chatValue: string) => { - const [ serverCommands, setServerCommands ] = useState(cachedServerCommands); + const serverCommands = useChatCommandStore(s => s.serverCommands); + const setServerCommands = useChatCommandStore(s => s.setServerCommands); const [ selectedIndex, setSelectedIndex ] = useState(0); const [ dismissed, setDismissed ] = useState(false); - // Ensure global listener is registered useEffect(() => { + // Cover the case where the module-level registration failed + // because GetCommunication() wasn't ready at import time. 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, event => { const parser = event.getParser(); - const cmds = parser.commands.map(cmd => ({ key: cmd.key, description: cmd.description })); - cachedServerCommands = cmds; - setServerCommands(cmds); + setServerCommands(parser.commands.map(cmd => ({ key: cmd.key, description: cmd.description }))); }); const allCommands = useMemo(() => @@ -93,10 +114,7 @@ export const useChatCommandSelector = (chatValue: string) => for(const clientCmd of CLIENT_COMMANDS) { - if(!merged.some(cmd => cmd.key === clientCmd.key)) - { - merged.push(clientCmd); - } + if(!merged.some(cmd => cmd.key === clientCmd.key)) merged.push(clientCmd); } return merged.sort((a, b) => a.key.localeCompare(b.key));