Split usePollWidget into subscriptions + actions (proposal #4) + doc update

usePollWidget bundled two unrelated responsibilities:
- three useNitroEvent listeners that bridge RoomSessionPollEvent
  (OFFER / ERROR / CONTENT) onto the UI event bus via DispatchUiEvent
  — pure side-effects, zero local state, should mount once;
- three imperative actions (startPoll, rejectPoll, answerPoll) that
  every consumer wants, but which shouldn't re-register the listeners.

In practice the only consumer of usePollWidget was useWordQuizWidget,
which needed only `answerPoll` — but pulled in the three subscriptions
as a side effect every time the word-quiz widget rendered. That's the
classic god-hook anti-pattern this proposal targets.

Split (mirrors the doorbell pattern already in place):
- src/hooks/rooms/widgets/usePollSubscriptions.ts (new): the three
  bridge listeners, returns void. Should be mounted ONCE at the
  highest stable level above poll-aware UI (room widgets root). For
  now still mounted by the shim — follow-up PR can move it.
- src/hooks/rooms/widgets/usePollActions.ts (new): the three
  imperative actions. Defensive `?.` on roomSession so a poll action
  during a room transition no longer crashes.
- src/hooks/rooms/widgets/usePollWidget.ts: kept as a deprecated shim
  that composes both — preserves the old `{ startPoll, rejectPoll,
  answerPoll }` shape so existing consumers don't break.
- src/hooks/rooms/widgets/useWordQuizWidget.ts: migrated to import
  usePollActions directly. The word-quiz widget no longer registers
  poll subscriptions transitively — its render no longer has the side
  effect of subscribing to three renderer events.

Doc
- docs/ARCHITECTURE.md "What's already in place": records both god-hook
  splits (doorbell + poll), the now-enabled React Query and Zustand,
  and the test infrastructure. Removes the "not yet enabled" markers
  for #2 and #5.
- "How to pick the next refactor PR": rewritten to reflect that the
  foundations are done. New priority order:
    1. migrate useCatalog's read-only fetches to useNitroQuery,
    2. hoist usePollSubscriptions to room-session level,
    3. split useCatalog along the doorbell/poll lines,
    4. broaden Vitest coverage,
    5. per-tab WiredCreatorToolsView split.

Verification
- yarn eslint on the touched files: 0 errors / 0 warnings.
- yarn test: 22/22 passing, 2 files, ~1.0s.
- Existing useWordQuizWidget consumers (RoomWidgetsView ->
  WordQuizWidgetView) unaffected — they import from the barrel which
  still re-exports the same shape.

https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
This commit is contained in:
simoleo89
2026-05-11 16:31:53 +00:00
parent 6793de2106
commit 7218285583
6 changed files with 126 additions and 77 deletions
+2
View File
@@ -10,6 +10,8 @@ export * from './useFilterWordsWidget';
export * from './useFriendRequestWidget';
export * from './useFurniChooserWidget';
export * from './usePetPackageWidget';
export * from './usePollActions';
export * from './usePollSubscriptions';
export * from './usePollWidget';
export * from './useUserChooserWidget';
export * from './useWordQuizWidget';
+19
View File
@@ -0,0 +1,19 @@
import { useRoom } from '../useRoom';
/**
* Imperative poll actions. Stateless on purpose — split from
* usePollSubscriptions so components that only need to dispatch a
* vote / accept / reject don't also register the global subscription
* listeners.
*/
export const usePollActions = () =>
{
const { roomSession = null } = useRoom();
return {
startPoll: (pollId: number) => roomSession?.sendPollStartMessage(pollId),
rejectPoll: (pollId: number) => roomSession?.sendPollRejectMessage(pollId),
answerPoll: (pollId: number, questionId: number, answers: string[]) =>
roomSession?.sendPollAnswerMessage(pollId, questionId, answers)
};
};
@@ -0,0 +1,47 @@
import { RoomSessionPollEvent } from '@nitrots/nitro-renderer';
import { DispatchUiEvent, RoomWidgetPollUpdateEvent } from '../../../api';
import { useNitroEvent } from '../../events';
/**
* Bridges the three poll-related renderer events (OFFER / ERROR / CONTENT)
* onto the UI event bus. Pure subscription — no React state, no return
* value. Mount this once where polls should be observable.
*
* This is the "subscriptions" half of the proposal #4 split for
* usePollWidget. The "actions" half is in usePollActions.
*/
export const usePollSubscriptions = (): void =>
{
useNitroEvent<RoomSessionPollEvent>(RoomSessionPollEvent.OFFER, event =>
{
const pollEvent = new RoomWidgetPollUpdateEvent(RoomWidgetPollUpdateEvent.OFFER, event.id);
pollEvent.summary = event.summary;
pollEvent.headline = event.headline;
DispatchUiEvent(pollEvent);
});
useNitroEvent<RoomSessionPollEvent>(RoomSessionPollEvent.ERROR, event =>
{
const pollEvent = new RoomWidgetPollUpdateEvent(RoomWidgetPollUpdateEvent.ERROR, event.id);
pollEvent.summary = event.summary;
pollEvent.headline = event.headline;
DispatchUiEvent(pollEvent);
});
useNitroEvent<RoomSessionPollEvent>(RoomSessionPollEvent.CONTENT, event =>
{
const pollEvent = new RoomWidgetPollUpdateEvent(RoomWidgetPollUpdateEvent.CONTENT, event.id);
pollEvent.startMessage = event.startMessage;
pollEvent.endMessage = event.endMessage;
pollEvent.numQuestions = event.numQuestions;
pollEvent.questionArray = event.questionArray;
pollEvent.npsPoll = event.npsPoll;
DispatchUiEvent(pollEvent);
});
};
+13 -48
View File
@@ -1,52 +1,17 @@
import { RoomSessionPollEvent } from '@nitrots/nitro-renderer';
import { DispatchUiEvent, RoomWidgetPollUpdateEvent } from '../../../api';
import { useNitroEvent } from '../../events';
import { useRoom } from '../useRoom';
import { usePollActions } from './usePollActions';
import { usePollSubscriptions } from './usePollSubscriptions';
const usePollWidgetState = () =>
/**
* @deprecated Prefer `usePollSubscriptions` (mount once, top-level) and
* `usePollActions` (anywhere a component dispatches a vote/accept/reject).
* This shim preserves the old `{ startPoll, rejectPoll, answerPoll }`
* shape for existing consumers, but each call also re-mounts the three
* subscription listeners — which is wrong if the hook is called from
* multiple places.
*/
export const usePollWidget = () =>
{
const { roomSession = null } = useRoom();
usePollSubscriptions();
const startPoll = (pollId: number) => roomSession.sendPollStartMessage(pollId);
const rejectPoll = (pollId: number) => roomSession.sendPollRejectMessage(pollId);
const answerPoll = (pollId: number, questionId: number, answers: string[]) => roomSession.sendPollAnswerMessage(pollId, questionId, answers);
useNitroEvent<RoomSessionPollEvent>(RoomSessionPollEvent.OFFER, event =>
{
const pollEvent = new RoomWidgetPollUpdateEvent(RoomWidgetPollUpdateEvent.OFFER, event.id);
pollEvent.summary = event.summary;
pollEvent.headline = event.headline;
DispatchUiEvent(pollEvent);
});
useNitroEvent<RoomSessionPollEvent>(RoomSessionPollEvent.ERROR, event =>
{
const pollEvent = new RoomWidgetPollUpdateEvent(RoomWidgetPollUpdateEvent.ERROR, event.id);
pollEvent.summary = event.summary;
pollEvent.headline = event.headline;
DispatchUiEvent(pollEvent);
});
useNitroEvent<RoomSessionPollEvent>(RoomSessionPollEvent.CONTENT, event =>
{
const pollEvent = new RoomWidgetPollUpdateEvent(RoomWidgetPollUpdateEvent.CONTENT, event.id);
pollEvent.startMessage = event.startMessage;
pollEvent.endMessage = event.endMessage;
pollEvent.numQuestions = event.numQuestions;
pollEvent.questionArray = event.questionArray;
pollEvent.npsPoll = event.npsPoll;
DispatchUiEvent(pollEvent);
});
return { startPoll, rejectPoll, answerPoll };
return usePollActions();
};
export const usePollWidget = usePollWidgetState;
+2 -2
View File
@@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
import { VoteValue } from '../../../api';
import { useNitroEvent } from '../../events';
import { useRoom } from '../useRoom';
import { usePollWidget } from './usePollWidget';
import { usePollActions } from './usePollActions';
const DEFAULT_DISPLAY_DELAY = 4000;
const SIGN_FADE_DELAY = 3;
@@ -16,7 +16,7 @@ const useWordQuizWidgetState = () =>
const [ questionClearTimeout, setQuestionClearTimeout ] = useState<ReturnType<typeof setTimeout>>(null);
const [ answerCounts, setAnswerCounts ] = useState<Map<string, number>>(new Map());
const [ userAnswers, setUserAnswers ] = useState<Map<number, VoteValue>>(new Map());
const { answerPoll = null } = usePollWidget();
const { answerPoll } = usePollActions();
const { roomSession = null } = useRoom();
const clearQuestion = () =>