Files
Housekeeping-web-admin/node_modules/@base44/vite-plugin/dist/injections/page-height-bridge.js
T
2026-06-24 09:48:54 +02:00

510 lines
21 KiB
JavaScript

// page-height-bridge — postMessage protocol the iframe exposes to its parent.
// Inert until the parent posts a message; no observers, rewrites, or timers
// fire on their own.
//
// parent → child { type: "freeze-vh-units", referenceVhBase?: number }
// Rewrites every viewport-height unit (`vh`/`dvh`/`svh`/`lvh`) in
// <style> + CSSOM to a CSS variable-backed expression (kills the
// vh ↔ auto-resize feedback loop). Repeated calls update the variable,
// so callers can rebase without recovering raw CSS. Idempotent; covers
// HMR-added styles too. Fire-and-forget.
//
// parent → child { type: "measure-page-height", settleMs?: number }
// After `settleMs` (default 2000) posts the page's measured content height
// as `{ type: "page-height-measured", height }` to the requester.
const FALLBACK_VHBASE = 900;
const MIN_VHBASE = 400;
const DEFAULT_SETTLE_MS = 2000;
const NEUTRALIZE_DEBOUNCE_MS = 16;
// Three consecutive same-height samples ≈ ~50ms of "no change". Three is
// enough to filter single-frame jitter from layout passes but short enough
// that responses arrive promptly when the DOM is already settled.
const STABILITY_SAMPLES = 3;
// Interval between stability samples. ~1 frame at 60Hz; chosen as a plain
// setTimeout (not rAF) because jsdom rAFs are unreliably timed for tests and
// in real browsers a 16ms tick is post-layout for any layout work that fits
// inside one frame.
const STABILITY_TICK_MS = 16;
// Hard cap on the stability poll past settleMs. Pages that genuinely never
// stabilize (looping height-animating content) reply with their last sample
// rather than hanging the parent's resize logic.
const STABILITY_MAX_WAIT_MS = 1500;
const REFERENCE_VH_BASE_VAR = "--base44-reference-vh-base";
const noop = () => { };
let started = false;
/**
* Installs the parent-driven message listener. Returns a teardown that
* removes the listener, disconnects any observers attached as a result of
* `freeze-vh-units`, and clears any pending response timer. Returns a
* no-op when bailed (already started, SSR, top window).
*/
export function setupPageHeightBridge() {
if (started)
return noop;
if (typeof window === "undefined")
return noop;
if (window.self === window.top)
return noop;
started = true;
const controller = createPageHeightBridgeController();
const onMessage = (event) => {
const data = (event.data ?? null);
if (!data || typeof data !== "object")
return;
switch (data.type) {
case "freeze-vh-units": {
const override = typeof data.referenceVhBase === "number" ? data.referenceVhBase : undefined;
controller.freezeVhUnits(override);
return;
}
case "measure-page-height": {
const settleMs = typeof data.settleMs === "number" ? data.settleMs : DEFAULT_SETTLE_MS;
const origin = event.origin && event.origin !== "null" ? event.origin : "*";
controller.measurePageHeight(origin, settleMs);
return;
}
}
};
window.addEventListener("message", onMessage);
let torn = false;
return () => {
if (torn)
return;
torn = true;
started = false;
window.removeEventListener("message", onMessage);
controller.teardown();
};
}
export function createPageHeightBridgeController() {
let vhCleanups = null;
let vhForceRun = null;
let pendingSettle;
let pendingTick;
let pendingOrigin = "*";
const cancelPending = () => {
if (pendingSettle !== undefined) {
window.clearTimeout(pendingSettle);
pendingSettle = undefined;
}
if (pendingTick !== undefined) {
window.clearTimeout(pendingTick);
pendingTick = undefined;
}
};
return {
freezeVhUnits: (override) => {
const referenceVhBase = resolveReferenceVhBase(override);
setReferenceVhBase(referenceVhBase);
if (vhCleanups) {
vhForceRun?.();
return;
}
vhCleanups = [];
vhForceRun = startVhNeutralizer(vhCleanups);
},
// Target the requester's origin so the height isn't broadcast to anyone
// who happens to embed us. Falls back to "*" when origin is unavailable
// (jsdom default, sandboxed iframes with `null` origin).
//
// Stability-wait response: after `settleMs`, poll measureContentHeight at
// STABILITY_TICK_MS intervals until it's unchanged for STABILITY_SAMPLES
// consecutive ticks, then send exactly one reply. Catches late React
// mounts (sticky-positioned sections committing after settleMs), debounced
// neutralizer mutations not yet flushed, image/font-driven layout shifts.
// STABILITY_MAX_WAIT_MS caps the poll so a page that genuinely never
// settles still replies eventually.
measurePageHeight: (origin, settleMs = DEFAULT_SETTLE_MS) => {
pendingOrigin = origin;
cancelPending();
pendingSettle = window.setTimeout(() => {
pendingSettle = undefined;
// Flush any debounced unscroll / inline-vh rewrites NOW so the first
// sample sees a DOM that reflects every mutation triggered during the
// settle window. Without this, an `overflow-y: scroll` container that
// mounted mid-settle can still be hiding its children at sample time.
vhForceRun?.();
const deadline = nowMs() + STABILITY_MAX_WAIT_MS;
let lastHeight = -1;
let stableSamples = 0;
const respond = (height) => {
pendingTick = undefined;
window.parent.postMessage({ type: "page-height-measured", height }, pendingOrigin);
};
const tick = () => {
pendingTick = undefined;
const height = measureContentHeight();
if (height === lastHeight) {
stableSamples++;
}
else {
stableSamples = 1;
lastHeight = height;
}
if (stableSamples >= STABILITY_SAMPLES || nowMs() >= deadline) {
respond(height);
return;
}
pendingTick = window.setTimeout(tick, STABILITY_TICK_MS);
};
tick();
}, settleMs);
},
teardown: () => {
if (vhCleanups) {
for (const c of vhCleanups)
c();
vhCleanups = null;
vhForceRun = null;
}
cancelPending();
},
};
}
function nowMs() {
if (typeof performance !== "undefined" && typeof performance.now === "function") {
return performance.now();
}
return Date.now();
}
function resolveReferenceVhBase(override) {
if (override !== undefined)
return override;
const detected = window.innerHeight || 0;
return detected >= MIN_VHBASE ? detected : FALLBACK_VHBASE;
}
function setReferenceVhBase(referenceVhBase) {
document.documentElement.style.setProperty(REFERENCE_VH_BASE_VAR, `${referenceVhBase}px`);
}
// Caches keep work proportional to *new* CSS, not total CSS. <head> observer
// catches `<style>`/`<link>` adds; per-element observers catch HMR text edits.
// Returns a `forceRun` so the caller can re-trigger neutralization on later
// parent requests (idempotent — already-rewritten text is skipped via the
// processed sets).
function startVhNeutralizer(cleanups) {
// Match vh + the dynamic/small/large variants. Tailwind v4 emits `h-screen`
// as `100dvh`; all four collapse to the same frozen `referenceVhBase`.
const VH_RE = /(\d+(?:\.\d+)?)(?:d|s|l)?vh\b/g;
const processedStyles = new WeakSet();
const processedSheets = new WeakMap();
const watchedStyles = new WeakSet();
const styleObservers = new Set();
const unscrolled = new WeakSet();
const rewrite = (input) => input.replace(VH_RE, (_match, n) => `calc(var(${REFERENCE_VH_BASE_VAR}) * ${formatVhFactor(n)})`);
// Defeats internal vertical scrollers. The canvas wants the whole page laid
// out top-to-bottom, but `<div class="overflow-y-auto" style={{height:
// "100vh"}}>` hides its children behind an internal scrollbar — only the
// first child appears in the preview. Forcing `overflow-y: visible` lets
// children paint outside the parent's box; block flow positions them at
// their natural offsets so document.body extends to include them. `auto`
// and `scroll` only — `hidden` and `clip` express intentional clipping (UI
// chrome, rounded-corner masks) we shouldn't undo.
const unscrollY = (el) => {
if (unscrolled.has(el))
return;
const computedStyle = window.getComputedStyle(el);
const ovY = computedStyle.overflowY;
if (ovY !== "auto" && ovY !== "scroll")
return;
unscrolled.add(el);
el.style.setProperty("overflow-y", "visible", "important");
};
const unscrollSubtree = (root) => {
unscrollY(root);
root.querySelectorAll("*").forEach(unscrollY);
};
// Rewrites vh-bearing properties on a single element's inline style. Covers
// React's `style={{ height: "100vh" }}` and imperative `el.style.h = ".vh"`
// — both bypass <style> tags and CSSOM. Per-property setProperty preserves
// `!important`. Snapshotting prop names first guards against iteration-time
// mutation of `style.length`.
const rewriteInlineStyle = (el) => {
const style = el.style;
if (!style || style.length === 0)
return;
const props = [];
for (let i = 0; i < style.length; i++) {
const prop = style[i];
if (prop)
props.push(prop);
}
for (const prop of props) {
const value = style.getPropertyValue(prop);
if (!value || value.indexOf("vh") === -1)
continue;
const next = rewrite(value);
if (next !== value)
style.setProperty(prop, next, style.getPropertyPriority(prop));
}
};
const neutralize = () => {
document.querySelectorAll("style").forEach((el) => {
watchStyleEl(el);
if (processedStyles.has(el))
return;
processedStyles.add(el);
const text = el.textContent;
if (!text || text.indexOf("vh") === -1)
return;
const next = rewrite(text);
if (next !== text)
el.textContent = next;
});
for (let i = 0; i < document.styleSheets.length; i++) {
const sheet = document.styleSheets[i];
if (!sheet)
continue;
let rules;
try {
rules = sheet.cssRules;
}
catch {
continue; // CORS-protected
}
if (processedSheets.get(sheet) === rules.length)
continue;
rewriteVhInRules(rules, rewrite);
processedSheets.set(sheet, rules.length);
}
// Initial sweep of inline `style="...vh..."` attributes already in the DOM
// at freeze time. Future inline-style mutations (React commits, motion
// libraries, imperative assignments) are picked up by inlineStyleObserver.
document.querySelectorAll('[style*="vh"]').forEach(rewriteInlineStyle);
// Defeat internal scrollers — same dual coverage (initial sweep here,
// subtree additions in inlineStyleObserver).
if (document.body)
unscrollSubtree(document.body);
};
const debouncer = createDebouncer(neutralize, NEUTRALIZE_DEBOUNCE_MS);
const debouncedNeutralize = debouncer.trigger;
cleanups.push(debouncer.cancel);
const watchStyleEl = (el) => {
if (watchedStyles.has(el))
return;
watchedStyles.add(el);
const obs = new MutationObserver(() => {
processedStyles.delete(el);
debouncedNeutralize();
});
obs.observe(el, { characterData: true, childList: true, subtree: true });
styleObservers.add(obs);
};
cleanups.push(() => {
for (const obs of styleObservers)
obs.disconnect();
styleObservers.clear();
});
debouncedNeutralize();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", debouncedNeutralize);
cleanups.push(() => document.removeEventListener("DOMContentLoaded", debouncedNeutralize));
}
window.addEventListener("load", debouncedNeutralize);
cleanups.push(() => window.removeEventListener("load", debouncedNeutralize));
const headObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (containsStylesheetNode(m.addedNodes) || containsStylesheetNode(m.removedNodes)) {
debouncedNeutralize();
return;
}
}
});
cleanups.push(() => headObserver.disconnect());
const attachHeadObserver = () => {
if (document.head)
headObserver.observe(document.head, { childList: true, subtree: false });
};
if (document.head) {
attachHeadObserver();
}
else {
document.addEventListener("DOMContentLoaded", attachHeadObserver);
cleanups.push(() => document.removeEventListener("DOMContentLoaded", attachHeadObserver));
}
// Pass 3: inline `style` attributes. One global observer covers every
// element — past, present, and future — without per-node tracking. Two
// mutation kinds matter:
// • attributes: React/JS sets `style` on an element already in the tree
// (`el.style.h = "..vh"`, re-render diff). After rewrite the value has
// no "vh", so the next mutation gates out — single-tick convergence.
// • childList: a node is mounted with its `style` attribute already set
// off-tree (React's initial mount path uses createElement+setAttribute
// BEFORE appendChild, so attribute mutations never fire for them). Scan
// the added subtree for `[style*="vh"]` and rewrite.
// Microtask delivery means rewrites land before layout, so no flash.
const scanSubtreeForInlineVh = (node) => {
if (!(node instanceof Element))
return;
const attr = node.getAttribute("style");
if (attr && attr.indexOf("vh") !== -1)
rewriteInlineStyle(node);
node.querySelectorAll('[style*="vh"]').forEach(rewriteInlineStyle);
// New subtrees may introduce overflow-y: auto|scroll containers (route
// changes, conditional UI). Catch them here too.
unscrollSubtree(node);
};
const inlineStyleObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === "attributes") {
const target = m.target;
if (!(target instanceof Element))
continue;
const attr = target.getAttribute("style");
if (!attr || attr.indexOf("vh") === -1)
continue;
rewriteInlineStyle(target);
}
else if (m.type === "childList") {
for (let i = 0; i < m.addedNodes.length; i++) {
const node = m.addedNodes[i];
if (node)
scanSubtreeForInlineVh(node);
}
}
}
});
cleanups.push(() => inlineStyleObserver.disconnect());
const attachInlineStyleObserver = () => {
const root = document.documentElement;
if (!root)
return;
inlineStyleObserver.observe(root, {
attributes: true,
attributeFilter: ["style"],
childList: true,
subtree: true,
});
};
attachInlineStyleObserver();
return debouncedNeutralize;
}
function formatVhFactor(value) {
return String(Number((parseFloat(value) / 100).toFixed(6)));
}
function rewriteVhInRules(rules, rewrite) {
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
if (!rule)
continue;
if (rule.cssRules)
rewriteVhInRules(rule.cssRules, rewrite);
const style = rule.style;
if (!style)
continue;
for (let j = 0; j < style.length; j++) {
const prop = style[j];
if (!prop)
continue;
const value = style.getPropertyValue(prop);
if (!value || value.indexOf("vh") === -1)
continue;
const next = rewrite(value);
if (next !== value)
style.setProperty(prop, next, style.getPropertyPriority(prop));
}
}
}
function containsStylesheetNode(nodes) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node instanceof HTMLStyleElement || node instanceof HTMLLinkElement)
return true;
}
return false;
}
function measureContentHeight() {
const scrollHeight = Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0);
const referenceVhBase = readReferenceVhBase();
// Excludes `body.clientHeight`: it grows with content, which makes
// viewportBottom land at the document bottom and trips the stretched-
// container heuristic on the last section. `referenceVhBase` keeps the
// h-screen-wrapper case covered when the iframe is shorter than 100vh.
const viewportHeight = Math.max(window.innerHeight || 0, document.documentElement.clientHeight, referenceVhBase);
const contentBottom = measureElementContentBottom(viewportHeight);
if (contentBottom > 0)
return Math.ceil(Math.max(contentBottom, referenceVhBase));
return Math.ceil(Math.max(scrollHeight, referenceVhBase));
}
function readReferenceVhBase() {
const value = document.documentElement.style.getPropertyValue(REFERENCE_VH_BASE_VAR);
const parsed = parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function measureElementContentBottom(viewportHeight) {
if (!document.body)
return 0;
const elements = [document.body, ...Array.from(document.body.querySelectorAll("*"))];
const contentBottoms = new WeakMap();
const viewportBottom = window.scrollY + viewportHeight;
for (let i = elements.length - 1; i >= 0; i--) {
const el = elements[i];
if (!el)
continue;
const childContentBottom = readChildrenContentBottom(el, contentBottoms);
const metrics = readElementMetrics(el);
const selfBottom = isViewportStretchedContainer(metrics, childContentBottom, viewportHeight, viewportBottom)
? 0
: metrics.bottom;
contentBottoms.set(el, Math.max(childContentBottom, selfBottom));
}
return contentBottoms.get(document.body) ?? 0;
}
function readChildrenContentBottom(el, contentBottoms) {
let childContentBottom = 0;
for (let i = 0; i < el.children.length; i++) {
const child = el.children[i];
if (!child)
continue;
childContentBottom = Math.max(childContentBottom, contentBottoms.get(child) ?? 0);
}
return childContentBottom;
}
function readElementMetrics(el) {
const computedStyle = window.getComputedStyle(el);
if (isOutOfFlowDecoration(computedStyle))
return { bottom: 0, height: 0 };
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0)
return { bottom: 0, height: 0 };
return {
bottom: rect.bottom + window.scrollY + readMarginBottom(computedStyle),
height: rect.height,
};
}
function readMarginBottom(computedStyle) {
const marginBottom = parseFloat(computedStyle.marginBottom);
return Number.isFinite(marginBottom) ? marginBottom : 0;
}
function isOutOfFlowDecoration(computedStyle) {
if (computedStyle.position === "fixed")
return true;
return computedStyle.position === "absolute" && computedStyle.pointerEvents === "none";
}
// A "stretched container" spans the full viewport top-to-bottom. Requires
// BOTH `bottom ≈ viewportBottom` AND `height ≈ viewportHeight` — otherwise an
// in-flow section that just happens to end at viewportBottom (e.g. when the
// iframe has been content-sized to the previous measurement) gets falsely
// filtered, undermeasuring the page.
function isViewportStretchedContainer(metrics, childBottom, viewportHeight, viewportBottom) {
return (childBottom > 0 &&
Math.abs(metrics.bottom - viewportBottom) <= 1 &&
Math.abs(metrics.height - viewportHeight) <= 1 &&
metrics.bottom - childBottom > 8);
}
function createDebouncer(fn, delayMs) {
let timer;
return {
trigger: () => {
if (timer !== undefined)
window.clearTimeout(timer);
timer = window.setTimeout(fn, delayMs);
},
cancel: () => {
if (timer !== undefined) {
window.clearTimeout(timer);
timer = undefined;
}
},
};
}
//# sourceMappingURL=page-height-bridge.js.map