@hyperframes/studio 0.7.29 → 0.7.31

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.29",
3
+ "version": "0.7.31",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.29",
50
- "@hyperframes/parsers": "0.7.29",
51
- "@hyperframes/studio-server": "0.7.29",
52
- "@hyperframes/player": "0.7.29",
53
- "@hyperframes/sdk": "0.7.29"
49
+ "@hyperframes/core": "0.7.31",
50
+ "@hyperframes/parsers": "0.7.31",
51
+ "@hyperframes/sdk": "0.7.31",
52
+ "@hyperframes/player": "0.7.31",
53
+ "@hyperframes/studio-server": "0.7.31"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.29"
68
+ "@hyperframes/producer": "0.7.31"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
@@ -1,10 +1,14 @@
1
- import { describe, expect, it, vi, beforeEach } from "vitest";
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,27 +90,34 @@ async function flushEvents(): Promise<void> {
90
90
  }
91
91
  }
92
92
 
93
+ // Synchronously drains the queue via sendBeacon — safe to call from any
94
+ // tab-hide handler regardless of listener registration order. Exported so
95
+ // other modules (e.g. sdkResolverShadow.ts) can force delivery of an event
96
+ // they just queued without racing this module's own visibilitychange
97
+ // listener below.
98
+ export function flushViaBeacon(): void {
99
+ if (flushTimer) {
100
+ clearInterval(flushTimer);
101
+ flushTimer = null;
102
+ }
103
+ if (queue.length === 0) return;
104
+ const batch = queue.map((e) => ({
105
+ event: e.event,
106
+ properties: { ...e.properties, $ip: null },
107
+ distinct_id: getDistinctId(),
108
+ timestamp: e.timestamp,
109
+ }));
110
+ queue = [];
111
+ const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
112
+ try {
113
+ navigator.sendBeacon(`${POSTHOG_HOST}/batch/`, body);
114
+ } catch {
115
+ // best-effort
116
+ }
117
+ }
118
+
93
119
  if (typeof window !== "undefined") {
94
120
  window.addEventListener("visibilitychange", () => {
95
- if (document.visibilityState === "hidden") {
96
- if (flushTimer) {
97
- clearInterval(flushTimer);
98
- flushTimer = null;
99
- }
100
- if (queue.length === 0) return;
101
- const batch = queue.map((e) => ({
102
- event: e.event,
103
- properties: { ...e.properties, $ip: null },
104
- distinct_id: getDistinctId(),
105
- timestamp: e.timestamp,
106
- }));
107
- queue = [];
108
- const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
109
- try {
110
- navigator.sendBeacon(`${POSTHOG_HOST}/batch/`, body);
111
- } catch {
112
- // best-effort
113
- }
114
- }
121
+ if (document.visibilityState === "hidden") flushViaBeacon();
115
122
  });
116
123
  }