fix(useSessionSnapshots): defensive guards against missing renderer methods

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.
This commit is contained in:
simoleo89
2026-05-18 22:06:32 +02:00
parent 790ad2b279
commit c35a2d4b4f
+108 -24
View File
@@ -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<IUserDataSnapshot> = 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<string[]>([]) as ReadonlyArray<string>
}) as Readonly<IUserDataSnapshot>;
const EMPTY_IGNORED_LIST: ReadonlyArray<string> = Object.freeze<string[]>([]) as ReadonlyArray<string>;
const EMPTY_GROUP_BADGES: ReadonlyMap<number, string> = new Map();
const EMPTY_USER_LIST: ReadonlyArray<IRoomUserData> = Object.freeze<IRoomUserData[]>([]) as ReadonlyArray<IRoomUserData>;
const DEFAULT_VOLUMES: Readonly<ISoundVolumesSnapshot> = Object.freeze({
system: 0.5,
furni: 0.5,
trax: 0.5
}) as Readonly<ISoundVolumesSnapshot>;
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<IUserDataSnapshot> =>
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<IRoomSessionSnapshot> | 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<string> =>
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<number, string> =>
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<ISoundVolumesSnapshot> =>
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<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();
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<IRoomUserData> =>
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();
}
);