mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(studio): shadow resolves bare leaf via dispatch path, not getElement (#1552)
The resolver-parity shadow tripwire decided element_not_found via Composition.getElement, which is canonical-only for a bare id by design (removeElement/getElement must agree on the same instance — the session.subcomp "ambiguous bare id" suite). But the cutover persist path dispatches the studio's bare data-hf-id and resolves it via resolveScoped, which locates the leaf anywhere in the document (canonical preferred, else first match). So getElement under-resolved a bare leaf living inside an inlined sub-composition (scopedId "host/leaf"), and the shadow emitted a false element_not_found the real dispatch path never hits — ~445 such events in PostHog, all one user editing an inlined yt-lower-third. Add resolveSnapshot in the shadow mirroring resolveScoped, used at all three element_not_found sites. getElement is unchanged (its contract is correct). Regression tests cover the inlined-sub-comp leaf case. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
28bfe09f21
commit
5fb5153e4e
@@ -189,7 +189,7 @@ describe("C. Resolver-parity detection", () => {
|
||||
it("C8: element_not_found fires when SDK resolver returns null (v0.6.110 class)", () => {
|
||||
// Simulate the regression: SDK session cannot resolve the hfId the server
|
||||
// would address (e.g. scoped-id mismatch, resolver bug).
|
||||
const session = { getElement: () => null } as unknown as Parameters<
|
||||
const session = { getElement: () => null, getElements: () => [] } as unknown as Parameters<
|
||||
typeof sdkResolverShadowCheck
|
||||
>[0];
|
||||
const mismatches = sdkResolverShadowCheck(
|
||||
@@ -364,3 +364,46 @@ describe("G. recordAnimationResolverParity", () => {
|
||||
expect(trackedEvents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── H. Inlined sub-composition: bare leaf id resolves (regression) ───────────
|
||||
|
||||
// PostHog showed ~445 false `element_not_found` events, all on a bare leaf id
|
||||
// (hf-0ytc / #subscribe-btn) inside an inlined sub-composition. The studio reads
|
||||
// the bare data-hf-id off the DOM and the cutover dispatch resolves it via
|
||||
// resolveScoped (which locates the leaf inside the host subtree). But the shadow
|
||||
// resolved via Composition.getElement, which is canonical-only for a bare id and
|
||||
// returns null for a scoped element — so it flagged a divergence the real
|
||||
// dispatch path would not hit. The shadow now mirrors dispatch via resolveSnapshot.
|
||||
describe("H. inlined sub-composition leaf", () => {
|
||||
// host carries data-composition-file → new scope; leaf's scopedId is
|
||||
// "hf-host/hf-leaf" but its raw data-hf-id (what the studio reads) is bare.
|
||||
const INLINED_HTML = /* html */ `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<div data-hf-id="hf-leaf" style="color: red">Subscribe</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
it("getElement(bareLeaf) is null (canonical-only) — the trap the shadow used to hit", async () => {
|
||||
const session = await openComposition(INLINED_HTML);
|
||||
expect(session.getElement("hf-leaf")).toBeNull();
|
||||
expect(session.getElement("hf-host/hf-leaf")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("recordResolverParity emits NOTHING for a bare leaf inside a sub-comp", async () => {
|
||||
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
||||
const session = await openComposition(INLINED_HTML);
|
||||
recordResolverParity(session, "hf-leaf", "setTiming");
|
||||
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sdkResolverShadowCheck does not flag element_not_found for a bare leaf in a sub-comp", async () => {
|
||||
const session = await openComposition(INLINED_HTML);
|
||||
const mismatches = sdkResolverShadowCheck(session, "hf-leaf", [
|
||||
{ type: "inline-style", property: "color", value: "blue" },
|
||||
]);
|
||||
expect(mismatches.some((m) => m.kind === "element_not_found")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,31 @@ function normalizeText(v: string | null | undefined): string | null {
|
||||
type FlatEl = NonNullable<ReturnType<Composition["getElement"]>>;
|
||||
type AttrMap = Record<string, string | null>;
|
||||
|
||||
/**
|
||||
* Resolve an hf-id to its snapshot the SAME way the SDK dispatch path does
|
||||
* (engine/model.ts resolveScoped), NOT via Composition.getElement.
|
||||
*
|
||||
* getElement is canonical-only for a bare id by design — it deliberately will
|
||||
* not resolve a bare id to a non-canonical (sub-composition) element, so that
|
||||
* removeElement(bareId) and getElement(bareId) agree on the same instance
|
||||
* (session.subcomp.test "ambiguous bare id" suite). But the cutover persist
|
||||
* path dispatches the studio's bare data-hf-id, and dispatch resolves it via
|
||||
* resolveScoped, which locates the leaf anywhere (canonical preferred, else
|
||||
* first match). So getElement under-resolves a bare leaf that lives inside an
|
||||
* inlined sub-composition (scopedId "host/leaf") — exactly the false
|
||||
* `element_not_found` this tripwire was emitting for inlined compositions.
|
||||
*
|
||||
* Mirror resolveScoped here: exact scoped-path match, then canonical bare
|
||||
* match, then first bare match — the resolvability dispatch actually has.
|
||||
*/
|
||||
function resolveSnapshot(session: Composition, id: string): FlatEl | null {
|
||||
const els = session.getElements();
|
||||
const exact = els.find((el) => el.scopedId === id);
|
||||
if (exact) return exact;
|
||||
const matches = els.filter((el) => el.id === id);
|
||||
return matches.find((el) => el.scopedId === el.id) ?? matches[0] ?? null;
|
||||
}
|
||||
|
||||
function checkStyleOp(
|
||||
op: PatchOperation,
|
||||
el: FlatEl,
|
||||
@@ -140,7 +165,7 @@ export function sdkResolverShadowCheck(
|
||||
hfId: string,
|
||||
ops: PatchOperation[],
|
||||
): SdkResolverMismatch[] {
|
||||
if (!session.getElement(hfId)) {
|
||||
if (!resolveSnapshot(session, hfId)) {
|
||||
return [{ kind: "element_not_found", hfId }];
|
||||
}
|
||||
|
||||
@@ -172,7 +197,7 @@ export function sdkResolverShadowCheck(
|
||||
return [{ kind: "dispatch_error", hfId, error: String(err) }];
|
||||
}
|
||||
|
||||
const el = session.getElement(hfId);
|
||||
const el = resolveSnapshot(session, hfId);
|
||||
if (!el) return [{ kind: "element_not_found", hfId }];
|
||||
|
||||
return shadowable
|
||||
@@ -253,7 +278,7 @@ export function recordResolverParity(
|
||||
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
||||
if (!session || !hfId) return;
|
||||
try {
|
||||
if (session.getElement(hfId)) return; // resolves — parity, nothing to record
|
||||
if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
|
||||
trackStudioEvent("sdk_resolver_shadow", {
|
||||
hfId,
|
||||
opLabel,
|
||||
|
||||
Reference in New Issue
Block a user