@hyperframes/studio 0.7.22 → 0.7.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,115 +8,9 @@ import { trackStudioEvent } from "./studioTelemetry";
8
8
  import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
9
9
  import { patchOpsToSdkEditOps } from "./sdkOpMapping";
10
10
  import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
11
- import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety";
11
+ import { shouldDeclineTextCutoverForTarget, shouldUseSdkCutover } from "./sdkCutoverEligibility";
12
12
 
13
- const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
14
- "inline-style",
15
- "text-content",
16
- "attribute",
17
- "html-attribute",
18
- ]);
19
-
20
- // Mirrors the SDK's RESERVED_ATTRS (mutate.ts): a bare `attribute` op is
21
- // force-prefixed `data-`, so e.g. property "end" → "data-end", which the SDK
22
- // rejects with a throw. Detect that up front and decline the whole batch so it
23
- // takes the server path cleanly, instead of throwing inside the dispatch and
24
- // silently falling back per op.
25
- // ponytail: small mirror of the SDK set; if the SDK adds a reserved attr, a new
26
- // op for it just reverts to the (working) throw→fallback path until synced.
27
- const RESERVED_CUTOVER_ATTRS = new Set<string>([
28
- "data-hf-id",
29
- "data-composition-id",
30
- "data-width",
31
- "data-height",
32
- "data-start",
33
- "data-end",
34
- "data-track-index",
35
- "data-hold-start",
36
- "data-hold-end",
37
- "data-hold-fill",
38
- ]);
39
-
40
- function sdkAttrName(op: PatchOperation): string | null {
41
- if (op.type === "attribute") {
42
- return op.property.startsWith("data-") ? op.property : `data-${op.property}`;
43
- }
44
- if (op.type === "html-attribute") return op.property;
45
- return null;
46
- }
47
-
48
- function mapsToReservedAttr(op: PatchOperation): boolean {
49
- const name = sdkAttrName(op);
50
- // Lowercase to match the SDK's validateSetAttribute (it lowercases before the
51
- // reserved check), so "DATA-START" is declined up front too; covers both
52
- // `attribute` (prefixed) and `html-attribute` (raw) ops.
53
- return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase());
54
- }
55
-
56
- // ─── html-attribute safety ───────────────────────────────────────────────────
57
-
58
- function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean {
59
- return ops.some(
60
- (op) =>
61
- op.type === "html-attribute" &&
62
- (!isAllowedHtmlAttribute(op.property) ||
63
- (op.value !== null && !isSafeAttributeValue(op.property, op.value))),
64
- );
65
- }
66
-
67
- function hasTextContentOp(ops: PatchOperation[]): boolean {
68
- return ops.some((op) => op.type === "text-content");
69
- }
70
-
71
- function targetChildren(target: unknown): unknown[] | null {
72
- if (!target || typeof target !== "object" || !("children" in target)) return null;
73
- const children = (target as { children?: unknown }).children;
74
- return Array.isArray(children) ? children : null;
75
- }
76
-
77
- function elementTag(element: unknown): string | null {
78
- if (!element || typeof element !== "object" || !("tag" in element)) return null;
79
- const tag = (element as { tag?: unknown }).tag;
80
- return typeof tag === "string" ? tag.toLowerCase() : null;
81
- }
82
-
83
- // Tags that are non-HTML namespace elements in a linkedom-parsed HTML body.
84
- // Mirrors the engine's `isHTMLElementTarget` (model.ts) which uses `instanceof
85
- // HTMLElement` — that runtime check catches the same set, but we can't use it
86
- // here because `target` is a plain SDK object, not a DOM Element. If linkedom
87
- // (or a future parser) surfaces additional foreign-content elements as
88
- // non-HTMLElement, add them here.
89
- const NON_HTML_CHILD_TAGS = new Set(["svg", "math"]);
90
-
91
- function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOperation[]): boolean {
92
- if (!hasTextContentOp(ops)) return false;
93
- const children = targetChildren(target);
94
- if (!children) return false;
95
- // Legacy patch-element replaces the whole element for multi-child targets and
96
- // for single non-HTML children. The SDK text patch stream stores a scalar
97
- // inverse, so those shapes cannot be made both byte-identical and undo-safe
98
- // here. Let the server path remain authoritative for them.
99
- if (children.length > 1) return true;
100
- const tag = elementTag(children[0]);
101
- return tag !== null && NON_HTML_CHILD_TAGS.has(tag);
102
- }
103
-
104
- export function shouldUseSdkCutover(
105
- flagEnabled: boolean,
106
- hasSession: boolean,
107
- hfId: string | null | undefined,
108
- ops: PatchOperation[],
109
- ): boolean {
110
- return (
111
- flagEnabled &&
112
- hasSession &&
113
- !!hfId &&
114
- ops.length > 0 &&
115
- ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
116
- !ops.some(mapsToReservedAttr) &&
117
- !hasUnsafeHtmlAttributeOp(ops)
118
- );
119
- }
13
+ export { shouldUseSdkCutover } from "./sdkCutoverEligibility";
120
14
 
121
15
  export interface CutoverDeps {
122
16
  editHistory: {
@@ -271,7 +165,13 @@ export async function sdkTimingPersist(
271
165
  ): Promise<boolean> {
272
166
  // Resolver tripwire — runs BEFORE the cutover gate (decoupled): records when
273
167
  // the SDK can't resolve a target the server timing path is addressing.
274
- recordResolverParity(sdkSession, hfId, "setTiming");
168
+ const timingSrc = deps.readProjectFile;
169
+ void recordResolverParity(
170
+ sdkSession,
171
+ hfId,
172
+ "setTiming",
173
+ timingSrc ? () => timingSrc(targetPath) : undefined,
174
+ );
275
175
  // Dark-launch gate: without this, timing cutover runs whenever an SDK session
276
176
  // exists (it always does, for shadow/selection) — flipping the flag OFF would
277
177
  // NOT disable it. Gate here so flag-off routes back to the legacy server path.
@@ -313,13 +213,21 @@ export function sdkGsapTweenPersist(
313
213
  // animationId (animation-resolution parity). Done here, not via
314
214
  // dispatchGsapOpAndPersist's resolverTarget, because the gate below returns
315
215
  // before that call when cutover is off.
316
- if (op.kind === "add") recordResolverParity(sdkSession, op.target, "addGsapTween");
317
- else
216
+ if (op.kind === "add") {
217
+ const gsapSrc = deps.readProjectFile;
218
+ void recordResolverParity(
219
+ sdkSession,
220
+ op.target,
221
+ "addGsapTween",
222
+ gsapSrc ? () => gsapSrc(targetPath) : undefined,
223
+ );
224
+ } else {
318
225
  recordAnimationResolverParity(
319
226
  sdkSession,
320
227
  op.animationId,
321
228
  op.kind === "set" ? "setGsapTween" : "removeGsapTween",
322
229
  );
230
+ }
323
231
  // Leading dark-launch gate so flag-off does no SDK touch (getElement) at all —
324
232
  // matches the other three chokepoints' discipline.
325
233
  if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(false);
@@ -576,7 +484,9 @@ export async function sdkDeletePersist(
576
484
  deps: CutoverDeps,
577
485
  ): Promise<boolean> {
578
486
  // Resolver tripwire — runs BEFORE the cutover gate (decoupled).
579
- recordResolverParity(sdkSession, hfId, "removeElement");
487
+ void recordResolverParity(sdkSession, hfId, "removeElement", () =>
488
+ Promise.resolve(originalContent),
489
+ );
580
490
  // Dark-launch gate: flag OFF → legacy server delete path.
581
491
  if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
582
492
  if (!sdkSession || !sdkSession.getElement(hfId)) return false;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Cutover eligibility checks: whether a batch of patch ops is safe to route
3
+ * through the SDK cutover path instead of the legacy server path. Split out of
4
+ * sdkCutover.ts (which hit the packages/studio 600-line filesize cap) — this
5
+ * block has no dependency on the persist/dispatch functions there.
6
+ */
7
+ import type { PatchOperation } from "./sourcePatcher";
8
+ import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety";
9
+
10
+ const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
11
+ "inline-style",
12
+ "text-content",
13
+ "attribute",
14
+ "html-attribute",
15
+ ]);
16
+
17
+ // Mirrors the SDK's RESERVED_ATTRS (mutate.ts): a bare `attribute` op is
18
+ // force-prefixed `data-`, so e.g. property "end" → "data-end", which the SDK
19
+ // rejects with a throw. Detect that up front and decline the whole batch so it
20
+ // takes the server path cleanly, instead of throwing inside the dispatch and
21
+ // silently falling back per op.
22
+ // ponytail: small mirror of the SDK set; if the SDK adds a reserved attr, a new
23
+ // op for it just reverts to the (working) throw→fallback path until synced.
24
+ const RESERVED_CUTOVER_ATTRS = new Set<string>([
25
+ "data-hf-id",
26
+ "data-composition-id",
27
+ "data-width",
28
+ "data-height",
29
+ "data-start",
30
+ "data-end",
31
+ "data-track-index",
32
+ "data-hold-start",
33
+ "data-hold-end",
34
+ "data-hold-fill",
35
+ ]);
36
+
37
+ function sdkAttrName(op: PatchOperation): string | null {
38
+ if (op.type === "attribute") {
39
+ return op.property.startsWith("data-") ? op.property : `data-${op.property}`;
40
+ }
41
+ if (op.type === "html-attribute") return op.property;
42
+ return null;
43
+ }
44
+
45
+ function mapsToReservedAttr(op: PatchOperation): boolean {
46
+ const name = sdkAttrName(op);
47
+ // Lowercase to match the SDK's validateSetAttribute (it lowercases before the
48
+ // reserved check), so "DATA-START" is declined up front too; covers both
49
+ // `attribute` (prefixed) and `html-attribute` (raw) ops.
50
+ return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase());
51
+ }
52
+
53
+ // ─── html-attribute safety ───────────────────────────────────────────────────
54
+
55
+ function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean {
56
+ return ops.some(
57
+ (op) =>
58
+ op.type === "html-attribute" &&
59
+ (!isAllowedHtmlAttribute(op.property) ||
60
+ (op.value !== null && !isSafeAttributeValue(op.property, op.value))),
61
+ );
62
+ }
63
+
64
+ function hasTextContentOp(ops: PatchOperation[]): boolean {
65
+ return ops.some((op) => op.type === "text-content");
66
+ }
67
+
68
+ function targetChildren(target: unknown): unknown[] | null {
69
+ if (!target || typeof target !== "object" || !("children" in target)) return null;
70
+ const children = (target as { children?: unknown }).children;
71
+ return Array.isArray(children) ? children : null;
72
+ }
73
+
74
+ function elementTag(element: unknown): string | null {
75
+ if (!element || typeof element !== "object" || !("tag" in element)) return null;
76
+ const tag = (element as { tag?: unknown }).tag;
77
+ return typeof tag === "string" ? tag.toLowerCase() : null;
78
+ }
79
+
80
+ // Tags that are non-HTML namespace elements in a linkedom-parsed HTML body.
81
+ // Mirrors the engine's `isHTMLElementTarget` (model.ts) which uses `instanceof
82
+ // HTMLElement` — that runtime check catches the same set, but we can't use it
83
+ // here because `target` is a plain SDK object, not a DOM Element. If linkedom
84
+ // (or a future parser) surfaces additional foreign-content elements as
85
+ // non-HTMLElement, add them here.
86
+ const NON_HTML_CHILD_TAGS = new Set(["svg", "math"]);
87
+
88
+ export function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOperation[]): boolean {
89
+ if (!hasTextContentOp(ops)) return false;
90
+ const children = targetChildren(target);
91
+ if (!children) return false;
92
+ // Legacy patch-element replaces the whole element for multi-child targets and
93
+ // for single non-HTML children. The SDK text patch stream stores a scalar
94
+ // inverse, so those shapes cannot be made both byte-identical and undo-safe
95
+ // here. Let the server path remain authoritative for them.
96
+ if (children.length > 1) return true;
97
+ const tag = elementTag(children[0]);
98
+ return tag !== null && NON_HTML_CHILD_TAGS.has(tag);
99
+ }
100
+
101
+ export function shouldUseSdkCutover(
102
+ flagEnabled: boolean,
103
+ hasSession: boolean,
104
+ hfId: string | null | undefined,
105
+ ops: PatchOperation[],
106
+ ): boolean {
107
+ return (
108
+ flagEnabled &&
109
+ hasSession &&
110
+ !!hfId &&
111
+ ops.length > 0 &&
112
+ ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
113
+ !ops.some(mapsToReservedAttr) &&
114
+ !hasUnsafeHtmlAttributeOp(ops)
115
+ );
116
+ }
@@ -273,6 +273,22 @@ describe("C. Resolver-parity detection", () => {
273
273
  expect(lastShadow()?.sourceHfIdCount).toBe(2);
274
274
  });
275
275
 
276
+ it("C8 sourceLooseMatchOnly: hfId matches source only as plain text, not a data-hf-id attribute", async () => {
277
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
278
+ const session = { getElement: () => null, getElements: () => [] } as unknown as Composition;
279
+ // "hf-widget" appears only inside a class name, never as data-hf-id="hf-widget".
280
+ const source = `<div class="hf-widget-container">no attribute match here</div>`;
281
+ runResolverShadow(
282
+ session,
283
+ "hf-widget",
284
+ [{ type: "inline-style", property: "color", value: "red" }],
285
+ source,
286
+ );
287
+ const ev = lastShadow();
288
+ expect(ev?.sourceHfIdCount).toBe(0);
289
+ expect(ev?.sourceLooseMatchOnly).toBe(true);
290
+ });
291
+
276
292
  it("C10: unmappable op type produces no mismatch (excluded, not flagged)", async () => {
277
293
  const session = await openComposition(BASE_HTML);
278
294
  // "unknown-op" is not in MAPPED_OP_TYPES, so it must be silently excluded.
@@ -345,7 +361,7 @@ describe("F. recordResolverParity", () => {
345
361
  it("emits element_not_found when the SDK cannot resolve the target", async () => {
346
362
  mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
347
363
  const session = await openComposition(BASE_HTML);
348
- recordResolverParity(session, "hf-missing", "setTiming");
364
+ await recordResolverParity(session, "hf-missing", "setTiming");
349
365
  const ev = lastShadow();
350
366
  expect(ev?.mismatchCount).toBe(1);
351
367
  expect(ev?.opLabel).toBe("setTiming");
@@ -355,7 +371,7 @@ describe("F. recordResolverParity", () => {
355
371
  it("emits nothing when the target resolves (parity)", async () => {
356
372
  mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
357
373
  const session = await openComposition(BASE_HTML);
358
- recordResolverParity(session, "hf-box", "removeElement");
374
+ await recordResolverParity(session, "hf-box", "removeElement");
359
375
  expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
360
376
  });
361
377
 
@@ -363,7 +379,7 @@ describe("F. recordResolverParity", () => {
363
379
  mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
364
380
  const session = await openComposition(BASE_HTML);
365
381
  const spy = vi.spyOn(session, "getElement");
366
- recordResolverParity(session, "hf-missing", "setTiming");
382
+ await recordResolverParity(session, "hf-missing", "setTiming");
367
383
  expect(trackedEvents).toHaveLength(0);
368
384
  expect(spy).not.toHaveBeenCalled();
369
385
  });
@@ -371,9 +387,71 @@ describe("F. recordResolverParity", () => {
371
387
  it("never mutates the session (read-only resolver check)", async () => {
372
388
  mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
373
389
  const session = await openComposition(BASE_HTML);
374
- recordResolverParity(session, "hf-box", "setTiming");
390
+ await recordResolverParity(session, "hf-box", "setTiming");
375
391
  expect(session.getElement("hf-box")?.inlineStyles.color).toBe("red"); // unchanged
376
392
  });
393
+
394
+ it("suppresses the emit when the hfId is absent from source (runtime node)", async () => {
395
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
396
+ const session = await openComposition(BASE_HTML);
397
+ await recordResolverParity(session, "hf-runtime", "setTiming", () =>
398
+ Promise.resolve('<div data-hf-id="hf-other"></div>'),
399
+ );
400
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
401
+ });
402
+
403
+ it("emits with sourceHfIdCount=1 when the hfId IS in source but missing from the session", async () => {
404
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
405
+ const session = await openComposition(BASE_HTML);
406
+ await recordResolverParity(session, "hf-ghost", "setTiming", () =>
407
+ Promise.resolve('<div data-hf-id="hf-ghost"></div>'),
408
+ );
409
+ const ev = lastShadow();
410
+ expect(ev?.mismatchCount).toBe(1);
411
+ expect(ev?.sourceHfIdCount).toBe(1);
412
+ });
413
+
414
+ it("reports sourceHfIdCount=2 for a duplicate-id source (ambiguity)", async () => {
415
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
416
+ const session = await openComposition(BASE_HTML);
417
+ await recordResolverParity(session, "hf-dup", "setTiming", () =>
418
+ Promise.resolve('<a data-hf-id="hf-dup"></a><b data-hf-id="hf-dup"></b>'),
419
+ );
420
+ expect(lastShadow()?.sourceHfIdCount).toBe(2);
421
+ });
422
+
423
+ it("emits without sourceHfIdCount when no reader is supplied (status quo)", async () => {
424
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
425
+ const session = await openComposition(BASE_HTML);
426
+ await recordResolverParity(session, "hf-missing", "setTiming");
427
+ const ev = lastShadow();
428
+ expect(ev?.mismatchCount).toBe(1);
429
+ expect(ev?.sourceHfIdCount).toBeUndefined();
430
+ });
431
+
432
+ it("fails open: a readSource error still emits (no suppression)", async () => {
433
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
434
+ const session = await openComposition(BASE_HTML);
435
+ await recordResolverParity(session, "hf-missing", "setTiming", () =>
436
+ Promise.reject(new Error("read failed")),
437
+ );
438
+ const ev = lastShadow();
439
+ expect(ev?.mismatchCount).toBe(1);
440
+ expect(ev?.sourceHfIdCount).toBeUndefined();
441
+ });
442
+
443
+ it("tags sourceLooseMatchOnly when hfId matches source only as plain text, not a data-hf-id attribute", async () => {
444
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
445
+ const session = await openComposition(BASE_HTML);
446
+ // "hf-widget" appears only inside a class name, never as data-hf-id="hf-widget".
447
+ await recordResolverParity(session, "hf-widget", "setTiming", () =>
448
+ Promise.resolve('<div class="hf-widget-container"></div>'),
449
+ );
450
+ const ev = lastShadow();
451
+ expect(ev?.mismatchCount).toBe(1);
452
+ expect(ev?.sourceHfIdCount).toBe(0);
453
+ expect(ev?.sourceLooseMatchOnly).toBe(true);
454
+ });
377
455
  });
378
456
 
379
457
  // ─── G. recordAnimationResolverParity (GSAP animationId ops) ──────────────────
@@ -442,7 +520,7 @@ describe("H. inlined sub-composition leaf", () => {
442
520
  it("recordResolverParity emits NOTHING for a bare leaf inside a sub-comp", async () => {
443
521
  mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
444
522
  const session = await openComposition(INLINED_HTML);
445
- recordResolverParity(session, "hf-leaf", "setTiming");
523
+ await recordResolverParity(session, "hf-leaf", "setTiming");
446
524
  expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
447
525
  });
448
526
 
@@ -183,6 +183,9 @@ export function sdkResolverShadowCheck(
183
183
  // a genuine resolver divergence (the v0.6.110 class) — keep emitting that.
184
184
  // ponytail: substring match; biases toward keeping signal on a loose hit.
185
185
  if (sourceContent !== undefined && !sourceContent.includes(hfId)) return [];
186
+ // Loose match here vs. countHfIdInSource's strict data-hf-id="..." match in the
187
+ // caller (runResolverShadow) means an emitted event can carry sourceHfIdCount: 0 —
188
+ // see the comment on that field in runResolverShadow for what 0 means in that case.
186
189
  return [{ kind: "element_not_found", hfId }];
187
190
  }
188
191
 
@@ -269,19 +272,25 @@ export function runResolverShadow(
269
272
  // every style/text/attr edit (the editor's chattiest path) at default-ON.
270
273
  if (mismatches.length === 0) return;
271
274
  const isElementNotFound = mismatches.some((m) => m.kind === "element_not_found");
275
+ const strictCount =
276
+ isElementNotFound && sourceContent !== undefined
277
+ ? countHfIdInSource(sourceContent, hfId)
278
+ : undefined;
272
279
  trackStudioEvent("sdk_resolver_shadow", {
273
280
  hfId,
274
281
  // sessionElementCount > 0 + element_not_found = runtime-only element;
275
282
  // sessionElementCount === 0 = session is empty/broken (actionable).
276
283
  sessionElementCount: session.getElements().length,
277
284
  // Count of data-hf-id="<id>" occurrences in source for an emitted
278
- // element_not_found (the runtime-node filter already dropped absent-from-
279
- // source ids, so an emitted one is in source ≥1×). >1 = duplicate ids →
280
- // resolver picked the wrong instance; =1 = single static node the SDK
281
- // parse dropped (foreign-content exclusion / sub-comp inlining gap).
282
- ...(isElementNotFound && sourceContent !== undefined
283
- ? { sourceHfIdCount: countHfIdInSource(sourceContent, hfId) }
284
- : {}),
285
+ // element_not_found. >1 = duplicate ids resolver picked the wrong
286
+ // instance; =1 = single static node the SDK parse dropped (foreign-content
287
+ // exclusion / sub-comp inlining gap); =0 = the runtime-node filter above
288
+ // uses a loose substring match (biased toward keeping signal) while this
289
+ // count uses a strict attribute match — see sourceLooseMatchOnly below.
290
+ ...(strictCount !== undefined ? { sourceHfIdCount: strictCount } : {}),
291
+ // Loose suppression check matched (kept this event) but the strict
292
+ // attribute count came back 0 — see the sourceHfIdCount comment above.
293
+ ...(strictCount === 0 ? { sourceLooseMatchOnly: true } : {}),
285
294
  mismatchCount: mismatches.length,
286
295
  mismatches: JSON.stringify(redactMismatches(mismatches)),
287
296
  });
@@ -300,19 +309,49 @@ export function runResolverShadow(
300
309
  *
301
310
  * No-op when the shadow flag is off; never throws; never mutates the session.
302
311
  */
303
- export function recordResolverParity(
312
+ export async function recordResolverParity(
304
313
  session: Composition | null | undefined,
305
314
  hfId: string | null | undefined,
306
315
  opLabel: string,
307
- ): void {
316
+ readSource?: () => Promise<string | undefined>,
317
+ ): Promise<void> {
308
318
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
309
319
  if (!session || !hfId) return;
310
320
  try {
311
321
  if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
322
+ // Capture BEFORE any await: this call is fire-and-forget (`void recordResolverParity(...)`)
323
+ // and the caller runs its own session mutation synchronously right after this call
324
+ // returns. getElements() caches and that cache is invalidated on dispatch, so reading
325
+ // the count after an await would silently reflect POST-edit state, not the pre-edit
326
+ // state this field exists to diagnose.
327
+ const sessionElementCount = session.getElements().length;
328
+ // Cheap check passed above, so the source read only runs on a real divergence.
329
+ let source: string | undefined;
330
+ if (readSource) {
331
+ try {
332
+ source = await readSource();
333
+ } catch {
334
+ source = undefined; // fail-open: a read error must not drop a real divergence
335
+ }
336
+ }
337
+ // Runtime-generated node the static parse can't model — suppress (mirrors the dom-edit path).
338
+ if (source !== undefined && !source.includes(hfId)) return;
339
+ const strictCount = source !== undefined ? countHfIdInSource(source, hfId) : undefined;
312
340
  trackStudioEvent("sdk_resolver_shadow", {
313
341
  hfId,
314
342
  opLabel,
315
- sessionElementCount: session.getElements().length,
343
+ sessionElementCount,
344
+ // sourceHfIdCount: strict data-hf-id="..." attribute count. Can be 0 even
345
+ // on an emitted (non-suppressed) event — the suppression check above is a
346
+ // loose substring match (biased toward keeping signal); see the longer
347
+ // comment on this field in runResolverShadow for the full explanation.
348
+ ...(strictCount !== undefined ? { sourceHfIdCount: strictCount } : {}),
349
+ // Loose suppression check matched (kept this event) but the strict
350
+ // attribute count came back 0 — hfId appeared as plain text (class name,
351
+ // comment, script string) but never as a data-hf-id="..." attribute.
352
+ // Lets telemetry consumers filter this cohort without parsing the
353
+ // sourceHfIdCount comment above.
354
+ ...(strictCount === 0 ? { sourceLooseMatchOnly: true } : {}),
316
355
  mismatchCount: 1,
317
356
  mismatches: JSON.stringify([
318
357
  { kind: "element_not_found", hfId } satisfies SdkResolverMismatch,
@@ -1,4 +1,4 @@
1
- import { generateId } from "./generateId";
1
+ import { resolveStudioDistinctId } from "../telemetry/distinctId";
2
2
 
3
3
  // PostHog public ingest key — write-only, safe to ship in the client bundle
4
4
  const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
@@ -18,26 +18,12 @@ interface QueuedEvent {
18
18
 
19
19
  let queue: QueuedEvent[] = [];
20
20
  let flushTimer: ReturnType<typeof setInterval> | null = null;
21
- let distinctId: string | null = null;
22
21
 
22
+ // Delegates to the single source of truth (telemetry/distinctId.ts) so `studio:*`
23
+ // events share one id with `studio_*` / render events, and adopt the CLI's
24
+ // distinct_id when the CLI launched Studio.
23
25
  function getDistinctId(): string {
24
- if (distinctId) return distinctId;
25
- try {
26
- const stored = localStorage.getItem("hf-studio-anon-id");
27
- if (stored) {
28
- distinctId = stored;
29
- return stored;
30
- }
31
- } catch {
32
- // localStorage may be unavailable
33
- }
34
- distinctId = generateId();
35
- try {
36
- localStorage.setItem("hf-studio-anon-id", distinctId);
37
- } catch {
38
- // best-effort persistence
39
- }
40
- return distinctId;
26
+ return resolveStudioDistinctId();
41
27
  }
42
28
 
43
29
  function isEnabled(): boolean {