@marimo-team/islands 0.19.8-dev7 → 0.19.8-dev9

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.
package/dist/main.js CHANGED
@@ -52274,7 +52274,9 @@ Database schema: ${c}`), (_a3 = r2.aiFix) == null ? void 0 : _a3.setAiCompletion
52274
52274
  let { visible: c, hidden: d } = applyHidingRules(new Set(e.map(([e2]) => e2)), r.hidingRules);
52275
52275
  return {
52276
52276
  entries: sortByPrecedence(e.filter(([e2]) => c.has(e2)), r.precedence),
52277
- hidden: Array.from(d)
52277
+ hidden: [
52278
+ ...d
52279
+ ]
52278
52280
  };
52279
52281
  }
52280
52282
  const LazyVegaEmbed = import_react.lazy(() => import("./react-vega-3WcLHYC7.js").then((e) => ({
@@ -63542,10 +63544,10 @@ ${O}`,
63542
63544
  }
63543
63545
  var import_client$1 = __toESM(require_client(), 1);
63544
63546
  function PluginSlotInternal({ hostElement: e, plugin: r, children: c, getInitialValue: d }, f) {
63545
- let [_, v] = (0, import_react.useState)(c), [y, S] = (0, import_react.useState)(d()), { theme: w } = useTheme(), [E, O] = (0, import_react.useState)(() => r.validator.safeParse(parseDataset(e)));
63547
+ let [_, v] = (0, import_react.useState)(c), [y, S] = (0, import_react.useState)(d()), { theme: w } = useTheme(), [E, O] = (0, import_react.useState)(() => r.validator.safeParse(parseDataset(e))), [M, I] = (0, import_react.useState)(0);
63546
63548
  (0, import_react.useImperativeHandle)(f, () => ({
63547
63549
  reset: () => {
63548
- S(d()), O(r.validator.safeParse(parseDataset(e)));
63550
+ S(d()), O(r.validator.safeParse(parseDataset(e))), I((e2) => e2 + 1);
63549
63551
  },
63550
63552
  setChildren: (e2) => {
63551
63553
  v(e2);
@@ -63568,12 +63570,12 @@ ${O}`,
63568
63570
  e,
63569
63571
  r.validator
63570
63572
  ]);
63571
- let M = useEvent_default((r2) => {
63573
+ let z = useEvent_default((r2) => {
63572
63574
  S((c2) => {
63573
63575
  let d2 = Functions.asUpdater(r2)(c2);
63574
63576
  return shallowCompare(d2, c2) || e.dispatchEvent(createInputEvent(d2, e)), d2;
63575
63577
  });
63576
- }), I = (0, import_react.useMemo)(() => {
63578
+ }), G = (0, import_react.useMemo)(() => {
63577
63579
  if (!r.functions) return {};
63578
63580
  let c2 = {};
63579
63581
  for (let [d2, f2] of Objects.entries(r.functions)) {
@@ -63606,7 +63608,8 @@ ${O}`,
63606
63608
  return c2;
63607
63609
  }, [
63608
63610
  r.functions,
63609
- e
63611
+ e,
63612
+ M
63610
63613
  ]);
63611
63614
  return E.success ? (0, import_jsx_runtime.jsx)(StyleNamespace, {
63612
63615
  children: (0, import_jsx_runtime.jsx)("div", {
@@ -63614,12 +63617,12 @@ ${O}`,
63614
63617
  children: (0, import_jsx_runtime.jsx)(import_react.Suspense, {
63615
63618
  fallback: (0, import_jsx_runtime.jsx)("div", {}),
63616
63619
  children: r.render({
63617
- setValue: M,
63620
+ setValue: z,
63618
63621
  value: y,
63619
63622
  data: E.data,
63620
63623
  children: _,
63621
63624
  host: e,
63622
- functions: I
63625
+ functions: G
63623
63626
  })
63624
63627
  })
63625
63628
  })
@@ -73175,7 +73178,7 @@ Image URL: ${r.imageUrl}`)), contextToXml({
73175
73178
  return Logger.warn("Failed to get version from mount config"), null;
73176
73179
  }
73177
73180
  }
73178
- const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.8-dev7"), showCodeInRunModeAtom = atom(true);
73181
+ const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.8-dev9"), showCodeInRunModeAtom = atom(true);
73179
73182
  atom(null);
73180
73183
  var import_compiler_runtime$88 = require_compiler_runtime();
73181
73184
  function useKeydownOnElement(e, r) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.19.8-dev7",
3
+ "version": "0.19.8-dev9",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -1,6 +1,11 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
3
- import { render, screen } from "@testing-library/react";
2
+ import type {
3
+ ColumnDef,
4
+ PaginationState,
5
+ RowSelectionState,
6
+ SortingState,
7
+ } from "@tanstack/react-table";
8
+ import { render, screen, within } from "@testing-library/react";
4
9
  import { describe, expect, it, vi } from "vitest";
5
10
  import { TooltipProvider } from "@/components/ui/tooltip";
6
11
  import { DataTable } from "../data-table";
@@ -95,4 +100,91 @@ describe("DataTable", () => {
95
100
  expect(rows[1]).toHaveAttribute("title", "Michael Scott");
96
101
  expect(rows[2]).toHaveAttribute("title", "Jim Halpert");
97
102
  });
103
+
104
+ it("should display updated data after rerender with manual sorting and pagination", () => {
105
+ // Simulates the bug from issue #8023:
106
+ // When a user sorts a table, rows that moved from page 2 to page 1
107
+ // don't visually refresh after the underlying data is updated.
108
+
109
+ interface RowData {
110
+ id: number;
111
+ status: string;
112
+ value: number;
113
+ }
114
+
115
+ // Initial data: 4 rows, page_size=3
116
+ const initialData: RowData[] = [
117
+ { id: 4, status: "pending", value: 40 },
118
+ { id: 3, status: "pending", value: 30 },
119
+ { id: 2, status: "pending", value: 20 },
120
+ ];
121
+
122
+ const columns: ColumnDef<RowData>[] = [
123
+ { id: "id", accessorFn: (row) => row.id, header: "id" },
124
+ { id: "status", accessorFn: (row) => row.status, header: "status" },
125
+ { id: "value", accessorFn: (row) => row.value, header: "value" },
126
+ ];
127
+
128
+ // Simulate sorted state (value descending) - manual sorting means
129
+ // data comes pre-sorted from backend
130
+ const sorting: SortingState = [{ id: "value", desc: true }];
131
+ const setSorting = vi.fn();
132
+
133
+ const paginationState: PaginationState = { pageIndex: 0, pageSize: 3 };
134
+ const setPaginationState = vi.fn();
135
+
136
+ const commonProps = {
137
+ columns,
138
+ selection: null as "single" | "multi" | null,
139
+ totalRows: 4,
140
+ totalColumns: 3,
141
+ pagination: true,
142
+ manualPagination: true,
143
+ paginationState,
144
+ setPaginationState,
145
+ manualSorting: true,
146
+ sorting,
147
+ setSorting,
148
+ };
149
+
150
+ const { rerender } = render(
151
+ <TooltipProvider>
152
+ <DataTable {...commonProps} data={initialData} />
153
+ </TooltipProvider>,
154
+ );
155
+
156
+ // Verify initial data is displayed - look for "pending" in cells
157
+ const rows = screen.getAllByRole("row");
158
+ // Row 0 is header, rows 1-3 are data rows
159
+ expect(rows).toHaveLength(4); // 1 header + 3 data rows
160
+ // All rows should show "pending"
161
+ expect(within(rows[1]).getByText("pending")).toBeTruthy();
162
+ expect(within(rows[2]).getByText("pending")).toBeTruthy();
163
+ expect(within(rows[3]).getByText("pending")).toBeTruthy();
164
+
165
+ // Now simulate data update: row with id=4 is now "approved"
166
+ // Backend returns sorted data with the update applied
167
+ const updatedData: RowData[] = [
168
+ { id: 4, status: "approved", value: 40 },
169
+ { id: 3, status: "pending", value: 30 },
170
+ { id: 2, status: "pending", value: 20 },
171
+ ];
172
+
173
+ // Rerender with updated data (same sorting, same pagination)
174
+ rerender(
175
+ <TooltipProvider>
176
+ <DataTable {...commonProps} data={updatedData} />
177
+ </TooltipProvider>,
178
+ );
179
+
180
+ // BUG: The row should show "approved" but might show stale "pending"
181
+ const updatedRows = screen.getAllByRole("row");
182
+ expect(updatedRows).toHaveLength(4);
183
+
184
+ // The first data row (id=4) should now show "approved"
185
+ expect(within(updatedRows[1]).getByText("approved")).toBeTruthy();
186
+ // Other rows should still show "pending"
187
+ expect(within(updatedRows[2]).getByText("pending")).toBeTruthy();
188
+ expect(within(updatedRows[3]).getByText("pending")).toBeTruthy();
189
+ });
98
190
  });
@@ -39,14 +39,18 @@ const LazyGalleryPage = reactLazyWithPreload(
39
39
  );
40
40
 
41
41
  export function preloadPage(mode: string) {
42
- if (mode === "home") {
43
- LazyHomePage.preload();
44
- } else if (mode === "gallery") {
45
- LazyGalleryPage.preload();
46
- } else if (mode === "read") {
47
- LazyRunPage.preload();
48
- } else {
49
- LazyEditPage.preload();
42
+ switch (mode) {
43
+ case "home":
44
+ LazyHomePage.preload();
45
+ break;
46
+ case "gallery":
47
+ LazyGalleryPage.preload();
48
+ break;
49
+ case "read":
50
+ LazyRunPage.preload();
51
+ break;
52
+ default:
53
+ LazyEditPage.preload();
50
54
  }
51
55
  }
52
56
 
@@ -101,10 +101,17 @@ function PluginSlotInternal<T>(
101
101
  return plugin.validator.safeParse(parseDataset(hostElement));
102
102
  });
103
103
 
104
+ // Incremented on each reset to invalidate memoized function references.
105
+ // This ensures that plugin functions (e.g., search) are re-created when
106
+ // the underlying UI element instance changes (new object-id), even if
107
+ // the element's data attributes haven't changed.
108
+ const [resetNonce, setResetNonce] = useState(0);
109
+
104
110
  useImperativeHandle(ref, () => ({
105
111
  reset: () => {
106
112
  setValue(getInitialValue());
107
113
  setParsedResult(plugin.validator.safeParse(parseDataset(hostElement)));
114
+ setResetNonce((n) => n + 1);
108
115
  },
109
116
  setChildren: (children) => {
110
117
  setChildNodes(children);
@@ -224,7 +231,8 @@ function PluginSlotInternal<T>(
224
231
  }
225
232
 
226
233
  return methods;
227
- }, [plugin.functions, hostElement]);
234
+ // eslint-disable-next-line react-hooks/exhaustive-deps
235
+ }, [plugin.functions, hostElement, resetNonce]);
228
236
 
229
237
  // If we failed to parse the initial value, render an error
230
238
  if (!parsedResult.success) {
@@ -0,0 +1,164 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { TooltipProvider } from "@radix-ui/react-tooltip";
4
+ import { act, render, screen, waitFor } from "@testing-library/react";
5
+ import { Provider } from "jotai";
6
+ import { beforeAll, describe, expect, it, vi } from "vitest";
7
+ import type { DownloadAsArgs } from "@/components/data-table/schemas";
8
+ import type { FieldTypesWithExternalType } from "@/components/data-table/types";
9
+ import { store } from "@/core/state/jotai";
10
+ import {
11
+ type GetDataUrl,
12
+ type GetRowIds,
13
+ LoadingDataTableComponent,
14
+ } from "../DataTablePlugin";
15
+
16
+ beforeAll(() => {
17
+ global.ResizeObserver = class ResizeObserver {
18
+ observe() {
19
+ // do nothing
20
+ }
21
+ unobserve() {
22
+ // do nothing
23
+ }
24
+ disconnect() {
25
+ // do nothing
26
+ }
27
+ };
28
+ });
29
+
30
+ describe("LoadingDataTableComponent", () => {
31
+ /**
32
+ * Regression test for https://github.com/marimo-team/marimo/issues/8023
33
+ *
34
+ * When a table is replaced via mo.output.replace() with updated data,
35
+ * but the initial page data (unsorted first page) hasn't changed,
36
+ * the useAsyncData hook's deps may all remain the same.
37
+ * Previously, the `search` function reference was memoized on
38
+ * [plugin.functions, hostElement] and wouldn't change on reset(),
39
+ * so the useAsyncData effect wouldn't re-fire.
40
+ *
41
+ * The fix adds a resetNonce to the functionMethods memo deps,
42
+ * so when the plugin is reset (table instance changes), the search
43
+ * function reference changes, triggering useAsyncData to re-fetch.
44
+ *
45
+ * This test verifies that when the search function reference changes
46
+ * (simulating reset()), the component re-fetches data even if
47
+ * props.data hasn't changed.
48
+ */
49
+ it("should refetch data when search function reference changes", async () => {
50
+ const host = document.createElement("div");
51
+ const setValue = vi.fn();
52
+
53
+ // The initial page data string - identical for both renders.
54
+ // This simulates the case where only a row on page 2 changed,
55
+ // so the first page data is the same.
56
+ const initialPageData = JSON.stringify([
57
+ { id: 1, status: "pending", value: 10 },
58
+ { id: 2, status: "pending", value: 20 },
59
+ { id: 3, status: "pending", value: 30 },
60
+ ]);
61
+
62
+ const searchResult = {
63
+ data: [
64
+ { id: 1, status: "pending", value: 10 },
65
+ { id: 2, status: "pending", value: 20 },
66
+ { id: 3, status: "pending", value: 30 },
67
+ ],
68
+ total_rows: 4,
69
+ cell_styles: null,
70
+ cell_hover_texts: null,
71
+ };
72
+
73
+ const searchFn1 = vi.fn().mockResolvedValue(searchResult);
74
+ const searchFn2 = vi.fn().mockResolvedValue(searchResult);
75
+
76
+ const fieldTypes: FieldTypesWithExternalType = [
77
+ ["id", ["integer", "integer"]],
78
+ ["status", ["string", "string"]],
79
+ ["value", ["integer", "integer"]],
80
+ ];
81
+
82
+ const commonProps = {
83
+ label: null,
84
+ totalRows: 4,
85
+ pagination: true,
86
+ pageSize: 3,
87
+ selection: "single" as const,
88
+ showDownload: false,
89
+ showFilters: false,
90
+ showColumnSummaries: false as const,
91
+ showDataTypes: false,
92
+ showPageSizeSelector: false,
93
+ showColumnExplorer: false,
94
+ showRowExplorer: false,
95
+ showChartBuilder: false,
96
+ rowHeaders: [] as FieldTypesWithExternalType,
97
+ fieldTypes,
98
+ totalColumns: 3,
99
+ maxColumns: "all" as const,
100
+ hasStableRowId: false,
101
+ lazy: false,
102
+ host,
103
+ enableSearch: true,
104
+ value: [] as (number | string | { rowId: string; columnName?: string })[],
105
+ setValue,
106
+ download_as: vi.fn() as DownloadAsArgs,
107
+ get_column_summaries: vi.fn().mockResolvedValue({
108
+ data: null,
109
+ stats: {},
110
+ bin_values: {},
111
+ value_counts: {},
112
+ show_charts: false,
113
+ }),
114
+ get_data_url: vi.fn() as GetDataUrl,
115
+ get_row_ids: vi.fn() as GetRowIds,
116
+ };
117
+
118
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
119
+ <Provider store={store}>
120
+ <TooltipProvider>{children}</TooltipProvider>
121
+ </Provider>
122
+ );
123
+
124
+ // Render with first search function
125
+ const { rerender } = render(
126
+ <Wrapper>
127
+ <LoadingDataTableComponent
128
+ {...commonProps}
129
+ data={initialPageData}
130
+ search={searchFn1}
131
+ />
132
+ </Wrapper>,
133
+ );
134
+
135
+ // Wait for the table to render with data
136
+ await waitFor(() => {
137
+ expect(screen.getAllByRole("row").length).toBeGreaterThan(1);
138
+ });
139
+
140
+ // Search was called on initial load (fire-and-forget for canShowInitialPage)
141
+ expect(searchFn1).toHaveBeenCalled();
142
+
143
+ // Now rerender with the same data but a NEW search function reference.
144
+ // This simulates what happens after reset() when resetNonce increments
145
+ // and functionMethods is recreated.
146
+ await act(async () => {
147
+ rerender(
148
+ <Wrapper>
149
+ <LoadingDataTableComponent
150
+ {...commonProps}
151
+ data={initialPageData}
152
+ search={searchFn2}
153
+ />
154
+ </Wrapper>,
155
+ );
156
+ });
157
+
158
+ // The new search function should be called because the search
159
+ // dependency changed in useAsyncData.
160
+ await waitFor(() => {
161
+ expect(searchFn2).toHaveBeenCalled();
162
+ });
163
+ });
164
+ });
@@ -132,7 +132,7 @@ describe("mime-types", () => {
132
132
 
133
133
  describe("sortByPrecedence", () => {
134
134
  it("should sort entries by precedence order", () => {
135
- const entries: Array<[MimeType, string]> = [
135
+ const entries: [MimeType, string][] = [
136
136
  ["text/plain", "plain"],
137
137
  ["text/html", "html"],
138
138
  ["image/png", "png"],
@@ -151,7 +151,7 @@ describe("mime-types", () => {
151
151
  });
152
152
 
153
153
  it("should place unknown mime types at the end", () => {
154
- const entries: Array<[MimeType, string]> = [
154
+ const entries: [MimeType, string][] = [
155
155
  ["text/plain", "plain"],
156
156
  ["text/html", "html"],
157
157
  ["application/json", "json"],
@@ -173,7 +173,7 @@ describe("mime-types", () => {
173
173
  });
174
174
 
175
175
  it("should handle empty precedence", () => {
176
- const entries: Array<[MimeType, string]> = [
176
+ const entries: [MimeType, string][] = [
177
177
  ["text/plain", "plain"],
178
178
  ["text/html", "html"],
179
179
  ];
@@ -184,7 +184,7 @@ describe("mime-types", () => {
184
184
  });
185
185
 
186
186
  it("should not mutate original array", () => {
187
- const entries: Array<[MimeType, string]> = [
187
+ const entries: [MimeType, string][] = [
188
188
  ["text/plain", "plain"],
189
189
  ["text/html", "html"],
190
190
  ];
@@ -198,7 +198,7 @@ describe("mime-types", () => {
198
198
 
199
199
  describe("processMimeBundle", () => {
200
200
  it("should filter and sort mime entries", () => {
201
- const entries: Array<[MimeType, string]> = [
201
+ const entries: [MimeType, string][] = [
202
202
  ["text/plain", "plain"],
203
203
  ["text/html", "html"],
204
204
  ["image/png", "png"],
@@ -226,7 +226,7 @@ describe("mime-types", () => {
226
226
  });
227
227
 
228
228
  it("should use default config when not provided", () => {
229
- const entries: Array<[MimeType, string]> = [
229
+ const entries: [MimeType, string][] = [
230
230
  ["text/html", "html"],
231
231
  ["image/png", "png"],
232
232
  ["text/markdown", "md"],
@@ -240,9 +240,7 @@ describe("mime-types", () => {
240
240
 
241
241
  it("should preserve data associated with mime types", () => {
242
242
  const htmlData = { content: "<h1>Hello</h1>" };
243
- const entries: Array<[MimeType, typeof htmlData]> = [
244
- ["text/html", htmlData],
245
- ];
243
+ const entries: [MimeType, typeof htmlData][] = [["text/html", htmlData]];
246
244
 
247
245
  const result = processMimeBundle(entries);
248
246
 
@@ -250,7 +248,7 @@ describe("mime-types", () => {
250
248
  });
251
249
 
252
250
  it("should sort by precedence after filtering", () => {
253
- const entries: Array<[MimeType, string]> = [
251
+ const entries: [MimeType, string][] = [
254
252
  ["text/plain", "plain"],
255
253
  ["text/markdown", "md"],
256
254
  ["text/html", "html"],
@@ -26,7 +26,7 @@ export interface MimeTypeConfig {
26
26
  */
27
27
  export interface ProcessedMimeTypes<T> {
28
28
  /** The filtered and sorted mime entries */
29
- entries: Array<[MimeType, T]>;
29
+ entries: [MimeType, T][];
30
30
  /** Mime types that were hidden by rules */
31
31
  hidden: MimeType[];
32
32
  }
@@ -146,9 +146,9 @@ export function applyHidingRules(
146
146
  * Mime types not in the map are placed at the end, preserving their original order.
147
147
  */
148
148
  export function sortByPrecedence<T>(
149
- entries: Array<[MimeType, T]>,
149
+ entries: [MimeType, T][],
150
150
  precedence: ReadonlyMap<MimeType, number>,
151
- ): Array<[MimeType, T]> {
151
+ ): [MimeType, T][] {
152
152
  const unknownPrecedence = precedence.size;
153
153
 
154
154
  return [...entries].sort((a, b) => {
@@ -162,7 +162,7 @@ export function sortByPrecedence<T>(
162
162
  * Main entry point: processes mime entries by applying hiding rules and sorting.
163
163
  */
164
164
  export function processMimeBundle<T>(
165
- entries: Array<[MimeType, T]>,
165
+ entries: [MimeType, T][],
166
166
  config: MimeTypeConfig = getDefaultMimeConfig(),
167
167
  ): ProcessedMimeTypes<T> {
168
168
  if (entries.length === 0) {
@@ -176,6 +176,6 @@ export function processMimeBundle<T>(
176
176
 
177
177
  return {
178
178
  entries: sortedEntries,
179
- hidden: Array.from(hidden),
179
+ hidden: [...hidden],
180
180
  };
181
181
  }