diff --git a/internal/check-print-frame-reclaim.mjs b/internal/check-print-frame-reclaim.mjs new file mode 100644 index 000000000..a5298ef05 --- /dev/null +++ b/internal/check-print-frame-reclaim.mjs @@ -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=` 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('', { 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('', { 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('', { 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.') diff --git a/library/composables/printInPlace.js b/library/composables/printInPlace.js index 4912718ce..552940696 100644 --- a/library/composables/printInPlace.js +++ b/library/composables/printInPlace.js @@ -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 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 = '', + `${esc(alt || title)}` 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 } diff --git a/package.json b/package.json index b257e1246..834fd2759 100644 --- a/package.json +++ b/package.json @@ -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 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.",