@hyperframes/studio 0.6.111 → 0.6.113
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/index-B8Qzwcpc.js +265 -0
- package/dist/assets/{index-x0c2-zQN.js → index-C0tFko2e.js} +1 -1
- package/dist/assets/{index-_IV-vm9l.js → index-DSLrl2tB.js} +24 -24
- package/dist/index.html +1 -1
- package/package.json +5 -5
- package/src/hooks/useDomEditCommits.ts +4 -0
- package/src/hooks/useDomEditSession.ts +9 -1
- package/src/hooks/useElementLifecycleOps.ts +9 -1
- package/src/hooks/useGsapAnimationOps.ts +96 -0
- package/src/hooks/useSdkSession.ts +29 -16
- package/src/utils/sdkCutover.ts +80 -0
- package/src/utils/sdkResolverShadow.test.ts +44 -1
- package/src/utils/sdkResolverShadow.ts +28 -3
- package/dist/assets/index-B7S86vK1.js +0 -370
package/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-B8Qzwcpc.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-DP8pPIk2.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.113",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"dompurify": "^3.2.4",
|
|
37
37
|
"marked": "^14.1.4",
|
|
38
38
|
"mediabunny": "^1.45.3",
|
|
39
|
-
"@hyperframes/
|
|
40
|
-
"@hyperframes/
|
|
41
|
-
"@hyperframes/
|
|
39
|
+
"@hyperframes/sdk": "0.6.113",
|
|
40
|
+
"@hyperframes/player": "0.6.113",
|
|
41
|
+
"@hyperframes/core": "0.6.113"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/react": "19",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"vite": "^6.4.2",
|
|
53
53
|
"vitest": "^3.2.4",
|
|
54
54
|
"zustand": "^5.0.0",
|
|
55
|
-
"@hyperframes/producer": "0.6.
|
|
55
|
+
"@hyperframes/producer": "0.6.113"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"react": "19",
|
|
@@ -82,6 +82,8 @@ export interface UseDomEditCommitsParams {
|
|
|
82
82
|
) => Promise<boolean>;
|
|
83
83
|
/** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */
|
|
84
84
|
onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
|
|
85
|
+
/** Resolver-shadow tripwire for z-index reorder targets (telemetry-only, decoupled from cutover). */
|
|
86
|
+
onReorderShadow?: (targets: string[]) => void;
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
export function useDomEditCommits({
|
|
@@ -105,6 +107,7 @@ export function useDomEditCommits({
|
|
|
105
107
|
forceReloadSdkSession,
|
|
106
108
|
onTrySdkPersist,
|
|
107
109
|
onTrySdkDelete,
|
|
110
|
+
onReorderShadow,
|
|
108
111
|
}: UseDomEditCommitsParams) {
|
|
109
112
|
const resolveImportedFontAsset = useCallback(
|
|
110
113
|
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
@@ -324,6 +327,7 @@ export function useDomEditCommits({
|
|
|
324
327
|
reloadPreview,
|
|
325
328
|
clearDomSelection,
|
|
326
329
|
onTrySdkDelete,
|
|
330
|
+
onReorderShadow,
|
|
327
331
|
forceReloadSdkSession,
|
|
328
332
|
commitPositionPatchToHtml,
|
|
329
333
|
});
|
|
@@ -6,7 +6,7 @@ import type { PatchTarget } from "../utils/sourcePatcher";
|
|
|
6
6
|
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
|
|
7
7
|
import type { Composition } from "@hyperframes/sdk";
|
|
8
8
|
import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover";
|
|
9
|
-
import { runResolverShadow } from "../utils/sdkResolverShadow";
|
|
9
|
+
import { runResolverShadow, recordResolverParity } from "../utils/sdkResolverShadow";
|
|
10
10
|
import { useAskAgentModal } from "./useAskAgentModal";
|
|
11
11
|
import { useDomSelection } from "./useDomSelection";
|
|
12
12
|
import { usePreviewInteraction } from "./usePreviewInteraction";
|
|
@@ -270,6 +270,14 @@ export function useDomEditSession({
|
|
|
270
270
|
compositionPath: activeCompPath,
|
|
271
271
|
})
|
|
272
272
|
: undefined,
|
|
273
|
+
// Resolver shadow for the z-index reorder edit: it takes the server path (no
|
|
274
|
+
// SDK persist), but the tripwire is decoupled from cutover — record whether
|
|
275
|
+
// the SDK resolves each reordered element (the reorderElements op's targets).
|
|
276
|
+
onReorderShadow: sdkSession
|
|
277
|
+
? (targets: string[]) => {
|
|
278
|
+
for (const target of targets) recordResolverParity(sdkSession, target, "reorderElements");
|
|
279
|
+
}
|
|
280
|
+
: undefined,
|
|
273
281
|
});
|
|
274
282
|
|
|
275
283
|
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
|
|
@@ -28,6 +28,8 @@ interface UseElementLifecycleOpsParams {
|
|
|
28
28
|
clearDomSelection: () => void;
|
|
29
29
|
/** Route delete through SDK when session resolves the hf-id; returns true if handled. */
|
|
30
30
|
onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
|
|
31
|
+
/** Resolver-shadow tripwire for the reordered targets (telemetry-only, decoupled from cutover). */
|
|
32
|
+
onReorderShadow?: (targets: string[]) => void;
|
|
31
33
|
/** Resync the SDK session after a server-fallback delete. */
|
|
32
34
|
forceReloadSdkSession?: () => void;
|
|
33
35
|
commitPositionPatchToHtml: (
|
|
@@ -49,6 +51,7 @@ export function useElementLifecycleOps({
|
|
|
49
51
|
reloadPreview,
|
|
50
52
|
clearDomSelection,
|
|
51
53
|
onTrySdkDelete,
|
|
54
|
+
onReorderShadow,
|
|
52
55
|
forceReloadSdkSession,
|
|
53
56
|
commitPositionPatchToHtml,
|
|
54
57
|
onElementDeleted,
|
|
@@ -168,6 +171,11 @@ export function useElementLifecycleOps({
|
|
|
168
171
|
}>,
|
|
169
172
|
) => {
|
|
170
173
|
if (entries.length === 0) return;
|
|
174
|
+
// Resolver shadow (telemetry-only, decoupled from cutover): record whether
|
|
175
|
+
// the SDK resolves each reordered element — the reorderElements op's targets.
|
|
176
|
+
onReorderShadow?.(
|
|
177
|
+
entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null),
|
|
178
|
+
);
|
|
171
179
|
const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
|
|
172
180
|
for (let i = 0; i < entries.length; i++) {
|
|
173
181
|
const entry = entries[i];
|
|
@@ -202,7 +210,7 @@ export function useElementLifecycleOps({
|
|
|
202
210
|
).catch(() => undefined);
|
|
203
211
|
}
|
|
204
212
|
},
|
|
205
|
-
[commitPositionPatchToHtml],
|
|
213
|
+
[commitPositionPatchToHtml, onReorderShadow],
|
|
206
214
|
);
|
|
207
215
|
|
|
208
216
|
return {
|
|
@@ -5,6 +5,8 @@ import { roundTo3 } from "../utils/rounding";
|
|
|
5
5
|
import {
|
|
6
6
|
sdkGsapTweenPersist,
|
|
7
7
|
sdkGsapDeleteAllForSelectorPersist,
|
|
8
|
+
sdkAddWithKeyframesPersist,
|
|
9
|
+
sdkReplaceWithKeyframesPersist,
|
|
8
10
|
type CutoverDeps,
|
|
9
11
|
} from "../utils/sdkCutover";
|
|
10
12
|
import {
|
|
@@ -181,10 +183,104 @@ export function useGsapAnimationOps({
|
|
|
181
183
|
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession, sdkDeps],
|
|
182
184
|
);
|
|
183
185
|
|
|
186
|
+
type KeyframeEntry = {
|
|
187
|
+
percentage: number;
|
|
188
|
+
properties: Record<string, number | string>;
|
|
189
|
+
ease?: string;
|
|
190
|
+
auto?: boolean;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const addWithKeyframes = useCallback(
|
|
194
|
+
async (
|
|
195
|
+
selection: DomEditSelection,
|
|
196
|
+
targetSelector: string,
|
|
197
|
+
position: number,
|
|
198
|
+
duration: number,
|
|
199
|
+
keyframes: KeyframeEntry[],
|
|
200
|
+
ease?: string,
|
|
201
|
+
label = "Add animation with keyframes",
|
|
202
|
+
) => {
|
|
203
|
+
if (sdkSession && sdkDeps) {
|
|
204
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
205
|
+
const handled = await sdkAddWithKeyframesPersist(
|
|
206
|
+
targetPath,
|
|
207
|
+
targetSelector,
|
|
208
|
+
position,
|
|
209
|
+
duration,
|
|
210
|
+
keyframes,
|
|
211
|
+
ease,
|
|
212
|
+
sdkSession,
|
|
213
|
+
sdkDeps,
|
|
214
|
+
{ label },
|
|
215
|
+
);
|
|
216
|
+
if (handled) return;
|
|
217
|
+
}
|
|
218
|
+
void commitMutation(
|
|
219
|
+
selection,
|
|
220
|
+
{
|
|
221
|
+
type: "add-with-keyframes",
|
|
222
|
+
targetSelector,
|
|
223
|
+
position,
|
|
224
|
+
duration,
|
|
225
|
+
keyframes,
|
|
226
|
+
...(ease ? { ease } : {}),
|
|
227
|
+
},
|
|
228
|
+
{ label, softReload: true },
|
|
229
|
+
);
|
|
230
|
+
},
|
|
231
|
+
[commitMutation, activeCompPath, sdkSession, sdkDeps],
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const replaceWithKeyframes = useCallback(
|
|
235
|
+
async (
|
|
236
|
+
selection: DomEditSelection,
|
|
237
|
+
animationId: string,
|
|
238
|
+
targetSelector: string,
|
|
239
|
+
position: number,
|
|
240
|
+
duration: number,
|
|
241
|
+
keyframes: KeyframeEntry[],
|
|
242
|
+
ease?: string,
|
|
243
|
+
label = "Replace animation with keyframes",
|
|
244
|
+
) => {
|
|
245
|
+
if (sdkSession && sdkDeps) {
|
|
246
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
247
|
+
const handled = await sdkReplaceWithKeyframesPersist(
|
|
248
|
+
targetPath,
|
|
249
|
+
animationId,
|
|
250
|
+
targetSelector,
|
|
251
|
+
position,
|
|
252
|
+
duration,
|
|
253
|
+
keyframes,
|
|
254
|
+
ease,
|
|
255
|
+
sdkSession,
|
|
256
|
+
sdkDeps,
|
|
257
|
+
{ label },
|
|
258
|
+
);
|
|
259
|
+
if (handled) return;
|
|
260
|
+
}
|
|
261
|
+
void commitMutation(
|
|
262
|
+
selection,
|
|
263
|
+
{
|
|
264
|
+
type: "replace-with-keyframes",
|
|
265
|
+
animationId,
|
|
266
|
+
targetSelector,
|
|
267
|
+
position,
|
|
268
|
+
duration,
|
|
269
|
+
keyframes,
|
|
270
|
+
...(ease ? { ease } : {}),
|
|
271
|
+
},
|
|
272
|
+
{ label, softReload: true },
|
|
273
|
+
);
|
|
274
|
+
},
|
|
275
|
+
[commitMutation, activeCompPath, sdkSession, sdkDeps],
|
|
276
|
+
);
|
|
277
|
+
|
|
184
278
|
return {
|
|
185
279
|
updateGsapMeta,
|
|
186
280
|
deleteGsapAnimation,
|
|
187
281
|
deleteAllForSelector,
|
|
188
282
|
addGsapAnimation,
|
|
283
|
+
addWithKeyframes,
|
|
284
|
+
replaceWithKeyframes,
|
|
189
285
|
};
|
|
190
286
|
}
|
|
@@ -1,11 +1,32 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from "react";
|
|
2
2
|
import type { MutableRefObject } from "react";
|
|
3
3
|
import { openComposition } from "@hyperframes/sdk";
|
|
4
|
-
import { createHttpAdapter } from "@hyperframes/sdk/adapters/http";
|
|
5
4
|
import type { Composition } from "@hyperframes/sdk";
|
|
6
5
|
import { readStudioFileChangePath } from "../components/editor/manualEdits";
|
|
7
6
|
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
|
8
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Read a project file's content, or undefined on a non-2xx (optional read).
|
|
10
|
+
* Replaces the removed SDK http adapter's `read()` — the only thing Studio used
|
|
11
|
+
* it for (Studio is the sole writer, so the adapter's write path was dead).
|
|
12
|
+
*/
|
|
13
|
+
async function readProjectFileOptional(
|
|
14
|
+
projectId: string,
|
|
15
|
+
path: string,
|
|
16
|
+
): Promise<string | undefined> {
|
|
17
|
+
// Reject traversal / NUL before building the request URL — `path` is a
|
|
18
|
+
// user-influenced composition path (mirrors the guard in timelineEditingHelpers,
|
|
19
|
+
// and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
|
|
20
|
+
// already confines both values to single segments of this same-origin URL.
|
|
21
|
+
if (path.includes("\0") || path.includes("..")) return undefined;
|
|
22
|
+
const res = await fetch(
|
|
23
|
+
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
|
|
24
|
+
);
|
|
25
|
+
if (!res.ok) return undefined;
|
|
26
|
+
const data = (await res.json()) as { content?: string };
|
|
27
|
+
return typeof data.content === "string" ? data.content : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
9
30
|
/**
|
|
10
31
|
* True when an external file-change payload targets the active composition and
|
|
11
32
|
* the SDK session must be re-opened to pick up the new content.
|
|
@@ -18,8 +39,8 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
|
|
|
18
39
|
/**
|
|
19
40
|
* Stage 7 Step 3a — SDK session wired to the active composition.
|
|
20
41
|
*
|
|
21
|
-
* Creates an SDK Composition
|
|
22
|
-
* (projectId, activeCompPath) change, disposes the old one on cleanup, and
|
|
42
|
+
* Creates an SDK Composition (reading the file via the project files API) on
|
|
43
|
+
* every (projectId, activeCompPath) change, disposes the old one on cleanup, and
|
|
23
44
|
* re-opens it when the active composition file changes on disk (code editor,
|
|
24
45
|
* agent, or server-side patch) so the in-memory linkedom document never goes
|
|
25
46
|
* stale. The session has NO persist queue — Studio is the sole file writer; see
|
|
@@ -81,10 +102,7 @@ export function useSdkSession(
|
|
|
81
102
|
useEffect(() => {
|
|
82
103
|
if (!activeCompPath) return;
|
|
83
104
|
const compPath = activeCompPath;
|
|
84
|
-
const
|
|
85
|
-
projectId != null
|
|
86
|
-
? createHttpAdapter({ projectFilesUrl: `/api/projects/${projectId}` })
|
|
87
|
-
: null;
|
|
105
|
+
const readProjectId = projectId ?? null;
|
|
88
106
|
const handler = (payload?: unknown) => {
|
|
89
107
|
if (!shouldReloadSdkSession(payload, compPath)) return;
|
|
90
108
|
const withinWindow =
|
|
@@ -96,13 +114,12 @@ export function useSdkSession(
|
|
|
96
114
|
const payloadContent = readFileChangeContent(payload);
|
|
97
115
|
// Prefer payload content; otherwise re-read so the decision is by IDENTITY
|
|
98
116
|
// (an undo's reverted bytes won't match a registered self-write → reload).
|
|
99
|
-
if (payloadContent != null ||
|
|
117
|
+
if (payloadContent != null || readProjectId == null) {
|
|
100
118
|
decide(payloadContent);
|
|
101
119
|
return;
|
|
102
120
|
}
|
|
103
|
-
|
|
104
|
-
.
|
|
105
|
-
.then((c) => decide(typeof c === "string" ? c : null))
|
|
121
|
+
readProjectFileOptional(readProjectId, compPath)
|
|
122
|
+
.then((c) => decide(c ?? null))
|
|
106
123
|
.catch(() => decide(null));
|
|
107
124
|
};
|
|
108
125
|
if (import.meta.hot) {
|
|
@@ -126,11 +143,7 @@ export function useSdkSession(
|
|
|
126
143
|
let cancelled = false;
|
|
127
144
|
const compRef = { current: null as Composition | null };
|
|
128
145
|
|
|
129
|
-
|
|
130
|
-
projectFilesUrl: `/api/projects/${projectId}`,
|
|
131
|
-
});
|
|
132
|
-
adapter
|
|
133
|
-
.read(activeCompPath)
|
|
146
|
+
readProjectFileOptional(projectId, activeCompPath)
|
|
134
147
|
.then(async (content) => {
|
|
135
148
|
if (cancelled || typeof content !== "string") return;
|
|
136
149
|
// No persist queue: Studio's writeProjectFile (via sdkCutover's
|
package/src/utils/sdkCutover.ts
CHANGED
|
@@ -435,6 +435,86 @@ export function sdkGsapConvertToKeyframesPersist(
|
|
|
435
435
|
);
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
type KeyframeSpec = {
|
|
439
|
+
percentage: number;
|
|
440
|
+
properties: Record<string, number | string>;
|
|
441
|
+
ease?: string;
|
|
442
|
+
auto?: boolean;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
type KeyframesPayload = {
|
|
446
|
+
targetSelector: string;
|
|
447
|
+
position: number;
|
|
448
|
+
duration: number;
|
|
449
|
+
keyframes: KeyframeSpec[];
|
|
450
|
+
ease?: string;
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
/** Shared inner dispatch for addWithKeyframes / replaceWithKeyframes ops. */
|
|
454
|
+
function dispatchWithKeyframes(
|
|
455
|
+
s: Composition,
|
|
456
|
+
payload: KeyframesPayload,
|
|
457
|
+
animationId?: string,
|
|
458
|
+
): void {
|
|
459
|
+
if (animationId !== undefined) {
|
|
460
|
+
s.dispatch({ type: "replaceWithKeyframes", animationId, ...payload });
|
|
461
|
+
} else {
|
|
462
|
+
s.dispatch({ type: "addWithKeyframes", ...payload });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function sdkAddWithKeyframesPersist(
|
|
467
|
+
targetPath: string,
|
|
468
|
+
targetSelector: string,
|
|
469
|
+
position: number,
|
|
470
|
+
duration: number,
|
|
471
|
+
keyframes: KeyframeSpec[],
|
|
472
|
+
ease: string | undefined,
|
|
473
|
+
sdkSession: Composition | null | undefined,
|
|
474
|
+
deps: CutoverDeps,
|
|
475
|
+
options?: CutoverOptions,
|
|
476
|
+
): Promise<boolean> {
|
|
477
|
+
const payload: KeyframesPayload = {
|
|
478
|
+
targetSelector,
|
|
479
|
+
position,
|
|
480
|
+
duration,
|
|
481
|
+
keyframes,
|
|
482
|
+
...(ease ? { ease } : {}),
|
|
483
|
+
};
|
|
484
|
+
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
|
|
485
|
+
dispatchWithKeyframes(s, payload),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function sdkReplaceWithKeyframesPersist(
|
|
490
|
+
targetPath: string,
|
|
491
|
+
animationId: string,
|
|
492
|
+
targetSelector: string,
|
|
493
|
+
position: number,
|
|
494
|
+
duration: number,
|
|
495
|
+
keyframes: KeyframeSpec[],
|
|
496
|
+
ease: string | undefined,
|
|
497
|
+
sdkSession: Composition | null | undefined,
|
|
498
|
+
deps: CutoverDeps,
|
|
499
|
+
options?: CutoverOptions,
|
|
500
|
+
): Promise<boolean> {
|
|
501
|
+
const payload: KeyframesPayload = {
|
|
502
|
+
targetSelector,
|
|
503
|
+
position,
|
|
504
|
+
duration,
|
|
505
|
+
keyframes,
|
|
506
|
+
...(ease ? { ease } : {}),
|
|
507
|
+
};
|
|
508
|
+
return dispatchGsapOpAndPersist(
|
|
509
|
+
targetPath,
|
|
510
|
+
sdkSession,
|
|
511
|
+
deps,
|
|
512
|
+
options,
|
|
513
|
+
(s) => dispatchWithKeyframes(s, payload, animationId),
|
|
514
|
+
{ animationId, opLabel: "replaceWithKeyframes" },
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
438
518
|
export async function sdkDeletePersist(
|
|
439
519
|
hfId: string,
|
|
440
520
|
originalContent: string,
|
|
@@ -189,7 +189,7 @@ describe("C. Resolver-parity detection", () => {
|
|
|
189
189
|
it("C8: element_not_found fires when SDK resolver returns null (v0.6.110 class)", () => {
|
|
190
190
|
// Simulate the regression: SDK session cannot resolve the hfId the server
|
|
191
191
|
// would address (e.g. scoped-id mismatch, resolver bug).
|
|
192
|
-
const session = { getElement: () => null } as unknown as Parameters<
|
|
192
|
+
const session = { getElement: () => null, getElements: () => [] } as unknown as Parameters<
|
|
193
193
|
typeof sdkResolverShadowCheck
|
|
194
194
|
>[0];
|
|
195
195
|
const mismatches = sdkResolverShadowCheck(
|
|
@@ -364,3 +364,46 @@ describe("G. recordAnimationResolverParity", () => {
|
|
|
364
364
|
expect(trackedEvents).toHaveLength(0);
|
|
365
365
|
});
|
|
366
366
|
});
|
|
367
|
+
|
|
368
|
+
// ─── H. Inlined sub-composition: bare leaf id resolves (regression) ───────────
|
|
369
|
+
|
|
370
|
+
// PostHog showed ~445 false `element_not_found` events, all on a bare leaf id
|
|
371
|
+
// (hf-0ytc / #subscribe-btn) inside an inlined sub-composition. The studio reads
|
|
372
|
+
// the bare data-hf-id off the DOM and the cutover dispatch resolves it via
|
|
373
|
+
// resolveScoped (which locates the leaf inside the host subtree). But the shadow
|
|
374
|
+
// resolved via Composition.getElement, which is canonical-only for a bare id and
|
|
375
|
+
// returns null for a scoped element — so it flagged a divergence the real
|
|
376
|
+
// dispatch path would not hit. The shadow now mirrors dispatch via resolveSnapshot.
|
|
377
|
+
describe("H. inlined sub-composition leaf", () => {
|
|
378
|
+
// host carries data-composition-file → new scope; leaf's scopedId is
|
|
379
|
+
// "hf-host/hf-leaf" but its raw data-hf-id (what the studio reads) is bare.
|
|
380
|
+
const INLINED_HTML = /* html */ `<!DOCTYPE html>
|
|
381
|
+
<html><body>
|
|
382
|
+
<div data-hf-id="hf-root" data-hf-root>
|
|
383
|
+
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
|
384
|
+
<div data-hf-id="hf-leaf" style="color: red">Subscribe</div>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
</body></html>`;
|
|
388
|
+
|
|
389
|
+
it("getElement(bareLeaf) is null (canonical-only) — the trap the shadow used to hit", async () => {
|
|
390
|
+
const session = await openComposition(INLINED_HTML);
|
|
391
|
+
expect(session.getElement("hf-leaf")).toBeNull();
|
|
392
|
+
expect(session.getElement("hf-host/hf-leaf")).not.toBeNull();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("recordResolverParity emits NOTHING for a bare leaf inside a sub-comp", async () => {
|
|
396
|
+
mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
|
|
397
|
+
const session = await openComposition(INLINED_HTML);
|
|
398
|
+
recordResolverParity(session, "hf-leaf", "setTiming");
|
|
399
|
+
expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("sdkResolverShadowCheck does not flag element_not_found for a bare leaf in a sub-comp", async () => {
|
|
403
|
+
const session = await openComposition(INLINED_HTML);
|
|
404
|
+
const mismatches = sdkResolverShadowCheck(session, "hf-leaf", [
|
|
405
|
+
{ type: "inline-style", property: "color", value: "blue" },
|
|
406
|
+
]);
|
|
407
|
+
expect(mismatches.some((m) => m.kind === "element_not_found")).toBe(false);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
@@ -65,6 +65,31 @@ function normalizeText(v: string | null | undefined): string | null {
|
|
|
65
65
|
type FlatEl = NonNullable<ReturnType<Composition["getElement"]>>;
|
|
66
66
|
type AttrMap = Record<string, string | null>;
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Resolve an hf-id to its snapshot the SAME way the SDK dispatch path does
|
|
70
|
+
* (engine/model.ts resolveScoped), NOT via Composition.getElement.
|
|
71
|
+
*
|
|
72
|
+
* getElement is canonical-only for a bare id by design — it deliberately will
|
|
73
|
+
* not resolve a bare id to a non-canonical (sub-composition) element, so that
|
|
74
|
+
* removeElement(bareId) and getElement(bareId) agree on the same instance
|
|
75
|
+
* (session.subcomp.test "ambiguous bare id" suite). But the cutover persist
|
|
76
|
+
* path dispatches the studio's bare data-hf-id, and dispatch resolves it via
|
|
77
|
+
* resolveScoped, which locates the leaf anywhere (canonical preferred, else
|
|
78
|
+
* first match). So getElement under-resolves a bare leaf that lives inside an
|
|
79
|
+
* inlined sub-composition (scopedId "host/leaf") — exactly the false
|
|
80
|
+
* `element_not_found` this tripwire was emitting for inlined compositions.
|
|
81
|
+
*
|
|
82
|
+
* Mirror resolveScoped here: exact scoped-path match, then canonical bare
|
|
83
|
+
* match, then first bare match — the resolvability dispatch actually has.
|
|
84
|
+
*/
|
|
85
|
+
function resolveSnapshot(session: Composition, id: string): FlatEl | null {
|
|
86
|
+
const els = session.getElements();
|
|
87
|
+
const exact = els.find((el) => el.scopedId === id);
|
|
88
|
+
if (exact) return exact;
|
|
89
|
+
const matches = els.filter((el) => el.id === id);
|
|
90
|
+
return matches.find((el) => el.scopedId === el.id) ?? matches[0] ?? null;
|
|
91
|
+
}
|
|
92
|
+
|
|
68
93
|
function checkStyleOp(
|
|
69
94
|
op: PatchOperation,
|
|
70
95
|
el: FlatEl,
|
|
@@ -140,7 +165,7 @@ export function sdkResolverShadowCheck(
|
|
|
140
165
|
hfId: string,
|
|
141
166
|
ops: PatchOperation[],
|
|
142
167
|
): SdkResolverMismatch[] {
|
|
143
|
-
if (!session
|
|
168
|
+
if (!resolveSnapshot(session, hfId)) {
|
|
144
169
|
return [{ kind: "element_not_found", hfId }];
|
|
145
170
|
}
|
|
146
171
|
|
|
@@ -172,7 +197,7 @@ export function sdkResolverShadowCheck(
|
|
|
172
197
|
return [{ kind: "dispatch_error", hfId, error: String(err) }];
|
|
173
198
|
}
|
|
174
199
|
|
|
175
|
-
const el = session
|
|
200
|
+
const el = resolveSnapshot(session, hfId);
|
|
176
201
|
if (!el) return [{ kind: "element_not_found", hfId }];
|
|
177
202
|
|
|
178
203
|
return shadowable
|
|
@@ -253,7 +278,7 @@ export function recordResolverParity(
|
|
|
253
278
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
|
254
279
|
if (!session || !hfId) return;
|
|
255
280
|
try {
|
|
256
|
-
if (session
|
|
281
|
+
if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
|
|
257
282
|
trackStudioEvent("sdk_resolver_shadow", {
|
|
258
283
|
hfId,
|
|
259
284
|
opLabel,
|