Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion packages/studio/src/utils/sdkResolverShadow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
});
});
31 changes: 28 additions & 3 deletions packages/studio/src/utils/sdkResolverShadow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }];
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading