( iframe: HTMLIFrameElement | null, scriptText: string, onAsyncFailure?: () => void, currentTimeOverride?: number, )
| 172 | * synchronous paths. |
| 173 | */ |
| 174 | export function applySoftReload( |
| 175 | iframe: HTMLIFrameElement | null, |
| 176 | scriptText: string, |
| 177 | onAsyncFailure?: () => void, |
| 178 | currentTimeOverride?: number, |
| 179 | ): SoftReloadResult { |
| 180 | if (!iframe || !scriptText) return "cannot-soft-reload"; |
| 181 | |
| 182 | const win = iframe.contentWindow as IframeWindow | null; |
| 183 | const doc = iframe.contentDocument; |
| 184 | if (!win || !doc) return "cannot-soft-reload"; |
| 185 | if (!win.gsap || !win.__hfForceTimelineRebind) return "cannot-soft-reload"; |
| 186 | |
| 187 | // Which composition(s) does this script rebuild? A soft reload re-runs ONE |
| 188 | // composition's GSAP script, which re-registers its own window.__timelines[key]. |
| 189 | // In a multi-composition preview (top-level + inlined subcompositions) each |
| 190 | // composition owns a separate timeline keyed by its id, and they're all children |
| 191 | // of the global timeline — so tearing down ALL of them (or the global timeline's |
| 192 | // children) and re-running a single script wipes every OTHER composition, |
| 193 | // reverting its edits. Scope the teardown to the keys THIS script re-registers. |
| 194 | const targetKeys = [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)] |
| 195 | .map((m) => m[1]!) |
| 196 | .filter((key) => key !== "__proxied"); |
| 197 | if (targetKeys.length === 0) return "cannot-soft-reload"; // can't scope safely → full reload |
| 198 | const gsapScripts = findGsapScriptElements(doc); |
| 199 | if (gsapScripts.length === 0) return "cannot-soft-reload"; |
| 200 | // Remove only the stale script element(s) that registered a target key; one we |
| 201 | // can't match in the doc is left alone (re-running appends a fresh element). |
| 202 | const staleScripts = gsapScripts.filter((script) => |
| 203 | targetKeys.some((key) => { |
| 204 | const text = script.textContent || ""; |
| 205 | return text.includes(`__timelines["${key}"]`) || text.includes(`__timelines['${key}']`); |
| 206 | }), |
| 207 | ); |
| 208 | // Multiple GSAP scripts exist but none registers a key this script owns — we |
| 209 | // can't identify which element to replace (ambiguous, matching |
| 210 | // extractGsapScriptText's single-script requirement). Escalate to a full reload |
| 211 | // rather than killing the target timeline and appending an orphan script. |
| 212 | if (gsapScripts.length > 1 && staleScripts.length === 0) return "cannot-soft-reload"; |
| 213 | |
| 214 | // Prefer the caller-supplied scrub position (the studio's own authoritative |
| 215 | // currentTime, e.g. usePlayerStore) over the iframe's raw `__player.getTime()`: |
| 216 | // the two can desync (a keyframe-node drag parks the playhead via the store |
| 217 | // BEFORE this reload's async commit resolves, and the iframe's own GSAP clock |
| 218 | // doesn't reliably reflect that yet), which re-seeks the freshly rebuilt |
| 219 | // timeline to the wrong frame and leaves the element (and its overlay) |
| 220 | // rendered at a stale/unrelated position. |
| 221 | const currentTime = currentTimeOverride ?? win.__player?.getTime?.() ?? 0; |
| 222 | |
| 223 | // Track whether the MotionPath async path was taken. When it is, the script |
| 224 | // executes inside pluginScript.onload — after applySoftReload has already |
| 225 | // returned. We optimistically return true because the script WILL execute |
| 226 | // once the plugin loads; the alternative (returning false) would trigger a |
| 227 | // full iframe reload that destroys the very WebGL context we're preserving. |
| 228 | let deferredToAsync = false; |
| 229 | |
| 230 | // fallow-ignore-next-line complexity |
| 231 | const doReload = () => { |
no test coverage detected