@marimo-team/islands 0.25.3-dev169 → 0.25.3-dev170

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 (123) hide show
  1. package/dist/{ConnectedDataExplorerComponent-BbDrlHvG.js → ConnectedDataExplorerComponent-BbSXV-Xa.js} +8 -8
  2. package/dist/{ErrorBoundary-RaIwGWT3.js → ErrorBoundary-NE86qEWZ.js} +4 -4
  3. package/dist/{abnfDiagram-VCTEODGH-Dr-fo8_z.js → abnfDiagram-VCTEODGH-D7n07nXf.js} +2 -2
  4. package/dist/{architectureDiagram-5GKGNRK7-ByKIl9GG.js → architectureDiagram-5GKGNRK7-D6CjRib5.js} +1 -1
  5. package/dist/{chat-ui-BsV2WH-4.js → chat-ui-CC7GekpG.js} +34 -34
  6. package/dist/{chunk-IMKFNOWR-IKDP18MQ.js → chunk-IMKFNOWR-6dtPz7uS.js} +1 -1
  7. package/dist/{chunk-SHT3W25Y-LkIgegwh.js → chunk-SHT3W25Y-BwnFNKDA.js} +1 -1
  8. package/dist/{chunk-SVP7TREG-DReBYtnB.js → chunk-SVP7TREG-D2eV4vBJ.js} +1 -1
  9. package/dist/{chunk-TICWLB2K-DQ0A0-1D.js → chunk-TICWLB2K-BDs4-_-r.js} +1 -1
  10. package/dist/{classDiagram-ZZMXUADV-CwvXdCNE.js → classDiagram-ZZMXUADV-DCJnf_8v.js} +2 -2
  11. package/dist/{classDiagram-v2-VYDZK3BY-D0XAxDsc.js → classDiagram-v2-VYDZK3BY-BOmSzPBD.js} +2 -2
  12. package/dist/{code-visibility-BMi0KLAQ.js → code-visibility-Bzvc6ajl.js} +23 -23
  13. package/dist/{common-BMkJrZsD.js → common-Ciw4zkh2.js} +3412 -3412
  14. package/dist/{cynefinDiagram-5FMLGOSQ-BFN68nHr.js → cynefinDiagram-5FMLGOSQ-Bln_Soky.js} +1 -1
  15. package/dist/{dagre-GXQ25YYZ-CZt4rtLq.js → dagre-GXQ25YYZ-D-nf7WzW.js} +1 -1
  16. package/dist/{data-grid-overlay-editor-CCc1P42P.js → data-grid-overlay-editor-AQREmxI7.js} +1 -1
  17. package/dist/{diagram-S7CK7UJ4-D5cOz47_.js → diagram-S7CK7UJ4-PsgSMkY2.js} +1 -1
  18. package/dist/{diagram-UQ7AKVKN-Qgdppb4a.js → diagram-UQ7AKVKN-DfwfUuSw.js} +1 -1
  19. package/dist/{diagram-VX7I27RA-BsDCvily.js → diagram-VX7I27RA-C9_e-oMc.js} +1 -1
  20. package/dist/{diagram-Z3DM3KII-igTkGjKI.js → diagram-Z3DM3KII-CWLa5tuY.js} +1 -1
  21. package/dist/{dropdown-menu-BWnnAstC.js → dropdown-menu-B6ftyzOg.js} +1 -1
  22. package/dist/{ebnfDiagram-PWID7BFC-BHuZGjIr.js → ebnfDiagram-PWID7BFC-B1QRM58v.js} +2 -2
  23. package/dist/{erDiagram-RLTQ6QDP-DKObXTVE.js → erDiagram-RLTQ6QDP-DJK_FqvW.js} +1 -1
  24. package/dist/{error-entries-BpUJSLUZ.js → error-entries-DgK3wUJR.js} +5640 -5591
  25. package/dist/{feedback-button-CMVkEos_.js → feedback-button-8a6SETmT.js} +24 -24
  26. package/dist/{flowDiagram-HODETNUW-DZI_kit0.js → flowDiagram-HODETNUW-Uxc25XSJ.js} +2 -2
  27. package/dist/{formats-CwJHWasD.js → formats-DQCyPd5Z.js} +1 -1
  28. package/dist/{glide-data-editor-BA2pcHSV.js → glide-data-editor-B70oke_v.js} +5 -5
  29. package/dist/{globals-B5jjJ0ms.js → globals-Cxd5hNlI.js} +2 -2
  30. package/dist/{html-to-image-CQr2-IDA.js → html-to-image-D4yG5Ru0.js} +25 -25
  31. package/dist/{infoDiagram-27XIBGKW-DdPl-7-n.js → infoDiagram-27XIBGKW-DmjxqwL0.js} +1 -1
  32. package/dist/{ishikawaDiagram-5VMMS53U-DbyW6l59.js → ishikawaDiagram-5VMMS53U-NgCLHqQi.js} +1 -1
  33. package/dist/{kanban-definition-UXKFOSKX-BMX9SyBS.js → kanban-definition-UXKFOSKX-CZaG-iam.js} +1 -1
  34. package/dist/main.js +187 -188
  35. package/dist/{mermaid-C8W8mTbA.js → mermaid-CSvw7c71.js} +34 -34
  36. package/dist/{mindmap-definition-YA3MSWOX-Zd_tAxWL.js → mindmap-definition-YA3MSWOX-DJYQBDH_.js} +1 -1
  37. package/dist/{number-overlay-editor-BIWiV-TU.js → number-overlay-editor-ChK-SKUk.js} +1 -1
  38. package/dist/{pegDiagram-XKGWAZYB-DjYydPdo.js → pegDiagram-XKGWAZYB-CHKO_dAe.js} +2 -2
  39. package/dist/{pieDiagram-E7YTZNPT-R5LKjO2n.js → pieDiagram-E7YTZNPT-DVevbMAo.js} +1 -1
  40. package/dist/{process-output-Uw-VJE5V.js → process-output-DxvBAeL3.js} +3 -3
  41. package/dist/{railroadDiagram-O6MQD6OU-C7uNKtfm.js → railroadDiagram-O6MQD6OU-Detj1kcB.js} +2 -2
  42. package/dist/{requirementDiagram-BXWQKSXE-CxAIzQfl.js → requirementDiagram-BXWQKSXE-BLFD0T_Z.js} +1 -1
  43. package/dist/{reveal-component-XiecZ3Ua.js → reveal-component-CXMXeDZD.js} +6 -6
  44. package/dist/{sequenceDiagram-WJ2MYXX4-B5tmlPd5.js → sequenceDiagram-WJ2MYXX4-BZBgatQV.js} +1 -1
  45. package/dist/{spec-45FnujvG.js → spec-Mnkn3XEr.js} +1 -1
  46. package/dist/{stateDiagram-D77RDMKH-C1hc5H7Y.js → stateDiagram-D77RDMKH-4BZW9-6_.js} +2 -2
  47. package/dist/{stateDiagram-v2-MP3YSRHH-DRWeqQTn.js → stateDiagram-v2-MP3YSRHH-C4sKC8Iq.js} +2 -2
  48. package/dist/style.css +1 -1
  49. package/dist/{swimlanes-42K2YHIH-NTLas--s.js → swimlanes-42K2YHIH-SL5yeGe2.js} +1 -1
  50. package/dist/{swimlanesDiagram-VR7AAH4N-ChPggljD.js → swimlanesDiagram-VR7AAH4N-DqPmAYUv.js} +2 -2
  51. package/dist/{timeline-definition-24CTP7MA-B4CZ9srJ.js → timeline-definition-24CTP7MA-BfXUMWkq.js} +1 -1
  52. package/dist/{toDate-rySyYxqk.js → toDate-TMubKIVL.js} +22 -22
  53. package/dist/{useAsyncData-B7MiltyV.js → useAsyncData-DjMGFzFy.js} +1 -1
  54. package/dist/{useDeepCompareMemoize-DZmHBD_1.js → useDeepCompareMemoize-BQR5TJot.js} +1 -1
  55. package/dist/{useLifecycle-DRbRhzxg.js → useLifecycle-C_GvKWbQ.js} +26 -23
  56. package/dist/{useTheme-TLkqJsvo.js → useTheme-Bns2gJZ6.js} +179 -178
  57. package/dist/{vega-component-DC6P127L.js → vega-component-BsVptuhz.js} +5 -5
  58. package/dist/{vennDiagram-4TSXK5OY-DJIvMt44.js → vennDiagram-4TSXK5OY-CH65znGa.js} +1 -1
  59. package/dist/{wardleyDiagram-VM6X3IG4-45H0zg0u.js → wardleyDiagram-VM6X3IG4-z9pWKb2_.js} +1 -1
  60. package/dist/{xychartDiagram-S5SC5T6Z-ZT-XzcPN.js → xychartDiagram-S5SC5T6Z-MxyaWjjq.js} +1 -1
  61. package/package.json +1 -1
  62. package/src/__mocks__/requests.ts +0 -1
  63. package/src/components/editor/cell/StagedAICell.tsx +10 -2
  64. package/src/components/editor/cell/code/cell-editor.tsx +7 -14
  65. package/src/components/editor/chrome/wrapper/__tests__/document-sync-status.test.tsx +92 -0
  66. package/src/components/editor/chrome/wrapper/footer-items/document-sync-status.tsx +54 -0
  67. package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
  68. package/src/core/ai/__tests__/staged-cells.test.ts +8 -5
  69. package/src/core/ai/staged-cells.ts +7 -2
  70. package/src/core/ai/tools/__tests__/edit-notebook-tool.test.ts +3 -15
  71. package/src/core/ai/tools/edit-notebook-tool.ts +14 -3
  72. package/src/core/cells/__tests__/cells.test.ts +0 -297
  73. package/src/core/cells/__tests__/document.test.ts +410 -0
  74. package/src/core/cells/__tests__/flush-document.test.ts +40 -0
  75. package/src/core/cells/__tests__/notebook-outline.test.ts +1 -6
  76. package/src/core/cells/__tests__/with-document-save.test.ts +67 -0
  77. package/src/core/cells/cells.ts +27 -158
  78. package/src/core/cells/columns.ts +38 -0
  79. package/src/core/cells/document.ts +360 -0
  80. package/src/core/cells/types.ts +12 -0
  81. package/src/core/codemirror/cells/__tests__/extensions.test.ts +27 -1
  82. package/src/core/codemirror/cells/extensions.ts +14 -0
  83. package/src/core/codemirror/format.ts +6 -5
  84. package/src/core/codemirror/language/__tests__/utils.test.ts +187 -15
  85. package/src/core/codemirror/language/cell-code.ts +114 -0
  86. package/src/core/codemirror/language/extension.ts +47 -30
  87. package/src/core/codemirror/language/languages/sql/sql.ts +7 -17
  88. package/src/core/codemirror/language/utils.ts +9 -39
  89. package/src/core/codemirror/lsp/notebook-lsp.ts +6 -5
  90. package/src/core/codemirror/rtc/extension.ts +0 -1
  91. package/src/core/edit-app.tsx +4 -3
  92. package/src/core/export/layout.ts +2 -2
  93. package/src/core/islands/bridge.ts +0 -1
  94. package/src/core/network/requests-lazy.ts +0 -1
  95. package/src/core/network/requests-network.ts +0 -9
  96. package/src/core/network/requests-static.ts +0 -1
  97. package/src/core/network/requests-toasting.tsx +0 -1
  98. package/src/core/network/types.ts +0 -5
  99. package/src/core/notebook/document/__tests__/codemirror.test.ts +76 -14
  100. package/src/core/notebook/document/__tests__/hold.test.ts +88 -0
  101. package/src/core/notebook/document/__tests__/hooks.test.tsx +3 -3
  102. package/src/core/notebook/document/__tests__/lifecycle-scenarios.test.ts +3 -3
  103. package/src/core/notebook/document/__tests__/model.test.ts +71 -15
  104. package/src/core/notebook/document/__tests__/scenario-fixture.ts +1 -1
  105. package/src/core/notebook/document/codemirror.ts +27 -122
  106. package/src/core/notebook/document/hooks.tsx +2 -2
  107. package/src/core/notebook/document/index.ts +9 -1
  108. package/src/core/notebook/document/model.ts +74 -24
  109. package/src/core/notebook/document/sync.ts +53 -4
  110. package/src/core/run-app.tsx +44 -41
  111. package/src/core/saving/save-component.tsx +2 -5
  112. package/src/core/session/__tests__/useNotebookSession.hook.test.tsx +63 -3
  113. package/src/core/session/active-notebook.ts +4 -0
  114. package/src/core/session/useNotebookSession.tsx +39 -19
  115. package/src/core/wasm/__tests__/bridge.test.ts +3 -3
  116. package/src/core/wasm/bridge.ts +5 -12
  117. package/src/core/wasm/worker/worker.ts +0 -9
  118. package/src/utils/__tests__/{createReducer.test.ts → createReducer.test.tsx} +28 -0
  119. package/src/utils/createReducer.ts +42 -21
  120. package/src/core/cells/__tests__/apply-transaction.test.ts +0 -991
  121. package/src/core/cells/__tests__/document-changes.test.ts +0 -1140
  122. package/src/core/cells/__tests__/document-roundtrip.test.ts +0 -392
  123. package/src/core/cells/document-changes.ts +0 -944
@@ -34,6 +34,7 @@ import {
34
34
  useCellActions,
35
35
  } from "./cells/cells";
36
36
  import type { AppConfig, UserConfig } from "./config/config-schema";
37
+ import { DocumentProvider } from "./notebook/document";
37
38
  import { RuntimeState } from "./kernel/RuntimeState";
38
39
  import { getSessionId } from "./kernel/session";
39
40
  import { useTogglePresenting } from "./layout/useTogglePresenting";
@@ -83,7 +84,7 @@ export const EditApp: React.FC<AppProps> = ({
83
84
  };
84
85
  }, [sendComponentValues]);
85
86
 
86
- const { connection, reconnect } = useNotebookSession({
87
+ const { connection, reconnect, documentModel } = useNotebookSession({
87
88
  autoInstantiate: userConfig.runtime.auto_instantiate,
88
89
  setCells: (cells, layout) => {
89
90
  setCells(cells);
@@ -174,7 +175,7 @@ export const EditApp: React.FC<AppProps> = ({
174
175
  );
175
176
 
176
177
  return (
177
- <>
178
+ <DocumentProvider model={documentModel}>
178
179
  <SandboxController onReconnect={reconnect} />
179
180
  <AppContainer
180
181
  connection={connection}
@@ -228,6 +229,6 @@ export const EditApp: React.FC<AppProps> = ({
228
229
  />
229
230
  </TooltipProvider>
230
231
  )}
231
- </>
232
+ </DocumentProvider>
232
233
  );
233
234
  };
@@ -1,9 +1,9 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { flushDocumentChanges } from "@/core/cells/document-changes";
3
+ import { flushDocument } from "@/core/cells/document";
4
4
  import { getSerializedLayout } from "@/core/layout/layout";
5
5
 
6
6
  export async function getExportLayout() {
7
- await flushDocumentChanges();
7
+ await flushDocument();
8
8
  return getSerializedLayout();
9
9
  }
@@ -347,7 +347,6 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
347
347
  public saveAppConfig = throwNotImplemented;
348
348
  public saveCellConfig = throwNotImplemented;
349
349
  public sendRestart = throwNotImplemented;
350
- public sendDocumentTransaction = throwNotImplemented;
351
350
  public readCode = throwNotImplemented;
352
351
  public readSnippets = throwNotImplemented;
353
352
  public previewDatasetColumn = throwNotImplemented;
@@ -144,7 +144,6 @@ const ACTIONS: Record<keyof AllRequests, Action> = {
144
144
  downloadStorage: "waitForConnectionOpen",
145
145
 
146
146
  // Sync operations that wait for connection
147
- sendDocumentTransaction: "waitForConnectionOpen",
148
147
  sendCodeCompletionRequest: "waitForConnectionOpen",
149
148
  };
150
149
 
@@ -70,15 +70,6 @@ export function createNetworkRequests(): EditRequests & RunRequests {
70
70
  })
71
71
  .then(handleResponseReturnNull);
72
72
  },
73
- sendDocumentTransaction: async (request) => {
74
- await waitForConnectionOpen();
75
- return getClient()
76
- .POST("/api/document/transaction", {
77
- body: request,
78
- params: getParams(),
79
- })
80
- .then(handleResponseReturnNull);
81
- },
82
73
  sendRename: (request) => {
83
74
  return getClient()
84
75
  .POST("/api/kernel/rename", {
@@ -40,7 +40,6 @@ export function createStaticRequests(): EditRequests & RunRequests {
40
40
  return null;
41
41
  },
42
42
  sendRestart: throwNotInEditMode,
43
- sendDocumentTransaction: throwNotInEditMode,
44
43
  sendRun: throwNotInEditMode,
45
44
  sendRunScratchpad: throwNotInEditMode,
46
45
  sendRename: throwNotInEditMode,
@@ -21,7 +21,6 @@ export function createErrorToastingRequests(
21
21
  sendInstantiate: "Failed to instantiate",
22
22
  sendFunctionRequest: "Failed to send function request",
23
23
  sendRestart: "Failed to restart",
24
- sendDocumentTransaction: "Failed to sync document transaction",
25
24
  sendRun: "Failed to run",
26
25
  sendRunScratchpad: "Failed to run scratchpad",
27
26
  sendRename: "Failed to rename",
@@ -117,8 +117,6 @@ export interface FileCreateInput {
117
117
  }
118
118
  export type UpdateUIElementRequest = schemas["UpdateUIElementRequest"];
119
119
  export type ModelRequest = schemas["ModelRequest"];
120
- export type NotebookDocumentTransactionRequest =
121
- schemas["NotebookDocumentTransactionRequest"];
122
120
  export type UpdateUserConfigRequest = schemas["UpdateUserConfigRequest"];
123
121
  export type ShutdownSessionRequest = schemas["ShutdownSessionRequest"];
124
122
  export type Snippet = schemas["Snippet"];
@@ -179,9 +177,6 @@ export interface EditRequests {
179
177
  saveAppConfig: (request: SaveAppConfigurationRequest) => Promise<null>;
180
178
  saveCellConfig: (request: SetCellConfigRequest) => Promise<null>;
181
179
  sendRestart: () => Promise<null>;
182
- sendDocumentTransaction: (
183
- request: NotebookDocumentTransactionRequest,
184
- ) => Promise<null>;
185
180
  sendInstallMissingPackages: (
186
181
  request: InstallPackagesRequest,
187
182
  ) => Promise<null>;
@@ -84,14 +84,14 @@ function setup({
84
84
  } = {}) {
85
85
  const store = createStore();
86
86
  const source = atom(initial);
87
- const controller = new AbortController();
88
- lifetimes.push(controller);
89
87
  const model = createDocumentModel({
90
88
  store,
91
89
  documentAtom: source,
92
90
  backendAtom: atom(fakeBackend({ edit: () => new Promise(() => {}) })),
93
- signal: controller.signal,
94
91
  });
92
+ const controller = new AbortController();
93
+ lifetimes.push(controller);
94
+ model.start({ signal: controller.signal });
95
95
  const editor = (text = doc) => {
96
96
  const view = new EditorView({
97
97
  state: EditorState.create({
@@ -326,7 +326,7 @@ describe("cellCodeBinding", () => {
326
326
  });
327
327
  });
328
328
 
329
- it("switches language for host code without rewriting it", async () => {
329
+ it("keeps Python mode for host code without rewriting it", async () => {
330
330
  const { store, source, view, code, status } = setup({ adapters: true });
331
331
  await settle();
332
332
  expect(language(view)).toBe("python");
@@ -337,12 +337,58 @@ describe("cellCodeBinding", () => {
337
337
  );
338
338
  await settle();
339
339
 
340
- expect(language(view)).toBe("markdown");
341
- expect(view.state.doc.toString()).toBe("hi");
340
+ expect(language(view)).toBe("python");
341
+ expect(view.state.doc.toString()).toBe('mo.md("hi")');
342
342
  expect(code()).toBe('mo.md("hi")');
343
343
  expect(status()).toEqual({ status: "idle" });
344
344
  });
345
345
 
346
+ it("keeps Python mode while typing mo.md() and receiving its echo", async () => {
347
+ const { store, source, view, type } = setup({
348
+ adapters: true,
349
+ initial: withCells(document(), { c1: cell("c1", "") }),
350
+ });
351
+ await settle();
352
+ type("mo.md");
353
+ await settle();
354
+ type("()");
355
+ await settle();
356
+ store.set(source, withCells(document(), { c1: cell("c1", "mo.md()") }));
357
+ await settle();
358
+
359
+ expect(language(view)).toBe("python");
360
+ expect(view.state.doc.toString()).toBe("mo.md()");
361
+ });
362
+
363
+ it("falls back to Python when host code no longer fits Markdown", async () => {
364
+ const { store, source, view, code, status } = setup({
365
+ adapters: true,
366
+ initial: withCells(document(), { c1: cell("c1", 'mo.md("hi")') }),
367
+ });
368
+ await settle();
369
+ expect(language(view)).toBe("markdown");
370
+
371
+ store.set(source, withCells(document(), { c1: cell("c1", "x = 1") }));
372
+ await settle();
373
+
374
+ expect(language(view)).toBe("python");
375
+ expect(view.state.doc.toString()).toBe("x = 1");
376
+ expect(code()).toBe("x = 1");
377
+ expect(status()).toEqual({ status: "idle" });
378
+ });
379
+
380
+ it("preserves Python when typing before the initial pull", async () => {
381
+ const { view, type } = setup({
382
+ adapters: true,
383
+ initial: withCells(document(), { c1: cell("c1", "") }),
384
+ });
385
+ type("mo.md()");
386
+ await settle();
387
+
388
+ expect(language(view)).toBe("python");
389
+ expect(view.state.doc.toString()).toBe("mo.md()");
390
+ });
391
+
346
392
  it("writes changes to language metadata alone", async () => {
347
393
  const { view, code } = setup({
348
394
  adapters: true,
@@ -435,15 +481,12 @@ describe("cellCodeBinding", () => {
435
481
  });
436
482
 
437
483
  it("edits SQL as SQL and keeps its output name", async () => {
438
- const { store, source, view, code, type } = setup({ adapters: true });
439
- await settle();
440
-
441
- store.set(
442
- source,
443
- withCells(document(), {
484
+ const { view, code, type } = setup({
485
+ adapters: true,
486
+ initial: withCells(document(), {
444
487
  c1: cell("c1", 'df = mo.sql(f"""SELECT 1""")'),
445
488
  }),
446
- );
489
+ });
447
490
  await settle();
448
491
  expect(language(view)).toBe("sql");
449
492
  expect(view.state.doc.toString()).toBe("SELECT 1");
@@ -456,7 +499,12 @@ describe("cellCodeBinding", () => {
456
499
  });
457
500
 
458
501
  it("sets the SQL dialect from the host's engine", async () => {
459
- const { store, source } = setup({ adapters: true });
502
+ const { store, source } = setup({
503
+ adapters: true,
504
+ initial: withCells(document(), {
505
+ c1: cell("c1", 'df = mo.sql("SELECT 1")'),
506
+ }),
507
+ });
460
508
  await settle();
461
509
  const dialect = vi.spyOn(SCHEMA_CACHE, "getDialect");
462
510
  try {
@@ -483,3 +531,17 @@ describe("cellCodeBinding", () => {
483
531
  });
484
532
  });
485
533
  });
534
+
535
+ describe("cellCodeBinding before the cell has source", () => {
536
+ it("still infers the language when the source first arrives", async () => {
537
+ const { store, view, source, type } = setup({
538
+ initial: null,
539
+ adapters: true,
540
+ });
541
+ type("x");
542
+ await settle();
543
+ store.set(source, withCells(document(), { c1: cell("c1", 'mo.md("hi")') }));
544
+ await settle();
545
+ expect(view.state.field(languageAdapterState).type).toBe("markdown");
546
+ });
547
+ });
@@ -0,0 +1,88 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { atom, createStore } from "jotai";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { Document } from "../../replica/resources/document";
6
+ import { createDocumentModel } from "..";
7
+ import { type FakeEdit, fakeBackend } from "../../replica/__tests__/fixtures";
8
+ import { document } from "./fixtures";
9
+
10
+ const lifetimes: AbortController[] = [];
11
+
12
+ afterEach(() => {
13
+ lifetimes.splice(0).forEach((controller) => controller.abort());
14
+ });
15
+
16
+ /** The document after the host accepts one edit. */
17
+ function accepted(): Document {
18
+ const base = document();
19
+ return {
20
+ ...base,
21
+ revision: { ...base.revision, version: 2, etag: "document-2" },
22
+ };
23
+ }
24
+
25
+ function setup() {
26
+ const store = createStore();
27
+ const source = atom<Document | null>(document());
28
+ // Answer the edit, then publish the accepted document as the host would.
29
+ const edit = vi.fn<FakeEdit>().mockImplementation(async () => {
30
+ queueMicrotask(() => store.set(source, accepted()));
31
+ return {
32
+ ok: true,
33
+ status: 200,
34
+ data: { document: accepted().revision, cells: {} },
35
+ };
36
+ });
37
+ const model = createDocumentModel({
38
+ store,
39
+ documentAtom: source,
40
+ backendAtom: atom(fakeBackend({ edit })),
41
+ });
42
+ const controller = new AbortController();
43
+ lifetimes.push(controller);
44
+ model.start({ signal: controller.signal });
45
+ return { store, model, edit, controller };
46
+ }
47
+
48
+ describe("document sync hold", () => {
49
+ it("keeps an edit local until released, then lets a waiting flush send it", async () => {
50
+ const { store, model, edit } = setup();
51
+ const release = model.hold();
52
+ store.set(model.editAtom, (tx) => tx.setCode("c1", "x = 2"));
53
+ const flushing = model.flush();
54
+ await Promise.resolve();
55
+ expect(edit).not.toHaveBeenCalled();
56
+ expect(store.get(model.syncStatusAtom)).toEqual({ status: "pending" });
57
+
58
+ release();
59
+ await flushing;
60
+ expect(edit).toHaveBeenCalledTimes(1);
61
+ });
62
+
63
+ it("releases once the last of nested holds is released", async () => {
64
+ const { store, model, edit } = setup();
65
+ const outer = model.hold();
66
+ const inner = model.hold();
67
+ store.set(model.editAtom, (tx) => tx.setCode("c1", "x = 2"));
68
+ const flushing = model.flush();
69
+
70
+ inner();
71
+ inner();
72
+ await Promise.resolve();
73
+ expect(edit).not.toHaveBeenCalled();
74
+
75
+ outer();
76
+ await flushing;
77
+ expect(edit).toHaveBeenCalledTimes(1);
78
+ });
79
+
80
+ it("rejects a flush waiting on a hold when the model stops", async () => {
81
+ const { store, model, controller } = setup();
82
+ model.hold();
83
+ store.set(model.editAtom, (tx) => tx.setCode("c1", "x = 2"));
84
+ const flushing = model.flush();
85
+ controller.abort(new Error("session ended"));
86
+ await expect(flushing).rejects.toThrow("session ended");
87
+ });
88
+ });
@@ -32,14 +32,14 @@ function setup(
32
32
  const source = atom(initial);
33
33
  const detached = vi.fn();
34
34
  source.onMount = () => detached;
35
- const controller = new AbortController();
36
- lifetimes.push(controller);
37
35
  const model = createDocumentModel({
38
36
  store,
39
37
  documentAtom: source,
40
38
  backendAtom: atom(fakeBackend({ edit })),
41
- signal: controller.signal,
42
39
  });
40
+ const controller = new AbortController();
41
+ lifetimes.push(controller);
42
+ model.start({ signal: controller.signal });
43
43
  const wrapper = ({ children }: PropsWithChildren) => (
44
44
  <Provider store={store}>
45
45
  <DocumentProvider model={model}>{children}</DocumentProvider>
@@ -13,7 +13,7 @@ test("closing a session before the debounce prevents submission", async ({
13
13
  await vi.advanceTimersByTimeAsync(1000);
14
14
  expect(notebook.edit).not.toHaveBeenCalled();
15
15
  expect(vi.getTimerCount()).toBe(0);
16
- await expect(user.sync()).rejects.toBe(reason);
16
+ await expect(user.sync()).rejects.toThrow("not started");
17
17
  });
18
18
 
19
19
  test("closing a session before a queued flush starts prevents submission", async ({
@@ -55,7 +55,7 @@ test.for(["request", "confirmation", "retry"] as const)(
55
55
  await vi.advanceTimersByTimeAsync(1000);
56
56
  expect(notebook.edit).toHaveBeenCalledOnce();
57
57
  expect(vi.getTimerCount()).toBe(0);
58
- await expect(user.sync()).rejects.toBe(reason);
59
- await expect(user.retry()).rejects.toBe(reason);
58
+ await expect(user.sync()).rejects.toThrow("not started");
59
+ await expect(user.retry()).rejects.toThrow("not started");
60
60
  },
61
61
  );
@@ -52,14 +52,14 @@ function setup({ connected = true } = {}) {
52
52
  sent({ notebookId, changes, ...options }),
53
53
  });
54
54
  const backendAtom = atom<NotebookBackend | null>(connected ? backend : null);
55
- const controller = new AbortController();
56
- lifetimes.push(controller);
57
55
  const model = createDocumentModel({
58
56
  store,
59
57
  documentAtom: source,
60
58
  backendAtom,
61
- signal: controller.signal,
62
59
  });
60
+ const controller = new AbortController();
61
+ lifetimes.push(controller);
62
+ model.start({ signal: controller.signal });
63
63
  return {
64
64
  store,
65
65
  source,
@@ -85,28 +85,84 @@ afterEach(() => {
85
85
  });
86
86
 
87
87
  describe("document synchronization", () => {
88
- it("rejects an aborted lifetime before subscribing or sending", () => {
88
+ it("subscribes to nothing until started", async () => {
89
89
  const store = createStore();
90
90
  const source = atom<Document | null>(document());
91
91
  const mounted = vi.fn();
92
92
  source.onMount = mounted;
93
- const controller = new AbortController();
94
- const reason = new Error("Session ended");
95
- controller.abort(reason);
96
93
  const edit = vi.fn<FakeEdit>();
97
- expect(() =>
98
- createDocumentModel({
99
- store,
100
- documentAtom: source,
101
- backendAtom: atom(fakeBackend({ edit })),
102
- signal: controller.signal,
103
- }),
104
- ).toThrow(reason);
94
+ const model = createDocumentModel({
95
+ store,
96
+ documentAtom: source,
97
+ backendAtom: atom(fakeBackend({ edit })),
98
+ });
99
+ store.set(model.editAtom, (tx) => tx.setCode("c1", "local"));
100
+ await expect(model.flush()).rejects.toThrow("not started");
101
+ expect(store.get(model.documentAtom)).toBeNull();
102
+ expect(store.get(model.syncStatusAtom)).toEqual({ status: "unavailable" });
105
103
  expect(mounted).not.toHaveBeenCalled();
106
104
  expect(edit).not.toHaveBeenCalled();
107
105
  expect(vi.getTimerCount()).toBe(0);
108
106
  });
109
107
 
108
+ it("rejects an aborted signal before subscribing", () => {
109
+ const store = createStore();
110
+ const source = atom<Document | null>(document());
111
+ const mounted = vi.fn();
112
+ source.onMount = mounted;
113
+ const model = createDocumentModel({
114
+ store,
115
+ documentAtom: source,
116
+ backendAtom: atom(fakeBackend({ edit: vi.fn<FakeEdit>() })),
117
+ });
118
+ const reason = new Error("Session ended");
119
+ expect(() => model.start({ signal: AbortSignal.abort(reason) })).toThrow(
120
+ reason,
121
+ );
122
+ expect(mounted).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it("starts again after a subscriber aborts during startup", async () => {
126
+ const store = createStore();
127
+ const model = createDocumentModel({
128
+ store,
129
+ documentAtom: atom(document()),
130
+ backendAtom: atom(null),
131
+ });
132
+ const controller = new AbortController();
133
+ lifetimes.push(controller);
134
+ const unsubscribe = store.sub(model.documentAtom, () => {
135
+ if (store.get(model.documentAtom)) {
136
+ controller.abort();
137
+ }
138
+ });
139
+ model.start({ signal: controller.signal });
140
+ unsubscribe();
141
+ expect(controller.signal.aborted).toBe(true);
142
+ expect(store.get(model.documentAtom)).toBeNull();
143
+ await expect(model.flush()).rejects.toThrow("not started");
144
+
145
+ const next = new AbortController();
146
+ lifetimes.push(next);
147
+ model.start({ signal: next.signal });
148
+ expect(store.get(model.documentAtom)?.cells.get("c1")?.code).toBe("x = 1");
149
+ });
150
+
151
+ it("starts again without the drafts of a stopped run", () => {
152
+ const { store, model, controller, edit, value } = setup();
153
+ expect(() => model.start({ signal: new AbortController().signal })).toThrow(
154
+ "already started",
155
+ );
156
+ edit("local");
157
+ controller.abort();
158
+ const next = new AbortController();
159
+ lifetimes.push(next);
160
+ model.start({ signal: next.signal });
161
+ expect(value()?.code).toBe("x = 1");
162
+ store.set(model.editAtom, (tx) => tx.setCode("c1", "restarted"));
163
+ expect(value()?.code).toBe("restarted");
164
+ });
165
+
110
166
  it("fails without repeating a request when the response names another notebook", async () => {
111
167
  const { sent, replies, edit, model, status } = setup();
112
168
  edit("local");
@@ -15,8 +15,8 @@ function createSession(initial: Document, controller: AbortController) {
15
15
  store,
16
16
  documentAtom: notebook.documentAtom,
17
17
  backendAtom: notebook.backendAtom,
18
- signal: controller.signal,
19
18
  });
19
+ model.start({ signal: controller.signal });
20
20
  return {
21
21
  user: createUser(store, model),
22
22
  host: control,