@@ -8,6 +8,7 @@ import type { DomEditSelection, DomEditTextField } from "../components/editor/do
88import type { ImportedFontAsset } from "../components/editor/fontAssets" ;
99import { usePlayerStore } from "../player" ;
1010import { StudioSaveHttpError } from "../utils/studioSaveDiagnostics" ;
11+ import { createDomEditSaveQueue } from "../utils/domEditSaveQueue" ;
1112import { trackStudioEvent } from "../utils/studioTelemetry" ;
1213import type { CutoverResult } from "../utils/sdkCutover" ;
1314import { useDomEditCommits } from "./useDomEditCommits" ;
@@ -39,6 +40,7 @@ interface RenderDomEditCommitsOptions {
3940 importedFontAssets ?: ImportedFontAsset [ ] ;
4041 writeProjectFile ?: ( path : string , content : string , expectedContent ?: string ) => Promise < void > ;
4142 onTrySdkPersist ?: ( ) => Promise < CutoverResult > ;
43+ queueDomEditSave ?: < T > ( save : ( ) => Promise < T > ) => Promise < T > ;
4244}
4345
4446type FetchHandler = (
@@ -214,7 +216,7 @@ function renderDomEditCommits(
214216 activeCompPath : "index.html" ,
215217 previewIframeRef,
216218 showToast,
217- queueDomEditSave : async ( save ) => save ( ) ,
219+ queueDomEditSave : options . queueDomEditSave ?? ( async ( save ) => save ( ) ) ,
218220 writeProjectFile : options . writeProjectFile ?? ( async ( ) => { } ) ,
219221 domEditSaveTimestampRef,
220222 editHistory : { recordEdit } ,
@@ -1019,6 +1021,96 @@ describe("useDomEditCommits style persist handling", () => {
10191021 }
10201022 } ) ;
10211023
1024+ it ( "serializes the full read, write, and history transaction across overlapping commits" , async ( ) => {
1025+ const firstPatch = createDeferred < Response > ( ) ;
1026+ let readCount = 0 ;
1027+ let patchCount = 0 ;
1028+ vi . stubGlobal (
1029+ "fetch" ,
1030+ vi . fn ( async ( input : Parameters < typeof fetch > [ 0 ] ) => {
1031+ const url = requestUrl ( input ) ;
1032+ if ( url . includes ( "/api/projects/p1/files/" ) ) {
1033+ readCount += 1 ;
1034+ return jsonResponse ( {
1035+ content :
1036+ readCount === 1
1037+ ? '<div data-hf-id="hf-card" style="color: red">Card</div>'
1038+ : '<div data-hf-id="hf-card" style="color: blue">Card</div>' ,
1039+ } ) ;
1040+ }
1041+ if ( url . includes ( "/api/projects/p1/file-mutations/patch-element/" ) ) {
1042+ patchCount += 1 ;
1043+ if ( patchCount === 1 ) return firstPatch . promise ;
1044+ return jsonResponse ( {
1045+ ok : true ,
1046+ changed : true ,
1047+ matched : true ,
1048+ content : '<div data-hf-id="hf-card" style="color: green">Card</div>' ,
1049+ path : "index.html" ,
1050+ version : '"sha256:green"' ,
1051+ } ) ;
1052+ }
1053+ throw new Error ( `Unexpected fetch: ${ url } ` ) ;
1054+ } ) ,
1055+ ) ;
1056+ const queue = createDomEditSaveQueue ( ) ;
1057+ const { iframe, element } = createPreviewElement ( ) ;
1058+ const rendered = renderDomEditCommits ( createSelection ( element ) , iframe , {
1059+ queueDomEditSave : queue . enqueue ,
1060+ } ) ;
1061+
1062+ try {
1063+ const first = rendered . hook . handleDomStyleCommit ( "color" , "blue" ) ;
1064+ await flushAsyncWork ( ) ;
1065+ const second = rendered . hook . handleDomStyleCommit ( "color" , "green" ) ;
1066+ await flushAsyncWork ( ) ;
1067+
1068+ expect ( readCount ) . toBe ( 1 ) ;
1069+ expect ( patchCount ) . toBe ( 1 ) ;
1070+
1071+ firstPatch . resolve (
1072+ jsonResponse ( {
1073+ ok : true ,
1074+ changed : true ,
1075+ matched : true ,
1076+ content : '<div data-hf-id="hf-card" style="color: blue">Card</div>' ,
1077+ path : "index.html" ,
1078+ version : '"sha256:blue"' ,
1079+ } ) ,
1080+ ) ;
1081+ await first ;
1082+ await second ;
1083+
1084+ expect ( readCount ) . toBe ( 2 ) ;
1085+ expect ( patchCount ) . toBe ( 2 ) ;
1086+ expect ( rendered . recordEdit ) . toHaveBeenNthCalledWith (
1087+ 1 ,
1088+ expect . objectContaining ( {
1089+ files : {
1090+ "index.html" : expect . objectContaining ( {
1091+ before : expect . stringContaining ( "color: red" ) ,
1092+ after : expect . stringContaining ( "color: blue" ) ,
1093+ } ) ,
1094+ } ,
1095+ } ) ,
1096+ ) ;
1097+ expect ( rendered . recordEdit ) . toHaveBeenNthCalledWith (
1098+ 2 ,
1099+ expect . objectContaining ( {
1100+ files : {
1101+ "index.html" : expect . objectContaining ( {
1102+ before : expect . stringContaining ( "color: blue" ) ,
1103+ after : expect . stringContaining ( "color: green" ) ,
1104+ } ) ,
1105+ } ,
1106+ } ) ,
1107+ ) ;
1108+ } finally {
1109+ queue . destroy ( ) ;
1110+ rendered . cleanup ( ) ;
1111+ }
1112+ } ) ;
1113+
10221114 it ( "preserves the SDK cutover version as the style commit's durable evidence" , async ( ) => {
10231115 const fetchMock = stubPatchFetch ( { ok : true , changed : true , matched : true } ) ;
10241116 const { iframe, element } = createPreviewElement ( ) ;
@@ -1520,9 +1612,7 @@ describe("useDomEditCommits attribute persist handling", () => {
15201612 // optimistic apply) and succeeds before the older one rejects. Without the
15211613 // per-key version guard, the stale rejection would revert to the older
15221614 // commit's own previousValue (null) and stomp the newer commit's value.
1523- const firstCommit = act ( async ( ) => {
1524- await rendered . hook . handleDomHtmlAttributeCommit ( "muted" , "first-value" ) ;
1525- } ) ;
1615+ const firstCommit = rendered . hook . handleDomHtmlAttributeCommit ( "muted" , "first-value" ) ;
15261616 await act ( async ( ) => {
15271617 await rendered . hook . handleDomHtmlAttributeCommit ( "muted" , "second-value" ) ;
15281618 } ) ;
0 commit comments