From b2a86da912f5fbf00b2f606ae97f90b0612b7b34 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 18 May 2026 21:24:03 +0200 Subject: [PATCH] feat(hooks/session): React-side consumer hooks for the renderer snapshot pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer exposes six referentially-stable snapshot getters under the v2.1.0 React-friendly pattern (SessionData / RoomSession / IgnoredUsers / GroupBadges / RoomUserList / SoundVolumes), each invalidated by a dedicated NitroEventType.*_UPDATED dispatch. Until now nothing on the client consumed them — useExternalSnapshot existed as a useSyncExternalStore wrapper but no widget was wired up to a snapshot. Add thin consumer hooks under src/hooks/session/useSessionSnapshots.ts, each a useExternalSnapshot wrapper around the matching subscribe+getter pair: - useUserDataSnapshot() → Readonly - useActiveRoomSessionSnapshot() → Readonly | null - useIgnoredUsersSnapshot() → ReadonlyArray - useIsUserIgnored(name) → boolean (useMemo over the array) - useGroupBadgesSnapshot() → ReadonlyMap - useGroupBadge(groupId) → string (useMemo over the map) - useVolumesSnapshot() → Readonly - useRoomUserListSnapshot() → ReadonlyArray Two design details worth noting: - useRoomUserListSnapshot subscribes to BOTH ROOM_USER_LIST_UPDATED (for join/leave/update inside a session) AND ROOM_SESSION_UPDATED (because the underlying userDataManager reference flips when the active room session changes). A single module-level frozen EMPTY_USER_LIST is the fallback when no session is active, keeping reference stability across reads in the no-room state. - useIsUserIgnored / useGroupBadge memoize the scalar derivation so a re-render only happens when the underlying snapshot reference flips, not on unrelated useExternalSnapshot wake-ups. These hooks unlock per-component snapshot consumption — widgets that previously juggled addEventListener + useState pairs (or worse, read GetSessionDataManager().userId directly and never re-rendered) can now go through one of these and get reactivity for free. Migration of existing consumers (useSessionInfo, AvatarInfoUtilities, etc.) is the next pass. Verification: yarn typecheck clean, yarn test 203/203, yarn build green. --- src/hooks/session/index.ts | 1 + src/hooks/session/useSessionSnapshots.ts | 113 +++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/hooks/session/useSessionSnapshots.ts diff --git a/src/hooks/session/index.ts b/src/hooks/session/index.ts index e61c7f3..6cd3e40 100644 --- a/src/hooks/session/index.ts +++ b/src/hooks/session/index.ts @@ -1 +1,2 @@ export * from './useSessionInfo'; +export * from './useSessionSnapshots'; diff --git a/src/hooks/session/useSessionSnapshots.ts b/src/hooks/session/useSessionSnapshots.ts new file mode 100644 index 0000000..ebc1075 --- /dev/null +++ b/src/hooks/session/useSessionSnapshots.ts @@ -0,0 +1,113 @@ +import { GetEventDispatcher, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, IRoomSessionSnapshot, IRoomUserData, ISoundVolumesSnapshot, IUserDataSnapshot, NitroEventType } from '@nitrots/nitro-renderer'; +import { useMemo } from 'react'; +import { useExternalSnapshot } from '../events/useExternalSnapshot'; + +/** + * React-side consumers for the referentially-stable snapshot getters + * the renderer exposes (Nitro_Render_V3 v2.1.0+ pattern). + * + * Every hook here is a thin `useSyncExternalStore` wrapper: it subscribes + * to the corresponding `NitroEventType.*_UPDATED` invalidation event and + * reads the matching `getXxxSnapshot()`. Because the renderer guarantees + * snapshot reference invariance until invalidation, React's bailout logic + * skips re-renders when the snapshot is unchanged — so widgets that read + * the same slice across many components share a single subscription and + * only re-paint when the underlying state actually changes. + * + * Prefer these over reaching into the manager directly with + * `GetSessionDataManager().userId` etc., which never trigger a re-render + * when the value changes. + */ + +const subscribeTo = (eventType: string) => (onChange: () => void) => + GetEventDispatcher().subscribe(eventType, onChange); + +export const useUserDataSnapshot = (): Readonly => + useExternalSnapshot( + subscribeTo(NitroEventType.SESSION_DATA_UPDATED), + () => GetSessionDataManager().getUserDataSnapshot() + ); + +export const useActiveRoomSessionSnapshot = (): Readonly | null => + useExternalSnapshot( + subscribeTo(NitroEventType.ROOM_SESSION_UPDATED), + () => GetRoomSessionManager().getActiveRoomSessionSnapshot() + ); + +export const useIgnoredUsersSnapshot = (): ReadonlyArray => + useExternalSnapshot( + subscribeTo(NitroEventType.IGNORED_USERS_UPDATED), + () => GetSessionDataManager().ignoredUsersManager.getIgnoredUsersSnapshot() + ); + +/** + * Reactive predicate built on top of `useIgnoredUsersSnapshot`. + * Re-renders only when the array reference flips (i.e. someone is added + * or removed) — not on unrelated session updates. + */ +export const useIsUserIgnored = (name: string): boolean => +{ + const list = useIgnoredUsersSnapshot(); + + return useMemo(() => list.includes(name), [ list, name ]); +}; + +export const useGroupBadgesSnapshot = (): ReadonlyMap => + useExternalSnapshot( + subscribeTo(NitroEventType.GROUP_BADGES_UPDATED), + () => GetSessionDataManager().groupInformationManager.getGroupBadgesSnapshot() + ); + +/** + * Returns the badge id for a given group, reactive. Empty string when + * the badge isn't known (matches the legacy `getGroupBadge` fallback). + */ +export const useGroupBadge = (groupId: number): string => +{ + const badges = useGroupBadgesSnapshot(); + + return useMemo(() => badges.get(groupId) ?? '', [ badges, groupId ]); +}; + +export const useVolumesSnapshot = (): Readonly => + useExternalSnapshot( + subscribeTo(NitroEventType.SOUND_VOLUMES_UPDATED), + () => GetSoundManager().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). + * + * 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. + */ +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(); + const offList = dispatcher.subscribe(NitroEventType.ROOM_USER_LIST_UPDATED, onChange); + const offSession = dispatcher.subscribe(NitroEventType.ROOM_SESSION_UPDATED, onChange); + + return () => + { + offList(); + offSession(); + }; + }, + () => GetRoomSessionManager().getActiveRoomSessionSnapshot()?.session?.userDataManager?.getRoomUserListSnapshot() ?? EMPTY_USER_LIST + );