@marimo-team/frontend 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.
@@ -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
+ }