@marimo-team/islands 0.25.3-dev275 → 0.25.3-dev279
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/{ErrorBoundary-BuIXiuc5.js → ErrorBoundary-DmTMuDPR.js} +1 -1
- package/dist/{chat-ui-SZ6lSH52.js → chat-ui-v1Fdh0NA.js} +270 -270
- package/dist/{code-visibility-BEAPMrC-.js → code-visibility-Dw4-TXN0.js} +2 -2
- package/dist/{common-CEmzaoJ8.js → common-BXMFi4lr.js} +4947 -4943
- package/dist/{data-grid-overlay-editor-CUo25_W8.js → data-grid-overlay-editor-hZzwX7Ia.js} +1 -1
- package/dist/{error-entries-B-1yUdcH.js → error-entries-C1S-Rmyt.js} +545 -535
- package/dist/{feedback-button-BqiR7zMO.js → feedback-button-6Ui2TDLR.js} +5 -5
- package/dist/{glide-data-editor-B7dguvqk.js → glide-data-editor-PZNlrSDI.js} +3 -3
- package/dist/{globals-CYykXfGI.js → globals-DX6FPZYq.js} +1 -1
- package/dist/{html-to-image-DC_pmZLs.js → html-to-image-CqGUd58T.js} +14452 -14452
- package/dist/main.js +6031 -6036
- package/dist/{number-overlay-editor-C4u7SJAJ.js → number-overlay-editor-D0CsL3_R.js} +1 -1
- package/dist/{process-output-DYu84VfZ.js → process-output-DskX9jLa.js} +2 -2
- package/dist/{reveal-component-DGi-irHV.js → reveal-component-D7xeI1QR.js} +906 -906
- package/package.json +1 -1
- package/src/__mocks__/executions.ts +4 -2
- package/src/__mocks__/notebook.ts +0 -2
- package/src/__tests__/CellStatus.test.tsx +6 -6
- package/src/components/editor/__tests__/data-attributes.test.tsx +0 -2
- package/src/components/editor/__tests__/output-persistence.test.tsx +20 -5
- package/src/components/editor/cell/CellStatus.tsx +6 -5
- package/src/components/editor/cell/__tests__/useRunCells.test.tsx +169 -0
- package/src/components/editor/cell/collapse.tsx +3 -4
- package/src/components/editor/cell/useRunCells.ts +32 -3
- package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +2 -3
- package/src/components/editor/notebook-cell.tsx +23 -37
- package/src/components/editor/renderers/grid-layout/grid-layout.tsx +4 -10
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +0 -2
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +8 -5
- package/src/components/slides/minimap.tsx +3 -2
- package/src/components/slides/reveal-component.tsx +3 -2
- package/src/components/slides/slide-cell-view.tsx +8 -8
- package/src/core/cells/__tests__/__snapshots__/cells.test.ts.snap +0 -101
- package/src/core/cells/__tests__/actions.test.ts +0 -1
- package/src/core/cells/__tests__/cell.test.ts +81 -17
- package/src/core/cells/__tests__/cells.test.ts +0 -100
- package/src/core/cells/__tests__/document.test.ts +2 -8
- package/src/core/cells/__tests__/utils.test.ts +93 -24
- package/src/core/cells/actions.ts +0 -1
- package/src/core/cells/cell.ts +37 -14
- package/src/core/cells/cells.ts +10 -99
- package/src/core/cells/document.ts +2 -13
- package/src/core/cells/types.ts +0 -23
- package/src/core/cells/utils.ts +44 -17
- package/src/core/codemirror/cells/extensions.ts +0 -14
- package/src/core/errors/__tests__/error-entries.test.ts +40 -1
- package/src/core/errors/error-entries.ts +2 -3
- package/src/core/executions/__tests__/freshness.test.ts +420 -0
- package/src/core/executions/__tests__/hooks.test.tsx +106 -0
- package/src/core/executions/atoms.ts +22 -0
- package/src/core/executions/freshness.ts +164 -0
- package/src/core/executions/hooks.ts +17 -1
- package/src/core/executions/index.ts +3 -1
- package/src/core/islands/bootstrap.ts +0 -1
- package/src/core/kernel/handlers.ts +0 -4
- package/src/core/session/useNotebookSession.tsx +2 -7
- package/src/stories/cell-status.stories.tsx +12 -14
- package/src/stories/cell.stories.tsx +44 -10
- package/src/stories/layout/vertical/one-column.stories.tsx +0 -10
- package/src/components/editor/cell/__tests__/useRunCells.test.ts +0 -45
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import type * as Protocol from "@marimo-team/client/protocol";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import type { Execution, Executions } from "@/core/notebook/replica";
|
|
6
|
+
import { type CellFreshnessInputs, freshnessOf } from "../freshness";
|
|
7
|
+
|
|
8
|
+
const T0 = "2026-10-01T09:20:11Z";
|
|
9
|
+
const T1 = "2026-10-01T09:20:12.500Z";
|
|
10
|
+
|
|
11
|
+
/** The spec's worked example: `c3` binds `x`, which `k7f2` reads. */
|
|
12
|
+
const C3_FINGERPRINT = "0f71456db2ea83f4";
|
|
13
|
+
const K7F2_FINGERPRINT = "db2b1c9e4a7f0d36";
|
|
14
|
+
|
|
15
|
+
function cell(
|
|
16
|
+
id: string,
|
|
17
|
+
fingerprint: string,
|
|
18
|
+
overrides: Partial<Protocol.Cell> = {},
|
|
19
|
+
): Protocol.Cell {
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
notebook_id: "n1",
|
|
23
|
+
code: null,
|
|
24
|
+
name: "_",
|
|
25
|
+
config: {
|
|
26
|
+
column: null,
|
|
27
|
+
disabled: false,
|
|
28
|
+
hide_code: false,
|
|
29
|
+
expand_output: false,
|
|
30
|
+
},
|
|
31
|
+
etag: `etag-${id}`,
|
|
32
|
+
code_fingerprint: fingerprint,
|
|
33
|
+
version: 1,
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function record(
|
|
39
|
+
overrides: Partial<Protocol.Execution> & {
|
|
40
|
+
id: string;
|
|
41
|
+
cell_id: string;
|
|
42
|
+
code_fingerprint: string;
|
|
43
|
+
},
|
|
44
|
+
): Protocol.Execution {
|
|
45
|
+
return {
|
|
46
|
+
run_id: "run-1",
|
|
47
|
+
runtime_id: "r1",
|
|
48
|
+
caused_by: null,
|
|
49
|
+
code: null,
|
|
50
|
+
depends_on: {},
|
|
51
|
+
status: "completed",
|
|
52
|
+
outcome: { kind: "value", display: null },
|
|
53
|
+
started_at: T0,
|
|
54
|
+
completed_at: T1,
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function variable(
|
|
60
|
+
name: string,
|
|
61
|
+
execution: string,
|
|
62
|
+
update = 0,
|
|
63
|
+
): Protocol.Variable {
|
|
64
|
+
return {
|
|
65
|
+
id: `${execution}-${name}`,
|
|
66
|
+
execution_id: execution,
|
|
67
|
+
name,
|
|
68
|
+
revision: { execution, update },
|
|
69
|
+
type: null,
|
|
70
|
+
preview: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function execution(
|
|
75
|
+
rec: Protocol.Execution,
|
|
76
|
+
variables: Protocol.Variable[] = [],
|
|
77
|
+
): Execution {
|
|
78
|
+
return {
|
|
79
|
+
record: rec,
|
|
80
|
+
items: new Map(),
|
|
81
|
+
variables: new Map(variables.map((v) => [v.id, v])),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function inputs({
|
|
86
|
+
cells,
|
|
87
|
+
executions,
|
|
88
|
+
results = executions.map((e) => e.record),
|
|
89
|
+
}: {
|
|
90
|
+
cells: Protocol.Cell[];
|
|
91
|
+
executions: Execution[];
|
|
92
|
+
/** Defaults to the executions on the stream. */
|
|
93
|
+
results?: Protocol.Execution[];
|
|
94
|
+
}): CellFreshnessInputs {
|
|
95
|
+
const stream: Executions = {
|
|
96
|
+
runs: new Map(),
|
|
97
|
+
executions: new Map(executions.map((e) => [e.record.id, e])),
|
|
98
|
+
latest: new Map(
|
|
99
|
+
executions.flatMap((e) =>
|
|
100
|
+
e.record.cell_id === null ? [] : [[e.record.cell_id, e.record.id]],
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
};
|
|
104
|
+
const retained = new Map<Protocol.CellId, Protocol.Execution | null>(
|
|
105
|
+
cells.map((c) => [c.id, null]),
|
|
106
|
+
);
|
|
107
|
+
for (const result of results) {
|
|
108
|
+
if (result.cell_id !== null) {
|
|
109
|
+
retained.set(result.cell_id, result);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
cells: new Map(cells.map((c) => [c.id, c])),
|
|
114
|
+
executions: stream,
|
|
115
|
+
results: retained,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const c3 = cell("c3", C3_FINGERPRINT);
|
|
120
|
+
const k7f2 = cell("k7f2", K7F2_FINGERPRINT);
|
|
121
|
+
const e12 = record({
|
|
122
|
+
id: "e12",
|
|
123
|
+
cell_id: "c3",
|
|
124
|
+
code_fingerprint: C3_FINGERPRINT,
|
|
125
|
+
});
|
|
126
|
+
const e13 = record({
|
|
127
|
+
id: "e13",
|
|
128
|
+
cell_id: "k7f2",
|
|
129
|
+
code_fingerprint: K7F2_FINGERPRINT,
|
|
130
|
+
depends_on: { x: { execution: "e12", update: 0 } },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("cellFreshness", () => {
|
|
134
|
+
it("is current for a cell that never completed, having nothing to compare", () => {
|
|
135
|
+
const state = inputs({ cells: [c3], executions: [] });
|
|
136
|
+
expect(freshnessOf(state)("c3")).toEqual({
|
|
137
|
+
sourceChanged: false,
|
|
138
|
+
inputsChanged: false,
|
|
139
|
+
});
|
|
140
|
+
expect(freshnessOf(state)("missing")).toEqual({
|
|
141
|
+
sourceChanged: false,
|
|
142
|
+
inputsChanged: false,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("finds both executions of the worked example current", () => {
|
|
147
|
+
const state = inputs({
|
|
148
|
+
cells: [c3, k7f2],
|
|
149
|
+
executions: [execution(e12, [variable("x", "e12")]), execution(e13)],
|
|
150
|
+
});
|
|
151
|
+
const freshness = freshnessOf(state);
|
|
152
|
+
expect(freshness("c3")).toEqual({
|
|
153
|
+
sourceChanged: false,
|
|
154
|
+
inputsChanged: false,
|
|
155
|
+
});
|
|
156
|
+
expect(freshness("k7f2")).toEqual({
|
|
157
|
+
sourceChanged: false,
|
|
158
|
+
inputsChanged: false,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("is outdated as updated once a value it depends on advances", () => {
|
|
163
|
+
const state = inputs({
|
|
164
|
+
cells: [c3, k7f2],
|
|
165
|
+
executions: [execution(e12, [variable("x", "e12", 1)]), execution(e13)],
|
|
166
|
+
});
|
|
167
|
+
const freshness = freshnessOf(state);
|
|
168
|
+
expect(freshness("c3")).toEqual({
|
|
169
|
+
sourceChanged: false,
|
|
170
|
+
inputsChanged: false,
|
|
171
|
+
});
|
|
172
|
+
expect(freshness("k7f2")).toEqual({
|
|
173
|
+
sourceChanged: false,
|
|
174
|
+
inputsChanged: true,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("is outdated as edited when the cell's code changed, and so are its dependents", () => {
|
|
179
|
+
const state = inputs({
|
|
180
|
+
cells: [cell("c3", "ffff000011112222"), k7f2],
|
|
181
|
+
executions: [execution(e12, [variable("x", "e12")]), execution(e13)],
|
|
182
|
+
});
|
|
183
|
+
const freshness = freshnessOf(state);
|
|
184
|
+
expect(freshness("c3")).toEqual({
|
|
185
|
+
sourceChanged: true,
|
|
186
|
+
inputsChanged: false,
|
|
187
|
+
});
|
|
188
|
+
// The dependent's own source and the values it read are as they were.
|
|
189
|
+
expect(freshness("k7f2")).toEqual({
|
|
190
|
+
sourceChanged: false,
|
|
191
|
+
inputsChanged: false,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("marks changed inputs through a binder that did not run again", () => {
|
|
196
|
+
const m = cell("m", "aaaa111122223333");
|
|
197
|
+
const e14 = record({
|
|
198
|
+
id: "e14",
|
|
199
|
+
cell_id: "c3",
|
|
200
|
+
code_fingerprint: C3_FINGERPRINT,
|
|
201
|
+
});
|
|
202
|
+
const e15 = record({
|
|
203
|
+
id: "e15",
|
|
204
|
+
cell_id: "m",
|
|
205
|
+
code_fingerprint: "aaaa111122223333",
|
|
206
|
+
depends_on: { y: { execution: "e13", update: 0 } },
|
|
207
|
+
});
|
|
208
|
+
const state = inputs({
|
|
209
|
+
cells: [c3, k7f2, m],
|
|
210
|
+
executions: [
|
|
211
|
+
execution(e14, [variable("x", "e14")]),
|
|
212
|
+
execution(e13, [variable("y", "e13")]),
|
|
213
|
+
execution(e15),
|
|
214
|
+
],
|
|
215
|
+
});
|
|
216
|
+
const freshness = freshnessOf(state);
|
|
217
|
+
expect(freshness("k7f2")).toEqual({
|
|
218
|
+
sourceChanged: false,
|
|
219
|
+
inputsChanged: true,
|
|
220
|
+
});
|
|
221
|
+
expect(freshness("m")).toEqual({
|
|
222
|
+
sourceChanged: false,
|
|
223
|
+
inputsChanged: true,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("is outdated as replaced once the binding execution runs again", () => {
|
|
228
|
+
const e14 = record({
|
|
229
|
+
id: "e14",
|
|
230
|
+
cell_id: "c3",
|
|
231
|
+
code_fingerprint: C3_FINGERPRINT,
|
|
232
|
+
});
|
|
233
|
+
const state = inputs({
|
|
234
|
+
cells: [c3, k7f2],
|
|
235
|
+
executions: [execution(e14, [variable("x", "e14")]), execution(e13)],
|
|
236
|
+
});
|
|
237
|
+
expect(freshnessOf(state)("k7f2")).toEqual({
|
|
238
|
+
sourceChanged: false,
|
|
239
|
+
inputsChanged: true,
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("is outdated as replaced while the binding cell runs again and the name is unbound", () => {
|
|
244
|
+
const queued = record({
|
|
245
|
+
id: "e14",
|
|
246
|
+
cell_id: "c3",
|
|
247
|
+
code_fingerprint: C3_FINGERPRINT,
|
|
248
|
+
status: "queued",
|
|
249
|
+
outcome: null,
|
|
250
|
+
started_at: null,
|
|
251
|
+
completed_at: null,
|
|
252
|
+
});
|
|
253
|
+
const state = inputs({
|
|
254
|
+
cells: [c3, k7f2],
|
|
255
|
+
executions: [execution(queued), execution(e13)],
|
|
256
|
+
results: [e12, e13],
|
|
257
|
+
});
|
|
258
|
+
const freshness = freshnessOf(state);
|
|
259
|
+
expect(freshness("k7f2")).toEqual({
|
|
260
|
+
sourceChanged: false,
|
|
261
|
+
inputsChanged: true,
|
|
262
|
+
});
|
|
263
|
+
// The retained result for c3 still compares by its own fingerprint.
|
|
264
|
+
expect(freshness("c3")).toEqual({
|
|
265
|
+
sourceChanged: false,
|
|
266
|
+
inputsChanged: false,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("is outdated as replaced once a name it read as unbound is bound", () => {
|
|
271
|
+
const reader = record({
|
|
272
|
+
id: "e20",
|
|
273
|
+
cell_id: "k7f2",
|
|
274
|
+
code_fingerprint: K7F2_FINGERPRINT,
|
|
275
|
+
depends_on: { y: null },
|
|
276
|
+
});
|
|
277
|
+
const unbound = inputs({
|
|
278
|
+
cells: [c3, k7f2],
|
|
279
|
+
executions: [execution(reader)],
|
|
280
|
+
});
|
|
281
|
+
expect(freshnessOf(unbound)("k7f2")).toEqual({
|
|
282
|
+
sourceChanged: false,
|
|
283
|
+
inputsChanged: false,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const bound = inputs({
|
|
287
|
+
cells: [c3, k7f2],
|
|
288
|
+
executions: [execution(e12, [variable("y", "e12")]), execution(reader)],
|
|
289
|
+
});
|
|
290
|
+
expect(freshnessOf(bound)("k7f2")).toEqual({
|
|
291
|
+
sourceChanged: false,
|
|
292
|
+
inputsChanged: true,
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("reports both facts when the source and a value it read changed", () => {
|
|
297
|
+
const reader = record({
|
|
298
|
+
id: "e21",
|
|
299
|
+
cell_id: "k7f2",
|
|
300
|
+
code_fingerprint: "stale",
|
|
301
|
+
depends_on: {
|
|
302
|
+
x: { execution: "e12", update: 0 },
|
|
303
|
+
z: { execution: "e30", update: 0 },
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
const state = inputs({
|
|
307
|
+
cells: [c3, k7f2],
|
|
308
|
+
executions: [
|
|
309
|
+
execution(e12, [variable("x", "e12", 2)]),
|
|
310
|
+
execution(reader),
|
|
311
|
+
],
|
|
312
|
+
});
|
|
313
|
+
expect(freshnessOf(state)("k7f2")).toEqual({
|
|
314
|
+
sourceChanged: true,
|
|
315
|
+
inputsChanged: true,
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("compares a skipped execution by the dependencies it would have read", () => {
|
|
320
|
+
const skipped = record({
|
|
321
|
+
id: "e22",
|
|
322
|
+
cell_id: "k7f2",
|
|
323
|
+
code_fingerprint: K7F2_FINGERPRINT,
|
|
324
|
+
outcome: {
|
|
325
|
+
kind: "skipped",
|
|
326
|
+
reason: "upstream-failed",
|
|
327
|
+
message: "",
|
|
328
|
+
related: [],
|
|
329
|
+
},
|
|
330
|
+
depends_on: { x: { execution: "e12", update: 0 } },
|
|
331
|
+
});
|
|
332
|
+
const state = inputs({
|
|
333
|
+
cells: [c3, k7f2],
|
|
334
|
+
executions: [
|
|
335
|
+
execution(e12, [variable("x", "e12", 1)]),
|
|
336
|
+
execution(skipped),
|
|
337
|
+
],
|
|
338
|
+
});
|
|
339
|
+
expect(freshnessOf(state)("k7f2")).toEqual({
|
|
340
|
+
sourceChanged: false,
|
|
341
|
+
inputsChanged: true,
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("treats a result whose cell left the document as edited", () => {
|
|
346
|
+
const state = inputs({
|
|
347
|
+
cells: [k7f2],
|
|
348
|
+
executions: [execution(e12, [variable("x", "e12")]), execution(e13)],
|
|
349
|
+
results: [e12, e13],
|
|
350
|
+
});
|
|
351
|
+
expect(freshnessOf(state)("c3")).toEqual({
|
|
352
|
+
sourceChanged: true,
|
|
353
|
+
inputsChanged: false,
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("treats a name bound only by an earlier runtime as unbound", () => {
|
|
358
|
+
const reader = record({
|
|
359
|
+
id: "e20",
|
|
360
|
+
cell_id: "k7f2",
|
|
361
|
+
runtime_id: "r2",
|
|
362
|
+
code_fingerprint: K7F2_FINGERPRINT,
|
|
363
|
+
depends_on: { x: null },
|
|
364
|
+
});
|
|
365
|
+
const retained = inputs({
|
|
366
|
+
cells: [c3, k7f2],
|
|
367
|
+
executions: [execution(e12, [variable("x", "e12")]), execution(reader)],
|
|
368
|
+
});
|
|
369
|
+
expect(freshnessOf(retained)("k7f2")).toEqual({
|
|
370
|
+
sourceChanged: false,
|
|
371
|
+
inputsChanged: false,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const rebound = record({
|
|
375
|
+
id: "e14",
|
|
376
|
+
cell_id: "c3",
|
|
377
|
+
runtime_id: "r2",
|
|
378
|
+
code_fingerprint: C3_FINGERPRINT,
|
|
379
|
+
});
|
|
380
|
+
const bound = inputs({
|
|
381
|
+
cells: [c3, k7f2],
|
|
382
|
+
executions: [
|
|
383
|
+
execution(rebound, [variable("x", "e14")]),
|
|
384
|
+
execution(reader),
|
|
385
|
+
],
|
|
386
|
+
});
|
|
387
|
+
expect(freshnessOf(bound)("k7f2")).toEqual({
|
|
388
|
+
sourceChanged: false,
|
|
389
|
+
inputsChanged: true,
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("does not report changed source while a newer execution runs it", () => {
|
|
394
|
+
const edited = cell("c3", "ffff000011112222");
|
|
395
|
+
const running = record({
|
|
396
|
+
id: "e14",
|
|
397
|
+
cell_id: "c3",
|
|
398
|
+
code_fingerprint: "ffff000011112222",
|
|
399
|
+
status: "running",
|
|
400
|
+
outcome: null,
|
|
401
|
+
completed_at: null,
|
|
402
|
+
});
|
|
403
|
+
const covered = inputs({
|
|
404
|
+
cells: [edited, k7f2],
|
|
405
|
+
executions: [execution(running), execution(e13)],
|
|
406
|
+
results: [e12, e13],
|
|
407
|
+
});
|
|
408
|
+
expect(freshnessOf(covered)("c3").sourceChanged).toBe(false);
|
|
409
|
+
|
|
410
|
+
const stale = inputs({
|
|
411
|
+
cells: [edited, k7f2],
|
|
412
|
+
executions: [
|
|
413
|
+
execution({ ...running, code_fingerprint: "older" }),
|
|
414
|
+
execution(e13),
|
|
415
|
+
],
|
|
416
|
+
results: [e12, e13],
|
|
417
|
+
});
|
|
418
|
+
expect(freshnessOf(stale)("c3").sourceChanged).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { act, renderHook } from "@testing-library/react";
|
|
4
|
+
import { createStore, Provider } from "jotai";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { MockExecutions } from "@/__mocks__/executions";
|
|
8
|
+
import { cellId } from "@/__tests__/branded";
|
|
9
|
+
import {
|
|
10
|
+
cell,
|
|
11
|
+
document as documentOf,
|
|
12
|
+
withCells,
|
|
13
|
+
} from "@/core/notebook/document/__tests__/fixtures";
|
|
14
|
+
import { useCellFreshness } from "../hooks";
|
|
15
|
+
|
|
16
|
+
const C1 = cellId("c1");
|
|
17
|
+
const C2 = cellId("c2");
|
|
18
|
+
|
|
19
|
+
/** A completed execution of `id` whose source matches the fixture document. */
|
|
20
|
+
function ran(id: string) {
|
|
21
|
+
return MockExecutions.completed(id, { code_fingerprint: `${id}-hash` });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function setup(records: ReturnType<typeof ran>[]) {
|
|
25
|
+
const store = createStore();
|
|
26
|
+
const document = documentOf();
|
|
27
|
+
const replica = MockExecutions.seed(store, records, null, document);
|
|
28
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
29
|
+
<Provider store={store}>{children}</Provider>
|
|
30
|
+
);
|
|
31
|
+
return { store, document, replica, wrapper };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("useCellFreshness", () => {
|
|
35
|
+
it("is current for a cell that never completed", () => {
|
|
36
|
+
const { wrapper } = setup([]);
|
|
37
|
+
const { result } = renderHook(() => useCellFreshness(C1), { wrapper });
|
|
38
|
+
expect(result.current).toEqual({
|
|
39
|
+
sourceChanged: false,
|
|
40
|
+
inputsChanged: false,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("is current until the confirmed document arrives", () => {
|
|
45
|
+
const store = createStore();
|
|
46
|
+
MockExecutions.seed(store, [ran(C1)], null, null);
|
|
47
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
48
|
+
<Provider store={store}>{children}</Provider>
|
|
49
|
+
);
|
|
50
|
+
const { result } = renderHook(() => useCellFreshness(C1), { wrapper });
|
|
51
|
+
expect(result.current).toEqual({
|
|
52
|
+
sourceChanged: false,
|
|
53
|
+
inputsChanged: false,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("is current while the result's fingerprint matches the cell's", () => {
|
|
58
|
+
const { wrapper } = setup([ran(C1)]);
|
|
59
|
+
const { result } = renderHook(() => useCellFreshness(C1), { wrapper });
|
|
60
|
+
expect(result.current).toEqual({
|
|
61
|
+
sourceChanged: false,
|
|
62
|
+
inputsChanged: false,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("reports changed source once the confirmed document carries it", () => {
|
|
67
|
+
const { store, document, replica, wrapper } = setup([ran(C1)]);
|
|
68
|
+
const { result } = renderHook(() => useCellFreshness(C1), { wrapper });
|
|
69
|
+
|
|
70
|
+
act(() => {
|
|
71
|
+
store.set(
|
|
72
|
+
replica.documentAtom,
|
|
73
|
+
withCells(document, {
|
|
74
|
+
[C1]: cell(C1, "x = 2", { code_fingerprint: "c1-hash-2" }),
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(result.current).toEqual({
|
|
80
|
+
sourceChanged: true,
|
|
81
|
+
inputsChanged: false,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("leaves the cell alone when another cell's result changes", () => {
|
|
86
|
+
const { store, replica, wrapper } = setup([ran(C1)]);
|
|
87
|
+
let renders = 0;
|
|
88
|
+
const { result } = renderHook(
|
|
89
|
+
() => {
|
|
90
|
+
renders += 1;
|
|
91
|
+
return useCellFreshness(C1);
|
|
92
|
+
},
|
|
93
|
+
{ wrapper },
|
|
94
|
+
);
|
|
95
|
+
const before = { value: result.current, renders };
|
|
96
|
+
|
|
97
|
+
act(() => {
|
|
98
|
+
const next = MockExecutions.inputs([ran(C1), ran(C2)]);
|
|
99
|
+
store.set(replica.executionsAtom, next.executions);
|
|
100
|
+
store.set(replica.resultsAtom, next.results);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(result.current).toBe(before.value);
|
|
104
|
+
expect(renders).toBe(before.renders);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
} from "@/core/notebook/replica";
|
|
13
13
|
import { activeNotebookReplicaAtom } from "@/core/session/active-notebook";
|
|
14
14
|
import { waitFor } from "@/core/state/jotai";
|
|
15
|
+
import { type CellFreshnessInputs, freshnessOf } from "./freshness";
|
|
15
16
|
import {
|
|
16
17
|
anyRunning,
|
|
17
18
|
type CellExecutionInputs,
|
|
@@ -27,6 +28,7 @@ const NO_EXECUTIONS: Executions = {
|
|
|
27
28
|
latest: new Map(),
|
|
28
29
|
};
|
|
29
30
|
const NO_RESULTS: Results = new Map();
|
|
31
|
+
const NO_CELLS: ReadonlyMap<Protocol.CellId, Protocol.Cell> = new Map();
|
|
30
32
|
const NO_SUBMISSIONS: ReadonlyMap<
|
|
31
33
|
Protocol.CellId,
|
|
32
34
|
Protocol.ExecutionId | null
|
|
@@ -107,6 +109,26 @@ export const cellExecutionInputsAtom = atom((get): CellExecutionInputs => {
|
|
|
107
109
|
};
|
|
108
110
|
});
|
|
109
111
|
|
|
112
|
+
const cellFreshnessInputsAtom = atom((get): CellFreshnessInputs => {
|
|
113
|
+
const replica = get(activeNotebookReplicaAtom);
|
|
114
|
+
const document = replica === null ? null : get(replica.documentAtom);
|
|
115
|
+
// Results can arrive before the confirmed document on a snapshot. Until
|
|
116
|
+
// the document is ready there is nothing to compare them against.
|
|
117
|
+
if (replica === null || document === null) {
|
|
118
|
+
return { cells: NO_CELLS, executions: NO_EXECUTIONS, results: NO_RESULTS };
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
cells: document.cells,
|
|
122
|
+
executions: get(replica.executionsAtom),
|
|
123
|
+
results: get(replica.resultsAtom),
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** Freshness by cell id, indexed once per change to its inputs. */
|
|
128
|
+
export const cellFreshnessAtom = atom((get) =>
|
|
129
|
+
freshnessOf(get(cellFreshnessInputsAtom)),
|
|
130
|
+
);
|
|
131
|
+
|
|
110
132
|
/** The runtime's status, null while the notebook has none or the stream is not ready. */
|
|
111
133
|
export const runtimeStatusAtom = atom((get) => {
|
|
112
134
|
const replica = get(activeNotebookReplicaAtom);
|