@hyperframes/studio 0.7.28 → 0.7.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/assets/{index-BGUJ2C2G.js → index-BhSyGx7y.js} +1 -1
  2. package/dist/assets/{index-Bz6Eqd_G.js → index-D0yNztV_.js} +1 -1
  3. package/dist/assets/{index-CLlPjdPl.js → index-kbACg3_I.js} +139 -139
  4. package/dist/{chunk-AN2EWWK3.js → chunk-JND3XUJL.js} +38 -10
  5. package/dist/chunk-JND3XUJL.js.map +1 -0
  6. package/dist/{domEditingLayers-EK7R7R4G.js → domEditingLayers-UIQZJCOA.js} +4 -2
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1145 -675
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -7
  12. package/src/components/editor/DomEditOverlay.test.ts +95 -0
  13. package/src/components/editor/DomEditOverlay.tsx +5 -3
  14. package/src/components/editor/domEditOverlayGestures.ts +3 -4
  15. package/src/components/editor/domEditing.ts +1 -0
  16. package/src/components/editor/domEditingLayers.test.ts +52 -0
  17. package/src/components/editor/domEditingLayers.ts +55 -19
  18. package/src/components/editor/domEditingTypes.ts +1 -0
  19. package/src/components/editor/persistSeam.integration.test.ts +264 -0
  20. package/src/components/editor/propertyPanelPrimitives.tsx +15 -1
  21. package/src/components/editor/useDomEditOverlayGestures.ts +1 -0
  22. package/src/hooks/domEditCommitRunner.ts +46 -0
  23. package/src/hooks/domEditPersistFailure.test.ts +123 -0
  24. package/src/hooks/domEditPersistFailure.ts +89 -0
  25. package/src/hooks/domEditTextFieldCommitOps.test.ts +111 -0
  26. package/src/hooks/domEditTextFieldCommitOps.ts +63 -0
  27. package/src/hooks/domSelectionTestHarness.ts +40 -0
  28. package/src/hooks/useDomEditAttributeCommits.ts +227 -0
  29. package/src/hooks/useDomEditCommits.test.tsx +775 -0
  30. package/src/hooks/useDomEditCommits.ts +33 -5
  31. package/src/hooks/useDomEditTextCommits.ts +243 -220
  32. package/src/hooks/useDomSelection.test.ts +134 -0
  33. package/src/hooks/useDomSelection.ts +29 -15
  34. package/src/hooks/usePreviewInteraction.test.ts +260 -0
  35. package/src/hooks/usePreviewInteraction.ts +60 -19
  36. package/src/utils/sdkCutoverEligibility.test.ts +17 -0
  37. package/src/utils/sdkCutoverEligibility.ts +8 -2
  38. package/src/utils/sdkResolverShadow.test.ts +180 -1
  39. package/src/utils/sdkResolverShadow.ts +94 -1
  40. package/src/utils/sourcePatcher.ts +2 -0
  41. package/src/utils/studioPreviewHelpers.test.ts +95 -1
  42. package/src/utils/studioPreviewHelpers.ts +109 -10
  43. package/src/utils/studioSaveDiagnostics.ts +5 -2
  44. package/src/utils/studioTelemetry.ts +27 -20
  45. package/dist/chunk-AN2EWWK3.js.map +0 -1
  46. /package/dist/{domEditingLayers-EK7R7R4G.js.map → domEditingLayers-UIQZJCOA.js.map} +0 -0
@@ -0,0 +1,775 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { MutableRefObject } from "react";
6
+ import type { DomEditSelection, DomEditTextField } from "../components/editor/domEditing";
7
+ import type { ImportedFontAsset } from "../components/editor/fontAssets";
8
+ import { StudioSaveHttpError } from "../utils/studioSaveDiagnostics";
9
+ import { trackStudioEvent } from "../utils/studioTelemetry";
10
+ import { useDomEditCommits } from "./useDomEditCommits";
11
+
12
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
13
+
14
+ vi.mock("../utils/studioTelemetry", () => ({
15
+ trackStudioEvent: vi.fn(),
16
+ }));
17
+
18
+ interface PatchResponseBody {
19
+ ok?: boolean;
20
+ changed?: boolean;
21
+ matched?: boolean;
22
+ content?: string;
23
+ }
24
+
25
+ interface RenderedDomEditCommits {
26
+ hook: ReturnType<typeof useDomEditCommits>;
27
+ showToast: ReturnType<typeof makeShowToast>;
28
+ recordEdit: ReturnType<typeof vi.fn<() => Promise<void>>>;
29
+ cleanup: () => void;
30
+ }
31
+
32
+ interface RenderDomEditCommitsOptions {
33
+ importedFontAssets?: ImportedFontAsset[];
34
+ writeProjectFile?: (path: string, content: string) => Promise<void>;
35
+ }
36
+
37
+ type FetchHandler = (
38
+ input: Parameters<typeof fetch>[0],
39
+ init?: Parameters<typeof fetch>[1],
40
+ ) => Promise<Response>;
41
+
42
+ function makeShowToast() {
43
+ return vi.fn<(message: string, tone?: "error" | "info") => void>();
44
+ }
45
+
46
+ function ensureCssEscape(): void {
47
+ const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
48
+ if (typeof globalThis.CSS === "undefined") {
49
+ Object.defineProperty(globalThis, "CSS", {
50
+ value: { escape },
51
+ configurable: true,
52
+ });
53
+ return;
54
+ }
55
+ if (typeof globalThis.CSS.escape !== "function") {
56
+ Object.defineProperty(globalThis.CSS, "escape", {
57
+ value: escape,
58
+ configurable: true,
59
+ });
60
+ }
61
+ }
62
+
63
+ interface Deferred<T> {
64
+ promise: Promise<T>;
65
+ resolve: (value: T | PromiseLike<T>) => void;
66
+ reject: (reason?: unknown) => void;
67
+ }
68
+
69
+ function createDeferred<T>(): Deferred<T> {
70
+ let resolveFn: Deferred<T>["resolve"] | null = null;
71
+ let rejectFn: Deferred<T>["reject"] | null = null;
72
+ const promise = new Promise<T>((resolve, reject) => {
73
+ resolveFn = resolve;
74
+ rejectFn = reject;
75
+ });
76
+ if (!resolveFn || !rejectFn) throw new Error("Expected promise callbacks");
77
+ return { promise, resolve: resolveFn, reject: rejectFn };
78
+ }
79
+
80
+ function jsonResponse(body: unknown, status = 200): Response {
81
+ return new Response(JSON.stringify(body), {
82
+ status,
83
+ headers: { "content-type": "application/json" },
84
+ });
85
+ }
86
+
87
+ function requestUrl(input: Parameters<typeof fetch>[0]): string {
88
+ if (typeof input === "string") return input;
89
+ if (input instanceof URL) return input.toString();
90
+ return input.url;
91
+ }
92
+
93
+ function stubPatchFetch(
94
+ patchResponse: PatchResponseBody | Error,
95
+ sourceContent = '<div data-hf-id="hf-card" style="color: red">Card</div>',
96
+ ) {
97
+ const fetchMock = vi.fn(
98
+ async (
99
+ input: Parameters<typeof fetch>[0],
100
+ _init?: Parameters<typeof fetch>[1],
101
+ ): Promise<Response> => {
102
+ const url = requestUrl(input);
103
+ if (url.includes("/api/projects/p1/files/")) {
104
+ return jsonResponse({ content: sourceContent });
105
+ }
106
+ if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
107
+ if (patchResponse instanceof Error) throw patchResponse;
108
+ return jsonResponse(patchResponse);
109
+ }
110
+ throw new Error(`Unexpected fetch: ${url}`);
111
+ },
112
+ );
113
+ vi.stubGlobal("fetch", fetchMock);
114
+ return fetchMock;
115
+ }
116
+
117
+ function stubUnexpectedPersistFetch() {
118
+ const fetchMock = vi.fn(async (): Promise<Response> => {
119
+ throw new Error("persist should not run");
120
+ });
121
+ vi.stubGlobal("fetch", fetchMock);
122
+ return fetchMock;
123
+ }
124
+
125
+ async function flushAsyncWork(): Promise<void> {
126
+ for (let i = 0; i < 8; i += 1) {
127
+ await Promise.resolve();
128
+ }
129
+ }
130
+
131
+ function createPreviewElement(
132
+ bodyHtml = '<div data-hf-id="hf-card" style="color: red">Card</div>',
133
+ ): {
134
+ iframe: HTMLIFrameElement;
135
+ element: HTMLElement;
136
+ } {
137
+ const iframe = document.createElement("iframe");
138
+ document.body.append(iframe);
139
+ const doc = iframe.contentDocument;
140
+ if (!doc) throw new Error("Expected iframe contentDocument");
141
+ doc.body.innerHTML = bodyHtml;
142
+ const element = doc.querySelector('[data-hf-id="hf-card"]');
143
+ if (!(element instanceof HTMLElement)) throw new Error("Expected HTML target element");
144
+ return { iframe, element };
145
+ }
146
+
147
+ function textField(input: {
148
+ key: string;
149
+ value: string;
150
+ source: DomEditTextField["source"];
151
+ tagName?: string;
152
+ }): DomEditTextField {
153
+ return {
154
+ key: input.key,
155
+ label: input.key,
156
+ value: input.value,
157
+ tagName: input.tagName ?? "span",
158
+ attributes: [],
159
+ inlineStyles: {},
160
+ computedStyles: {},
161
+ source: input.source,
162
+ };
163
+ }
164
+
165
+ function createSelection(
166
+ element: HTMLElement,
167
+ overrides: Partial<DomEditSelection> = {},
168
+ ): DomEditSelection {
169
+ const base: DomEditSelection = {
170
+ element,
171
+ label: "Hero title",
172
+ tagName: "div",
173
+ sourceFile: "index.html",
174
+ compositionPath: "index.html",
175
+ isCompositionHost: false,
176
+ isInsideLockedComposition: false,
177
+ boundingBox: { x: 0, y: 0, width: 120, height: 40 },
178
+ textContent: element.textContent,
179
+ dataAttributes: {},
180
+ inlineStyles: { color: "red" },
181
+ computedStyles: {},
182
+ textFields: [],
183
+ capabilities: {
184
+ canSelect: true,
185
+ canEditStyles: true,
186
+ canMove: true,
187
+ canResize: true,
188
+ canApplyManualOffset: true,
189
+ canApplyManualSize: true,
190
+ canApplyManualRotation: true,
191
+ },
192
+ hfId: "hf-card",
193
+ selector: '[data-hf-id="hf-card"]',
194
+ selectorIndex: 0,
195
+ };
196
+ return { ...base, ...overrides };
197
+ }
198
+
199
+ function renderDomEditCommits(
200
+ selection: DomEditSelection,
201
+ iframe: HTMLIFrameElement,
202
+ options: RenderDomEditCommitsOptions = {},
203
+ ) {
204
+ const captured: { current: ReturnType<typeof useDomEditCommits> | null } = { current: null };
205
+ const showToast = makeShowToast();
206
+ const recordEdit = vi.fn(async () => {});
207
+ const previewIframeRef: MutableRefObject<HTMLIFrameElement | null> = { current: iframe };
208
+ const projectIdRef: MutableRefObject<string | null> = { current: "p1" };
209
+ const domEditSaveTimestampRef: MutableRefObject<number> = { current: 0 };
210
+
211
+ function Probe() {
212
+ captured.current = useDomEditCommits({
213
+ activeCompPath: "index.html",
214
+ previewIframeRef,
215
+ showToast,
216
+ queueDomEditSave: async (save) => save(),
217
+ writeProjectFile: options.writeProjectFile ?? (async () => {}),
218
+ domEditSaveTimestampRef,
219
+ editHistory: { recordEdit },
220
+ fileTree: [],
221
+ importedFontAssetsRef: { current: options.importedFontAssets ?? [] },
222
+ projectId: "p1",
223
+ projectIdRef,
224
+ reloadPreview: vi.fn(),
225
+ domEditSelection: selection,
226
+ applyDomSelection: vi.fn(),
227
+ clearDomSelection: vi.fn(),
228
+ refreshDomEditSelectionFromPreview: vi.fn(),
229
+ buildDomSelectionFromTarget: vi.fn(async () => null),
230
+ });
231
+ return null;
232
+ }
233
+
234
+ const container = document.createElement("div");
235
+ const root: Root = createRoot(container);
236
+ act(() => {
237
+ root.render(createElement(Probe));
238
+ });
239
+
240
+ if (!captured.current) throw new Error("Expected hook result");
241
+ return {
242
+ hook: captured.current,
243
+ showToast,
244
+ recordEdit,
245
+ cleanup: () => {
246
+ act(() => {
247
+ root.unmount();
248
+ });
249
+ container.remove();
250
+ },
251
+ } satisfies RenderedDomEditCommits;
252
+ }
253
+
254
+ async function commitStyleAgainst(response: Parameters<typeof stubPatchFetch>[0]) {
255
+ stubPatchFetch(response);
256
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
257
+ const { iframe, element } = createPreviewElement();
258
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
259
+ await act(async () => {
260
+ await rendered.hook.handleDomStyleCommit("color", "blue");
261
+ });
262
+ return {
263
+ element,
264
+ rendered,
265
+ warnSpy,
266
+ cleanup: () => {
267
+ warnSpy.mockRestore();
268
+ rendered.cleanup();
269
+ },
270
+ };
271
+ }
272
+
273
+ function renderStyleCommitWithFetch(fetchHandler: FetchHandler) {
274
+ const fetchMock = vi.fn(fetchHandler);
275
+ vi.stubGlobal("fetch", fetchMock);
276
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
277
+ const { iframe, element } = createPreviewElement();
278
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
279
+ return {
280
+ element,
281
+ fetchMock,
282
+ rendered,
283
+ warnSpy,
284
+ cleanup: () => {
285
+ warnSpy.mockRestore();
286
+ rendered.cleanup();
287
+ },
288
+ };
289
+ }
290
+
291
+ async function expectRejectedTextStructureEdit(
292
+ commit: (hook: ReturnType<typeof useDomEditCommits>) => Promise<unknown>,
293
+ ): Promise<void> {
294
+ const fetchMock = stubUnexpectedPersistFetch();
295
+ const { iframe, element } = createPreviewElement(
296
+ '<div data-hf-id="hf-card"><span>First</span><span>Second</span></div>',
297
+ );
298
+ const originalInnerHtml = element.innerHTML;
299
+ const selection = createSelection(element, {
300
+ textFields: [
301
+ textField({ key: "first", value: "First", source: "child" }),
302
+ textField({ key: "second", value: "Second", source: "child" }),
303
+ ],
304
+ });
305
+ const rendered = renderDomEditCommits(selection, iframe);
306
+
307
+ try {
308
+ await act(async () => {
309
+ await commit(rendered.hook);
310
+ });
311
+
312
+ expect(fetchMock).not.toHaveBeenCalled();
313
+ expect(rendered.showToast).toHaveBeenCalledWith(
314
+ expect.stringContaining("text structure change"),
315
+ "error",
316
+ );
317
+ expect(element.innerHTML).toBe(originalInnerHtml);
318
+ expect(rendered.recordEdit).not.toHaveBeenCalled();
319
+ } finally {
320
+ rendered.cleanup();
321
+ }
322
+ }
323
+
324
+ describe("useDomEditCommits style persist handling", () => {
325
+ beforeEach(() => {
326
+ ensureCssEscape();
327
+ vi.clearAllMocks();
328
+ vi.unstubAllGlobals();
329
+ document.body.replaceChildren();
330
+ });
331
+
332
+ it("toasts and reverts a style commit when the server cannot resolve the source element", async () => {
333
+ const { element, rendered, cleanup } = await commitStyleAgainst({
334
+ ok: true,
335
+ changed: false,
336
+ matched: false,
337
+ });
338
+
339
+ try {
340
+ expect(rendered.showToast).toHaveBeenCalledWith(
341
+ expect.stringMatching(/Couldn't save "Hero title": Couldn't find this element/),
342
+ "error",
343
+ );
344
+ expect(element.style.getPropertyValue("color")).toBe("red");
345
+ expect(trackStudioEvent).toHaveBeenCalledWith(
346
+ "save_skipped_unresolvable",
347
+ expect.objectContaining({ target_source_file: "index.html" }),
348
+ );
349
+ } finally {
350
+ cleanup();
351
+ }
352
+ });
353
+
354
+ it("warns without a toast when the server matched the element but reported no change", async () => {
355
+ const { rendered, warnSpy, cleanup } = await commitStyleAgainst({
356
+ ok: true,
357
+ changed: false,
358
+ matched: true,
359
+ });
360
+
361
+ try {
362
+ expect(rendered.showToast).not.toHaveBeenCalled();
363
+ expect(warnSpy).toHaveBeenCalledWith(
364
+ "[Studio] DOM edit persist no-op",
365
+ expect.objectContaining({ operations: "inline-style:color" }),
366
+ );
367
+ } finally {
368
+ cleanup();
369
+ }
370
+ });
371
+
372
+ it("toasts and reverts a style commit when the patch request rejects", async () => {
373
+ const { element, rendered, cleanup } = await commitStyleAgainst(new Error("network down"));
374
+
375
+ try {
376
+ expect(rendered.showToast).toHaveBeenCalledWith(
377
+ 'Couldn\'t save "Hero title": network down',
378
+ "error",
379
+ );
380
+ expect(element.style.getPropertyValue("color")).toBe("red");
381
+ } finally {
382
+ cleanup();
383
+ }
384
+ });
385
+
386
+ it("keeps the optimistic style and records history when the patch succeeds", async () => {
387
+ const { element, rendered, cleanup } = await commitStyleAgainst({
388
+ ok: true,
389
+ changed: true,
390
+ matched: true,
391
+ content: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
392
+ });
393
+
394
+ try {
395
+ expect(rendered.showToast).not.toHaveBeenCalled();
396
+ expect(element.style.getPropertyValue("color")).toBe("blue");
397
+ expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
398
+ } finally {
399
+ cleanup();
400
+ }
401
+ });
402
+
403
+ it("keeps a newer style value when an older overlapping commit later fails", async () => {
404
+ const firstPatch = createDeferred<Response>();
405
+ const secondPatch = createDeferred<Response>();
406
+ let patchCount = 0;
407
+ const { element, rendered, cleanup } = renderStyleCommitWithFetch(async (input) => {
408
+ const url = requestUrl(input);
409
+ if (url.includes("/api/projects/p1/files/")) {
410
+ return jsonResponse({
411
+ content: '<div data-hf-id="hf-card" style="color: red">Card</div>',
412
+ });
413
+ }
414
+ if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
415
+ patchCount += 1;
416
+ return patchCount === 1 ? firstPatch.promise : secondPatch.promise;
417
+ }
418
+ throw new Error(`Unexpected fetch: ${url}`);
419
+ });
420
+
421
+ try {
422
+ const firstCommit = rendered.hook.handleDomStyleCommit("color", "blue");
423
+ await flushAsyncWork();
424
+ expect(patchCount).toBe(1);
425
+
426
+ const secondCommit = rendered.hook.handleDomStyleCommit("color", "green");
427
+ await flushAsyncWork();
428
+ expect(patchCount).toBe(2);
429
+
430
+ secondPatch.resolve(
431
+ jsonResponse({
432
+ ok: true,
433
+ changed: true,
434
+ matched: true,
435
+ content: '<div data-hf-id="hf-card" style="color: green">Card</div>',
436
+ }),
437
+ );
438
+ await secondCommit;
439
+ expect(element.style.getPropertyValue("color")).toBe("green");
440
+
441
+ firstPatch.reject(new Error("server rejected blue"));
442
+ await firstCommit;
443
+
444
+ expect(element.style.getPropertyValue("color")).toBe("green");
445
+ } finally {
446
+ cleanup();
447
+ }
448
+ });
449
+
450
+ it("toasts read failures from the source file fetch", async () => {
451
+ const { rendered, cleanup } = renderStyleCommitWithFetch(async (input) => {
452
+ const url = requestUrl(input);
453
+ if (url.includes("/api/projects/p1/files/")) {
454
+ return new Response("read failed", { status: 503 });
455
+ }
456
+ throw new Error(`Unexpected fetch: ${url}`);
457
+ });
458
+
459
+ try {
460
+ await act(async () => {
461
+ await rendered.hook.handleDomStyleCommit("color", "blue");
462
+ });
463
+
464
+ expect(rendered.showToast).toHaveBeenCalledWith(
465
+ expect.stringContaining("Failed to read index.html (503)"),
466
+ "error",
467
+ );
468
+ } finally {
469
+ cleanup();
470
+ }
471
+ });
472
+
473
+ it("keeps the already-persisted patch and toasts once when the prepareContent write fails", async () => {
474
+ stubPatchFetch(
475
+ {
476
+ ok: true,
477
+ changed: true,
478
+ matched: true,
479
+ content:
480
+ '<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>',
481
+ },
482
+ '<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>',
483
+ );
484
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
485
+ const { iframe, element } = createPreviewElement();
486
+ const selection = createSelection(element, {
487
+ textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
488
+ });
489
+ const rendered = renderDomEditCommits(createSelection(element), iframe, {
490
+ writeProjectFile: async () => {
491
+ throw new StudioSaveHttpError("Failed to save index.html (500)", 500);
492
+ },
493
+ });
494
+
495
+ try {
496
+ await act(async () => {
497
+ await rendered.hook.commitDomTextFields(
498
+ selection,
499
+ [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
500
+ {
501
+ importedFont: {
502
+ family: "Imported",
503
+ path: "fonts/Imported.woff2",
504
+ url: "/api/projects/p1/preview/fonts/Imported.woff2",
505
+ },
506
+ },
507
+ );
508
+ });
509
+
510
+ // The base patch already landed server-side before the font-face write
511
+ // failed, so this is recorded as a completed edit (not reverted/re-toasted
512
+ // as a full failure) — only the font embellishment is reported as lost.
513
+ expect(rendered.showToast).toHaveBeenCalledTimes(1);
514
+ expect(rendered.showToast).toHaveBeenCalledWith(
515
+ expect.stringContaining("Saved, but couldn't finish updating index.html"),
516
+ "error",
517
+ );
518
+ expect(rendered.showToast).toHaveBeenCalledWith(
519
+ expect.stringContaining("Failed to save index.html (500)"),
520
+ "error",
521
+ );
522
+ expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
523
+ } finally {
524
+ warnSpy.mockRestore();
525
+ rendered.cleanup();
526
+ }
527
+ });
528
+
529
+ it("keeps a rejected patch request (HTTP error) to one toast", async () => {
530
+ const { rendered, cleanup } = renderStyleCommitWithFetch(async (input) => {
531
+ const url = requestUrl(input);
532
+ if (url.includes("/api/projects/p1/files/")) {
533
+ return jsonResponse({
534
+ content: '<div data-hf-id="hf-card" style="color: red">Card</div>',
535
+ });
536
+ }
537
+ if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
538
+ return jsonResponse({ error: "invalid value", fields: ["style.color"] }, 400);
539
+ }
540
+ throw new Error(`Unexpected fetch: ${url}`);
541
+ });
542
+
543
+ try {
544
+ await act(async () => {
545
+ await rendered.hook.handleDomStyleCommit("color", "blue");
546
+ });
547
+
548
+ expect(rendered.showToast).toHaveBeenCalledTimes(1);
549
+ expect(rendered.showToast).toHaveBeenCalledWith(
550
+ "Couldn't save edit: invalid value (style.color)",
551
+ "error",
552
+ );
553
+ } finally {
554
+ cleanup();
555
+ }
556
+ });
557
+
558
+ it("keeps the unsafe-value path to one toast", async () => {
559
+ stubPatchFetch({
560
+ ok: true,
561
+ changed: true,
562
+ matched: true,
563
+ content: '<div data-hf-id="hf-card" style="color: blue">Card</div>',
564
+ });
565
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
566
+ const { iframe, element } = createPreviewElement();
567
+ const rendered = renderDomEditCommits(createSelection(element, { id: null }), iframe);
568
+
569
+ try {
570
+ await act(async () => {
571
+ await rendered.hook.handleDomStyleCommit("color", "blue");
572
+ });
573
+
574
+ expect(rendered.showToast).toHaveBeenCalledTimes(1);
575
+ expect(rendered.showToast).toHaveBeenCalledWith(
576
+ "Couldn't save edit because it contains invalid layout values",
577
+ "error",
578
+ );
579
+ } finally {
580
+ warnSpy.mockRestore();
581
+ rendered.cleanup();
582
+ }
583
+ });
584
+
585
+ it("refuses added child text fields without persisting serialized markup", async () => {
586
+ await expectRejectedTextStructureEdit((hook) => hook.handleDomAddTextField("first"));
587
+ });
588
+
589
+ it("refuses removed child text fields without persisting serialized markup", async () => {
590
+ await expectRejectedTextStructureEdit((hook) => hook.handleDomRemoveTextField("first"));
591
+ });
592
+
593
+ it("keeps single self text commits on the text-content path", async () => {
594
+ stubPatchFetch({
595
+ ok: true,
596
+ changed: true,
597
+ matched: true,
598
+ content: '<div data-hf-id="hf-card">A &lt; B</div>',
599
+ });
600
+ const { iframe, element } = createPreviewElement('<div data-hf-id="hf-card">Card</div>');
601
+ const selection = createSelection(element, {
602
+ textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
603
+ });
604
+ const rendered = renderDomEditCommits(selection, iframe);
605
+
606
+ try {
607
+ await act(async () => {
608
+ await rendered.hook.handleDomTextCommit("A < B", "self");
609
+ });
610
+
611
+ expect(rendered.showToast).not.toHaveBeenCalled();
612
+ expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
613
+ expect(element.textContent).toBe("A < B");
614
+ } finally {
615
+ rendered.cleanup();
616
+ }
617
+ });
618
+
619
+ it("reverts and toasts a text commit when the server rejects the patch", async () => {
620
+ stubPatchFetch(new Error("network down"));
621
+ const { iframe, element } = createPreviewElement('<div data-hf-id="hf-card">Card</div>');
622
+ const selection = createSelection(element, {
623
+ textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
624
+ });
625
+ const rendered = renderDomEditCommits(selection, iframe);
626
+
627
+ try {
628
+ await act(async () => {
629
+ await rendered.hook.handleDomTextCommit("Updated", "self");
630
+ });
631
+
632
+ expect(rendered.showToast).toHaveBeenCalledWith(
633
+ 'Couldn\'t save "Hero title": network down',
634
+ "error",
635
+ );
636
+ expect(element.textContent).toBe("Card");
637
+ expect(rendered.recordEdit).not.toHaveBeenCalled();
638
+ } finally {
639
+ rendered.cleanup();
640
+ }
641
+ });
642
+ });
643
+
644
+ describe("useDomEditCommits attribute persist handling", () => {
645
+ beforeEach(() => {
646
+ ensureCssEscape();
647
+ vi.clearAllMocks();
648
+ vi.unstubAllGlobals();
649
+ document.body.replaceChildren();
650
+ });
651
+
652
+ it("toasts and reverts a data-attribute commit when the server cannot resolve the source element", async () => {
653
+ stubPatchFetch({ ok: true, changed: false, matched: false });
654
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
655
+ const { iframe, element } = createPreviewElement();
656
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
657
+
658
+ try {
659
+ await act(async () => {
660
+ await rendered.hook.handleDomAttributeCommit("volume", "0.8");
661
+ });
662
+
663
+ expect(rendered.showToast).toHaveBeenCalledWith(
664
+ expect.stringMatching(/Couldn't save "Hero title": Couldn't find this element/),
665
+ "error",
666
+ );
667
+ expect(element.getAttribute("data-volume")).toBeNull();
668
+ } finally {
669
+ warnSpy.mockRestore();
670
+ rendered.cleanup();
671
+ }
672
+ });
673
+
674
+ it("toasts and reverts a data-attribute commit when the patch request rejects", async () => {
675
+ stubPatchFetch(new Error("network down"));
676
+ const { iframe, element } = createPreviewElement();
677
+ element.setAttribute("data-volume", "0.5");
678
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
679
+
680
+ try {
681
+ await act(async () => {
682
+ await rendered.hook.handleDomAttributeCommit("volume", "0.8");
683
+ });
684
+
685
+ expect(rendered.showToast).toHaveBeenCalledWith(
686
+ 'Couldn\'t save "Hero title": network down',
687
+ "error",
688
+ );
689
+ expect(element.getAttribute("data-volume")).toBe("0.5");
690
+ } finally {
691
+ rendered.cleanup();
692
+ }
693
+ });
694
+
695
+ it("keeps a data-attribute commit on success", async () => {
696
+ stubPatchFetch({
697
+ ok: true,
698
+ changed: true,
699
+ matched: true,
700
+ content: '<div data-hf-id="hf-card" data-volume="0.8">Card</div>',
701
+ });
702
+ const { iframe, element } = createPreviewElement();
703
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
704
+
705
+ try {
706
+ await act(async () => {
707
+ await rendered.hook.handleDomAttributeCommit("volume", "0.8");
708
+ });
709
+
710
+ expect(rendered.showToast).not.toHaveBeenCalled();
711
+ expect(element.getAttribute("data-volume")).toBe("0.8");
712
+ } finally {
713
+ rendered.cleanup();
714
+ }
715
+ });
716
+
717
+ it("toasts and reverts an html-attribute commit when the patch request rejects", async () => {
718
+ stubPatchFetch(new Error("network down"));
719
+ const { iframe, element } = createPreviewElement();
720
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
721
+
722
+ try {
723
+ await act(async () => {
724
+ await rendered.hook.handleDomHtmlAttributeCommit("muted", "true");
725
+ });
726
+
727
+ expect(rendered.showToast).toHaveBeenCalledWith(
728
+ 'Couldn\'t save "Hero title": network down',
729
+ "error",
730
+ );
731
+ expect(element.getAttribute("muted")).toBeNull();
732
+ } finally {
733
+ rendered.cleanup();
734
+ }
735
+ });
736
+
737
+ it("keeps a newer html-attribute value when an older overlapping commit later fails", async () => {
738
+ const first = createDeferred<Response>();
739
+ let call = 0;
740
+ const { iframe, element } = createPreviewElement();
741
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
742
+ vi.stubGlobal(
743
+ "fetch",
744
+ vi.fn(async (input: Parameters<typeof fetch>[0]) => {
745
+ const url = requestUrl(input);
746
+ if (url.includes("/api/projects/p1/files/")) {
747
+ return jsonResponse({ content: '<div data-hf-id="hf-card"></div>' });
748
+ }
749
+ call += 1;
750
+ if (call === 1) return first.promise;
751
+ return jsonResponse({ ok: true, changed: true, matched: true, content: "" });
752
+ }),
753
+ );
754
+
755
+ try {
756
+ // Older commit's persist stays pending (captures previousValue=null); the
757
+ // newer commit captures previousValue="first-value" (the older commit's
758
+ // optimistic apply) and succeeds before the older one rejects. Without the
759
+ // per-key version guard, the stale rejection would revert to the older
760
+ // commit's own previousValue (null) and stomp the newer commit's value.
761
+ const firstCommit = act(async () => {
762
+ await rendered.hook.handleDomHtmlAttributeCommit("muted", "first-value");
763
+ });
764
+ await act(async () => {
765
+ await rendered.hook.handleDomHtmlAttributeCommit("muted", "second-value");
766
+ });
767
+ first.reject(new Error("stale request failed"));
768
+ await firstCommit;
769
+
770
+ expect(element.getAttribute("muted")).toBe("second-value");
771
+ } finally {
772
+ rendered.cleanup();
773
+ }
774
+ });
775
+ });