Prevent unneccesary rerenders of the board on change

zikaeroh created

Change summary

frontend/package.json             |  1 
frontend/src/app.tsx              | 10 ++----
frontend/src/components/board.tsx | 47 +++++++++++++++++++++-----------
frontend/src/hooks/index.tsx      |  7 ++++
frontend/src/pages/game.tsx       | 11 +++----
frontend/src/pages/gameView.tsx   | 25 +++++++++-------
frontend/yarn.lock                |  5 +++
7 files changed, 66 insertions(+), 40 deletions(-)

Detailed changes

frontend/package.json 🔗

@@ -22,6 +22,7 @@
         "myzod": "^1.0.0-alpha.9",
         "react": "^16.13.1",
         "react-dom": "^16.13.1",
+        "react-fast-compare": "^3.2.0",
         "react-hook-form": "^5.6.3",
         "react-scripts": "^3.4.1",
         "react-use-websocket": "^2.0.1",

frontend/src/app.tsx 🔗

@@ -1,13 +1,15 @@
 import querystring from 'querystring';
 import * as React from 'react';
 
-import { ServerTimeProvider } from './hooks/useServerTime';
+import { ServerTimeProvider } from './hooks';
 import { Game, GameProps } from './pages/game';
 import { Login } from './pages/login';
 import { StaticView } from './pages/staticView';
 
 export const App = (_props: {}) => {
     const [gameProps, setGameProps] = React.useState<GameProps | undefined>();
+    const leave = React.useCallback(() => setGameProps(undefined), []);
+    const onLogin = React.useCallback((roomID, nickname) => setGameProps({ roomID, nickname, leave }), [leave]);
 
     if (process.env.NODE_ENV === 'development') {
         const query = querystring.parse(window.location.search.substring(1));
@@ -24,9 +26,5 @@ export const App = (_props: {}) => {
         );
     }
 
-    return (
-        <Login
-            onLogin={(roomID, nickname) => setGameProps({ roomID, nickname, leave: () => setGameProps(undefined) })}
-        />
-    );
+    return <Login onLogin={onLogin} />;
 };

frontend/src/components/board.tsx 🔗

@@ -2,8 +2,9 @@ import { Button, createStyles, makeStyles, Theme, Typography } from '@material-u
 import { grey, orange, red } from '@material-ui/core/colors';
 import { Fireworks } from 'fireworks/lib/react';
 import * as React from 'react';
+import isEqual from 'react-fast-compare';
 
-import { isDefined } from '../common';
+import { isDefined, noop } from '../common';
 import { StateBoard, StateTile } from '../protocol';
 import { TeamHue, teamSpecs } from '../teams';
 import { AspectDiv } from './aspectDiv';
@@ -85,20 +86,37 @@ const useTileStyles = makeStyles((theme: Theme) =>
     })
 );
 
+const fireworksProps = {
+    interval: 0,
+    colors: [red[700], orange[800], grey[500]],
+    x: 0,
+    y: 0,
+};
+
 interface TileProps {
+    row: number;
+    col: number;
+    onClick: (row: number, col: number) => void;
     tile: StateTile;
-    onClick: () => void;
     spymaster: boolean;
     myTurn: boolean;
     winner: boolean;
 }
 
-const Tile = ({ tile, onClick, spymaster, myTurn, winner }: TileProps) => {
+const Tile = React.memo(function Tile({ row, col, onClick, tile, spymaster, myTurn, winner }: TileProps) {
     const classes = useTileStyles();
 
     const bombRevealed = !!(tile.revealed && tile.view?.bomb);
     const alreadyExploded = React.useRef(bombRevealed);
     const explode = bombRevealed && !alreadyExploded.current;
+    const disabled = spymaster || !myTurn || winner || tile.revealed;
+
+    const reveal = React.useMemo(() => {
+        if (disabled) {
+            return noop;
+        }
+        return () => onClick(row, col);
+    }, [disabled, row, col, onClick]);
 
     return (
         <AspectDiv aspectRatio="75%">
@@ -106,9 +124,9 @@ const Tile = ({ tile, onClick, spymaster, myTurn, winner }: TileProps) => {
                 type="button"
                 variant="contained"
                 className={classes.button}
-                onClick={onClick}
+                onClick={reveal}
                 style={tileStyle(tile, spymaster)}
-                disabled={spymaster || !myTurn || winner || tile.revealed}
+                disabled={disabled}
             >
                 <Typography variant="h6" className={classes.typo}>
                     {tile.word}
@@ -117,20 +135,13 @@ const Tile = ({ tile, onClick, spymaster, myTurn, winner }: TileProps) => {
             {explode ? (
                 <div className={classes.explosionWrapper}>
                     <div className={classes.explosion}>
-                        <Fireworks
-                            {...{
-                                interval: 0,
-                                colors: [red[700], orange[800], grey[500]],
-                                x: 0,
-                                y: 0,
-                            }}
-                        />
+                        <Fireworks {...fireworksProps} />
                     </div>
                 </div>
             ) : null}
         </AspectDiv>
     );
-};
+}, isEqual);
 
 export interface BoardProps {
     words: StateBoard;
@@ -154,7 +165,7 @@ const useStyles = makeStyles((theme: Theme) =>
     })
 );
 
-export const Board = (props: BoardProps) => {
+export const Board = React.memo(function Board(props: BoardProps) {
     const classes = useStyles(props);
 
     return (
@@ -163,8 +174,10 @@ export const Board = (props: BoardProps) => {
                 arr.map((tile, col) => (
                     <div key={row * props.words.length + col}>
                         <Tile
+                            row={row}
+                            col={col}
+                            onClick={props.onClick}
                             tile={tile}
-                            onClick={() => props.onClick(row, col)}
                             spymaster={props.spymaster}
                             myTurn={props.myTurn}
                             winner={props.winner}
@@ -174,4 +187,4 @@ export const Board = (props: BoardProps) => {
             )}
         </div>
     );
-};
+}, isEqual);

frontend/src/hooks/useServerTime.tsx → frontend/src/hooks/index.tsx 🔗

@@ -1,4 +1,5 @@
 import * as React from 'react';
+import { v4 } from 'uuid';
 
 export interface ServerTime {
     setOffset: (v: number) => void;
@@ -16,3 +17,9 @@ export const ServerTimeProvider = (props: React.PropsWithChildren<{}>) => {
 export function useServerTime() {
     return React.useContext(Context);
 }
+
+export function useStableUUID(): string {
+    const id = React.useRef<string | undefined>();
+    id.current = id.current ?? v4();
+    return id.current;
+}

frontend/src/pages/game.tsx 🔗

@@ -1,10 +1,10 @@
 import { fail } from 'assert';
 import * as React from 'react';
 import useWebSocket from 'react-use-websocket';
-import { v4 } from 'uuid';
+import { DeepReadonly } from 'ts-essentials';
 
 import { assertIsDefined, assertNever, noop, reloadOutdatedPage, websocketUrl } from '../common';
-import { useServerTime } from '../hooks/useServerTime';
+import { useServerTime, useStableUUID } from '../hooks';
 import { version as codiesVersion } from '../metadata.json';
 import { ClientNote, PartialClientNote, ServerNote, State, StatePlayer, TimeResponse, WordPack } from '../protocol';
 import { GameView, Sender } from './gameView';
@@ -175,8 +175,8 @@ export interface GameProps {
     leave: () => void;
 }
 
-export const Game = (props: GameProps) => {
-    const [playerID] = React.useState(v4);
+export const Game = (props: DeepReadonly<GameProps>) => {
+    const playerID = useStableUUID();
     const nickname = React.useRef(props.nickname); // Preserve a nickname for use in reconnects.
 
     const syncTime = useSyncedServerTime();
@@ -184,6 +184,7 @@ export const Game = (props: GameProps) => {
 
     const reducer = useStateReducer(sendJsonMessage);
     const [state, dispatch] = React.useReducer(reducer, undefined);
+    const player = usePlayer(playerID, state);
     const send = useSender(dispatch);
 
     React.useEffect(() => {
@@ -202,8 +203,6 @@ export const Game = (props: GameProps) => {
         }
     }, [lastJsonMessage]);
 
-    const player = usePlayer(playerID, state);
-
     if (!state) {
         return <Loading />;
     }

frontend/src/pages/gameView.tsx 🔗

@@ -38,7 +38,7 @@ import { Controller, useForm } from 'react-hook-form';
 import { isDefined, nameofFactory, noComplete } from '../common';
 import { Board } from '../components/board';
 import { ClipboardButton } from '../components/clipboard';
-import { useServerTime } from '../hooks/useServerTime';
+import { useServerTime } from '../hooks';
 import { State, StatePlayer, StateTimer, WordPack } from '../protocol';
 import { teamSpecs } from '../teams';
 
@@ -511,10 +511,11 @@ const Sidebar = ({ send, state, pState, pTeam }: GameViewProps) => {
 
 const Board2 = ({ send, state, pState, pTeam }: GameViewProps) => {
     const myTurn = state.turn === pTeam;
+
     return (
         <Board
             words={state.board}
-            onClick={(row, col) => myTurn && !pState.spymaster && send.reveal(row, col)}
+            onClick={send.reveal}
             spymaster={pState.spymaster}
             myTurn={myTurn}
             winner={isDefined(state.winner)}
@@ -637,6 +638,15 @@ const useStyles = makeStyles((theme: Theme) =>
         sidebar: {
             gridArea: 'sidebar',
         },
+        leaveWrapper: {
+            position: 'absolute',
+            top: 0,
+            left: 0,
+            margin: '0.5rem',
+        },
+        leaveButton: {
+            marginRight: '0.5rem',
+        },
     })
 );
 
@@ -645,15 +655,8 @@ export const GameView = (props: GameViewProps) => {
 
     return (
         <div className={classes.root}>
-            <div
-                style={{
-                    position: 'absolute',
-                    top: 0,
-                    left: 0,
-                    margin: '0.5rem',
-                }}
-            >
-                <Button type="button" onClick={props.leave} startIcon={<ArrowBack />} style={{ marginRight: '0.5rem' }}>
+            <div className={classes.leaveWrapper}>
+                <Button type="button" onClick={props.leave} startIcon={<ArrowBack />} className={classes.leaveButton}>
                     Leave
                 </Button>
                 <ClipboardButton

frontend/yarn.lock 🔗

@@ -9037,6 +9037,11 @@ react-error-overlay@^6.0.7:
   resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.7.tgz#1dcfb459ab671d53f660a991513cb2f0a0553108"
   integrity sha512-TAv1KJFh3RhqxNvhzxj6LeT5NWklP6rDr2a0jaTfsZ5wSZWHOGeqQyejUp3xxLfPt2UpyJEcVQB/zyPcmonNFA==
 
+react-fast-compare@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
+  integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
+
 react-hook-form@^5.6.3:
   version "5.7.2"
   resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-5.7.2.tgz#a84e259e5d37dd30949af4f79c4dac31101b79ac"