diff --git a/packages/studio/src/utils/sdkResolverShadow.test.ts b/packages/studio/src/utils/sdkResolverShadow.test.ts
index 399954ec2..df3bd06ca 100644
--- a/packages/studio/src/utils/sdkResolverShadow.test.ts
+++ b/packages/studio/src/utils/sdkResolverShadow.test.ts
@@ -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 */ `
+
+
+`;
+
+ 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);
+ });
+});
diff --git a/packages/studio/src/utils/sdkResolverShadow.ts b/packages/studio/src/utils/sdkResolverShadow.ts
index cf62d21f0..2612db3f9 100644
--- a/packages/studio/src/utils/sdkResolverShadow.ts
+++ b/packages/studio/src/utils/sdkResolverShadow.ts
@@ -65,6 +65,31 @@ function normalizeText(v: string | null | undefined): string | null {
type FlatEl = NonNullable>;
type AttrMap = Record;
+/**
+ * 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,