mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(studio): cross-file tripwire guard + stale-session disk check
Two resolver-shadow noise classes from production telemetry: - Cross-file guard (0.7.41: 479 false element_not_found from ONE session): the dom-edit tripwire ran for edits targeting a different file than the session models. The cutover gates already decline these (wrongCompositionFile); the tripwire now skips the same way — no event, no attempt, since the op structurally cannot cut over. - Stale-session disambiguation (0.7.48: 53 animation_not_found across keyframe ops): the GSAP panel derives animationIds from the CURRENT on-disk script every render, while the session's parsed id space dates from the last reload. Position edits shift every selector-method-position id, so panel ops landing before the reload target ids the session has never seen. Parser id-space parity was verified across legacy/acorn read/write paths (9 script shapes) — the ids agree; the session is just behind. On a miss with a reader wired, recordAnimationResolverParity now re-parses the on-disk file: a hit there = stale session (suppress); a miss there = genuine divergence, tagged diskChecked so the dashboard can trust the class. Attempt-counter machinery moved to sdkResolverAttempts.ts (600-LOC studio file gate); re-exported from sdkResolverShadow for API compat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e05debe1af
commit
405af8f8ba
@@ -260,8 +260,13 @@ export function useDomEditSession({
|
|||||||
? (selection, operations, originalContent, targetPath, options) => {
|
? (selection, operations, originalContent, targetPath, options) => {
|
||||||
// Resolver shadow runs regardless of the cutover flag — decoupled tripwire.
|
// Resolver shadow runs regardless of the cutover flag — decoupled tripwire.
|
||||||
// Pass originalContent so the runtime-node filter can suppress hf-ids
|
// Pass originalContent so the runtime-node filter can suppress hf-ids
|
||||||
// absent from source (script-created nodes the SDK can't model).
|
// absent from source (script-created nodes the SDK can't model), and
|
||||||
runResolverShadow(sdkSession, selection.hfId, operations, originalContent);
|
// the paths so cross-file edits (session models only the active comp)
|
||||||
|
// skip instead of emitting structural element_not_found noise.
|
||||||
|
runResolverShadow(sdkSession, selection.hfId, operations, originalContent, {
|
||||||
|
targetPath,
|
||||||
|
compositionPath: activeCompPath,
|
||||||
|
});
|
||||||
return sdkCutoverPersist(
|
return sdkCutoverPersist(
|
||||||
selection,
|
selection,
|
||||||
operations,
|
operations,
|
||||||
|
|||||||
@@ -82,6 +82,20 @@ function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean {
|
|||||||
return deps.compositionPath != null && targetPath !== deps.compositionPath;
|
return deps.compositionPath != null && targetPath !== deps.compositionPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reader for the animation-resolver tripwire's disk-truth check: on an
|
||||||
|
* animationId miss it re-parses the CURRENT file to distinguish a stale
|
||||||
|
* session (panel ids re-derive from disk every render; session ids date from
|
||||||
|
* the last reload) from a genuine resolver divergence.
|
||||||
|
*/
|
||||||
|
function gsapReadSource(
|
||||||
|
deps: CutoverDeps,
|
||||||
|
targetPath: string,
|
||||||
|
): (() => Promise<string | undefined>) | undefined {
|
||||||
|
const read = deps.readProjectFile;
|
||||||
|
return read ? () => read(targetPath) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
interface CutoverOptions {
|
interface CutoverOptions {
|
||||||
label?: string;
|
label?: string;
|
||||||
coalesceKey?: string;
|
coalesceKey?: string;
|
||||||
@@ -271,10 +285,11 @@ export function sdkGsapTweenPersist(
|
|||||||
gsapSrc ? () => gsapSrc(targetPath) : undefined,
|
gsapSrc ? () => gsapSrc(targetPath) : undefined,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
recordAnimationResolverParity(
|
void recordAnimationResolverParity(
|
||||||
sdkSession,
|
sdkSession,
|
||||||
op.animationId,
|
op.animationId,
|
||||||
op.kind === "set" ? "setGsapTween" : "removeGsapTween",
|
op.kind === "set" ? "setGsapTween" : "removeGsapTween",
|
||||||
|
gsapReadSource(deps, targetPath),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Leading dark-launch gate so flag-off does no SDK touch (getElement) at all —
|
// Leading dark-launch gate so flag-off does no SDK touch (getElement) at all —
|
||||||
@@ -309,7 +324,12 @@ async function dispatchGsapOpAndPersist(
|
|||||||
// Resolver tripwire — runs BEFORE the cutover gate (decoupled): records when
|
// Resolver tripwire — runs BEFORE the cutover gate (decoupled): records when
|
||||||
// the SDK can't resolve the animationId the server GSAP path is addressing.
|
// the SDK can't resolve the animationId the server GSAP path is addressing.
|
||||||
if (resolverTarget) {
|
if (resolverTarget) {
|
||||||
recordAnimationResolverParity(sdkSession, resolverTarget.animationId, resolverTarget.opLabel);
|
void recordAnimationResolverParity(
|
||||||
|
sdkSession,
|
||||||
|
resolverTarget.animationId,
|
||||||
|
resolverTarget.opLabel,
|
||||||
|
gsapReadSource(deps, targetPath),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Dark-launch gate (shared chokepoint for every GSAP-op cutover persist):
|
// Dark-launch gate (shared chokepoint for every GSAP-op cutover persist):
|
||||||
// flag OFF → return false → caller falls back to the legacy server path.
|
// flag OFF → return false → caller falls back to the legacy server path.
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Attempt counter — the denominator for the resolver-shadow soak gate.
|
||||||
|
*
|
||||||
|
* The emit functions in sdkResolverShadow.ts only fire a PostHog event on
|
||||||
|
* divergence — parity is silent, by design, to avoid firing on every edit.
|
||||||
|
* That leaves no way to compute a rate (divergences / attempts): we can count
|
||||||
|
* failures but never attempts. This counter tracks attempts in memory and
|
||||||
|
* rolls them up into ONE low-frequency event instead of firing per-attempt,
|
||||||
|
* which would recreate the exact chattiness problem the divergence-only
|
||||||
|
* design avoids.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { trackStudioEvent, flushViaBeacon } from "./studioTelemetry";
|
||||||
|
|
||||||
|
const attemptCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record that the resolver-shadow tripwire ran for `opLabel`, regardless of
|
||||||
|
* outcome (parity or divergence). No flag check of its own — only ever called
|
||||||
|
* from inside the shadow emit functions, after their own
|
||||||
|
* STUDIO_SDK_RESOLVER_SHADOW_ENABLED guard, so it's already flag-gated.
|
||||||
|
*/
|
||||||
|
export function recordAttempt(opLabel: string): void {
|
||||||
|
attemptCounts[opLabel] = (attemptCounts[opLabel] ?? 0) + 1;
|
||||||
|
ensureAttemptFlushScheduled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the accumulated attempt counts since the last flush (or `null` if
|
||||||
|
* nothing has been recorded — no point emitting an empty rollup), and reset
|
||||||
|
* the counter to empty.
|
||||||
|
*/
|
||||||
|
export function flushAttemptCounts(): Record<string, number> | null {
|
||||||
|
const keys = Object.keys(attemptCounts);
|
||||||
|
if (keys.length === 0) return null;
|
||||||
|
const snapshot: Record<string, number> = {};
|
||||||
|
for (const key of keys) {
|
||||||
|
snapshot[key] = attemptCounts[key];
|
||||||
|
delete attemptCounts[key];
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ATTEMPT_FLUSH_INTERVAL_MS = 5 * 60_000;
|
||||||
|
let attemptFlushTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let attemptVisibilityHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
function flushAndEmitAttempts(): void {
|
||||||
|
const counts = flushAttemptCounts();
|
||||||
|
if (counts === null) return;
|
||||||
|
trackStudioEvent("sdk_resolver_shadow_attempt", { counts: JSON.stringify(counts) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazily starts the rollup timer + visibilitychange listener on the FIRST
|
||||||
|
// attempt in a session — mirrors studioTelemetry.ts's own lazy flushTimer
|
||||||
|
// start, so a session that never exercises the tripwire never runs a
|
||||||
|
// background timer.
|
||||||
|
function ensureAttemptFlushScheduled(): void {
|
||||||
|
if (!attemptFlushTimer) {
|
||||||
|
attemptFlushTimer = setInterval(flushAndEmitAttempts, ATTEMPT_FLUSH_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
if (!attemptVisibilityHandler && typeof document !== "undefined") {
|
||||||
|
attemptVisibilityHandler = () => {
|
||||||
|
if (document.visibilityState !== "hidden") return;
|
||||||
|
flushAndEmitAttempts();
|
||||||
|
// studioTelemetry.ts registers its own visibilitychange listener (on
|
||||||
|
// window, at module load) that drains its queue via sendBeacon. Listener
|
||||||
|
// execution order between that handler and this one (on document,
|
||||||
|
// registered lazily) is not something to rely on — whichever runs
|
||||||
|
// first could otherwise beacon-flush before or after this rollup lands
|
||||||
|
// in the queue. Forcing a beacon flush here makes delivery of this
|
||||||
|
// rollup event correct regardless of that order.
|
||||||
|
flushViaBeacon();
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", attemptVisibilityHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: clears the lazy timer/listener singleton state so tests can
|
||||||
|
* verify the "starts on first attempt" behavior in isolation, without an
|
||||||
|
* earlier test's real-timer interval (or visibilitychange listener) silently
|
||||||
|
* surviving into a later test. Does NOT touch attemptCounts — only the
|
||||||
|
* scheduling state. Not part of the public module contract; only imported
|
||||||
|
* from sdkResolverShadow.test.ts.
|
||||||
|
*/
|
||||||
|
export function __resetAttemptSchedulingForTests(): void {
|
||||||
|
if (attemptFlushTimer) clearInterval(attemptFlushTimer);
|
||||||
|
attemptFlushTimer = null;
|
||||||
|
if (attemptVisibilityHandler && typeof document !== "undefined") {
|
||||||
|
document.removeEventListener("visibilitychange", attemptVisibilityHandler);
|
||||||
|
}
|
||||||
|
attemptVisibilityHandler = null;
|
||||||
|
}
|
||||||
@@ -562,6 +562,100 @@ describe("G. recordAnimationResolverParity", () => {
|
|||||||
recordAnimationResolverParity(session, unmatchedId, "removeAllKeyframes");
|
recordAnimationResolverParity(session, unmatchedId, "removeAllKeyframes");
|
||||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Stale-session disambiguation (the 0.7.48 keyframe-op class) ─────────────
|
||||||
|
// The GSAP panel computes animationIds from the CURRENT on-disk script (the
|
||||||
|
// server read path re-reads per render), but the session's parsed id space
|
||||||
|
// reflects the last session reload. An edit that shifts tween positions
|
||||||
|
// shifts every `selector-method-position` id, so a panel op landing between
|
||||||
|
// the write and the session reload targets an id the session has never seen.
|
||||||
|
// That is a sync gap, not a resolver bug — disambiguate against disk truth.
|
||||||
|
|
||||||
|
// Same tween, position moved 0→3: every id in the NEW script differs from
|
||||||
|
// the ids the OLD (session) script parses to.
|
||||||
|
const GSAP_DISK_MOVED_HTML = /* html */ `<!DOCTYPE html>
|
||||||
|
<html><body>
|
||||||
|
<div data-hf-id="hf-box" style="color: red">Hello</div>
|
||||||
|
<script>var tl = gsap.timeline({ paused: true }); tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 3);</script>
|
||||||
|
</body></html>`;
|
||||||
|
|
||||||
|
it("suppresses the emit when the animationId resolves against the on-disk script (stale session)", async () => {
|
||||||
|
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||||
|
const session = await openComposition(GSAP_HTML); // session parsed the OLD script
|
||||||
|
const disk = await openComposition(GSAP_DISK_MOVED_HTML);
|
||||||
|
const diskId = [...disk.getAllAnimationIds()][0] ?? "";
|
||||||
|
disk.dispose();
|
||||||
|
expect(diskId).not.toBe("");
|
||||||
|
expect(session.getAllAnimationIds().has(diskId)).toBe(false); // session can't see it
|
||||||
|
await recordAnimationResolverParity(session, diskId, "removeGsapKeyframe", () =>
|
||||||
|
Promise.resolve(GSAP_DISK_MOVED_HTML),
|
||||||
|
);
|
||||||
|
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits with diskChecked when the animationId resolves in NEITHER session nor disk", async () => {
|
||||||
|
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||||
|
const session = await openComposition(GSAP_HTML);
|
||||||
|
await recordAnimationResolverParity(session, "no-such-anim", "removeGsapKeyframe", () =>
|
||||||
|
Promise.resolve(GSAP_DISK_MOVED_HTML),
|
||||||
|
);
|
||||||
|
const ev = lastShadow();
|
||||||
|
expect(ev?.mismatchCount).toBe(1);
|
||||||
|
expect(ev?.diskChecked).toBe(true);
|
||||||
|
expect(JSON.stringify(ev?.mismatches)).toContain("animation_not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails open with sourceReadFailed when the reader throws", async () => {
|
||||||
|
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||||
|
const session = await openComposition(GSAP_HTML);
|
||||||
|
await recordAnimationResolverParity(session, "no-such-anim", "removeGsapKeyframe", () =>
|
||||||
|
Promise.reject(new Error("read failed")),
|
||||||
|
);
|
||||||
|
const ev = lastShadow();
|
||||||
|
expect(ev?.mismatchCount).toBe(1);
|
||||||
|
expect(ev?.sourceReadFailed).toBe(true);
|
||||||
|
expect(ev?.diskChecked).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── G2. runResolverShadow cross-file guard ───────────────────────────────────
|
||||||
|
// PostHog 0.7.41: one session emitted 479 element_not_found because the user
|
||||||
|
// edited elements whose sourceFile differed from the active composition — the
|
||||||
|
// session models ONLY the active comp, so cross-file targets are structurally
|
||||||
|
// unresolvable. The cutover gates already decline via wrongCompositionFile;
|
||||||
|
// the tripwire must skip the same way (no event, no attempt — the op can't
|
||||||
|
// cut over, so it belongs in neither side of the soak rate).
|
||||||
|
describe("G2. runResolverShadow cross-file guard", () => {
|
||||||
|
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
|
||||||
|
|
||||||
|
it("skips entirely when targetPath differs from the session's composition", async () => {
|
||||||
|
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||||
|
flushAttemptCounts();
|
||||||
|
const session = await openComposition(BASE_HTML);
|
||||||
|
runResolverShadow(session, "hf-cross", ops, undefined, {
|
||||||
|
targetPath: "compositions/sample-vote-count.html",
|
||||||
|
compositionPath: "templates/document-card.html",
|
||||||
|
});
|
||||||
|
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
||||||
|
expect(flushAttemptCounts()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still emits for a same-file divergence", async () => {
|
||||||
|
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||||
|
const session = await openComposition(BASE_HTML);
|
||||||
|
runResolverShadow(session, "hf-missing", ops, undefined, {
|
||||||
|
targetPath: "index.html",
|
||||||
|
compositionPath: "index.html",
|
||||||
|
});
|
||||||
|
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs normally when paths are not supplied (status quo)", async () => {
|
||||||
|
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||||
|
const session = await openComposition(BASE_HTML);
|
||||||
|
runResolverShadow(session, "hf-missing", ops);
|
||||||
|
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── H. Inlined sub-composition: bare leaf id resolves (regression) ───────────
|
// ─── H. Inlined sub-composition: bare leaf id resolves (regression) ───────────
|
||||||
|
|||||||
@@ -15,11 +15,12 @@
|
|||||||
* Telemetry-only — never writes to disk, never affects the user-visible edit.
|
* Telemetry-only — never writes to disk, never affects the user-visible edit.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { openComposition } from "@hyperframes/sdk";
|
||||||
import type { Composition, JsonPatchOp } from "@hyperframes/sdk";
|
import type { Composition, JsonPatchOp } from "@hyperframes/sdk";
|
||||||
import type { PatchOperation } from "./sourcePatcher";
|
import type { PatchOperation } from "./sourcePatcher";
|
||||||
import { STUDIO_SDK_RESOLVER_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
import { STUDIO_SDK_RESOLVER_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||||
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
||||||
import { trackStudioEvent, flushViaBeacon } from "./studioTelemetry";
|
import { trackStudioEvent } from "./studioTelemetry";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -234,95 +235,14 @@ export function sdkResolverShadowCheck(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Attempt counter (denominator for the soak gate) ──────────────────────────
|
// Attempt counting (the soak-gate denominator) lives in sdkResolverAttempts.ts;
|
||||||
//
|
// re-exported here so existing consumers/tests keep one import surface.
|
||||||
// The three emit functions below only fire a PostHog event on divergence —
|
export {
|
||||||
// parity is silent, by design, to avoid firing on every edit. That leaves no
|
recordAttempt,
|
||||||
// way to compute a rate (divergences / attempts): we can count failures but
|
flushAttemptCounts,
|
||||||
// never attempts. This counter tracks attempts in memory and rolls them up
|
__resetAttemptSchedulingForTests,
|
||||||
// into ONE low-frequency event instead of firing per-attempt, which would
|
} from "./sdkResolverAttempts";
|
||||||
// recreate the exact chattiness problem the divergence-only design avoids.
|
import { recordAttempt } from "./sdkResolverAttempts";
|
||||||
|
|
||||||
const attemptCounts: Record<string, number> = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record that the resolver-shadow tripwire ran for `opLabel`, regardless of
|
|
||||||
* outcome (parity or divergence). No flag check of its own — only ever called
|
|
||||||
* from inside the three emit functions below, after their own
|
|
||||||
* STUDIO_SDK_RESOLVER_SHADOW_ENABLED guard, so it's already flag-gated.
|
|
||||||
*/
|
|
||||||
export function recordAttempt(opLabel: string): void {
|
|
||||||
attemptCounts[opLabel] = (attemptCounts[opLabel] ?? 0) + 1;
|
|
||||||
ensureAttemptFlushScheduled();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the accumulated attempt counts since the last flush (or `null` if
|
|
||||||
* nothing has been recorded — no point emitting an empty rollup), and reset
|
|
||||||
* the counter to empty.
|
|
||||||
*/
|
|
||||||
export function flushAttemptCounts(): Record<string, number> | null {
|
|
||||||
const keys = Object.keys(attemptCounts);
|
|
||||||
if (keys.length === 0) return null;
|
|
||||||
const snapshot: Record<string, number> = {};
|
|
||||||
for (const key of keys) {
|
|
||||||
snapshot[key] = attemptCounts[key];
|
|
||||||
delete attemptCounts[key];
|
|
||||||
}
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ATTEMPT_FLUSH_INTERVAL_MS = 5 * 60_000;
|
|
||||||
let attemptFlushTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
let attemptVisibilityHandler: (() => void) | null = null;
|
|
||||||
|
|
||||||
function flushAndEmitAttempts(): void {
|
|
||||||
const counts = flushAttemptCounts();
|
|
||||||
if (counts === null) return;
|
|
||||||
trackStudioEvent("sdk_resolver_shadow_attempt", { counts: JSON.stringify(counts) });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lazily starts the rollup timer + visibilitychange listener on the FIRST
|
|
||||||
// attempt in a session — mirrors studioTelemetry.ts's own lazy flushTimer
|
|
||||||
// start, so a session that never exercises the tripwire never runs a
|
|
||||||
// background timer.
|
|
||||||
function ensureAttemptFlushScheduled(): void {
|
|
||||||
if (!attemptFlushTimer) {
|
|
||||||
attemptFlushTimer = setInterval(flushAndEmitAttempts, ATTEMPT_FLUSH_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
if (!attemptVisibilityHandler && typeof document !== "undefined") {
|
|
||||||
attemptVisibilityHandler = () => {
|
|
||||||
if (document.visibilityState !== "hidden") return;
|
|
||||||
flushAndEmitAttempts();
|
|
||||||
// studioTelemetry.ts registers its own visibilitychange listener (on
|
|
||||||
// window, at module load) that drains its queue via sendBeacon. Listener
|
|
||||||
// execution order between that handler and this one (on document,
|
|
||||||
// registered lazily) is not something to rely on — whichever runs
|
|
||||||
// first could otherwise beacon-flush before or after this rollup lands
|
|
||||||
// in the queue. Forcing a beacon flush here makes delivery of this
|
|
||||||
// rollup event correct regardless of that order.
|
|
||||||
flushViaBeacon();
|
|
||||||
};
|
|
||||||
document.addEventListener("visibilitychange", attemptVisibilityHandler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test-only: clears the lazy timer/listener singleton state so tests can
|
|
||||||
* verify the "starts on first attempt" behavior in isolation, without an
|
|
||||||
* earlier test's real-timer interval (or visibilitychange listener) silently
|
|
||||||
* surviving into a later test. Does NOT touch attemptCounts — only the
|
|
||||||
* scheduling state. Not part of the public module contract; only imported
|
|
||||||
* from sdkResolverShadow.test.ts.
|
|
||||||
*/
|
|
||||||
export function __resetAttemptSchedulingForTests(): void {
|
|
||||||
if (attemptFlushTimer) clearInterval(attemptFlushTimer);
|
|
||||||
attemptFlushTimer = null;
|
|
||||||
if (attemptVisibilityHandler && typeof document !== "undefined") {
|
|
||||||
document.removeEventListener("visibilitychange", attemptVisibilityHandler);
|
|
||||||
}
|
|
||||||
attemptVisibilityHandler = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Telemetry ────────────────────────────────────────────────────────────────
|
// ─── Telemetry ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -385,10 +305,24 @@ export function runResolverShadow(
|
|||||||
hfId: string | null | undefined,
|
hfId: string | null | undefined,
|
||||||
ops: PatchOperation[],
|
ops: PatchOperation[],
|
||||||
sourceContent?: string,
|
sourceContent?: string,
|
||||||
|
paths?: { targetPath?: string; compositionPath?: string | null },
|
||||||
): void {
|
): void {
|
||||||
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
||||||
if (!hfId) return;
|
if (!hfId) return;
|
||||||
try {
|
try {
|
||||||
|
// Cross-file edit: the session models ONLY the active composition, so a
|
||||||
|
// target living in another file is structurally unresolvable — the cutover
|
||||||
|
// gates decline it (wrongCompositionFile) and it can never cut over. Skip
|
||||||
|
// entirely (no event, no attempt), mirroring the empty-session rule below.
|
||||||
|
// PostHog 0.7.41: one cross-file editing session emitted 479 false
|
||||||
|
// element_not_found events through this path.
|
||||||
|
if (
|
||||||
|
paths?.targetPath !== undefined &&
|
||||||
|
paths.compositionPath != null &&
|
||||||
|
paths.targetPath !== paths.compositionPath
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (reportEmptySession(session, "dom-edit")) return;
|
if (reportEmptySession(session, "dom-edit")) return;
|
||||||
recordAttempt("dom-edit");
|
recordAttempt("dom-edit");
|
||||||
const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent);
|
const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent);
|
||||||
@@ -505,11 +439,48 @@ export async function recordResolverParity(
|
|||||||
*
|
*
|
||||||
* No-op when the shadow flag is off; never throws; never mutates the session.
|
* No-op when the shadow flag is off; never throws; never mutates the session.
|
||||||
*/
|
*/
|
||||||
export function recordAnimationResolverParity(
|
/**
|
||||||
|
* Disk-truth disambiguation for a missed animationId: the GSAP panel computes
|
||||||
|
* animationIds from the CURRENT on-disk script (the server read path re-reads
|
||||||
|
* per render), while the session's parsed id space reflects the last session
|
||||||
|
* reload. An edit that shifts tween positions shifts every
|
||||||
|
* `selector-method-position` id, so a panel op landing before the reload
|
||||||
|
* targets an id the session has never seen — a sync gap, not a resolver bug
|
||||||
|
* (the 0.7.48 keyframe-op class). `staleSession` = a fresh parse of the file
|
||||||
|
* resolves the id (suppress); `diskChecked` = the fresh parse ran and ALSO
|
||||||
|
* missed (a trustworthy real divergence); read errors fail open.
|
||||||
|
*/
|
||||||
|
async function checkAnimationIdOnDisk(
|
||||||
|
animationId: string,
|
||||||
|
readSource: () => Promise<string | undefined>,
|
||||||
|
): Promise<{ staleSession: boolean; diskChecked: boolean; sourceReadFailed: boolean }> {
|
||||||
|
let source: string | undefined;
|
||||||
|
try {
|
||||||
|
source = await readSource();
|
||||||
|
} catch {
|
||||||
|
return { staleSession: false, diskChecked: false, sourceReadFailed: true };
|
||||||
|
}
|
||||||
|
if (source === undefined) {
|
||||||
|
return { staleSession: false, diskChecked: false, sourceReadFailed: false };
|
||||||
|
}
|
||||||
|
const disk = await openComposition(source, { history: false });
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
staleSession: disk.getAllAnimationIds().has(animationId),
|
||||||
|
diskChecked: true,
|
||||||
|
sourceReadFailed: false,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
disk.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordAnimationResolverParity(
|
||||||
session: Composition | null | undefined,
|
session: Composition | null | undefined,
|
||||||
animationId: string,
|
animationId: string,
|
||||||
opLabel: string,
|
opLabel: string,
|
||||||
): void {
|
readSource?: () => Promise<string | undefined>,
|
||||||
|
): Promise<void> {
|
||||||
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
||||||
if (!session || !animationId) return;
|
if (!session || !animationId) return;
|
||||||
try {
|
try {
|
||||||
@@ -519,10 +490,25 @@ export function recordAnimationResolverParity(
|
|||||||
elements.some((el) => el.animationIds.includes(animationId)) ||
|
elements.some((el) => el.animationIds.includes(animationId)) ||
|
||||||
session.getAllAnimationIds().has(animationId);
|
session.getAllAnimationIds().has(animationId);
|
||||||
if (resolves) return; // SDK locates the animation — parity
|
if (resolves) return; // SDK locates the animation — parity
|
||||||
|
// Capture BEFORE any await (fire-and-forget caller mutates right after).
|
||||||
|
const sessionElementCount = elements.length;
|
||||||
|
let diskChecked = false;
|
||||||
|
let sourceReadFailed = false;
|
||||||
|
if (readSource) {
|
||||||
|
const verdict = await checkAnimationIdOnDisk(animationId, readSource);
|
||||||
|
if (verdict.staleSession) return; // sync gap, not a resolver bug — suppress
|
||||||
|
diskChecked = verdict.diskChecked;
|
||||||
|
sourceReadFailed = verdict.sourceReadFailed;
|
||||||
|
}
|
||||||
trackStudioEvent("sdk_resolver_shadow", {
|
trackStudioEvent("sdk_resolver_shadow", {
|
||||||
animationId,
|
animationId,
|
||||||
opLabel,
|
opLabel,
|
||||||
sessionElementCount: elements.length,
|
sessionElementCount,
|
||||||
|
// The id missed a fresh parse of the on-disk file too — this is a real
|
||||||
|
// resolver divergence, not session staleness. Absent when no reader was
|
||||||
|
// wired or the read failed (see sourceReadFailed).
|
||||||
|
...(diskChecked ? { diskChecked: true } : {}),
|
||||||
|
...(sourceReadFailed ? { sourceReadFailed: true } : {}),
|
||||||
mismatchCount: 1,
|
mismatchCount: 1,
|
||||||
mismatches: JSON.stringify([
|
mismatches: JSON.stringify([
|
||||||
{ kind: "animation_not_found", animationId } satisfies SdkResolverMismatch,
|
{ kind: "animation_not_found", animationId } satisfies SdkResolverMismatch,
|
||||||
|
|||||||
Reference in New Issue
Block a user