Navigator Modernization — P1: Hook Split + UI Store
Branch: feat/navigator-modernization (forked from origin/Dev @ d5d5ca59)
Date: 2026-05-26
Scope: P1 of a 4-phase Navigator modernization sweep (P1 → P2 → P3 → P4).
This spec covers ONLY P1. P2 (TanStack Query), P3 (reactive snapshots),
and P4 (visual rework + virtualization + persistence) will each get their
own spec when P1 lands.
1. Context
The Nitro-V3 client has established patterns for god-hook
modernization, all visible on the current origin/Dev tip:
- God-hook split into filters over a
useBetween singleton. Two
precedents:
useWiredTools — 4 files (useWiredToolsStore + useWiredToolsState
useWiredToolsActions + useWiredTools shim). 630-line store.
useCatalog — single 1055-line file holding store + three filters
(useCatalogData / useCatalogUiState / useCatalogActions).
- Zustand UI stores via
createNitroStore (src/state/createNitroStore.ts)
for cross-feature UI flags.
- Renderer snapshot consumer hooks (
useSyncExternalStore) — out of
scope for P1, used in P3.
useNitroQuery for composer/parser request-response — out of
scope for P1, used in P2.
- Co-located Vitest suites under
src/, sharing the renderer-SDK
stub at src/nitro-renderer.mock.ts.
src/hooks/navigator/useNavigator.ts is the largest remaining god-hook
on this branch: 492 lines, 21 event listeners, 9 internal useState,
consumed by 13 files (10 inside src/components/navigator/ + 3
outside in room-tools, room-filter-words, and catalog views). It
mixes three logically separate concerns:
- Navigator data — search results, categories, top-level
contexts, favourites, metadata.
- Door state — doorbell, password prompt, accepted / no-answer /
wrong-password lifecycle.
- Local UI flags — 9
useState in NavigatorView.tsx controlling
panel visibility and search lifecycle.
P1 separates these three and migrates all consumers.
2. Decisions
| Topic |
Decision |
| Door state |
Extract to src/hooks/rooms/widgets/useDoorState.ts |
| UI store scope |
All 9 flags into navigatorUiStore Zustand |
| Shim retention |
Remove useNavigator after all 13 consumers migrated |
| Filter shape |
Flat objects, mirroring useCatalog and useWiredTools |
| File layout |
4 separate files, mirroring wired-tools (not the monolithic useCatalog.ts) |
| Scope of P1 |
Pure refactor — zero user-visible change |
| Branch |
feat/navigator-modernization (forked from origin/Dev, not a sub-branch of any other modernization branch) |
3. Architecture
Mirrors the wired-tools layout exactly — 4 hook files in
src/hooks/navigator/, plus a sibling navigatorUiStore.ts for the
Zustand UI flags, plus useDoorState.ts extracted to
src/hooks/rooms/widgets/:
src/hooks/navigator/
├── useNavigatorStore.ts ← NEW: internal useBetween closure
│ (data state + non-door listeners + actions)
├── useNavigatorData.ts ← NEW: public filter — read-only data
├── useNavigatorUiState.ts ← NEW: public filter — read-only UI flags
├── useNavigatorActions.ts ← NEW: public filter — imperative actions
├── navigatorUiStore.ts ← NEW: Zustand UI store (9 flags + actions)
├── index.ts ← REWRITTEN: barrel exports the 3 filters,
│ useNavigatorUiStore, and re-exports useDoorState
└── useNavigator.ts ← DELETED at end of P1 (god-hook shim removed)
src/hooks/rooms/widgets/
└── useDoorState.ts ← NEW: extracted door lifecycle
3.1 Internal useNavigatorStore closure (in useNavigatorStore.ts)
The single useBetween singleton's internal function. Holds:
- All non-door state currently in
useNavigatorState of the old
useNavigator.ts: categories, eventCategories,
favouriteRoomIds, topLevelContext, topLevelContexts,
searchResult, navigatorSearches, navigatorData.
- All non-door event listeners (16 of them):
FavouritesEvent,
FavouriteChangedEvent, RoomSettingsUpdatedEvent,
CanCreateRoomEventEvent, UserInfoEvent, UserPermissionsEvent,
RoomForwardEvent, RoomEntryInfoMessageEvent,
NavigatorMetadataEvent, NavigatorSearchEvent,
UserFlatCatsEvent, UserEventCatsEvent, FlatCreatedEvent,
NavigatorHomeRoomEvent, RoomEnterErrorEvent,
NavigatorOpenRoomCreatorEvent, NavigatorSearchesEvent,
plus NitroEventType.SOCKET_RECONNECTING.
GetGuestRoomResultEvent — dual-subscribed (see §5.2).
GenericErrorEvent — dual-subscribed (see §5.3).
- New imperative actions
sendSearch and reloadCurrentSearch,
extracted from the current NavigatorView.tsx locals (today defined
on lines 42-79 of src/components/navigator/NavigatorView.tsx).
3.2 The three filters (flat shape, wired-tools layout)
// useNavigatorData.ts
import { useBetween } from 'use-between';
import { useNavigatorStore } from './useNavigatorStore';
export const useNavigatorData = () => {
const {
categories, eventCategories, favouriteRoomIds,
topLevelContext, topLevelContexts,
searchResult, navigatorSearches, navigatorData,
} = useBetween(useNavigatorStore);
return {
categories, eventCategories, favouriteRoomIds,
topLevelContext, topLevelContexts,
searchResult, navigatorSearches, navigatorData,
};
};
// useNavigatorUiState.ts
import { useNavigatorUiStore } from './navigatorUiStore';
export const useNavigatorUiState = () => {
const isVisible = useNavigatorUiStore(s => s.isVisible);
const isReady = useNavigatorUiStore(s => s.isReady);
const isCreatorOpen = useNavigatorUiStore(s => s.isCreatorOpen);
const isRoomInfoOpen = useNavigatorUiStore(s => s.isRoomInfoOpen);
const isRoomLinkOpen = useNavigatorUiStore(s => s.isRoomLinkOpen);
const isOpenSavesSearches = useNavigatorUiStore(s => s.isOpenSavesSearches);
const isLoading = useNavigatorUiStore(s => s.isLoading);
const needsInit = useNavigatorUiStore(s => s.needsInit);
const needsSearch = useNavigatorUiStore(s => s.needsSearch);
return {
isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen,
isOpenSavesSearches, isLoading, needsInit, needsSearch,
};
};
// useNavigatorActions.ts
import { useBetween } from 'use-between';
import { useNavigatorStore } from './useNavigatorStore';
export const useNavigatorActions = () => {
const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore);
return { sendSearch, reloadCurrentSearch };
};
useNavigatorActions is intentionally small in P1 — favourite
toggles, room visits, and door responses keep flowing through their
existing direct composer calls in consumer components. We only hoist
the two functions that are currently prop-drilled into
NavigatorSearchView and the tab onClick handlers.
useNavigatorUiState uses per-key Zustand selectors (one selector
per flag) so a component re-renders only when a flag it actually
reads changes. The flat object it returns preserves the API shape
consumers expect.
3.3 navigatorUiStore (Zustand)
// src/hooks/navigator/navigatorUiStore.ts
import { createNitroStore } from '../../state/createNitroStore';
type NavigatorUiState = {
isVisible: boolean;
isReady: boolean;
isCreatorOpen: boolean;
isRoomInfoOpen: boolean;
isRoomLinkOpen: boolean;
isOpenSavesSearches: boolean;
isLoading: boolean;
needsInit: boolean;
needsSearch: boolean;
};
type NavigatorUiActions = {
show(): void;
hide(): void;
toggle(): void;
openCreator(): void;
closeCreator(): void;
setRoomInfoOpen(open: boolean): void;
toggleRoomInfo(): void;
setRoomLinkOpen(open: boolean): void;
toggleRoomLink(): void;
toggleSavesSearches(): void;
setLoading(loading: boolean): void;
markReady(): void;
markInitDone(): void;
requestSearch(): void; // sets needsSearch = true
consumeSearchRequest(): void; // sets needsSearch = false
};
const INITIAL: NavigatorUiState = {
isVisible: false,
isReady: false,
isCreatorOpen: false,
isRoomInfoOpen: false,
isRoomLinkOpen: false,
isOpenSavesSearches: false,
isLoading: false,
needsInit: true,
needsSearch: false,
};
export const useNavigatorUiStore = createNitroStore<NavigatorUiState & NavigatorUiActions>()((set) => ({
...INITIAL,
show: () => set({ isVisible: true, needsSearch: true }),
hide: () => set({ isVisible: false }),
toggle: () => set((s) => s.isVisible
? { isVisible: false }
: { isVisible: true, needsSearch: true }),
openCreator: () => set({ isVisible: true, isCreatorOpen: true }),
closeCreator: () => set({ isCreatorOpen: false }),
setRoomInfoOpen: (open) => set({ isRoomInfoOpen: open }),
toggleRoomInfo: () => set((s) => ({ isRoomInfoOpen: !s.isRoomInfoOpen })),
setRoomLinkOpen: (open) => set({ isRoomLinkOpen: open }),
toggleRoomLink: () => set((s) => ({ isRoomLinkOpen: !s.isRoomLinkOpen })),
toggleSavesSearches: () => set((s) => ({ isOpenSavesSearches: !s.isOpenSavesSearches })),
setLoading: (loading) => set({ isLoading: loading }),
markReady: () => set({ isReady: true }),
markInitDone: () => set({ needsInit: false }),
requestSearch: () => set({ needsSearch: true }),
consumeSearchRequest: () => set({ needsSearch: false }),
}));
The linkTracker in NavigatorView.tsx calls these actions directly
on useNavigatorUiStore.getState() instead of mutating local
useState. That collapses the switch statement from 30+ lines to a
clean dispatch table and eliminates the closure-over-stale-state hazard
where the tracker re-registers on every isVisible change (today at
src/components/navigator/NavigatorView.tsx:162).
3.4 useDoorState (extracted to src/hooks/rooms/widgets/)
// src/hooks/rooms/widgets/useDoorState.ts
import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent,
GenericErrorEvent, GetGuestRoomResultEvent,
GetSessionDataManager, RoomDataParser,
RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer';
import { useCallback, useState } from 'react';
import { useBetween } from 'use-between';
import { DoorStateType } from '../../../api';
import { useMessageEvent } from '../../events';
export type DoorStateSnapshot = {
roomInfo: RoomDataParser | null;
state: number; // DoorStateType.*
};
const INITIAL: DoorStateSnapshot = { roomInfo: null, state: DoorStateType.NONE };
const useDoorStateStore = () => {
const [snapshot, setSnapshot] = useState<DoorStateSnapshot>(INITIAL);
useMessageEvent<DoorbellMessageEvent>(DoorbellMessageEvent, event => {
const parser = event.getParser();
if (parser.userName && parser.userName.length > 0) return;
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WAITING }));
});
useMessageEvent<RoomDoorbellAcceptedEvent>(RoomDoorbellAcceptedEvent, event => {
const parser = event.getParser();
if (parser.userName && parser.userName.length > 0) return;
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_ACCEPTED }));
});
useMessageEvent<FlatAccessDeniedMessageEvent>(FlatAccessDeniedMessageEvent, event => {
const parser = event.getParser();
if (parser.userName && parser.userName.length > 0) return;
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_NO_ANSWER }));
});
useMessageEvent<GenericErrorEvent>(GenericErrorEvent, event => {
const parser = event.getParser();
if (parser.errorCode !== -100002) return; // door-only error code
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WRONG_PASSWORD }));
});
useMessageEvent<GetGuestRoomResultEvent>(GetGuestRoomResultEvent, event => {
const parser = event.getParser();
// ONLY handle the roomForward branch with door modes
if (!parser.roomForward) return;
if (parser.data.ownerName === GetSessionDataManager().userName) return;
if (parser.isGroupMember) return;
if (parser.data.doorMode === RoomDataParser.DOORBELL_STATE) {
setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_DOORBELL });
} else if (parser.data.doorMode === RoomDataParser.PASSWORD_STATE) {
setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_PASSWORD });
}
});
const reset = useCallback(() => setSnapshot(INITIAL), []);
return { snapshot, setSnapshot, reset };
};
export const useDoorState = () => useBetween(useDoorStateStore);
The current NavigatorDoorStateView.tsx does
setDoorData({ roomInfo: null, state: DoorStateType.NONE }) to reset
— after P1 it calls reset().
4. Consumer migration map (13 files)
| File |
Reads today |
Reads after P1 |
NavigatorView.tsx |
full useNavigator() + 9 local useState |
useNavigatorData + useNavigatorActions + useNavigatorUiStore (one selector per flag) |
NavigatorDoorStateView.tsx |
doorData, setDoorData |
useDoorState (snapshot, setSnapshot, reset) |
NavigatorRoomCreatorView.tsx |
categories |
useNavigatorData |
NavigatorRoomInfoView.tsx |
navigatorData, favouriteRoomIds |
useNavigatorData |
NavigatorRoomLinkView.tsx |
navigatorData.enteredGuestRoom |
useNavigatorData |
NavigatorRoomSettingsBasicTabView.tsx |
categories |
useNavigatorData |
NavigatorSearchResultItemView.tsx |
favouriteRoomIds, navigatorData |
useNavigatorData |
NavigatorSearchResultItemInfoView.tsx |
navigatorData |
useNavigatorData |
NavigatorSearchResultView.tsx |
topLevelContext |
useNavigatorData |
NavigatorSearchView.tsx |
topLevelContext + sendSearch prop |
useNavigatorData + useNavigatorActions |
CatalogLayoutRoomAdsView.tsx |
navigatorData.currentRoomId |
useNavigatorData |
RoomFilterWordsWidgetView.tsx |
navigatorData.currentRoomId |
useNavigatorData |
RoomToolsWidgetView.tsx |
navigatorData |
useNavigatorData |
All 13 consumers get a one-line import swap (plus NavigatorView
which is more involved since it owns the 9 useState + linkTracker
dispatch + sendSearch prop drilling that all go away). No
behavioural change.
5. Dual-subscription edge cases
5.1 useBetween guarantee
useDoorState uses useBetween(useDoorStateStore), so multiple
consumers (currently only NavigatorDoorStateView) share a single
listener registration — same as how useNavigatorStore works.
5.2 GetGuestRoomResultEvent — dual subscription
Today this event is handled in one place (current useNavigator.ts
lines 130-209) with three branches: roomEnter, roomForward, else.
After P1:
useDoorStateStore subscribes and acts ONLY on the roomForward
branch when doorMode is DOORBELL_STATE or PASSWORD_STATE AND
the user is not the owner / not a group member.
useNavigatorStore subscribes and handles roomEnter, the
roomForward branch WITHOUT door modes (direct CreateRoomSession
call), and the else branch.
Multiple subscribers to the same event is an accepted pattern (see
FlatCreatedEvent listened in useNavigator and elsewhere). Both
listeners register through useMessageEvent so the renderer event
bus dispatches to both.
5.3 GenericErrorEvent — dual subscription
useDoorStateStore acts ONLY on errorCode === -100002 (wrong
password).
useNavigatorStore acts on 4009, 4010, 4011, 4013 (room
management alerts via simpleAlert).
Each side filters by errorCode immediately — no cross-effects.
6. Visual direction (anchor for P4 — informational only)
P1 ships zero visual change. This section documents the visual
target that P4's spec will detail, so the architecture choices in
P1 align with where we are heading.
6.1 Current pain points (from user screenshots, 2026-05-26)
- Tab "Pubbliche": empty state is bare text "No rooms found".
- Tab "Tutte le stanze": popular rooms shown as a small thumbnail
grid; the "Party" category uses a compact list mode with no
visual hierarchy or live signal.
- Tab "Eventi": empty state is bare text "No rooms found".
- Tab "Il mio mondo": sparse list, no per-room preview.
- Saved searches: today a 600px-wide sidebar that resizes the
card and pushes content right.
- Filter dropdown "Qualsiasi": opaque about what filters exist.
6.2 Target shape (P4 spec will detail)
Empty states with illustration + contextual CTA:
┌─────────────────────────────────────┐
│ Navigator @ Habbo [×] │
│ [⚡][Pubbliche][Tutte][Eventi][Mio] │
├─────────────────────────────────────┤
│ [🔓 Aperte] [🚪 Campanello] [🔒] │
│ [filtra stanze...] 🔍 │
│ [🔖 staff] [🔖 party] [🔖 chill] + │
├─────────────────────────────────────┤
│ ╭──────────╮ │
│ │ 🏠 ✨ │ │
│ ╰──────────╯ │
│ Nessuna stanza pubblica │
│ ancora attiva │
│ │
│ [ Esplora stanze popolari → ] │
├─────────────────────────────────────┤
│ [+ Crea stanza] [Da qualche parte] │
└─────────────────────────────────────┘
Card list with row-level hover-reveal:
▼ Stanze più popolari [▦ ☰] [⚡]
┌─────────┐ Big Party Room
│ 🏠 🎵 │ 👤 22 · 🔓 Aperta · ★ 4.7
│ (img) │ by @Cocco
└─────────┘ [Entra] [ⓘ] [☆ favori] ← shown on row hover
─────────────────────────────────────────
▼ Party [▦ ☰] [⚡]
🟢 fcfcvcvcv 👤2 🔓 [ⓘ]
🔒 aaaaa 👤1 🚪 [ⓘ]
Saved searches as horizontal chip row above the filter input
(replaces the 600px sidebar — no layout shift on toggle).
Filter intent as visible chips instead of "Qualsiasi" dropdown:
🔓 Aperte 🚪 Campanello 🔒 Con password 👥 Solo amici.
Sticky section headers when scrolling long lists.
Skeleton loaders during fetch (post-P2 when query state lands).
Per-card actions on hover: favourite ☆, info ⓘ, room link 🔗.
6.3 Why P1 architecture supports this
useNavigatorUiStore makes future flags (viewMode: 'compact' | 'expanded',
lastTab, lastScrollTop) trivial to add — they're new state on
the store; persistence can be added with a Zustand persist
middleware on a single line.
- Splitting
useDoorState out means the visual rework of the door
prompt (a separate panel, possibly modal) can evolve independently
of Navigator search UI.
- Three flat filters mean a new card variant (compact-vs-expanded
list) reads
useNavigatorData only — no risk of re-rendering the
whole Navigator when card-mode toggles.
7. Testing strategy
Coherent with CLAUDE.md "yarn test must stay green on every
commit":
| Suite |
New / changed |
Cases (target) |
navigatorUiStore.test.ts |
NEW |
~30: each action idempotent on no-op, transitions valid, requestSearch/consumeSearchRequest symmetric |
useDoorState.test.tsx |
NEW |
~12: each event listener happy path + filter-by-userName + filter-by-errorCode + reset() |
useNavigatorStore.test.tsx |
NEW (smoke) |
~5: 3 filters return expected shape, dispatch updates propagate to useNavigatorData, GenericError 4010 does NOT touch door state, GenericError -100002 DOES touch door state |
| Existing Vitest suites |
Stay green |
— |
All tests co-located under src/, alongside their subject. Reuse
src/nitro-renderer.mock.ts for event dispatching (the
mockEventDispatcher / clearMockEventDispatcher helpers).
CI gates that must stay green: yarn typecheck (TS 7 native),
yarn test, yarn lint:hooks (react-hooks/rules-of-hooks: error).
8. Compatibility with project conventions
feat/navigator-modernization is forked from origin/Dev @ d5d5ca59,
so it carries everything upstream has shipped through the floorplan
editor work + classic catalog view + emustats + housekeeping panel.
The design respects every constraint of this base:
- No new dependencies. Uses
zustand (present), use-between
(present), vitest (present), createNitroStore (present at
src/state/createNitroStore.ts).
- React 19 idioms identical to the rest of the codebase. No
manual
useMemo/useCallback unless the React Compiler asks for
them.
- TypeScript strict consistent with the rest of the project.
- Co-located tests under
src/ per the layout convention.
- No conflicts with adopted patterns:
useNitroEvent,
useMessageEvent, useBetween, createNitroStore. The new
filters expose plain data — they don't call snapshot hooks
(useSyncExternalStore) inside useBetween scopes, so the
documented "snapshot-outside-useBetween" constraint never
triggers here.
- Commit author per house rules:
simoleo89 <simoleo89@users.noreply.github.com> via per-command -c
overrides. No Co-Authored-By trailer.
- Branch policy: fresh branch off
origin/Dev, pushable
fast-forward to simoleo/feat/navigator-modernization (which
doesn't yet exist on the fork — first push creates it). No
force-push required.
9. Out of scope (explicit)
- TanStack Query migration of search (P2).
- Reactive favourite icons via snapshot (P3).
- Live user counts via snapshot (P3).
- Virtualization of result list (P4).
- Empty-state component (P4).
- Saved-search chip row (P4).
- Persistence of tab/scroll/filter (P4).
useActionState on search input (P6).
WidgetErrorBoundary wrapping of Navigator sub-views (P5 —
independent, can land in parallel).
- Any visual change. P1 ships byte-identical UI.
- Any change to
NavigatorRoomSettings* subtree (self-contained,
only reads categories in one tab).
10. Acceptance criteria
P1 is complete when:
src/hooks/navigator/useNavigator.ts does NOT exist (god-hook
removed).
src/hooks/navigator/ contains useNavigatorStore.ts,
useNavigatorData.ts, useNavigatorUiState.ts,
useNavigatorActions.ts, navigatorUiStore.ts, and an updated
index.ts.
src/hooks/rooms/widgets/useDoorState.ts exists.
- All 13 active consumers compile after their import swap.
yarn typecheck clean.
yarn lint:hooks clean.
yarn test --run green, with at least 3 new suites
(navigatorUiStore, useDoorState, useNavigatorStore smoke).
- Manual smoke test: open Navigator, switch each top-level tab, run
a search, open a room with a doorbell, get rejected, open a room
with a password, enter the right password, enter wrong password,
open a room you own, click a favourite ☆, open RoomInfo, open
RoomLink. Each path renders identically to pre-P1 behaviour.
- Branch
feat/navigator-modernization pushed (fast-forward only)
to simoleo/feat/navigator-modernization on the user's fork.
11. Risk register
| Risk |
Likelihood |
Mitigation |
| A consumer reads a field we forgot to expose on a filter |
medium |
Type-checker catches it — all 13 consumers re-typecheck on swap |
Dual-subscription on GetGuestRoomResultEvent causes double CreateRoomSession |
low |
useDoorStateStore only acts on doorMode bell/password; useNavigatorStore only acts on the other branches. Explicit if guards on both sides |
linkTracker re-registration leaks because deps changed |
low |
New tracker reads useNavigatorUiStore.getState() instead of closure-captured state, so its useEffect deps shrink |
useDoorState consumer in NavigatorDoorStateView regresses on reset() semantics |
low |
Smoke test in §10 covers this |
Per-key Zustand selectors in useNavigatorUiState cause stale-closure issues |
low |
Each selector is one-shot, no derived values; identical pattern to existing Zustand stores in the codebase |
| Renderer SDK mismatch on local dev (e.g. floorplan-live-preview not in renderer's main) |
medium |
Already exists today regardless of this PR; surface in plan as a yarn typecheck caveat, not introduced by P1 |
Navigator Modernization — P1: Hook Split + UI Store
Branch:
feat/navigator-modernization(forked fromorigin/Dev@d5d5ca59) Date: 2026-05-26 Scope: P1 of a 4-phase Navigator modernization sweep (P1 → P2 → P3 → P4). This spec covers ONLY P1. P2 (TanStack Query), P3 (reactive snapshots), and P4 (visual rework + virtualization + persistence) will each get their own spec when P1 lands.1. Context
The Nitro-V3 client has established patterns for god-hook modernization, all visible on the current
origin/Devtip:useBetweensingleton. Two precedents:useWiredTools— 4 files (useWiredToolsStore+useWiredToolsStateuseWiredToolsActions+useWiredToolsshim). 630-line store.useCatalog— single 1055-line file holding store + three filters (useCatalogData/useCatalogUiState/useCatalogActions).createNitroStore(src/state/createNitroStore.ts) for cross-feature UI flags.useSyncExternalStore) — out of scope for P1, used in P3.useNitroQueryfor composer/parser request-response — out of scope for P1, used in P2.src/, sharing the renderer-SDK stub atsrc/nitro-renderer.mock.ts.src/hooks/navigator/useNavigator.tsis the largest remaining god-hook on this branch: 492 lines, 21 event listeners, 9 internaluseState, consumed by 13 files (10 insidesrc/components/navigator/+ 3 outside inroom-tools,room-filter-words, andcatalogviews). It mixes three logically separate concerns:useStateinNavigatorView.tsxcontrolling panel visibility and search lifecycle.P1 separates these three and migrates all consumers.
2. Decisions
src/hooks/rooms/widgets/useDoorState.tsnavigatorUiStoreZustanduseNavigatorafter all 13 consumers migrateduseCataloganduseWiredToolswired-tools(not the monolithicuseCatalog.ts)feat/navigator-modernization(forked fromorigin/Dev, not a sub-branch of any other modernization branch)3. Architecture
Mirrors the
wired-toolslayout exactly — 4 hook files insrc/hooks/navigator/, plus a siblingnavigatorUiStore.tsfor the Zustand UI flags, plususeDoorState.tsextracted tosrc/hooks/rooms/widgets/:3.1 Internal
useNavigatorStoreclosure (inuseNavigatorStore.ts)The single
useBetweensingleton's internal function. Holds:useNavigatorStateof the olduseNavigator.ts:categories,eventCategories,favouriteRoomIds,topLevelContext,topLevelContexts,searchResult,navigatorSearches,navigatorData.FavouritesEvent,FavouriteChangedEvent,RoomSettingsUpdatedEvent,CanCreateRoomEventEvent,UserInfoEvent,UserPermissionsEvent,RoomForwardEvent,RoomEntryInfoMessageEvent,NavigatorMetadataEvent,NavigatorSearchEvent,UserFlatCatsEvent,UserEventCatsEvent,FlatCreatedEvent,NavigatorHomeRoomEvent,RoomEnterErrorEvent,NavigatorOpenRoomCreatorEvent,NavigatorSearchesEvent, plusNitroEventType.SOCKET_RECONNECTING.GetGuestRoomResultEvent— dual-subscribed (see §5.2).GenericErrorEvent— dual-subscribed (see §5.3).sendSearchandreloadCurrentSearch, extracted from the currentNavigatorView.tsxlocals (today defined on lines 42-79 ofsrc/components/navigator/NavigatorView.tsx).3.2 The three filters (flat shape, wired-tools layout)
useNavigatorActionsis intentionally small in P1 — favourite toggles, room visits, and door responses keep flowing through their existing direct composer calls in consumer components. We only hoist the two functions that are currently prop-drilled intoNavigatorSearchViewand the tabonClickhandlers.useNavigatorUiStateuses per-key Zustand selectors (one selector per flag) so a component re-renders only when a flag it actually reads changes. The flat object it returns preserves the API shape consumers expect.3.3
navigatorUiStore(Zustand)The
linkTrackerinNavigatorView.tsxcalls these actions directly onuseNavigatorUiStore.getState()instead of mutating localuseState. That collapses the switch statement from 30+ lines to a clean dispatch table and eliminates the closure-over-stale-state hazard where the tracker re-registers on everyisVisiblechange (today atsrc/components/navigator/NavigatorView.tsx:162).3.4
useDoorState(extracted tosrc/hooks/rooms/widgets/)The current
NavigatorDoorStateView.tsxdoessetDoorData({ roomInfo: null, state: DoorStateType.NONE })to reset — after P1 it callsreset().4. Consumer migration map (13 files)
NavigatorView.tsxuseNavigator()+ 9 local useStateuseNavigatorData+useNavigatorActions+useNavigatorUiStore(one selector per flag)NavigatorDoorStateView.tsxdoorData,setDoorDatauseDoorState(snapshot,setSnapshot,reset)NavigatorRoomCreatorView.tsxcategoriesuseNavigatorDataNavigatorRoomInfoView.tsxnavigatorData,favouriteRoomIdsuseNavigatorDataNavigatorRoomLinkView.tsxnavigatorData.enteredGuestRoomuseNavigatorDataNavigatorRoomSettingsBasicTabView.tsxcategoriesuseNavigatorDataNavigatorSearchResultItemView.tsxfavouriteRoomIds,navigatorDatauseNavigatorDataNavigatorSearchResultItemInfoView.tsxnavigatorDatauseNavigatorDataNavigatorSearchResultView.tsxtopLevelContextuseNavigatorDataNavigatorSearchView.tsxtopLevelContext+sendSearchpropuseNavigatorData+useNavigatorActionsCatalogLayoutRoomAdsView.tsxnavigatorData.currentRoomIduseNavigatorDataRoomFilterWordsWidgetView.tsxnavigatorData.currentRoomIduseNavigatorDataRoomToolsWidgetView.tsxnavigatorDatauseNavigatorDataAll 13 consumers get a one-line import swap (plus
NavigatorViewwhich is more involved since it owns the 9 useState + linkTracker dispatch +sendSearchprop drilling that all go away). No behavioural change.5. Dual-subscription edge cases
5.1
useBetweenguaranteeuseDoorStateusesuseBetween(useDoorStateStore), so multiple consumers (currently onlyNavigatorDoorStateView) share a single listener registration — same as howuseNavigatorStoreworks.5.2
GetGuestRoomResultEvent— dual subscriptionToday this event is handled in one place (current
useNavigator.tslines 130-209) with three branches:roomEnter,roomForward, else. After P1:useDoorStateStoresubscribes and acts ONLY on theroomForwardbranch whendoorModeisDOORBELL_STATEorPASSWORD_STATEAND the user is not the owner / not a group member.useNavigatorStoresubscribes and handlesroomEnter, theroomForwardbranch WITHOUT door modes (directCreateRoomSessioncall), and theelsebranch.Multiple subscribers to the same event is an accepted pattern (see
FlatCreatedEventlistened inuseNavigatorand elsewhere). Both listeners register throughuseMessageEventso the renderer event bus dispatches to both.5.3
GenericErrorEvent— dual subscriptionuseDoorStateStoreacts ONLY onerrorCode === -100002(wrong password).useNavigatorStoreacts on4009,4010,4011,4013(room management alerts viasimpleAlert).Each side filters by
errorCodeimmediately — no cross-effects.6. Visual direction (anchor for P4 — informational only)
P1 ships zero visual change. This section documents the visual target that P4's spec will detail, so the architecture choices in P1 align with where we are heading.
6.1 Current pain points (from user screenshots, 2026-05-26)
6.2 Target shape (P4 spec will detail)
Empty states with illustration + contextual CTA:
Card list with row-level hover-reveal:
Saved searches as horizontal chip row above the filter input (replaces the 600px sidebar — no layout shift on toggle).
Filter intent as visible chips instead of "Qualsiasi" dropdown:
🔓 Aperte🚪 Campanello🔒 Con password👥 Solo amici.Sticky section headers when scrolling long lists.
Skeleton loaders during fetch (post-P2 when query state lands).
Per-card actions on hover: favourite ☆, info ⓘ, room link 🔗.
6.3 Why P1 architecture supports this
useNavigatorUiStoremakes future flags (viewMode: 'compact' | 'expanded',lastTab,lastScrollTop) trivial to add — they're new state on the store; persistence can be added with a Zustandpersistmiddleware on a single line.useDoorStateout means the visual rework of the door prompt (a separate panel, possibly modal) can evolve independently of Navigator search UI.useNavigatorDataonly — no risk of re-rendering the whole Navigator when card-mode toggles.7. Testing strategy
Coherent with
CLAUDE.md"yarn testmust stay green on every commit":navigatorUiStore.test.tsrequestSearch/consumeSearchRequestsymmetricuseDoorState.test.tsxuseNavigatorStore.test.tsxuseNavigatorData, GenericError 4010 does NOT touch door state, GenericError -100002 DOES touch door stateAll tests co-located under
src/, alongside their subject. Reusesrc/nitro-renderer.mock.tsfor event dispatching (themockEventDispatcher/clearMockEventDispatcherhelpers).CI gates that must stay green:
yarn typecheck(TS 7 native),yarn test,yarn lint:hooks(react-hooks/rules-of-hooks: error).8. Compatibility with project conventions
feat/navigator-modernizationis forked fromorigin/Dev@d5d5ca59, so it carries everything upstream has shipped through the floorplan editor work + classic catalog view + emustats + housekeeping panel. The design respects every constraint of this base:zustand(present),use-between(present),vitest(present),createNitroStore(present atsrc/state/createNitroStore.ts).useMemo/useCallbackunless the React Compiler asks for them.src/per the layout convention.useNitroEvent,useMessageEvent,useBetween,createNitroStore. The new filters expose plain data — they don't call snapshot hooks (useSyncExternalStore) insideuseBetweenscopes, so the documented "snapshot-outside-useBetween" constraint never triggers here.simoleo89 <simoleo89@users.noreply.github.com>via per-command-coverrides. No Co-Authored-By trailer.origin/Dev, pushable fast-forward tosimoleo/feat/navigator-modernization(which doesn't yet exist on the fork — first push creates it). No force-push required.9. Out of scope (explicit)
useActionStateon search input (P6).WidgetErrorBoundarywrapping of Navigator sub-views (P5 — independent, can land in parallel).NavigatorRoomSettings*subtree (self-contained, only readscategoriesin one tab).10. Acceptance criteria
P1 is complete when:
src/hooks/navigator/useNavigator.tsdoes NOT exist (god-hook removed).src/hooks/navigator/containsuseNavigatorStore.ts,useNavigatorData.ts,useNavigatorUiState.ts,useNavigatorActions.ts,navigatorUiStore.ts, and an updatedindex.ts.src/hooks/rooms/widgets/useDoorState.tsexists.yarn typecheckclean.yarn lint:hooksclean.yarn test --rungreen, with at least 3 new suites (navigatorUiStore,useDoorState,useNavigatorStoresmoke).feat/navigator-modernizationpushed (fast-forward only) tosimoleo/feat/navigator-modernizationon the user's fork.11. Risk register
GetGuestRoomResultEventcauses doubleCreateRoomSessionuseDoorStateStoreonly acts on doorMode bell/password;useNavigatorStoreonly acts on the other branches. Explicitifguards on both sideslinkTrackerre-registration leaks because deps changeduseNavigatorUiStore.getState()instead of closure-captured state, so itsuseEffectdeps shrinkuseDoorStateconsumer inNavigatorDoorStateViewregresses onreset()semanticsuseNavigatorUiStatecause stale-closure issuesyarn typecheckcaveat, not introduced by P1