diff --git a/src/hooks/session/useSessionSnapshots.ts b/src/hooks/session/useSessionSnapshots.ts index ebc1075..08051bf 100644 --- a/src/hooks/session/useSessionSnapshots.ts +++ b/src/hooks/session/useSessionSnapshots.ts @@ -17,27 +17,98 @@ import { useExternalSnapshot } from '../events/useExternalSnapshot'; * Prefer these over reaching into the manager directly with * `GetSessionDataManager().userId` etc., which never trigger a re-render * when the value changes. + * + * The hooks are intentionally defensive: every call site is wrapped in a + * "is this method available?" guard so a renderer-version mismatch + * (e.g. a stale `dist/` shadowing the source) degrades to a stable empty + * fallback instead of crashing the React tree with `(intermediate value)() + * is undefined` during the first paint. */ -const subscribeTo = (eventType: string) => (onChange: () => void) => - GetEventDispatcher().subscribe(eventType, onChange); +// Module-level frozen defaults — referentially stable so the React bailout +// keeps working when a manager method is unavailable. +const NOOP_UNSUBSCRIBE = (): void => undefined; + +const DEFAULT_USER_DATA: Readonly = Object.freeze({ + userId: 0, + userName: '', + figure: '', + gender: '', + realName: '', + respectsReceived: 0, + respectsLeft: 0, + respectsPetLeft: 0, + canChangeName: false, + clubLevel: 0, + securityLevel: 0, + isAmbassador: false, + isEmailVerified: false, + isNoob: false, + isAuthenticHabbo: false, + isSystemOpen: false, + isSystemShutdown: false, + uiFlags: 0, + tags: Object.freeze([]) as ReadonlyArray +}) as Readonly; + +const EMPTY_IGNORED_LIST: ReadonlyArray = Object.freeze([]) as ReadonlyArray; +const EMPTY_GROUP_BADGES: ReadonlyMap = new Map(); +const EMPTY_USER_LIST: ReadonlyArray = Object.freeze([]) as ReadonlyArray; + +const DEFAULT_VOLUMES: Readonly = Object.freeze({ + system: 0.5, + furni: 0.5, + trax: 0.5 +}) as Readonly; + +const subscribeTo = (eventType: string) => (onChange: () => void): (() => void) => +{ + const dispatcher = GetEventDispatcher(); + + // Stale renderer (no v2.1.0 subscribe API) — return a no-op + // unsubscribe so useSyncExternalStore stays mounted cleanly. + if(!dispatcher || typeof dispatcher.subscribe !== 'function') return NOOP_UNSUBSCRIBE; + + return dispatcher.subscribe(eventType, onChange); +}; export const useUserDataSnapshot = (): Readonly => useExternalSnapshot( subscribeTo(NitroEventType.SESSION_DATA_UPDATED), - () => GetSessionDataManager().getUserDataSnapshot() + () => + { + const manager = GetSessionDataManager(); + + if(!manager || typeof manager.getUserDataSnapshot !== 'function') return DEFAULT_USER_DATA; + + return manager.getUserDataSnapshot(); + } ); export const useActiveRoomSessionSnapshot = (): Readonly | null => useExternalSnapshot( subscribeTo(NitroEventType.ROOM_SESSION_UPDATED), - () => GetRoomSessionManager().getActiveRoomSessionSnapshot() + () => + { + const manager = GetRoomSessionManager(); + + if(!manager || typeof manager.getActiveRoomSessionSnapshot !== 'function') return null; + + return manager.getActiveRoomSessionSnapshot(); + } ); export const useIgnoredUsersSnapshot = (): ReadonlyArray => useExternalSnapshot( subscribeTo(NitroEventType.IGNORED_USERS_UPDATED), - () => GetSessionDataManager().ignoredUsersManager.getIgnoredUsersSnapshot() + () => + { + const inner = GetSessionDataManager()?.ignoredUsersManager; + + if(!inner || typeof inner.getIgnoredUsersSnapshot !== 'function') return EMPTY_IGNORED_LIST; + + return inner.getIgnoredUsersSnapshot(); + } ); /** @@ -55,7 +126,14 @@ export const useIsUserIgnored = (name: string): boolean => export const useGroupBadgesSnapshot = (): ReadonlyMap => useExternalSnapshot( subscribeTo(NitroEventType.GROUP_BADGES_UPDATED), - () => GetSessionDataManager().groupInformationManager.getGroupBadgesSnapshot() + () => + { + const inner = GetSessionDataManager()?.groupInformationManager; + + if(!inner || typeof inner.getGroupBadgesSnapshot !== 'function') return EMPTY_GROUP_BADGES; + + return inner.getGroupBadgesSnapshot(); + } ); /** @@ -72,34 +150,33 @@ export const useGroupBadge = (groupId: number): string => export const useVolumesSnapshot = (): Readonly => useExternalSnapshot( subscribeTo(NitroEventType.SOUND_VOLUMES_UPDATED), - () => GetSoundManager().getVolumesSnapshot() + () => + { + const manager = GetSoundManager(); + + if(!manager || typeof manager.getVolumesSnapshot !== 'function') return DEFAULT_VOLUMES; + + return manager.getVolumesSnapshot(); + } ); /** * Returns the active room's user list, reactive. Returns an empty - * frozen array when no room session is active (matches the renderer's - * "no active session" shape). + * frozen array when no room session is active (or when the renderer + * doesn't expose the snapshot getter yet). * - * The room session itself is read via the active-room snapshot — - * `ROOM_USER_LIST_UPDATED` fires on user join/leave/update inside the - * active session, but the underlying `userDataManager` reference - * follows whichever session is current, so we re-resolve it on every - * snapshot read. The empty-array fallback is also frozen so consumers - * relying on referential stability don't accidentally trigger renders - * by getting a fresh `[]` each call when no session is active. + * Subscribes to BOTH `ROOM_USER_LIST_UPDATED` (join/leave/update inside + * the active session) AND `ROOM_SESSION_UPDATED` (because the underlying + * `userDataManager` reference flips when the active room changes). */ -const EMPTY_USER_LIST = Object.freeze([]) as ReadonlyArray; - export const useRoomUserListSnapshot = (): ReadonlyArray => useExternalSnapshot( - // Subscribe to BOTH events: ROOM_USER_LIST_UPDATED fires for - // join/leave/update inside the active session, but - // ROOM_SESSION_UPDATED fires when the active session itself - // changes (room change) — and the underlying `userDataManager` - // reference flips with it, so we need to re-read. (onChange) => { const dispatcher = GetEventDispatcher(); + + if(!dispatcher || typeof dispatcher.subscribe !== 'function') return NOOP_UNSUBSCRIBE; + const offList = dispatcher.subscribe(NitroEventType.ROOM_USER_LIST_UPDATED, onChange); const offSession = dispatcher.subscribe(NitroEventType.ROOM_SESSION_UPDATED, onChange); @@ -109,5 +186,12 @@ export const useRoomUserListSnapshot = (): ReadonlyArray => offSession(); }; }, - () => GetRoomSessionManager().getActiveRoomSessionSnapshot()?.session?.userDataManager?.getRoomUserListSnapshot() ?? EMPTY_USER_LIST + () => + { + const userDataManager = GetRoomSessionManager()?.getActiveRoomSessionSnapshot?.()?.session?.userDataManager; + + if(!userDataManager || typeof userDataManager.getRoomUserListSnapshot !== 'function') return EMPTY_USER_LIST; + + return userDataManager.getRoomUserListSnapshot(); + } );