diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 185720b0d..182cc5251 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -294,7 +294,12 @@ export function useDomEditSession({ // the SDK resolves each reordered element (the reorderElements op's targets). onReorderShadow: sdkSession ? (targets: string[]) => { - const reorderSrc = activeCompPath ? () => readProjectFile(activeCompPath) : undefined; + // Single-flight: every target in one reorder batch shares the same file, so + // memoize the read instead of firing one fetch per unresolved target. + let reorderSrcPromise: Promise | undefined; + const reorderSrc = activeCompPath + ? () => (reorderSrcPromise ??= readProjectFile(activeCompPath)) + : undefined; for (const target of targets) void recordResolverParity(sdkSession, target, "reorderElements", reorderSrc); } diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index c1367b5f1..7039bf420 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -221,12 +221,13 @@ export function sdkGsapTweenPersist( "addGsapTween", gsapSrc ? () => gsapSrc(targetPath) : undefined, ); - } else + } else { recordAnimationResolverParity( sdkSession, op.animationId, op.kind === "set" ? "setGsapTween" : "removeGsapTween", ); + } // Leading dark-launch gate so flag-off does no SDK touch (getElement) at all — // matches the other three chokepoints' discipline. if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(false); diff --git a/packages/studio/src/utils/sdkResolverShadow.test.ts b/packages/studio/src/utils/sdkResolverShadow.test.ts index 5f9167079..71ceaba2f 100644 --- a/packages/studio/src/utils/sdkResolverShadow.test.ts +++ b/packages/studio/src/utils/sdkResolverShadow.test.ts @@ -273,6 +273,22 @@ describe("C. Resolver-parity detection", () => { expect(lastShadow()?.sourceHfIdCount).toBe(2); }); + it("C8 sourceLooseMatchOnly: hfId matches source only as plain text, not a data-hf-id attribute", async () => { + mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; + const session = { getElement: () => null, getElements: () => [] } as unknown as Composition; + // "hf-widget" appears only inside a class name, never as data-hf-id="hf-widget". + const source = `
no attribute match here
`; + runResolverShadow( + session, + "hf-widget", + [{ type: "inline-style", property: "color", value: "red" }], + source, + ); + const ev = lastShadow(); + expect(ev?.sourceHfIdCount).toBe(0); + expect(ev?.sourceLooseMatchOnly).toBe(true); + }); + it("C10: unmappable op type produces no mismatch (excluded, not flagged)", async () => { const session = await openComposition(BASE_HTML); // "unknown-op" is not in MAPPED_OP_TYPES, so it must be silently excluded. @@ -423,6 +439,19 @@ describe("F. recordResolverParity", () => { expect(ev?.mismatchCount).toBe(1); expect(ev?.sourceHfIdCount).toBeUndefined(); }); + + it("tags sourceLooseMatchOnly when hfId matches source only as plain text, not a data-hf-id attribute", async () => { + mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; + const session = await openComposition(BASE_HTML); + // "hf-widget" appears only inside a class name, never as data-hf-id="hf-widget". + await recordResolverParity(session, "hf-widget", "setTiming", () => + Promise.resolve('
'), + ); + const ev = lastShadow(); + expect(ev?.mismatchCount).toBe(1); + expect(ev?.sourceHfIdCount).toBe(0); + expect(ev?.sourceLooseMatchOnly).toBe(true); + }); }); // ─── G. recordAnimationResolverParity (GSAP animationId ops) ────────────────── diff --git a/packages/studio/src/utils/sdkResolverShadow.ts b/packages/studio/src/utils/sdkResolverShadow.ts index e3347956f..537a1e3b6 100644 --- a/packages/studio/src/utils/sdkResolverShadow.ts +++ b/packages/studio/src/utils/sdkResolverShadow.ts @@ -272,6 +272,10 @@ export function runResolverShadow( // every style/text/attr edit (the editor's chattiest path) at default-ON. if (mismatches.length === 0) return; const isElementNotFound = mismatches.some((m) => m.kind === "element_not_found"); + const strictCount = + isElementNotFound && sourceContent !== undefined + ? countHfIdInSource(sourceContent, hfId) + : undefined; trackStudioEvent("sdk_resolver_shadow", { hfId, // sessionElementCount > 0 + element_not_found = runtime-only element; @@ -282,13 +286,11 @@ export function runResolverShadow( // instance; =1 = single static node the SDK parse dropped (foreign-content // exclusion / sub-comp inlining gap); =0 = the runtime-node filter above // uses a loose substring match (biased toward keeping signal) while this - // count uses a strict attribute match — an emitted event with count 0 - // means hfId appeared in source as plain text (e.g. a class name, comment, - // or script string) but never as a data-hf-id attribute. Treat 0 as "not - // a genuine attribute occurrence," not as a contradiction. - ...(isElementNotFound && sourceContent !== undefined - ? { sourceHfIdCount: countHfIdInSource(sourceContent, hfId) } - : {}), + // count uses a strict attribute match — see sourceLooseMatchOnly below. + ...(strictCount !== undefined ? { sourceHfIdCount: strictCount } : {}), + // Loose suppression check matched (kept this event) but the strict + // attribute count came back 0 — see the sourceHfIdCount comment above. + ...(strictCount === 0 ? { sourceLooseMatchOnly: true } : {}), mismatchCount: mismatches.length, mismatches: JSON.stringify(redactMismatches(mismatches)), }); @@ -334,6 +336,7 @@ export async function recordResolverParity( } // Runtime-generated node the static parse can't model — suppress (mirrors the dom-edit path). if (source !== undefined && !source.includes(hfId)) return; + const strictCount = source !== undefined ? countHfIdInSource(source, hfId) : undefined; trackStudioEvent("sdk_resolver_shadow", { hfId, opLabel, @@ -342,7 +345,13 @@ export async function recordResolverParity( // on an emitted (non-suppressed) event — the suppression check above is a // loose substring match (biased toward keeping signal); see the longer // comment on this field in runResolverShadow for the full explanation. - ...(source !== undefined ? { sourceHfIdCount: countHfIdInSource(source, hfId) } : {}), + ...(strictCount !== undefined ? { sourceHfIdCount: strictCount } : {}), + // Loose suppression check matched (kept this event) but the strict + // attribute count came back 0 — hfId appeared as plain text (class name, + // comment, script string) but never as a data-hf-id="..." attribute. + // Lets telemetry consumers filter this cohort without parsing the + // sourceHfIdCount comment above. + ...(strictCount === 0 ? { sourceLooseMatchOnly: true } : {}), mismatchCount: 1, mismatches: JSON.stringify([ { kind: "element_not_found", hfId } satisfies SdkResolverMismatch,