Skip to content

fix(iframe-pane): keep a loaded preview visible when an Activity boundary shows it again - #2182

Draft
stipsan wants to merge 1 commit into
mainfrom
cody/iframe-pane-no-fade-on-reveal-8079
Draft

stipsan wants to merge 1 commit into
mainfrom
cody/iframe-pane-no-fade-on-reveal-8079

Conversation

@stipsan

@stipsan stipsan commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Description

With beta.reactActivityMode, returning to a document whose preview pane was open faded the iframe out and back in over ~800ms. The Studio keeps recently used tools mounted behind <Activity>, which detaches refs while a tool is hidden and re-attaches them on reveal; Motion (14.1) treats a re-attached element as a remount on purpose: it jumps the element's values back to initial and replays initial → animate. The iframe's active variant animated opacity with [0, 0, 1] keyframes, so every reveal started it from 0 again, with the preview invisible for the first half of the animation.

The iframe now starts in its animate state (initial={false}): its first state is background, which is exactly what the old initial set, so the first load looks the same, but there are no initial values to jump back to on a remount. The loaded fade-in is expressed as a delayed transition instead of keyframes, so a replay animates from the current opacity to the same opacity and changes nothing. If the iframe finished loading while the tool was hidden, the reveal still fades it in, since Motion animates from the current value.

The transition lives on the active variant itself, so the desktop/mobile sizes, the box shadow and the reloading scale pulse keep Motion's defaults as before (a transition prop on the element would replace them for every value, not only the one it names). A variant transition also replaces the MotionConfig default, so active is a dynamic variant: it receives prefersReducedMotion through custom and leaves the transition out when reduced motion is preferred, which falls back to the {duration: 0} from MotionConfig. IframeInner already reads usePrefersReducedMotion() for that config and passes the boolean down to Frame.

Why not block the replay with <AnimatePresence initial={false}>: Motion applies the block before it applies the current animate values, so a preview that finished loading while hidden would come back invisible.

What to review

  • Iframe.tsx: initial={false} and custom={prefersReducedMotion} on the motion.iframe, and the dynamic active variant (delay: 0.4, duration: 0.4 matches the hold-then-fade of the [0, 0, 1] keyframes, which Motion ran over its 0.8s keyframe default). Frame takes prefersReducedMotion as a prop.
  • package.json / pnpm-lock.yaml: @testing-library/react and jsdom as catalog: devDependencies for the test (the lockfile change is the two importer entries).

Affects sanity-plugin-iframe-pane.

Testing

  • Iframe.test.tsx (jsdom, Studio hooks mocked):
    • a loaded preview's opacity is still 1 after an <Activity> hide/show cycle (on main it is 0: Motion has jumped it back to initial and holds it there for the first 0.4s of the replay);
    • the reload pulse keeps Motion's 0.8s keyframe default: transform is still none 350ms after clicking Reload and reaches scale(0.98) afterwards (with an opacity-only transition prop on the element, which strips the defaults from every other value, it was at scale(0.98) by 300ms);
    • with reduced motion preferred a loaded preview is at opacity 1 within 100ms instead of after the 0.4s hold, and switching to the mobile viewport puts it at 414px right away.
  • Sampled in jsdom every 50ms, this branch against main: opacity holds 0 until ~400ms and reaches 1 at ~800ms on both; after Reload, transform stays none through 500ms and reaches scale(0.98) at ~805ms on both; the width change to 414px completes at ~300ms on both.
  • pnpm --filter sanity-plugin-iframe-pane exec vitest run, pnpm build, pnpm lint, pnpm knip, pnpm format are clean.
  • Measured in dev/test-studio (Structure, iframeExample document on its Preview tab, reactActivityMode on) with a headed Playwright harness that switches to Vision, waits 7s, switches back and records DOM mutations and painted frames (CDP screencast) until the page is quiet for 30 frames. Before: the iframe's style went to opacity: 0 at +194ms and back to 1 at +1004ms, 46 painted frames and the final visual state reached at frame 39 (+755ms). After: no opacity mutation on the iframe, 25 painted frames, final visual state at frame 12 (+195ms); what remains is the Structure tool's own document list and status bar re-rendering on reveal, the same as the control document without this pane (36 frames, +753ms).

Preview pane before: the iframe fades out and in on reveal
Preview pane after: the iframe stays put

Notes for release

Fixes the preview iframe fading out and back in each time you return to the Structure tool in a Studio with beta.reactActivityMode enabled.

To show artifacts inline, enable in settings.

Open in Web Open in Cursor 

@vercel

vercel Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
plugins-studio Ready Ready Preview Oct 27, 2026 12:04pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
plugins-e2e-test-studio Ignored Ignored Oct 27, 2026 12:04pm UTC

Request Review

@stipsan stipsan added the ✨ ai PR authored by an AI agent (🤖 bot is for CI automation/Renovate) label Oct 13, 2026 — with Cursor
@changeset-bot

changeset-bot Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8ff619a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
sanity-plugin-iframe-pane Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

✅ E2E Tests

🟢 46 passed • view full report • view run

Studio: https://plugins-e2e-test-studio-igr6okr2o.sanity.dev

Datasets: pr-2182-chromium-40500754692, pr-2182-firefox-40500754692

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 38.76% 7807 / 20138
🔵 Statements 38.71% 8395 / 21685
🔵 Functions 34.42% 2135 / 6201
🔵 Branches 30.95% 4539 / 14662
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
plugins/sanity-plugin-iframe-pane/src/Iframe.tsx 65.88% 57.14% 66.66% 68.75% 92, 124, 131, 137-176, 182-189, 262, 333, 432-455
Generated in workflow #11399 for commit 8ff619a by the Vitest Coverage Report Action

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Reduced-motion iframe fade still plays
    • Confirmed that Motion's variant transition replaces rather than merges with the MotionConfig one, and made the active variant dynamic via custom={prefersReducedMotion} so reduced-motion users fall back to the config's zero-duration transition instead of the 400ms hold and fade.

Create PR

Or push these changes by commenting:

@cursor push 87e647f5f2
Preview (87e647f5f2)
diff --git a/plugins/sanity-plugin-iframe-pane/src/Iframe.tsx b/plugins/sanity-plugin-iframe-pane/src/Iframe.tsx
--- a/plugins/sanity-plugin-iframe-pane/src/Iframe.tsx
+++ b/plugins/sanity-plugin-iframe-pane/src/Iframe.tsx
@@ -322,6 +322,8 @@
     ref: iframe,
   } = props
 
+  const prefersReducedMotion = usePrefersReducedMotion()
+
   function handleIframeLoad() {
     setLoading(false)
     setReloading(false)
@@ -362,6 +364,7 @@
           // (its first state is `background`, the same as the old `initial`).
           initial={false}
           variants={iframeVariants}
+          custom={prefersReducedMotion}
           animate={[
             loading ? 'background' : 'active',
             reloading ? 'reloading' : 'idle',
@@ -401,13 +404,15 @@
   reloading: {
     scale: [1, 1, 1, 0.98],
   },
-  active: {
+  active: (prefersReducedMotion: boolean) => ({
     opacity: 1,
     scale: 1,
     // Hold while the spinner fades out, then fade in: the same choreography as the
-    // `[0, 0, 1]` keyframes this replaces, which Motion would replay from 0 on every reveal
-    transition: {opacity: {delay: 0.4, duration: 0.4}},
-  },
+    // `[0, 0, 1]` keyframes this replaces, which Motion would replay from 0 on every reveal.
+    // A variant transition replaces the `MotionConfig` one instead of merging with it, so
+    // reduced motion has to fall back to it explicitly.
+    transition: prefersReducedMotion ? undefined : {opacity: {delay: 0.4, duration: 0.4}},
+  }),
 }
 
 function Loading({iframeSize}: {iframeSize: IframeSizeKey}) {

You can send follow-ups to the cloud agent here.

Comment thread plugins/sanity-plugin-iframe-pane/src/Iframe.tsx Outdated

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread plugins/sanity-plugin-iframe-pane/src/Iframe.tsx Outdated

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix could not produce a summary for this run.

Create PR

Or push these changes by commenting:

@cursor push 8b5da8b80f
Preview (8b5da8b80f)
diff --git a/plugins/sanity-plugin-iframe-pane/src/Iframe.tsx b/plugins/sanity-plugin-iframe-pane/src/Iframe.tsx
--- a/plugins/sanity-plugin-iframe-pane/src/Iframe.tsx
+++ b/plugins/sanity-plugin-iframe-pane/src/Iframe.tsx
@@ -366,8 +366,14 @@
           // Hold while the spinner fades out, then fade in: the choreography of the `[0, 0, 1]`
           // opacity keyframes `active` used to have, which a replay would run from 0 again. An
           // explicit transition replaces `MotionConfig` for every value, so reduced motion is
-          // honoured here, for all of them.
-          transition={prefersReducedMotion ? {duration: 0} : {opacity: {delay: 0.4, duration: 0.4}}}
+          // honoured here, for all of them. The empty `default` keeps Motion's own per-value
+          // defaults for the size, shadow and scale animations, which otherwise resolve to the
+          // opacity transition and lose their timing.
+          transition={
+            prefersReducedMotion
+              ? {duration: 0}
+              : {default: {}, opacity: {delay: 0.4, duration: 0.4}}
+          }
           animate={[
             loading ? 'background' : 'active',
             reloading ? 'reloading' : 'idle',

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 154271f. Configure here.

Comment thread plugins/sanity-plugin-iframe-pane/src/Iframe.tsx Outdated
Comment thread plugins/sanity-plugin-iframe-pane/src/Iframe.tsx Outdated
@cursor

cursor Bot commented Oct 13, 2026

Copy link
Copy Markdown

Review: request changes

One blocking issue (inline on Iframe.tsx:370); the approach itself is right.

Verified

  • Iframe.test.tsx passes on this branch. With main's Iframe.tsx under the same tests, "keeps a loaded preview visible…" fails with expected '0' to be '1', as described. The reduced-motion case passes on both, which is expected for a guard on the new code.
  • The Motion mechanism in the description checks out in motion-dom/framer-motion 14.1.0: VisualElement.mount() jumps values back to initialValues once hasBeenMounted, and initialValues is only populated when props.initial is truthy; AnimationFeature.unmount() resets the animation state; useVisualElement replays animateChanges() in a layout effect on reconnection. Because that reset clears prevResolvedValues, keyframes compare as changed, so initial={false} alone would still replay [0, 0, 1] from 0. The plain opacity: 1 target is needed as well, so the shape is correct.
  • delay: 0.4, duration: 0.4 matches the old [0, 0, 1] over the 0.8 s keyframe default (times default spreads the hold over the first half).
  • CI green; lockfile diff is the two importer entries; no public API change.

Blocking

The opacity-only transition prop strips Motion's defaults from every other value (details and two verified fixes inline). The description's "with the other values on Motion's defaults, as before" and the matching code comment are not accurate as written; please update them with the fix.

Non-blocking

  • Frame now calls usePrefersReducedMotion() next to the existing call in IframeInner. With either fix the value is still needed in Frame, so consider passing the boolean down instead of a second subscription.

Verdict

Request changes until the transition no longer leaks into the other values and a test covers it. Everything else is ready.

@cursor

cursor Bot commented Oct 13, 2026

Copy link
Copy Markdown

Re-review (67edd84): approve

The blocking issue is fixed and its thread is resolved.

Verified

  • Iframe.test.tsx 3/3 pass on this head. The new reload-pulse test fails against the previous head 154271f (expected 'scale(0.98)' not to contain '0.98') and passes on main; the reveal test still fails on main (expected '0' to be '1'). Each of the three tests now fails without the change it protects.
  • Independent timing sample of the committed code: transform is none through 500 ms and scale(0.98) at 800 ms, matching main's 0.8 s keyframe default; the desktop → mobile width completes at 300 ms on the default ease. The dynamic active variant only touches opacity and scale, and the scale: 1 inside it is a no-op (1 → 1), so nothing else inherits its transition.
  • Reduced motion: the variant omits transition, so animateTarget falls back to MotionConfig's {duration: 0}; the reduced-motion test (opacity and the 414px size change) still passes.
  • prefersReducedMotion is passed from IframeInner to Frame, so there is a single usePrefersReducedMotion() subscription again.
  • Description and code comment now match the behaviour (the earlier "other values on Motion's defaults" claim was the inaccurate part, and it is now true). CI green, lockfile unchanged from the first review, no public API change.

Verdict

Approve.

…dary shows it again

With `beta.reactActivityMode` the Studio keeps recently used tools mounted behind
`<Activity>`, which detaches and re-attaches refs around a hidden period. Motion treats a
re-attached element as a remount and replays `initial` → `animate`, so returning to a
document's preview pane faded the iframe out and back in over 800ms (the `active`
variant's `[0, 0, 1]` opacity keyframes start from 0).

Start the iframe in its `animate` state (`initial={false}`; its first state is
`background`, the same as the old `initial`) so there is nothing to jump back to, and
express the loaded fade-in as a delayed transition on the `active` variant instead of
keyframes, so the replay animates from the current opacity to the same opacity and changes
nothing. Keeping the transition on the variant leaves the size, shadow and reload pulse on
Motion's defaults (a `transition` prop would replace them for every value). A variant
transition also replaces the `MotionConfig` one, so `active` is a dynamic variant that
gets `prefersReducedMotion` through `custom` and leaves the transition out when reduced
motion is preferred, falling back to the `{duration: 0}` config.

Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
Preview – plugins-studio — 8ff619ab Deployed Oct 27, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ ai PR authored by an AI agent (🤖 bot is for CI automation/Renovate)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants