@marimo-team/islands 0.25.3-dev48 → 0.25.3-dev51

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 (89) hide show
  1. package/dist/{ConnectedDataExplorerComponent-COS3f8io.js → ConnectedDataExplorerComponent-Da5OoEUL.js} +9 -9
  2. package/dist/{ErrorBoundary-DHfwqB0K.js → ErrorBoundary-CbxCC2kk.js} +8 -8
  3. package/dist/{abnfDiagram-VCTEODGH-DQ4bKxYa.js → abnfDiagram-VCTEODGH-Bz3mrprX.js} +2 -2
  4. package/dist/{any-language-editor-DQItQSua.js → any-language-editor-k2B8YI3Y.js} +2 -2
  5. package/dist/{architectureDiagram-5GKGNRK7-BA7-SI7Q.js → architectureDiagram-5GKGNRK7-Cs5ujb9K.js} +1 -1
  6. package/dist/{chat-ui-DlUp75HS.js → chat-ui-C1Gjgokv.js} +44 -44
  7. package/dist/{check-C209OH-W.js → check-P35dc2dH.js} +1 -1
  8. package/dist/{chunk-IMKFNOWR-DPY3kqOV.js → chunk-IMKFNOWR-CcTSaxZt.js} +1 -1
  9. package/dist/{chunk-SHT3W25Y-C14Ikodf.js → chunk-SHT3W25Y-DcJcjOaO.js} +1 -1
  10. package/dist/{chunk-SVP7TREG-BKu0HO4I.js → chunk-SVP7TREG-BVtDTjaF.js} +1 -1
  11. package/dist/{chunk-TICWLB2K-iJDDWGtE.js → chunk-TICWLB2K-BkDFkh8q.js} +1 -1
  12. package/dist/{classDiagram-ZZMXUADV-HuJXQI0F.js → classDiagram-ZZMXUADV-CfAt7HT5.js} +2 -2
  13. package/dist/{classDiagram-v2-VYDZK3BY-CpmmpTbC.js → classDiagram-v2-VYDZK3BY-CGEjNYa2.js} +2 -2
  14. package/dist/{code-visibility-5llx6KCK.js → code-visibility-DoZ9lxzx.js} +5 -5
  15. package/dist/{common-DBC1NAar.js → common-D1PacyXO.js} +75 -75
  16. package/dist/{copy-icon-L-mFk59e.js → copy-icon-CPoxUG7G.js} +2 -2
  17. package/dist/{cynefinDiagram-5FMLGOSQ-uh0puAW2.js → cynefinDiagram-5FMLGOSQ-BW6NmfJi.js} +1 -1
  18. package/dist/{dagre-GXQ25YYZ-4LBeAAMz.js → dagre-GXQ25YYZ-CWg3CTuF.js} +1 -1
  19. package/dist/{data-grid-overlay-editor-D01HzlXg.js → data-grid-overlay-editor-B50UpkXd.js} +1 -1
  20. package/dist/{diagram-S7CK7UJ4-BXPmZ2sX.js → diagram-S7CK7UJ4-BPxtoS-z.js} +1 -1
  21. package/dist/{diagram-UQ7AKVKN--Xz9UNpC.js → diagram-UQ7AKVKN-DA-vJdFL.js} +1 -1
  22. package/dist/{diagram-VX7I27RA-rt8VIRNT.js → diagram-VX7I27RA-D6bkR84S.js} +1 -1
  23. package/dist/{diagram-Z3DM3KII-Cs1GqfDD.js → diagram-Z3DM3KII-m60g4cAT.js} +1 -1
  24. package/dist/{dialog-CDbPtWUD.js → dialog-C1PcpCqX.js} +24 -24
  25. package/dist/{dropdown-menu-ugNoZmKx.js → dropdown-menu-MG5hiCIa.js} +793 -793
  26. package/dist/{ebnfDiagram-PWID7BFC-CgTsgbmZ.js → ebnfDiagram-PWID7BFC-CK99sho2.js} +2 -2
  27. package/dist/{erDiagram-RLTQ6QDP-CFoADGzT.js → erDiagram-RLTQ6QDP-Bryq0tdE.js} +1 -1
  28. package/dist/{error-banner-DMIrHA-R.js → error-banner-Sl2FlSJ4.js} +16 -16
  29. package/dist/{error-entries-D3ouOLDe.js → error-entries-CMNtA8NK.js} +25 -25
  30. package/dist/{feedback-button-gTC25Y-p.js → feedback-button-DqPqwryB.js} +8 -8
  31. package/dist/{flowDiagram-HODETNUW-BM2lvKuH.js → flowDiagram-HODETNUW-D-3aRpfS.js} +2 -2
  32. package/dist/{formats-C24TvExI.js → formats-6pLWWrY2.js} +1 -1
  33. package/dist/{glide-data-editor-BXXphavz.js → glide-data-editor-BxMhUd3K.js} +10 -10
  34. package/dist/{glide-utils-Q2ZsuA3h.js → glide-utils-Bt8_pBa2.js} +2 -2
  35. package/dist/{globals-BcxqBRmX.js → globals-DTJa_doJ.js} +3 -3
  36. package/dist/{html-to-image-VZvb968z.js → html-to-image-By72ALyI.js} +44 -44
  37. package/dist/{infoDiagram-27XIBGKW-Crwjiywp.js → infoDiagram-27XIBGKW-s1C1kgfU.js} +1 -1
  38. package/dist/{ishikawaDiagram-5VMMS53U-D4US2ShV.js → ishikawaDiagram-5VMMS53U-CCsRgolY.js} +1 -1
  39. package/dist/{kanban-definition-UXKFOSKX-Bk097CUX.js → kanban-definition-UXKFOSKX-BrmxDlcc.js} +1 -1
  40. package/dist/{label-CuEjBqYP.js → label-BRnUC0NX.js} +1 -1
  41. package/dist/main.js +614 -614
  42. package/dist/{mermaid-6EkeBlhr.js → mermaid-DKI0869I.js} +35 -35
  43. package/dist/{mindmap-definition-YA3MSWOX-DmORzfXQ.js → mindmap-definition-YA3MSWOX-641xJysF.js} +1 -1
  44. package/dist/{number-overlay-editor-CaspWg5p.js → number-overlay-editor-Z_H6x5b-.js} +1 -1
  45. package/dist/{pegDiagram-XKGWAZYB-DT5TL02S.js → pegDiagram-XKGWAZYB-keVUAUeS.js} +2 -2
  46. package/dist/{pieDiagram-E7YTZNPT-B1NwGcuH.js → pieDiagram-E7YTZNPT-DHktXPO_.js} +1 -1
  47. package/dist/{process-output-CCPgTShO.js → process-output-D-OMW5sV.js} +4 -4
  48. package/dist/{railroadDiagram-O6MQD6OU-BxoycdEu.js → railroadDiagram-O6MQD6OU-BVEF4OV_.js} +2 -2
  49. package/dist/{requirementDiagram-BXWQKSXE-DHq_eRrm.js → requirementDiagram-BXWQKSXE-CWo-UAKV.js} +1 -1
  50. package/dist/{reveal-component-CWk_paD2.js → reveal-component-tAB5K3QE.js} +9 -9
  51. package/dist/{select-D2iqeJYk.js → select-B0LRDH0g.js} +199 -199
  52. package/dist/{sequenceDiagram-WJ2MYXX4-Ba11BAOu.js → sequenceDiagram-WJ2MYXX4-CHQvLnq4.js} +1 -1
  53. package/dist/{spec-BCCcvWPS.js → spec-C-LRKGtT.js} +4 -4
  54. package/dist/{stateDiagram-D77RDMKH-OHQbvkbP.js → stateDiagram-D77RDMKH-BOJZgbUJ.js} +2 -2
  55. package/dist/{stateDiagram-v2-MP3YSRHH-BlIquvFJ.js → stateDiagram-v2-MP3YSRHH-C53XXdYH.js} +2 -2
  56. package/dist/{swimlanes-42K2YHIH-DSlitS4s.js → swimlanes-42K2YHIH-D2o1gOtw.js} +1 -1
  57. package/dist/{swimlanesDiagram-VR7AAH4N-ClwuzFNO.js → swimlanesDiagram-VR7AAH4N-BHFzN1Wk.js} +2 -2
  58. package/dist/{timeline-definition-24CTP7MA-hAzc5VkT.js → timeline-definition-24CTP7MA-rv7QUa46.js} +1 -1
  59. package/dist/toDate-CjPuEPou.js +705 -0
  60. package/dist/{toast-DxVfYlCp.js → toast-CFWpihJU.js} +221 -221
  61. package/dist/{tooltip-BCntm-jf.js → tooltip-CKO1pHJo.js} +51 -51
  62. package/dist/{use-restore-focus-BhXhsNLV.js → use-restore-focus-DBCITLeF.js} +1 -1
  63. package/dist/{useAsyncData-BbuzBAUp.js → useAsyncData-ChgNtdmE.js} +1 -1
  64. package/dist/{useDateFormatter-6AHbIR-J.js → useDateFormatter-c_yJtkAe.js} +2 -2
  65. package/dist/{useDeepCompareMemoize-da5A3c-Y.js → useDeepCompareMemoize-Bet7kSsI.js} +1 -1
  66. package/dist/{useLifecycle-C3nT1kwI.js → useLifecycle-VVmz_24o.js} +2 -2
  67. package/dist/{useTheme-BC8Lof0B.js → useTheme-DympCsQg.js} +83 -83
  68. package/dist/{vega-component-CF9IzE2r.js → vega-component-DnfQeoyt.js} +7 -7
  69. package/dist/{vennDiagram-4TSXK5OY-CINKaJgr.js → vennDiagram-4TSXK5OY-D36Zk7t-.js} +1 -1
  70. package/dist/{wardleyDiagram-VM6X3IG4-KG2tlXg_.js → wardleyDiagram-VM6X3IG4-BLBolvyQ.js} +1 -1
  71. package/dist/{xychartDiagram-S5SC5T6Z-XIH5cwgI.js → xychartDiagram-S5SC5T6Z-CfH57l0v.js} +1 -1
  72. package/package.json +1 -1
  73. package/src/__tests__/mount.test.ts +0 -20
  74. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +2 -2
  75. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +2 -2
  76. package/src/core/host/__tests__/notebook-client.test.ts +66 -3
  77. package/src/core/host/events.ts +1 -10
  78. package/src/core/host/notebook-client.ts +11 -18
  79. package/src/core/runtime/__tests__/runtime.test.ts +2 -53
  80. package/src/core/runtime/runtime.ts +1 -14
  81. package/src/core/runtime/types.ts +0 -2
  82. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +2 -23
  83. package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +17 -25
  84. package/src/core/websocket/transports/__tests__/sse.test.ts +17 -0
  85. package/src/core/websocket/transports/sse.ts +14 -2
  86. package/src/core/websocket/useNotebookClient.ts +1 -11
  87. package/src/core/websocket/useNotebookSession.tsx +1 -6
  88. package/src/mount.tsx +0 -4
  89. package/dist/toDate-C0jaysq2.js +0 -1184
@@ -6,7 +6,6 @@ import { initialLayoutState, layoutStateAtom } from "@/core/layout/state";
6
6
  import { kioskModeAtom } from "@/core/mode";
7
7
  import { connectionAtom } from "@/core/network/connection";
8
8
  import { store } from "@/core/state/jotai";
9
- import { runtimeConfigAtom } from "@/core/runtime/config";
10
9
  import { isStaticNotebook } from "@/core/static/static-state";
11
10
  import { ConnectionState } from "@/core/websocket/types";
12
11
  import { pairConfigAtom } from "@/core/config/pair";
@@ -102,25 +101,6 @@ describe("mount", () => {
102
101
  });
103
102
 
104
103
  describe("connection state initialization", () => {
105
- it("passes the host notebook identity to the local runtime", () => {
106
- mount({ ...baseOptions, notebookId: "nb_local" }, mockElement);
107
- expect(store.get(runtimeConfigAtom).notebookId).toBe("nb_local");
108
- });
109
-
110
- it("uses a remote runtime's own notebook identity", () => {
111
- mount(
112
- {
113
- ...baseOptions,
114
- notebookId: "nb_local",
115
- runtimeConfig: [
116
- { url: "https://remote.example", notebookId: "nb_remote" },
117
- ],
118
- },
119
- mockElement,
120
- );
121
- expect(store.get(runtimeConfigAtom).notebookId).toBe("nb_remote");
122
- });
123
-
124
104
  it("should set connection to CONNECTING when runtimeConfig has lazy=false", () => {
125
105
  mount(
126
106
  {
@@ -16,6 +16,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
16
16
  import { toast } from "@/components/ui/use-toast";
17
17
  import type { Attachment } from "@/core/attachments/state";
18
18
  import { pairConfigAtom } from "@/core/config/pair";
19
+ import { createNotebookClient } from "@/core/host/notebook-client";
19
20
  import {
20
21
  type StableSessionId,
21
22
  stableSessionIdAtom,
@@ -45,10 +46,9 @@ function renderStatus(attachments: Attachment[] = [AGENT], preview = true) {
45
46
  store.set(connectionAtom, { state: ConnectionState.OPEN });
46
47
  store.set(pairConfigAtom, { ...PAIR_CONFIG, hostEnabled: preview });
47
48
  store.set(notebookClientAtom, {
49
+ ...createNotebookClient(store).client,
48
50
  connectionAtom: atom({ state: ConnectionState.OPEN }),
49
- documentAtom: atom(null),
50
51
  attachmentsAtom,
51
- reconnect: async () => undefined,
52
52
  });
53
53
  store.set(attachmentsAtom, attachments);
54
54
  store.set(stableSessionIdAtom, "stable/test" as StableSessionId);
@@ -25,6 +25,7 @@ import {
25
25
  } from "@/core/cells/error-report";
26
26
  import { pairConfigAtom } from "@/core/config/pair";
27
27
  import { notebookStateFromSession } from "@/core/cells/session";
28
+ import { createNotebookClient } from "@/core/host/notebook-client";
28
29
  import type { MarimoError, OutputMessage } from "@/core/kernel/messages";
29
30
  import {
30
31
  type StableSessionId,
@@ -97,10 +98,9 @@ function renderButton({
97
98
  store.set(connectionAtom, { state: ConnectionState.OPEN });
98
99
  store.set(pairConfigAtom, { ...PAIR_CONFIG, hostEnabled: preview });
99
100
  store.set(notebookClientAtom, {
101
+ ...createNotebookClient(store).client,
100
102
  connectionAtom: atom({ state: ConnectionState.OPEN }),
101
- documentAtom: atom(null),
102
103
  attachmentsAtom,
103
- reconnect: async () => undefined,
104
104
  });
105
105
  store.set(attachmentsAtom, attachments);
106
106
  store.set(stableSessionIdAtom, stableId);
@@ -49,12 +49,10 @@ function setup(url: () => string = () => "ws://localhost/ws") {
49
49
  return transport;
50
50
  },
51
51
  url,
52
- messageFormat: "host",
53
52
  signal,
54
53
  waitUntilReady: () => healthy.promise,
55
54
  checkHealth,
56
55
  onNotification: (notification) => onNotification({ data: notification }),
57
- onMessageError: vi.fn(),
58
56
  decodeJson: jsonParseWithSpecialChar,
59
57
  classifyClose: classifyCloseEvent,
60
58
  });
@@ -76,6 +74,71 @@ function setup(url: () => string = () => "ws://localhost/ws") {
76
74
  }
77
75
 
78
76
  describe("NotebookClient lifetime", () => {
77
+ it("exposes identity and capabilities only after the notebook is ready", async () => {
78
+ const { connect, healthy, events, receive, client, store } = setup();
79
+ const lifetime = new AbortController();
80
+ connect(lifetime.signal);
81
+ healthy.resolve();
82
+ await healthy.promise;
83
+ events.dispatchEvent(new Event("open"));
84
+ const notebook = {
85
+ id: "nb/with space",
86
+ project_id: "p1",
87
+ path: null,
88
+ name: "Notebook",
89
+ operations: ["notebook.edit"],
90
+ updated_at: null,
91
+ };
92
+ receive({ event: "notebook", data: { notebook } });
93
+ expect(store.get(client.notebookAtom)).toBeNull();
94
+ receive({ event: "ready", id: "1", data: {} });
95
+ expect(store.get(client.notebookAtom)).toEqual(notebook);
96
+ receive({
97
+ event: "notebook",
98
+ id: "2",
99
+ data: { notebook: { ...notebook, operations: [] } },
100
+ });
101
+ expect(store.get(client.notebookAtom)?.operations).toEqual([]);
102
+ receive({ event: "notebook.removed", id: "3", data: { id: notebook.id } });
103
+ expect(store.get(client.notebookAtom)).toBeNull();
104
+ lifetime.abort();
105
+ });
106
+
107
+ it("rebuilds the replica after a reconnect reset selects a new notebook", async () => {
108
+ const notebookId = "n2";
109
+ const { connect, events, healthy, urls, receive, client, store } = setup(
110
+ () => "ws://localhost/ws?session_id=visitor",
111
+ );
112
+ const lifetime = new AbortController();
113
+ connect(lifetime.signal);
114
+ healthy.resolve();
115
+ await healthy.promise;
116
+ events.dispatchEvent(new Event("open"));
117
+ fixtureMessages("notebook-events.sse").forEach(receive);
118
+ expect(store.get(client.documentAtom)?.revision.notebook_id).toBe("n1");
119
+
120
+ events.dispatchEvent(new CloseEvent("close"));
121
+ expect(urls.at(-1)?.searchParams.get("cursor")).toBe("97");
122
+ expect(store.get(client.documentAtom)?.revision.notebook_id).toBe("n1");
123
+ expect(store.get(client.notebookAtom)).toBeNull();
124
+ events.dispatchEvent(new Event("open"));
125
+ receive({ event: "reset", data: {} });
126
+ expect(store.get(client.documentAtom)).toBeNull();
127
+ for (const event of fixtureMessages("notebook-events.sse")) {
128
+ receive(
129
+ JSON.parse(
130
+ JSON.stringify(event).replaceAll('"n1"', JSON.stringify(notebookId)),
131
+ ),
132
+ );
133
+ }
134
+ expect(store.get(client.documentAtom)?.revision.notebook_id).toBe(
135
+ notebookId,
136
+ );
137
+ expect(store.get(client.connectionAtom).state).toBe(ConnectionState.OPEN);
138
+ expect(store.get(client.notebookAtom)?.id).toBe(notebookId);
139
+ lifetime.abort();
140
+ });
141
+
79
142
  it.each(["{broken", JSON.stringify({ event: "ready", data: {} })])(
80
143
  "stops consuming a malformed host stream: %s",
81
144
  (data) => {
@@ -423,7 +486,7 @@ describe("NotebookClient attachments", () => {
423
486
  },
424
487
  );
425
488
 
426
- it("keeps legacy sessions empty", async () => {
489
+ it("keeps host resources empty for notification-only WASM connections", async () => {
427
490
  const { client, store, receive, lifetime } = await open();
428
491
  receive({ data: { op: "kernel-ready" } });
429
492
  receive({ data: { op: "reconnected" } });
@@ -10,15 +10,6 @@ import * as Protocol from "@marimo-team/client/protocol";
10
10
  * and cursor so clients can skip them without losing their place in the stream.
11
11
  */
12
12
 
13
- /** Event kinds requested by the client; removals and controls are implicit. */
14
- export const NOTEBOOK_EVENT_TYPES = [
15
- "notebook",
16
- "document",
17
- "cell",
18
- "attachment",
19
- "marimo",
20
- ] as const satisfies readonly (Protocol.ObjectType | "marimo")[];
21
-
22
13
  const envelope = z.object({
23
14
  event: z.string().min(1),
24
15
  data: z.unknown(),
@@ -54,7 +45,7 @@ export type NotebookEvent =
54
45
  const sessionEnvelope = z.object({ data: z.unknown() });
55
46
 
56
47
  /**
57
- * Decode a host event or a legacy `{ data: notification }` session message.
48
+ * Decode a host event or an in-process WASM `{ data: notification }` message.
58
49
  *
59
50
  * Throws if the envelope or a known event is malformed. Native notification
60
51
  * payloads are passed through for the session handler to interpret.
@@ -1,7 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { Logger } from "@/utils/Logger";
4
- import type { Attachment } from "@marimo-team/client/protocol";
4
+ import type { Attachment, NotebookOutput } from "@marimo-team/client/protocol";
5
5
  import { createReducer } from "@marimo-team/client/reducer";
6
6
  import { type Atom, atom, type createStore } from "jotai/vanilla";
7
7
  import {
@@ -27,6 +27,8 @@ const UNCURSORED = new Set<NotebookEvent["event"]>([
27
27
  export interface NotebookClient {
28
28
  /** Client connection status, independent of application session readiness. */
29
29
  readonly connectionAtom: Atom<ConnectionStatus>;
30
+ /** Identity and capabilities, available while the stream is connected and ready. */
31
+ readonly notebookAtom: Atom<NotebookOutput | null>;
30
32
  /**
31
33
  * The most recently confirmed document.
32
34
  *
@@ -47,8 +49,6 @@ export interface NotebookConnection {
47
49
  createTransport: (url: () => string) => NotebookTransport;
48
50
  /** The current stream URL, including authentication and subscription options. */
49
51
  url: () => string;
50
- /** Host events require an intact stream; legacy notifications are independent. */
51
- messageFormat: "host" | "session";
52
52
  /** Abort to close the transport and clear the client's state. */
53
53
  signal: AbortSignal;
54
54
  /** Wait until the host can accept the initial connection. */
@@ -59,8 +59,6 @@ export interface NotebookConnection {
59
59
  decodeJson: (message: string) => unknown;
60
60
  /** Handle a native marimo notification. Its payload has not been validated. */
61
61
  onNotification: (notification: unknown) => void;
62
- /** Report a malformed message without coupling the client to editor UI. */
63
- onMessageError: (error: unknown) => void;
64
62
  /** Choose the connection status and retry policy after the transport closes. */
65
63
  classifyClose: (event: CloseEvent) => CloseDecision;
66
64
  }
@@ -75,6 +73,11 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
75
73
  const streamAtom = atom(reducer.initial);
76
74
  // A resumed stream is live, but presence waits for this connection's ready.
77
75
  const readyAtom = atom(false);
76
+ const notebookAtom = atom((get) =>
77
+ get(readyAtom) && get(connectionAtom).state === ConnectionState.OPEN
78
+ ? get(streamAtom).notebook
79
+ : null,
80
+ );
78
81
  const documentAtom = atom(
79
82
  (get) => get(streamAtom).resources.document.current,
80
83
  );
@@ -86,6 +89,7 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
86
89
  let reconnect: (() => Promise<void>) | undefined;
87
90
  const client: NotebookClient = {
88
91
  connectionAtom,
92
+ notebookAtom,
89
93
  documentAtom,
90
94
  attachmentsAtom,
91
95
  reconnect: async () => reconnect?.(),
@@ -94,12 +98,10 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
94
98
  function connect({
95
99
  createTransport,
96
100
  url,
97
- messageFormat,
98
101
  signal,
99
102
  waitUntilReady,
100
103
  checkHealth,
101
104
  onNotification,
102
- onMessageError,
103
105
  decodeJson,
104
106
  classifyClose,
105
107
  }: NotebookConnection) {
@@ -113,9 +115,6 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
113
115
  // The id of the last live event. Null before `ready` and after `reset`.
114
116
  let cursor: string | null = null;
115
117
  const transport = createTransport(() => {
116
- if (messageFormat === "session") {
117
- return url();
118
- }
119
118
  const next = new URL(url());
120
119
  next.searchParams.delete("cursor");
121
120
  if (signal.aborted) {
@@ -131,11 +130,9 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
131
130
  // A live stream keeps its state and resumes from the cursor. An
132
131
  // incomplete snapshot starts over.
133
132
  const state =
134
- key !== streamKey
133
+ key !== streamKey || current.phase === "snapshot"
135
134
  ? reducer.initial
136
- : current.phase === "snapshot"
137
- ? { ...reducer.initial, notebookId: current.notebookId }
138
- : current;
135
+ : current;
139
136
  if (state.phase === "snapshot") {
140
137
  cursor = null;
141
138
  }
@@ -226,10 +223,6 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
226
223
  }
227
224
  } catch (error) {
228
225
  Logger.error("Failed to decode notebook message", error);
229
- if (messageFormat === "session") {
230
- onMessageError(error);
231
- return;
232
- }
233
226
  store.set(streamAtom, reducer.initial);
234
227
  cursor = null;
235
228
  terminal = true;
@@ -2,11 +2,7 @@
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { Mocks } from "@/__mocks__/common";
5
- import {
6
- generateSessionId,
7
- isSessionId,
8
- type SessionId,
9
- } from "@/core/kernel/session";
5
+ import type { SessionId } from "@/core/kernel/session";
10
6
  import { Logger } from "@/utils/Logger";
11
7
  import { RuntimeManager } from "../runtime";
12
8
  import type { RuntimeConfig } from "../types";
@@ -56,38 +52,6 @@ describe("RuntimeManager", () => {
56
52
  });
57
53
 
58
54
  describe("getWsURL", () => {
59
- it("uses host notebook events with the existing session and auth parameters", () => {
60
- const original = window.location.href;
61
- window.history.replaceState({}, "", "/?types=run");
62
- try {
63
- const runtime = new RuntimeManager({
64
- ...mockConfig,
65
- url: "https://example.com/nested?file=notebook.py",
66
- notebookId: "nb_123",
67
- });
68
- const sessionId = "s_123456";
69
- if (!isSessionId(sessionId)) {
70
- throw new Error("Invalid test session ID");
71
- }
72
- const url = runtime.getWsURL(sessionId);
73
- expect({
74
- endpoint: `${url.origin}${url.pathname}`,
75
- params: Object.fromEntries(url.searchParams),
76
- }).toEqual({
77
- endpoint:
78
- "wss://example.com/nested/api/marimo/v1/notebooks/nb_123/events/ws",
79
- params: {
80
- file: "notebook.py",
81
- types: "notebook,document,cell,attachment,marimo",
82
- session_id: sessionId,
83
- access_token: "test-token",
84
- },
85
- });
86
- } finally {
87
- window.history.replaceState({}, "", original);
88
- }
89
- });
90
-
91
55
  it("should return WebSocket URL with session ID for https", () => {
92
56
  const runtime = new RuntimeManager(mockConfig);
93
57
  const sessionId = "1234" as SessionId;
@@ -257,21 +221,6 @@ describe("RuntimeManager", () => {
257
221
  });
258
222
 
259
223
  describe("getSseURL", () => {
260
- it.each([undefined, "nb_123"])(
261
- "uses the legacy session stream when notebookId is %s",
262
- (notebookId) => {
263
- const runtime = new RuntimeManager({
264
- ...mockConfig,
265
- url: "https://example.com/nested?file=notebook.py",
266
- notebookId,
267
- });
268
- const sessionId = generateSessionId();
269
- expect(runtime.getSseURL(sessionId).toString()).toBe(
270
- `https://example.com/nested/sse?file=notebook.py&session_id=${sessionId}`,
271
- );
272
- },
273
- );
274
-
275
224
  it("should never put the auth token in the URL, even cross-origin", () => {
276
225
  // SSE requests can send headers, so auth travels in the
277
226
  // Authorization header; a token in the URL would leak into
@@ -781,7 +730,7 @@ describe("RuntimeManager", () => {
781
730
  );
782
731
  });
783
732
 
784
- it("should throw when creating WebSocket URLs from blob URLs", () => {
733
+ it("warns when creating WebSocket URLs from blob URLs", () => {
785
734
  const runtime = new RuntimeManager({
786
735
  url: "blob:https://example.com/12345678-1234-1234-1234-123456789abc",
787
736
  lazy: true,
@@ -3,7 +3,6 @@
3
3
  import { Deferred } from "@/utils/Deferred";
4
4
  import { Logger } from "@/utils/Logger";
5
5
  import { KnownQueryParams } from "../constants";
6
- import { NOTEBOOK_EVENT_TYPES } from "../host/events";
7
6
  import { isIslands } from "../islands/utils";
8
7
  import { getSessionId, type SessionId } from "../kernel/session";
9
8
  import { isStaticNotebook } from "../static/static-state";
@@ -155,17 +154,7 @@ export class RuntimeManager {
155
154
  */
156
155
  public getWsURL(sessionId: SessionId): URL {
157
156
  const searchParams = this.getSessionSearchParams(sessionId);
158
- // TODO(MO-8039): Rediscover hosts and resolve notebooks on reconnect.
159
- const notebookId = this.config.notebookId;
160
- const path = notebookId
161
- ? `/api/marimo/v1/notebooks/${encodeURIComponent(notebookId)}/events/ws`
162
- : "/ws";
163
- const url = this.formatWsURL(path, searchParams);
164
- if (notebookId) {
165
- // TODO(MO-8034): Separate host options from notebook query params.
166
- url.searchParams.set("types", NOTEBOOK_EVENT_TYPES.join(","));
167
- }
168
- return url;
157
+ return this.formatWsURL("/ws", searchParams);
169
158
  }
170
159
 
171
160
  /**
@@ -175,8 +164,6 @@ export class RuntimeManager {
175
164
  * (see `headers()`) instead of the URL.
176
165
  */
177
166
  public getSseURL(sessionId: SessionId): URL {
178
- // TODO: Migrate editor SSE to the host event stream. Until then, host
179
- // state such as attachments is unavailable on this legacy connection.
180
167
  const url = this.formatHttpURL({
181
168
  path: "/sse",
182
169
  searchParams: this.getSessionSearchParams(sessionId),
@@ -1,8 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  export interface RuntimeConfig {
4
- /** The host notebook for WebSocket events. SSE uses the legacy session stream. */
5
- readonly notebookId?: string;
6
4
  /**
7
5
  * The URL of the runtime server.
8
6
  */
@@ -15,11 +15,12 @@ import { notebookClientAtom } from "../state";
15
15
 
16
16
  vi.mock("../transports/create", () => ({ createConnectionTransport: vi.fn() }));
17
17
 
18
- function makeTransport() {
18
+ function makeTransport(url?: () => string) {
19
19
  const events = new EventTarget();
20
20
  const transport: IConnectionTransport = {
21
21
  readyState: WebSocket.CLOSED,
22
22
  reconnect: vi.fn(() => {
23
+ url?.();
23
24
  transport.readyState = WebSocket.OPEN;
24
25
  events.dispatchEvent(new Event("open"));
25
26
  }),
@@ -52,16 +53,13 @@ function makeTransport() {
52
53
  function setup(strict = false) {
53
54
  const store = createStore();
54
55
  const options = {
55
- sessionId: "first",
56
56
  static: false,
57
57
  transportType: "websocket" as const,
58
- messageFormat: "host" as const,
59
58
  url: () => "ws://localhost/ws",
60
59
  headers: () => ({}),
61
60
  waitUntilReady: async () => undefined,
62
61
  checkHealth: async () => true,
63
62
  onNotification: vi.fn(),
64
- onMessageError: vi.fn(),
65
63
  };
66
64
  const wrapper = ({ children }: PropsWithChildren) => (
67
65
  <Provider store={store}>{children}</Provider>
@@ -121,25 +119,6 @@ describe("useNotebookClient", () => {
121
119
  unmount();
122
120
  });
123
121
 
124
- it("replaces the transport when session identity changes and ignores old messages", async () => {
125
- const first = makeTransport();
126
- const second = makeTransport();
127
- vi.mocked(createConnectionTransport)
128
- .mockReturnValueOnce(first.transport)
129
- .mockReturnValueOnce(second.transport);
130
- const { rerender, options, unmount } = setup();
131
- await act(async () => Promise.resolve());
132
- rerender({ ...options, sessionId: "second" });
133
- await act(async () => Promise.resolve());
134
- expect(first.transport.close).toHaveBeenCalledOnce();
135
- act(() => {
136
- first.notify();
137
- second.notify();
138
- });
139
- expect(options.onNotification).toHaveBeenCalledOnce();
140
- unmount();
141
- });
142
-
143
122
  it("cleans up every transport under Strict Mode and only connects the active lifetime", async () => {
144
123
  const transports: ReturnType<typeof makeTransport>[] = [];
145
124
  vi.mocked(createConnectionTransport).mockImplementation(() => {
@@ -36,7 +36,6 @@ vi.mock("@/core/runtime/config", async () => {
36
36
  });
37
37
 
38
38
  import { MockNotebook } from "@/__mocks__/notebook";
39
- import * as Toast from "@/components/ui/use-toast";
40
39
  import { Logger } from "@/utils/Logger";
41
40
  import { Deferred } from "@/utils/Deferred";
42
41
  import { cellId } from "@/__tests__/branded";
@@ -235,36 +234,29 @@ describe("useNotebookSession messages", () => {
235
234
  }
236
235
 
237
236
  it.each(["{broken", "null"])(
238
- "reports a malformed legacy frame and continues applying notifications: %s",
237
+ "closes a malformed stream and ignores subsequent notifications: %s",
239
238
  (data) => {
240
- const toast = vi.spyOn(Toast, "toast");
241
- try {
242
- receive({ data: { op: "reconnected" } });
243
- act(() =>
244
- getTransportHandlers().onMessage(
245
- new MessageEvent("message", { data }),
246
- ),
247
- );
248
- expect(toast).toHaveBeenCalledExactlyOnceWith({
249
- title: "Failed to handle message",
250
- description: expect.any(String),
251
- variant: "danger",
252
- });
253
- expect(store.get(connectionAtom)).toEqual({
254
- state: ConnectionState.OPEN,
255
- });
256
- receive({
257
- data: {
239
+ receive({ event: "marimo", data: { marimo: { op: "reconnected" } } });
240
+ act(() =>
241
+ getTransportHandlers().onMessage(new MessageEvent("message", { data })),
242
+ );
243
+ expect(store.get(connectionAtom)).toEqual({
244
+ state: ConnectionState.CLOSED,
245
+ code: "KERNEL_DISCONNECTED",
246
+ reason: "Failed to decode notebook message",
247
+ });
248
+ receive({
249
+ event: "marimo",
250
+ data: {
251
+ marimo: {
258
252
  op: "completed-run",
259
253
  run_id: "r1",
260
254
  outcome: "succeeded",
261
255
  error: null,
262
256
  },
263
- });
264
- expect(store.get(initialRunCompletedAtom)).toBe(true);
265
- } finally {
266
- toast.mockRestore();
267
- }
257
+ },
258
+ });
259
+ expect(store.get(initialRunCompletedAtom)).toBe(false);
268
260
  },
269
261
  );
270
262
 
@@ -90,6 +90,23 @@ describe("SseTransport", () => {
90
90
  expect(messages[1].data).toBe("line1\nline2");
91
91
  });
92
92
 
93
+ it("preserves host event names and cursors without carrying IDs into snapshots", async () => {
94
+ const { stream, push } = createControllableStream();
95
+ fetchMock.mockResolvedValueOnce(sseResponse(stream));
96
+ transport.reconnect();
97
+ await vi.waitFor(() => expect(opens).toHaveLength(1));
98
+
99
+ push("id: cursor-1\nevent: rea");
100
+ push("dy\ndata: {}\n\n: keep-alive\n\nevent: reset\ndata: {}\n\n");
101
+ push('event: marimo\ndata: {"marimo":\ndata: {"op":"reconnected"}}\n\n');
102
+ await vi.waitFor(() => expect(messages).toHaveLength(3));
103
+ expect(messages.map((message) => JSON.parse(message.data))).toEqual([
104
+ { event: "ready", id: "cursor-1", data: {} },
105
+ { event: "reset", data: {} },
106
+ { event: "marimo", data: { marimo: { op: "reconnected" } } },
107
+ ]);
108
+ });
109
+
93
110
  it("ignores keep-alive comments", async () => {
94
111
  const { stream, push } = createControllableStream();
95
112
  fetchMock.mockResolvedValueOnce(sseResponse(stream));
@@ -218,16 +218,20 @@ export class SseTransport implements IConnectionTransport {
218
218
  const decoder = new TextDecoder();
219
219
  let buffer = "";
220
220
  let eventName: string | null = null;
221
+ let eventId: string | null = null;
221
222
  let dataLines: string[] = [];
222
223
 
223
224
  const dispatch = (): ServerClose | null => {
224
225
  if (dataLines.length === 0) {
225
226
  eventName = null;
227
+ eventId = null;
226
228
  return null;
227
229
  }
228
230
  const data = dataLines.join("\n");
229
231
  const name = eventName;
232
+ const id = eventId;
230
233
  eventName = null;
234
+ eventId = null;
231
235
  dataLines = [];
232
236
  if (name === "close") {
233
237
  return parseServerClose(data);
@@ -236,9 +240,15 @@ export class SseTransport implements IConnectionTransport {
236
240
  // (rather than on open, so a server that accepts and immediately
237
241
  // closes cannot retry forever).
238
242
  this.retryCount = 0;
243
+ // Normalize SSE fields into the same envelope as host WebSockets.
244
+ // Leave the payload text intact for the client's JSON decoder.
245
+ const envelope =
246
+ name === null
247
+ ? data
248
+ : `{"event":${JSON.stringify(name)},"data":${data}${id === null ? "" : `,"id":${JSON.stringify(id)}`}}`;
239
249
  this.subscriptions.notify(
240
250
  "message",
241
- new MessageEvent("message", { data }),
251
+ new MessageEvent("message", { data: envelope }),
242
252
  );
243
253
  return null;
244
254
  };
@@ -284,8 +294,10 @@ export class SseTransport implements IConnectionTransport {
284
294
  dataLines.push(fieldValue);
285
295
  } else if (field === "event") {
286
296
  eventName = fieldValue;
297
+ } else if (field === "id") {
298
+ eventId = fieldValue;
287
299
  }
288
- // `id` and `retry` fields are unused.
300
+ // The host client owns the cursor; `retry` does not override our policy.
289
301
  }
290
302
  if (cursor > 0) {
291
303
  buffer = buffer.slice(cursor);