Skip to content

Commit d7be25d

Browse files
committed
Refactor snapshot checking on Plugin side
1 parent 29fadc8 commit d7be25d

File tree

2 files changed

+125
-40
lines changed

2 files changed

+125
-40
lines changed

plugins/code-link/src/api.test.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -348,8 +348,8 @@ describe("CodeFilesAPI", () => {
348348
})
349349

350350
framerMock.getCodeFiles.mockResolvedValueOnce([existing])
351-
existing.rename.mockImplementation(async (nextName: string) => {
352-
existing.name = nextName
351+
existing.rename.mockImplementation(async (targetName: string) => {
352+
existing.name = targetName
353353
framerMock.getCodeFiles.mockResolvedValue([existing])
354354
await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker)
355355
})
@@ -409,6 +409,52 @@ describe("CodeFilesAPI", () => {
409409
expect(socket.send).not.toHaveBeenCalled()
410410
})
411411

412+
it("seeds snapshot before a remote delete finishes to avoid echoing subscription updates", async () => {
413+
const { api, socket, tracker, trackerRemember } = setup()
414+
const content = "export const DeleteMe = 1"
415+
const existing = createCodeFile({ name: "DeleteMe.tsx", content })
416+
417+
await publishSnapshotAndClear({
418+
api,
419+
socket,
420+
files: [createCodeFile({ name: "DeleteMe.tsx", content })],
421+
})
422+
423+
framerMock.getCodeFiles.mockResolvedValueOnce([existing])
424+
existing.remove.mockImplementation(async () => {
425+
framerMock.getCodeFiles.mockResolvedValue([])
426+
await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker)
427+
})
428+
429+
await api.applyRemoteDelete("DeleteMe.tsx")
430+
431+
expect(socket.send).not.toHaveBeenCalled()
432+
expect(trackerRemember).not.toHaveBeenCalled()
433+
})
434+
435+
it("restores snapshot state when a remote delete fails", async () => {
436+
const { api, socket, tracker, trackerRemember } = setup()
437+
const content = "export const DeleteMe = 1"
438+
const existing = createCodeFile({ name: "DeleteMe.tsx", content })
439+
440+
await publishSnapshotAndClear({
441+
api,
442+
socket,
443+
files: [createCodeFile({ name: "DeleteMe.tsx", content })],
444+
})
445+
446+
existing.remove.mockRejectedValueOnce(new Error("delete failed"))
447+
framerMock.getCodeFiles.mockResolvedValueOnce([existing])
448+
449+
await expect(api.applyRemoteDelete("DeleteMe.tsx")).rejects.toThrow("delete failed")
450+
451+
mockCodeFiles([createCodeFile({ name: "DeleteMe.tsx", content })])
452+
await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker)
453+
454+
expect(socket.send).not.toHaveBeenCalled()
455+
expect(trackerRemember).not.toHaveBeenCalled()
456+
})
457+
412458
it("returns an error when rename cannot fetch code files", async () => {
413459
const { api, socket } = setup()
414460

@@ -455,8 +501,15 @@ describe("CodeFilesAPI", () => {
455501
})
456502

457503
it("returns an error when rename throws and does not confirm sync", async () => {
458-
const { api, socket } = setup()
459-
const existing = createCodeFile({ name: "Old.tsx", content: "export const Old = 1" })
504+
const { api, socket, tracker, trackerRemember } = setup()
505+
const content = "export const Old = 1"
506+
const existing = createCodeFile({ name: "Old.tsx", content })
507+
508+
await publishSnapshotAndClear({
509+
api,
510+
socket,
511+
files: [createCodeFile({ name: "Old.tsx", content })],
512+
})
460513

461514
existing.rename.mockRejectedValueOnce(new Error("rename failed"))
462515
framerMock.getCodeFiles.mockResolvedValueOnce([existing])
@@ -470,5 +523,12 @@ describe("CodeFilesAPI", () => {
470523
message: "Failed to rename Old.tsx -> New",
471524
},
472525
])
526+
527+
socket.send.mockClear()
528+
mockCodeFiles([createCodeFile({ name: "Old.tsx", content })])
529+
await api.handleFramerFilesChanged(socket as unknown as WebSocket, tracker)
530+
531+
expect(socket.send).not.toHaveBeenCalled()
532+
expect(trackerRemember).not.toHaveBeenCalled()
473533
})
474534
})

plugins/code-link/src/api.ts

Lines changed: 61 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,44 @@ import * as log from "./utils/logger"
1111
export class CodeFilesAPI {
1212
private lastSnapshot = new Map<string, string>()
1313

14+
// Keep the snapshot aligned with the state Framer should expose, even while a remote mutation is in flight.
15+
private async withExpectedSnapshotPatch<T>(
16+
patch: {
17+
upserts?: { fileName: string; content: string }[]
18+
deletes?: string[]
19+
},
20+
run: () => Promise<T>
21+
): Promise<T> {
22+
const previousEntries = new Map<string, string | undefined>()
23+
24+
for (const fileName of patch.deletes ?? []) {
25+
if (!previousEntries.has(fileName)) {
26+
previousEntries.set(fileName, this.lastSnapshot.get(fileName))
27+
}
28+
this.lastSnapshot.delete(fileName)
29+
}
30+
31+
for (const entry of patch.upserts ?? []) {
32+
if (!previousEntries.has(entry.fileName)) {
33+
previousEntries.set(entry.fileName, this.lastSnapshot.get(entry.fileName))
34+
}
35+
this.lastSnapshot.set(entry.fileName, entry.content)
36+
}
37+
38+
try {
39+
return await run()
40+
} catch (error) {
41+
for (const [fileName, previousContent] of previousEntries) {
42+
if (previousContent === undefined) {
43+
this.lastSnapshot.delete(fileName)
44+
} else {
45+
this.lastSnapshot.set(fileName, previousContent)
46+
}
47+
}
48+
throw error
49+
}
50+
}
51+
1452
private async getCodeFilesWithCanonicalNames() {
1553
// Always all files instead of single file calls.
1654
// The API internally does that anyways.
@@ -78,22 +116,12 @@ export class CodeFilesAPI {
78116

79117
async applyRemoteChange(fileName: string, content: string, socket: WebSocket) {
80118
const normalizedName = normalizeCodeFileName(fileName)
81-
const previousSnapshot = this.lastSnapshot.get(normalizedName)
82-
83-
// Update snapshot BEFORE upsert to prevent race with file subscription.
84-
this.lastSnapshot.set(normalizedName, content)
85-
86-
let updatedAt: number | undefined
87-
try {
88-
updatedAt = await upsertFramerFile(normalizedName, content)
89-
} catch (error) {
90-
if (previousSnapshot !== undefined) {
91-
this.lastSnapshot.set(normalizedName, previousSnapshot)
92-
} else {
93-
this.lastSnapshot.delete(normalizedName)
94-
}
95-
throw error
96-
}
119+
const updatedAt = await this.withExpectedSnapshotPatch(
120+
{
121+
upserts: [{ fileName: normalizedName, content }],
122+
},
123+
async () => await upsertFramerFile(normalizedName, content)
124+
)
97125

98126
// Send file-synced message with timestamp
99127
const syncTimestamp = updatedAt ?? Date.now()
@@ -110,8 +138,15 @@ export class CodeFilesAPI {
110138
}
111139

112140
async applyRemoteDelete(fileName: string) {
113-
await deleteFramerFile(fileName)
114-
this.lastSnapshot.delete(normalizeCodeFileName(fileName))
141+
const normalizedName = normalizeCodeFileName(fileName)
142+
await this.withExpectedSnapshotPatch(
143+
{
144+
deletes: [normalizedName],
145+
},
146+
async () => {
147+
await deleteFramerFile(normalizedName)
148+
}
149+
)
115150
}
116151

117152
async readCurrentContent(fileName: string) {
@@ -209,16 +244,16 @@ export class CodeFilesAPI {
209244
return false
210245
}
211246

212-
const previousSourceSnapshot = this.lastSnapshot.get(sourceFileName)
213-
const previousTargetSnapshot = this.lastSnapshot.get(targetFileName)
214-
const renamedContent = previousSourceSnapshot ?? existing.content
215-
216-
// Update snapshot BEFORE rename to prevent race with file subscription.
217-
this.lastSnapshot.delete(sourceFileName)
218-
this.lastSnapshot.set(targetFileName, renamedContent)
247+
const content = this.lastSnapshot.get(sourceFileName) ?? existing.content
219248

220249
try {
221-
await existing.rename(targetFileName)
250+
await this.withExpectedSnapshotPatch(
251+
{
252+
upserts: [{ fileName: targetFileName, content }],
253+
deletes: [sourceFileName],
254+
},
255+
async () => await existing.rename(targetFileName)
256+
)
222257
socket.send(
223258
JSON.stringify({
224259
type: "file-synced",
@@ -228,16 +263,6 @@ export class CodeFilesAPI {
228263
)
229264
return true
230265
} catch (err) {
231-
if (previousSourceSnapshot !== undefined) {
232-
this.lastSnapshot.set(sourceFileName, previousSourceSnapshot)
233-
}
234-
235-
if (previousTargetSnapshot !== undefined) {
236-
this.lastSnapshot.set(targetFileName, previousTargetSnapshot)
237-
} else {
238-
this.lastSnapshot.delete(targetFileName)
239-
}
240-
241266
const message = `Failed to rename ${oldFileName} -> ${newFileName}`
242267
log.error(message, err)
243268
socket.send(

0 commit comments

Comments
 (0)