Files
OpenMAIC/components/stage.tsx
T
cdc04b7670 fix(interactive): keep-alive iframe pool — interactive scenes no longer reload on remount (#619) (#629)
* fix(interactive): keep-alive iframe pool so interactive scenes don't reload on remount (#619)

Interactive scene iframes reloaded on every remount — Pro mode toggle,
scene switch and back, any PlaybackChromeRoot remount — because the
<iframe srcDoc> was rendered inline in the scene subtree and React's
unmount (or a DOM re-parent) makes the browser drop and re-run the doc.

Persist the iframes in a stable host at the Stage root, outside the
mode-swap/scene subtree, keyed by sceneId:

- lib/store/interactive-iframe-pool: per-scene entries (srcDoc/src, content
  hash, tracked rect, visibility), LRU-bounded (CAP=3). Re-mounting with an
  unchanged hash keeps the existing content reference, so the iframe is
  never re-set; only a content-hash change rebuilds it.
- InteractiveIframeHost: mounted once in Stage, portals one persisted
  <iframe> per entry to document.body, positioned over each scene's rect.
  Hidden (visibility, never display:none) in edit mode and whenever the
  placeholder is gone. Owns the widget-iframe postMessage registration.
- InteractiveRenderer: now a placeholder that registers content, marks the
  scene active/visible, reports its rect via a rAF loop, and hides (not
  evicts) on unmount.

Mode toggle, back-and-forth scene switching, and re-renders now hit the
cache with zero reload and zero flash. widget-iframe messaging API is
unchanged; no change to the playback engine / SSE / edit-lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): interactive iframe keep-alive across Pro toggle + scene switch (#619)

Seeds an interactive scene whose widget holds click-counter state, then drives
the two remount triggers (Pro mode toggle, scene switch and back). A
MutationObserver scoped to the keep-alive iframe's title asserts the element is
never removed or recreated across either trigger, and the in-iframe counter
state is preserved — i.e. zero reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(interactive): address review — widget callback timing, fullscreen, content compare, rect gate (#619)

Code-review round-1 fixes for the keep-alive iframe pool:

- PlaybackChromeRoot: resolve the widget postMessage callback lazily at send
  time instead of capturing it once at engine construction. The iframe (and its
  registration) now lives in the keep-alive host, which registers a commit after
  the engine is built — so eager capture got null on a scene's first visit and
  silently dropped every widget action.
- InteractiveIframeHost: portal target follows document.fullscreenElement so the
  iframe stays inside the presentation-fullscreen subtree (requestFullscreen runs
  on the stage, not body) instead of vanishing. Known tradeoff: entering/exiting
  fullscreen re-parents the iframe and reloads it once — accepted over an
  invisible widget during presentation.
- InteractiveIframeHost: reset the pool when the host unmounts (classroom switch)
  so a new classroom doesn't retain the previous one's resident iframes.
- InteractiveIframeHost: only show the iframe once its rect has a real measured
  size (width>0 && height>0), avoiding a 0x0 flash at the viewport origin before
  first layout.
- pool: drop the djb2 hash; compare srcDoc/src by value (string === is value
  equality, so an equal-but-new remount string still hits the keep-alive path).
  Removes the hash-collision-skips-rebuild hole and simplifies the store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(interactive): owner-token visibility so stale cross-fade cleanup can't blank the iframe (#619)

cosarah reproduced: after toggling Edit mode on an interactive scene and
returning to playback, the scene flashed its preserved state then went
permanently blank. Root cause is a visibility race, not a reload — the document
stayed alive. Visibility was keyed only by sceneId, so during Stage's
AnimatePresence mode cross-fade the outgoing PlaybackChromeRoot's placeholder
cleanup (hide) ran after the incoming placeholder had already shown the same
scene, hiding the live iframe.

Give each placeholder mount a visibility-ownership token (useId). The pool
records the owner on claim(); release() only clears visibility when the caller
still owns it, so a stale cleanup no-ops instead of blanking a newer instance.
The host now shows iff entry.owner !== null.

- store: replace visible boolean + show/hide with owner + claim/release.
- interactive-renderer: owner = useId(); claim on mount, release on unmount.
- e2e: after returning from Pro mode, wait out the cross-fade and re-assert the
  iframe stays visible with its state (guards the blank-on-return regression).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-06-01 23:42:24 +08:00

168 lines
7.0 KiB
TypeScript

'use client';
import { useCallback, useEffect, useRef } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useStageStore } from '@/lib/store';
import { isCurrentSceneEditable } from '@/lib/edit/stage-mode';
import { isMaicEditorEnabled } from '@/lib/config/feature-flags';
import { EditChromeRoot } from '@/components/edit/EditChromeRoot';
import {
PlaybackChromeRoot,
type PlaybackChromeRootHandle,
} from '@/components/edit/PlaybackChromeRoot';
import { useEditModeLock } from '@/components/edit/use-edit-mode-lock';
import { MultiTabEditConflictPrompt } from '@/components/edit/MultiTabEditConflictPrompt';
import { InteractiveIframeHost } from '@/components/scene-renderers/InteractiveIframeHost';
import { CHROME_EASE } from '@/lib/edit/transitions';
import { preloadEditor } from '@/lib/edit/preload-editor';
/**
* Stage — top-level classroom container. Dispatches between the two
* chrome roots based on `useStageStore.mode`:
*
* mode === 'edit' → EditChromeRoot
* mode === 'playback' / 'autonomous' → PlaybackChromeRoot
*
* The two roots are wholly independent. Stage's only responsibilities
* are: mode dispatch, edit-lock coordination (cross-tab), Pro Switch
* toggle wiring (calls into PlaybackChromeRoot.teardown via ref before
* flipping mode), and rendering the cross-tab conflict prompt (which
* needs to be mountable from playback mode too, since the lock-conflict
* dialog can surface when Pro Switch is clicked but acquire fails).
*/
export function Stage({
onRetryOutline,
}: {
onRetryOutline?: (outlineId: string) => Promise<void>;
}) {
const { mode, setMode, scenes, currentSceneId, generatingOutlines, stage } = useStageStore();
const currentScene = useStageStore((s) => s.getCurrentScene());
// Predicate for "can the user enter Pro mode for the current scene?".
// Single source of truth feeds the Header's Pro Switch state and the
// auto-exit effect below; keeping them in lock-step prevents an
// edit-mode entry that would immediately auto-exit.
const isEditable = isCurrentSceneEditable({
currentSceneId,
sceneCount: scenes.length,
generatingOutlineCount: generatingOutlines.length,
hasCurrentScene: !!currentScene,
});
// Cross-tab edit lock (#571). Lives at this layer because entry must
// be refused BEFORE the live session is torn down; PlaybackChromeRoot
// can't own this since it can't refuse its own unmount path.
const editLock = useEditModeLock(stage?.id);
const playbackRef = useRef<PlaybackChromeRootHandle>(null);
// Pro Switch handler. Edit→playback is a plain flip (PlaybackChromeRoot
// will mount fresh; its engine effect re-inits). Playback→edit must
// (1) refuse on lock conflict, (2) await SSE / engine / TTS teardown
// so PlaybackChromeRoot is quiescent before it unmounts.
const handleToggleEditMode = useCallback(async () => {
if (mode === 'edit') {
setMode('playback');
return;
}
if (!editLock.acquire()) return;
// Load the editor chunk (fonts + slide surface) BEFORE flipping mode,
// so the edit chrome animates in with its content already present and
// the slide surface registered — no mid-animation pop-in / NOOP flash.
// Runs concurrently with teardown; the import is promise-cached so it's
// a no-op on subsequent toggles.
const editorLoad = preloadEditor();
try {
await Promise.all([playbackRef.current?.teardown(), editorLoad]);
} catch (err) {
// Teardown failed after the cross-tab lock was acquired but before we
// flipped into edit mode. Release the lock we just took: otherwise it
// stays HELD while mode stays 'playback', and the release effect (keyed
// on `mode`) never re-fires, stranding the lock until tab close and
// blocking this and every other tab from Pro mode. Stay in playback so
// the failure surfaces rather than half-entering edit mode.
editLock.release();
console.error('[Stage] Pro mode entry failed during teardown', err);
return;
}
setMode('edit');
}, [editLock, mode, setMode]);
// Auto-exit edit mode when the current scene becomes uneditable
// (pending generation, no scenes, currently generating).
useEffect(() => {
if (mode === 'edit' && !isEditable) {
setMode('playback');
}
}, [mode, isEditable, setMode]);
// Release the lock whenever we're not in edit mode (covers manual
// exit, auto-exit, scene becomes uneditable). The hook also self-
// releases on unmount / tab close.
const releaseEditLock = editLock.release;
useEffect(() => {
if (mode !== 'edit') releaseEditLock();
}, [mode, releaseEditLock]);
const toggleHandler = isMaicEditorEnabled() ? handleToggleEditMode : undefined;
// Mode swap choreography — a clean opacity cross-fade. Both roots layer
// via `absolute inset-0` so they coexist for the ~280ms window without
// one popping out before the other arrives. The outgoing root keeps
// rendering its canvas during exit so `canvasStore` (the shared scale
// writer) doesn't briefly read zero.
//
// Deliberately NO transform (translateY) on these layers: the edit
// chrome hosts the Pro Switch / settings pill, which morph across the
// swap via `layoutId`. A transform on this ancestor distorts motion's
// layout measurement (the pill visibly drifts) and the blurred chrome
// would repaint its backdrop-filter every frame while translating. A
// pure fade keeps layout static so the shared elements land precisely.
return (
<div className="relative flex flex-1 overflow-hidden">
<AnimatePresence initial={false}>
{mode === 'edit' && currentScene ? (
<motion.div
key="edit"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.28, ease: CHROME_EASE }}
className="absolute inset-0 flex"
>
<EditChromeRoot
scene={currentScene}
isEditable={isEditable}
onToggleEditMode={toggleHandler}
/>
</motion.div>
) : (
<motion.div
key="playback"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.28, ease: CHROME_EASE }}
className="absolute inset-0 flex"
>
<PlaybackChromeRoot
ref={playbackRef}
onRetryOutline={onRetryOutline}
canEnterProMode={isEditable}
onEnterProMode={toggleHandler}
/>
</motion.div>
)}
</AnimatePresence>
<MultiTabEditConflictPrompt
open={editLock.conflictOpen}
onDismiss={editLock.dismissConflict}
/>
{/* Keep-alive host for interactive scene iframes (#619). Lives here, above
the mode-swap subtree, so its iframes survive Pro mode toggles and
scene switches instead of reloading on every remount. */}
<InteractiveIframeHost />
</div>
);
}