@marimo-team/islands 0.25.3-dev192 → 0.25.3-dev193

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 (154) hide show
  1. package/dist/{ConnectedDataExplorerComponent-BBbOElk3.js → ConnectedDataExplorerComponent-BRuwLOIx.js} +9 -9
  2. package/dist/{ErrorBoundary-Dw6uRKVk.js → ErrorBoundary-BVu_xT-p.js} +2 -2
  3. package/dist/{abnfDiagram-VCTEODGH-DhMT6Uhd.js → abnfDiagram-VCTEODGH-LlJTOOU_.js} +2 -2
  4. package/dist/{architectureDiagram-5GKGNRK7-BVbEiJLq.js → architectureDiagram-5GKGNRK7-9PQtGLLZ.js} +1 -1
  5. package/dist/{arrays-R6JsImiT.js → arrays-DA6KkLfq.js} +6 -12
  6. package/dist/assets/__vite-browser-external-BCJsx8QS.js +1 -0
  7. package/dist/assets/{worker-C6e9ERyd.js → worker-DodjWT_Z.js} +2 -2
  8. package/dist/{capabilities-CI4xUPsp.js → capabilities-CvWU-ey_.js} +1 -1
  9. package/dist/{chat-ui-BguxTQEL.js → chat-ui-Bl2euMBD.js} +93 -93
  10. package/dist/{chunk-IMKFNOWR-By5hSbJJ.js → chunk-IMKFNOWR-DcXv-BD5.js} +1 -1
  11. package/dist/{chunk-SHT3W25Y-DFrEYG54.js → chunk-SHT3W25Y-VwnELN23.js} +1 -1
  12. package/dist/{chunk-SVP7TREG-DFXGc9ew.js → chunk-SVP7TREG-DgQpI5Zi.js} +1 -1
  13. package/dist/{chunk-TICWLB2K-DZdNIpoZ.js → chunk-TICWLB2K-CTg6VTJT.js} +1 -1
  14. package/dist/{classDiagram-ZZMXUADV-Cgj_eEQA.js → classDiagram-ZZMXUADV-UzmcFrnW.js} +2 -2
  15. package/dist/{classDiagram-v2-VYDZK3BY-DLAUrSqj.js → classDiagram-v2-VYDZK3BY-DhjiI6IQ.js} +2 -2
  16. package/dist/{code-visibility-v_7ijLtx.js → code-visibility-BTVimCUw.js} +38 -42
  17. package/dist/{common-BUwWHpnK.js → common-fTgq7gO-.js} +849 -849
  18. package/dist/{cynefinDiagram-5FMLGOSQ-Cikf9AKa.js → cynefinDiagram-5FMLGOSQ-O4ShIbJX.js} +1 -1
  19. package/dist/{dagre-GXQ25YYZ-C5KEi5_l.js → dagre-GXQ25YYZ-dtHhhcPu.js} +1 -1
  20. package/dist/{data-grid-overlay-editor-4IZymnXZ.js → data-grid-overlay-editor-DAZZkLWg.js} +1 -1
  21. package/dist/{diagram-S7CK7UJ4-BaoBTQjR.js → diagram-S7CK7UJ4-ppPYVZ6W.js} +1 -1
  22. package/dist/{diagram-UQ7AKVKN-DDufBsUA.js → diagram-UQ7AKVKN-CMZDf--k.js} +1 -1
  23. package/dist/{diagram-VX7I27RA-CY6RALJ8.js → diagram-VX7I27RA-CFvJdx-C.js} +1 -1
  24. package/dist/{diagram-Z3DM3KII-Bc-LHsD6.js → diagram-Z3DM3KII-Dt6_uFbv.js} +1 -1
  25. package/dist/{dropdown-menu-drDjxJyV.js → dropdown-menu-CeaOsjWZ.js} +2 -2
  26. package/dist/{ebnfDiagram-PWID7BFC-Cz0J-tcC.js → ebnfDiagram-PWID7BFC-DpRFrKK-.js} +2 -2
  27. package/dist/{erDiagram-RLTQ6QDP-DTXTDgFu.js → erDiagram-RLTQ6QDP-CqAU4b0u.js} +1 -1
  28. package/dist/{error-entries-CZ1NQ6Kv.js → error-entries-CYGwr5-O.js} +5596 -6012
  29. package/dist/feedback-button-DzFFn-n4.js +374 -0
  30. package/dist/{flowDiagram-HODETNUW-RbwWf2zc.js → flowDiagram-HODETNUW-i8-Gw8HO.js} +2 -2
  31. package/dist/{formats-DbXnijtH.js → formats-BtoHHy42.js} +1 -1
  32. package/dist/{glide-data-editor-DU8_EDG5.js → glide-data-editor-DFsqVbWR.js} +5 -5
  33. package/dist/{globals-BeBDOk22.js → globals-Dr0MRANu.js} +2 -2
  34. package/dist/{html-to-image-D_4_hQ8U.js → html-to-image-Cy5UDpQJ.js} +56 -56
  35. package/dist/{infoDiagram-27XIBGKW-DlmzZjmI.js → infoDiagram-27XIBGKW-DpfOKdLL.js} +1 -1
  36. package/dist/{ishikawaDiagram-5VMMS53U-EN3TYP_q.js → ishikawaDiagram-5VMMS53U-Dg7mrMG7.js} +1 -1
  37. package/dist/{kanban-definition-UXKFOSKX-CK_-Ru7Q.js → kanban-definition-UXKFOSKX-QB6e3Jrj.js} +1 -1
  38. package/dist/main.js +5875 -5876
  39. package/dist/{mermaid-DU9ZRmkG.js → mermaid-CeWG4Egr.js} +48 -48
  40. package/dist/{mindmap-definition-YA3MSWOX-C-hqgUvF.js → mindmap-definition-YA3MSWOX-BTz3uTGe.js} +1 -1
  41. package/dist/{number-overlay-editor-BoIkyh6H.js → number-overlay-editor-DupOSYHx.js} +1 -1
  42. package/dist/{once-eG8Vmvjd.js → once-DWGO2mQ2.js} +1 -1
  43. package/dist/{pegDiagram-XKGWAZYB-BiMejYPB.js → pegDiagram-XKGWAZYB-3ZIEa3mB.js} +2 -2
  44. package/dist/{pieDiagram-E7YTZNPT-B1zCZ1eq.js → pieDiagram-E7YTZNPT-BJAlk046.js} +1 -1
  45. package/dist/{process-output-DxSCHBWp.js → process-output-q9MTAIMX.js} +2 -2
  46. package/dist/{railroadDiagram-O6MQD6OU-BLcfaASl.js → railroadDiagram-O6MQD6OU-Dn3bsSRa.js} +2 -2
  47. package/dist/{requirementDiagram-BXWQKSXE-DtBOf52-.js → requirementDiagram-BXWQKSXE-wcOmQQCL.js} +1 -1
  48. package/dist/{reveal-component-5M3a2rpj.js → reveal-component-Bg8rP_Uo.js} +17 -17
  49. package/dist/{sequenceDiagram-WJ2MYXX4-DIwF19_n.js → sequenceDiagram-WJ2MYXX4-DSh_qxoF.js} +1 -1
  50. package/dist/{spec-DZDnnlgT.js → spec-C7Fos-If.js} +1 -1
  51. package/dist/{stateDiagram-D77RDMKH-sWk1AdYL.js → stateDiagram-D77RDMKH-DH_H8NMH.js} +2 -2
  52. package/dist/{stateDiagram-v2-MP3YSRHH-CiNo-OI7.js → stateDiagram-v2-MP3YSRHH-D_JM7MEK.js} +2 -2
  53. package/dist/style.css +1 -1
  54. package/dist/{swimlanes-42K2YHIH-Bj3hiOY5.js → swimlanes-42K2YHIH-CfoTJO-9.js} +1 -1
  55. package/dist/{swimlanesDiagram-VR7AAH4N-BzEo2r_N.js → swimlanesDiagram-VR7AAH4N-Z_QhvK0G.js} +2 -2
  56. package/dist/{swiper-component-BkCm-kLg.js → swiper-component-D2geL1Ku.js} +1 -1
  57. package/dist/{timeline-definition-24CTP7MA-CrPQk42H.js → timeline-definition-24CTP7MA-BFtdScaL.js} +1 -1
  58. package/dist/{toDate-Cu2pIZcC.js → toDate-CQAFSwos.js} +24 -24
  59. package/dist/{useAsyncData-DEhLdRIu.js → useAsyncData-BeyZyjso.js} +1 -1
  60. package/dist/{useDeepCompareMemoize-RjZGVhBV.js → useDeepCompareMemoize-DOQgae0W.js} +1 -1
  61. package/dist/{useIframeCapabilities-CSdWn6NP.js → useIframeCapabilities-DNvaj9wM.js} +1 -1
  62. package/dist/{useLifecycle-DtcB2s1z.js → useLifecycle-n2lnCFS8.js} +1 -1
  63. package/dist/{useTheme-CoEtr4mK.js → useTheme-CY777LXN.js} +303 -99
  64. package/dist/{vega-component-BhjTrP9-.js → vega-component-0nQzA-I8.js} +6 -6
  65. package/dist/{vennDiagram-4TSXK5OY-BaYMoIpj.js → vennDiagram-4TSXK5OY-BXY8IGCA.js} +1 -1
  66. package/dist/{wardleyDiagram-VM6X3IG4-DjHeuj8p.js → wardleyDiagram-VM6X3IG4-D0tFe6eC.js} +1 -1
  67. package/dist/{xychartDiagram-S5SC5T6Z-Bc_hcuXS.js → xychartDiagram-S5SC5T6Z-Dx8wFM-y.js} +1 -1
  68. package/package.json +1 -1
  69. package/src/__mocks__/notebook.ts +0 -1
  70. package/src/__mocks__/partial.ts +26 -0
  71. package/src/__tests__/main.test.tsx +24 -1
  72. package/src/components/app-config/app-config-form.tsx +5 -9
  73. package/src/components/editor/__tests__/data-attributes.test.tsx +0 -1
  74. package/src/components/editor/__tests__/output-persistence.test.tsx +0 -1
  75. package/src/components/editor/actions/useCellActionButton.tsx +6 -5
  76. package/src/components/editor/actions/useConfigActions.tsx +3 -4
  77. package/src/components/editor/actions/useNotebookActions.tsx +5 -4
  78. package/src/components/editor/cell/code/cell-editor.tsx +13 -6
  79. package/src/components/editor/cell/collapse.tsx +6 -1
  80. package/src/components/editor/chrome/components/feedback-button.tsx +1 -7
  81. package/src/components/editor/controls/Controls.tsx +1 -1
  82. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +8 -6
  83. package/src/components/editor/navigation/__tests__/navigation.test.ts +15 -15
  84. package/src/components/editor/navigation/__tests__/selection.test.ts +44 -21
  85. package/src/components/editor/navigation/focus-utils.ts +19 -0
  86. package/src/components/editor/navigation/multi-cell-action-toolbar.tsx +6 -13
  87. package/src/components/editor/navigation/navigation.ts +52 -43
  88. package/src/components/editor/navigation/selection.ts +30 -29
  89. package/src/components/editor/notebook-cell.tsx +10 -18
  90. package/src/components/editor/renderers/cell-array.tsx +6 -5
  91. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +0 -1
  92. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +1 -1
  93. package/src/components/pages/edit-page.tsx +0 -13
  94. package/src/components/scratchpad/scratchpad.tsx +0 -1
  95. package/src/components/slides/reveal-component.tsx +1 -1
  96. package/src/components/slides/slide-cell-view.tsx +0 -1
  97. package/src/components/sort/SortableCellsProvider.tsx +6 -5
  98. package/src/core/MarimoApp.tsx +1 -1
  99. package/src/core/cells/__tests__/__snapshots__/cells.test.ts.snap +0 -37
  100. package/src/core/cells/__tests__/actions.test.ts +190 -80
  101. package/src/core/cells/__tests__/arrange.test.ts +263 -0
  102. package/src/core/cells/__tests__/cells.test.ts +11 -1101
  103. package/src/core/cells/__tests__/document.test.ts +3 -32
  104. package/src/core/cells/__tests__/editor.test.ts +57 -0
  105. package/src/core/cells/__tests__/harness.ts +82 -0
  106. package/src/core/cells/__tests__/layout.test.ts +189 -0
  107. package/src/core/cells/__tests__/notebook-outline.test.ts +7 -1
  108. package/src/core/cells/__tests__/session.test.ts +329 -174
  109. package/src/core/cells/__tests__/utils.test.ts +0 -1
  110. package/src/core/cells/actions.ts +247 -82
  111. package/src/core/cells/arrange.ts +341 -0
  112. package/src/core/cells/cells.ts +27 -516
  113. package/src/core/cells/document.ts +6 -5
  114. package/src/core/cells/edit.ts +30 -0
  115. package/src/core/cells/editor.ts +82 -0
  116. package/src/core/cells/layout.ts +152 -0
  117. package/src/core/cells/session.ts +75 -52
  118. package/src/core/cells/types.ts +0 -5
  119. package/src/core/cells/utils.ts +6 -4
  120. package/src/core/config/__tests__/app-config.test.ts +64 -0
  121. package/src/core/config/__tests__/config-schema.test.ts +1 -1
  122. package/src/core/config/config-schema.ts +6 -2
  123. package/src/core/config/config.ts +26 -12
  124. package/src/core/islands/bootstrap.ts +0 -1
  125. package/src/core/kernel/handlers.ts +1 -13
  126. package/src/core/meta/__tests__/code-visibility.test.tsx +78 -70
  127. package/src/core/meta/code-visibility.ts +18 -9
  128. package/src/core/notebook/__tests__/operations.test.ts +51 -0
  129. package/src/core/notebook/document/__tests__/codemirror.test.ts +15 -0
  130. package/src/core/notebook/index.ts +2 -1
  131. package/src/core/notebook/operations.ts +30 -0
  132. package/src/core/saving/__tests__/state.test.ts +111 -0
  133. package/src/core/saving/save-component.tsx +19 -33
  134. package/src/core/saving/state.ts +38 -39
  135. package/src/core/saving/useAutoSave.ts +8 -35
  136. package/src/core/session/__tests__/backend.test.ts +1 -0
  137. package/src/core/session/__tests__/useNotebookReplica.hook.test.tsx +1 -0
  138. package/src/core/session/active-notebook.ts +11 -0
  139. package/src/core/session/backend.ts +17 -19
  140. package/src/core/session/embedded.ts +7 -0
  141. package/src/core/session/useNotebookReplica.ts +3 -1
  142. package/src/core/session/useNotebookSession.tsx +7 -8
  143. package/src/core/transports/__tests__/static.test.ts +139 -8
  144. package/src/core/transports/static.ts +98 -16
  145. package/src/core/wasm/__tests__/bridge.test.ts +1 -0
  146. package/src/mount.tsx +19 -10
  147. package/src/stories/cell.stories.tsx +0 -2
  148. package/src/stories/layout/vertical/one-column.stories.tsx +0 -2
  149. package/src/utils/__tests__/id-tree.test.ts +20 -1548
  150. package/src/utils/id-tree.tsx +12 -537
  151. package/dist/assets/__vite-browser-external-C8gQho07.js +0 -1
  152. package/dist/feedback-button-MpeoIn1D.js +0 -379
  153. package/src/components/editor/navigation/__tests__/state.test.ts +0 -81
  154. package/src/components/editor/navigation/state.ts +0 -65
@@ -1,52 +1,51 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
+ /** Whether the notebook has changes the host has not written to its store. */
4
+
3
5
  import { dequal as isEqual } from "dequal";
4
6
  import { atom } from "jotai";
5
- import { arrayShallowEquals } from "@/utils/arrays";
6
- import { type NotebookState, notebookAtom } from "../cells/cells";
7
7
  import { type LayoutState, layoutStateAtom } from "../layout/state";
8
- import type { CellConfig } from "../network/types";
9
-
10
- export interface LastSavedNotebook {
11
- codes: string[];
12
- configs: CellConfig[];
13
- names: string[];
14
- layout: LayoutState;
15
- }
8
+ import {
9
+ activeDocumentModelAtom,
10
+ confirmedDocumentAtom,
11
+ } from "../session/active-notebook";
16
12
 
17
- export const lastSavedNotebookAtom = atom<LastSavedNotebook | undefined>(
18
- undefined,
13
+ /** The document version, which counts confirmed changes. Null without a document. */
14
+ export const documentVersionAtom = atom(
15
+ (get) => get(confirmedDocumentAtom)?.revision.version ?? null,
19
16
  );
20
17
 
21
- export const needsSaveAtom = atom((get) => {
22
- const lastSavedNotebook = get(lastSavedNotebookAtom);
23
- const state = get(notebookAtom);
24
- const layout = get(layoutStateAtom);
25
- return notebookNeedsSave({ state, layout, lastSavedNotebook });
26
- });
18
+ /**
19
+ * The layout as the file last had it. The host document does not carry
20
+ * the layout file, so this client compares it itself until it does.
21
+ */
22
+ export const savedLayoutAtom = atom<LayoutState | undefined>(undefined);
27
23
 
28
- function notebookNeedsSave({
29
- state,
30
- layout,
31
- lastSavedNotebook,
32
- }: {
33
- state: NotebookState;
34
- layout: LayoutState;
35
- lastSavedNotebook: LastSavedNotebook | undefined;
36
- }) {
37
- if (!lastSavedNotebook) {
24
+ /**
25
+ * Whether saving would change the file: the host's document differs from
26
+ * what it last wrote, this client has edits the host has not confirmed,
27
+ * or the layout changed. A document never written reports as unsaved.
28
+ */
29
+ export const needsSaveAtom = atom((get) => {
30
+ const document = get(confirmedDocumentAtom);
31
+ if (document === null) {
38
32
  return false;
39
33
  }
40
- const { cellIds, cellData } = state;
41
- const data = cellIds.inOrderIds.map((cellId) => cellData[cellId]);
42
- const codes = data.map((d) => d.code);
43
- const configs = data.map((d) => d.config);
44
- const names = data.map((d) => d.name);
34
+ if (document.revision.etag !== document.revision.saved_etag) {
35
+ return true;
36
+ }
37
+ const model = get(activeDocumentModelAtom);
38
+ if (model !== null) {
39
+ const { status } = get(model.syncStatusAtom);
40
+ if (status !== "idle" && status !== "unavailable") {
41
+ return true;
42
+ }
43
+ }
44
+ const saved = get(savedLayoutAtom);
45
+ const layout = get(layoutStateAtom);
45
46
  return (
46
- !arrayShallowEquals(codes, lastSavedNotebook.codes) ||
47
- !arrayShallowEquals(names, lastSavedNotebook.names) ||
48
- !isEqual(configs, lastSavedNotebook.configs) ||
49
- !isEqual(layout.selectedLayout, lastSavedNotebook.layout.selectedLayout) ||
50
- !isEqual(layout.layoutData, lastSavedNotebook.layout.layoutData)
47
+ saved !== undefined &&
48
+ (layout.selectedLayout !== saved.selectedLayout ||
49
+ !isEqual(layout.layoutData, saved.layoutData))
51
50
  );
52
- }
51
+ });
@@ -1,40 +1,23 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { useEffect, useRef } from "react";
3
3
  import type { UserConfig } from "../config/config-schema";
4
- import type { CellConfig } from "../network/types";
5
4
  import { type ConnectionStatus, ConnectionState } from "../session/types";
6
5
 
6
+ /** Save after a delay while the notebook needs it, restarting on every confirmed change. */
7
7
  export function useAutoSave(opts: {
8
- codes: string[];
9
- cellConfigs: CellConfig[];
10
- cellNames: string[];
8
+ /** The document version, which counts confirmed changes. */
9
+ version: number | null;
11
10
  config: UserConfig["save"];
12
11
  connStatus: ConnectionStatus;
13
12
  needsSave: boolean;
14
- kioskMode: boolean;
13
+ canEdit: boolean;
15
14
  onSave: () => void;
16
15
  }) {
17
- const {
18
- codes,
19
- cellConfigs,
20
- config,
21
- connStatus,
22
- cellNames,
23
- needsSave,
24
- kioskMode,
25
- onSave,
26
- } = opts;
16
+ const { version, config, connStatus, needsSave, canEdit, onSave } = opts;
27
17
  const autosaveTimeoutId = useRef<NodeJS.Timeout | null>(null);
28
18
 
29
- const codesString = codes.join(":");
30
- const cellConfigsString = cellConfigs
31
- .map((config) => JSON.stringify(config))
32
- .join(":");
33
- const cellNamesString = cellNames.join(":");
34
-
35
19
  useEffect(() => {
36
- // If kiosk mode is enabled, do not autosave
37
- if (kioskMode) {
20
+ if (!canEdit) {
38
21
  return;
39
22
  }
40
23
 
@@ -53,16 +36,6 @@ export function useAutoSave(opts: {
53
36
  clearTimeout(autosaveTimeoutId.current);
54
37
  }
55
38
  };
56
- // codes, cellConfigs, cellNames needed in deps array to prevent race condition
57
- // with needsSave when user changes state rapidly
58
- }, [
59
- codesString,
60
- cellConfigsString,
61
- cellNamesString,
62
- config,
63
- connStatus.state,
64
- onSave,
65
- kioskMode,
66
- needsSave,
67
- ]);
39
+ // `version` restarts the delay on every confirmed change.
40
+ }, [version, config, connStatus.state, onSave, canEdit, needsSave]);
68
41
  }
@@ -27,6 +27,7 @@ afterEach(() => {
27
27
 
28
28
  const options = {
29
29
  static: false,
30
+ snapshot: null,
30
31
  transportType: "websocket" as const,
31
32
  url: () => "ws://localhost/ws",
32
33
  headers: () => ({}),
@@ -48,6 +48,7 @@ function setup(strict = false) {
48
48
  const store = createStore();
49
49
  const options = {
50
50
  static: false,
51
+ snapshot: null,
51
52
  transportType: "websocket" as const,
52
53
  url: () => "ws://localhost/ws",
53
54
  headers: () => ({}),
@@ -9,3 +9,14 @@ export const activeNotebookReplicaAtom = atom<NotebookReplica | null>(null);
9
9
 
10
10
  /** The document model owned by the mounted notebook session. */
11
11
  export const activeDocumentModelAtom = atom<DocumentModel | null>(null);
12
+
13
+ /**
14
+ * The document the host last confirmed, or null while no notebook is
15
+ * connected. Facts the host owns read this one, such as permission, the
16
+ * app config, saved state and whether source is available. What the user
17
+ * is editing reads the model's document, which adds this browser's drafts.
18
+ */
19
+ export const confirmedDocumentAtom = atom((get) => {
20
+ const replica = get(activeNotebookReplicaAtom);
21
+ return replica === null ? null : get(replica.documentAtom);
22
+ });
@@ -8,11 +8,18 @@ import type { NotebookBackend } from "../notebook/replica";
8
8
  import { PyodideBridge } from "../wasm/bridge";
9
9
  import { isWasm } from "../wasm/utils";
10
10
  import { sessionSse, websocket, withHeaders } from "../transports/server";
11
- import { idle } from "../transports/static";
11
+ import {
12
+ createStaticNotebookBackend,
13
+ emptySnapshot,
14
+ type StaticNotebookSnapshot,
15
+ startWithSnapshot,
16
+ } from "../transports/static";
12
17
 
13
18
  /** How the editor follows its notebook. */
14
19
  export interface EditorBackendOptions {
15
20
  static: boolean;
21
+ /** The notebook the page was served with, shown until the host answers. */
22
+ snapshot: StaticNotebookSnapshot | null;
16
23
  transportType: "websocket" | "sse";
17
24
  /** The session route, read before each connection. */
18
25
  url: () => string;
@@ -86,30 +93,21 @@ function wasm(): NotebookBackend {
86
93
  };
87
94
  }
88
95
 
89
- /** Commands for a notebook the editor cannot change through its backend. */
90
- function uneditable(): ReturnType<NotebookBackend["notebook"]> {
91
- const unsupported: NotebookClient["save"] = async () => ({
92
- ok: false,
93
- status: 501,
94
- problem: {
95
- type: "tag:marimo.io,2026:host/unsupported-operation",
96
- title: "Document mutations are unavailable on this connection",
97
- status: 501,
98
- },
99
- });
100
- return { edit: () => unsupported(), save: unsupported };
101
- }
102
-
103
96
  /** Choose the backend for the editor's notebook. */
104
97
  export function createEditorBackend(
105
98
  options: EditorBackendOptions,
106
99
  ): NotebookBackend {
107
100
  if (options.static) {
108
- return { transport: idle, notebook: uneditable };
109
- }
110
- if (isWasm()) {
111
- return wasm();
101
+ return createStaticNotebookBackend(
102
+ options.snapshot ?? emptySnapshot("static"),
103
+ );
112
104
  }
105
+ const live = isWasm() ? wasm() : server(options);
106
+ return options.snapshot ? startWithSnapshot(options.snapshot, live) : live;
107
+ }
108
+
109
+ /** Follow the notebook on the server, over SSE or a WebSocket. */
110
+ function server(options: EditorBackendOptions): NotebookBackend {
113
111
  const url = afterStartup(options);
114
112
  return {
115
113
  transport:
@@ -0,0 +1,7 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { atom } from "jotai";
4
+ import type { StaticNotebookSnapshot } from "../transports/static";
5
+
6
+ /** The notebook the page was served with, read at mount. Null when none was. */
7
+ export const embeddedSnapshotAtom = atom<StaticNotebookSnapshot | null>(null);
@@ -22,7 +22,7 @@ type Options = EditorBackendOptions & {
22
22
 
23
23
  export function useNotebookReplica(options: Options): NotebookReplica {
24
24
  const store = useStore();
25
- const { static: isStatic, transportType } = options;
25
+ const { static: isStatic, snapshot, transportType } = options;
26
26
  const replica = useMemo(() => createNotebookReplica(store), [store]);
27
27
  const url = useEvent(options.url);
28
28
  const headers = useEvent(options.headers);
@@ -35,6 +35,7 @@ export function useNotebookReplica(options: Options): NotebookReplica {
35
35
  () =>
36
36
  createEditorBackend({
37
37
  static: isStatic,
38
+ snapshot,
38
39
  transportType,
39
40
  url,
40
41
  headers,
@@ -44,6 +45,7 @@ export function useNotebookReplica(options: Options): NotebookReplica {
44
45
  }),
45
46
  [
46
47
  isStatic,
48
+ snapshot,
47
49
  transportType,
48
50
  url,
49
51
  headers,
@@ -35,7 +35,7 @@ import {
35
35
  } from "../codemirror/cells/debugger-state";
36
36
  import { notebookCells } from "../cells/utils";
37
37
  import { capabilitiesAtom } from "../config/capabilities";
38
- import { connectionTransportTypeAtom, useSetAppConfig } from "../config/config";
38
+ import { connectionTransportTypeAtom } from "../config/config";
39
39
  import { useDataSourceActions } from "../datasets/data-source-connections";
40
40
  import { invalidateDataSourceDiscovery } from "../datasets/data-source-discovery";
41
41
  import type { ConnectionName } from "../datasets/engines";
@@ -62,9 +62,10 @@ import {
62
62
  stableSessionIdAtom,
63
63
  } from "../kernel/session";
64
64
  import { initialRunCompletedAtom, kernelStateAtom } from "../kernel/state";
65
- import { type LayoutState, useLayoutActions } from "../layout/state";
65
+ import { useLayoutActions } from "../layout/state";
66
66
  import { kioskModeAtom } from "../mode";
67
67
  import { connectionAtom, startupProgressAtom } from "../network/connection";
68
+ import { savedLayoutAtom } from "../saving/state";
68
69
  import type { RequestId } from "../network/DeferredRequestRegistry";
69
70
  import { useRuntimeManager } from "../runtime/config";
70
71
  import { SECRETS_REGISTRY } from "../secrets/request-registry";
@@ -77,16 +78,15 @@ import { useStorageActions } from "../storage/state";
77
78
  import { useVariablesActions } from "../variables/state";
78
79
  import type { VariableName } from "../variables/types";
79
80
  import { activeDocumentModelAtom } from "./active-notebook";
81
+ import { embeddedSnapshotAtom } from "./embedded";
80
82
  import { WebSocketClosedReason, ConnectionState } from "./types";
81
83
 
82
84
  /** Coordinate session startup and apply notifications to the editor. */
83
85
  export function useNotebookSession(opts: {
84
86
  sessionId: SessionId;
85
87
  autoInstantiate: boolean;
86
- /** Called with the layout the kernel reported, after its cells are ready. */
87
- onKernelReady?: (layout: LayoutState) => void;
88
88
  }) {
89
- const { autoInstantiate, sessionId, onKernelReady } = opts;
89
+ const { autoInstantiate, sessionId } = opts;
90
90
  const { showBoundary } = useErrorBoundary();
91
91
  const store = useStore();
92
92
 
@@ -99,7 +99,6 @@ export function useNotebookSession(opts: {
99
99
  const { addCellNotification } = useRunsActions();
100
100
  const setKernelState = useSetAtom(kernelStateAtom);
101
101
  const setInitialRunCompleted = useSetAtom(initialRunCompletedAtom);
102
- const setAppConfig = useSetAppConfig();
103
102
  const { setVariables, setMetadata } = useVariablesActions();
104
103
  const { addColumnPreview } = useDatasetsActions();
105
104
  const { addDatasets, filterDatasetsFromVariables } = useDatasetsActions();
@@ -156,12 +155,11 @@ export function useNotebookSession(opts: {
156
155
  setLastRun,
157
156
  resetCellRuntimes,
158
157
  setLayoutData,
159
- setAppConfig,
160
158
  setCapabilities,
161
159
  setKernelState,
162
160
  onError: showBoundary,
163
161
  });
164
- onKernelReady?.(layout);
162
+ store.set(savedLayoutAtom, layout);
165
163
  if (!msg.data.resumed) {
166
164
  // A freshly started kernel may expose a different environment
167
165
  // (e.g. new env vars); re-run discovery instead of reusing stale
@@ -399,6 +397,7 @@ export function useNotebookSession(opts: {
399
397
  };
400
398
  const replica = useNotebookReplica({
401
399
  static: isStaticNotebook(),
400
+ snapshot: useAtomValue(embeddedSnapshotAtom),
402
401
  transportType,
403
402
  url: () =>
404
403
  transportType === "sse"
@@ -3,10 +3,13 @@
3
3
  import { createNotebookReducer, NotebookEvents } from "@marimo-team/client";
4
4
  import * as Protocol from "@marimo-team/client/protocol";
5
5
  import { afterEach, assert, describe, expect, it, vi } from "vitest";
6
+ import type { NotebookBackend } from "../../notebook/replica";
6
7
  import { document as documentResource } from "../../notebook/replica/resources/document";
7
8
  import {
8
9
  createStaticNotebookBackend,
10
+ emptySnapshot,
9
11
  type StaticNotebookSnapshot,
12
+ startWithSnapshot,
10
13
  } from "../static";
11
14
  import { fixtureEvents } from "../../notebook/replica/__tests__/fixtures";
12
15
 
@@ -34,24 +37,48 @@ afterEach(async () => {
34
37
  vi.useRealTimers();
35
38
  });
36
39
 
37
- async function read(
38
- backend: ReturnType<typeof createStaticNotebookBackend>,
39
- cursor?: string,
40
- ) {
41
- const stream = new NotebookEvents(backend.transport, { cursor });
42
- streams.push(stream);
40
+ /** Collect a stream's events up to and including its next `ready`. */
41
+ function untilReady(stream: NotebookEvents): Promise<Protocol.EventOutput[]> {
43
42
  const events: Protocol.EventOutput[] = [];
44
- await new Promise<void>((resolve, reject) => {
43
+ return new Promise((resolve, reject) => {
45
44
  stream.subscribe((event) => {
46
45
  events.push(Protocol.Event.parse(event));
47
46
  if (event.event === "ready") {
48
- resolve();
47
+ resolve(events);
49
48
  }
50
49
  }, reject);
51
50
  });
51
+ }
52
+
53
+ /** Open a backend's stream and read until it is ready. */
54
+ async function read(
55
+ backend: ReturnType<typeof createStaticNotebookBackend>,
56
+ cursor?: string,
57
+ ) {
58
+ const stream = new NotebookEvents(backend.transport, { cursor });
59
+ streams.push(stream);
60
+ const events = await untilReady(stream);
52
61
  return { stream, events };
53
62
  }
54
63
 
64
+ /** A live backend whose stream sends nothing until `release` is called. */
65
+ function silentUntilReleased(backend: NotebookBackend) {
66
+ let release = () => {};
67
+ const released = new Promise<void>((resolve) => {
68
+ release = resolve;
69
+ });
70
+ const attempts: Array<string | null> = [];
71
+ const silent: NotebookBackend = {
72
+ ...backend,
73
+ transport: async (attempt, handlers) => {
74
+ attempts.push(attempt.cursor);
75
+ await released;
76
+ return backend.transport(attempt, handlers);
77
+ },
78
+ };
79
+ return { backend: silent, release: () => release(), attempts };
80
+ }
81
+
55
82
  describe("static notebook backend", () => {
56
83
  it("supplies a complete document through the normal event reader and reducer", async () => {
57
84
  const source = snapshot();
@@ -173,3 +200,107 @@ describe("static notebook backend", () => {
173
200
  expect(message).toHaveBeenCalledOnce();
174
201
  });
175
202
  });
203
+
204
+ describe("starting from an embedded snapshot", () => {
205
+ it("shows the snapshot until the host answers, then resets into its stream", async () => {
206
+ const embedded = snapshot();
207
+ const live = silentUntilReleased(
208
+ createStaticNotebookBackend(emptySnapshot("live")),
209
+ );
210
+ const reducer = createNotebookReducer({ document: documentResource });
211
+
212
+ // The snapshot is published on its own, with the host still silent.
213
+ const { stream, events } = await read(
214
+ startWithSnapshot(embedded, live.backend),
215
+ );
216
+ expect(events.map((event) => event.event)).toEqual([
217
+ "notebook",
218
+ "cell",
219
+ "cell",
220
+ "cell",
221
+ "document",
222
+ "ready",
223
+ ]);
224
+ const shown = events.reduce(reducer.reduce, reducer.initial);
225
+ expect(shown.notebook?.id).toBe(embedded.notebook.id);
226
+ expect(shown.resources.document.current?.cells.size).toBe(3);
227
+ expect(live.attempts).toEqual([null]);
228
+
229
+ // The host's first frame discards it, and the host's snapshot follows.
230
+ const host = untilReady(stream);
231
+ live.release();
232
+ const rest = await host;
233
+ expect(rest.map((event) => event.event)).toEqual([
234
+ "reset",
235
+ "notebook",
236
+ "document",
237
+ "ready",
238
+ ]);
239
+ const state = rest.reduce(reducer.reduce, shown);
240
+ expect(state.notebook?.id).toBe("live");
241
+ expect(state.resources.document.current?.cells.size).toBe(0);
242
+ expect(stream.status).toBe("connected");
243
+ });
244
+
245
+ it("does not open the host when cancelled while showing the snapshot", async () => {
246
+ const attempts: Array<string | null> = [];
247
+ const live: NotebookBackend = {
248
+ ...createStaticNotebookBackend(emptySnapshot("live")),
249
+ transport: async (attempt) => {
250
+ attempts.push(attempt.cursor);
251
+ },
252
+ };
253
+ const controller = new AbortController();
254
+ const message = vi.fn(() => controller.abort());
255
+
256
+ await startWithSnapshot(snapshot(), live).transport(
257
+ { cursor: null, signal: controller.signal },
258
+ { open: vi.fn(), message },
259
+ );
260
+
261
+ expect(message).toHaveBeenCalledOnce();
262
+ expect(attempts).toEqual([]);
263
+ });
264
+
265
+ it("starts the host connection fresh when resuming from the snapshot's cursor", async () => {
266
+ const embedded = snapshot();
267
+ const live = silentUntilReleased(
268
+ createStaticNotebookBackend(emptySnapshot("live")),
269
+ );
270
+ live.release();
271
+ const { events } = await read(
272
+ startWithSnapshot(embedded, live.backend),
273
+ `${embedded.notebook.id}:snapshot`,
274
+ );
275
+ expect(live.attempts).toEqual([null]);
276
+ expect(events.map((event) => event.event)).toEqual([
277
+ "reset",
278
+ "notebook",
279
+ "document",
280
+ "ready",
281
+ ]);
282
+ });
283
+
284
+ it("skips the snapshot when a connection resumes", async () => {
285
+ const backend = startWithSnapshot(
286
+ snapshot(),
287
+ createStaticNotebookBackend(emptySnapshot("live")),
288
+ );
289
+ const { events } = await read(backend, "static");
290
+ expect(events.map((event) => event.event)).toEqual([
291
+ "reset",
292
+ "notebook",
293
+ "document",
294
+ "ready",
295
+ ]);
296
+ });
297
+
298
+ it("keeps the live backend's commands", async () => {
299
+ const backend = startWithSnapshot(
300
+ snapshot(),
301
+ createStaticNotebookBackend(emptySnapshot("live")),
302
+ );
303
+ const result = await backend.notebook("live").save();
304
+ expect(result).toMatchObject({ ok: false, status: 501 });
305
+ });
306
+ });
@@ -2,7 +2,7 @@
2
2
 
3
3
  /** A backend that serves a document snapshot without a host. */
4
4
 
5
- import type { NotebookClient, NotebookTransport } from "@marimo-team/client";
5
+ import type { NotebookClient } from "@marimo-team/client";
6
6
  import type * as Protocol from "@marimo-team/client/protocol";
7
7
  import type { NotebookBackend } from "../notebook/replica";
8
8
 
@@ -13,14 +13,9 @@ export interface StaticNotebookSnapshot {
13
13
  readonly cells: readonly Protocol.Cell[];
14
14
  }
15
15
 
16
- /**
17
- * Serve a document without a server, runtime, or mutation operations.
18
- * Callers and readers must treat the snapshot records as immutable.
19
- */
20
- export function createStaticNotebookBackend(
21
- snapshot: StaticNotebookSnapshot,
22
- ): NotebookBackend {
23
- const frames: Protocol.Event[] = [
16
+ /** The events that describe a snapshot, in the order a host sends them. */
17
+ function snapshotFrames(snapshot: StaticNotebookSnapshot): Protocol.Event[] {
18
+ return [
24
19
  {
25
20
  event: "notebook",
26
21
  data: { notebook: { ...snapshot.notebook, operations: [] } },
@@ -30,6 +25,100 @@ export function createStaticNotebookBackend(
30
25
  data: { cell },
31
26
  })),
32
27
  { event: "document", data: { document: snapshot.document } },
28
+ ];
29
+ }
30
+
31
+ /** A snapshot of a notebook with no cells. */
32
+ export function emptySnapshot(notebookId: string): StaticNotebookSnapshot {
33
+ return {
34
+ notebook: {
35
+ id: notebookId,
36
+ project_id: notebookId,
37
+ path: null,
38
+ name: "",
39
+ updated_at: null,
40
+ },
41
+ document: {
42
+ notebook_id: notebookId,
43
+ order: [],
44
+ app_config: {},
45
+ etag: `${notebookId}:document`,
46
+ saved_etag: `${notebookId}:document`,
47
+ order_etag: `${notebookId}:order`,
48
+ app_config_etag: `${notebookId}:app_config`,
49
+ version: 1,
50
+ },
51
+ cells: [],
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Show a snapshot until `live` answers.
57
+ *
58
+ * The page carries the notebook it was served with, so cells appear before
59
+ * the host connects. The snapshot ends in a `ready` of its own, like a
60
+ * static notebook's, so the replica publishes it. At the host's first sign
61
+ * of life a `reset` discards it, and the host's snapshot takes over. A
62
+ * connection that resumes from a host cursor skips the snapshot; one that
63
+ * resumes from the snapshot's cursor starts the host connection fresh,
64
+ * since the host never issued it.
65
+ */
66
+ export function startWithSnapshot(
67
+ snapshot: StaticNotebookSnapshot,
68
+ live: NotebookBackend,
69
+ ): NotebookBackend {
70
+ const cursor = `${snapshot.notebook.id}:snapshot`;
71
+ return {
72
+ ...live,
73
+ transport: (attempt, handlers) => {
74
+ if (attempt.cursor !== null && attempt.cursor !== cursor) {
75
+ return live.transport(attempt, handlers);
76
+ }
77
+ if (attempt.cursor === null) {
78
+ for (const frame of snapshotFrames(snapshot)) {
79
+ if (attempt.signal.aborted) {
80
+ break;
81
+ }
82
+ handlers.message(frame);
83
+ }
84
+ if (attempt.signal.aborted) {
85
+ return Promise.resolve();
86
+ }
87
+ handlers.message({ event: "ready", id: cursor, data: {} });
88
+ }
89
+ let shown = true;
90
+ const discard = () => {
91
+ if (shown) {
92
+ shown = false;
93
+ handlers.message({ event: "reset", data: {} });
94
+ }
95
+ };
96
+ return live.transport(
97
+ { ...attempt, cursor: null },
98
+ {
99
+ open: () => {
100
+ discard();
101
+ handlers.open();
102
+ },
103
+ message: (frame) => {
104
+ discard();
105
+ handlers.message(frame);
106
+ },
107
+ },
108
+ );
109
+ },
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Serve a document without a server, runtime, or mutation operations.
115
+ * Callers and readers must treat the snapshot records as immutable.
116
+ */
117
+ export function createStaticNotebookBackend(
118
+ snapshot: StaticNotebookSnapshot,
119
+ ): NotebookBackend {
120
+ const frames: Protocol.Event[] = [
121
+ ...snapshotFrames(snapshot),
33
122
  { event: "ready", id: "static", data: {} },
34
123
  ];
35
124
 
@@ -75,10 +164,3 @@ export function createStaticNotebookBackend(
75
164
  },
76
165
  };
77
166
  }
78
-
79
- /** A connection that opens and delivers nothing, for static notebooks. */
80
- export const idle: NotebookTransport = ({ signal }, { open }) =>
81
- new Promise((settle) => {
82
- open();
83
- signal.addEventListener("abort", () => settle(), { once: true });
84
- });
@@ -235,6 +235,7 @@ describe("PyodideBridge.sendSave", () => {
235
235
  store.set(activeNotebookReplicaAtom, replica);
236
236
  const backend = createEditorBackend({
237
237
  static: false,
238
+ snapshot: null,
238
239
  transportType: "sse",
239
240
  url: () => "http://localhost/",
240
241
  headers: () => ({}),