From fc8e9c835d34c6dcaedacffb8a1724c00563d89d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:23:40 +0000 Subject: [PATCH 1/2] Keep long sessions fast: block scrollback rendering, longer slices, O(1) hasFocus, buffered appends (#457) - Scrollback rows now go into blocks of 100 (src/ui/output/outputRows.ts), and content-visibility sits on the full blocks instead of on every row. Blink tracked every row with its internal intersection observer, which became the largest cost per frame once scrollback passed ~10k lines. The trigger cursor, eviction, the split view, caret mode, scripted scrolling and OSC 8 line deletion all walk rows through the new helpers. Eviction drops whole blocks or one Range per partial block, and never leaves an empty block behind. The open tail block is never skipped, so its height stays exact while the reader is scrolled up. - Inbound slices now run up to 50 ms, or 12 ms when isInputPending reports waiting input, instead of yielding every 12 ms. Each yield could let a render frame through, so a large burst paid for a frame on nearly every slice. - hasFocus() now asks activeElement.closest('.command-input') instead of running a document-wide querySelector, which walked every scrollback row. - ProfileVFS keeps bytes appended to an existing file in memory and writes them in one go: after 500 ms, at 256 KB, before any read, stat, rewrite, rename or removal of the file, on flush/unmount, and when the page is hidden or closed. ZenFS rewrote the whole file on every append. stat() and exists() count the held tail, so io.open(f, "a") never forces a flush. Fixes #457 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014UBzjfaJoyFC6yixLRLJ7z --- src/App.css | 44 ++-- src/mud/connection/MudClient.ts | 49 ++++- src/mud/text/FormatState.ts | 35 +-- src/mud/text/hyperlinkVisibility.ts | 4 +- src/scripting/ScriptingAPI.ts | 6 +- src/scripting/vfs/ProfileVFS.ts | 190 +++++++++++++--- src/ui/output/CaretReviewPanel.tsx | 28 ++- src/ui/output/OutputRenderer.ts | 107 ++++----- src/ui/output/caretMode.ts | 11 +- src/ui/output/outputRows.ts | 240 +++++++++++++++++++++ src/ui/windows/WindowManager.ts | 12 +- tests/mud/connection/inboundSlices.test.ts | 23 +- tests/scripting/hasFocusCost457.test.ts | 31 +++ tests/scripting/vfsAppendTail457.test.ts | 126 +++++++++++ tests/ui/scrollbackBlocks457.test.ts | 187 ++++++++++++++++ tests/ui/scrollbackRenderCost443.test.ts | 9 +- 16 files changed, 932 insertions(+), 170 deletions(-) create mode 100644 src/ui/output/outputRows.ts create mode 100644 tests/scripting/hasFocusCost457.test.ts create mode 100644 tests/scripting/vfsAppendTail457.test.ts create mode 100644 tests/ui/scrollbackBlocks457.test.ts diff --git a/src/App.css b/src/App.css index a0b79621..21712771 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } diff --git a/src/mud/connection/MudClient.ts b/src/mud/connection/MudClient.ts index c340ec6f..5b9abca7 100644 --- a/src/mud/connection/MudClient.ts +++ b/src/mud/connection/MudClient.ts @@ -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 { @@ -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[] = []; @@ -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; } } diff --git a/src/mud/text/FormatState.ts b/src/mud/text/FormatState.ts index d8dd6402..f6f59718 100644 --- a/src/mud/text/FormatState.ts +++ b/src/mud/text/FormatState.ts @@ -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"; @@ -1049,7 +1050,8 @@ 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; } @@ -1057,11 +1059,10 @@ export class AnsiAwareBuffer { /** * `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 { @@ -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 { diff --git a/src/mud/text/hyperlinkVisibility.ts b/src/mud/text/hyperlinkVisibility.ts index b8073539..51919b7a 100644 --- a/src/mud/text/hyperlinkVisibility.ts +++ b/src/mud/text/hyperlinkVisibility.ts @@ -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"; @@ -47,7 +48,8 @@ const SKIP_KEY: Record = { const armedLinks = new Set(); 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"; } diff --git a/src/scripting/ScriptingAPI.ts b/src/scripting/ScriptingAPI.ts index 54fa1729..64fdb6e2 100644 --- a/src/scripting/ScriptingAPI.ts +++ b/src/scripting/ScriptingAPI.ts @@ -6799,8 +6799,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)); diff --git a/src/scripting/vfs/ProfileVFS.ts b/src/scripting/vfs/ProfileVFS.ts index b8b8557d..f5bb12a4 100644 --- a/src/scripting/vfs/ProfileVFS.ts +++ b/src/scripting/vfs/ProfileVFS.ts @@ -46,6 +46,42 @@ const mountChain = new Map>(); export type VFSSource = 'folder' | 'idb'; +/** How long an appended tail waits in memory before it is written out. */ +export const APPEND_FLUSH_MS = 500; +/** A tail that has grown this large is written out at once. */ +export const APPEND_FLUSH_BYTES = 256 * 1024; + +/** A file's appended bytes not yet in the store (see appendBinaryFile). */ +interface PendingAppend { + chunks: Uint8Array[]; + bytes: number; + mtimeMs: number; +} + +/** The mounts holding an appended tail, so the page going away writes them + * all out. */ +const vfsWithAppends = new Set(); +/** The page is being hidden or unloaded: no timer may be counted on now, so + * an append goes straight to the store. */ +let pageHiding = false; +let pageListenersInstalled = false; + +function installPageListeners(): void { + if (pageListenersInstalled || typeof window === 'undefined' || typeof window.addEventListener !== 'function') return; + pageListenersInstalled = true; + const flushAll = () => { for (const vfs of vfsWithAppends) vfs.flushAppends(); }; + // Whichever order this runs in against the sysExitEvent handler (#438), + // nothing is left behind: an append made before it is flushed here, one + // made after it finds pageHiding set and is written through. + window.addEventListener('pagehide', () => { pageHiding = true; flushAll(); }, { capture: true }); + window.addEventListener('pageshow', () => { pageHiding = false; }); + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') flushAll(); + }); + } +} + // AsyncMixin exposes sync(), but it isn't on FileSystem's public type. type Syncable = FileSystem & { sync?: () => Promise }; @@ -103,6 +139,9 @@ export class ProfileVFS { /** See {@link writeGeneration}. Bumped in afterWrite, the one place every * write, removal and rename already reports through. */ private readonly writeGenerations = new Map(); + /** See {@link appendBinaryFile}. Keyed by absolute path. */ + private readonly pendingAppends = new Map(); + private appendFlushTimer: ReturnType | null = null; private constructor( readonly connectionId: string, @@ -226,15 +265,21 @@ export class ProfileVFS { } exists(path: string): boolean { - try { return existsSync(this.resolvePath(path)); } catch { return false; } + try { + const abs = this.resolvePath(path); + // Only a file already in the store gets a held tail, so it exists + // — answered without writing the tail out, since io.open(f, "a") + // asks on every close. + return this.pendingAppends.has(abs) || existsSync(abs); + } catch { return false; } } readFile(path: string): string { - return readFileSync(this.beforeRead(this.resolvePath(path)), 'utf8') as string; + return readFileSync(this.settle(this.beforeRead(this.resolvePath(path))), 'utf8') as string; } readBinaryFile(path: string): Uint8Array { - return readFileSync(this.beforeRead(this.resolvePath(path))) as unknown as Uint8Array; + return readFileSync(this.settle(this.beforeRead(this.resolvePath(path)))) as unknown as Uint8Array; } /** @@ -250,7 +295,7 @@ export class ProfileVFS { } writeFile(path: string, content: string): void { - const abs = this.resolvePath(path); + const abs = this.settle(this.resolvePath(path)); ensureParentDir(abs); this.clearForOverwrite(abs); writeFileSync(abs, content, 'utf8'); @@ -261,7 +306,7 @@ export class ProfileVFS { /** `createParents: false` writes only into a directory that already exists, * throwing as open(2) would otherwise — what a download's QFile does. */ writeBinaryFile(path: string, data: Uint8Array, { createParents = true }: { createParents?: boolean } = {}): void { - const abs = this.resolvePath(path); + const abs = this.settle(this.resolvePath(path)); if (createParents) ensureParentDir(abs); this.clearForOverwrite(abs); writeFileSync(abs, data); @@ -283,30 +328,108 @@ export class ProfileVFS { } appendFile(path: string, content: string): void { - const abs = this.resolvePath(path); - ensureParentDir(abs); - appendFileSync(abs, content, 'utf8'); - this.invalidate(abs); - this.afterWrite(abs, 'write'); + this.appendBinaryFile(path, new TextEncoder().encode(content)); } /** * Add `data` to the end of a file, creating it when missing — what Lua's - * io.open(f, "a") writes through, so an append costs what is appended - * rather than a read and rewrite of everything the file already holds. The - * read barrier runs first so the bytes land after anything a database still - * has pending for that file. + * io.open(f, "a") writes through. The read barrier runs first so the bytes + * land after anything a database still has pending for that file. + * + * ZenFS keeps a file as one value in its store, and an append reads that + * value, copies it one longer and puts the whole of it back, so every + * append cost the size of the file: a chat log EMCO appends a line to + * per message took 30 ms a line at 5 MB (#457), where desktop's write is + * the line's bytes. So the bytes appended to a file already in the store + * are held here and written out together — after APPEND_FLUSH_MS, once + * they reach APPEND_FLUSH_BYTES, or as soon as anything reads, stats, + * rewrites, moves or removes the file (every such path goes through + * {@link settle}), and when the page is hidden or goes away. One write per + * window instead of one per line, and nothing can see the file without + * the tail: {@link stat} counts it into the size and {@link exists} knows + * the file is there. + * + * A file that is not there yet, a link, or an append while the page is + * going away is written straight through, as before. */ appendBinaryFile(path: string, data: Uint8Array): void { const abs = this.beforeRead(this.resolvePath(path)); - ensureParentDir(abs); - appendFileSync(abs, data); + let pending = this.pendingAppends.get(abs); + if (!pending && !pageHiding && this.isPlainFile(abs)) { + pending = { chunks: [], bytes: 0, mtimeMs: 0 }; + this.pendingAppends.set(abs, pending); + vfsWithAppends.add(this); + installPageListeners(); + } + if (pending) { + // Held past the call, so not the caller's buffer to reuse. + pending.chunks.push(data.slice()); + pending.bytes += data.byteLength; + pending.mtimeMs = Date.now(); + if (pending.bytes >= APPEND_FLUSH_BYTES || pageHiding) this.flushAppends(abs); + else this.scheduleAppendFlush(); + } else { + ensureParentDir(abs); + appendFileSync(abs, data); + } this.invalidate(abs); this.afterWrite(abs, 'write'); } + /** A regular file in the store with only this one name — the only kind + * whose appends can be held: a held tail is found again by its path. */ + private isPlainFile(abs: string): boolean { + try { + const s = lstatSync(abs); + return s.isFile() && s.nlink <= 1; + } catch { return false; } + } + + private scheduleAppendFlush(): void { + if (this.appendFlushTimer !== null) return; + this.appendFlushTimer = setTimeout(() => { + this.appendFlushTimer = null; + this.flushAppends(); + }, APPEND_FLUSH_MS); + } + + /** + * Write out the appended bytes held for `scope` — that file, or every + * file under it when it is a directory — or for every file when omitted. + * Returns `scope`, so a path can be settled on its way into a call. + */ + flushAppends(scope?: string): string | undefined { + if (this.pendingAppends.size === 0) return scope; + const prefix = scope === undefined ? '' : `${scope}/`; + for (const [abs, pending] of [...this.pendingAppends]) { + if (scope !== undefined && abs !== scope && !abs.startsWith(prefix)) continue; + this.pendingAppends.delete(abs); + const data = pending.chunks.length === 1 ? pending.chunks[0] : concatBytes(pending.chunks, pending.bytes); + try { + ensureParentDir(abs); + appendFileSync(abs, data); + } catch (e) { + console.error(`[ProfileVFS] could not write appended data to ${abs}:`, e); + } + } + if (this.pendingAppends.size === 0) { + vfsWithAppends.delete(this); + if (this.appendFlushTimer !== null) { + clearTimeout(this.appendFlushTimer); + this.appendFlushTimer = null; + } + } + return scope; + } + + /** {@link flushAppends} for one absolute path, returned for chaining. */ + private settle(abs: string): string { + if (this.pendingAppends.size > 0) this.flushAppends(abs); + return abs; + } + deleteFile(path: string): void { - const abs = this.resolvePath(path); + const abs = this.settle(this.resolvePath(path)); unlinkSync(abs); this.invalidate(abs); this.afterWrite(abs, 'remove'); @@ -322,8 +445,8 @@ export class ProfileVFS { * directory (EISDIR). */ rename(oldPath: string, newPath: string, { posix = false }: { posix?: boolean } = {}): void { - const absOld = this.beforeRead(this.resolvePath(oldPath)); - const absNew = this.resolvePath(newPath); + const absOld = this.settle(this.beforeRead(this.resolvePath(oldPath))); + const absNew = this.settle(this.resolvePath(newPath)); if (posix) { this.checkPosixPath(oldPath, 'EBUSY'); this.checkPosixPath(newPath, 'EBUSY'); @@ -389,7 +512,7 @@ export class ProfileVFS { * saved data with a call that desktop refuses. */ rmdir(path: string, { recursive = true }: { recursive?: boolean } = {}): void { - const abs = this.resolvePath(path); + const abs = this.settle(this.resolvePath(path)); if (recursive) { try { rmdirSync(abs); @@ -417,7 +540,7 @@ export class ProfileVFS { // glibc's remove() unlinks, and only on EISDIR tries rmdir — which // refuses a path ending in "." (EINVAL). this.checkPosixPath(path, 'EINVAL'); - const abs = this.resolvePath(path); + const abs = this.settle(this.resolvePath(path)); // A symbolic link goes itself, whatever (if anything) it points at. let isLink = false; try { isLink = lstatSync(abs).isSymbolicLink(); } catch { /* not there */ } @@ -465,7 +588,7 @@ export class ProfileVFS { * `lfs.touch` does — it never creates a file. */ touch(path: string, atime: Date, mtime: Date): void { - const abs = this.resolvePath(path); + const abs = this.settle(this.resolvePath(path)); if (!existsSync(abs)) throw new FsError('ENOENT', abs); utimesSync(abs, atime, mtime); } @@ -476,11 +599,15 @@ export class ProfileVFS { stat(path: string): VfsStat | null { try { - const s = statSync(this.beforeRead(this.resolvePath(path))); + const abs = this.beforeRead(this.resolvePath(path)); + const s = statSync(abs); + // A held tail is part of the file: io.open(f, "a") starts its + // handle at this size on every open. + const pending = this.pendingAppends.get(abs); return { type: s.isDirectory() ? 'dir' : 'file', - size: s.size, - mtime: new Date(s.mtimeMs), + size: s.size + (pending?.bytes ?? 0), + mtime: new Date(pending ? Math.max(pending.mtimeMs, s.mtimeMs) : s.mtimeMs), atime: new Date(s.atimeMs), ctime: new Date(s.ctimeMs), mode: s.mode, @@ -499,7 +626,7 @@ export class ProfileVFS { /** `lstat(2)`: like {@link stat}, but about a symbolic link itself, which * reports as type `link`. Throws the filesystem's error (ENOENT …). */ lstat(path: string): VfsLstat { - const s = lstatSync(this.resolvePath(path)); + const s = lstatSync(this.settle(this.resolvePath(path))); return { type: s.isSymbolicLink() ? 'link' : s.isDirectory() ? 'dir' : 'file', size: s.size, @@ -527,6 +654,8 @@ export class ProfileVFS { * target is stored as given (it may be relative, or dangle); a hard link's * must exist. Throws the filesystem's error (EEXIST, ENOENT, ENOTSUP …). */ link(target: string, path: string, symbolic: boolean): void { + // A hard link would give a held tail a second name it is not found by. + if (!symbolic) this.settle(this.resolvePath(target)); const abs = this.resolvePath(path); let present = false; try { lstatSync(abs); present = true; } catch { /* free */ } @@ -561,6 +690,7 @@ export class ProfileVFS { * it persists to IndexedDB. Safe to call on any source. */ async flush(): Promise { + this.flushAppends(); if (typeof this._fs.sync === 'function') { try { await this._fs.sync(); } catch (err) { console.warn('[ProfileVFS] flush failed:', err); } } @@ -599,6 +729,7 @@ export class ProfileVFS { // have replaced us at this path before our destroy()'s fire-and-forget // flush() resolved; unmounting then would kill the replacement. if (mounts.get(this.profilePath) !== this._fs) return; + this.flushAppends(); try { umount(this.profilePath); } catch { /* already unmounted */ } } } @@ -632,6 +763,13 @@ function ensureProfileDirs(profilePath: string): void { } } +function concatBytes(chunks: readonly Uint8Array[], total: number): Uint8Array { + const out = new Uint8Array(total); + let at = 0; + for (const c of chunks) { out.set(c, at); at += c.byteLength; } + return out; +} + function ensureParentDir(absPath: string): void { const parent = absPath.substring(0, absPath.lastIndexOf('/')); if (parent && !existsSync(parent)) { diff --git a/src/ui/output/CaretReviewPanel.tsx b/src/ui/output/CaretReviewPanel.tsx index 947058f3..efce0939 100644 --- a/src/ui/output/CaretReviewPanel.tsx +++ b/src/ui/output/CaretReviewPanel.tsx @@ -3,6 +3,7 @@ import type React from 'react'; import type { MudSession } from '../../mud/MudSession'; import { useProfileField } from '../../storage'; import { handleLinkNavKeydown } from './linkNavigation'; +import { isOutputBlock } from './outputRows'; import { matchCaretToggle, outputLineElements, @@ -89,26 +90,39 @@ export function CaretReviewPanel({ } scroll.scrollTop = scroll.scrollHeight; // start at the newest line + // Lines arrive and leave as rows inside scrollback blocks, and as whole + // blocks (outputRows.ts), so the observer watches the subtree and only + // looks at what was added to or taken from the scroller or a block. + const rowsOf = (n: Node): HTMLElement[] => { + if (!(n instanceof HTMLElement)) return []; + if (n.classList.contains('output-msg')) return [n]; + return isOutputBlock(n) ? outputLineElements(n) : []; + }; const observer = new MutationObserver((mutations) => { const stick = isNearBottom(scroll); for (const m of mutations) { + const target = m.target; + if (target !== mainOutput && !(target instanceof HTMLElement && isOutputBlock(target))) continue; m.addedNodes.forEach((n) => { - if (n instanceof HTMLElement && n.classList.contains('output-msg')) { - const clone = cloneOutputLine(n); - cloneByOrig.set(n, clone); + for (const row of rowsOf(n)) { + // A new block's row is reported twice: once with the + // block, once added to it. + if (cloneByOrig.has(row) || !row.isConnected) continue; + const clone = cloneOutputLine(row); + cloneByOrig.set(row, clone); scroll.appendChild(clone); } }); m.removedNodes.forEach((n) => { - if (n instanceof HTMLElement) { - const clone = cloneByOrig.get(n); - if (clone) { clone.remove(); cloneByOrig.delete(n); } + for (const row of rowsOf(n)) { + const clone = cloneByOrig.get(row); + if (clone) { clone.remove(); cloneByOrig.delete(row); } } }); } if (stick) scroll.scrollTop = scroll.scrollHeight; }); - observer.observe(mainOutput, { childList: true }); + observer.observe(mainOutput, { childList: true, subtree: true }); // Capture-phase so we settle Escape and link-nav before the // document-level main-output link handler (whose Ctrl+] would otherwise diff --git a/src/ui/output/OutputRenderer.ts b/src/ui/output/OutputRenderer.ts index 5697e07e..a4f41e03 100644 --- a/src/ui/output/OutputRenderer.ts +++ b/src/ui/output/OutputRenderer.ts @@ -1,5 +1,8 @@ import { AnsiAwareBuffer } from "../../mud/text/FormatState"; import { installLinkNavigation } from "./linkNavigation"; +import { + appendRow, clearRows, detachRow, lastRow, lastRows, nextRow, previousRow, rowAt, rowCount, rowIndex, +} from "./outputRows"; // Buffers are stored keyed by their wrapper element. The WeakMap means the // AnsiAwareBuffer is garbage-collected automatically when its element is removed @@ -16,22 +19,6 @@ export type MessageSource = { on(event: 'script.movecursordown', listener: () => void): () => void; }; -/** Returns the 0-based index of `el` among the non-sentinel children of `parent`. */ -function elementIndex(parent: HTMLElement, el: Element, sentinel: HTMLElement): number { - const children = parent.children; - for (let i = 0; i < children.length; i++) { - if (children[i] === sentinel) break; - if (children[i] === el) return i; - } - return -1; -} - -/** Line count: all children before the sentinel. */ -function lineCount(parent: HTMLElement, sentinel: HTMLElement): number { - const idx = Array.prototype.indexOf.call(parent.children, sentinel); - return idx >= 0 ? idx : parent.childElementCount; -} - export type CursorOps = { /** Plain text of the line at the current cursor position. */ getLine: () => string; @@ -282,17 +269,29 @@ export function setupOutputRenderer( // ── Trigger cursor ──────────────────────────────────────────────────────── // - // `cursorEl` — the element the cursor is currently pointing at. - // null means "use sentinel.previousElementSibling" (the - // latest rendered line), which is also the post-delete state. - // `deletedPrev`— saved previousElementSibling of the element that was just - // deleted via deleteLine(). moveCursorUp() consumes this to - // land on the correct next element. + // `cursorEl` — the row the cursor is currently pointing at. + // null means "the newest row", which is also the + // post-delete state. + // `deletedPrev`— saved previous row of the one that was just deleted via + // deleteLine(). moveCursorUp() consumes this to land on the + // correct next row. + // + // Rows sit in scrollback blocks (outputRows.ts), so every step from row to + // row goes through those helpers rather than through siblings. let cursorEl: Element | null = null; let deletedPrev: Element | null = null; function resolveElement(): Element | null { - return cursorEl ?? sentinel.previousElementSibling; + return cursorEl ?? lastRow(outputWrapper); + } + + function deleteCursorLine(): void { + const el = resolveElement(); + if (!el || el === sentinel) return; + if (el === promptLineEl) promptLineEl = null; + deletedPrev = previousRow(el); + detachRow(el); + cursorEl = null; } const cursorOps: CursorOps = { @@ -308,14 +307,7 @@ export function setupOutputRenderer( return elementBuffers.get(el) ?? null; }, - deleteLine(): void { - const el = resolveElement(); - if (!el || el === sentinel) return; - if (el === promptLineEl) promptLineEl = null; - deletedPrev = el.previousElementSibling === sentinel ? null : el.previousElementSibling; - outputWrapper.removeChild(el); - cursorEl = null; - }, + deleteLine: deleteCursorLine, moveUp(): void { if (cursorEl === null) { @@ -323,28 +315,26 @@ export function setupOutputRenderer( cursorEl = deletedPrev; deletedPrev = null; } else { - const latest = sentinel.previousElementSibling; - cursorEl = latest?.previousElementSibling ?? null; + const latest = lastRow(outputWrapper); + cursorEl = latest ? previousRow(latest) : null; } } else { - cursorEl = cursorEl.previousElementSibling; + cursorEl = previousRow(cursorEl); } }, moveDown(): void { const current = resolveElement(); if (!current || current === sentinel) return; - const next = current.nextElementSibling; - // Stop at sentinel — it marks the end of output. - cursorEl = (next && next !== sentinel) ? next : null; + // Past the newest row the cursor is back on the newest row. + cursorEl = nextRow(current); deletedPrev = null; }, moveTo(line: number): void { // line is 1-indexed from the top of the buffer. - const idx = line - 1; - const el = outputWrapper.children[idx]; - if (el && el !== sentinel) { + const el = rowAt(outputWrapper, line - 1); + if (el) { cursorEl = el; deletedPrev = null; } @@ -353,22 +343,20 @@ export function setupOutputRenderer( getLineNumber(): number { const el = resolveElement(); if (!el || el === sentinel) return 0; - const idx = elementIndex(outputWrapper, el, sentinel); + const idx = rowIndex(outputWrapper, el); return idx >= 0 ? idx + 1 : 0; // 1-indexed }, getLineCount(): number { - return lineCount(outputWrapper, sentinel); + return rowCount(outputWrapper); }, getLines(from: number, to: number): string[] { const result: string[] = []; - const children = outputWrapper.children; - const clampedTo = Math.min(to, lineCount(outputWrapper, sentinel)); - for (let i = from - 1; i < clampedTo; i++) { - const el = children[i]; - if (!el || el === sentinel) break; + let el: Element | null = rowAt(outputWrapper, from - 1); + for (let i = from; el && i <= to; i++) { result.push(elementBuffers.get(el)?.text ?? ''); + el = nextRow(el); } return result; }, @@ -492,7 +480,7 @@ export function setupOutputRenderer( } } else { const wrapper = createMessageWrapper(message, 'script', timestampValue); - outputWrapper.insertBefore(wrapper, sentinel); + appendRow(outputWrapper, wrapper, sentinel); partialLineEl = wrapper; cursorEl = wrapper; deletedPrev = null; @@ -540,7 +528,7 @@ export function setupOutputRenderer( const wrapper = createMessageWrapper(message, effectiveType, timestampValue); - outputWrapper.insertBefore(wrapper, sentinel); + appendRow(outputWrapper, wrapper, sentinel); // Reset the cursor to the freshly rendered line so trigger processing // that fires immediately after always starts at the right position. @@ -585,11 +573,7 @@ export function setupOutputRenderer( function populateStickyArea() { clearStickyArea(); - const children = Array.from(outputWrapper.children); - const sentinelIdx = children.indexOf(sentinel as Element); - const messages = sentinelIdx >= 0 ? children.slice(0, sentinelIdx) : children; - const lastN = messages.slice(-stickyLines); - for (const el of lastN) { + for (const el of lastRows(outputWrapper, stickyLines)) { stickyArea.appendChild(el.cloneNode(true)); } applyTimestampVisibility(); @@ -600,14 +584,7 @@ export function setupOutputRenderer( if (source) { const unsubscribeMessage = source.on('message', handleMessage); - const unsubscribeDeleteLine = source.on('script.deleteline', () => { - const el = resolveElement(); - if (!el || el === sentinel) return; - if (el === promptLineEl) promptLineEl = null; - deletedPrev = el.previousElementSibling === sentinel ? null : el.previousElementSibling; - outputWrapper.removeChild(el); - cursorEl = null; - }); + const unsubscribeDeleteLine = source.on('script.deleteline', deleteCursorLine); const unsubscribeClearWindow = source.on('script.clearwindow', clearAll); const unsubscribeMoveCursorUp = source.on('script.movecursorup', cursorOps.moveUp); @@ -628,11 +605,7 @@ export function setupOutputRenderer( partialStickyEl = null; promptLineEl = null; promptStickyEl = null; - while (outputWrapper.firstElementChild !== sentinel) { - if (outputWrapper.firstElementChild) { - outputWrapper.removeChild(outputWrapper.firstElementChild); - } else break; - } + clearRows(outputWrapper); cursorEl = null; deletedPrev = null; } diff --git a/src/ui/output/caretMode.ts b/src/ui/output/caretMode.ts index 9c65943b..35b02299 100644 --- a/src/ui/output/caretMode.ts +++ b/src/ui/output/caretMode.ts @@ -1,3 +1,5 @@ +import { outputRows } from './outputRows'; + /** * Caret mode / reading cursor — keyboard-navigable review of the scrollback for * screen-reader users (Mudlet's caret mode, ported web-natively). @@ -32,12 +34,11 @@ export function matchCaretToggle(e: KeyboardEvent, shortcut: CaretShortcut): boo } /** The rendered line elements under the main output wrapper, in order. Each - * logical line is one `div.output-msg`; the height:0 sticky sentinel and any - * other children are skipped (mirrors WindowManager.lineElements). */ + * logical line is one `div.output-msg`, inside a scrollback block + * (outputRows.ts); the height:0 sticky sentinel and any other children are + * skipped (mirrors WindowManager.lineElements). */ export function outputLineElements(outputEl: HTMLElement): HTMLElement[] { - return Array.from(outputEl.children).filter( - (el): el is HTMLElement => el instanceof HTMLElement && el.classList.contains('output-msg'), - ); + return outputRows(outputEl); } /** diff --git a/src/ui/output/outputRows.ts b/src/ui/output/outputRows.ts new file mode 100644 index 00000000..ea716875 --- /dev/null +++ b/src/ui/output/outputRows.ts @@ -0,0 +1,240 @@ +// The rows of a console's scrollback, grouped into blocks (#457). +// +// Every line a console draws is one `div.output-msg` row. They used to be +// flat children of the `.output-wrapper` scroller, each with +// `content-visibility: auto` (#443) — but with a long scrollback that is tens +// of thousands of elements the browser tracks for visibility every frame, and +// that tracking became the largest cost in the frame: a client streaming 20 +// lines a second fell seconds behind the server once the scrollback passed +// 20k lines. Now the rows go into `div.output-block`s of OUTPUT_BLOCK_ROWS +// each, and the block carries `content-visibility` (App.css): a frame looks +// at a hundredth as many elements, and a block off screen skips style, +// layout and paint for all its rows at once. +// +// Desktop's TTextEdit paints only the lines in view; this keeps every row in +// the DOM — find-in-page, selection, copy, caret mode and the accessibility +// tree all still see the whole scrollback — and only lets the browser skip +// whole blocks of it. +// +// Everything that walks a console's rows goes through here rather than +// through the scroller's children, so it reads the same whether a row sits in +// a block or (a console built before this, or a test fixture) directly in the +// scroller. + +export const OUTPUT_ROW_CLASS = 'output-msg'; +export const OUTPUT_BLOCK_CLASS = 'output-block'; +/** The newest block, still being written to, which is always rendered. */ +export const OUTPUT_OPEN_BLOCK_CLASS = 'output-block--open'; +/** Rows a block holds before the next row starts a new one. */ +export const OUTPUT_BLOCK_ROWS = 100; + +function isRow(el: Element | null): el is HTMLElement { + return el !== null && el.classList.contains(OUTPUT_ROW_CLASS); +} + +export function isOutputBlock(el: Element | null): el is HTMLElement { + return el !== null && el.classList.contains(OUTPUT_BLOCK_CLASS); +} + +/** + * Add `row` as the console's newest line: into the last block before + * `sentinel` (the end of the scroller when null), or a new block when that + * one is full or there is none. + */ +export function appendRow(container: HTMLElement, row: HTMLElement, sentinel: Element | null = null): void { + const before = sentinel && sentinel.parentElement === container ? sentinel : null; + const last = before ? before.previousElementSibling : container.lastElementChild; + let block: Element; + if (isOutputBlock(last) && last.childElementCount < OUTPUT_BLOCK_ROWS) { + block = last; + } else { + // The block before is full: from here on the browser may skip it + // while it is off screen (App.css). + if (isOutputBlock(last)) last.classList.remove(OUTPUT_OPEN_BLOCK_CLASS); + block = document.createElement('div'); + block.className = `${OUTPUT_BLOCK_CLASS} ${OUTPUT_OPEN_BLOCK_CLASS}`; + container.insertBefore(block, before); + } + block.appendChild(row); +} + +/** Every row of the console, oldest first. */ +export function outputRows(container: Element): HTMLElement[] { + const out: HTMLElement[] = []; + for (let el = container.firstElementChild; el; el = el.nextElementSibling) { + if (isOutputBlock(el)) { + for (let row = el.firstElementChild; row; row = row.nextElementSibling) { + if (isRow(row)) out.push(row); + } + } else if (isRow(el)) { + out.push(el); + } + } + return out; +} + +/** The last `n` rows of the console, oldest first. */ +export function lastRows(container: Element, n: number): HTMLElement[] { + const out: HTMLElement[] = []; + for (let el = container.lastElementChild; el && out.length < n; el = el.previousElementSibling) { + if (isOutputBlock(el)) { + for (let row = el.lastElementChild; row && out.length < n; row = row.previousElementSibling) { + if (isRow(row)) out.push(row); + } + } else if (isRow(el)) { + out.push(el); + } + } + return out.reverse(); +} + +/** How many rows the console holds. Counts blocks, not rows. */ +export function rowCount(container: Element): number { + let n = 0; + for (let el = container.firstElementChild; el; el = el.nextElementSibling) { + if (isOutputBlock(el)) n += el.childElementCount; + else if (isRow(el)) n++; + } + return n; +} + +/** The row at 0-based `index`, or null past either end. */ +export function rowAt(container: Element, index: number): HTMLElement | null { + if (index < 0) return null; + let left = index; + for (let el = container.firstElementChild; el; el = el.nextElementSibling) { + if (isOutputBlock(el)) { + const n = el.childElementCount; + if (left < n) { + const row = el.children[left]; + return isRow(row) ? row : null; + } + left -= n; + } else if (isRow(el)) { + if (left === 0) return el; + left--; + } + } + return null; +} + +/** `row`'s 0-based position in the console, or -1 when it is not one of its rows. */ +export function rowIndex(container: Element, row: Element): number { + const parent = row.parentElement; + if (!parent) return -1; + const block = parent === container ? null : parent; + if (block && block.parentElement !== container) return -1; + let n = 0; + for (let el = container.firstElementChild; el; el = el.nextElementSibling) { + if (el === block) { + for (let r = el.firstElementChild; r; r = r.nextElementSibling) { + if (r === row) return n; + n++; + } + return -1; + } + if (el === row) return n; + if (isOutputBlock(el)) n += el.childElementCount; + else if (isRow(el)) n++; + } + return -1; +} + +/** The newest row, or null when the console is empty. */ +export function lastRow(container: Element): HTMLElement | null { + return lastRows(container, 1)[0] ?? null; +} + +/** The row before `row`, across a block boundary; null at the oldest. */ +export function previousRow(row: Element): HTMLElement | null { + let el = row.previousElementSibling; + while (el && !isRow(el)) { + if (isOutputBlock(el) && el.lastElementChild) return el.lastElementChild as HTMLElement; + el = el.previousElementSibling; + } + if (el) return el; + const parent = row.parentElement; + if (!isOutputBlock(parent)) return null; + for (let b = parent.previousElementSibling; b; b = b.previousElementSibling) { + if (isOutputBlock(b) && isRow(b.lastElementChild)) return b.lastElementChild; + if (isRow(b)) return b; + } + return null; +} + +/** The row after `row`, across a block boundary; null at the newest. */ +export function nextRow(row: Element): HTMLElement | null { + let el = row.nextElementSibling; + while (el && !isRow(el)) { + if (isOutputBlock(el) && el.firstElementChild) return el.firstElementChild as HTMLElement; + el = el.nextElementSibling; + } + if (el) return el; + const parent = row.parentElement; + if (!isOutputBlock(parent)) return null; + for (let b = parent.nextElementSibling; b; b = b.nextElementSibling) { + if (isOutputBlock(b) && isRow(b.firstElementChild)) return b.firstElementChild; + if (isRow(b)) return b; + } + return null; +} + +/** Take `el` out of its parent, and the block it was in with it once empty. */ +export function detachRow(el: Element): void { + const parent = el.parentElement; + if (!parent) return; + parent.removeChild(el); + if (isOutputBlock(parent) && parent.childElementCount === 0) parent.remove(); +} + +/** + * Take a run of `rows` out of the DOM at once — eviction's batch removal. A + * trim takes lines off the head of the console, which sit side by side in a + * few blocks: a block they fill goes whole, and the part of one they share + * with lines that stay goes as one Range deletion. Rows not side by side in + * their parent (some other element between two of them) are removed one at a + * time, so nothing that is not one of these rows is touched. + */ +export function detachRows(rows: readonly Element[]): void { + let i = 0; + while (i < rows.length) { + const parent = rows[i].parentElement; + let j = i + 1; + while (j < rows.length && rows[j].parentElement === parent) j++; + if (parent) detachRun(parent, rows, i, j); + i = j; + } +} + +function detachRun(parent: HTMLElement, rows: readonly Element[], from: number, to: number): void { + const count = to - from; + // nextSibling, not nextElementSibling: a range takes every node between + // its ends, so not even a stray text node may sit in the run. + let contiguous = true; + for (let k = from + 1; k < to; k++) { + if (rows[k - 1].nextSibling !== rows[k]) { contiguous = false; break; } + } + if (contiguous && isOutputBlock(parent) && parent.childNodes.length === count) { + parent.remove(); + return; + } + if (contiguous && count > 1 && typeof document !== 'undefined' && document.createRange) { + const range = document.createRange(); + range.setStartBefore(rows[from]); + range.setEndAfter(rows[to - 1]); + range.deleteContents(); + } else { + for (let k = from; k < to; k++) parent.removeChild(rows[k]); + } + if (isOutputBlock(parent) && parent.childElementCount === 0) parent.remove(); +} + +/** Remove every row (and block) of the console, keeping anything else — the + * sticky sentinel. */ +export function clearRows(container: Element): void { + for (let el = container.firstElementChild; el;) { + const next = el.nextElementSibling; + if (isOutputBlock(el) || isRow(el)) el.remove(); + el = next; + } +} diff --git a/src/ui/windows/WindowManager.ts b/src/ui/windows/WindowManager.ts index 233c780b..131dd6fa 100644 --- a/src/ui/windows/WindowManager.ts +++ b/src/ui/windows/WindowManager.ts @@ -19,6 +19,7 @@ import { OverlayLayerOrder } from '../layout/overlayLayerOrder'; import { TIMESTAMP_GUTTER_COLUMNS } from '../../mud/connection/TelnetNegotiator'; import { DEFAULT_CMD_LINE_HOST, SubCommandLine, type CmdLineHost } from '../cmdline/subCommandLine'; import { cssEscape } from '../labels/qtCss'; +import { outputRows } from '../output/outputRows'; interface ScriptWindowData extends ScriptWindowRenderData { pendingText: Array; @@ -2561,14 +2562,11 @@ export class WindowManager { el.classList.toggle('mudlet-no-scrolling', !s.scrollingEnabled); } - /** Direct line-element children of the wrapper (skips the sticky-output - * sentinel, which sits at the end with height: 0). */ + /** The wrapper's line elements, in order, through its scrollback blocks + * (skips the sticky-output sentinel, which sits at the end with + * height: 0). */ private lineElements(el: HTMLElement): HTMLElement[] { - const out: HTMLElement[] = []; - for (const child of Array.from(el.children) as HTMLElement[]) { - if (child.classList.contains('output-msg')) out.push(child); - } - return out; + return outputRows(el); } /** Mudlet setBackgroundColor for a userwindow / miniconsole. */ diff --git a/tests/mud/connection/inboundSlices.test.ts b/tests/mud/connection/inboundSlices.test.ts index 407ecc68..44c75d0a 100644 --- a/tests/mud/connection/inboundSlices.test.ts +++ b/tests/mud/connection/inboundSlices.test.ts @@ -199,8 +199,10 @@ describe('a large read is handled in slices', () => { it('drops the reads after the one a listener disconnects in', async () => { const { client, sock, log, bus } = connected(15); + // Past the first slice (at most four 15 ms batches inside the 50 ms + // budget), so the late read below is queued before the disconnect. bus.on('flushLines', groups => { - if (groups.some(g => g.text.includes('line 40\n'))) client.disconnect(); + if (groups.some(g => g.text.includes('line 200\n'))) client.disconnect(); }); const onmessage = sock.onmessage!; sock.deliver(lines(0, 300).map(l => `${l}\r\n`).join('')); @@ -214,3 +216,22 @@ describe('a large read is handled in slices', () => { expect(log.filter(l => l !== 'disconnect')).toEqual(lines(0, 300)); }); }); + +// #457: every turn the event loop gets is a chance to render, and each frame +// costs layout over the whole console, so a burst yielding every 12 ms paid +// for a frame almost every slice. A slice now runs to 50 ms, and gives way +// sooner only to input actually waiting. +describe('how long a slice runs', () => { + it('runs to the read budget when nothing is waiting', async () => { + const { inboundSliceDue } = await import('../../../src/mud/connection/MudClient'); + expect(inboundSliceDue(12, () => false)).toBe(false); + expect(inboundSliceDue(49, () => false)).toBe(false); + expect(inboundSliceDue(50, () => false)).toBe(true); + }); + + it('gives way after the short budget when input is waiting', async () => { + const { inboundSliceDue } = await import('../../../src/mud/connection/MudClient'); + expect(inboundSliceDue(11, () => true)).toBe(false); + expect(inboundSliceDue(12, () => true)).toBe(true); + }); +}); diff --git a/tests/scripting/hasFocusCost457.test.ts b/tests/scripting/hasFocusCost457.test.ts new file mode 100644 index 00000000..3dac89ab --- /dev/null +++ b/tests/scripting/hasFocusCost457.test.ts @@ -0,0 +1,31 @@ +// #457: hasFocus() ran `document.querySelector('.command-input')`, which walks +// every scrollback row ahead of the command line — several ms a call in a +// long session, and EMCO calls it once per chat line. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ScriptingAPI } from '../../src/scripting/ScriptingAPI'; + +const api = { session: { windows: { getElement: () => null } } } as unknown as ScriptingAPI; +const hasFocus = (name?: string) => ScriptingAPI.prototype.hasFocus.call(api, name); + +describe('hasFocus() (#457)', () => { + afterEach(() => { document.body.replaceChildren(); vi.restoreAllMocks(); }); + + it('answers from the focused element, without searching the document', () => { + const output = document.createElement('div'); + for (let i = 0; i < 500; i++) output.appendChild(document.createElement('div')); + const input = document.createElement('textarea'); + input.className = 'command-input'; + const other = document.createElement('input'); + document.body.append(output, input, other); + + const query = vi.spyOn(document, 'querySelector'); + const queryAll = vi.spyOn(document, 'querySelectorAll'); + input.focus(); + expect(hasFocus()).toBe(true); + expect(hasFocus('main')).toBe(true); + other.focus(); + expect(hasFocus()).toBe(false); + expect(query).not.toHaveBeenCalled(); + expect(queryAll).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/scripting/vfsAppendTail457.test.ts b/tests/scripting/vfsAppendTail457.test.ts new file mode 100644 index 00000000..77ee5c09 --- /dev/null +++ b/tests/scripting/vfsAppendTail457.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * #457: ZenFS keeps a file as one value in its store, so every append read the + * whole file back and wrote all of it again — EMCO's chat log, appended to a + * line at a time, cost 30 ms a line at 5 MB. ProfileVFS now holds the bytes + * appended to an existing file and writes them out together. These pin that + * the store is not touched per append, and that nothing reading the file + * through the VFS can tell the tail is held. + * + * The IndexedDB backend is swapped for ZenFS's InMemory store, as in + * vfsLogDirectory440: what is under test is ProfileVFS's own buffering. + */ + +vi.mock('@zenfs/dom', async () => { + const { InMemory } = await import('@zenfs/core'); + return { IndexedDB: InMemory, WebAccess: InMemory }; +}); +vi.mock('../../src/scripting/vfs/folderHandleStore', () => ({ + loadFolderHandle: async () => null, + checkFolderPermission: async () => 'denied', +})); +vi.mock('../../src/storage/storageMigration', () => ({ + whenIdbNamesMigrated: async () => undefined, +})); + +const zen = await import('@zenfs/core'); +const { ProfileVFS, APPEND_FLUSH_MS, APPEND_FLUSH_BYTES } = await import('../../src/scripting/vfs/ProfileVFS'); + +const bytes = (s: string) => new TextEncoder().encode(s); +const decode = (b: Uint8Array) => new TextDecoder().decode(b); + +let n = 0; +async function mounted() { + const vfs = await ProfileVFS.mount(`append-${++n}`); + const file = `${vfs.profilePath}/log/chat.html`; + vfs.writeFile(file, ''); + /** The file as the store holds it, behind ProfileVFS's back. */ + const stored = () => zen.readFileSync(file, 'utf8') as string; + return { vfs, file, stored }; +} + +describe('io.open(f, "a") appends (#457)', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('does not rewrite the file in the store on every append', async () => { + const { vfs, file, stored } = await mounted(); + for (let i = 0; i < 50; i++) vfs.appendBinaryFile(file, bytes(`

${i}

`)); + // Nothing in the store yet: 50 appends, no whole-file rewrite. + expect(stored()).toBe(''); + vi.advanceTimersByTime(APPEND_FLUSH_MS); + // One write, everything in order. + const expected = '' + Array.from({ length: 50 }, (_, i) => `

${i}

`).join(''); + expect(stored()).toBe(expected); + vfs.unmount(); + }); + + it('is invisible to every reader through the VFS', async () => { + const { vfs, file } = await mounted(); + vfs.appendBinaryFile(file, bytes('

a

')); + // What io.open(f, "a") asks on every open, answered without a flush. + expect(vfs.exists(file)).toBe(true); + expect(vfs.stat(file)?.size).toBe('

a

'.length); + vfs.appendBinaryFile(file, bytes('

b

')); + expect(decode(vfs.readBinaryFile(file))).toBe('

a

b

'); + vfs.appendBinaryFile(file, bytes('

c

')); + expect(vfs.readFile(file)).toBe('

a

b

c

'); + vfs.unmount(); + }); + + it('lets a rewrite, a rename or a removal see the tail first', async () => { + const { vfs, file, stored } = await mounted(); + vfs.appendBinaryFile(file, bytes('

a

')); + vfs.writeFile(file, 'fresh'); + vi.advanceTimersByTime(APPEND_FLUSH_MS); + expect(stored()).toBe('fresh'); + + vfs.appendBinaryFile(file, bytes('+1')); + const moved = `${vfs.profilePath}/log/moved.html`; + vfs.rename(file, moved); + expect(vfs.readFile(moved)).toBe('fresh+1'); + + vfs.appendBinaryFile(moved, bytes('+2')); + vfs.deleteFile(moved); + vi.advanceTimersByTime(APPEND_FLUSH_MS); + expect(vfs.exists(moved)).toBe(false); + + // A directory going takes the held tails under it first. + const sub = `${vfs.profilePath}/logs2`; + vfs.writeFile(`${sub}/x.txt`, 'x'); + vfs.appendBinaryFile(`${sub}/x.txt`, bytes('y')); + vfs.rename(sub, `${vfs.profilePath}/logs3`); + expect(vfs.readFile(`${vfs.profilePath}/logs3/x.txt`)).toBe('xy'); + vfs.unmount(); + }); + + it('writes a large tail out at once, and everything on flush()', async () => { + const { vfs, file, stored } = await mounted(); + vfs.appendBinaryFile(file, new Uint8Array(APPEND_FLUSH_BYTES).fill(0x61)); + expect(stored().length).toBe(''.length + APPEND_FLUSH_BYTES); + + vfs.appendBinaryFile(file, bytes('!')); + await vfs.flush(); + expect(stored().endsWith('a!')).toBe(true); + vfs.unmount(); + }); + + it('writes through a new file, as before', async () => { + const { vfs } = await mounted(); + const fresh = `${vfs.profilePath}/log/new.txt`; + vfs.appendBinaryFile(fresh, bytes('first')); + expect(zen.readFileSync(fresh, 'utf8')).toBe('first'); + vfs.unmount(); + }); + + it('does not keep the caller\'s buffer', async () => { + const { vfs, file } = await mounted(); + const buf = bytes('abc'); + vfs.appendBinaryFile(file, buf); + buf.fill(0x7a); + expect(vfs.readFile(file)).toBe('abc'); + vfs.unmount(); + }); +}); diff --git a/tests/ui/scrollbackBlocks457.test.ts b/tests/ui/scrollbackBlocks457.test.ts new file mode 100644 index 00000000..3a0d0510 --- /dev/null +++ b/tests/ui/scrollbackBlocks457.test.ts @@ -0,0 +1,187 @@ +// mudlet-web#457: the scrollback's rows are grouped into blocks of +// OUTPUT_BLOCK_ROWS, and `content-visibility` sits on the block instead of on +// every row — tens of thousands of rows tracked for visibility each frame was +// the largest cost in a frame once the scrollback passed ~10k lines. These pin +// that the rows really are grouped, and that everything that walks rows (the +// trigger cursor, eviction, the sticky split view, caret mode, scripted +// scrolling) reads them through the blocks exactly as it read flat rows. +import { describe, it, expect, beforeEach } from 'vitest'; +import { Console } from '../../src/mud/text/Console'; +import { AnsiAwareBuffer } from '../../src/mud/text/FormatState'; +import { setupOutputRenderer, type CursorOps, type OutputRendererControls } from '../../src/ui/output/OutputRenderer'; +import { + OUTPUT_BLOCK_ROWS, appendRow, detachRows, lastRows, nextRow, outputRows, previousRow, rowAt, rowCount, rowIndex, +} from '../../src/ui/output/outputRows'; +import { outputLineElements } from '../../src/ui/output/caretMode'; + +beforeEach(() => { + document.body.replaceChildren(); +}); + +function mount(stickyLines = 5): { + wrapper: HTMLElement; sentinel: HTMLElement; stickyArea: HTMLElement; + controls: OutputRendererControls; cursor: CursorOps; +} { + const wrapper = document.createElement('div'); + const sentinel = document.createElement('div'); + wrapper.appendChild(sentinel); + const stickyArea = document.createElement('div'); + document.body.append(wrapper, stickyArea); + let cursor!: CursorOps; + const controls = setupOutputRenderer(null, { + outputWrapper: wrapper, sentinel, stickyArea, + isSplitView: () => false, stickyLines, + onCursorReady: ops => { cursor = ops; }, + }); + return { wrapper, sentinel, stickyArea, controls, cursor }; +} + +const blocks = (wrapper: HTMLElement) => Array.from(wrapper.querySelectorAll(':scope > .output-block')); +const text = (el: Element) => (el.querySelector('.output-msg-content') ?? el).textContent?.trim(); +const texts = (els: Element[]) => els.map(text); + +describe('scrollback blocks (#457)', () => { + it('groups rows into blocks of OUTPUT_BLOCK_ROWS, ahead of the sentinel', () => { + const { wrapper, sentinel, controls } = mount(); + const n = OUTPUT_BLOCK_ROWS * 2 + 7; + for (let i = 0; i < n; i++) controls.push(`line ${i}`, 'mud'); + + const bs = blocks(wrapper); + expect(bs.map(b => b.childElementCount)).toEqual([OUTPUT_BLOCK_ROWS, OUTPUT_BLOCK_ROWS, 7]); + // No row is a child of the scroller itself any more. + expect(wrapper.querySelectorAll(':scope > .output-msg')).toHaveLength(0); + expect(wrapper.lastElementChild).toBe(sentinel); + // Only the newest block is open (always rendered); full ones may be skipped. + expect(bs.map(b => b.classList.contains('output-block--open'))).toEqual([false, false, true]); + expect(texts(outputRows(wrapper))).toEqual(Array.from({ length: n }, (_, i) => `line ${i}`)); + }); + + it('keeps the trigger cursor exact across block boundaries', () => { + const { controls, cursor } = mount(); + const n = OUTPUT_BLOCK_ROWS * 3; + for (let i = 1; i <= n; i++) controls.push(`line ${i}`, 'mud'); + + expect(cursor.getLineCount()).toBe(n); + expect(cursor.getLine()).toBe(`line ${n}`); + expect(cursor.getLineNumber()).toBe(n); + + const edge = OUTPUT_BLOCK_ROWS + 1; // the first row of the second block + cursor.moveTo(edge); + expect(cursor.getLine()).toBe(`line ${edge}`); + expect(cursor.getLineNumber()).toBe(edge); + cursor.moveUp(); + expect(cursor.getLine()).toBe(`line ${edge - 1}`); + expect(cursor.getLineNumber()).toBe(edge - 1); + cursor.moveDown(); + cursor.moveDown(); + expect(cursor.getLine()).toBe(`line ${edge + 1}`); + + expect(cursor.getLines(OUTPUT_BLOCK_ROWS - 1, OUTPUT_BLOCK_ROWS + 2)).toEqual([ + `line ${OUTPUT_BLOCK_ROWS - 1}`, `line ${OUTPUT_BLOCK_ROWS}`, + `line ${OUTPUT_BLOCK_ROWS + 1}`, `line ${OUTPUT_BLOCK_ROWS + 2}`, + ]); + // Past the end stops at the last row; before the start reads nothing. + expect(cursor.getLines(n - 1, n + 10)).toEqual([`line ${n - 1}`, `line ${n}`]); + expect(cursor.getLines(0, 3)).toEqual([]); + + // Moving down off the newest row leaves the cursor on the newest row. + cursor.moveTo(n); + cursor.moveDown(); + expect(cursor.getLine()).toBe(`line ${n}`); + }); + + it('deleteLine takes an emptied block with it, and moveUp lands on the row before', () => { + const { wrapper, controls, cursor } = mount(); + for (let i = 1; i <= OUTPUT_BLOCK_ROWS + 1; i++) controls.push(`line ${i}`, 'mud'); + expect(blocks(wrapper)).toHaveLength(2); + + // The newest row is alone in the second block. + cursor.deleteLine(); + expect(blocks(wrapper)).toHaveLength(1); + cursor.moveUp(); + expect(cursor.getLine()).toBe(`line ${OUTPUT_BLOCK_ROWS}`); + expect(cursor.getLineCount()).toBe(OUTPUT_BLOCK_ROWS); + + // The full block is no longer open, so a new row starts a fresh one. + controls.push('after', 'mud'); + expect(blocks(wrapper).map(b => b.childElementCount)).toEqual([OUTPUT_BLOCK_ROWS, 1]); + }); + + it('evicts whole blocks and the head of a partial one, leaving no empty block', () => { + const { wrapper, controls } = mount(); + const con = new Console(); + con.setMaxLines(250); + con.setBatchDeleteSize(130); + for (let i = 1; i <= 400; i++) { + con.echo(`line ${i}\n`); + for (const line of con.takeLines()) controls.push(line, 'script', line.timestamp); + } + const live = con.getLineCount() + 1; + const rows = outputRows(wrapper); + expect(rows).toHaveLength(live); + expect(text(rows[rows.length - 1])).toBe('line 400'); + for (const b of blocks(wrapper)) expect(b.childElementCount).toBeGreaterThan(0); + }); + + it('detachRows removes a run spanning blocks, and only that run', () => { + const wrapper = document.createElement('div'); + const sentinel = document.createElement('div'); + wrapper.appendChild(sentinel); + const all: HTMLElement[] = []; + for (let i = 0; i < OUTPUT_BLOCK_ROWS * 2 + 10; i++) { + const row = document.createElement('div'); + row.className = 'output-msg'; + row.textContent = `r${i}`; + appendRow(wrapper, row, sentinel); + all.push(row); + } + detachRows(all.slice(0, OUTPUT_BLOCK_ROWS + 5)); + expect(blocks(wrapper).map(b => b.childElementCount)).toEqual([OUTPUT_BLOCK_ROWS - 5, 10]); + expect(texts(outputRows(wrapper))).toEqual(texts(all.slice(OUTPUT_BLOCK_ROWS + 5))); + expect(wrapper.lastElementChild).toBe(sentinel); + }); + + it('the row helpers agree with a flat walk', () => { + const wrapper = document.createElement('div'); + const sentinel = document.createElement('div'); + wrapper.appendChild(sentinel); + for (let i = 0; i < 250; i++) { + const row = document.createElement('div'); + row.className = 'output-msg'; + row.textContent = `${i}`; + appendRow(wrapper, row, sentinel); + } + const flat = Array.from(wrapper.querySelectorAll('.output-msg')); + expect(rowCount(wrapper)).toBe(250); + expect(outputRows(wrapper)).toEqual(flat); + expect(lastRows(wrapper, 3)).toEqual(flat.slice(-3)); + for (const i of [0, 99, 100, 101, 249]) { + expect(rowAt(wrapper, i)).toBe(flat[i]); + expect(rowIndex(wrapper, flat[i])).toBe(i); + expect(previousRow(flat[i])).toBe(flat[i - 1] ?? null); + expect(nextRow(flat[i])).toBe(flat[i + 1] ?? null); + } + expect(rowAt(wrapper, 250)).toBeNull(); + expect(rowAt(wrapper, -1)).toBeNull(); + }); + + it('the split view and caret mode read the newest rows through the blocks', () => { + const { wrapper, stickyArea, controls } = mount(3); + for (let i = 1; i <= OUTPUT_BLOCK_ROWS + 1; i++) controls.push(`line ${i}`, 'mud'); + controls.populateStickyArea(); + expect(texts(Array.from(stickyArea.children))).toEqual([ + `line ${OUTPUT_BLOCK_ROWS - 1}`, `line ${OUTPUT_BLOCK_ROWS}`, `line ${OUTPUT_BLOCK_ROWS + 1}`, + ]); + expect(outputLineElements(wrapper)).toHaveLength(OUTPUT_BLOCK_ROWS + 1); + }); + + it('clear() empties the scroller of blocks but keeps the sentinel', () => { + const { wrapper, sentinel, controls, cursor } = mount(); + for (let i = 0; i < 150; i++) controls.push(new AnsiAwareBuffer(`x ${i}`), 'mud'); + controls.clear(); + expect(Array.from(wrapper.children)).toEqual([sentinel]); + expect(cursor.getLineCount()).toBe(0); + controls.push('fresh', 'mud'); + expect(cursor.getLine()).toBe('fresh'); + }); +}); diff --git a/tests/ui/scrollbackRenderCost443.test.ts b/tests/ui/scrollbackRenderCost443.test.ts index 2895d307..bb37055b 100644 --- a/tests/ui/scrollbackRenderCost443.test.ts +++ b/tests/ui/scrollbackRenderCost443.test.ts @@ -67,11 +67,14 @@ describe('mudlet-web#443: scrollback rendering cost', () => { expect(wrapper.scrollTop).toBe(400); }); - it('scrollback rows off screen skip rendering, as flat children of the scroller', () => { - const rule = /\.output-wrapper\s*>\s*\.output-msg\s*\{([^}]*)\}/.exec(appCss); + it('scrollback off screen skips rendering, a block of rows at a time (#457)', () => { + // Not on every row: tens of thousands of rows tracked for visibility + // each frame became the largest cost in it (#457). + expect(appCss).not.toMatch(/\.output-wrapper\s*>\s*\.output-msg\s*\{[^}]*content-visibility/); + const rule = /\.output-block:not\(\.output-block--open\)\s*\{([^}]*)\}/.exec(appCss); expect(rule).not.toBeNull(); expect(rule![1]).toMatch(/content-visibility:\s*auto/); - // A remembered height, so a wrapped row does not collapse off screen. + // A remembered height, so a block of wrapped rows does not collapse off screen. expect(rule![1]).toMatch(/contain-intrinsic-block-size:\s*auto\b/); }); }); From d2ad7f1212b5eb1649d9fd8b3c137c73d4bf9bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:14:35 +0000 Subject: [PATCH 2/2] Flush held VFS appends when a hidden page is frozen (#457) A hidden tab can be frozen and then discarded without a pagehide, and a frozen page runs no timer, so a tail appended after the tab was hidden was never written out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Um3ZSHRbGj1Rbf55rAQzdM --- src/scripting/vfs/ProfileVFS.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/scripting/vfs/ProfileVFS.ts b/src/scripting/vfs/ProfileVFS.ts index f5bb12a4..9dde8430 100644 --- a/src/scripting/vfs/ProfileVFS.ts +++ b/src/scripting/vfs/ProfileVFS.ts @@ -79,6 +79,10 @@ function installPageListeners(): void { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flushAll(); }); + // A hidden tab can be frozen and then discarded without a pagehide; + // frozen, it runs no timer, so a tail appended while hidden would + // never be written out. + document.addEventListener('freeze', flushAll); } }