From 1e9a50ddea6db8f99426e6267ebff642bd78f8a9 Mon Sep 17 00:00:00 2001 From: Jeff Luyau Date: Thu, 17 Jul 2025 11:56:21 -0700 Subject: [PATCH 1/4] do not dispatch loadMore if hook is still in loading state --- .../s2/stories/TableView.stories.tsx | 77 ++++++++++++++++++- .../table/stories/Table.stories.tsx | 56 +++++++++++++- .../@react-stately/data/src/useAsyncList.ts | 2 +- 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 06d02144de2..eebf05f8ca7 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -11,12 +11,29 @@ */ import {action} from '@storybook/addon-actions'; -import {ActionButton, Cell, Column, Content, Heading, IllustratedMessage, Link, MenuItem, MenuSection, Row, TableBody, TableHeader, TableView, TableViewProps, Text} from '../src'; +import { + ActionButton, + ActionButtonGroup, + Cell, + Column, + Content, + Heading, + IllustratedMessage, + Link, + MenuItem, + MenuSection, + Row, + TableBody, + TableHeader, + TableView, + TableViewProps, + Text +} from '../src'; import {categorizeArgTypes} from './utils'; import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; import type {Meta, StoryObj} from '@storybook/react'; -import {ReactElement, useState} from 'react'; +import {ReactElement, useEffect, useState} from 'react'; import {SortDescriptor} from 'react-aria-components'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {useAsyncList} from '@react-stately/data'; @@ -1372,3 +1389,59 @@ const ResizableTable = () => { } } }; + + +export const Test = { + render: () => { + return ; + } +}; + +function SWTable() { + const [film, setFilm] = useState(undefined); + + + let list = useAsyncList({ + async load({signal, cursor}) { + console.log('load', cursor, film); + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + let items = []; + await new Promise(resolve => setTimeout(resolve, 1500)); + let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search&film=${film}`, {signal}); + let json = await res.json(); + items = json.results.map((element, index) => ({title: element.name})); + + return { + items: items, + cursor: json.next + }; + } + }); + + useEffect(() => { + list.reload(); + }, [film]); + + return ( +
+ + setFilm(undefined)}>All + setFilm('https://swapi.py4e.com/api/films/1/')}>A New Hope + + + + Name + + + { + item => ( + {item.title} + ) + } + + +
+ ); +} diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index 27558e39fb2..5f88d7c2091 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -33,7 +33,7 @@ import {Meta, StoryFn, StoryObj} from '@storybook/react'; import NoSearchResults from '@spectrum-icons/illustrations/NoSearchResults'; import {Picker} from '@react-spectrum/picker'; import {Radio, RadioGroup} from '@react-spectrum/radio'; -import React, {JSX, useCallback, useState} from 'react'; +import React, {JSX, useCallback, useEffect, useState} from 'react'; import {SearchField} from '@react-spectrum/searchfield'; import {Switch} from '@react-spectrum/switch'; import {TextField} from '@react-spectrum/textfield'; @@ -2262,3 +2262,57 @@ export const AsyncLoadOverflowWrapReproStory: TableStory = { }; export {Performance} from './Performance'; + + +export const Test = { + render: () => { + return ; + } +}; + +function SWTable() { + const [film, setFilm] = useState(undefined); + + + let list = useAsyncList({ + async load({signal, cursor}) { + console.log('load', cursor, film); + if (cursor) { + cursor = cursor.replace(/^http:\/\//i, 'https://'); + } + let items = []; + await new Promise(resolve => setTimeout(resolve, 1500)); + let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search&film=${film}`, {signal}); + let json = await res.json(); + items = json.results.map((element, index) => ({title: element.name})); + + return { + items: items, + cursor: json.next + }; + } + }); + + useEffect(() => { + list.reload(); + }, [film]); + + return ( +
+ setFilm(undefined)}>All + setFilm('https://swapi.py4e.com/api/films/1/')}>A New Hope + + + Name + + + { + item => ( + {item.title} + ) + } + + +
+ ); +} diff --git a/packages/@react-stately/data/src/useAsyncList.ts b/packages/@react-stately/data/src/useAsyncList.ts index f78c6a3f93e..5bf913cc9a6 100644 --- a/packages/@react-stately/data/src/useAsyncList.ts +++ b/packages/@react-stately/data/src/useAsyncList.ts @@ -339,7 +339,7 @@ export function useAsyncList(options: AsyncListOptions): As }, loadMore() { // Ignore if already loading more or if performing server side filtering. - if (data.state === 'loadingMore' || data.state === 'filtering' || data.cursor == null) { + if (data.state === 'loading' || data.state === 'loadingMore' || data.state === 'filtering' || data.cursor == null) { return; } From 94fcb591d9b2f42df2b7692a3a967e9496ed5f79 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 17 Jul 2025 15:14:07 -0700 Subject: [PATCH 2/4] fix lint --- .../@react-spectrum/s2/stories/TableView.stories.tsx | 9 ++++++--- packages/@react-spectrum/table/stories/Table.stories.tsx | 8 ++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index eebf05f8ca7..829f60300c5 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -1400,10 +1400,13 @@ export const Test = { function SWTable() { const [film, setFilm] = useState(undefined); + interface Item { + id: string, + title: string + } - let list = useAsyncList({ + let list = useAsyncList({ async load({signal, cursor}) { - console.log('load', cursor, film); if (cursor) { cursor = cursor.replace(/^http:\/\//i, 'https://'); } @@ -1411,7 +1414,7 @@ function SWTable() { await new Promise(resolve => setTimeout(resolve, 1500)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search&film=${film}`, {signal}); let json = await res.json(); - items = json.results.map((element, index) => ({title: element.name})); + items = json.results.map((element) => ({title: element.name})); return { items: items, diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index 5f88d7c2091..db3de2d0570 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -2273,8 +2273,12 @@ export const Test = { function SWTable() { const [film, setFilm] = useState(undefined); + interface Item { + id: string, + title: string + } - let list = useAsyncList({ + let list = useAsyncList({ async load({signal, cursor}) { console.log('load', cursor, film); if (cursor) { @@ -2284,7 +2288,7 @@ function SWTable() { await new Promise(resolve => setTimeout(resolve, 1500)); let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search&film=${film}`, {signal}); let json = await res.json(); - items = json.results.map((element, index) => ({title: element.name})); + items = json.results.map((element) => ({title: element.name})); return { items: items, From a28519a555be189b4afd6b0816fda12fb8cf1e84 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 18 Jul 2025 10:29:22 -0700 Subject: [PATCH 3/4] fis lint --- packages/@react-spectrum/s2/stories/TableView.stories.tsx | 2 +- packages/@react-spectrum/table/stories/Table.stories.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 829f60300c5..3b23882c07a 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -1398,7 +1398,7 @@ export const Test = { }; function SWTable() { - const [film, setFilm] = useState(undefined); + const [film, setFilm] = useState(undefined); interface Item { id: string, diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index db3de2d0570..c31245019ae 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -2271,7 +2271,7 @@ export const Test = { }; function SWTable() { - const [film, setFilm] = useState(undefined); + const [film, setFilm] = useState(undefined); interface Item { id: string, From cf3ec42e79fc8964998dd3782220bbd51d2e541d Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 18 Jul 2025 11:13:53 -0700 Subject: [PATCH 4/4] add test --- .../s2/stories/TableView.stories.tsx | 62 +------------------ .../table/stories/Table.stories.tsx | 60 +----------------- .../data/test/useAsyncList.test.js | 50 +++++++++++++++ 3 files changed, 52 insertions(+), 120 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 829f60300c5..c08479b0263 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -13,7 +13,6 @@ import {action} from '@storybook/addon-actions'; import { ActionButton, - ActionButtonGroup, Cell, Column, Content, @@ -33,7 +32,7 @@ import {categorizeArgTypes} from './utils'; import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; import type {Meta, StoryObj} from '@storybook/react'; -import {ReactElement, useEffect, useState} from 'react'; +import {ReactElement, useState} from 'react'; import {SortDescriptor} from 'react-aria-components'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {useAsyncList} from '@react-stately/data'; @@ -1389,62 +1388,3 @@ const ResizableTable = () => { } } }; - - -export const Test = { - render: () => { - return ; - } -}; - -function SWTable() { - const [film, setFilm] = useState(undefined); - - interface Item { - id: string, - title: string - } - - let list = useAsyncList({ - async load({signal, cursor}) { - if (cursor) { - cursor = cursor.replace(/^http:\/\//i, 'https://'); - } - let items = []; - await new Promise(resolve => setTimeout(resolve, 1500)); - let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search&film=${film}`, {signal}); - let json = await res.json(); - items = json.results.map((element) => ({title: element.name})); - - return { - items: items, - cursor: json.next - }; - } - }); - - useEffect(() => { - list.reload(); - }, [film]); - - return ( -
- - setFilm(undefined)}>All - setFilm('https://swapi.py4e.com/api/films/1/')}>A New Hope - - - - Name - - - { - item => ( - {item.title} - ) - } - - -
- ); -} diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index db3de2d0570..27558e39fb2 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -33,7 +33,7 @@ import {Meta, StoryFn, StoryObj} from '@storybook/react'; import NoSearchResults from '@spectrum-icons/illustrations/NoSearchResults'; import {Picker} from '@react-spectrum/picker'; import {Radio, RadioGroup} from '@react-spectrum/radio'; -import React, {JSX, useCallback, useEffect, useState} from 'react'; +import React, {JSX, useCallback, useState} from 'react'; import {SearchField} from '@react-spectrum/searchfield'; import {Switch} from '@react-spectrum/switch'; import {TextField} from '@react-spectrum/textfield'; @@ -2262,61 +2262,3 @@ export const AsyncLoadOverflowWrapReproStory: TableStory = { }; export {Performance} from './Performance'; - - -export const Test = { - render: () => { - return ; - } -}; - -function SWTable() { - const [film, setFilm] = useState(undefined); - - interface Item { - id: string, - title: string - } - - let list = useAsyncList({ - async load({signal, cursor}) { - console.log('load', cursor, film); - if (cursor) { - cursor = cursor.replace(/^http:\/\//i, 'https://'); - } - let items = []; - await new Promise(resolve => setTimeout(resolve, 1500)); - let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search&film=${film}`, {signal}); - let json = await res.json(); - items = json.results.map((element) => ({title: element.name})); - - return { - items: items, - cursor: json.next - }; - } - }); - - useEffect(() => { - list.reload(); - }, [film]); - - return ( -
- setFilm(undefined)}>All - setFilm('https://swapi.py4e.com/api/films/1/')}>A New Hope - - - Name - - - { - item => ( - {item.title} - ) - } - - -
- ); -} diff --git a/packages/@react-stately/data/test/useAsyncList.test.js b/packages/@react-stately/data/test/useAsyncList.test.js index 1c98781370d..13ce5fec24b 100644 --- a/packages/@react-stately/data/test/useAsyncList.test.js +++ b/packages/@react-stately/data/test/useAsyncList.test.js @@ -334,6 +334,56 @@ describe('useAsyncList', () => { expect(result.current.items).toEqual(ITEMS); }); + it('should prevent loadMore from firing if in the middle of a load', async () => { + let load = jest.fn().mockImplementation(getItems); + let {result} = renderHook( + () => useAsyncList({load}) + ); + + expect(load).toHaveBeenCalledTimes(1); + expect(result.current.isLoading).toBe(true); + expect(result.current.items).toEqual([]); + + await act(async () => { + result.current.loadMore(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.items).toEqual([]); + expect(load).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.items).toEqual(ITEMS); + + await act(async () => { + result.current.reload(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.items).toEqual([]); + expect(load).toHaveBeenCalledTimes(2); + + await act(async () => { + result.current.loadMore(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.items).toEqual([]); + expect(load).toHaveBeenCalledTimes(2); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.items).toEqual(ITEMS); + }); + + it('should ignore duplicate loads where first resolves first', async () => { let load = jest.fn().mockImplementation(getItems2); let sort = jest.fn().mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({items: ITEMS2}), 100)));