@hyperframes/studio 0.7.21 → 0.7.23
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.
- package/dist/assets/hyperframes-player-BGuumSiM.js +459 -0
- package/dist/assets/{index-jbPe1Dih.js → index-B8XMgbWF.js} +157 -157
- package/dist/assets/{index-pAaVqALC.js → index-BRZTrsSs.js} +1 -1
- package/dist/assets/{index-gFA786gK.js → index-DPO5Gw_v.js} +1 -1
- package/dist/{chunk-SBGXX7WY.js → chunk-AN2EWWK3.js} +59 -108
- package/dist/chunk-AN2EWWK3.js.map +1 -0
- package/dist/{domEditingLayers-VZMLL4AP.js → domEditingLayers-EK7R7R4G.js} +4 -2
- package/dist/index.html +1 -1
- package/dist/index.js +163 -99
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -5
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/editor/PropertyPanel.tsx +11 -8
- package/src/components/editor/domEditingDom.ts +4 -33
- package/src/components/editor/domEditingLayers.ts +88 -108
- package/src/components/editor/manualEditingAvailability.ts +0 -10
- package/src/components/editor/propertyPanelColorGradingSection.tsx +0 -4
- package/src/components/editor/propertyPanelMediaSection.tsx +0 -6
- package/src/contexts/ViewModeContext.tsx +10 -19
- package/src/hooks/useDomEditSession.test.tsx +277 -0
- package/src/hooks/useDomEditSession.ts +7 -4
- package/src/telemetry/config.ts +10 -33
- package/src/telemetry/distinctId.test.ts +103 -0
- package/src/telemetry/distinctId.ts +125 -0
- package/src/utils/safeStorage.ts +20 -0
- package/src/utils/sdkCutover.ts +21 -112
- package/src/utils/sdkCutoverEligibility.ts +116 -0
- package/src/utils/sdkResolverShadow.test.ts +101 -5
- package/src/utils/sdkResolverShadow.ts +67 -4
- package/src/utils/studioTelemetry.ts +5 -19
- package/dist/assets/hyperframes-player-DNLS_l47.js +0 -459
- package/dist/chunk-SBGXX7WY.js.map +0 -1
- package/src/hooks/useDomEditSession.test.ts +0 -41
- /package/dist/{domEditingLayers-VZMLL4AP.js.map → domEditingLayers-EK7R7R4G.js.map} +0 -0
package/src/utils/sdkCutover.ts
CHANGED
|
@@ -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 {
|
|
11
|
+
import { shouldDeclineTextCutoverForTarget, shouldUseSdkCutover } from "./sdkCutoverEligibility";
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
|
|
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,8 +213,15 @@ 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")
|
|
317
|
-
|
|
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,
|
|
@@ -576,7 +483,9 @@ export async function sdkDeletePersist(
|
|
|
576
483
|
deps: CutoverDeps,
|
|
577
484
|
): Promise<boolean> {
|
|
578
485
|
// Resolver tripwire — runs BEFORE the cutover gate (decoupled).
|
|
579
|
-
recordResolverParity(sdkSession, hfId, "removeElement")
|
|
486
|
+
void recordResolverParity(sdkSession, hfId, "removeElement", () =>
|
|
487
|
+
Promise.resolve(originalContent),
|
|
488
|
+
);
|
|
580
489
|
// Dark-launch gate: flag OFF → legacy server delete path.
|
|
581
490
|
if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
|
|
582
491
|
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
|
+
}
|
|
@@ -226,6 +226,53 @@ describe("C. Resolver-parity detection", () => {
|
|
|
226
226
|
});
|
|
227
227
|
});
|
|
228
228
|
|
|
229
|
+
it("C8 runtime-node filter: hfId absent from source → suppressed (not a resolver bug)", () => {
|
|
230
|
+
// The studio resolved a live-DOM element to an hf-id that the SDK session
|
|
231
|
+
// doesn't contain AND that never appears in the on-disk source — it's a
|
|
232
|
+
// node a composition <script> created at runtime (e.g. caption spans). Not
|
|
233
|
+
// a resolver divergence; suppress.
|
|
234
|
+
const session = { getElement: () => null, getElements: () => [] } as unknown as Parameters<
|
|
235
|
+
typeof sdkResolverShadowCheck
|
|
236
|
+
>[0];
|
|
237
|
+
const source = `<div data-hf-id="hf-static">no runtime id here</div>`;
|
|
238
|
+
const mismatches = sdkResolverShadowCheck(
|
|
239
|
+
session,
|
|
240
|
+
"hf-runtimeonly",
|
|
241
|
+
[{ type: "inline-style", property: "color", value: "red" }],
|
|
242
|
+
source,
|
|
243
|
+
);
|
|
244
|
+
expect(mismatches).toHaveLength(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("C8 runtime-node filter: hfId PRESENT in source but missing from session → still flagged (real bug)", () => {
|
|
248
|
+
const session = { getElement: () => null, getElements: () => [] } as unknown as Parameters<
|
|
249
|
+
typeof sdkResolverShadowCheck
|
|
250
|
+
>[0];
|
|
251
|
+
const source = `<div data-hf-id="hf-realbug">in source, not in SDK session</div>`;
|
|
252
|
+
const mismatches = sdkResolverShadowCheck(
|
|
253
|
+
session,
|
|
254
|
+
"hf-realbug",
|
|
255
|
+
[{ type: "inline-style", property: "color", value: "red" }],
|
|
256
|
+
source,
|
|
257
|
+
);
|
|
258
|
+
expect(mismatches).toHaveLength(1);
|
|
259
|
+
expect(mismatches[0]?.kind).toBe("element_not_found");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("C8 sourceHfIdCount: emitted element_not_found carries source occurrence count", async () => {
|
|
263
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
264
|
+
const session = { getElement: () => null, getElements: () => [] } as unknown as Composition;
|
|
265
|
+
// id present twice in source (duplicate-id ambiguity) but absent from session
|
|
266
|
+
const source = `<div data-hf-id="hf-dup">a</div><div data-hf-id="hf-dup">b</div>`;
|
|
267
|
+
runResolverShadow(
|
|
268
|
+
session,
|
|
269
|
+
"hf-dup",
|
|
270
|
+
[{ type: "inline-style", property: "color", value: "red" }],
|
|
271
|
+
source,
|
|
272
|
+
);
|
|
273
|
+
expect(lastShadow()?.sourceHfIdCount).toBe(2);
|
|
274
|
+
});
|
|
275
|
+
|
|
229
276
|
it("C10: unmappable op type produces no mismatch (excluded, not flagged)", async () => {
|
|
230
277
|
const session = await openComposition(BASE_HTML);
|
|
231
278
|
// "unknown-op" is not in MAPPED_OP_TYPES, so it must be silently excluded.
|
|
@@ -298,7 +345,7 @@ describe("F. recordResolverParity", () => {
|
|
|
298
345
|
it("emits element_not_found when the SDK cannot resolve the target", async () => {
|
|
299
346
|
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
300
347
|
const session = await openComposition(BASE_HTML);
|
|
301
|
-
recordResolverParity(session, "hf-missing", "setTiming");
|
|
348
|
+
await recordResolverParity(session, "hf-missing", "setTiming");
|
|
302
349
|
const ev = lastShadow();
|
|
303
350
|
expect(ev?.mismatchCount).toBe(1);
|
|
304
351
|
expect(ev?.opLabel).toBe("setTiming");
|
|
@@ -308,7 +355,7 @@ describe("F. recordResolverParity", () => {
|
|
|
308
355
|
it("emits nothing when the target resolves (parity)", async () => {
|
|
309
356
|
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
310
357
|
const session = await openComposition(BASE_HTML);
|
|
311
|
-
recordResolverParity(session, "hf-box", "removeElement");
|
|
358
|
+
await recordResolverParity(session, "hf-box", "removeElement");
|
|
312
359
|
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
|
313
360
|
});
|
|
314
361
|
|
|
@@ -316,7 +363,7 @@ describe("F. recordResolverParity", () => {
|
|
|
316
363
|
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
|
|
317
364
|
const session = await openComposition(BASE_HTML);
|
|
318
365
|
const spy = vi.spyOn(session, "getElement");
|
|
319
|
-
recordResolverParity(session, "hf-missing", "setTiming");
|
|
366
|
+
await recordResolverParity(session, "hf-missing", "setTiming");
|
|
320
367
|
expect(trackedEvents).toHaveLength(0);
|
|
321
368
|
expect(spy).not.toHaveBeenCalled();
|
|
322
369
|
});
|
|
@@ -324,9 +371,58 @@ describe("F. recordResolverParity", () => {
|
|
|
324
371
|
it("never mutates the session (read-only resolver check)", async () => {
|
|
325
372
|
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
326
373
|
const session = await openComposition(BASE_HTML);
|
|
327
|
-
recordResolverParity(session, "hf-box", "setTiming");
|
|
374
|
+
await recordResolverParity(session, "hf-box", "setTiming");
|
|
328
375
|
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("red"); // unchanged
|
|
329
376
|
});
|
|
377
|
+
|
|
378
|
+
it("suppresses the emit when the hfId is absent from source (runtime node)", async () => {
|
|
379
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
380
|
+
const session = await openComposition(BASE_HTML);
|
|
381
|
+
await recordResolverParity(session, "hf-runtime", "setTiming", () =>
|
|
382
|
+
Promise.resolve('<div data-hf-id="hf-other"></div>'),
|
|
383
|
+
);
|
|
384
|
+
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("emits with sourceHfIdCount=1 when the hfId IS in source but missing from the session", async () => {
|
|
388
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
389
|
+
const session = await openComposition(BASE_HTML);
|
|
390
|
+
await recordResolverParity(session, "hf-ghost", "setTiming", () =>
|
|
391
|
+
Promise.resolve('<div data-hf-id="hf-ghost"></div>'),
|
|
392
|
+
);
|
|
393
|
+
const ev = lastShadow();
|
|
394
|
+
expect(ev?.mismatchCount).toBe(1);
|
|
395
|
+
expect(ev?.sourceHfIdCount).toBe(1);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("reports sourceHfIdCount=2 for a duplicate-id source (ambiguity)", async () => {
|
|
399
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
400
|
+
const session = await openComposition(BASE_HTML);
|
|
401
|
+
await recordResolverParity(session, "hf-dup", "setTiming", () =>
|
|
402
|
+
Promise.resolve('<a data-hf-id="hf-dup"></a><b data-hf-id="hf-dup"></b>'),
|
|
403
|
+
);
|
|
404
|
+
expect(lastShadow()?.sourceHfIdCount).toBe(2);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("emits without sourceHfIdCount when no reader is supplied (status quo)", async () => {
|
|
408
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
409
|
+
const session = await openComposition(BASE_HTML);
|
|
410
|
+
await recordResolverParity(session, "hf-missing", "setTiming");
|
|
411
|
+
const ev = lastShadow();
|
|
412
|
+
expect(ev?.mismatchCount).toBe(1);
|
|
413
|
+
expect(ev?.sourceHfIdCount).toBeUndefined();
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("fails open: a readSource error still emits (no suppression)", async () => {
|
|
417
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
418
|
+
const session = await openComposition(BASE_HTML);
|
|
419
|
+
await recordResolverParity(session, "hf-missing", "setTiming", () =>
|
|
420
|
+
Promise.reject(new Error("read failed")),
|
|
421
|
+
);
|
|
422
|
+
const ev = lastShadow();
|
|
423
|
+
expect(ev?.mismatchCount).toBe(1);
|
|
424
|
+
expect(ev?.sourceHfIdCount).toBeUndefined();
|
|
425
|
+
});
|
|
330
426
|
});
|
|
331
427
|
|
|
332
428
|
// ─── G. recordAnimationResolverParity (GSAP animationId ops) ──────────────────
|
|
@@ -395,7 +491,7 @@ describe("H. inlined sub-composition leaf", () => {
|
|
|
395
491
|
it("recordResolverParity emits NOTHING for a bare leaf inside a sub-comp", async () => {
|
|
396
492
|
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
397
493
|
const session = await openComposition(INLINED_HTML);
|
|
398
|
-
recordResolverParity(session, "hf-leaf", "setTiming");
|
|
494
|
+
await recordResolverParity(session, "hf-leaf", "setTiming");
|
|
399
495
|
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
|
400
496
|
});
|
|
401
497
|
|
|
@@ -82,6 +82,14 @@ type AttrMap = Record<string, string | null>;
|
|
|
82
82
|
* Mirror resolveScoped here: exact scoped-path match, then canonical bare
|
|
83
83
|
* match, then first bare match — the resolvability dispatch actually has.
|
|
84
84
|
*/
|
|
85
|
+
// Count static `data-hf-id="<id>"` occurrences (both quote styles) in source.
|
|
86
|
+
// Substring split, not regex — no escaping, and the id never contains a quote.
|
|
87
|
+
function countHfIdInSource(source: string, id: string): number {
|
|
88
|
+
return (
|
|
89
|
+
source.split(`data-hf-id="${id}"`).length - 1 + (source.split(`data-hf-id='${id}'`).length - 1)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
85
93
|
function resolveSnapshot(session: Composition, id: string): FlatEl | null {
|
|
86
94
|
const els = session.getElements();
|
|
87
95
|
const exact = els.find((el) => el.scopedId === id);
|
|
@@ -164,8 +172,20 @@ export function sdkResolverShadowCheck(
|
|
|
164
172
|
session: Composition,
|
|
165
173
|
hfId: string,
|
|
166
174
|
ops: PatchOperation[],
|
|
175
|
+
sourceContent?: string,
|
|
167
176
|
): SdkResolverMismatch[] {
|
|
168
177
|
if (!resolveSnapshot(session, hfId)) {
|
|
178
|
+
// Runtime-node filter: an hf-id absent from the on-disk source the SDK
|
|
179
|
+
// parsed was never in the static DOM — it belongs to an element a
|
|
180
|
+
// composition <script> creates at runtime (e.g. caption word/group spans),
|
|
181
|
+
// which the SDK session cannot model by design. That is NOT a resolver bug,
|
|
182
|
+
// so suppress it. An hf-id PRESENT in source but missing from the session IS
|
|
183
|
+
// a genuine resolver divergence (the v0.6.110 class) — keep emitting that.
|
|
184
|
+
// ponytail: substring match; biases toward keeping signal on a loose hit.
|
|
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.
|
|
169
189
|
return [{ kind: "element_not_found", hfId }];
|
|
170
190
|
}
|
|
171
191
|
|
|
@@ -241,17 +261,34 @@ export function runResolverShadow(
|
|
|
241
261
|
session: Composition,
|
|
242
262
|
hfId: string | null | undefined,
|
|
243
263
|
ops: PatchOperation[],
|
|
264
|
+
sourceContent?: string,
|
|
244
265
|
): void {
|
|
245
266
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
|
246
267
|
if (!hfId) return;
|
|
247
268
|
try {
|
|
248
|
-
const mismatches = sdkResolverShadowCheck(session, hfId, ops);
|
|
269
|
+
const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent);
|
|
249
270
|
// Emit only on divergence — parity is silent, matching recordResolverParity
|
|
250
271
|
// and recordAnimationResolverParity. Otherwise this fires a PostHog event on
|
|
251
272
|
// every style/text/attr edit (the editor's chattiest path) at default-ON.
|
|
252
273
|
if (mismatches.length === 0) return;
|
|
274
|
+
const isElementNotFound = mismatches.some((m) => m.kind === "element_not_found");
|
|
253
275
|
trackStudioEvent("sdk_resolver_shadow", {
|
|
254
276
|
hfId,
|
|
277
|
+
// sessionElementCount > 0 + element_not_found = runtime-only element;
|
|
278
|
+
// sessionElementCount === 0 = session is empty/broken (actionable).
|
|
279
|
+
sessionElementCount: session.getElements().length,
|
|
280
|
+
// Count of data-hf-id="<id>" occurrences in source for an emitted
|
|
281
|
+
// element_not_found. >1 = duplicate ids → resolver picked the wrong
|
|
282
|
+
// instance; =1 = single static node the SDK parse dropped (foreign-content
|
|
283
|
+
// exclusion / sub-comp inlining gap); =0 = the runtime-node filter above
|
|
284
|
+
// uses a loose substring match (biased toward keeping signal) while this
|
|
285
|
+
// count uses a strict attribute match — an emitted event with count 0
|
|
286
|
+
// means hfId appeared in source as plain text (e.g. a class name, comment,
|
|
287
|
+
// or script string) but never as a data-hf-id attribute. Treat 0 as "not
|
|
288
|
+
// a genuine attribute occurrence," not as a contradiction.
|
|
289
|
+
...(isElementNotFound && sourceContent !== undefined
|
|
290
|
+
? { sourceHfIdCount: countHfIdInSource(sourceContent, hfId) }
|
|
291
|
+
: {}),
|
|
255
292
|
mismatchCount: mismatches.length,
|
|
256
293
|
mismatches: JSON.stringify(redactMismatches(mismatches)),
|
|
257
294
|
});
|
|
@@ -270,18 +307,42 @@ export function runResolverShadow(
|
|
|
270
307
|
*
|
|
271
308
|
* No-op when the shadow flag is off; never throws; never mutates the session.
|
|
272
309
|
*/
|
|
273
|
-
export function recordResolverParity(
|
|
310
|
+
export async function recordResolverParity(
|
|
274
311
|
session: Composition | null | undefined,
|
|
275
312
|
hfId: string | null | undefined,
|
|
276
313
|
opLabel: string,
|
|
277
|
-
)
|
|
314
|
+
readSource?: () => Promise<string | undefined>,
|
|
315
|
+
): Promise<void> {
|
|
278
316
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
|
279
317
|
if (!session || !hfId) return;
|
|
280
318
|
try {
|
|
281
319
|
if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
|
|
320
|
+
// Capture BEFORE any await: this call is fire-and-forget (`void recordResolverParity(...)`)
|
|
321
|
+
// and the caller runs its own session mutation synchronously right after this call
|
|
322
|
+
// returns. getElements() caches and that cache is invalidated on dispatch, so reading
|
|
323
|
+
// the count after an await would silently reflect POST-edit state, not the pre-edit
|
|
324
|
+
// state this field exists to diagnose.
|
|
325
|
+
const sessionElementCount = session.getElements().length;
|
|
326
|
+
// Cheap check passed above, so the source read only runs on a real divergence.
|
|
327
|
+
let source: string | undefined;
|
|
328
|
+
if (readSource) {
|
|
329
|
+
try {
|
|
330
|
+
source = await readSource();
|
|
331
|
+
} catch {
|
|
332
|
+
source = undefined; // fail-open: a read error must not drop a real divergence
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// Runtime-generated node the static parse can't model — suppress (mirrors the dom-edit path).
|
|
336
|
+
if (source !== undefined && !source.includes(hfId)) return;
|
|
282
337
|
trackStudioEvent("sdk_resolver_shadow", {
|
|
283
338
|
hfId,
|
|
284
339
|
opLabel,
|
|
340
|
+
sessionElementCount,
|
|
341
|
+
// sourceHfIdCount: strict data-hf-id="..." attribute count. Can be 0 even
|
|
342
|
+
// on an emitted (non-suppressed) event — the suppression check above is a
|
|
343
|
+
// loose substring match (biased toward keeping signal); see the longer
|
|
344
|
+
// comment on this field in runResolverShadow for the full explanation.
|
|
345
|
+
...(source !== undefined ? { sourceHfIdCount: countHfIdInSource(source, hfId) } : {}),
|
|
285
346
|
mismatchCount: 1,
|
|
286
347
|
mismatches: JSON.stringify([
|
|
287
348
|
{ kind: "element_not_found", hfId } satisfies SdkResolverMismatch,
|
|
@@ -310,11 +371,13 @@ export function recordAnimationResolverParity(
|
|
|
310
371
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
|
311
372
|
if (!session || !animationId) return;
|
|
312
373
|
try {
|
|
313
|
-
const
|
|
374
|
+
const elements = session.getElements();
|
|
375
|
+
const resolves = elements.some((el) => el.animationIds.includes(animationId));
|
|
314
376
|
if (resolves) return; // SDK locates the animation — parity
|
|
315
377
|
trackStudioEvent("sdk_resolver_shadow", {
|
|
316
378
|
animationId,
|
|
317
379
|
opLabel,
|
|
380
|
+
sessionElementCount: elements.length,
|
|
318
381
|
mismatchCount: 1,
|
|
319
382
|
mismatches: JSON.stringify([
|
|
320
383
|
{ kind: "animation_not_found", animationId } satisfies SdkResolverMismatch,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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 {
|