Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 26 additions & 18 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -2141,26 +2141,34 @@ body.is-sorting {
display: block;
}

/* Scrollback rows off screen skip style, layout and paint (#443). Every line
stays a live, flat child of the scroller — the cursor ops, eviction, search,
copy, caret mode and link navigation all index those children directly, so
the rows are not grouped into chunks or virtualised away — but with tens of
thousands of them, each output burst re-walked them all in PrePaint and
Paint. `auto` keeps a skipped row in the DOM, the accessibility tree,
find-in-page and the selection, and renders it again the moment it nears
the viewport or a selection reaches into it.

Placeholder height is one line until the row has been laid out once; `auto`
then remembers its real height, so a wrapped row keeps its size off screen.
Only rows drawn while scrolled up (split view) wait on the estimate.

Paint containment comes with it and clips a row to its own box, so the
/* Scrollback off screen skips style, layout and paint (#443, #457). Rows are
grouped into blocks of 100 (src/ui/output/outputRows.ts) and the block, not
the row, carries `content-visibility`: on every row it made tens of
thousands of elements the browser had to track for visibility each frame,
and that tracking grew to the largest cost in a frame. `auto` keeps a
skipped block in the DOM, the accessibility tree, find-in-page and the
selection, and renders it again the moment it nears the viewport or a
selection reaches into it.

The open block — the newest, still being written to — is left out: it is
at most a hundred rows, it is where the reader is, and while they are
scrolled up (split view) its height then stays its real one rather than
what it measured before its last rows arrived.

Placeholder height is a full block until a block has been laid out once;
`auto` then remembers its real height, wrapped rows included.

Paint containment comes with it and clips a block to its own box, so the
clip edge is pushed out: a span's 2px padding and a tall glyph may still
reach into the row above or below as they always could. */
.output-wrapper > .output-msg {
reach past the first or last row as they always could. */
.output-block {
display: block;
}

.output-block:not(.output-block--open) {
content-visibility: auto;
contain-intrinsic-block-size: auto 1.3em;
contain-intrinsic-block-size: auto 1lh;
contain-intrinsic-block-size: auto 130em;
contain-intrinsic-block-size: auto 100lh;
overflow-clip-margin: 0.5em;
}

Expand Down
49 changes: 40 additions & 9 deletions src/mud/connection/MudClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,38 @@ type InboundItem =

/** Lines a slice of a large read hands the pipeline at a time. */
const INBOUND_SLICE_LINES = 32;
/** How long a slice runs before the event loop gets a turn. */
/** How long a slice runs before the event loop gets a turn while the user
* has input waiting (where the browser can tell — isInputPending). */
const INBOUND_SLICE_BUDGET_MS = 12;
/** How long a slice runs before the event loop gets a turn regardless: what
* timers and input wait at most during a flood.
*
* Not the 12 ms it was. Every turn the event loop gets is a chance for the
* browser to render, and a frame costs layout and paint over the console
* however few lines it adds, so a 2000-line burst yielding every 12 ms paid
* for a frame roughly every slice — a long burst into a long scrollback took
* five times as long as one handled whole (#457). 50 ms is the long-task
* threshold: the page still answers within a frame or three, and a burst
* pays for a quarter of the frames. */
const INBOUND_READ_BUDGET_MS = 50;

/** Chromium's `navigator.scheduling.isInputPending()`: true when a key press
* or click is queued behind the running task. False wherever it is missing. */
function inputPending(): boolean {
const scheduling = (globalThis.navigator as { scheduling?: { isInputPending?: () => boolean } } | undefined)?.scheduling;
try {
return scheduling?.isInputPending?.() === true;
} catch {
return false;
}
}

/** Whether a slice that has run `elapsed` ms should give the event loop its
* turn. Exported for the tests. */
export function inboundSliceDue(elapsed: number, hasInput: () => boolean = inputPending): boolean {
if (elapsed >= INBOUND_READ_BUDGET_MS) return true;
return elapsed >= INBOUND_SLICE_BUDGET_MS && hasInput();
}

/** Where the `n`th line of `data` ends (just past its `\n`), or its length. */
function afterLines(data: string, n: number): number {
Expand Down Expand Up @@ -1415,13 +1445,14 @@ export class MudClient {
// one go held the page (input, timers, painting) for seconds (#435).
// Socket reads are queued here instead and run in slices: a few dozen
// lines at a time, giving the event loop a turn once a slice has taken
// INBOUND_SLICE_BUDGET_MS. Nothing changes order — the queue is the one
// way in, so lines, prompts, GMCP and what triggers send stay exactly in
// stream order — and a pause only ever falls between whole lines, with no
// partial line, held wrap line or half a telnet sequence pending: a point
// a read could have ended at anyway, so what runs in a pause is only what
// could run between two reads. Anything that has to see the stream up to
// date (close, disconnect, connect, feedTelnet, a replay) drains the queue
// INBOUND_READ_BUDGET_MS (INBOUND_SLICE_BUDGET_MS with input waiting).
// Nothing changes order — the queue is the one way in, so lines,
// prompts, GMCP and what triggers send stay exactly in stream order —
// and a pause only ever falls between whole lines, with no partial line,
// held wrap line or half a telnet sequence pending: a point a read could
// have ended at anyway, so what runs in a pause is only what could run
// between two reads. Anything that has to see the stream up to date
// (close, disconnect, connect, feedTelnet, a replay) drains the queue
// first. A read of no more than INBOUND_SLICE_LINES lines — any ordinary
// one — is still handled whole before onmessage returns.
private readonly inbound: InboundItem[] = [];
Expand Down Expand Up @@ -1524,7 +1555,7 @@ export class MudClient {
console.error('Error during data processing:', processingError);
}
if (slice && queue.length > 0 && this.pendingTelnet === '' && this.assembler.idle
&& performance.now() - started >= INBOUND_SLICE_BUDGET_MS) {
&& inboundSliceDue(performance.now() - started)) {
return false;
}
}
Expand Down
35 changes: 11 additions & 24 deletions src/mud/text/FormatState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type UnderlineStyle,
} from "./hyperlinkConfig";
import { applyVisibility } from "./hyperlinkVisibility";
import { detachRow, detachRows } from "../../ui/output/outputRows";
import { appendCells, cellsToHtml, columnAfter } from "./cellRender";
import { getControlCharacterMode } from "./controlCharacterMode";
import { getExpectColorSpaceId } from "./colorSpaceId";
Expand Down Expand Up @@ -1049,19 +1050,19 @@ export class AnsiAwareBuffer {
// the line element itself, which is both).
const el = this._lineElement ?? this._renderContainer;
if (!el) return;
el.parentElement?.removeChild(el);
// With the scrollback block it sat in, once that is empty (#457).
detachRow(el);
this._renderContainer = null;
this._lineElement = null;
}

/**
* `removeFromDom()` for a whole eviction batch at once. A trim takes a run
* of lines off the head of a buffer, and their rows normally sit side by
* side at the top of one container — those go in a single Range deletion
* rather than one removeChild apiece, which on a 20,000-line trim was half
* a second of mutation bookkeeping alone (#443). Anything that is not one
* unbroken run of rows in one parent (rows spread over containers, a gap
* where some other element sits between two of them) falls back to the
* side at the top of the console — those go a block or a Range deletion
* at a time rather than one removeChild apiece, which on a 20,000-line
* trim was half a second of mutation bookkeeping alone (#443). Rows with
* a gap between them (some other element in the way) fall back to the
* per-line removal, so nothing that is not one of these lines is touched.
*/
static removeAllFromDom(buffers: readonly AnsiAwareBuffer[]): void {
Expand All @@ -1074,24 +1075,10 @@ export class AnsiAwareBuffer {
// place hands its row to the buffer that replaced it).
if (el && el.parentElement && rows[rows.length - 1] !== el) rows.push(el);
}
if (rows.length === 0) return;
// nextSibling, not nextElementSibling: the range takes every node
// between its ends, so not even a stray text node may sit in the run.
let contiguous = true;
for (let i = 1; i < rows.length; i++) {
if (rows[i - 1].nextSibling !== rows[i]) {
contiguous = false;
break;
}
}
if (contiguous && rows.length > 1 && typeof document !== 'undefined' && document.createRange) {
const range = document.createRange();
range.setStartBefore(rows[0]);
range.setEndAfter(rows[rows.length - 1]);
range.deleteContents();
return;
}
for (const el of rows) el.parentElement?.removeChild(el);
// Rows are grouped into scrollback blocks (outputRows.ts): a block the
// trim empties goes whole, and each partial block's run goes as one
// Range deletion.
detachRows(rows);
}

onRender(callback: (container: HTMLElement) => void): this {
Expand Down
4 changes: 3 additions & 1 deletion src/mud/text/hyperlinkVisibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import type { VisibilitySettings } from "./hyperlinkConfig";
import type { AnsiAwareBuffer } from "./FormatState";
import { detachRow } from "../../ui/output/outputRows";

const OUTPUT_LINE_SELECTOR = ".output-msg";

Expand All @@ -47,7 +48,8 @@ const SKIP_KEY: Record<string, string> = {
const armedLinks = new Set<HTMLElement>();

function concealElement(el: HTMLElement, deleteLine: boolean): void {
if (deleteLine) (el.closest(OUTPUT_LINE_SELECTOR) ?? el).remove();
// With its scrollback block, once that is empty (outputRows.ts).
if (deleteLine) detachRow(el.closest(OUTPUT_LINE_SELECTOR) ?? el);
else el.style.visibility = "hidden";
}

Expand Down
6 changes: 4 additions & 2 deletions src/scripting/ScriptingAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6868,8 +6868,10 @@ export class ScriptingAPI {
const activeEl = document.activeElement;
if (!activeEl) return false;
if (!windowName || windowName === 'main') {
const input = document.querySelector('.command-input');
return !!input && (activeEl === input || (!!input && input.contains(activeEl)));
// Asked from the focused element up, not by finding the input:
// a document-wide querySelector walks every scrollback row ahead
// of the command line, and EMCO asks once per chat line (#457).
return activeEl.closest('.command-input') !== null;
}
const el = this.session.windows.getElement(windowName);
return !!el && (activeEl === el || el.contains(activeEl));
Expand Down
Loading
Loading