From fd1835ca5daf583b09e3e8a35b7e65558c2815b4 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 11 May 2026 16:31:53 +0000 Subject: [PATCH] Enable Zustand (proposal #5) + convert isCreatingRoom singleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the refactor plan in docs/ARCHITECTURE.md. Install - yarn add zustand (^5, matches React 19 peer requirement). Wiring - src/state/createNitroStore.ts: replaces the previous prototype (which threw on call) with a re-export of zustand's `create` under the project-local name `createNitroStore`. Comments document the convention (one store per domain, subscribe to slices not the whole store). First migration target - src/components/navigator/views/navigatorRoomCreatorStore.ts (new): a Zustand store with `isCreating: boolean` and `beginCreate()` — the latter latches the flag to true, dispatches an internal setTimeout to auto-reset after 5s, and replaces any in-flight timer on re-entry. The timer handle lives in the store's closure, so a remount of the view doesn't reset the lockout and StrictMode's double-mount no longer schedules two pending timers. - src/components/navigator/views/NavigatorRoomCreatorView.tsx: removes the two module-level `let` variables that the React Compiler was flagging ("Writing to a variable defined outside a component is not allowed"). The component now reads `isCreating` via a slice subscription and calls `beginCreate()` from the click handler. The imperative guard (`if (isCreating) return`) uses `useRoomCreatorStore.getState()` so it reads the latest value synchronously without being a stale closure. - Also cleans up `FC<{}>` -> `FC` while touching the file. Verification - yarn eslint on the three touched files: 1 pre-existing error (the `setCategory(categories[0].id)` set-state-in-effect on the categories hook, deliberately left as-is in Phase C — it's the "init from late-arriving async data" pattern; baseline matches). - yarn tsc: clean. Migration path (per docs/ARCHITECTURE.md) - This is the smallest possible Zustand pilot (~30 lines), chosen because the let-singleton anti-pattern was the most obvious quick win and the React Compiler was already complaining about it. - Next adoption targets (cross-feature UI state): the toolbar's active-window state (currently inside scattered Contexts), the notification center's open-state, the catalog's currentPage/selection state (after the god-hook split). https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q --- package.json | 3 +- .../views/NavigatorRoomCreatorView.tsx | 21 +++------ .../views/navigatorRoomCreatorStore.ts | 44 ++++++++++++++++++ src/state/createNitroStore.ts | 45 ++++--------------- yarn.lock | 5 +++ 5 files changed, 66 insertions(+), 52 deletions(-) create mode 100644 src/components/navigator/views/navigatorRoomCreatorStore.ts diff --git a/package.json b/package.json index 89ca35f..985c3d2 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "react-error-boundary": "^6.1.1", "react-icons": "^5.6.0", "react-player": "^2.16.0", - "use-between": "^1.4.0" + "use-between": "^1.4.0", + "zustand": "^5.0.13" }, "devDependencies": { "@tailwindcss/forms": "^0.5.11", diff --git a/src/components/navigator/views/NavigatorRoomCreatorView.tsx b/src/components/navigator/views/NavigatorRoomCreatorView.tsx index 98b9e3d..af278c6 100644 --- a/src/components/navigator/views/NavigatorRoomCreatorView.tsx +++ b/src/components/navigator/views/NavigatorRoomCreatorView.tsx @@ -5,13 +5,11 @@ import { GetClubMemberLevel, GetConfigurationValue, IRoomModel, LocalizeText, Se import { Button, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, Text } from '../../../common'; import { useNavigator } from '../../../hooks'; import { NitroInput } from '../../../layout'; - -let isCreatingRoom = false; -let createRoomTimeout: ReturnType = null; +import { useRoomCreatorStore } from './navigatorRoomCreatorStore'; const MAX_VISITORS_LIST: number[] = Array.from({ length: 10 }, (_, i) => (i + 1) * 10); -export const NavigatorRoomCreatorView: FC<{}> = props => +export const NavigatorRoomCreatorView: FC = () => { const [ name, setName ] = useState(null); const [ description, setDescription ] = useState(null); @@ -25,7 +23,8 @@ export const NavigatorRoomCreatorView: FC<{}> = props => return (models && models.length) ? models[0].name : ''; }); - const [ isCreating, setIsCreating ] = useState(isCreatingRoom); + const isCreating = useRoomCreatorStore(s => s.isCreating); + const beginCreate = useRoomCreatorStore(s => s.beginCreate); const { categories = null } = useNavigator(); const hcDisabled = GetConfigurationValue('hc.disabled', false); @@ -41,19 +40,11 @@ export const NavigatorRoomCreatorView: FC<{}> = props => const createRoom = () => { - if(isCreatingRoom) return; + if(useRoomCreatorStore.getState().isCreating) return; - isCreatingRoom = true; - setIsCreating(true); + beginCreate(); SendMessageComposer(new CreateFlatMessageComposer(name, description, 'model_' + selectedModelName, Number(category), Number(visitorsCount), tradesSetting)); - - if(createRoomTimeout) clearTimeout(createRoomTimeout); - createRoomTimeout = setTimeout(() => - { - isCreatingRoom = false; - setIsCreating(false); - }, 5000); }; useEffect(() => diff --git a/src/components/navigator/views/navigatorRoomCreatorStore.ts b/src/components/navigator/views/navigatorRoomCreatorStore.ts new file mode 100644 index 0000000..39d8591 --- /dev/null +++ b/src/components/navigator/views/navigatorRoomCreatorStore.ts @@ -0,0 +1,44 @@ +import { createNitroStore } from '../../../state/createNitroStore'; + +const CREATE_LOCKOUT_MS = 5000; + +interface RoomCreatorState +{ + isCreating: boolean; + /** + * Latch `isCreating` to true and auto-clear after CREATE_LOCKOUT_MS. + * Acts as a debounce so duplicate clicks on the Create button can't + * dispatch the composer twice. + */ + beginCreate: () => void; +} + +/** + * Replaces the previous `let isCreatingRoom` / `let createRoomTimeout` + * module-level pair (anti-pattern flagged by the React Compiler: + * "Writing to a variable defined outside a component or hook"). + * + * The timer handle lives in the store's closure so a remount of the + * view doesn't reset the in-flight lockout, and so React strict-mode's + * double-mount no longer schedules two pending timers. + */ +export const useRoomCreatorStore = createNitroStore()((set) => +{ + let timeoutHandle: ReturnType | null = null; + + return { + isCreating: false, + beginCreate: () => + { + if(timeoutHandle !== null) clearTimeout(timeoutHandle); + + set({ isCreating: true }); + + timeoutHandle = setTimeout(() => + { + timeoutHandle = null; + set({ isCreating: false }); + }, CREATE_LOCKOUT_MS); + } + }; +}); diff --git a/src/state/createNitroStore.ts b/src/state/createNitroStore.ts index 386ab0f..79a8cb1 100644 --- a/src/state/createNitroStore.ts +++ b/src/state/createNitroStore.ts @@ -1,42 +1,15 @@ /** - * Skeleton for proposal #5 (unified UI store). + * Re-export of zustand's `create` under a project-local name. * - * NOT YET ENABLED — `zustand` is not in package.json. - * To activate: + * Convention: each domain owns one store file. Either: + * - `src/state/.ts` for cross-feature stores + * - `src/components//Store.ts` for feature-local stores * - * yarn add zustand + * Components subscribe to specific slices only: * - * Then this file becomes: + * const isCreating = useNavigatorRoomCreatorStore(s => s.isCreating); * - * 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//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()((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. + * Do NOT pull the whole store (`const all = useStore()`) — that + * subscribes to every change and defeats the point. */ - -export const createNitroStore = (): never => -{ - throw new Error('createNitroStore is not enabled. See docs/ARCHITECTURE.md proposal #5.'); -}; +export { create as createNitroStore } from 'zustand'; diff --git a/yarn.lock b/yarn.lock index 1108213..b79c1d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3221,3 +3221,8 @@ zod@^3.22.4: version "4.4.3" resolved "https://registry.yarnpkg.com/zod/-/zod-4.4.3.tgz#b680f172885d18bbebf21a834ea25e55a1bbf356" integrity sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ== + +zustand@^5.0.13: + version "5.0.13" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.13.tgz#06995c126e8903cd27100af04da91c36ae3051ed" + integrity sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==