Browse Source

fix: clear imageRef when user provides new image to prevent stale saves

When a user pastes/uploads a new image into a node that already has a
saved imageRef, the externalization logic incorrectly assumes the base64
is just hydrated data and discards it. Clear *Ref fields whenever new
image data is set so the save logic correctly persists the new image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
8019288fd3
  1. 2
      src/components/AnnotationModal.tsx
  2. 1
      src/components/WorkflowCanvas.tsx
  3. 4
      src/components/__tests__/AnnotationNode.test.tsx
  4. 2
      src/components/__tests__/ImageInputNode.test.tsx
  5. 4
      src/components/nodes/AnnotationNode.tsx
  6. 2
      src/components/nodes/ImageInputNode.tsx
  7. 10
      src/store/execution/__tests__/simpleNodeExecutors.test.ts
  8. 4
      src/store/execution/simpleNodeExecutors.ts
  9. 1
      src/store/execution/splitGridExecutor.ts

2
src/components/AnnotationModal.tsx

@ -323,7 +323,7 @@ export function AnnotationModal() {
const handleDone = useCallback(() => { const handleDone = useCallback(() => {
if (!sourceNodeId) return; if (!sourceNodeId) return;
const flattenedImage = flattenImage(); const flattenedImage = flattenImage();
updateNodeData(sourceNodeId, { annotations, outputImage: flattenedImage }); updateNodeData(sourceNodeId, { annotations, outputImage: flattenedImage, outputImageRef: undefined });
closeModal(); closeModal();
}, [sourceNodeId, annotations, flattenImage, updateNodeData, closeModal]); }, [sourceNodeId, annotations, flattenImage, updateNodeData, closeModal]);

1
src/components/WorkflowCanvas.tsx

@ -1143,6 +1143,7 @@ export function WorkflowCanvas() {
// Update the selected imageInput node with the pasted image // Update the selected imageInput node with the pasted image
updateNodeData(selectedImageInputNode.id, { updateNodeData(selectedImageInputNode.id, {
image: dataUrl, image: dataUrl,
imageRef: undefined,
filename: `pasted-${Date.now()}.png`, filename: `pasted-${Date.now()}.png`,
dimensions: { width: img.width, height: img.height }, dimensions: { width: img.width, height: img.height },
}); });

4
src/components/__tests__/AnnotationNode.test.tsx

@ -347,7 +347,9 @@ describe("AnnotationNode", () => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-annotation-1", { expect(mockUpdateNodeData).toHaveBeenCalledWith("test-annotation-1", {
sourceImage: null, sourceImage: null,
sourceImageRef: undefined,
outputImage: null, outputImage: null,
outputImageRef: undefined,
annotations: [], annotations: [],
}); });
}); });
@ -412,7 +414,9 @@ describe("AnnotationNode", () => {
await waitFor(() => { await waitFor(() => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-annotation-1", { expect(mockUpdateNodeData).toHaveBeenCalledWith("test-annotation-1", {
sourceImage: "data:image/png;base64,uploadedImage", sourceImage: "data:image/png;base64,uploadedImage",
sourceImageRef: undefined,
outputImage: null, outputImage: null,
outputImageRef: undefined,
annotations: [], annotations: [],
}); });
}); });

2
src/components/__tests__/ImageInputNode.test.tsx

@ -234,6 +234,7 @@ describe("ImageInputNode", () => {
await waitFor(() => { await waitFor(() => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-image-1", { expect(mockUpdateNodeData).toHaveBeenCalledWith("test-image-1", {
image: "data:image/png;base64,test123", image: "data:image/png;base64,test123",
imageRef: undefined,
filename: "test.png", filename: "test.png",
dimensions: { width: 1024, height: 768 }, dimensions: { width: 1024, height: 768 },
}); });
@ -406,6 +407,7 @@ describe("ImageInputNode", () => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-image-1", { expect(mockUpdateNodeData).toHaveBeenCalledWith("test-image-1", {
image: null, image: null,
imageRef: undefined,
filename: null, filename: null,
dimensions: null, dimensions: null,
}); });

4
src/components/nodes/AnnotationNode.tsx

@ -37,7 +37,9 @@ export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeT
const base64 = event.target?.result as string; const base64 = event.target?.result as string;
updateNodeData(id, { updateNodeData(id, {
sourceImage: base64, sourceImage: base64,
sourceImageRef: undefined,
outputImage: null, outputImage: null,
outputImageRef: undefined,
annotations: [], annotations: [],
}); });
}; };
@ -81,7 +83,9 @@ export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeT
const handleRemove = useCallback(() => { const handleRemove = useCallback(() => {
updateNodeData(id, { updateNodeData(id, {
sourceImage: null, sourceImage: null,
sourceImageRef: undefined,
outputImage: null, outputImage: null,
outputImageRef: undefined,
annotations: [], annotations: [],
}); });
}, [id, updateNodeData]); }, [id, updateNodeData]);

2
src/components/nodes/ImageInputNode.tsx

@ -37,6 +37,7 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
img.onload = () => { img.onload = () => {
updateNodeData(id, { updateNodeData(id, {
image: base64, image: base64,
imageRef: undefined,
filename: file.name, filename: file.name,
dimensions: { width: img.width, height: img.height }, dimensions: { width: img.width, height: img.height },
}); });
@ -74,6 +75,7 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
const handleRemove = useCallback(() => { const handleRemove = useCallback(() => {
updateNodeData(id, { updateNodeData(id, {
image: null, image: null,
imageRef: undefined,
filename: null, filename: null,
dimensions: null, dimensions: null,
}); });

10
src/store/execution/__tests__/simpleNodeExecutors.test.ts

@ -61,7 +61,7 @@ describe("executeAnnotation", () => {
await executeAnnotation(ctx); await executeAnnotation(ctx);
expect(ctx.updateNodeData).toHaveBeenCalledWith("ann", { sourceImage: "data:image/png;base64,abc" }); expect(ctx.updateNodeData).toHaveBeenCalledWith("ann", { sourceImage: "data:image/png;base64,abc", sourceImageRef: undefined });
}); });
it("should pass through image as output when no annotations exist", async () => { it("should pass through image as output when no annotations exist", async () => {
@ -79,7 +79,7 @@ describe("executeAnnotation", () => {
await executeAnnotation(ctx); await executeAnnotation(ctx);
expect(ctx.updateNodeData).toHaveBeenCalledWith("ann", { outputImage: "data:image/png;base64,abc" }); expect(ctx.updateNodeData).toHaveBeenCalledWith("ann", { outputImage: "data:image/png;base64,abc", outputImageRef: undefined });
}); });
it("should not overwrite existing annotated outputImage", async () => { it("should not overwrite existing annotated outputImage", async () => {
@ -98,7 +98,7 @@ describe("executeAnnotation", () => {
await executeAnnotation(ctx); await executeAnnotation(ctx);
// Should set sourceImage but NOT overwrite outputImage (it has real annotations) // Should set sourceImage but NOT overwrite outputImage (it has real annotations)
expect(ctx.updateNodeData).toHaveBeenCalledWith("ann", { sourceImage: "data:image/png;base64,abc" }); expect(ctx.updateNodeData).toHaveBeenCalledWith("ann", { sourceImage: "data:image/png;base64,abc", sourceImageRef: undefined });
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls; const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
const outputCall = calls.find((c: unknown[]) => (c[1] as Record<string, unknown>).outputImage !== undefined); const outputCall = calls.find((c: unknown[]) => (c[1] as Record<string, unknown>).outputImage !== undefined);
expect(outputCall).toBeUndefined(); expect(outputCall).toBeUndefined();
@ -120,8 +120,8 @@ describe("executeAnnotation", () => {
await executeAnnotation(ctx); await executeAnnotation(ctx);
expect(ctx.updateNodeData).toHaveBeenCalledWith("ann", { sourceImage: "new-image" }); expect(ctx.updateNodeData).toHaveBeenCalledWith("ann", { sourceImage: "new-image", sourceImageRef: undefined });
expect(ctx.updateNodeData).toHaveBeenCalledWith("ann", { outputImage: "new-image" }); expect(ctx.updateNodeData).toHaveBeenCalledWith("ann", { outputImage: "new-image", outputImageRef: undefined });
}); });
it("should do nothing when no images connected", async () => { it("should do nothing when no images connected", async () => {

4
src/store/execution/simpleNodeExecutors.ts

@ -27,11 +27,11 @@ export async function executeAnnotation(ctx: NodeExecutionContext): Promise<void
const image = images[0] || null; const image = images[0] || null;
if (image) { if (image) {
const nodeData = node.data as AnnotationNodeData; const nodeData = node.data as AnnotationNodeData;
updateNodeData(node.id, { sourceImage: image }); updateNodeData(node.id, { sourceImage: image, sourceImageRef: undefined });
// Pass through the image if no annotations exist, or if the previous // Pass through the image if no annotations exist, or if the previous
// output was itself a pass-through of the old source image // output was itself a pass-through of the old source image
if (!nodeData.outputImage || nodeData.outputImage === nodeData.sourceImage) { if (!nodeData.outputImage || nodeData.outputImage === nodeData.sourceImage) {
updateNodeData(node.id, { outputImage: image }); updateNodeData(node.id, { outputImage: image, outputImageRef: undefined });
} }
} }
} catch (err) { } catch (err) {

1
src/store/execution/splitGridExecutor.ts

@ -55,6 +55,7 @@ export async function executeSplitGrid(ctx: NodeExecutionContext): Promise<void>
img.onload = () => { img.onload = () => {
updateNodeData(childSet.imageInput, { updateNodeData(childSet.imageInput, {
image: splitImages[index], image: splitImages[index],
imageRef: undefined,
filename: `split-${Math.floor(index / nodeData.gridCols) + 1}-${(index % nodeData.gridCols) + 1}.png`, filename: `split-${Math.floor(index / nodeData.gridCols) + 1}-${(index % nodeData.gridCols) + 1}.png`,
dimensions: { width: img.width, height: img.height }, dimensions: { width: img.width, height: img.height },
}); });

Loading…
Cancel
Save