diff --git a/app/src/components/Actions/Actions.test.tsx b/app/src/components/Actions/Actions.test.tsx
index 31c65965..94a0c684 100644
--- a/app/src/components/Actions/Actions.test.tsx
+++ b/app/src/components/Actions/Actions.test.tsx
@@ -1,14 +1,31 @@
// @vitest-environment jsdom
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, act, fireEvent } from "@testing-library/react";
import { clone, create } from "@bufbuild/protobuf";
import React from "react";
import { APPKERNEL_RUNNER_NAME, APPKERNEL_RUNNER_LABEL } from "../../lib/runtime/appKernel";
-import { parser_pb, RunmeMetadataKey } from "../../runme/client";
+import { MimeType, parser_pb, RunmeMetadataKey } from "../../runme/client";
import type { CellData } from "../../lib/notebookData";
import { Action } from "./Actions";
+const editorMockState = vi.hoisted(() => ({
+ onChangeHandlers: [] as Array<(value: string) => void>,
+}));
+
+vi.mock("./Editor", () => ({
+ default: ({
+ value,
+ onChange,
+ }: {
+ value: string;
+ onChange: (value: string) => void;
+ }) => {
+ editorMockState.onChangeHandlers.push(onChange);
+ return ;
+ },
+}));
+
// Minimal mocks for contexts Action consumes
vi.mock("../../contexts/OutputContext", () => ({
useOutput: () => ({
@@ -42,6 +59,12 @@ vi.mock("../../contexts/NotebookContext", () => ({
}),
}));
+vi.mock("../../contexts/NotebookStoreContext", () => ({
+ useNotebookStore: () => ({
+ store: null,
+ }),
+}));
+
vi.mock("../../contexts/CurrentDocContext", () => ({
useCurrentDoc: () => ({
getCurrentDoc: () => null,
@@ -129,6 +152,10 @@ class StubCellData {
}
describe("Action component", () => {
+ beforeEach(() => {
+ editorMockState.onChangeHandlers.length = 0;
+ });
+
it("updates CellConsole key when runID changes", async () => {
const cell = create(parser_pb.CellSchema,{
refId: "cell-1",
@@ -140,9 +167,9 @@ describe("Action component", () => {
},
value: "echo hi",
});
- const stub = new StubCellData(cell) as unknown as CellData;
+ const stub = new StubCellData(cell);
- render();
+ render();
const first = screen.getByTestId("cell-console") as HTMLElement;
const firstKey = first.dataset.runkey;
@@ -169,9 +196,9 @@ describe("Action component", () => {
},
value: "echo hi",
});
- const stub = new StubCellData(cell) as unknown as CellData;
+ const stub = new StubCellData(cell);
- render();
+ render();
expect(screen.getByTestId("cell-console")).toBeTruthy();
await act(async () => {
@@ -182,6 +209,53 @@ describe("Action component", () => {
expect(screen.queryByTestId("cell-console")).toBeNull();
});
+ it("preserves latest outputs when edit callback uses stale closure", async () => {
+ const cell = create(parser_pb.CellSchema, {
+ refId: "cell-stale-closure",
+ kind: parser_pb.CellKind.CODE,
+ languageId: "bash",
+ outputs: [],
+ metadata: {
+ [RunmeMetadataKey.LastRunID]: "run-0",
+ },
+ value: "echo hi",
+ });
+ const stub = new StubCellData(cell);
+
+ render();
+
+ const staleOnChange = editorMockState.onChangeHandlers[0];
+ expect(staleOnChange).toBeTypeOf("function");
+
+ await act(async () => {
+ const withOutput = clone(parser_pb.CellSchema, stub.snapshot);
+ withOutput.outputs = [
+ create(parser_pb.CellOutputSchema, {
+ items: [
+ create(parser_pb.CellOutputItemSchema, {
+ mime: MimeType.VSCodeNotebookStdOut,
+ type: "Buffer",
+ }),
+ ],
+ }),
+ ];
+ stub.update(withOutput);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ staleOnChange?.("echo edited");
+ await Promise.resolve();
+ });
+
+ const latestUpdateCall = stub.update.mock.calls.at(-1);
+ const updatedCell = latestUpdateCall?.[0] as parser_pb.Cell;
+ expect(updatedCell.outputs).toHaveLength(1);
+ expect(updatedCell.outputs[0]?.items[0]?.mime).toBe(
+ MimeType.VSCodeNotebookStdOut,
+ );
+ });
+
it("shows language selector in markdown edit mode and converts to code language", () => {
const cell = create(parser_pb.CellSchema, {
refId: "cell-md",
diff --git a/app/src/components/Actions/Actions.tsx b/app/src/components/Actions/Actions.tsx
index e1be13bb..c64eb8de 100644
--- a/app/src/components/Actions/Actions.tsx
+++ b/app/src/components/Actions/Actions.tsx
@@ -8,7 +8,7 @@ import {
useSyncExternalStore,
} from "react";
-import { create } from "@bufbuild/protobuf";
+import { clone, create } from "@bufbuild/protobuf";
import { Button, ScrollArea, Tabs, Text } from "@radix-ui/themes";
import { useParams } from "react-router-dom";
@@ -568,7 +568,11 @@ export function Action({
return;
}
- const updatedCell = create(parser_pb.CellSchema, cell);
+ const latestCell = cellData.snapshot;
+ if (!latestCell) {
+ return;
+ }
+ const updatedCell = clone(parser_pb.CellSchema, latestCell);
if (nextValue === "markdown") {
setMarkdownEditRequest((request) => request + 1);
updatedCell.kind = parser_pb.CellKind.MARKUP;
@@ -588,7 +592,7 @@ export function Action({
setPid(null);
setExitCode(null);
},
- [cell, selectedLanguage, updateCellLocal],
+ [cell, cellData, selectedLanguage, updateCellLocal],
);
// Determine if this cell is a markdown cell (either MARKUP kind or CODE with markdown language)
@@ -739,7 +743,11 @@ export function Action({
fontSize={fontSettings.fontSize}
fontFamily={fontSettings.fontFamily}
onChange={(v) => {
- const updated = create(parser_pb.CellSchema, cell);
+ const latestCell = cellData.snapshot;
+ if (!latestCell) {
+ return;
+ }
+ const updated = clone(parser_pb.CellSchema, latestCell);
updated.value = v;
updateCellLocal(updated);
}}