// flash-frames.mjs — look at the flash instead of at a proxy for it. // // The previous investigation counted DOM mutations on reveal, found 21 // redundant attribute writes, removed them, and reported the flash // fixed. Paul still sees it. Counting mutations was measuring a proxy: // it can go to zero while the thing a person actually sees is unchanged, // because a flash is a PAINT, and paints and mutations are not the same // list. // // So this records frames. Chromium's screencast hands back one PNG per // painted frame; identical paints encode to identical bytes, so hashing // each frame turns the reveal into a sequence of distinct visual states. // If revealing the tab is visually instant there are two states at most // (nothing, then the page). Anything more is the flash, and the frames // themselves say what changed. // // The other correction is the scenario. Last time tab B never had focus // — it loaded hidden and stayed hidden — so its toolbar was disabled // before the switch and disabled after, and every write really was // redundant. That is not what a person does. A person is TYPING in tab // B, tabs away, and tabs back: the editor blurs and refocuses, and any // state keyed on focus genuinely changes value in both directions. This // drives that sequence. import { writeFileSync, mkdirSync } from 'node:fs' import { createHash } from 'node:crypto' const [, , cdpBase, baseURL, outDir] = process.argv const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) mkdirSync(outDir, { recursive: true }) async function connect(wsUrl, { focusEmulation }) { const ws = new WebSocket(wsUrl) let nextId = 0 const pending = new Map() const listeners = new Map() ws.onmessage = (e) => { const m = JSON.parse(e.data) if (m.id && pending.has(m.id)) { const { resolve, reject } = pending.get(m.id) pending.delete(m.id) if (m.error) reject(new Error(JSON.stringify(m.error))) else resolve(m.result) return } const l = listeners.get(m.method) if (l) for (const fn of l) fn(m.params) } await new Promise((res, rej) => { ws.onopen = res ws.onerror = rej }) const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++nextId pending.set(id, { resolve, reject }) ws.send(JSON.stringify({ id, method, params })) }) const on = (method, fn) => { if (!listeners.has(method)) listeners.set(method, []) listeners.get(method).push(fn) } await send('Runtime.enable') await send('Page.enable') if (focusEmulation) await send('Emulation.setFocusEmulationEnabled', { enabled: true }) const evaluate = async (expression) => { const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }) if (r.exceptionDetails) { throw new Error('page threw: ' + (r.exceptionDetails.exception?.description || '')) } return r.result.value } return { send, on, evaluate } } const list = await fetch(`${cdpBase}/json/list`).then((r) => r.json()) const pages = list.filter((t) => t.type === 'page') if (pages.length < 2) { console.error(`need two tabs, found ${pages.length}`) process.exit(1) } // A types while backgrounded, so it needs focus emulation. B is the tab // under test and must NOT have it — its focus and visibility have to be // the real thing, because that is the whole subject. const A = await connect(pages[0].webSocketDebuggerUrl, { focusEmulation: true }) const B = await connect(pages[1].webSocketDebuggerUrl, { focusEmulation: false }) // Find a document the way the app offers one: open the list and take // the first row's link. The seeded database always has some. await A.send('Page.navigate', { url: baseURL }) let docURL = null for (let i = 0; i < 200; i++) { const href = await A.evaluate(`document.querySelector('a[href^="/d/"]')?.href || null`) if (href) { docURL = href; break } await sleep(100) } if (!docURL) { console.error('no document link on the list page') process.exit(1) } console.log('document:', docURL) for (const tab of [A, B]) await tab.send('Page.navigate', { url: docURL }) for (const tab of [A, B]) { for (let i = 0; i < 200; i++) { if (await tab.evaluate(`!!document.querySelector('[docs-editor]')`)) break await sleep(100) } } await sleep(1500) // Give B a real caret, the way a person has one before they tab away. await B.send('Page.bringToFront') await sleep(300) const clickEditor = async (tab) => { const at = await tab.evaluate(`(() => { const el = document.querySelector('[docs-editor]') const r = el.getBoundingClientRect() return { x: Math.round(r.left + 40), y: Math.round(r.top + 20) } })()`) for (const type of ['mousePressed', 'mouseReleased']) { await tab.send('Input.dispatchMouseEvent', { type, x: at.x, y: at.y, button: 'left', clickCount: 1, }) } } const typeText = async (tab, text) => { for (const ch of text) { await tab.send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch, unmodifiedText: ch }) await tab.send('Input.dispatchKeyEvent', { type: 'keyUp', text: ch, unmodifiedText: ch }) } } await clickEditor(B) await typeText(B, 'SEED') await sleep(1200) console.log('tab B has a caret:', await B.evaluate(`document.activeElement?.hasAttribute('docs-editor') || document.querySelector('[docs-editor]').contains(document.activeElement)`)) console.log('tab B toolbar disabled before switching away:', await B.evaluate(` [...document.querySelectorAll('[docs-tool]')].filter(b => b.disabled).length + ' of ' + document.querySelectorAll('[docs-tool]').length`)) // Now the switch away. A comes to the front; B is genuinely hidden and // its editor genuinely blurs, which is the state the last harness never // produced. await A.send('Page.bringToFront') await sleep(500) await clickEditor(A) const marker = 'MARK' + Math.floor(Math.random() * 10000) await typeText(A, marker) console.log(`typed ${marker} in tab A`) await sleep(1500) // The decisive reading, and the one the first version of this harness // did not take. A stale FRAME on reveal has two completely different // causes with the same appearance: the DOM is still old (the tab never // received the edit while hidden), or the DOM is already current and // the browser is briefly showing a cached compositor surface from // before. Only the first is this app's bug, and only this line tells // them apart — so it is read while still hidden, before the reveal can // change the answer. console.log('tab B DOM already has the marker WHILE HIDDEN:', await B.evaluate(`document.querySelector('[docs-editor]').innerText`).then((t) => t.includes(marker))) console.log('tab B visibilityState while away:', await B.evaluate('document.visibilityState')) console.log('tab B toolbar disabled while away: ', await B.evaluate(` [...document.querySelectorAll('[docs-tool]')].filter(b => b.disabled).length + ' of ' + document.querySelectorAll('[docs-tool]').length`)) // Record what the DOM does too, so a frame that changes can be matched // to a cause rather than only described. await B.evaluate(`(() => { window.__log = [] const t0 = performance.now() const stamp = (what) => window.__log.push(Math.round(performance.now() - t0) + 'ms ' + what) for (const ev of ['visibilitychange', 'focus', 'blur']) { document.addEventListener(ev, () => stamp('document ' + ev + ' (' + document.visibilityState + ')'), true) } const ed = document.querySelector('[docs-editor]') ed.addEventListener('focus', () => stamp('editor focus'), true) ed.addEventListener('blur', () => stamp('editor blur'), true) new MutationObserver((muts) => { for (const m of muts) { if (m.type === 'attributes') { const now = m.target.getAttribute(m.attributeName) if (m.oldValue === now) continue stamp('ATTR ' + m.target.nodeName.toLowerCase() + (m.target.getAttribute('data-docs-command') ? '[' + m.target.getAttribute('data-docs-command') + ']' : '') + ' ' + m.attributeName + ': ' + JSON.stringify(m.oldValue) + ' -> ' + JSON.stringify(now)) } else if (m.addedNodes.length || m.removedNodes.length) { stamp('NODES on ' + m.target.nodeName.toLowerCase() + ' +' + m.addedNodes.length + ' -' + m.removedNodes.length) } } }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeOldValue: true }) window.__t0 = t0 return true })()`) // What was actually ON SCREEN, frame by frame. requestAnimationFrame // does not run in a hidden tab and resumes with the first painted frame // after reveal, so this samples exactly the thing a person sees — and // unlike the screencast, which only emits when the page happens to // paint and cannot resolve a 20ms window, it records every frame the // browser draws. Each entry says whether the caught-up text was on // screen for that frame. await B.evaluate(`(() => { window.__paints = [] const ed = document.querySelector('[docs-editor]') const marker = ${JSON.stringify(marker)} const start = performance.now() const tick = () => { window.__paints.push({ at: Math.round(performance.now() - start), fresh: ed.innerText.includes(marker), }) if (window.__paints.length < 120) requestAnimationFrame(tick) } requestAnimationFrame(tick) return true })()`) // Frames. Started before the reveal so the first painted frame after it // is captured rather than missed. const frames = [] let castStarted = 0 B.on('Page.screencastFrame', async (p) => { frames.push({ at: Date.now(), data: p.data }) try { await B.send('Page.screencastFrameAck', { sessionId: p.sessionId }) } catch { /* the cast is stopped below; a late ack losing its target is not a finding */ } }) castStarted = Date.now() await B.send('Page.startScreencast', { format: 'png', everyNthFrame: 1 }) // A deliberate gap between starting the cast and revealing the tab. // Chromium can hand a screencast the last composited surface it still // holds — a picture of the tab as it looked before it was hidden — and // that arrives as an ordinary frame, indistinguishable from a paint. If // any frame lands in this window, while the tab is provably still // hidden and therefore painting nothing, then a "stale first frame" on // reveal is this harness looking at a cached surface rather than // anything a person would see, and reading it as the flash would be // measuring the instrument. await sleep(800) const framesBeforeReveal = frames.length console.log(`frames delivered while STILL HIDDEN, before any reveal: ${framesBeforeReveal}`) console.log('--- revealing tab B ---') const revealed = Date.now() await B.send('Page.bringToFront') await sleep(2500) await B.send('Page.stopScreencast') const paints = await B.evaluate('window.__paints') const stale = paints.filter((p) => !p.fresh) console.log(`\nPainted frames after reveal: ${paints.length}`) console.log(`Frames showing the OLD text: ${stale.length}` + (stale.length ? ` (through +${stale.at(-1).at}ms — this is the flash)` : ' — the first painted frame was already current')) const log = await B.evaluate('window.__log') console.log('\nDOM during the reveal (changed values only):') if (!log.length) console.log(' (nothing)') for (const line of log) console.log(' ' + line) console.log(`\ntab B sees the marker: ${await B.evaluate(`document.querySelector('[docs-editor]').innerText`).then((t) => t.includes(marker))}`) // Identical paints encode identically, so a hash per frame collapses the // cast into the sequence of distinct visual states. const seen = new Map() const sequence = [] for (const f of frames) { const buf = Buffer.from(f.data, 'base64') const h = createHash('sha256').update(buf).digest('hex').slice(0, 12) if (!seen.has(h)) { const name = `${outDir}/state-${String(seen.size).padStart(2, '0')}-${h}.png` writeFileSync(name, buf) seen.set(h, { name, first: f.at - revealed }) } if (sequence.at(-1)?.hash !== h) { sequence.push({ hash: h, at: f.at - revealed, hidden: f.at < revealed }) } } console.log(`\n${frames.length} frames captured, ${seen.size} visually distinct`) for (const s of sequence) { console.log(` ${s.hidden ? 'HIDDEN ' : '+'}${s.at}ms ${s.hash} ${seen.get(s.hash).name}`) } process.exit(0)