@marimo-team/islands 0.23.12-dev5 → 0.23.12-dev7

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 (46) hide show
  1. package/dist/{chat-ui-BElU7iES.js → chat-ui-BEOvjkmJ.js} +2 -2
  2. package/dist/{code-visibility-C4oGgzI1.js → code-visibility-43gCeXKe.js} +12 -12
  3. package/dist/{formats-CGj29bgR.js → formats-d6MhLuQ9.js} +1 -1
  4. package/dist/{html-to-image-Pd4oj3-L.js → html-to-image-Di0mtt6O.js} +58 -58
  5. package/dist/main.js +1214 -1201
  6. package/dist/{process-output-nrhrehth.js → process-output-BLd4KuwX.js} +1 -1
  7. package/dist/{reveal-component-BnYITWzo.js → reveal-component-BQHpjptH.js} +3 -3
  8. package/dist/style.css +2 -2
  9. package/dist/{vega-component-CKPImOhx.js → vega-component-Pk6lyc_a.js} +1 -1
  10. package/package.json +3 -3
  11. package/src/components/ai/display-helpers.tsx +5 -5
  12. package/src/components/app-config/ai-config.tsx +5 -5
  13. package/src/components/app-config/mcp-config.tsx +3 -3
  14. package/src/components/chat/acp/agent-panel.tsx +3 -3
  15. package/src/components/chat/acp/blocks.tsx +36 -38
  16. package/src/components/chat/acp/common.tsx +12 -16
  17. package/src/components/chat/acp/scroll-to-bottom-button.tsx +1 -1
  18. package/src/components/chat/acp/session-tabs.tsx +2 -2
  19. package/src/components/chat/chat-history-popover.tsx +1 -1
  20. package/src/components/data-table/columns.tsx +2 -2
  21. package/src/components/data-table/filter-pill-editor.tsx +1 -1
  22. package/src/components/dependency-graph/minimap-content.tsx +1 -1
  23. package/src/components/editor/RecoveryButton.tsx +1 -1
  24. package/src/components/editor/actions/pair-with-agent-modal.tsx +2 -2
  25. package/src/components/editor/ai/ai-completion-editor.tsx +1 -1
  26. package/src/components/editor/cell/CreateCellButton.tsx +1 -1
  27. package/src/components/editor/chrome/panels/empty-state.tsx +1 -1
  28. package/src/components/editor/chrome/panels/outline/floating-outline.tsx +1 -1
  29. package/src/components/editor/chrome/wrapper/pending-ai-cells.tsx +1 -1
  30. package/src/components/editor/columns/cell-column.tsx +1 -1
  31. package/src/components/editor/columns/sortable-column.tsx +2 -2
  32. package/src/components/editor/output/MarimoErrorOutput.tsx +1 -1
  33. package/src/components/editor/output/TextOutput.tsx +2 -2
  34. package/src/components/slides/minimap.tsx +2 -2
  35. package/src/components/slides/reveal-component.tsx +1 -1
  36. package/src/components/ui/alert.tsx +1 -1
  37. package/src/components/ui/command.tsx +2 -2
  38. package/src/components/ui/reorderable-list.tsx +1 -1
  39. package/src/components/ui/table.tsx +2 -5
  40. package/src/core/codemirror/language/languages/sql/renderers.tsx +60 -68
  41. package/src/plugins/impl/MatrixPlugin.tsx +2 -2
  42. package/src/plugins/impl/TabsPlugin.tsx +1 -1
  43. package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +1 -1
  44. package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +155 -98
  45. package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +154 -1
  46. package/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts +10 -0
@@ -174,99 +174,99 @@ const MplInteractiveSlot = (props: IPluginProps<ModelIdRef, Data>) => {
174
174
  const containerRef = useRef<HTMLDivElement>(null);
175
175
  const figureRef = useRef<MplFigure | null>(null);
176
176
  const wsRef = useRef<MplCommWebSocket | null>(null);
177
+ // Sends to the currently bound backend model. Re-pointed on every (re)bind
178
+ // so the persistent socket and toolbar downloads always reach the live comm.
179
+ const sendRef = useRef<(msg: unknown) => void>(Functions.NOOP);
180
+ // Detaches the model bound by the most recent bindModel call. Shared between
181
+ // the mount and rebind effects so a rerun disposes the prior model's
182
+ // listener before attaching the next one (never stacking listeners).
183
+ const boundModelCleanupRef = useRef<(() => void) | undefined>(undefined);
184
+ // Latest model id, read by the mount effect without being a dependency:
185
+ // the figure is built once, and switching to a new model is the rebind
186
+ // effect's job, not a reason to tear the canvas down.
187
+ const modelIdRef = useRef(modelId);
188
+ modelIdRef.current = modelId;
189
+ // The data attributes are re-parsed into fresh objects on every rerun, so
190
+ // `toolbarImages` changes identity even when its contents do not. Read it
191
+ // from a ref so it can't retrigger the mount effect and rebuild the canvas.
192
+ const toolbarImagesRef = useRef(toolbarImages);
193
+ toolbarImagesRef.current = toolbarImages;
194
+
195
+ // Bind the already-rendered figure/socket to a backend model, leaving the
196
+ // DOM, figure, and socket in place so they survive across reruns. Disposes
197
+ // the previously bound model first and records the new cleanup in
198
+ // boundModelCleanupRef, so exactly one model listener is ever attached.
199
+ const bindModel = useCallback(async (id: WidgetModelId): Promise<void> => {
200
+ const fakeWs = wsRef.current;
201
+ if (!fakeWs) {
202
+ return;
203
+ }
177
204
 
178
- const setupFigure = useCallback(
179
- async (container: HTMLElement) => {
180
- // Load mpl.js globally (only once, via <script src>)
181
- await ensureMplJs(mplJsUrl);
205
+ let model: Model<ModelState>;
206
+ try {
207
+ model = await MODEL_MANAGER.get(id);
208
+ } catch {
209
+ Logger.error("Failed to get model for mpl interactive", id);
210
+ return;
211
+ }
182
212
 
183
- if (!window.mpl) {
184
- Logger.error("mpl.js failed to load");
185
- return;
186
- }
213
+ // The figure may have been torn down (unmount, or a structural rebuild)
214
+ // while we awaited the model; don't wire a listener that would outlive it.
215
+ if (wsRef.current !== fakeWs) {
216
+ return;
217
+ }
187
218
 
188
- // Get the model from MODEL_MANAGER
189
- let model: Model<ModelState>;
190
- try {
191
- model = await MODEL_MANAGER.get(modelId);
192
- } catch {
193
- Logger.error("Failed to get model for mpl interactive", modelId);
219
+ // Detach the previously bound model before wiring the new one.
220
+ boundModelCleanupRef.current?.();
221
+
222
+ // Re-point outbound traffic at this model without recreating the socket,
223
+ // so mpl.js's onopen/onmessage wiring stays intact.
224
+ const send = (msg: unknown) => model.send(msg);
225
+ fakeWs.setSendHandler(send);
226
+ sendRef.current = send;
227
+
228
+ // Listen for backend → frontend messages via model custom events
229
+ const handleCustomMessage = (
230
+ content: { type: string; data?: unknown; format?: string },
231
+ buffers?: readonly DataView[],
232
+ ) => {
233
+ if (!content) {
194
234
  return;
195
235
  }
196
236
 
197
- // Create the fake WebSocket
198
- const fakeWs = new MplCommWebSocket((msg: unknown) => {
199
- // Send from frontend backend via model custom message
200
- model.send(msg);
201
- });
202
- wsRef.current = fakeWs;
203
-
204
- // Listen for backend → frontend messages via model custom events
205
- const handleCustomMessage = (
206
- content: { type: string; data?: unknown; format?: string },
207
- buffers?: readonly DataView[],
208
- ) => {
209
- if (!content) {
210
- return;
211
- }
212
-
213
- if (content.type === "json") {
214
- fakeWs.receiveJson(content.data);
215
- } else if (content.type === "binary" && buffers && buffers.length > 0) {
216
- fakeWs.receiveBinary(buffers[0]);
217
- } else if (
218
- content.type === "download" &&
219
- buffers &&
220
- buffers.length > 0
221
- ) {
222
- const fmt = content.format || "png";
223
- const dv = buffers[0];
224
- const ab = dv.buffer.slice(
225
- dv.byteOffset,
226
- dv.byteOffset + dv.byteLength,
227
- ) as ArrayBuffer;
228
- downloadBlob(
229
- new Blob([ab], { type: `image/${fmt}` }),
230
- `figure.${fmt}`,
231
- );
232
- }
233
- };
237
+ if (content.type === "json") {
238
+ fakeWs.receiveJson(content.data);
239
+ } else if (content.type === "binary" && buffers && buffers.length > 0) {
240
+ fakeWs.receiveBinary(buffers[0]);
241
+ } else if (content.type === "download" && buffers && buffers.length > 0) {
242
+ const fmt = content.format || "png";
243
+ const dv = buffers[0];
244
+ const ab = dv.buffer.slice(
245
+ dv.byteOffset,
246
+ dv.byteOffset + dv.byteLength,
247
+ ) as ArrayBuffer;
248
+ downloadBlob(new Blob([ab], { type: `image/${fmt}` }), `figure.${fmt}`);
249
+ }
250
+ };
234
251
 
235
- model.on("msg:custom", handleCustomMessage as any);
252
+ model.on("msg:custom", handleCustomMessage as any);
236
253
 
237
- // Create the mpl figure
238
- const figId = modelId;
239
- const ondownload = (_figure: MplFigure, format: string) => {
240
- // Send download request to backend
241
- model.send({ type: "download", format });
242
- };
254
+ // Replay the mpl.js handshake against the new comm so the backend
255
+ // re-establishes image mode and pushes a frame. The figure DOM and the
256
+ // backend manager's toolbar state are left untouched.
257
+ fakeWs.onopen?.();
243
258
 
244
- const fig = new window.mpl.figure(figId, fakeWs, ondownload, container);
245
- figureRef.current = fig;
246
-
247
- // Set the canvas_div to the backend's figure size so the
248
- // ResizeObserver doesn't trigger an immediate resize cycle.
249
- // mpl.js creates: fig.root > [titlebar, canvas_div, toolbar]
250
- const canvasDiv = fig.root.querySelector<HTMLElement>("div[tabindex]");
251
- if (canvasDiv) {
252
- canvasDiv.style.width = `${width}px`;
253
- canvasDiv.style.height = `${height}px`;
259
+ boundModelCleanupRef.current = () => {
260
+ model.off("msg:custom", handleCustomMessage as any);
261
+ if (sendRef.current === send) {
262
+ sendRef.current = Functions.NOOP;
254
263
  }
264
+ };
265
+ }, []);
255
266
 
256
- // Trigger the onopen callback to start communication
257
- // mpl.js sends initial messages in onopen
258
- setTimeout(() => {
259
- fakeWs.onopen?.();
260
- }, 0);
261
-
262
- return () => {
263
- model.off("msg:custom", handleCustomMessage as any);
264
- fakeWs.close();
265
- };
266
- },
267
- [modelId, mplJsUrl, width, height],
268
- );
269
-
267
+ // Mount: build the DOM, mpl figure, and socket once. modelId is read from a
268
+ // ref and intentionally omitted from the deps — rebinding to a new model is
269
+ // handled by the effect below, not by rebuilding the canvas.
270
270
  useEffect(() => {
271
271
  const container = containerRef.current;
272
272
  if (!container) {
@@ -280,34 +280,90 @@ const MplInteractiveSlot = (props: IPluginProps<ModelIdRef, Data>) => {
280
280
  const removeCss = injectCss(container, cssUrl);
281
281
 
282
282
  // Patch toolbar images
283
- const removeImageObserver = patchToolbarImages(container, toolbarImages);
283
+ const removeImageObserver = patchToolbarImages(
284
+ container,
285
+ toolbarImagesRef.current,
286
+ );
284
287
 
285
- let cleanup: (() => void) | undefined;
286
288
  let cancelled = false;
287
289
 
288
- setupFigure(container)
289
- .then((cleanupFn) => {
290
- if (cancelled) {
291
- cleanupFn?.();
292
- return;
293
- }
294
- cleanup = cleanupFn;
295
- })
296
- .catch((error) => {
297
- if (!cancelled) {
298
- Logger.error("Failed to set up MPL interactive figure", error);
299
- }
290
+ const setup = async () => {
291
+ // Load mpl.js globally (only once, via <script src>)
292
+ await ensureMplJs(mplJsUrl);
293
+
294
+ if (!window.mpl) {
295
+ Logger.error("mpl.js failed to load");
296
+ return;
297
+ }
298
+
299
+ // The send handler is swapped per bind; route through sendRef so the
300
+ // socket outlives any single model.
301
+ const fakeWs = new MplCommWebSocket((msg: unknown) => {
302
+ sendRef.current(msg);
300
303
  });
304
+ wsRef.current = fakeWs;
305
+
306
+ const ondownload = (_figure: MplFigure, format: string) => {
307
+ sendRef.current({ type: "download", format });
308
+ };
309
+
310
+ const fig = new window.mpl.figure(
311
+ modelIdRef.current,
312
+ fakeWs,
313
+ ondownload,
314
+ container,
315
+ );
316
+ figureRef.current = fig;
317
+
318
+ // Set the canvas_div to the backend's figure size so the
319
+ // ResizeObserver doesn't trigger an immediate resize cycle.
320
+ // mpl.js creates: fig.root > [titlebar, canvas_div, toolbar]
321
+ const canvasDiv = fig.root.querySelector<HTMLElement>("div[tabindex]");
322
+ if (canvasDiv) {
323
+ canvasDiv.style.width = `${width}px`;
324
+ canvasDiv.style.height = `${height}px`;
325
+ }
326
+
327
+ await bindModel(modelIdRef.current);
328
+ };
329
+
330
+ setup().catch((error) => {
331
+ if (!cancelled) {
332
+ Logger.error("Failed to set up MPL interactive figure", error);
333
+ }
334
+ });
301
335
 
302
336
  return () => {
303
337
  cancelled = true;
338
+ boundModelCleanupRef.current?.();
339
+ boundModelCleanupRef.current = undefined;
304
340
  removeCss();
305
341
  removeImageObserver();
306
- cleanup?.();
342
+ wsRef.current?.close();
343
+ wsRef.current = null;
344
+ figureRef.current = null;
307
345
  // Clear DOM on unmount so stale content doesn't linger
308
346
  container.innerHTML = "";
309
347
  };
310
- }, [modelId, cssUrl, toolbarImages, setupFigure]);
348
+ }, [mplJsUrl, cssUrl, width, height, bindModel]);
349
+
350
+ // Rebind to a new model when the cell re-runs, keeping the rendered figure
351
+ // and toolbar in place. The initial bind is owned by the mount effect, so
352
+ // skip the first run here.
353
+ const isInitialBindRef = useRef(true);
354
+ useEffect(() => {
355
+ if (isInitialBindRef.current) {
356
+ isInitialBindRef.current = false;
357
+ return;
358
+ }
359
+
360
+ // bindModel disposes the previously bound model and guards against a
361
+ // teardown that races the awaited model lookup, so no per-run cleanup is
362
+ // needed here; the mount effect's cleanup detaches the final bind.
363
+ bindModel(modelId).catch((error) => {
364
+ Logger.error("Failed to rebind MPL interactive figure", error);
365
+ });
366
+ }, [modelId, bindModel]);
311
367
 
312
368
  // Re-request figure when tab becomes visible
313
369
  useEventListener(document, "visibilitychange", () => {
@@ -324,6 +380,7 @@ const MplInteractiveSlot = (props: IPluginProps<ModelIdRef, Data>) => {
324
380
  export const visibleForTesting = {
325
381
  ensureMplJs,
326
382
  injectCss,
383
+ MplInteractiveSlot,
327
384
  resetMplJsLoading: () => {
328
385
  mplJsLoading = null;
329
386
  },
@@ -1,4 +1,8 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
+ /* oxlint-disable typescript/no-explicit-any */
3
+ /* oxlint-disable marimo/prefer-object-params -- the mocked mpl.js figure
4
+ constructor must match its real positional signature. */
5
+ import { render, waitFor } from "@testing-library/react";
2
6
  import type { ExtractAtomValue } from "jotai";
3
7
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
8
  import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
@@ -7,9 +11,12 @@ import { parseUserConfig } from "@/core/config/config-schema";
7
11
  import { initialModeAtom } from "@/core/mode";
8
12
  import { store } from "@/core/state/jotai";
9
13
  import { Logger } from "@/utils/Logger";
14
+ import { MODEL_MANAGER, Model } from "@/plugins/impl/anywidget/model";
15
+ import type { WidgetModelId } from "@/plugins/impl/anywidget/types";
10
16
  import { visibleForTesting } from "../MplInteractivePlugin";
11
17
 
12
- const { ensureMplJs, injectCss, resetMplJsLoading } = visibleForTesting;
18
+ const { ensureMplJs, injectCss, MplInteractiveSlot, resetMplJsLoading } =
19
+ visibleForTesting;
13
20
 
14
21
  /**
15
22
  * Clear every "notebook trust" signal `isTrustedVirtualFileUrl` consults so
@@ -151,3 +158,149 @@ describe("MplInteractivePlugin URL validation", () => {
151
158
  });
152
159
  });
153
160
  });
161
+
162
+ const asModelId = (id: string): WidgetModelId => id as WidgetModelId;
163
+
164
+ /**
165
+ * Minimal stand-in for the global `window.mpl.figure` constructor. mpl.js
166
+ * builds the canvas DOM and wires `onopen`/`onmessage` onto the socket at
167
+ * construction; the mock reproduces just enough of that for the slot to mount
168
+ * and rebind.
169
+ */
170
+ function installMplFigureMock(): ReturnType<typeof vi.fn> {
171
+ const ctor = vi.fn(function (
172
+ this: any,
173
+ id: string,
174
+ ws: any,
175
+ _ondownload: unknown,
176
+ container: HTMLElement,
177
+ ) {
178
+ this.id = id;
179
+ this.ws = ws;
180
+ const root = document.createElement("div");
181
+ const canvasDiv = document.createElement("div");
182
+ canvasDiv.setAttribute("tabindex", "0");
183
+ root.append(canvasDiv);
184
+ container.append(root);
185
+ this.root = root;
186
+ this.send_message = vi.fn();
187
+ ws.onopen = vi.fn();
188
+ ws.onmessage = vi.fn();
189
+ });
190
+ (window as unknown as { mpl: unknown }).mpl = {
191
+ figure: ctor,
192
+ toolbar_items: [],
193
+ };
194
+ return ctor;
195
+ }
196
+
197
+ function makeModel(): Model<Record<string, never>> {
198
+ return new Model(
199
+ {},
200
+ {
201
+ sendUpdate: vi.fn().mockResolvedValue(undefined),
202
+ sendCustomMessage: vi.fn().mockResolvedValue(undefined),
203
+ },
204
+ );
205
+ }
206
+
207
+ function makeProps(modelId: WidgetModelId) {
208
+ return {
209
+ data: {
210
+ mplJsUrl: "./@file/1-mpl.js",
211
+ cssUrl: "./@file/2-mpl.css",
212
+ toolbarImages: {},
213
+ width: 640,
214
+ height: 480,
215
+ },
216
+ value: { model_id: modelId },
217
+ host: document.createElement("div"),
218
+ setValue: vi.fn(),
219
+ functions: {},
220
+ } as any;
221
+ }
222
+
223
+ describe("MplInteractiveSlot rerun rebinding", () => {
224
+ beforeEach(() => {
225
+ vi.spyOn(Logger, "error").mockImplementation(() => {});
226
+ resetMplJsLoading();
227
+ });
228
+
229
+ afterEach(() => {
230
+ vi.restoreAllMocks();
231
+ delete (window as { mpl?: unknown }).mpl;
232
+ });
233
+
234
+ it("rebinds to a new model without rebuilding the figure DOM", async () => {
235
+ const ctor = installMplFigureMock();
236
+ const idA = asModelId("model-a");
237
+ const idB = asModelId("model-b");
238
+ MODEL_MANAGER.set(idA, makeModel());
239
+ MODEL_MANAGER.set(idB, makeModel());
240
+
241
+ const { container, rerender } = render(
242
+ <MplInteractiveSlot {...makeProps(idA)} />,
243
+ );
244
+
245
+ await waitFor(() => expect(ctor).toHaveBeenCalledTimes(1));
246
+
247
+ const figureRoot = ctor.mock.instances[0].root as HTMLElement;
248
+ const socket = ctor.mock.calls[0][1];
249
+ const slot = container.querySelector(".mpl-interactive-figure");
250
+ expect(slot?.contains(figureRoot)).toBe(true);
251
+ // One handshake on the initial bind.
252
+ expect(socket.onopen).toHaveBeenCalledTimes(1);
253
+ const setSendHandler = vi.spyOn(socket, "setSendHandler");
254
+
255
+ // Cell re-run: only the model id changes.
256
+ rerender(<MplInteractiveSlot {...makeProps(idB)} />);
257
+
258
+ // The new model is bound through the existing socket, with a fresh
259
+ // handshake, and the figure is never reconstructed.
260
+ await waitFor(() => expect(socket.onopen).toHaveBeenCalledTimes(2));
261
+ expect(ctor).toHaveBeenCalledTimes(1);
262
+ expect(setSendHandler).toHaveBeenCalledTimes(1);
263
+ // The same rendered DOM is still in place — not cleared and rebuilt.
264
+ expect(container.querySelector(".mpl-interactive-figure")).toBe(slot);
265
+ expect(slot?.contains(figureRoot)).toBe(true);
266
+ });
267
+
268
+ it("detaches the previous model's listener on each rerun (no buildup)", async () => {
269
+ installMplFigureMock();
270
+ // Unique ids: MODEL_MANAGER is a module singleton whose deferreds resolve
271
+ // once, so reusing ids from another test would return that test's models.
272
+ const idA = asModelId("leak-a");
273
+ const idB = asModelId("leak-b");
274
+ const idC = asModelId("leak-c");
275
+ const modelA = makeModel();
276
+ const modelB = makeModel();
277
+ const modelC = makeModel();
278
+ MODEL_MANAGER.set(idA, modelA);
279
+ MODEL_MANAGER.set(idB, modelB);
280
+ MODEL_MANAGER.set(idC, modelC);
281
+
282
+ const onA = vi.spyOn(modelA, "on");
283
+ const offA = vi.spyOn(modelA, "off");
284
+ const onB = vi.spyOn(modelB, "on");
285
+ const offB = vi.spyOn(modelB, "off");
286
+ const onC = vi.spyOn(modelC, "on");
287
+ const offC = vi.spyOn(modelC, "off");
288
+
289
+ const { rerender } = render(<MplInteractiveSlot {...makeProps(idA)} />);
290
+ await waitFor(() =>
291
+ expect(onA).toHaveBeenCalledWith("msg:custom", expect.any(Function)),
292
+ );
293
+
294
+ rerender(<MplInteractiveSlot {...makeProps(idB)} />);
295
+ await waitFor(() => expect(onB).toHaveBeenCalled());
296
+
297
+ rerender(<MplInteractiveSlot {...makeProps(idC)} />);
298
+ await waitFor(() => expect(onC).toHaveBeenCalled());
299
+
300
+ // Each superseded model had its listener detached exactly once when the
301
+ // next bind replaced it; the current model's listener stays attached.
302
+ expect(offA).toHaveBeenCalledTimes(1);
303
+ expect(offB).toHaveBeenCalledTimes(1);
304
+ expect(offC).not.toHaveBeenCalled();
305
+ });
306
+ });
@@ -21,6 +21,16 @@ export class MplCommWebSocket {
21
21
  this.sendFn = sendFn;
22
22
  }
23
23
 
24
+ /**
25
+ * Re-point outbound sends at a new backend comm without recreating the
26
+ * socket. The figure manager is reused across cell reruns, so the same
27
+ * socket instance stays bound to mpl.js (which wires `onopen`/`onmessage`
28
+ * onto it at construction); only the comm behind it changes.
29
+ */
30
+ setSendHandler(sendFn: (msg: unknown) => void): void {
31
+ this.sendFn = sendFn;
32
+ }
33
+
24
34
  /**
25
35
  * Called by mpl.js to send a message to the backend.
26
36
  * mpl.js always sends JSON strings.