Skip to content

Commit df05cd7

Browse files
committed
Improve output rendering performance to reduce lag
1 parent 7a3d4d1 commit df05cd7

3 files changed

Lines changed: 230 additions & 0 deletions

File tree

src/components/output.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,16 @@ a.exit {
228228
.output .output-line.focused-line {
229229
scroll-margin: 20px;
230230
}
231+
232+
.history-exposure-toggle {
233+
display: block;
234+
font-family: inherit;
235+
font-size: 0.85em;
236+
color: var(--color-bg-deepest, #000);
237+
background-color: #c0c0c0;
238+
border: 1px solid #808080;
239+
border-radius: 3px;
240+
padding: var(--space-1, 2px) var(--space-2, 6px);
241+
margin-bottom: var(--space-2, 6px);
242+
cursor: pointer;
243+
}

src/components/output.test.tsx

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,156 @@ describe("Output persistence", () => {
248248
expect(setItemSpy).not.toHaveBeenCalled();
249249
});
250250
});
251+
252+
describe("Output accessibility-tree exposure cap", () => {
253+
const makeLines = (count: number): OutputLine[] =>
254+
Array.from({ length: count }, (_, i) => ({
255+
content: <div>{`line ${i}`}</div>,
256+
id: i,
257+
sourceContent: `line ${i}`,
258+
sourceType: "test",
259+
type: OutputType.ServerMessage,
260+
}));
261+
262+
// Instantiate an Output with history and a real frozen container so
263+
// freezeOverflow/trimFrozen operate on actual DOM.
264+
const makeOutput = (lines: OutputLine[]): Output => {
265+
const output = new Output({ client: {} as MudClient });
266+
Object.defineProperty(output, "allLines", { value: lines, writable: true });
267+
const frozenDiv = document.createElement("div");
268+
Object.defineProperty(output, "frozenRef", { value: { current: frozenDiv } });
269+
return output;
270+
};
271+
272+
const frozen = (output: Output): HTMLDivElement =>
273+
(output as unknown as { frozenRef: { current: HTMLDivElement } }).frozenRef.current;
274+
275+
const freeze = (output: Output) =>
276+
(output as unknown as { freezeOverflow: () => void }).freezeOverflow();
277+
278+
const hiddenCount = (output: Output): number =>
279+
frozen(output).querySelectorAll('[aria-hidden="true"]').length;
280+
281+
it("hides frozen lines beyond the exposure cap, oldest first", () => {
282+
const total = Output.LIVE_WINDOW_SIZE + Output.A11Y_EXPOSED_FROZEN_LINES + 50;
283+
const output = makeOutput(makeLines(total));
284+
285+
freeze(output);
286+
287+
const frozenDiv = frozen(output);
288+
expect(frozenDiv.children.length).toBe(total - Output.LIVE_WINDOW_SIZE);
289+
expect(hiddenCount(output)).toBe(50);
290+
// The oldest lines are hidden; the most recent frozen lines are exposed.
291+
expect(frozenDiv.children[0].getAttribute("aria-hidden")).toBe("true");
292+
expect(frozenDiv.children[49].getAttribute("aria-hidden")).toBe("true");
293+
expect(frozenDiv.children[50].hasAttribute("aria-hidden")).toBe(false);
294+
expect(
295+
frozenDiv.children[frozenDiv.children.length - 1].hasAttribute("aria-hidden")
296+
).toBe(false);
297+
});
298+
299+
it("exposes everything while under the cap", () => {
300+
const output = makeOutput(
301+
makeLines(Output.LIVE_WINDOW_SIZE + Output.A11Y_EXPOSED_FROZEN_LINES)
302+
);
303+
304+
freeze(output);
305+
306+
expect(frozen(output).children.length).toBe(Output.A11Y_EXPOSED_FROZEN_LINES);
307+
expect(hiddenCount(output)).toBe(0);
308+
});
309+
310+
it("hides incrementally as more lines freeze", () => {
311+
const start = Output.LIVE_WINDOW_SIZE + Output.A11Y_EXPOSED_FROZEN_LINES;
312+
const output = makeOutput(makeLines(start));
313+
freeze(output);
314+
expect(hiddenCount(output)).toBe(0);
315+
316+
(output as unknown as { allLines: OutputLine[] }).allLines = makeLines(start + 10);
317+
freeze(output);
318+
319+
expect(hiddenCount(output)).toBe(10);
320+
});
321+
322+
it("keeps the hidden count consistent across trims from the front", () => {
323+
const total = Output.LIVE_WINDOW_SIZE + Output.A11Y_EXPOSED_FROZEN_LINES + 30;
324+
const output = makeOutput(makeLines(total));
325+
freeze(output);
326+
expect(hiddenCount(output)).toBe(30);
327+
328+
(output as unknown as { trimFrozen: (n: number) => void }).trimFrozen(30);
329+
330+
// The 30 hidden (oldest) lines were removed; nothing exposed got hidden.
331+
expect(hiddenCount(output)).toBe(0);
332+
expect(frozen(output).children.length).toBe(Output.A11Y_EXPOSED_FROZEN_LINES);
333+
334+
// Freezing more lines re-hides from the new front, incrementally.
335+
(output as unknown as { allLines: OutputLine[] }).allLines = makeLines(total - 30 + 5);
336+
freeze(output);
337+
expect(hiddenCount(output)).toBe(5);
338+
});
339+
});
340+
341+
describe("Output history exposure toggle", () => {
342+
const makeLines = (count: number): OutputLine[] =>
343+
Array.from({ length: count }, (_, i) => ({
344+
content: <div>{`line ${i}`}</div>,
345+
id: i,
346+
sourceContent: `line ${i}`,
347+
sourceType: "test",
348+
type: OutputType.ServerMessage,
349+
}));
350+
351+
const makeOutput = (lines: OutputLine[]): Output => {
352+
const output = new Output({ client: {} as MudClient });
353+
Object.defineProperty(output, "allLines", { value: lines, writable: true });
354+
const frozenDiv = document.createElement("div");
355+
Object.defineProperty(output, "frozenRef", { value: { current: frozenDiv } });
356+
return output;
357+
};
358+
359+
const frozen = (output: Output): HTMLDivElement =>
360+
(output as unknown as { frozenRef: { current: HTMLDivElement } }).frozenRef.current;
361+
362+
const freeze = (output: Output) =>
363+
(output as unknown as { freezeOverflow: () => void }).freezeOverflow();
364+
365+
const hiddenCount = (output: Output): number =>
366+
frozen(output).querySelectorAll('[aria-hidden="true"]').length;
367+
368+
const setRevealed = (output: Output, historyRevealed: boolean) => {
369+
Object.assign(output.state, { historyRevealed });
370+
};
371+
372+
it("exposes all frozen lines while revealed, including new arrivals", () => {
373+
const total = Output.LIVE_WINDOW_SIZE + Output.A11Y_EXPOSED_FROZEN_LINES + 40;
374+
const output = makeOutput(makeLines(total));
375+
freeze(output);
376+
expect(hiddenCount(output)).toBe(40);
377+
378+
setRevealed(output, true);
379+
freeze(output);
380+
expect(hiddenCount(output)).toBe(0);
381+
382+
// New lines freezing while revealed stay exposed too.
383+
(output as unknown as { allLines: OutputLine[] }).allLines = makeLines(total + 10);
384+
freeze(output);
385+
expect(hiddenCount(output)).toBe(0);
386+
});
387+
388+
it("re-hides everything beyond the cap when revealed is switched off", () => {
389+
const total = Output.LIVE_WINDOW_SIZE + Output.A11Y_EXPOSED_FROZEN_LINES + 40;
390+
const output = makeOutput(makeLines(total));
391+
setRevealed(output, true);
392+
freeze(output);
393+
expect(hiddenCount(output)).toBe(0);
394+
395+
setRevealed(output, false);
396+
freeze(output);
397+
398+
const frozenDiv = frozen(output);
399+
expect(hiddenCount(output)).toBe(40);
400+
expect(frozenDiv.children[0].getAttribute("aria-hidden")).toBe("true");
401+
expect(frozenDiv.children[40].hasAttribute("aria-hidden")).toBe(false);
402+
});
403+
});

src/components/output.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ interface State {
8484
newLinesCount: number; // Added to track the count of new lines
8585
localEchoActive: boolean; // To store the current local echo preference
8686
focusedLineIndex: number | null; // Index of currently focused line for keyboard navigation
87+
historyRevealed: boolean; // Expose beyond-cap history to the accessibility tree
8788
}
8889

8990
// Add a small threshold for scroll calculations to handle browser differences
@@ -94,6 +95,15 @@ class Output extends React.Component<Props, State> {
9495
private frozenRef: React.RefObject<HTMLDivElement> = React.createRef();
9596
static MAX_OUTPUT_LENGTH = 3000; // Maximum number of messages to keep
9697
static LIVE_WINDOW_SIZE = 200; // Number of lines React manages (rest are frozen HTML)
98+
// Frozen lines beyond this stay visible but are removed from the
99+
// accessibility tree (aria-hidden). Screen readers pay a per-announcement
100+
// cost that scales with the document's accessibility-tree size: with a full
101+
// 3000-line buffer exposed, NVDA took multiple seconds to speak each new
102+
// message; capping exposure at ~1000 total lines (~50KB) restored instant
103+
// announcements (measured live with NVDA + Chrome, 2026-08).
104+
// Review commands (reviewRecentOutputLine etc.) read allLines directly and
105+
// are unaffected. See investigations/output-large-burst-perf.md.
106+
static A11Y_EXPOSED_FROZEN_LINES = 800;
97107
static LOCAL_STORAGE_KEY = "outputLog"; // Key for saving output in LocalStorage
98108
static SAVE_DEBOUNCE_MS = 500; // Coalesce bursts of server lines into one write
99109
messageKey: number = 0;
@@ -111,6 +121,8 @@ class Output extends React.Component<Props, State> {
111121
private allLines: OutputLine[] = [];
112122
// How many lines have been rendered into the frozen container
113123
private frozenCount: number = 0;
124+
// How many frozen lines (from the front) carry aria-hidden
125+
private frozenHiddenCount: number = 0;
114126
// Total lines ever added (monotonically increasing, survives trimming)
115127
private totalLinesAdded: number = 0;
116128
private prevTotalLinesAdded: number = 0;
@@ -126,6 +138,7 @@ class Output extends React.Component<Props, State> {
126138
newLinesCount: 0,
127139
localEchoActive: usePreferences.getState().general.localEcho,
128140
focusedLineIndex: null,
141+
historyRevealed: false,
129142
};
130143
}
131144

@@ -411,8 +424,45 @@ componentDidUpdate(
411424
frozenDiv.appendChild(wrapper);
412425
this.frozenCount++;
413426
}
427+
428+
// Hide frozen lines that crossed the exposure boundary from the
429+
// accessibility tree (or expose everything while historyRevealed).
430+
// Incremental: only lines whose state changes are touched.
431+
const shouldBeHidden = this.state.historyRevealed
432+
? 0
433+
: Math.max(0, frozenDiv.children.length - Output.A11Y_EXPOSED_FROZEN_LINES);
434+
while (this.frozenHiddenCount < shouldBeHidden) {
435+
frozenDiv.children[this.frozenHiddenCount].setAttribute('aria-hidden', 'true');
436+
this.frozenHiddenCount++;
437+
}
438+
while (this.frozenHiddenCount > shouldBeHidden) {
439+
this.frozenHiddenCount--;
440+
frozenDiv.children[this.frozenHiddenCount].removeAttribute('aria-hidden');
441+
}
442+
}
443+
444+
/** Lines currently (or potentially) excluded from the accessibility tree. */
445+
private beyondCapLineCount(): number {
446+
return Math.max(
447+
0,
448+
this.allLines.length - Output.LIVE_WINDOW_SIZE - Output.A11Y_EXPOSED_FROZEN_LINES
449+
);
414450
}
415451

452+
toggleHistoryExposure = () => {
453+
const revealing = !this.state.historyRevealed;
454+
const count = this.beyondCapLineCount();
455+
// freezeOverflow applies the new exposure in componentDidUpdate.
456+
this.setState({ historyRevealed: revealing }, () => {
457+
announce(
458+
revealing
459+
? `${count} earlier messages shown to screen reader`
460+
: 'Earlier messages hidden from screen reader',
461+
'polite'
462+
);
463+
});
464+
};
465+
416466
/**
417467
* Remove old lines from the front of the frozen container.
418468
*/
@@ -426,6 +476,8 @@ componentDidUpdate(
426476
}
427477
}
428478
this.frozenCount = Math.max(0, this.frozenCount - count);
479+
// Trimmed lines come off the front, which is where the hidden ones are.
480+
this.frozenHiddenCount = Math.max(0, this.frozenHiddenCount - count);
429481
}
430482

431483

@@ -693,6 +745,7 @@ scrollToBottom = () => { const output = this.outputRef.current; if (output) {
693745
this.cancelScheduledSave();
694746
this.allLines = [];
695747
this.frozenCount = 0;
748+
this.frozenHiddenCount = 0;
696749
this.totalLinesAdded = 0;
697750
this.prevTotalLinesAdded = 0;
698751
const frozenDiv = this.frozenRef.current;
@@ -920,6 +973,17 @@ scrollToBottom = () => { const output = this.outputRef.current; if (output) {
920973
tabIndex={0}
921974
aria-label="Game output log - use arrow keys to navigate"
922975
>
976+
{this.beyondCapLineCount() > 0 && (
977+
<button
978+
type="button"
979+
className="history-exposure-toggle"
980+
onClick={this.toggleHistoryExposure}
981+
>
982+
{this.state.historyRevealed
983+
? "Hide earlier history from screen reader"
984+
: `Show ${this.beyondCapLineCount()} earlier messages to screen reader`}
985+
</button>
986+
)}
923987
<div ref={this.frozenRef} />
924988
{visibleLiveOutput.map((line, index) => (
925989
<div

0 commit comments

Comments
 (0)