From a1bee1d825c9c8c34f1bec252e07f03e4c1ba43c Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 11 May 2026 16:31:50 +0000 Subject: [PATCH 001/129] React 19 modernization: forwardRef removal, Compiler, ErrorBoundary, Suspense, native + + + + + + Nitro + + +
+ + + diff --git a/package.json b/package.json index 119bdb5..aaf169a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "postcss": "^8.5.12", "postcss-nested": "^7.0.2", "sass": "^1.99.0", + "sirv": "^3.0.2", "tailwindcss": "^4.2.4", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", diff --git a/src/App.tsx b/src/App.tsx index e54defc..0a77cbf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -76,18 +76,63 @@ export const App: FC<{}> = props => const rendererPromiseRef = useRef>(null); const gameInitPromiseRef = useRef | null>(null); const bootstrapDoneRef = useRef(false); + const lastPrepareTriggerRef = useRef(null); const tickersStartedRef = useRef(false); const heartbeatIntervalRef = useRef(null); const rememberRotateIntervalRef = useRef(null); + const isReadyRef = useRef(false); + const reconnectInProgressRef = useRef(false); + + const clearStoredCredentials = useCallback(() => + { + ClearRememberLogin(); + try { delete (window as any).NitroConfig?.['sso.ticket']; } catch {} + try { GetConfiguration().setValue('sso.ticket', ''); } catch {} + // Drop `?sso=` from the URL too — otherwise the next reload re-applies + // the same already-consumed ticket via bootstrap.ts and we loop right + // back into "Session expired" without ever showing the login form. + try + { + const url = new URL(window.location.href); + + if(url.searchParams.has('sso')) + { + url.searchParams.delete('sso'); + window.history.replaceState({}, '', url.toString()); + } + } + catch {} + }, []); + + const fallbackToLogin = useCallback(() => + { + // Using console.warn (not NitroLogger.log) on purpose: NitroLogger + // is gated on LOG_DEBUG, which only flips to true once startWarmup's + // GetConfiguration().init() completes. Auth-failure paths fire before + // that, so NitroLogger swallows their messages silently. + console.warn('[App] fallbackToLogin — surfacing login form, credentials cleared'); + // Wipe whatever credential the server just rejected so the form is + // pristine and the next attempt isn't sabotaged by the same stale data. + clearStoredCredentials(); + setHomeUrl(''); + setErrorMessage(''); + setIsReady(false); + setShowLogin(true); + setIsEnteringHotel(false); + }, [ clearStoredCredentials ]); + const showSessionExpired = useCallback(() => { + console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)'); + clearStoredCredentials(); + const baseUrl = window.location.origin + '/'; setHomeUrl(baseUrl); setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.'); setIsReady(false); setShowLogin(false); setIsEnteringHotel(false); - }, []); + }, [ clearStoredCredentials ]); const applySsoTicket = useCallback((ssoTicket: string) => { @@ -109,10 +154,20 @@ export const App: FC<{}> = props => { const remembered = GetRememberLogin(); - if(!remembered) return ''; - if(!remembered.token?.length && remembered.ssoTicket?.length) return remembered.ssoTicket; + console.warn('[App] tryRememberLogin start', { + hasRemembered: !!remembered, + hasToken: !!remembered?.token?.length, + hasStoredSso: !!remembered?.ssoTicket?.length + }); - let allowSsoFallback = true; + if(!remembered?.token?.length) + { + // No remember token means we'd be reusing a one-shot ssoTicket that + // the server already consumed. Force the login screen instead. + if(remembered) ClearRememberLogin(); + console.warn('[App] tryRememberLogin → no token, returning empty'); + return ''; + } try { @@ -139,24 +194,28 @@ export const App: FC<{}> = props => const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : ''); + console.warn('[App] tryRememberLogin → remember endpoint replied', { + status: response.status, + ok: response.ok, + gotSsoTicket: !!ssoTicket + }); + if(response.ok && ssoTicket) { StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : remembered.username, ssoTicket); return ssoTicket; } - - if(response.status === 400 || response.status === 401 || response.status === 403) - { - allowSsoFallback = false; - ClearRememberLogin(); - } } catch(error) { - NitroLogger.error('[LoginScreen] Remember login failed', error); + console.warn('[App] tryRememberLogin → fetch threw', error); } - if(allowSsoFallback && remembered.ssoTicket?.length) return remembered.ssoTicket; + // Any failure (rejected token, bad payload, network error) — drop the + // stored credentials. Never fall back to the cached ssoTicket: it's + // one-shot and reusing it leads straight to "Session expired". + ClearRememberLogin(); + console.warn('[App] tryRememberLogin → cleared remember, returning empty'); return ''; }, []); @@ -204,8 +263,51 @@ export const App: FC<{}> = props => } }, []); - // Listen for socket closed events (code 1000 "Bye" - server rejected SSO) - useNitroEvent(NitroEventType.SOCKET_CLOSED, showSessionExpired); + // Mirror isReady into a ref so the socket handlers below can read the + // freshest value without needing to re-subscribe on every state change. + useEffect(() => { isReadyRef.current = isReady; }, [ isReady ]); + + // Track whether a reconnect cycle is active. The renderer dispatches + // SOCKET_RECONNECTING when it starts retrying after an abnormal close + // (code != 1000/1001). On exhausted retries it fires SOCKET_RECONNECT_FAILED + // *and* a final SOCKET_CLOSED — we keep the flag set through that pair + // so ReconnectView's own overlay owns the UX and we don't double-render. + useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () => { reconnectInProgressRef.current = true; }); + useNitroEvent(NitroEventType.SOCKET_REAUTHENTICATED, () => { reconnectInProgressRef.current = false; }); + + useNitroEvent(NitroEventType.SOCKET_CLOSED, () => + { + // Three distinct close scenarios converge here: + // + // 1. !isReady — initial handshake just failed (server replied + // with "Bye" / code 1000 to a bad SSO ticket). The user never + // had a session. Surface the login form instead of the + // misleading "Session expired" diagnostic. + // + // 2. isReady && reconnect in progress — ReconnectView already + // owns the UX (its overlay shows attempts and the "Session + // expired" message on RECONNECT_FAILED). Stay out of its way. + // + // 3. isReady && no reconnect — instant server kick mid-game + // (code 1000 from the server side). No reconnect path will + // run. Show the legacy session-expired diagnostic so the + // user knows to reload. + console.warn('[App] SOCKET_CLOSED fired', { + isReady: isReadyRef.current, + reconnectInProgress: reconnectInProgressRef.current + }); + + if(!isReadyRef.current) + { + console.warn('[App] Socket closed before authentication completed — falling back to login'); + fallbackToLogin(); + return; + } + + if(reconnectInProgressRef.current) return; + + showSessionExpired(); + }); useMessageEvent(LoadGameUrlEvent, event => { @@ -317,11 +419,19 @@ export const App: FC<{}> = props => }, []); const onSessionExpired = useEffectEvent(() => showSessionExpired()); + const onInitFailure = useEffectEvent(() => fallbackToLogin()); useEffect(() => { const prepare = async (width: number, height: number) => { + console.warn('[App] prepare() start', { + hasNitroConfig: !!window.NitroConfig, + ssoTicketInConfig: !!window.NitroConfig?.['sso.ticket'], + hasRememberLocal: !!GetRememberLogin(), + urlSso: new URLSearchParams(window.location.search).get('sso') + }); + try { if(!window.NitroConfig) throw new Error('NitroConfig is not defined!'); @@ -346,6 +456,13 @@ export const App: FC<{}> = props => const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false); const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1; + console.warn('[App] no SSO path — login gate', { + configInitError: configInitError ? String((configInitError as Error)?.message ?? configInitError) : null, + rawLoginEnabled, + rawLoginEnabledType: typeof rawLoginEnabled, + loginScreenEnabled + }); + if(configInitError) { NitroLogger.error('[LoginScreen] Failed to load renderer-config.json — cannot resolve login.screen.enabled', configInitError); @@ -432,12 +549,27 @@ export const App: FC<{}> = props => } catch(err) { - NitroLogger.error(err); - setIsEnteringHotel(false); - onSessionExpired(); + NitroLogger.error('[App] Initialization failed — falling back to login', err); + // Anything thrown out of the post-auth chain (renderer init, + // asset download, GetCommunication().init()) is an init/connect + // failure, not session expiration. The credential we used is + // suspect — drop it and present the login form so the user + // can retry instead of getting stuck on a stale "Session expired". + onInitFailure(); } }; + // React Strict Mode in dev runs every effect twice (mount → cleanup → mount). + // `prepare()` is full of one-shot side effects (renderer init, websocket + // connect, NitroConfig mutation) — calling it twice with the same trigger + // value causes the second pass to race with the first and clobber state + // (e.g. the second pass falls through to onSessionExpired while the first + // had just set showLogin=true). Guard by trigger value: skip duplicate + // runs at the same trigger, but still re-run when handleAuthenticated + // bumps prepareTrigger after a successful login. + if(lastPrepareTriggerRef.current === prepareTrigger) return; + lastPrepareTriggerRef.current = prepareTrigger; + const { width, height } = getViewportDimensions(); prepare(width, height); @@ -455,7 +587,13 @@ export const App: FC<{}> = props => 0 } message={ errorMessage } homeUrl={ homeUrl } /> } { !isReady && showLogin && } { isReady && } - + { /* Reconnect overlay must NOT render before we've actually entered + the hotel — otherwise the renderer's auto-retry on an initial + handshake failure (e.g. emulator unreachable) would cover the + login form with "Reconnecting..." → "Session expired" and the + user wouldn't be able to interact with the form we just put up + via fallbackToLogin. */ } + { isReady && } ); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 3aeda2f..fe1eb9a 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,3 +1,4 @@ +import { GetConfiguration } from '@nitrots/configuration'; import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets'; const ensureMobileViewport = () => @@ -15,7 +16,6 @@ const ensureMobileViewport = () => }; ensureMobileViewport(); -installSecureFetch(); const setBootDebug = (message: string) => { @@ -30,8 +30,6 @@ const setBootDebug = (message: string) => {} }; -setBootDebug('boot: secure fetch installed'); - const deployBaseUrl = (): string => { try @@ -89,6 +87,9 @@ const loadClientMode = async () => await loadClientMode(); +installSecureFetch(); +setBootDebug('boot: secure fetch installed'); + const search = new URLSearchParams(window.location.search); const clientMode = getClientMode(); @@ -107,6 +108,21 @@ const clientMode = getClientMode(); setBootDebug('boot: NitroConfig assigned'); +// Load renderer-config.json + ui-config.json BEFORE rendering React. Otherwise +// the first paint triggers a flood of "Missing configuration key" warnings for +// every key components read synchronously (asset.url, login.endpoint, …) until +// prepare()'s deferred init() finally lands. Doing it here makes the config +// already populated by the time index.tsx mounts . +try +{ + await GetConfiguration().init(); + setBootDebug('boot: configuration init done'); +} +catch(error) +{ + setBootDebug(`boot: configuration init failed ${ error?.message || error }`); +} + import('./index') .then(() => setBootDebug('boot: app bundle imported')) .catch(error => diff --git a/src/secure-assets.ts b/src/secure-assets.ts index 7f7c170..e2fc71e 100644 --- a/src/secure-assets.ts +++ b/src/secure-assets.ts @@ -123,15 +123,19 @@ const REKEY_ENDPOINTS = new Set([ '/api/auth/logout' ]); +let clientModeCache: NitroClientMode | null = null; + export const getClientMode = (): NitroClientMode => { + if(clientModeCache) return clientModeCache; + try { const configured = (window as any).__nitroClientMode; if(configured && typeof configured === 'object') { - return { + clientModeCache = { distObfuscationEnabled: configured.distObfuscationEnabled !== false, secureAssetsEnabled: configured.secureAssetsEnabled !== false, secureApiEnabled: configured.secureApiEnabled !== false, @@ -139,12 +143,14 @@ export const getClientMode = (): NitroClientMode => plainConfigBaseUrl: typeof configured.plainConfigBaseUrl === 'string' ? configured.plainConfigBaseUrl : '', plainGamedataBaseUrl: typeof configured.plainGamedataBaseUrl === 'string' ? configured.plainGamedataBaseUrl : '' }; + + return clientModeCache; } } catch {} - return { ...CLIENT_MODE_DEFAULTS }; + return CLIENT_MODE_DEFAULTS; }; const bytesToBase64 = (bytes: ArrayBuffer): string => @@ -489,6 +495,14 @@ export const installSecureFetch = (): void => { if(installed) return; + const mode = getClientMode(); + + if(!mode.secureAssetsEnabled && !mode.secureApiEnabled) + { + installed = true; + return; + } + installed = true; const nativeFetch = window.fetch.bind(window); diff --git a/vite.config.mjs b/vite.config.mjs index 29fe0fa..56ed2c6 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -2,11 +2,56 @@ import react from '@vitejs/plugin-react'; import { existsSync } from 'fs'; import { resolve } from 'path'; import { defineConfig } from 'vite'; +import sirv from 'sirv'; const legacyRendererRoot = resolve(__dirname, '..', 'renderer'); const currentRendererRoot = resolve(__dirname, '..', 'Nitro_Render_V3'); const rendererRoot = existsSync(currentRendererRoot) ? currentRendererRoot : legacyRendererRoot; +// Game assets live outside the repo, in a sibling directory next to Nitro-V3. +// They are NOT placed under public/ on purpose: with ~177k files a symlink +// under public/ makes chokidar try to install a watcher on each one and the +// dev server takes minutes to start on Windows. Serving them with a +// dedicated sirv middleware (below) bypasses chokidar entirely. +const nitroFilesRoot = resolve(__dirname, '..', 'Nitro-Files'); +const nitroAssetsRoot = resolve(nitroFilesRoot, 'nitro-assets'); +const swfRoot = resolve(nitroFilesRoot, 'swf'); + +const nitroAssetsServer = () => ({ + name: 'nitro-assets-serve', + configureServer(server) + { + if(existsSync(nitroAssetsRoot)) + { + server.middlewares.use('/nitro-assets', sirv(nitroAssetsRoot, { dev: true, etag: true, maxAge: 0 })); + } + else + { + server.config.logger.warn(`[nitro-assets-serve] ${ nitroAssetsRoot } not found — /nitro-assets/* requests will 404.`); + } + + if(existsSync(swfRoot)) + { + server.middlewares.use('/swf', sirv(swfRoot, { dev: true, etag: true, maxAge: 0 })); + } + else + { + server.config.logger.warn(`[nitro-assets-serve] ${ swfRoot } not found — /swf/* requests will 404.`); + } + }, + configurePreviewServer(server) + { + if(existsSync(nitroAssetsRoot)) + { + server.middlewares.use('/nitro-assets', sirv(nitroAssetsRoot, { dev: false, etag: true })); + } + if(existsSync(swfRoot)) + { + server.middlewares.use('/swf', sirv(swfRoot, { dev: false, etag: true })); + } + } +}); + if(!existsSync(rendererRoot)) { // Fail fast with a useful message instead of waiting for Rolldown to @@ -36,7 +81,8 @@ export default defineConfig({ [ 'babel-plugin-react-compiler', ReactCompilerConfig ] ] } - }) + }), + nitroAssetsServer() ], server: { fs: { @@ -47,7 +93,7 @@ export default defineConfig({ }, proxy: { '/api': { - target: process.env.AUTH_PROXY_TARGET || 'http://192.168.0.181:2096', + target: process.env.AUTH_PROXY_TARGET || 'http://localhost:2096', changeOrigin: true, } } diff --git a/yarn.lock b/yarn.lock index cd86ce1..80a5588 100644 --- a/yarn.lock +++ b/yarn.lock @@ -701,6 +701,11 @@ "@parcel/watcher-win32-ia32" "2.5.6" "@parcel/watcher-win32-x64" "2.5.6" +"@polka/url@^1.0.0-next.24": + version "1.0.0-next.29" + resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1" + integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww== + "@radix-ui/number@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090" @@ -3096,6 +3101,11 @@ motion-utils@^12.36.0: resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.36.0.tgz#cff2df2a28c3fe53a3de7e0103ba7f73ff7d77a7" integrity sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg== +mrmime@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc" + integrity sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ== + ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" @@ -3665,6 +3675,15 @@ siginfo@^2.0.0: resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== +sirv@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.2.tgz#f775fccf10e22a40832684848d636346f41cd970" + integrity sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g== + dependencies: + "@polka/url" "^1.0.0-next.24" + mrmime "^2.0.0" + totalist "^3.0.0" + "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" @@ -3826,6 +3845,11 @@ tldts@^7.0.5: dependencies: tldts-core "^7.0.30" +totalist@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8" + integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ== + tough-cookie@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-6.0.1.tgz#a495f833836609ed983c19bc65639cfbceb54c76" From 2053c8e015e7da3c5eaab5d34f5f499f6820a2b6 Mon Sep 17 00:00:00 2001 From: duckietm Date: Mon, 11 May 2026 18:07:54 +0200 Subject: [PATCH 070/129] =?UTF-8?q?=F0=9F=86=95=20Added=20Reset=20password?= =?UTF-8?q?=20/=20Email=20and=20chenge=20username=20in=20user=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 4 +- src/components/MainView.tsx | 2 + src/components/login/LoginView.tsx | 3 +- .../user-settings/UserAccountSettingsView.tsx | 758 ++++++++++++++++++ .../user-settings/UserSettingsView.tsx | 19 +- 5 files changed, 782 insertions(+), 4 deletions(-) create mode 100644 src/components/user-settings/UserAccountSettingsView.tsx diff --git a/src/App.tsx b/src/App.tsx index 0a77cbf..27f6ec9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'; -import { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload } from './api'; +import { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api'; import { Base } from './common'; import { LoadingView } from './components/loading/LoadingView'; import { LoginView } from './components/login/LoginView'; @@ -202,6 +202,7 @@ export const App: FC<{}> = props => if(response.ok && ssoTicket) { + persistAccessTokenFromPayload(payload); StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : remembered.username, ssoTicket); return ssoTicket; } @@ -251,6 +252,7 @@ export const App: FC<{}> = props => if(response.ok) { + persistAccessTokenFromPayload(payload); StoreRememberLoginFromPayload(payload, remembered.username, remembered.ssoTicket); return; } diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index cc5baf4..8df9234 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -34,6 +34,7 @@ import { ToolbarView } from './toolbar/ToolbarView'; import { TranslationBootstrap } from './translation/TranslationBootstrap'; import { TranslationSettingsView } from './translation/TranslationSettingsView'; import { UserProfileView } from './user-profile/UserProfileView'; +import { UserAccountSettingsView } from './user-settings/UserAccountSettingsView'; import { UserSettingsView } from './user-settings/UserSettingsView'; import { WiredView } from './wired/WiredView'; import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView'; @@ -133,6 +134,7 @@ export const MainView: FC<{}> = props => + diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index ce7a1b5..37e36df 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -1,7 +1,7 @@ import { AvatarScaleType, AvatarSetType, GetAvatarRenderManager, GetConfiguration, IAvatarImage } from '@nitrots/nitro-renderer'; import { FC, useActionState, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useFormStatus } from 'react-dom'; -import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; +import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from '../../api'; import { configFileUrl } from '../../secure-assets'; import flagBr from '../../assets/images/flag_icon/flag_icon_br.png'; import flagDe from '../../assets/images/flag_icon/flag_icon_de.png'; @@ -527,6 +527,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa if(ok && ssoTicket) { clearLock(); + persistAccessTokenFromPayload(payload); if(rememberFlag) StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : usernameInput, ssoTicket); else ClearRememberLogin(); onAuthenticated(ssoTicket); diff --git a/src/components/user-settings/UserAccountSettingsView.tsx b/src/components/user-settings/UserAccountSettingsView.tsx new file mode 100644 index 0000000..c834ba2 --- /dev/null +++ b/src/components/user-settings/UserAccountSettingsView.tsx @@ -0,0 +1,758 @@ +import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, KeyboardEvent, useEffect, useMemo, useState } from 'react'; +import { FaArrowLeft, FaCheckCircle, FaChevronRight, FaEnvelope, FaExclamationTriangle, FaEye, FaEyeSlash, FaIdBadge, FaInfoCircle, FaKey, FaShieldAlt, FaUserCog } from 'react-icons/fa'; +import { GetConfigurationValue, getAccessToken } from '../../api'; +import { Button, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; + +const MIN_PASSWORD_LENGTH = 8; +const MAX_PASSWORD_LENGTH = 128; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MAX_EMAIL_LENGTH = 254; + +const USERNAME_RE = /^[A-Za-z0-9._-]{3,25}$/; +const MIN_USERNAME_LENGTH = 3; +const MAX_USERNAME_LENGTH = 25; + +type FeedbackKind = 'error' | 'success'; +type Section = 'menu' | 'password' | 'email' | 'username'; + +const passwordStrength = (value: string): { score: number; label: string; color: string } => +{ + if(!value) return { score: 0, label: '', color: 'bg-black/10' }; + + let score = 0; + if(value.length >= MIN_PASSWORD_LENGTH) score++; + if(value.length >= 12) score++; + if(/[A-Z]/.test(value) && /[a-z]/.test(value)) score++; + if(/\d/.test(value)) score++; + if(/[^A-Za-z0-9]/.test(value)) score++; + + if(score <= 1) return { score: 1, label: 'Weak', color: 'bg-[#a81a12]' }; + if(score === 2) return { score: 2, label: 'Fair', color: 'bg-[#ffc107]' }; + if(score === 3) return { score: 3, label: 'Good', color: 'bg-[#1e7295]' }; + return { score: 4, label: 'Strong', color: 'bg-[#00800b]' }; +}; + +export const UserAccountSettingsView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const [ section, setSection ] = useState
('menu'); + const [ currentPassword, setCurrentPassword ] = useState(''); + const [ newPassword, setNewPassword ] = useState(''); + const [ confirmPassword, setConfirmPassword ] = useState(''); + const [ showCurrent, setShowCurrent ] = useState(false); + const [ showNew, setShowNew ] = useState(false); + const [ emailCurrentPassword, setEmailCurrentPassword ] = useState(''); + const [ newEmail, setNewEmail ] = useState(''); + const [ showEmailPassword, setShowEmailPassword ] = useState(false); + const [ usernameCurrentPassword, setUsernameCurrentPassword ] = useState(''); + const [ newUsername, setNewUsername ] = useState(''); + const [ showUsernamePassword, setShowUsernamePassword ] = useState(false); + const [ submitting, setSubmitting ] = useState(false); + const [ feedback, setFeedback ] = useState<{ kind: FeedbackKind; message: string } | null>(null); + + const session = useMemo(() => + { + try + { + const manager = GetSessionDataManager(); + return { + username: manager?.userName ?? '', + figure: manager?.figure ?? '' + }; + } + catch + { + return { username: '', figure: '' }; + } + }, [ isVisible ]); + + const strength = useMemo(() => passwordStrength(newPassword), [ newPassword ]); + + const resetForm = () => + { + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setShowCurrent(false); + setShowNew(false); + setEmailCurrentPassword(''); + setNewEmail(''); + setShowEmailPassword(false); + setUsernameCurrentPassword(''); + setNewUsername(''); + setShowUsernamePassword(false); + setFeedback(null); + }; + + const close = () => + { + setIsVisible(false); + setSection('menu'); + resetForm(); + setSubmitting(false); + }; + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': + setIsVisible(true); + return; + case 'hide': + close(); + return; + case 'toggle': + setIsVisible(prev => !prev); + return; + } + }, + eventUrlPrefix: 'user-account-settings/' + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + + const submitPasswordChange = async () => + { + if(submitting) return; + + setFeedback(null); + + if(!currentPassword || !newPassword || !confirmPassword) + { + setFeedback({ kind: 'error', message: 'All fields are required.' }); + return; + } + + if(newPassword.length < MIN_PASSWORD_LENGTH) + { + setFeedback({ kind: 'error', message: `Password must be at least ${ MIN_PASSWORD_LENGTH } characters.` }); + return; + } + + if(newPassword.length > MAX_PASSWORD_LENGTH) + { + setFeedback({ kind: 'error', message: 'Password is too long.' }); + return; + } + + if(newPassword !== confirmPassword) + { + setFeedback({ kind: 'error', message: 'New passwords do not match.' }); + return; + } + + if(newPassword === currentPassword) + { + setFeedback({ kind: 'error', message: 'New password must be different from the current password.' }); + return; + } + + const token = getAccessToken(); + if(!token) + { + setFeedback({ kind: 'error', message: 'You are not authenticated. Please log in again.' }); + return; + } + + const endpoint = GetConfigurationValue('account.change-password.endpoint', '/api/auth/change-password'); + + setSubmitting(true); + try + { + const response = await fetch(endpoint, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${ token }`, + 'X-Requested-With': 'NitroUserAccountSettings' + }, + body: JSON.stringify({ currentPassword, newPassword, confirmPassword }) + }); + + let payload: Record = {}; + try { payload = await response.json(); } + catch {} + + if(!response.ok) + { + const message = typeof payload.error === 'string' && payload.error + ? payload.error + : `Request failed (${ response.status }).`; + setFeedback({ kind: 'error', message }); + return; + } + + const message = typeof payload.message === 'string' && payload.message + ? payload.message + : 'Password updated successfully.'; + setFeedback({ kind: 'success', message }); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setShowCurrent(false); + setShowNew(false); + } + catch + { + setFeedback({ kind: 'error', message: 'Could not reach the server. Please try again.' }); + } + finally + { + setSubmitting(false); + } + }; + + const submitEmailChange = async () => + { + if(submitting) return; + + setFeedback(null); + + if(!emailCurrentPassword || !newEmail) + { + setFeedback({ kind: 'error', message: 'All fields are required.' }); + return; + } + + if(newEmail.length > MAX_EMAIL_LENGTH) + { + setFeedback({ kind: 'error', message: 'Email address is too long.' }); + return; + } + + if(!EMAIL_RE.test(newEmail)) + { + setFeedback({ kind: 'error', message: 'Please enter a valid email address.' }); + return; + } + + const token = getAccessToken(); + if(!token) + { + setFeedback({ kind: 'error', message: 'You are not authenticated. Please log in again.' }); + return; + } + + const endpoint = GetConfigurationValue('account.change-email.endpoint', '/api/auth/change-email'); + + setSubmitting(true); + try + { + const response = await fetch(endpoint, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${ token }`, + 'X-Requested-With': 'NitroUserAccountSettings' + }, + body: JSON.stringify({ currentPassword: emailCurrentPassword, newEmail }) + }); + + let payload: Record = {}; + try { payload = await response.json(); } + catch {} + + if(!response.ok) + { + const message = typeof payload.error === 'string' && payload.error + ? payload.error + : `Request failed (${ response.status }).`; + setFeedback({ kind: 'error', message }); + return; + } + + const message = typeof payload.message === 'string' && payload.message + ? payload.message + : 'Email updated successfully.'; + setFeedback({ kind: 'success', message }); + setEmailCurrentPassword(''); + setNewEmail(''); + setShowEmailPassword(false); + } + catch + { + setFeedback({ kind: 'error', message: 'Could not reach the server. Please try again.' }); + } + finally + { + setSubmitting(false); + } + }; + + const submitUsernameChange = async () => + { + if(submitting) return; + + setFeedback(null); + + if(!usernameCurrentPassword || !newUsername) + { + setFeedback({ kind: 'error', message: 'All fields are required.' }); + return; + } + + if(newUsername.length < MIN_USERNAME_LENGTH || newUsername.length > MAX_USERNAME_LENGTH) + { + setFeedback({ kind: 'error', message: `Username must be between ${ MIN_USERNAME_LENGTH } and ${ MAX_USERNAME_LENGTH } characters.` }); + return; + } + + if(!USERNAME_RE.test(newUsername)) + { + setFeedback({ kind: 'error', message: 'Username may only contain letters, numbers, dot, underscore and dash.' }); + return; + } + + if(newUsername === session.username) + { + setFeedback({ kind: 'error', message: 'New username must be different from the current one.' }); + return; + } + + const token = getAccessToken(); + if(!token) + { + setFeedback({ kind: 'error', message: 'You are not authenticated. Please log in again.' }); + return; + } + + const endpoint = GetConfigurationValue('account.change-username.endpoint', '/api/auth/change-username'); + + setSubmitting(true); + try + { + const response = await fetch(endpoint, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${ token }`, + 'X-Requested-With': 'NitroUserAccountSettings' + }, + body: JSON.stringify({ currentPassword: usernameCurrentPassword, newUsername }) + }); + + let payload: Record = {}; + try { payload = await response.json(); } + catch {} + + if(!response.ok) + { + const message = typeof payload.error === 'string' && payload.error + ? payload.error + : `Request failed (${ response.status }).`; + setFeedback({ kind: 'error', message }); + return; + } + + const message = typeof payload.message === 'string' && payload.message + ? payload.message + : 'Username updated. Please log in again with your new name.'; + setFeedback({ kind: 'success', message }); + setUsernameCurrentPassword(''); + setNewUsername(''); + setShowUsernamePassword(false); + + // The server has dropped our session — clear local credentials and bounce + // the user back to the login screen so the whole client reloads cleanly. + try { window.localStorage.removeItem('nitro.access.token'); } catch {} + try { window.localStorage.removeItem('nitro.access.token.exp'); } catch {} + window.setTimeout(() => + { + try { window.location.reload(); } + catch {} + }, 2500); + } + catch + { + setFeedback({ kind: 'error', message: 'Could not reach the server. Please try again.' }); + } + finally + { + setSubmitting(false); + } + }; + + if(!isVisible) return null; + + return ( + + + +
+
+ { session.figure && ( +
+ +
+ ) } +
+ My account + { session.username || 'Guest' } + Manage your account and security +
+
+ + + { section === 'menu' && ( +
+ Account + + + + + + +
+
+ +
+
+ More coming soon + Two-factor authentication and more. +
+
+
+ ) } + + { section === 'password' && ( +
) => { if(event.key === 'Enter') { event.preventDefault(); submitPasswordChange(); } } }> +
+ + + Reset password +
+ +
+ + Use at least { MIN_PASSWORD_LENGTH } characters. Mix upper & lowercase, numbers and symbols for a stronger password. +
+ + + +
+
+ +
); From 3a7c9ba940d029bec762bc8db924c363a691195a Mon Sep 17 00:00:00 2001 From: duckietm Date: Tue, 12 May 2026 10:54:01 +0200 Subject: [PATCH 071/129] =?UTF-8?q?=F0=9F=86=99=20Fix=20wear=20badge=20in?= =?UTF-8?q?=20popup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layout/LayoutNotificationBubbleView.tsx | 2 +- .../NotificationBadgeReceivedBubbleView.tsx | 30 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/common/layout/LayoutNotificationBubbleView.tsx b/src/common/layout/LayoutNotificationBubbleView.tsx index b502b3b..b72fe65 100644 --- a/src/common/layout/LayoutNotificationBubbleView.tsx +++ b/src/common/layout/LayoutNotificationBubbleView.tsx @@ -16,7 +16,7 @@ export const LayoutNotificationBubbleView: FC const getClassNames = useMemo(() => { - const newClassNames: string[] = [ 'text-sm bg-[#1c1c20f2] px-[5px] py-[6px] [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] ', 'rounded' ]; + const newClassNames: string[] = [ 'pointer-events-auto text-sm bg-[#1c1c20f2] px-[5px] py-[6px] [box-shadow:inset_0_5px_#22222799,inset_0_-4px_#12121599] ', 'rounded' ]; if(classNames.length) newClassNames.push(...classNames); diff --git a/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx index edd866f..333e51f 100644 --- a/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx +++ b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx @@ -12,11 +12,9 @@ export interface NotificationBadgeReceivedBubbleViewProps extends LayoutNotifica export const NotificationBadgeReceivedBubbleView: FC = props => { const { item = null, onClose = null, ...rest } = props; - const { badgeCodes = [], toggleBadge = null } = useInventoryBadges(); + const { activeBadgeCodes = [], toggleBadge = null, isWearingBadge = null, canWearBadges = null } = useInventoryBadges(); - const requestBadgesIfEmpty = useEffectEvent(() => - { - if(badgeCodes.length === 0) SendMessageComposer(new RequestBadgesComposer()); + if(activeBadgeCodes.length === 0) SendMessageComposer(new RequestBadgesComposer()); }); useEffect(() => @@ -24,14 +22,17 @@ export const NotificationBadgeReceivedBubbleView: FC 0; + const alreadyWearing = !!badgeCode && !!isWearingBadge && isWearingBadge(badgeCode); + const slotsAvailable = !!canWearBadges && canWearBadges(); + const canShowWearButton = !!badgeCode && isLoaded && !alreadyWearing && slotsAvailable; + const handleWear = (event: React.MouseEvent) => { event.stopPropagation(); - if(item.linkUrl) - { - toggleBadge(item.linkUrl); - } + if(canShowWearButton && toggleBadge) toggleBadge(badgeCode); if(onClose) onClose(); }; @@ -59,12 +60,13 @@ export const NotificationBadgeReceivedBubbleView: FC - + { canShowWearButton && + } { LocalizeText('notifications.button.later') } From 9ef6983632ed0958da549e204aaea1558283dacc Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 13 May 2026 21:16:52 +0200 Subject: [PATCH 072/129] post cherry-pick: restore useEffectEvent wrapper + fix configuration import Two typecheck regressions surfaced after pulling duckietm PR #126 onto this branch: - NotificationBadgeReceivedBubbleView lost its `useEffectEvent` wrapper during conflict resolution. The PR rewrote the effect to use a plain `useEffect(..., [activeBadgeCodes.length])`; reintroduce the `requestBadgesIfEmpty = useEffectEvent(...)` shape that the rest of this branch uses, applied to the renamed activeBadgeCodes selector. - `src/bootstrap.ts` was importing `GetConfiguration` from the package alias `@nitrots/configuration`, which Vite resolves via filesystem alias but tsgo does not. Swap to the monolithic `@nitrots/nitro-renderer` re-export, matching how App.tsx already imports the same symbol. Both fixes get `yarn typecheck` green again and all 113 Vitest cases still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bootstrap.ts | 2 +- .../bubble-layouts/NotificationBadgeReceivedBubbleView.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bootstrap.ts b/src/bootstrap.ts index fe1eb9a..ac0b441 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,4 +1,4 @@ -import { GetConfiguration } from '@nitrots/configuration'; +import { GetConfiguration } from '@nitrots/nitro-renderer'; import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets'; const ensureMobileViewport = () => diff --git a/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx index 333e51f..a8520a3 100644 --- a/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx +++ b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx @@ -14,6 +14,8 @@ export const NotificationBadgeReceivedBubbleView: FC + { if(activeBadgeCodes.length === 0) SendMessageComposer(new RequestBadgesComposer()); }); From b01f09c8eaa7450683d1f68b24c258b12118845e Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 13 May 2026 21:17:06 +0200 Subject: [PATCH 073/129] fix: null-check the set type before reading .paletteID in avatar editor `buildCategory` was reading `set.paletteID` on the line directly above the `if(!set || !palette) return null` guard. For categories where `getSetType()` legitimately returns null (PETS, MISC with no figure data on the server), this threw "can't access property paletteID, set is null" and triggered the WidgetErrorBoundary when the user opened the avatar editor. Split the guard: bail out as soon as `set` is null, then resolve the palette, then bail again if that's null too. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/avatar-editor/useAvatarEditor.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/hooks/avatar-editor/useAvatarEditor.ts b/src/hooks/avatar-editor/useAvatarEditor.ts index 7ee02b9..098a4ce 100644 --- a/src/hooks/avatar-editor/useAvatarEditor.ts +++ b/src/hooks/avatar-editor/useAvatarEditor.ts @@ -264,9 +264,12 @@ const useAvatarEditorState = () => for(let i = 0; i < MAX_PALETTES; i++) colorItems.push([]); const set = GetAvatarRenderManager().structureData.getSetType(setType); + + if(!set) return null; + const palette = GetAvatarRenderManager().structureData.getPalette(set.paletteID); - if(!set || !palette) return null; + if(!palette) return null; for(const partColor of palette.colors.getValues()) { From 622d73c2f0148a301be93bd88d410ab34aa3223c Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 13 May 2026 21:19:34 +0200 Subject: [PATCH 074/129] docs: reflect PR #126 cherry-pick + boot/asset infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md - TL;DR mentions the duckietm PR #126 cherry-pick (UserAccountSettings, wear-badge popup fix) and the sirv-based dev asset serving so a fresh session knows what's living on top of upstream main. - New patterns section for the bootstrap.ts configuration pre-init and the nitroAssetsServer Vite plugin, with a pointer to the .gitignore note explaining why public/{nitro-assets,swf} symlinks are a trap. - "What's wired up" table gets two rows: Form Actions, and the PR #126 pickup. - "Where everything lives" gets entries for UserAccountSettingsView, the persistAccessTokenFromPayload helper, the asset middleware, and the bootstrap pre-init call. docs/ARCHITECTURE.md - Recently fixed: adds the useAvatarEditor PETS/MISC paletteID null-pointer that surfaced when the editor was opened. - New Bonus subsections describing the boot-time orchestration in bootstrap.ts, the dev asset serving via sirv (and why symlinking under public/ is the wrong move on Windows), and the upstream feature catch-up via PR #126. No code changes in this commit — pure documentation refresh. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 40 ++++++++++++++++++++++++++++++++ docs/ARCHITECTURE.md | 54 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index d8a0451..7cfdb23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,18 @@ 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`. +On top of the modernization work this branch also picks up a couple of +upstream feature commits that lived only on `duckietm/Nitro-V3` (PR #126): +reset password / email / change username under user settings, and the +wear-badge popup fix. + +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. @@ -220,6 +232,25 @@ Login / Register / Forgot in `src/components/login/LoginView.tsx` use `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 | @@ -231,6 +262,8 @@ Login / Register / Forgot in `src/components/login/LoginView.tsx` use | God-hook split (`useBetween` singleton + state filter + actions filter + shim) | `wired-tools`, `translation`, `notification`, `friends` | | `WidgetErrorBoundary` | `RoomWidgetsView` umbrella | | Vitest | 113/113 cases on pure helpers + the Zustand store | +| Form Actions | Login / Register / Forgot (LoginView.tsx) | +| Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating | | Not yet | Notes | |---|---| @@ -283,3 +316,10 @@ Fix shapes documented; both are reasonable PRs on their own. `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()` in `vite.config.mjs` +- Configuration pre-init: `src/bootstrap.ts` (`await GetConfiguration().init()` + before `import('./index')`) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 658bf95..7b4d277 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -565,6 +565,53 @@ Status after this round of work: session start. Captures the layout convention, the patterns to use, what's wired up, what isn't, and the open logic bugs. +### Boot-time orchestration (`src/bootstrap.ts`) +- Mobile viewport meta tag inserted before anything else. +- `await loadClientMode()` — fetches `client-mode.json` into + `window.__nitroClientMode` so `getClientMode()` can pick up + `secureAssetsEnabled` / `secureApiEnabled` / `apiBaseUrl` for the + fetch interceptor. +- `installSecureFetch()` (no-op when both `secureAssetsEnabled` and + `secureApiEnabled` are off, which is the dev default). +- Populate `window.NitroConfig` with `config.urls`, `sso.ticket`, + forward parameters. +- **`await GetConfiguration().init()`** — eager configuration load + before React mounts. Eliminates the "Missing configuration key: + asset.url / login.endpoint / login.turnstile.* / …" warning flood + that happens when components synchronously read keys on the first + paint while `prepare()`'s deferred init is still in flight. +- `import('./index')` — dynamic, so we keep top-level await for the + steps above. + +### Dev asset serving (`vite.config.mjs`) +- Game asset directories (`bundled/`, `c_images/`, `gamedata/`, `swf/`) + live OUTSIDE the repo. The historical "symlink them into `public/` + so Vite serves them via `publicDir`" trick is a trap on Windows: + chokidar tries to install a watcher on every file under `public/` + and the dev server hangs for minutes on ~177k assets. +- The current setup installs a tiny Vite plugin (`nitroAssetsServer`) + that mounts `sirv` on `/nitro-assets` and `/swf`, reading from + `../Nitro-Files/{nitro-assets,swf}`. `sirv` is connect-style + middleware; it bypasses chokidar entirely. +- The same plugin wires the same handler into + `configurePreviewServer` so `yarn preview` keeps working with the + production build. +- `.gitignore` has explicit entries for `/public/nitro-assets` and + `/public/swf` plus a comment explaining why those paths must not be + recreated as symlinks. + +### Upstream feature catch-up +- `duckietm/Nitro-V3` PR #126 is cherry-picked: adds + `src/components/user-settings/UserAccountSettingsView.tsx` + (reset password / email / change username flows under the user + settings overlay) and a wear-badge popup fix in + `NotificationBadgeReceivedBubbleView` that gates the button on the + `canShowWearButton` derived predicate. The cherry-pick required + reconciling the LoginView fork to the Form Actions migration + (`useActionState` + `useFormStatus`) and restoring the + `useEffectEvent`-wrapped subscription pattern used elsewhere in + this branch. + --- ## How to pick the next refactor PR @@ -709,3 +756,10 @@ data-corrupting. `roomSession.userDataManager.getPetData(parser.petId)` could throw if `roomSession` was null at the moment the event arrived (between rooms). Fixed with `?.` chain. +- **`useAvatarEditor` `set.paletteID` null-pointer** — + `buildCategory` read `set.paletteID` on the line above its + `if(!set || !palette) return null` guard. For categories where + `getSetType()` legitimately returns null (PETS / MISC without + server-side figure data), this threw and the avatar editor crashed + on open, escalating to `WidgetErrorBoundary`. Split the guard so + `set` is checked before its property access. From c4018392f9f051dd05b2d9d52c8fce6b71ab1c34 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 13 May 2026 21:31:08 +0200 Subject: [PATCH 075/129] tests: add renderer-SDK mock layer + first 2 component-/hook-level pilots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundations for widening Vitest coverage past the pure-helper subset. The real `@nitrots/nitro-renderer` eagerly loads Pixi v8 and the full Habbo message parser/composer registry at module-import time, which jsdom cannot host: any `tests/**` file that transitively pulled a renderer symbol would throw before a single assertion ran. That's why the existing 8 suites all stuck to pure modules imported by concrete path and used `import type` for renderer-side names. Add a stub at `tests/mocks/renderer-mock.ts`, aliased over the package via `vitest.config.mts`. It exports: - Explicit behavioral stubs for the symbols tests actually exercise: `NitroLogger`, `GetEventDispatcher`, the `mockEventDispatcher` helper with `addEventListener` / `removeEventListener` / `dispatchEvent` / `hasListeners`, and `RoomSessionDoorbellEvent` (signature matches the real `(type, session, userName)` to keep tsgo happy). - String-keyed Proxy enums for `NitroEventType`, `RoomObjectCategory`, `AvatarFigurePartType`, etc. — each access returns a stable unique string so dispatch and listener agree. - Lightweight `class StubClass {}` placeholders for the ~30 Pixi and gameplay classes the `src/api/*` barrel touches at import time (`NitroAlphaFilter`, `NitroContainer`, `EventDispatcher`, …). Keeps the cascade from throwing without simulating behavior tests don't care about. - Singleton getters (`GetAssetManager`, `GetCommunication`, `GetSessionDataManager`, …) returning a chainable Proxy so deeply nested `GetX().y.z(…)` access evaluates to no-op proxies. Pilots on top of that layer (each one designed to catch a different class of regression): - `tests/WidgetErrorBoundary.test.tsx` (4 cases) — happy path, default silent fallback + `NitroLogger.error` call, custom fallback node, default `unknown` widget name. - `tests/useDoorbellState.test.tsx` (7 cases) — initial empty state, append on `RSDE_DOORBELL`, dedup duplicate names, remove on `RSDE_ACCEPTED` / `RSDE_REJECTED`, ignore stale events for never-pending users, full unsubscribe on unmount. Suite count now 124/124 across 10 files (was 113/113 across 8). `yarn typecheck` still green. Docs: CLAUDE.md's Vitest row and "Where everything lives" pointer updated; `docs/ARCHITECTURE.md` Tests section now lists the new suites + a description of what the mock layer covers, and the "Wider Vitest coverage" entry in the next-steps list is reframed from "needs a renderer mock" to "pick the next adopter". Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 12 +- docs/ARCHITECTURE.md | 57 +++++-- tests/WidgetErrorBoundary.test.tsx | 96 +++++++++++ tests/mocks/renderer-mock.ts | 260 +++++++++++++++++++++++++++++ tests/useDoorbellState.test.tsx | 102 +++++++++++ vitest.config.mts | 9 +- 6 files changed, 519 insertions(+), 17 deletions(-) create mode 100644 tests/WidgetErrorBoundary.test.tsx create mode 100644 tests/mocks/renderer-mock.ts create mode 100644 tests/useDoorbellState.test.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 7cfdb23..5d45bc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,7 +261,7 @@ into `configurePreviewServer` so `yarn preview` keeps working. | 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` | | `WidgetErrorBoundary` | `RoomWidgetsView` umbrella | -| Vitest | 113/113 cases on pure helpers + the Zustand store | +| Vitest | 124/124 cases — 113 on pure helpers + Zustand store, plus the first 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the new renderer-SDK mock at `tests/mocks/renderer-mock.ts` | | Form Actions | Login / Register / Forgot (LoginView.tsx) | | Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating | @@ -271,7 +271,7 @@ into `configurePreviewServer` so `yarn preview` keeps working. | Split `useChatWidget` / `useAvatarInfoWidget` | Both state-driven via events with no clean imperative actions to extract — skip-motivated. Already touched today for the InfoStand listener move. | | Split `usePetPackageWidget` / `useWordQuizWidget` / `useChatCommandSelector` | Their "actions" mutate internal state or are tightly interdependent — skip-motivated. | | Hoist Wired Creator Tools shared state to a Zustand slice | Would remove ~25 props passed to the 3 tab sub-components. (Wired-tools split done as singleton-filter; Zustand slice is the next step.) | -| Wider Vitest coverage (React components) | `@testing-library/*` is installed; needs a small renderer-SDK mock layer first. | +| Widen the component / hook test coverage | Mock layer is in place (`tests/mocks/renderer-mock.ts`) and the first 2 pilots pass. Good follow-up targets: other `*State` hooks built on event reducers, `LoginView` Form Actions happy/error paths, OfferView with `useNitroQuery`. | ## Known open logic bugs @@ -323,3 +323,11 @@ Fix shapes documented; both are reasonable PRs on their own. - Asset middleware: `nitroAssetsServer()` in `vite.config.mjs` - Configuration pre-init: `src/bootstrap.ts` (`await GetConfiguration().init()` before `import('./index')`) +- Renderer-SDK mock for Vitest: `tests/mocks/renderer-mock.ts` + (aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`). + Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` / + `clearMockEventDispatcher` helpers used by hook tests, the + `RoomSessionDoorbellEvent` stub, and a long list of placeholder + classes/enums kept around just so the `src/api/*` barrel cascade + imports without throwing. **Grow this file when a new test needs a + symbol; prefer real deterministic stubs over `vi.fn()`.** diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7b4d277..57d2cb6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -484,7 +484,7 @@ Status after this round of work: - Vitest 3 + jsdom + `@testing-library/react` + `@testing-library/jest-dom` configured. Separate `vitest.config.mts` so the runner doesn't drag in the renderer SDK aliases from `vite.config.mjs`. -- **113 cases passing** across 8 test files: +- **124 cases passing** across 10 test files. Pure-module suites: - `WiredCreatorTools.helpers.test.ts` (18) — formatters + snapshot factory. - `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants @@ -504,13 +504,40 @@ Status after this round of work: bail-out branches (state-not-AvatarInfoUser, mismatched user/roomIndex, equal-after-dedup) + the figure / favorite-group apply paths. -- **Pure-module convention**: tests live in `tests/` and import from - concrete file paths (e.g. `../src/api/catalog/CatalogType`) rather - than the api barrel, so jsdom doesn't transitively load the renderer - SDK's Pixi-bound modules. Renderer event type imports use - `import type { … }` so they're erased at compile time and don't - trigger the runtime module load either. -- `yarn test` + `yarn test:watch` scripts added. + + Component-/hook-level suites (on the new renderer-SDK mock): + - `WidgetErrorBoundary.test.tsx` (4) — happy path + caught render + error logged via `NitroLogger.error` + custom fallback + + `unknown` default name. + - `useDoorbellState.test.tsx` (7) — initial empty state, append on + `DOORBELL`, dedup duplicates, remove on `RSDE_ACCEPTED` / + `RSDE_REJECTED`, ignore stale events, unsubscribe on unmount. + +- **Renderer-SDK mock at `tests/mocks/renderer-mock.ts`** — + `vitest.config.mts` aliases `@nitrots/nitro-renderer` over this file + so jsdom-hosted tests never load Pixi or the message + parser/composer registry. The mock exports: + - Explicit, behavioral stubs for the symbols tests actually + exercise: `NitroLogger`, `GetEventDispatcher`, + `mockEventDispatcher` / `clearMockEventDispatcher` helpers, the + `RoomSessionDoorbellEvent` class (signature mirrors the real + `(type, session, userName)` so `tsgo` stays happy). + - String-keyed `Proxy` enums for every `*EventType` / + `*FigurePartType` / `RoomObjectCategory` etc. — each access + returns a stable unique string so dispatch + listener agree. + - Lightweight `class StubClass {}` placeholders for the ~30 Pixi + and gameplay classes the `src/api/*` barrel touches at import + time (`NitroAlphaFilter`, `NitroContainer`, `EventDispatcher`, + etc.). Keeps the cascade from throwing without simulating + behavior tests don't care about. + - Singleton getters (`GetAssetManager`, `GetCommunication`, + `GetSessionDataManager`, …) returning a chainable proxy so + `GetX().y.z` evaluates to a no-op proxy instead of crashing. +- **Pure-module convention** (still applies for non-component tests): + import from concrete file paths so jsdom doesn't transitively load + the renderer SDK; use `import type { … }` for type-only renderer + imports. +- `yarn test` + `yarn test:watch` scripts. ### Logic bug fixes - Doorbell close button didn't close while users were pending @@ -648,11 +675,15 @@ Remaining order of value/risk for the next contributor: token; the `LayoutFurniImageView` / `LayoutAvatarImageView` async fetch race needs a request-id ref (or is solved by migrating the image fetch to `useNitroQuery` keyed on props). -6. **Wider Vitest coverage** — next worthwhile targets: the - `useNitroQuery` adapter (timeout + cleanup + accept-filter - behavior, needs a stub for `@nitrots/nitro-renderer`), - `useDoorbellState`/`useUserChooserState` event-reducer logic - (needs the same renderer stub). +6. **Widen the component/hook Vitest coverage.** The renderer-SDK + mock layer is in place (`tests/mocks/renderer-mock.ts`) and the + first two pilots — `WidgetErrorBoundary` and `useDoorbellState` — + pass. Good follow-up targets: other `*State` hooks built on event + reducers (`useFurniChooserState`, `useUserChooserState`, + `useFriendRequestState`, `useChatInputState`), the `useNitroQuery` + adapter (timeout + cleanup + accept-filter behavior), and the + `LoginView` Form Actions happy/error paths. Each new test will + likely need to add 1-3 named exports to the renderer mock. Skipped intentionally and documented in commit messages: diff --git a/tests/WidgetErrorBoundary.test.tsx b/tests/WidgetErrorBoundary.test.tsx new file mode 100644 index 0000000..3d6b4a9 --- /dev/null +++ b/tests/WidgetErrorBoundary.test.tsx @@ -0,0 +1,96 @@ +/* @vitest-environment jsdom */ + +import { NitroLogger } from '@nitrots/nitro-renderer'; +import { cleanup, render, screen } from '@testing-library/react'; +import { FC } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WidgetErrorBoundary } from '../src/common/error-boundary/WidgetErrorBoundary'; + +// `import { NitroLogger } from '@nitrots/nitro-renderer'` resolves to +// `tests/mocks/renderer-mock.ts` via the alias in vitest.config.mts. +// The SUT imports the same path, so both reach the same vi.fn instance. + +describe('WidgetErrorBoundary', () => +{ + beforeEach(() => + { + vi.clearAllMocks(); + // react-error-boundary lets React's "uncaught error" log through + // by default — silence it so jsdom doesn't dump a stack trace + // every time we deliberately throw below. + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => + { + cleanup(); + vi.restoreAllMocks(); + }); + + it('renders its children when nothing throws', () => + { + render( + + visible + + ); + + expect(screen.getByTestId('child')).toHaveTextContent('visible'); + }); + + it('swallows a render-time error to a silent fallback and logs it', () => + { + const Boom: FC = () => + { + throw new Error('kaboom'); + }; + + const { container } = render( + + + + ); + + // Default fallback is `() => null` → boundary subtree is empty. + expect(container).toBeEmptyDOMElement(); + + expect(NitroLogger.error).toHaveBeenCalledTimes(1); + const [ message, err ] = (NitroLogger.error as ReturnType).mock.calls[0]; + expect(message).toBe('[Widget:Boom] crashed'); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe('kaboom'); + }); + + it('renders a custom fallback node when provided', () => + { + const Boom: FC = () => + { + throw new Error('explode'); + }; + + render( + offline }> + + + ); + + expect(screen.getByTestId('fb')).toHaveTextContent('offline'); + }); + + it('uses "unknown" as the widget name when the prop is omitted', () => + { + const Boom: FC = () => + { + throw new Error('anonymous'); + }; + + render( + + + + ); + + expect(NitroLogger.error).toHaveBeenCalledTimes(1); + expect((NitroLogger.error as ReturnType).mock.calls[0][0]).toBe('[Widget:unknown] crashed'); + }); +}); diff --git a/tests/mocks/renderer-mock.ts b/tests/mocks/renderer-mock.ts new file mode 100644 index 0000000..acd410e --- /dev/null +++ b/tests/mocks/renderer-mock.ts @@ -0,0 +1,260 @@ +/** + * Stub of `@nitrots/nitro-renderer` for Vitest. + * + * The real package eagerly loads Pixi v8 + a few hundred Habbo message + * parsers/composers at module import time, which jsdom cannot host: + * any `tests/**` file that transitively pulls a renderer symbol throws + * before a single assertion runs. + * + * This module replaces it via `resolve.alias` in `vitest.config.mts`. + * We provide explicit named exports for the symbols a test currently + * needs (logger, event dispatcher, doorbell event class); everything + * else mentioned in the comments below is a generic stub kept just to + * keep the side-effectful imports happy as `src/api/index.ts` and + * friends are pulled in transitively by the barrel cascade. + * + * Grow this file as new tests require additional symbols. Prefer adding + * a real (deterministic) stub over wiring `vi.fn()` — it keeps the + * mocks readable and avoids state bleed between cases. + */ + +import { vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Logger +// --------------------------------------------------------------------------- + +export const NitroLogger = { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + enableContexts: vi.fn(), + setDebug: vi.fn() +}; + +// --------------------------------------------------------------------------- +// Event dispatcher +// --------------------------------------------------------------------------- +// +// `GetEventDispatcher()` in the real SDK returns the renderer-wide event +// bus. Tests use `mockEventDispatcher.dispatchEvent(event)` to simulate +// a server push. `clearMockEventDispatcher()` resets the listener map +// between cases so subscriptions from a previous test don't leak. + +type Listener = (event: any) => void; + +const listeners = new Map>(); + +export const mockEventDispatcher = { + addEventListener(type: string, handler: Listener) + { + let bucket = listeners.get(type); + + if(!bucket) + { + bucket = new Set(); + listeners.set(type, bucket); + } + + bucket.add(handler); + }, + removeEventListener(type: string, handler: Listener) + { + listeners.get(type)?.delete(handler); + }, + dispatchEvent(event: { type: string }) + { + const bucket = listeners.get(event.type); + + if(!bucket) return; + + for(const handler of bucket) handler(event); + }, + hasListeners(type: string) + { + return (listeners.get(type)?.size ?? 0) > 0; + } +}; + +export const clearMockEventDispatcher = () => +{ + listeners.clear(); +}; + +export const GetEventDispatcher = vi.fn(() => mockEventDispatcher); + +// --------------------------------------------------------------------------- +// Event type enums (string-keyed Proxies) +// --------------------------------------------------------------------------- +// +// The real `*EventType` is a `class { static readonly FOO = '...'; … }` +// with stable wire strings. Tests only need each constant to be a +// unique string so dispatch + listener agree. + +const makeEnumProxy = (label: string) => new Proxy({}, { + get: (_, prop) => typeof prop === 'string' ? `mock:${ label }:${ prop }` : undefined +}) as Record; + +export const NitroEventType = makeEnumProxy('NitroEventType'); +export const MouseEventType = makeEnumProxy('MouseEventType'); +export const TouchEventType = makeEnumProxy('TouchEventType'); +export const RoomObjectCategory = makeEnumProxy('RoomObjectCategory'); +export const RoomObjectPlacementSource = makeEnumProxy('RoomObjectPlacementSource'); +export const RoomObjectType = makeEnumProxy('RoomObjectType'); +export const RoomObjectVariable = makeEnumProxy('RoomObjectVariable'); +export const RoomControllerLevel = makeEnumProxy('RoomControllerLevel'); +export const RoomTradingLevelEnum = makeEnumProxy('RoomTradingLevelEnum'); +export const HabboClubLevelEnum = makeEnumProxy('HabboClubLevelEnum'); +export const FurnitureType = makeEnumProxy('FurnitureType'); +export const PetType = makeEnumProxy('PetType'); +export const AvatarFigurePartType = makeEnumProxy('AvatarFigurePartType'); +export const AvatarScaleType = makeEnumProxy('AvatarScaleType'); +export const AvatarSetType = makeEnumProxy('AvatarSetType'); +export const AvatarAction = makeEnumProxy('AvatarAction'); +export const RoomWidgetEnumItemExtradataParameter = makeEnumProxy('RoomWidgetEnumItemExtradataParameter'); + +// --------------------------------------------------------------------------- +// Doorbell event class +// --------------------------------------------------------------------------- + +export class RoomSessionDoorbellEvent +{ + // Wire strings copied from the real class so any consumer that + // ignores the indirection through the `.DOORBELL` static still + // matches. + static readonly DOORBELL = 'RSDE_DOORBELL'; + static readonly RSDE_ACCEPTED = 'RSDE_ACCEPTED'; + static readonly RSDE_REJECTED = 'RSDE_REJECTED'; + + // Mirrors the real constructor signature `(type, session, userName)` + // so `tsgo` is happy. Tests can pass `null` for the session: the + // SUT only reads `.userName` and `.type`. + constructor(public readonly type: string, public readonly _session: unknown, public readonly userName: string) {} +} + +// --------------------------------------------------------------------------- +// Generic classes — placeholders for symbols that need to exist as +// constructors so module-level `new X(...)` calls don't crash during +// the barrel cascade, but whose behavior tests don't yet exercise. +// --------------------------------------------------------------------------- + +class StubClass +{ + constructor(..._args: unknown[]) {} +} + +export class NitroAlphaFilter extends StubClass {} +export class NitroContainer extends StubClass {} +export class NitroRectangle extends StubClass {} +export class NitroSprite extends StubClass {} +export class NitroTexture extends StubClass {} +export class NitroSoundEvent extends StubClass {} +export class NitroEvent extends StubClass {} +export class MessageEvent extends StubClass {} +export class RoomEngineObjectEvent extends StubClass {} +export class CreateLinkEvent extends StubClass {} +export class EventDispatcher extends StubClass {} +export class AdvancedMap extends StubClass {} +export class AvatarFigureContainer extends StubClass {} +export class Vector3d extends StubClass {} +export class ObjectDataFactory extends StubClass {} +export class RoomDataParser extends StubClass {} +export class RoomModerationSettings extends StubClass {} +export class StringDataType extends StubClass {} +export class SellablePetPaletteData extends StubClass {} +export class PetFigureData extends StubClass {} +export class PetData extends StubClass {} +export class NodeData extends StubClass {} +export class ItemDataStructure extends StubClass {} +export class HabboGroupEntryData extends StubClass {} +export class FriendParser extends StubClass {} +export class FriendCategoryData extends StubClass {} +export class FriendRequestData extends StubClass {} +export class FurnitureListItemParser extends StubClass {} +export class BotData extends StubClass {} +export class AchievementData extends StubClass {} +export class CatalogPageMessageProductData extends StubClass {} +export class GiftWrappingConfigurationParser extends StubClass {} +export class WiredFilter extends StubClass {} +export class HabboWebTools extends StubClass {} + +// Composers — symbol-only constructors; only their identity matters in the +// codebase ("did the SUT call SendMessageComposer(new FooComposer(args))"). +export class AddFavouriteRoomMessageComposer extends StubClass {} +export class DeleteFavouriteRoomMessageComposer extends StubClass {} +export class DesktopViewComposer extends StubClass {} +export class FurniturePlacePaintComposer extends StubClass {} +export class GetGuestRoomMessageComposer extends StubClass {} +export class GetProductOfferComposer extends StubClass {} +export class GroupFavoriteComposer extends StubClass {} +export class GroupInformationComposer extends StubClass {} +export class GroupJoinComposer extends StubClass {} +export class GroupUnfavoriteComposer extends StubClass {} +export class UserProfileComposer extends StubClass {} + +// `ChooserSelectionFilter` is used as a string enum in some call sites. +export const ChooserSelectionFilter = makeEnumProxy('ChooserSelectionFilter'); + +// --------------------------------------------------------------------------- +// Singleton getters +// --------------------------------------------------------------------------- + +const stubManager = () => +{ + const sentinel = new Proxy(() => undefined, { + get(target, prop) + { + if(prop === 'then') return undefined; + const cached = (target as any)[prop]; + if(cached !== undefined) return cached; + // Most fields read from a real manager are either methods + // (return functions) or sub-objects (return proxies). We + // return another callable proxy so chained access works. + const value = stubManager(); + (target as any)[prop] = value; + return value; + }, + apply() + { + return undefined; + } + }); + + return sentinel; +}; + +export const GetAssetManager = vi.fn(stubManager); +export const GetAvatarRenderManager = vi.fn(stubManager); +export const GetCommunication = vi.fn(stubManager); +export const GetConfiguration = vi.fn(stubManager); +export const GetLocalizationManager = vi.fn(stubManager); +export const GetRoomEngine = vi.fn(stubManager); +export const GetRoomSessionManager = vi.fn(stubManager); +export const GetSessionDataManager = vi.fn(stubManager); +export const GetTickerTime = vi.fn(() => 0); +export const TextureUtils = stubManager(); +export const NitroVersion = stubManager(); + +// --------------------------------------------------------------------------- +// Type-only re-exports (interfaces erase at compile time, but listing them +// here documents what the codebase imports through the type channel). +// +// IAvatarFigureContainer · IEventDispatcher · IFigurePart · IFigurePartSet · +// IFurnitureData · IFurnitureItemData · IGraphicAsset · IMessageComposer · +// IMessageEvent · IObjectData · IPartColor · IPollQuestion · IProductData · +// IRoomEngine · IRoomModerationSettings · IRoomObject · IRoomObjectController · +// IRoomObjectSpriteVisualization · IRoomPetData · IRoomSession · IRoomUserData +// +// No need to alias them — TypeScript only consults them in the type +// system, and the production `tsconfig.json` resolves them against the +// real renderer via `node_modules/@nitrots/nitro-renderer/src`. + +// --------------------------------------------------------------------------- +// Catch-all +// --------------------------------------------------------------------------- +// +// Anything else still resolves to `undefined`. If a test fails with +// "X is not a constructor" / "X.SOMETHING is not a function", add the +// missing symbol above with a real stub. Avoid the temptation to +// blanket-mock everything — explicit stubs surface intent and let +// failing tests pinpoint what behavior they actually rely on. diff --git a/tests/useDoorbellState.test.tsx b/tests/useDoorbellState.test.tsx new file mode 100644 index 0000000..d04be03 --- /dev/null +++ b/tests/useDoorbellState.test.tsx @@ -0,0 +1,102 @@ +/* @vitest-environment jsdom */ + +import { RoomSessionDoorbellEvent } from '@nitrots/nitro-renderer'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { useDoorbellState } from '../src/hooks/rooms/widgets/useDoorbellState'; +import { clearMockEventDispatcher, mockEventDispatcher } from './mocks/renderer-mock'; + +// Server push helper — mirrors the renderer wire by emitting the same +// constants the SUT listens to. The real constructor takes a session +// reference too; pass null since the SUT only reads `.userName`. +const dispatchDoorbell = (type: string, userName: string) => +{ + act(() => + { + mockEventDispatcher.dispatchEvent(new RoomSessionDoorbellEvent(type, null as any, userName)); + }); +}; + +describe('useDoorbellState', () => +{ + beforeEach(() => + { + clearMockEventDispatcher(); + }); + + afterEach(() => + { + cleanup(); + }); + + it('starts with no users pending', () => + { + const { result } = renderHook(() => useDoorbellState()); + + expect(result.current).toEqual([]); + }); + + it('appends the username from a DOORBELL event', () => + { + const { result } = renderHook(() => useDoorbellState()); + + dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice'); + + expect(result.current).toEqual([ 'Alice' ]); + }); + + it('ignores duplicate DOORBELL events for the same username', () => + { + const { result } = renderHook(() => useDoorbellState()); + + dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice'); + dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice'); + + expect(result.current).toEqual([ 'Alice' ]); + }); + + it('removes a user on RSDE_ACCEPTED while keeping the others', () => + { + const { result } = renderHook(() => useDoorbellState()); + + dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Alice'); + dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Bob'); + dispatchDoorbell(RoomSessionDoorbellEvent.RSDE_ACCEPTED, 'Alice'); + + expect(result.current).toEqual([ 'Bob' ]); + }); + + it('removes a user on RSDE_REJECTED', () => + { + const { result } = renderHook(() => useDoorbellState()); + + dispatchDoorbell(RoomSessionDoorbellEvent.DOORBELL, 'Carol'); + dispatchDoorbell(RoomSessionDoorbellEvent.RSDE_REJECTED, 'Carol'); + + expect(result.current).toEqual([]); + }); + + it('ignores accept/reject events for users that were never pending', () => + { + const { result } = renderHook(() => useDoorbellState()); + + dispatchDoorbell(RoomSessionDoorbellEvent.RSDE_ACCEPTED, 'Ghost'); + + expect(result.current).toEqual([]); + }); + + it('unsubscribes from all three events on unmount', () => + { + const { unmount } = renderHook(() => useDoorbellState()); + + expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.DOORBELL)).toBe(true); + expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_ACCEPTED)).toBe(true); + expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_REJECTED)).toBe(true); + + unmount(); + + expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.DOORBELL)).toBe(false); + expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_ACCEPTED)).toBe(false); + expect(mockEventDispatcher.hasListeners(RoomSessionDoorbellEvent.RSDE_REJECTED)).toBe(false); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index b6cddc3..7c217c6 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -5,8 +5,12 @@ import { resolve } from 'path'; * Test runner config — kept separate from vite.config.mjs because the * dev/build config wires up the renderer SDK via filesystem aliases that * point at sibling working trees (`../renderer`, `../Nitro_Render_V3`). - * Tests are deliberately written against pure modules (helpers, stores) - * that don't pull in the renderer. + * + * Test files were originally written against pure modules (helpers, + * stores) that don't pull in the renderer. We now also support + * component-level tests by aliasing `@nitrots/nitro-renderer` to a + * hand-written stub at `tests/mocks/renderer-mock.ts` so jsdom doesn't + * try to evaluate Pixi + the full message parser/composer registry. */ export default defineConfig({ test: { @@ -18,6 +22,7 @@ export default defineConfig({ }, resolve: { alias: { + '@nitrots/nitro-renderer': resolve(__dirname, 'tests/mocks/renderer-mock.ts'), '@': resolve(__dirname, 'src') } } From fd3ef7875d8e28b03c8739a0c9dd2412710b8382 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 13 May 2026 21:42:04 +0200 Subject: [PATCH 076/129] catalog: extract pure helpers + 34 cases, consume them from useCatalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of the proposed `useCatalog` decomposition. The 1036-line god-hook still owns the singleton-via-useBetween, but the pure logic it used to define inline now lives in a dependency-free module so it can be tested in isolation and reused by future split-out hooks (`useCatalogData` / `useCatalogUiState` / `useCatalogActions` when those land). New module: `src/hooks/catalog/useCatalog.helpers.ts` (222 LOC). - `normalizeCatalogType(type?)` — coerce the optional catalog type to `NORMAL` / `BUILDER`. Was a 5-line `useCallback` with an empty dependency array. - `getOfferProductKeys(offer)` — produces the canonical `productType:id:classId` and `productType:class:className` keys for the resolved-offer cache. - `findNodeById` / `findNodeByName` — DFS over the catalog tree, root explicitly excluded so callers can't select the synthetic root by mistake. - `getNodesByOfferIdFromMap(offerId, map, onlyVisible)` — extracted from the closed-over `getNodesByOfferId`. The `onlyVisible` fallback to the full bucket when nothing visible remains is preserved. - `buildCatalogNodeTree(NodeData)` — pulled out of the `CatalogPagesListEvent` reducer. Builds the tree and the offerId index in one pass; the caller now does `const { rootNode, offersToNodes } = buildCatalogNodeTree(parser.root)` instead of carrying an inline recursive walker + a local map. - `resolveBuilderFurniPlaceableStatus(input)` — the placement decision tree as a pure function. The hook keeps the `GetRoomEngine` / `GetSessionDataManager` reads that count non-self, non-moderator visitors (only when the subscription has expired) and forwards the resulting `visitorCount` into the helper, so the previous early-exit semantics are preserved. `useCatalog.ts` now imports these and removes ~140 lines of inline copies. Net hook size: 1036 → 961 LOC. Behavior unchanged. Tests: `tests/useCatalog.helpers.test.ts` (34 cases). - `normalizeCatalogType` (4) — BUILDER pass-through, NORMAL pass-through, undefined/empty fallback, unknown string fallback. - `getOfferProductKeys` (5) — both keys, id-only when classId<0, class-only when className empty, no-product short-circuit, empty productType short-circuit. - `findNodeById` (5) — null input, root exclusion, immediate child, grandchild, miss returns null. - `findNodeByName` (2) — match by name + root exclusion, miss. - `getNodesByOfferIdFromMap` (5) — empty map, raw bucket pass-through, visible-only filter, fallback when no visible remain, miss. - `buildCatalogNodeTree` (3) — root depth=0 + empty offer map for a leaf-only root, DFS traversal tracks offer→nodes across branch and leaf, child.parent === root. - `resolveBuilderFurniPlaceableStatus` (10) — missing offer, not-in-room, owner happy path, non-owner without fallback, guild admin with time, furni limit reached, shared-pool override ignoring the limit, expired+blocked-by-visitors flag, expired+visitor count > 0, expired+empty room is okay. To support the placement-status test the renderer mock gains real numeric values for `RoomControllerLevel` (NONE..MODERATOR) and `RoomObjectCategory` (MINIMUM..MAXIMUM); the previous string-keyed Proxy stubs made `controllerLevel >= GUILD_ADMIN` evaluate to NaN. Suite: 158/158 (was 124/124). `yarn typecheck` green. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 8 +- docs/ARCHITECTURE.md | 37 ++- src/hooks/catalog/useCatalog.helpers.ts | 222 ++++++++++++++ src/hooks/catalog/useCatalog.ts | 171 +++-------- tests/mocks/renderer-mock.ts | 25 +- tests/useCatalog.helpers.test.ts | 379 ++++++++++++++++++++++++ 6 files changed, 714 insertions(+), 128 deletions(-) create mode 100644 src/hooks/catalog/useCatalog.helpers.ts create mode 100644 tests/useCatalog.helpers.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5d45bc8..20b8ee8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,13 +261,13 @@ into `configurePreviewServer` so `yarn preview` keeps working. | 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` | | `WidgetErrorBoundary` | `RoomWidgetsView` umbrella | -| Vitest | 124/124 cases — 113 on pure helpers + Zustand store, plus the first 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the new renderer-SDK mock at `tests/mocks/renderer-mock.ts` | +| Vitest | 158/158 cases — pure helpers + Zustand store + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `tests/mocks/renderer-mock.ts`, plus 34 cases on the freshly extracted catalog helpers | | Form Actions | Login / Register / Forgot (LoginView.tsx) | | Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating | | Not yet | Notes | |---|---| -| Core `useCatalog` split | Session-stable secondary fetches all migrated to TanStack queries (see ARCHITECTURE.md). What's left: core `rootNode`/`offersToNodes`/`currentPage` slice + Builders Club status. Needs a dedicated `useCatalogData`/`useCatalogUiState`/`useCatalogActions` split. | +| Singleton-filter split of `useCatalog` | Pure helpers extracted to `useCatalog.helpers.ts` and consumed in the hook (`buildCatalogNodeTree`, `findNodeById`, `findNodeByName`, `getNodesByOfferIdFromMap`, `getOfferProductKeys`, `normalizeCatalogType`, `resolveBuilderFurniPlaceableStatus`). What still remains: split the singleton state into `useCatalogData` / `useCatalogUiState` / `useCatalogActions` filters via `useBetween`, mirroring the wired-tools / translation / notification / friends pattern. The 48 consumers can stay on the shim during the transition. | | Split `useChatWidget` / `useAvatarInfoWidget` | Both state-driven via events with no clean imperative actions to extract — skip-motivated. Already touched today for the InfoStand listener move. | | Split `usePetPackageWidget` / `useWordQuizWidget` / `useChatCommandSelector` | Their "actions" mutate internal state or are tightly interdependent — skip-motivated. | | Hoist Wired Creator Tools shared state to a Zustand slice | Would remove ~25 props passed to the 3 tab sub-components. (Wired-tools split done as singleton-filter; Zustand slice is the next step.) | @@ -323,6 +323,10 @@ Fix shapes documented; both are reasonable PRs on their own. - Asset middleware: `nitroAssetsServer()` in `vite.config.mjs` - Configuration pre-init: `src/bootstrap.ts` (`await GetConfiguration().init()` before `import('./index')`) +- Catalog pure helpers: `src/hooks/catalog/useCatalog.helpers.ts` + (`buildCatalogNodeTree`, `findNodeById` / `findNodeByName`, + `getNodesByOfferIdFromMap`, `getOfferProductKeys`, + `normalizeCatalogType`, `resolveBuilderFurniPlaceableStatus`) - Renderer-SDK mock for Vitest: `tests/mocks/renderer-mock.ts` (aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`). Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` / diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 57d2cb6..345c0e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -480,11 +480,39 @@ Status after this round of work: | CatalogPagesList / CatalogPage | **deferred** — core state slice (rootNode / offersToNodes / currentPage), needs its own split-out store | | BuildersClubFurniCount / SubscriptionStatus | **deferred** — read by the internal `getBuilderFurniPlaceableStatus` logic, moves with the data/actions split | +Pure-helper extraction landed before the singleton split: +`src/hooks/catalog/useCatalog.helpers.ts` hosts the dependency-free +pieces previously inlined in the hook — + +- `normalizeCatalogType(type?)` — coerce the optional catalog type + back to `NORMAL` / `BUILDER`. +- `getOfferProductKeys(offer)` — canonical lookup keys for the + resolved-offer cache. +- `findNodeById` / `findNodeByName` — DFS over the catalog tree, + root excluded. +- `getNodesByOfferIdFromMap(offerId, map, onlyVisible)` — used to be + the closed-over `getNodesByOfferId`; the `onlyVisible` fallback to + the full bucket is preserved. +- `buildCatalogNodeTree(NodeData)` — pulled out of the + `CatalogPagesListEvent` reducer; returns the tree + the offerId + index map in one pass. +- `resolveBuilderFurniPlaceableStatus(input)` — the placement + decision tree as a pure function; the hook keeps the `GetRoomEngine` + / `GetSessionDataManager` reads (to count non-self, non-moderator + visitors) and passes the resulting `visitorCount` into the helper. + +`useCatalog.ts` now imports these instead of defining them inline +(net **−75 LOC**). Test file `tests/useCatalog.helpers.test.ts` covers +all six helpers with 34 cases (tree depth + offerId mapping, +node lookups including root exclusion, the limit-reached / guild-admin +fallback / visitors-in-room paths of the placement helper, and the +empty-map / partial-bucket branches of the offer lookup). + ### Tests - Vitest 3 + jsdom + `@testing-library/react` + `@testing-library/jest-dom` configured. Separate `vitest.config.mts` so the runner doesn't drag in the renderer SDK aliases from `vite.config.mjs`. -- **124 cases passing** across 10 test files. Pure-module suites: +- **158 cases passing** across 11 test files. Pure-module suites: - `WiredCreatorTools.helpers.test.ts` (18) — formatters + snapshot factory. - `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants @@ -504,6 +532,13 @@ Status after this round of work: bail-out branches (state-not-AvatarInfoUser, mismatched user/roomIndex, equal-after-dedup) + the figure / favorite-group apply paths. + - `useCatalog.helpers.test.ts` (34) — catalog pure helpers + extracted out of the god-hook: `normalizeCatalogType`, + `getOfferProductKeys`, `findNodeById` / `findNodeByName` (with + the root-exclusion guard), `getNodesByOfferIdFromMap` (with + the partial-visible fallback), `buildCatalogNodeTree` (tree + depth + offerId index), and the full decision tree of + `resolveBuilderFurniPlaceableStatus`. Component-/hook-level suites (on the new renderer-SDK mock): - `WidgetErrorBoundary.test.tsx` (4) — happy path + caught render diff --git a/src/hooks/catalog/useCatalog.helpers.ts b/src/hooks/catalog/useCatalog.helpers.ts new file mode 100644 index 0000000..ac78527 --- /dev/null +++ b/src/hooks/catalog/useCatalog.helpers.ts @@ -0,0 +1,222 @@ +import { NodeData, RoomControllerLevel, RoomObjectCategory, RoomObjectType } from '@nitrots/nitro-renderer'; +import { BuilderFurniPlaceableStatus, CatalogNode, CatalogType, ICatalogNode, IPurchasableOffer } from '../../api'; + +/** + * Pure helpers extracted from `useCatalog.ts`. Each function takes the + * relevant pieces of state as inputs (instead of closing over them via + * useCallback) so it can be unit-tested without rendering a React tree. + * + * Keep these dependency-free at the React layer: no `useState`, no + * refs, no `vi.fn`-able side effects beyond what the renderer SDK + * already exposes (`GetRoomEngine`, etc., which the call sites guard). + */ + +/** + * The catalog has two top-level "types" — the regular catalog and the + * Builders Club catalog. Anything else maps to NORMAL. Centralising + * the coercion in one place keeps the switch from drifting between + * call sites and the message-event handlers. + */ +export const normalizeCatalogType = (type?: string): string => +{ + if(type === CatalogType.BUILDER) return CatalogType.BUILDER; + + return CatalogType.NORMAL; +}; + +/** + * Build the canonical product-key list for a purchasable offer. Used + * by the resolved-offer cache so the same offer can be looked up by + * either `productType:id:classId` or `productType:class:className`. + */ +export const getOfferProductKeys = (offer: IPurchasableOffer | null | undefined): string[] => +{ + const keys: string[] = []; + const product = offer?.product; + + if(!product) return keys; + + if(product.productType && (product.productClassId >= 0)) + { + keys.push(`${ product.productType }:id:${ product.productClassId }`); + } + + if(product.productType && product.furnitureData?.className?.length) + { + keys.push(`${ product.productType }:class:${ product.furnitureData.className }`); + } + + return keys; +}; + +/** + * Depth-first search by pageId. The root is excluded so callers never + * select the synthetic "root" node by mistake. Recursive but bounded + * by the tree the server sends (typically 3-4 levels deep). + */ +export const findNodeById = (id: number, node: ICatalogNode | null, rootNode: ICatalogNode | null): ICatalogNode | null => +{ + if(!node) return null; + if((node.pageId === id) && (node !== rootNode)) return node; + + for(const child of node.children) + { + const found = findNodeById(id, child, rootNode); + + if(found) return found; + } + + return null; +}; + +/** + * Depth-first search by pageName. Same exclusion of the root as + * `findNodeById`. + */ +export const findNodeByName = (name: string, node: ICatalogNode | null, rootNode: ICatalogNode | null): ICatalogNode | null => +{ + if(!node) return null; + if((node.pageName === name) && (node !== rootNode)) return node; + + for(const child of node.children) + { + const found = findNodeByName(name, child, rootNode); + + if(found) return found; + } + + return null; +}; + +/** + * Lookup the list of catalog nodes a given offer appears under. When + * `onlyVisible` is true the helper falls back to the full list if the + * filtered subset is empty — matches the original behavior in + * `getNodesByOfferId(offerId, true)`. + */ +export const getNodesByOfferIdFromMap = ( + offerId: number, + offersToNodes: Map | null | undefined, + onlyVisible: boolean = false +): ICatalogNode[] | null => +{ + if(!offersToNodes || !offersToNodes.size) return null; + + if(onlyVisible) + { + const offers = offersToNodes.get(offerId); + const visible: ICatalogNode[] = []; + + if(offers && offers.length) + { + for(const offer of offers) + { + if(offer.isVisible) visible.push(offer); + } + } + + if(visible.length) return visible; + } + + return offersToNodes.get(offerId) ?? null; +}; + +/** + * Turn the server-side NodeData tree into a CatalogNode tree paired + * with an offerId → nodes index map. Pure (besides the `new + * CatalogNode` construction). + * + * Original lived inline inside the `CatalogPagesListEvent` handler; + * extracted so the reducer is testable without rendering the hook. + */ +export const buildCatalogNodeTree = (root: NodeData): { rootNode: ICatalogNode; offersToNodes: Map } => +{ + const offersToNodes: Map = new Map(); + + const walk = (node: NodeData, depth: number, parent: ICatalogNode | null): ICatalogNode => + { + const catalogNode = (new CatalogNode(node, depth, parent) as ICatalogNode); + + for(const offerId of catalogNode.offerIds) + { + const existing = offersToNodes.get(offerId); + + if(existing) existing.push(catalogNode); + else offersToNodes.set(offerId, [ catalogNode ]); + } + + for(const child of node.children) catalogNode.addChild(walk(child, depth + 1, catalogNode)); + + return catalogNode; + }; + + return { rootNode: walk(root, 0, null), offersToNodes }; +}; + +/** + * Pure-input version of the placement-status decision. The original + * `getBuilderFurniPlaceableStatus` closes over a handful of state + * slices + reads `GetRoomSession()` / `GetRoomEngine()` / + * `GetSessionDataManager()` directly. Pulling those reads up to the + * call site (the hook still does them) makes the rest of the + * decision tree testable in isolation. + * + * `roomSession` may be null (user is in the hotel view, not a room). + * `usersInRoomMinusSelf` is the number of non-moderator, non-self + * users sharing the room — only consulted when `secondsLeft <= 0`, + * because the limit-reached / not-in-room paths short-circuit first. + */ +export interface BuilderPlacementStatusInput +{ + offer: IPurchasableOffer | null | undefined; + roomSession: { isGuildRoom: boolean; isRoomOwner: boolean; controllerLevel: number } | null; + secondsLeft: number; + furniCount: number; + furniLimit: number; + builderPlacementAllowedInCurrentRoom: boolean; + builderPlacementBlockedByVisitors: boolean; + /** Count of non-moderator, non-self users in the room. Only consulted when `secondsLeft <= 0`. */ + visitorCount?: number; +} + +export const resolveBuilderFurniPlaceableStatus = (input: BuilderPlacementStatusInput): BuilderFurniPlaceableStatus => +{ + const { offer, roomSession, secondsLeft, furniCount, furniLimit, builderPlacementAllowedInCurrentRoom, builderPlacementBlockedByVisitors, visitorCount = 0 } = input; + + if(!offer) return BuilderFurniPlaceableStatus.MISSING_OFFER; + + if(!roomSession) return BuilderFurniPlaceableStatus.NOT_IN_ROOM; + + const canUseGuildAdminFallback = (roomSession.isGuildRoom + && (roomSession.controllerLevel >= RoomControllerLevel.GUILD_ADMIN) + && (secondsLeft > 0)); + + const usesSharedPlacementPool = (!roomSession.isRoomOwner && (builderPlacementAllowedInCurrentRoom || canUseGuildAdminFallback)); + + if(!roomSession.isRoomOwner && !builderPlacementAllowedInCurrentRoom && !canUseGuildAdminFallback) + { + return BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN; + } + + if(!usesSharedPlacementPool && ((furniCount < 0) || (furniCount >= furniLimit))) + { + return BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED; + } + + if((secondsLeft <= 0) && builderPlacementBlockedByVisitors) + { + return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM; + } + + if((secondsLeft <= 0) && (visitorCount > 0)) + { + return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM; + } + + return BuilderFurniPlaceableStatus.OKAY; +}; + +// Re-exports for the legacy categories so the call site in useCatalog +// doesn't need to know whether the constant comes from the renderer +// SDK enum or our own copy. +export { RoomControllerLevel, RoomObjectCategory, RoomObjectType }; diff --git a/src/hooks/catalog/useCatalog.ts b/src/hooks/catalog/useCatalog.ts index 9676eba..0f21028 100644 --- a/src/hooks/catalog/useCatalog.ts +++ b/src/hooks/catalog/useCatalog.ts @@ -1,10 +1,11 @@ -import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetRoomEngine, GetSessionDataManager, GetTickerTime, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, NodeData, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomControllerLevel, RoomEngineObjectPlacedEvent, RoomObjectCategory, RoomObjectPlacementSource, RoomObjectType, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer'; +import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetRoomEngine, GetSessionDataManager, GetTickerTime, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomEngineObjectPlacedEvent, RoomObjectPlacementSource, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useBetween } from 'use-between'; -import { BuilderFurniPlaceableStatus, CatalogNode, CatalogPage, CatalogType, DispatchUiEvent, FurniCategory, GetFurnitureData, GetProductDataForLocalization, GetRoomSession, ICatalogNode, ICatalogPage, IPageLocalization, IProduct, IPurchasableOffer, IPurchaseOptions, LocalizeText, NotificationAlertType, Offer, PageLocalization, PlacedObjectPurchaseData, PlaySound, Product, ProductTypeEnum, RequestedPage, SearchResult, SendMessageComposer, SoundNames } from '../../api'; +import { BuilderFurniPlaceableStatus, CatalogPage, CatalogType, DispatchUiEvent, FurniCategory, GetFurnitureData, GetProductDataForLocalization, GetRoomSession, ICatalogNode, ICatalogPage, IPageLocalization, IProduct, IPurchasableOffer, IPurchaseOptions, LocalizeText, NotificationAlertType, Offer, PageLocalization, PlacedObjectPurchaseData, PlaySound, Product, ProductTypeEnum, RequestedPage, SearchResult, SendMessageComposer, SoundNames } from '../../api'; import { CatalogPurchaseFailureEvent, CatalogPurchaseNotAllowedEvent, CatalogPurchaseSoldOutEvent, CatalogPurchasedEvent, InventoryFurniAddedEvent } from '../../events'; import { useMessageEvent, useNitroEvent, useUiEvent } from '../events'; import { useNotification } from '../notification'; +import { buildCatalogNodeTree, findNodeById, findNodeByName, getNodesByOfferIdFromMap, getOfferProductKeys, normalizeCatalogType, resolveBuilderFurniPlaceableStatus, RoomControllerLevel, RoomObjectCategory, RoomObjectType } from './useCatalog.helpers'; import { useCatalogPlaceMultipleItems } from './useCatalogPlaceMultipleItems'; import { useCatalogSkipPurchaseConfirmation } from './useCatalogSkipPurchaseConfirmation'; @@ -62,13 +63,6 @@ const useCatalogState = () => setIsVisible(false); }, []); - const normalizeCatalogType = useCallback((type?: string) => - { - if(type === CatalogType.BUILDER) return CatalogType.BUILDER; - - return CatalogType.NORMAL; - }, []); - const resetVisibleCatalogState = useCallback((type?: string) => { requestedPage.current.resetRequest(); @@ -85,7 +79,7 @@ const useCatalogState = () => setFrontPageItems([]); setNavigationHidden(false); setCurrentType(normalizeCatalogType(type)); - }, [ normalizeCatalogType ]); + }, []); const openCatalogByType = useCallback((type?: string) => { @@ -97,7 +91,7 @@ const useCatalogState = () => } setIsVisible(true); - }, [ currentType, normalizeCatalogType, resetVisibleCatalogState ]); + }, [ currentType, resetVisibleCatalogState ]); const toggleCatalogByType = useCallback((type?: string) => { @@ -116,54 +110,60 @@ const useCatalogState = () => } setIsVisible(true); - }, [ isVisible, currentType, normalizeCatalogType, resetVisibleCatalogState ]); + }, [ isVisible, currentType, resetVisibleCatalogState ]); const getBuilderFurniPlaceableStatus = useCallback((offer: IPurchasableOffer) => { - if(!offer) return BuilderFurniPlaceableStatus.MISSING_OFFER; - const roomSession = GetRoomSession(); - const canUseGuildAdminFallback = (!!roomSession - && roomSession.isGuildRoom - && (roomSession.controllerLevel >= RoomControllerLevel.GUILD_ADMIN) - && (secondsLeft > 0)); - const usesSharedPlacementPool = (!!roomSession && !roomSession.isRoomOwner && (builderPlacementAllowedInCurrentRoom || canUseGuildAdminFallback)); - if(!roomSession) return BuilderFurniPlaceableStatus.NOT_IN_ROOM; + // Count non-self, non-moderator users sharing the room. Only + // matters when the subscription has expired — the pure helper + // short-circuits on the limit-reached / not-in-room paths + // first, so we skip the room scan when there's still time on + // the clock. + let visitorCount = 0; - if(!roomSession.isRoomOwner && !builderPlacementAllowedInCurrentRoom && !canUseGuildAdminFallback) return BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN; - - if(!usesSharedPlacementPool && ((furniCount < 0) || (furniCount >= furniLimit))) return BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED; - - if((secondsLeft <= 0) && builderPlacementBlockedByVisitors) return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM; - - if(secondsLeft <= 0) + if(roomSession && (secondsLeft <= 0) && !builderPlacementBlockedByVisitors) { const roomEngine = GetRoomEngine(); const userDataManager = roomSession.userDataManager; const sessionDataManager = GetSessionDataManager(); - if(!roomEngine || !userDataManager || !sessionDataManager) return BuilderFurniPlaceableStatus.OKAY; - - const roomObjects = roomEngine.getRoomObjects(roomSession.roomId, RoomObjectCategory.UNIT); - - if(!roomObjects || !roomObjects.length) return BuilderFurniPlaceableStatus.OKAY; - - for(const roomObject of roomObjects) + if(roomEngine && userDataManager && sessionDataManager) { - if(!roomObject) continue; + const roomObjects = roomEngine.getRoomObjects(roomSession.roomId, RoomObjectCategory.UNIT); - const userData = userDataManager.getUserDataByIndex(roomObject.id); + if(roomObjects && roomObjects.length) + { + for(const roomObject of roomObjects) + { + if(!roomObject) continue; - if(!userData || (userData.type !== RoomObjectType.USER)) continue; - if(userData.webID === sessionDataManager.userId) continue; - if(userData.isModerator) continue; + const userData = userDataManager.getUserDataByIndex(roomObject.id); - return BuilderFurniPlaceableStatus.VISITORS_IN_ROOM; + if(!userData || (userData.type !== RoomObjectType.USER)) continue; + if(userData.webID === sessionDataManager.userId) continue; + if(userData.isModerator) continue; + + visitorCount++; + break; + } + } } } - return BuilderFurniPlaceableStatus.OKAY; + return resolveBuilderFurniPlaceableStatus({ + offer, + roomSession: roomSession + ? { isGuildRoom: roomSession.isGuildRoom, isRoomOwner: roomSession.isRoomOwner, controllerLevel: roomSession.controllerLevel } + : null, + secondsLeft, + furniCount, + furniLimit, + builderPlacementAllowedInCurrentRoom, + builderPlacementBlockedByVisitors, + visitorCount + }); }, [ builderPlacementAllowedInCurrentRoom, builderPlacementBlockedByVisitors, furniCount, furniLimit, secondsLeft ]); const isDraggable = useCallback((offer: IPurchasableOffer) => @@ -294,70 +294,12 @@ const useCatalogState = () => }); }, [ resetObjectMover, resetRoomPaint ]); - const getNodeById = useCallback((id: number, node: ICatalogNode) => - { - if((node.pageId === id) && (node !== rootNode)) return node; + const getNodeById = useCallback((id: number, node: ICatalogNode) => findNodeById(id, node, rootNode), [ rootNode ]); - for(const child of node.children) - { - const found = (getNodeById(id, child)); - - if(found) return found; - } - - return null; - }, [ rootNode ]); - - const getNodeByName = useCallback((name: string, node: ICatalogNode) => - { - if((node.pageName === name) && (node !== rootNode)) return node; - - for(const child of node.children) - { - const found = (getNodeByName(name, child)); - - if(found) return found; - } - - return null; - }, [ rootNode ]); + const getNodeByName = useCallback((name: string, node: ICatalogNode) => findNodeByName(name, node, rootNode), [ rootNode ]); const getNodesByOfferId = useCallback((offerId: number, flag: boolean = false) => - { - if(!offersToNodes || !offersToNodes.size) return null; - - if(flag) - { - const nodes: ICatalogNode[] = []; - const offers = offersToNodes.get(offerId); - - if(offers && offers.length) for(const offer of offers) (offer.isVisible && nodes.push(offer)); - - if(nodes.length) return nodes; - } - - return offersToNodes.get(offerId); - }, [ offersToNodes ]); - - const getOfferProductKeys = useCallback((offer: IPurchasableOffer) => - { - const product = offer?.product; - const keys: string[] = []; - - if(!product) return keys; - - if(product.productType && (product.productClassId >= 0)) - { - keys.push(`${ product.productType }:id:${ product.productClassId }`); - } - - if(product.productType && product.furnitureData?.className?.length) - { - keys.push(`${ product.productType }:class:${ product.furnitureData.className }`); - } - - return keys; - }, []); + getNodesByOfferIdFromMap(offerId, offersToNodes, flag), [ offersToNodes ]); const cacheResolvedOffer = useCallback((offer: IPurchasableOffer) => { @@ -365,7 +307,7 @@ const useCatalogState = () => { resolvedOffersByProductKey.current.set(key, offer); } - }, [ getOfferProductKeys ]); + }, []); const applySelectedOffer = useCallback((offer: IPurchasableOffer) => { @@ -563,27 +505,10 @@ const useCatalogState = () => if(parserCatalogType !== currentType) return; - const offers: Map = new Map(); + const { rootNode: builtRoot, offersToNodes: builtOffers } = buildCatalogNodeTree(parser.root); - const getCatalogNode = (node: NodeData, depth: number, parent: ICatalogNode) => - { - const catalogNode = (new CatalogNode(node, depth, parent) as ICatalogNode); - - for(const offerId of catalogNode.offerIds) - { - if(offers.has(offerId)) offers.get(offerId).push(catalogNode); - else offers.set(offerId, [ catalogNode ]); - } - - depth++; - - for(const child of node.children) catalogNode.addChild(getCatalogNode(child, depth, catalogNode)); - - return catalogNode; - }; - - setRootNode(getCatalogNode(parser.root, 0, null)); - setOffersToNodes(offers); + setRootNode(builtRoot); + setOffersToNodes(builtOffers); }); useMessageEvent(CatalogPageMessageEvent, event => diff --git a/tests/mocks/renderer-mock.ts b/tests/mocks/renderer-mock.ts index acd410e..25137f6 100644 --- a/tests/mocks/renderer-mock.ts +++ b/tests/mocks/renderer-mock.ts @@ -98,11 +98,9 @@ const makeEnumProxy = (label: string) => new Proxy({}, { export const NitroEventType = makeEnumProxy('NitroEventType'); export const MouseEventType = makeEnumProxy('MouseEventType'); export const TouchEventType = makeEnumProxy('TouchEventType'); -export const RoomObjectCategory = makeEnumProxy('RoomObjectCategory'); export const RoomObjectPlacementSource = makeEnumProxy('RoomObjectPlacementSource'); export const RoomObjectType = makeEnumProxy('RoomObjectType'); export const RoomObjectVariable = makeEnumProxy('RoomObjectVariable'); -export const RoomControllerLevel = makeEnumProxy('RoomControllerLevel'); export const RoomTradingLevelEnum = makeEnumProxy('RoomTradingLevelEnum'); export const HabboClubLevelEnum = makeEnumProxy('HabboClubLevelEnum'); export const FurnitureType = makeEnumProxy('FurnitureType'); @@ -113,6 +111,29 @@ export const AvatarSetType = makeEnumProxy('AvatarSetType'); export const AvatarAction = makeEnumProxy('AvatarAction'); export const RoomWidgetEnumItemExtradataParameter = makeEnumProxy('RoomWidgetEnumItemExtradataParameter'); +// Numeric enums — values mirror the real renderer SDK so comparisons +// (`controllerLevel >= GUILD_ADMIN`, category branching) keep working. + +export class RoomControllerLevel +{ + static readonly NONE = 0; + static readonly GUEST = 1; + static readonly GUILD_MEMBER = 2; + static readonly GUILD_ADMIN = 3; + static readonly ROOM_OWNER = 4; + static readonly MODERATOR = 5; +} + +export class RoomObjectCategory +{ + static readonly MINIMUM = 0; + static readonly ROOM = 10; + static readonly UNIT = 20; + static readonly FLOOR = 30; + static readonly WALL = 40; + static readonly MAXIMUM = 50; +} + // --------------------------------------------------------------------------- // Doorbell event class // --------------------------------------------------------------------------- diff --git a/tests/useCatalog.helpers.test.ts b/tests/useCatalog.helpers.test.ts new file mode 100644 index 0000000..5a64fba --- /dev/null +++ b/tests/useCatalog.helpers.test.ts @@ -0,0 +1,379 @@ +import { describe, expect, it } from 'vitest'; +import { BuilderFurniPlaceableStatus } from '../src/api/catalog/BuilderFurniPlaceableStatus'; +import { CatalogType } from '../src/api/catalog/CatalogType'; +import { + buildCatalogNodeTree, + findNodeById, + findNodeByName, + getNodesByOfferIdFromMap, + getOfferProductKeys, + normalizeCatalogType, + resolveBuilderFurniPlaceableStatus +} from '../src/hooks/catalog/useCatalog.helpers'; + +// --------------------------------------------------------------------------- +// normalizeCatalogType +// --------------------------------------------------------------------------- + +describe('normalizeCatalogType', () => +{ + it('returns BUILDER when explicitly asked for BUILDER', () => + { + expect(normalizeCatalogType(CatalogType.BUILDER)).toBe(CatalogType.BUILDER); + }); + + it('returns NORMAL for the explicit NORMAL value', () => + { + expect(normalizeCatalogType(CatalogType.NORMAL)).toBe(CatalogType.NORMAL); + }); + + it('returns NORMAL when type is omitted', () => + { + expect(normalizeCatalogType()).toBe(CatalogType.NORMAL); + }); + + it('returns NORMAL for any unknown string', () => + { + expect(normalizeCatalogType('something_else')).toBe(CatalogType.NORMAL); + expect(normalizeCatalogType('')).toBe(CatalogType.NORMAL); + }); +}); + +// --------------------------------------------------------------------------- +// getOfferProductKeys +// --------------------------------------------------------------------------- + +describe('getOfferProductKeys', () => +{ + const makeOffer = (overrides: any = {}) => + ({ + product: { + productType: 'floor', + productClassId: 42, + furnitureData: { className: 'chair_basic' }, + ...overrides + } + }) as any; + + it('returns both id and className keys when the product has both', () => + { + expect(getOfferProductKeys(makeOffer())).toEqual([ + 'floor:id:42', + 'floor:class:chair_basic' + ]); + }); + + it('omits the id key when productClassId is negative', () => + { + const offer = makeOffer({ productClassId: -1 }); + + expect(getOfferProductKeys(offer)).toEqual([ 'floor:class:chair_basic' ]); + }); + + it('omits the className key when furnitureData has no className', () => + { + const offer = makeOffer({ furnitureData: { className: '' } }); + + expect(getOfferProductKeys(offer)).toEqual([ 'floor:id:42' ]); + }); + + it('returns an empty array when the offer has no product', () => + { + expect(getOfferProductKeys(null)).toEqual([]); + expect(getOfferProductKeys(undefined)).toEqual([]); + expect(getOfferProductKeys({} as any)).toEqual([]); + }); + + it('returns an empty array when productType is missing', () => + { + const offer = makeOffer({ productType: '' }); + + expect(getOfferProductKeys(offer)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// findNodeById / findNodeByName +// --------------------------------------------------------------------------- + +const makeNode = (overrides: { pageId?: number; pageName?: string; children?: any[] } = {}) => + ({ + pageId: overrides.pageId ?? -1, + pageName: overrides.pageName ?? 'unnamed', + isVisible: true, + children: overrides.children ?? [] + }); + +describe('findNodeById', () => +{ + it('returns null when the input node is null', () => + { + expect(findNodeById(7, null, null)).toBeNull(); + }); + + it('skips the root node even when its pageId matches', () => + { + const root = makeNode({ pageId: 7, pageName: 'root' }) as any; + + expect(findNodeById(7, root, root)).toBeNull(); + }); + + it('finds an immediate child by pageId', () => + { + const child = makeNode({ pageId: 7, pageName: 'shop' }) as any; + const root = makeNode({ pageId: 0, pageName: 'root', children: [ child ] }) as any; + + expect(findNodeById(7, root, root)).toBe(child); + }); + + it('descends into grandchildren', () => + { + const grandchild = makeNode({ pageId: 42, pageName: 'sale' }) as any; + const child = makeNode({ pageId: 7, pageName: 'shop', children: [ grandchild ] }) as any; + const root = makeNode({ pageId: 0, pageName: 'root', children: [ child ] }) as any; + + expect(findNodeById(42, root, root)).toBe(grandchild); + }); + + it('returns null when no node has that pageId', () => + { + const child = makeNode({ pageId: 7, pageName: 'shop' }) as any; + const root = makeNode({ pageId: 0, pageName: 'root', children: [ child ] }) as any; + + expect(findNodeById(99, root, root)).toBeNull(); + }); +}); + +describe('findNodeByName', () => +{ + it('finds a node by pageName ignoring the root', () => + { + const child = makeNode({ pageName: 'frontpage' }) as any; + const root = makeNode({ pageName: 'root', children: [ child ] }) as any; + + expect(findNodeByName('frontpage', root, root)).toBe(child); + expect(findNodeByName('root', root, root)).toBeNull(); + }); + + it('returns null when nothing matches', () => + { + const root = makeNode({ pageName: 'root', children: [ makeNode({ pageName: 'a' }) as any ] }) as any; + + expect(findNodeByName('b', root, root)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// getNodesByOfferIdFromMap +// --------------------------------------------------------------------------- + +describe('getNodesByOfferIdFromMap', () => +{ + const visibleNode = (id: number) => ({ pageId: id, isVisible: true } as any); + const hiddenNode = (id: number) => ({ pageId: id, isVisible: false } as any); + + it('returns null when the map is missing or empty', () => + { + expect(getNodesByOfferIdFromMap(1, null)).toBeNull(); + expect(getNodesByOfferIdFromMap(1, undefined)).toBeNull(); + expect(getNodesByOfferIdFromMap(1, new Map())).toBeNull(); + }); + + it('returns the raw bucket when onlyVisible is false', () => + { + const bucket = [ visibleNode(1), hiddenNode(2) ]; + const map = new Map([ [ 9, bucket ] ]); + + expect(getNodesByOfferIdFromMap(9, map)).toBe(bucket); + }); + + it('filters out hidden nodes when onlyVisible is true', () => + { + const visible = visibleNode(1); + const map = new Map([ [ 9, [ visible, hiddenNode(2) ] ] ]); + + expect(getNodesByOfferIdFromMap(9, map, true)).toEqual([ visible ]); + }); + + it('falls back to the raw bucket when no visible nodes remain', () => + { + const bucket = [ hiddenNode(1), hiddenNode(2) ]; + const map = new Map([ [ 9, bucket ] ]); + + expect(getNodesByOfferIdFromMap(9, map, true)).toBe(bucket); + }); + + it('returns null for an offerId not in the map', () => + { + const map = new Map([ [ 1, [ visibleNode(1) ] ] ]); + + expect(getNodesByOfferIdFromMap(99, map)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// buildCatalogNodeTree +// --------------------------------------------------------------------------- + +describe('buildCatalogNodeTree', () => +{ + // CatalogNode in the real codebase reads `node.pageId`, `node.pageName`, + // `node.offerIds`, `node.children`. Anything else is irrelevant to the + // build step. + const makeNodeData = (overrides: any = {}) => + ({ + pageId: -1, + pageName: 'unnamed', + localization: '', + iconId: -1, + offerIds: [] as number[], + children: [] as any[], + visible: true, + ...overrides + }); + + it('returns a CatalogNode root with the depth=0', () => + { + const rootData = makeNodeData({ pageId: 0, pageName: 'root' }); + const { rootNode, offersToNodes } = buildCatalogNodeTree(rootData as any); + + expect(rootNode.pageId).toBe(0); + expect(rootNode.depth).toBe(0); + expect(offersToNodes.size).toBe(0); + }); + + it('walks children depth-first and tracks offerId mappings', () => + { + const leaf = makeNodeData({ pageId: 5, pageName: 'sale', offerIds: [ 100, 200 ] }); + const branch = makeNodeData({ pageId: 3, pageName: 'shop', offerIds: [ 100 ], children: [ leaf ] }); + const rootData = makeNodeData({ pageId: 0, pageName: 'root', children: [ branch ] }); + + const { rootNode, offersToNodes } = buildCatalogNodeTree(rootData as any); + + // tree shape + expect(rootNode.children).toHaveLength(1); + expect(rootNode.children[0].pageId).toBe(3); + expect(rootNode.children[0].children[0].pageId).toBe(5); + + // depth incremented + expect(rootNode.depth).toBe(0); + expect(rootNode.children[0].depth).toBe(1); + expect(rootNode.children[0].children[0].depth).toBe(2); + + // offerId index records both nodes for offer 100, only the leaf for 200 + expect(offersToNodes.get(100)).toHaveLength(2); + expect(offersToNodes.get(100)?.map(n => n.pageId)).toEqual([ 3, 5 ]); + expect(offersToNodes.get(200)?.map(n => n.pageId)).toEqual([ 5 ]); + }); + + it('preserves child-parent relationships', () => + { + const leaf = makeNodeData({ pageId: 5, pageName: 'sale' }); + const rootData = makeNodeData({ pageId: 0, pageName: 'root', children: [ leaf ] }); + + const { rootNode } = buildCatalogNodeTree(rootData as any); + + expect(rootNode.children[0].parent).toBe(rootNode); + }); +}); + +// --------------------------------------------------------------------------- +// resolveBuilderFurniPlaceableStatus +// --------------------------------------------------------------------------- + +describe('resolveBuilderFurniPlaceableStatus', () => +{ + const offer = { offerId: 1 } as any; + + const baseInput = { + offer, + roomSession: { isGuildRoom: false, isRoomOwner: true, controllerLevel: 0 }, + secondsLeft: 60, + furniCount: 0, + furniLimit: 10, + builderPlacementAllowedInCurrentRoom: false, + builderPlacementBlockedByVisitors: false, + visitorCount: 0 + }; + + it('returns MISSING_OFFER when offer is null', () => + { + expect(resolveBuilderFurniPlaceableStatus({ ...baseInput, offer: null })).toBe(BuilderFurniPlaceableStatus.MISSING_OFFER); + }); + + it('returns NOT_IN_ROOM when roomSession is null', () => + { + expect(resolveBuilderFurniPlaceableStatus({ ...baseInput, roomSession: null })).toBe(BuilderFurniPlaceableStatus.NOT_IN_ROOM); + }); + + it('returns OKAY for the room owner with time on the clock', () => + { + expect(resolveBuilderFurniPlaceableStatus(baseInput)).toBe(BuilderFurniPlaceableStatus.OKAY); + }); + + it('returns NOT_GROUP_ADMIN for a non-owner without group fallback or shared pool', () => + { + const input = { + ...baseInput, + roomSession: { isGuildRoom: false, isRoomOwner: false, controllerLevel: 0 } + }; + + expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.NOT_GROUP_ADMIN); + }); + + it('returns OKAY for guild admin with subscription time remaining', () => + { + const input = { + ...baseInput, + roomSession: { isGuildRoom: true, isRoomOwner: false, controllerLevel: 4 /* GUILD_ADMIN */ }, + secondsLeft: 60 + }; + + expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.OKAY); + }); + + it('returns FURNI_LIMIT_REACHED when count meets the limit and no shared pool applies', () => + { + const input = { ...baseInput, furniCount: 10, furniLimit: 10 }; + + expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.FURNI_LIMIT_REACHED); + }); + + it('skips the furni limit when builderPlacementAllowedInCurrentRoom for a non-owner', () => + { + const input = { + ...baseInput, + roomSession: { isGuildRoom: false, isRoomOwner: false, controllerLevel: 0 }, + furniCount: 99, + furniLimit: 10, + builderPlacementAllowedInCurrentRoom: true + }; + + expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.OKAY); + }); + + it('returns VISITORS_IN_ROOM when the subscription has expired and the flag is set', () => + { + const input = { + ...baseInput, + secondsLeft: 0, + builderPlacementBlockedByVisitors: true + }; + + expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.VISITORS_IN_ROOM); + }); + + it('returns VISITORS_IN_ROOM when the subscription has expired and there are visitors counted', () => + { + const input = { ...baseInput, secondsLeft: 0, visitorCount: 3 }; + + expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.VISITORS_IN_ROOM); + }); + + it('returns OKAY when the subscription has expired but the room is empty', () => + { + const input = { ...baseInput, secondsLeft: 0, visitorCount: 0 }; + + expect(resolveBuilderFurniPlaceableStatus(input)).toBe(BuilderFurniPlaceableStatus.OKAY); + }); +}); From 59d6c4cab3a2ec974ec73a4ed898367f05c886b1 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 13 May 2026 21:50:56 +0200 Subject: [PATCH 077/129] catalog: three-way singleton-filter split + first 3 consumer migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the useCatalog decomposition. After the previous commit extracted the pure helpers, this one splits the singleton-via-useBetween store into three slice-specific entry points and migrates a handful of consumers as proof. `src/hooks/catalog/useCatalog.ts` - Internal `useCatalogState` → renamed to `useCatalogStore` and is no longer exported. The full return shape is unchanged so callers that still go through the shim see the exact same object. - Three new exports built on top of the same `useBetween` instance: - `useCatalogData()` — server-driven read-only slice (rootNode, offersToNodes, currentPage, currentOffer, frontPageItems, searchResult, roomPreviewer, isBusy, catalog localization version, Builders Club counters + timers). - `useCatalogUiState()` — UI ephemeral state + writers (isVisible, pageId, previousPageId, currentType, activeNodes, navigationHidden, purchaseOptions, catalogPlaceMultipleObjects, plus every `set*` writer including the ones that mutate the data slice on user-driven selection). - `useCatalogActions()` — imperative operations only (openCatalogByType, toggleCatalogByType, activateNode, openPageBy{Id,Name,OfferId}, requestOfferToMover, selectCatalogOffer, getNodeBy{Id,Name}, getBuilderFurniPlaceableStatus). - `useCatalog` is kept as a deprecated shim that returns the full historical surface, so the 48 existing consumers compile and run unchanged. Pilot consumer migrations (3 of 48): - `CatalogBuildersClubStatusView` — Data (furni counters, seconds timers) + UiState (currentType). - `CatalogBreadcrumbView` — UiState (activeNodes) + Actions (activateNode). - `CatalogNavigationItemView` — UiState (currentType) + Actions (activateNode). Tests: `tests/useCatalog.filters.test.tsx` (5 cases). `useBetween` is mocked via `vi.hoisted` so the four hooks share one deterministic fake store — rendering the real `useCatalogStore` would mount ~30 useState calls + open a fresh RoomPreviewer + subscribe to a dozen renderer events, which is more than these contract tests need. - `useCatalogData` exposes exactly its read-only keys. - `useCatalogUiState` exposes exactly its UI keys + setters. - `useCatalogActions` exposes exactly its imperative ops (and explicitly NOT data fields — proves no leak across slices). - Singleton identity: callbacks read through the shim are `===` to the ones read through the slices. - Shim surface: the historical key set is still present so un-migrated consumers don't silently break. Suite: 163/163 (was 158/158). `yarn typecheck` green. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 9 +- docs/ARCHITECTURE.md | 44 +++- .../CatalogBuildersClubStatusView.tsx | 5 +- .../navigation/CatalogBreadcrumbView.tsx | 5 +- .../navigation/CatalogNavigationItemView.tsx | 5 +- src/hooks/catalog/useCatalog.ts | 113 +++++++++- tests/useCatalog.filters.test.tsx | 206 ++++++++++++++++++ 7 files changed, 372 insertions(+), 15 deletions(-) create mode 100644 tests/useCatalog.filters.test.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 20b8ee8..63dfff2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,15 +259,15 @@ into `configurePreviewServer` so `yarn preview` keeps working. | `useNitroQuery` + `useNitroEventInvalidator` | `OfferView`, `CatalogLayoutRoomAdsView`, `ModToolsChatlogView`, `CfhChatlogView`, `useGiftConfiguration`, `useUserGroups`, `useClubOffers(windowId)`, `useSellablePetPalette(breed)`, `useMarketplaceConfiguration`, `useClubGifts` (with invalidator) | | Zustand | `NavigatorRoomCreatorView` (`useRoomCreatorStore`) | | 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` | +| God-hook split (`useBetween` singleton + state filter + actions filter + shim) | `wired-tools`, `translation`, `notification`, `friends`, `catalog` (three-way: `useCatalogData` / `useCatalogUiState` / `useCatalogActions`, plus the `useCatalog` shim) | | `WidgetErrorBoundary` | `RoomWidgetsView` umbrella | -| Vitest | 158/158 cases — pure helpers + Zustand store + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `tests/mocks/renderer-mock.ts`, plus 34 cases on the freshly extracted catalog helpers | +| Vitest | 163/163 cases — pure helpers + Zustand store + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `tests/mocks/renderer-mock.ts`, 34 cases on the catalog pure helpers, 5 contract cases on the catalog filters | | Form Actions | Login / Register / Forgot (LoginView.tsx) | | Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating | | Not yet | Notes | |---|---| -| Singleton-filter split of `useCatalog` | Pure helpers extracted to `useCatalog.helpers.ts` and consumed in the hook (`buildCatalogNodeTree`, `findNodeById`, `findNodeByName`, `getNodesByOfferIdFromMap`, `getOfferProductKeys`, `normalizeCatalogType`, `resolveBuilderFurniPlaceableStatus`). What still remains: split the singleton state into `useCatalogData` / `useCatalogUiState` / `useCatalogActions` filters via `useBetween`, mirroring the wired-tools / translation / notification / friends pattern. The 48 consumers can stay on the shim during the transition. | +| Migrate the 48 `useCatalog()` consumers to the new filters | The split is done: pure helpers in `useCatalog.helpers.ts`, three filters (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`) plus the deprecated `useCatalog` shim. Three pilot consumers already migrated (`CatalogBuildersClubStatusView`, `CatalogBreadcrumbView`, `CatalogNavigationItemView`). The remaining 45 still hit the shim — incremental work, each migration is mechanical: split the destructure into 2-3 filter calls based on which keys are read. | | Split `useChatWidget` / `useAvatarInfoWidget` | Both state-driven via events with no clean imperative actions to extract — skip-motivated. Already touched today for the InfoStand listener move. | | Split `usePetPackageWidget` / `useWordQuizWidget` / `useChatCommandSelector` | Their "actions" mutate internal state or are tightly interdependent — skip-motivated. | | Hoist Wired Creator Tools shared state to a Zustand slice | Would remove ~25 props passed to the 3 tab sub-components. (Wired-tools split done as singleton-filter; Zustand slice is the next step.) | @@ -327,6 +327,9 @@ Fix shapes documented; both are reasonable PRs on their own. (`buildCatalogNodeTree`, `findNodeById` / `findNodeByName`, `getNodesByOfferIdFromMap`, `getOfferProductKeys`, `normalizeCatalogType`, `resolveBuilderFurniPlaceableStatus`) +- Catalog three-way filter split: `useCatalogData` / + `useCatalogUiState` / `useCatalogActions` (with the deprecated + `useCatalog` shim) in `src/hooks/catalog/useCatalog.ts` - Renderer-SDK mock for Vitest: `tests/mocks/renderer-mock.ts` (aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`). Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` / diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 345c0e5..8eafc58 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -480,9 +480,39 @@ Status after this round of work: | CatalogPagesList / CatalogPage | **deferred** — core state slice (rootNode / offersToNodes / currentPage), needs its own split-out store | | BuildersClubFurniCount / SubscriptionStatus | **deferred** — read by the internal `getBuilderFurniPlaceableStatus` logic, moves with the data/actions split | -Pure-helper extraction landed before the singleton split: -`src/hooks/catalog/useCatalog.helpers.ts` hosts the dependency-free -pieces previously inlined in the hook — +**Helper extraction + filter split both landed.** The 1100-line hook +now has its dependency-free logic in +`src/hooks/catalog/useCatalog.helpers.ts` and exposes three public +filters built on top of the same `useBetween` singleton: + +- `useCatalogData()` — server-driven read-only slice (`rootNode`, + `offersToNodes`, `currentPage`, `currentOffer`, `frontPageItems`, + `searchResult`, `roomPreviewer`, `isBusy`, + `catalogLocalizationVersion`, Builders Club counters + timers). +- `useCatalogUiState()` — UI ephemeral state + writers + (`isVisible`, `pageId`, `previousPageId`, `currentType`, + `activeNodes`, `navigationHidden`, `purchaseOptions`, + `catalogPlaceMultipleObjects`, plus all the `set*` writers, + including the ones that mutate the data slice on page / offer / + search-result selection). +- `useCatalogActions()` — imperative operations + (`openCatalogByType`, `toggleCatalogByType`, `activateNode`, + `openPageBy{Id,Name,OfferId}`, `requestOfferToMover`, + `selectCatalogOffer`, `getNodeBy{Id,Name}`, + `getBuilderFurniPlaceableStatus`). + +The internal store is named `useCatalogStore` and is **not exported**; +the four public entry points (`useCatalogData` / `useCatalogUiState` +/ `useCatalogActions` / `useCatalog`) all funnel into the same +`useBetween` instance, so listeners + state register once. The +deprecated `useCatalog` shim continues to expose the full historical +return shape so the 48 existing consumers compile unchanged; they +should be incrementally migrated to the specific filters as PRs +touch them. Three pilot migrations already landed in +`CatalogBuildersClubStatusView`, `CatalogBreadcrumbView`, and +`CatalogNavigationItemView`. + +Pure helpers in `useCatalog.helpers.ts`: - `normalizeCatalogType(type?)` — coerce the optional catalog type back to `NORMAL` / `BUILDER`. @@ -512,7 +542,7 @@ empty-map / partial-bucket branches of the offer lookup). - Vitest 3 + jsdom + `@testing-library/react` + `@testing-library/jest-dom` configured. Separate `vitest.config.mts` so the runner doesn't drag in the renderer SDK aliases from `vite.config.mjs`. -- **158 cases passing** across 11 test files. Pure-module suites: +- **163 cases passing** across 12 test files. Pure-module suites: - `WiredCreatorTools.helpers.test.ts` (18) — formatters + snapshot factory. - `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants @@ -539,6 +569,12 @@ empty-map / partial-bucket branches of the offer lookup). the partial-visible fallback), `buildCatalogNodeTree` (tree depth + offerId index), and the full decision tree of `resolveBuilderFurniPlaceableStatus`. + - `useCatalog.filters.test.tsx` (5) — contract tests for the + three-way singleton-filter split. Stubs `use-between` so the + filters share one fake store, asserts each filter exposes + exactly the keys it owns (no leak across slices), and pins + down `===` identity of callbacks between the shim and each + slice so the migration of the 48 consumers stays safe. Component-/hook-level suites (on the new renderer-SDK mock): - `WidgetErrorBoundary.test.tsx` (4) — happy path + caught render diff --git a/src/components/catalog/views/catalog-header/CatalogBuildersClubStatusView.tsx b/src/components/catalog/views/catalog-header/CatalogBuildersClubStatusView.tsx index 769118f..c36c78d 100644 --- a/src/components/catalog/views/catalog-header/CatalogBuildersClubStatusView.tsx +++ b/src/components/catalog/views/catalog-header/CatalogBuildersClubStatusView.tsx @@ -2,11 +2,12 @@ import { GetTickerTime } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useState } from 'react'; import { CatalogType, FriendlyTime, LocalizeText } from '../../../../api'; import buildersClubIcon from '../../../../assets/images/toolbar/icons/buildersclub.png'; -import { useCatalog } from '../../../../hooks'; +import { useCatalogData, useCatalogUiState } from '../../../../hooks'; export const CatalogBuildersClubStatusView: FC = () => { - const { currentType = CatalogType.NORMAL, furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalog(); + const { furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalogData(); + const { currentType = CatalogType.NORMAL } = useCatalogUiState(); const [ ticker, setTicker ] = useState(() => GetTickerTime()); useEffect(() => diff --git a/src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx b/src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx index 60ed73a..f677606 100644 --- a/src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx +++ b/src/components/catalog/views/navigation/CatalogBreadcrumbView.tsx @@ -1,11 +1,12 @@ import { FC } from 'react'; import { FaChevronRight, FaHome } from 'react-icons/fa'; import { LocalizeText } from '../../../../api'; -import { useCatalog } from '../../../../hooks'; +import { useCatalogActions, useCatalogUiState } from '../../../../hooks'; export const CatalogBreadcrumbView: FC<{}> = () => { - const { activeNodes = [], activateNode } = useCatalog(); + const { activeNodes = [] } = useCatalogUiState(); + const { activateNode } = useCatalogActions(); if(!activeNodes || activeNodes.length === 0) { diff --git a/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx b/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx index ffeba3a..73bd63e 100644 --- a/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx +++ b/src/components/catalog/views/navigation/CatalogNavigationItemView.tsx @@ -1,7 +1,7 @@ import { FC, useCallback, useRef, useState } from 'react'; import { FaArrowsAlt, FaCaretDown, FaCaretUp, FaPlus, FaStar, FaTrash } from 'react-icons/fa'; import { CatalogType, ICatalogNode, LocalizeText } from '../../../../api'; -import { useCatalog, useCatalogFavorites } from '../../../../hooks'; +import { useCatalogActions, useCatalogFavorites, useCatalogUiState } from '../../../../hooks'; import { useCatalogAdmin } from '../../CatalogAdminContext'; import { CatalogIconView } from '../catalog-icon/CatalogIconView'; import { CatalogNavigationSetView } from './CatalogNavigationSetView'; @@ -15,7 +15,8 @@ export interface CatalogNavigationItemViewProps export const CatalogNavigationItemView: FC = props => { const { node = null, child = false } = props; - const { activateNode = null, currentType = CatalogType.NORMAL } = useCatalog(); + const { activateNode = null } = useCatalogActions(); + const { currentType = CatalogType.NORMAL } = useCatalogUiState(); const catalogAdmin = useCatalogAdmin(); const adminMode = catalogAdmin?.adminMode ?? false; const { isFavoritePage, toggleFavoritePage } = useCatalogFavorites(); diff --git a/src/hooks/catalog/useCatalog.ts b/src/hooks/catalog/useCatalog.ts index 0f21028..67d3054 100644 --- a/src/hooks/catalog/useCatalog.ts +++ b/src/hooks/catalog/useCatalog.ts @@ -12,7 +12,15 @@ import { useCatalogSkipPurchaseConfirmation } from './useCatalogSkipPurchaseConf const DUMMY_PAGE_ID_FOR_OFFER_SEARCH = -12345678; const DRAG_AND_DROP_ENABLED = true; -const useCatalogState = () => +// Internal singleton store — held together by `useBetween` so every +// public filter below sees the same listeners + state. Do NOT export +// this directly; consumers must go through the filters or the +// deprecated `useCatalog` shim. The previous 1100-line monolith +// exposed everything via `useCatalog`; the three filters below +// (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`) +// shrink the surface each consumer subscribes to, which lets the +// React Compiler memoize and avoids unrelated re-renders. +const useCatalogStore = () => { const [ isVisible, setIsVisible ] = useState(false); const [ isBusy, setIsBusy ] = useState(false); @@ -958,4 +966,105 @@ const useCatalogState = () => return { isVisible, setIsVisible, isBusy, pageId, previousPageId, currentType, rootNode, offersToNodes, currentPage, setCurrentPage, currentOffer, setCurrentOffer, activeNodes, searchResult, setSearchResult, frontPageItems, roomPreviewer, navigationHidden, setNavigationHidden, purchaseOptions, setPurchaseOptions, catalogLocalizationVersion, getNodeById, getNodeByName, activateNode, openPageById, openPageByName, openPageByOfferId, requestOfferToMover, openCatalogByType, toggleCatalogByType, furniCount, furniLimit, maxFurniLimit, secondsLeft, secondsLeftWithGrace, updateTime, catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects, getBuilderFurniPlaceableStatus, selectCatalogOffer }; }; -export const useCatalog = () => useBetween(useCatalogState); +/** + * Read-only slice of server-driven catalog state. Anything a consumer + * needs to *display* (page tree, current page, offers, Builders Club + * counters) lives here. + * + * `roomPreviewer` and the busy flag are kept here too because they + * are observed (not mutated) by every consumer that renders a preview. + */ +export const useCatalogData = () => +{ + const { + isBusy, + rootNode, offersToNodes, + currentPage, currentOffer, + frontPageItems, searchResult, + roomPreviewer, + catalogLocalizationVersion, + furniCount, furniLimit, maxFurniLimit, + secondsLeft, secondsLeftWithGrace, updateTime + } = useBetween(useCatalogStore); + + return { + isBusy, + rootNode, offersToNodes, + currentPage, currentOffer, + frontPageItems, searchResult, + roomPreviewer, + catalogLocalizationVersion, + furniCount, furniLimit, maxFurniLimit, + secondsLeft, secondsLeftWithGrace, updateTime + }; +}; + +/** + * UI-side state owned by the catalog overlay itself: visibility, the + * currently-rendered page id and breadcrumb, search query result, + * purchase options, multi-place toggle. Includes the setters that + * mutate the data slice when the user picks a page / offer / search + * result — those don't trigger server traffic so they belong to the + * UI layer. + */ +export const useCatalogUiState = () => +{ + const { + isVisible, setIsVisible, + pageId, previousPageId, + currentType, + activeNodes, + navigationHidden, setNavigationHidden, + purchaseOptions, setPurchaseOptions, + catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects, + setCurrentPage, setCurrentOffer, setSearchResult + } = useBetween(useCatalogStore); + + return { + isVisible, setIsVisible, + pageId, previousPageId, + currentType, + activeNodes, + navigationHidden, setNavigationHidden, + purchaseOptions, setPurchaseOptions, + catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects, + setCurrentPage, setCurrentOffer, setSearchResult + }; +}; + +/** + * Imperative actions: open / toggle the catalog, navigate the page + * tree, request a furni to the mover, look up nodes by id/name, run + * the Builders Club placement check. These all either send a + * composer to the server, dispatch a UI event, or run synchronous + * tree queries — none of them are React state by themselves. + */ +export const useCatalogActions = () => +{ + const { + openCatalogByType, toggleCatalogByType, + activateNode, + openPageById, openPageByName, openPageByOfferId, + requestOfferToMover, selectCatalogOffer, + getNodeById, getNodeByName, + getBuilderFurniPlaceableStatus + } = useBetween(useCatalogStore); + + return { + openCatalogByType, toggleCatalogByType, + activateNode, + openPageById, openPageByName, openPageByOfferId, + requestOfferToMover, selectCatalogOffer, + getNodeById, getNodeByName, + getBuilderFurniPlaceableStatus + }; +}; + +/** + * Deprecated. Kept so the 48 existing consumers compile unchanged — + * incrementally migrate them to `useCatalogData` / `useCatalogUiState` + * / `useCatalogActions` and remove this shim once the call sites are + * gone. Mirrors the same `useBetween` singleton, so behavior is + * identical. + */ +export const useCatalog = () => useBetween(useCatalogStore); diff --git a/tests/useCatalog.filters.test.tsx b/tests/useCatalog.filters.test.tsx new file mode 100644 index 0000000..a44eb56 --- /dev/null +++ b/tests/useCatalog.filters.test.tsx @@ -0,0 +1,206 @@ +/* @vitest-environment jsdom */ + +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +// `useCatalogStore` mounts ~30 useState calls, opens a fresh +// RoomPreviewer, subscribes to a dozen renderer message events, and +// reaches into `useNotification()` for the alert helpers — too much +// surface to render under jsdom and not what these tests are about. +// +// We just want to lock down the *contract* of the three filters +// (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`) and +// the shim: each one must read its specific subset of keys from the +// same `useBetween` singleton. +// +// Stub `use-between` so all four hooks share one deterministic store +// object. `vi.hoisted` lets us reference the fake from the mock +// factory (which is itself hoisted). + +const { fakeStore } = vi.hoisted(() => +{ + const fakeStore = { + // Data slice + isBusy: false, + rootNode: null, + offersToNodes: null, + currentPage: null, + currentOffer: null, + frontPageItems: [], + searchResult: null, + roomPreviewer: null, + catalogLocalizationVersion: 0, + furniCount: 0, + furniLimit: 0, + maxFurniLimit: 0, + secondsLeft: 0, + secondsLeftWithGrace: 0, + updateTime: 0, + // UiState slice + isVisible: false, + setIsVisible: vi.fn(), + pageId: -1, + previousPageId: -1, + currentType: 'NORMAL', + activeNodes: [] as any[], + navigationHidden: false, + setNavigationHidden: vi.fn(), + purchaseOptions: { quantity: 1 }, + setPurchaseOptions: vi.fn(), + catalogPlaceMultipleObjects: false, + setCatalogPlaceMultipleObjects: vi.fn(), + setCurrentPage: vi.fn(), + setCurrentOffer: vi.fn(), + setSearchResult: vi.fn(), + // Actions slice + openCatalogByType: vi.fn(), + toggleCatalogByType: vi.fn(), + activateNode: vi.fn(), + openPageById: vi.fn(), + openPageByName: vi.fn(), + openPageByOfferId: vi.fn(), + requestOfferToMover: vi.fn(), + selectCatalogOffer: vi.fn(), + getNodeById: vi.fn(), + getNodeByName: vi.fn(), + getBuilderFurniPlaceableStatus: vi.fn() + }; + + return { fakeStore }; +}); + +vi.mock('use-between', () => ({ + useBetween: () => fakeStore +})); + +// Import AFTER the mock is set up. The hooks resolve `useBetween` at +// import time via the module graph, so the order matters. +import { useCatalog, useCatalogActions, useCatalogData, useCatalogUiState } from '../src/hooks/catalog/useCatalog'; + +describe('useCatalog filter contract', () => +{ + it('useCatalogData returns the read-only data slice', () => + { + const { result } = renderHook(() => useCatalogData()); + + expect(Object.keys(result.current).sort()).toEqual([ + 'catalogLocalizationVersion', + 'currentOffer', + 'currentPage', + 'frontPageItems', + 'furniCount', + 'furniLimit', + 'isBusy', + 'maxFurniLimit', + 'offersToNodes', + 'roomPreviewer', + 'rootNode', + 'searchResult', + 'secondsLeft', + 'secondsLeftWithGrace', + 'updateTime' + ]); + + // Reads point at the same underlying values. + expect(result.current.rootNode).toBe(fakeStore.rootNode); + expect(result.current.furniCount).toBe(fakeStore.furniCount); + expect(result.current.frontPageItems).toBe(fakeStore.frontPageItems); + }); + + it('useCatalogUiState returns the UI fields plus their setters', () => + { + const { result } = renderHook(() => useCatalogUiState()); + + expect(Object.keys(result.current).sort()).toEqual([ + 'activeNodes', + 'catalogPlaceMultipleObjects', + 'currentType', + 'isVisible', + 'navigationHidden', + 'pageId', + 'previousPageId', + 'purchaseOptions', + 'setCatalogPlaceMultipleObjects', + 'setCurrentOffer', + 'setCurrentPage', + 'setIsVisible', + 'setNavigationHidden', + 'setPurchaseOptions', + 'setSearchResult' + ]); + + expect(result.current.setIsVisible).toBe(fakeStore.setIsVisible); + expect(result.current.setCurrentPage).toBe(fakeStore.setCurrentPage); + }); + + it('useCatalogActions returns only imperative operations', () => + { + const { result } = renderHook(() => useCatalogActions()); + + expect(Object.keys(result.current).sort()).toEqual([ + 'activateNode', + 'getBuilderFurniPlaceableStatus', + 'getNodeById', + 'getNodeByName', + 'openCatalogByType', + 'openPageById', + 'openPageByName', + 'openPageByOfferId', + 'requestOfferToMover', + 'selectCatalogOffer', + 'toggleCatalogByType' + ]); + + // No data fields leak through. + expect(result.current).not.toHaveProperty('rootNode'); + expect(result.current).not.toHaveProperty('isVisible'); + expect(result.current).not.toHaveProperty('currentPage'); + + expect(result.current.activateNode).toBe(fakeStore.activateNode); + expect(result.current.openCatalogByType).toBe(fakeStore.openCatalogByType); + }); + + it('all four hooks observe the same singleton — refs are ===', () => + { + const { result } = renderHook(() => + ({ + data: useCatalogData(), + ui: useCatalogUiState(), + actions: useCatalogActions(), + full: useCatalog() + })); + + // The shim and the slices reach the same fakeStore. Any + // accidental copy would break this `===` check. + expect(result.current.full.activateNode).toBe(result.current.actions.activateNode); + expect(result.current.full.openCatalogByType).toBe(result.current.actions.openCatalogByType); + expect(result.current.full.setIsVisible).toBe(result.current.ui.setIsVisible); + expect(result.current.full.setCurrentPage).toBe(result.current.ui.setCurrentPage); + expect(result.current.full.rootNode).toBe(result.current.data.rootNode); + expect(result.current.full.furniCount).toBe(result.current.data.furniCount); + expect(result.current.full.roomPreviewer).toBe(result.current.data.roomPreviewer); + }); + + it('useCatalog (deprecated shim) preserves the full historical surface', () => + { + const { result } = renderHook(() => useCatalog()); + + // Sample one field from each slice, including the setters + // that the 48 existing consumers still destructure straight + // out of `useCatalog()`. If a setter or callback ever stops + // being forwarded, the shim breaks and those consumers + // silently fail. + const required = [ + 'rootNode', 'offersToNodes', 'currentPage', 'currentOffer', 'frontPageItems', + 'isVisible', 'setIsVisible', 'pageId', 'previousPageId', 'currentType', + 'setCurrentPage', 'setCurrentOffer', 'setSearchResult', + 'openCatalogByType', 'toggleCatalogByType', 'activateNode', + 'openPageById', 'openPageByName', 'openPageByOfferId', + 'requestOfferToMover', 'selectCatalogOffer', + 'getNodeById', 'getNodeByName', 'getBuilderFurniPlaceableStatus', + 'furniCount', 'furniLimit', 'secondsLeft', 'updateTime' + ]; + + for(const key of required) expect(result.current).toHaveProperty(key); + }); +}); From 8844cc13288b63c74104082d9192574f467aae7d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 13 May 2026 21:55:31 +0200 Subject: [PATCH 078/129] ci: run typecheck + Vitest on every push to main/feat/** and on every PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the test suite was authoritative only when run locally; nothing stopped a commit landing with `yarn test` red. Wire up a GitHub Actions workflow that gates push + pull_request on both `yarn typecheck` and `yarn test --run`. The setup mirrors CLAUDE.md's "Setup walkthrough": - Check the client into `/Nitro-V3`. - Check `duckietm/Nitro_Render_V3` as a sibling at `/Nitro_Render_V3`, since the build / typecheck wire the renderer in via filesystem aliases that expect that layout. - Symlink `Nitro-V3/node_modules/@nitrots/nitro-renderer` → `../../../Nitro_Render_V3` so `tsconfig.json`'s `include` entry pointing at `node_modules/@nitrots/nitro-renderer/src/**/*.ts` actually resolves. - `yarn install --frozen-lockfile` in both repos, then run `yarn typecheck` and `yarn test --run` in the client. Node 22 (matches the local toolchain). Yarn classic, with the workflow's `setup-node` caching the `yarn.lock` of both repos so subsequent runs don't reinstall from scratch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 62 ++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 5 +++- 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f1ab182 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: + - main + - 'feat/**' + pull_request: + +jobs: + check: + name: Type check + tests + runs-on: ubuntu-latest + steps: + # The build/dev/typecheck setup expects the Nitro renderer SDK to + # live as a sibling of this repo (see CLAUDE.md → Setup walkthrough). + # Mirror that here by checking the client into /Nitro-V3 + # and the renderer into /Nitro_Render_V3. + - name: Checkout Nitro-V3 + uses: actions/checkout@v4 + with: + path: Nitro-V3 + + - name: Checkout Nitro_Render_V3 (sibling) + uses: actions/checkout@v4 + with: + repository: duckietm/Nitro_Render_V3 + path: Nitro_Render_V3 + + - name: Setup Node 22 + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: yarn + cache-dependency-path: | + Nitro-V3/yarn.lock + Nitro_Render_V3/yarn.lock + + # The renderer SDK is consumed via a filesystem symlink in + # node_modules/@nitrots/nitro-renderer; create it explicitly so + # tsgo (TS 7 native preview) can resolve the tsconfig `include` + # entry pointing at the renderer's `src/**/*.ts`. + - name: Symlink renderer into client node_modules + run: | + mkdir -p Nitro-V3/node_modules/@nitrots + ln -sfn ../../../Nitro_Render_V3 Nitro-V3/node_modules/@nitrots/nitro-renderer + + - name: Install renderer SDK deps + working-directory: Nitro_Render_V3 + run: yarn install --frozen-lockfile + + - name: Install client deps + working-directory: Nitro-V3 + run: yarn install --frozen-lockfile + + - name: Type check (tsgo) + working-directory: Nitro-V3 + run: yarn typecheck + + - name: Vitest + working-directory: Nitro-V3 + run: yarn test --run diff --git a/CLAUDE.md b/CLAUDE.md index 63dfff2..6a3981f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -299,7 +299,10 @@ Fix shapes documented; both are reasonable PRs on their own. - **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 test` must stay green** on every commit. Currently 113/113. +- **`yarn test` must stay green** on every commit. Currently 163/163. + The GitHub Actions workflow at `.github/workflows/ci.yml` runs + `yarn typecheck` + `yarn test --run` on every push to `main` / + `feat/**` and on every PR — both must pass. - **Lint baseline**: don't regress. Some pre-existing errors (`FC<{}>`, `IMessageEvent | undefined` redundant union in the local sandbox where the renderer SDK isn't installed) are out of scope here. From 53fc5f09fd528a80ddc8c5d48617f65ff18af45b Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 13 May 2026 21:57:35 +0200 Subject: [PATCH 079/129] ci: create renderer symlink after yarn install, not before MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yarn cleans up anything in node_modules that's not declared in package.json, so the previous order (symlink → yarn install) wiped the link and tsgo could not resolve @nitrots/nitro-renderer. Move the symlink step after both installs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1ab182..f6dbe46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,15 +36,6 @@ jobs: Nitro-V3/yarn.lock Nitro_Render_V3/yarn.lock - # The renderer SDK is consumed via a filesystem symlink in - # node_modules/@nitrots/nitro-renderer; create it explicitly so - # tsgo (TS 7 native preview) can resolve the tsconfig `include` - # entry pointing at the renderer's `src/**/*.ts`. - - name: Symlink renderer into client node_modules - run: | - mkdir -p Nitro-V3/node_modules/@nitrots - ln -sfn ../../../Nitro_Render_V3 Nitro-V3/node_modules/@nitrots/nitro-renderer - - name: Install renderer SDK deps working-directory: Nitro_Render_V3 run: yarn install --frozen-lockfile @@ -53,6 +44,18 @@ jobs: working-directory: Nitro-V3 run: yarn install --frozen-lockfile + # The renderer SDK is consumed via a filesystem symlink in + # node_modules/@nitrots/nitro-renderer; create it AFTER yarn + # install (otherwise yarn would clean it up since the package + # isn't declared in package.json). tsgo (TS 7 native preview) + # then resolves the tsconfig `include` entry pointing at the + # renderer's `src/**/*.ts`. + - name: Symlink renderer into client node_modules + run: | + mkdir -p Nitro-V3/node_modules/@nitrots + ln -sfn ../../../Nitro_Render_V3 Nitro-V3/node_modules/@nitrots/nitro-renderer + ls -la Nitro-V3/node_modules/@nitrots/ + - name: Type check (tsgo) working-directory: Nitro-V3 run: yarn typecheck From 5d7a20ac39576908c9bf70f8e29df6f4d2c3b58a Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 13 May 2026 22:02:15 +0200 Subject: [PATCH 080/129] ci: use absolute symlink target + check out feat/react19-event-bus on the renderer fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up fixes after the first CI run failed with TS2307 across ~250 files: 1. The relative symlink target `../../../Nitro_Render_V3` resolves from inside `Nitro-V3/node_modules/@nitrots/nitro-renderer` to `Nitro-V3/Nitro_Render_V3` (one too few `..`) — that path doesn't exist, so tsgo couldn't find the renderer SDK and bailed on every `@nitrots/nitro-renderer` import. Switch to an absolute target via ${{ github.workspace }}. 2. The client depends on renderer API additions (`allowUnderpass` on RoomSettingsData, `sendBackgroundMessage` on IRoomSession, the NitroConfig Window declaration alignment) that live on `feat/react19-event-bus` of `simoleo89/Nitro_Render_V3` and not on `duckietm/Nitro_Render_V3:main`. Point the checkout at the fork + that branch so tsgo sees what the local working tree sees. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6dbe46..42d68d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,17 @@ jobs: with: path: Nitro-V3 + # The client tracks renderer changes that are pushed to the + # `feat/react19-event-bus` branch of `simoleo89/Nitro_Render_V3` + # (allowUnderpass + sendBackgroundMessage + Window NitroConfig + # alignment, etc.). `duckietm/Nitro_Render_V3:main` doesn't yet + # have those, so tsgo would fail right away if we checked that + # out instead. - name: Checkout Nitro_Render_V3 (sibling) uses: actions/checkout@v4 with: - repository: duckietm/Nitro_Render_V3 + repository: simoleo89/Nitro_Render_V3 + ref: feat/react19-event-bus path: Nitro_Render_V3 - name: Setup Node 22 @@ -50,11 +57,18 @@ jobs: # isn't declared in package.json). tsgo (TS 7 native preview) # then resolves the tsconfig `include` entry pointing at the # renderer's `src/**/*.ts`. + # + # Use an absolute path so the link target is unambiguous + # regardless of the cwd that reads it. A relative target like + # `../../../Nitro_Render_V3` resolves to + # `Nitro-V3/Nitro_Render_V3` (one too few `..`), which doesn't + # exist and makes tsgo report TS2307 across the entire src/. - name: Symlink renderer into client node_modules run: | mkdir -p Nitro-V3/node_modules/@nitrots - ln -sfn ../../../Nitro_Render_V3 Nitro-V3/node_modules/@nitrots/nitro-renderer + ln -sfn "${{ github.workspace }}/Nitro_Render_V3" Nitro-V3/node_modules/@nitrots/nitro-renderer ls -la Nitro-V3/node_modules/@nitrots/ + ls Nitro-V3/node_modules/@nitrots/nitro-renderer/packages/api/src/ | head -5 - name: Type check (tsgo) working-directory: Nitro-V3 From cb7502f3b0243f33ca93b3bf32dde86525871cea Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 13 May 2026 22:06:04 +0200 Subject: [PATCH 081/129] ci: opt the JavaScript actions into Node.js 24 Node 20 is being removed from GitHub-hosted runners in Sept 2026 and the actions/checkout@v4 / setup-node@v4 steps were warning on every run. Set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true at the workflow level so they pick up the Node 24 runtime now. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42d68d5..ca03d52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,14 @@ on: - 'feat/**' pull_request: +# Opt into the Node.js 24 runtime for the JavaScript actions +# (actions/checkout, actions/setup-node, …). Node 20 will be removed +# from GitHub-hosted runners in September 2026; this env var asks the +# runner to use Node 24 today so the workflow logs stop warning about +# it on every run. +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + jobs: check: name: Type check + tests From 0f9fa1203b3a581b51c40f2c65c069997170faa4 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 14 May 2026 20:05:44 +0200 Subject: [PATCH 082/129] catalog: migrate remaining 36 useCatalog() consumers to the three filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces every direct call to the deprecated useCatalog() shim with the targeted filter(s) (useCatalogData / useCatalogUiState / useCatalogActions). Each consumer now subscribes only to the slice it actually reads, which restores React Compiler memoization and stops catalog-wide re-renders whenever an unrelated key changes. Removes the now-unused useCatalog shim from useCatalog.ts and the shim-specific case in tests/useCatalog.filters.test.tsx. The "all four hooks observe the same singleton" test becomes "all three filters", since there is no shim left to compare against. useCatalogFavorites swaps its internal useCatalog() call for useCatalogUiState() (currentType lives in the UI slice). Updates CLAUDE.md and docs/ARCHITECTURE.md to reflect that all 48 historical consumers are migrated and the shim is gone. Vitest: 162/162 (was 163 — minus the deprecated-shim contract case). --- CLAUDE.md | 10 ++-- docs/ARCHITECTURE.md | 16 +++---- .../catalog/CatalogAdminContext.tsx | 4 +- src/components/catalog/CatalogClassicView.tsx | 6 ++- src/components/catalog/CatalogModernView.tsx | 6 ++- src/components/catalog/CatalogView.tsx | 4 +- .../views/admin/CatalogAdminOfferEditView.tsx | 4 +- .../views/admin/CatalogAdminPageEditView.tsx | 5 +- .../views/favorites/CatalogFavoritesView.tsx | 5 +- .../navigation/CatalogNavigationView.tsx | 4 +- .../page/common/CatalogGridOfferView.tsx | 5 +- .../views/page/common/CatalogSearchView.tsx | 5 +- .../layout/CatalogLayoutBadgeDisplayView.tsx | 4 +- .../CatalogLayoutBuildersClubBuyView.tsx | 4 +- .../layout/CatalogLayoutColorGroupingView.tsx | 5 +- .../page/layout/CatalogLayoutDefaultView.tsx | 4 +- .../CatalogLayoutGuildCustomFurniView.tsx | 4 +- .../layout/CatalogLayoutGuildForumView.tsx | 5 +- .../page/layout/CatalogLayoutRoomAdsView.tsx | 4 +- .../layout/CatalogLayoutSoundMachineView.tsx | 4 +- .../page/layout/CatalogLayoutSpacesView.tsx | 4 +- .../page/layout/CatalogLayoutTrophiesView.tsx | 5 +- .../page/layout/CatalogLayoutVipBuyView.tsx | 4 +- .../CatalogLayoutFrontpage4View.tsx | 4 +- .../page/layout/pets/CatalogLayoutPetView.tsx | 5 +- .../widgets/CatalogAddOnBadgeWidgetView.tsx | 4 +- .../CatalogBadgeSelectorWidgetView.tsx | 5 +- .../widgets/CatalogBundleGridWidgetView.tsx | 4 +- .../CatalogFirstProductSelectorWidgetView.tsx | 5 +- .../widgets/CatalogGuildBadgeWidgetView.tsx | 5 +- .../CatalogGuildSelectorWidgetView.tsx | 5 +- .../widgets/CatalogItemGridWidgetView.tsx | 5 +- .../widgets/CatalogLimitedItemWidgetView.tsx | 4 +- .../widgets/CatalogPriceDisplayWidgetView.tsx | 4 +- .../widgets/CatalogPurchaseWidgetView.tsx | 6 ++- .../widgets/CatalogSimplePriceWidgetView.tsx | 4 +- .../page/widgets/CatalogSpacesWidgetView.tsx | 5 +- .../page/widgets/CatalogSpinnerWidgetView.tsx | 5 +- .../page/widgets/CatalogTotalPriceWidget.tsx | 4 +- .../widgets/CatalogViewProductWidgetView.tsx | 5 +- src/hooks/catalog/useCatalog.ts | 8 ---- src/hooks/catalog/useCatalogFavorites.ts | 4 +- tests/useCatalog.filters.test.tsx | 48 +++++-------------- 43 files changed, 123 insertions(+), 137 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6a3981f..982ba69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,15 +259,14 @@ into `configurePreviewServer` so `yarn preview` keeps working. | `useNitroQuery` + `useNitroEventInvalidator` | `OfferView`, `CatalogLayoutRoomAdsView`, `ModToolsChatlogView`, `CfhChatlogView`, `useGiftConfiguration`, `useUserGroups`, `useClubOffers(windowId)`, `useSellablePetPalette(breed)`, `useMarketplaceConfiguration`, `useClubGifts` (with invalidator) | | Zustand | `NavigatorRoomCreatorView` (`useRoomCreatorStore`) | | 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`, plus the `useCatalog` shim) | +| 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 | -| Vitest | 163/163 cases — pure helpers + Zustand store + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `tests/mocks/renderer-mock.ts`, 34 cases on the catalog pure helpers, 5 contract cases on the catalog filters | +| Vitest | 162/162 cases — pure helpers + Zustand store + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `tests/mocks/renderer-mock.ts`, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters | | Form Actions | Login / Register / Forgot (LoginView.tsx) | | Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating | | Not yet | Notes | |---|---| -| Migrate the 48 `useCatalog()` consumers to the new filters | The split is done: pure helpers in `useCatalog.helpers.ts`, three filters (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`) plus the deprecated `useCatalog` shim. Three pilot consumers already migrated (`CatalogBuildersClubStatusView`, `CatalogBreadcrumbView`, `CatalogNavigationItemView`). The remaining 45 still hit the shim — incremental work, each migration is mechanical: split the destructure into 2-3 filter calls based on which keys are read. | | Split `useChatWidget` / `useAvatarInfoWidget` | Both state-driven via events with no clean imperative actions to extract — skip-motivated. Already touched today for the InfoStand listener move. | | Split `usePetPackageWidget` / `useWordQuizWidget` / `useChatCommandSelector` | Their "actions" mutate internal state or are tightly interdependent — skip-motivated. | | Hoist Wired Creator Tools shared state to a Zustand slice | Would remove ~25 props passed to the 3 tab sub-components. (Wired-tools split done as singleton-filter; Zustand slice is the next step.) | @@ -331,8 +330,9 @@ Fix shapes documented; both are reasonable PRs on their own. `getNodesByOfferIdFromMap`, `getOfferProductKeys`, `normalizeCatalogType`, `resolveBuilderFurniPlaceableStatus`) - Catalog three-way filter split: `useCatalogData` / - `useCatalogUiState` / `useCatalogActions` (with the deprecated - `useCatalog` shim) in `src/hooks/catalog/useCatalog.ts` + `useCatalogUiState` / `useCatalogActions` in + `src/hooks/catalog/useCatalog.ts` (all 48 consumers migrated; + deprecated `useCatalog` shim removed) - Renderer-SDK mock for Vitest: `tests/mocks/renderer-mock.ts` (aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`). Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` / diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8eafc58..57fca54 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -502,15 +502,11 @@ filters built on top of the same `useBetween` singleton: `getBuilderFurniPlaceableStatus`). The internal store is named `useCatalogStore` and is **not exported**; -the four public entry points (`useCatalogData` / `useCatalogUiState` -/ `useCatalogActions` / `useCatalog`) all funnel into the same -`useBetween` instance, so listeners + state register once. The -deprecated `useCatalog` shim continues to expose the full historical -return shape so the 48 existing consumers compile unchanged; they -should be incrementally migrated to the specific filters as PRs -touch them. Three pilot migrations already landed in -`CatalogBuildersClubStatusView`, `CatalogBreadcrumbView`, and -`CatalogNavigationItemView`. +the three public entry points (`useCatalogData` / `useCatalogUiState` +/ `useCatalogActions`) all funnel into the same `useBetween` +instance, so listeners + state register once. All 48 historical +consumers have been migrated to the targeted filters; the deprecated +`useCatalog` shim has been removed. Pure helpers in `useCatalog.helpers.ts`: @@ -569,7 +565,7 @@ empty-map / partial-bucket branches of the offer lookup). the partial-visible fallback), `buildCatalogNodeTree` (tree depth + offerId index), and the full decision tree of `resolveBuilderFurniPlaceableStatus`. - - `useCatalog.filters.test.tsx` (5) — contract tests for the + - `useCatalog.filters.test.tsx` (4) — contract tests for the three-way singleton-filter split. Stubs `use-between` so the filters share one fake store, asserts each filter exposes exactly the keys it owns (no leak across slices), and pins diff --git a/src/components/catalog/CatalogAdminContext.tsx b/src/components/catalog/CatalogAdminContext.tsx index 2d27597..29e1f84 100644 --- a/src/components/catalog/CatalogAdminContext.tsx +++ b/src/components/catalog/CatalogAdminContext.tsx @@ -1,7 +1,7 @@ import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminPublishComposer, CatalogAdminResultEvent, CatalogAdminSaveOfferComposer, CatalogAdminSavePageComposer } from '@nitrots/nitro-renderer'; import { createContext, FC, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { ICatalogNode, IPurchasableOffer, NotificationAlertType, SendMessageComposer } from '../../api'; -import { useCatalog, useMessageEvent, useNotification } from '../../hooks'; +import { useCatalogUiState, useMessageEvent, useNotification } from '../../hooks'; export interface IPageEditData { @@ -76,7 +76,7 @@ export const useCatalogAdmin = () => useContext(CatalogAdminContext); export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) => { - const { currentType } = useCatalog(); + const { currentType } = useCatalogUiState(); const [ adminMode, setAdminMode ] = useState(false); const [ editingOffer, setEditingOffer ] = useState(null); const [ editingPageData, setEditingPageData ] = useState(false); diff --git a/src/components/catalog/CatalogClassicView.tsx b/src/components/catalog/CatalogClassicView.tsx index 2a49735..9c3111c 100644 --- a/src/components/catalog/CatalogClassicView.tsx +++ b/src/components/catalog/CatalogClassicView.tsx @@ -3,7 +3,7 @@ import { FC, useEffect } from 'react'; import { FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa'; import { CatalogType, GetConfigurationValue, LocalizeText } from '../../api'; import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; -import { useCatalog } from '../../hooks'; +import { useCatalogActions, useCatalogData, useCatalogUiState } from '../../hooks'; import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext'; import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView'; import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView'; @@ -16,7 +16,9 @@ import { MarketplacePostOfferView } from './views/page/layout/marketplace/Market const CatalogClassicViewInner: FC<{}> = () => { - const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null, currentType = CatalogType.NORMAL } = useCatalog(); + const { rootNode = null, currentPage = null, searchResult = null } = useCatalogData(); + const { isVisible = false, setIsVisible = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], setSearchResult = null, currentType = CatalogType.NORMAL } = useCatalogUiState(); + const { openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null } = useCatalogActions(); const catalogAdmin = useCatalogAdmin(); const adminMode = catalogAdmin?.adminMode ?? false; const setAdminMode = catalogAdmin?.setAdminMode ?? (() => diff --git a/src/components/catalog/CatalogModernView.tsx b/src/components/catalog/CatalogModernView.tsx index 5e56267..12b8ac1 100644 --- a/src/components/catalog/CatalogModernView.tsx +++ b/src/components/catalog/CatalogModernView.tsx @@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react'; import { FaCog, FaEdit, FaEye, FaEyeSlash, FaHeart, FaPlus, FaStar, FaTrash } from 'react-icons/fa'; import { CatalogType, LocalizeText } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; -import { useCatalog, useCatalogFavorites } from '../../hooks'; +import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState } from '../../hooks'; import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext'; import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView'; import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView'; @@ -18,7 +18,9 @@ import { MarketplacePostOfferView } from './views/page/layout/marketplace/Market const CatalogModernViewInner: FC<{}> = () => { - const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null, currentType = CatalogType.NORMAL } = useCatalog(); + const { rootNode = null, currentPage = null, searchResult = null } = useCatalogData(); + const { isVisible = false, setIsVisible = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], setSearchResult = null, currentType = CatalogType.NORMAL } = useCatalogUiState(); + const { openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null } = useCatalogActions(); const catalogAdmin = useCatalogAdmin(); const adminMode = catalogAdmin?.adminMode ?? false; const setAdminMode = catalogAdmin?.setAdminMode ?? (() => diff --git a/src/components/catalog/CatalogView.tsx b/src/components/catalog/CatalogView.tsx index a7ea8f1..2652fd6 100644 --- a/src/components/catalog/CatalogView.tsx +++ b/src/components/catalog/CatalogView.tsx @@ -1,12 +1,12 @@ import { FC } from 'react'; import { GetConfigurationValue } from '../../api'; -import { useCatalog } from '../../hooks'; +import { useCatalogData } from '../../hooks'; import { CatalogClassicView } from './CatalogClassicView'; import { CatalogModernView } from './CatalogModernView'; export const CatalogView: FC<{}> = () => { - const { catalogLocalizationVersion = 0 } = useCatalog(); + const { catalogLocalizationVersion = 0 } = useCatalogData(); const useNewStyle = GetConfigurationValue('catalog.style.new', false); if(useNewStyle) return ( diff --git a/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx b/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx index 32fec7f..c61c14b 100644 --- a/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx +++ b/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx @@ -2,12 +2,12 @@ import { FC, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa'; import { LocalizeText } from '../../../../api'; -import { useCatalog } from '../../../../hooks'; +import { useCatalogData } from '../../../../hooks'; import { IOfferEditData, useCatalogAdmin } from '../../CatalogAdminContext'; export const CatalogAdminOfferEditView: FC<{}> = () => { - const { currentPage = null } = useCatalog(); + const { currentPage = null } = useCatalogData(); const catalogAdmin = useCatalogAdmin(); const editingOffer = catalogAdmin?.editingOffer ?? null; const setEditingOffer = catalogAdmin?.setEditingOffer; diff --git a/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx b/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx index 037af96..457a301 100644 --- a/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx +++ b/src/components/catalog/views/admin/CatalogAdminPageEditView.tsx @@ -1,7 +1,7 @@ import { FC, useEffect, useState } from 'react'; import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa'; import { CatalogType, LocalizeText } from '../../../../api'; -import { useCatalog } from '../../../../hooks'; +import { useCatalogData, useCatalogUiState } from '../../../../hooks'; import { IPageEditData, useCatalogAdmin } from '../../CatalogAdminContext'; const LAYOUT_OPTIONS = [ @@ -21,7 +21,8 @@ const MODE_OPTIONS = [ export const CatalogAdminPageEditView: FC<{}> = () => { - const { currentPage = null, activeNodes = [], rootNode = null, currentType = CatalogType.NORMAL } = useCatalog(); + const { currentPage = null, rootNode = null } = useCatalogData(); + const { activeNodes = [], currentType = CatalogType.NORMAL } = useCatalogUiState(); const catalogAdmin = useCatalogAdmin(); const editingPageData = catalogAdmin?.editingPageData ?? false; const editingRootPage = catalogAdmin?.editingRootPage ?? false; diff --git a/src/components/catalog/views/favorites/CatalogFavoritesView.tsx b/src/components/catalog/views/favorites/CatalogFavoritesView.tsx index 6b403d3..0de88c5 100644 --- a/src/components/catalog/views/favorites/CatalogFavoritesView.tsx +++ b/src/components/catalog/views/favorites/CatalogFavoritesView.tsx @@ -1,7 +1,7 @@ import { FC, useMemo } from 'react'; import { FaHeart, FaStar, FaTimes } from 'react-icons/fa'; import { ICatalogNode, LocalizeText } from '../../../../api'; -import { useCatalog, useCatalogFavorites } from '../../../../hooks'; +import { useCatalogActions, useCatalogData, useCatalogFavorites } from '../../../../hooks'; import { CatalogIconView } from '../catalog-icon/CatalogIconView'; interface CatalogFavoritesViewProps @@ -13,7 +13,8 @@ export const CatalogFavoritesView: FC = props => { const { onClose } = props; const { favoriteOffers, favoritePageIds, toggleFavoritePage, toggleFavoriteOffer } = useCatalogFavorites(); - const { offersToNodes, activateNode, openPageByOfferId, rootNode } = useCatalog(); + const { offersToNodes, rootNode } = useCatalogData(); + const { activateNode, openPageByOfferId } = useCatalogActions(); const favoritePages = useMemo(() => { diff --git a/src/components/catalog/views/navigation/CatalogNavigationView.tsx b/src/components/catalog/views/navigation/CatalogNavigationView.tsx index 777c5fd..97907f6 100644 --- a/src/components/catalog/views/navigation/CatalogNavigationView.tsx +++ b/src/components/catalog/views/navigation/CatalogNavigationView.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { ICatalogNode } from '../../../../api'; -import { useCatalog } from '../../../../hooks'; +import { useCatalogData } from '../../../../hooks'; import { CatalogNavigationItemView } from './CatalogNavigationItemView'; import { CatalogNavigationSetView } from './CatalogNavigationSetView'; @@ -12,7 +12,7 @@ export interface CatalogNavigationViewProps export const CatalogNavigationView: FC = props => { const { node = null } = props; - const { searchResult = null } = useCatalog(); + const { searchResult = null } = useCatalogData(); return (
diff --git a/src/components/catalog/views/page/common/CatalogGridOfferView.tsx b/src/components/catalog/views/page/common/CatalogGridOfferView.tsx index a634705..660f1ef 100644 --- a/src/components/catalog/views/page/common/CatalogGridOfferView.tsx +++ b/src/components/catalog/views/page/common/CatalogGridOfferView.tsx @@ -3,7 +3,7 @@ import { FC, MouseEvent, useMemo, useState } from 'react'; import { FaHeart } from 'react-icons/fa'; import { CatalogType, IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api'; import { LayoutAvatarImageView, LayoutGridItem, LayoutGridItemProps } from '../../../../../common'; -import { useCatalog, useCatalogFavorites, useInventoryFurni } from '../../../../../hooks'; +import { useCatalogActions, useCatalogFavorites, useCatalogUiState, useInventoryFurni } from '../../../../../hooks'; interface CatalogGridOfferViewProps extends LayoutGridItemProps { @@ -15,7 +15,8 @@ export const CatalogGridOfferView: FC = props => { const { offer = null, selectOffer = null, itemActive = false, ...rest } = props; const [ isMouseDown, setMouseDown ] = useState(false); - const { requestOfferToMover = null, currentType = CatalogType.NORMAL } = useCatalog(); + const { requestOfferToMover = null } = useCatalogActions(); + const { currentType = CatalogType.NORMAL } = useCatalogUiState(); const { isVisible = false } = useInventoryFurni(); const { isFavoriteOffer, toggleFavoriteOffer } = useCatalogFavorites(); const isFav = offer ? isFavoriteOffer(offer.offerId) : false; diff --git a/src/components/catalog/views/page/common/CatalogSearchView.tsx b/src/components/catalog/views/page/common/CatalogSearchView.tsx index bb8583c..5fd9dca 100644 --- a/src/components/catalog/views/page/common/CatalogSearchView.tsx +++ b/src/components/catalog/views/page/common/CatalogSearchView.tsx @@ -2,12 +2,13 @@ import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { FaSearch, FaTimes } from 'react-icons/fa'; import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData, useCatalogUiState } from '../../../../../hooks'; export const CatalogSearchView: FC<{}> = () => { const [ searchValue, setSearchValue ] = useState(''); - const { currentType = null, rootNode = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog(); + const { rootNode = null, searchResult = null } = useCatalogData(); + const { currentType = null, setSearchResult = null, setCurrentPage = null } = useCatalogUiState(); const normalizeSearchText = (value: string) => (value || '') .toLocaleLowerCase() diff --git a/src/components/catalog/views/page/layout/CatalogLayoutBadgeDisplayView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutBadgeDisplayView.tsx index ee82e6e..d2ef2af 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutBadgeDisplayView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutBadgeDisplayView.tsx @@ -1,7 +1,7 @@ import { FC } from 'react'; import { LocalizeText, SanitizeHtml } from '../../../../../api'; import { Column, Grid, Text } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData } from '../../../../../hooks'; import { CatalogBadgeSelectorWidgetView } from '../widgets/CatalogBadgeSelectorWidgetView'; import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView'; import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView'; @@ -14,7 +14,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types'; export const CatalogLayoutBadgeDisplayView: FC = props => { const { page = null } = props; - const { currentOffer = null } = useCatalog(); + const { currentOffer = null } = useCatalogData(); return ( <> diff --git a/src/components/catalog/views/page/layout/CatalogLayoutBuildersClubBuyView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutBuildersClubBuyView.tsx index 19e8c2c..9c458fe 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutBuildersClubBuyView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutBuildersClubBuyView.tsx @@ -3,7 +3,7 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CatalogPurchaseState, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../api'; import { Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, LayoutLoadingSpinnerView, Text } from '../../../../../common'; import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events'; -import { useCatalog, useClubOffers, usePurse, useUiEvent } from '../../../../../hooks'; +import { useCatalogData, useClubOffers, usePurse, useUiEvent } from '../../../../../hooks'; import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView'; import { CatalogLayoutProps } from './CatalogLayout.types'; @@ -14,7 +14,7 @@ export const CatalogLayoutBuildersClubBuyView: FC = () => { const [ pendingOffer, setPendingOffer ] = useState(null); const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE); - const { currentPage = null } = useCatalog(); + const { currentPage = null } = useCatalogData(); const { getCurrencyAmount = null } = usePurse(); const isPurchasingRef = useRef(false); const isAddonLayout = (currentPage?.layoutCode === 'builders_club_addons'); diff --git a/src/components/catalog/views/page/layout/CatalogLayoutColorGroupingView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutColorGroupingView.tsx index 4c1944c..cb4c53a 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutColorGroupingView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutColorGroupingView.tsx @@ -3,7 +3,7 @@ import { FC, useMemo, useState } from 'react'; import { FaFillDrip } from 'react-icons/fa'; import { IPurchasableOffer, SanitizeHtml } from '../../../../../api'; import { AutoGrid, Button, Column, Grid, LayoutGridItem, Text } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData, useCatalogUiState } from '../../../../../hooks'; import { CatalogGridOfferView } from '../common/CatalogGridOfferView'; import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView'; import { CatalogLimitedItemWidgetView } from '../widgets/CatalogLimitedItemWidgetView'; @@ -22,7 +22,8 @@ export const CatalogLayoutColorGroupingView: FC>(new Map()); - const { currentOffer = null, setCurrentOffer = null } = useCatalog(); + const { currentOffer = null } = useCatalogData(); + const { setCurrentOffer = null } = useCatalogUiState(); const [ colorsShowing, setColorsShowing ] = useState(false); const sortByColorIndex = (a: IPurchasableOffer, b: IPurchasableOffer) => diff --git a/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx index e9417ab..f01401f 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx @@ -2,7 +2,7 @@ import { FC } from 'react'; import { FaEdit, FaPlus } from 'react-icons/fa'; import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api'; import { Text } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData } from '../../../../../hooks'; import { useCatalogAdmin } from '../../../CatalogAdminContext'; import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView'; import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView'; @@ -17,7 +17,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types'; export const CatalogLayoutDefaultView: FC = props => { const { page = null } = props; - const { currentOffer = null, currentPage = null } = useCatalog(); + const { currentOffer = null, currentPage = null } = useCatalogData(); const catalogAdmin = useCatalogAdmin(); const adminMode = catalogAdmin?.adminMode ?? false; diff --git a/src/components/catalog/views/page/layout/CatalogLayoutGuildCustomFurniView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutGuildCustomFurniView.tsx index cb8b6fa..6d28e1e 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutGuildCustomFurniView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutGuildCustomFurniView.tsx @@ -1,7 +1,7 @@ import { FC } from 'react'; import { SanitizeHtml } from '../../../../../api'; import { Column, Grid, Text } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData } from '../../../../../hooks'; import { CatalogGuildBadgeWidgetView } from '../widgets/CatalogGuildBadgeWidgetView'; import { CatalogGuildSelectorWidgetView } from '../widgets/CatalogGuildSelectorWidgetView'; import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView'; @@ -13,7 +13,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types'; export const CatalogLayouGuildCustomFurniView: FC = props => { const { page = null } = props; - const { currentOffer = null } = useCatalog(); + const { currentOffer = null } = useCatalogData(); return ( diff --git a/src/components/catalog/views/page/layout/CatalogLayoutGuildForumView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutGuildForumView.tsx index b7d9cf9..58baa3a 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutGuildForumView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutGuildForumView.tsx @@ -1,7 +1,7 @@ import { FC, useState } from 'react'; import { SanitizeHtml } from '../../../../../api'; import { Column, Grid, Text } from '../../../../../common'; -import { useCatalog, useUserGroups } from '../../../../../hooks'; +import { useCatalogData, useCatalogUiState, useUserGroups } from '../../../../../hooks'; import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView'; import { CatalogGuildSelectorWidgetView } from '../widgets/CatalogGuildSelectorWidgetView'; import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView'; @@ -12,7 +12,8 @@ export const CatalogLayouGuildForumView: FC = props => { const { page = null } = props; const [ selectedGroupIndex, setSelectedGroupIndex ] = useState(0); - const { currentOffer = null, setCurrentOffer = null } = useCatalog(); + const { currentOffer = null } = useCatalogData(); + const { setCurrentOffer = null } = useCatalogUiState(); const { data: groups = null } = useUserGroups(); return ( diff --git a/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx index d9c6c87..8016c40 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx @@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react'; import { LocalizeText, SendMessageComposer } from '../../../../../api'; import { useNitroQuery } from '../../../../../api/nitro-query'; import { Button, Column, Text } from '../../../../../common'; -import { useCatalog, useNavigator, useRoomPromote } from '../../../../../hooks'; +import { useCatalogUiState, useNavigator, useRoomPromote } from '../../../../../hooks'; import { NitroInput } from '../../../../../layout'; import { CatalogLayoutProps } from './CatalogLayout.types'; @@ -18,7 +18,7 @@ export const CatalogLayoutRoomAdsView: FC = props => const [ extended, setExtended ] = useState(false); const [ categoryId, setCategoryId ] = useState(1); const { categories = null } = useNavigator(); - const { setIsVisible = null } = useCatalog(); + const { setIsVisible = null } = useCatalogUiState(); const { promoteInformation, isExtended, setIsExtended } = useRoomPromote(); const { data: availableRooms = [] } = useNitroQuery({ diff --git a/src/components/catalog/views/page/layout/CatalogLayoutSoundMachineView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutSoundMachineView.tsx index 0aa131e..e6c12f4 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutSoundMachineView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutSoundMachineView.tsx @@ -2,7 +2,7 @@ import { GetOfficialSongIdMessageComposer, GetSoundManager, MusicPriorities, Off import { FC, useEffect, useState } from 'react'; import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml, SendMessageComposer } from '../../../../../api'; import { Button, Column, Grid, LayoutImage, Text } from '../../../../../common'; -import { useCatalog, useMessageEvent } from '../../../../../hooks'; +import { useCatalogData, useMessageEvent } from '../../../../../hooks'; import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView'; import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView'; import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView'; @@ -18,7 +18,7 @@ export const CatalogLayoutSoundMachineView: FC = props => const { page = null } = props; const [ songId, setSongId ] = useState(-1); const [ officialSongId, setOfficialSongId ] = useState(''); - const { currentOffer = null, currentPage = null } = useCatalog(); + const { currentOffer = null, currentPage = null } = useCatalogData(); const previewSong = (previewSongId: number) => GetSoundManager().musicController?.playSong(previewSongId, MusicPriorities.PRIORITY_PURCHASE_PREVIEW, 15, 0, 0, 0); diff --git a/src/components/catalog/views/page/layout/CatalogLayoutSpacesView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutSpacesView.tsx index 9cd18b6..3d5ccfe 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutSpacesView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutSpacesView.tsx @@ -1,7 +1,7 @@ import { FC, useEffect } from 'react'; import { SanitizeHtml } from '../../../../../api'; import { Column, Grid, Text } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData } from '../../../../../hooks'; import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView'; import { CatalogSpacesWidgetView } from '../widgets/CatalogSpacesWidgetView'; import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget'; @@ -11,7 +11,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types'; export const CatalogLayoutSpacesView: FC = props => { const { page = null } = props; - const { currentOffer = null, roomPreviewer = null } = useCatalog(); + const { currentOffer = null, roomPreviewer = null } = useCatalogData(); useEffect(() => { diff --git a/src/components/catalog/views/page/layout/CatalogLayoutTrophiesView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutTrophiesView.tsx index 1e1c174..31299a0 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutTrophiesView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutTrophiesView.tsx @@ -2,7 +2,7 @@ import { FC, useEffect, useState } from 'react'; import { FaEdit, FaPen, FaPlus, FaTrophy } from 'react-icons/fa'; import { LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api'; import { Text } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData, useCatalogUiState } from '../../../../../hooks'; import { useCatalogAdmin } from '../../../CatalogAdminContext'; import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView'; import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView'; @@ -15,7 +15,8 @@ export const CatalogLayoutTrophiesView: FC = props => { const { page = null } = props; const [ trophyText, setTrophyText ] = useState(''); - const { currentOffer = null, setPurchaseOptions = null } = useCatalog(); + const { currentOffer = null } = useCatalogData(); + const { setPurchaseOptions = null } = useCatalogUiState(); const catalogAdmin = useCatalogAdmin(); const adminMode = catalogAdmin?.adminMode ?? false; diff --git a/src/components/catalog/views/page/layout/CatalogLayoutVipBuyView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutVipBuyView.tsx index b63f4f6..09202de 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutVipBuyView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutVipBuyView.tsx @@ -3,7 +3,7 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CatalogPurchaseState, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../api'; import { AutoGrid, Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, LayoutLoadingSpinnerView, Text } from '../../../../../common'; import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events'; -import { useCatalog, useClubOffers, usePurse, useUiEvent } from '../../../../../hooks'; +import { useCatalogData, useClubOffers, usePurse, useUiEvent } from '../../../../../hooks'; import { CatalogLayoutProps } from './CatalogLayout.types'; const VIP_WINDOW_ID = 1; @@ -12,7 +12,7 @@ export const CatalogLayoutVipBuyView: FC = props => { const [ pendingOffer, setPendingOffer ] = useState(null); const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE); - const { currentPage = null } = useCatalog(); + const { currentPage = null } = useCatalogData(); const { purse = null, getCurrencyAmount = null } = usePurse(); const { data: offers = null } = useClubOffers(VIP_WINDOW_ID); const isPurchasingRef = useRef(false); diff --git a/src/components/catalog/views/page/layout/frontpage4/CatalogLayoutFrontpage4View.tsx b/src/components/catalog/views/page/layout/frontpage4/CatalogLayoutFrontpage4View.tsx index a7e74b2..29ead13 100644 --- a/src/components/catalog/views/page/layout/frontpage4/CatalogLayoutFrontpage4View.tsx +++ b/src/components/catalog/views/page/layout/frontpage4/CatalogLayoutFrontpage4View.tsx @@ -1,7 +1,7 @@ import { CreateLinkEvent, FrontPageItem } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect } from 'react'; import { Column, Grid } from '../../../../../../common'; -import { useCatalog } from '../../../../../../hooks'; +import { useCatalogData } from '../../../../../../hooks'; import { CatalogRedeemVoucherView } from '../../common/CatalogRedeemVoucherView'; import { CatalogLayoutProps } from '../CatalogLayout.types'; import { CatalogLayoutFrontPageItemView } from './CatalogLayoutFrontPageItemView'; @@ -9,7 +9,7 @@ import { CatalogLayoutFrontPageItemView } from './CatalogLayoutFrontPageItemView export const CatalogLayoutFrontpage4View: FC = props => { const { page = null, hideNavigation = null } = props; - const { frontPageItems = [] } = useCatalog(); + const { frontPageItems = [] } = useCatalogData(); const selectItem = useCallback((item: FrontPageItem) => { diff --git a/src/components/catalog/views/page/layout/pets/CatalogLayoutPetView.tsx b/src/components/catalog/views/page/layout/pets/CatalogLayoutPetView.tsx index 589d6bb..7081428 100644 --- a/src/components/catalog/views/page/layout/pets/CatalogLayoutPetView.tsx +++ b/src/components/catalog/views/page/layout/pets/CatalogLayoutPetView.tsx @@ -4,7 +4,7 @@ import { FaCheck, FaEdit, FaFillDrip, FaPaw, FaPlus, FaTimes } from 'react-icons import { DispatchUiEvent, GetPetAvailableColors, GetPetIndexFromLocalization, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../../api'; import { LayoutGridItem, LayoutPetImageView } from '../../../../../../common'; import { CatalogPurchaseFailureEvent } from '../../../../../../events'; -import { useCatalog, useMessageEvent, useSellablePetPalette } from '../../../../../../hooks'; +import { useCatalogData, useCatalogUiState, useMessageEvent, useSellablePetPalette } from '../../../../../../hooks'; import { useCatalogAdmin } from '../../../../CatalogAdminContext'; import { CatalogAddOnBadgeWidgetView } from '../../widgets/CatalogAddOnBadgeWidgetView'; import { CatalogTotalPriceWidget } from '../../widgets/CatalogTotalPriceWidget'; @@ -23,7 +23,8 @@ export const CatalogLayoutPetView: FC = props => const [ petName, setPetName ] = useState(''); const [ approvalPending, setApprovalPending ] = useState(true); const [ approvalResult, setApprovalResult ] = useState(-1); - const { currentOffer = null, setCurrentOffer = null, setPurchaseOptions = null, roomPreviewer = null } = useCatalog(); + const { currentOffer = null, roomPreviewer = null } = useCatalogData(); + const { setCurrentOffer = null, setPurchaseOptions = null } = useCatalogUiState(); const catalogAdmin = useCatalogAdmin(); const adminMode = catalogAdmin?.adminMode ?? false; const breed: string = (currentOffer?.product?.productData?.type as unknown as string) ?? ''; diff --git a/src/components/catalog/views/page/widgets/CatalogAddOnBadgeWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogAddOnBadgeWidgetView.tsx index fd3b652..657729b 100644 --- a/src/components/catalog/views/page/widgets/CatalogAddOnBadgeWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogAddOnBadgeWidgetView.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { BaseProps, LayoutBadgeImageView } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData } from '../../../../../hooks'; interface CatalogAddOnBadgeWidgetViewProps extends BaseProps { @@ -10,7 +10,7 @@ interface CatalogAddOnBadgeWidgetViewProps extends BaseProps export const CatalogAddOnBadgeWidgetView: FC = props => { const { ...rest } = props; - const { currentOffer = null } = useCatalog(); + const { currentOffer = null } = useCatalogData(); if(!currentOffer || !currentOffer.badgeCode || !currentOffer.badgeCode.length) return null; diff --git a/src/components/catalog/views/page/widgets/CatalogBadgeSelectorWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogBadgeSelectorWidgetView.tsx index 625b39f..8ef5c4d 100644 --- a/src/components/catalog/views/page/widgets/CatalogBadgeSelectorWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogBadgeSelectorWidgetView.tsx @@ -1,7 +1,7 @@ import { StringDataType } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useState } from 'react'; import { AutoGrid, AutoGridProps, LayoutBadgeImageView, LayoutGridItem } from '../../../../../common'; -import { useCatalog, useInventoryBadges } from '../../../../../hooks'; +import { useCatalogData, useCatalogUiState, useInventoryBadges } from '../../../../../hooks'; const EXCLUDED_BADGE_CODES: string[] = []; @@ -15,7 +15,8 @@ export const CatalogBadgeSelectorWidgetView: FC(null); - const { currentOffer = null, setPurchaseOptions = null } = useCatalog(); + const { currentOffer = null } = useCatalogData(); + const { setPurchaseOptions = null } = useCatalogUiState(); const { badgeCodes = [], activate = null, deactivate = null } = useInventoryBadges(); const previewStuffData = useMemo(() => diff --git a/src/components/catalog/views/page/widgets/CatalogBundleGridWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogBundleGridWidgetView.tsx index a5cffce..cf77622 100644 --- a/src/components/catalog/views/page/widgets/CatalogBundleGridWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogBundleGridWidgetView.tsx @@ -1,6 +1,6 @@ import { FC, useEffect, useRef } from 'react'; import { AutoGrid, AutoGridProps, LayoutGridItem } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData } from '../../../../../hooks'; interface CatalogBundleGridWidgetViewProps extends AutoGridProps { @@ -10,7 +10,7 @@ interface CatalogBundleGridWidgetViewProps extends AutoGridProps export const CatalogBundleGridWidgetView: FC = props => { const { columnCount = 5, children = null, ...rest } = props; - const { currentOffer = null } = useCatalog(); + const { currentOffer = null } = useCatalogData(); const elementRef = useRef(null); useEffect(() => diff --git a/src/components/catalog/views/page/widgets/CatalogFirstProductSelectorWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogFirstProductSelectorWidgetView.tsx index 855a175..c5e9542 100644 --- a/src/components/catalog/views/page/widgets/CatalogFirstProductSelectorWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogFirstProductSelectorWidgetView.tsx @@ -1,9 +1,10 @@ import { FC, useEffect } from 'react'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData, useCatalogUiState } from '../../../../../hooks'; export const CatalogFirstProductSelectorWidgetView: FC<{}> = props => { - const { currentPage = null, setCurrentOffer = null } = useCatalog(); + const { currentPage = null } = useCatalogData(); + const { setCurrentOffer = null } = useCatalogUiState(); useEffect(() => { diff --git a/src/components/catalog/views/page/widgets/CatalogGuildBadgeWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogGuildBadgeWidgetView.tsx index d56958e..c69c895 100644 --- a/src/components/catalog/views/page/widgets/CatalogGuildBadgeWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogGuildBadgeWidgetView.tsx @@ -1,7 +1,7 @@ import { StringDataType } from '@nitrots/nitro-renderer'; import { FC, useMemo } from 'react'; import { BaseProps, LayoutBadgeImageView } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData, useCatalogUiState } from '../../../../../hooks'; interface CatalogGuildBadgeWidgetViewProps extends BaseProps { @@ -11,7 +11,8 @@ interface CatalogGuildBadgeWidgetViewProps extends BaseProps export const CatalogGuildBadgeWidgetView: FC = props => { const { ...rest } = props; - const { currentOffer = null, purchaseOptions = null } = useCatalog(); + const { currentOffer = null } = useCatalogData(); + const { purchaseOptions = null } = useCatalogUiState(); const { previewStuffData = null } = purchaseOptions; const badgeCode = useMemo(() => diff --git a/src/components/catalog/views/page/widgets/CatalogGuildSelectorWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogGuildSelectorWidgetView.tsx index d98ba4c..3d9e4a4 100644 --- a/src/components/catalog/views/page/widgets/CatalogGuildSelectorWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogGuildSelectorWidgetView.tsx @@ -2,12 +2,13 @@ import { StringDataType } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useState } from 'react'; import { LocalizeText } from '../../../../../api'; import { Button, Flex } from '../../../../../common'; -import { useCatalog, useUserGroups } from '../../../../../hooks'; +import { useCatalogData, useCatalogUiState, useUserGroups } from '../../../../../hooks'; export const CatalogGuildSelectorWidgetView: FC<{}> = props => { const [ selectedGroupIndex, setSelectedGroupIndex ] = useState(0); - const { currentOffer = null, setPurchaseOptions = null } = useCatalog(); + const { currentOffer = null } = useCatalogData(); + const { setPurchaseOptions = null } = useCatalogUiState(); const { data: groups = null } = useUserGroups(); const previewStuffData = useMemo(() => diff --git a/src/components/catalog/views/page/widgets/CatalogItemGridWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogItemGridWidgetView.tsx index 5b627c7..deff50a 100644 --- a/src/components/catalog/views/page/widgets/CatalogItemGridWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogItemGridWidgetView.tsx @@ -1,7 +1,7 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'; import { IPurchasableOffer } from '../../../../../api'; import { AutoGrid, AutoGridProps } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogActions, useCatalogData } from '../../../../../hooks'; import { useCatalogAdmin } from '../../../CatalogAdminContext'; import { CatalogGridOfferView } from '../common/CatalogGridOfferView'; @@ -13,7 +13,8 @@ interface CatalogItemGridWidgetViewProps extends AutoGridProps export const CatalogItemGridWidgetView: FC = props => { const { columnCount = 5, children = null, ...rest } = props; - const { currentOffer = null, currentPage = null, selectCatalogOffer = null } = useCatalog(); + const { currentOffer = null, currentPage = null } = useCatalogData(); + const { selectCatalogOffer = null } = useCatalogActions(); const catalogAdmin = useCatalogAdmin(); const adminMode = catalogAdmin?.adminMode ?? false; const elementRef = useRef(null); diff --git a/src/components/catalog/views/page/widgets/CatalogLimitedItemWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogLimitedItemWidgetView.tsx index b2c85e8..faf02a2 100644 --- a/src/components/catalog/views/page/widgets/CatalogLimitedItemWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogLimitedItemWidgetView.tsx @@ -1,11 +1,11 @@ import { FC } from 'react'; import { Offer } from '../../../../../api'; import { LayoutLimitedEditionCompletePlateView } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData } from '../../../../../hooks'; export const CatalogLimitedItemWidgetView: FC = props => { - const { currentOffer = null } = useCatalog(); + const { currentOffer = null } = useCatalogData(); if(!currentOffer || (currentOffer.pricingModel !== Offer.PRICING_MODEL_SINGLE) || !currentOffer.product.isUniqueLimitedItem) return null; diff --git a/src/components/catalog/views/page/widgets/CatalogPriceDisplayWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogPriceDisplayWidgetView.tsx index 718eb2e..a83a981 100644 --- a/src/components/catalog/views/page/widgets/CatalogPriceDisplayWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogPriceDisplayWidgetView.tsx @@ -2,7 +2,7 @@ import { FC } from 'react'; import { FaPlus } from 'react-icons/fa'; import { IPurchasableOffer } from '../../../../../api'; import { LayoutCurrencyIcon, Text } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogUiState } from '../../../../../hooks'; interface CatalogPriceDisplayWidgetViewProps { @@ -13,7 +13,7 @@ interface CatalogPriceDisplayWidgetViewProps export const CatalogPriceDisplayWidgetView: FC = props => { const { offer = null, separator = false } = props; - const { purchaseOptions = null } = useCatalog(); + const { purchaseOptions = null } = useCatalogUiState(); const { quantity = 1 } = purchaseOptions; if(!offer) return null; diff --git a/src/components/catalog/views/page/widgets/CatalogPurchaseWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogPurchaseWidgetView.tsx index b329fa7..d5321e0 100644 --- a/src/components/catalog/views/page/widgets/CatalogPurchaseWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogPurchaseWidgetView.tsx @@ -3,7 +3,7 @@ import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { BuilderFurniPlaceableStatus, CatalogPurchaseState, CatalogType, DispatchUiEvent, GetClubMemberLevel, LocalStorageKeys, LocalizeText, NotificationBubbleType, Offer, ProductTypeEnum, SendMessageComposer } from '../../../../../api'; import { Button, LayoutLoadingSpinnerView, Text } from '../../../../../common'; import { CatalogEvent, CatalogInitGiftEvent, CatalogPurchaseFailureEvent, CatalogPurchaseNotAllowedEvent, CatalogPurchaseSoldOutEvent, CatalogPurchasedEvent } from '../../../../../events'; -import { useCatalog, useLocalStorage, useNotification, usePurse, useUiEvent } from '../../../../../hooks'; +import { useCatalogActions, useCatalogData, useCatalogUiState, useLocalStorage, useNotification, usePurse, useUiEvent } from '../../../../../hooks'; interface CatalogPurchaseWidgetViewProps { @@ -20,7 +20,9 @@ export const CatalogPurchaseWidgetView: FC = pro const [ purchaseWillBeGift, setPurchaseWillBeGift ] = useState(false); const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE); const [ catalogSkipPurchaseConfirmation, setCatalogSkipPurchaseConfirmation ] = useLocalStorage(LocalStorageKeys.CATALOG_SKIP_PURCHASE_CONFIRMATION, false); - const { currentOffer = null, currentPage = null, currentType = CatalogType.NORMAL, purchaseOptions = null, setPurchaseOptions = null, requestOfferToMover = null, setCatalogPlaceMultipleObjects = null, getBuilderFurniPlaceableStatus = null } = useCatalog(); + const { currentOffer = null, currentPage = null } = useCatalogData(); + const { currentType = CatalogType.NORMAL, purchaseOptions = null, setPurchaseOptions = null, setCatalogPlaceMultipleObjects = null } = useCatalogUiState(); + const { requestOfferToMover = null, getBuilderFurniPlaceableStatus = null } = useCatalogActions(); const { getCurrencyAmount = null } = usePurse(); const { showSingleBubble = null } = useNotification(); diff --git a/src/components/catalog/views/page/widgets/CatalogSimplePriceWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogSimplePriceWidgetView.tsx index bd82411..c34fe4a 100644 --- a/src/components/catalog/views/page/widgets/CatalogSimplePriceWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogSimplePriceWidgetView.tsx @@ -1,10 +1,10 @@ import { FC } from 'react'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData } from '../../../../../hooks'; import { CatalogPriceDisplayWidgetView } from './CatalogPriceDisplayWidgetView'; export const CatalogSimplePriceWidgetView: FC<{}> = props => { - const { currentOffer = null } = useCatalog(); + const { currentOffer = null } = useCatalogData(); return (
diff --git a/src/components/catalog/views/page/widgets/CatalogSpacesWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogSpacesWidgetView.tsx index 722589c..b100cbe 100644 --- a/src/components/catalog/views/page/widgets/CatalogSpacesWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogSpacesWidgetView.tsx @@ -1,7 +1,7 @@ import { FC, useEffect, useRef, useState } from 'react'; import { IPurchasableOffer, LocalizeText, Offer, ProductTypeEnum } from '../../../../../api'; import { AutoGrid, AutoGridProps, Button } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData, useCatalogUiState } from '../../../../../hooks'; import { CatalogGridOfferView } from '../common/CatalogGridOfferView'; interface CatalogSpacesWidgetViewProps extends AutoGridProps @@ -17,7 +17,8 @@ export const CatalogSpacesWidgetView: FC = props = const [ groupedOffers, setGroupedOffers ] = useState(null); const [ selectedGroupIndex, setSelectedGroupIndex ] = useState(-1); const [ selectedOfferForGroup, setSelectedOfferForGroup ] = useState(null); - const { currentPage = null, currentOffer = null, setCurrentOffer = null, setPurchaseOptions = null } = useCatalog(); + const { currentPage = null, currentOffer = null } = useCatalogData(); + const { setCurrentOffer = null, setPurchaseOptions = null } = useCatalogUiState(); const elementRef = useRef(null); const setSelectedOffer = (offer: IPurchasableOffer) => diff --git a/src/components/catalog/views/page/widgets/CatalogSpinnerWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogSpinnerWidgetView.tsx index 6b543a1..573465e 100644 --- a/src/components/catalog/views/page/widgets/CatalogSpinnerWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogSpinnerWidgetView.tsx @@ -1,14 +1,15 @@ import { FC } from 'react'; import { FaMinus, FaPlus } from 'react-icons/fa'; import { LocalizeText } from '../../../../../api'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData, useCatalogUiState } from '../../../../../hooks'; const MIN_VALUE: number = 1; const MAX_VALUE: number = 99; export const CatalogSpinnerWidgetView: FC<{}> = props => { - const { currentOffer = null, purchaseOptions = null, setPurchaseOptions = null } = useCatalog(); + const { currentOffer = null } = useCatalogData(); + const { purchaseOptions = null, setPurchaseOptions = null } = useCatalogUiState(); const { quantity = 1 } = purchaseOptions; const updateQuantity = (value: number) => diff --git a/src/components/catalog/views/page/widgets/CatalogTotalPriceWidget.tsx b/src/components/catalog/views/page/widgets/CatalogTotalPriceWidget.tsx index 365bdf6..2ad6129 100644 --- a/src/components/catalog/views/page/widgets/CatalogTotalPriceWidget.tsx +++ b/src/components/catalog/views/page/widgets/CatalogTotalPriceWidget.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { Column, ColumnProps } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData } from '../../../../../hooks'; import { CatalogPriceDisplayWidgetView } from './CatalogPriceDisplayWidgetView'; interface CatalogSimplePriceWidgetViewProps extends ColumnProps @@ -10,7 +10,7 @@ interface CatalogSimplePriceWidgetViewProps extends ColumnProps export const CatalogTotalPriceWidget: FC = props => { const { gap = 1, ...rest } = props; - const { currentOffer = null } = useCatalog(); + const { currentOffer = null } = useCatalogData(); return ( diff --git a/src/components/catalog/views/page/widgets/CatalogViewProductWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogViewProductWidgetView.tsx index 14b4516..027aa36 100644 --- a/src/components/catalog/views/page/widgets/CatalogViewProductWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogViewProductWidgetView.tsx @@ -2,11 +2,12 @@ import { GetAvatarRenderManager, GetSessionDataManager, Vector3d } from '@nitrot import { FC, useEffect } from 'react'; import { BuildPurchasableClothingFigure, FurniCategory, Offer, ProductTypeEnum } from '../../../../../api'; import { AutoGrid, Column, LayoutGridItem, LayoutRoomPreviewerView } from '../../../../../common'; -import { useCatalog } from '../../../../../hooks'; +import { useCatalogData, useCatalogUiState } from '../../../../../hooks'; export const CatalogViewProductWidgetView: FC<{}> = props => { - const { currentOffer = null, roomPreviewer = null, purchaseOptions = null } = useCatalog(); + const { currentOffer = null, roomPreviewer = null } = useCatalogData(); + const { purchaseOptions = null } = useCatalogUiState(); const { previewStuffData = null } = purchaseOptions; useEffect(() => diff --git a/src/hooks/catalog/useCatalog.ts b/src/hooks/catalog/useCatalog.ts index 67d3054..0214cf5 100644 --- a/src/hooks/catalog/useCatalog.ts +++ b/src/hooks/catalog/useCatalog.ts @@ -1060,11 +1060,3 @@ export const useCatalogActions = () => }; }; -/** - * Deprecated. Kept so the 48 existing consumers compile unchanged — - * incrementally migrate them to `useCatalogData` / `useCatalogUiState` - * / `useCatalogActions` and remove this shim once the call sites are - * gone. Mirrors the same `useBetween` singleton, so behavior is - * identical. - */ -export const useCatalog = () => useBetween(useCatalogStore); diff --git a/src/hooks/catalog/useCatalogFavorites.ts b/src/hooks/catalog/useCatalogFavorites.ts index 5fc4d9e..2eb1854 100644 --- a/src/hooks/catalog/useCatalogFavorites.ts +++ b/src/hooks/catalog/useCatalogFavorites.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useBetween } from 'use-between'; import { CatalogType } from '../../api'; -import { useCatalog } from './useCatalog'; +import { useCatalogUiState } from './useCatalog'; import { getOffersStorageKey, getPagesStorageKey, IFavoriteOffer, LEGACY_STORAGE_KEY_OFFERS, LEGACY_STORAGE_KEY_PAGES, normalizeCatalogType, parseOffers, parsePages } from './useCatalogFavorites.helpers'; export type { IFavoriteOffer } from './useCatalogFavorites.helpers'; @@ -66,7 +66,7 @@ const writePages = (catalogType: string, ids: number[]) => const useCatalogFavoritesState = () => { - const { currentType = CatalogType.NORMAL } = useCatalog(); + const { currentType = CatalogType.NORMAL } = useCatalogUiState(); const catalogType = normalizeCatalogType(currentType); const [ favoriteOffersByType, setFavoriteOffersByType ] = useState>({ [CatalogType.NORMAL]: [], diff --git a/tests/useCatalog.filters.test.tsx b/tests/useCatalog.filters.test.tsx index a44eb56..4164e1c 100644 --- a/tests/useCatalog.filters.test.tsx +++ b/tests/useCatalog.filters.test.tsx @@ -75,7 +75,7 @@ vi.mock('use-between', () => ({ // Import AFTER the mock is set up. The hooks resolve `useBetween` at // import time via the module graph, so the order matters. -import { useCatalog, useCatalogActions, useCatalogData, useCatalogUiState } from '../src/hooks/catalog/useCatalog'; +import { useCatalogActions, useCatalogData, useCatalogUiState } from '../src/hooks/catalog/useCatalog'; describe('useCatalog filter contract', () => { @@ -160,47 +160,23 @@ describe('useCatalog filter contract', () => expect(result.current.openCatalogByType).toBe(fakeStore.openCatalogByType); }); - it('all four hooks observe the same singleton — refs are ===', () => + it('all three filters observe the same singleton — refs are ===', () => { const { result } = renderHook(() => ({ data: useCatalogData(), ui: useCatalogUiState(), - actions: useCatalogActions(), - full: useCatalog() + actions: useCatalogActions() })); - // The shim and the slices reach the same fakeStore. Any - // accidental copy would break this `===` check. - expect(result.current.full.activateNode).toBe(result.current.actions.activateNode); - expect(result.current.full.openCatalogByType).toBe(result.current.actions.openCatalogByType); - expect(result.current.full.setIsVisible).toBe(result.current.ui.setIsVisible); - expect(result.current.full.setCurrentPage).toBe(result.current.ui.setCurrentPage); - expect(result.current.full.rootNode).toBe(result.current.data.rootNode); - expect(result.current.full.furniCount).toBe(result.current.data.furniCount); - expect(result.current.full.roomPreviewer).toBe(result.current.data.roomPreviewer); - }); - - it('useCatalog (deprecated shim) preserves the full historical surface', () => - { - const { result } = renderHook(() => useCatalog()); - - // Sample one field from each slice, including the setters - // that the 48 existing consumers still destructure straight - // out of `useCatalog()`. If a setter or callback ever stops - // being forwarded, the shim breaks and those consumers - // silently fail. - const required = [ - 'rootNode', 'offersToNodes', 'currentPage', 'currentOffer', 'frontPageItems', - 'isVisible', 'setIsVisible', 'pageId', 'previousPageId', 'currentType', - 'setCurrentPage', 'setCurrentOffer', 'setSearchResult', - 'openCatalogByType', 'toggleCatalogByType', 'activateNode', - 'openPageById', 'openPageByName', 'openPageByOfferId', - 'requestOfferToMover', 'selectCatalogOffer', - 'getNodeById', 'getNodeByName', 'getBuilderFurniPlaceableStatus', - 'furniCount', 'furniLimit', 'secondsLeft', 'updateTime' - ]; - - for(const key of required) expect(result.current).toHaveProperty(key); + // Each slice reaches the same fakeStore via useBetween. Any + // accidental copy would break these `===` checks. + expect(result.current.actions.activateNode).toBe(fakeStore.activateNode); + expect(result.current.actions.openCatalogByType).toBe(fakeStore.openCatalogByType); + expect(result.current.ui.setIsVisible).toBe(fakeStore.setIsVisible); + expect(result.current.ui.setCurrentPage).toBe(fakeStore.setCurrentPage); + expect(result.current.data.rootNode).toBe(fakeStore.rootNode); + expect(result.current.data.furniCount).toBe(fakeStore.furniCount); + expect(result.current.data.roomPreviewer).toBe(fakeStore.roomPreviewer); }); }); From 9d10e52a55d19cd7eeb5f51c8fdcc4f6a4b2e0f1 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 14 May 2026 20:09:23 +0200 Subject: [PATCH 083/129] fix(MainView): collapse CREATED/ENDED listeners into a session-aware reducer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent useNitroEvent listeners updated landingViewVisible from RoomSessionEvent.CREATED and ENDED with no notion of which session was active. Under flaky websocket reconnects the events can land out of order: a stale ENDED for the previous room arrives after CREATED for the new one, flips landingViewVisible back to true, and the user is left at the hotel view inside a room (or vice versa) until the next room change. Folds both events into one useNitroEventReducer that carries the tracked sessionId. CREATED sets the id and closes the landing view; ENDED is applied only when its event.session.roomId matches the tracked id (or no session is active) — otherwise it's a stale ENDED for a previous session and is ignored. The reducer companion is the existing useNitroEventReducer from src/hooks/events, so no new infrastructure. Moves the entry in docs/ARCHITECTURE.md from "Open" to "Recently fixed". --- docs/ARCHITECTURE.md | 48 ++++++++----------------------------- src/components/MainView.tsx | 30 +++++++++++++++++++---- 2 files changed, 36 insertions(+), 42 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 57fca54..fdbfcec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -776,44 +776,6 @@ this repo. ### Open -#### `MainView` — race between `RoomSessionEvent.CREATED` and `ENDED` - -`src/components/MainView.tsx:47-48` writes the same `landingViewVisible` -state from two independent listeners with no session-token guard: - -```ts -useNitroEvent(RoomSessionEvent.CREATED, () => setLandingViewVisible(false)); -useNitroEvent(RoomSessionEvent.ENDED, e => setLandingViewVisible(e.openLandingView)); -``` - -If the events arrive out of order (fast reconnect, network reordering), -the final state contradicts the actual session state — landing view stuck -open inside a room, or stuck closed at the hotel view. Resolves on next -room change. - -**Fix shape** (deferred until `useNitroEventReducer` companion lands — -see proposal #1): - -```ts -// One reducer owns both events + the active session token -const { sessionId, landingViewVisible } = useNitroEventReducer<...>( - [RoomSessionEvent.CREATED, RoomSessionEvent.ENDED], - (state, e) => { - if (e.type === RoomSessionEvent.CREATED) { - return { sessionId: e.session.roomId, landingViewVisible: false }; - } - if (state.sessionId !== null && e.session.roomId !== state.sessionId) { - return state; // stale ENDED for old session, ignore - } - return { sessionId: null, landingViewVisible: e.openLandingView }; - }, - { sessionId: null, landingViewVisible: true } -); -``` - -**Severity**: edge case, observed only after unstable websocket -reconnects. UX-degrading, not data-corrupting. - #### `LayoutFurniImageView` / `LayoutAvatarImageView` — async fetch race In both files an effect kicks off an async `processAsImageUrl` / @@ -832,6 +794,16 @@ data-corrupting. ### Recently fixed (in this branch) +- **`MainView` CREATED/ENDED race fixed.** Two independent + `useNitroEvent` listeners on `RoomSessionEvent.CREATED` / + `RoomSessionEvent.ENDED` could land out of order under flaky + reconnects, leaving `landingViewVisible` contradicting the actual + session state. Replaced with a single `useNitroEventReducer` that + carries the active session's `roomId`: a CREATED bumps the tracked + id and closes the landing view; an ENDED is honored only if its + `event.session.roomId` matches the tracked id (or no session is + active), otherwise it's a stale ENDED for a previous session and + gets ignored. - **Doorbell close button didn't close** while users were pending (`useEffect(() => setIsVisible(!!users.length))` overrode the close). Fixed by `src/components/room/widgets/doorbell/DoorbellWidgetView.tsx` diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 8df9234..c620ad7 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -1,7 +1,7 @@ import { AddLinkEventTracker, GetCommunication, GetRoomSessionManager, HabboWebTools, ILinkEventTracker, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer'; import { AnimatePresence, motion } from 'framer-motion'; import { FC, useEffect, useState } from 'react'; -import { useNitroEvent } from '../hooks'; +import { useNitroEventReducer } from '../hooks'; import { AchievementsView } from './achievements/AchievementsView'; import { AvatarEditorView } from './avatar-editor'; import { BadgeCreatorView } from './badge-creator'; @@ -42,11 +42,33 @@ import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView'; export const MainView: FC<{}> = props => { const [ isReady, setIsReady ] = useState(false); - const [ landingViewVisible, setLandingViewVisible ] = useState(true); const [ localizationVersion, setLocalizationVersion ] = useState(0); - useNitroEvent(RoomSessionEvent.CREATED, event => setLandingViewVisible(false)); - useNitroEvent(RoomSessionEvent.ENDED, event => setLandingViewVisible(event.openLandingView)); + // CREATED and ENDED can arrive out of order under flaky reconnects. + // Treating them as two independent setters left landingViewVisible + // contradicting the actual session state (stuck open in-room or + // stuck closed at the hotel view). The reducer carries the active + // session's roomId so a stale ENDED for a previous session is + // ignored — only an ENDED matching the tracked session (or when + // no session is active) is honored. + const { landingViewVisible } = useNitroEventReducer<{ sessionId: number | null; landingViewVisible: boolean }, RoomSessionEvent>( + [ RoomSessionEvent.CREATED, RoomSessionEvent.ENDED ], + (state, event) => + { + if(event.type === RoomSessionEvent.CREATED) + { + return { sessionId: event.session.roomId, landingViewVisible: false }; + } + + if((state.sessionId !== null) && (event.session.roomId !== state.sessionId)) + { + return state; + } + + return { sessionId: null, landingViewVisible: event.openLandingView }; + }, + { sessionId: null, landingViewVisible: true } + ); useEffect(() => { From 97c9717253762267d38553eb7cb770ad88990954 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 14 May 2026 20:10:56 +0200 Subject: [PATCH 084/129] fix(layout-image): guard async image fetch with a request-id ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LayoutFurniImageView and LayoutAvatarImageView both fired async image generation (TextureUtils.generateImage / SDK resetFigure callback) and wrote the result back through setImageElement / setAvatarUrl with only an isMounted / isDisposed component-level guard. If props changed twice in rapid succession the older request could resolve last and overwrite the newer image with a stale one, visible on slow connections or fast scroll over grids of unique items. Each effect now captures `const requestId = ++requestIdRef.current` and threads it into every async callback (TextureUtils.generateImage, the SDK's resetFigure listener, the cache write). When a callback fires it bails if `requestIdRef.current !== requestId` — only the latest effect's callbacks make it past the gate. A stale ENDED for the previous figure now leaves the cache and the rendered url unchanged. Moves both bugs from "Open" to "Recently fixed" in docs/ARCHITECTURE.md. --- docs/ARCHITECTURE.md | 28 ++++++++++----------- src/common/layout/LayoutAvatarImageView.tsx | 11 ++++++-- src/common/layout/LayoutFurniImageView.tsx | 15 ++++++++--- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fdbfcec..18193a8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -776,24 +776,22 @@ this repo. ### Open -#### `LayoutFurniImageView` / `LayoutAvatarImageView` — async fetch race - -In both files an effect kicks off an async `processAsImageUrl` / -`generateImage` and writes the result via `setImageElement`. If props -change twice in quick succession, the first fetch can resolve **after** -the second one and overwrite the newer image with the older one. - -**Fix shape**: capture a request-id ref at the start of the effect, only -write the result if the ref hasn't been bumped meanwhile. Or — better — -once React Query (#2) is enabled, model the image fetch as a query keyed -on the props tuple; React Query handles cancellation and ordering for -free. - -**Severity**: visible only on slow connections / rapid prop changes. Not -data-corrupting. +_(none — both previously-listed bugs have landed; see "Recently fixed" +below.)_ ### Recently fixed (in this branch) +- **`LayoutFurniImageView` / `LayoutAvatarImageView` async fetch race + fixed.** Both effects kicked off async image work + (`TextureUtils.generateImage` / SDK `resetFigure` callback) and wrote + the result via `setImageElement` / `setAvatarUrl` guarded only by an + `isMounted` / `isDisposed` ref. If props changed twice in quick + succession the older fetch could resolve last and overwrite the + newer image. Both now capture a `requestIdRef` bumped at the start + of the effect; the async callback bails when its captured id no + longer matches the latest one. (React Query keyed on the props + tuple would also work, but neither call goes through a composer / + parser pair so the request-id ref is the lighter fix.) - **`MainView` CREATED/ENDED race fixed.** Two independent `useNitroEvent` listeners on `RoomSessionEvent.CREATED` / `RoomSessionEvent.ENDED` could land out of order under flaky diff --git a/src/common/layout/LayoutAvatarImageView.tsx b/src/common/layout/LayoutAvatarImageView.tsx index 853d361..75233a8 100644 --- a/src/common/layout/LayoutAvatarImageView.tsx +++ b/src/common/layout/LayoutAvatarImageView.tsx @@ -20,6 +20,12 @@ export const LayoutAvatarImageView: FC = props => const [ avatarUrl, setAvatarUrl ] = useState(null); const [ isReady, setIsReady ] = useState(false); const isDisposed = useRef(false); + // Request id bumped on every prop change. The SDK can call + // resetFigure asynchronously when server-side figure data lands; + // if props change in quick succession the older callback could + // otherwise overwrite the newer image. The closure captures the + // id and bails when stale. + const requestIdRef = useRef(0); const getClassNames = useMemo(() => { @@ -52,6 +58,7 @@ export const LayoutAvatarImageView: FC = props => { if(!isReady) return; + const requestId = ++requestIdRef.current; const figureKey = [ figure, gender, direction, headOnly ].join('-'); if(AVATAR_IMAGE_CACHE.has(figureKey)) @@ -62,7 +69,7 @@ export const LayoutAvatarImageView: FC = props => { const resetFigure = (_figure: string) => { - if(isDisposed.current) return; + if(isDisposed.current || (requestIdRef.current !== requestId)) return; const avatarImage = GetAvatarRenderManager().createAvatarImage(_figure, AvatarScaleType.LARGE, gender, { resetFigure: (figure: string) => resetFigure(figure), dispose: null, disposed: false }); @@ -74,7 +81,7 @@ export const LayoutAvatarImageView: FC = props => const imageUrl = avatarImage.processAsImageUrl(setType); - if(imageUrl && !isDisposed.current) + if(imageUrl && !isDisposed.current && (requestIdRef.current === requestId)) { if(!avatarImage.isPlaceholder()) { diff --git a/src/common/layout/LayoutFurniImageView.tsx b/src/common/layout/LayoutFurniImageView.tsx index 8e456b9..c2ad62b 100644 --- a/src/common/layout/LayoutFurniImageView.tsx +++ b/src/common/layout/LayoutFurniImageView.tsx @@ -17,6 +17,11 @@ export const LayoutFurniImageView: FC = props => const { productType = 's', productClassId = -1, direction = 2, extraData = '', scale = 1, style = {}, ...rest } = props; const [ imageElement, setImageElement ] = useState(null); const isMounted = useRef(true); + // Request id bumped by the effect on every prop change. The async + // generateImage / imageReady callbacks capture it and only write + // back if it still matches — prevents an older, slower fetch from + // overwriting a newer one when props change in quick succession. + const requestIdRef = useRef(0); useEffect(() => { @@ -28,13 +33,13 @@ export const LayoutFurniImageView: FC = props => }; }, []); - const updateImage = useCallback(async (texture: any) => + const updateImage = useCallback(async (texture: any, requestId: number) => { if(!texture) return; const image = await TextureUtils.generateImage(texture); - if(image && isMounted.current) setImageElement(image); + if(image && isMounted.current && (requestIdRef.current === requestId)) setImageElement(image); }, []); const getStyle = useMemo(() => @@ -62,12 +67,14 @@ export const LayoutFurniImageView: FC = props => useEffect(() => { + const requestId = ++requestIdRef.current; + setImageElement(null); let imageResult: ImageResult = null; const listener: IGetImageListener = { - imageReady: (result) => updateImage(result?.data), + imageReady: (result) => updateImage(result?.data, requestId), imageFailed: null }; @@ -81,7 +88,7 @@ export const LayoutFurniImageView: FC = props => break; } - if(imageResult?.data) updateImage(imageResult.data); + if(imageResult?.data) updateImage(imageResult.data, requestId); }, [ productType, productClassId, direction, extraData, updateImage ]); return ; From ab93113ce7b8c7b8ffa8e2a91ce51ee16d87cd04 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 14 May 2026 20:18:38 +0200 Subject: [PATCH 085/129] widgets: wrap each room + furniture widget in its own WidgetErrorBoundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The umbrella boundary on RoomWidgetsView caught any widget crash but unmounted every sibling along with the failing widget — a single bad parser in ChatWidget would dark out the avatar info, chat input, doorbell and all furniture overlays until the next remount. Wraps each of the 13 direct children of RoomWidgetsView (AvatarInfo, Chat, ChatInput, Doorbell, RoomTools, RoomFilterWords, RoomThumbnail, FurniChooser, PetPackage, UserChooser, WordQuiz, FriendRequest, plus the FurnitureWidgets umbrella) and each of the 20 sub-widgets inside FurnitureWidgetsView in its own named WidgetErrorBoundary. A crash now silently logs through NitroLogger with the widget name and renders null for that one widget; every sibling keeps rendering. The outer umbrella stays as defense-in-depth for the wrapper div and the listener setup in RoomWidgetsView itself. Closes the "Per-widget WidgetErrorBoundary wrapping" roadmap item; updates CLAUDE.md and docs/ARCHITECTURE.md accordingly. --- CLAUDE.md | 2 +- docs/ARCHITECTURE.md | 25 ++++------- .../room/widgets/RoomWidgetsView.tsx | 26 ++++++------ .../furniture/FurnitureWidgetsView.tsx | 41 ++++++++++--------- 4 files changed, 43 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 982ba69..e9ab4ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -260,7 +260,7 @@ into `configurePreviewServer` so `yarn preview` keeps working. | Zustand | `NavigatorRoomCreatorView` (`useRoomCreatorStore`) | | 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 | +| `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 | 162/162 cases — pure helpers + Zustand store + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `tests/mocks/renderer-mock.ts`, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters | | Form Actions | Login / Register / Forgot (LoginView.tsx) | | Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 18193a8..a305cea 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -302,12 +302,12 @@ takes down the whole UI. Implementation lives at `src/common/error-boundary/WidgetErrorBoundary.tsx`. **Status.** Implemented + applied to `RoomWidgetsView` as the umbrella for -all in-room widgets. A widget crash now degrades gracefully (the offending -widget disappears) instead of unmounting the room. - -A more granular pass could wrap each individual widget for finer-grained -fallbacks, but the umbrella alone already prevents the worst class of -failures. +all in-room widgets, **plus** a per-widget pass that wraps each of the 13 +direct children of `RoomWidgetsView` and each of the 20 sub-widgets in +`FurnitureWidgetsView`. A crash in any single widget now silently logs +through `NitroLogger` and renders `null` for that widget only — its +siblings keep rendering. Each boundary carries a `name` prop matching +the widget so the log line identifies the culprit. --- @@ -729,20 +729,11 @@ Remaining order of value/risk for the next contributor: siblings under `src/hooks/catalog/`). Only after step 1 — React Query removes ~60% of the file's responsibility, Zustand can absorb the UI state slice. -3. **Per-widget `WidgetErrorBoundary` wrapping** inside `RoomWidgetsView`. - The umbrella is in place; granular wrapping means a crash in one - widget (e.g. `ChatWidgetView`) doesn't take down the rest of the - room overlay. Mechanical and safe. -4. **Hoist `WiredCreatorToolsView`'s shared state to a Zustand slice.** +3. **Hoist `WiredCreatorToolsView`'s shared state to a Zustand slice.** The 4-tab split is done but the parent still passes ~25 props to each tab. A slice at `src/components/wired-tools/wiredToolsStore.ts` would make each tab subscribe to the keys it needs. -5. **Address the two open logic bugs** (see the "Known logic bugs" - section above): the `MainView` CREATED/ENDED race needs a session - token; the `LayoutFurniImageView` / `LayoutAvatarImageView` async - fetch race needs a request-id ref (or is solved by migrating the - image fetch to `useNitroQuery` keyed on props). -6. **Widen the component/hook Vitest coverage.** The renderer-SDK +4. **Widen the component/hook Vitest coverage.** The renderer-SDK mock layer is in place (`tests/mocks/renderer-mock.ts`) and the first two pilots — `WidgetErrorBoundary` and `useDoorbellState` — pass. Good follow-up targets: other `*State` hooks built on event diff --git a/src/components/room/widgets/RoomWidgetsView.tsx b/src/components/room/widgets/RoomWidgetsView.tsx index 66745c2..0d86075 100644 --- a/src/components/room/widgets/RoomWidgetsView.tsx +++ b/src/components/room/widgets/RoomWidgetsView.tsx @@ -161,20 +161,20 @@ export const RoomWidgetsView: FC<{}> = props => return (
- +
- - - - - - - - - - - - + + + + + + + + + + + +
); }; diff --git a/src/components/room/widgets/furniture/FurnitureWidgetsView.tsx b/src/components/room/widgets/furniture/FurnitureWidgetsView.tsx index 443a883..f4ca400 100644 --- a/src/components/room/widgets/furniture/FurnitureWidgetsView.tsx +++ b/src/components/room/widgets/furniture/FurnitureWidgetsView.tsx @@ -1,4 +1,5 @@ import { FC } from 'react'; +import { WidgetErrorBoundary } from '../../../../common'; import { FurnitureBackgroundColorView } from './FurnitureBackgroundColorView'; import { FurnitureAreaHideView } from './FurnitureAreaHideView'; import { FurnitureBadgeDisplayView } from './FurnitureBadgeDisplayView'; @@ -24,26 +25,26 @@ export const FurnitureWidgetsView: FC<{}> = props => { return ( <> - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ); }; From c16ac1d2766673602d84bb6a21039137d2af7e67 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 16 May 2026 11:21:10 +0200 Subject: [PATCH 086/129] wired-tools: hoist UI-only state flags to Zustand store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move 14 pure UI flags off useState in WiredCreatorToolsView and into a new feature-local Zustand store (useWiredCreatorToolsUiStore): tab navigation (isVisible, activeTab, inspectionType, variablesType), modal open flags (monitor history/info, inspection give, variable manage, managed give), and the variable-manage / monitor-history filter + sort + page selectors. The setters accept either a value or a (prev => next) updater to preserve the toggle/pagination call sites. WiredInspectionTabView and WiredVariablesTabView now consume the store directly for inspectionType / variablesType / isInspectionGiveOpen, dropping six props from their interfaces. Behaviour is unchanged: every listener and memo in the parent still reads the same values through selectors, and the new tests pin the defaults and setter semantics across the 14 flags. Derived selection state (selectedFurni, monitorSnapshot, variable highlight overlays, etc.) intentionally stays in the parent for this pass — moving those requires moving their listener effects too. --- .../wired-tools/WiredCreatorToolsView.tsx | 48 +++-- .../wired-tools/WiredInspectionTabView.tsx | 22 +-- .../wired-tools/WiredVariablesTabView.tsx | 16 +- .../wired-tools/wiredCreatorToolsUiStore.ts | 85 +++++++++ tests/wiredCreatorToolsUiStore.test.ts | 180 ++++++++++++++++++ 5 files changed, 312 insertions(+), 39 deletions(-) create mode 100644 src/components/wired-tools/wiredCreatorToolsUiStore.ts create mode 100644 tests/wiredCreatorToolsUiStore.test.ts diff --git a/src/components/wired-tools/WiredCreatorToolsView.tsx b/src/components/wired-tools/WiredCreatorToolsView.tsx index de5ae3b..dde13ad 100644 --- a/src/components/wired-tools/WiredCreatorToolsView.tsx +++ b/src/components/wired-tools/WiredCreatorToolsView.tsx @@ -9,6 +9,7 @@ import { useInventoryTrade, useMessageEvent, useNotification, useObjectSelectedE import { DIRECTION_NAMES, EDITABLE_FURNI_VARIABLES, EDITABLE_USER_VARIABLES, INSPECTION_ELEMENTS, MONITOR_ERROR_INFO, MONITOR_LOG_ORDER, MONTH_NAMES, TABS, TEAM_COLOR_NAMES, VARIABLES_ELEMENTS, VARIABLE_DEFINITIONS, WEEKDAY_NAMES, WIRED_CLOCK_REFRESH_MS, WIRED_FREEZE_EFFECT_IDS, WIRED_INSPECTION_REFRESH_MS, WIRED_MONITOR_ACTION_CLEAR_LOGS, WIRED_MONITOR_ACTION_FETCH, WIRED_MONITOR_POLL_MS, WIRED_VARIABLES_POLL_MS } from './WiredCreatorTools.constants'; import { createEmptyMonitorSnapshot, formatMonitorHistoryOccurrence, formatMonitorLatestOccurrence, formatMonitorSource, formatVariableTimestamp, getHotelDateTimeParts, getHotelTimeFormatter, normalizeMonitorReason } from './WiredCreatorTools.helpers'; import { HotelDateTimeParts, InspectionElementButton, InspectionElementType, InspectionFurniLiveState, InspectionFurniSelection, InspectionUserLiveState, InspectionUserSelection, InspectionUserTeamData, InspectionVariable, ManagedHolderVariableEntry, MonitorLog, MonitorLogDetails, MonitorSnapshot, MonitorStat, ParsedWallLocation, TeamEffectData, VariableDefinition, VariableHighlightOverlay, VariableHighlightTarget, VariableManageEntry, VariableTextValue, VariablesElementButton, VariablesElementType, WiredToolsTab } from './WiredCreatorTools.types'; +import { useWiredCreatorToolsUiStore } from './wiredCreatorToolsUiStore'; import { WiredInspectionTabView } from './WiredInspectionTabView'; import { WiredMonitorTabView } from './WiredMonitorTabView'; import { WiredToolsSettingsTabView } from './WiredToolsSettingsTabView'; @@ -16,10 +17,13 @@ import { WiredVariablesTabView } from './WiredVariablesTabView'; export const WiredCreatorToolsView: FC<{}> = () => { - const [ isVisible, setIsVisible ] = useState(false); - const [ activeTab, setActiveTab ] = useState('monitor'); - const [ inspectionType, setInspectionType ] = useState('furni'); - const [ variablesType, setVariablesType ] = useState('furni'); + const isVisible = useWiredCreatorToolsUiStore(s => s.isVisible); + const setIsVisible = useWiredCreatorToolsUiStore(s => s.setIsVisible); + const activeTab = useWiredCreatorToolsUiStore(s => s.activeTab); + const setActiveTab = useWiredCreatorToolsUiStore(s => s.setActiveTab); + const inspectionType = useWiredCreatorToolsUiStore(s => s.inspectionType); + const setInspectionType = useWiredCreatorToolsUiStore(s => s.setInspectionType); + const variablesType = useWiredCreatorToolsUiStore(s => s.variablesType); const [ keepSelected, setKeepSelected ] = useState(false); const [ selectedFurni, setSelectedFurni ] = useState(null); const [ selectedFurniLiveState, setSelectedFurniLiveState ] = useState(null); @@ -31,10 +35,14 @@ export const WiredCreatorToolsView: FC<{}> = () => const [ monitorSnapshot, setMonitorSnapshot ] = useState(() => createEmptyMonitorSnapshot()); const [ selectedMonitorErrorType, setSelectedMonitorErrorType ] = useState(null); const [ selectedMonitorLogDetails, setSelectedMonitorLogDetails ] = useState(null); - const [ isMonitorHistoryOpen, setIsMonitorHistoryOpen ] = useState(false); - const [ isMonitorInfoOpen, setIsMonitorInfoOpen ] = useState(false); - const [ monitorHistorySeverityFilter, setMonitorHistorySeverityFilter ] = useState<'ALL' | 'ERROR' | 'WARNING'>('ALL'); - const [ monitorHistoryTypeFilter, setMonitorHistoryTypeFilter ] = useState('ALL'); + const isMonitorHistoryOpen = useWiredCreatorToolsUiStore(s => s.isMonitorHistoryOpen); + const setIsMonitorHistoryOpen = useWiredCreatorToolsUiStore(s => s.setIsMonitorHistoryOpen); + const isMonitorInfoOpen = useWiredCreatorToolsUiStore(s => s.isMonitorInfoOpen); + const setIsMonitorInfoOpen = useWiredCreatorToolsUiStore(s => s.setIsMonitorInfoOpen); + const monitorHistorySeverityFilter = useWiredCreatorToolsUiStore(s => s.monitorHistorySeverityFilter); + const setMonitorHistorySeverityFilter = useWiredCreatorToolsUiStore(s => s.setMonitorHistorySeverityFilter); + const monitorHistoryTypeFilter = useWiredCreatorToolsUiStore(s => s.monitorHistoryTypeFilter); + const setMonitorHistoryTypeFilter = useWiredCreatorToolsUiStore(s => s.setMonitorHistoryTypeFilter); const [ editingVariable, setEditingVariable ] = useState(null); const [ editingValue, setEditingValue ] = useState(''); const [ selectedInspectionVariableKeys, setSelectedInspectionVariableKeys ] = useState>({ @@ -42,18 +50,24 @@ export const WiredCreatorToolsView: FC<{}> = () => user: '', global: '' }); - const [ isInspectionGiveOpen, setIsInspectionGiveOpen ] = useState(false); + const isInspectionGiveOpen = useWiredCreatorToolsUiStore(s => s.isInspectionGiveOpen); + const setIsInspectionGiveOpen = useWiredCreatorToolsUiStore(s => s.setIsInspectionGiveOpen); const [ inspectionGiveVariableItemId, setInspectionGiveVariableItemId ] = useState(0); const [ inspectionGiveValue, setInspectionGiveValue ] = useState('0'); - const [ isVariableManageOpen, setIsVariableManageOpen ] = useState(false); - const [ variableManageTypeFilter, setVariableManageTypeFilter ] = useState('ALL'); - const [ variableManageSort, setVariableManageSort ] = useState('highest_value'); - const [ variableManagePage, setVariableManagePage ] = useState(1); + const isVariableManageOpen = useWiredCreatorToolsUiStore(s => s.isVariableManageOpen); + const setIsVariableManageOpen = useWiredCreatorToolsUiStore(s => s.setIsVariableManageOpen); + const variableManageTypeFilter = useWiredCreatorToolsUiStore(s => s.variableManageTypeFilter); + const setVariableManageTypeFilter = useWiredCreatorToolsUiStore(s => s.setVariableManageTypeFilter); + const variableManageSort = useWiredCreatorToolsUiStore(s => s.variableManageSort); + const setVariableManageSort = useWiredCreatorToolsUiStore(s => s.setVariableManageSort); + const variableManagePage = useWiredCreatorToolsUiStore(s => s.variableManagePage); + const setVariableManagePage = useWiredCreatorToolsUiStore(s => s.setVariableManagePage); const [ selectedManagedVariableEntry, setSelectedManagedVariableEntry ] = useState(null); const [ selectedManagedHolderVariableId, setSelectedManagedHolderVariableId ] = useState(0); const [ editingManagedHolderVariableId, setEditingManagedHolderVariableId ] = useState(0); const [ editingManagedHolderValue, setEditingManagedHolderValue ] = useState(''); - const [ isManagedGiveOpen, setIsManagedGiveOpen ] = useState(false); + const isManagedGiveOpen = useWiredCreatorToolsUiStore(s => s.isManagedGiveOpen); + const setIsManagedGiveOpen = useWiredCreatorToolsUiStore(s => s.setIsManagedGiveOpen); const [ managedGiveVariableItemId, setManagedGiveVariableItemId ] = useState(0); const [ managedGiveValue, setManagedGiveValue ] = useState('0'); const [ isVariableHighlightActive, setIsVariableHighlightActive ] = useState(false); @@ -3085,8 +3099,6 @@ export const WiredCreatorToolsView: FC<{}> = () => /> } { (activeTab === 'inspection') && = () => setSelectedInspectionVariableKeys(prev => ({ ...prev, [inspectionType]: variable.key })); beginVariableEdit(variable); } } - isInspectionGiveOpen={ isInspectionGiveOpen } - onToggleInspectionGive={ () => setIsInspectionGiveOpen(value => !value) } selectedInspectionGiveDefinition={ selectedInspectionGiveDefinition } onSelectGiveVariable={ setInspectionGiveVariableItemId } availableInspectionDefinitions={ availableInspectionDefinitions } @@ -3124,8 +3134,6 @@ export const WiredCreatorToolsView: FC<{}> = () => /> } { (activeTab === 'variables') && setSelectedVariableKeys(prev => ({ ...prev, [variablesType]: key })) } diff --git a/src/components/wired-tools/WiredInspectionTabView.tsx b/src/components/wired-tools/WiredInspectionTabView.tsx index c364d05..94c7a9c 100644 --- a/src/components/wired-tools/WiredInspectionTabView.tsx +++ b/src/components/wired-tools/WiredInspectionTabView.tsx @@ -2,7 +2,8 @@ import { KeyboardEvent } from 'react'; import wiredGlobalPlaceholderImage from '../../assets/images/wiredtools/wired_global_placeholder.png'; import { Button, LayoutAvatarImageView, LayoutPetImageView, LayoutRoomObjectImageView, Text } from '../../common'; import { INSPECTION_ELEMENTS } from './WiredCreatorTools.constants'; -import { InspectionElementType, InspectionFurniSelection, InspectionUserSelection, InspectionVariable } from './WiredCreatorTools.types'; +import { InspectionFurniSelection, InspectionUserSelection, InspectionVariable } from './WiredCreatorTools.types'; +import { useWiredCreatorToolsUiStore } from './wiredCreatorToolsUiStore'; /** * Structural shape we need from the renderer's variable-definition @@ -18,9 +19,7 @@ export interface InspectionGiveDefinition export interface WiredInspectionTabViewProps { - // element type + preview - inspectionType: InspectionElementType; - onInspectionTypeChange: (next: InspectionElementType) => void; + // preview selectedFurni: InspectionFurniSelection | null; selectedUser: InspectionUserSelection | null; roomId: number | null; @@ -44,8 +43,6 @@ export interface WiredInspectionTabViewProps onBeginVariableEdit: (variable: InspectionVariable) => void; // give-variable popover - isInspectionGiveOpen: boolean; - onToggleInspectionGive: () => void; selectedInspectionGiveDefinition: InspectionGiveDefinition | null; onSelectGiveVariable: (itemId: number) => void; availableInspectionDefinitions: InspectionGiveDefinition[]; @@ -67,8 +64,6 @@ export interface WiredInspectionTabViewProps export const WiredInspectionTabView = (props: WiredInspectionTabViewProps) => { const { - inspectionType, - onInspectionTypeChange, selectedFurni, selectedUser, roomId, @@ -84,8 +79,6 @@ export const WiredInspectionTabView = (props: WiredInspectionTabViewProps) => onCancelVariableEdit, onVariableInputKeyDown, onBeginVariableEdit, - isInspectionGiveOpen, - onToggleInspectionGive, selectedInspectionGiveDefinition, onSelectGiveVariable, availableInspectionDefinitions, @@ -97,6 +90,11 @@ export const WiredInspectionTabView = (props: WiredInspectionTabViewProps) => onRemoveInspectionVariable } = props; + const inspectionType = useWiredCreatorToolsUiStore(s => s.inspectionType); + const setInspectionType = useWiredCreatorToolsUiStore(s => s.setInspectionType); + const isInspectionGiveOpen = useWiredCreatorToolsUiStore(s => s.isInspectionGiveOpen); + const setIsInspectionGiveOpen = useWiredCreatorToolsUiStore(s => s.setIsInspectionGiveOpen); + return (
@@ -108,7 +106,7 @@ export const WiredInspectionTabView = (props: WiredInspectionTabViewProps) => key={ element.key } type="button" className={ `w-[42px] h-[38px] rounded border flex items-center justify-center shadow-[inset_0_1px_0_rgba(255,255,255,.7)] ${ (inspectionType === element.key) ? 'border-[#222] bg-[#d9d6cf]' : 'border-[#7f7f7f] bg-[#ece9e1]' }` } - onClick={ () => onInspectionTypeChange(element.key) } + onClick={ () => setInspectionType(element.key) } title={ element.label }> { @@ -225,7 +223,7 @@ export const WiredInspectionTabView = (props: WiredInspectionTabViewProps) =>
diff --git a/src/components/wired-tools/WiredVariablesTabView.tsx b/src/components/wired-tools/WiredVariablesTabView.tsx index 44fcd1b..2ca1ed9 100644 --- a/src/components/wired-tools/WiredVariablesTabView.tsx +++ b/src/components/wired-tools/WiredVariablesTabView.tsx @@ -1,12 +1,11 @@ import { FC } from 'react'; import { Button, Text } from '../../common'; import { VARIABLES_ELEMENTS } from './WiredCreatorTools.constants'; -import { VariableDefinition, VariablesElementType, VariableTextValue } from './WiredCreatorTools.types'; +import { VariableDefinition, VariableTextValue } from './WiredCreatorTools.types'; +import { useWiredCreatorToolsUiStore } from './wiredCreatorToolsUiStore'; export interface WiredVariablesTabViewProps { - variablesType: VariablesElementType; - onVariablesTypeChange: (next: VariablesElementType) => void; variablePickerDefinitions: VariableDefinition[]; selectedVariableDefinition: VariableDefinition | null; onPickVariable: (key: string) => void; @@ -27,8 +26,6 @@ export interface WiredVariablesTabViewProps * testable in isolation. */ export const WiredVariablesTabView: FC = ({ - variablesType, - onVariablesTypeChange, variablePickerDefinitions, selectedVariableDefinition, onPickVariable, @@ -40,7 +37,11 @@ export const WiredVariablesTabView: FC = ({ selectedVariableProperties, selectedVariableTextValues }) => - ( +{ + const variablesType = useWiredCreatorToolsUiStore(s => s.variablesType); + const setVariablesType = useWiredCreatorToolsUiStore(s => s.setVariablesType); + + return (
@@ -52,7 +53,7 @@ export const WiredVariablesTabView: FC = ({ type="button" className={ `w-[42px] h-[38px] rounded border flex items-center justify-center shadow-[inset_0_1px_0_rgba(255,255,255,.7)] ${ element.disabled ? 'border-[#b7b7b7] bg-[#e7e3da] opacity-60 cursor-not-allowed' : ((variablesType === element.key) ? 'border-[#222] bg-[#d9d6cf]' : 'border-[#7f7f7f] bg-[#ece9e1]') }` } disabled={ element.disabled } - onClick={ () => !element.disabled && onVariablesTypeChange(element.key) } + onClick={ () => !element.disabled && setVariablesType(element.key) } title={ element.label }> { @@ -148,3 +149,4 @@ export const WiredVariablesTabView: FC = ({
); +}; diff --git a/src/components/wired-tools/wiredCreatorToolsUiStore.ts b/src/components/wired-tools/wiredCreatorToolsUiStore.ts new file mode 100644 index 0000000..34aaf68 --- /dev/null +++ b/src/components/wired-tools/wiredCreatorToolsUiStore.ts @@ -0,0 +1,85 @@ +import { createNitroStore } from '../../state/createNitroStore'; +import { InspectionElementType, VariablesElementType, WiredToolsTab } from './WiredCreatorTools.types'; + +type MonitorSeverityFilter = 'ALL' | 'ERROR' | 'WARNING'; +type Updater = T | ((prev: T) => T); + +const apply = (prev: T, next: Updater): T => + ((typeof next === 'function') ? (next as (p: T) => T)(prev) : next); + +interface WiredCreatorToolsUiState +{ + isVisible: boolean; + activeTab: WiredToolsTab; + inspectionType: InspectionElementType; + variablesType: VariablesElementType; + + isMonitorHistoryOpen: boolean; + isMonitorInfoOpen: boolean; + isInspectionGiveOpen: boolean; + isVariableManageOpen: boolean; + isManagedGiveOpen: boolean; + + monitorHistorySeverityFilter: MonitorSeverityFilter; + monitorHistoryTypeFilter: string; + + variableManageTypeFilter: string; + variableManageSort: string; + variableManagePage: number; + + setIsVisible: (next: Updater) => void; + setActiveTab: (next: WiredToolsTab) => void; + setInspectionType: (next: InspectionElementType) => void; + setVariablesType: (next: VariablesElementType) => void; + + setIsMonitorHistoryOpen: (next: boolean) => void; + setIsMonitorInfoOpen: (next: boolean) => void; + setIsInspectionGiveOpen: (next: Updater) => void; + setIsVariableManageOpen: (next: boolean) => void; + setIsManagedGiveOpen: (next: Updater) => void; + + setMonitorHistorySeverityFilter: (next: MonitorSeverityFilter) => void; + setMonitorHistoryTypeFilter: (next: string) => void; + + setVariableManageTypeFilter: (next: string) => void; + setVariableManageSort: (next: string) => void; + setVariableManagePage: (next: Updater) => void; +} + +export const useWiredCreatorToolsUiStore = createNitroStore()((set) => ({ + isVisible: false, + activeTab: 'monitor', + inspectionType: 'furni', + variablesType: 'furni', + + isMonitorHistoryOpen: false, + isMonitorInfoOpen: false, + isInspectionGiveOpen: false, + isVariableManageOpen: false, + isManagedGiveOpen: false, + + monitorHistorySeverityFilter: 'ALL', + monitorHistoryTypeFilter: 'ALL', + + variableManageTypeFilter: 'ALL', + variableManageSort: 'highest_value', + variableManagePage: 1, + + setIsVisible: (next) => set(state => ({ isVisible: apply(state.isVisible, next) })), + setActiveTab: (next) => set({ activeTab: next }), + setInspectionType: (next) => set({ inspectionType: next }), + setVariablesType: (next) => set({ variablesType: next }), + + setIsMonitorHistoryOpen: (next) => set({ isMonitorHistoryOpen: next }), + setIsMonitorInfoOpen: (next) => set({ isMonitorInfoOpen: next }), + setIsInspectionGiveOpen: (next) => set(state => ({ isInspectionGiveOpen: apply(state.isInspectionGiveOpen, next) })), + setIsVariableManageOpen: (next) => set({ isVariableManageOpen: next }), + setIsManagedGiveOpen: (next) => set(state => ({ isManagedGiveOpen: apply(state.isManagedGiveOpen, next) })), + + setMonitorHistorySeverityFilter: (next) => set({ monitorHistorySeverityFilter: next }), + setMonitorHistoryTypeFilter: (next) => set({ monitorHistoryTypeFilter: next }), + + setVariableManageTypeFilter: (next) => set({ variableManageTypeFilter: next }), + setVariableManageSort: (next) => set({ variableManageSort: next }), + setVariableManagePage: (next) => set(state => ({ variableManagePage: apply(state.variableManagePage, next) })) +})); diff --git a/tests/wiredCreatorToolsUiStore.test.ts b/tests/wiredCreatorToolsUiStore.test.ts new file mode 100644 index 0000000..9618616 --- /dev/null +++ b/tests/wiredCreatorToolsUiStore.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useWiredCreatorToolsUiStore } from '../src/components/wired-tools/wiredCreatorToolsUiStore'; + +const INITIAL = { + isVisible: false, + activeTab: 'monitor' as const, + inspectionType: 'furni' as const, + variablesType: 'furni' as const, + isMonitorHistoryOpen: false, + isMonitorInfoOpen: false, + isInspectionGiveOpen: false, + isVariableManageOpen: false, + isManagedGiveOpen: false, + monitorHistorySeverityFilter: 'ALL' as const, + monitorHistoryTypeFilter: 'ALL', + variableManageTypeFilter: 'ALL', + variableManageSort: 'highest_value', + variableManagePage: 1 +}; + +describe('useWiredCreatorToolsUiStore', () => +{ + beforeEach(() => + { + useWiredCreatorToolsUiStore.setState(INITIAL); + }); + + it('exposes the documented defaults', () => + { + const state = useWiredCreatorToolsUiStore.getState(); + + expect(state.isVisible).toBe(false); + expect(state.activeTab).toBe('monitor'); + expect(state.inspectionType).toBe('furni'); + expect(state.variablesType).toBe('furni'); + expect(state.isMonitorHistoryOpen).toBe(false); + expect(state.isMonitorInfoOpen).toBe(false); + expect(state.isInspectionGiveOpen).toBe(false); + expect(state.isVariableManageOpen).toBe(false); + expect(state.isManagedGiveOpen).toBe(false); + expect(state.monitorHistorySeverityFilter).toBe('ALL'); + expect(state.monitorHistoryTypeFilter).toBe('ALL'); + expect(state.variableManageTypeFilter).toBe('ALL'); + expect(state.variableManageSort).toBe('highest_value'); + expect(state.variableManagePage).toBe(1); + }); + + describe('setIsVisible', () => + { + it('accepts a direct boolean', () => + { + useWiredCreatorToolsUiStore.getState().setIsVisible(true); + expect(useWiredCreatorToolsUiStore.getState().isVisible).toBe(true); + }); + + it('accepts a functional updater (toggle pattern)', () => + { + useWiredCreatorToolsUiStore.getState().setIsVisible(prev => !prev); + expect(useWiredCreatorToolsUiStore.getState().isVisible).toBe(true); + + useWiredCreatorToolsUiStore.getState().setIsVisible(prev => !prev); + expect(useWiredCreatorToolsUiStore.getState().isVisible).toBe(false); + }); + }); + + describe('setActiveTab', () => + { + it('switches the active tab', () => + { + useWiredCreatorToolsUiStore.getState().setActiveTab('variables'); + expect(useWiredCreatorToolsUiStore.getState().activeTab).toBe('variables'); + + useWiredCreatorToolsUiStore.getState().setActiveTab('inspection'); + expect(useWiredCreatorToolsUiStore.getState().activeTab).toBe('inspection'); + }); + }); + + describe('setInspectionType / setVariablesType', () => + { + it('updates the inspection element type', () => + { + useWiredCreatorToolsUiStore.getState().setInspectionType('user'); + expect(useWiredCreatorToolsUiStore.getState().inspectionType).toBe('user'); + }); + + it('updates the variables element type (including context)', () => + { + useWiredCreatorToolsUiStore.getState().setVariablesType('context'); + expect(useWiredCreatorToolsUiStore.getState().variablesType).toBe('context'); + }); + }); + + describe('modal/popover flags', () => + { + it('setIsMonitorHistoryOpen toggles the history modal flag', () => + { + useWiredCreatorToolsUiStore.getState().setIsMonitorHistoryOpen(true); + expect(useWiredCreatorToolsUiStore.getState().isMonitorHistoryOpen).toBe(true); + + useWiredCreatorToolsUiStore.getState().setIsMonitorHistoryOpen(false); + expect(useWiredCreatorToolsUiStore.getState().isMonitorHistoryOpen).toBe(false); + }); + + it('setIsMonitorInfoOpen toggles the info modal flag', () => + { + useWiredCreatorToolsUiStore.getState().setIsMonitorInfoOpen(true); + expect(useWiredCreatorToolsUiStore.getState().isMonitorInfoOpen).toBe(true); + }); + + it('setIsInspectionGiveOpen accepts a functional updater', () => + { + useWiredCreatorToolsUiStore.getState().setIsInspectionGiveOpen(prev => !prev); + expect(useWiredCreatorToolsUiStore.getState().isInspectionGiveOpen).toBe(true); + + useWiredCreatorToolsUiStore.getState().setIsInspectionGiveOpen(prev => !prev); + expect(useWiredCreatorToolsUiStore.getState().isInspectionGiveOpen).toBe(false); + }); + + it('setIsVariableManageOpen takes a direct boolean', () => + { + useWiredCreatorToolsUiStore.getState().setIsVariableManageOpen(true); + expect(useWiredCreatorToolsUiStore.getState().isVariableManageOpen).toBe(true); + }); + + it('setIsManagedGiveOpen accepts a functional updater', () => + { + useWiredCreatorToolsUiStore.getState().setIsManagedGiveOpen(prev => !prev); + expect(useWiredCreatorToolsUiStore.getState().isManagedGiveOpen).toBe(true); + }); + }); + + describe('monitor history filters', () => + { + it('setMonitorHistorySeverityFilter narrows to ERROR / WARNING / ALL', () => + { + useWiredCreatorToolsUiStore.getState().setMonitorHistorySeverityFilter('ERROR'); + expect(useWiredCreatorToolsUiStore.getState().monitorHistorySeverityFilter).toBe('ERROR'); + + useWiredCreatorToolsUiStore.getState().setMonitorHistorySeverityFilter('WARNING'); + expect(useWiredCreatorToolsUiStore.getState().monitorHistorySeverityFilter).toBe('WARNING'); + + useWiredCreatorToolsUiStore.getState().setMonitorHistorySeverityFilter('ALL'); + expect(useWiredCreatorToolsUiStore.getState().monitorHistorySeverityFilter).toBe('ALL'); + }); + + it('setMonitorHistoryTypeFilter stores an arbitrary type label', () => + { + useWiredCreatorToolsUiStore.getState().setMonitorHistoryTypeFilter('FurnitureRuntime'); + expect(useWiredCreatorToolsUiStore.getState().monitorHistoryTypeFilter).toBe('FurnitureRuntime'); + }); + }); + + describe('variable manage UI', () => + { + it('setVariableManageTypeFilter / setVariableManageSort store string filters', () => + { + useWiredCreatorToolsUiStore.getState().setVariableManageTypeFilter('Number'); + useWiredCreatorToolsUiStore.getState().setVariableManageSort('lowest_value'); + + expect(useWiredCreatorToolsUiStore.getState().variableManageTypeFilter).toBe('Number'); + expect(useWiredCreatorToolsUiStore.getState().variableManageSort).toBe('lowest_value'); + }); + + it('setVariableManagePage accepts a direct value', () => + { + useWiredCreatorToolsUiStore.getState().setVariableManagePage(4); + expect(useWiredCreatorToolsUiStore.getState().variableManagePage).toBe(4); + }); + + it('setVariableManagePage accepts a functional updater (next/prev pagination)', () => + { + useWiredCreatorToolsUiStore.getState().setVariableManagePage(2); + useWiredCreatorToolsUiStore.getState().setVariableManagePage(prev => prev + 1); + expect(useWiredCreatorToolsUiStore.getState().variableManagePage).toBe(3); + + useWiredCreatorToolsUiStore.getState().setVariableManagePage(prev => Math.max(1, prev - 1)); + expect(useWiredCreatorToolsUiStore.getState().variableManagePage).toBe(2); + }); + }); +}); From eb8d87969df408485f564c09b73490770fe5b40d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 16 May 2026 11:22:50 +0200 Subject: [PATCH 087/129] docs(claude): record wiredCreatorToolsUiStore adoption + new test count Zustand row in 'Adopted' now lists both store adoptions; the 'Not yet' row reframes the Wired Creator Tools follow-up as 'hoist the *derived* event-driven state' since the UI flags are now done. Vitest count bumped to 178/178 and the second store suite is mentioned. --- CLAUDE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e9ab4ca..9f11331 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -211,7 +211,7 @@ Components subscribe to slices, not the whole store: const value = useFooStore(s => s.value); ``` -First adoption: `src/components/navigator/views/navigatorRoomCreatorStore.ts`. +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` @@ -257,11 +257,11 @@ into `configurePreviewServer` so `yarn preview` keeps working. |---|---| | `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`) | +| Zustand | `NavigatorRoomCreatorView` (`useRoomCreatorStore`), `WiredCreatorToolsView` (`useWiredCreatorToolsUiStore` — 14 UI-only flags: tab nav, modal/popover open, monitor + variable-manage filters) | | 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 | 162/162 cases — pure helpers + Zustand store + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `tests/mocks/renderer-mock.ts`, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters | +| Vitest | 178/178 cases — pure helpers + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore`) + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `tests/mocks/renderer-mock.ts`, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters | | Form Actions | Login / Register / Forgot (LoginView.tsx) | | Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating | @@ -269,7 +269,7 @@ into `configurePreviewServer` so `yarn preview` keeps working. |---|---| | Split `useChatWidget` / `useAvatarInfoWidget` | Both state-driven via events with no clean imperative actions to extract — skip-motivated. Already touched today for the InfoStand listener move. | | Split `usePetPackageWidget` / `useWordQuizWidget` / `useChatCommandSelector` | Their "actions" mutate internal state or are tightly interdependent — skip-motivated. | -| Hoist Wired Creator Tools shared state to a Zustand slice | Would remove ~25 props passed to the 3 tab sub-components. (Wired-tools split done as singleton-filter; Zustand slice is the next step.) | +| Hoist Wired Creator Tools **derived** state to the Zustand slice | UI-only flags are already hoisted (`useWiredCreatorToolsUiStore`). What's left is the event-driven derived state — `selectedFurni` / `selectedUser` / `monitorSnapshot` / `variableHighlightOverlays` — which can only move alongside their listener effects (multi-session refactor). | | Widen the component / hook test coverage | Mock layer is in place (`tests/mocks/renderer-mock.ts`) and the first 2 pilots pass. Good follow-up targets: other `*State` hooks built on event reducers, `LoginView` Form Actions happy/error paths, OfferView with `useNitroQuery`. | ## Known open logic bugs @@ -298,7 +298,7 @@ Fix shapes documented; both are reasonable PRs on their own. - **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 test` must stay green** on every commit. Currently 163/163. +- **`yarn test` must stay green** on every commit. Currently 178/178. The GitHub Actions workflow at `.github/workflows/ci.yml` runs `yarn typecheck` + `yarn test --run` on every push to `main` / `feat/**` and on every PR — both must pass. From 8b4308af16c337c8cc5861f823d79b8e79504848 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 16 May 2026 11:35:03 +0200 Subject: [PATCH 088/129] tests: co-locate every Vitest suite next to its subject under src/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminate the parallel `tests/` tree. Each `*.test.ts` / `*.test.tsx` now sits in the same directory as the module it covers, mirroring its filename (`Foo.ts` ↔ `Foo.test.ts`). The renderer-SDK mock used by component / hook tests moves to `src/__mocks__/nitro-renderer.ts` and the Vitest setup file becomes `src/test-setup.ts` — both still wired through `vitest.config.mts` exactly as before, only the paths changed. All 13 suites + 178/178 cases still pass. The production build is unaffected: rollup only follows imports from `src/index.tsx` and never crosses into `.test.ts` files, so test code is naturally tree-shaken out of the bundle. `yarn build` output is byte-for-byte the same on the user-facing chunks. tsconfig drops the now-redundant `tests` include entry. CLAUDE.md 'Layout convention' replaces the old `tests/` row with three rows documenting the new co-located convention, the `__mocks__/` directory and the `test-setup.ts` entry; ARCHITECTURE.md picks up the same update. The 'DO NOT CHANGE' qualifier on the layout is preserved — this rewrite IS the change, decided deliberately to make tests a first-class part of the source tree rather than a sibling project. --- CLAUDE.md | 13 ++++++++----- docs/ARCHITECTURE.md | 8 ++++---- .../__mocks__/nitro-renderer.ts | 0 {tests => src/api/avatar}/dedupeBadges.test.ts | 2 +- {tests => src/api/utils}/api-utils-extra.test.ts | 6 +++--- {tests => src/api/utils}/api-utils.test.ts | 12 ++++++------ {tests => src/api/utils}/friendly-time.test.ts | 4 ++-- .../error-boundary}/WidgetErrorBoundary.test.tsx | 4 ++-- .../views}/navigatorRoomCreatorStore.test.ts | 2 +- .../WiredCreatorTools.helpers.test.ts | 2 +- .../wired-tools}/wiredCreatorToolsUiStore.test.ts | 2 +- .../hooks/catalog}/useCatalog.filters.test.tsx | 2 +- .../hooks/catalog}/useCatalog.helpers.test.ts | 6 +++--- .../catalog/useCatalogFavorites.helpers.test.ts | 4 ++-- .../rooms/widgets/avatarInfo.reducers.test.ts | 6 +++--- .../rooms/widgets}/useDoorbellState.test.tsx | 4 ++-- tests/setup.ts => src/test-setup.ts | 0 tsconfig.json | 1 - vitest.config.mts | 15 +++++++-------- 19 files changed, 47 insertions(+), 46 deletions(-) rename tests/mocks/renderer-mock.ts => src/__mocks__/nitro-renderer.ts (100%) rename {tests => src/api/avatar}/dedupeBadges.test.ts (95%) rename {tests => src/api/utils}/api-utils-extra.test.ts (96%) rename {tests => src/api/utils}/api-utils.test.ts (93%) rename {tests => src/api/utils}/friendly-time.test.ts (96%) rename {tests => src/common/error-boundary}/WidgetErrorBoundary.test.tsx (94%) rename {tests => src/components/navigator/views}/navigatorRoomCreatorStore.test.ts (94%) rename {tests => src/components/wired-tools}/WiredCreatorTools.helpers.test.ts (98%) rename {tests => src/components/wired-tools}/wiredCreatorToolsUiStore.test.ts (98%) rename {tests => src/hooks/catalog}/useCatalog.filters.test.tsx (99%) rename {tests => src/hooks/catalog}/useCatalog.helpers.test.ts (98%) rename tests/catalog-favorites.helpers.test.ts => src/hooks/catalog/useCatalogFavorites.helpers.test.ts (96%) rename tests/avatar-info-reducers.test.ts => src/hooks/rooms/widgets/avatarInfo.reducers.test.ts (97%) rename {tests => src/hooks/rooms/widgets}/useDoorbellState.test.tsx (97%) rename tests/setup.ts => src/test-setup.ts (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 9f11331..61edd80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,7 +106,9 @@ src/hooks/// → hooks, FLAT files, no per-feature s src/api/ → cross-cutting helpers (LocalizeText, composers, formatters) src/common/ → reusable UI primitives + error boundary src/state/ → Zustand stores (cross-feature only) -tests/ → Vitest suites (mirror filename of subject) +src/**/*.test.{ts,tsx} → Vitest suites co-located next to their subject (e.g. `Foo.ts` + `Foo.test.ts`) +src/__mocks__/ → 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 @@ -261,7 +263,7 @@ into `configurePreviewServer` so `yarn preview` keeps working. | 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 | 178/178 cases — pure helpers + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore`) + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `tests/mocks/renderer-mock.ts`, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters | +| Vitest | 178/178 cases — pure helpers + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore`) + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `src/__mocks__/nitro-renderer.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) | | Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating | @@ -270,7 +272,7 @@ into `configurePreviewServer` so `yarn preview` keeps working. | Split `useChatWidget` / `useAvatarInfoWidget` | Both state-driven via events with no clean imperative actions to extract — skip-motivated. Already touched today for the InfoStand listener move. | | Split `usePetPackageWidget` / `useWordQuizWidget` / `useChatCommandSelector` | Their "actions" mutate internal state or are tightly interdependent — skip-motivated. | | Hoist Wired Creator Tools **derived** state to the Zustand slice | UI-only flags are already hoisted (`useWiredCreatorToolsUiStore`). What's left is the event-driven derived state — `selectedFurni` / `selectedUser` / `monitorSnapshot` / `variableHighlightOverlays` — which can only move alongside their listener effects (multi-session refactor). | -| Widen the component / hook test coverage | Mock layer is in place (`tests/mocks/renderer-mock.ts`) and the first 2 pilots pass. Good follow-up targets: other `*State` hooks built on event reducers, `LoginView` Form Actions happy/error paths, OfferView with `useNitroQuery`. | +| Widen the component / hook test coverage | Mock layer is in place (`src/__mocks__/nitro-renderer.ts`) and the first 2 pilots pass. Good follow-up targets: other `*State` hooks built on event reducers, `LoginView` Form Actions happy/error paths, OfferView with `useNitroQuery`. | ## Known open logic bugs @@ -310,7 +312,8 @@ Fix shapes documented; both are reasonable PRs on their own. - Architecture doc: `docs/ARCHITECTURE.md` - Test runner config: `vitest.config.mts` (separate from `vite.config.mjs`) -- Test setup: `tests/setup.ts` +- Test setup: `src/test-setup.ts` +- Test convention: co-located under `src/` next to the subject (`src//Foo.ts` ↔ `src//Foo.test.ts`). No separate `tests/` tree. - React Query adapter: `src/api/nitro-query/createNitroQuery.ts` - Zustand factory: `src/state/createNitroStore.ts` - Error boundary: `src/common/error-boundary/WidgetErrorBoundary.tsx` @@ -333,7 +336,7 @@ Fix shapes documented; both are reasonable PRs on their own. `useCatalogUiState` / `useCatalogActions` in `src/hooks/catalog/useCatalog.ts` (all 48 consumers migrated; deprecated `useCatalog` shim removed) -- Renderer-SDK mock for Vitest: `tests/mocks/renderer-mock.ts` +- Renderer-SDK mock for Vitest: `src/__mocks__/nitro-renderer.ts` (aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`). Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` / `clearMockEventDispatcher` helpers used by hook tests, the diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a305cea..94e2f9d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -528,7 +528,7 @@ Pure helpers in `useCatalog.helpers.ts`: visitors) and passes the resulting `visitorCount` into the helper. `useCatalog.ts` now imports these instead of defining them inline -(net **−75 LOC**). Test file `tests/useCatalog.helpers.test.ts` covers +(net **−75 LOC**). Co-located test file `src/hooks/catalog/useCatalog.helpers.test.ts` covers all six helpers with 34 cases (tree depth + offerId mapping, node lookups including root exclusion, the limit-reached / guild-admin fallback / visitors-in-room paths of the placement helper, and the @@ -538,7 +538,7 @@ empty-map / partial-bucket branches of the offer lookup). - Vitest 3 + jsdom + `@testing-library/react` + `@testing-library/jest-dom` configured. Separate `vitest.config.mts` so the runner doesn't drag in the renderer SDK aliases from `vite.config.mjs`. -- **163 cases passing** across 12 test files. Pure-module suites: +- **178 cases passing** across 13 test files, **co-located under `src/`** next to each subject (no separate `tests/` tree). Pure-module suites: - `WiredCreatorTools.helpers.test.ts` (18) — formatters + snapshot factory. - `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants @@ -580,7 +580,7 @@ empty-map / partial-bucket branches of the offer lookup). `DOORBELL`, dedup duplicates, remove on `RSDE_ACCEPTED` / `RSDE_REJECTED`, ignore stale events, unsubscribe on unmount. -- **Renderer-SDK mock at `tests/mocks/renderer-mock.ts`** — +- **Renderer-SDK mock at `src/__mocks__/nitro-renderer.ts`** — `vitest.config.mts` aliases `@nitrots/nitro-renderer` over this file so jsdom-hosted tests never load Pixi or the message parser/composer registry. The mock exports: @@ -734,7 +734,7 @@ Remaining order of value/risk for the next contributor: each tab. A slice at `src/components/wired-tools/wiredToolsStore.ts` would make each tab subscribe to the keys it needs. 4. **Widen the component/hook Vitest coverage.** The renderer-SDK - mock layer is in place (`tests/mocks/renderer-mock.ts`) and the + mock layer is in place (`src/__mocks__/nitro-renderer.ts`) and the first two pilots — `WidgetErrorBoundary` and `useDoorbellState` — pass. Good follow-up targets: other `*State` hooks built on event reducers (`useFurniChooserState`, `useUserChooserState`, diff --git a/tests/mocks/renderer-mock.ts b/src/__mocks__/nitro-renderer.ts similarity index 100% rename from tests/mocks/renderer-mock.ts rename to src/__mocks__/nitro-renderer.ts diff --git a/tests/dedupeBadges.test.ts b/src/api/avatar/dedupeBadges.test.ts similarity index 95% rename from tests/dedupeBadges.test.ts rename to src/api/avatar/dedupeBadges.test.ts index 103db9d..33067a1 100644 --- a/tests/dedupeBadges.test.ts +++ b/src/api/avatar/dedupeBadges.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { dedupeBadges } from '../src/api/avatar/dedupeBadges'; +import { dedupeBadges } from './dedupeBadges'; describe('dedupeBadges', () => { diff --git a/tests/api-utils-extra.test.ts b/src/api/utils/api-utils-extra.test.ts similarity index 96% rename from tests/api-utils-extra.test.ts rename to src/api/utils/api-utils-extra.test.ts index afd2d77..9fb5205 100644 --- a/tests/api-utils-extra.test.ts +++ b/src/api/utils/api-utils-extra.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { ColorUtils } from '../src/api/utils/ColorUtils'; -import { FixedSizeStack } from '../src/api/utils/FixedSizeStack'; -import { LocalizeFormattedNumber } from '../src/api/utils/LocalizeFormattedNumber'; +import { ColorUtils } from './ColorUtils'; +import { FixedSizeStack } from './FixedSizeStack'; +import { LocalizeFormattedNumber } from './LocalizeFormattedNumber'; describe('LocalizeFormattedNumber', () => { diff --git a/tests/api-utils.test.ts b/src/api/utils/api-utils.test.ts similarity index 93% rename from tests/api-utils.test.ts rename to src/api/utils/api-utils.test.ts index 816f019..d200be3 100644 --- a/tests/api-utils.test.ts +++ b/src/api/utils/api-utils.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { CloneObject } from '../src/api/utils/CloneObject'; -import { ConvertSeconds } from '../src/api/utils/ConvertSeconds'; -import { LocalizeShortNumber } from '../src/api/utils/LocalizeShortNumber'; -import { GetWiredTimeLocale } from '../src/api/wired/GetWiredTimeLocale'; -import { WiredDateToString } from '../src/api/wired/WiredDateToString'; -import { getPrefixFontStyle, parsePrefixColors, PRESET_PREFIX_FONTS } from '../src/api/utils/PrefixUtils'; +import { CloneObject } from './CloneObject'; +import { ConvertSeconds } from './ConvertSeconds'; +import { LocalizeShortNumber } from './LocalizeShortNumber'; +import { GetWiredTimeLocale } from '../wired/GetWiredTimeLocale'; +import { WiredDateToString } from '../wired/WiredDateToString'; +import { getPrefixFontStyle, parsePrefixColors, PRESET_PREFIX_FONTS } from './PrefixUtils'; describe('ConvertSeconds', () => { diff --git a/tests/friendly-time.test.ts b/src/api/utils/friendly-time.test.ts similarity index 96% rename from tests/friendly-time.test.ts rename to src/api/utils/friendly-time.test.ts index 3753278..10e7e30 100644 --- a/tests/friendly-time.test.ts +++ b/src/api/utils/friendly-time.test.ts @@ -5,12 +5,12 @@ import { describe, expect, it, vi } from 'vitest'; * with a deterministic stub. The stub returns `key|amount` so each test * can assert both the bucket FriendlyTime chose AND the value it computed. */ -vi.mock('../src/api/utils/LocalizeText', () => ({ +vi.mock('./LocalizeText', () => ({ LocalizeText: (key: string, _params?: string[], replacements?: string[]) => `${ key }|${ replacements?.[0] ?? '' }` })); -import { FriendlyTime } from '../src/api/utils/FriendlyTime'; +import { FriendlyTime } from './FriendlyTime'; const MINUTE = 60; const HOUR = 60 * MINUTE; diff --git a/tests/WidgetErrorBoundary.test.tsx b/src/common/error-boundary/WidgetErrorBoundary.test.tsx similarity index 94% rename from tests/WidgetErrorBoundary.test.tsx rename to src/common/error-boundary/WidgetErrorBoundary.test.tsx index 3d6b4a9..4e76971 100644 --- a/tests/WidgetErrorBoundary.test.tsx +++ b/src/common/error-boundary/WidgetErrorBoundary.test.tsx @@ -4,10 +4,10 @@ import { NitroLogger } from '@nitrots/nitro-renderer'; import { cleanup, render, screen } from '@testing-library/react'; import { FC } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { WidgetErrorBoundary } from '../src/common/error-boundary/WidgetErrorBoundary'; +import { WidgetErrorBoundary } from './WidgetErrorBoundary'; // `import { NitroLogger } from '@nitrots/nitro-renderer'` resolves to -// `tests/mocks/renderer-mock.ts` via the alias in vitest.config.mts. +// `src/__mocks__/nitro-renderer.ts` via the alias in vitest.config.mts. // The SUT imports the same path, so both reach the same vi.fn instance. describe('WidgetErrorBoundary', () => diff --git a/tests/navigatorRoomCreatorStore.test.ts b/src/components/navigator/views/navigatorRoomCreatorStore.test.ts similarity index 94% rename from tests/navigatorRoomCreatorStore.test.ts rename to src/components/navigator/views/navigatorRoomCreatorStore.test.ts index e2747c6..be2079d 100644 --- a/tests/navigatorRoomCreatorStore.test.ts +++ b/src/components/navigator/views/navigatorRoomCreatorStore.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useRoomCreatorStore } from '../src/components/navigator/views/navigatorRoomCreatorStore'; +import { useRoomCreatorStore } from './navigatorRoomCreatorStore'; describe('useRoomCreatorStore', () => { diff --git a/tests/WiredCreatorTools.helpers.test.ts b/src/components/wired-tools/WiredCreatorTools.helpers.test.ts similarity index 98% rename from tests/WiredCreatorTools.helpers.test.ts rename to src/components/wired-tools/WiredCreatorTools.helpers.test.ts index 73ce4ae..8698d6f 100644 --- a/tests/WiredCreatorTools.helpers.test.ts +++ b/src/components/wired-tools/WiredCreatorTools.helpers.test.ts @@ -6,7 +6,7 @@ import { formatMonitorSource, formatVariableTimestamp, normalizeMonitorReason -} from '../src/components/wired-tools/WiredCreatorTools.helpers'; +} from './WiredCreatorTools.helpers'; describe('WiredCreatorTools helpers', () => { diff --git a/tests/wiredCreatorToolsUiStore.test.ts b/src/components/wired-tools/wiredCreatorToolsUiStore.test.ts similarity index 98% rename from tests/wiredCreatorToolsUiStore.test.ts rename to src/components/wired-tools/wiredCreatorToolsUiStore.test.ts index 9618616..1efccae 100644 --- a/tests/wiredCreatorToolsUiStore.test.ts +++ b/src/components/wired-tools/wiredCreatorToolsUiStore.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { useWiredCreatorToolsUiStore } from '../src/components/wired-tools/wiredCreatorToolsUiStore'; +import { useWiredCreatorToolsUiStore } from './wiredCreatorToolsUiStore'; const INITIAL = { isVisible: false, diff --git a/tests/useCatalog.filters.test.tsx b/src/hooks/catalog/useCatalog.filters.test.tsx similarity index 99% rename from tests/useCatalog.filters.test.tsx rename to src/hooks/catalog/useCatalog.filters.test.tsx index 4164e1c..59b6446 100644 --- a/tests/useCatalog.filters.test.tsx +++ b/src/hooks/catalog/useCatalog.filters.test.tsx @@ -75,7 +75,7 @@ vi.mock('use-between', () => ({ // Import AFTER the mock is set up. The hooks resolve `useBetween` at // import time via the module graph, so the order matters. -import { useCatalogActions, useCatalogData, useCatalogUiState } from '../src/hooks/catalog/useCatalog'; +import { useCatalogActions, useCatalogData, useCatalogUiState } from './useCatalog'; describe('useCatalog filter contract', () => { diff --git a/tests/useCatalog.helpers.test.ts b/src/hooks/catalog/useCatalog.helpers.test.ts similarity index 98% rename from tests/useCatalog.helpers.test.ts rename to src/hooks/catalog/useCatalog.helpers.test.ts index 5a64fba..d3bd696 100644 --- a/tests/useCatalog.helpers.test.ts +++ b/src/hooks/catalog/useCatalog.helpers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { BuilderFurniPlaceableStatus } from '../src/api/catalog/BuilderFurniPlaceableStatus'; -import { CatalogType } from '../src/api/catalog/CatalogType'; +import { BuilderFurniPlaceableStatus } from '../../api/catalog/BuilderFurniPlaceableStatus'; +import { CatalogType } from '../../api/catalog/CatalogType'; import { buildCatalogNodeTree, findNodeById, @@ -9,7 +9,7 @@ import { getOfferProductKeys, normalizeCatalogType, resolveBuilderFurniPlaceableStatus -} from '../src/hooks/catalog/useCatalog.helpers'; +} from './useCatalog.helpers'; // --------------------------------------------------------------------------- // normalizeCatalogType diff --git a/tests/catalog-favorites.helpers.test.ts b/src/hooks/catalog/useCatalogFavorites.helpers.test.ts similarity index 96% rename from tests/catalog-favorites.helpers.test.ts rename to src/hooks/catalog/useCatalogFavorites.helpers.test.ts index db2603e..9f8c319 100644 --- a/tests/catalog-favorites.helpers.test.ts +++ b/src/hooks/catalog/useCatalogFavorites.helpers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { CatalogType } from '../src/api/catalog/CatalogType'; -import { getOffersStorageKey, getPagesStorageKey, normalizeCatalogType, parseOffers, parsePages, STORAGE_KEY_OFFERS_BUILDER, STORAGE_KEY_OFFERS_NORMAL, STORAGE_KEY_PAGES_BUILDER, STORAGE_KEY_PAGES_NORMAL } from '../src/hooks/catalog/useCatalogFavorites.helpers'; +import { CatalogType } from '../../api/catalog/CatalogType'; +import { getOffersStorageKey, getPagesStorageKey, normalizeCatalogType, parseOffers, parsePages, STORAGE_KEY_OFFERS_BUILDER, STORAGE_KEY_OFFERS_NORMAL, STORAGE_KEY_PAGES_BUILDER, STORAGE_KEY_PAGES_NORMAL } from './useCatalogFavorites.helpers'; describe('normalizeCatalogType', () => { diff --git a/tests/avatar-info-reducers.test.ts b/src/hooks/rooms/widgets/avatarInfo.reducers.test.ts similarity index 97% rename from tests/avatar-info-reducers.test.ts rename to src/hooks/rooms/widgets/avatarInfo.reducers.test.ts index bdaffa0..24411c1 100644 --- a/tests/avatar-info-reducers.test.ts +++ b/src/hooks/rooms/widgets/avatarInfo.reducers.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { AvatarInfoUser } from '../src/api/room/widgets/AvatarInfoUser'; -import type { IAvatarInfo } from '../src/api/room/widgets/IAvatarInfo'; -import { applyFavouriteGroupUpdate, applyUserBadgesUpdate, applyUserFigureUpdate } from '../src/hooks/rooms/widgets/avatarInfo.reducers'; +import { AvatarInfoUser } from '../../../api/room/widgets/AvatarInfoUser'; +import type { IAvatarInfo } from '../../../api/room/widgets/IAvatarInfo'; +import { applyFavouriteGroupUpdate, applyUserBadgesUpdate, applyUserFigureUpdate } from './avatarInfo.reducers'; /** * Pure reducers for the InfoStand pilot. They take the inspected diff --git a/tests/useDoorbellState.test.tsx b/src/hooks/rooms/widgets/useDoorbellState.test.tsx similarity index 97% rename from tests/useDoorbellState.test.tsx rename to src/hooks/rooms/widgets/useDoorbellState.test.tsx index d04be03..407088d 100644 --- a/tests/useDoorbellState.test.tsx +++ b/src/hooks/rooms/widgets/useDoorbellState.test.tsx @@ -3,8 +3,8 @@ import { RoomSessionDoorbellEvent } from '@nitrots/nitro-renderer'; import { act, cleanup, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { useDoorbellState } from '../src/hooks/rooms/widgets/useDoorbellState'; -import { clearMockEventDispatcher, mockEventDispatcher } from './mocks/renderer-mock'; +import { useDoorbellState } from './useDoorbellState'; +import { clearMockEventDispatcher, mockEventDispatcher } from '../../../__mocks__/nitro-renderer'; // Server push helper — mirrors the renderer wire by emitting the same // constants the SUT listens to. The real constructor takes a session diff --git a/tests/setup.ts b/src/test-setup.ts similarity index 100% rename from tests/setup.ts rename to src/test-setup.ts diff --git a/tsconfig.json b/tsconfig.json index e89ab76..04ef8b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,7 +30,6 @@ }, "include": [ "src", - "tests", "node_modules/@nitrots/nitro-renderer/src/**/*.ts" ] } diff --git a/vitest.config.mts b/vitest.config.mts index 7c217c6..59c4208 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -6,23 +6,22 @@ import { resolve } from 'path'; * dev/build config wires up the renderer SDK via filesystem aliases that * point at sibling working trees (`../renderer`, `../Nitro_Render_V3`). * - * Test files were originally written against pure modules (helpers, - * stores) that don't pull in the renderer. We now also support - * component-level tests by aliasing `@nitrots/nitro-renderer` to a - * hand-written stub at `tests/mocks/renderer-mock.ts` so jsdom doesn't - * try to evaluate Pixi + the full message parser/composer registry. + * Tests live next to their subject under `src/` (`foo.ts` + `foo.test.ts`). + * The renderer SDK is aliased to a hand-written stub at + * `src/__mocks__/nitro-renderer.ts` so jsdom doesn't try to evaluate + * Pixi + the full message parser/composer registry at import time. */ export default defineConfig({ test: { environment: 'jsdom', globals: false, - include: [ 'tests/**/*.test.ts', 'tests/**/*.test.tsx' ], - setupFiles: [ './tests/setup.ts' ], + include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx' ], + setupFiles: [ './src/test-setup.ts' ], css: false }, resolve: { alias: { - '@nitrots/nitro-renderer': resolve(__dirname, 'tests/mocks/renderer-mock.ts'), + '@nitrots/nitro-renderer': resolve(__dirname, 'src/__mocks__/nitro-renderer.ts'), '@': resolve(__dirname, 'src') } } From 803de20dfe9618f64a3424f2d4a5e4839ecc930b Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 16 May 2026 11:37:33 +0200 Subject: [PATCH 089/129] tests: flatten renderer mock to src/nitro-renderer.mock.ts (drop __mocks__/) The Jest-style __mocks__/ folder added one indirection for a single file. Move the stub to src/nitro-renderer.mock.ts at src/ root next to test-setup.ts, drop the folder, repoint the vitest alias, and update the lone test that imports the helpers directly (useDoorbellState). Same behaviour, one fewer directory. --- CLAUDE.md | 8 ++++---- docs/ARCHITECTURE.md | 4 ++-- src/common/error-boundary/WidgetErrorBoundary.test.tsx | 2 +- src/hooks/rooms/widgets/useDoorbellState.test.tsx | 2 +- .../nitro-renderer.ts => nitro-renderer.mock.ts} | 0 vitest.config.mts | 6 +++--- 6 files changed, 11 insertions(+), 11 deletions(-) rename src/{__mocks__/nitro-renderer.ts => nitro-renderer.mock.ts} (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 61edd80..f184187 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,7 +107,7 @@ src/api/ → cross-cutting helpers (LocalizeText 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/__mocks__/ → hand-written renderer-SDK stub for tests (aliased over `@nitrots/nitro-renderer`) +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.) ``` @@ -263,7 +263,7 @@ into `configurePreviewServer` so `yarn preview` keeps working. | 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 | 178/178 cases — pure helpers + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore`) + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `src/__mocks__/nitro-renderer.ts`, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters. **Tests are co-located** under `src/`, alongside their subject. | +| Vitest | 178/178 cases — pure helpers + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore`) + 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) | | Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating | @@ -272,7 +272,7 @@ into `configurePreviewServer` so `yarn preview` keeps working. | Split `useChatWidget` / `useAvatarInfoWidget` | Both state-driven via events with no clean imperative actions to extract — skip-motivated. Already touched today for the InfoStand listener move. | | Split `usePetPackageWidget` / `useWordQuizWidget` / `useChatCommandSelector` | Their "actions" mutate internal state or are tightly interdependent — skip-motivated. | | Hoist Wired Creator Tools **derived** state to the Zustand slice | UI-only flags are already hoisted (`useWiredCreatorToolsUiStore`). What's left is the event-driven derived state — `selectedFurni` / `selectedUser` / `monitorSnapshot` / `variableHighlightOverlays` — which can only move alongside their listener effects (multi-session refactor). | -| Widen the component / hook test coverage | Mock layer is in place (`src/__mocks__/nitro-renderer.ts`) and the first 2 pilots pass. Good follow-up targets: other `*State` hooks built on event reducers, `LoginView` Form Actions happy/error paths, OfferView with `useNitroQuery`. | +| Widen the component / hook test coverage | Mock layer is in place (`src/nitro-renderer.mock.ts`) and the first 2 pilots pass. Good follow-up targets: other `*State` hooks built on event reducers, `LoginView` Form Actions happy/error paths, OfferView with `useNitroQuery`. | ## Known open logic bugs @@ -336,7 +336,7 @@ Fix shapes documented; both are reasonable PRs on their own. `useCatalogUiState` / `useCatalogActions` in `src/hooks/catalog/useCatalog.ts` (all 48 consumers migrated; deprecated `useCatalog` shim removed) -- Renderer-SDK mock for Vitest: `src/__mocks__/nitro-renderer.ts` +- Renderer-SDK mock for Vitest: `src/nitro-renderer.mock.ts` (aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`). Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` / `clearMockEventDispatcher` helpers used by hook tests, the diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 94e2f9d..fccb1be 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -580,7 +580,7 @@ empty-map / partial-bucket branches of the offer lookup). `DOORBELL`, dedup duplicates, remove on `RSDE_ACCEPTED` / `RSDE_REJECTED`, ignore stale events, unsubscribe on unmount. -- **Renderer-SDK mock at `src/__mocks__/nitro-renderer.ts`** — +- **Renderer-SDK mock at `src/nitro-renderer.mock.ts`** — `vitest.config.mts` aliases `@nitrots/nitro-renderer` over this file so jsdom-hosted tests never load Pixi or the message parser/composer registry. The mock exports: @@ -734,7 +734,7 @@ Remaining order of value/risk for the next contributor: each tab. A slice at `src/components/wired-tools/wiredToolsStore.ts` would make each tab subscribe to the keys it needs. 4. **Widen the component/hook Vitest coverage.** The renderer-SDK - mock layer is in place (`src/__mocks__/nitro-renderer.ts`) and the + mock layer is in place (`src/nitro-renderer.mock.ts`) and the first two pilots — `WidgetErrorBoundary` and `useDoorbellState` — pass. Good follow-up targets: other `*State` hooks built on event reducers (`useFurniChooserState`, `useUserChooserState`, diff --git a/src/common/error-boundary/WidgetErrorBoundary.test.tsx b/src/common/error-boundary/WidgetErrorBoundary.test.tsx index 4e76971..03725a6 100644 --- a/src/common/error-boundary/WidgetErrorBoundary.test.tsx +++ b/src/common/error-boundary/WidgetErrorBoundary.test.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { WidgetErrorBoundary } from './WidgetErrorBoundary'; // `import { NitroLogger } from '@nitrots/nitro-renderer'` resolves to -// `src/__mocks__/nitro-renderer.ts` via the alias in vitest.config.mts. +// `src/nitro-renderer.mock.ts` via the alias in vitest.config.mts. // The SUT imports the same path, so both reach the same vi.fn instance. describe('WidgetErrorBoundary', () => diff --git a/src/hooks/rooms/widgets/useDoorbellState.test.tsx b/src/hooks/rooms/widgets/useDoorbellState.test.tsx index 407088d..9335dff 100644 --- a/src/hooks/rooms/widgets/useDoorbellState.test.tsx +++ b/src/hooks/rooms/widgets/useDoorbellState.test.tsx @@ -4,7 +4,7 @@ import { RoomSessionDoorbellEvent } from '@nitrots/nitro-renderer'; import { act, cleanup, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { useDoorbellState } from './useDoorbellState'; -import { clearMockEventDispatcher, mockEventDispatcher } from '../../../__mocks__/nitro-renderer'; +import { clearMockEventDispatcher, mockEventDispatcher } from '../../../nitro-renderer.mock'; // Server push helper — mirrors the renderer wire by emitting the same // constants the SUT listens to. The real constructor takes a session diff --git a/src/__mocks__/nitro-renderer.ts b/src/nitro-renderer.mock.ts similarity index 100% rename from src/__mocks__/nitro-renderer.ts rename to src/nitro-renderer.mock.ts diff --git a/vitest.config.mts b/vitest.config.mts index 59c4208..428021f 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -8,8 +8,8 @@ import { resolve } from 'path'; * * Tests live next to their subject under `src/` (`foo.ts` + `foo.test.ts`). * The renderer SDK is aliased to a hand-written stub at - * `src/__mocks__/nitro-renderer.ts` so jsdom doesn't try to evaluate - * Pixi + the full message parser/composer registry at import time. + * `src/nitro-renderer.mock.ts` so jsdom doesn't try to evaluate Pixi + + * the full message parser/composer registry at import time. */ export default defineConfig({ test: { @@ -21,7 +21,7 @@ export default defineConfig({ }, resolve: { alias: { - '@nitrots/nitro-renderer': resolve(__dirname, 'src/__mocks__/nitro-renderer.ts'), + '@nitrots/nitro-renderer': resolve(__dirname, 'src/nitro-renderer.mock.ts'), '@': resolve(__dirname, 'src') } } From 82bccd4040a34476132592ba6fa22886eb3a9dd6 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 16 May 2026 12:20:35 +0200 Subject: [PATCH 090/129] wired-tools: hoist monitorSnapshot + polling reset to the Zustand store Move the monitor snapshot off WiredCreatorToolsView's useState into useWiredCreatorToolsUiStore. The WiredMonitorDataEvent listener still lives in the component (it can't move alongside without dragging useMessageEvent into the store), but it now writes to setMonitorSnapshot and the room-change reset calls resetMonitorSnapshot() instead of re-instantiating the default in the component. Direct benefit: the snapshot now survives closing and reopening the panel between two server pushes. Before this commit, the parent remounted on every visibility flip (parent renders null while `!isVisible`) which dropped the snapshot back to the empty default; the user would briefly see zeroed stats until the next `monitor:fetch` roundtrip landed. Holding the snapshot in zustand decouples the data from the component's mount lifecycle. Tests: three new cases on the store cover setMonitorSnapshot, resetMonitorSnapshot returning a fresh empty instance, and the "close/reopen panel preserves snapshot" lifecycle. Total 181/181. --- .../wired-tools/WiredCreatorToolsView.tsx | 10 ++-- .../wiredCreatorToolsUiStore.test.ts | 58 ++++++++++++++++++- .../wired-tools/wiredCreatorToolsUiStore.ts | 22 ++++++- 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/components/wired-tools/WiredCreatorToolsView.tsx b/src/components/wired-tools/WiredCreatorToolsView.tsx index dde13ad..2018c16 100644 --- a/src/components/wired-tools/WiredCreatorToolsView.tsx +++ b/src/components/wired-tools/WiredCreatorToolsView.tsx @@ -7,8 +7,8 @@ import { AvatarInfoUtilities, GetRoomObjectBounds, GetRoomObjectScreenLocation, import { Button, DraggableWindowPosition, LayoutAvatarImageView, LayoutPetImageView, LayoutRoomObjectImageView, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../common'; import { useInventoryTrade, useMessageEvent, useNotification, useObjectSelectedEvent, useRoom, useWiredTools } from '../../hooks'; import { DIRECTION_NAMES, EDITABLE_FURNI_VARIABLES, EDITABLE_USER_VARIABLES, INSPECTION_ELEMENTS, MONITOR_ERROR_INFO, MONITOR_LOG_ORDER, MONTH_NAMES, TABS, TEAM_COLOR_NAMES, VARIABLES_ELEMENTS, VARIABLE_DEFINITIONS, WEEKDAY_NAMES, WIRED_CLOCK_REFRESH_MS, WIRED_FREEZE_EFFECT_IDS, WIRED_INSPECTION_REFRESH_MS, WIRED_MONITOR_ACTION_CLEAR_LOGS, WIRED_MONITOR_ACTION_FETCH, WIRED_MONITOR_POLL_MS, WIRED_VARIABLES_POLL_MS } from './WiredCreatorTools.constants'; -import { createEmptyMonitorSnapshot, formatMonitorHistoryOccurrence, formatMonitorLatestOccurrence, formatMonitorSource, formatVariableTimestamp, getHotelDateTimeParts, getHotelTimeFormatter, normalizeMonitorReason } from './WiredCreatorTools.helpers'; -import { HotelDateTimeParts, InspectionElementButton, InspectionElementType, InspectionFurniLiveState, InspectionFurniSelection, InspectionUserLiveState, InspectionUserSelection, InspectionUserTeamData, InspectionVariable, ManagedHolderVariableEntry, MonitorLog, MonitorLogDetails, MonitorSnapshot, MonitorStat, ParsedWallLocation, TeamEffectData, VariableDefinition, VariableHighlightOverlay, VariableHighlightTarget, VariableManageEntry, VariableTextValue, VariablesElementButton, VariablesElementType, WiredToolsTab } from './WiredCreatorTools.types'; +import { formatMonitorHistoryOccurrence, formatMonitorLatestOccurrence, formatMonitorSource, formatVariableTimestamp, getHotelDateTimeParts, getHotelTimeFormatter, normalizeMonitorReason } from './WiredCreatorTools.helpers'; +import { HotelDateTimeParts, InspectionElementButton, InspectionElementType, InspectionFurniLiveState, InspectionFurniSelection, InspectionUserLiveState, InspectionUserSelection, InspectionUserTeamData, InspectionVariable, ManagedHolderVariableEntry, MonitorLog, MonitorLogDetails, MonitorStat, ParsedWallLocation, TeamEffectData, VariableDefinition, VariableHighlightOverlay, VariableHighlightTarget, VariableManageEntry, VariableTextValue, VariablesElementButton, VariablesElementType, WiredToolsTab } from './WiredCreatorTools.types'; import { useWiredCreatorToolsUiStore } from './wiredCreatorToolsUiStore'; import { WiredInspectionTabView } from './WiredInspectionTabView'; import { WiredMonitorTabView } from './WiredMonitorTabView'; @@ -32,7 +32,9 @@ export const WiredCreatorToolsView: FC<{}> = () => const [ selectedUserActionVersion, setSelectedUserActionVersion ] = useState(0); const [ globalClock, setGlobalClock ] = useState(Date.now()); const [ roomEnteredAt, setRoomEnteredAt ] = useState(Date.now()); - const [ monitorSnapshot, setMonitorSnapshot ] = useState(() => createEmptyMonitorSnapshot()); + const monitorSnapshot = useWiredCreatorToolsUiStore(s => s.monitorSnapshot); + const setMonitorSnapshot = useWiredCreatorToolsUiStore(s => s.setMonitorSnapshot); + const resetMonitorSnapshot = useWiredCreatorToolsUiStore(s => s.resetMonitorSnapshot); const [ selectedMonitorErrorType, setSelectedMonitorErrorType ] = useState(null); const [ selectedMonitorLogDetails, setSelectedMonitorLogDetails ] = useState(null); const isMonitorHistoryOpen = useWiredCreatorToolsUiStore(s => s.isMonitorHistoryOpen); @@ -688,7 +690,7 @@ export const WiredCreatorToolsView: FC<{}> = () => useEffect(() => { - setMonitorSnapshot(createEmptyMonitorSnapshot()); + resetMonitorSnapshot(); setSelectedMonitorErrorType(null); setSelectedMonitorLogDetails(null); setIsMonitorHistoryOpen(false); diff --git a/src/components/wired-tools/wiredCreatorToolsUiStore.test.ts b/src/components/wired-tools/wiredCreatorToolsUiStore.test.ts index 1efccae..6c5f54e 100644 --- a/src/components/wired-tools/wiredCreatorToolsUiStore.test.ts +++ b/src/components/wired-tools/wiredCreatorToolsUiStore.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; +import { createEmptyMonitorSnapshot } from './WiredCreatorTools.helpers'; import { useWiredCreatorToolsUiStore } from './wiredCreatorToolsUiStore'; const INITIAL = { @@ -15,7 +16,8 @@ const INITIAL = { monitorHistoryTypeFilter: 'ALL', variableManageTypeFilter: 'ALL', variableManageSort: 'highest_value', - variableManagePage: 1 + variableManagePage: 1, + monitorSnapshot: createEmptyMonitorSnapshot() }; describe('useWiredCreatorToolsUiStore', () => @@ -43,6 +45,7 @@ describe('useWiredCreatorToolsUiStore', () => expect(state.variableManageTypeFilter).toBe('ALL'); expect(state.variableManageSort).toBe('highest_value'); expect(state.variableManagePage).toBe(1); + expect(state.monitorSnapshot).toEqual(createEmptyMonitorSnapshot()); }); describe('setIsVisible', () => @@ -177,4 +180,57 @@ describe('useWiredCreatorToolsUiStore', () => expect(useWiredCreatorToolsUiStore.getState().variableManagePage).toBe(2); }); }); + + describe('monitorSnapshot', () => + { + it('setMonitorSnapshot replaces the snapshot with the server payload shape', () => + { + const next = { + ...createEmptyMonitorSnapshot(), + usageCurrentWindow: 7, + usageLimitPerWindow: 10, + isHeavy: true, + averageExecutionMs: 42 + }; + + useWiredCreatorToolsUiStore.getState().setMonitorSnapshot(next); + + expect(useWiredCreatorToolsUiStore.getState().monitorSnapshot).toEqual(next); + expect(useWiredCreatorToolsUiStore.getState().monitorSnapshot.isHeavy).toBe(true); + }); + + it('resetMonitorSnapshot returns a fresh empty snapshot (new reference)', () => + { + const populated = { + ...createEmptyMonitorSnapshot(), + usageCurrentWindow: 5, + logs: [ { amount: 1, latestOccurrenceSeconds: 0, latestReason: '', latestSourceId: 0, latestSourceLabel: '', severity: 'ERROR', type: 'foo' } ], + history: [ { occurredAtSeconds: 0, reason: '', sourceId: 0, sourceLabel: '', severity: 'ERROR', type: 'foo' } ] + }; + useWiredCreatorToolsUiStore.getState().setMonitorSnapshot(populated); + + useWiredCreatorToolsUiStore.getState().resetMonitorSnapshot(); + + const cleared = useWiredCreatorToolsUiStore.getState().monitorSnapshot; + expect(cleared).toEqual(createEmptyMonitorSnapshot()); + expect(cleared).not.toBe(populated); + expect(cleared.logs).toEqual([]); + expect(cleared.history).toEqual([]); + }); + + it('the snapshot persists across the panel close/reopen lifecycle (UI flag flip)', () => + { + // Server pushed a non-empty snapshot while the panel was open. + const payload = { ...createEmptyMonitorSnapshot(), usageCurrentWindow: 3 }; + useWiredCreatorToolsUiStore.getState().setMonitorSnapshot(payload); + + // User closes the panel — UI flag flips, snapshot should NOT reset. + useWiredCreatorToolsUiStore.getState().setIsVisible(false); + + // User reopens — the last-known stats are still there. + useWiredCreatorToolsUiStore.getState().setIsVisible(true); + + expect(useWiredCreatorToolsUiStore.getState().monitorSnapshot.usageCurrentWindow).toBe(3); + }); + }); }); diff --git a/src/components/wired-tools/wiredCreatorToolsUiStore.ts b/src/components/wired-tools/wiredCreatorToolsUiStore.ts index 34aaf68..f4e754c 100644 --- a/src/components/wired-tools/wiredCreatorToolsUiStore.ts +++ b/src/components/wired-tools/wiredCreatorToolsUiStore.ts @@ -1,5 +1,6 @@ import { createNitroStore } from '../../state/createNitroStore'; -import { InspectionElementType, VariablesElementType, WiredToolsTab } from './WiredCreatorTools.types'; +import { createEmptyMonitorSnapshot } from './WiredCreatorTools.helpers'; +import { InspectionElementType, MonitorSnapshot, VariablesElementType, WiredToolsTab } from './WiredCreatorTools.types'; type MonitorSeverityFilter = 'ALL' | 'ERROR' | 'WARNING'; type Updater = T | ((prev: T) => T); @@ -27,6 +28,15 @@ interface WiredCreatorToolsUiState variableManageSort: string; variableManagePage: number; + /** + * Latest snapshot pushed by the server through `WiredMonitorDataEvent`. + * Held in the store (rather than `useState`) so it survives remount + * — e.g. closing and reopening the panel between two server pushes + * keeps the last-known stats visible instead of flashing back to the + * empty snapshot. + */ + monitorSnapshot: MonitorSnapshot; + setIsVisible: (next: Updater) => void; setActiveTab: (next: WiredToolsTab) => void; setInspectionType: (next: InspectionElementType) => void; @@ -44,6 +54,9 @@ interface WiredCreatorToolsUiState setVariableManageTypeFilter: (next: string) => void; setVariableManageSort: (next: string) => void; setVariableManagePage: (next: Updater) => void; + + setMonitorSnapshot: (next: MonitorSnapshot) => void; + resetMonitorSnapshot: () => void; } export const useWiredCreatorToolsUiStore = createNitroStore()((set) => ({ @@ -65,6 +78,8 @@ export const useWiredCreatorToolsUiStore = createNitroStore set(state => ({ isVisible: apply(state.isVisible, next) })), setActiveTab: (next) => set({ activeTab: next }), setInspectionType: (next) => set({ inspectionType: next }), @@ -81,5 +96,8 @@ export const useWiredCreatorToolsUiStore = createNitroStore set({ variableManageTypeFilter: next }), setVariableManageSort: (next) => set({ variableManageSort: next }), - setVariableManagePage: (next) => set(state => ({ variableManagePage: apply(state.variableManagePage, next) })) + setVariableManagePage: (next) => set(state => ({ variableManagePage: apply(state.variableManagePage, next) })), + + setMonitorSnapshot: (next) => set({ monitorSnapshot: next }), + resetMonitorSnapshot: () => set({ monitorSnapshot: createEmptyMonitorSnapshot() }) })); From 7758af710ec311513b3d3a722a4d2e5ced24db63 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 16 May 2026 12:20:52 +0200 Subject: [PATCH 091/129] docs(claude): bump vitest count to 181/181 after monitorSnapshot cases --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f184187..e98de4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -263,7 +263,7 @@ into `configurePreviewServer` so `yarn preview` keeps working. | 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 | 178/178 cases — pure helpers + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore`) + 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. | +| Vitest | 181/181 cases — pure helpers + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore`) + 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) | | Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating | @@ -300,7 +300,7 @@ Fix shapes documented; both are reasonable PRs on their own. - **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 test` must stay green** on every commit. Currently 178/178. +- **`yarn test` must stay green** on every commit. Currently 181/181. The GitHub Actions workflow at `.github/workflows/ci.yml` runs `yarn typecheck` + `yarn test --run` on every push to `main` / `feat/**` and on every PR — both must pass. From 8182e06be49964730a6d569df555ef048cb0ad12 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 16 May 2026 12:25:31 +0200 Subject: [PATCH 092/129] wired-tools: hoist inspection selection (+ live state + action version) to the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five more useStates leave WiredCreatorToolsView: selectedFurni, selectedFurniLiveState, selectedUser, selectedUserLiveState, and the monotonic selectedUserActionVersion counter. All five now live in useWiredCreatorToolsUiStore; the room-event listeners (useObjectSelectedEvent, the per-kind useMessageEvent + useNitroEvent handlers, the per-action effects that bump the version counter) stay in the component because they need React's subscription lifecycle — they just call the store actions instead of setState. Same persistence benefit as the previous monitorSnapshot pass: the currently-inspected target survives a panel close/reopen instead of being dropped to null on remount. Live-state setters and the action version counter accept Updater so the many `previousValue => ...` call sites stayed verbatim. Tests: six new cases (setSelectedFurni + null clear, functional updater on FurniLiveState, paired setSelectedUser + LiveState, monotonic ActionVersion via updater, close/reopen persistence). The test fixtures use the real interface shapes — InspectionFurniSelection includes a renderer-typed `info: AvatarInfoFurni` that is cast through `as never` so the test doesn't have to construct the full avatar info shape. 187/187 passing. --- .../wired-tools/WiredCreatorToolsView.tsx | 15 ++-- .../wiredCreatorToolsUiStore.test.ts | 83 ++++++++++++++++++- .../wired-tools/wiredCreatorToolsUiStore.ts | 40 ++++++++- 3 files changed, 130 insertions(+), 8 deletions(-) diff --git a/src/components/wired-tools/WiredCreatorToolsView.tsx b/src/components/wired-tools/WiredCreatorToolsView.tsx index 2018c16..5ada188 100644 --- a/src/components/wired-tools/WiredCreatorToolsView.tsx +++ b/src/components/wired-tools/WiredCreatorToolsView.tsx @@ -25,11 +25,16 @@ export const WiredCreatorToolsView: FC<{}> = () => const setInspectionType = useWiredCreatorToolsUiStore(s => s.setInspectionType); const variablesType = useWiredCreatorToolsUiStore(s => s.variablesType); const [ keepSelected, setKeepSelected ] = useState(false); - const [ selectedFurni, setSelectedFurni ] = useState(null); - const [ selectedFurniLiveState, setSelectedFurniLiveState ] = useState(null); - const [ selectedUser, setSelectedUser ] = useState(null); - const [ selectedUserLiveState, setSelectedUserLiveState ] = useState(null); - const [ selectedUserActionVersion, setSelectedUserActionVersion ] = useState(0); + const selectedFurni = useWiredCreatorToolsUiStore(s => s.selectedFurni); + const setSelectedFurni = useWiredCreatorToolsUiStore(s => s.setSelectedFurni); + const selectedFurniLiveState = useWiredCreatorToolsUiStore(s => s.selectedFurniLiveState); + const setSelectedFurniLiveState = useWiredCreatorToolsUiStore(s => s.setSelectedFurniLiveState); + const selectedUser = useWiredCreatorToolsUiStore(s => s.selectedUser); + const setSelectedUser = useWiredCreatorToolsUiStore(s => s.setSelectedUser); + const selectedUserLiveState = useWiredCreatorToolsUiStore(s => s.selectedUserLiveState); + const setSelectedUserLiveState = useWiredCreatorToolsUiStore(s => s.setSelectedUserLiveState); + const selectedUserActionVersion = useWiredCreatorToolsUiStore(s => s.selectedUserActionVersion); + const setSelectedUserActionVersion = useWiredCreatorToolsUiStore(s => s.setSelectedUserActionVersion); const [ globalClock, setGlobalClock ] = useState(Date.now()); const [ roomEnteredAt, setRoomEnteredAt ] = useState(Date.now()); const monitorSnapshot = useWiredCreatorToolsUiStore(s => s.monitorSnapshot); diff --git a/src/components/wired-tools/wiredCreatorToolsUiStore.test.ts b/src/components/wired-tools/wiredCreatorToolsUiStore.test.ts index 6c5f54e..b1ce3f5 100644 --- a/src/components/wired-tools/wiredCreatorToolsUiStore.test.ts +++ b/src/components/wired-tools/wiredCreatorToolsUiStore.test.ts @@ -17,7 +17,12 @@ const INITIAL = { variableManageTypeFilter: 'ALL', variableManageSort: 'highest_value', variableManagePage: 1, - monitorSnapshot: createEmptyMonitorSnapshot() + monitorSnapshot: createEmptyMonitorSnapshot(), + selectedFurni: null, + selectedFurniLiveState: null, + selectedUser: null, + selectedUserLiveState: null, + selectedUserActionVersion: 0 }; describe('useWiredCreatorToolsUiStore', () => @@ -46,6 +51,11 @@ describe('useWiredCreatorToolsUiStore', () => expect(state.variableManageSort).toBe('highest_value'); expect(state.variableManagePage).toBe(1); expect(state.monitorSnapshot).toEqual(createEmptyMonitorSnapshot()); + expect(state.selectedFurni).toBeNull(); + expect(state.selectedFurniLiveState).toBeNull(); + expect(state.selectedUser).toBeNull(); + expect(state.selectedUserLiveState).toBeNull(); + expect(state.selectedUserActionVersion).toBe(0); }); describe('setIsVisible', () => @@ -233,4 +243,75 @@ describe('useWiredCreatorToolsUiStore', () => expect(useWiredCreatorToolsUiStore.getState().monitorSnapshot.usageCurrentWindow).toBe(3); }); }); + + describe('inspection selection', () => + { + const furniSelection = { + objectId: 42, + category: 10, + info: { id: 42, name: 'sofa', description: '', image: null } as never + }; + const userSelection = { + kind: 'user' as const, + roomIndex: 7, + name: 'simoleo', + figure: 'hd-180-1.lg-3023-110', + gender: 'M', + userId: 99, + level: 12, + posture: 'std' + } as never; + + it('setSelectedFurni stores the picked furni selection', () => + { + useWiredCreatorToolsUiStore.getState().setSelectedFurni(furniSelection); + + expect(useWiredCreatorToolsUiStore.getState().selectedFurni).toEqual(furniSelection); + }); + + it('setSelectedFurni(null) clears the selection (deselect path)', () => + { + useWiredCreatorToolsUiStore.getState().setSelectedFurni(furniSelection); + useWiredCreatorToolsUiStore.getState().setSelectedFurni(null); + + expect(useWiredCreatorToolsUiStore.getState().selectedFurni).toBeNull(); + }); + + it('setSelectedFurniLiveState accepts a functional updater', () => + { + const initial = { positionX: 1, positionY: 2, altitude: 3, rotation: 4, state: 5 }; + useWiredCreatorToolsUiStore.getState().setSelectedFurniLiveState(initial); + + useWiredCreatorToolsUiStore.getState().setSelectedFurniLiveState(prev => (prev ? { ...prev, state: prev.state + 1 } : null)); + + expect(useWiredCreatorToolsUiStore.getState().selectedFurniLiveState).toEqual({ ...initial, state: 6 }); + }); + + it('setSelectedUser + setSelectedUserLiveState write the user selection / live state', () => + { + useWiredCreatorToolsUiStore.getState().setSelectedUser(userSelection); + useWiredCreatorToolsUiStore.getState().setSelectedUserLiveState({ positionX: 5, positionY: 6, altitude: 0, direction: 2 }); + + expect(useWiredCreatorToolsUiStore.getState().selectedUser).toEqual(userSelection); + expect(useWiredCreatorToolsUiStore.getState().selectedUserLiveState).toEqual({ positionX: 5, positionY: 6, altitude: 0, direction: 2 }); + }); + + it('setSelectedUserActionVersion bumps the monotonic counter via functional updater', () => + { + useWiredCreatorToolsUiStore.getState().setSelectedUserActionVersion(prev => prev + 1); + useWiredCreatorToolsUiStore.getState().setSelectedUserActionVersion(prev => prev + 1); + useWiredCreatorToolsUiStore.getState().setSelectedUserActionVersion(prev => prev + 1); + + expect(useWiredCreatorToolsUiStore.getState().selectedUserActionVersion).toBe(3); + }); + + it('the selection persists across the panel close/reopen lifecycle', () => + { + useWiredCreatorToolsUiStore.getState().setSelectedFurni(furniSelection); + useWiredCreatorToolsUiStore.getState().setIsVisible(false); + useWiredCreatorToolsUiStore.getState().setIsVisible(true); + + expect(useWiredCreatorToolsUiStore.getState().selectedFurni).toEqual(furniSelection); + }); + }); }); diff --git a/src/components/wired-tools/wiredCreatorToolsUiStore.ts b/src/components/wired-tools/wiredCreatorToolsUiStore.ts index f4e754c..e117eb5 100644 --- a/src/components/wired-tools/wiredCreatorToolsUiStore.ts +++ b/src/components/wired-tools/wiredCreatorToolsUiStore.ts @@ -1,6 +1,6 @@ import { createNitroStore } from '../../state/createNitroStore'; import { createEmptyMonitorSnapshot } from './WiredCreatorTools.helpers'; -import { InspectionElementType, MonitorSnapshot, VariablesElementType, WiredToolsTab } from './WiredCreatorTools.types'; +import { InspectionElementType, InspectionFurniLiveState, InspectionFurniSelection, InspectionUserLiveState, InspectionUserSelection, MonitorSnapshot, VariablesElementType, WiredToolsTab } from './WiredCreatorTools.types'; type MonitorSeverityFilter = 'ALL' | 'ERROR' | 'WARNING'; type Updater = T | ((prev: T) => T); @@ -37,6 +37,24 @@ interface WiredCreatorToolsUiState */ monitorSnapshot: MonitorSnapshot; + /** + * Inspection selection. The room-event listeners + * (`useObjectSelectedEvent` and the per-kind `useMessageEvent` + * handlers) still live in `WiredCreatorToolsView` — they need React + * lifecycle to subscribe/unsubscribe correctly — but the resulting + * state lives here so a closed/reopened panel keeps the last + * inspected target. + * + * `*ActionVersion` is a monotonic counter the user-action handlers + * bump to force the live-state recomputation effect to re-run even + * when neither `selectedUser` nor `roomIndex` changed identity. + */ + selectedFurni: InspectionFurniSelection | null; + selectedFurniLiveState: InspectionFurniLiveState | null; + selectedUser: InspectionUserSelection | null; + selectedUserLiveState: InspectionUserLiveState | null; + selectedUserActionVersion: number; + setIsVisible: (next: Updater) => void; setActiveTab: (next: WiredToolsTab) => void; setInspectionType: (next: InspectionElementType) => void; @@ -57,6 +75,12 @@ interface WiredCreatorToolsUiState setMonitorSnapshot: (next: MonitorSnapshot) => void; resetMonitorSnapshot: () => void; + + setSelectedFurni: (next: InspectionFurniSelection | null) => void; + setSelectedFurniLiveState: (next: Updater) => void; + setSelectedUser: (next: InspectionUserSelection | null) => void; + setSelectedUserLiveState: (next: Updater) => void; + setSelectedUserActionVersion: (next: Updater) => void; } export const useWiredCreatorToolsUiStore = createNitroStore()((set) => ({ @@ -80,6 +104,12 @@ export const useWiredCreatorToolsUiStore = createNitroStore set(state => ({ isVisible: apply(state.isVisible, next) })), setActiveTab: (next) => set({ activeTab: next }), setInspectionType: (next) => set({ inspectionType: next }), @@ -99,5 +129,11 @@ export const useWiredCreatorToolsUiStore = createNitroStore set(state => ({ variableManagePage: apply(state.variableManagePage, next) })), setMonitorSnapshot: (next) => set({ monitorSnapshot: next }), - resetMonitorSnapshot: () => set({ monitorSnapshot: createEmptyMonitorSnapshot() }) + resetMonitorSnapshot: () => set({ monitorSnapshot: createEmptyMonitorSnapshot() }), + + setSelectedFurni: (next) => set({ selectedFurni: next }), + setSelectedFurniLiveState: (next) => set(state => ({ selectedFurniLiveState: apply(state.selectedFurniLiveState, next) })), + setSelectedUser: (next) => set({ selectedUser: next }), + setSelectedUserLiveState: (next) => set(state => ({ selectedUserLiveState: apply(state.selectedUserLiveState, next) })), + setSelectedUserActionVersion: (next) => set(state => ({ selectedUserActionVersion: apply(state.selectedUserActionVersion, next) })) })); From 50fd908d5af2c541e68ad1340a17c588985d2f86 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 16 May 2026 12:25:43 +0200 Subject: [PATCH 093/129] docs(claude): bump vitest count to 187/187 after selection-hoist cases --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e98de4c..9560e09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -263,7 +263,7 @@ into `configurePreviewServer` so `yarn preview` keeps working. | 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 | 181/181 cases — pure helpers + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore`) + 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. | +| Vitest | 187/187 cases — pure helpers + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore`) + 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) | | Cherry-picked from `duckietm` PR #126 | `UserAccountSettingsView` (reset password / email / username under user settings), plus the wear-badge popup `canShowWearButton` gating | @@ -300,7 +300,7 @@ Fix shapes documented; both are reasonable PRs on their own. - **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 test` must stay green** on every commit. Currently 181/181. +- **`yarn test` must stay green** on every commit. Currently 187/187. The GitHub Actions workflow at `.github/workflows/ci.yml` runs `yarn typecheck` + `yarn test --run` on every push to `main` / `feat/**` and on every PR — both must pass. From 0fc32a1e19d78dc296fc55a2d2beee78bc4c44a4 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 16 May 2026 12:31:19 +0200 Subject: [PATCH 094/129] wired-tools: hoist variable-highlight toggle + overlays to the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the highlight feature pair into useWiredCreatorToolsUiStore: isVariableHighlightActive (toggle UI flag) and variableHighlightOverlays (computed screen-space overlay positions). The two screen-coords effects in WiredCreatorToolsView stay where they are (they need React's lifecycle to install / tear down WiredSelectionVisualizer highlights on the active room objects) but now write to setVariableHighlightOverlays. WiredVariablesTabView drops the isVariableHighlightActive + onToggleVariableHighlight props and consumes the store directly — same shape as the previous tab-prop reductions on this branch. The toggle button keeps the same UX (Highlight ↔ Undo) but no longer crosses the prop boundary. Direct benefit: closing and reopening the panel while a variable highlight is active no longer flickers the overlays off and back on — the active flag + the last-computed overlay set both persist in zustand and the effect re-runs from the same starting point. Tests: three new cases on the store (toggle via direct + updater, overlay replace + clear, close/reopen persistence). 190/190 passing. variableHighlightObjectsRef stays a useRef inside the component: it tracks the live PIXI objects WiredSelectionVisualizer drew onto, used only for the cleanup pass — refs don't trigger renders and don't need to live in the store. --- .../wired-tools/WiredCreatorToolsView.tsx | 8 ++-- .../wired-tools/WiredVariablesTabView.tsx | 8 ++-- .../wiredCreatorToolsUiStore.test.ts | 44 ++++++++++++++++++- .../wired-tools/wiredCreatorToolsUiStore.ts | 23 +++++++++- 4 files changed, 71 insertions(+), 12 deletions(-) diff --git a/src/components/wired-tools/WiredCreatorToolsView.tsx b/src/components/wired-tools/WiredCreatorToolsView.tsx index 5ada188..f5cefcd 100644 --- a/src/components/wired-tools/WiredCreatorToolsView.tsx +++ b/src/components/wired-tools/WiredCreatorToolsView.tsx @@ -77,8 +77,10 @@ export const WiredCreatorToolsView: FC<{}> = () => const setIsManagedGiveOpen = useWiredCreatorToolsUiStore(s => s.setIsManagedGiveOpen); const [ managedGiveVariableItemId, setManagedGiveVariableItemId ] = useState(0); const [ managedGiveValue, setManagedGiveValue ] = useState('0'); - const [ isVariableHighlightActive, setIsVariableHighlightActive ] = useState(false); - const [ variableHighlightOverlays, setVariableHighlightOverlays ] = useState([]); + const isVariableHighlightActive = useWiredCreatorToolsUiStore(s => s.isVariableHighlightActive); + const setIsVariableHighlightActive = useWiredCreatorToolsUiStore(s => s.setIsVariableHighlightActive); + const variableHighlightOverlays = useWiredCreatorToolsUiStore(s => s.variableHighlightOverlays); + const setVariableHighlightOverlays = useWiredCreatorToolsUiStore(s => s.setVariableHighlightOverlays); const variableHighlightObjectsRef = useRef>([]); const shouldPauseVariableSnapshotRefresh = (!!editingVariable || !!editingManagedHolderVariableId || isInspectionGiveOpen || isManagedGiveOpen); const [ selectedVariableKeys, setSelectedVariableKeys ] = useState>({ @@ -3145,8 +3147,6 @@ export const WiredCreatorToolsView: FC<{}> = () => selectedVariableDefinition={ selectedVariableDefinition } onPickVariable={ key => setSelectedVariableKeys(prev => ({ ...prev, [variablesType]: key })) } canVariableHighlight={ canVariableHighlight } - isVariableHighlightActive={ isVariableHighlightActive } - onToggleVariableHighlight={ () => setIsVariableHighlightActive(value => !value) } variableManageCanOpen={ variableManageCanOpen } onOpenManagePanel={ () => { diff --git a/src/components/wired-tools/WiredVariablesTabView.tsx b/src/components/wired-tools/WiredVariablesTabView.tsx index 2ca1ed9..163a53d 100644 --- a/src/components/wired-tools/WiredVariablesTabView.tsx +++ b/src/components/wired-tools/WiredVariablesTabView.tsx @@ -10,8 +10,6 @@ export interface WiredVariablesTabViewProps selectedVariableDefinition: VariableDefinition | null; onPickVariable: (key: string) => void; canVariableHighlight: boolean; - isVariableHighlightActive: boolean; - onToggleVariableHighlight: () => void; variableManageCanOpen: boolean; onOpenManagePanel: () => void; selectedVariableProperties: { key: string; value: string; }[]; @@ -30,8 +28,6 @@ export const WiredVariablesTabView: FC = ({ selectedVariableDefinition, onPickVariable, canVariableHighlight, - isVariableHighlightActive, - onToggleVariableHighlight, variableManageCanOpen, onOpenManagePanel, selectedVariableProperties, @@ -40,6 +36,8 @@ export const WiredVariablesTabView: FC = ({ { const variablesType = useWiredCreatorToolsUiStore(s => s.variablesType); const setVariablesType = useWiredCreatorToolsUiStore(s => s.setVariablesType); + const isVariableHighlightActive = useWiredCreatorToolsUiStore(s => s.isVariableHighlightActive); + const setIsVariableHighlightActive = useWiredCreatorToolsUiStore(s => s.setIsVariableHighlightActive); return (
@@ -83,7 +81,7 @@ export const WiredVariablesTabView: FC = ({