This guide is for the embed app at rate.tapchiphowall.com (a separate Nuxt project). It explains how to make the embed report its content height to the parent (tapchiphowall.com / en.tapchiphowall.com) so the parent's <iframe> resizes correctly when content inside the embed changes (tab switches, async data loads, etc.).
Browsers enforce same-origin policy: tapchiphowall.com cannot read the height of an iframe served from rate.tapchiphowall.com. The parent has no way to detect content reflows inside the embed unless the embed cooperates by posting its current height via window.parent.postMessage(...).
Polling from the parent only works if the embed responds to height-request messages. The cleanest fix is for the embed to push height updates whenever its own content changes — no polling required.
Create composables/useEmbedHeightReporter.ts in the embed app (rate.tapchiphowall.com's repo):
const ALLOWED_PARENT_ORIGINS = [
'https://tapchiphowall.com',
'https://en.tapchiphowall.com',
'http://localhost:3000' // tapchiphowall-app dev server
]
export function useEmbedHeightReporter() {
if (import.meta.server) return
onMounted(() => {
if (window.self === window.top) return // not embedded — skip
// Pick the parent origin from referrer; fall back to first allowed.
const referrerOrigin = (() => {
try { return new URL(document.referrer).origin } catch { return '' }
})()
const targetOrigin = ALLOWED_PARENT_ORIGINS.includes(referrerOrigin)
? referrerOrigin
: ALLOWED_PARENT_ORIGINS[0]
let lastHeight = 0
let raf = 0
const postHeight = () => {
cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
const h = Math.ceil(document.documentElement.scrollHeight)
if (h === lastHeight || h <= 0) return
lastHeight = h
window.parent.postMessage({ type: 'height', height: h }, targetOrigin)
})
}
// 1. ResizeObserver catches layout reflows (tab switches, content swaps).
const ro = new ResizeObserver(postHeight)
ro.observe(document.documentElement)
ro.observe(document.body)
// 2. MutationObserver catches DOM swaps that don't change box size
// immediately (e.g. tab switch followed by async content render).
const mo = new MutationObserver(postHeight)
mo.observe(document.body, { childList: true, subtree: true })
// 3. Respond to explicit polls from the parent (so the parent's
// watchdog still works as a safety net).
const onMessage = (e: MessageEvent) => {
if (!ALLOWED_PARENT_ORIGINS.includes(e.origin)) return
const d = e.data
const isRequest =
d === 'requestHeight' || d === 'getHeight' ||
d?.type === 'requestHeight' || d?.type === 'getHeight' ||
d?.action === 'getHeight'
if (!isRequest) return
lastHeight = 0 // force re-send even if value unchanged
postHeight()
}
window.addEventListener('message', onMessage)
window.addEventListener('load', postHeight)
window.addEventListener('resize', postHeight)
postHeight() // initial
onBeforeUnmount(() => {
ro.disconnect()
mo.disconnect()
window.removeEventListener('message', onMessage)
window.removeEventListener('load', postHeight)
window.removeEventListener('resize', postHeight)
cancelAnimationFrame(raf)
})
})
}In the embed app, edit pages/ecb.vue (or whichever page is loaded inside the iframe):
<script setup>
useEmbedHeightReporter()
</script>That's it. The composable auto-imports because it's in composables/.
The parent (tapchiphowall-app) runs on http://localhost:3000. The embed needs to be reachable from the parent during dev — pick one of:
- In the embed app, start the dev server on a different port:
pnpm dev --port 3100
- In the parent app (
tapchiphowall-app), temporarily change the iframe URL inpages/interest-rate.vueto point athttp://localhost:3100/ecbinstead ofhttps://rate.tapchiphowall.com/ecb. - Run the parent:
pnpm dev(defaults to port 3000). - Visit
http://localhost:3000/interest-rate. - Open DevTools console and watch for height messages — switching tabs inside the embed should produce a stream of
{ type: 'height', height: ... }postMessages, and the outer iframe should resize immediately.
If you don't want to run two Nuxt servers, deploy the embed change to a preview URL first and update the parent iframe to point at the preview URL temporarily.
In the parent's DevTools console, run:
window.addEventListener('message', e => {
if (e.origin.endsWith('tapchiphowall.com') || e.origin.includes('localhost')) {
console.log('FROM EMBED:', e.origin, e.data)
}
})Then switch tabs inside the embed. You should see { type: 'height', height: <number> } log on each interaction.
- Deploy the embed app change to
rate.tapchiphowall.com. - No changes are required on the
tapchiphowall-appparent side —extractHeight()inpages/interest-rate.vuealready handles the{ type: 'height', height: ... }payload via itsdata.heightbranch. - (Optional cleanup) Once you've confirmed push-based updates work, you can relax the parent's polling interval in
pages/interest-rate.vuefrom500back to2000ms or higher — it becomes a redundant safety net.
Nothing logs in the parent's console after deploying.
- Check the embed has actually shipped — view source on
rate.tapchiphowall.com/ecband confirm the composable code is present. - Check
event.originon the embed side — ifreferrerOrigindoesn't match an allowed parent, the embed posts to the fallback (tapchiphowall.com) anden.tapchiphowall.comparents won't see it. Add the missing origin toALLOWED_PARENT_ORIGINS.
Messages arrive but the iframe doesn't resize.
- Open DevTools and inspect the
<iframe>height attribute. If it's updating but visually clipped, look foroverflow: hiddenormax-heighton a wrapper.
Infinite resize loop.
- The
lastHeightguard prevents re-posting an unchanged height, but if the embed's content height genuinely oscillates (e.g. a chart that resizes to fit the iframe, which then resizes to fit the chart), add a small min-change threshold:if (Math.abs(h - lastHeight) < 4) return
Embed loads in a sandboxed iframe and window.parent is null.
- Check the parent iframe's
sandboxattribute. The current parent doesn't set one, so this shouldn't happen — but if it ever does,allow-scriptsmust be present for the embed to run, and posting toparentdoesn't require any extra sandbox token.
- A composable is opt-in per page. Safer if
rate.tapchiphowall.comlater adds non-embedded routes. - A client plugin (
plugins/embed-height.client.ts) runs on every page automatically — convenient if all routes will always be embedded. Same body, just wrap indefineNuxtPlugin(() => { /* setup */ })and call the setup directly instead of insideonMounted.
For a single-page embed (/ecb only), either works; the composable is recommended for explicitness.