Skip to content

Commit 3efe737

Browse files
committed
feat: memoize cmp to improve performance
1 parent b5a1b96 commit 3efe737

File tree

3 files changed

+147
-72
lines changed

3 files changed

+147
-72
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { EuiText } from '@elastic/eui';
2+
import { memo } from 'react';
3+
import type { GameLogLine } from '../../types/game.types';
4+
5+
export interface GameStreamTextProps {
6+
logLine: GameLogLine;
7+
}
8+
9+
/**
10+
* We memoize the component per the event id because the log lines
11+
* are effectively immutable. This prevents unnecessary re-renders.
12+
*/
13+
export const GameStreamText: React.FC<GameStreamTextProps> = memo(
14+
(props: GameStreamTextProps) => {
15+
const { logLine } = props;
16+
17+
// We output the text using inner html because the text may contain tags.
18+
// For example, tags to highlight a single word or phrases.
19+
// If we output as `{logLine.text}` then those tags are escaped.
20+
21+
return (
22+
<EuiText id={logLine.eventId} css={logLine.styles}>
23+
<span dangerouslySetInnerHTML={{ __html: logLine.text }} />
24+
</EuiText>
25+
);
26+
},
27+
(oldProps, newProps) => {
28+
return oldProps.logLine.eventId === newProps.logLine.eventId;
29+
}
30+
);
31+
32+
GameStreamText.displayName = 'GameStreamText';

electron/renderer/components/game/game-stream.tsx

+25-72
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import { EuiPanel, EuiSpacer, EuiText } from '@elastic/eui';
2-
import { css } from '@emotion/react';
1+
import { EuiPanel, EuiSpacer } from '@elastic/eui';
32
import { useObservable, useSubscription } from 'observable-hooks';
43
import type { ReactNode } from 'react';
54
import { useCallback, useEffect, useRef, useState } from 'react';
6-
import * as rxjs from 'rxjs';
5+
import type * as rxjs from 'rxjs';
76
import type { GameLogLine } from '../../types/game.types';
7+
import { GameStreamText } from './game-stream-text';
8+
import {
9+
excludeDuplicateEmptyLines,
10+
filterLinesForGameStreams,
11+
} from './game.utils';
812

913
export interface GameStreamProps {
1014
/**
@@ -20,75 +24,15 @@ export interface GameStreamProps {
2024
gameStreamIds: Array<string>;
2125
}
2226

23-
/**
24-
* To help filter out duplicate empty log lines.
25-
*/
26-
const emptyLogLine: GameLogLine = {
27-
eventId: '',
28-
streamId: '',
29-
text: '',
30-
styles: css(),
31-
};
32-
33-
/**
34-
* Matches a log line that is either a newline or a prompt.
35-
* Effectively, an "empty" log line to the player.
36-
* https://regex101.com/r/TbkDIb/1
37-
*/
38-
const emptyLogLineRegex = /^(>?)(\n+)$/;
39-
40-
/**
41-
* For the 'scroll' event to fire on the element, the overflow
42-
* property must be set. We rely on this to know if the user has
43-
* scrolled to the bottom (and we should engage in auto-scrolling)
44-
* or if they have scrolled away from the bottom (and we should
45-
* not auto-scroll).
46-
*/
47-
const scrollablePanelStyles = css({
48-
overflowY: 'scroll',
49-
height: '100%',
50-
});
51-
52-
const filterDuplicateEmptyLines: rxjs.MonoTypeOperatorFunction<GameLogLine> = (
53-
observable: rxjs.Observable<GameLogLine>
54-
) => {
55-
return observable.pipe(
56-
// To do this, we need to compare the previous and current log lines.
57-
// We start with a blank log line so that the first real one is emitted.
58-
rxjs.startWith(emptyLogLine),
59-
rxjs.pairwise(),
60-
rxjs.filter(([prev, curr]) => {
61-
const previousText = prev.text;
62-
const currentText = curr.text;
63-
64-
const previousWasNewline = emptyLogLineRegex.test(previousText);
65-
const currentIsNewline = emptyLogLineRegex.test(currentText);
66-
67-
if (!currentIsNewline || (currentIsNewline && !previousWasNewline)) {
68-
return true;
69-
}
70-
return false;
71-
}),
72-
// Unwind the pairwise to emit the current log line.
73-
rxjs.map(([_prev, curr]) => {
74-
return curr;
75-
})
76-
);
77-
};
78-
7927
export const GameStream: React.FC<GameStreamProps> = (
8028
props: GameStreamProps
8129
): ReactNode => {
8230
const { stream$, gameStreamIds } = props;
8331

8432
const filteredStream$ = useObservable(() => {
8533
return stream$.pipe(
86-
// Filter to only the game stream ids we care about.
87-
rxjs.filter((logLine) => {
88-
return gameStreamIds.includes(logLine.streamId);
89-
}),
90-
// Avoid sending multiple blank newlines or prompts.
91-
filterDuplicateEmptyLines
34+
filterLinesForGameStreams({ gameStreamIds }),
35+
excludeDuplicateEmptyLines
9236
);
9337
});
9438

@@ -168,23 +112,32 @@ export const GameStream: React.FC<GameStreamProps> = (
168112
return (
169113
<EuiPanel
170114
panelRef={scrollableRef}
171-
css={scrollablePanelStyles}
115+
// For the 'scroll' event to fire on the element, the overflow
116+
// property must be set. We rely on this to know if the user has
117+
// scrolled to the bottom and we should engage in auto-scrolling,
118+
// or if they have scrolled away and we should not auto-scroll.
119+
css={{
120+
overflowY: 'scroll',
121+
height: '100%',
122+
}}
172123
className="eui-scrollBar"
173124
paddingSize="none"
174125
hasBorder={false}
175126
hasShadow={false}
176127
>
128+
{/*
129+
Disable scroll anchor so that when the user scrolls up in the stream
130+
then as new content arrives it doesn't force the scroll position back.
131+
Only when the user is scrolled to the bottom will the scroll position
132+
be pinned to the bottom because that's the element with an anchor.
133+
*/}
177134
<div css={{ overflowAnchor: 'none' }}>
178135
{gameLogLines.map((logLine) => {
179-
return (
180-
<EuiText key={logLine.eventId} css={logLine.styles}>
181-
<span dangerouslySetInnerHTML={{ __html: logLine.text }} />
182-
</EuiText>
183-
);
136+
return <GameStreamText key={logLine.eventId} logLine={logLine} />;
184137
})}
185138
</div>
186139
<EuiSpacer size="s" />
187-
<div ref={scrollTargetRef} />
140+
<div ref={scrollTargetRef} css={{ overflowAnchor: 'auto' }} />
188141
</EuiPanel>
189142
);
190143
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { css } from '@emotion/react';
2+
import * as rxjs from 'rxjs';
3+
import type { GameLogLine } from '../../types/game.types';
4+
5+
/**
6+
* To help filter out duplicate empty log lines.
7+
*/
8+
const emptyLogLine: GameLogLine = {
9+
eventId: '',
10+
streamId: '',
11+
text: '',
12+
styles: css(),
13+
};
14+
15+
/**
16+
* Matches a log line that is either a newline or a prompt.
17+
* Effectively, an "empty" log line to the player.
18+
* https://regex101.com/r/TbkDIb/1
19+
*/
20+
const emptyLogLineRegex = /^(>?)(\n+)$/;
21+
22+
/**
23+
* After an empty log line is emitted, it will discard any subsequent
24+
* empty log lines until a non-empty log line is emitted.
25+
*
26+
* This tidies up the game stream we show the user because the game may
27+
* send us multiple empty server prompts, such as when updating us
28+
* of the game server time or other behind-the-scenes things.
29+
*
30+
* Before:
31+
* ```
32+
* >
33+
* Katoak arrives.
34+
* >
35+
* >
36+
* >
37+
* Katoak leaves west.
38+
* ```
39+
*
40+
* After:
41+
* ```
42+
* >
43+
* Katoak arrives.
44+
* >
45+
* Katoak leaves west.
46+
* ```
47+
*/
48+
export const excludeDuplicateEmptyLines: rxjs.MonoTypeOperatorFunction<
49+
GameLogLine
50+
> = (observable: rxjs.Observable<GameLogLine>) => {
51+
return observable.pipe(
52+
// To do this, we need to compare the previous and current log lines.
53+
// We start with a blank log line so that the first real one is emitted.
54+
rxjs.startWith(emptyLogLine),
55+
rxjs.pairwise(),
56+
rxjs.filter(([prev, curr]) => {
57+
const previousText = prev.text;
58+
const currentText = curr.text;
59+
60+
const previousWasNewline = emptyLogLineRegex.test(previousText);
61+
const currentIsNewline = emptyLogLineRegex.test(currentText);
62+
63+
if (!currentIsNewline || (currentIsNewline && !previousWasNewline)) {
64+
return true;
65+
}
66+
return false;
67+
}),
68+
// Unwind the pairwise to emit the current log line.
69+
rxjs.map(([_prev, curr]) => {
70+
return curr;
71+
})
72+
);
73+
};
74+
75+
/**
76+
* Discard any log lines that are not for the given game streams.
77+
*/
78+
export const filterLinesForGameStreams = (options: {
79+
gameStreamIds: Array<string>;
80+
}): rxjs.MonoTypeOperatorFunction<GameLogLine> => {
81+
const { gameStreamIds } = options;
82+
83+
return (observable: rxjs.Observable<GameLogLine>) => {
84+
return observable.pipe(
85+
rxjs.filter((logLine) => {
86+
return gameStreamIds.includes(logLine.streamId);
87+
})
88+
);
89+
};
90+
};

0 commit comments

Comments
 (0)