From 55cc06126e69db32fc9a7721775672da687453cc Mon Sep 17 00:00:00 2001 From: Andy Kurnia Date: Sat, 22 Jan 2022 18:05:17 +0800 Subject: [PATCH 1/3] reset store without reconnecting socket --- liwords-ui/src/App.tsx | 33 ++--- liwords-ui/src/lobby/login.tsx | 6 +- liwords-ui/src/settings/settings.tsx | 6 +- liwords-ui/src/socket/socket.ts | 89 ++++++++--- liwords-ui/src/store/login_state.ts | 12 +- liwords-ui/src/store/socket_handlers.ts | 2 + liwords-ui/src/store/store.tsx | 188 ++++++++++++++++++++---- liwords-ui/src/topbar/topbar.tsx | 4 +- liwords-ui/src/tournament/room.tsx | 4 +- 9 files changed, 254 insertions(+), 90 deletions(-) diff --git a/liwords-ui/src/App.tsx b/liwords-ui/src/App.tsx index ac0a106bf..ad441b153 100644 --- a/liwords-ui/src/App.tsx +++ b/liwords-ui/src/App.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { Route, Switch, useLocation, Redirect } from 'react-router-dom'; -import { useMountedState } from './utils/mounted'; import './App.scss'; import axios from 'axios'; import 'antd/dist/antd.css'; @@ -17,7 +16,7 @@ import { useFriendsStoreContext, } from './store/store'; -import { LiwordsSocket } from './socket/socket'; +import { useLiwordsSocketContext } from './socket/socket'; import { Team } from './about/team'; import { Register } from './lobby/register'; import { UserProfile } from './profile/profile'; @@ -65,8 +64,6 @@ if (bnjyTile) { } const App = React.memo(() => { - const { useState } = useMountedState(); - const { setExcludedPlayers, setExcludedPlayersFetched, @@ -74,6 +71,11 @@ const App = React.memo(() => { setPendingBlockRefresh, } = useExcludedPlayersStoreContext(); + const { + liwordsSocketValues: { sendMessage }, + resetLiwordsSocketStore, + } = useLiwordsSocketContext(); + const { loginState } = useLoginStateStoreContext(); const { loggedIn, userID } = loginState; @@ -89,26 +91,16 @@ const App = React.memo(() => { setPendingFriendsRefresh, } = useFriendsStoreContext(); - const { resetStore } = useResetStoreContext(); - - // See store.tsx for how this works. - const [socketId, setSocketId] = useState(0); - const resetSocket = useCallback(() => setSocketId((n) => (n + 1) | 0), []); - - const [liwordsSocketValues, setLiwordsSocketValues] = useState({ - sendMessage: (msg: Uint8Array) => {}, - justDisconnected: false, - }); - const { sendMessage } = liwordsSocketValues; + const { resetRestOfStore } = useResetStoreContext(); const location = useLocation(); const knownLocation = useRef(location.pathname); // Remember the location on first render. const isCurrentLocation = knownLocation.current === location.pathname; useEffect(() => { if (!isCurrentLocation) { - resetStore(); + resetRestOfStore(); } - }, [isCurrentLocation, resetStore]); + }, [isCurrentLocation, resetRestOfStore]); const getFullBlocks = useCallback(() => { void userID; // used only as effect dependency @@ -225,17 +217,12 @@ const App = React.memo(() => { return (
- diff --git a/liwords-ui/src/lobby/login.tsx b/liwords-ui/src/lobby/login.tsx index 9171ad11f..5f6670668 100644 --- a/liwords-ui/src/lobby/login.tsx +++ b/liwords-ui/src/lobby/login.tsx @@ -11,7 +11,7 @@ import { toAPIUrl } from '../api/api'; export const Login = React.memo(() => { const { useState } = useMountedState(); - const { resetStore } = useResetStoreContext(); + const { resetLoginStateStore } = useResetStoreContext(); const [err, setErr] = useState(''); const [loggedIn, setLoggedIn] = useState(false); @@ -42,9 +42,9 @@ export const Login = React.memo(() => { React.useEffect(() => { if (loggedIn) { - resetStore(); + resetLoginStateStore(); } - }, [loggedIn, resetStore]); + }, [loggedIn, resetLoginStateStore]); return (
diff --git a/liwords-ui/src/settings/settings.tsx b/liwords-ui/src/settings/settings.tsx index b0943d206..86b4bcd6e 100644 --- a/liwords-ui/src/settings/settings.tsx +++ b/liwords-ui/src/settings/settings.tsx @@ -80,7 +80,7 @@ export const Settings = React.memo((props: Props) => { const { loginState } = useLoginStateStoreContext(); const { userID, username: viewer, loggedIn } = loginState; const { useState } = useMountedState(); - const { resetStore } = useResetStoreContext(); + const { resetLoginStateStore } = useResetStoreContext(); const { section } = useParams(); const [category, setCategory] = useState( getInitialCategory(section, loggedIn) @@ -170,13 +170,13 @@ export const Settings = React.memo((props: Props) => { message: 'Success', description: 'You have been logged out.', }); - resetStore(); + resetLoginStateStore(); history.push('/'); }) .catch((e) => { console.log(e); }); - }, [history, resetStore]); + }, [history, resetLoginStateStore]); const updatedAvatar = useCallback( (avatarUrl: string) => { diff --git a/liwords-ui/src/socket/socket.ts b/liwords-ui/src/socket/socket.ts index 46ce9d32f..318e55d67 100644 --- a/liwords-ui/src/socket/socket.ts +++ b/liwords-ui/src/socket/socket.ts @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react'; import axios from 'axios'; import jwt from 'jsonwebtoken'; import useWebSocket from 'react-use-websocket'; @@ -7,7 +14,6 @@ import { message } from 'antd'; import { useMountedState } from '../utils/mounted'; import { useLoginStateStoreContext } from '../store/store'; import { - useOnSocketMsg, ReverseMessageType, enableShowSocket, parseMsgs, @@ -18,6 +24,42 @@ import { ActionType } from '../actions/actions'; import { reloadAction } from './reload'; import { birthdateWarning } from './birthdateWarning'; +// Store-specific code. + +const defaultFunction = () => {}; + +export type LiwordsSocketValues = { + sendMessage: (msg: Uint8Array) => void; + justDisconnected: boolean; +}; + +export type OnSocketMsgType = (reader: FileReader) => void; + +type LiwordsSocketStoreData = { + liwordsSocketValues: LiwordsSocketValues; + onSocketMsg: OnSocketMsgType; + resetLiwordsSocketStore: () => void; + setLiwordsSocketValues: React.Dispatch< + React.SetStateAction + >; + setOnSocketMsg: React.Dispatch>; +}; + +export const LiwordsSocketContext = createContext({ + liwordsSocketValues: { + sendMessage: defaultFunction, + justDisconnected: false, + }, + onSocketMsg: defaultFunction, + resetLiwordsSocketStore: defaultFunction, + setLiwordsSocketValues: defaultFunction, + setOnSocketMsg: defaultFunction, +}); + +export const useLiwordsSocketContext = () => useContext(LiwordsSocketContext); + +// Non-Store code follows. + const getSocketURI = (): string => { const loc = window.location; let protocol; @@ -51,23 +93,36 @@ type DecodedToken = { // Returning undefined from useEffect is fine, but some linters dislike it. const doNothing = () => {}; -export const LiwordsSocket = (props: { - resetSocket: () => void; - setValues: (_: { - sendMessage: (msg: Uint8Array) => void; - justDisconnected: boolean; - }) => void; -}): null => { +export const LiwordsSocket = (props: {}): null => { const isMountedRef = useRef(true); useEffect(() => () => void (isMountedRef.current = false), []); const { useState } = useMountedState(); - const { resetSocket, setValues } = props; - const onSocketMsg = useOnSocketMsg(); + const { + onSocketMsg, + resetLiwordsSocketStore, + setLiwordsSocketValues, + } = useLiwordsSocketContext(); const loginStateStore = useLoginStateStoreContext(); const location = useLocation(); - const { pathname } = location; + const pathname = useMemo(() => { + const originalPathname = location.pathname; + // XXX: The socket requires path to know which realms it has to connect to. + // See liwords-socket pkg/hub/hub.go RegisterRealm. + // That calls back into liwords pkg/bus/bus.go handleNatsRequest. + + // It seems only a few paths matter. + if ( + originalPathname.startsWith('/game/') || + originalPathname.startsWith('/tournament/') || + originalPathname.startsWith('/club/') + ) + return originalPathname; + + // For everything else, there's MasterCard. + return '/'; + }, [location.pathname]); // const [socketToken, setSocketToken] = useState(''); const [justDisconnected, setJustDisconnected] = useState(false); @@ -109,8 +164,6 @@ export const LiwordsSocket = (props: { userID: decoded.uid, loggedIn: decoded.a, connID: cid, - isChild: decoded.cs, - path: pathname, perms: decoded.perms?.split(','), }, }); @@ -213,12 +266,12 @@ export const LiwordsSocket = (props: { useEffect(() => { const t = setTimeout(() => { console.log('reconnecting socket'); - resetSocket(); + resetLiwordsSocketStore(); }, 15000); return () => { clearTimeout(t); }; - }, [patienceId, resetSocket]); + }, [patienceId, resetLiwordsSocketStore]); const { sendMessage: originalSendMessage } = useWebSocket( getFullSocketUrlAsync, @@ -271,8 +324,8 @@ export const LiwordsSocket = (props: { justDisconnected, ]); useEffect(() => { - setValues(ret); - }, [setValues, ret]); + setLiwordsSocketValues(ret); + }, [setLiwordsSocketValues, ret]); return null; }; diff --git a/liwords-ui/src/store/login_state.ts b/liwords-ui/src/store/login_state.ts index ab0a8b8d7..ab70d9c92 100644 --- a/liwords-ui/src/store/login_state.ts +++ b/liwords-ui/src/store/login_state.ts @@ -1,21 +1,15 @@ import { Action, ActionType } from '../actions/actions'; -export type LoginState = { +export type AuthInfo = { username: string; userID: string; loggedIn: boolean; connID: string; - connectedToSocket: boolean; - path: string; perms: Array; }; -export type AuthInfo = { - username: string; - userID: string; - loggedIn: boolean; - connID: string; - perms: Array; +export type LoginState = AuthInfo & { + connectedToSocket: boolean; }; export function LoginStateReducer( diff --git a/liwords-ui/src/store/socket_handlers.ts b/liwords-ui/src/store/socket_handlers.ts index b63a9bc90..1256db8aa 100644 --- a/liwords-ui/src/store/socket_handlers.ts +++ b/liwords-ui/src/store/socket_handlers.ts @@ -155,6 +155,8 @@ export const ReverseMessageType = (() => { return ret; })(); +// This needs to have access to Rest Of Store. +// Therefore it cannot be used directly from LiwordsSocket. export const useOnSocketMsg = () => { const { challengeResultEvent } = useChallengeResultEventStoreContext(); const { addChat, deleteChat } = useChatStoreContext(); diff --git a/liwords-ui/src/store/store.tsx b/liwords-ui/src/store/store.tsx index 24419c14a..12798e878 100644 --- a/liwords-ui/src/store/store.tsx +++ b/liwords-ui/src/store/store.tsx @@ -33,6 +33,14 @@ import { } from './reducers/tournament_reducer'; import { MetaEventState, MetaStates } from './meta_game_events'; import { StandardEnglishAlphabet } from '../constants/alphabets'; +import { + LiwordsSocket, + LiwordsSocketContext, + LiwordsSocketValues, + OnSocketMsgType, + useLiwordsSocketContext, +} from '../socket/socket'; +import { useOnSocketMsg } from './socket_handlers'; export enum ChatEntityType { UserChat, @@ -245,7 +253,6 @@ const LoginStateContext = createContext({ loggedIn: false, connectedToSocket: false, connID: '', - path: '', perms: [], }, dispatchLoginState: defaultFunction, @@ -761,9 +768,87 @@ const ExaminableStore = ({ children }: { children: React.ReactNode }) => { return ; }; -// The Real Store. +// The Real LoginState Store. + +const RealLoginStateStore = ({ children, ...props }: Props) => { + const { useState } = useMountedState(); + + const [loginState, setLoginState] = useState({ + username: '', + userID: '', + loggedIn: false, + connectedToSocket: false, + connID: '', + perms: new Array(), + }); + const dispatchLoginState = useCallback( + (action) => setLoginState((state) => LoginStateReducer(state, action)), + [] + ); + + const loginStateStore = useMemo( + () => ({ + loginState, + dispatchLoginState, + }), + [loginState, dispatchLoginState] + ); + + return ( + + ); +}; + +// The Real LiwordsSocket Store. + +const RealLiwordsSocketStore = ({ + resetLiwordsSocketStore, + children, + ...props +}: Props & { + resetLiwordsSocketStore: () => void; +}) => { + const { useState } = useMountedState(); + + const [onSocketMsg, setOnSocketMsg] = useState( + () => defaultFunction + ); + + const [liwordsSocketValues, setLiwordsSocketValues] = useState< + LiwordsSocketValues + >({ + sendMessage: defaultFunction, + justDisconnected: false, + }); + + const liwordsSocketStore = useMemo( + () => ({ + liwordsSocketValues, + onSocketMsg, + resetLiwordsSocketStore, + setLiwordsSocketValues, + setOnSocketMsg, + }), + [ + liwordsSocketValues, + onSocketMsg, + resetLiwordsSocketStore, + setLiwordsSocketValues, + setOnSocketMsg, + ] + ); + + return ( + + ); +}; + +// The Real Rest Of Store. -const RealStore = ({ children, ...props }: Props) => { +const RealRestOfStore = ({ children, ...props }: Props) => { const { useState } = useMountedState(); const clockController = useRef(null); @@ -790,19 +875,6 @@ const RealStore = ({ children, ...props }: Props) => { (action) => setLobbyContext((state) => LobbyReducer(state, action)), [] ); - const [loginState, setLoginState] = useState({ - username: '', - userID: '', - loggedIn: false, - connectedToSocket: false, - connID: '', - path: '', - perms: new Array(), - }); - const dispatchLoginState = useCallback( - (action) => setLoginState((state) => LoginStateReducer(state, action)), - [] - ); const [tournamentContext, setTournamentContext] = useState( defaultTournamentState @@ -980,13 +1052,6 @@ const RealStore = ({ children, ...props }: Props) => { }), [lobbyContext, dispatchLobbyContext] ); - const loginStateStore = useMemo( - () => ({ - loginState, - dispatchLoginState, - }), - [loginState, dispatchLoginState] - ); const tournamentStateStore = useMemo( () => ({ tournamentContext, @@ -1158,7 +1223,6 @@ const RealStore = ({ children, ...props }: Props) => { ); ret = ; - ret = ; ret = ; ret = ( @@ -1208,30 +1272,92 @@ const RealStore = ({ children, ...props }: Props) => { return ; }; -const ResetStoreContext = createContext({ resetStore: defaultFunction }); +// This needs to be nested inside the Rest Of Store. + +const InstallOnSocketMsg = ({ children }: { children: React.ReactNode }) => { + const { onSocketMsg, setOnSocketMsg } = useLiwordsSocketContext(); + + const newOnSocketMsg = useOnSocketMsg(); + + const oldOnSocketMsgRef = useRef(onSocketMsg); + oldOnSocketMsgRef.current = onSocketMsg; + + React.useEffect(() => { + const old = oldOnSocketMsgRef.current; + setOnSocketMsg(() => newOnSocketMsg); + return () => { + setOnSocketMsg(() => old); + }; + }, [newOnSocketMsg, setOnSocketMsg]); + + return ; +}; + +const ResetStoreContext = createContext({ + resetLoginStateStore: defaultFunction, + resetRestOfStore: defaultFunction, +}); export const useResetStoreContext = () => useContext(ResetStoreContext); +// Now includes the Socket. + export const Store = ({ children }: { children: React.ReactNode }) => { const { useState } = useMountedState(); // In JS the | 0 loops within int32 and avoids reaching Number.MAX_SAFE_INTEGER. - const [storeId, setStoreId] = useState(0); - const resetStore = useCallback(() => setStoreId((n) => (n + 1) | 0), []); + const [loginStateStoreId, setLoginStateStoreId] = useState(0); + const resetLoginStateStore = useCallback( + () => setLoginStateStoreId((n) => (n + 1) | 0), + [] + ); + const [liwordsSocketStoreId, setLiwordsSocketStoreId] = useState(0); + const resetLiwordsSocketStore = useCallback( + () => setLiwordsSocketStoreId((n) => (n + 1) | 0), + [] + ); + const [restOfStoreId, setRestOfStoreId] = useState(0); + const resetRestOfStore = useCallback( + () => setRestOfStoreId((n) => (n + 1) | 0), + [] + ); + + // Combine keys hierarchically. These must be strings. + const loginStateStoreKey = `${loginStateStoreId}`; + const liwordsSocketStoreKey = `${loginStateStoreKey} ${liwordsSocketStoreId}`; + const restOfStoreKey = `${liwordsSocketStoreKey} ${restOfStoreId}`; // Reset on browser navigation. React.useEffect(() => { const handleBrowserNavigation = (evt: PopStateEvent) => { - resetStore(); + resetRestOfStore(); }; window.addEventListener('popstate', handleBrowserNavigation); return () => { window.removeEventListener('popstate', handleBrowserNavigation); }; - }, [resetStore]); + }, [resetRestOfStore]); + + const resetStore = useMemo( + () => ({ + resetLoginStateStore, + resetRestOfStore, + }), + [resetLoginStateStore, resetRestOfStore] + ); return ( - - + + + + + + {children} + + + ); }; diff --git a/liwords-ui/src/topbar/topbar.tsx b/liwords-ui/src/topbar/topbar.tsx index 6ee0a01fd..e3e824836 100644 --- a/liwords-ui/src/topbar/topbar.tsx +++ b/liwords-ui/src/topbar/topbar.tsx @@ -81,7 +81,7 @@ export const TopBar = React.memo((props: Props) => { const { currentLagMs } = useLagStoreContext(); const { loginState } = useLoginStateStoreContext(); - const { resetStore } = useResetStoreContext(); + const { resetLoginStateStore } = useResetStoreContext(); const { tournamentContext } = useTournamentStoreContext(); const { username, loggedIn, connectedToSocket } = loginState; const [loginModalVisible, setLoginModalVisible] = useState(false); @@ -97,7 +97,7 @@ export const TopBar = React.memo((props: Props) => { message: 'Success', description: 'You have been logged out.', }); - resetStore(); + resetLoginStateStore(); }) .catch((e) => { console.log(e); diff --git a/liwords-ui/src/tournament/room.tsx b/liwords-ui/src/tournament/room.tsx index eef06e476..1375b269b 100644 --- a/liwords-ui/src/tournament/room.tsx +++ b/liwords-ui/src/tournament/room.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useCallback, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; import { useLoginStateStoreContext, @@ -35,7 +36,8 @@ export const TournamentRoom = (props: Props) => { const { competitorState: competitorContext } = tournamentContext; const { isRegistered } = competitorContext; const { sendSocketMsg } = props; - const { path } = loginState; + const location = useLocation(); + const path = location.pathname; const [badTournament, setBadTournament] = useState(false); const [selectedGameTab, setSelectedGameTab] = useState('GAMES'); From 2227a42663a9fad16097cfcb9489ab42c81d5cdd Mon Sep 17 00:00:00 2001 From: Andy Kurnia Date: Sat, 22 Jan 2022 21:37:18 +0800 Subject: [PATCH 2/3] use implicit key hierarchy --- liwords-ui/src/store/store.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/liwords-ui/src/store/store.tsx b/liwords-ui/src/store/store.tsx index 12798e878..60f028ad5 100644 --- a/liwords-ui/src/store/store.tsx +++ b/liwords-ui/src/store/store.tsx @@ -1321,11 +1321,6 @@ export const Store = ({ children }: { children: React.ReactNode }) => { [] ); - // Combine keys hierarchically. These must be strings. - const loginStateStoreKey = `${loginStateStoreId}`; - const liwordsSocketStoreKey = `${loginStateStoreKey} ${liwordsSocketStoreId}`; - const restOfStoreKey = `${liwordsSocketStoreKey} ${restOfStoreId}`; - // Reset on browser navigation. React.useEffect(() => { const handleBrowserNavigation = (evt: PopStateEvent) => { @@ -1347,13 +1342,13 @@ export const Store = ({ children }: { children: React.ReactNode }) => { return ( - + - + {children} From 8ca2ced791fcc2910a998884e32efa40aa44f5ea Mon Sep 17 00:00:00 2001 From: Andy Kurnia Date: Sat, 22 Jan 2022 23:23:20 +0800 Subject: [PATCH 3/3] explicitly reconnect when socket pathname changes --- liwords-ui/src/socket/socket.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/liwords-ui/src/socket/socket.ts b/liwords-ui/src/socket/socket.ts index 318e55d67..76c6ca1ff 100644 --- a/liwords-ui/src/socket/socket.ts +++ b/liwords-ui/src/socket/socket.ts @@ -273,6 +273,15 @@ export const LiwordsSocket = (props: {}): null => { }; }, [patienceId, resetLiwordsSocketStore]); + // Force reconnection when pathname materially changes. + const knownPathname = useRef(pathname); // Remember the pathname on first render. + const isCurrentPathname = knownPathname.current === pathname; + useEffect(() => { + if (!isCurrentPathname) { + resetLiwordsSocketStore(); + } + }, [isCurrentPathname, resetLiwordsSocketStore]); + const { sendMessage: originalSendMessage } = useWebSocket( getFullSocketUrlAsync, {