feat(hooks/session): React-side consumer hooks for the renderer snapshot pattern

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<IUserDataSnapshot>
- useActiveRoomSessionSnapshot() → Readonly<IRoomSessionSnapshot> | null
- useIgnoredUsersSnapshot()    → ReadonlyArray<string>
- useIsUserIgnored(name)       → boolean (useMemo over the array)
- useGroupBadgesSnapshot()     → ReadonlyMap<number, string>
- useGroupBadge(groupId)       → string (useMemo over the map)
- useVolumesSnapshot()         → Readonly<ISoundVolumesSnapshot>
- useRoomUserListSnapshot()    → ReadonlyArray<IRoomUserData>

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.
This commit is contained in:
simoleo89
2026-05-18 21:24:03 +02:00
parent e7e8bcc65f
commit b2a86da912
2 changed files with 114 additions and 0 deletions
+1
View File
@@ -1 +1,2 @@
export * from './useSessionInfo';
export * from './useSessionSnapshots';
+113
View File
@@ -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<IUserDataSnapshot> =>
useExternalSnapshot(
subscribeTo(NitroEventType.SESSION_DATA_UPDATED),
() => GetSessionDataManager().getUserDataSnapshot()
);
export const useActiveRoomSessionSnapshot = (): Readonly<IRoomSessionSnapshot> | null =>
useExternalSnapshot(
subscribeTo(NitroEventType.ROOM_SESSION_UPDATED),
() => GetRoomSessionManager().getActiveRoomSessionSnapshot()
);
export const useIgnoredUsersSnapshot = (): ReadonlyArray<string> =>
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<number, string> =>
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<ISoundVolumesSnapshot> =>
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<IRoomUserData[]>([]) as ReadonlyArray<IRoomUserData>;
export const useRoomUserListSnapshot = (): ReadonlyArray<IRoomUserData> =>
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
);