@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.
- package/dist/assets/{index-BGUJ2C2G.js → index-BhSyGx7y.js} +1 -1
- package/dist/assets/{index-Bz6Eqd_G.js → index-D0yNztV_.js} +1 -1
- package/dist/assets/{index-CLlPjdPl.js → index-kbACg3_I.js} +139 -139
- package/dist/{chunk-AN2EWWK3.js → chunk-JND3XUJL.js} +38 -10
- package/dist/chunk-JND3XUJL.js.map +1 -0
- package/dist/{domEditingLayers-EK7R7R4G.js → domEditingLayers-UIQZJCOA.js} +4 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.html +1 -1
- package/dist/index.js +1145 -675
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/DomEditOverlay.test.ts +95 -0
- package/src/components/editor/DomEditOverlay.tsx +5 -3
- package/src/components/editor/domEditOverlayGestures.ts +3 -4
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.test.ts +52 -0
- package/src/components/editor/domEditingLayers.ts +55 -19
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/persistSeam.integration.test.ts +264 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +15 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +1 -0
- package/src/hooks/domEditCommitRunner.ts +46 -0
- package/src/hooks/domEditPersistFailure.test.ts +123 -0
- package/src/hooks/domEditPersistFailure.ts +89 -0
- package/src/hooks/domEditTextFieldCommitOps.test.ts +111 -0
- package/src/hooks/domEditTextFieldCommitOps.ts +63 -0
- package/src/hooks/domSelectionTestHarness.ts +40 -0
- package/src/hooks/useDomEditAttributeCommits.ts +227 -0
- package/src/hooks/useDomEditCommits.test.tsx +775 -0
- package/src/hooks/useDomEditCommits.ts +33 -5
- package/src/hooks/useDomEditTextCommits.ts +243 -220
- package/src/hooks/useDomSelection.test.ts +134 -0
- package/src/hooks/useDomSelection.ts +29 -15
- package/src/hooks/usePreviewInteraction.test.ts +260 -0
- package/src/hooks/usePreviewInteraction.ts +60 -19
- package/src/utils/sdkCutoverEligibility.test.ts +17 -0
- package/src/utils/sdkCutoverEligibility.ts +8 -2
- package/src/utils/sdkResolverShadow.test.ts +180 -1
- package/src/utils/sdkResolverShadow.ts +94 -1
- package/src/utils/sourcePatcher.ts +2 -0
- package/src/utils/studioPreviewHelpers.test.ts +95 -1
- package/src/utils/studioPreviewHelpers.ts +109 -10
- package/src/utils/studioSaveDiagnostics.ts +5 -2
- package/src/utils/studioTelemetry.ts +27 -20
- package/dist/chunk-AN2EWWK3.js.map +0 -1
- /package/dist/{domEditingLayers-EK7R7R4G.js.map → domEditingLayers-UIQZJCOA.js.map} +0 -0
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
3
|
import {
|
|
3
4
|
sdkResolverShadowCheck,
|
|
4
5
|
runResolverShadow,
|
|
5
6
|
recordResolverParity,
|
|
6
7
|
recordAnimationResolverParity,
|
|
7
8
|
evaluateSoakGate,
|
|
9
|
+
recordAttempt,
|
|
10
|
+
flushAttemptCounts,
|
|
11
|
+
__resetAttemptSchedulingForTests,
|
|
8
12
|
type SdkResolverMismatch,
|
|
9
13
|
} from "./sdkResolverShadow";
|
|
10
14
|
import type { PatchOperation } from "./sourcePatcher";
|
|
@@ -13,12 +17,15 @@ import { openComposition } from "@hyperframes/sdk";
|
|
|
13
17
|
// ─── Telemetry capture ────────────────────────────────────────────────────────
|
|
14
18
|
|
|
15
19
|
const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
|
|
20
|
+
const flushViaBeacon = vi.fn();
|
|
16
21
|
vi.mock("./studioTelemetry", () => ({
|
|
17
22
|
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
|
|
18
23
|
trackedEvents.push({ event, props }),
|
|
24
|
+
flushViaBeacon: () => flushViaBeacon(),
|
|
19
25
|
}));
|
|
20
26
|
beforeEach(() => {
|
|
21
27
|
trackedEvents.length = 0;
|
|
28
|
+
flushViaBeacon.mockClear();
|
|
22
29
|
});
|
|
23
30
|
const lastShadow = () =>
|
|
24
31
|
trackedEvents.filter((e) => e.event === "sdk_resolver_shadow").at(-1)?.props;
|
|
@@ -532,3 +539,175 @@ describe("H. inlined sub-composition leaf", () => {
|
|
|
532
539
|
expect(mismatches.some((m) => m.kind === "element_not_found")).toBe(false);
|
|
533
540
|
});
|
|
534
541
|
});
|
|
542
|
+
|
|
543
|
+
// ─── I. Attempt counter (denominator for the soak gate) ───────────────────────
|
|
544
|
+
|
|
545
|
+
describe("I. recordAttempt / flushAttemptCounts", () => {
|
|
546
|
+
beforeEach(() => {
|
|
547
|
+
// Drain any counts left over from a prior test so each test starts clean.
|
|
548
|
+
flushAttemptCounts();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("flushAttemptCounts returns null when nothing has been recorded", () => {
|
|
552
|
+
expect(flushAttemptCounts()).toBeNull();
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("increments the counter for a given op label", () => {
|
|
556
|
+
recordAttempt("setTiming");
|
|
557
|
+
expect(flushAttemptCounts()).toEqual({ setTiming: 1 });
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("accumulates multiple calls with the same label", () => {
|
|
561
|
+
recordAttempt("setTiming");
|
|
562
|
+
recordAttempt("setTiming");
|
|
563
|
+
recordAttempt("setTiming");
|
|
564
|
+
expect(flushAttemptCounts()).toEqual({ setTiming: 3 });
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("tracks different labels independently", () => {
|
|
568
|
+
recordAttempt("setTiming");
|
|
569
|
+
recordAttempt("addGsapTween");
|
|
570
|
+
recordAttempt("setTiming");
|
|
571
|
+
expect(flushAttemptCounts()).toEqual({ setTiming: 2, addGsapTween: 1 });
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("resets to empty after a flush", () => {
|
|
575
|
+
recordAttempt("setTiming");
|
|
576
|
+
flushAttemptCounts();
|
|
577
|
+
expect(flushAttemptCounts()).toBeNull();
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
describe("I. attempt counting inside the three emit functions", () => {
|
|
582
|
+
beforeEach(() => {
|
|
583
|
+
flushAttemptCounts();
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("runResolverShadow counts an attempt on the parity (silent) path", async () => {
|
|
587
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
588
|
+
const session = await openComposition(BASE_HTML);
|
|
589
|
+
runResolverShadow(session, "hf-box", [
|
|
590
|
+
{ type: "inline-style", property: "color", value: "blue" },
|
|
591
|
+
]);
|
|
592
|
+
expect(flushAttemptCounts()).toEqual({ "dom-edit": 1 });
|
|
593
|
+
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("runResolverShadow counts an attempt on the divergence (emits) path too", async () => {
|
|
597
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
598
|
+
const session = await openComposition(BASE_HTML);
|
|
599
|
+
runResolverShadow(session, "hf-missing", [
|
|
600
|
+
{ type: "inline-style", property: "color", value: "blue" },
|
|
601
|
+
]);
|
|
602
|
+
expect(flushAttemptCounts()).toEqual({ "dom-edit": 1 });
|
|
603
|
+
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("recordResolverParity counts an attempt on the parity (silent) path", async () => {
|
|
607
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
608
|
+
const session = await openComposition(BASE_HTML);
|
|
609
|
+
await recordResolverParity(session, "hf-box", "setTiming");
|
|
610
|
+
expect(flushAttemptCounts()).toEqual({ setTiming: 1 });
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("recordResolverParity counts an attempt on the divergence (emits) path too", async () => {
|
|
614
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
615
|
+
const session = await openComposition(BASE_HTML);
|
|
616
|
+
await recordResolverParity(session, "hf-missing", "setTiming");
|
|
617
|
+
expect(flushAttemptCounts()).toEqual({ setTiming: 1 });
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("recordAnimationResolverParity counts an attempt on the parity (silent) path", async () => {
|
|
621
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
622
|
+
const session = await openComposition(GSAP_HTML);
|
|
623
|
+
const realId = session.getElements().flatMap((e) => [...e.animationIds])[0] ?? "";
|
|
624
|
+
expect(realId).not.toBe(""); // fixture has a tween on hf-box, see block G above
|
|
625
|
+
recordAnimationResolverParity(session, realId, "removeGsapTween");
|
|
626
|
+
expect(flushAttemptCounts()).toEqual({ removeGsapTween: 1 });
|
|
627
|
+
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("recordAnimationResolverParity counts an attempt on the divergence (emits) path too", async () => {
|
|
631
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
632
|
+
const session = await openComposition(GSAP_HTML);
|
|
633
|
+
recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
|
|
634
|
+
expect(flushAttemptCounts()).toEqual({ setGsapTween: 1 });
|
|
635
|
+
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it("counts accumulate across multiple different chokepoints in one rollup", async () => {
|
|
639
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
640
|
+
const session = await openComposition(BASE_HTML);
|
|
641
|
+
await recordResolverParity(session, "hf-box", "setTiming");
|
|
642
|
+
await recordResolverParity(session, "hf-box", "setTiming");
|
|
643
|
+
recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
|
|
644
|
+
expect(flushAttemptCounts()).toEqual({ setTiming: 2, setGsapTween: 1 });
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("does not count an attempt when the flag is off", async () => {
|
|
648
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
|
|
649
|
+
const session = await openComposition(BASE_HTML);
|
|
650
|
+
runResolverShadow(session, "hf-box", [
|
|
651
|
+
{ type: "inline-style", property: "color", value: "blue" },
|
|
652
|
+
]);
|
|
653
|
+
await recordResolverParity(session, "hf-box", "setTiming");
|
|
654
|
+
recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
|
|
655
|
+
expect(flushAttemptCounts()).toBeNull();
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
describe("I. production rollup wiring", () => {
|
|
660
|
+
beforeEach(() => {
|
|
661
|
+
flushAttemptCounts();
|
|
662
|
+
__resetAttemptSchedulingForTests();
|
|
663
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "setInterval", "clearInterval", "clearTimeout"] });
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
afterEach(() => {
|
|
667
|
+
__resetAttemptSchedulingForTests();
|
|
668
|
+
vi.useRealTimers();
|
|
669
|
+
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("does not emit a rollup event when nothing was recorded", () => {
|
|
673
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
674
|
+
vi.advanceTimersByTime(5 * 60_000);
|
|
675
|
+
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt")).toHaveLength(0);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("emits a sdk_resolver_shadow_attempt rollup event every 5 minutes after the first attempt", async () => {
|
|
679
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
680
|
+
const session = await openComposition(BASE_HTML);
|
|
681
|
+
await recordResolverParity(session, "hf-box", "setTiming");
|
|
682
|
+
vi.advanceTimersByTime(5 * 60_000);
|
|
683
|
+
const rollups = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt");
|
|
684
|
+
expect(rollups).toHaveLength(1);
|
|
685
|
+
expect(rollups[0].props.counts).toBe(JSON.stringify({ setTiming: 1 }));
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("flushes a rollup and forces a beacon delivery on visibilitychange -> hidden", async () => {
|
|
689
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
690
|
+
const session = await openComposition(BASE_HTML);
|
|
691
|
+
await recordResolverParity(session, "hf-box", "setTiming");
|
|
692
|
+
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
|
|
693
|
+
document.dispatchEvent(new Event("visibilitychange"));
|
|
694
|
+
const rollups = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt");
|
|
695
|
+
expect(rollups).toHaveLength(1);
|
|
696
|
+
expect(rollups[0].props.counts).toBe(JSON.stringify({ setTiming: 1 }));
|
|
697
|
+
// Delivery must not depend on studioTelemetry's own visibilitychange listener
|
|
698
|
+
// winning a race — this module forces its own beacon flush.
|
|
699
|
+
expect(flushViaBeacon).toHaveBeenCalledTimes(1);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it("does not register a duplicate visibilitychange listener after a scheduling reset", async () => {
|
|
703
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
704
|
+
const session = await openComposition(BASE_HTML);
|
|
705
|
+
await recordResolverParity(session, "hf-box", "setTiming");
|
|
706
|
+
__resetAttemptSchedulingForTests();
|
|
707
|
+
await recordResolverParity(session, "hf-box", "setTiming"); // re-arms scheduling, incl. listener
|
|
708
|
+
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
|
|
709
|
+
document.dispatchEvent(new Event("visibilitychange"));
|
|
710
|
+
// If the reset had leaked the old listener, this would fire twice.
|
|
711
|
+
expect(flushViaBeacon).toHaveBeenCalledTimes(1);
|
|
712
|
+
});
|
|
713
|
+
});
|
|
@@ -19,7 +19,7 @@ import type { Composition, JsonPatchOp } from "@hyperframes/sdk";
|
|
|
19
19
|
import type { PatchOperation } from "./sourcePatcher";
|
|
20
20
|
import { STUDIO_SDK_RESOLVER_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
21
21
|
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
|
22
|
-
import { trackStudioEvent } from "./studioTelemetry";
|
|
22
|
+
import { trackStudioEvent, flushViaBeacon } from "./studioTelemetry";
|
|
23
23
|
|
|
24
24
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
25
25
|
|
|
@@ -229,6 +229,96 @@ export function sdkResolverShadowCheck(
|
|
|
229
229
|
}
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
// ─── Attempt counter (denominator for the soak gate) ──────────────────────────
|
|
233
|
+
//
|
|
234
|
+
// The three emit functions below only fire a PostHog event on divergence —
|
|
235
|
+
// parity is silent, by design, to avoid firing on every edit. That leaves no
|
|
236
|
+
// way to compute a rate (divergences / attempts): we can count failures but
|
|
237
|
+
// never attempts. This counter tracks attempts in memory and rolls them up
|
|
238
|
+
// into ONE low-frequency event instead of firing per-attempt, which would
|
|
239
|
+
// recreate the exact chattiness problem the divergence-only design avoids.
|
|
240
|
+
|
|
241
|
+
const attemptCounts: Record<string, number> = {};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Record that the resolver-shadow tripwire ran for `opLabel`, regardless of
|
|
245
|
+
* outcome (parity or divergence). No flag check of its own — only ever called
|
|
246
|
+
* from inside the three emit functions below, after their own
|
|
247
|
+
* STUDIO_SDK_RESOLVER_SHADOW_ENABLED guard, so it's already flag-gated.
|
|
248
|
+
*/
|
|
249
|
+
export function recordAttempt(opLabel: string): void {
|
|
250
|
+
attemptCounts[opLabel] = (attemptCounts[opLabel] ?? 0) + 1;
|
|
251
|
+
ensureAttemptFlushScheduled();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Return the accumulated attempt counts since the last flush (or `null` if
|
|
256
|
+
* nothing has been recorded — no point emitting an empty rollup), and reset
|
|
257
|
+
* the counter to empty.
|
|
258
|
+
*/
|
|
259
|
+
export function flushAttemptCounts(): Record<string, number> | null {
|
|
260
|
+
const keys = Object.keys(attemptCounts);
|
|
261
|
+
if (keys.length === 0) return null;
|
|
262
|
+
const snapshot: Record<string, number> = {};
|
|
263
|
+
for (const key of keys) {
|
|
264
|
+
snapshot[key] = attemptCounts[key];
|
|
265
|
+
delete attemptCounts[key];
|
|
266
|
+
}
|
|
267
|
+
return snapshot;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const ATTEMPT_FLUSH_INTERVAL_MS = 5 * 60_000;
|
|
271
|
+
let attemptFlushTimer: ReturnType<typeof setInterval> | null = null;
|
|
272
|
+
let attemptVisibilityHandler: (() => void) | null = null;
|
|
273
|
+
|
|
274
|
+
function flushAndEmitAttempts(): void {
|
|
275
|
+
const counts = flushAttemptCounts();
|
|
276
|
+
if (counts === null) return;
|
|
277
|
+
trackStudioEvent("sdk_resolver_shadow_attempt", { counts: JSON.stringify(counts) });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Lazily starts the rollup timer + visibilitychange listener on the FIRST
|
|
281
|
+
// attempt in a session — mirrors studioTelemetry.ts's own lazy flushTimer
|
|
282
|
+
// start, so a session that never exercises the tripwire never runs a
|
|
283
|
+
// background timer.
|
|
284
|
+
function ensureAttemptFlushScheduled(): void {
|
|
285
|
+
if (!attemptFlushTimer) {
|
|
286
|
+
attemptFlushTimer = setInterval(flushAndEmitAttempts, ATTEMPT_FLUSH_INTERVAL_MS);
|
|
287
|
+
}
|
|
288
|
+
if (!attemptVisibilityHandler && typeof document !== "undefined") {
|
|
289
|
+
attemptVisibilityHandler = () => {
|
|
290
|
+
if (document.visibilityState !== "hidden") return;
|
|
291
|
+
flushAndEmitAttempts();
|
|
292
|
+
// studioTelemetry.ts registers its own visibilitychange listener (on
|
|
293
|
+
// window, at module load) that drains its queue via sendBeacon. Listener
|
|
294
|
+
// execution order between that handler and this one (on document,
|
|
295
|
+
// registered lazily) is not something to rely on — whichever runs
|
|
296
|
+
// first could otherwise beacon-flush before or after this rollup lands
|
|
297
|
+
// in the queue. Forcing a beacon flush here makes delivery of this
|
|
298
|
+
// rollup event correct regardless of that order.
|
|
299
|
+
flushViaBeacon();
|
|
300
|
+
};
|
|
301
|
+
document.addEventListener("visibilitychange", attemptVisibilityHandler);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Test-only: clears the lazy timer/listener singleton state so tests can
|
|
307
|
+
* verify the "starts on first attempt" behavior in isolation, without an
|
|
308
|
+
* earlier test's real-timer interval (or visibilitychange listener) silently
|
|
309
|
+
* surviving into a later test. Does NOT touch attemptCounts — only the
|
|
310
|
+
* scheduling state. Not part of the public module contract; only imported
|
|
311
|
+
* from sdkResolverShadow.test.ts.
|
|
312
|
+
*/
|
|
313
|
+
export function __resetAttemptSchedulingForTests(): void {
|
|
314
|
+
if (attemptFlushTimer) clearInterval(attemptFlushTimer);
|
|
315
|
+
attemptFlushTimer = null;
|
|
316
|
+
if (attemptVisibilityHandler && typeof document !== "undefined") {
|
|
317
|
+
document.removeEventListener("visibilitychange", attemptVisibilityHandler);
|
|
318
|
+
}
|
|
319
|
+
attemptVisibilityHandler = null;
|
|
320
|
+
}
|
|
321
|
+
|
|
232
322
|
// ─── Telemetry ────────────────────────────────────────────────────────────────
|
|
233
323
|
|
|
234
324
|
// Redact all user-content values before telemetry: style values and text both
|
|
@@ -266,6 +356,7 @@ export function runResolverShadow(
|
|
|
266
356
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
|
267
357
|
if (!hfId) return;
|
|
268
358
|
try {
|
|
359
|
+
recordAttempt("dom-edit");
|
|
269
360
|
const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent);
|
|
270
361
|
// Emit only on divergence — parity is silent, matching recordResolverParity
|
|
271
362
|
// and recordAnimationResolverParity. Otherwise this fires a PostHog event on
|
|
@@ -318,6 +409,7 @@ export async function recordResolverParity(
|
|
|
318
409
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
|
319
410
|
if (!session || !hfId) return;
|
|
320
411
|
try {
|
|
412
|
+
recordAttempt(opLabel);
|
|
321
413
|
if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
|
|
322
414
|
// Capture BEFORE any await: this call is fire-and-forget (`void recordResolverParity(...)`)
|
|
323
415
|
// and the caller runs its own session mutation synchronously right after this call
|
|
@@ -380,6 +472,7 @@ export function recordAnimationResolverParity(
|
|
|
380
472
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
|
381
473
|
if (!session || !animationId) return;
|
|
382
474
|
try {
|
|
475
|
+
recordAttempt(opLabel);
|
|
383
476
|
const elements = session.getElements();
|
|
384
477
|
const resolves = elements.some((el) => el.animationIds.includes(animationId));
|
|
385
478
|
if (resolves) return; // SDK locates the animation — parity
|
|
@@ -90,6 +90,8 @@ export interface PatchOperation {
|
|
|
90
90
|
type: "inline-style" | "attribute" | "text-content" | "html-attribute";
|
|
91
91
|
property: string;
|
|
92
92
|
value: string | null;
|
|
93
|
+
childSelector?: string;
|
|
94
|
+
childIndex?: number;
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
// Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value
|
|
@@ -1,5 +1,29 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
1
3
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
coversComposition,
|
|
6
|
+
getPreviewTargetFromPointer,
|
|
7
|
+
pauseStudioPreviewPlayback,
|
|
8
|
+
} from "./studioPreviewHelpers";
|
|
9
|
+
|
|
10
|
+
function domRect(left: number, top: number, width: number, height: number): DOMRect {
|
|
11
|
+
return {
|
|
12
|
+
x: left,
|
|
13
|
+
y: top,
|
|
14
|
+
left,
|
|
15
|
+
top,
|
|
16
|
+
width,
|
|
17
|
+
height,
|
|
18
|
+
right: left + width,
|
|
19
|
+
bottom: top + height,
|
|
20
|
+
toJSON: () => ({}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stubRect(el: Element, rect: DOMRect): void {
|
|
25
|
+
el.getBoundingClientRect = () => rect;
|
|
26
|
+
}
|
|
3
27
|
|
|
4
28
|
describe("coversComposition (full-bleed canvas-pick exclusion)", () => {
|
|
5
29
|
const viewport = { width: 1920, height: 1080 };
|
|
@@ -79,3 +103,73 @@ describe("pauseStudioPreviewPlayback", () => {
|
|
|
79
103
|
expect(siblingPause).toHaveBeenCalledTimes(1);
|
|
80
104
|
});
|
|
81
105
|
});
|
|
106
|
+
|
|
107
|
+
describe("getPreviewTargetFromPointer", () => {
|
|
108
|
+
it("skips candidates hidden from author hit-testing by inherited pointer-events:none", () => {
|
|
109
|
+
const iframe = document.createElement("iframe");
|
|
110
|
+
document.body.append(iframe);
|
|
111
|
+
const doc = iframe.contentDocument;
|
|
112
|
+
if (!doc) throw new Error("Expected iframe document");
|
|
113
|
+
|
|
114
|
+
doc.body.innerHTML = `
|
|
115
|
+
<main id="scene" data-composition-id="scene">
|
|
116
|
+
<h1 id="headline">Launch title</h1>
|
|
117
|
+
<div id="overlay-parent" style="pointer-events: none;">
|
|
118
|
+
<div id="overlay" style="position: absolute; background: rgba(0, 0, 0, 0.1);"></div>
|
|
119
|
+
</div>
|
|
120
|
+
</main>
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
const scene = doc.getElementById("scene");
|
|
124
|
+
const headline = doc.getElementById("headline");
|
|
125
|
+
const overlayParent = doc.getElementById("overlay-parent");
|
|
126
|
+
const overlay = doc.getElementById("overlay");
|
|
127
|
+
if (!scene || !headline || !overlayParent || !overlay) {
|
|
128
|
+
throw new Error("Expected preview fixture elements");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
stubRect(iframe, domRect(0, 0, 400, 300));
|
|
132
|
+
stubRect(scene, domRect(0, 0, 400, 300));
|
|
133
|
+
stubRect(headline, domRect(40, 40, 160, 48));
|
|
134
|
+
stubRect(overlayParent, domRect(0, 0, 360, 260));
|
|
135
|
+
stubRect(overlay, domRect(0, 0, 360, 260));
|
|
136
|
+
doc.elementsFromPoint = () => [overlay, overlayParent, headline, scene];
|
|
137
|
+
|
|
138
|
+
expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
|
|
139
|
+
|
|
140
|
+
iframe.remove();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("honors a CSS-class pointer-events:auto opt-in under a pointer-events:none ancestor", () => {
|
|
144
|
+
const iframe = document.createElement("iframe");
|
|
145
|
+
document.body.append(iframe);
|
|
146
|
+
const doc = iframe.contentDocument;
|
|
147
|
+
if (!doc) throw new Error("Expected iframe document");
|
|
148
|
+
|
|
149
|
+
doc.head.innerHTML = `<style>.clickable { pointer-events: auto; }</style>`;
|
|
150
|
+
doc.body.innerHTML = `
|
|
151
|
+
<main id="scene" data-composition-id="scene">
|
|
152
|
+
<div id="overlay-parent" style="pointer-events: none;">
|
|
153
|
+
<button id="clickable-child" class="clickable">Play</button>
|
|
154
|
+
</div>
|
|
155
|
+
</main>
|
|
156
|
+
`;
|
|
157
|
+
|
|
158
|
+
const scene = doc.getElementById("scene");
|
|
159
|
+
const overlayParent = doc.getElementById("overlay-parent");
|
|
160
|
+
const clickableChild = doc.getElementById("clickable-child");
|
|
161
|
+
if (!scene || !overlayParent || !clickableChild) {
|
|
162
|
+
throw new Error("Expected preview fixture elements");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
stubRect(iframe, domRect(0, 0, 400, 300));
|
|
166
|
+
stubRect(scene, domRect(0, 0, 400, 300));
|
|
167
|
+
stubRect(overlayParent, domRect(0, 0, 360, 260));
|
|
168
|
+
stubRect(clickableChild, domRect(40, 40, 80, 24));
|
|
169
|
+
doc.elementsFromPoint = () => [clickableChild, overlayParent, scene];
|
|
170
|
+
|
|
171
|
+
expect(getPreviewTargetFromPointer(iframe, 60, 50, "index.html")).toBe(clickableChild);
|
|
172
|
+
|
|
173
|
+
iframe.remove();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isElementComputedVisible,
|
|
5
5
|
resolveAllVisualDomEditTargets,
|
|
6
6
|
} from "../components/editor/domEditingElement";
|
|
7
|
+
import { isHtmlElement } from "../components/editor/domEditingDom";
|
|
7
8
|
import { getEventTargetElement } from "./studioHelpers";
|
|
8
9
|
|
|
9
10
|
interface PreviewLocalPointer {
|
|
@@ -81,11 +82,90 @@ function removePointerEventsOverride(style: HTMLStyleElement | null): void {
|
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
const pointerEventsInheritanceFallbackByDocument = new WeakMap<Document, boolean>();
|
|
86
|
+
|
|
87
|
+
function needsPointerEventsInheritanceFallback(doc: Document, win: Window): boolean {
|
|
88
|
+
const cached = pointerEventsInheritanceFallbackByDocument.get(doc);
|
|
89
|
+
if (cached !== undefined) return cached;
|
|
90
|
+
|
|
91
|
+
const parent = doc.createElement("div");
|
|
92
|
+
const child = doc.createElement("div");
|
|
93
|
+
parent.style.pointerEvents = "none";
|
|
94
|
+
parent.appendChild(child);
|
|
95
|
+
const host = doc.body ?? doc.documentElement;
|
|
96
|
+
if (!host) return false;
|
|
97
|
+
|
|
98
|
+
host.appendChild(parent);
|
|
99
|
+
const needsFallback = win.getComputedStyle(child).pointerEvents !== "none";
|
|
100
|
+
parent.remove();
|
|
101
|
+
pointerEventsInheritanceFallbackByDocument.set(doc, needsFallback);
|
|
102
|
+
return needsFallback;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Own declared pointer-events value, via computed style rather than inline
|
|
106
|
+
// style, so a CSS-class opt-in/opt-out (not just an inline style attribute)
|
|
107
|
+
// is honored when walking back down from a pointer-events:none ancestor.
|
|
108
|
+
function hasOwnPointerEventsOverride(el: HTMLElement, win: Window): boolean {
|
|
109
|
+
const value = win.getComputedStyle(el).pointerEvents;
|
|
110
|
+
return value !== "" && value !== "inherit" && value !== "unset";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function inheritsPointerEventsNoneFromAncestor(el: HTMLElement, win: Window): boolean {
|
|
114
|
+
let current = el.parentElement;
|
|
115
|
+
while (current) {
|
|
116
|
+
if (win.getComputedStyle(current).pointerEvents === "none") {
|
|
117
|
+
let descendant: HTMLElement | null = el;
|
|
118
|
+
while (descendant && descendant !== current) {
|
|
119
|
+
if (hasOwnPointerEventsOverride(descendant, win)) {
|
|
120
|
+
return win.getComputedStyle(descendant).pointerEvents === "none";
|
|
121
|
+
}
|
|
122
|
+
descendant = descendant.parentElement;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
current = current.parentElement;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function hasAuthorPointerEventsNone(el: HTMLElement): boolean {
|
|
132
|
+
const win = el.ownerDocument.defaultView;
|
|
133
|
+
if (!win) return false;
|
|
134
|
+
if (win.getComputedStyle(el).pointerEvents === "none") return true;
|
|
135
|
+
if (!needsPointerEventsInheritanceFallback(el.ownerDocument, win)) return false;
|
|
136
|
+
return inheritsPointerEventsNoneFromAncestor(el, win);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function collectPointerEventsNoneTargets(
|
|
140
|
+
elements: Iterable<Element | null | undefined>,
|
|
141
|
+
): WeakSet<HTMLElement> {
|
|
142
|
+
const disabled = new WeakSet<HTMLElement>();
|
|
143
|
+
for (const entry of elements) {
|
|
144
|
+
if (isHtmlElement(entry) && hasAuthorPointerEventsNone(entry)) {
|
|
145
|
+
disabled.add(entry);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return disabled;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Shared tail of both pointer resolvers: hit-test candidates minus elements the
|
|
152
|
+
// author hid from hit-testing via pointer-events:none.
|
|
153
|
+
function filterAuthorInteractiveTargets(
|
|
154
|
+
elements: Element[],
|
|
155
|
+
activeCompositionPath: string | null,
|
|
156
|
+
): HTMLElement[] {
|
|
157
|
+
const pointerEventsNoneTargets = collectPointerEventsNoneTargets(elements);
|
|
158
|
+
return resolveAllVisualDomEditTargets(elements, { activeCompositionPath }).filter(
|
|
159
|
+
(el) => !pointerEventsNoneTargets.has(el),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
84
163
|
// Animated group members can move outside their wrapper's static layout box, so
|
|
85
164
|
// the empty space inside a group's *visual* bounds (the member-union the overlay
|
|
86
165
|
// draws) doesn't hit-test to the group via elementsFromPoint. Recover it: if the
|
|
87
166
|
// point falls within a group's live member-union rect, return that wrapper.
|
|
88
167
|
// Innermost (smallest-area) group wins for nested groups.
|
|
168
|
+
// fallow-ignore-next-line complexity
|
|
89
169
|
function findGroupAtPoint(doc: Document, x: number, y: number): HTMLElement | null {
|
|
90
170
|
let best: HTMLElement | null = null;
|
|
91
171
|
let bestArea = Infinity;
|
|
@@ -132,26 +212,39 @@ export function getPreviewTargetFromPointer(
|
|
|
132
212
|
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
|
133
213
|
if (!localPointer) return null;
|
|
134
214
|
|
|
135
|
-
|
|
215
|
+
let overrideStyle = forcePointerEventsAuto(doc);
|
|
136
216
|
try {
|
|
137
217
|
if (typeof doc.elementsFromPoint === "function") {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
);
|
|
218
|
+
const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
|
|
219
|
+
removePointerEventsOverride(overrideStyle);
|
|
220
|
+
overrideStyle = null;
|
|
221
|
+
const candidates = filterAuthorInteractiveTargets(elements, activeCompositionPath);
|
|
142
222
|
const visualTarget =
|
|
143
223
|
candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null;
|
|
144
224
|
if (visualTarget) return visualTarget;
|
|
145
225
|
}
|
|
146
226
|
|
|
227
|
+
// Belt-and-suspenders: elementsFromPoint is universally supported in the
|
|
228
|
+
// browsers this ships in, so the override is already removed by this
|
|
229
|
+
// point in practice — but guard the environment without it too, so
|
|
230
|
+
// hasAuthorPointerEventsNone below never reads a forced-auto value.
|
|
231
|
+
removePointerEventsOverride(overrideStyle);
|
|
232
|
+
overrideStyle = null;
|
|
233
|
+
|
|
147
234
|
// No element hit (e.g. empty space inside an animated group's overlay) — fall
|
|
148
235
|
// back to the group whose member-union contains the point, so the whole group
|
|
149
236
|
// area is hoverable/selectable, not just where a member currently sits.
|
|
150
237
|
const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y);
|
|
151
|
-
if (
|
|
238
|
+
if (
|
|
239
|
+
groupHit &&
|
|
240
|
+
!hasAuthorPointerEventsNone(groupHit) &&
|
|
241
|
+
getDomLayerPatchTarget(groupHit, activeCompositionPath)
|
|
242
|
+
)
|
|
243
|
+
return groupHit;
|
|
152
244
|
|
|
153
245
|
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
|
154
246
|
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
|
|
247
|
+
if (hasAuthorPointerEventsNone(fallback)) return null;
|
|
155
248
|
if (!isElementComputedVisible(fallback)) return null;
|
|
156
249
|
if (isFullBleedTarget(fallback, localPointer.viewport)) return null;
|
|
157
250
|
return fallback;
|
|
@@ -180,15 +273,21 @@ export function getAllPreviewTargetsFromPointer(
|
|
|
180
273
|
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
|
181
274
|
if (!localPointer) return [];
|
|
182
275
|
|
|
183
|
-
|
|
276
|
+
let overrideStyle = forcePointerEventsAuto(doc);
|
|
184
277
|
try {
|
|
185
278
|
if (typeof doc.elementsFromPoint === "function") {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
279
|
+
const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
|
|
280
|
+
removePointerEventsOverride(overrideStyle);
|
|
281
|
+
overrideStyle = null;
|
|
282
|
+
return filterAuthorInteractiveTargets(elements, activeCompositionPath).filter(
|
|
283
|
+
(el) => !isFullBleedTarget(el, localPointer.viewport),
|
|
284
|
+
);
|
|
189
285
|
}
|
|
190
286
|
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
|
191
287
|
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return [];
|
|
288
|
+
removePointerEventsOverride(overrideStyle);
|
|
289
|
+
overrideStyle = null;
|
|
290
|
+
if (hasAuthorPointerEventsNone(fallback)) return [];
|
|
192
291
|
if (!isElementComputedVisible(fallback)) return [];
|
|
193
292
|
if (isFullBleedTarget(fallback, localPointer.viewport)) return [];
|
|
194
293
|
return [fallback];
|
|
@@ -18,11 +18,13 @@ export interface StudioSaveFailureInput {
|
|
|
18
18
|
|
|
19
19
|
export class StudioSaveHttpError extends Error {
|
|
20
20
|
readonly statusCode: number;
|
|
21
|
+
readonly alreadyToasted: boolean;
|
|
21
22
|
|
|
22
|
-
constructor(message: string, statusCode: number) {
|
|
23
|
+
constructor(message: string, statusCode: number, options: { alreadyToasted?: boolean } = {}) {
|
|
23
24
|
super(message);
|
|
24
25
|
this.name = "StudioSaveHttpError";
|
|
25
26
|
this.statusCode = statusCode;
|
|
27
|
+
this.alreadyToasted = options.alreadyToasted ?? false;
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -130,6 +132,7 @@ export function trackStudioSaveFailure(input: StudioSaveFailureInput): void {
|
|
|
130
132
|
export async function createStudioSaveHttpError(
|
|
131
133
|
response: Response,
|
|
132
134
|
fallbackMessage: string,
|
|
135
|
+
options: { alreadyToasted?: boolean } = {},
|
|
133
136
|
): Promise<StudioSaveHttpError> {
|
|
134
137
|
let body = "";
|
|
135
138
|
try {
|
|
@@ -141,7 +144,7 @@ export async function createStudioSaveHttpError(
|
|
|
141
144
|
const message = detail
|
|
142
145
|
? `${fallbackMessage} (${response.status}): ${detail}`
|
|
143
146
|
: `${fallbackMessage} (${response.status})`;
|
|
144
|
-
return new StudioSaveHttpError(message, response.status);
|
|
147
|
+
return new StudioSaveHttpError(message, response.status, options);
|
|
145
148
|
}
|
|
146
149
|
|
|
147
150
|
export async function retryStudioSave<T>(
|