@hyperframes/studio 0.8.48 → 0.8.49

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.
Files changed (40) hide show
  1. package/dist/assets/hyperframes-player-CqON2Rzo.js +464 -0
  2. package/dist/assets/{index-B3-orIjW.js → index-BGovDksE.js} +1 -1
  3. package/dist/assets/{index-C9gJKpqQ.js → index-Bgmx2h7W.js} +135 -135
  4. package/dist/assets/{index-D_j-0PD3.js → index-mIgSsKqs.js} +1 -1
  5. package/dist/index.d.ts +4 -3
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +443 -303
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/hooks/useBlockHandlers.ts +7 -2
  11. package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
  12. package/src/hooks/useTimelineAssetDropOps.ts +4 -2
  13. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
  14. package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
  15. package/src/player/components/timelineClipDragCommit.test.ts +114 -0
  16. package/src/player/components/timelineClipDragCommit.ts +18 -49
  17. package/src/player/components/timelineClipDragPreview.test.ts +173 -1
  18. package/src/player/components/timelineClipDragPreview.ts +8 -1
  19. package/src/player/components/timelineCollision.test.ts +23 -0
  20. package/src/player/components/timelineCollision.ts +27 -0
  21. package/src/player/components/timelineDragLanding.ts +108 -0
  22. package/src/player/components/timelineDropReveal.test.ts +34 -0
  23. package/src/player/components/timelineDropReveal.ts +12 -0
  24. package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
  25. package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
  26. package/src/player/components/useTimelineGeometry.test.tsx +117 -0
  27. package/src/player/components/useTimelineGeometry.ts +7 -2
  28. package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
  29. package/src/player/components/useTimelineLogicalFocus.ts +1 -0
  30. package/src/player/lib/timelineElementHelpers.ts +8 -0
  31. package/src/player/store/readinessSlice.ts +4 -3
  32. package/src/utils/blockInstaller.test.ts +4 -0
  33. package/src/utils/blockInstaller.ts +9 -3
  34. package/src/utils/retryBudget.ts +20 -0
  35. package/src/utils/sdkCutover.familyGate.test.ts +5 -1
  36. package/src/utils/sdkCutover.ts +7 -4
  37. package/src/utils/sdkCutoverEligibility.test.ts +96 -1
  38. package/src/utils/sdkCutoverEligibility.ts +34 -11
  39. package/src/utils/timelineCompositionInsert.ts +2 -1
  40. package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
@@ -11,6 +11,7 @@ import {
11
11
  type TimelineLogicalTarget,
12
12
  } from "./timelineKeyboardNavigation";
13
13
  import { computeRevealScroll } from "./timelineRevealScroll";
14
+ import { recordRetryAttempt, type RetryBudgetState } from "../../utils/retryBudget";
14
15
 
15
16
  interface TimelineFocusCoordinatorInput {
16
17
  scrollRef: RefObject<HTMLDivElement | null>;
@@ -24,6 +25,7 @@ interface TimelineFocusCoordinatorInput {
24
25
  projectId: string | null;
25
26
  sessionEpoch: number;
26
27
  syncScrollViewport: (element: HTMLDivElement) => void;
28
+ lastScrollLeftRef: RefObject<number>;
27
29
  }
28
30
 
29
31
  export interface TimelineFocusCoordinatorState {
@@ -77,6 +79,7 @@ function scrollToTarget(
77
79
  pixelsPerSecond: number,
78
80
  contentOrigin: number,
79
81
  allowHorizontal: boolean,
82
+ lastScrollLeftRef: RefObject<number>,
80
83
  ): boolean {
81
84
  const rowIndex = rowGeometry.getRowIndex(resolution.row.physicalTrackKey);
82
85
  if (rowIndex < 0) return false;
@@ -103,11 +106,26 @@ function scrollToTarget(
103
106
  stickyTop: RULER_H,
104
107
  allowHorizontal: allowHorizontal && left !== null,
105
108
  });
106
- if (target.left !== null) container.scrollLeft = target.left;
109
+ if (target.left !== null) {
110
+ container.scrollLeft = target.left;
111
+ // Keep the geometry effect's "restore scroll after an edit re-derives the
112
+ // elements" in sync, or it reads the pre-reveal position on the next tick
113
+ // and scrolls the freshly-revealed clip back out of view.
114
+ lastScrollLeftRef.current = target.left;
115
+ }
107
116
  if (target.top !== null) container.scrollTop = target.top;
108
117
  return target.left !== null || target.top !== null;
109
118
  }
110
119
 
120
+ // A request racing a fresh element's own creation (drop/paste) resolves within
121
+ // a render or two; past this many misses on the same nonce it truly never will.
122
+ const MAX_UNRESOLVED_FOCUS_RETRIES = 5;
123
+
124
+ // Backstop for a request whose target never appears and nothing else ever
125
+ // re-renders: the retry budget above can't advance without a rerun, so this
126
+ // bounds it by wall time instead, independent of re-renders.
127
+ const UNRESOLVED_FOCUS_TIMEOUT_MS = 4000;
128
+
111
129
  /** Model-first focus actor; mounting is a consequence of its returned pins. */
112
130
  // Resolution, fallback, reveal, and focus form one ordered state machine.
113
131
  // fallow-ignore-next-line complexity
@@ -123,11 +141,13 @@ export function useTimelineFocusCoordinator({
123
141
  projectId,
124
142
  sessionEpoch,
125
143
  syncScrollViewport,
144
+ lastScrollLeftRef,
126
145
  }: TimelineFocusCoordinatorInput): TimelineFocusCoordinatorState {
127
146
  const request = usePlayerStore((state) => state.timelineFocus);
128
147
  const previousRowsRef = useRef(logicalRows);
129
148
  const resolvedRef = useRef<{ nonce: number; id: string } | null>(null);
130
149
  const appliedRef = useRef<{ nonce: number; id: string } | null>(null);
150
+ const unresolvedFocusAttemptsRef = useRef<RetryBudgetState<number>>({ id: -1, count: 0 });
131
151
  const resolution = useMemo<ResolvedFocus | null>(() => {
132
152
  if (isCurrentRequest(request, projectId, sessionEpoch)) {
133
153
  if (resolvedRef.current?.nonce !== request.nonce) {
@@ -163,8 +183,26 @@ export function useTimelineFocusCoordinator({
163
183
  useEffect(() => {
164
184
  if (!isCurrentRequest(request, projectId, sessionEpoch)) return;
165
185
  if (!resolution) {
166
- usePlayerStore.getState().clearTimelineFocus(request.nonce);
167
- return;
186
+ // A request issued the same tick as its element (a fresh drop/paste) can
187
+ // outrun logicalRows by a render or two; only give up once it clearly
188
+ // never resolves, so the reveal isn't lost to that ordinary race.
189
+ const withinBudget = recordRetryAttempt(
190
+ unresolvedFocusAttemptsRef,
191
+ request.nonce,
192
+ MAX_UNRESOLVED_FOCUS_RETRIES,
193
+ );
194
+ if (!withinBudget) {
195
+ usePlayerStore.getState().clearTimelineFocus(request.nonce);
196
+ return;
197
+ }
198
+ const nonce = request.nonce;
199
+ const timeout = setTimeout(() => {
200
+ usePlayerStore.getState().clearTimelineFocus(nonce);
201
+ }, UNRESOLVED_FOCUS_TIMEOUT_MS);
202
+ return () => clearTimeout(timeout);
203
+ }
204
+ if (unresolvedFocusAttemptsRef.current.count !== 0) {
205
+ unresolvedFocusAttemptsRef.current = { id: -1, count: 0 };
168
206
  }
169
207
  if (resolution.target.id !== request.id) {
170
208
  usePlayerStore.getState().requestTimelineFocus(resolution.target.id);
@@ -187,6 +225,7 @@ export function useTimelineFocusCoordinator({
187
225
  pixelsPerSecond,
188
226
  contentOrigin,
189
227
  allowHorizontal,
228
+ lastScrollLeftRef,
190
229
  )
191
230
  ) {
192
231
  syncScrollViewport(container);
@@ -197,6 +236,7 @@ export function useTimelineFocusCoordinator({
197
236
  allowHorizontal,
198
237
  contentOrigin,
199
238
  elements,
239
+ lastScrollLeftRef,
200
240
  pixelsPerSecond,
201
241
  projectId,
202
242
  request,
@@ -0,0 +1,117 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useRef } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
+ import { useTimelineGeometry } from "./useTimelineGeometry";
8
+
9
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
10
+
11
+ const element: TimelineElement = { id: "hero", tag: "div", start: 20, duration: 2, track: 1 };
12
+
13
+ const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
14
+
15
+ function Harness({
16
+ expandedElements,
17
+ lastScrollLeftRef,
18
+ scrollWidth = 20_000,
19
+ }: {
20
+ expandedElements: TimelineElement[];
21
+ lastScrollLeftRef: React.RefObject<number>;
22
+ scrollWidth?: number;
23
+ }) {
24
+ const scrollRef = useRef<HTMLDivElement>(null);
25
+ const isDragging = useRef(false);
26
+ const ppsRef = useRef(0);
27
+ const fitPpsRef = useRef(0);
28
+ useTimelineGeometry({
29
+ viewportWidth: 1600,
30
+ effectiveDuration: 60,
31
+ zoomMode: "manual",
32
+ manualZoomPercent: 100,
33
+ ppsRef,
34
+ fitPpsRef,
35
+ draggedClip: null,
36
+ resizingClip: null,
37
+ expandedElements,
38
+ isDragging,
39
+ scrollRef,
40
+ lastScrollLeftRef,
41
+ contentOrigin: 32,
42
+ });
43
+ return (
44
+ <div
45
+ ref={(node) => {
46
+ scrollRef.current = node;
47
+ if (node) {
48
+ Object.defineProperty(node, "clientWidth", { configurable: true, value: 1600 });
49
+ Object.defineProperty(node, "scrollWidth", { configurable: true, value: scrollWidth });
50
+ }
51
+ }}
52
+ />
53
+ );
54
+ }
55
+
56
+ let host: HTMLDivElement;
57
+ let root: Root;
58
+ beforeEach(() => {
59
+ usePlayerStore.setState({ timelineProjectId: "project-a" });
60
+ host = document.createElement("div");
61
+ document.body.append(host);
62
+ root = createRoot(host);
63
+ });
64
+ afterEach(() => {
65
+ act(() => root.unmount());
66
+ usePlayerStore.getState().reset();
67
+ document.body.replaceChildren();
68
+ });
69
+
70
+ describe("useTimelineGeometry restore-scroll effect", () => {
71
+ it("reads lastScrollLeftRef at frame time, not effect time, so a same-commit reveal isn't undone", async () => {
72
+ // A sibling effect in the same commit (a fresh timeline-focus reveal) can
73
+ // update lastScrollLeftRef and the DOM scrollLeft together after this
74
+ // effect has already scheduled its frame. The frame must see that update,
75
+ // not the value that was current when it was scheduled.
76
+ const lastScrollLeftRef = { current: 132 };
77
+ act(() => {
78
+ root.render(<Harness expandedElements={[]} lastScrollLeftRef={lastScrollLeftRef} />);
79
+ });
80
+ const scroll = host.firstElementChild as HTMLDivElement;
81
+ scroll.scrollLeft = 132;
82
+
83
+ // Flush this effect (it schedules a frame, capturing whatever it reads
84
+ // right now) BEFORE the sibling mutates the ref, exactly as in a real
85
+ // commit where this effect registers and runs ahead of the reveal's.
86
+ act(() => {
87
+ root.render(<Harness expandedElements={[element]} lastScrollLeftRef={lastScrollLeftRef} />);
88
+ });
89
+ // Simulate the sibling reveal effect: it runs after this one in the same
90
+ // commit and updates both the DOM and the ref before the frame fires.
91
+ lastScrollLeftRef.current = 450;
92
+ scroll.scrollLeft = 450;
93
+ await act(async () => {
94
+ await nextFrame();
95
+ });
96
+
97
+ expect(scroll.scrollLeft).toBe(450);
98
+ });
99
+
100
+ it("still restores the pre-edit position when nothing else changed the ref mid-frame", async () => {
101
+ const lastScrollLeftRef = { current: 300 };
102
+ act(() => {
103
+ root.render(<Harness expandedElements={[]} lastScrollLeftRef={lastScrollLeftRef} />);
104
+ });
105
+ const scroll = host.firstElementChild as HTMLDivElement;
106
+ scroll.scrollLeft = 0;
107
+
108
+ act(() => {
109
+ root.render(<Harness expandedElements={[element]} lastScrollLeftRef={lastScrollLeftRef} />);
110
+ });
111
+ await act(async () => {
112
+ await nextFrame();
113
+ });
114
+
115
+ expect(scroll.scrollLeft).toBe(300);
116
+ });
117
+ });
@@ -93,9 +93,14 @@ export function useTimelineGeometry({
93
93
  useEffect(() => {
94
94
  if (zoomMode !== "manual" || isDragging.current) return;
95
95
  const el = scrollRef.current;
96
- const target = lastScrollLeftRef.current;
97
- if (!el || target <= 0) return;
96
+ if (!el) return;
97
+ // Read the ref inside the frame, not here: a sibling effect in the same
98
+ // commit (a fresh reveal) can still update lastScrollLeftRef before this
99
+ // frame runs, and a value captured now would fight that reveal with a
100
+ // stale target.
98
101
  const raf = requestAnimationFrame(() => {
102
+ const target = lastScrollLeftRef.current;
103
+ if (target <= 0) return;
99
104
  const max = Math.max(0, el.scrollWidth - el.clientWidth);
100
105
  const next = Math.min(target, max);
101
106
  if (Math.abs(el.scrollLeft - next) > 0.5) el.scrollLeft = next;
@@ -0,0 +1,184 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useMemo, useRef } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { usePlayerStore } from "../store/playerStore";
7
+ import { createTimelineRowGeometry } from "./timelineLayout";
8
+ import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
9
+ import { timelineClipFocusId, timelineTrackRowId } from "./timelineNavigationIdentity";
10
+ import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
11
+ import { useTimelineGeometry } from "./useTimelineGeometry";
12
+
13
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
14
+
15
+ const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
16
+
17
+ const oldElement = { id: "old", tag: "div", start: 0, duration: 1, track: 1 };
18
+ const heroElement = { id: "hero", tag: "div", start: 20, duration: 2, track: 1 };
19
+ const clipId = timelineClipFocusId("hero");
20
+ const rowId = timelineTrackRowId(1);
21
+ const beforeRows: readonly TimelineLogicalRow[] = [
22
+ {
23
+ id: rowId,
24
+ kind: "row",
25
+ physicalTrackKey: 1,
26
+ logicalIndex: 0,
27
+ level: 1,
28
+ parentId: null,
29
+ elementId: null,
30
+ expandable: false,
31
+ expanded: false,
32
+ items: [],
33
+ },
34
+ ];
35
+ const afterRows: readonly TimelineLogicalRow[] = [
36
+ {
37
+ id: rowId,
38
+ kind: "row",
39
+ physicalTrackKey: 1,
40
+ logicalIndex: 0,
41
+ level: 1,
42
+ parentId: null,
43
+ elementId: null,
44
+ expandable: false,
45
+ expanded: false,
46
+ items: [{ id: clipId, kind: "clip", rowId, elementId: "hero", time: 21 }],
47
+ },
48
+ ];
49
+
50
+ // Renders the two real production hooks side by side, in a caller-chosen call
51
+ // order, to test whether their interaction actually depends on that order —
52
+ // as opposed to simulating one hook's effect by hand.
53
+ function OrderHarness({
54
+ order,
55
+ expandedElements,
56
+ logicalRows,
57
+ lastScrollLeftRef,
58
+ }: {
59
+ order: "geometry-first" | "coordinator-first";
60
+ expandedElements: (typeof oldElement)[];
61
+ logicalRows: readonly TimelineLogicalRow[];
62
+ lastScrollLeftRef: React.RefObject<number>;
63
+ }) {
64
+ const scrollRef = useRef<HTMLDivElement>(null);
65
+ const isDragging = useRef(false);
66
+ const ppsRef = useRef(0);
67
+ const fitPpsRef = useRef(0);
68
+ const rowGeometry = useMemo(() => createTimelineRowGeometry([1], [48]), []);
69
+
70
+ const runGeometry = () =>
71
+ useTimelineGeometry({
72
+ viewportWidth: 300,
73
+ effectiveDuration: 60,
74
+ zoomMode: "manual",
75
+ manualZoomPercent: 100,
76
+ ppsRef,
77
+ fitPpsRef,
78
+ draggedClip: null,
79
+ resizingClip: null,
80
+ expandedElements,
81
+ isDragging,
82
+ scrollRef,
83
+ lastScrollLeftRef,
84
+ contentOrigin: 32,
85
+ });
86
+ const runFocus = () =>
87
+ useTimelineFocusCoordinator({
88
+ scrollRef,
89
+ logicalRows,
90
+ elements: expandedElements,
91
+ rowGeometry,
92
+ pixelsPerSecond: 100,
93
+ contentOrigin: 32,
94
+ allowHorizontal: true,
95
+ viewportVersion: logicalRows.length,
96
+ projectId: "project-a",
97
+ sessionEpoch: 1,
98
+ syncScrollViewport: () => {},
99
+ lastScrollLeftRef,
100
+ });
101
+
102
+ // `order` is fixed for the lifetime of one mounted harness, so this call
103
+ // order is stable across that instance's renders.
104
+ if (order === "geometry-first") {
105
+ runGeometry();
106
+ runFocus();
107
+ } else {
108
+ runFocus();
109
+ runGeometry();
110
+ }
111
+
112
+ return (
113
+ <div
114
+ ref={(node) => {
115
+ scrollRef.current = node;
116
+ if (node) {
117
+ Object.defineProperty(node, "clientWidth", { configurable: true, value: 300 });
118
+ Object.defineProperty(node, "clientHeight", { configurable: true, value: 100 });
119
+ Object.defineProperty(node, "scrollWidth", { configurable: true, value: 20_000 });
120
+ }
121
+ }}
122
+ />
123
+ );
124
+ }
125
+
126
+ let host: HTMLDivElement;
127
+ let root: Root;
128
+ beforeEach(() => {
129
+ usePlayerStore.setState({ timelineProjectId: "project-a", timelineSessionEpoch: 1 });
130
+ host = document.createElement("div");
131
+ document.body.append(host);
132
+ root = createRoot(host);
133
+ });
134
+ afterEach(() => {
135
+ act(() => root.unmount());
136
+ usePlayerStore.getState().reset();
137
+ document.body.replaceChildren();
138
+ });
139
+
140
+ describe.each(["geometry-first", "coordinator-first"] as const)(
141
+ "useTimelineGeometry + useTimelineFocusCoordinator, %s",
142
+ (order) => {
143
+ it("a same-commit reveal survives the geometry restore effect regardless of hook registration order", async () => {
144
+ const lastScrollLeftRef = { current: 132 };
145
+ await act(async () => {
146
+ root.render(
147
+ <OrderHarness
148
+ order={order}
149
+ expandedElements={[oldElement]}
150
+ logicalRows={beforeRows}
151
+ lastScrollLeftRef={lastScrollLeftRef}
152
+ />,
153
+ );
154
+ });
155
+ const scroll = host.firstElementChild as HTMLDivElement;
156
+ scroll.scrollLeft = 132;
157
+ await act(async () => {
158
+ await nextFrame();
159
+ });
160
+ expect(scroll.scrollLeft).toBe(132);
161
+
162
+ // One commit: the drop both adds the element (expandedElements changes,
163
+ // arming the geometry restore effect) and resolves the focus request
164
+ // (arming the coordinator's reveal), exactly as Timeline.tsx renders them.
165
+ usePlayerStore.getState().requestTimelineFocus(clipId);
166
+ await act(async () => {
167
+ root.render(
168
+ <OrderHarness
169
+ order={order}
170
+ expandedElements={[oldElement, heroElement]}
171
+ logicalRows={afterRows}
172
+ lastScrollLeftRef={lastScrollLeftRef}
173
+ />,
174
+ );
175
+ });
176
+ await act(async () => {
177
+ await nextFrame();
178
+ });
179
+
180
+ expect(scroll.scrollLeft).toBe(1_944);
181
+ expect(lastScrollLeftRef.current).toBe(1_944);
182
+ });
183
+ },
184
+ );
@@ -63,6 +63,7 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
63
63
  projectId,
64
64
  sessionEpoch: input.sessionEpoch,
65
65
  syncScrollViewport: input.syncScrollViewport,
66
+ lastScrollLeftRef: input.lastScrollLeftRef,
66
67
  });
67
68
  const rows = useTimelineRowVirtualization({
68
69
  scrollRef: input.scrollRef,
@@ -392,6 +392,14 @@ export function deriveTimelineStoreKey(params: {
392
392
  return buildTimelineElementKey({ id: "", fallbackIndex: 0, ...params });
393
393
  }
394
394
 
395
+ /**
396
+ * {@link deriveTimelineStoreKey} for a caller that already has a DOM id in
397
+ * hand (e.g. one it just minted), so the undefined branch never applies.
398
+ */
399
+ export function deriveTimelineStoreKeyForDomId(domId: string, sourceFile?: string): string {
400
+ return deriveTimelineStoreKey({ domId, sourceFile })!;
401
+ }
402
+
395
403
  // ---------------------------------------------------------------------------
396
404
  // DOM node querying
397
405
  // ---------------------------------------------------------------------------
@@ -1,7 +1,8 @@
1
1
  /** Gates `timelineReady` on the composition's declared readiness inputs
2
- * (media, for now) instead of just a known duration. The generation counter
3
- * is module-scope, not store state: it guards an in-flight settlement, not
4
- * something a component reads, so bumping it shouldn't trigger a render. */
2
+ * (media, compute, and the paint-and-idle default) instead of just a known
3
+ * duration. The generation counter is module-scope, not store state: it
4
+ * guards an in-flight settlement, not something a component reads, so
5
+ * bumping it shouldn't trigger a render. */
5
6
  import type { StoreApi } from "zustand";
6
7
  import { settleCompositionReadiness } from "@hyperframes/core/composition-readiness";
7
8
 
@@ -63,5 +63,9 @@ describe("addBlockToProject", () => {
63
63
  expect(source).toContain('data-start="2.5"');
64
64
  expect(source).toContain('data-duration="4.25"');
65
65
  expect(source).toContain('data-track-index="3"');
66
+ // hostKey addresses the new element the same way the timeline selects
67
+ // clips (sourceFile#domId), so the caller can select and reveal it.
68
+ expect(result?.hostKey).toBe("compositions/scene.html#camcorder-hud");
69
+ expect(source).toContain('id="camcorder-hud"');
66
70
  });
67
71
  });
@@ -11,6 +11,7 @@ import { formatTimelineAttributeNumber } from "../player/components/timelineEdit
11
11
  import { saveProjectFilesWithHistory } from "./studioFileHistory";
12
12
  import type { EditHistoryKind } from "./editHistory";
13
13
  import { extendRootDurationInSource } from "./rootDuration";
14
+ import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
14
15
 
15
16
  function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number {
16
17
  try {
@@ -174,7 +175,7 @@ function buildSubCompositionHtml({
174
175
 
175
176
  export async function addBlockToProject(
176
177
  opts: AddBlockOptions,
177
- ): Promise<{ block: RegistryItem; compositionPath: string } | null> {
178
+ ): Promise<{ block: RegistryItem; compositionPath: string; hostKey: string } | null> {
178
179
  const {
179
180
  projectId,
180
181
  blockName,
@@ -213,8 +214,9 @@ export async function addBlockToProject(
213
214
  currentTime: opts.currentTime ?? 0,
214
215
  });
215
216
  const { width, height } = resolveTimelineAssetCompositionSize(originalContent);
217
+ const hostId = buildUniqueCompositionId(block.name, collectHtmlIds(originalContent));
216
218
  const subComposition = buildSubCompositionHtml({
217
- id: buildUniqueCompositionId(block.name, collectHtmlIds(originalContent)),
219
+ id: hostId,
218
220
  compositionFile,
219
221
  start,
220
222
  duration,
@@ -242,7 +244,11 @@ export async function addBlockToProject(
242
244
  await refreshFileTree();
243
245
  reloadPreview();
244
246
 
245
- return { block, compositionPath: compositionFile };
247
+ return {
248
+ block,
249
+ compositionPath: compositionFile,
250
+ hostKey: deriveTimelineStoreKeyForDomId(hostId, targetPath),
251
+ };
246
252
  } catch (error) {
247
253
  const message = error instanceof Error ? error.message : "Failed to add block";
248
254
  showToast(message);
@@ -0,0 +1,20 @@
1
+ import type { MutableRefObject } from "react";
2
+
3
+ export interface RetryBudgetState<K> {
4
+ id: K;
5
+ count: number;
6
+ }
7
+
8
+ // A target still resolving (a just-dropped/pasted/duplicated element, or a
9
+ // preview still reloading) heals within a few retries; one that never will
10
+ // would otherwise retry forever. A new id resets the count.
11
+ export function recordRetryAttempt<K>(
12
+ ref: MutableRefObject<RetryBudgetState<K>>,
13
+ id: K,
14
+ maxRetries: number,
15
+ ): boolean {
16
+ const previous = ref.current;
17
+ const count = previous.id === id ? previous.count + 1 : 1;
18
+ ref.current = { id, count };
19
+ return count <= maxRetries;
20
+ }
@@ -41,6 +41,10 @@ describe("SDK family gate mapping", () => {
41
41
  {} as never,
42
42
  deps,
43
43
  ),
44
- ).resolves.toMatchObject({ status: "declined", reason: "ineligible_operation" });
44
+ // The dom family is off, so the gate declines on the FLAG, not on the op
45
+ // batch (which is a perfectly eligible inline-style edit). Before the
46
+ // reason split this reported `ineligible_operation`, which read as "the
47
+ // SDK cannot express this edit" when the truth was "this family is off".
48
+ ).resolves.toMatchObject({ status: "declined", reason: "feature_disabled" });
45
49
  });
46
50
  });
@@ -7,8 +7,8 @@ import { patchOpsToSdkEditOps } from "./sdkOpMapping";
7
7
  import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
8
8
  import {
9
9
  isResolverDisagreement,
10
+ sdkCutoverIneligibleReason,
10
11
  shouldDeclineTextCutoverForTarget,
11
- shouldUseSdkCutover,
12
12
  } from "./sdkCutoverEligibility";
13
13
  import {
14
14
  asCutoverError,
@@ -81,11 +81,14 @@ export async function sdkCutoverPersist(
81
81
  deps: CutoverDeps,
82
82
  options?: CutoverOptions,
83
83
  ): Promise<CutoverResult> {
84
- if (!shouldUseSdkCutover(sdkFamilyEnabled("dom"), !!sdkSession, selection.hfId, ops))
85
- return declinedCutover("ineligible_operation", "dom");
84
+ // Name WHICH eligibility check failed: on v0.8.47 this one string covered 26 of
85
+ // 30 dom declines. The two below aren't properties of the batch, so they differ.
86
+ if (!sdkFamilyEnabled("dom")) return declinedCutover("feature_disabled", "dom");
86
87
  if (!sdkSession) return declinedCutover("session_unavailable", "dom");
87
88
  const hfId = selection.hfId;
88
- if (!hfId) return declinedCutover("target_unaddressable", "dom");
89
+ const ineligible = sdkCutoverIneligibleReason(hfId, ops);
90
+ // `!hfId` is already a reason; testing it again narrows the type for the rest.
91
+ if (ineligible || !hfId) return declinedCutover(ineligible ?? "target_unaddressable", "dom");
89
92
  const target = sdkSession.getElement(hfId);
90
93
  if (!target)
91
94
  return declinedCutover("target_not_found", "dom", isResolverDisagreement(sdkSession, hfId));