@hyperframes/studio 0.8.28 → 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/dist/assets/{hyperframes-player-Bldv3Gqv.js → hyperframes-player-DMIVO02i.js} +1 -1
- package/dist/assets/{index-O3ilbgfR.js → index-CAyl2GL2.js} +137 -137
- package/dist/assets/{index-ZfDXlqBQ.js → index-Cerhq2OW.js} +1 -1
- package/dist/assets/{index-B50A8gg2.js → index-ok1knGut.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +52 -27
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +41 -4
- package/src/hooks/useExternalFileChangeCoordinator.ts +60 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
55
|
-
"@hyperframes/
|
|
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.
|
|
72
|
+
"@hyperframes/producer": "0.8.29"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"react": "19",
|
|
@@ -175,7 +175,7 @@ describe("external file change coordinator", () => {
|
|
|
175
175
|
expect(options.reloadSdkSession).toHaveBeenCalledOnce();
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
it("
|
|
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
|
-
|
|
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
|
|
218
|
+
const drainOnePending = useCallback(
|
|
217
219
|
// fallow-ignore-next-line complexity
|
|
218
|
-
async (payload: unknown
|
|
220
|
+
async (payload: unknown) => {
|
|
219
221
|
const path = readStudioFileChangePath(payload);
|
|
220
|
-
if (!path
|
|
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
|
|
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
|
|
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
|
-
|
|
385
|
+
processChange(current.payload);
|
|
361
386
|
}, [processChange, resetSaveQueues]);
|
|
362
387
|
|
|
363
388
|
const useExternalFile = useCallback(
|