@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/dist/assets/{index-BX3GR25I.js → index-ClVRei_K.js} +1 -1
- package/dist/assets/{index-CthldRjm.js → index-DM6n1UAe.js} +1 -1
- package/dist/assets/{index-mz4av-V1.js → index-DeqMZHQ4.js} +107 -107
- package/dist/index.html +1 -1
- package/dist/index.js +59 -19
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/utils/sdkResolverShadow.test.ts +180 -1
- package/src/utils/sdkResolverShadow.ts +94 -1
- package/src/utils/studioTelemetry.ts +27 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
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.
|
|
50
|
-
"@hyperframes/parsers": "0.7.
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/player": "0.7.
|
|
53
|
-
"@hyperframes/
|
|
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.
|
|
68
|
+
"@hyperframes/producer": "0.7.31"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"react": "19",
|
|
@@ -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,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
|
}
|