@marimo-team/islands 0.23.12-dev8 → 0.23.12

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 (41) hide show
  1. package/dist/{chat-ui-BEOvjkmJ.js → chat-ui-CsPewo4h.js} +2 -2
  2. package/dist/{code-visibility-B9yvB9rV.js → code-visibility-BFhOAQbo.js} +714 -707
  3. package/dist/{html-to-image-Di0mtt6O.js → html-to-image-DXwLcQ6l.js} +22 -15
  4. package/dist/main.js +1160 -1027
  5. package/dist/{process-output-BLd4KuwX.js → process-output-C6_e1pT_.js} +1 -1
  6. package/dist/{reveal-component-D6wEWbxH.js → reveal-component-ghVwQgXR.js} +13 -13
  7. package/dist/style.css +1 -1
  8. package/package.json +1 -1
  9. package/src/components/data-table/TableBottomBar.tsx +4 -1
  10. package/src/components/data-table/data-table.tsx +26 -17
  11. package/src/components/data-table/utils.ts +1 -4
  12. package/src/components/editor/actions/useNotebookActions.tsx +4 -4
  13. package/src/components/editor/ai/__tests__/completion-utils.test.ts +48 -2
  14. package/src/components/editor/ai/completion-utils.ts +54 -36
  15. package/src/components/editor/app-container.tsx +3 -1
  16. package/src/components/editor/output/ImageOutput.tsx +12 -3
  17. package/src/components/editor/renderers/vertical-layout/vertical-layout-wrapper.tsx +2 -2
  18. package/src/components/home/components.tsx +4 -4
  19. package/src/components/icons/github.tsx +21 -0
  20. package/src/components/icons/youtube.tsx +21 -0
  21. package/src/components/storage/components.tsx +3 -7
  22. package/src/core/codemirror/go-to-definition/__tests__/commands.test.ts +67 -0
  23. package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +47 -0
  24. package/src/core/codemirror/go-to-definition/commands.ts +47 -30
  25. package/src/core/codemirror/go-to-definition/utils.ts +0 -1
  26. package/src/core/codemirror/reactive-references/__tests__/analyzer.test.ts +54 -0
  27. package/src/core/codemirror/reactive-references/analyzer.ts +44 -35
  28. package/src/core/islands/__tests__/bridge.test.ts +25 -0
  29. package/src/core/islands/__tests__/parse.test.ts +585 -1
  30. package/src/core/islands/__tests__/test-utils.tsx +10 -1
  31. package/src/core/islands/bridge.ts +6 -1
  32. package/src/core/islands/constants.ts +2 -0
  33. package/src/core/islands/parse.ts +290 -13
  34. package/src/plugins/impl/DataTablePlugin.tsx +20 -1
  35. package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +141 -1
  36. package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +54 -4
  37. package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +104 -1
  38. package/src/plugins/impl/anywidget/__tests__/model.test.ts +19 -0
  39. package/src/plugins/impl/anywidget/model.ts +15 -0
  40. package/src/utils/__tests__/records.test.ts +27 -0
  41. package/src/utils/records.ts +12 -0
@@ -3,8 +3,10 @@
3
3
  import {
4
4
  ISLAND_DATA_ATTRIBUTES,
5
5
  ISLAND_TAG_NAMES,
6
+ ISLANDS_JSON_SCRIPT_TYPE,
6
7
  } from "@/core/islands/constants";
7
8
  import { Logger } from "@/utils/Logger";
9
+ import { isRecord } from "@/utils/records";
8
10
 
9
11
  /**
10
12
  * DOM elements look like this:
@@ -23,6 +25,10 @@ export interface MarimoIslandApp {
23
25
  * ID since we allow multiple apps on the same page.
24
26
  */
25
27
  id: string;
28
+ /**
29
+ * Whether cells came from a supported JSON payload instead of DOM parsing.
30
+ */
31
+ payloadBacked?: boolean;
26
32
  /**
27
33
  * Cells in the app.
28
34
  */
@@ -42,6 +48,30 @@ interface MarimoIslandCell {
42
48
  * Index of the cell.
43
49
  */
44
50
  idx: number;
51
+ /**
52
+ * Stable cell identifier, when provided by the island payload.
53
+ */
54
+ cellId?: string;
55
+ /**
56
+ * Whether the generated marimo cell should be present but not executed.
57
+ */
58
+ disabled?: boolean;
59
+ }
60
+
61
+ interface MarimoIslandPayload {
62
+ schemaVersion: 1;
63
+ appId: string;
64
+ cells: MarimoIslandPayloadCell[];
65
+ }
66
+
67
+ interface MarimoIslandPayloadCell {
68
+ cellId: string;
69
+ code: string;
70
+ outputHtml: string;
71
+ outputMimetype: string;
72
+ reactive: boolean;
73
+ displayCode: boolean;
74
+ displayOutput: boolean;
45
75
  }
46
76
 
47
77
  /**
@@ -51,14 +81,20 @@ interface MarimoIslandCell {
51
81
  export function parseMarimoIslandApps(
52
82
  root: Document | Element = document,
53
83
  ): MarimoIslandApp[] {
54
- const embeds = root.querySelectorAll<HTMLElement>(ISLAND_TAG_NAMES.ISLAND);
84
+ const embeds = [
85
+ ...root.querySelectorAll<HTMLElement>(ISLAND_TAG_NAMES.ISLAND),
86
+ ];
87
+ const payloads = parseMarimoIslandPayloads(root);
55
88
  if (embeds.length === 0) {
56
89
  Logger.warn("No embedded marimo apps found.");
57
90
  return [];
58
91
  }
59
92
 
60
- // eslint-disable-next-line prefer-spread
61
- return parseIslandElementsIntoApps(Array.from(embeds));
93
+ if (payloads.length > 0) {
94
+ return parsePayloadBackedApps(embeds, payloads);
95
+ }
96
+
97
+ return parseIslandElementsIntoApps(embeds);
62
98
  }
63
99
 
64
100
  /**
@@ -90,11 +126,12 @@ export function parseIslandElementsIntoApps(
90
126
  continue;
91
127
  }
92
128
 
93
- if (!apps.has(appId)) {
94
- apps.set(appId, { id: appId, cells: [] });
129
+ let app = apps.get(appId);
130
+ if (!app) {
131
+ app = { id: appId, cells: [] };
132
+ apps.set(appId, app);
95
133
  }
96
134
 
97
- const app = apps.get(appId)!;
98
135
  const idx = app.cells.length;
99
136
  app.cells.push({
100
137
  output: cellData.output,
@@ -109,6 +146,159 @@ export function parseIslandElementsIntoApps(
109
146
  return [...apps.values()];
110
147
  }
111
148
 
149
+ function parsePayloadBackedApps(
150
+ embeds: HTMLElement[],
151
+ payloads: MarimoIslandPayload[],
152
+ ): MarimoIslandApp[] {
153
+ const apps = new Map<string, MarimoIslandApp>();
154
+ const matchedPayloadCells = new Map<MarimoIslandPayloadCell, HTMLElement>();
155
+ const consumedEmbeds = new Set<HTMLElement>();
156
+ const acceptedPayloads: MarimoIslandPayload[] = [];
157
+
158
+ for (const payload of payloads) {
159
+ let hasMatchedIsland = false;
160
+ for (const cell of payload.cells) {
161
+ const embed = findMatchingIsland({
162
+ embeds,
163
+ appId: payload.appId,
164
+ cell,
165
+ consumedEmbeds,
166
+ });
167
+ if (!embed) {
168
+ continue;
169
+ }
170
+ consumedEmbeds.add(embed);
171
+ matchedPayloadCells.set(cell, embed);
172
+ materializeIslandPayload(embed, cell);
173
+ hasMatchedIsland = true;
174
+ }
175
+ // Only payloads matched to island anchors can start runtime apps.
176
+ if (hasMatchedIsland) {
177
+ acceptedPayloads.push(payload);
178
+ }
179
+ }
180
+
181
+ const payloadAppIds = new Set(
182
+ acceptedPayloads.map((payload) => payload.appId),
183
+ );
184
+ const reactivePayloadAppIds = new Set(
185
+ acceptedPayloads
186
+ .filter((payload) => payload.cells.some((cell) => cell.reactive))
187
+ .map((payload) => payload.appId),
188
+ );
189
+
190
+ for (const payload of acceptedPayloads) {
191
+ for (const cell of payload.cells) {
192
+ const embed = matchedPayloadCells.get(cell);
193
+ // Static-only payload apps render from HTML and do not need a Pyodide
194
+ // session.
195
+ if (!reactivePayloadAppIds.has(payload.appId)) {
196
+ continue;
197
+ }
198
+
199
+ let app = apps.get(payload.appId);
200
+ if (!app) {
201
+ app = { id: payload.appId, payloadBacked: true, cells: [] };
202
+ apps.set(payload.appId, app);
203
+ }
204
+
205
+ const idx = app.cells.length;
206
+ const appCell: MarimoIslandCell = {
207
+ cellId: cell.cellId,
208
+ output: cell.outputHtml,
209
+ code: cell.reactive ? cell.code : "",
210
+ idx: idx,
211
+ };
212
+ // Keep static cells in the generated file so later reactive cells keep
213
+ // stable runtime indices without executing static code.
214
+ if (!cell.reactive) {
215
+ appCell.disabled = true;
216
+ }
217
+ app.cells.push(appCell);
218
+ if (cell.reactive) {
219
+ embed?.setAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX, idx.toString());
220
+ }
221
+ }
222
+ }
223
+
224
+ // A supported payload is the runtime source for its app. Extra same-app DOM
225
+ // islands are disconnected from runtime binding.
226
+ for (const embed of embeds) {
227
+ const appId = embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID);
228
+ if (appId && payloadAppIds.has(appId) && !consumedEmbeds.has(embed)) {
229
+ embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID);
230
+ embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX);
231
+ embed.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "false");
232
+ }
233
+ }
234
+
235
+ const domOnlyEmbeds = embeds.filter((embed) => {
236
+ const appId = embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID);
237
+ return !appId || !payloadAppIds.has(appId);
238
+ });
239
+
240
+ return [...apps.values(), ...parseIslandElementsIntoApps(domOnlyEmbeds)];
241
+ }
242
+
243
+ function findMatchingIsland({
244
+ embeds,
245
+ appId,
246
+ cell,
247
+ consumedEmbeds,
248
+ }: {
249
+ embeds: HTMLElement[];
250
+ appId: string;
251
+ cell: MarimoIslandPayloadCell;
252
+ consumedEmbeds: Set<HTMLElement>;
253
+ }): HTMLElement | undefined {
254
+ return embeds.find((embed) => {
255
+ if (consumedEmbeds.has(embed)) {
256
+ return false;
257
+ }
258
+ return (
259
+ embed.getAttribute(ISLAND_DATA_ATTRIBUTES.APP_ID) === appId &&
260
+ embed.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID) === cell.cellId
261
+ );
262
+ });
263
+ }
264
+
265
+ function materializeIslandPayload(
266
+ embed: HTMLElement,
267
+ cell: MarimoIslandPayloadCell,
268
+ ): void {
269
+ embed.setAttribute(
270
+ ISLAND_DATA_ATTRIBUTES.REACTIVE,
271
+ JSON.stringify(cell.reactive),
272
+ );
273
+ // The runtime file is synthesized from payload order, so DOM anchors bind
274
+ // by index.
275
+ embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID);
276
+ if (!cell.reactive) {
277
+ embed.removeAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX);
278
+ }
279
+
280
+ const output = ensureIslandChild(embed, ISLAND_TAG_NAMES.CELL_OUTPUT);
281
+ output.innerHTML = cell.displayOutput ? cell.outputHtml : "";
282
+
283
+ const code = ensureIslandChild(embed, ISLAND_TAG_NAMES.CELL_CODE);
284
+ code.hidden = true;
285
+ code.textContent = encodeURIComponent(cell.code);
286
+
287
+ const editor = embed.querySelector<HTMLElement>(ISLAND_TAG_NAMES.CODE_EDITOR);
288
+ if (editor) {
289
+ editor.setAttribute("data-initial-value", JSON.stringify(cell.code));
290
+ }
291
+ }
292
+
293
+ function ensureIslandChild(embed: HTMLElement, tagName: string): HTMLElement {
294
+ let child = embed.querySelector<HTMLElement>(tagName);
295
+ if (!child) {
296
+ child = embed.ownerDocument.createElement(tagName);
297
+ embed.appendChild(child);
298
+ }
299
+ return child;
300
+ }
301
+
112
302
  /**
113
303
  * Parses a single island element into cell data
114
304
  * @param embed - The island HTML element
@@ -132,26 +322,35 @@ export function parseIslandElement(
132
322
  };
133
323
  }
134
324
 
135
- export function createMarimoFile(app: { cells: { code: string }[] }): string {
325
+ export function createMarimoFile(app: {
326
+ cells: { code: string; disabled?: boolean }[];
327
+ }): string {
136
328
  const lines = [
137
329
  "import marimo",
138
330
  "app = marimo.App()",
139
331
  app.cells
140
332
  .map((cell) => {
141
- // Add 4 spaces to each line
142
- const code = cell.code
143
- .split("\n")
144
- .map((line) => ` ${line}`)
145
- .join("\n");
333
+ // Disabled payload cells are placeholders. Emit pass so static code
334
+ // does not define names in the runtime graph.
335
+ const sourceCode = cell.disabled ? "" : cell.code;
336
+ const code = sourceCode
337
+ ? sourceCode
338
+ .split("\n")
339
+ .map((line) => ` ${line}`)
340
+ .join("\n")
341
+ : " pass";
146
342
 
147
343
  // TODO: Handle async cells better
148
344
  // This is probably not the best way to check if the code is async
149
345
  // Ideally this is pushed into the Python code
150
346
  const isAsync = code.includes("await ");
151
347
  const prefix = isAsync ? "async def" : "def";
348
+ const decorator = cell.disabled
349
+ ? "@app.cell(disabled=True)"
350
+ : "@app.cell";
152
351
 
153
352
  // Wrap in a function
154
- return `@app.cell\n${prefix} __():\n${code}\n return`;
353
+ return `${decorator}\n${prefix} __():\n${code}\n return`;
155
354
  })
156
355
  .join("\n"),
157
356
  ];
@@ -204,3 +403,81 @@ export function extractIslandCodeFromEmbed(embed: HTMLElement): string {
204
403
 
205
404
  return "";
206
405
  }
406
+
407
+ function parseMarimoIslandPayloads(
408
+ root: Document | Element,
409
+ ): MarimoIslandPayload[] {
410
+ const scripts = root.querySelectorAll<HTMLScriptElement>(
411
+ `script[type="${ISLANDS_JSON_SCRIPT_TYPE}"]`,
412
+ );
413
+ const payloads: MarimoIslandPayload[] = [];
414
+
415
+ for (const script of scripts) {
416
+ if (isNestedIslandPayloadScript(script)) {
417
+ continue;
418
+ }
419
+ const payload = parseMarimoIslandPayload(script.textContent);
420
+ if (payload) {
421
+ payloads.push(payload);
422
+ }
423
+ }
424
+
425
+ return payloads;
426
+ }
427
+
428
+ function isNestedIslandPayloadScript(script: HTMLScriptElement): boolean {
429
+ return Boolean(
430
+ script.closest(ISLAND_TAG_NAMES.ISLAND) ||
431
+ script.closest(ISLAND_TAG_NAMES.CELL_OUTPUT),
432
+ );
433
+ }
434
+
435
+ function parseMarimoIslandPayload(
436
+ text: string | undefined | null,
437
+ ): MarimoIslandPayload | null {
438
+ if (!text) {
439
+ return null;
440
+ }
441
+
442
+ try {
443
+ const payload = JSON.parse(text);
444
+ if (isMarimoIslandPayload(payload)) {
445
+ return payload;
446
+ }
447
+ } catch {
448
+ return null;
449
+ }
450
+
451
+ return null;
452
+ }
453
+
454
+ function isMarimoIslandPayload(
455
+ payload: unknown,
456
+ ): payload is MarimoIslandPayload {
457
+ if (!isRecord(payload)) {
458
+ return false;
459
+ }
460
+ return (
461
+ payload.schemaVersion === 1 &&
462
+ typeof payload.appId === "string" &&
463
+ Array.isArray(payload.cells) &&
464
+ payload.cells.every(isMarimoIslandPayloadCell)
465
+ );
466
+ }
467
+
468
+ function isMarimoIslandPayloadCell(
469
+ cell: unknown,
470
+ ): cell is MarimoIslandPayloadCell {
471
+ if (!isRecord(cell)) {
472
+ return false;
473
+ }
474
+ return (
475
+ typeof cell.cellId === "string" &&
476
+ typeof cell.code === "string" &&
477
+ typeof cell.outputHtml === "string" &&
478
+ typeof cell.outputMimetype === "string" &&
479
+ typeof cell.reactive === "boolean" &&
480
+ typeof cell.displayCode === "boolean" &&
481
+ typeof cell.displayOutput === "boolean"
482
+ );
483
+ }
@@ -21,6 +21,7 @@ import React, {
21
21
  useMemo,
22
22
  useState,
23
23
  } from "react";
24
+ import { useLocale } from "react-aria";
24
25
  import useEvent from "react-use-event-hook";
25
26
  import { z } from "zod";
26
27
  import type { CellSelectionState } from "@/components/data-table/cell-selection/types";
@@ -75,6 +76,7 @@ import { useEffectSkipFirstRender } from "@/hooks/useEffectSkipFirstRender";
75
76
  import { Arrays } from "@/utils/arrays";
76
77
  import { Functions } from "@/utils/functions";
77
78
  import { Logger } from "@/utils/Logger";
79
+ import { prettyNumber } from "@/utils/numbers";
78
80
  import {
79
81
  generateColumns,
80
82
  inferFieldTypes,
@@ -699,7 +701,9 @@ export const LoadingDataTableComponent = memo(
699
701
  >(async () => {
700
702
  // TODO: props.get_column_summaries is always true,
701
703
  // so we are unable to detect if the function is registered
702
- if (props.totalRows === 0 || !props.showColumnSummaries) {
704
+ // Column summaries come from a kernel RPC, absent in static exports.
705
+ const isStatic = isStaticNotebook();
706
+ if (props.totalRows === 0 || !props.showColumnSummaries || isStatic) {
703
707
  return {
704
708
  data: null,
705
709
  stats: {},
@@ -875,10 +879,13 @@ const DataTableComponent = ({
875
879
  sizeBytesIsLoading?: boolean;
876
880
  }): JSX.Element => {
877
881
  const id = useId();
882
+ const { locale } = useLocale();
878
883
  const [viewedRowIdx, setViewedRowIdx] = useState(0);
879
884
  const { isPanelOpen, isAnyPanelOpen, togglePanel, panelType, setPanelType } =
880
885
  usePanelOwnership(id, cellId);
881
886
 
887
+ const isStatic = isStaticNotebook();
888
+
882
889
  const chartSpecModel = useMemo(() => {
883
890
  if (!columnSummaries) {
884
891
  return ColumnChartSpecModel.EMPTY;
@@ -949,6 +956,11 @@ const DataTableComponent = ({
949
956
  showDataTypes = false;
950
957
  }
951
958
 
959
+ // Row/cell selection writes back to the kernel, absent in static exports.
960
+ if (isStatic) {
961
+ selection = null;
962
+ }
963
+
952
964
  const columns = useMemo(
953
965
  () =>
954
966
  generateColumns({
@@ -1109,6 +1121,13 @@ const DataTableComponent = ({
1109
1121
  Result clipped. Showing {shownColumns} of {totalColumns} columns.
1110
1122
  </Banner>
1111
1123
  )}
1124
+ {isStatic && typeof totalRows === "number" && data.length < totalRows && (
1125
+ <Banner className="mb-1 rounded">
1126
+ Showing the first <strong>{prettyNumber(data.length, locale)}</strong>{" "}
1127
+ of <strong>{prettyNumber(totalRows, locale)}</strong> rows. Increase
1128
+ the table's <code>page_size</code> to embed more in the static export.
1129
+ </Banner>
1130
+ )}
1112
1131
  {columnSummaries?.is_disabled && (
1113
1132
  // Note: Keep the text in sync with the constant defined in table_manager.py
1114
1133
  // This hard-code can be removed when Functions can pass structural
@@ -6,7 +6,7 @@ const TooltipProvider = Tooltip.Provider;
6
6
 
7
7
  import { act, render, screen, waitFor } from "@testing-library/react";
8
8
  import { Provider } from "jotai";
9
- import { beforeAll, describe, expect, it, vi } from "vitest";
9
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
10
10
  import { SetupMocks } from "@/__mocks__/common";
11
11
  import type { DownloadAsArgs } from "@/components/data-table/schemas";
12
12
  import type { FieldTypesWithExternalType } from "@/components/data-table/types";
@@ -17,6 +17,14 @@ import {
17
17
  LoadingDataTableComponent,
18
18
  } from "../DataTablePlugin";
19
19
 
20
+ // Default to normal (non-static) mode; individual tests flip this on.
21
+ const mockIsStatic = vi.fn().mockReturnValue(false);
22
+ vi.mock("@/core/static/static-state", async (importOriginal) => {
23
+ const actual =
24
+ await importOriginal<typeof import("@/core/static/static-state")>();
25
+ return { ...actual, isStaticNotebook: () => mockIsStatic() };
26
+ });
27
+
20
28
  beforeAll(() => {
21
29
  SetupMocks.resizeObserver();
22
30
  });
@@ -157,3 +165,135 @@ describe("LoadingDataTableComponent", () => {
157
165
  });
158
166
  });
159
167
  });
168
+
169
+ describe("static notebook control suppression", () => {
170
+ const fieldTypes: FieldTypesWithExternalType = [
171
+ ["id", ["integer", "integer"]],
172
+ ["name", ["string", "string"]],
173
+ ];
174
+
175
+ // Only the first page is embedded in a static export; the total is larger.
176
+ const TOTAL_ROWS = 50;
177
+ const PAGE_SIZE = 10;
178
+ const firstPage = JSON.stringify(
179
+ Array.from({ length: PAGE_SIZE }, (_, i) => ({
180
+ id: i + 1,
181
+ name: `item-${i + 1}`,
182
+ })),
183
+ );
184
+
185
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
186
+ <Provider store={store}>
187
+ <TooltipProvider>{children}</TooltipProvider>
188
+ </Provider>
189
+ );
190
+
191
+ const makeProps = () => {
192
+ const host = document.createElement("div");
193
+ return {
194
+ label: null,
195
+ totalRows: TOTAL_ROWS,
196
+ pagination: true,
197
+ pageSize: PAGE_SIZE,
198
+ selection: "multi" as const,
199
+ showDownload: true,
200
+ showFilters: true,
201
+ showColumnSummaries: true as const,
202
+ showDataTypes: true,
203
+ showPageSizeSelector: true,
204
+ showColumnExplorer: false,
205
+ showRowExplorer: false,
206
+ showChartBuilder: false,
207
+ rowHeaders: [] as FieldTypesWithExternalType,
208
+ fieldTypes,
209
+ totalColumns: 2,
210
+ maxColumns: "all" as const,
211
+ hasStableRowId: true,
212
+ lazy: false,
213
+ host,
214
+ showSearch: true,
215
+ value: [] as (number | string | { rowId: string; columnName?: string })[],
216
+ setValue: vi.fn(),
217
+ data: firstPage,
218
+ search: vi.fn().mockResolvedValue({
219
+ data: firstPage,
220
+ total_rows: TOTAL_ROWS,
221
+ cell_styles: null,
222
+ cell_hover_texts: null,
223
+ }),
224
+ download_as: vi.fn() as DownloadAsArgs,
225
+ get_column_summaries: vi.fn().mockResolvedValue({
226
+ data: null,
227
+ stats: {},
228
+ bin_values: {},
229
+ value_counts: {},
230
+ show_charts: false,
231
+ }),
232
+ get_data_url: vi.fn() as GetDataUrl,
233
+ get_row_ids: vi.fn() as GetRowIds,
234
+ get_size_bytes: vi.fn().mockResolvedValue({ size_bytes: null }),
235
+ };
236
+ };
237
+
238
+ beforeEach(() => {
239
+ mockIsStatic.mockReturnValue(false);
240
+ });
241
+
242
+ it("renders kernel-dependent controls in normal mode", async () => {
243
+ const props = makeProps();
244
+ render(
245
+ <Wrapper>
246
+ <LoadingDataTableComponent {...props} />
247
+ </Wrapper>,
248
+ );
249
+
250
+ await waitFor(() => {
251
+ expect(screen.getAllByRole("row").length).toBeGreaterThan(1);
252
+ });
253
+
254
+ expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
255
+ expect(screen.getByTestId("next-page-button")).toBeInTheDocument();
256
+ expect(screen.getByTestId("select-all-checkbox")).toBeInTheDocument();
257
+ expect(props.get_column_summaries).toHaveBeenCalled();
258
+ expect(screen.queryByText(/Showing the first/)).not.toBeInTheDocument();
259
+ });
260
+
261
+ it("suppresses kernel-dependent controls in static mode", async () => {
262
+ mockIsStatic.mockReturnValue(true);
263
+ const props = makeProps();
264
+ render(
265
+ <Wrapper>
266
+ <LoadingDataTableComponent {...props} />
267
+ </Wrapper>,
268
+ );
269
+
270
+ await waitFor(() => {
271
+ expect(screen.getAllByRole("row").length).toBeGreaterThan(1);
272
+ });
273
+
274
+ // Top bar (search), pagination, and the selection column are gone.
275
+ expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument();
276
+ expect(screen.queryByTestId("next-page-button")).not.toBeInTheDocument();
277
+ expect(screen.queryByTestId("select-all-checkbox")).not.toBeInTheDocument();
278
+ // Column summaries never hit the kernel.
279
+ expect(props.get_column_summaries).not.toHaveBeenCalled();
280
+ // The truncation banner explains the missing rows.
281
+ expect(screen.getByText(/Showing the first/)).toBeInTheDocument();
282
+ });
283
+
284
+ it("suppresses search even when the author opts in via showSearch", async () => {
285
+ mockIsStatic.mockReturnValue(true);
286
+ const props = makeProps();
287
+ render(
288
+ <Wrapper>
289
+ <LoadingDataTableComponent {...props} showSearch={true} />
290
+ </Wrapper>,
291
+ );
292
+
293
+ await waitFor(() => {
294
+ expect(screen.getAllByRole("row").length).toBeGreaterThan(1);
295
+ });
296
+
297
+ expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument();
298
+ });
299
+ });
@@ -10,8 +10,9 @@ import { createPlugin } from "@/plugins/core/builder";
10
10
  import type { IPluginProps } from "@/plugins/types";
11
11
  import { prettyError } from "@/utils/errors";
12
12
  import { Logger } from "@/utils/Logger";
13
+ import { hasFunctionProperty, isRecord } from "@/utils/records";
13
14
  import { ErrorBanner } from "../common/error-banner";
14
- import { MODEL_MANAGER, type Model } from "./model";
15
+ import { getMarimoInternal, MODEL_MANAGER, type Model } from "./model";
15
16
  import type { ModelState, WidgetModelId } from "./types";
16
17
  import { BINDING_MANAGER, WIDGET_DEF_REGISTRY } from "./widget-binding";
17
18
 
@@ -140,10 +141,9 @@ const AnyWidgetSlot = (props: IPluginProps<ModelIdRef, Data>) => {
140
141
  }
141
142
 
142
143
  if (!isAnyWidgetModule(jsModule)) {
143
- const error = new Error(
144
- `Module at ${jsUrl} does not appear to be a valid anywidget`,
144
+ return (
145
+ <ErrorBanner error={getInvalidAnyWidgetModuleError(jsModule, jsUrl)} />
145
146
  );
146
- return <ErrorBanner error={error} />;
147
147
  }
148
148
 
149
149
  return (
@@ -178,6 +178,9 @@ async function runAnyWidgetModule<T extends AnyWidgetState>(
178
178
  const binding = BINDING_MANAGER.getOrCreate(modelId);
179
179
  const render = await binding.bind(widgetDef, model);
180
180
  await render(el, signal);
181
+ // Replay current model values so render listeners observe hydrated state
182
+ // even if backend updates arrived before listeners were attached.
183
+ getMarimoInternal(model).reemitState();
181
184
  } catch (error) {
182
185
  Logger.error("Error rendering anywidget", error);
183
186
  el.classList.add("text-error");
@@ -197,6 +200,52 @@ function isAnyWidgetModule(mod: any): mod is { default: AnyWidget } {
197
200
  );
198
201
  }
199
202
 
203
+ function getInvalidAnyWidgetModuleError(mod: unknown, jsUrl: string): Error {
204
+ const afmDocs = "https://anywidget.dev/en/afm/";
205
+ const hasNamedRender = isRecord(mod) && hasFunctionProperty(mod, "render");
206
+ const hasNamedInitialize =
207
+ isRecord(mod) && hasFunctionProperty(mod, "initialize");
208
+
209
+ if (hasNamedRender || hasNamedInitialize) {
210
+ const namedExports = [
211
+ hasNamedRender ? "`render`" : null,
212
+ hasNamedInitialize ? "`initialize`" : null,
213
+ ]
214
+ .filter(Boolean)
215
+ .join(" and ");
216
+ const lifecycleHooks = [
217
+ hasNamedRender ? "render" : null,
218
+ hasNamedInitialize ? "initialize" : null,
219
+ ].filter((hook): hook is string => hook !== null);
220
+ const defaultExportExample = `export default { ${lifecycleHooks.join(", ")} }`;
221
+ const namedExportExample =
222
+ lifecycleHooks.length === 1
223
+ ? `export function ${lifecycleHooks[0]}`
224
+ : "named export function ...";
225
+ return new Error(
226
+ `Anywidget module at ${jsUrl} uses named exports (${namedExports}). ` +
227
+ "Per the AFM spec, use a default export instead: " +
228
+ `\`${defaultExportExample}\` (not \`${namedExportExample}\`). ` +
229
+ `See ${afmDocs}`,
230
+ );
231
+ }
232
+
233
+ if (!isRecord(mod) || mod.default === undefined) {
234
+ return new Error(
235
+ `Anywidget module at ${jsUrl} is missing a default export. ` +
236
+ "Per the AFM spec, use `export default { render }` or " +
237
+ "`export default async () => ({ render })`. " +
238
+ `See ${afmDocs}`,
239
+ );
240
+ }
241
+
242
+ return new Error(
243
+ `Anywidget module at ${jsUrl} has an invalid default export. ` +
244
+ "Expected a factory function or an object with `render` or `initialize`. " +
245
+ `See ${afmDocs}`,
246
+ );
247
+ }
248
+
200
249
  interface Props<T extends AnyWidgetState> {
201
250
  widget: AnyWidget<T>;
202
251
  modelId: WidgetModelId;
@@ -247,4 +296,5 @@ export const visibleForTesting = {
247
296
  LoadedSlot,
248
297
  runAnyWidgetModule,
249
298
  isAnyWidgetModule,
299
+ getInvalidAnyWidgetModuleError,
250
300
  };