@marimo-team/islands 0.25.3-dev19 → 0.25.3-dev20

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.
@@ -135,7 +135,7 @@ function F(e) {
135
135
  }
136
136
  //#endregion
137
137
  //#region src/components/editor/boundary/ErrorBoundary.tsx
138
- var I = (0, T.lazy)(() => import("./feedback-button-_f7fx7zj.js").then((e) => ({ default: e.FeedbackModal }))), L = (e) => {
138
+ var I = (0, T.lazy)(() => import("./feedback-button-wnAMXOGA.js").then((e) => ({ default: e.FeedbackModal }))), L = (e) => {
139
139
  let t = (0, w.c)(2), n;
140
140
  return t[0] === e.children ? n = t[1] : (n = /* @__PURE__ */ (0, j.jsx)(O, {
141
141
  FallbackComponent: R,
@@ -18,7 +18,7 @@ function x() {
18
18
  return e.warn("Failed to get version from mount config"), null;
19
19
  }
20
20
  }
21
- var S = x() || "0.25.3-dev19", C = u(S), w = u(!0);
21
+ var S = x() || "0.25.3-dev20", C = u(S), w = u(!0);
22
22
  u(null);
23
23
  //#endregion
24
24
  //#region src/core/wasm/state.ts
@@ -12,7 +12,7 @@ import { t as dt } from "./check-EDwSwP89.js";
12
12
  import { A as ft, C as pt, M as mt, O as ht, S as gt, _ as _t, b as vt, c as yt, d as bt, g as xt, h as St, i as Ct, l as wt, m as Tt, n as Et, p as Dt, s as Ot, t as kt, u as At, v as jt, y as Mt } from "./select-BButFi-d.js";
13
13
  import { $ as Nt, $t as Pt, A as Ft, At as It, B as Lt, Bt as Rt, Ct as zt, D as Bt, Dt as Vt, E as Ht, Et as Ut, F as Wt, Ft as Gt, G as Kt, Gt as qt, H as Jt, Ht as Yt, I as Xt, It as Zt, J as Qt, Jt as $t, Kt as en, L as tn, Lt as nn, M as rn, Mt as an, N as on, Nt as sn, O as cn, Ot as ln, P as un, Pt as dn, Q as fn, Qt as pn, R as mn, Rt as hn, S as gn, St as _n, T as vn, Tt as yn, U as bn, Ut as xn, V as Sn, Vt as Cn, W as wn, Wt as Tn, X as En, Xt as Dn, Y as On, Yt as kn, Z as An, Zt as jn, _ as Mn, _n as Nn, _t as Pn, a as Fn, at as In, b as Ln, bt as Rn, cn as zn, ct as Bn, d as Vn, dn as Hn, dt as Un, et as Wn, f as Gn, fn as Kn, ft as qn, gn as Jn, gt as Yn, h as Xn, hn as Zn, ht as Qn, i as $n, in as er, it as tr, j as nr, jt as rr, k as ir, kt as ar, l as or, lt as sr, mn as cr, mt as lr, n as ur, nn as dr, nt as fr, o as pr, on as mr, ot as hr, pn as gr, pt as _r, qt as vr, rn as yr, rt as br, s as xr, sn as Sr, st as Cr, t as wr, tn as Tr, tt as Er, u as Dr, un as Or, vn as kr, vt as Ar, w as jr, wt as Mr, x as Nr, xt as Pr, y as Fr, yt as Ir, z as Lr, zt as Rr } from "./dropdown-menu-CP_qCYHC.js";
14
14
  import { a as zr, c as Br, d as Vr, i as Hr, l as Ur, n as Wr, o as Gr, r as Kr, t as qr, v as Jr, x as Yr, y as Xr } from "./toDate-UtiwmnH2.js";
15
- import { n as Zr, t as Qr } from "./ErrorBoundary-CBHB755J.js";
15
+ import { n as Zr, t as Qr } from "./ErrorBoundary-DAPIRYqd.js";
16
16
  import { t as $r } from "./clsx-CoeGtWvn.js";
17
17
  import { t as ei } from "./react-dom-DUmH0GXv.js";
18
18
  import { t as ti } from "./jsx-runtime-3BD1kT0s.js";
@@ -2,7 +2,7 @@ import { a as e } from "./rolldown-runtime-Cb76FRpK.js";
2
2
  import { t } from "./react-sHtZdjeP.js";
3
3
  import { t as n } from "./react-dom-DUmH0GXv.js";
4
4
  import { c as r, l as i, u as a } from "./data-grid-types-BH_r7g7l.js";
5
- import { a as o, i as s, n as c, r as l } from "./glide-data-editor-Cmu4vWKD.js";
5
+ import { a as o, i as s, n as c, r as l } from "./glide-data-editor-KvS90MGh.js";
6
6
  //#region ../node_modules/.pnpm/@glideapps+glide-data-grid@6.0.4-alpha24_lodash@4.17.21_marked@15.0.12_react-dom@19.3.0_aca11a353ce3b112922852200b9c160f/node_modules/@glideapps/glide-data-grid/dist/esm/internal/data-grid-overlay-editor/data-grid-overlay-editor-style.js
7
7
  var u = /* @__PURE__ */ e(t(), 1), d = /* @__PURE__ */ e(n(), 1), f = /*#__PURE__*/ o("div")({
8
8
  name: "DataGridOverlayEditorStyle",
@@ -5,12 +5,12 @@ import { t as i } from "./compiler-runtime-CldRdSsv.js";
5
5
  import { t as a } from "./copy-icon-Ft7t9bqz.js";
6
6
  import { G as o, Lt as s, Ot as c, V as l, fn as u, h as d, i as f, n as p, q as m, zt as h } from "./error-entries-Bv6bZPF3.js";
7
7
  import { b as g } from "./select-BButFi-d.js";
8
- import { n as ee, t as te } from "./globals-1Hfhz6Dx.js";
8
+ import { n as ee, t as te } from "./globals-yruoELTW.js";
9
9
  import { t as _ } from "./jsx-runtime-3BD1kT0s.js";
10
10
  import { C as v, _ as ne, i as y } from "./useTheme-CRnjkku0.js";
11
11
  import { a as re, i as ie, n as ae, r as oe } from "./dialog-CEBFZNyc.js";
12
12
  import { t as se } from "./useAsyncData-Deh5kszT.js";
13
- import { t as ce } from "./code-visibility-CEXXuuOE.js";
13
+ import { t as ce } from "./code-visibility-DkCqn-PO.js";
14
14
  //#region src/components/ui/skeleton.tsx
15
15
  var b = /* @__PURE__ */ e(r(), 1), x = i(), S = _(), le = (e) => {
16
16
  let n = (0, x.c)(8), r, i;
@@ -6,7 +6,7 @@ import { i as s, n as c, r as l } from "./copy-icon-Ft7t9bqz.js";
6
6
  import { C as u, c as d, i as f, n as p, s as m, t as h, x as g } from "./select-BButFi-d.js";
7
7
  import { d as _, h as v, i as y, l as b, n as x, o as S, s as C, t as w, u as T } from "./dropdown-menu-CP_qCYHC.js";
8
8
  import { c as E, d as D, l as O, n as k, o as A, s as ee, t as j, u as te } from "./glide-utils-DJyqh0EM.js";
9
- import { n as ne, t as re } from "./ErrorBoundary-CBHB755J.js";
9
+ import { n as ne, t as re } from "./ErrorBoundary-DAPIRYqd.js";
10
10
  import { t as M } from "./react-dom-DUmH0GXv.js";
11
11
  import { t as N } from "./jsx-runtime-3BD1kT0s.js";
12
12
  import { t as ie } from "./label-BDKF3Afc.js";
@@ -7062,7 +7062,7 @@ function rs(e, t, n, r, i) {
7062
7062
  }
7063
7063
  //#endregion
7064
7064
  //#region ../node_modules/.pnpm/@glideapps+glide-data-grid@6.0.4-alpha24_lodash@4.17.21_marked@15.0.12_react-dom@19.3.0_aca11a353ce3b112922852200b9c160f/node_modules/@glideapps/glide-data-grid/dist/esm/data-editor/data-editor.js
7065
- var is = /* @__PURE__ */ e(_r(), 1), as = /* @__PURE__ */ e(xr(), 1), os = K.lazy(async () => await import("./data-grid-overlay-editor-CpHX98zN.js")), ss = 0;
7065
+ var is = /* @__PURE__ */ e(_r(), 1), as = /* @__PURE__ */ e(xr(), 1), os = K.lazy(async () => await import("./data-grid-overlay-editor-D7UuVsgB.js")), ss = 0;
7066
7066
  function cs(e) {
7067
7067
  return (0, is.default)((0, as.default)((0, as.default)(e).filter((e) => e.span !== void 0).map((e) => (0, Za.default)((e.span?.[0] ?? 0) + 1, (e.span?.[1] ?? 0) + 1))));
7068
7068
  }
@@ -9293,7 +9293,7 @@ function Vs(e, t, n, r, i) {
9293
9293
  }
9294
9294
  //#endregion
9295
9295
  //#region ../node_modules/.pnpm/@glideapps+glide-data-grid@6.0.4-alpha24_lodash@4.17.21_marked@15.0.12_react-dom@19.3.0_aca11a353ce3b112922852200b9c160f/node_modules/@glideapps/glide-data-grid/dist/esm/cells/number-cell.js
9296
- var Hs = K.lazy(async () => await import("./number-overlay-editor-BQN3-Nye.js")), Us = {
9296
+ var Hs = K.lazy(async () => await import("./number-overlay-editor-R2i0FdxV.js")), Us = {
9297
9297
  getAccessibilityString: (e) => e.data?.toString() ?? "",
9298
9298
  kind: W.Number,
9299
9299
  needsHover: (e) => e.hoverEffect === !0,
@@ -1,6 +1,6 @@
1
1
  import { h as e } from "./tooltip-BO1REbqs.js";
2
2
  import { _ as t } from "./useTheme-CRnjkku0.js";
3
- import { r as n } from "./code-visibility-CEXXuuOE.js";
3
+ import { r as n } from "./code-visibility-DkCqn-PO.js";
4
4
  var r = e("triangle-alert", [
5
5
  ["path", {
6
6
  d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3",
package/dist/main.js CHANGED
@@ -7,7 +7,7 @@ import { C as w, F as T, G as E, I as D, J as O, K as k, L as A, P as j, R as M,
7
7
  import { a as oe, d as se, f as ce, h as V, i as le, n as ue, o as de, r as fe, t as pe } from "./tooltip-BO1REbqs.js";
8
8
  import { $ as me, At as he, B as ge, Bt as _e, D as ve, Dt as ye, E as be, Ht as xe, It as Se, J as Ce, Jt as we, Kt as Te, M as Ee, Mt as De, N as Oe, Nt as ke, O as Ae, Ot as je, Q as Me, R as Ne, S as Pe, Tt as Fe, V as Ie, Vt as Le, W as Re, Wt as ze, X as Be, Xt as Ve, Y as He, Yt as Ue, _ as We, _n as Ge, _t as Ke, an as qe, b as Je, cn as Ye, ct as Xe, dn as Ze, dt as Qe, en as $e, f as et, ft as tt, g as nt, gn as rt, gt as it, h as at, it as ot, j as st, jt as ct, k as lt, kt as ut, ln as dt, m as ft, mn as pt, nn as mt, o as ht, p as gt, pn as _t, pt as vt, qt as yt, rn as bt, rt as xt, s as St, tn as Ct, u as wt, un as Tt, v as Et, xt as Dt, y as Ot, z as kt } from "./error-entries-Bv6bZPF3.js";
9
9
  import { D as At, E as jt, O as Mt, S as Nt, T as Pt, _ as Ft, a as It, c as Lt, d as Rt, f as zt, g as Bt, h as Vt, i as Ht, l as Ut, m as Wt, n as Gt, o as Kt, p as qt, r as Jt, s as Yt, t as Xt, u as Zt, v as Qt, w as $t, x as en, y as tn } from "./spec-BwarBpUJ.js";
10
- import { $t as nn, A as rn, At as an, B as on, Bt as sn, C as cn, Ct as ln, D as un, Dt as dn, E as fn, Et as pn, F as mn, Ft as hn, G as gn, Gt as _n, H as vn, Ht as yn, I as bn, It as xn, J as Sn, Jt as Cn, K as wn, Kt as Tn, L as En, Lt as Dn, M as On, Mt as kn, N as An, Nt as jn, O as Mn, Ot as Nn, P as Pn, Pt as Fn, Qt as In, R as Ln, Rt as Rn, S as zn, St as Bn, T as Vn, Tt as Hn, U as Un, Ut as Wn, V as Gn, Vt as Kn, W as qn, Wt as Jn, X as Yn, Xt as Xn, Y as Zn, Yt as Qn, Zt as $n, _ as er, _t as tr, an as nr, at as rr, b as ir, c as ar, cn as or, ct as sr, d as cr, dn as lr, dt as ur, en as dr, et as fr, f as pr, fn as mr, ft as hr, g as gr, gt as _r, h as vr, hn as yr, ht as br, in as xr, it as Sr, j as Cr, jt as wr, k as Tr, kt as Er, ln as Dr, lt as Or, m as kr, mn as Ar, mt as jr, nn as Mr, nt as Nr, ot as Pr, p as Fr, pn as Ir, pt as Lr, q as Rr, qt as zr, r as Br, rn as Vr, rt as Hr, sn as Ur, st as Wr, t as Gr, tn as Kr, tt as qr, u as Jr, un as Yr, ut as Xr, v as Zr, vt as Qr, w as $r, wt as ei, x as ti, y as ni, yt as ri, z as ii, zt as ai } from "./common-OLE92qsR.js";
10
+ import { $t as nn, A as rn, At as an, B as on, Bt as sn, C as cn, Ct as ln, D as un, Dt as dn, E as fn, Et as pn, F as mn, Ft as hn, G as gn, Gt as _n, H as vn, Ht as yn, I as bn, It as xn, J as Sn, Jt as Cn, K as wn, Kt as Tn, L as En, Lt as Dn, M as On, Mt as kn, N as An, Nt as jn, O as Mn, Ot as Nn, P as Pn, Pt as Fn, Qt as In, R as Ln, Rt as Rn, S as zn, St as Bn, T as Vn, Tt as Hn, U as Un, Ut as Wn, V as Gn, Vt as Kn, W as qn, Wt as Jn, X as Yn, Xt as Xn, Y as Zn, Yt as Qn, Zt as $n, _ as er, _t as tr, an as nr, at as rr, b as ir, c as ar, cn as or, ct as sr, d as cr, dn as lr, dt as ur, en as dr, et as fr, f as pr, fn as mr, ft as hr, g as gr, gt as _r, h as vr, hn as yr, ht as br, in as xr, it as Sr, j as Cr, jt as wr, k as Tr, kt as Er, ln as Dr, lt as Or, m as kr, mn as Ar, mt as jr, nn as Mr, nt as Nr, ot as Pr, p as Fr, pn as Ir, pt as Lr, q as Rr, qt as zr, r as Br, rn as Vr, rt as Hr, sn as Ur, st as Wr, t as Gr, tn as Kr, tt as qr, u as Jr, un as Yr, ut as Xr, v as Zr, vt as Qr, w as $r, wt as ei, x as ti, y as ni, yt as ri, z as ii, zt as ai } from "./common-DsUPhkD6.js";
11
11
  import { c as oi, i as si, n as ci, o as li, t as ui } from "./useLifecycle-j7D9uD1o.js";
12
12
  import { t as di } from "./check-EDwSwP89.js";
13
13
  import { A as fi, C as pi, D as mi, E as hi, M as gi, O as _i, S as vi, T as yi, a as bi, b as xi, c as Si, i as Ci, k as wi, l as Ti, n as Ei, o as Di, r as Oi, s as ki, t as Ai, w as ji, x as Mi } from "./select-BButFi-d.js";
@@ -15,8 +15,8 @@ import { $ as Ni, C as Pi, Et as Fi, J as Ii, K as Li, R as Ri, S as zi, U as Bi
15
15
  import { C as ua, _ as da, a as fa, b as pa, c as ma, f as ha, h as ga, m as _a, p as va, s as ya, u as ba, x as xa, y as Sa } from "./toDate-UtiwmnH2.js";
16
16
  import { a as Ca, c as wa, d as Ta, f as Ea, i as Da, l as Oa, n as ka, o as Aa, r as ja, s as Ma, t as Na } from "./process-output-BZg56CCb.js";
17
17
  import { a as Pa, c as Fa, d as Ia, i as La, l as Ra, r as za, u as Ba } from "./glide-utils-DJyqh0EM.js";
18
- import { n as Va, t as Ha } from "./ErrorBoundary-CBHB755J.js";
19
- import { n as Ua, t as Wa } from "./globals-1Hfhz6Dx.js";
18
+ import { n as Va, t as Ha } from "./ErrorBoundary-DAPIRYqd.js";
19
+ import { n as Ua, t as Wa } from "./globals-yruoELTW.js";
20
20
  import { t as Ga } from "./react-dom-DUmH0GXv.js";
21
21
  import { t as Ka } from "./jsx-runtime-3BD1kT0s.js";
22
22
  import { B as qa, C as H, Ct as Ja, D as Ya, E as Xa, F as U, G as Za, H as Qa, I as $a, L as eo, M as W, N as to, O as no, P as ro, Q as io, R as ao, S as G, St as oo, Tt as so, U as co, W as lo, Y as uo, Z as fo, _ as po, _t as mo, a as ho, b as go, bt as _o, c as vo, d as yo, f as bo, g as xo, h as So, ht as Co, i as wo, j as K, k as To, l as Eo, m as Do, n as Oo, o as ko, p as Ao, r as jo, s as Mo, u as No, ut as Po, v as Fo, vt as Io, w as Lo, wt as Ro, x as zo, xt as Bo, y as Vo, yt as Ho } from "./use-restore-focus-BEM7sFWl.js";
@@ -39,7 +39,7 @@ import { t as Ms } from "./useAsyncData-Deh5kszT.js";
39
39
  import { i as Ns, n as Ps, r as Fs } from "./formats-27CW_UP4.js";
40
40
  import { M as Is, N as Ls } from "./vega-loader.browser-DNST6uVf.js";
41
41
  import { a as Rs, n as zs } from "./loader-BQOfjTIJ.js";
42
- import { c as Bs, d as Vs, i as Hs, l as Us, o as Ws, s as Gs, t as Ks, u as qs } from "./code-visibility-CEXXuuOE.js";
42
+ import { c as Bs, d as Vs, i as Hs, l as Us, o as Ws, s as Gs, t as Ks, u as qs } from "./code-visibility-DkCqn-PO.js";
43
43
  import { t as Js } from "./useDeepCompareMemoize-DXwzRINI.js";
44
44
  import { t as Ys } from "./esm-D2gTsXEV.js";
45
45
  //#region ../node_modules/.pnpm/iconify-icon@2.3.0/node_modules/iconify-icon/dist/iconify-icon.mjs
@@ -10533,7 +10533,7 @@ function VC(e, t) {
10533
10533
  }
10534
10534
  //#endregion
10535
10535
  //#region src/plugins/impl/DataEditorPlugin.tsx
10536
- var HC = J.lazy(() => import("./glide-data-editor-Cmu4vWKD.js").then((e) => e.t)), UC = wS("marimo-data-editor", { cssStyles: [qS] }).withData(W({
10536
+ var HC = J.lazy(() => import("./glide-data-editor-KvS90MGh.js").then((e) => e.t)), UC = wS("marimo-data-editor", { cssStyles: [qS] }).withData(W({
10537
10537
  initialValue: W({ edits: G(W({
10538
10538
  rowIdx: K(),
10539
10539
  columnId: U(),
@@ -30107,7 +30107,7 @@ function OR(e) {
30107
30107
  }
30108
30108
  //#endregion
30109
30109
  //#region src/components/editor/renderers/slides-layout/slides-layout.tsx
30110
- var kR = J.lazy(() => import("./reveal-component-nqUW7zgn.js")), AR = (e) => {
30110
+ var kR = J.lazy(() => import("./reveal-component-C42-YBfQ.js")), AR = (e) => {
30111
30111
  let t = (0, X.c)(33), { layout: n, setLayout: r, cells: i, mode: a } = e, o = ns(da), s = a === "read" || o, c = ns(We) > 1, [l, u] = (0, J.useState)(null), d;
30112
30112
  t[0] !== i || t[1] !== n ? (d = EP(i, n), t[0] = i, t[1] = n, t[2] = d) : d = t[2];
30113
30113
  let { slideCells: f, skippedIds: p, noOutputIds: m, slideTypes: h, startCellIndex: g } = d, _;
@@ -1,6 +1,6 @@
1
1
  import { a as e } from "./rolldown-runtime-Cb76FRpK.js";
2
2
  import { t } from "./react-sHtZdjeP.js";
3
- import { a as n } from "./glide-data-editor-Cmu4vWKD.js";
3
+ import { a as n } from "./glide-data-editor-KvS90MGh.js";
4
4
  //#region ../node_modules/.pnpm/@glideapps+glide-data-grid@6.0.4-alpha24_lodash@4.17.21_marked@15.0.12_react-dom@19.3.0_aca11a353ce3b112922852200b9c160f/node_modules/@glideapps/glide-data-grid/dist/esm/internal/data-grid-overlay-editor/private/number-overlay-editor-style.js
5
5
  var r = /* @__PURE__ */ e(t()), i = /*#__PURE__*/ n("div")({
6
6
  name: "NumberOverlayEditorStyle",
@@ -4,14 +4,14 @@ import { t as o } from "./react-sHtZdjeP.js";
4
4
  import { t as s } from "./compiler-runtime-CldRdSsv.js";
5
5
  import { h as c, t as l } from "./tooltip-BO1REbqs.js";
6
6
  import { O as u } from "./error-entries-Bv6bZPF3.js";
7
- import { $ as d, Q as f, Z as p, an as m, i as h, l as g, ln as _, n as v, on as y, r as b, t as x, u as S } from "./common-OLE92qsR.js";
7
+ import { $ as d, Q as f, Z as p, an as m, i as h, l as g, ln as _, n as v, on as y, r as b, t as x, u as S } from "./common-DsUPhkD6.js";
8
8
  import { M as C } from "./select-BButFi-d.js";
9
9
  import { q as ee } from "./dropdown-menu-CP_qCYHC.js";
10
10
  import { _ as w } from "./toDate-UtiwmnH2.js";
11
11
  import { t as T } from "./jsx-runtime-3BD1kT0s.js";
12
12
  import { q as E } from "./use-restore-focus-BEM7sFWl.js";
13
13
  import { A as D, C as O, b as k } from "./useTheme-CRnjkku0.js";
14
- import { a as A, n as te, t as ne } from "./code-visibility-CEXXuuOE.js";
14
+ import { a as A, n as te, t as ne } from "./code-visibility-DkCqn-PO.js";
15
15
  /* empty css */
16
16
  var j = c("sticky-note", [["path", {
17
17
  d: "M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.25.3-dev19",
3
+ "version": "0.25.3-dev20",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -4,3 +4,8 @@ Production code may import host modules, `jotai/vanilla`, `zod`, and
4
4
  `@/utils/Logger`. Enforced by `marimo/host-imports`. Tests are exempt.
5
5
 
6
6
  Define interfaces here and inject application behavior through adapters.
7
+
8
+ `stream.ts` reduces stream events into one state. Each kind of object is a
9
+ `Resource` in `resources/` with pure handlers keyed by event name. The stream
10
+ handles phase, notebook identity, and control events itself. The client holds
11
+ one state atom and exposes derived atoms.
@@ -0,0 +1,124 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import type { NotebookEvent } from "../events";
5
+ import { document } from "../resources/document";
6
+ import { createStream } from "../stream";
7
+ import { fixtureEvents } from "./fixtures";
8
+
9
+ const stream = createStream({ document });
10
+ const snapshot = fixtureEvents("notebook-events.sse");
11
+ const edits = fixtureEvents("edits.sse");
12
+
13
+ function replay(events: readonly NotebookEvent[], from = stream.initial) {
14
+ return events.reduce((state, event) => stream.reduce(state, event), from);
15
+ }
16
+
17
+ const confirmed = () => replay(snapshot);
18
+ const current = (state: ReturnType<typeof replay>) =>
19
+ state.resources.document.current;
20
+
21
+ describe("document resource", () => {
22
+ it("publishes the snapshot at ready, then each completed edit and save", () => {
23
+ const ready = snapshot.findIndex((event) => event.event === "ready");
24
+ const opening = replay(snapshot.slice(0, ready));
25
+ expect(current(opening)).toBeNull();
26
+ const first = current(replay(snapshot.slice(ready), opening));
27
+ expect(first?.revision.order).toEqual(["c1", "c3", "k7f2"]);
28
+ expect([...first!.cells.keys()]).toEqual(first?.revision.order);
29
+
30
+ const [edit, save] = edits.filter((event) => event.event === "document");
31
+ const pending = replay(
32
+ edits.filter((event) => event.event !== "document"),
33
+ confirmed(),
34
+ );
35
+ expect(current(pending)).toEqual(first);
36
+ const changed = stream.reduce(pending, edit);
37
+ expect(current(changed)?.revision).toEqual(edit.data.document);
38
+ expect([...current(changed)!.cells.keys()].toSorted()).toEqual(
39
+ edit.data.document.order.toSorted(),
40
+ );
41
+ const saved = current(stream.reduce(changed, save));
42
+ expect(saved?.revision).toEqual(save.data.document);
43
+ expect(saved?.cells.get("c3")?.code).toBe("x = 22");
44
+ expect(saved?.cells.has("k7f2")).toBe(false);
45
+ expect(saved?.revision.saved_etag).toBe(saved?.revision.etag);
46
+ expect(first?.cells.get("c3")?.code).toBe("x = 21");
47
+ expect(first?.cells.has("k7f2")).toBe(true);
48
+ });
49
+
50
+ it("applies order-only changes and distinguishes an empty notebook from no document", () => {
51
+ const state = confirmed();
52
+ const revision = current(state)!.revision;
53
+ const reordered = {
54
+ ...revision,
55
+ order: revision.order.toReversed(),
56
+ version: revision.version + 1,
57
+ };
58
+ const changed = stream.reduce(state, {
59
+ event: "document",
60
+ data: { document: reordered },
61
+ id: "200",
62
+ });
63
+ expect(current(changed)).toEqual({
64
+ revision: reordered,
65
+ cells: current(state)?.cells,
66
+ });
67
+
68
+ const empty = { ...revision, order: [] };
69
+ const opening = stream.reduce(stream.initial, {
70
+ event: "document",
71
+ data: { document: empty },
72
+ });
73
+ expect(current(opening)).toBeNull();
74
+ expect(
75
+ current(stream.reduce(opening, { event: "ready", data: {}, id: "0" })),
76
+ ).toEqual({ revision: empty, cells: new Map() });
77
+ expect(
78
+ current(
79
+ stream.reduce(stream.initial, { event: "ready", data: {}, id: "0" }),
80
+ ),
81
+ ).toBeNull();
82
+ });
83
+
84
+ it("keeps the document while a live update is pending and after a replay completes it", () => {
85
+ const state = confirmed();
86
+ const [cell, , removed, published] = edits;
87
+ if (published.event !== "document") {
88
+ throw new Error("Fixture does not publish a document");
89
+ }
90
+ const partial = replay([cell, removed], state);
91
+ expect(current(partial)).toBe(current(state));
92
+ expect(stream.resume(partial)).toBe(partial);
93
+ const completed = replay(
94
+ [published, { event: "ready", data: {}, id: "113" }],
95
+ partial,
96
+ );
97
+ expect(current(completed)?.revision).toEqual(published.data.document);
98
+ });
99
+
100
+ it("removes a notebook without waiting for individual cell removals", () => {
101
+ const removed = stream.reduce(confirmed(), {
102
+ event: "notebook.removed",
103
+ data: { id: "n1", time: "2026-10-01T09:30:00Z" },
104
+ id: "200",
105
+ });
106
+ expect(current(removed)).toBeNull();
107
+ });
108
+
109
+ it("rejects records that cannot form a complete revision", () => {
110
+ const state = confirmed();
111
+ const revision = current(state)!.revision;
112
+ expect(() =>
113
+ stream.reduce(state, {
114
+ event: "document",
115
+ data: { document: { ...revision, order: ["missing"] } },
116
+ id: "200",
117
+ }),
118
+ ).toThrow("Document records do not match");
119
+ const partial = stream.reduce(state, edits[0]);
120
+ expect(() =>
121
+ stream.reduce(partial, { event: "ready", data: {}, id: "200" }),
122
+ ).toThrow("incomplete document update");
123
+ });
124
+ });
@@ -0,0 +1,39 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import NodeFs from "node:fs";
4
+ import NodeUrl from "node:url";
5
+ import { parseNotebookEvent, type NotebookEvent } from "../events";
6
+
7
+ /** Read the protocol's portable examples as transport messages. */
8
+ export function fixtureMessages(name: string) {
9
+ const path = new NodeUrl.URL(
10
+ `../../../../../protocols/host/fixtures/${name}`,
11
+ import.meta.url,
12
+ );
13
+ return NodeFs.readFileSync(path, "utf8")
14
+ .split(/\r?\n\r?\n/)
15
+ .flatMap((frame) => {
16
+ const event = /^event: (.+)$/m.exec(frame)?.[1];
17
+ if (!event) {
18
+ return [];
19
+ }
20
+ const data = frame
21
+ .split(/\r?\n/)
22
+ .filter((line) => line.startsWith("data:"))
23
+ .map((line) => line.slice(5).trimStart())
24
+ .join("\n");
25
+ const payload: unknown = JSON.parse(data);
26
+ return [
27
+ {
28
+ event,
29
+ id: /^id: (.+)$/m.exec(frame)?.[1],
30
+ data: payload,
31
+ },
32
+ ];
33
+ });
34
+ }
35
+
36
+ /** Read the protocol's portable examples as decoded notebook events. */
37
+ export function fixtureEvents(name: string): NotebookEvent[] {
38
+ return fixtureMessages(name).map(parseNotebookEvent);
39
+ }
@@ -8,13 +8,30 @@ import { Deferred } from "@/utils/Deferred";
8
8
  import { jsonParseWithSpecialChar } from "@/utils/json/json-parser";
9
9
  import { classifyCloseEvent } from "../../websocket/connection-state";
10
10
  import { createNotebookClient } from "../notebook-client";
11
+ import { fixtureMessages } from "./fixtures";
11
12
 
12
- function setup() {
13
+ function notebookMessage(id: string) {
14
+ const notebook = {
15
+ id,
16
+ project_id: "p1",
17
+ path: null,
18
+ name: id,
19
+ operations: [],
20
+ updated_at: null,
21
+ };
22
+ return JSON.stringify({ event: "notebook", data: { notebook } });
23
+ }
24
+
25
+ function setup(url: () => string = () => "ws://localhost/ws") {
13
26
  const events = new EventTarget();
14
27
  const healthy = new Deferred<void>();
28
+ let provider = url;
29
+ const urls: URL[] = [];
15
30
  const transport = {
16
31
  readyState: WebSocket.CLOSED,
17
- reconnect: vi.fn(),
32
+ reconnect: vi.fn(() => {
33
+ urls.push(new URL(provider()));
34
+ }),
18
35
  close: vi.fn(() => events.dispatchEvent(new CloseEvent("close"))),
19
36
  send: vi.fn(),
20
37
  addEventListener: events.addEventListener.bind(events),
@@ -27,8 +44,11 @@ function setup() {
27
44
  const client = controller.client;
28
45
  const connect = (signal: AbortSignal) =>
29
46
  controller.connect({
30
- createTransport: () => transport,
31
- url: () => "ws://localhost/ws",
47
+ createTransport: (next) => {
48
+ provider = next;
49
+ return transport;
50
+ },
51
+ url,
32
52
  messageFormat: "host",
33
53
  signal,
34
54
  waitUntilReady: () => healthy.promise,
@@ -47,6 +67,11 @@ function setup() {
47
67
  onNotification,
48
68
  store,
49
69
  checkHealth,
70
+ urls,
71
+ receive: (message: unknown) =>
72
+ events.dispatchEvent(
73
+ new MessageEvent("message", { data: JSON.stringify(message) }),
74
+ ),
50
75
  };
51
76
  }
52
77
 
@@ -79,6 +104,93 @@ describe("NotebookClient lifetime", () => {
79
104
  },
80
105
  );
81
106
 
107
+ it("resumes from the last cursor until reset or a new session", async () => {
108
+ const session = { id: "first", token: "a" };
109
+ const { connect, events, healthy, urls, receive } = setup(
110
+ () =>
111
+ `ws://localhost/ws?session_id=${session.id}&access_token=${session.token}&cursor=999`,
112
+ );
113
+ const lifetime = new AbortController();
114
+ connect(lifetime.signal);
115
+ healthy.resolve();
116
+ await healthy.promise;
117
+ expect(urls[0].searchParams.get("cursor")).toBeNull();
118
+
119
+ const snapshot = fixtureMessages("notebook-events.sse");
120
+ events.dispatchEvent(new Event("open"));
121
+ for (const message of snapshot) {
122
+ receive(message);
123
+ }
124
+ session.token = "b";
125
+ events.dispatchEvent(new CloseEvent("close"));
126
+ expect(urls[1].searchParams.get("cursor")).toBe("97");
127
+ expect(urls[1].searchParams.get("access_token")).toBe("b");
128
+
129
+ events.dispatchEvent(new Event("open"));
130
+ receive({ event: "reset", data: {} });
131
+ events.dispatchEvent(new CloseEvent("close"));
132
+ expect(urls[2].searchParams.get("cursor")).toBeNull();
133
+
134
+ events.dispatchEvent(new Event("open"));
135
+ for (const message of snapshot) {
136
+ receive(message);
137
+ }
138
+ session.id = "second";
139
+ events.dispatchEvent(new CloseEvent("close"));
140
+ expect(urls[3].searchParams.get("cursor")).toBeNull();
141
+ expect(urls[3].searchParams.get("session_id")).toBe("second");
142
+ lifetime.abort();
143
+ });
144
+
145
+ it("exposes the confirmed document until the lifetime ends", async () => {
146
+ const { connect, events, client, store, healthy, receive } = setup();
147
+ const lifetime = new AbortController();
148
+ connect(lifetime.signal);
149
+ healthy.resolve();
150
+ await healthy.promise;
151
+ events.dispatchEvent(new Event("open"));
152
+ const snapshot = fixtureMessages("notebook-events.sse");
153
+ for (const message of snapshot.slice(0, -3)) {
154
+ receive(message);
155
+ }
156
+ expect(store.get(client.documentAtom)).toBeNull();
157
+ for (const message of snapshot.slice(-3)) {
158
+ receive(message);
159
+ }
160
+ expect(store.get(client.documentAtom)?.revision.order).toEqual([
161
+ "c1",
162
+ "c3",
163
+ "k7f2",
164
+ ]);
165
+ events.dispatchEvent(new CloseEvent("close"));
166
+ expect(store.get(client.documentAtom)?.revision.order).toEqual([
167
+ "c1",
168
+ "c3",
169
+ "k7f2",
170
+ ]);
171
+ lifetime.abort();
172
+ expect(store.get(client.documentAtom)).toBeNull();
173
+ });
174
+
175
+ it("stops consuming a stream whose notebook changes", () => {
176
+ const { connect, events, client, store, transport } = setup();
177
+ const lifetime = new AbortController();
178
+ connect(lifetime.signal);
179
+ events.dispatchEvent(new Event("open"));
180
+ for (const id of ["n1", "n2"]) {
181
+ events.dispatchEvent(
182
+ new MessageEvent("message", { data: notebookMessage(id) }),
183
+ );
184
+ }
185
+ expect(store.get(client.connectionAtom)).toEqual({
186
+ state: ConnectionState.CLOSED,
187
+ code: "KERNEL_DISCONNECTED",
188
+ reason: "Failed to decode notebook message",
189
+ });
190
+ expect(transport.close).toHaveBeenCalledOnce();
191
+ lifetime.abort();
192
+ });
193
+
82
194
  it.each(["MARIMO_UNAUTHORIZED", "MARIMO_TRANSPORT_EXHAUSTED"])(
83
195
  "stops automatic recovery after %s but permits an explicit reconnect",
84
196
  async (reason) => {
@@ -0,0 +1,211 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import type { NotebookEvent } from "../events";
5
+ import type { Resource } from "../resources/resource";
6
+ import { createStream, type Stream, type StreamState } from "../stream";
7
+ import { fixtureEvents } from "./fixtures";
8
+
9
+ interface CellIds {
10
+ readonly ids: readonly string[];
11
+ }
12
+
13
+ /** A resource whose events are complete on their own. */
14
+ const cells: Resource<CellIds> = {
15
+ initial: { ids: [] },
16
+ on: {
17
+ cell: (state, event) =>
18
+ state.ids.includes(event.data.cell.id)
19
+ ? state
20
+ : { ids: [...state.ids, event.data.cell.id] },
21
+ "cell.removed": (state, event) =>
22
+ state.ids.includes(event.data.id)
23
+ ? { ids: state.ids.filter((id) => id !== event.data.id) }
24
+ : state,
25
+ },
26
+ };
27
+
28
+ interface Revisions {
29
+ /** Cells changed since the last revision. */
30
+ readonly pending: readonly string[];
31
+ readonly published: readonly (readonly string[])[];
32
+ }
33
+
34
+ function publish(state: Revisions): Revisions {
35
+ return { pending: [], published: [...state.published, state.pending] };
36
+ }
37
+
38
+ /** A resource assembled from groups of events, like a document from its cells. */
39
+ const revisions: Resource<Revisions> = {
40
+ initial: { pending: [], published: [] },
41
+ on: {
42
+ cell: (state, event) => ({
43
+ ...state,
44
+ pending: [...state.pending, event.data.cell.id],
45
+ }),
46
+ "cell.removed": (state, event) => ({
47
+ ...state,
48
+ pending: [...state.pending, event.data.id],
49
+ }),
50
+ // A live revision completes a group. A snapshot's revision waits for ready.
51
+ document: (state, _event, { phase }) =>
52
+ phase === "live" ? publish(state) : state,
53
+ ready: publish,
54
+ },
55
+ };
56
+
57
+ interface Resources {
58
+ cells: CellIds;
59
+ revisions: Revisions;
60
+ }
61
+ type State = StreamState<Resources>;
62
+
63
+ function replay(
64
+ stream: Stream<Resources>,
65
+ events: readonly NotebookEvent[],
66
+ from: State = stream.initial,
67
+ ): State {
68
+ return events.reduce((state, event) => stream.reduce(state, event), from);
69
+ }
70
+
71
+ const snapshot = fixtureEvents("notebook-events.sse");
72
+ const ready = snapshot.findIndex((event) => event.event === "ready");
73
+ const cellEvent = snapshot.find((event) => event.event === "cell");
74
+ if (cellEvent?.event !== "cell") {
75
+ throw new Error("Fixture is missing a cell event");
76
+ }
77
+
78
+ describe("notebook stream", () => {
79
+ it("reads the snapshot, goes live at ready, and rebuilds after reset", () => {
80
+ const stream = createStream({ cells, revisions });
81
+ const partial = replay(stream, snapshot.slice(0, ready));
82
+ expect(partial).toMatchObject({
83
+ phase: "snapshot",
84
+ notebookId: "n1",
85
+ cursor: null,
86
+ });
87
+ expect(partial.resources.cells.ids).toEqual(["c1", "c3", "k7f2"]);
88
+ expect(partial.resources.revisions).toEqual({
89
+ pending: ["c1", "c3", "k7f2"],
90
+ published: [],
91
+ });
92
+
93
+ const live = replay(stream, snapshot.slice(ready), partial);
94
+ expect(live).toMatchObject({ phase: "live", cursor: "97" });
95
+ expect(live.resources.cells).toBe(partial.resources.cells);
96
+ expect(live.resources.revisions).toEqual({
97
+ pending: [],
98
+ published: [["c1", "c3", "k7f2"]],
99
+ });
100
+
101
+ const resetEvents = fixtureEvents("events-reset.sse");
102
+ const afterReset = stream.reduce(live, resetEvents[0]);
103
+ expect(afterReset).toEqual({ ...stream.initial, notebookId: "n1" });
104
+ expect(replay(stream, resetEvents, live).phase).toBe("live");
105
+ });
106
+
107
+ it("keeps resource state by reference when an event does not apply", () => {
108
+ const stream = createStream({ cells, revisions });
109
+ const state = replay(stream, snapshot);
110
+ const runtime = snapshot.find((event) => event.event === "runtime");
111
+ const unhandled = stream.reduce(state, { ...runtime!, id: "100" });
112
+ expect(unhandled.resources).toBe(state.resources);
113
+ expect(unhandled.cursor).toBe("100");
114
+ expect(
115
+ stream.reduce(state, { event: "unknown", name: "x", id: "101" })
116
+ .resources,
117
+ ).toBe(state.resources);
118
+ expect(
119
+ stream.reduce(state, { event: "marimo", data: { marimo: {} } }),
120
+ ).toBe(state);
121
+ });
122
+
123
+ it("clears resources when its notebook is removed and ignores other notebooks", () => {
124
+ const stream = createStream({ cells, revisions });
125
+ const state = replay(stream, snapshot);
126
+ const time = "2026-10-01T09:30:00Z";
127
+ expect(
128
+ stream.reduce(state, {
129
+ event: "notebook.removed",
130
+ id: "100",
131
+ data: { id: "n2", time },
132
+ }).resources,
133
+ ).toBe(state.resources);
134
+ const removed = stream.reduce(state, {
135
+ event: "notebook.removed",
136
+ id: "100",
137
+ data: { id: "n1", time },
138
+ });
139
+ expect(removed).toMatchObject({ phase: "live", notebookId: "n1" });
140
+ expect(removed.resources).toBe(stream.initial.resources);
141
+ });
142
+
143
+ it("rejects objects from another notebook", () => {
144
+ const stream = createStream({ cells, revisions });
145
+ const state = replay(stream, snapshot.slice(0, ready));
146
+ const notebook = snapshot[0];
147
+ if (notebook.event !== "notebook") {
148
+ throw new Error("Fixture does not start with its notebook");
149
+ }
150
+ expect(() =>
151
+ stream.reduce(state, {
152
+ ...notebook,
153
+ data: { notebook: { ...notebook.data.notebook, id: "n2" } },
154
+ }),
155
+ ).toThrow("changed identity");
156
+ expect(() =>
157
+ stream.reduce(state, {
158
+ ...cellEvent,
159
+ data: { cell: { ...cellEvent.data.cell, notebook_id: "n2" } },
160
+ }),
161
+ ).toThrow("changed identity");
162
+ });
163
+
164
+ it("records each live event's cursor and resumes in place", () => {
165
+ const stream = createStream({ cells, revisions });
166
+ const partial = replay(stream, snapshot.slice(0, ready));
167
+ expect(stream.resume(partial)).toEqual({
168
+ ...stream.initial,
169
+ notebookId: "n1",
170
+ });
171
+
172
+ const live = replay(stream, snapshot.slice(ready), partial);
173
+ const [cell, , removed, published, saved] = fixtureEvents("edits.sse");
174
+ const editing = replay(stream, [cell], live);
175
+ expect(editing.cursor).toBe("110");
176
+ expect(editing.resources.revisions.pending).toEqual(["c2"]);
177
+ expect(stream.resume(editing)).toBe(editing);
178
+
179
+ const edited = replay(stream, [removed, published], editing);
180
+ expect(edited.cursor).toBe("113");
181
+ expect(edited.resources.revisions).toEqual({
182
+ pending: [],
183
+ published: [
184
+ ["c1", "c3", "k7f2"],
185
+ ["c2", "k7f2"],
186
+ ],
187
+ });
188
+ expect(stream.reduce(edited, saved).cursor).toBe("114");
189
+ expect(
190
+ stream.reduce(edited, { event: "ready", id: "116", data: {} }).cursor,
191
+ ).toBe("116");
192
+ expect(stream.reduce(edited, { event: "reset", data: {} }).cursor).toBe(
193
+ null,
194
+ );
195
+ });
196
+
197
+ it("requires a cursor on live events other than reset and notifications", () => {
198
+ const stream = createStream({ cells, revisions });
199
+ const state = replay(stream, snapshot);
200
+ expect(() => stream.reduce(state, cellEvent)).toThrow("cursor");
201
+ expect(
202
+ stream.reduce(state, { ...cellEvent, id: "100" }).resources.cells,
203
+ ).toBe(state.resources.cells);
204
+ expect(stream.reduce(state, { event: "reset", data: {} }).phase).toBe(
205
+ "snapshot",
206
+ );
207
+ expect(
208
+ stream.reduce(state, { event: "marimo", data: { marimo: null } }),
209
+ ).toBe(state);
210
+ });
211
+ });
@@ -11,11 +11,21 @@ import {
11
11
  TransportReadyState,
12
12
  } from "./connection";
13
13
  import { parseNotebookEvent } from "./events";
14
+ import { type Document, document } from "./resources/document";
15
+ import { createStream } from "./stream";
14
16
 
15
17
  /** A connection to a notebook on a marimo host. */
16
18
  export interface NotebookClient {
17
19
  /** Client connection status, independent of application session readiness. */
18
20
  readonly connectionAtom: Atom<ConnectionStatus>;
21
+ /**
22
+ * The most recently confirmed document.
23
+ *
24
+ * Null until a snapshot is complete, after reset or removal, and on
25
+ * connections without host document events. A temporary disconnection
26
+ * retains the last document. Partial updates are not exposed.
27
+ */
28
+ readonly documentAtom: Atom<Document | null>;
19
29
  /** Attempt to restore a closed connection after checking host health. */
20
30
  reconnect(): Promise<void>;
21
31
  }
@@ -49,9 +59,16 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
49
59
  const connectionAtom = atom<ConnectionStatus>({
50
60
  state: ConnectionState.NOT_STARTED,
51
61
  });
62
+ // One atom holds the whole stream, which resource atoms derive from.
63
+ const stream = createStream({ document });
64
+ const streamAtom = atom(stream.initial);
65
+ const documentAtom = atom(
66
+ (get) => get(streamAtom).resources.document.current,
67
+ );
52
68
  let reconnect: (() => Promise<void>) | undefined;
53
69
  const client: NotebookClient = {
54
70
  connectionAtom,
71
+ documentAtom,
55
72
  reconnect: async () => reconnect?.(),
56
73
  };
57
74
 
@@ -73,7 +90,33 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
73
90
  let shouldRetry = true;
74
91
  let terminal = false;
75
92
  let checkingHealth = false;
76
- const transport = createTransport(url);
93
+ let streamKey: string | undefined;
94
+ const transport = createTransport(() => {
95
+ if (messageFormat === "session") {
96
+ return url();
97
+ }
98
+ const next = new URL(url());
99
+ next.searchParams.delete("cursor");
100
+ if (signal.aborted) {
101
+ return next.toString();
102
+ }
103
+ // A cursor is useful with the state that produced it, which a change
104
+ // of session or notebook invalidates. Authentication may change freely.
105
+ const identity = new URL(next);
106
+ identity.searchParams.delete("access_token");
107
+ identity.searchParams.sort();
108
+ const key = identity.toString();
109
+ const state =
110
+ key === streamKey
111
+ ? stream.resume(store.get(streamAtom))
112
+ : stream.initial;
113
+ streamKey = key;
114
+ store.set(streamAtom, state);
115
+ if (state.cursor !== null) {
116
+ next.searchParams.set("cursor", state.cursor);
117
+ }
118
+ return next.toString();
119
+ });
77
120
  const setStatus = (status: ConnectionStatus) =>
78
121
  store.set(connectionAtom, status);
79
122
  const disconnected = {
@@ -130,12 +173,14 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
130
173
  let event;
131
174
  try {
132
175
  event = parseNotebookEvent(decodeJson(message.data));
176
+ store.set(streamAtom, stream.reduce(store.get(streamAtom), event));
133
177
  } catch (error) {
134
178
  Logger.error("Failed to decode notebook message", error);
135
179
  if (messageFormat === "session") {
136
180
  onMessageError(error);
137
181
  return;
138
182
  }
183
+ store.set(streamAtom, stream.initial);
139
184
  terminal = true;
140
185
  setStatus({
141
186
  ...disconnected,
@@ -193,6 +238,7 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
193
238
  transport.removeEventListener("message", onMessage);
194
239
  transport.close();
195
240
  reconnect = undefined;
241
+ store.set(streamAtom, stream.initial);
196
242
  setStatus({ state: ConnectionState.NOT_STARTED });
197
243
  },
198
244
  { once: true },
@@ -0,0 +1,101 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type * as Protocol from "../protocol";
4
+ import type { Resource } from "./resource";
5
+
6
+ /**
7
+ * The notebook's content at one revision.
8
+ *
9
+ * The revision record carries the cell order, app config, etags, and
10
+ * version. Each cell in its order has a record in `cells`.
11
+ */
12
+ export interface Document {
13
+ readonly revision: Protocol.Document;
14
+ readonly cells: ReadonlyMap<Protocol.CellId, Protocol.Cell>;
15
+ }
16
+
17
+ /** The records of a revision still being received. */
18
+ interface PendingDocument {
19
+ /** Null until the `document` event that closes the group arrives. */
20
+ readonly revision: Protocol.Document | null;
21
+ readonly cells: ReadonlyMap<Protocol.CellId, Protocol.Cell>;
22
+ }
23
+
24
+ /**
25
+ * The confirmed document and the revision being assembled after it.
26
+ *
27
+ * A snapshot's records are pending until `ready`. A live revision is pending
28
+ * from its first cell until its `document` event.
29
+ */
30
+ export interface DocumentState {
31
+ readonly current: Document | null;
32
+ readonly pending: PendingDocument | null;
33
+ }
34
+
35
+ function pendingOf(state: DocumentState): PendingDocument {
36
+ return (
37
+ state.pending ?? {
38
+ revision: state.current?.revision ?? null,
39
+ cells: state.current?.cells ?? new Map(),
40
+ }
41
+ );
42
+ }
43
+
44
+ /** Confirm a revision whose records are complete. */
45
+ function publish(
46
+ revision: Protocol.Document,
47
+ cells: ReadonlyMap<Protocol.CellId, Protocol.Cell>,
48
+ ): DocumentState {
49
+ const order = new Set(revision.order);
50
+ if (
51
+ order.size !== revision.order.length ||
52
+ order.size !== cells.size ||
53
+ [...order].some((id) => !cells.has(id)) ||
54
+ [...cells.values()].some((cell) => cell.version > revision.version)
55
+ ) {
56
+ throw new Error("Document records do not match its revision and order");
57
+ }
58
+ return { current: { revision, cells }, pending: null };
59
+ }
60
+
61
+ export const document: Resource<DocumentState> = {
62
+ initial: { current: null, pending: null },
63
+ on: {
64
+ cell: (state, { data }) => {
65
+ const pending = pendingOf(state);
66
+ return {
67
+ ...state,
68
+ pending: {
69
+ revision: pending.revision,
70
+ cells: new Map(pending.cells).set(data.cell.id, data.cell),
71
+ },
72
+ };
73
+ },
74
+ "cell.removed": (state, { data }) => {
75
+ const pending = pendingOf(state);
76
+ if (!pending.cells.has(data.id)) {
77
+ return state;
78
+ }
79
+ const cells = new Map(pending.cells);
80
+ cells.delete(data.id);
81
+ return { ...state, pending: { ...pending, cells } };
82
+ },
83
+ document: (state, { data }, { phase }) => {
84
+ const { cells } = pendingOf(state);
85
+ return phase === "snapshot"
86
+ ? { ...state, pending: { revision: data.document, cells } }
87
+ : publish(data.document, cells);
88
+ },
89
+ ready: (state, _event, { phase }) => {
90
+ if (state.pending === null) {
91
+ return state;
92
+ }
93
+ if (phase === "live") {
94
+ throw new Error("Stream ended with an incomplete document update");
95
+ }
96
+ return state.pending.revision === null
97
+ ? { current: null, pending: null }
98
+ : publish(state.pending.revision, state.pending.cells);
99
+ },
100
+ },
101
+ };
@@ -0,0 +1,59 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type * as Protocol from "../protocol";
4
+
5
+ /** An event name from the protocol. */
6
+ export type EventName = Protocol.EventOutput["event"];
7
+
8
+ /** The decoded event with the given name. */
9
+ export type EventOf<Name extends EventName> = Extract<
10
+ Protocol.EventOutput,
11
+ { event: Name }
12
+ >;
13
+
14
+ /**
15
+ * Events the stream handles itself.
16
+ *
17
+ * They identify the notebook, discard state, or carry a native
18
+ * notification. Resources do not receive them.
19
+ */
20
+ export type StreamEventName =
21
+ | "reset"
22
+ | "notebook"
23
+ | "notebook.removed"
24
+ | "marimo";
25
+
26
+ /** Events a resource may handle. `ready` marks the end of the snapshot. */
27
+ export type ResourceEventName = Exclude<EventName, StreamEventName>;
28
+
29
+ /** `snapshot` until `ready` arrives and `live` after it. */
30
+ export type StreamPhase = "snapshot" | "live";
31
+
32
+ /** What a handler may know about the stream beyond the event. */
33
+ export interface HandlerContext {
34
+ readonly phase: StreamPhase;
35
+ }
36
+
37
+ /**
38
+ * A pure handler for each event the resource reads.
39
+ *
40
+ * A handler returns `state` when the event does not apply to it.
41
+ */
42
+ export type EventHandlers<State> = {
43
+ readonly [Name in ResourceEventName]?: (
44
+ state: State,
45
+ event: EventOf<Name>,
46
+ context: HandlerContext,
47
+ ) => State;
48
+ };
49
+
50
+ /**
51
+ * One kind of object assembled from its events.
52
+ *
53
+ * The stream returns a resource to `initial` on `reset` and when the
54
+ * notebook is removed.
55
+ */
56
+ export interface Resource<State> {
57
+ readonly initial: State;
58
+ readonly on: EventHandlers<State>;
59
+ }
@@ -0,0 +1,213 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { NotebookEvent } from "./events";
4
+ import type * as Protocol from "./protocol";
5
+ import type {
6
+ EventOf,
7
+ HandlerContext,
8
+ Resource,
9
+ ResourceEventName,
10
+ StreamPhase,
11
+ } from "./resources/resource";
12
+
13
+ /** A resource for each entry of a state map under the same name. */
14
+ export type Resources<States> = {
15
+ readonly [Name in keyof States]: Resource<States[Name]>;
16
+ };
17
+
18
+ /**
19
+ * The state of one notebook stream.
20
+ *
21
+ * Holds the phase, the notebook identity, the resume cursor, and the state
22
+ * of each resource.
23
+ */
24
+ export interface StreamState<States> {
25
+ readonly phase: StreamPhase;
26
+ /**
27
+ * The notebook the stream describes.
28
+ *
29
+ * Retained across `reset`.
30
+ */
31
+ readonly notebookId: Protocol.NotebookIdOutput | null;
32
+ /**
33
+ * The id of the last live event.
34
+ *
35
+ * Null before `ready` and after `reset`.
36
+ */
37
+ readonly cursor: string | null;
38
+ /** The state of each resource by name. */
39
+ readonly resources: States;
40
+ }
41
+
42
+ /** A reducer over the events of one notebook stream. */
43
+ export interface Stream<States> {
44
+ readonly initial: StreamState<States>;
45
+ /**
46
+ * Apply one event.
47
+ *
48
+ * Throws on a live event without a cursor or on an object from another
49
+ * notebook.
50
+ */
51
+ reduce(state: StreamState<States>, event: NotebookEvent): StreamState<States>;
52
+ /**
53
+ * The state to hold while reconnecting.
54
+ *
55
+ * A live stream keeps its state and resumes from `cursor`. An incomplete
56
+ * snapshot starts over.
57
+ */
58
+ resume(state: StreamState<States>): StreamState<States>;
59
+ }
60
+
61
+ /** Events that may arrive without a cursor after `ready`. */
62
+ const UNCURSORED = new Set<NotebookEvent["event"]>([
63
+ "reset",
64
+ "marimo",
65
+ "unknown",
66
+ ]);
67
+
68
+ /**
69
+ * The notebook the event's object belongs to.
70
+ *
71
+ * Undefined for objects that do not name one.
72
+ */
73
+ function notebookIdOf(
74
+ event: EventOf<ResourceEventName>,
75
+ ): Protocol.NotebookIdOutput | undefined {
76
+ switch (event.event) {
77
+ case "document":
78
+ return event.data.document.notebook_id;
79
+ case "cell":
80
+ return event.data.cell.notebook_id;
81
+ case "analysis":
82
+ return event.data.analysis.notebook_id;
83
+ case "runtime":
84
+ return event.data.runtime.notebook_id;
85
+ case "attachment":
86
+ return event.data.attachment.notebook_id;
87
+ case "run":
88
+ return event.data.run.notebook_id;
89
+ default:
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ /** Compose resources into one stream. */
95
+ export function createStream<States extends object>(
96
+ resources: Resources<States>,
97
+ ): Stream<States> {
98
+ const names = Object.keys(resources) as (keyof States & string)[];
99
+ const initialResources = Object.fromEntries(
100
+ names.map((name) => [name, resources[name].initial]),
101
+ ) as States;
102
+ const initial: StreamState<States> = {
103
+ phase: "snapshot",
104
+ notebookId: null,
105
+ cursor: null,
106
+ resources: initialResources,
107
+ };
108
+
109
+ function identify(
110
+ state: StreamState<States>,
111
+ notebookId: Protocol.NotebookIdOutput,
112
+ ): StreamState<States> {
113
+ if (state.notebookId === notebookId) {
114
+ return state;
115
+ }
116
+ if (state.notebookId !== null) {
117
+ throw new Error("Notebook stream changed identity");
118
+ }
119
+ return { ...state, notebookId };
120
+ }
121
+
122
+ function clear(state: StreamState<States>): StreamState<States> {
123
+ return state.resources === initialResources
124
+ ? state
125
+ : { ...state, resources: initialResources };
126
+ }
127
+
128
+ function fanOut(
129
+ state: StreamState<States>,
130
+ event: EventOf<ResourceEventName>,
131
+ ): StreamState<States> {
132
+ const context: HandlerContext = { phase: state.phase };
133
+ let next = state.resources;
134
+ for (const name of names) {
135
+ type State = States[typeof name];
136
+ // Handlers are keyed by the event name they accept.
137
+ const handler = resources[name].on[event.event] as
138
+ | ((
139
+ state: State,
140
+ event: Protocol.EventOutput,
141
+ context: HandlerContext,
142
+ ) => State)
143
+ | undefined;
144
+ if (handler === undefined) {
145
+ continue;
146
+ }
147
+ const before = next[name];
148
+ const after = handler(before, event, context);
149
+ if (after !== before) {
150
+ next = { ...next, [name]: after };
151
+ }
152
+ }
153
+ return next === state.resources ? state : { ...state, resources: next };
154
+ }
155
+
156
+ /** Record the position of a live event. */
157
+ function advance(
158
+ state: StreamState<States>,
159
+ id: string | undefined,
160
+ ): StreamState<States> {
161
+ return state.phase === "live" && id !== undefined && id !== state.cursor
162
+ ? { ...state, cursor: id }
163
+ : state;
164
+ }
165
+
166
+ return {
167
+ initial,
168
+ reduce(state, event) {
169
+ if (
170
+ state.phase === "live" &&
171
+ event.id === undefined &&
172
+ !UNCURSORED.has(event.event)
173
+ ) {
174
+ throw new Error("Live notebook event is missing its cursor");
175
+ }
176
+ switch (event.event) {
177
+ case "marimo":
178
+ case "unknown":
179
+ return advance(state, event.id);
180
+ case "reset":
181
+ return { ...initial, notebookId: state.notebookId };
182
+ case "ready":
183
+ // Handlers see the phase that ended, so a snapshot's ready differs
184
+ // from one that ends a replay.
185
+ return { ...fanOut(state, event), phase: "live", cursor: event.id };
186
+ case "notebook":
187
+ return advance(identify(state, event.data.notebook.id), event.id);
188
+ case "notebook.removed":
189
+ return advance(
190
+ state.notebookId !== null && event.data.id !== state.notebookId
191
+ ? state
192
+ : clear(state),
193
+ event.id,
194
+ );
195
+ default: {
196
+ const notebookId = notebookIdOf(event);
197
+ return advance(
198
+ fanOut(
199
+ notebookId === undefined ? state : identify(state, notebookId),
200
+ event,
201
+ ),
202
+ event.id,
203
+ );
204
+ }
205
+ }
206
+ },
207
+ resume(state) {
208
+ return state.phase === "snapshot"
209
+ ? { ...initial, notebookId: state.notebookId }
210
+ : state;
211
+ },
212
+ };
213
+ }