// fixtures/toolbar.test.mjs — the editor's chrome must not write to the // DOM when the DOM is already right. // // Named for the toolbar because the toolbar's plugin is the entry // point, but it covers both bars: editor-entry.js gives the whole // chrome ONE ProseMirror plugin, and the menu bar rides on it through // createToolbar's onSync. So this file wires them the same way and // holds one property across both. // // THIS FILE EXISTS BECAUSE ITS ABSENCE SHIPPED A VISIBLE FLASH. Driven // in a real browser with one genuinely hidden tab, switching back to a // document produced 23 DOM mutations on reveal, 21 of them attribute // writes on chrome that was already on screen — and recording each // write's old value against its new one, 21 of 21 were redundant. Zero // changed anything. Every one restated what was already there: // aria-pressed "false" -> "false", disabled "" -> "". setAttribute with // an identical value still invalidates style and still records a // mutation, and so does setting a reflected boolean property to the // value it already holds, so the browser spent a frame repainting // chrome nothing had changed. // // The tab switch was only where it became visible. Both bars sync on // EVERY ProseMirror state update, so this was the cost of every // keystroke as well; tabbing back merely gathered a page of it into one // frame. And it grows: the same measurement gave 12 mutations before // the toolbar redesign and 23 after, because each control added brought // its own unconditional pair of writes. The menu bar, written after the // toolbar by a different session, reproduced the defect from scratch — // which is the argument for holding this against the source and not // only against the controls that exist today. // // So the property is not "few mutations on a tab switch", which is a // symptom at one moment. It is: AFTER A STATE UPDATE THAT CHANGES // NOTHING, THE CHROME PERFORMS ZERO WRITES. // // No DOM and no browser, the same arrangement menu.test.mjs uses: a // stand-in for the handful of DOM surface these modules touch, which // records every write WHETHER OR NOT IT CHANGES ANYTHING. Recording the // call rather than the change is the entire seam — a check that // compared the bars before and after would see nothing wrong, because // nothing IS different afterwards. import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { Schema } from 'prosemirror-model' import { EditorState, TextSelection } from 'prosemirror-state' import { schema as basic } from 'prosemirror-schema-basic' import { addListNodes } from 'prosemirror-schema-list' import { alignedParagraph } from './align.mjs' import { withIndent } from './indent.mjs' import { SIZE_MARKS, SIZES } from './sizes.mjs' import { FONTS } from './fonts.mjs' import { createToolbar } from './toolbar.mjs' import { mountMenu } from './menu.mjs' // toolbar.mjs asks the document which element has focus, so that // showInCombo can decline to overwrite a filter box somebody is typing // into; menu.mjs listens on the document for a click-away close. Those // are the two reaches for a global in these modules, and under Node // there is nothing to reach for. A stand-in with nothing focused is // what a chrome syncing while the caret is in the document sees anyway. globalThis.document = { activeElement: null, addEventListener() {} } // The schema editor-entry.js builds, assembled the same way from the // same modules. Written out rather than imported because // editor-entry.js is a program, not a module surface — it mounts an // EditorView on import — but every piece of the vocabulary here comes // from the file that owns it, so the two cannot drift in the parts this // test reads (the font marks, the size marks, the block types). function spanMark(cls, group) { return { group, excludes: group, parseDOM: [{ tag: 'span.' + cls }], toDOM: () => ['span', { class: cls }, 0], } } const marks = SIZE_MARKS.reduce( (spec, mark) => spec.addToStart(mark, spanMark('docs-' + mark.replace('_', '-'), 'fontSize')), basic.spec.marks, ) .addToStart('font_mono', spanMark('docs-font-mono', 'fontFamily')) .addToStart('font_sans', spanMark('docs-font-sans', 'fontFamily')) .addToStart('font_serif', spanMark('docs-font-serif', 'fontFamily')) const nodes = addListNodes( basic.spec.nodes .update('paragraph', withIndent(alignedParagraph(basic.spec.nodes.get('paragraph')), 'p')) .update('heading', withIndent(basic.spec.nodes.get('heading'), 'h1')), 'paragraph block*', 'block', ) const schema = new Schema({ nodes, marks }) // A recording stand-in for the DOM surface the chrome touches. // // Every attribute write and every property write goes into one log, // with its old value beside its new one, whether or not the two differ. // Nothing here is a DOM implementation and it must not grow into one — // the moment a test needs layout or real focus it belongs in the // browser drive (fixtures/browser-drive.mjs). // // EVERY property write, through a Proxy, and not just the three this // file bothered to define accessors for. That distinction was a real // hole, found in review with a probe planted on menu.mjs's // every-state-update path: // // if (!name.includes('https://')) el.title = 'seen' // // which all three tests passed. `title` is attribute-reflected, so an // identical-value write invalidates style and records a mutation on // every keystroke — the exact defect this file exists to stop — and it // was invisible twice over. The structural check below missed it // because stripComments does not parse string literals, so the `//` // inside 'https://' blanked the rest of the line; and this fake missed // it because an assignment to a property with no accessor landed on a // plain object without a sound. Two nets, one hole in each, and the // holes overlapped. // // The Proxy closes this one, and closes it for a class rather than for // a name: el.title, el.textContent, el.className, el.checked, // el.hidden, el.innerHTML, el['disabled'] written through a computed // key, and compound forms like `||=` that the structural check's // bare-`=` shape does not match either. The three accessors stay for // their semantics — a select refusing a value no option carries — and // no longer log, because the trap already did. // // Two roots, not one, because that is how the page is built: the // formatting bar is [docs-toolbar] and the menu bar is [estilo-menubar], // and both hold elements carrying data-docs-command. A single root // would hand the toolbar the menu's items and test something the // browser never does. function makeDOM() { const log = [] const bar = [] const menu = [] // `extra` is applied to the raw node BEFORE the Proxy wraps it: the // fixture's own setup (a select's options, its combobox wrapper) is // not a write by the code under test and must not be logged as one. function element(tag, id, attrs, into, extra = {}) { const own = { ...attrs } let disabled = 'disabled' in own let tabIndex = 'tabindex' in own ? Number(own.tabindex) : -1 let value = '' const node = { tag, id, options: [], selectedIndex: -1, children: [], previousElementSibling: null, getAttribute: (k) => (k in own ? own[k] : null), hasAttribute: (k) => k in own, setAttribute(k, v) { log.push({ id, name: k, from: node.getAttribute(k), to: String(v) }) own[k] = String(v) }, removeAttribute(k) { log.push({ id, name: k, from: node.getAttribute(k), to: null }) delete own[k] }, // Two selector forms, which is all these modules use: a bare tag // name, and one or more [attribute] clauses that must all match. matches(sel) { if (sel === tag) return true const parts = sel.match(/\[[^\]]+\]/g) return parts ? parts.every((p) => p.slice(1, -1) in own) : false }, querySelector: (sel) => node.children.find((c) => c.matches(sel)) || null, querySelectorAll: (sel) => node.children.filter((c) => c.matches(sel)), closest: () => null, addEventListener() {}, focus() {}, } // The three properties that are not plain storage. They keep their // accessors for the behaviour, and do no logging of their own — the // Proxy below logs every write, these included, so a value here // would be counted twice. Object.defineProperty(node, 'disabled', { get: () => disabled, set: (v) => { disabled = v }, }) Object.defineProperty(node, 'tabIndex', { get: () => tabIndex, set: (v) => { tabIndex = v }, }) Object.defineProperty(node, 'value', { get: () => value, set: (v) => { // A real