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 556d0772..e879a022 100644 --- a/src/scripting/ScriptingAPI.ts +++ b/src/scripting/ScriptingAPI.ts @@ -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)); diff --git a/src/scripting/vfs/ProfileVFS.ts b/src/scripting/vfs/ProfileVFS.ts index b8b8557d..9dde8430 100644 --- a/src/scripting/vfs/ProfileVFS.ts +++ b/src/scripting/vfs/ProfileVFS.ts @@ -46,6 +46,46 @@ 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(); + }); + // 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); + } +} + // AsyncMixin exposes sync(), but it isn't on FileSystem's public type. type Syncable = FileSystem & { sync?: () => Promise }; @@ -103,6 +143,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 +269,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 +299,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 +310,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 +332,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 +449,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 +516,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 +544,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 +592,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 +603,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 +630,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 +658,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 +694,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 +733,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 +767,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 cb8a9052..5b57787a 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; @@ -295,17 +282,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 = { @@ -321,14 +320,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) { @@ -336,28 +328,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; } @@ -366,22 +356,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; }, @@ -505,7 +493,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; @@ -553,7 +541,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. @@ -598,11 +586,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(); @@ -613,14 +597,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); @@ -641,11 +618,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/); }); });