@marimo-team/islands 0.18.3 → 0.18.4

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.
@@ -2,7 +2,7 @@ import { s as __toESM } from "./chunk-BNovOVIE.js";
2
2
  import { t as require_react } from "./react-BSzAiXXz.js";
3
3
  import { t as require_compiler_runtime } from "./compiler-runtime-CNX0xYDF.js";
4
4
  import "./Combination-DnWHe36P.js";
5
- import { C as CircleQuestionMark, h as asRemoteURL, i as Alert, n as useDeepCompareMemoize, o as AlertTitle, s as isValid, t as arrow } from "./formats-4m4HuHTj.js";
5
+ import { S as CircleQuestionMark, a as AlertTitle, m as asRemoteURL, n as useDeepCompareMemoize, o as isValid, r as Alert, t as arrow } from "./formats-Bi_tbdwB.js";
6
6
  import "./clsx-D2KVTYnW.js";
7
7
  import { l as Events } from "./button-XnD6ylpt.js";
8
8
  import { o as Objects, s as Logger } from "./hotkeys-CwkyZ6ZF.js";
@@ -509,8 +509,8 @@ async function resolveVegaSpecData(e) {
509
509
  } catch {
510
510
  return e2;
511
511
  }
512
- let O = await vegaLoadData(E2.href, e2.data.format);
513
- return w[E2.pathname] = O, {
512
+ let D = await vegaLoadData(E2.href, e2.data.format);
513
+ return w[E2.pathname] = D, {
514
514
  ...e2,
515
515
  data: { name: E2.pathname }
516
516
  };
@@ -540,17 +540,17 @@ var VegaComponent = (e) => {
540
540
  spec: P,
541
541
  embedOptions: A
542
542
  }), w[5] = D, w[6] = A, w[7] = O, w[8] = P, w[9] = E, w[10] = T, w[11] = I) : I = w[11], I;
543
- }, LoadedVegaComponent = ({ value: e, setValue: w, chartSelection: T, fieldSelection: D, spec: M, embedOptions: N }) => {
543
+ }, LoadedVegaComponent = ({ value: e, setValue: w, chartSelection: T, fieldSelection: O, spec: A, embedOptions: N }) => {
544
544
  let { theme: L } = useTheme(), R = (0, import_react.useRef)(null), z = (0, import_react.useRef)(void 0), [B, V] = (0, import_react.useState)(), H = (0, import_react.useMemo)(() => N && "actions" in N ? N.actions : {
545
545
  source: false,
546
546
  compiled: false
547
- }, [N]), U = useDeepCompareMemoize(M), W = (0, import_react.useMemo)(() => makeSelectable(fixRelativeUrl(U), {
547
+ }, [N]), U = useDeepCompareMemoize(A), W = (0, import_react.useMemo)(() => makeSelectable(fixRelativeUrl(U), {
548
548
  chartSelection: T,
549
- fieldSelection: D
549
+ fieldSelection: O
550
550
  }), [
551
551
  U,
552
552
  T,
553
- D
553
+ O
554
554
  ]), G = (0, import_react.useMemo)(() => getSelectionParamNames(W), [W]), K = useEvent_default((T2) => {
555
555
  w({
556
556
  ...e,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.18.3",
3
+ "version": "0.18.4",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -95,11 +95,11 @@ export const CellContextMenu = <TData,>({
95
95
  const column = cell.column;
96
96
  const canFilter = column.getCanFilter() && column.columnDef.meta?.filterType;
97
97
 
98
- const handleFilterCell = () => {
98
+ const handleFilterCell = (operator: "in" | "not_in") => {
99
99
  column.setFilterValue(
100
100
  Filter.select({
101
101
  options: [cell.getValue()],
102
- operator: "in",
102
+ operator,
103
103
  }),
104
104
  );
105
105
  };
@@ -119,10 +119,14 @@ export const CellContextMenu = <TData,>({
119
119
  {canFilter && (
120
120
  <>
121
121
  <ContextMenuSeparator />
122
- <ContextMenuItem onClick={handleFilterCell}>
122
+ <ContextMenuItem onClick={() => handleFilterCell("in")}>
123
123
  <FilterIcon className="mo-dropdown-icon h-3 w-3" />
124
124
  Filter by this value
125
125
  </ContextMenuItem>
126
+ <ContextMenuItem onClick={() => handleFilterCell("not_in")}>
127
+ <FilterIcon className="mo-dropdown-icon h-3 w-3" />
128
+ Remove rows with this value
129
+ </ContextMenuItem>
126
130
  </>
127
131
  )}
128
132
  </ContextMenuContent>
@@ -96,7 +96,8 @@ function formatValue(value: ColumnFilterValue, timeFormatter: DateFormatter) {
96
96
  const stringifiedOptions = value.options.map((o) =>
97
97
  stringifyUnknownValue({ value: o }),
98
98
  );
99
- return `is in [${stringifiedOptions.join(", ")}]`;
99
+ const operator = value.operator === "in" ? "is in" : "not in";
100
+ return `${operator} [${stringifiedOptions.join(", ")}]`;
100
101
  }
101
102
  if (value.type === "text") {
102
103
  return `contains "${value.text}"`;
@@ -7,6 +7,7 @@ import type { ConditionType } from "@/plugins/impl/data-frames/schema";
7
7
  import type { ColumnId } from "@/plugins/impl/data-frames/types";
8
8
  import type { OperatorType } from "@/plugins/impl/data-frames/utils/operators";
9
9
  import { assertNever } from "@/utils/assertNever";
10
+ import { Logger } from "@/utils/Logger";
10
11
 
11
12
  declare module "@tanstack/react-table" {
12
13
  //allows us to define custom properties for our columns
@@ -192,12 +193,20 @@ export function filterToFilterCondition(
192
193
  }
193
194
 
194
195
  return [];
195
- case "select":
196
+ case "select": {
197
+ let operator = filter.operator;
198
+ if (filter.operator !== "in" && filter.operator !== "not_in") {
199
+ Logger.warn("Invalid operator for select filter", {
200
+ operator: filter.operator,
201
+ });
202
+ operator = "in"; // default to in operator
203
+ }
196
204
  return {
197
205
  column_id: columnId,
198
- operator: "in",
206
+ operator,
199
207
  value: filter.options,
200
208
  };
209
+ }
201
210
 
202
211
  default:
203
212
  assertNever(filter);
@@ -46,7 +46,7 @@ export const CreateCellButton = ({
46
46
  <div>{baseTooltipContent}</div>
47
47
  <div className="text-xs text-muted-foreground font-medium pt-1 -mt-2 border-t border-border">
48
48
  {<MinimalHotkeys shortcut={shortcut} className="inline" />}{" "}
49
- <span>to auto insert a cell</span>
49
+ <span>for other cell types</span>
50
50
  </div>
51
51
  </div>
52
52
  );
@@ -81,7 +81,9 @@ export const CreateCellButton = ({
81
81
  };
82
82
 
83
83
  const handleButtonClick = (e: React.MouseEvent) => {
84
- if (oneClickShortcut === "shift" ? e.shiftKey : e.metaKey || e.ctrlKey) {
84
+ const hasModifier =
85
+ oneClickShortcut === "shift" ? e.shiftKey : e.metaKey || e.ctrlKey;
86
+ if (!hasModifier) {
85
87
  e.preventDefault();
86
88
  e.stopPropagation();
87
89
  addPythonCell();
@@ -123,7 +125,7 @@ export const CreateCellButton = ({
123
125
  >
124
126
  <Tooltip content={finalTooltipContent}>
125
127
  <PlusIcon
126
- strokeWidth={4}
128
+ strokeWidth={3}
127
129
  size={14}
128
130
  className="opacity-60 hover:opacity-90"
129
131
  />
@@ -44,9 +44,9 @@ export const CollapseToggle: React.FC<Props> = (props) => {
44
44
 
45
45
  const Arrow = ({ isCollapsed }: { isCollapsed: boolean }) => {
46
46
  return isCollapsed ? (
47
- <ChevronRightIcon className="w-5 h-5 shrink-0" />
47
+ <ChevronRightIcon className="w-5 h-5 shrink-0 opacity-60" strokeWidth={2} />
48
48
  ) : (
49
- <ChevronDownIcon className="w-5 h-5 shrink-0" />
49
+ <ChevronDownIcon className="w-5 h-5 shrink-0 opacity-60" strokeWidth={2} />
50
50
  );
51
51
  };
52
52
 
@@ -26,7 +26,7 @@ export const NotebookBanner: React.FC<Props> = ({ width }) => {
26
26
  <div
27
27
  className={cn(
28
28
  "flex flex-col gap-4 mb-5 print:hidden",
29
- width === "columns" && "sticky left-12 w-full max-w-[80vw]",
29
+ width === "columns" && "w-full max-w-[80vw]",
30
30
  )}
31
31
  >
32
32
  {banners.map((banner) => (
@@ -2,8 +2,9 @@
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
3
 
4
4
  import type { AnyWidget, Experimental } from "@anywidget/types";
5
- import { get, isEqual, set } from "lodash-es";
5
+ import { isEqual } from "lodash-es";
6
6
  import { useEffect, useMemo, useRef } from "react";
7
+ import useEvent from "react-use-event-hook";
7
8
  import { z } from "zod";
8
9
  import { MarimoIncomingMessageEvent } from "@/core/dom/events";
9
10
  import { asRemoteURL } from "@/core/runtime/config";
@@ -17,10 +18,13 @@ import { createPlugin } from "@/plugins/core/builder";
17
18
  import { rpc } from "@/plugins/core/rpc";
18
19
  import type { IPluginProps } from "@/plugins/types";
19
20
  import {
20
- type Base64String,
21
- byteStringToBinary,
22
- typedAtob,
23
- } from "@/utils/json/base64";
21
+ decodeFromWire,
22
+ isWireFormat,
23
+ serializeBuffersToBase64,
24
+ type WireFormat,
25
+ } from "@/utils/data-views";
26
+ import { prettyError } from "@/utils/errors";
27
+ import type { Base64String } from "@/utils/json/base64";
24
28
  import { Logger } from "@/utils/Logger";
25
29
  import { ErrorBanner } from "../common/error-banner";
26
30
  import { MODEL_MANAGER, Model } from "./model";
@@ -29,44 +33,56 @@ interface Data {
29
33
  jsUrl: string;
30
34
  jsHash: string;
31
35
  css?: string | null;
32
- bufferPaths?: (string | number)[][] | null;
33
- initialValue: T;
34
36
  }
35
37
 
36
- type T = Record<string, any>;
38
+ type T = Record<string, unknown>;
37
39
 
38
- // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
39
40
  type PluginFunctions = {
40
- send_to_widget: <T>(req: { content?: any }) => Promise<null | undefined>;
41
+ send_to_widget: <T>(req: {
42
+ content: unknown;
43
+ buffers: Base64String[];
44
+ }) => Promise<null | undefined>;
41
45
  };
42
46
 
43
- export const AnyWidgetPlugin = createPlugin<T>("marimo-anywidget")
47
+ export const AnyWidgetPlugin = createPlugin<WireFormat<T>>("marimo-anywidget")
44
48
  .withData(
45
49
  z.object({
46
50
  jsUrl: z.string(),
47
51
  jsHash: z.string(),
48
52
  css: z.string().nullish(),
49
- bufferPaths: z
50
- .array(z.array(z.union([z.string(), z.number()])))
51
- .nullish(),
52
- initialValue: z.object({}).passthrough(),
53
53
  }),
54
54
  )
55
55
  .withFunctions<PluginFunctions>({
56
56
  send_to_widget: rpc
57
- .input(z.object({ content: z.any() }))
57
+ .input(
58
+ z.object({
59
+ content: z.unknown(),
60
+ buffers: z.array(z.string().transform((v) => v as Base64String)),
61
+ }),
62
+ )
58
63
  .output(z.null().optional()),
59
64
  })
60
65
  .renderer((props) => <AnyWidgetSlot {...props} />);
61
66
 
62
- type Props = IPluginProps<T, Data, PluginFunctions>;
63
-
64
- const AnyWidgetSlot = (props: Props) => {
65
- const { css, jsUrl, jsHash, bufferPaths } = props.data;
67
+ const AnyWidgetSlot = (
68
+ props: IPluginProps<WireFormat<T>, Data, PluginFunctions>,
69
+ ) => {
70
+ const { css, jsUrl, jsHash } = props.data;
66
71
 
72
+ // Decode wire format { state, bufferPaths, buffers } to state with DataViews
67
73
  const valueWithBuffers = useMemo(() => {
68
- return resolveInitialValue(props.value, bufferPaths ?? []);
69
- }, [props.value, bufferPaths]);
74
+ if (isWireFormat(props.value)) {
75
+ const decoded = decodeFromWire(props.value);
76
+ Logger.debug("AnyWidget decoded wire format:", {
77
+ bufferPaths: props.value.bufferPaths,
78
+ buffersCount: props.value.buffers?.length,
79
+ decodedKeys: Object.keys(decoded),
80
+ });
81
+ return decoded;
82
+ }
83
+ Logger.warn("AnyWidget value is not wire format:", props.value);
84
+ return props.value;
85
+ }, [props.value]);
70
86
 
71
87
  // JS is an ESM file with a render function on it
72
88
  // export function render({ model, el }) {
@@ -135,6 +151,12 @@ const AnyWidgetSlot = (props: Props) => {
135
151
  };
136
152
  }, [css, props.host]);
137
153
 
154
+ // Wrap setValue to serialize DataViews back to base64 before sending
155
+ // Structure matches ipywidgets protocol: { state, bufferPaths, buffers }
156
+ const wrappedSetValue = useEvent((partialValue: Partial<T>) =>
157
+ props.setValue(serializeBuffersToBase64(partialValue)),
158
+ );
159
+
138
160
  if (error) {
139
161
  return <ErrorBanner error={error} />;
140
162
  }
@@ -162,6 +184,7 @@ const AnyWidgetSlot = (props: Props) => {
162
184
  key={key}
163
185
  {...props}
164
186
  widget={module.default}
187
+ setValue={wrappedSetValue}
165
188
  value={valueWithBuffers}
166
189
  />
167
190
  );
@@ -191,10 +214,19 @@ async function runAnyWidgetModule(
191
214
  const widget =
192
215
  typeof widgetDef === "function" ? await widgetDef() : widgetDef;
193
216
  await widget.initialize?.({ model, experimental });
194
- const unsub = await widget.render?.({ model, el, experimental });
195
- return () => {
196
- unsub?.();
197
- };
217
+ try {
218
+ const unsub = await widget.render?.({ model, el, experimental });
219
+ return () => {
220
+ unsub?.();
221
+ };
222
+ } catch (error) {
223
+ Logger.error("Error rendering anywidget", error);
224
+ el.classList.add("text-error");
225
+ el.innerHTML = `Error rendering anywidget: ${prettyError(error)}`;
226
+ return () => {
227
+ // No-op
228
+ };
229
+ }
198
230
  }
199
231
 
200
232
  function isAnyWidgetModule(mod: any): mod is { default: AnyWidget } {
@@ -218,6 +250,13 @@ function hasModelId(message: unknown): message is { model_id: string } {
218
250
  );
219
251
  }
220
252
 
253
+ interface Props
254
+ extends Omit<IPluginProps<T, Data, PluginFunctions>, "setValue"> {
255
+ widget: AnyWidget;
256
+ value: T;
257
+ setValue: (value: Partial<T>) => void;
258
+ }
259
+
221
260
  const LoadedSlot = ({
222
261
  value,
223
262
  setValue,
@@ -228,15 +267,9 @@ const LoadedSlot = ({
228
267
  }: Props & { widget: AnyWidget }) => {
229
268
  const htmlRef = useRef<HTMLDivElement>(null);
230
269
 
270
+ // value is already decoded from wire format
231
271
  const model = useRef<Model<T>>(
232
- new Model(
233
- // Merge the initial value with the current value
234
- // since we only send partial updates to the backend
235
- { ...data.initialValue, ...value },
236
- setValue,
237
- functions.send_to_widget,
238
- getDirtyFields(value, data.initialValue),
239
- ),
272
+ new Model(value, setValue, functions.send_to_widget, new Set()),
240
273
  );
241
274
 
242
275
  // Listen to incoming messages
@@ -289,16 +322,3 @@ export const visibleForTesting = {
289
322
  isAnyWidgetModule,
290
323
  getDirtyFields,
291
324
  };
292
-
293
- export function resolveInitialValue(
294
- raw: Record<string, any>,
295
- bufferPaths: readonly (readonly (string | number)[])[],
296
- ) {
297
- const out = structuredClone(raw);
298
- for (const bufferPath of bufferPaths) {
299
- const base64String: Base64String = get(raw, bufferPath);
300
- const bytes = byteStringToBinary(typedAtob(base64String));
301
- set(out, bufferPath, new DataView(bytes.buffer));
302
- }
303
- return out;
304
- }
@@ -5,11 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { TestUtils } from "@/__tests__/test-helpers";
6
6
  import type { UIElementId } from "@/core/cells/ids";
7
7
  import { MarimoIncomingMessageEvent } from "@/core/dom/events";
8
- import {
9
- getDirtyFields,
10
- resolveInitialValue,
11
- visibleForTesting,
12
- } from "../AnyWidgetPlugin";
8
+ import { getDirtyFields, visibleForTesting } from "../AnyWidgetPlugin";
13
9
  import { Model } from "../model";
14
10
 
15
11
  const { LoadedSlot } = visibleForTesting;
@@ -132,6 +128,7 @@ describe("LoadedSlot", () => {
132
128
  message: {
133
129
  method: "update",
134
130
  state: { count: 10 },
131
+ buffer_paths: [],
135
132
  },
136
133
  buffers: [],
137
134
  },
@@ -183,55 +180,3 @@ describe("LoadedSlot", () => {
183
180
  });
184
181
  });
185
182
  });
186
-
187
- describe("resolveInitialValue", () => {
188
- it("should convert base64 strings to DataView at specified paths", () => {
189
- const result = resolveInitialValue(
190
- {
191
- a: 10,
192
- b: "aGVsbG8=", // "hello" in base64
193
- c: [1, "d29ybGQ="], // "world" in base64
194
- d: {
195
- foo: "bWFyaW1vCg==", // "marimo" in base64
196
- baz: 20,
197
- },
198
- },
199
- [["b"], ["c", 1], ["d", "foo"]],
200
- );
201
-
202
- expect(result).toMatchInlineSnapshot(`
203
- {
204
- "a": 10,
205
- "b": DataView [
206
- 104,
207
- 101,
208
- 108,
209
- 108,
210
- 111,
211
- ],
212
- "c": [
213
- 1,
214
- DataView [
215
- 119,
216
- 111,
217
- 114,
218
- 108,
219
- 100,
220
- ],
221
- ],
222
- "d": {
223
- "baz": 20,
224
- "foo": DataView [
225
- 109,
226
- 97,
227
- 114,
228
- 105,
229
- 109,
230
- 111,
231
- 10,
232
- ],
233
- },
234
- }
235
- `);
236
- });
237
- });
@@ -9,6 +9,7 @@ import {
9
9
  vi,
10
10
  } from "vitest";
11
11
  import { TestUtils } from "@/__tests__/test-helpers";
12
+ import type { Base64String } from "@/utils/json/base64";
12
13
  import {
13
14
  type AnyWidgetMessage,
14
15
  handleWidgetMessage,
@@ -23,7 +24,10 @@ describe("Model", () => {
23
24
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
25
  let onChange: (value: any) => void;
25
26
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
- let sendToWidget: (req: { content?: any }) => Promise<null | undefined>;
27
+ let sendToWidget: (req: {
28
+ content: unknown;
29
+ buffers: Base64String[];
30
+ }) => Promise<null | undefined>;
27
31
 
28
32
  beforeEach(() => {
29
33
  onChange = vi.fn();
@@ -72,7 +76,7 @@ describe("Model", () => {
72
76
  });
73
77
  });
74
78
 
75
- it("should send all dirty fields", () => {
79
+ it("should clear dirty fields after save", () => {
76
80
  model.set("foo", "new value");
77
81
  model.save_changes();
78
82
 
@@ -83,14 +87,13 @@ describe("Model", () => {
83
87
  model.set("bar", 456);
84
88
  model.save_changes();
85
89
 
90
+ // After clearing, only the newly changed field is sent
86
91
  expect(onChange).toHaveBeenCalledWith({
87
- foo: "new value",
88
92
  bar: 456,
89
93
  });
90
94
  });
91
95
 
92
- // Skip because we don't clear the dirty fields after save
93
- it.skip("should clear dirty fields after save", () => {
96
+ it("should not call onChange when no dirty fields", () => {
94
97
  model.set("foo", "new value");
95
98
  model.save_changes();
96
99
  model.save_changes(); // Second save should not call onChange
@@ -144,21 +147,16 @@ describe("Model", () => {
144
147
  const callback = vi.fn();
145
148
  model.send({ test: true }, callback);
146
149
 
147
- expect(sendToWidget).toHaveBeenCalledWith({ content: { test: true } });
150
+ expect(sendToWidget).toHaveBeenCalledWith({
151
+ content: {
152
+ state: { test: true },
153
+ bufferPaths: [],
154
+ },
155
+ buffers: [],
156
+ });
148
157
  await TestUtils.nextTick(); // flush
149
158
  expect(callback).toHaveBeenCalledWith(null);
150
159
  });
151
-
152
- it("should warn when buffers are provided", () => {
153
- const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {
154
- // noop
155
- });
156
- model.send({ test: true }, null, [new ArrayBuffer(8)]);
157
-
158
- expect(consoleSpy).toHaveBeenCalledWith(
159
- "buffers not supported in marimo anywidget.send",
160
- );
161
- });
162
160
  });
163
161
 
164
162
  describe("widget_manager", () => {
@@ -228,7 +226,11 @@ describe("Model", () => {
228
226
  it("should handle update messages", () => {
229
227
  model.receiveCustomMessage({
230
228
  method: "update",
231
- state: { foo: "updated", bar: 789 },
229
+ state: {
230
+ foo: "updated",
231
+ bar: 789,
232
+ },
233
+ buffer_paths: [],
232
234
  });
233
235
 
234
236
  expect(model.get("foo")).toBe("updated");
@@ -333,7 +335,9 @@ describe("ModelManager", () => {
333
335
 
334
336
  const updateMessage: AnyWidgetMessage = {
335
337
  method: "update",
336
- state: { count: 1 },
338
+ state: {
339
+ count: 1,
340
+ },
337
341
  buffer_paths: [],
338
342
  };
339
343