Merge branch 'copilot/investigate-performance-regression' into develop

This commit is contained in:
nicole mikołajczyk
2026-03-01 13:22:35 +01:00
7 changed files with 77 additions and 19 deletions

3
.gitignore vendored
View File

@ -1,3 +1,4 @@
/node_modules/
yarn-error.log*
coverage/
coverage/
package-lock.json

View File

@ -72,8 +72,9 @@ interface IAntennaAccountsForm {
const AntennaAccountsForm: React.FC<IAntennaAccountsForm> = ({ antennaId, excluded = false }) => {
const [searchValue, setSearchValue] = useState('');
const { data: accountIds = [], isFetching: isFetchingAccounts } = useAntennaAccounts(antennaId);
const { data: excludedAccountIds = [], isFetching: isFetchingExcludedAccounts } =
const { data: accountIds = [] as Array<string>, isFetching: isFetchingAccounts } =
useAntennaAccounts(antennaId);
const { data: excludedAccountIds = [] as Array<string>, isFetching: isFetchingExcludedAccounts } =
useAntennaExcludedAccounts(antennaId);
const { data: searchAccountIds = [] } = useAccountSearch(searchValue, {
following: true,

View File

@ -45,7 +45,8 @@ const CircleEditorModal: React.FC<BaseModalProps & CircleEditorModalProps> = ({
const { data: circle } = useCircle(circleId);
const { mutate: updateCircle, isPending: disabled } = useUpdateCircle(circleId);
const { data: accountIds = [], isFetching: isFetchingAccounts } = useCircleAccounts(circleId);
const { data: accountIds = [] as Array<string>, isFetching: isFetchingAccounts } =
useCircleAccounts(circleId);
const { data: searchAccountIds = [] } = useAccountSearch(searchValue, {
following: true,
limit: 5,

View File

@ -22,7 +22,7 @@ interface IListMembersForm {
const ListMembersForm: React.FC<IListMembersForm> = ({ listId }) => {
const [searchValue, setSearchValue] = useState('');
const { data: accountIds = [], isFetching } = useListAccounts(listId);
const { data: accountIds = [] as Array<string>, isFetching } = useListAccounts(listId);
const { data: searchAccountIds = [] } = useAccountSearch(searchValue, {
following: true,
limit: 5,

View File

@ -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;
}

View File

@ -11,8 +11,23 @@ import { useOwnAccount } from '@/hooks/use-own-account';
import type { PaginatedResponse, PlApiClient } from 'pl-api';
class PaginatedResponseArray<T> extends Array<T> {
total?: number;
partial?: boolean;
declare total: number | undefined;
declare partial: boolean | undefined;
static override from<T>(items: ArrayLike<T> | Iterable<T>): PaginatedResponseArray<T> {
const arr = new PaginatedResponseArray<T>();
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<T, IsArray extends boolean> = 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;
}

View File

@ -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<unknown>).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);
});
});