F5b: bound the model-slot App Block iframe to the viewer's viewport (#4589)

* fix(app-blocks): bound the model-slot iframe to the viewer's viewport (F5b)

`IframeHost.applyHeight` had three layers of height defense: the
isFinite/positive value guard, `manifest.iframe.maxHeight`, and
`HARD_HEIGHT_CEILING` (8000px).

The gap is layer 2 being optional. `public/schemas/app-block/v1.json` types
`iframe.maxHeight` as `["integer","null"]` ("null for unbounded") and `iframe`
declares no required fields at all, so a manifest that simply omits it is
bounded only at 8000px. A block self-reporting 3000px therefore got a 3000px
iframe inside a ~640px phone viewport, and the inline slot swallowed the page.

Adds layer 4:

    next = Math.min(next, Math.max(min, viewportHeight));

The iframe may never exceed the visible viewport height, but the manifest's
`minHeight` still wins if the viewport is somehow shorter than it. The block
scrolls internally instead, which is the intended outcome.

Two properties the shape alone does not give you:

  - the viewport is read at CALL time, never captured at mount, so a rotate or
    a browser-chrome resize cannot leave a stale bound in place;
  - the block's own STATED height is stashed in a ref and the clamp is
    re-applied on `resize`, so the bound tracks the viewport in both
    directions. Host-side only — nothing is posted back to the block, which
    is never asked to re-measure (RESIZE_IFRAME is one-way).

A viewport that cannot be measured (SSR, or an `innerHeight` that is not a
positive finite number) means DO NOT CLAMP, never clamp-to-zero: a failed
measurement degrades to the pre-existing three-layer behaviour.

Surface: the inline model-page slot only. `IframeHost` is the only
RESIZE_IFRAME consumer in `src/`; the full-page host `PageBlockHost`
(`/apps/run/<slug>`) has no RESIZE_IFRAME handling and is already
viewport-bound by its own `calc(100dvh - HEADER_HEIGHT_PX)`. It is untouched.

Tests: `IframeHostViewportHeightClamp.browser.test.tsx`, 6 cases, all at an
explicitly-set phone viewport. The harness default is 414x896, at which every
height the neighbouring IframeHost suites assert (640/700/800) is already under
the bound and the clamp never fires — so each test calls `page.viewport(...)`
first and then re-reads `window.innerHeight` to prove the value took.

  red at origin/main (bbbe837d13): 3 failed | 3 passed (6)
  green at HEAD:                   6 passed (6)

* fix(app-blocks): bound the model-slot WIDGET to the viewport, cap minHeight (F5b audit round 1)

Two measured defects from the adversarial audit of #4589, plus the fixture and
mutation-coverage gaps it found.

1) THE CLAMP BOUNDED THE IFRAME, NOT THE WIDGET.

`framed()` renders AppBlockChrome ABOVE the iframe inside one bordered box, so
clamping the iframe to the viewport still produced a `viewport + chrome` widget.
Measured at 390x640: iframe 640 (correct), frame 738, chrome 98 — a 738px widget
on a 640px screen. On `model.sidebar_top` the block sits in normal page flow on
mobile, so the viewer could never see the whole block AND anything below it.

The budget is now `viewport - overhead`, where overhead is MEASURED live as
`frame.offsetHeight - iframe.offsetHeight` — invariant to the iframe's current
height, correct if the frame ever gains another sibling, and not the pinned
single-row `CHROME_BAR_PX`, which is one theme away from wrong and has gone
stale before. Against the complete approved population (11 of 11 blocks) this is
worth 98px on every block at every viewport: at an 844px viewport it takes
overflow from 11/11 to 0/11.

The suite now asserts the FRAME's height and the document scroll height, not the
iframe's. An iframe-only assertion is exactly how the first revision passed with
the widget still overflowing.

2) A SCHEMA-LEGAL `minHeight` DEFEATED THE CLAMP ENTIRELY.

`Math.max(min, budget)` means the publisher's floor always wins, and `minHeight`
shared `maxHeight`'s ceiling of 4000. Measured at 390x640 with `{minHeight:
4000}` and the block reporting 100: appliedHeight 4000, with the clamp present —
the exact failure the clamp exists to prevent, through one field.

`minHeight` now has its OWN ceiling, `MIN_HEIGHT_MAX_CEILING = 800`, mirrored in
the canonical schema. `maxHeight` stays at 4000, and that asymmetry is
load-bearing in both directions: raising minHeight back re-opens the defect;
lowering maxHeight rejects the entire live population, all 11 of which declare
`maxHeight: 4000`. A new drift guard asserts each bound independently and drives
the validator across the minHeight boundary.

800 rejects nothing that exists: the largest live declared floor is 700.

🔴 THE CAP DOES NOT CLOSE THE RESIDUE, and neither the code nor the PR claims it
does. Live floors are 400 x1 / 600 x5 / 640 x3 / 700 x2, so at a 640px viewport
(budget 542 after chrome) 10 of 11 blocks are bound by their own floor and still
overflow by 58-158px. Shrinking that is a per-publisher change or a change to
which of floor/viewport wins — deliberately not attempted here.

Chose the validator cap over merely restating the claim after measuring the
mirror cost: civitai CI is unaffected (nothing here guards `iframe.*` until the
guard added by this commit). Downstream is time-delayed to the `main` ->
`release` cut, not to this merge, and needs two small hand edits that are NOT in
this repo — see the PR body.

ALSO FIXED, all from the audit:
  - fixture `MIN_H` was 200, identical to the source's own `?? 200` default, so a
    mutant hardcoding 200 as the floor survived the whole file. Now 160, plus a
    case that shrinks the viewport below the floor (without it the re-clamp's
    `min` argument is never exercised — every other case runs where the budget
    wins).
  - the `resize` cleanup had no coverage; deleting it survived. Added a ledger
    test asserting every handler added is removed on unmount, with a positive
    control on the ledger itself.
  - `if (reported === null) return;` is behaviourally inert on every reachable
    input. Relabelled in both the source and the suite as a TYPE NARROWING and an
    INVARIANT guard rather than coverage it cannot have.
  - the suite header named `assertViewportHeight`; the function is `setViewport`.

Nine mutants on the host clamp and three on the validator, all killed by this
suite's own assertion messages. Full matrix in the PR body.

* refactor(app-blocks): split the minHeight cap out of this PR, keep the host fix

Operator decision: ship the host-side viewport clamp on its own. The manifest
contract change (`MIN_HEIGHT_MAX_CEILING`, the schema bound and their guards)
moves to branch `zach/f5b-minheight-cap`, as a separate PR based on `main` — NOT
stacked on this one.

Why the split, recorded so it is not re-litigated:

  - The chrome-subtraction fix has ZERO cross-repo cost and takes the live
    approved population from 11 of 11 blocks overflowing to 0 of 11 at an 844px
    viewport. It should not wait behind anything.
  - The cap closes a hole NO LIVE BLOCK EXERCISES (largest declared `minHeight`
    is 700; all 11 declare `maxHeight: 4000`), costs two hand edits in repos we
    do not control, would turn civitai-app-starters' CI red until a human fixes
    it — and does not close the residue: 600/640/700 stay legal and all exceed
    the 542px budget at a 640px viewport. Weak trade for the coupling. Not
    cancelled, decoupled.

Reverted here: block-manifest-validator.service.ts, public/schemas/app-block/
v1.json, the validator's own test additions, and manifest-iframe-height.schema-
drift.test.ts.

Kept, unchanged: the chrome-overhead subtraction, `frameRef`, the live
`frame.offsetHeight - iframe.offsetHeight` measurement (including that it is
re-read inside the resize handler), the docblock's honest limit about a chrome
bar that changes height with no viewport change, the fixture `MIN_H` of 160 plus
the below-the-floor case, the unmount ledger and its positive control, the
`reported === null` relabelling as a type narrowing rather than coverage, the
`setViewport` header correction, and the IframeHostReadyTransition viewport
change at :516 — that last one is a legitimate consequence of the chrome
subtraction, not scaffolding for the cap.

Prose fixed in the same pass: two docblocks asserted the floor "is now capped at
800", which is false on this branch. They now state the residue accurately
against the UNCAPPED 4000 ceiling and point at the separate branch, so neither
file claims a bound this PR does not deliver.

* fix(app-blocks): measure the clamp's overhead with the real cascade loaded

Round-2 audit finding, and it is a measurement defect rather than a behaviour
one: the shipped clamp was right, every number written about it was not.

THE ARTIFACT. This suite did not import `@mantine/core/styles.css`. Its sibling
`AppBlockChromeResponsive.browser.test.tsx` does, and its header says, verbatim,
that without it "each computes to something meaningless while the assertions
still pass". This file measured its chrome overhead in exactly the harness that
sibling warns about, and every assertion passed anyway because they are all
self-relative.

  measured WITHOUT the stylesheet:  chrome 98, frame border 0,  overhead 98
  measured WITH it:                 chrome 31, frame border 1+1, overhead 33

The 98 also implied a border of zero, because
`border: 1px solid var(--mantine-color-default-border)` is
invalid-at-computed-value-time with no cascade. The 31 matches the sibling's own
pin exactly (22 + 8 from `py={4}` + 1 border).

WHAT THAT FALSIFIED. The published residue table said "at a 640px viewport the
budget is 542, so 10 of 11 live blocks overflow by 58-158px", and the split
commit's stated rationale leaned on "600/640/700 all exceed the 542px budget".
The real budget is 607, and the 600-tier — FIVE of the eleven live blocks —
FITS. Corrected everywhere it appears:

  vp 640, no subtraction:  11/11 overflow, worst 93px
  vp 640, with it:          5/11 overflow, worst 93px  (400- and 600-tiers fit)
  vp 844, no subtraction:  11/11 overflow, 33px each
  vp 844, with it:          0/11 overflow

NO BEHAVIOUR CHANGE. The overhead is measured live at runtime, which is exactly
what made the code right while the comment was wrong; 33 is recorded as an
OBSERVATION, never as a constant the clamp assumes.

THE SUITE NOW LOADS THE STYLESHEET, and says why that is not a contradiction of
the sibling's "siblings MUST NOT" rule: that rule is scoped to suites asserting
attributes and ARIA, which is what the shared scaffold is for. This one asserts
PIXELS, so it takes the same exception the sibling takes for itself. Browser mode
runs each file in its own iframe, so the import cannot leak sideways.

Guarded, not just fixed: `renderReady` now asserts the chrome computes to
`display: flex` — the sibling's own `styleSheetLoaded` predicate. Negative
control run: deleting the import fails all 8 cases with that message, where
before it changed nothing visible.

The two budget assertions were also wrong by the border width, because they
derived the expectation as `viewport - chrome`. They now use
`viewport - chrome - frameBorderPx()`, with the border read from
`getComputedStyle` — an INDEPENDENT observable. Deriving it as
`frameHeight() - appliedHeight()` would have been the implementation's own
arithmetic and vacuously true.

Also: `frameOverheadPx`'s `!frame || !iframe` line was documented as a real
degradation path ("either element unmounted") it cannot take. Relabelled as a
TYPE NARROWING, matching the `reported === null` early-out in the re-clamp
effect — both refs are assigned during commit, before any passive effect runs or
any postMessage can be dispatched.

Verified at this tip: clamp suite 8 passed; red at origin/main b7fd0d0685 with
only the IframeHost hunk reverted, 5 failed | 3 passed; mutants A, F, G and H
each killed by this suite's own message (H now reports a 673px widget, the real
overhead arithmetic, where it reported 738 under the artifact); typecheck 0
errors.

The commit messages already on this branch predate the correction and carry the
98; they cannot be amended after pushing, so the PR body is the corrected record.

Context worth keeping: #4601 (e92cf5fe4) added a whole `geometry` vitest project
BECAUSE the component tier omits the real cascade and lets geometry numbers be
meaningless while assertions pass. This PR hit that trap in the same repo, the
same week, in a file whose sibling documents it. Anyone measuring geometry in a
`.browser.test.tsx` should read that project's setup first.

* docs(app-blocks): correct a false reachability claim, and sweep the shape

Round-3 audit finding. Comment-only — `git diff` with comment lines stripped is
empty, verified mechanically, not by eye.

THE FALSE SENTENCE. `frameOverheadPx`'s docblock said:

  "Both refs are assigned during commit, before any passive effect runs and
   before any postMessage can be dispatched, so neither is null on any path that
   reaches this function."

That is wrong, and the file's OWN comments contain the mechanism that makes it
wrong. Confirmed from the code independently of the instrumentation that found
it (branch counter, hit count 1):

  1. the re-clamp effect's deps are the manifest min/max heights — `status` is
     deliberately absent (the `readGateStatus` note explains why), so its window
     `resize` listener survives a status change;
  2. `BLOCK_ERROR {fatal:true}` sets status 'fatal' (`setStatus` at :1790 admits
     'loading' OR 'ready'), `hostRenderDecision` returns 'collapse', the
     component `return null`s at :2826 — unmounting the frame Box and the
     iframe, so React nulls BOTH refs while the component stays mounted and the
     listener stays registered;
  3. `reportedHeightRef.current` still holds the last stated height, so the
     `reported === null` early-out does not fire;
  4. the next viewport change calls `frameOverheadPx(null, null)`.

No wrong output today: 'fatal' is terminal and the host renders null, so the
recomputed height is unobservable. The harm was what the sentence licensed —
a maintainer acting on it writes `frame!.offsetHeight` and ships a TypeError out
of a resize listener for every viewer who rotates after any block reports a
fatal error.

The TYPE-NARROWING label is KEPT — the branch is genuinely inert — and the
reachability claim is replaced with the path above, plus why deleting the check
is unsafe anyway.

The same paragraph also called the pre-layout zero-boxes case "the reachable
case". Instrumentation never reached it, and every caller runs after the block
has stated a height, which implies a laid-out iframe. Both halves were inverted.
It is now recorded as NOT ESTABLISHED either way rather than asserted — the
honest state, since I could neither reach it nor prove it unreachable.

THE SWEEP (a condition of stopping, not polish). Every reachability / invariant
/ "cannot happen" claim in this file, checked against the code:

  React-lifecycle class — the shape that failed here — 10 claims, 8 hold:
    - statusRef assigned in the render body, not an effect (holds: the only
      writer is `statusRef.current = status` at module render scope)
    - its three concurrent-rendering bullets (hold: idempotent, converges,
      written only from component state)
    - readGateStatus "the window cannot exist if the listener never needs
      replacing" (holds: four gated effects carry "`status` deliberately absent")
    - H-11 "`status` can never become 'ready' again from a terminal state"
      (holds: ALL FIVE `setStatus` writers are `current`-guarded; exactly one
      writes 'ready', and only from 'loading')
    - notifyReady "cannot revive `status`" / "cannot weaken H-11" (holds, same
      enumeration)
    - readyTransitionAppliedRef "an identity change of applyHeight would re-run
      the effect" (holds: `applyHeight` IS in that effect's dep array)
    - the re-clamp's own `reported === null` inert-on-every-reachable-input
      (holds — and note it is consistent with the correction above: the ref is
      NON-null after a collapse, which is precisely what routes execution into
      the branch this commit fixes)
    - reportedHeightRef "could only ever ratchet downward" (holds; mutant C
      demonstrated exactly that failure empirically)
    - the two corrected above.

  Derivation / single-source class — 4 claims, all hold:
    clampBlockHeight "cannot drift apart" (both call sites call it);
    expectedOrigin derived from the BASE src, not the fragmented one;
    opaqueOrigin derived from the same sandbox string; getRecentlyOpenedApps
    SSR-safety (verified: `if (!isClient()) return []`).

  SDK / third-party contract class — 7 claims, NOT verified here and said so
  rather than counted as swept: the requestId-correlation claims, the two
  "a drop cannot hang the block", "an error reply is never dropped", and the
  Mantine-unmount "it could never appear". Each names its mechanism and the SDK
  version it is about, but settling them needs blocks-react and Mantine, not
  this repo.

  21 claims examined; 12 verified true, 2 corrected (both mine, both in the one
  paragraph), 7 out of scope to settle from here.

NOT FIXED, deliberately, and recorded on the PR so they read as open rather than
absent: `expectedBudget()` sums fractional `getComputedStyle` readings against an
integer applied height, and the suite goes red if the frame gains a third child.
Both fail RED rather than falsely green, so they are in the safe direction.
This commit is contained in:
Zachary Lowden
2026-09-03 18:26:10 -05:00
committed by GitHub
parent f7d558ce8b
commit 0dbe0a6bfe
3 changed files with 907 additions and 8 deletions
+211 -7
View File
@@ -108,6 +108,149 @@ const TOKEN_WAIT_TIMEOUT_MS = 15_000;
// otherwise OOM the tab. 8000px is well above any legitimate block.
const HARD_HEIGHT_CEILING = 8_000;
/**
* The viewer's viewport height in CSS pixels, or `null` when there is nothing
* usable to measure — no `window` (SSR / a prerender pass), or an `innerHeight`
* that is not a positive finite number.
*
* 🔴 `null` means "DO NOT CLAMP", never "clamp to zero". A failed measurement
* must degrade to the pre-existing three-layer behaviour: collapsing a block to
* a 0px iframe because we could not read the viewport is a worse outcome than
* the over-tall iframe the clamp exists to prevent.
*
* `window.innerHeight` deliberately, not `visualViewport.height` — nothing else
* in `src/` reads `visualViewport`, and the pinch-zoom/keyboard-inset precision
* it would add buys nothing for a bound whose whole job is "roughly one screen".
*/
function viewportHeightPx(): number | null {
if (typeof window === 'undefined') return null;
const h = window.innerHeight;
return typeof h === 'number' && Number.isFinite(h) && h > 0 ? h : null;
}
/**
* Everything inside the host frame that is NOT the iframe — the `AppBlockChrome`
* bar, plus the frame's own borders — measured live rather than assumed.
*
* 🔴 THE IFRAME IS NOT THE WIDGET. `framed()` renders the chrome ABOVE the
* iframe inside one bordered box, so a viewport-sized iframe produces a
* `viewport + chrome + borders` widget. Measured at 390x640 with the real
* cascade loaded: chrome 31 (matching the `CHROME_BAR_PX` sibling's pin of
* 22 + 8 + 1) and 1px on each frame border, so the overhead is 33 — a 673px
* widget on a 640px screen for a block reporting 640, i.e. layer 4 bounding
* exactly the wrong box. The clamp's budget is therefore `viewport - overhead`.
*
* MEASURED, NEVER HARDCODED — and the 33 above is an OBSERVATION, not a
* constant this code may assume. `CHROME_BAR_PX` is a *resting* contract for one
* row at one breakpoint; the real bar wraps, changes with theme and Mantine
* sizing, and has already gone stale once in this arc. Reading
* `frame.offsetHeight - iframe.offsetHeight` is invariant to whatever height the
* iframe currently has, so it measures the overhead itself — borders included —
* and it stays correct if the frame ever gains another sibling.
*
* Returns 0 (i.e. no overhead, plain viewport clamp) whenever the difference is
* not a usable positive number. Same degradation rule as `viewportHeightPx`: a
* failed measurement must never make the budget SMALLER than the honest
* fallback. (Whether a pre-layout read — both boxes still 0 — is reachable is
* NOT established either way here: every caller runs after the block has stated
* a height, which implies a laid-out iframe. Instrumentation never reached it.
* Stated as unknown rather than asserted in either direction.)
*
* 🔴 THE `!frame || !iframe` LINE IS REACHABLE, AND IT IS LOAD-BEARING. It is a
* TYPE NARROWING in the sense that the branch is behaviourally inert — but do
* NOT read that as "dead code" and replace it with `frame!.offsetHeight`. The
* path, measured by instrumenting the branch and driving it (hit count 1):
*
* 1. the re-clamp effect's deps are the manifest min/max heights — `status` is
* deliberately NOT among them (see `readGateStatus`), so its window
* `resize` listener SURVIVES a status change;
* 2. a `BLOCK_ERROR {fatal:true}` sets status 'fatal', `hostRenderDecision`
* returns 'collapse', and the component `return null`s — unmounting the
* frame Box and the iframe, so React nulls BOTH refs while the component
* itself stays mounted and the listener stays registered;
* 3. `reportedHeightRef.current` still holds the last stated height, so the
* `reported === null` early-out below does NOT fire;
* 4. the next viewport change calls this function with (null, null).
*
* It produces no wrong output today only because 'fatal' is terminal and the
* host renders null, so the recomputed height is unobservable. Delete the check
* and that same path throws a TypeError out of a `resize` listener for every
* viewer who rotates after any block reported a fatal error.
*
* (An earlier revision of this comment asserted the opposite — "neither is null
* on any path that reaches this function", reasoning from commit ordering. The
* reasoning was wrong in exactly the way the deps array above makes possible,
* and it is recorded here because a false safety comment is what licenses
* deleting the guard it describes.)
*
* KNOWN LIMIT, stated rather than implied: this is re-measured when the clamp
* RUNS — on a RESIZE_IFRAME and on a window `resize`. A chrome bar that changes
* height with no viewport change (a menu opening, a late font swap) does not
* itself re-trigger the clamp, so the widget can be off by that delta until the
* next event. Closing that would need a ResizeObserver on the chrome, which is
* not warranted for a bound whose job is "roughly one screen".
*/
function frameOverheadPx(frame: HTMLElement | null, iframe: HTMLElement | null): number {
if (!frame || !iframe) return 0;
const overhead = frame.offsetHeight - iframe.offsetHeight;
return Number.isFinite(overhead) && overhead > 0 ? overhead : 0;
}
/**
* The height layers 24, as one pure function of a height the block has already
* stated, the manifest's declared bounds, and the measured frame overhead. Layer
* 1 (the `isFinite`/positive value guard) stays at the call site, because it
* decides whether there is a stated height at all.
*
* Kept out of the component so the RESIZE_IFRAME path and the viewport-change
* re-clamp cannot drift apart — they are the same four rules applied to the same
* stashed number, differing only in what triggered them.
*
* 🔴 WHAT LAYER 4 DOES AND DOES NOT GUARANTEE. It bounds every height the BLOCK
* can state: whatever a block reports over RESIZE_IFRAME, the framed widget ends
* up no taller than the viewport. It does NOT bound the PUBLISHER's declared
* `iframe.minHeight`, which deliberately still wins — `Math.max(min, budget)`,
* not a bare `budget`, so the manifest's own reserve is not silently undone and
* a short/failed block keeps the space it asked for.
*
* 🔴 THAT FLOOR IS UNBOUNDED BY ANYTHING HERE, AND IS A REAL RESIDUE, NOT A
* THEORETICAL ONE. `HEIGHT_MAX_CEILING` in
* `src/server/services/block-manifest-validator.service.ts` lets a manifest
* declare `minHeight` up to 4000, and at that value a single schema-legal field
* reproduces this defect in full — a 4000px slot on a 640px screen, measured,
* with this clamp present. Even without an extreme value: measured against the
* complete approved population (11 of 11 blocks) the declared floors are
* 400 x1, 600 x5, 640 x3, 700 x2, so at a 640px viewport — where the budget
* after 33px of overhead is 607 — the 640-tier (x3) and 700-tier (x2) are bound
* by their OWN floor and overflow by 33px and 93px. That is 5 of 11; the 400-
* and 600-tiers fit. At an 844px viewport (budget 811) all 11 fit.
*
* Capping `minHeight` at the validator is a manifest-CONTRACT change with
* byte-mirrors outside this repo, so it is deliberately NOT bundled with this
* host-side fix; it is tracked on its own branch. And note that a cap at 800
* would not close the residue either — 640 and 700 are modest values, well
* under any plausible cap, that still exceed the 607px budget. Shrinking it is
* a per-publisher change or a change to which of floor/viewport wins, not a
* constant.
*/
function clampBlockHeight(
h: number,
min: number,
max: number | null | undefined,
overhead: number
): number {
let next = Math.max(h, min);
if (typeof max === 'number') next = Math.min(next, max);
next = Math.min(next, HARD_HEIGHT_CEILING);
const viewport = viewportHeightPx();
// Layer 4. The budget is the viewport MINUS the chrome the host renders above
// the iframe, so it is the whole widget that fits the screen rather than the
// iframe alone. `Math.max(min, …)` for the publisher-floor reason in the
// docblock above.
if (viewport !== null) next = Math.min(next, Math.max(min, viewport - overhead));
return next;
}
// Max "Recently run" entries shown in the app-chrome platform-nav dropdown.
// Kept short so the compact menu doesn't grow unbounded (the store itself caps
// at MAX_RECENTS PER KIND; this is the additional display cap after excluding
@@ -146,7 +289,9 @@ function storageErrorMessage(err: unknown): string {
* (`if (!this.initResolved)`). See iframeInitController.ts.
* 2. Wait for BLOCK_READY (≤10s). Timeout shows BlockFallback("timeout").
* 3. BLOCK_ERROR with `fatal: true` shows BlockFallback("fatal_block_error").
* 4. RESIZE_IFRAME updates the iframe height, clamped to manifest bounds.
* 4. RESIZE_IFRAME updates the iframe height, clamped to manifest bounds and
* sized so the FRAMED WIDGET (chrome + iframe) fits the viewer's viewport
* (see `clampBlockHeight`).
* 5. Page-visibility change drives SUSPEND / RESUME.
* 6. Token rotation triggers TOKEN_REFRESH (host-pushed) with the new
* wrapped token. A block-initiated REQUEST_TOKEN is answered CONDITIONALLY:
@@ -951,6 +1096,10 @@ export function IframeHost({
// `getServerSideProps` conjunction, not just the pages flag.
const features = useFeatureFlags();
const iframeRef = useRef<HTMLIFrameElement | null>(null);
// The host trust frame (`framed()` below) — the box the VIEWER sees, which is
// the chrome bar plus the iframe. Needed so layer 4 of the height clamp can
// measure its own overhead rather than assume it; see `frameOverheadPx`.
const frameRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>('loading');
// Mirror of `status`, read by the four status-gated message handlers
// (RESIZE_IFRAME, REQUEST_SIGN_IN, REQUEST_CONSENT, OPEN_BUZZ_PURCHASE) via
@@ -1126,28 +1275,81 @@ export function IframeHost({
const { send, onMessage } = usePostMessage({ iframeRef, expectedOrigin, opaqueOrigin });
// The last height the BLOCK ITSELF stated, before any clamping — stashed so a
// viewport change can re-run the clamp against the new bound. The block is
// never asked to re-measure (RESIZE_IFRAME is one-way, block → host), so
// without this the host would have nothing but its OWN already-clamped value
// and could only ever ratchet downward: a block that stated 3000 at a 640px
// viewport would stay pinned at 640 after the viewer rotated to a 900px one.
const reportedHeightRef = useRef<number | null>(null);
// applyHeight is wrapped so the postMessage subscribers keep a stable
// reference even though install.manifest is stable across renders.
//
// Three layers of height defense:
// Four layers of height defense:
// 1. isFinite + positive guard — rejects NaN, Infinity, negatives.
// 2. manifest.maxHeight (publisher's stated ceiling), if set.
// 3. HARD_HEIGHT_CEILING — independent backstop in case maxHeight is
// null (allowed by the manifest validator) and the block sends a
// huge number. This is the OOM guard.
// 4. The viewer's viewport height, MINUS the host chrome rendered above the
// iframe — the layer that binds the COMMON case, and it bounds the framed
// WIDGET rather than the iframe alone. `iframe.maxHeight` is
// `["integer","null"]` in the manifest schema and `iframe` requires no
// fields at all, so a manifest that simply omits it is bounded only by
// layer 3: a block self-reporting 3000px got a 3000px iframe inside a
// ~640px phone viewport. The block scrolls internally instead, which is
// the intended outcome. Both the viewport AND the chrome overhead are
// read at CALL time, never captured at mount — either value stashed at
// mount is stale after a rotate, a browser-chrome resize, or a chrome bar
// that re-wraps. What it does NOT bound is the publisher's `minHeight`;
// see `clampBlockHeight`.
const applyHeight = useCallback(
(h: unknown) => {
if (typeof h !== 'number' || !Number.isFinite(h) || h <= 0) return;
const min = install.manifest.iframe?.minHeight ?? 200;
const max = install.manifest.iframe?.maxHeight;
let next = Math.max(h, min);
if (typeof max === 'number') next = Math.min(next, max);
next = Math.min(next, HARD_HEIGHT_CEILING);
setIframeHeight(next);
reportedHeightRef.current = h;
setIframeHeight(
clampBlockHeight(h, min, max, frameOverheadPx(frameRef.current, iframeRef.current))
);
},
[install.manifest.iframe?.minHeight, install.manifest.iframe?.maxHeight]
);
// Layer 4 is only a bound if it MOVES with the viewport. A block that reported
// 3000px while the viewport was 900px tall must shrink when the viewer rotates
// to a 640px one — otherwise the clamp is a one-shot decided by whatever the
// viewport happened to be at handshake time.
//
// Host-side only: nothing is posted back to the block. The re-clamp reads the
// block's own last stated height out of the ref and re-applies the same four
// rules, so a viewport change can shrink AND re-grow within the bounds the
// block already asked for. The chrome overhead is re-measured on each event
// too, so a bar that re-wraps at the new width is accounted for.
useEffect(() => {
if (typeof window === 'undefined') return;
const min = install.manifest.iframe?.minHeight ?? 200;
const max = install.manifest.iframe?.maxHeight;
const onViewportChange = () => {
const reported = reportedHeightRef.current;
// 🔴 A TYPE NARROWING, NOT A COVERED BRANCH — labelled so nobody reads it
// as a guard that is tested. It is behaviourally INERT on every reachable
// input: the ref is null only pre-handshake, when `iframeHeight` is already
// `min`, and the clamp of any value against `Math.max(min, …)` returns
// `min` there anyway. It exists because `reportedHeightRef.current` is
// `number | null` and `clampBlockHeight` takes a `number`. The suite's
// pre-handshake case is an INVARIANT guard on that equivalence, not
// regression coverage for this line.
if (reported === null) return;
setIframeHeight(
clampBlockHeight(reported, min, max, frameOverheadPx(frameRef.current, iframeRef.current))
);
};
window.addEventListener('resize', onViewportChange);
return () => window.removeEventListener('resize', onViewportChange);
}, [install.manifest.iframe?.minHeight, install.manifest.iframe?.maxHeight]);
// A6 lazy consent: the scopes ACTUALLY carried by the minted token — the
// manifest scopes minus the consent-gated ones the viewer hasn't granted yet
// (`missingScopes`, reported by the mint). The server signs exactly this set
@@ -1474,7 +1676,8 @@ export function IframeHost({
// Validate the shape — payload comes from cross-origin iframe code and
// is functionally untyped. Reject anything that isn't {height?:number}.
// (`applyHeight` is the value guard: it drops anything non-finite/≤0 and
// clamps to manifest min/max + HARD_HEIGHT_CEILING.)
// clamps to manifest min/max + HARD_HEIGHT_CEILING + the viewport
// budget left over after the host chrome.)
const payload =
raw && typeof raw === 'object' && 'height' in raw ? (raw as { height?: unknown }) : {};
// Record the offered height for the ready-transition effect below to apply
@@ -2609,6 +2812,7 @@ export function IframeHost({
// a visible frame on failure.
const framed = (children: ReactNode) => (
<Box
ref={frameRef}
data-testid="app-block-frame"
data-block-instance-id={install.blockInstanceId}
style={{
@@ -513,11 +513,35 @@ describe('IframeHost ready transition — untrusted height payload', () => {
}
);
/**
* 🔴 THE VIEWPORT IS SET EXPLICITLY, AND IT HAS TO BE. This case is about
* LAYER 2 (the manifest's `maxHeight`), so every other height layer must be
* slack — otherwise it silently becomes a test of whichever layer happens to
* bind first.
*
* It used to rely on the harness default (414x896) and broke the day
* `IframeHost` gained layer 4, the viewport clamp: the budget at 896 is
* `896 - 98` of host chrome = 798, which is TIGHTER than this fixture's
* `MAX_HEIGHT` of 800, so the applied height was 798 and the assertion read
* `expected 798 to be 800`. The clamp was right and the test's premise was
* stale by 2px — a coincidence of the default viewport, not a real
* disagreement.
*
* 1200 leaves a budget of ~1102, well clear of 800, so `maxHeight` is
* unambiguously the binding layer again. Layer 4 has its own suite
* (`IframeHostViewportHeightClamp.browser.test.tsx`).
*/
test('an over-ceiling height is clamped to the manifest maxHeight', async () => {
await page.viewport(414, 1200);
renderWithProviders(<IframeHost {...baseProps} />);
await driveToReady({ height: 999_999 });
await vi.waitFor(() => {
expect(appliedHeight()).toBe(MAX_HEIGHT);
expect(
appliedHeight(),
`layer 2 did not bind: with a ${1200}px viewport the chrome-adjusted budget is far above ` +
`the manifest maxHeight of ${MAX_HEIGHT}, so ${MAX_HEIGHT} is what must be applied, not ` +
`${appliedHeight()}`
).toBe(MAX_HEIGHT);
});
});
@@ -0,0 +1,671 @@
// 🔴 THIS FILE ASSERTS PIXELS, SO IT LOADS THE REAL CASCADE — see the header note
// "WHY THIS FILE LOADS THE STYLESHEET" below before removing this line.
import '@mantine/core/styles.css';
import { describe, expect, test, vi } from 'vitest';
import { page } from 'vitest/browser';
// `test/` lives outside `src`, so the `~` alias doesn't reach it — relative import.
import { renderWithProviders } from '../../../test/component-setup';
// Type-only namespace import for the `importOriginal` spread below (the repo's
// local-rules/no-wholesale-module-mock cure). NOT `typeof import(...)`, which
// @typescript-eslint/consistent-type-imports rejects.
import type * as TrpcMod from '~/utils/trpc';
/**
* IframeHost — layer 4 of the height defense: the VIEWPORT clamp
* (`model.sidebar_top`, the inline model-page slot).
*
* THE GAP. `applyHeight` had three layers: the `isFinite`/positive value guard,
* `manifest.iframe.maxHeight`, and `HARD_HEIGHT_CEILING` (8000). But
* `public/schemas/app-block/v1.json` types `iframe.maxHeight` as
* `["integer","null"]` and `iframe` declares NO required fields, so a manifest
* that simply OMITS `maxHeight` is bounded only at 8000px. A block
* self-reporting 3000px therefore got a 3000px-tall iframe inside a ~640px
* phone viewport — the slot swallowed the page. Layer 4 bounds the block's
* stated height to `Math.max(minHeight, viewport overhead)`, where the
* overhead is the host chrome plus the frame's own borders; the block scrolls
* internally instead, which is the intended outcome.
*
* 🔴 WHAT LAYER 4 DOES NOT BOUND, SO NO ONE READS THESE CASES AS WIDER THAN THEY
* ARE: the publisher's own `iframe.minHeight`. `Math.max(min, …)` means the
* manifest floor always wins, and nothing in this change bounds it: the
* validator (`HEIGHT_MAX_CEILING`,
* `src/server/services/block-manifest-validator.service.ts`) permits `minHeight`
* up to 4000, at which one schema-legal field reproduces this defect in full —
* measured, a 4000px slot on a 640px screen with the clamp present. Even at
* modest values it bites: over the complete approved population (11 of 11) the
* floors are 400 x1 / 600 x5 / 640 x3 / 700 x2, and at a 640px viewport the
* budget is 640 - 33 = 607, so the 640-tier (x3) and the 700-tier (x2) are bound
* by their OWN floor and overflow by 33px and 93px — 5 of 11. The 400- and
* 600-tiers fit. Capping the floor is a manifest-CONTRACT change with
* byte-mirrors outside this repo and is tracked separately; it would not close
* that residue anyway. Every case below fixes a modest `minHeight` and varies
* what the BLOCK states, which is the surface layer 4 actually governs.
*
* 🔴 ASSERT THE FRAME, NOT THE IFRAME. `framed()` renders AppBlockChrome above
* the iframe inside one bordered box, so a viewport-sized IFRAME is a
* `viewport + chrome + borders` WIDGET. Measured at 390x640 with the stylesheet
* loaded: chrome 31, frame borders 1px each, so the overhead is 33 and a
* pre-fix 3000px report gives a 3033px widget. An iframe-only assertion passes
* that straight through, which is exactly how the first version of this suite
* did.
*
* 🔴 WHY THIS FILE LOADS THE STYLESHEET, AND WHY THAT IS NOT A CONTRADICTION OF
* THE SIBLING'S RULE. `AppBlockChromeResponsive.browser.test.tsx` says its
* siblings MUST NOT import `@mantine/core/styles.css` — and that rule is scoped
* to suites asserting ATTRIBUTES AND ARIA, which is what the shared scaffold is
* built for. This suite asserts PIXELS, so it is the same exception that file
* takes for itself, for the same stated reason: "without it each computes to
* something meaningless while the assertions still pass". Browser mode runs each
* file in its own iframe, so the import cannot leak sideways.
*
* 🔴 IT IS NOT COSMETIC — MEASURED. Without the stylesheet this suite reported a
* chrome overhead of 98 and a frame border of ZERO (the `Box`'s
* `border: 1px solid var(--mantine-color-default-border)` is
* invalid-at-computed-value-time with no cascade), and every assertion still
* passed because they are all self-relative. The published residue table was
* computed from that 98 and was wrong in a way that mattered: it claimed the
* 600-tier — FIVE of the eleven live blocks — does not fit, when at the real
* budget of 607 it does. `renderReady` now asserts the sheet is loaded, so this
* file cannot silently slide back into the meaningless harness.
*
* The longer-term home for pixel assertions is the `geometry` vitest project
* added by #4601 (`.geometry.test.tsx` + `test/geometry-setup.tsx`), which loads
* the real cascade by construction. This file stays in `component` because it is
* mostly a postMessage/handshake suite that happens to assert geometry, and it
* needs the component scaffold's mocks; the single import buys the same
* correctness here.
*
* 🔴 THE HARNESS WILL MAKE THIS FILE PASS VACUOUSLY IF YOU LET IT. Vitest's
* browser default viewport is 414x896 (measured: `resolved.browser.viewport
* .height ??= 896` in vitest's config resolution), and `test/component-setup
* .tsx` sets none. At 896px tall, every height any neighbouring IframeHost suite
* asserts (640, 700, 800) is already UNDER the bound, so the clamp never fires
* and a test written without `page.viewport(...)` cannot tell layer 4 from its
* absence. Every test here goes through `setViewport`, which calls
* `page.viewport(...)` and then re-reads `window.innerHeight` so a viewport that
* silently did not take is a failure rather than a green.
*
* 🔴 THIS SUITE IS THE WHOLE COVERAGE, AND DELIBERATELY SO. `IframeHost` is the
* only `RESIZE_IFRAME` consumer in `src/`; the full-page sibling
* `PageBlockHost` (`/apps/run/<slug>`) has no RESIZE_IFRAME handling at all and
* is already viewport-bound by its own `calc(100dvh - HEADER_HEIGHT_PX)`. So
* layer 4 is an inline-slot property, not a shared-surface one, and there is no
* parity file to mirror.
*
* Mocks mirror `IframeHostThemeChange.browser.test.tsx` (the model-slot
* scaffold): the two tRPC queries IframeHost drives at render must report
* `isLoading: false` so the init handshake is allowed to start.
*/
vi.mock('~/hooks/useCurrentUser', () => ({ useCurrentUser: () => null }));
// 🔴 `useOptionalFeatureFlags` IS LISTED TOO, AND OMITTING IT BREAKS THE WHOLE FILE.
// This factory REPLACES the module, so it must name every export anything in this
// file's module graph imports. The app-block chrome's breadcrumb crumb reads
// `useOptionalFeatureFlags` for its store gate (the non-throwing variant, because
// the chrome renders outside a provider); a factory naming only `useFeatureFlags`
// fails the LINK, not a test — nothing is collected and the run reports failing
// FILES with zero failing assertions. Read the file count, not the test count.
vi.mock('~/providers/FeatureFlagsProvider', () => ({
useFeatureFlags: () => ({ appBlocks: false, appBlocksPages: false }),
useOptionalFeatureFlags: () => ({ appBlocks: false, appBlocksPages: false }),
}));
vi.mock('~/utils/trpc', async (importOriginal) => ({
...(await importOriginal<typeof TrpcMod>()),
trpc: {
blocks: {
getEffectiveCheckpoint: {
useQuery: () => ({ data: { checkpoint: null }, isLoading: false }),
},
getShowcaseImages: {
useQuery: () => ({ data: [], isLoading: false }),
},
submitWorkflow: { useMutation: () => ({ mutateAsync: vi.fn() }) },
estimateWorkflow: { useMutation: () => ({ mutateAsync: vi.fn() }) },
pollWorkflow: { useMutation: () => ({ mutateAsync: vi.fn() }) },
cancelWorkflow: { useMutation: () => ({ mutateAsync: vi.fn() }) },
updateUserSettings: { useMutation: () => ({ mutateAsync: vi.fn() }) },
getMyBuzzBalance: { useMutation: () => ({ mutateAsync: vi.fn() }) },
},
apps: {
shared: {
append: { useMutation: () => ({ mutateAsync: vi.fn() }) },
update: { useMutation: () => ({ mutateAsync: vi.fn() }) },
vote: { useMutation: () => ({ mutateAsync: vi.fn() }) },
unvote: { useMutation: () => ({ mutateAsync: vi.fn() }) },
withdraw: { useMutation: () => ({ mutateAsync: vi.fn() }) },
report: { useMutation: () => ({ mutateAsync: vi.fn() }) },
},
storage: {
set: { useMutation: () => ({ mutateAsync: vi.fn() }) },
delete: { useMutation: () => ({ mutateAsync: vi.fn() }) },
},
},
useUtils: () => ({
apps: {
shared: {
list: { fetch: vi.fn() },
getCount: { fetch: vi.fn() },
getCounts: { fetch: vi.fn() },
get: { fetch: vi.fn() },
},
storage: {
get: { fetch: vi.fn() },
list: { fetch: vi.fn() },
getQuota: { fetch: vi.fn() },
},
},
}),
},
}));
vi.mock('~/components/BrowsingLevel/BrowsingLevelProvider', () => ({
useBrowsingLevelDebounced: () => 1,
}));
// eslint-disable-next-line import/first
import { IframeHost } from '~/components/AppBlocks/IframeHost';
// eslint-disable-next-line import/first
import type { BlockInstall, ModelSlotContext } from '~/components/AppBlocks/types';
const SAME_ORIGIN_SRC = `${window.location.origin}/`;
/**
* Fixture geometry. Every number is distinct from every other AND from every
* constant the assertions or the SOURCE name, so no assertion can be satisfied
* by a collision:
*
* PHONE_H 640 — the bound under test.
* TALL_H 900 — a second viewport, for the re-clamp on resize.
* REPORT 3000 — what the block claims. Well OVER 640 (so the clamp is
* demonstrably what acts) and well UNDER HARD_HEIGHT_CEILING
* (8000), so layer 3 cannot be what produces a pass.
* SHORT 400 — under both viewports, so the clamp must NOT fire.
* MIN_H 160 — the manifest floor.
* `maxHeight` is ABSENT: that omission is the gap this file exists for.
*
* 🔴 MIN_H IS 160 AND NOT 200 FOR A MEASURED REASON. The source defaults the
* floor with `install.manifest.iframe?.minHeight ?? 200`, so a fixture floor of
* 200 is numerically identical to the source's own literal — and a mutant that
* replaces the `min` argument with a hardcoded `200` then SURVIVES the whole
* file (6 passed). 160 makes the fixture value and the source literal disagree,
* which is the only thing that can see that mutant.
*/
const PHONE: [number, number] = [390, 640];
const PHONE_H = 640;
const TALL: [number, number] = [390, 900];
const TALL_H = 900;
const REPORT = 3000;
const SHORT = 400;
const MIN_H = 160;
function makeInstall(iframeOverrides: Record<string, unknown> = {}): BlockInstall {
return {
blockInstanceId: 'inst_test',
blockId: 'my-model-app',
appId: 'app_test',
appBlockId: 'apb_test',
manifest: {
name: 'Background Remover',
scopes: ['ai:write:budgeted'],
iframe: {
src: SAME_ORIGIN_SRC,
minHeight: MIN_H,
// NO maxHeight — the manifest shape the clamp exists for.
resizable: true,
sandbox: 'allow-scripts',
...iframeOverrides,
},
},
publisherSettings: {},
enabled: true,
renderMode: 'iframe',
trustTier: 'internal',
} as BlockInstall;
}
const context: ModelSlotContext = {
slotId: 'model.sidebar_top',
entityType: 'model',
modelId: 123,
modelVersionId: 456,
modelName: 'Some Model',
modelType: 'Checkpoint',
modelNsfwLevel: 1,
creatorUserId: 7,
viewerUserId: 42,
viewerNsfwEnabled: false,
viewerUsername: 'tester',
theme: 'light',
};
const iframeEl = () => page.getByTestId('block-iframe').element() as HTMLIFrameElement;
const iframeQuery = () => page.getByTestId('block-iframe').query() as HTMLIFrameElement | null;
const frameEl = () => page.getByTestId('app-block-frame').element() as HTMLElement;
const chromeEl = () => page.getByTestId('app-block-chrome').element() as HTMLElement;
/** The height the host has actually written onto the iframe element. */
function appliedHeight(): number {
return Number.parseFloat(iframeEl().style.height);
}
/**
* 🔴 THE OBSERVABLE THAT MATTERS — the LAID-OUT height of the whole widget the
* viewer sees, chrome bar included.
*
* `appliedHeight()` above reads the iframe alone, and an iframe-only assertion
* is exactly how the first version of this suite passed while the widget still
* overflowed the screen: at 390x640 the iframe was a correct 640 and the frame
* was 673. Anything claiming "fits the viewport" must assert THIS number.
*/
function frameHeight(): number {
return frameEl().getBoundingClientRect().height;
}
/** The host chrome's own laid-out height — measured, never assumed (31 at 390px). */
function chromeHeight(): number {
return chromeEl().getBoundingClientRect().height;
}
/**
* The frame's own top+bottom border, measured from the cascade (1px each).
*
* 🔴 NOT DECORATION — it is 2 of the 33px the clamp has to subtract, and leaving
* it out is how the budget assertions below would be wrong by exactly that much.
* It is also the half that is INVISIBLE without `@mantine/core/styles.css`:
* `border: 1px solid var(--mantine-color-default-border)` is
* invalid-at-computed-value-time with no cascade, so it computes to 0 and the
* omission hides itself.
*
* Read from `getComputedStyle` rather than as `frameHeight() - appliedHeight()`
* on purpose: that subtraction is the implementation's OWN arithmetic, so an
* expectation built from it would be vacuously true. Chrome height plus border
* width are independent observables.
*/
function frameBorderPx(): number {
const cs = getComputedStyle(frameEl());
return Number.parseFloat(cs.borderTopWidth) + Number.parseFloat(cs.borderBottomWidth);
}
/** The height budget layer 4 should leave the iframe, derived independently. */
function expectedBudget(viewportHeight: number): number {
return viewportHeight - chromeHeight() - frameBorderPx();
}
function postFromBlock(type: string, payload?: unknown) {
const cw = iframeEl().contentWindow;
if (!cw) throw new Error('iframe contentWindow missing');
window.dispatchEvent(
new MessageEvent('message', {
data: { type, payload },
origin: window.location.origin,
source: cw,
})
);
}
async function waitForMount() {
await vi.waitFor(() => {
const el = iframeQuery();
if (!el?.contentWindow) throw new Error('not mounted yet');
});
}
async function driveToReady(payload: unknown = {}) {
await waitForMount();
await vi.waitFor(() => {
postFromBlock('BLOCK_READY', payload);
if (iframeEl().getAttribute('data-block-ready') !== 'true') throw new Error('not ready yet');
});
}
/**
* 🔴 THE CONTROL THAT STOPS THIS FILE PASSING VACUOUSLY. `page.viewport` is
* asynchronous and resizes the tester iframe rather than the browser window; if
* it silently did not take, every clamp assertion below would be graded against
* the 896px DEFAULT, at which the clamp never fires and layer 4 could be deleted
* with the file still green. So the value the component will read is asserted
* directly, in the same units, before anything renders.
*/
async function setViewport([w, h]: [number, number]) {
await page.viewport(w, h);
expect(
window.innerHeight,
`page.viewport did not take: the component reads window.innerHeight, which is ` +
`${window.innerHeight}, not the ${h} this test set`
).toBe(h);
}
/** Render at a viewport and hand the block ready. */
async function renderReady(
viewport: [number, number],
iframeOverrides: Record<string, unknown> = {}
) {
await setViewport(viewport);
const rendered = renderWithProviders(
<IframeHost
install={makeInstall(iframeOverrides)}
context={context}
token="tok_abc"
expiresAt={new Date(Date.now() + 15 * 60_000).toISOString()}
/>
);
await driveToReady();
// 🔴 THE GUARD THAT KEEPS EVERY PIXEL BELOW MEANINGFUL. Same predicate the
// styled sibling uses: the chrome's `Group` only computes to `display: flex`
// once `@mantine/core/styles.css` is in the cascade. Without it this suite
// still passes — every assertion is self-relative — while measuring a chrome
// of 98 and a frame border of 0, which is exactly how a wrong residue table
// got published. If the import at the top of this file is ever dropped, this
// fails instead of quietly going meaningless.
expect(
getComputedStyle(chromeEl()).display,
'@mantine/core/styles.css is not loaded: the chrome computes to ' +
`"${getComputedStyle(chromeEl()).display}" instead of "flex", so every pixel this suite ` +
'measures is a property of the empty cascade rather than of the real component'
).toBe('flex');
expect(
appliedHeight(),
'precondition: the iframe should start at the manifest minHeight, so a later pass ' +
'cannot be "it was already there"'
).toBe(MIN_H);
return rendered;
}
describe('IframeHost height layer 4 — the viewport clamp', () => {
test('a 3000px self-report at a 640px viewport leaves the WHOLE FRAMED WIDGET inside the viewport', async () => {
// 🔴 THE ASSERTION IS ON THE FRAME, NOT THE IFRAME, AND THAT IS THE POINT.
// `framed()` renders AppBlockChrome ABOVE the iframe inside one bordered box,
// so bounding the iframe at the viewport still yields a
// `viewport + chrome + borders` widget. Measured before the fix at 390x640:
// iframe 640 (correct), frame 673 (chrome 31 + 1px top + 1px bottom) — a
// 673px widget on a 640px screen, which an iframe-only assertion passes
// straight through.
await renderReady(PHONE);
postFromBlock('RESIZE_IFRAME', { height: REPORT });
// 🔴 `toBe(PHONE_H)`, NOT `toBeLessThanOrEqual` — measured, the loose form is
// satisfied by the PRE-UPDATE state (the minHeight reserve, 160 + 33 = 193
// ≤ 640), so `vi.waitFor` returns before the resize has been applied and the
// assertions after it grade the wrong frame. Requiring the widget to fill the
// viewport exactly is both the real property and a condition the initial
// state cannot meet.
await vi.waitFor(() =>
expect(
frameHeight(),
`layer 4 did not bound the WIDGET: the block asked for ${REPORT}px inside a ${PHONE_H}px ` +
`viewport and the framed widget is ${frameHeight()}px (iframe ${appliedHeight()}px + ` +
`chrome ${chromeHeight()}px + border ${frameBorderPx()}px). With no manifest ` +
`maxHeight the only other bound is ` +
`HARD_HEIGHT_CEILING (8000), which cannot produce ${PHONE_H}.`
).toBe(PHONE_H)
);
// The iframe therefore takes exactly the budget the overhead leaves. Both
// halves are MEASURED here, never assumed: hardcoding the 33px observed
// above is one theme or breakpoint away from wrong.
expect(
appliedHeight(),
`the iframe should take exactly the viewport budget left by the chrome: viewport ` +
`${PHONE_H} chrome ${chromeHeight()} border ${frameBorderPx()} = ${expectedBudget(
PHONE_H
)}, got ${appliedHeight()}`
).toBe(expectedBudget(PHONE_H));
// Nothing below the widget is pushed off-screen.
expect(
document.documentElement.scrollHeight,
`the document overflows the viewport by ` +
`${document.documentElement.scrollHeight - PHONE_H}px`
).toBeLessThanOrEqual(PHONE_H);
});
test('a 400px self-report at a 640px viewport is honoured unchanged — the clamp is a ceiling, not a pin', async () => {
// 🔴 The negative half, and it is not optional: without it a mutant that
// replaces the clamp with `next = budget` outright satisfies the test above
// and survives. 400 ≠ the budget, so this one sees it.
await renderReady(PHONE);
// Precondition, so this test cannot silently change meaning if the chrome
// ever grows past 240px and the budget drops below SHORT.
expect(
expectedBudget(PHONE_H),
`fixture precondition broken: the ${PHONE_H}px viewport leaves only ` +
`${expectedBudget(PHONE_H)}px after ${chromeHeight()}px of chrome and ` +
`${frameBorderPx()}px of border, which is not more ` +
`than the ${SHORT}px this test reports — the clamp would legitimately fire and this ` +
`case would stop testing what it says it does`
).toBeGreaterThan(SHORT);
postFromBlock('RESIZE_IFRAME', { height: SHORT });
await vi.waitFor(() =>
expect(
appliedHeight(),
`layer 4 over-fired: a ${SHORT}px block already fits a ${PHONE_H}px viewport, so the ` +
`host must apply ${SHORT}px and not ${appliedHeight()}px`
).toBe(SHORT)
);
});
test('the manifest minHeight still wins on a viewport shorter than it', async () => {
// `Math.max(min, budget)`, not a bare `budget`: a clamp that ignored the
// floor would undo the manifest's own reserve and pin the slot at the
// viewport (or, once the chrome is subtracted, below it).
//
// 🔴 This assertion has the shape of a reassuring ZERO — "the height did not
// move off minHeight" — which a RESIZE_IFRAME that was never delivered would
// also produce. What rules that out is the mutation control rather than
// anything visible here: replacing `Math.max(min, budget)` with a bare
// `budget` fails this test reporting the BUDGET, so the message did arrive
// and did drive the clamp.
await renderReady([320, 100]);
postFromBlock('RESIZE_IFRAME', { height: REPORT });
await new Promise((r) => setTimeout(r, 150));
expect(
appliedHeight(),
`the manifest floor lost to the viewport: minHeight is ${MIN_H} and the viewport is 100, ` +
`so the host must apply ${MIN_H}px and not ${appliedHeight()}px`
).toBe(MIN_H);
});
test('a manifest maxHeight tighter than the viewport still wins (layer 2 is not bypassed)', async () => {
await renderReady(PHONE, { maxHeight: 300 });
postFromBlock('RESIZE_IFRAME', { height: REPORT });
await vi.waitFor(() =>
expect(
appliedHeight(),
`layer 2 was bypassed: manifest maxHeight is 300 and the viewport is ${PHONE_H}, so the ` +
`tighter of the two (300) must win, not ${appliedHeight()}px`
).toBe(300)
);
});
});
describe('IframeHost height layer 4 — re-clamp on viewport change', () => {
test('a height negotiated at a phone viewport re-grows when the viewport does, then shrinks again', async () => {
// 🔴 A clamp read ONCE, at handshake time, is not a bound — it is a snapshot.
// The block is never asked to re-measure (RESIZE_IFRAME is one-way,
// block → host), so the host has to keep the block's own STATED height and
// re-apply the rules itself.
//
// 🔴 THE ORDER IS LOAD-BEARING: SHORT VIEWPORT FIRST. Written the other way
// round (negotiate at 900, shrink to 640, grow back to 900) a mutant that
// stashes the CLAMPED height instead of the reported one SURVIVES — measured,
// the whole file stayed green — because the value it stashed at the tall
// viewport (900) is numerically the same as the answer the last assertion
// wants. Negotiating at 640 makes the stashed value 3000 vs 640, and those
// two disagree at every later viewport.
await renderReady(PHONE);
postFromBlock('RESIZE_IFRAME', { height: REPORT });
// Exact, for the reason spelled out in the first test: a `<=` wait is
// satisfied by the pre-update minHeight reserve and would capture `atPhone`
// below as the reserve rather than the clamped height.
await vi.waitFor(() =>
expect(frameHeight(), `the ${PHONE_H}px viewport did not bound a ${REPORT}px report`).toBe(
PHONE_H
)
);
const atPhone = appliedHeight();
// Rotate to the tall viewport. Nothing is re-sent by the block, and the host
// must re-derive from what the block STATED (3000), not from what it applied
// — a host that re-clamped its own clamped value could only ever ratchet
// downward and would stay at the phone-sized height here.
await setViewport(TALL);
await vi.waitFor(() =>
expect(
appliedHeight(),
`the slot did not re-grow when the viewport did: the block stated ${REPORT}px, the ` +
`viewport is now ${TALL_H}px, and the host still applies ${appliedHeight()}px (it was ` +
`${atPhone}px at the ${PHONE_H}px viewport) — it is either not listening for viewport ` +
`changes at all, or re-clamping its own clamped value rather than the block's stated ` +
`height.`
).toBe(expectedBudget(TALL_H))
);
expect(
frameHeight(),
`the re-grown widget overflows the ${TALL_H}px viewport at ${frameHeight()}px`
).toBeLessThanOrEqual(TALL_H);
// …and back down, so the bound is shown to track the viewport in both
// directions rather than only ratcheting one way.
await setViewport(PHONE);
await vi.waitFor(() =>
expect(
appliedHeight(),
`the slot did not shrink on a viewport change: the viewport is now ${PHONE_H}px and the ` +
`host still applies ${appliedHeight()}px.`
).toBe(atPhone)
);
});
test('shrinking the viewport BELOW the manifest floor falls back to the floor, not through it', async () => {
// 🔴 WITHOUT THIS CASE THE RE-CLAMP'S `min` ARGUMENT IS UNTESTED. Measured: a
// mutant hardcoding the source's own default (`clampBlockHeight(reported,
// 200, …)`) in the re-clamp call site SURVIVED the rest of this file — every
// other re-clamp case runs at a viewport where the BUDGET wins, so which
// number is passed as the floor never shows. Only a viewport small enough for
// the floor to win can see it, and only because MIN_H is not 200.
await renderReady(PHONE);
postFromBlock('RESIZE_IFRAME', { height: REPORT });
await vi.waitFor(() =>
expect(
frameHeight(),
`setup for the floor case: the widget should first settle at the ${PHONE_H}px viewport ` +
`before it is shrunk, and it is ${frameHeight()}px`
).toBe(PHONE_H)
);
await setViewport([320, 100]);
await vi.waitFor(() =>
expect(
appliedHeight(),
`the re-clamp used the wrong floor: the manifest declares minHeight ${MIN_H} and the ` +
`viewport (100px) leaves less than that, so the host must fall back to ${MIN_H}px and ` +
`not ${appliedHeight()}px`
).toBe(MIN_H)
);
});
/**
* 🔴 AN INVARIANT GUARD, NOT REGRESSION COVERAGE — labelled so nobody counts it
* as the latter. The line it corresponds to (`if (reported === null) return;`)
* is a TYPE NARROWING and is behaviourally inert on every reachable input:
* pre-handshake the height state is already `min`, and clamping anything
* against `Math.max(min, budget)` at that moment returns `min` too, so
* neutering the early-out changes nothing observable. Deleting the line is not
* possible without a `??` because the ref is `number | null`.
*
* What this case genuinely pins is that equivalence: a viewport change before
* the handshake must not move the slot off its reserve. If someone later makes
* the pre-handshake path do real work, this goes red.
*/
test('INVARIANT: a viewport change before the block has stated any height leaves the slot at minHeight', async () => {
await renderReady(TALL);
await setViewport(PHONE);
await new Promise((r) => setTimeout(r, 150));
expect(
appliedHeight(),
`a viewport change moved the slot before the block stated any height: expected the ` +
`${MIN_H}px minHeight reserve, got ${appliedHeight()}px`
).toBe(MIN_H);
});
/**
* 🔴 THE CLEANUP HAS NO OBSERVABLE OF ITS OWN, SO PIN THE RELATIONSHIP. Deleting
* the effect's `removeEventListener` return survives every behavioural test in
* this file — React does not warn on a setState from an unmounted component, so
* a leaked `resize` listener is completely silent. The honest guard is a ledger:
* capture the identity of every `resize` handler the component ADDS and every
* one it REMOVES, then require the two sets to match after unmount. That fails
* if the cleanup is dropped, if it removes a different function than it added,
* and if a future second listener is added without a matching removal.
*/
test('the resize listener is removed on unmount — no leaked handler', async () => {
const added = new Set<EventListenerOrEventListenerObject>();
const removed = new Set<EventListenerOrEventListenerObject>();
// 🔴 A DIRECT PATCH, NOT `vi.spyOn(...).mockImplementation(...)`. Both
// `addEventListener` and `removeEventListener` are OVERLOADED, and
// `mockImplementation` wants one concrete signature — every spelling of the
// parameters is rejected by `pnpm typecheck` (TS2345, then TS2769) while
// running green locally, which is the worst combination. Patching the two
// methods behind a single cast each is honest about where the unavoidable
// unsoundness is, and both are restored in `finally`.
const origAdd = window.addEventListener;
const origRemove = window.removeEventListener;
window.addEventListener = ((
type: string,
fn: EventListenerOrEventListenerObject,
opts?: boolean | AddEventListenerOptions
) => {
if (type === 'resize' && fn) added.add(fn);
origAdd.call(window, type, fn, opts);
}) as typeof window.addEventListener;
window.removeEventListener = ((
type: string,
fn: EventListenerOrEventListenerObject,
opts?: boolean | EventListenerOptions
) => {
if (type === 'resize' && fn) removed.add(fn);
origRemove.call(window, type, fn, opts);
}) as typeof window.removeEventListener;
try {
const rendered = await renderReady(PHONE);
postFromBlock('RESIZE_IFRAME', { height: REPORT });
await vi.waitFor(() => expect(appliedHeight()).not.toBe(MIN_H));
// POSITIVE CONTROL for the ledger itself: a zero-vs-zero comparison would
// pass with the spies wired to nothing at all.
expect(
added.size,
'the ledger observed no `resize` listener being added at all, so the emptiness of the ' +
'leak set below would prove nothing about the cleanup'
).toBeGreaterThan(0);
await rendered.unmount();
const leaked = [...added].filter((fn) => !removed.has(fn));
expect(
leaked.length,
`${leaked.length} of ${added.size} \`resize\` listener(s) added by the host outlived its ` +
`unmount. The effect's cleanup either does not run, or removes a different function ` +
`than it added.`
).toBe(0);
} finally {
window.addEventListener = origAdd;
window.removeEventListener = origRemove;
}
});
});