Phone print: going away again cancels a pending drop; a 60 s grace; no early touch

A second read measured a hole in the phone print keep: a touch after print()
started the grace, and if the print UI then opened (the page hidden, or the
window blurred), nothing cancelled it, so the document could go while the
reader was still choosing Save as PDF. Now:

- going away again during the grace cancels the pending drop, and the next
  "back" counts;
- BACK_GRACE_MS is 60 s (was 5 s): a slow phone can still be writing an
  11-page packet after its dialog closes, and the kept document is invisible
  on screen and replaced by the next print;
- a touch in the first TOUCH_SETTLE_MS (2 s) after print() is not "back" (a
  double tap while the preview builds).

check:phone-print-keep gains arm H (a touch, then the print UI, by hiding and
by blur: still mounted past the grace, released after the next back) and arm I
(a touch in the settle window), with the poisons "no cancel when away again"
and "no touch settle window"; arm C now taps Print a few seconds after the
document is ready and stays in the dialog past the grace. The first cut fails
H and I; the pilot's file fails A to H. Desktop stays byte-identical.
This commit is contained in:
Doxa Maps 2026-10-02 17:15:56 +00:00
parent 0b1fcde1db
commit 967457f65e
3 changed files with 50 additions and 18 deletions

View file

@ -9,7 +9,7 @@
* later, after the reader taps Save, from the page as it is then.
*
* A timeline on fake timers and a stub DOM, with an Android user agent (Playwright cannot reproduce Android's print
* pipeline; the Driver's phone is the real check):
* pipeline; a real phone is the real check):
* A. after print() and afterprint, the document and its print style are still mounted 30 s on (the Save render);
* B. it goes once the reader is BACK: the page shown again after being hidden, the window focused again after
* losing focus, or a touch after print(), then BACK_GRACE_MS later, not before;
@ -18,10 +18,15 @@
* D. a reader who never comes back: dropped at FRAME_CEILING_MS, not before;
* E. printImageBlob: its object URL is NOT revoked at afterprint, and is revoked when the document goes;
* F. a new print replaces the previous document (one root mounted, and the previous one released);
* G. the desktop path is the frame, as before (printDocument's first line, verbatim).
* G. the desktop path is the frame, as before (printDocument's first line, verbatim);
* H. a touch AFTER the first TOUCH_SETTLE_MS, then the print UI opening (the page hidden, or the window blurred):
* the pending drop is cancelled, so the document is still mounted well past BACK_GRACE_MS in the UI, and goes
* after the next "back" (a measured hole in the first cut, found on a second read);
* I. a touch in the first TOUCH_SETTLE_MS after print() (a double tap while the preview builds) is not "back".
* `--impl=<path>` runs the timeline against another copy (the pilot's, before this fix, fails A and E: the cause).
* `--self-test`: the old drop at afterprint, no release when the reader is back, the listeners armed at mount (so the
* Print tap drops it), no grace, printImageBlob revoking at afterprint, and no ceiling must each FAIL.
* Print tap drops it), no grace, printImageBlob revoking at afterprint, no ceiling, no cancel when the page goes away
* again during the grace, and no touch settle window must each FAIL.
*/
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
@ -96,8 +101,8 @@ const load = (src) => import('data:text/javascript;base64,' + Buffer.from(src +
async function audit(src) {
const fails = []
const sample = await load(src)
const FRAME_CEILING_MS = sample.FRAME_CEILING_MS || 600000, BACK_GRACE_MS = sample.BACK_GRACE_MS || 0
if (!sample.FRAME_CEILING_MS || !sample.BACK_GRACE_MS) fails.push('G: printInPlace.js does not export FRAME_CEILING_MS and BACK_GRACE_MS (the timeline runs on, with a grace of 0)')
const FRAME_CEILING_MS = sample.FRAME_CEILING_MS || 600000, BACK_GRACE_MS = sample.BACK_GRACE_MS || 0, TOUCH_SETTLE_MS = sample.TOUCH_SETTLE_MS || 0
if (!sample.FRAME_CEILING_MS || !sample.BACK_GRACE_MS || !sample.TOUCH_SETTLE_MS) fails.push('G: printInPlace.js does not export FRAME_CEILING_MS, BACK_GRACE_MS and TOUCH_SETTLE_MS (the timeline runs on, with 0 for each missing one)')
const HTML = '<!doctype html><html><head><style>@page{size:letter portrait}</style></head><body><div>packet</div></body></html>'
// A + B (hidden → visible)
@ -117,16 +122,17 @@ async function audit(src) {
if (!mounted()) fails.push('B: the document went while the window had lost focus')
window.fire('focus'); advance(BACK_GRACE_MS + 1)
if (mounted()) fails.push('B: the document stayed after the window was focused again (back on the page)') }
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(5000); window.fire('pointerdown'); advance(BACK_GRACE_MS + 1)
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(TOUCH_SETTLE_MS + 3000); window.fire('pointerdown'); advance(BACK_GRACE_MS + 1)
if (mounted()) fails.push('B: the document stayed after the page was touched again') }
// C: a late print (the tap's activation expired): the Print tap is not "back"
{ world({ active: false }); const m = await load(src); let printNow = null
const how = await m.printDocument(HTML, { onNeedsTap: (p) => { printNow = p } })
if (how !== 'needs-tap' || !printNow) fails.push(`C: an expired tap did not hand out printNow (returned ${how})`)
else {
window.fire('pointerdown'); printNow(); window.fire('afterprint'); advance(30000)
// the reader taps Print a few seconds after the document is ready, then spends longer than the grace in the dialog
advance(TOUCH_SETTLE_MS + 3000); window.fire('pointerdown'); printNow(); window.fire('afterprint'); advance(BACK_GRACE_MS + 20000)
if (window.prints.length !== 1 || !window.prints[0]) fails.push('C: printNow did not print the mounted document')
if (!mounted()) fails.push('C: the Print tap counted as being back: the document was gone 30 s into the dialog')
if (!mounted()) fails.push(`C: the Print tap counted as being back: the document was gone ${(BACK_GRACE_MS + 20000) / 1000} s into the dialog`)
} }
// D: never back
{ world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(FRAME_CEILING_MS - 1)
@ -145,6 +151,21 @@ async function audit(src) {
await m.printDocument(HTML, { onRelease: () => { first++ } }); window.fire('afterprint'); await m.printDocument(HTML)
if (rootCount() !== 1) fails.push(`F: ${rootCount()} print roots mounted after a second print (expected 1)`)
if (first !== 1) fails.push(`F: the first print's onRelease ran ${first} time(s) when a second print replaced it (expected 1)`) }
// H: a touch after the settle window, then the print UI opens: away again cancels the pending drop
for (const via of ['hidden', 'blur']) {
world(); const m = await load(src); await m.printDocument(HTML); advance(TOUCH_SETTLE_MS + 500); window.fire('pointerdown'); advance(500)
if (via === 'hidden') { document.visibilityState = 'hidden'; document.fire('visibilitychange') } else window.fire('blur')
window.fire('afterprint'); advance(BACK_GRACE_MS + 20000)
if (!mounted()) fails.push(`H: a touch, then the print UI (${via}): the grace the touch started was not cancelled, and the document went while the reader was still in the UI (a PDF saved then is the bare page)`)
else {
if (via === 'hidden') { document.visibilityState = 'visible'; document.fire('visibilitychange') } else window.fire('focus')
advance(BACK_GRACE_MS + 1)
if (mounted()) fails.push(`H: after the print UI (${via}) closed, the document stayed past the grace (the next "back" did not count)`)
}
}
// I: a touch in the settle window (no hide, no blur: an in-page sheet) is not "back"
{ world(); const m = await load(src); await m.printDocument(HTML); advance(Math.max(1, TOUCH_SETTLE_MS - 1500)); window.fire('pointerdown'); advance(BACK_GRACE_MS + 30000)
if (!mounted()) fails.push('I: a touch right after print() (a double tap while the preview builds) counted as being back, and the document went with the dialog still up') }
// G: desktop unchanged (the frame path)
if (!src.includes("if (!isPhonePrint()) return openPrintFrame(html, { autoprint: true }) ? 'frame' : 'blocked'")) fails.push('G: printDocument\'s desktop path is no longer the frame, verbatim')
return fails
@ -160,6 +181,8 @@ if (process.argv.includes('--self-test')) {
['no grace once the reader is back', src.replace('ceiling = setTimeout(drop, BACK_GRACE_MS) }', 'drop() }'), /^B: the document went at once/],
['printImageBlob revoking at afterprint', src.replace(" const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }\n", " const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }\n window.addEventListener('afterprint', () => setTimeout(release, 0))\n"), /^E: printImageBlob revoked/],
['no ceiling', src.replace(MOUNT_END, ' currentJob = job\n'), /^D: the document outlived/],
['no cancel when the page goes away again', src.replace('const awayAgain = () => { away = true; if (leaving) { leaving = false; clearTimeout(ceiling); ceiling = setTimeout(drop, FRAME_CEILING_MS) } }', 'const awayAgain = () => { away = true }'), /^H: a touch, then the print UI/],
['no touch settle window', src.replace('const onTouch = () => { if (!settling) back() }', 'const onTouch = () => back()'), /^I: /],
]
let missed = 0
for (const [name, poisoned, rx] of poisons) {
@ -172,5 +195,5 @@ if (process.argv.includes('--self-test')) {
} else {
const f = await audit(src)
if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:phone-print-keep (${f.length})`); process.exitCode = 1 }
else console.log('PASS check:phone-print-keep — on a phone the print document (and printImageBlob\'s image) outlives afterprint, so a PDF saved after the preview is the document; it goes once the reader is back (shown, focused or touched again) plus a grace, or at the ceiling; the Print tap of a late print does not count; a new print replaces it; desktop prints through the frame as before')
else console.log('PASS check:phone-print-keep — on a phone the print document (and printImageBlob\'s image) outlives afterprint, so a PDF saved after the preview is the document; it goes once the reader is back (shown, focused or touched again) plus a grace, or at the ceiling; the Print tap of a late print and a touch right after print() do not count; going away again cancels a pending drop; a new print replaces it; desktop prints through the frame as before')
}

View file

@ -179,21 +179,27 @@ function mountPrintRoot(html, { onRelease = null } = {}) {
// ★ KEPT UNTIL THE READER IS BACK, never dropped at afterprint (printDocument says why). From the moment print() is
// called (printing(), not at mount: the Print tap that starts a late print must not count), the reader is back when
// the page is shown again after being hidden, the window is focused again after losing focus, or the page is
// touched (the system print UI covers the page, so a touch there means it has gone). It goes BACK_GRACE_MS after
// that, since a print job can still be rendering while the dialog animates away. A new print replaces it, a
// cancelled one drops it, and FRAME_CEILING_MS is the ceiling. On screen it is display:none, so keeping it shows nothing.
let ceiling = null, armed = false, away = false, leaving = false
// touched (the system print UI covers the page, so a touch there means it has gone). A touch in the first
// TOUCH_SETTLE_MS after print() does not count (a double tap while the preview builds). It goes BACK_GRACE_MS after
// that, since a slow phone can still be writing the file after its dialog closes; if the page goes AWAY again during
// the grace (the print UI opened after an early touch), the pending drop is cancelled and the next "back" counts. A
// new print replaces it, a cancelled one drops it, and FRAME_CEILING_MS is the ceiling. On screen it is display:none,
// so keeping it shows nothing.
let ceiling = null, settle = null, armed = false, away = false, leaving = false, settling = false
const back = () => { if (leaving) return; leaving = true; clearTimeout(ceiling); ceiling = setTimeout(drop, BACK_GRACE_MS) }
const onVisibility = () => { if (document.visibilityState === 'hidden') away = true; else if (away) back() }
const onBlur = () => { away = true }
const awayAgain = () => { away = true; if (leaving) { leaving = false; clearTimeout(ceiling); ceiling = setTimeout(drop, FRAME_CEILING_MS) } }
const onVisibility = () => { if (document.visibilityState === 'hidden') awayAgain(); else if (away) back() }
const onBlur = () => { awayAgain() }
const onFocus = () => { if (away) back() }
const onTouch = () => back()
const onTouch = () => { if (!settling) back() }
const job = {
live: () => currentJob === job,
drop,
printing: () => {
if (armed) return
armed = true
settling = true
settle = setTimeout(() => { settling = false }, TOUCH_SETTLE_MS)
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('blur', onBlur)
window.addEventListener('focus', onFocus)
@ -211,6 +217,7 @@ function mountPrintRoot(html, { onRelease = null } = {}) {
}
function drop() {
clearTimeout(ceiling)
clearTimeout(settle)
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('blur', onBlur)
window.removeEventListener('focus', onFocus)
@ -230,7 +237,9 @@ export const FRAME_KEEP_MS = 60000
/** The absolute ceiling for a print frame whose load never ends. */
export const FRAME_CEILING_MS = 600000
/** A phone's in-page print document goes this long after the reader is back on the page (mountPrintRoot). */
export const BACK_GRACE_MS = 5000
export const BACK_GRACE_MS = 60000
/** A touch this soon after print() is not the reader coming back (a double tap while the preview builds). */
export const TOUCH_SETTLE_MS = 2000
/**
* Print a rendered poster IMAGE, at its real paper size, through the same in-place frame.

View file

@ -438,7 +438,7 @@
"check:phone-print-keep:self-test": "node internal/check-phone-print-keep.mjs --self-test"
},
"//scripts": {
"check:phone-print-keep": "on a PHONE the in-page print document (#dxm-print-root and its print style, library/composables/printInPlace.js) and printImageBlob's object URL outlive afterprint, because Android Chrome fires afterprint once the PREVIEW is built and renders a saved PDF later, from the page as it is then (a phone's saved packet was the bare host page). A timeline on fake timers and a stub DOM with an Android user agent: still mounted 30 s after afterprint; released once the reader is back (shown again after hidden, focused again after blur, or touched after print()) plus BACK_GRACE_MS, or at the ceiling; the Print tap of a late print does not count; a new print replaces it; desktop is the frame path, verbatim. --impl=<path> runs it against another copy (the pre-fix file fails). Self-test poisons: the old drop at afterprint, no release when back, listeners armed at mount, no grace, printImageBlob revoking at afterprint, no ceiling.",
"check:phone-print-keep": "on a PHONE the in-page print document (#dxm-print-root and its print style, library/composables/printInPlace.js) and printImageBlob's object URL outlive afterprint, because Android Chrome fires afterprint once the PREVIEW is built and renders a saved PDF later, from the page as it is then (a phone's saved packet was the bare host page). A timeline on fake timers and a stub DOM with an Android user agent: still mounted 30 s after afterprint; released once the reader is back (shown again after hidden, focused again after blur, or touched after print() but not in its first TOUCH_SETTLE_MS) plus BACK_GRACE_MS (60 s), or at the ceiling; going away again during the grace cancels the pending drop; the Print tap of a late print does not count; a new print replaces it; desktop is the frame path, verbatim. --impl=<path> runs it against another copy (the pre-fix file fails). Self-test poisons: the old drop at afterprint, no release when back, listeners armed at mount, no grace, printImageBlob revoking at afterprint, no ceiling, no cancel when away again, no touch settle window.",
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
"browser-gates": "lists every package.json script that launches a browser (the same detection run-parallel uses to route them through internal/browser-slot.sh). Run each as internal/browser-slot.sh bun run <name>.",
"cap:sync": "NOT `bunx` — bunx is not on PATH in this environment (command not found, exit 127), which broke cap:sync -> apk:debug -> phone at step one. The CLI IS installed locally (@capacitor/cli 7.6.9); invoke the local bin directly. The rest of package.json already uses the spaced `bun x` form.",