fix(MainView): collapse CREATED/ENDED listeners into a session-aware reducer

Two independent useNitroEvent listeners updated landingViewVisible from
RoomSessionEvent.CREATED and ENDED with no notion of which session was
active. Under flaky websocket reconnects the events can land out of
order: a stale ENDED for the previous room arrives after CREATED for
the new one, flips landingViewVisible back to true, and the user is
left at the hotel view inside a room (or vice versa) until the next
room change.

Folds both events into one useNitroEventReducer that carries the
tracked sessionId. CREATED sets the id and closes the landing view;
ENDED is applied only when its event.session.roomId matches the
tracked id (or no session is active) — otherwise it's a stale ENDED
for a previous session and is ignored. The reducer companion is the
existing useNitroEventReducer from src/hooks/events, so no new
infrastructure.

Moves the entry in docs/ARCHITECTURE.md from "Open" to "Recently
fixed".
This commit is contained in:
simoleo89
2026-05-14 20:09:23 +02:00
parent 0f9fa1203b
commit 9d10e52a55
2 changed files with 36 additions and 42 deletions
+10 -38
View File
@@ -776,44 +776,6 @@ this repo.
### Open
#### `MainView` — race between `RoomSessionEvent.CREATED` and `ENDED`
`src/components/MainView.tsx:47-48` writes the same `landingViewVisible`
state from two independent listeners with no session-token guard:
```ts
useNitroEvent(RoomSessionEvent.CREATED, () => setLandingViewVisible(false));
useNitroEvent(RoomSessionEvent.ENDED, e => setLandingViewVisible(e.openLandingView));
```
If the events arrive out of order (fast reconnect, network reordering),
the final state contradicts the actual session state — landing view stuck
open inside a room, or stuck closed at the hotel view. Resolves on next
room change.
**Fix shape** (deferred until `useNitroEventReducer` companion lands —
see proposal #1):
```ts
// One reducer owns both events + the active session token
const { sessionId, landingViewVisible } = useNitroEventReducer<...>(
[RoomSessionEvent.CREATED, RoomSessionEvent.ENDED],
(state, e) => {
if (e.type === RoomSessionEvent.CREATED) {
return { sessionId: e.session.roomId, landingViewVisible: false };
}
if (state.sessionId !== null && e.session.roomId !== state.sessionId) {
return state; // stale ENDED for old session, ignore
}
return { sessionId: null, landingViewVisible: e.openLandingView };
},
{ sessionId: null, landingViewVisible: true }
);
```
**Severity**: edge case, observed only after unstable websocket
reconnects. UX-degrading, not data-corrupting.
#### `LayoutFurniImageView` / `LayoutAvatarImageView` — async fetch race
In both files an effect kicks off an async `processAsImageUrl` /
@@ -832,6 +794,16 @@ data-corrupting.
### Recently fixed (in this branch)
- **`MainView` CREATED/ENDED race fixed.** Two independent
`useNitroEvent` listeners on `RoomSessionEvent.CREATED` /
`RoomSessionEvent.ENDED` could land out of order under flaky
reconnects, leaving `landingViewVisible` contradicting the actual
session state. Replaced with a single `useNitroEventReducer` that
carries the active session's `roomId`: a CREATED bumps the tracked
id and closes the landing view; an ENDED is honored only if its
`event.session.roomId` matches the tracked id (or no session is
active), otherwise it's a stale ENDED for a previous session and
gets ignored.
- **Doorbell close button didn't close** while users were pending
(`useEffect(() => setIsVisible(!!users.length))` overrode the close).
Fixed by `src/components/room/widgets/doorbell/DoorbellWidgetView.tsx`
+26 -4
View File
@@ -1,7 +1,7 @@
import { AddLinkEventTracker, GetCommunication, GetRoomSessionManager, HabboWebTools, ILinkEventTracker, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer';
import { AnimatePresence, motion } from 'framer-motion';
import { FC, useEffect, useState } from 'react';
import { useNitroEvent } from '../hooks';
import { useNitroEventReducer } from '../hooks';
import { AchievementsView } from './achievements/AchievementsView';
import { AvatarEditorView } from './avatar-editor';
import { BadgeCreatorView } from './badge-creator';
@@ -42,11 +42,33 @@ import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView';
export const MainView: FC<{}> = props =>
{
const [ isReady, setIsReady ] = useState(false);
const [ landingViewVisible, setLandingViewVisible ] = useState(true);
const [ localizationVersion, setLocalizationVersion ] = useState(0);
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.CREATED, event => setLandingViewVisible(false));
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.ENDED, event => setLandingViewVisible(event.openLandingView));
// CREATED and ENDED can arrive out of order under flaky reconnects.
// Treating them as two independent setters left landingViewVisible
// contradicting the actual session state (stuck open in-room or
// stuck closed at the hotel view). The reducer carries the active
// session's roomId so a stale ENDED for a previous session is
// ignored — only an ENDED matching the tracked session (or when
// no session is active) is honored.
const { landingViewVisible } = useNitroEventReducer<{ sessionId: number | null; landingViewVisible: boolean }, RoomSessionEvent>(
[ RoomSessionEvent.CREATED, RoomSessionEvent.ENDED ],
(state, event) =>
{
if(event.type === RoomSessionEvent.CREATED)
{
return { sessionId: event.session.roomId, landingViewVisible: false };
}
if((state.sessionId !== null) && (event.session.roomId !== state.sessionId))
{
return state;
}
return { sessionId: null, landingViewVisible: event.openLandingView };
},
{ sessionId: null, landingViewVisible: true }
);
useEffect(() =>
{