The snapshot hooks were chained against renderer Manager methods
(getUserDataSnapshot, getIgnoredUsersSnapshot, subscribe, …) under the
assumption that the resolved \`@nitrots/nitro-renderer\` bundle always
includes the v2.1.0+ snapshot API.
That assumption fails in two real scenarios:
1. A stale \`dist/index.js\` shadows the source umbrella at resolution
time (the vite alias commit 790ad2b mitigates this in dev, but it
only takes effect after a server restart).
2. A consumer bundles the client against an older renderer release
(e.g. NitroV3-Housekeeping's embedded copy in \`public/nitro3\`).
In both cases the snapshot hook calls \`undefined()\` and React shows
the error-boundary fallback "(intermediate value)() is undefined".
Wrap every renderer-side call with a typeof guard:
const manager = GetSessionDataManager();
if(!manager || typeof manager.getUserDataSnapshot !== 'function')
return DEFAULT_USER_DATA;
return manager.getUserDataSnapshot();
Module-level frozen defaults (DEFAULT_USER_DATA, EMPTY_IGNORED_LIST,
EMPTY_GROUP_BADGES, EMPTY_USER_LIST, DEFAULT_VOLUMES, NOOP_UNSUBSCRIBE)
keep the snapshot reference stable across fallback calls, so
useSyncExternalStore's bailout still works and we don't trigger render
loops on the degraded path.
Once the renderer is upgraded (or the alias kicks in after restart),
the hooks transparently switch to the real getters — no code change
needed at any consumer.
Verification: yarn typecheck clean, yarn test 207/207, yarn build green.
The fix is defense-in-depth on top of 790ad2b (vite alias) — both can
coexist, neither alone is sufficient for every deployment surface.
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.