diff --git a/.gitignore b/.gitignore index bf179d8cd..70f70853a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /node_modules/ yarn-error.log* -coverage/ \ No newline at end of file +coverage/ +package-lock.json diff --git a/packages/nicolium/src/modals/antenna-editor-modal.tsx b/packages/nicolium/src/modals/antenna-editor-modal.tsx index 22c489222..10ce63bf9 100644 --- a/packages/nicolium/src/modals/antenna-editor-modal.tsx +++ b/packages/nicolium/src/modals/antenna-editor-modal.tsx @@ -72,8 +72,9 @@ interface IAntennaAccountsForm { const AntennaAccountsForm: React.FC = ({ antennaId, excluded = false }) => { const [searchValue, setSearchValue] = useState(''); - const { data: accountIds = [], isFetching: isFetchingAccounts } = useAntennaAccounts(antennaId); - const { data: excludedAccountIds = [], isFetching: isFetchingExcludedAccounts } = + const { data: accountIds = [] as Array, isFetching: isFetchingAccounts } = + useAntennaAccounts(antennaId); + const { data: excludedAccountIds = [] as Array, isFetching: isFetchingExcludedAccounts } = useAntennaExcludedAccounts(antennaId); const { data: searchAccountIds = [] } = useAccountSearch(searchValue, { following: true, diff --git a/packages/nicolium/src/modals/circle-editor-modal.tsx b/packages/nicolium/src/modals/circle-editor-modal.tsx index d498bd9f3..ad3b542d9 100644 --- a/packages/nicolium/src/modals/circle-editor-modal.tsx +++ b/packages/nicolium/src/modals/circle-editor-modal.tsx @@ -45,7 +45,8 @@ const CircleEditorModal: React.FC = ({ const { data: circle } = useCircle(circleId); const { mutate: updateCircle, isPending: disabled } = useUpdateCircle(circleId); - const { data: accountIds = [], isFetching: isFetchingAccounts } = useCircleAccounts(circleId); + const { data: accountIds = [] as Array, isFetching: isFetchingAccounts } = + useCircleAccounts(circleId); const { data: searchAccountIds = [] } = useAccountSearch(searchValue, { following: true, limit: 5, diff --git a/packages/nicolium/src/modals/list-editor-modal/components/list-members-form.tsx b/packages/nicolium/src/modals/list-editor-modal/components/list-members-form.tsx index 6beef36b2..b5991d198 100644 --- a/packages/nicolium/src/modals/list-editor-modal/components/list-members-form.tsx +++ b/packages/nicolium/src/modals/list-editor-modal/components/list-members-form.tsx @@ -22,7 +22,7 @@ interface IListMembersForm { const ListMembersForm: React.FC = ({ listId }) => { const [searchValue, setSearchValue] = useState(''); - const { data: accountIds = [], isFetching } = useListAccounts(listId); + const { data: accountIds = [] as Array, isFetching } = useListAccounts(listId); const { data: searchAccountIds = [] } = useAccountSearch(searchValue, { following: true, limit: 5, diff --git a/packages/nicolium/src/queries/utils/make-paginated-response-query-options.ts b/packages/nicolium/src/queries/utils/make-paginated-response-query-options.ts index 04e866dc9..32bc55c26 100644 --- a/packages/nicolium/src/queries/utils/make-paginated-response-query-options.ts +++ b/packages/nicolium/src/queries/utils/make-paginated-response-query-options.ts @@ -48,14 +48,11 @@ const makePaginatedResponseQueryOptions = } if (Array.isArray(lastPage.items)) { - const items = new PaginatedResponseArray( - ...data.pages.flatMap((page) => + const items = PaginatedResponseArray.from( + data.pages.flatMap((page) => Array.isArray(page.items) ? page.items : [page.items], ), - ); - - items.total = lastPage.total; - items.partial = lastPage.partial; + ).setMeta(lastPage.total, lastPage.partial); return items as T3; } diff --git a/packages/nicolium/src/queries/utils/make-paginated-response-query.ts b/packages/nicolium/src/queries/utils/make-paginated-response-query.ts index 4fda81d16..07d87b9d3 100644 --- a/packages/nicolium/src/queries/utils/make-paginated-response-query.ts +++ b/packages/nicolium/src/queries/utils/make-paginated-response-query.ts @@ -11,8 +11,23 @@ import { useOwnAccount } from '@/hooks/use-own-account'; import type { PaginatedResponse, PlApiClient } from 'pl-api'; class PaginatedResponseArray extends Array { - total?: number; - partial?: boolean; + declare total: number | undefined; + declare partial: boolean | undefined; + + static override from(items: ArrayLike | Iterable): PaginatedResponseArray { + const arr = new PaginatedResponseArray(); + for (const item of Array.from(items)) { + arr.push(item); + } + return arr; + } + + /** Set metadata as non-enumerable to preserve TanStack Query structural sharing. */ + setMeta(total: number | undefined, partial: boolean | undefined): this { + Object.defineProperty(this, 'total', { value: total, writable: true, enumerable: false, configurable: true }); + Object.defineProperty(this, 'partial', { value: partial, writable: true, enumerable: false, configurable: true }); + return this; + } } type PaginatedResponseQueryResult = IsArray extends true @@ -59,14 +74,11 @@ const makePaginatedResponseQuery = } if (Array.isArray(lastPage.items)) { - const items = new PaginatedResponseArray( - ...data.pages.flatMap((page) => + const items = PaginatedResponseArray.from( + data.pages.flatMap((page) => Array.isArray(page.items) ? page.items : [page.items], ), - ); - - items.total = lastPage.total; - items.partial = lastPage.partial; + ).setMeta(lastPage.total, lastPage.partial); return items as T3; } diff --git a/packages/nicolium/tests/queries/utils/paginated-response-array.test.ts b/packages/nicolium/tests/queries/utils/paginated-response-array.test.ts new file mode 100644 index 000000000..c8bb2d195 --- /dev/null +++ b/packages/nicolium/tests/queries/utils/paginated-response-array.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { PaginatedResponseArray } from '@/queries/utils/make-paginated-response-query'; + +/** + * TanStack Query's isPlainArray check used for structural sharing. + * If this returns false, structural sharing breaks, causing new references + * on every render and leading to performance degradation. + */ +const isPlainArray = (value: unknown): boolean => + Array.isArray(value) && (value as Array).length === Object.keys(value as object).length; + +describe('PaginatedResponseArray', () => { + it('creates an array from items without spread operator', () => { + const items = PaginatedResponseArray.from([1, 2, 3]); + expect(items).toEqual([1, 2, 3]); + expect(items.length).toBe(3); + expect(items instanceof PaginatedResponseArray).toBe(true); + }); + + it('handles large arrays without stack overflow', () => { + const largeArray = Array.from({ length: 100_000 }, (_, i) => i); + const items = PaginatedResponseArray.from(largeArray); + expect(items.length).toBe(100_000); + }); + + it('supports non-enumerable total and partial properties via setMeta', () => { + const items = PaginatedResponseArray.from(['a', 'b', 'c']).setMeta(42, true); + + expect(items.total).toBe(42); + expect(items.partial).toBe(true); + }); + + it('passes isPlainArray check with non-enumerable properties for structural sharing', () => { + const items = PaginatedResponseArray.from([1, 2, 3]).setMeta(10, false); + + expect(isPlainArray(items)).toBe(true); + }); + + it('total and partial properties are writable after setMeta', () => { + const items = PaginatedResponseArray.from([1]).setMeta(5, false); + + items.total = 10; + expect(items.total).toBe(10); + }); +});