A print frame is kept until it has printed: its 60 s clock starts at load, not at creation

openPrintFrame's autoprint prints on the frame's 'load', which waits for every image, but the frame was removed 60 s after it was CREATED, loaded or not. The Wall's poster loads 939 photos (132 MB), so on a connection below about 18 Mbps the frame was gone before 'load': no print dialog, and nothing to say so. Now the frame is reclaimed 60 s after its load, at afterprint (as before), or at a 10-minute ceiling for a load that never ends. printImageBlob revoked its object URL on the same creation clock, which blanks an image that has not loaded; it now follows the frame's clock too.

New gate check:print-frame-reclaim (gates:pre), fake timers and a fake frame, so 61 s and 10 minutes take no time: a load at 61 s still finds the frame (and the object URL), 60 s after load it is reclaimed, a frame that never loads goes at the ceiling and not before, afterprint reclaims at once. Two-sided: the copy before this fix fails 6 of its 12 cases (the load-after-61-s and ceiling arms); the controls pass on both.
This commit is contained in:
Doxa Maps 2026-09-29 07:44:21 +00:00
parent 592880ac75
commit f30006d02a
3 changed files with 138 additions and 11 deletions

View file

@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* check-print-frame-reclaim — a print frame is kept until it has printed, and reclaimed after. Headless,
* with fake timers and a fake frame, so "61 s" and "10 minutes" take no time at all.
*
* THE DEFECT THIS GUARDS (2026-09-29). openPrintFrame's autoprint prints on the frame's 'load', which
* waits for every image. It also removed the frame 60 s after CREATION, loaded or not. The Wall's
* poster loads 939 photos (132 MB), so on a connection below about 18 Mbps the frame was gone before
* 'load': no print dialog, and nothing to say so. printImageBlob revoked its object URL on the same
* clock, which blanks an image that has not loaded yet.
*
* ASSERTS, for openPrintFrame's frame AND printImageBlob's object URL:
* 1. a frame whose 'load' arrives at 61 s is still there (so it prints), and is reclaimed 60 s later;
* 2. a frame that never loads is reclaimed at the ceiling (10 minutes), and not before;
* 3. afterprint reclaims at once.
* `--impl=<path>` runs the same assertions against another copy of printInPlace.js; the copy before
* this fix must FAIL arms 1 and 2 (the two-sided proof).
* EXIT 0 pass · 1 fail
*/
import { resolve, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const implArg = process.argv.find((a) => a.startsWith('--impl='))
const IMPL = implArg ? resolve(implArg.slice(7)) : resolve(ROOT, 'library/composables/printInPlace.js')
// ── fake timers: a clock that only moves when the test says so ────────────────────────────────────
let now = 0, seq = 0
const timers = new Map()
globalThis.setTimeout = (fn, ms = 0) => { const id = ++seq; timers.set(id, { at: now + Math.max(0, ms), fn }); return id }
globalThis.clearTimeout = (id) => { timers.delete(id) }
function advanceTo(t) {
for (;;) {
let next = null
for (const [id, x] of timers) if (x.at <= t && (!next || x.at < next[1].at || (x.at === next[1].at && id < next[0]))) next = [id, x]
if (!next) break
timers.delete(next[0]); now = next[1].at; next[1].fn()
}
now = t
}
function reset() { now = 0; timers.clear() }
// ── a fake frame: records what was written, and whether it is still in the page ───────────────────
let frame = null
const body = { appendChild: (f) => { f.parentNode = body } }
globalThis.document = {
body,
createElement: () => {
const listeners = {}
frame = {
parentNode: null, style: {}, html: '',
setAttribute() {}, remove() { this.parentNode = null },
contentDocument: { open() {}, write(s) { frame.html += s }, close() {} },
contentWindow: { addEventListener: (type, fn) => { (listeners[type] = listeners[type] || []).push(fn) } },
fire(type) { for (const fn of listeners[type] || []) fn() },
}
return frame
},
}
const revoked = []
globalThis.URL = { createObjectURL: () => 'blob:fake', revokeObjectURL: (u) => revoked.push([u, now]) }
const attached = () => !!(frame && frame.parentNode)
const { openPrintFrame, printImageBlob } = await import(pathToFileURL(IMPL).href)
const fails = []
const check = (ok, what) => { console.log(` ${ok ? 'ok ' : 'FAIL'} ${what}`); if (!ok) fails.push(what) }
// ── openPrintFrame ─────────────────────────────────────────────────────────────────────────────────
reset(); openPrintFrame('<img src="x.jpg">', { autoprint: true })
check(frame.html.includes('window.print()'), 'autoprint writes its print-on-load bootstrap (control)')
advanceTo(61000)
check(attached(), "frame: a 'load' that arrives at 61 s finds the frame still there, so the poster prints")
frame.fire('load'); advanceTo(61000 + 59999)
check(attached(), "frame: kept for 60 s after its 'load' (the dialog is up)")
advanceTo(61000 + 60000)
check(!attached(), "frame: reclaimed 60 s after its 'load'")
reset(); openPrintFrame('<img src="x.jpg">', { autoprint: true })
advanceTo(599999)
check(attached(), 'frame: a load that never ends is not reclaimed before the ceiling')
advanceTo(600000)
check(!attached(), 'frame: a load that never ends is reclaimed at the 10-minute ceiling')
reset(); openPrintFrame('<img src="x.jpg">', { autoprint: true })
advanceTo(1000); frame.fire('load'); advanceTo(2000); frame.fire('afterprint'); advanceTo(2000)
check(!attached(), 'frame: afterprint reclaims it at once')
// ── printImageBlob's object URL ────────────────────────────────────────────────────────────────────
reset(); revoked.length = 0; printImageBlob({ size: 1 })
advanceTo(61000)
check(!revoked.length, "object URL: not revoked before a 'load' that arrives at 61 s (the preview would be blank)")
frame.fire('load'); advanceTo(61000 + 60000)
check(revoked.length === 1 && revoked[0][1] === 121000, "object URL: revoked 60 s after the frame's 'load'")
reset(); revoked.length = 0; printImageBlob({ size: 1 })
advanceTo(599999)
check(!revoked.length, 'object URL: a load that never ends keeps it until the ceiling')
advanceTo(600000)
check(revoked.length === 1, 'object URL: revoked at the 10-minute ceiling')
reset(); revoked.length = 0; printImageBlob({ size: 1 })
advanceTo(500); frame.fire('load'); frame.fire('afterprint'); advanceTo(500)
check(revoked.length >= 1 && revoked[0][1] === 500, 'object URL: afterprint revokes it at once')
if (fails.length) { console.log(`PRINT-FRAME-RECLAIM: FAIL — ${fails.length} case(s) [${IMPL.replace(ROOT + '/', '')}]`); process.exit(1) }
console.log('PRINT-FRAME-RECLAIM: PASS — a print frame waits for its load however long it takes, is reclaimed 60 s after it, at afterprint, or at the 10-minute ceiling; its object URL follows the same clock.')

View file

@ -18,7 +18,8 @@
* engine, and a zero-height layout can print a blank first page.
* - autoprint appends the print bootstrap AFTER the caller's markup so it runs once the
* document is parsed (matches <img onload> timing).
* - afterprint + a 60s floor reclaim the frame, so repeated prints never leak nodes.
* - afterprint, or 60 s after the frame has LOADED, reclaims the frame, so repeated prints never
* leak nodes. A 10-minute ceiling reclaims a frame whose load never ends.
*
* Returns the frame's contentWindow, or null when the host is sandboxed/detached (caller
* keeps its own fallback).
@ -39,13 +40,27 @@ export function openPrintFrame(bodyHtml, { autoprint = false } = {}) {
}
doc.close()
const drop = () => { if (frame.parentNode) frame.remove() }
try { frame.contentWindow.addEventListener('afterprint', () => setTimeout(drop, 0)) } catch { /* cross-doc guard */ }
setTimeout(drop, 60000)
// ★ THE 60 s CLOCK STARTS AT 'load', NOT AT CREATION (2026-09-29). autoprint prints on 'load', which
// waits for every image in the frame. The Wall's poster loads 939 photos (132 MB), so below about
// 18 Mbps its 'load' came after 60 s, and the frame had already been removed: no dialog and no
// message. afterprint still drops the frame at once; FRAME_CEILING_MS reclaims one that never loads.
let ceiling = null
const drop = () => { clearTimeout(ceiling); if (frame.parentNode) frame.remove() }
ceiling = setTimeout(drop, FRAME_CEILING_MS)
try {
const win = frame.contentWindow
win.addEventListener('afterprint', () => setTimeout(drop, 0))
win.addEventListener('load', () => setTimeout(drop, FRAME_KEEP_MS), { once: true })
} catch { setTimeout(drop, FRAME_KEEP_MS) /* cross-doc guard: no events to wait for */ }
return frame.contentWindow
}
/** How long a print frame (and printImageBlob's object URL) is kept after the frame has loaded. */
export const FRAME_KEEP_MS = 60000
/** The absolute ceiling for a print frame whose load never ends. */
export const FRAME_CEILING_MS = 600000
/**
* Print a rendered poster IMAGE, at its real paper size, through the same in-place frame.
*
@ -59,9 +74,9 @@ export function openPrintFrame(bodyHtml, { autoprint = false } = {}) {
* poster previewed on A4 is a different document from the one that was rendered. margin:0 because the
* renderer already composed the margins into the image; a second set would scale the poster down.
*
* The object URL is revoked on afterprint AND on the frame's 60s reclaim, since afterprint does not
* fire in every engine when the dialog is dismissed. Revoking early would blank the preview, so the
* image element is kept alive by the frame itself rather than by a timer here.
* The object URL is revoked on afterprint AND on the frame's own clock (60 s after it has loaded, or
* the ceiling), since afterprint does not fire in every engine when the dialog is dismissed. Revoking
* before the image has loaded would blank the preview, which is why that clock starts at 'load'.
*
* Returns true when the dialog was opened, false when the host blocked the frame — the caller decides
* what to do about it, because a silent no-op is exactly the failure this replaced.
@ -80,9 +95,13 @@ export function printImageBlob(blob, { widthIn = 8.5, heightIn = 11, title = '',
+ `<body><img src="${url}" alt="${esc(alt || title)}"></body></html>`
const win = openPrintFrame(html, { autoprint: true })
if (!win) { URL.revokeObjectURL(url); return false }
const revoke = () => { try { URL.revokeObjectURL(url) } catch { /* already gone */ } }
try { win.addEventListener('afterprint', () => setTimeout(revoke, 0)) } catch { /* cross-doc guard */ }
setTimeout(revoke, 60000)
let ceiling = null
const revoke = () => { clearTimeout(ceiling); try { URL.revokeObjectURL(url) } catch { /* already gone */ } }
ceiling = setTimeout(revoke, FRAME_CEILING_MS)
try {
win.addEventListener('afterprint', () => setTimeout(revoke, 0))
win.addEventListener('load', () => setTimeout(revoke, FRAME_KEEP_MS), { once: true })
} catch { setTimeout(revoke, FRAME_KEEP_MS) /* cross-doc guard */ }
return true
}

View file

@ -246,6 +246,7 @@
"check:prayer-walk-planner": "node internal/check-prayer-walk-planner.mjs",
"check:preset-config-merge": "node internal/check-preset-config-merge.mjs",
"check:print-geometry": "node internal/check-print-geometry.mjs",
"check:print-frame-reclaim": "node internal/check-print-frame-reclaim.mjs",
"check:pin-dim-slider": "node plugin-designs/doxa-research-map/scripts/check-pin-dim-slider.mjs",
"check:profile-table": "node internal/check-profile-table.mjs",
"check:profiles-mount": "node internal/check-composites-mount.mjs --all-profiles",
@ -325,7 +326,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:host-locale-fetch",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:host-locale-fetch check:print-frame-reclaim",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -522,6 +523,7 @@
"check:pin-selection": "the shared source reader understands BOTH engines: mapbox-gl stores a GeoJSON source _data as the FeatureCollection, maplibre-gl wraps it as { geojson } (or { url } when remote). Reading only the Mapbox shape returned an EMPTY list on MapLibre — the keyless engine public builds use — which silently killed zoomToLegendRow camera fits on three maps and the research packet row selection, with no error because every caller no-ops on no features. Asserts both shapes, that a remote source returns [] AND warns, the serialize fallback, and that selectedPinIds agrees with the reader. Headless, no map. Exit 0 pass, 1 fail.",
"check:popup-href-escaped": "a URL interpolated into an HTML string is escaped before it lands in href= or src=. Written for RegionCountryMap's popup, which escaped every value except its two link URLs -- both built from joinBase(hostAttribute, apiSlug), so a quote in either escaped the attribute. Data URLs, blob URLs, encodeURIComponent and safeUrl() are recognised as safe; anything else needs an href-escaped-ok: reason. 6 self-tests. Source-only, headless.",
"check:poster-action-print": "a poster button opens the print dialog (Driver rule 2026-09-28: never a direct download). Two arms: PosterWall's posterAction defaults to 'print' and any consumer opting into 'download' carries a posterAction-download: comment saying why; and no shipped file hand-rolls an <a download> for a poster IMAGE (svg/png/webp/pdf) without first attempting to print, which the prop arm cannot see. Data exports (csv/txt/json) are out of scope. 6 self-tests. Source-only, headless.",
"check:print-frame-reclaim": "a print frame is kept until it has printed: openPrintFrame reclaims it 60 s after its load (not after its creation), at afterprint, or at a 10-minute ceiling, and printImageBlob revokes its object URL on the same clock. Fake timers; two-sided (the pre-fix copy fails). The Wall poster loads 132 MB of photos, and the old 60 s-from-creation clock removed the frame before it could print.",
"check:poster-attribution": "the poster footer is a LICENCE statement, so it must credit whoever actually served the tiles. posterDefaults ships '© Mapbox © OpenStreetMap', which under the fleet's MapLibre default credits a company that served nothing. Checked live 2026-09-28: the OpenFreeMap STYLE declares no attribution on either source — the credit is in the TileJSON, merged into the SOURCE object after load — so it is read from map.getSource(id), flattened from HTML, deduped, and an unreadable map falls back rather than printing a blank line. Headless; in gates:pre.",
"check:poster-card-fits": "a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
"check:poster-degrades": "with NEITHER poster nor source — the DEFAULT drop — nothing must print and the reader must be told why. check:poster-print-fallback SKIPs that case, so the behaviour a reader most often meets had no gate. MUST be served from the TREE ROOT, not public/, or assetsBase resolves outside the tree and the poster loads when it should 404.",