@hyperframes/studio 0.8.27 → 0.8.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.27",
3
+ "version": "0.8.29",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,11 +48,11 @@
48
48
  "gsap": "^3.13.0",
49
49
  "marked": "^14.1.4",
50
50
  "mediabunny": "^1.45.3",
51
- "@hyperframes/core": "0.8.27",
52
- "@hyperframes/parsers": "0.8.27",
53
- "@hyperframes/sdk": "0.8.27",
54
- "@hyperframes/studio-server": "0.8.27",
55
- "@hyperframes/player": "0.8.27"
51
+ "@hyperframes/core": "0.8.29",
52
+ "@hyperframes/parsers": "0.8.29",
53
+ "@hyperframes/player": "0.8.29",
54
+ "@hyperframes/sdk": "0.8.29",
55
+ "@hyperframes/studio-server": "0.8.29"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/react": "19",
@@ -69,7 +69,7 @@
69
69
  "vite": "^6.4.2",
70
70
  "vitest": "^3.2.4",
71
71
  "zustand": "^5.0.0",
72
- "@hyperframes/producer": "0.8.27"
72
+ "@hyperframes/producer": "0.8.29"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "react": "19",
@@ -180,12 +180,6 @@ export function useElementLifecycleOps({
180
180
  }
181
181
  const patchedContent =
182
182
  typeof removeData.content === "string" ? removeData.content : originalContent;
183
- // ponytail: the server remove-element route (removeElementFromHtml) strips
184
- // only the element node — it does NOT cascade-remove GSAP tweens targeting
185
- // it, unlike the SDK path (removeElement → cascadeRemoveAnimations). This
186
- // fallback runs only when the element isn't in the SDK doc (e.g. runtime-
187
- // generated / unaddressable), where targeting tweens are unlikely. Upgrade
188
- // path: cascade in removeElementFromHtml by selector/hf-id to fully match.
189
183
  await saveProjectFilesWithHistory({
190
184
  projectId: pid,
191
185
  label: "Delete element",
@@ -175,7 +175,7 @@ describe("external file change coordinator", () => {
175
175
  expect(options.reloadSdkSession).toHaveBeenCalledOnce();
176
176
  });
177
177
 
178
- it("ignores stale drain completion after a newer generation", async () => {
178
+ it("serializes drains and processes stashed events", async () => {
179
179
  const drains: Array<(result: { status: "clean" }) => void> = [];
180
180
  const { options } = await mountCoordinator({
181
181
  drainPendingChanges: () => new Promise((resolve) => drains.push(resolve)),
@@ -184,11 +184,14 @@ describe("external file change coordinator", () => {
184
184
  handler?.({ path: "index.html", content: "first", version: "v2" });
185
185
  handler?.({ path: "index.html", content: "second", version: "v3" });
186
186
  });
187
+ expect(drains).toHaveLength(1);
187
188
  await act(async () => drains[0]?.({ status: "clean" }));
188
- expect(options.reloadPreview).not.toHaveBeenCalled();
189
- await act(async () => drains[1]?.({ status: "clean" }));
190
189
  expect(options.reloadPreview).toHaveBeenCalledOnce();
191
- expect(options.reloadSdkSession).toHaveBeenCalledOnce();
190
+ await act(async () => {});
191
+ expect(drains).toHaveLength(2);
192
+ await act(async () => drains[1]?.({ status: "clean" }));
193
+ expect(options.reloadPreview).toHaveBeenCalledTimes(2);
194
+ expect(options.reloadSdkSession).toHaveBeenCalledTimes(2);
192
195
  });
193
196
 
194
197
  it("restores a durable unresolved conflict after remount", async () => {
@@ -285,4 +288,38 @@ describe("external file change coordinator", () => {
285
288
  );
286
289
  expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
287
290
  });
291
+
292
+ it("completes a reload after a burst of rapid external writes", async () => {
293
+ const drains: Array<(result: { status: "clean" }) => void> = [];
294
+ const reloadPreview = vi.fn();
295
+ const onAcceptedPersistedFileChange = vi.fn();
296
+ await mountCoordinator({
297
+ drainPendingChanges: vi.fn(
298
+ () => new Promise<{ status: "clean" }>((resolve) => drains.push(resolve)),
299
+ ),
300
+ reloadPreview,
301
+ onAcceptedPersistedFileChange,
302
+ });
303
+
304
+ // Fire three events in rapid succession (simulates generator + check + snapshot)
305
+ act(() => {
306
+ handler?.({ path: "index.html", content: "write-1", version: "v1" });
307
+ handler?.({ path: "index.html", content: "write-2", version: "v2" });
308
+ handler?.({ path: "index.html", content: "write-3", version: "v3" });
309
+ });
310
+
311
+ // Only one drain runs — events 2 and 3 are stashed (last one wins)
312
+ expect(drains).toHaveLength(1);
313
+
314
+ // Complete the first drain — triggers reload, then stashed event starts a second drain
315
+ await act(async () => drains[0]?.({ status: "clean" }));
316
+ expect(reloadPreview).toHaveBeenCalledOnce();
317
+ await act(async () => {});
318
+ expect(drains).toHaveLength(2);
319
+
320
+ // Complete the second drain — processes the final write
321
+ await act(async () => drains[1]?.({ status: "clean" }));
322
+ expect(reloadPreview).toHaveBeenCalledTimes(2);
323
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledTimes(2);
324
+ });
288
325
  });
@@ -135,6 +135,8 @@ export function useExternalFileChangeCoordinator({
135
135
  const lastEventIdentityRef = useRef<string | null>(null);
136
136
  const blockedRef = useRef(blocked);
137
137
  const snapshotWriteTailRef = useRef<Promise<void>>(Promise.resolve());
138
+ const drainingRef = useRef(false);
139
+ const pendingPayloadRef = useRef<{ payload: unknown } | null>(null);
138
140
  blockedRef.current = blocked;
139
141
 
140
142
  useEffect(() => {
@@ -213,37 +215,11 @@ export function useExternalFileChangeCoordinator({
213
215
  await next;
214
216
  }, []);
215
217
 
216
- const processChange = useCallback(
218
+ const drainOnePending = useCallback(
217
219
  // fallow-ignore-next-line complexity
218
- async (payload: unknown, allowDuplicate = false) => {
220
+ async (payload: unknown) => {
219
221
  const path = readStudioFileChangePath(payload);
220
- if (!path || !projectId) return;
221
- const pendingTimelinePaths = pendingTimelineEditPathRef.current;
222
- // The old path-only suppression could drop a real agent/user write that
223
- // raced ahead of the timeline write receipt. Clear the legacy marker but
224
- // decide ownership only from the exact write token/content below.
225
- pendingTimelinePaths.delete(path);
226
-
227
- const content = readFileChangeContent(payload);
228
- const token = readFileChangeWriteToken(payload);
229
- logReload("file-change", { path, token: token ?? null, hasContent: content != null });
230
- const identity = eventIdentity(path, payload);
231
- if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) {
232
- logReload("suppressed", { path, why: "duplicate event" });
233
- return;
234
- }
235
- lastEventIdentityRef.current = identity;
236
-
237
- const ownWriteToken = consumeStudioWriteToken(token);
238
- const ownContentEcho = content != null && isSelfWriteEcho(path, content);
239
- if (ownWriteToken || ownContentEcho) {
240
- onAcceptedPersistedFileChange(path);
241
- logReload("suppressed", {
242
- path,
243
- why: ownWriteToken ? "own write token" : "own content echo",
244
- });
245
- return;
246
- }
222
+ if (!path) return;
247
223
 
248
224
  const generation = ++generationRef.current;
249
225
  const result = await drainPendingChanges();
@@ -253,7 +229,7 @@ export function useExternalFileChangeCoordinator({
253
229
  const previousBlocked = blockedRef.current;
254
230
  if (previousBlocked?.status === "failed" && deleteConflictSnapshot) {
255
231
  try {
256
- await deleteConflictSnapshot(projectId, path);
232
+ await deleteConflictSnapshot(projectId!, path);
257
233
  } catch (error) {
258
234
  if (mountedRef.current && generation === generationRef.current) {
259
235
  setBlocked({ ...previousBlocked, generation, error });
@@ -267,6 +243,7 @@ export function useExternalFileChangeCoordinator({
267
243
  reloadAcceptedGeneration(path);
268
244
  return;
269
245
  }
246
+ const content = readFileChangeContent(payload);
270
247
  if (result.status === "failed") {
271
248
  const candidate = getPendingCandidate?.();
272
249
  const studioContent = candidate?.path === path ? candidate.content : null;
@@ -275,7 +252,7 @@ export function useExternalFileChangeCoordinator({
275
252
  try {
276
253
  await persistSnapshotInOrder(() =>
277
254
  persistFailureSnapshot(
278
- projectId,
255
+ projectId!,
279
256
  path,
280
257
  studioContent,
281
258
  readFileChangeVersion(payload),
@@ -305,7 +282,7 @@ export function useExternalFileChangeCoordinator({
305
282
  return;
306
283
  }
307
284
  try {
308
- await persistSnapshotInOrder(() => persistConflictSnapshot(projectId, result.error));
285
+ await persistSnapshotInOrder(() => persistConflictSnapshot(projectId!, result.error));
309
286
  } catch (error) {
310
287
  if (!mountedRef.current || generation !== generationRef.current) return;
311
288
  setBlocked({
@@ -323,9 +300,8 @@ export function useExternalFileChangeCoordinator({
323
300
  setBlocked({ status: "conflict", generation, error: result.error, payload });
324
301
  },
325
302
  [
326
- projectId,
327
- pendingTimelineEditPathRef,
328
303
  drainPendingChanges,
304
+ projectId,
329
305
  deleteConflictSnapshot,
330
306
  getPendingCandidate,
331
307
  persistConflictSnapshot,
@@ -336,6 +312,55 @@ export function useExternalFileChangeCoordinator({
336
312
  ],
337
313
  );
338
314
 
315
+ const startDrainLoop = useCallback(async () => {
316
+ if (drainingRef.current) return;
317
+ drainingRef.current = true;
318
+ try {
319
+ while (mountedRef.current) {
320
+ const pending = pendingPayloadRef.current;
321
+ if (!pending) break;
322
+ pendingPayloadRef.current = null;
323
+ await drainOnePending(pending.payload);
324
+ }
325
+ } finally {
326
+ drainingRef.current = false;
327
+ }
328
+ }, [drainOnePending]);
329
+
330
+ const processChange = useCallback(
331
+ // fallow-ignore-next-line complexity
332
+ (payload: unknown) => {
333
+ const path = readStudioFileChangePath(payload);
334
+ if (!path || !projectId) return;
335
+ pendingTimelineEditPathRef.current.delete(path);
336
+
337
+ const content = readFileChangeContent(payload);
338
+ const token = readFileChangeWriteToken(payload);
339
+ logReload("file-change", { path, token: token ?? null, hasContent: content != null });
340
+ const identity = eventIdentity(path, payload);
341
+ if (identity != null && identity === lastEventIdentityRef.current) {
342
+ logReload("suppressed", { path, why: "duplicate event" });
343
+ return;
344
+ }
345
+ lastEventIdentityRef.current = identity;
346
+
347
+ const ownWriteToken = consumeStudioWriteToken(token);
348
+ const ownContentEcho = content != null && isSelfWriteEcho(path, content);
349
+ if (ownWriteToken || ownContentEcho) {
350
+ onAcceptedPersistedFileChange(path);
351
+ logReload("suppressed", {
352
+ path,
353
+ why: ownWriteToken ? "own write token" : "own content echo",
354
+ });
355
+ return;
356
+ }
357
+
358
+ pendingPayloadRef.current = { payload };
359
+ void startDrainLoop();
360
+ },
361
+ [projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
362
+ );
363
+
339
364
  useEffect(() => {
340
365
  const handler = (payload?: unknown) => processChange(payload);
341
366
  const adapter = testHotAdapter();
@@ -357,7 +382,7 @@ export function useExternalFileChangeCoordinator({
357
382
  if (!current || current.status === "conflict" || current.recovered) return;
358
383
  resetSaveQueues?.();
359
384
  lastEventIdentityRef.current = null;
360
- await processChange(current.payload, true);
385
+ processChange(current.payload);
361
386
  }, [processChange, resetSaveQueues]);
362
387
 
363
388
  const useExternalFile = useCallback(