Build on the useIsModerator landing (532cb28c) along three axes:
1. Family. Extract `useHasSecurityLevel(min)` as the primitive,
backed by a fresh `useUserSecurityLevel()` raw-level reader. The
six SecurityLevel constants (1..9) deserve named wrappers so the
"show this only to X-and-up" pattern doesn't get re-derived ad-hoc
each time: shipped `useIsModerator` / `useIsPlayerSupport` /
`useIsCommunity` / `useIsAdmin` as one-line shims. Also added
`useIsAmbassador()` as a sibling — not derived from security level,
reads the boolean field on the snapshot directly.
2. Audit. The 532cb28c migration covered 6 React-render reads but
missed 5 more discovered by a follow-up grep:
- FurniEditorView (top-level `const isMod`)
- InfoStandWidgetFurniView (inline JSX, mod-only build-tools button)
- NavigatorRoomInfoView (3 reads in hasPermission(): isModerator
and securityLevel >= COMMUNITY for the staff-pick gate. The
userId read stays imperative — userId doesn't flip at runtime in
practice, no reactivity gain.)
- AvatarInfoWidgetPetView (inside useMemo with [roomSession] deps;
migrated and isModerator added to the deps so a runtime
promote/demote re-derives canPickUp without remount)
- FurnitureMannequinView (inside useEffect; same treatment — added
isModerator to the deps so the mode re-resolves on flip)
The remaining ~17 reads (CanManipulateFurniture,
AvatarInfoUtilities.populate*, useChatInputActions,
useFurnitureDimmerWidget / useFurniturePlaylistEditorWidget /
useFurnitureStickieWidget canModify checks, useCatalog admin
filter, useNavigator door-mode guard) are click-time / event-time
imperative — they read at the moment a user action fires, so a
reactive value would be cached at hook execution and stale by the
time the action runs. Leaving them on the synchronous manager read
is correct.
3. Test. Added four cases pinning the contract:
- useUserSecurityLevel returns the raw level.
- useHasSecurityLevel does `>=` against the threshold.
- Named wrappers map to the right constants (MODERATOR=5,
COMMUNITY=7, ADMINISTRATOR=8).
- **Reactive flip** — mutate the snapshot, dispatch the
SESSION_DATA_UPDATED event on the mock dispatcher, assert the
hook re-derives. Locks in the whole point of the snapshot
pattern (a static read would pass cases 1-3 but fail case 4).
Mock changes:
- Added SecurityLevel class (mirrors the renderer enum 0..9) so the
family wrappers resolve to actual numbers in jsdom — without it
`useIsModerator()` would call `useHasSecurityLevel(undefined)` and
the test would silently pass false-positives.
Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
213/213 (209 baseline + 4 new family/reactivity cases).
Two follow-ups to the CatalogPurchaseWidgetView fix (6bf3366):
1. CatalogItemGridWidgetView had the same shape — four useCallback
declarations (handleDragStart / handleDragOver / handleDrop /
handleDragEnd) sat below an `if(!currentPage) return null` early
return. When currentPage flipped from null to a real page the hook
count jumped by 4 and React would have thrown "Rendered more hooks
than during the previous render" the moment any consumer rendered
the grid in admin mode. Moved the four useCallback declarations
above the early-return; their bodies are safe pre-load (only
currentPage?.offers is accessed inside handleDrop, optional-chained
already).
2. CI gate — the existing GitHub Actions workflow runs `yarn
typecheck` and `yarn test`, but NOT `yarn eslint`. That's why this
pattern slipped through twice in a row: ESLint flags it locally
but no PR check enforces it. Full `yarn eslint` emits ~900
pre-existing baseline errors (brace-style, indentation,
recommended TS rules — out of scope for this branch), so a blanket
step would always fail. Instead added a focused
`eslint.hooks.config.mjs` + `yarn lint:hooks` script that runs
ESLint with ONLY `react-hooks/rules-of-hooks: error`. Wired into
ci.yml between `typecheck` and `test`. The local repo now has
zero violations of the rule.
3. useSessionSnapshots.test.tsx — added eslint-disable-next-line
comments on the three lines that intentionally violate the rule
(they're the assertions that the broken pattern crashes). Without
the comments the new CI gate would fail on the regression-guard
suite.
Verification: yarn lint:hooks green, yarn typecheck clean, yarn test
209/209.
Root cause of last session's "(intermediate value)() is undefined" at
ToolbarView.tsx:46:
use-between 1.x ships its own React-dispatcher proxy (ownDispatcher
in node_modules/use-between/release/index.esm.js:54-169) that
re-implements only useState, useReducer, useEffect, useLayoutEffect,
useCallback, useMemo, useRef and useImperativeHandle. It does NOT
implement useSyncExternalStore. When the inner state function of
useBetween(stateFn) calls useSyncExternalStore (directly or via
useExternalSnapshot / useUserDataSnapshot), React resolves the
dispatcher to use-between's proxy, finds .useSyncExternalStore
missing, and calls undefined() — that's the exact production crash
in Firefox. Chrome reports the same as
"dispatcher.useSyncExternalStore is not a function".
Neither the vite alias (790ad2b) nor the defensive renderer-method
guards (c35a2d4) could fix it — both addressed downstream symptoms
(stale dist / missing manager methods) but the dispatcher is upstream
of both. That's why every retry kept reproducing the same error.
Fix is structural: snapshot hooks (useUserDataSnapshot,
useIsUserIgnored, etc.) MUST run outside any useBetween scope. Three
re-applied migrations:
- useSessionInfo: snapshot read moved into the outer wrapper. The
inner useSessionInfoState (useBetween-shared) now contains only
use-between-safe hooks: useState, useMessageEvent, plain actions.
userFigure / userRespectRemaining / petRespectRemaining come from
useUserDataSnapshot() OUTSIDE useBetween, so useSyncExternalStore
installs against the real React dispatcher.
- useChatWidget.ownUserId: direct snapshot read. useChatWidget is
exported as `useChatWidget = useChatWidgetState` (NOT wrapped in
useBetween), so this hook never sat inside the broken scope — the
precautionary rollback was unnecessary in retrospect. Gains
session-change reactivity (e.g. reconnect under a different user id).
- AvatarInfoWidgetAvatarView Ignore/Unignore: useIsUserIgnored(name)
read directly in the component body. Same reasoning as
useChatWidget — never inside useBetween. The menu auto-flips
Ignore <-> Unignore while the popup is open.
Added regression guard at src/hooks/session/useSessionSnapshots.test.tsx
with two cases: (1) useSyncExternalStore inside useBetween throws,
(2) useSyncExternalStore outside useBetween in the same render works.
Pins the constraint so future migrations cannot reintroduce the bad
shape silently.
Verification: yarn typecheck clean, yarn test 209/209 (207 baseline
+ 2 new regression cases), no consumer surface changes — every
destructured field (userFigure, userRespectRemaining, respectUser,
petRespectRemaining, respectPet, chatStyleId, updateChatStyleId) is
still returned with the same name and shape.