@marimo-team/islands 0.23.10-dev42 → 0.23.10-dev44

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.10-dev42",
3
+ "version": "0.23.10-dev44",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -141,6 +141,7 @@
141
141
  "react-vega": "^8.0.0",
142
142
  "react-virtuoso": "^4.18.1",
143
143
  "reactflow": "^11.11.4",
144
+ "rehype-sanitize": "^6.0.0",
144
145
  "remark-gfm": "^4.0.1",
145
146
  "reveal.js": "^6.0.0",
146
147
  "rpc-anywhere": "^1.7.0",
@@ -60,8 +60,8 @@ export const renderUIMessage = ({
60
60
 
61
61
  switch (part.type) {
62
62
  case "text":
63
- // Streamdown sanitizes the HTML which strips out marimo elements
64
- // So instead, we render the HTML with our custom renderer.
63
+ // Streamdown strips marimo elements, so render them with our own HTML
64
+ // renderer. Other markdown (incl. mo.md HTML) goes through Streamdown.
65
65
  if (part.text.includes("<marimo-")) {
66
66
  return (
67
67
  <React.Fragment key={index}>
@@ -0,0 +1,43 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import { describe, expect, it } from "vitest";
4
+ import { MarkdownRenderer } from "../markdown-renderer";
5
+
6
+ describe("MarkdownRenderer", () => {
7
+ // Regression test for https://github.com/marimo-team/marimo/issues/9847
8
+ it("preserves class names on raw HTML (e.g. marimo admonitions)", async () => {
9
+ const { container } = render(
10
+ <MarkdownRenderer
11
+ content={
12
+ '<div class="admonition error">' +
13
+ '<p class="admonition-title">Error</p>' +
14
+ "<p>Nope!</p>" +
15
+ "</div>"
16
+ }
17
+ />,
18
+ );
19
+
20
+ await waitFor(() => {
21
+ expect(screen.getByText("Nope!")).toBeInTheDocument();
22
+ });
23
+
24
+ const admonition = container.querySelector(".admonition.error");
25
+ expect(admonition).toBeInTheDocument();
26
+ expect(container.querySelector(".admonition-title")).toBeInTheDocument();
27
+ });
28
+
29
+ it("strips unsafe tags while keeping class names", async () => {
30
+ const { container } = render(
31
+ <MarkdownRenderer
32
+ content={'<div class="safe"><script>alert(1)</script>hello</div>'}
33
+ />,
34
+ );
35
+
36
+ await waitFor(() => {
37
+ expect(screen.getByText("hello")).toBeInTheDocument();
38
+ });
39
+
40
+ expect(container.querySelector(".safe")).toBeInTheDocument();
41
+ expect(container.querySelector("script")).not.toBeInTheDocument();
42
+ });
43
+ });
@@ -4,7 +4,12 @@ import { EditorView } from "@codemirror/view";
4
4
  import { useAtomValue } from "jotai";
5
5
  import { BetweenHorizontalStartIcon } from "lucide-react";
6
6
  import { memo, Suspense, useState } from "react";
7
- import { Streamdown, type StreamdownProps } from "streamdown";
7
+ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
8
+ import {
9
+ defaultRehypePlugins,
10
+ Streamdown,
11
+ type StreamdownProps,
12
+ } from "streamdown";
8
13
  import { Button, type ButtonProps } from "@/components/ui/button";
9
14
  import { maybeAddMarimoImport } from "@/core/cells/add-missing-import";
10
15
  import { useCellActions } from "@/core/cells/cells";
@@ -148,6 +153,23 @@ const CopyButton: React.FC<ButtonProps> = ({ onClick, ...props }) => {
148
153
  );
149
154
  };
150
155
 
156
+ // Allow `className` on every element; the default GitHub schema strips it,
157
+ // which drops the styling on marimo HTML like `mo.md(...)` admonitions.
158
+ const sanitizeSchema = {
159
+ ...defaultSchema,
160
+ attributes: {
161
+ ...defaultSchema.attributes,
162
+ "*": [...(defaultSchema.attributes?.["*"] ?? []), "className"],
163
+ },
164
+ };
165
+
166
+ // Keep Streamdown's other rehype plugins (raw, harden) so scripts and unsafe
167
+ // URLs are still sanitized.
168
+ const REHYPE_PLUGINS: StreamdownProps["rehypePlugins"] = Object.values({
169
+ ...defaultRehypePlugins,
170
+ sanitize: [rehypeSanitize, sanitizeSchema],
171
+ });
172
+
151
173
  type Components = StreamdownProps["components"];
152
174
 
153
175
  const COMPONENTS: Components = {
@@ -187,6 +209,7 @@ export const MarkdownRenderer = memo(({ content }: { content: string }) => {
187
209
  <Streamdown
188
210
  components={COMPONENTS}
189
211
  plugins={plugins}
212
+ rehypePlugins={REHYPE_PLUGINS}
190
213
  className="mo-markdown-renderer"
191
214
  >
192
215
  {content}
@@ -153,6 +153,33 @@ function filterEntries(
153
153
  return entries.filter((entry) => entryMatchesSearch(entry, context));
154
154
  }
155
155
 
156
+ const LoadMoreStorageEntries: React.FC<{
157
+ depth: number;
158
+ isLoading: boolean;
159
+ error?: Error;
160
+ onLoadMore: () => void;
161
+ }> = ({ depth, isLoading, error, onLoadMore }) => {
162
+ return (
163
+ <div className="py-px text-xs" style={indentStyle(depth)}>
164
+ <Button
165
+ variant="text"
166
+ size="xs"
167
+ className="h-6 px-0 hover:text-blue-600"
168
+ disabled={isLoading}
169
+ onClick={onLoadMore}
170
+ >
171
+ {isLoading && <LoaderCircle className="h-3 w-3 mr-1 animate-spin" />}
172
+ {isLoading ? "Loading..." : "Load more"}
173
+ </Button>
174
+ {error && (
175
+ <span className="ml-2 text-destructive">
176
+ Failed to load: {error.message}
177
+ </span>
178
+ )}
179
+ </div>
180
+ );
181
+ };
182
+
156
183
  /**
157
184
  * Lazily loaded children of a directory entry.
158
185
  * Caches fetched entries in the Jotai store so re-expanding doesn't re-fetch.
@@ -183,6 +210,10 @@ const StorageEntryChildren: React.FC<{
183
210
  entries: children,
184
211
  isPending,
185
212
  error,
213
+ hasMore,
214
+ loadMore,
215
+ isLoadingMore,
216
+ loadMoreError,
186
217
  } = useStorageEntries(namespace, prefix);
187
218
 
188
219
  if (isPending) {
@@ -242,6 +273,14 @@ const StorageEntryChildren: React.FC<{
242
273
  />
243
274
  );
244
275
  })}
276
+ {hasMore && (
277
+ <LoadMoreStorageEntries
278
+ depth={depth}
279
+ isLoading={isLoadingMore}
280
+ error={loadMoreError}
281
+ onLoadMore={loadMore}
282
+ />
283
+ )}
245
284
  </>
246
285
  );
247
286
  };
@@ -461,6 +500,10 @@ const StorageNamespaceSection: React.FC<{
461
500
  entries: fetchedEntries,
462
501
  isPending,
463
502
  error,
503
+ hasMore,
504
+ loadMore,
505
+ isLoadingMore,
506
+ loadMoreError,
464
507
  refetch,
465
508
  } = useStorageEntries(namespaceName);
466
509
 
@@ -559,6 +602,14 @@ const StorageNamespaceSection: React.FC<{
559
602
  />
560
603
  );
561
604
  })}
605
+ {hasMore && (
606
+ <LoadMoreStorageEntries
607
+ depth={1}
608
+ isLoading={isLoadingMore}
609
+ error={loadMoreError}
610
+ onLoadMore={loadMore}
611
+ />
612
+ )}
562
613
  </>
563
614
  )}
564
615
  </>
@@ -48,6 +48,7 @@ describe("storage state", () => {
48
48
  expect(state).toEqual({
49
49
  namespaces: [],
50
50
  entriesByPath: new Map(),
51
+ pageMetadataByPath: new Map(),
51
52
  });
52
53
  });
53
54
  });
@@ -134,6 +135,49 @@ describe("storage state", () => {
134
135
  expect(state.entriesByPath.get("my_s3::data/")).toEqual(entries);
135
136
  });
136
137
 
138
+ it("should store next page tokens keyed by namespace and prefix", () => {
139
+ const entries = [makeEntry({ path: "a.txt" })];
140
+
141
+ actions.setEntries({
142
+ namespace: "my_s3",
143
+ prefix: "data/",
144
+ entries,
145
+ nextPageToken: "150",
146
+ });
147
+
148
+ expect(state.entriesByPath.get("my_s3::data/")).toEqual(entries);
149
+ expect(state.pageMetadataByPath.get("my_s3::data/")?.nextPageToken).toBe(
150
+ "150",
151
+ );
152
+ });
153
+
154
+ it("should append entries when requested", () => {
155
+ const firstPage = [makeEntry({ path: "a.txt" })];
156
+ const secondPage = [makeEntry({ path: "b.txt" })];
157
+
158
+ actions.setEntries({
159
+ namespace: "my_s3",
160
+ prefix: "data/",
161
+ entries: firstPage,
162
+ nextPageToken: "150",
163
+ });
164
+ actions.setEntries({
165
+ namespace: "my_s3",
166
+ prefix: "data/",
167
+ entries: secondPage,
168
+ nextPageToken: null,
169
+ append: true,
170
+ });
171
+
172
+ expect(state.entriesByPath.get("my_s3::data/")).toEqual([
173
+ ...firstPage,
174
+ ...secondPage,
175
+ ]);
176
+ expect(
177
+ state.pageMetadataByPath.get("my_s3::data/")?.nextPageToken,
178
+ ).toBeNull();
179
+ });
180
+
137
181
  it("should use empty string for null prefix", () => {
138
182
  const entries = [makeEntry({ path: "root.txt" })];
139
183
 
@@ -251,6 +295,11 @@ describe("storage state", () => {
251
295
  expect(state.entriesByPath.get("my_s3::")).toBeUndefined();
252
296
  expect(state.entriesByPath.get("my_s3::data/")).toBeUndefined();
253
297
  expect(state.entriesByPath.get("my_s3::data/nested/")).toBeUndefined();
298
+ expect(state.pageMetadataByPath.get("my_s3::")).toBeUndefined();
299
+ expect(state.pageMetadataByPath.get("my_s3::data/")).toBeUndefined();
300
+ expect(
301
+ state.pageMetadataByPath.get("my_s3::data/nested/"),
302
+ ).toBeUndefined();
254
303
  });
255
304
 
256
305
  it("should not affect entries from other namespaces", () => {
@@ -1,11 +1,15 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { renderHook, waitFor } from "@testing-library/react";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
4
  import { createStore, Provider } from "jotai";
5
5
  import type { ReactNode } from "react";
6
6
  import * as React from "react";
7
7
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
- import { storageAtom, useStorageEntries } from "../state";
8
+ import {
9
+ storageAtom,
10
+ useStorageEntries,
11
+ useStoragePageFetcher,
12
+ } from "../state";
9
13
  import type { StorageEntry, StorageState } from "../types";
10
14
 
11
15
  const mockRequest = vi.fn();
@@ -175,13 +179,205 @@ describe("useStorageEntries", () => {
175
179
 
176
180
  it("should store fetched entries in the atom", async () => {
177
181
  const entries = [makeEntry({ path: "new.txt" })];
178
- mockRequest.mockResolvedValue({ entries });
182
+ mockRequest.mockResolvedValue({
183
+ entries,
184
+ next_page_token: "150",
185
+ });
179
186
 
180
187
  renderHook(() => useStorageEntries("ns", "sub/"), { wrapper });
181
188
 
182
189
  await waitFor(() => {
183
190
  const state = store.get(storageAtom);
184
191
  expect(state.entriesByPath.get("ns::sub/")).toEqual(entries);
192
+ expect(state.pageMetadataByPath.get("ns::sub/")?.nextPageToken).toBe(
193
+ "150",
194
+ );
195
+ });
196
+ });
197
+
198
+ it("should load more entries when a next page token exists", async () => {
199
+ const firstPage = [makeEntry({ path: "a.txt" })];
200
+ const secondPage = [makeEntry({ path: "b.txt" })];
201
+ mockRequest
202
+ .mockResolvedValueOnce({
203
+ entries: firstPage,
204
+ next_page_token: "150",
205
+ })
206
+ .mockResolvedValueOnce({
207
+ entries: secondPage,
208
+ next_page_token: null,
209
+ });
210
+
211
+ const { result } = renderHook(() => useStorageEntries("ns", "sub/"), {
212
+ wrapper,
213
+ });
214
+
215
+ await waitFor(() => {
216
+ expect(result.current.entries).toEqual(firstPage);
217
+ });
218
+ expect(result.current.hasMore).toBe(true);
219
+
220
+ await act(async () => {
221
+ await result.current.loadMore();
222
+ });
223
+
224
+ expect(result.current.entries).toEqual([...firstPage, ...secondPage]);
225
+ expect(result.current.hasMore).toBe(false);
226
+ expect(mockRequest).toHaveBeenLastCalledWith({
227
+ namespace: "ns",
228
+ prefix: "sub/",
229
+ limit: 150,
230
+ pageToken: "150",
231
+ });
232
+ });
233
+
234
+ it("should load more entries when the next page token is an empty string", async () => {
235
+ const firstPage = [makeEntry({ path: "a.txt" })];
236
+ const secondPage = [makeEntry({ path: "b.txt" })];
237
+ mockRequest
238
+ .mockResolvedValueOnce({
239
+ entries: firstPage,
240
+ next_page_token: "",
241
+ })
242
+ .mockResolvedValueOnce({
243
+ entries: secondPage,
244
+ next_page_token: null,
245
+ });
246
+
247
+ const { result } = renderHook(() => useStorageEntries("ns", "sub/"), {
248
+ wrapper,
249
+ });
250
+
251
+ await waitFor(() => {
252
+ expect(result.current.entries).toEqual(firstPage);
253
+ });
254
+ expect(result.current.hasMore).toBe(true);
255
+
256
+ await act(async () => {
257
+ await result.current.loadMore();
258
+ });
259
+
260
+ expect(result.current.entries).toEqual([...firstPage, ...secondPage]);
261
+ expect(result.current.hasMore).toBe(false);
262
+ expect(mockRequest).toHaveBeenLastCalledWith({
263
+ namespace: "ns",
264
+ prefix: "sub/",
265
+ limit: 150,
266
+ pageToken: "",
267
+ });
268
+ });
269
+
270
+ it("should ignore duplicate load more calls while a page is loading", async () => {
271
+ const firstPage = [makeEntry({ path: "a.txt" })];
272
+ const secondPage = [makeEntry({ path: "b.txt" })];
273
+ let resolveLoadMore!: (value: {
274
+ entries: StorageEntry[];
275
+ next_page_token: string | null;
276
+ }) => void;
277
+ const loadMorePromise = new Promise<{
278
+ entries: StorageEntry[];
279
+ next_page_token: string | null;
280
+ }>((resolve) => {
281
+ resolveLoadMore = resolve;
282
+ });
283
+ mockRequest
284
+ .mockResolvedValueOnce({
285
+ entries: firstPage,
286
+ next_page_token: "150",
287
+ })
288
+ .mockReturnValueOnce(loadMorePromise);
289
+
290
+ const { result } = renderHook(() => useStorageEntries("ns", "sub/"), {
291
+ wrapper,
292
+ });
293
+
294
+ await waitFor(() => {
295
+ expect(result.current.entries).toEqual(firstPage);
296
+ });
297
+
298
+ await act(async () => {
299
+ const firstLoad = result.current.loadMore();
300
+ const secondLoad = result.current.loadMore();
301
+
302
+ expect(mockRequest).toHaveBeenCalledTimes(2);
303
+ resolveLoadMore({
304
+ entries: secondPage,
305
+ next_page_token: null,
306
+ });
307
+ await Promise.all([firstLoad, secondLoad]);
308
+ });
309
+
310
+ expect(result.current.entries).toEqual([...firstPage, ...secondPage]);
311
+ expect(mockRequest).toHaveBeenCalledTimes(2);
312
+ });
313
+
314
+ it("should fetch arbitrary storage pages", async () => {
315
+ const firstPage = [makeEntry({ path: "folder/a.txt" })];
316
+ const secondPage = [makeEntry({ path: "folder/b.txt" })];
317
+ mockRequest
318
+ .mockResolvedValueOnce({
319
+ entries: firstPage,
320
+ next_page_token: "150",
321
+ })
322
+ .mockResolvedValueOnce({
323
+ entries: secondPage,
324
+ next_page_token: null,
325
+ });
326
+
327
+ const { result } = renderHook(() => useStoragePageFetcher(), {
328
+ wrapper,
329
+ });
330
+
331
+ await act(async () => {
332
+ await result.current({
333
+ namespace: "ns",
334
+ prefix: "folder/",
335
+ });
336
+ });
337
+ await act(async () => {
338
+ await result.current({
339
+ namespace: "ns",
340
+ prefix: "folder/",
341
+ pageToken: "150",
342
+ append: true,
343
+ });
344
+ });
345
+
346
+ expect(store.get(storageAtom).entriesByPath.get("ns::folder/")).toEqual([
347
+ ...firstPage,
348
+ ...secondPage,
349
+ ]);
350
+ expect(mockRequest).toHaveBeenLastCalledWith({
351
+ namespace: "ns",
352
+ prefix: "folder/",
353
+ limit: 150,
354
+ pageToken: "150",
355
+ });
356
+ });
357
+
358
+ it("should forward an empty string page token", async () => {
359
+ mockRequest.mockResolvedValue({
360
+ entries: [],
361
+ next_page_token: null,
362
+ });
363
+
364
+ const { result } = renderHook(() => useStoragePageFetcher(), {
365
+ wrapper,
366
+ });
367
+
368
+ await act(async () => {
369
+ await result.current({
370
+ namespace: "ns",
371
+ prefix: "folder/",
372
+ pageToken: "",
373
+ });
374
+ });
375
+
376
+ expect(mockRequest).toHaveBeenLastCalledWith({
377
+ namespace: "ns",
378
+ prefix: "folder/",
379
+ limit: 150,
380
+ pageToken: "",
185
381
  });
186
382
  });
187
383
  });
@@ -1,11 +1,15 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { atom, useAtomValue } from "jotai";
4
+ import { useCallback, useRef, useState } from "react";
4
5
  import { useAsyncData } from "@/hooks/useAsyncData";
5
6
  import { createReducerAndAtoms } from "@/utils/createReducer";
6
7
  import type { NotificationMessageData } from "../kernel/messages";
7
8
  import type { VariableName } from "../variables/types";
8
- import { ListStorageEntries } from "./request-registry";
9
+ import {
10
+ ListStorageEntries,
11
+ type StorageEntriesResult,
12
+ } from "./request-registry";
9
13
  import type { StorageEntry, StorageState } from "./types";
10
14
  import {
11
15
  DEFAULT_FETCH_LIMIT,
@@ -18,6 +22,7 @@ function initialState(): StorageState {
18
22
  return {
19
23
  namespaces: [],
20
24
  entriesByPath: new Map(),
25
+ pageMetadataByPath: new Map(),
21
26
  };
22
27
  }
23
28
 
@@ -48,23 +53,48 @@ const {
48
53
  namespace: string;
49
54
  prefix: string | null | undefined;
50
55
  entries: StorageEntry[];
56
+ nextPageToken?: string | null;
57
+ append?: boolean;
51
58
  },
52
59
  ) => {
53
60
  const key = storagePathKey(opts.namespace, opts.prefix);
54
61
  const entriesByPath = new Map(state.entriesByPath);
55
- entriesByPath.set(key, opts.entries);
56
- return { ...state, entriesByPath };
62
+ const entries =
63
+ opts.append && entriesByPath.has(key)
64
+ ? [...(entriesByPath.get(key) ?? []), ...opts.entries]
65
+ : opts.entries;
66
+ entriesByPath.set(key, entries);
67
+
68
+ const pageMetadataByPath = new Map(state.pageMetadataByPath);
69
+ pageMetadataByPath.set(key, {
70
+ nextPageToken: opts.nextPageToken ?? null,
71
+ });
72
+ return {
73
+ ...state,
74
+ entriesByPath,
75
+ pageMetadataByPath,
76
+ };
57
77
  },
58
78
 
59
79
  clearNamespaceCache: (state, namespace: string) => {
60
80
  const entriesByPath = new Map(state.entriesByPath);
81
+ const pageMetadataByPath = new Map(state.pageMetadataByPath);
61
82
  const prefix = storageNamespacePrefix(namespace);
62
83
  for (const key of entriesByPath.keys()) {
63
84
  if (key.startsWith(prefix)) {
64
85
  entriesByPath.delete(key);
65
86
  }
66
87
  }
67
- return { ...state, entriesByPath };
88
+ for (const key of pageMetadataByPath.keys()) {
89
+ if (key.startsWith(prefix)) {
90
+ pageMetadataByPath.delete(key);
91
+ }
92
+ }
93
+ return {
94
+ ...state,
95
+ entriesByPath,
96
+ pageMetadataByPath,
97
+ };
68
98
  },
69
99
 
70
100
  filterFromVariables: (state, variableNames: VariableName[]) => {
@@ -93,38 +123,119 @@ export function useStorageActions() {
93
123
 
94
124
  export { storageAtom };
95
125
 
126
+ async function fetchStorageEntriesPage({
127
+ namespace,
128
+ prefix,
129
+ pageToken,
130
+ append,
131
+ setEntries,
132
+ }: {
133
+ namespace: string;
134
+ prefix: string | null | undefined;
135
+ pageToken?: string | null;
136
+ append?: boolean;
137
+ setEntries: ReturnType<typeof useStorageActions>["setEntries"];
138
+ }): Promise<StorageEntriesResult> {
139
+ const result = await ListStorageEntries.request({
140
+ namespace,
141
+ prefix: prefix ?? ROOT_PATH,
142
+ limit: DEFAULT_FETCH_LIMIT,
143
+ ...(pageToken != null ? { pageToken } : {}),
144
+ });
145
+ if (result.error) {
146
+ throw new Error(result.error);
147
+ }
148
+ setEntries({
149
+ namespace,
150
+ prefix,
151
+ entries: result.entries,
152
+ nextPageToken: result.next_page_token,
153
+ append,
154
+ });
155
+ return result;
156
+ }
157
+
96
158
  /**
97
159
  * Hook that fetches and caches storage entries for a given namespace/prefix.
98
160
  * Entries are fetched on first access and cached in the store for subsequent renders.
99
161
  */
100
162
  export function useStorageEntries(namespace: string, prefix?: string) {
101
- const { entriesByPath } = useStorage();
163
+ const { entriesByPath, pageMetadataByPath } = useStorage();
102
164
  const { setEntries } = useStorageActions();
103
- const cached = entriesByPath.get(storagePathKey(namespace, prefix));
165
+ const key = storagePathKey(namespace, prefix);
166
+ const cached = entriesByPath.get(key);
167
+ const metadata = pageMetadataByPath.get(key);
168
+ const nextPageToken = metadata?.nextPageToken ?? null;
169
+ const isLoadingMoreRef = useRef(false);
170
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
171
+ const [loadMoreError, setLoadMoreError] = useState<Error | undefined>();
104
172
 
105
173
  const { isPending, error, refetch } = useAsyncData(async () => {
106
174
  if (cached) {
107
175
  return;
108
176
  }
109
- const result = await ListStorageEntries.request({
177
+ await fetchStorageEntriesPage({
110
178
  namespace,
111
- prefix: prefix ?? ROOT_PATH,
112
- limit: DEFAULT_FETCH_LIMIT,
179
+ prefix,
180
+ setEntries,
113
181
  });
114
- if (result.error) {
115
- throw new Error(result.error);
116
- }
117
- setEntries({ namespace, prefix, entries: result.entries });
118
182
  }, [namespace, prefix, cached === undefined]);
119
183
 
184
+ const loadMore = useCallback(async () => {
185
+ if (nextPageToken == null || isLoadingMoreRef.current) {
186
+ return;
187
+ }
188
+
189
+ isLoadingMoreRef.current = true;
190
+ setIsLoadingMore(true);
191
+ setLoadMoreError(undefined);
192
+ try {
193
+ await fetchStorageEntriesPage({
194
+ namespace,
195
+ prefix,
196
+ pageToken: nextPageToken,
197
+ append: true,
198
+ setEntries,
199
+ });
200
+ } catch (error) {
201
+ setLoadMoreError(
202
+ error instanceof Error ? error : new Error(String(error)),
203
+ );
204
+ } finally {
205
+ isLoadingMoreRef.current = false;
206
+ setIsLoadingMore(false);
207
+ }
208
+ }, [namespace, prefix, nextPageToken, setEntries]);
209
+
120
210
  return {
121
211
  entries: cached ?? [],
122
212
  isPending: isPending && !cached,
123
213
  error: cached ? undefined : error,
214
+ hasMore: nextPageToken !== null,
215
+ loadMore,
216
+ isLoadingMore,
217
+ loadMoreError,
124
218
  refetch,
125
219
  };
126
220
  }
127
221
 
222
+ export function useStoragePageFetcher() {
223
+ const { setEntries } = useStorageActions();
224
+ return useCallback(
225
+ (opts: {
226
+ namespace: string;
227
+ prefix: string | null | undefined;
228
+ pageToken?: string | null;
229
+ append?: boolean;
230
+ }) =>
231
+ fetchStorageEntriesPage({
232
+ ...opts,
233
+ setEntries,
234
+ }),
235
+ [setEntries],
236
+ );
237
+ }
238
+
128
239
  export const exportedForTesting = {
129
240
  reducer,
130
241
  createActions,