@marimo-team/frontend 0.19.7-dev35 → 0.19.7-dev37

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.
Files changed (84) hide show
  1. package/dist/assets/{JsonOutput-BGSqWZkD.js → JsonOutput-DtidtKaJ.js} +2 -2
  2. package/dist/assets/{MarimoErrorOutput-DFV7O8tN.js → MarimoErrorOutput-Cci2wITc.js} +1 -1
  3. package/dist/assets/{add-cell-with-ai-gDjJxct8.js → add-cell-with-ai-DT1ae2MK.js} +1 -1
  4. package/dist/assets/{add-database-form-CqEjMrq2.js → add-database-form-_pTLtiHN.js} +1 -1
  5. package/dist/assets/{agent-panel-D_hcgMlT.js → agent-panel-oLGqa4bG.js} +1 -1
  6. package/dist/assets/{ai-model-dropdown-C_l_zAXd.js → ai-model-dropdown-BL-PaF8o.js} +1 -1
  7. package/dist/assets/{app-config-button-CM05VD_Y.js → app-config-button-BiwYEPE8.js} +1 -1
  8. package/dist/assets/{cell-editor-ihhw9Ql3.js → cell-editor-B3U1SnYJ.js} +1 -1
  9. package/dist/assets/{chat-display-yGAtJXm0.js → chat-display-B34MaCGM.js} +1 -1
  10. package/dist/assets/{chat-panel-D3T1CjpZ.js → chat-panel-fuQFRvFm.js} +1 -1
  11. package/dist/assets/{column-preview-DbqM1diq.js → column-preview-B-dViv1i.js} +1 -1
  12. package/dist/assets/{command-palette-C2L80RlN.js → command-palette-KuNgJNix.js} +1 -1
  13. package/dist/assets/{common-D32S2RVD.js → common-DxKcMlJZ.js} +1 -1
  14. package/dist/assets/{dependency-graph-panel-C7ZXgECY.js → dependency-graph-panel-BZEIOxVz.js} +1 -1
  15. package/dist/assets/download-Bwa9P-Pz.js +6 -0
  16. package/dist/assets/{dropdown-menu-df9T83C0.js → dropdown-menu-B-6unW-7.js} +1 -1
  17. package/dist/assets/edit-page-DBFIML4p.js +13 -0
  18. package/dist/assets/{error-panel-DKIWwbhe.js → error-panel-DulelhA-.js} +1 -1
  19. package/dist/assets/{file-explorer-panel-D4VHkjMW.js → file-explorer-panel-Dn9tKw3E.js} +1 -1
  20. package/dist/assets/{form-XjMNGyzu.js → form-B1n-e_X0.js} +1 -1
  21. package/dist/assets/{glide-data-editor-BBSxoBI-.js → glide-data-editor-HGkaxqOo.js} +1 -1
  22. package/dist/assets/home-page--XVUAUCM.js +4 -0
  23. package/dist/assets/hooks-B1nUQK2T.js +1 -0
  24. package/dist/assets/{html-to-image-DKvXQkl5.js → html-to-image-Cu1p0tCK.js} +2 -2
  25. package/dist/assets/{index-DmMvDRRC.css → index-Bj5F80Z9.css} +1 -1
  26. package/dist/assets/{index-BRCNo5ma.js → index-DTpRqi46.js} +5 -5
  27. package/dist/assets/{layout-DiDoTAUA.js → layout-KY92f2Sm.js} +3 -3
  28. package/dist/assets/{markdown-renderer-xQJ1KM4c.js → markdown-renderer-Dpn5NCvn.js} +1 -1
  29. package/dist/assets/{packages-panel-D53RuG3X.js → packages-panel-CBc59eNR.js} +1 -1
  30. package/dist/assets/{panels-BhznEx5N.js → panels-B0B71dYl.js} +1 -1
  31. package/dist/assets/{popover-D16ZremR.js → popover-Gz-GJzym.js} +1 -1
  32. package/dist/assets/{readonly-python-code-kImQwJ5f.js → readonly-python-code-CCwpyiLX.js} +1 -1
  33. package/dist/assets/{renderShortcut-DHc-p-_c.js → renderShortcut-DEwfrKeS.js} +1 -1
  34. package/dist/assets/run-page-Dug0EU2T.js +1 -0
  35. package/dist/assets/{scratchpad-panel-C8wx6cRl.js → scratchpad-panel-SMFZ5eRQ.js} +1 -1
  36. package/dist/assets/{secrets-panel-Br6CcsOE.js → secrets-panel-BaEqnh6m.js} +1 -1
  37. package/dist/assets/{session-panel-JOOuJNOH.js → session-panel-CR_CZBSy.js} +1 -1
  38. package/dist/assets/table-C8uQmBAN.js +1 -0
  39. package/dist/assets/{terminal-DNwT6UrR.js → terminal-C7HXI-7B.js} +1 -1
  40. package/dist/assets/{tree-BdwmBGSx.js → tree-B1vM35Zj.js} +1 -1
  41. package/dist/assets/{useAddCell-BMDEXuVk.js → useAddCell-DRmuczCx.js} +1 -1
  42. package/dist/assets/{useCellActionButton-BDMlZzyv.js → useCellActionButton-DwRoApVS.js} +1 -1
  43. package/dist/assets/{useDeleteCell-BrMXAFkS.js → useDeleteCell-CR3IczUk.js} +1 -1
  44. package/dist/assets/{useDependencyPanelTab-B63bb4YX.js → useDependencyPanelTab-CLgnO1zH.js} +1 -1
  45. package/dist/assets/useNotebookActions-DhF-uJ0P.js +1 -0
  46. package/dist/assets/{useSplitCell-Cb0lf5MV.js → useSplitCell-IQsKBoRj.js} +1 -1
  47. package/dist/assets/{utilities.esm-BVFPJPyV.js → utilities.esm-DyYLtC1k.js} +2 -2
  48. package/dist/index.html +20 -20
  49. package/package.json +1 -1
  50. package/src/components/data-table/TableActions.tsx +5 -3
  51. package/src/components/data-table/download-actions.tsx +7 -2
  52. package/src/components/data-table/pagination.tsx +4 -4
  53. package/src/components/debug/indicator.tsx +1 -1
  54. package/src/components/editor/actions/useNotebookActions.tsx +4 -2
  55. package/src/components/editor/chrome/panels/context-aware-panel/context-aware-panel.tsx +1 -1
  56. package/src/components/editor/chrome/wrapper/app-chrome.tsx +6 -4
  57. package/src/components/editor/chrome/wrapper/footer-items/lsp-status.tsx +178 -0
  58. package/src/components/editor/chrome/wrapper/footer.tsx +1 -1
  59. package/src/components/editor/chrome/wrapper/sidebar.tsx +1 -1
  60. package/src/components/editor/controls/Controls.tsx +2 -2
  61. package/src/components/editor/controls/notebook-menu-dropdown.tsx +1 -1
  62. package/src/components/editor/file-tree/file-explorer.tsx +1 -1
  63. package/src/components/editor/header/status.tsx +1 -1
  64. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +13 -4
  65. package/src/components/home/components.tsx +1 -1
  66. package/src/components/static-html/static-banner.tsx +1 -1
  67. package/src/components/ui/dropdown-menu.tsx +1 -1
  68. package/src/components/ui/table.tsx +1 -1
  69. package/src/core/export/__tests__/hooks.test.ts +60 -58
  70. package/src/core/export/hooks.ts +71 -31
  71. package/src/core/network/types.ts +4 -0
  72. package/src/css/app/print.css +0 -14
  73. package/src/utils/__tests__/async-capture-tracker.test.ts +353 -0
  74. package/src/utils/__tests__/download.test.tsx +5 -114
  75. package/src/utils/async-capture-tracker.ts +168 -0
  76. package/src/utils/download.ts +17 -57
  77. package/src/utils/html-to-image.ts +9 -12
  78. package/dist/assets/download-CzPV-R6Z.js +0 -6
  79. package/dist/assets/edit-page-DFpXAt-U.js +0 -12
  80. package/dist/assets/home-page-DBWXVxWa.js +0 -4
  81. package/dist/assets/hooks-CpjzmBkw.js +0 -1
  82. package/dist/assets/run-page-BCOAGhl5.js +0 -1
  83. package/dist/assets/table-BSASHvkq.js +0 -1
  84. package/dist/assets/useNotebookActions-Cj4FtiIb.js +0 -1
@@ -1,9 +1,10 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import { atom, useAtom, useAtomValue } from "jotai";
2
+ import { useAtomValue } from "jotai";
3
3
  import type { MimeType } from "@/components/editor/Output";
4
4
  import { toast } from "@/components/ui/use-toast";
5
5
  import { appConfigAtom } from "@/core/config/config";
6
6
  import { useInterval } from "@/hooks/useInterval";
7
+ import { AsyncCaptureTracker } from "@/utils/async-capture-tracker";
7
8
  import { getImageDataUrlForCell } from "@/utils/download";
8
9
  import { Logger } from "@/utils/Logger";
9
10
  import { Objects } from "@/utils/objects";
@@ -67,7 +68,6 @@ export function useAutoExport() {
67
68
  const screenshotFn = () =>
68
69
  takeScreenshots({
69
70
  progress: ProgressState.indeterminate(),
70
- snappy: true,
71
71
  });
72
72
  await updateCellOutputsWithScreenshots({
73
73
  takeScreenshots: screenshotFn,
@@ -89,9 +89,6 @@ export function useAutoExport() {
89
89
  );
90
90
  }
91
91
 
92
- // We track cells that need screenshots, these will be exported to IPYNB
93
- const richCellsToOutputAtom = atom<Record<CellId, unknown>>({});
94
-
95
92
  // MIME types to capture screenshots for
96
93
  const MIME_TYPES_TO_CAPTURE_SCREENSHOTS = new Set<MimeType>([
97
94
  "text/html",
@@ -101,66 +98,109 @@ const MIME_TYPES_TO_CAPTURE_SCREENSHOTS = new Set<MimeType>([
101
98
  "application/vnd.vega.v6+json",
102
99
  ]);
103
100
 
104
- type ScreenshotResults = Record<CellId, ["image/png", string]>;
101
+ type ScreenshotResult = ["image/png", string];
102
+ type ScreenshotResults = Record<CellId, ScreenshotResult>;
103
+
104
+ // Only marks cells as captured after successful screenshot.
105
+ export const captureTracker = new AsyncCaptureTracker<
106
+ CellId,
107
+ ScreenshotResult
108
+ >();
109
+
110
+ interface UseEnrichCellOutputsOptions {
111
+ progress: ProgressState;
112
+ }
105
113
 
106
114
  /**
107
115
  * Take screenshots of cells with HTML outputs. These images will be sent to the backend to be exported to IPYNB.
108
116
  * @returns A map of cell IDs to their screenshots data.
109
117
  */
110
- export function useEnrichCellOutputs() {
111
- const [richCellsOutput, setRichCellsOutput] = useAtom(richCellsToOutputAtom);
118
+ export function useEnrichCellOutputs(): (
119
+ opts: UseEnrichCellOutputsOptions,
120
+ ) => Promise<ScreenshotResults> {
112
121
  const cellRuntimes = useAtomValue(cellsRuntimeAtom);
113
122
 
114
- return async ({
115
- progress,
116
- snappy,
117
- }: {
118
- progress: ProgressState;
119
- snappy: boolean;
120
- }): Promise<ScreenshotResults> => {
121
- const trackedCellsOutput: Record<CellId, unknown> = {};
123
+ return async (
124
+ opts: UseEnrichCellOutputsOptions,
125
+ ): Promise<ScreenshotResults> => {
126
+ const { progress } = opts;
127
+
128
+ // Prune tracked state for cells that no longer exist
129
+ const currentCellIds = new Set(Objects.keys(cellRuntimes));
130
+ captureTracker.prune(currentCellIds);
122
131
 
123
132
  const cellsToCaptureScreenshot: [CellId, unknown][] = [];
133
+ const inFlightWaiters: {
134
+ cellId: CellId;
135
+ promise: Promise<ScreenshotResult | undefined>;
136
+ }[] = [];
137
+
124
138
  for (const [cellId, runtime] of Objects.entries(cellRuntimes)) {
125
139
  const outputData = runtime.output?.data;
126
- const outputHasChanged = richCellsOutput[cellId] !== outputData;
127
- // Track latest output for this cell
128
- trackedCellsOutput[cellId] = outputData;
129
140
  if (
130
141
  runtime.output?.mimetype &&
131
142
  MIME_TYPES_TO_CAPTURE_SCREENSHOTS.has(runtime.output.mimetype) &&
132
- outputData &&
133
- outputHasChanged
143
+ outputData
134
144
  ) {
135
- cellsToCaptureScreenshot.push([cellId, runtime]);
145
+ if (captureTracker.needsCapture(cellId, outputData)) {
146
+ cellsToCaptureScreenshot.push([cellId, outputData]);
147
+ } else {
148
+ // If already in-flight with the same value, await its result
149
+ const promise = captureTracker.waitForInFlight(cellId, outputData);
150
+ if (promise) {
151
+ inFlightWaiters.push({ cellId, promise });
152
+ }
153
+ }
136
154
  }
137
155
  }
138
- // Always update tracked outputs, this ensures data is fresh for the next run
139
- setRichCellsOutput(trackedCellsOutput);
140
156
 
141
- if (cellsToCaptureScreenshot.length === 0) {
157
+ if (cellsToCaptureScreenshot.length === 0 && inFlightWaiters.length === 0) {
142
158
  return {};
143
159
  }
144
160
 
145
- // Capture screenshots
146
- const total = cellsToCaptureScreenshot.length;
147
- progress.addTotal(total);
161
+ // Start the progress bar for new captures only
162
+ if (cellsToCaptureScreenshot.length > 0) {
163
+ progress.addTotal(cellsToCaptureScreenshot.length);
164
+ }
165
+
166
+ // Capture screenshots — each key gets its own AbortSignal so
167
+ // aborting one cell does not affect the others.
148
168
  const results: ScreenshotResults = {};
149
- for (const [cellId] of cellsToCaptureScreenshot) {
169
+ for (const [cellId, outputData] of cellsToCaptureScreenshot) {
170
+ const handle = captureTracker.startCapture(cellId, outputData);
150
171
  try {
151
- const dataUrl = await getImageDataUrlForCell(cellId, snappy);
172
+ const dataUrl = await getImageDataUrlForCell(cellId);
173
+ if (handle.signal.aborted) {
174
+ continue;
175
+ }
152
176
  if (!dataUrl) {
153
177
  Logger.error(`Failed to capture screenshot for cell ${cellId}`);
178
+ handle.markFailed();
154
179
  continue;
155
180
  }
156
- results[cellId] = ["image/png", dataUrl];
181
+ const result: ScreenshotResult = ["image/png", dataUrl];
182
+ results[cellId] = result;
183
+ handle.markCaptured(result);
157
184
  } catch (error) {
158
185
  Logger.error(`Error screenshotting cell ${cellId}:`, error);
186
+ handle.markFailed();
159
187
  } finally {
160
188
  progress.increment(1);
161
189
  }
162
190
  }
163
191
 
192
+ // Await in-flight captures started by concurrent callers
193
+ const settled = await Promise.allSettled(
194
+ inFlightWaiters.map(({ promise }) => promise),
195
+ );
196
+ for (const [i, { cellId }] of inFlightWaiters.entries()) {
197
+ const result =
198
+ settled[i].status === "fulfilled" ? settled[i].value : undefined;
199
+ if (result) {
200
+ results[cellId] = result;
201
+ }
202
+ }
203
+
164
204
  return results;
165
205
  };
166
206
  }
@@ -100,6 +100,10 @@ export type InvokeAiToolRequest = schemas["InvokeAiToolRequest"];
100
100
  export type InvokeAiToolResponse = schemas["InvokeAiToolResponse"];
101
101
  export type ClearCacheRequest = schemas["ClearCacheRequest"];
102
102
  export type GetCacheInfoRequest = schemas["GetCacheInfoRequest"];
103
+ export type LspHealthResponse = schemas["LspHealthResponse"];
104
+ export type LspRestartRequest = schemas["LspRestartRequest"];
105
+ export type LspRestartResponse = schemas["LspRestartResponse"];
106
+ export type LspServerHealth = schemas["LspServerHealth"];
103
107
 
104
108
  /**
105
109
  * Requests sent to the BE during run/edit mode.
@@ -1,24 +1,10 @@
1
1
  @reference "../globals.css";
2
2
 
3
- /* Hide on print
4
- To hide an element on print, add the class `no-print` to it.
5
- When printing, the class `printing` is added to the body element to enable this rule. */
6
- body.printing .no-print {
7
- display: none !important;
8
- }
9
-
10
3
  body.printing #App {
11
4
  /* Full screen print */
12
5
  height: fit-content !important;
13
6
  }
14
7
 
15
- /* When printing the output of a cell, this unset the max-height set by the notebook to capture the full output */
16
- .printing-output {
17
- max-height: none !important;
18
-
19
- @apply bg-background;
20
- }
21
-
22
8
  @media print {
23
9
  * {
24
10
  -webkit-print-color-adjust: exact;
@@ -0,0 +1,353 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { beforeEach, describe, expect, it } from "vitest";
4
+ import { AsyncCaptureTracker } from "../async-capture-tracker";
5
+
6
+ describe("AsyncCaptureTracker", () => {
7
+ let tracker: AsyncCaptureTracker<string, string>;
8
+
9
+ beforeEach(() => {
10
+ tracker = new AsyncCaptureTracker();
11
+ });
12
+
13
+ describe("needsCapture", () => {
14
+ it("should return true for keys not yet captured", () => {
15
+ expect(tracker.needsCapture("a", "value1")).toBe(true);
16
+ });
17
+
18
+ it("should return false for keys captured with the same value", () => {
19
+ const handle = tracker.startCapture("a", "value1");
20
+ handle.markCaptured("result");
21
+ expect(tracker.needsCapture("a", "value1")).toBe(false);
22
+ });
23
+
24
+ it("should return true for keys captured with a different value", () => {
25
+ const handle = tracker.startCapture("a", "value1");
26
+ handle.markCaptured("result");
27
+ expect(tracker.needsCapture("a", "value2")).toBe(true);
28
+ });
29
+
30
+ it("should return false for keys in-flight with the same value", () => {
31
+ tracker.startCapture("a", "value1");
32
+ expect(tracker.needsCapture("a", "value1")).toBe(false);
33
+ });
34
+
35
+ it("should return true for keys in-flight with a different value", () => {
36
+ tracker.startCapture("a", "value1");
37
+ expect(tracker.needsCapture("a", "value2")).toBe(true);
38
+ });
39
+
40
+ it("should return true for keys that failed (returned to idle)", () => {
41
+ const handle = tracker.startCapture("a", "value1");
42
+ handle.markFailed();
43
+ expect(tracker.needsCapture("a", "value1")).toBe(true);
44
+ });
45
+ });
46
+
47
+ describe("startCapture", () => {
48
+ it("should return a handle with a non-aborted signal", () => {
49
+ const handle = tracker.startCapture("a", "v");
50
+ expect(handle.signal.aborted).toBe(false);
51
+ });
52
+
53
+ it("should abort only the same key's previous signal", () => {
54
+ const handleA = tracker.startCapture("a", "v1");
55
+ const handleB = tracker.startCapture("b", "v1");
56
+ const handleA2 = tracker.startCapture("a", "v2");
57
+
58
+ expect(handleA.signal.aborted).toBe(true);
59
+ expect(handleB.signal.aborted).toBe(false);
60
+ expect(handleA2.signal.aborted).toBe(false);
61
+
62
+ // Unrelated key still completes independently
63
+ handleB.markCaptured("result-b");
64
+ expect(tracker.needsCapture("b", "v1")).toBe(false);
65
+ });
66
+
67
+ it("should not abort other keys when one is re-captured", () => {
68
+ tracker.startCapture("a", "v");
69
+ tracker.startCapture("b", "v");
70
+ tracker.startCapture("c", "v");
71
+
72
+ const handleB2 = tracker.startCapture("b", "v2");
73
+
74
+ expect(tracker.needsCapture("a", "v")).toBe(false); // still in-flight
75
+ expect(tracker.needsCapture("c", "v")).toBe(false); // still in-flight
76
+ expect(handleB2.signal.aborted).toBe(false);
77
+ });
78
+
79
+ it("should mark key as in-flight", () => {
80
+ tracker.startCapture("a", "v");
81
+ expect(tracker.isCapturing).toBe(true);
82
+ expect(tracker.needsCapture("a", "v")).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe("handle.markCaptured", () => {
87
+ it("should remove key from in-flight", () => {
88
+ const handle = tracker.startCapture("a", "v");
89
+ expect(tracker.isCapturing).toBe(true);
90
+ handle.markCaptured("result");
91
+ expect(tracker.isCapturing).toBe(false);
92
+ });
93
+
94
+ it("should persist the captured value", () => {
95
+ const handle = tracker.startCapture("a", "value1");
96
+ handle.markCaptured("result");
97
+ expect(tracker.needsCapture("a", "value1")).toBe(false);
98
+ expect(tracker.needsCapture("a", "value2")).toBe(true);
99
+ });
100
+
101
+ it("should update the captured value on re-capture", () => {
102
+ const h1 = tracker.startCapture("a", "v1");
103
+ h1.markCaptured("r1");
104
+
105
+ const h2 = tracker.startCapture("a", "v2");
106
+ h2.markCaptured("r2");
107
+
108
+ expect(tracker.needsCapture("a", "v1")).toBe(true);
109
+ expect(tracker.needsCapture("a", "v2")).toBe(false);
110
+ });
111
+
112
+ it("should be a no-op when handle is stale (superseded)", () => {
113
+ const handle1 = tracker.startCapture("a", "v1");
114
+ const handle2 = tracker.startCapture("a", "v2");
115
+
116
+ // handle1 is stale — markCaptured should not affect state
117
+ handle1.markCaptured("stale-result");
118
+
119
+ // "a" should still be in-flight with v2
120
+ expect(tracker.needsCapture("a", "v2")).toBe(false); // in-flight
121
+ expect(tracker.needsCapture("a", "v1")).toBe(true); // not captured
122
+ expect(tracker.isCapturing).toBe(true);
123
+
124
+ // handle2 still works
125
+ handle2.markCaptured("real-result");
126
+ expect(tracker.needsCapture("a", "v2")).toBe(false); // captured
127
+ expect(tracker.isCapturing).toBe(false);
128
+ });
129
+ });
130
+
131
+ describe("handle.markFailed", () => {
132
+ it("should remove key from in-flight", () => {
133
+ const handle = tracker.startCapture("a", "v");
134
+ handle.markFailed();
135
+ expect(tracker.isCapturing).toBe(false);
136
+ });
137
+
138
+ it("should not add a captured entry", () => {
139
+ const handle = tracker.startCapture("a", "v");
140
+ handle.markFailed();
141
+ expect(tracker.needsCapture("a", "v")).toBe(true);
142
+ });
143
+
144
+ it("should not clear a previously captured value", () => {
145
+ const h1 = tracker.startCapture("a", "v1");
146
+ h1.markCaptured("r1");
147
+
148
+ const h2 = tracker.startCapture("a", "v2");
149
+ h2.markFailed();
150
+
151
+ expect(tracker.needsCapture("a", "v1")).toBe(false);
152
+ expect(tracker.needsCapture("a", "v2")).toBe(true);
153
+ });
154
+
155
+ it("should be a no-op when handle is stale", () => {
156
+ const handle1 = tracker.startCapture("a", "v1");
157
+ tracker.startCapture("a", "v2");
158
+
159
+ handle1.markFailed();
160
+
161
+ // "a" should still be in-flight with v2
162
+ expect(tracker.isCapturing).toBe(true);
163
+ expect(tracker.needsCapture("a", "v2")).toBe(false);
164
+ });
165
+ });
166
+
167
+ describe("waitForInFlight", () => {
168
+ it("should return a promise for in-flight key with same value", async () => {
169
+ const handle = tracker.startCapture("a", "v1");
170
+ const promise = tracker.waitForInFlight("a", "v1");
171
+ expect(promise).not.toBeNull();
172
+
173
+ handle.markCaptured("result-a");
174
+ const result = await promise;
175
+ expect(result).toBe("result-a");
176
+ });
177
+
178
+ it("should return null for captured key (not in-flight)", () => {
179
+ const handle = tracker.startCapture("a", "v1");
180
+ handle.markCaptured("result");
181
+ expect(tracker.waitForInFlight("a", "v1")).toBeNull();
182
+ });
183
+
184
+ it("should return null for key not being tracked", () => {
185
+ expect(tracker.waitForInFlight("a", "v1")).toBeNull();
186
+ });
187
+
188
+ it("should return null for in-flight key with different value", () => {
189
+ tracker.startCapture("a", "v1");
190
+ expect(tracker.waitForInFlight("a", "v2")).toBeNull();
191
+ });
192
+
193
+ it("should resolve with undefined when capture fails", async () => {
194
+ const handle = tracker.startCapture("a", "v1");
195
+ const promise = tracker.waitForInFlight("a", "v1");
196
+
197
+ handle.markFailed();
198
+ const result = await promise;
199
+ expect(result).toBeUndefined();
200
+ });
201
+
202
+ it("should resolve with undefined when capture is superseded", async () => {
203
+ tracker.startCapture("a", "v1");
204
+ const promise = tracker.waitForInFlight("a", "v1");
205
+
206
+ // Supersede with new capture
207
+ tracker.startCapture("a", "v2");
208
+ const result = await promise;
209
+ expect(result).toBeUndefined();
210
+ });
211
+
212
+ it("should resolve with undefined when aborted", async () => {
213
+ tracker.startCapture("a", "v1");
214
+ const promise = tracker.waitForInFlight("a", "v1");
215
+
216
+ tracker.abort();
217
+ const result = await promise;
218
+ expect(result).toBeUndefined();
219
+ });
220
+
221
+ it("should allow multiple waiters on the same key", async () => {
222
+ const handle = tracker.startCapture("a", "v1");
223
+ const p1 = tracker.waitForInFlight("a", "v1");
224
+ const p2 = tracker.waitForInFlight("a", "v1");
225
+
226
+ handle.markCaptured("shared-result");
227
+
228
+ const [r1, r2] = await Promise.all([p1, p2]);
229
+ expect(r1).toBe("shared-result");
230
+ expect(r2).toBe("shared-result");
231
+ });
232
+ });
233
+
234
+ describe("prune", () => {
235
+ it("should remove captured entries for keys not in the set", () => {
236
+ const hA = tracker.startCapture("a", "v1");
237
+ hA.markCaptured("r1");
238
+ const hB = tracker.startCapture("b", "v2");
239
+ hB.markCaptured("r2");
240
+
241
+ tracker.prune(new Set(["a"]));
242
+
243
+ expect(tracker.needsCapture("a", "v1")).toBe(false);
244
+ expect(tracker.needsCapture("b", "v2")).toBe(true);
245
+ });
246
+
247
+ it("should abort and remove in-flight entries for pruned keys", () => {
248
+ const handleA = tracker.startCapture("a", "v");
249
+ const handleB = tracker.startCapture("b", "v");
250
+
251
+ tracker.prune(new Set(["a"]));
252
+
253
+ expect(handleA.signal.aborted).toBe(false);
254
+ expect(handleB.signal.aborted).toBe(true);
255
+ expect(tracker.needsCapture("b", "v")).toBe(true);
256
+ });
257
+
258
+ it("should resolve pruned in-flight deferreds with undefined", async () => {
259
+ tracker.startCapture("b", "v");
260
+ const promise = tracker.waitForInFlight("b", "v");
261
+
262
+ tracker.prune(new Set(["a"]));
263
+ const result = await promise;
264
+ expect(result).toBeUndefined();
265
+ });
266
+
267
+ it("should do nothing if all keys are current", () => {
268
+ const hA = tracker.startCapture("a", "v1");
269
+ hA.markCaptured("r1");
270
+ const hB = tracker.startCapture("b", "v2");
271
+ hB.markCaptured("r2");
272
+
273
+ tracker.prune(new Set(["a", "b"]));
274
+
275
+ expect(tracker.needsCapture("a", "v1")).toBe(false);
276
+ expect(tracker.needsCapture("b", "v2")).toBe(false);
277
+ });
278
+ });
279
+
280
+ describe("abort", () => {
281
+ it("should abort all in-flight signals", () => {
282
+ const handleA = tracker.startCapture("a", "v");
283
+ const handleB = tracker.startCapture("b", "v");
284
+ tracker.abort();
285
+ expect(handleA.signal.aborted).toBe(true);
286
+ expect(handleB.signal.aborted).toBe(true);
287
+ });
288
+
289
+ it("should clear in-flight state", () => {
290
+ tracker.startCapture("a", "v");
291
+ tracker.abort();
292
+ expect(tracker.isCapturing).toBe(false);
293
+ expect(tracker.needsCapture("a", "v")).toBe(true);
294
+ });
295
+
296
+ it("should not clear captured state", () => {
297
+ const handle = tracker.startCapture("a", "v1");
298
+ handle.markCaptured("result");
299
+ tracker.abort();
300
+ expect(tracker.needsCapture("a", "v1")).toBe(false);
301
+ });
302
+ });
303
+
304
+ describe("reset", () => {
305
+ it("should clear all state", () => {
306
+ const hA = tracker.startCapture("a", "v1");
307
+ hA.markCaptured("r1");
308
+ tracker.startCapture("b", "v2");
309
+
310
+ tracker.reset();
311
+
312
+ expect(tracker.isCapturing).toBe(false);
313
+ expect(tracker.needsCapture("a", "v1")).toBe(true);
314
+ expect(tracker.needsCapture("b", "v2")).toBe(true);
315
+ });
316
+ });
317
+
318
+ describe("isCapturing", () => {
319
+ it("should be false when nothing is in-flight", () => {
320
+ expect(tracker.isCapturing).toBe(false);
321
+ });
322
+
323
+ it("should be true when items are in-flight", () => {
324
+ tracker.startCapture("a", "v");
325
+ expect(tracker.isCapturing).toBe(true);
326
+ });
327
+
328
+ it("should be false when all items are completed", () => {
329
+ const hA = tracker.startCapture("a", "v");
330
+ const hB = tracker.startCapture("b", "v");
331
+ hA.markCaptured("r");
332
+ hB.markFailed();
333
+ expect(tracker.isCapturing).toBe(false);
334
+ });
335
+ });
336
+
337
+ describe("granular abort", () => {
338
+ it("should allow independent completion of unrelated keys", () => {
339
+ const hA = tracker.startCapture("a", "v1");
340
+ tracker.startCapture("b", "v1");
341
+ const hC = tracker.startCapture("c", "v1");
342
+
343
+ hA.markCaptured("ra");
344
+ tracker.startCapture("b", "v2"); // abort old "b", start new
345
+ hC.markCaptured("rc");
346
+
347
+ expect(tracker.needsCapture("a", "v1")).toBe(false);
348
+ expect(tracker.needsCapture("c", "v1")).toBe(false);
349
+ expect(tracker.needsCapture("b", "v2")).toBe(false); // in-flight
350
+ expect(tracker.needsCapture("b", "v1")).toBe(true);
351
+ });
352
+ });
353
+ });