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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/dist/{ConnectedDataExplorerComponent-BbDrlHvG.js → ConnectedDataExplorerComponent-BbSXV-Xa.js} +8 -8
  2. package/dist/{ErrorBoundary-RaIwGWT3.js → ErrorBoundary-NE86qEWZ.js} +4 -4
  3. package/dist/{abnfDiagram-VCTEODGH-Dr-fo8_z.js → abnfDiagram-VCTEODGH-D7n07nXf.js} +2 -2
  4. package/dist/{architectureDiagram-5GKGNRK7-ByKIl9GG.js → architectureDiagram-5GKGNRK7-D6CjRib5.js} +1 -1
  5. package/dist/{chat-ui-BsV2WH-4.js → chat-ui-CC7GekpG.js} +34 -34
  6. package/dist/{chunk-IMKFNOWR-IKDP18MQ.js → chunk-IMKFNOWR-6dtPz7uS.js} +1 -1
  7. package/dist/{chunk-SHT3W25Y-LkIgegwh.js → chunk-SHT3W25Y-BwnFNKDA.js} +1 -1
  8. package/dist/{chunk-SVP7TREG-DReBYtnB.js → chunk-SVP7TREG-D2eV4vBJ.js} +1 -1
  9. package/dist/{chunk-TICWLB2K-DQ0A0-1D.js → chunk-TICWLB2K-BDs4-_-r.js} +1 -1
  10. package/dist/{classDiagram-ZZMXUADV-CwvXdCNE.js → classDiagram-ZZMXUADV-DCJnf_8v.js} +2 -2
  11. package/dist/{classDiagram-v2-VYDZK3BY-D0XAxDsc.js → classDiagram-v2-VYDZK3BY-BOmSzPBD.js} +2 -2
  12. package/dist/{code-visibility-BMi0KLAQ.js → code-visibility-Bzvc6ajl.js} +23 -23
  13. package/dist/{common-BMkJrZsD.js → common-Ciw4zkh2.js} +3412 -3412
  14. package/dist/{cynefinDiagram-5FMLGOSQ-BFN68nHr.js → cynefinDiagram-5FMLGOSQ-Bln_Soky.js} +1 -1
  15. package/dist/{dagre-GXQ25YYZ-CZt4rtLq.js → dagre-GXQ25YYZ-D-nf7WzW.js} +1 -1
  16. package/dist/{data-grid-overlay-editor-CCc1P42P.js → data-grid-overlay-editor-AQREmxI7.js} +1 -1
  17. package/dist/{diagram-S7CK7UJ4-D5cOz47_.js → diagram-S7CK7UJ4-PsgSMkY2.js} +1 -1
  18. package/dist/{diagram-UQ7AKVKN-Qgdppb4a.js → diagram-UQ7AKVKN-DfwfUuSw.js} +1 -1
  19. package/dist/{diagram-VX7I27RA-BsDCvily.js → diagram-VX7I27RA-C9_e-oMc.js} +1 -1
  20. package/dist/{diagram-Z3DM3KII-igTkGjKI.js → diagram-Z3DM3KII-CWLa5tuY.js} +1 -1
  21. package/dist/{dropdown-menu-BWnnAstC.js → dropdown-menu-B6ftyzOg.js} +1 -1
  22. package/dist/{ebnfDiagram-PWID7BFC-BHuZGjIr.js → ebnfDiagram-PWID7BFC-B1QRM58v.js} +2 -2
  23. package/dist/{erDiagram-RLTQ6QDP-DKObXTVE.js → erDiagram-RLTQ6QDP-DJK_FqvW.js} +1 -1
  24. package/dist/{error-entries-BpUJSLUZ.js → error-entries-DgK3wUJR.js} +5640 -5591
  25. package/dist/{feedback-button-CMVkEos_.js → feedback-button-8a6SETmT.js} +24 -24
  26. package/dist/{flowDiagram-HODETNUW-DZI_kit0.js → flowDiagram-HODETNUW-Uxc25XSJ.js} +2 -2
  27. package/dist/{formats-CwJHWasD.js → formats-DQCyPd5Z.js} +1 -1
  28. package/dist/{glide-data-editor-BA2pcHSV.js → glide-data-editor-B70oke_v.js} +5 -5
  29. package/dist/{globals-B5jjJ0ms.js → globals-Cxd5hNlI.js} +2 -2
  30. package/dist/{html-to-image-CQr2-IDA.js → html-to-image-D4yG5Ru0.js} +25 -25
  31. package/dist/{infoDiagram-27XIBGKW-DdPl-7-n.js → infoDiagram-27XIBGKW-DmjxqwL0.js} +1 -1
  32. package/dist/{ishikawaDiagram-5VMMS53U-DbyW6l59.js → ishikawaDiagram-5VMMS53U-NgCLHqQi.js} +1 -1
  33. package/dist/{kanban-definition-UXKFOSKX-BMX9SyBS.js → kanban-definition-UXKFOSKX-CZaG-iam.js} +1 -1
  34. package/dist/main.js +187 -188
  35. package/dist/{mermaid-C8W8mTbA.js → mermaid-CSvw7c71.js} +34 -34
  36. package/dist/{mindmap-definition-YA3MSWOX-Zd_tAxWL.js → mindmap-definition-YA3MSWOX-DJYQBDH_.js} +1 -1
  37. package/dist/{number-overlay-editor-BIWiV-TU.js → number-overlay-editor-ChK-SKUk.js} +1 -1
  38. package/dist/{pegDiagram-XKGWAZYB-DjYydPdo.js → pegDiagram-XKGWAZYB-CHKO_dAe.js} +2 -2
  39. package/dist/{pieDiagram-E7YTZNPT-R5LKjO2n.js → pieDiagram-E7YTZNPT-DVevbMAo.js} +1 -1
  40. package/dist/{process-output-Uw-VJE5V.js → process-output-DxvBAeL3.js} +3 -3
  41. package/dist/{railroadDiagram-O6MQD6OU-C7uNKtfm.js → railroadDiagram-O6MQD6OU-Detj1kcB.js} +2 -2
  42. package/dist/{requirementDiagram-BXWQKSXE-CxAIzQfl.js → requirementDiagram-BXWQKSXE-BLFD0T_Z.js} +1 -1
  43. package/dist/{reveal-component-XiecZ3Ua.js → reveal-component-CXMXeDZD.js} +6 -6
  44. package/dist/{sequenceDiagram-WJ2MYXX4-B5tmlPd5.js → sequenceDiagram-WJ2MYXX4-BZBgatQV.js} +1 -1
  45. package/dist/{spec-45FnujvG.js → spec-Mnkn3XEr.js} +1 -1
  46. package/dist/{stateDiagram-D77RDMKH-C1hc5H7Y.js → stateDiagram-D77RDMKH-4BZW9-6_.js} +2 -2
  47. package/dist/{stateDiagram-v2-MP3YSRHH-DRWeqQTn.js → stateDiagram-v2-MP3YSRHH-C4sKC8Iq.js} +2 -2
  48. package/dist/style.css +1 -1
  49. package/dist/{swimlanes-42K2YHIH-NTLas--s.js → swimlanes-42K2YHIH-SL5yeGe2.js} +1 -1
  50. package/dist/{swimlanesDiagram-VR7AAH4N-ChPggljD.js → swimlanesDiagram-VR7AAH4N-DqPmAYUv.js} +2 -2
  51. package/dist/{timeline-definition-24CTP7MA-B4CZ9srJ.js → timeline-definition-24CTP7MA-BfXUMWkq.js} +1 -1
  52. package/dist/{toDate-rySyYxqk.js → toDate-TMubKIVL.js} +22 -22
  53. package/dist/{useAsyncData-B7MiltyV.js → useAsyncData-DjMGFzFy.js} +1 -1
  54. package/dist/{useDeepCompareMemoize-DZmHBD_1.js → useDeepCompareMemoize-BQR5TJot.js} +1 -1
  55. package/dist/{useLifecycle-DRbRhzxg.js → useLifecycle-C_GvKWbQ.js} +26 -23
  56. package/dist/{useTheme-TLkqJsvo.js → useTheme-Bns2gJZ6.js} +179 -178
  57. package/dist/{vega-component-DC6P127L.js → vega-component-BsVptuhz.js} +5 -5
  58. package/dist/{vennDiagram-4TSXK5OY-DJIvMt44.js → vennDiagram-4TSXK5OY-CH65znGa.js} +1 -1
  59. package/dist/{wardleyDiagram-VM6X3IG4-45H0zg0u.js → wardleyDiagram-VM6X3IG4-z9pWKb2_.js} +1 -1
  60. package/dist/{xychartDiagram-S5SC5T6Z-ZT-XzcPN.js → xychartDiagram-S5SC5T6Z-MxyaWjjq.js} +1 -1
  61. package/package.json +1 -1
  62. package/src/__mocks__/requests.ts +0 -1
  63. package/src/components/editor/cell/StagedAICell.tsx +10 -2
  64. package/src/components/editor/cell/code/cell-editor.tsx +7 -14
  65. package/src/components/editor/chrome/wrapper/__tests__/document-sync-status.test.tsx +92 -0
  66. package/src/components/editor/chrome/wrapper/footer-items/document-sync-status.tsx +54 -0
  67. package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
  68. package/src/core/ai/__tests__/staged-cells.test.ts +8 -5
  69. package/src/core/ai/staged-cells.ts +7 -2
  70. package/src/core/ai/tools/__tests__/edit-notebook-tool.test.ts +3 -15
  71. package/src/core/ai/tools/edit-notebook-tool.ts +14 -3
  72. package/src/core/cells/__tests__/cells.test.ts +0 -297
  73. package/src/core/cells/__tests__/document.test.ts +410 -0
  74. package/src/core/cells/__tests__/flush-document.test.ts +40 -0
  75. package/src/core/cells/__tests__/notebook-outline.test.ts +1 -6
  76. package/src/core/cells/__tests__/with-document-save.test.ts +67 -0
  77. package/src/core/cells/cells.ts +27 -158
  78. package/src/core/cells/columns.ts +38 -0
  79. package/src/core/cells/document.ts +360 -0
  80. package/src/core/cells/types.ts +12 -0
  81. package/src/core/codemirror/cells/__tests__/extensions.test.ts +27 -1
  82. package/src/core/codemirror/cells/extensions.ts +14 -0
  83. package/src/core/codemirror/format.ts +6 -5
  84. package/src/core/codemirror/language/__tests__/utils.test.ts +187 -15
  85. package/src/core/codemirror/language/cell-code.ts +114 -0
  86. package/src/core/codemirror/language/extension.ts +47 -30
  87. package/src/core/codemirror/language/languages/sql/sql.ts +7 -17
  88. package/src/core/codemirror/language/utils.ts +9 -39
  89. package/src/core/codemirror/lsp/notebook-lsp.ts +6 -5
  90. package/src/core/codemirror/rtc/extension.ts +0 -1
  91. package/src/core/edit-app.tsx +4 -3
  92. package/src/core/export/layout.ts +2 -2
  93. package/src/core/islands/bridge.ts +0 -1
  94. package/src/core/network/requests-lazy.ts +0 -1
  95. package/src/core/network/requests-network.ts +0 -9
  96. package/src/core/network/requests-static.ts +0 -1
  97. package/src/core/network/requests-toasting.tsx +0 -1
  98. package/src/core/network/types.ts +0 -5
  99. package/src/core/notebook/document/__tests__/codemirror.test.ts +76 -14
  100. package/src/core/notebook/document/__tests__/hold.test.ts +88 -0
  101. package/src/core/notebook/document/__tests__/hooks.test.tsx +3 -3
  102. package/src/core/notebook/document/__tests__/lifecycle-scenarios.test.ts +3 -3
  103. package/src/core/notebook/document/__tests__/model.test.ts +71 -15
  104. package/src/core/notebook/document/__tests__/scenario-fixture.ts +1 -1
  105. package/src/core/notebook/document/codemirror.ts +27 -122
  106. package/src/core/notebook/document/hooks.tsx +2 -2
  107. package/src/core/notebook/document/index.ts +9 -1
  108. package/src/core/notebook/document/model.ts +74 -24
  109. package/src/core/notebook/document/sync.ts +53 -4
  110. package/src/core/run-app.tsx +44 -41
  111. package/src/core/saving/save-component.tsx +2 -5
  112. package/src/core/session/__tests__/useNotebookSession.hook.test.tsx +63 -3
  113. package/src/core/session/active-notebook.ts +4 -0
  114. package/src/core/session/useNotebookSession.tsx +39 -19
  115. package/src/core/wasm/__tests__/bridge.test.ts +3 -3
  116. package/src/core/wasm/bridge.ts +5 -12
  117. package/src/core/wasm/worker/worker.ts +0 -9
  118. package/src/utils/__tests__/{createReducer.test.ts → createReducer.test.tsx} +28 -0
  119. package/src/utils/createReducer.ts +42 -21
  120. package/src/core/cells/__tests__/apply-transaction.test.ts +0 -991
  121. package/src/core/cells/__tests__/document-changes.test.ts +0 -1140
  122. package/src/core/cells/__tests__/document-roundtrip.test.ts +0 -392
  123. package/src/core/cells/document-changes.ts +0 -944
@@ -1,7 +1,11 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { atom } from "jotai";
4
+ import type { DocumentModel } from "../notebook/document";
4
5
  import type { NotebookReplica } from "../notebook/replica";
5
6
 
6
7
  /** The replica owned by the mounted notebook session. */
7
8
  export const activeNotebookReplicaAtom = atom<NotebookReplica | null>(null);
9
+
10
+ /** The document model owned by the mounted notebook session. */
11
+ export const activeDocumentModelAtom = atom<DocumentModel | null>(null);
@@ -2,16 +2,13 @@
2
2
 
3
3
  import { invalidatePackageData } from "@/core/packages/package-data";
4
4
  import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
5
- import { useLayoutEffect } from "react";
5
+ import { useEffect, useLayoutEffect, useMemo } from "react";
6
6
  import { useErrorBoundary } from "react-error-boundary";
7
7
  import { toast } from "@/components/ui/use-toast";
8
8
  import { getNotebook, useCellActions } from "@/core/cells/cells";
9
- import { applyTransactionChanges } from "@/core/cells/document-changes";
10
9
  import { AUTOCOMPLETER } from "@/core/codemirror/completion/Autocompleter";
11
- import type {
12
- NotificationMessageData,
13
- NotificationPayload,
14
- } from "@/core/kernel/messages";
10
+ import { createDocumentModel } from "@/core/notebook/document";
11
+ import type { NotificationPayload } from "@/core/kernel/messages";
15
12
  import { useNotebookReplica } from "@/core/session/useNotebookReplica";
16
13
  import { renderHTML } from "@/plugins/core/RenderHTML";
17
14
  import {
@@ -75,6 +72,7 @@ import {
75
72
  import { useStorageActions } from "../storage/state";
76
73
  import { useVariablesActions } from "../variables/state";
77
74
  import type { VariableName } from "../variables/types";
75
+ import { activeDocumentModelAtom } from "./active-notebook";
78
76
  import { WebSocketClosedReason, ConnectionState } from "./types";
79
77
 
80
78
  const SUPPORTS_LAZY_KERNELS = true;
@@ -100,17 +98,7 @@ export function useNotebookSession(opts: {
100
98
  const { showBoundary } = useErrorBoundary();
101
99
 
102
100
  const { handleCellMessage } = useCellActions();
103
- const actionsWithoutMiddleware = useCellActions({ skipMiddleware: true });
104
-
105
- const handleDocumentTransaction = (
106
- transaction: NotificationMessageData<"notebook-document-transaction">["transaction"],
107
- ) => {
108
- applyTransactionChanges(
109
- transaction.changes,
110
- actionsWithoutMiddleware,
111
- () => getNotebook().cellIds.inOrderIds,
112
- );
113
- };
101
+ const { reconcileDocument } = useCellActions({ skipMiddleware: true });
114
102
  const { addCellNotification } = useRunsActions();
115
103
  const setKernelState = useSetAtom(kernelStateAtom);
116
104
  const setInitialRunCompleted = useSetAtom(initialRunCompletedAtom);
@@ -395,7 +383,7 @@ export function useNotebookSession(opts: {
395
383
  );
396
384
  return;
397
385
  case "notebook-document-transaction":
398
- handleDocumentTransaction(msg.data.transaction);
386
+ // Edits reach the notebook through the host document.
399
387
  return;
400
388
  case "consumer-capabilities":
401
389
  setKioskMode(!msg.data.consumer_capabilities.edit);
@@ -440,6 +428,38 @@ export function useNotebookSession(opts: {
440
428
  },
441
429
  });
442
430
 
431
+ const documentModel = useMemo(
432
+ () =>
433
+ createDocumentModel({
434
+ store,
435
+ documentAtom: replica.documentAtom,
436
+ backendAtom: replica.backendAtom,
437
+ }),
438
+ [store, replica],
439
+ );
440
+ // Edits outlive reconnects and end with the session. The notebook's cells
441
+ // follow the document for as long as the session is mounted.
442
+ useEffect(() => {
443
+ const controller = new AbortController();
444
+ documentModel.start({ signal: controller.signal });
445
+ store.set(activeDocumentModelAtom, documentModel);
446
+ const follow = () => {
447
+ const document = store.get(documentModel.documentAtom);
448
+ if (document !== null) {
449
+ reconcileDocument(document);
450
+ }
451
+ };
452
+ const unsubscribe = store.sub(documentModel.documentAtom, follow);
453
+ follow();
454
+ return () => {
455
+ unsubscribe();
456
+ controller.abort();
457
+ if (store.get(activeDocumentModelAtom) === documentModel) {
458
+ store.set(activeDocumentModelAtom, null);
459
+ }
460
+ };
461
+ }, [documentModel, store, reconcileDocument]);
462
+
443
463
  // Subscribe before passive connection setup: static/WASM transports can emit synchronously.
444
464
  useLayoutEffect(() => {
445
465
  const updateConnection = () => {
@@ -485,5 +505,5 @@ export function useNotebookSession(opts: {
485
505
  updateStartupProgress,
486
506
  ]);
487
507
 
488
- return { replica, connection, reconnect: replica.reconnect };
508
+ return { replica, documentModel, connection, reconnect: replica.reconnect };
489
509
  }
@@ -101,9 +101,9 @@ vi.mock("@/core/wasm/store", () => ({
101
101
  notebookFileStore: { readFile: mockNotebookReadFile, saveFile: vi.fn() },
102
102
  }));
103
103
 
104
- vi.mock("@/core/cells/document-changes", () => ({
105
- flushDocumentChanges: mockFlush,
106
- documentTransactionMiddleware: vi.fn(),
104
+ vi.mock("@/core/cells/document", async (importOriginal) => ({
105
+ ...(await importOriginal<typeof import("@/core/cells/document")>()),
106
+ flushDocument: mockFlush,
107
107
  }));
108
108
 
109
109
  // Import after all mocks are set up
@@ -13,7 +13,7 @@ import { once } from "@/utils/once";
13
13
  import { generateUUID } from "@/utils/uuid";
14
14
  import { createModuleWorker } from "@/utils/worker";
15
15
  import { notebookIsRunningAtom } from "../cells/cells";
16
- import { flushDocumentChanges } from "../cells/document-changes";
16
+ import { flushDocument } from "../cells/document";
17
17
  import { createWasmNotebookBackend } from "../transports/wasm";
18
18
  import type { ClientCommand } from "../kernel/messages";
19
19
  import { getInitialAppMode } from "../mode";
@@ -280,7 +280,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
280
280
  }
281
281
 
282
282
  public sendSave: EditRequests["sendSave"] = async ({ layout }) => {
283
- await flushDocumentChanges();
283
+ await flushDocument();
284
284
  await this.rpc.proxy.request.setNotebookLayout(layout ?? null);
285
285
  await this.saveHostNotebook();
286
286
  return null;
@@ -565,7 +565,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
565
565
  public exportAsHTML: EditRequests["exportAsHTML"] = async (
566
566
  request: ExportAsHTMLRequest,
567
567
  ) => {
568
- await flushDocumentChanges();
568
+ await flushDocument();
569
569
  const response = await this.rpc.proxy.request.bridge({
570
570
  functionName: "export_html",
571
571
  payload: withDevAssetUrl(request),
@@ -576,7 +576,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
576
576
  public exportAsMarkdown: EditRequests["exportAsMarkdown"] = async (
577
577
  request: ExportAsMarkdownRequest,
578
578
  ) => {
579
- await flushDocumentChanges();
579
+ await flushDocument();
580
580
  const response = await this.rpc.proxy.request.bridge({
581
581
  functionName: "export_markdown",
582
582
  payload: request,
@@ -587,7 +587,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
587
587
  public exportAsScript: EditRequests["exportAsScript"] = async (
588
588
  request: ExportAsScriptRequest,
589
589
  ) => {
590
- await flushDocumentChanges();
590
+ await flushDocument();
591
591
  const response = await this.rpc.proxy.request.bridge({
592
592
  functionName: "export_script",
593
593
  payload: request,
@@ -658,13 +658,6 @@ export class PyodideBridge implements RunRequests, EditRequests {
658
658
  return null;
659
659
  };
660
660
 
661
- public sendDocumentTransaction: EditRequests["sendDocumentTransaction"] =
662
- async (request) => {
663
- await this.initialized.promise;
664
- await this.rpc.proxy.request.documentTransaction(request);
665
- return null;
666
- };
667
-
668
661
  public addPackage: EditRequests["addPackage"] = async (request) => {
669
662
  return this.rpc.proxy.request.addPackage(request);
670
663
  };
@@ -12,7 +12,6 @@ import type { UserConfig } from "@/core/config/config-schema";
12
12
  import type {
13
13
  ListPackagesResponse,
14
14
  PackageOperationResponse,
15
- NotebookDocumentTransactionRequest,
16
15
  SaveNotebookRequest,
17
16
  } from "@/core/network/types";
18
17
  import { decodeUtf8 } from "@/utils/strings";
@@ -302,14 +301,6 @@ const requestHandler = createRPCRequestHandler({
302
301
  };
303
302
  },
304
303
 
305
- documentTransaction: async (request: NotebookDocumentTransactionRequest) => {
306
- const notebook = await getNotebook();
307
- try {
308
- await notebook.transaction(JSON.stringify(request));
309
- } finally {
310
- notebook.destroy();
311
- }
312
- },
313
304
  setNotebookLayout: async (layout: SaveNotebookRequest["layout"]) => {
314
305
  const notebook = await getNotebook();
315
306
  try {
@@ -1,4 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { renderHook } from "@testing-library/react";
3
+ import { createStore, Provider } from "jotai";
4
+ import type { PropsWithChildren } from "react";
2
5
  import { describe, expect, it, vi } from "vitest";
3
6
  import { Logger } from "@/utils/Logger";
4
7
  import { createReducer, createReducerAndAtoms } from "../createReducer";
@@ -140,6 +143,31 @@ describe("createReducerAndAtoms", () => {
140
143
  expect(middleware).toHaveBeenCalledWith(state, { count: 5 }, action);
141
144
  });
142
145
 
146
+ it("runs middleware after the write commits, so it sees the new state", () => {
147
+ const initialState = () => ({ count: 0 });
148
+ const reducers = {
149
+ increment: (state: State, payload: number) => ({
150
+ count: state.count + payload,
151
+ }),
152
+ };
153
+ const seen: number[] = [];
154
+ const { valueAtom, useActions } = createReducerAndAtoms(
155
+ initialState,
156
+ reducers,
157
+ [(_prev, next) => seen.push(store.get(valueAtom).count, next.count)],
158
+ );
159
+ const store = createStore();
160
+ const wrapper = ({ children }: PropsWithChildren) => (
161
+ <Provider store={store}>{children}</Provider>
162
+ );
163
+ const { result } = renderHook(() => useActions(), { wrapper });
164
+
165
+ result.current.increment(5);
166
+
167
+ expect(seen).toEqual([5, 5]);
168
+ expect(store.get(valueAtom)).toEqual({ count: 5 });
169
+ });
170
+
143
171
  it("should log an error for non-existent action types", () => {
144
172
  const initialState = () => ({ count: 0 });
145
173
  const reducers = {
@@ -104,49 +104,70 @@ export function createReducerAndAtoms<
104
104
  };
105
105
  const { reducer, createActions } = createReducer(initialState, reducers);
106
106
 
107
- const reducerWithMiddleware = (state: State, action: ReducerActionOf<RH>) => {
107
+ const reduceSafely = (state: State, action: ReducerActionOf<RH>) => {
108
108
  try {
109
- const newState = reducer(state, action);
110
- for (const mw of allMiddleware) {
111
- try {
112
- mw(state, newState, action);
113
- } catch (error) {
114
- Logger.error(`Error in middleware for action ${action.type}:`, error);
115
- }
116
- }
117
- return newState;
109
+ return reducer(state, action);
118
110
  } catch (error) {
119
111
  Logger.error(`Error in reducer for action ${action.type}:`, error);
120
112
  return state;
121
113
  }
122
114
  };
123
115
 
116
+ const runMiddleware = (
117
+ prev: State,
118
+ next: State,
119
+ action: ReducerActionOf<RH>,
120
+ ) => {
121
+ for (const mw of allMiddleware) {
122
+ try {
123
+ mw(prev, next, action);
124
+ } catch (error) {
125
+ Logger.error(`Error in middleware for action ${action.type}:`, error);
126
+ }
127
+ }
128
+ };
129
+
130
+ const reducerWithMiddleware = (state: State, action: ReducerActionOf<RH>) => {
131
+ const next = reduceSafely(state, action);
132
+ runMiddleware(state, next, action);
133
+ return next;
134
+ };
135
+
124
136
  const valueAtom = atom(initialState());
125
- // map of SetAtom => Actions
137
+ // map of SetAtom => Actions, with and without middleware
126
138
  const actionsMap = new WeakMap();
139
+ const plainActionsMap = new WeakMap();
127
140
 
128
141
  function useActions(
129
142
  options: { skipMiddleware?: boolean } = {},
130
143
  ): ReducerActions<RH> {
131
144
  const setState = useSetAtom(valueAtom);
145
+ const skipMiddleware = options.skipMiddleware === true;
146
+ const cache = skipMiddleware ? plainActionsMap : actionsMap;
132
147
 
133
- if (options.skipMiddleware === true) {
134
- return createActions((action: ReducerActionOf<RH>) => {
135
- setState((state: State) => reducer(state, action));
136
- });
137
- }
138
-
139
- if (!actionsMap.has(setState)) {
140
- actionsMap.set(
148
+ if (!cache.has(setState)) {
149
+ cache.set(
141
150
  setState,
142
151
  createActions((action: ReducerActionOf<RH>) => {
143
- setState((state: State) => reducerWithMiddleware(state, action));
152
+ // Reduce inside the write. Middleware runs once the write has
153
+ // committed and its subscribers have run, so anything it sets in
154
+ // turn observes the new state rather than the one being replaced.
155
+ let prev: State | undefined;
156
+ let next: State | undefined;
157
+ setState((state: State) => {
158
+ prev = state;
159
+ next = reduceSafely(state, action);
160
+ return next;
161
+ });
162
+ if (!skipMiddleware && prev !== undefined && next !== undefined) {
163
+ runMiddleware(prev, next, action);
164
+ }
144
165
  }),
145
166
  );
146
167
  }
147
168
 
148
169
  // oxlint-disable-next-line typescript/no-non-null-assertion
149
- return actionsMap.get(setState)!;
170
+ return cache.get(setState)!;
150
171
  }
151
172
 
152
173
  return {