88 extractGsapScriptText ,
99 findGsapScriptElements ,
1010} from "./gsapSoftReload" ;
11+ import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids" ;
1112import { findAuthoredElement , parseSavedSource } from "./authoredSource" ;
1213import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply" ;
1314import { markScenesStale } from "../player/sceneSwap" ;
@@ -40,9 +41,9 @@ function elementIdentityKey(el: Element): string | null {
4041
4142const IDENTITY_SELECTOR = "[id], [data-hf-id]" ;
4243
43- function identityElementMap ( doc : Document ) : Map < string , Element > | null {
44+ function identityElementMap ( root : ParentNode ) : Map < string , Element > | null {
4445 const map = new Map < string , Element > ( ) ;
45- for ( const el of doc . querySelectorAll ( IDENTITY_SELECTOR ) ) {
46+ for ( const el of root . querySelectorAll ( IDENTITY_SELECTOR ) ) {
4647 const key = elementIdentityKey ( el ) ;
4748 if ( ! key ) continue ;
4849 // Ambiguous identity must full-reload; silently overwriting would restore
@@ -53,6 +54,17 @@ function identityElementMap(doc: Document): Map<string, Element> | null {
5354 return map ;
5455}
5556
57+ // A sub-composition file wraps its markup in a template; the preview inlines that markup into its host.
58+ function parseRestoreSource ( html : string ) : Document {
59+ const doc = new DOMParser ( ) . parseFromString ( html , "text/html" ) ;
60+ for ( const template of Array . from ( doc . querySelectorAll ( "template" ) ) . filter (
61+ isCompositionTemplate ,
62+ ) ) {
63+ template . replaceWith ( template . content ) ;
64+ }
65+ return doc ;
66+ }
67+
5668// Strip identified elements to their bare identity attributes and blank GSAP
5769// scripts, in place: docs that differ only in identified-element attributes/
5870// inline-style/script text normalize equal; any residual difference is beyond
@@ -103,8 +115,8 @@ export function diffSoftReloadableRestore(
103115 let prevDoc : Document ;
104116 let nextDoc : Document ;
105117 try {
106- prevDoc = new DOMParser ( ) . parseFromString ( previous , "text/html" ) ;
107- nextDoc = new DOMParser ( ) . parseFromString ( restored , "text/html" ) ;
118+ prevDoc = parseRestoreSource ( previous ) ;
119+ nextDoc = parseRestoreSource ( restored ) ;
108120 } catch {
109121 return null ;
110122 }
@@ -163,11 +175,48 @@ function hasAmbiguousGsapScriptChange(previous: string, restored: string): boole
163175 ) ;
164176}
165177
178+ /**
179+ * Every live element a restore changes, paired with its restored markup, or null when the restore is
180+ * beyond an attribute sync. A file is the active document or a sub-composition the live preview
181+ * inlines into each `[data-composition-file]` host; only the active document's GSAP script may change.
182+ */
183+ function planRestoreTargets (
184+ doc : Document ,
185+ activeDocPath : string ,
186+ files : Record < string , UndoRestoreFile > ,
187+ ) : Array < { live : Element ; restored : Element } > | null {
188+ const targets : Array < { live : Element ; restored : Element } > = [ ] ;
189+ for ( const [ path , { previous, restored } ] of Object . entries ( files ) ) {
190+ const isActive = path === activeDocPath ;
191+ const scopes : ParentNode [ ] = isActive
192+ ? [ doc ]
193+ : Array . from ( doc . querySelectorAll ( `[data-composition-file="${ CSS . escape ( path ) } "]` ) ) ;
194+ const diff = diffSoftReloadableRestore ( previous , restored ) ;
195+ if ( ! scopes . length || ! diff || hasAmbiguousGsapScriptChange ( previous , restored ) ) return null ;
196+ if ( ! isActive && extractGsapScriptText ( previous ) !== extractGsapScriptText ( restored ) )
197+ return null ;
198+ const restoredByKey = identityElementMap ( parseRestoreSource ( restored ) ) ;
199+ if ( ! restoredByKey ) return null ;
200+ for ( const scope of scopes ) {
201+ const liveByKey = identityElementMap ( scope ) ;
202+ if ( ! liveByKey ) return null ;
203+ for ( const key of diff . changedElementKeys ) {
204+ const live = liveByKey . get ( key ) ;
205+ const restoredEl = restoredByKey . get ( key ) ;
206+ // The preview rewrites a sub-composition's own root, so its attributes are not the file's.
207+ if ( ! live || ! restoredEl || live . hasAttribute ( "data-hf-inner-root" ) ) return null ;
208+ targets . push ( { live, restored : restoredEl } ) ;
209+ }
210+ }
211+ }
212+ return targets ;
213+ }
214+
166215/**
167216 * Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
168- * remount (which blanks the frame black and re-flashes the WebGL context). Only
169- * the active composition — the document living in the root iframe — is eligible ;
170- * a sub-comp or multi-file restore falls back to `reloadPreview`.
217+ * remount (which blanks the frame black and re-flashes the WebGL context). Eligible
218+ * files are the active composition and any sub-composition the preview inlines ;
219+ * a file the preview does not show falls back to `reloadPreview`.
171220 *
172221 * The restore is soft-applied when its only differences are identified-element
173222 * (id / data-hf-id) attributes / inline-style and/or the GSAP script (see
@@ -210,62 +259,26 @@ export function applyUndoRestoreToPreview(
210259 markScenesStale ( iframe , paths ) ;
211260 reload ( ) ;
212261 } ;
213- // Soft path only covers the single active-comp document in the root iframe.
214- if ( ! iframe || ! files || paths . length !== 1 || paths [ 0 ] !== activeDocPath ) {
215- reloadPreview ( ) ;
216- return "full" ;
217- }
218- const doc = iframe . contentDocument ;
219- const win = iframe . contentWindow as PreviewWindow | null ;
220- if ( ! doc || ! win ) {
262+ const doc = iframe ?. contentDocument ;
263+ const win = iframe ?. contentWindow as PreviewWindow | null ;
264+ const changedTargets = doc && files && win ? planRestoreTargets ( doc , activeDocPath , files ) : null ;
265+ if ( ! doc || ! files || ! win || ! changedTargets ) {
221266 reloadPreview ( ) ;
222267 return "full" ;
223268 }
224- const { previous, restored } = files [ activeDocPath ] ! ;
225- const diff = diffSoftReloadableRestore ( previous , restored ) ;
226- if ( ! diff ) {
227- reloadPreview ( ) ;
228- return "full" ;
229- }
230- // A serialized snapshot cannot identify which of several GSAP scripts owns a
231- // rewrite. Keep attribute-only restores soft when every script byte is equal,
232- // but fail closed before touching the live DOM when an ambiguous script changed.
233- if ( hasAmbiguousGsapScriptChange ( previous , restored ) ) {
234- reloadPreview ( ) ;
235- return "full" ;
236- }
237-
238- // Resolve every changed pair BEFORE touching the live DOM. A missing target
239- // makes the soft restore incomplete, so escalate without leaving a partially
240- // restored preview behind.
241- const liveByKey = identityElementMap ( doc ) ;
242- const restoredByKey = identityElementMap ( new DOMParser ( ) . parseFromString ( restored , "text/html" ) ) ;
243- if ( ! liveByKey || ! restoredByKey ) {
244- reloadPreview ( ) ;
245- return "full" ;
246- }
247- const changedTargets : Array < { live : Element ; restored : Element } > = [ ] ;
248- for ( const key of diff . changedElementKeys ) {
249- const liveEl = liveByKey . get ( key ) ;
250- const restoredEl = restoredByKey . get ( key ) ;
251- if ( ! liveEl || ! restoredEl ) {
252- reloadPreview ( ) ;
253- return "full" ;
254- }
255- changedTargets . push ( { live : liveEl , restored : restoredEl } ) ;
256- }
257269 // Sync each changed element's attributes onto the live DOM from the restored
258270 // markup, so the runtime's seek-reapply reads the reverted values.
259271 for ( const target of changedTargets ) syncElementAttributes ( target . live , target . restored ) ;
260272
261- const restoredScript = extractGsapScriptText ( restored ) ;
262- const previousScript = extractGsapScriptText ( previous ) ;
273+ const active = files [ activeDocPath ] ;
274+ const restoredScript = active ? extractGsapScriptText ( active . restored ) : null ;
275+ const previousScript = active ? extractGsapScriptText ( active . previous ) : null ;
263276 if ( restoredScript && restoredScript !== previousScript ) {
264- syncStaleEditMarks ( doc , restored ) ;
277+ syncStaleEditMarks ( doc , active . restored ) ;
265278 const result = applySoftReload ( iframe , restoredScript , {
266279 onAsyncFailure : reloadPreview ,
267280 currentTimeOverride : currentTime ,
268- authoredHtml : restored ,
281+ authoredHtml : active . restored ,
269282 } ) ;
270283 if ( result === "cannot-soft-reload" ) {
271284 reloadPreview ( ) ;
0 commit comments