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:
Vance Ingalls
2026-06-18 00:48:04 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 28bfe09f21
commit 5fb5153e4e
2 changed files with 72 additions and 4 deletions
@@ -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);
});
});
+28 -3
View File
@@ -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,