Skip to content

Commit ddfb21b

Browse files
perf(scenes): stop replacing the entire overlay node on each UI change
Right now UIManager is more React-like in data flow, but it still does a full root swap. That’s acceptable for this small UI, but the cleaner next step would be one of these: - Keep a stable root and re-render only its children. This avoids replacing the mounted DOM subtree and is a better fit if you add more UI state or transitions. I’d keep #ui-overlay mounted once and make only its contents change. Right now UIManager creates a new root <div id="ui-overlay">...</div> and replaces the old one on each meaningful UI update. That works, but it means every state change destroys and recreates the whole overlay subtree, including the button element and any future transition/focus state. The cleaner version is: 1. Mount a stable root once in the constructor. Create this.root = document.createElement('div'), give it the static overlay classes/id, append it to document.body, and never replace that node again. 2. Add a dedicated content container inside the root. For example this.contentRoot = document.createElement('div') or just render directly into this.root if jsx-dom supports replacing children cleanly. The point is that the outer shell stays stable. 3. Compute a pure UI view model in render(state, score). Something like: - scoreText - isScoreVisible - panelContent - buttonMode This isolates game-state mapping from DOM work. 4. Re-render only the children from props. GameOverlay becomes a pure presentational component: - <GameOverlay scoreText=... isScoreVisible=... panelContent=... onPrimaryAction=... /> Then UIManager.render(...) clears/replaces the children inside the stable root instead of replacing the root itself. 5. Keep callbacks stable. The button click handler should still call this.handlePrimaryAction(), and buttonMode stays owned by UIManager. Why this is better: - #ui-overlay remains mounted, so future transitions, focus handling, and DOM hooks are more reliable. - The UI still feels React-like because rendering is props-driven. - UIManager becomes a thin container instead of a DOM mutation class. - It makes the next step easy: extracting getUIState(...) into a pure function and testing it independently.
1 parent 0764155 commit ddfb21b

1 file changed

Lines changed: 24 additions & 22 deletions

File tree

src/scenes/ui-manager.tsx

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,25 @@ type OverlayPanelContent = PanelContent & {
1616
* Lightweight DOM UI for game states and score.
1717
*/
1818
export class UIManager {
19-
private root: HTMLDivElement;
19+
private readonly root: HTMLDivElement;
20+
private readonly contentRoot: HTMLDivElement;
2021
private readonly callbacks: UIManagerCallbacks;
2122
private buttonMode: ButtonMode = 'start';
2223
private lastRenderedState: GameStateEnum | null = null;
2324
private lastRenderedScore = Number.NaN;
2425

2526
constructor(callbacks: UIManagerCallbacks) {
2627
this.callbacks = callbacks;
27-
this.root = this.createOverlay('', false, null);
28+
this.root = document.createElement('div');
29+
this.root.id = 'ui-overlay';
30+
this.root.className =
31+
'pointer-events-none fixed inset-0 z-2 grid grid-rows-[auto_1fr]';
32+
33+
this.contentRoot = document.createElement('div');
34+
this.contentRoot.className = 'contents';
35+
this.root.append(this.contentRoot);
2836
document.body.append(this.root);
37+
this.updateOverlay('', false, null);
2938
}
3039

3140
/**
@@ -48,13 +57,11 @@ export class UIManager {
4857
this.buttonMode = panelContent.mode;
4958
}
5059

51-
const nextRoot = this.createOverlay(
60+
this.updateOverlay(
5261
`Distance ${String(roundedScore)}m`,
5362
state !== GameStateEnum.Start,
5463
panelContent,
5564
);
56-
this.root.replaceWith(nextRoot);
57-
this.root = nextRoot;
5865
}
5966

6067
/**
@@ -113,25 +120,20 @@ export class UIManager {
113120
return null;
114121
}
115122

116-
private createOverlay(
123+
private updateOverlay(
117124
scoreText: string,
118125
isScoreVisible: boolean,
119126
panelContent: PanelContent | null,
120-
): HTMLDivElement {
121-
return (
122-
<div
123-
id="ui-overlay"
124-
className="pointer-events-none fixed inset-0 z-2 grid grid-rows-[auto_1fr]"
125-
>
126-
<GameOverlay
127-
scoreText={scoreText}
128-
isScoreVisible={isScoreVisible}
129-
panelContent={panelContent}
130-
onPrimaryAction={() => {
131-
this.handlePrimaryAction();
132-
}}
133-
/>
134-
</div>
135-
) as HTMLDivElement;
127+
): void {
128+
this.contentRoot.replaceChildren(
129+
<GameOverlay
130+
scoreText={scoreText}
131+
isScoreVisible={isScoreVisible}
132+
panelContent={panelContent}
133+
onPrimaryAction={() => {
134+
this.handlePrimaryAction();
135+
}}
136+
/>,
137+
);
136138
}
137139
}

0 commit comments

Comments
 (0)