diff --git a/.changeset/pagination-interruptible-button-guard.md b/.changeset/pagination-interruptible-button-guard.md
new file mode 100644
index 0000000000000..ab285b5850455
--- /dev/null
+++ b/.changeset/pagination-interruptible-button-guard.md
@@ -0,0 +1,6 @@
+---
+'@astryxdesign/core': patch
+---
+
+[fix] `Pagination`'s `changeAction` is now interruptible — page changes run in a transition with optimistic page state, so rapid prev/next clicks advance through pages instead of being dropped. `Button`'s `clickAction` keeps its single-fire guard.
+@cixzhang
diff --git a/packages/core/src/Button/Button.test.tsx b/packages/core/src/Button/Button.test.tsx
index 98f3d8c960e5b..6b70636c5b8d7 100644
--- a/packages/core/src/Button/Button.test.tsx
+++ b/packages/core/src/Button/Button.test.tsx
@@ -10,7 +10,7 @@
*/
import {describe, it, expect, vi} from 'vitest';
-import {render, screen} from '@testing-library/react';
+import {render, screen, fireEvent, act} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {Button} from './Button';
import {Badge} from '../Badge/Badge';
@@ -228,11 +228,7 @@ describe('Button', () => {
order.push('clickAction');
});
render(
- ,
+ ,
);
await user.click(screen.getByRole('button'));
@@ -246,11 +242,7 @@ describe('Button', () => {
const handleClick = vi.fn((e: React.MouseEvent) => e.preventDefault());
const handleAction = vi.fn();
render(
- ,
+ ,
);
await user.click(screen.getByRole('button'));
@@ -258,6 +250,29 @@ describe('Button', () => {
expect(handleAction).not.toHaveBeenCalled();
});
+ it('fires clickAction once on a fast double-click (no double-submit)', async () => {
+ let resolveAction: (() => void) | undefined;
+ const handleAction = vi.fn(
+ async () =>
+ new Promise(resolve => {
+ resolveAction = resolve;
+ }),
+ );
+ render();
+
+ const button = screen.getByRole('button');
+ await act(async () => {
+ fireEvent.click(button);
+ fireEvent.click(button);
+ });
+ expect(handleAction).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ resolveAction?.();
+ await Promise.resolve();
+ });
+ });
+
// type/name/value/form props
it('defaults type to button', () => {
render();
diff --git a/packages/core/src/Button/Button.tsx b/packages/core/src/Button/Button.tsx
index 4a599cf337275..83fcb86238517 100644
--- a/packages/core/src/Button/Button.tsx
+++ b/packages/core/src/Button/Button.tsx
@@ -513,6 +513,8 @@ export function Button({
const buttonGroup = useButtonGroup();
const [isPending, startTransition] = useTransition();
+ // clickAction is fire-once (submit/save/pay), so a same-tick double-click must
+ // dedupe — which neither isPending nor useOptimistic do. Hence the ref guard.
const actionInFlightRef = useRef(false);
const isLoadingState = isLoading || isPending;
const groupDisabled = buttonGroup?.isDisabled ?? false;
diff --git a/packages/core/src/Pagination/Pagination.test.tsx b/packages/core/src/Pagination/Pagination.test.tsx
index 1aa20970aaa8e..6a0cc3e3ad6c2 100644
--- a/packages/core/src/Pagination/Pagination.test.tsx
+++ b/packages/core/src/Pagination/Pagination.test.tsx
@@ -10,7 +10,7 @@
*/
import {describe, it, expect, vi} from 'vitest';
-import {render, screen, within} from '@testing-library/react';
+import {render, screen, within, fireEvent, act} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {Pagination, generatePageRange} from './Pagination';
@@ -346,6 +346,140 @@ describe('Pagination', () => {
});
});
+ // ---------------------------------------------------------------------------
+ // changeAction (interruptible, optimistic)
+ // ---------------------------------------------------------------------------
+
+ describe('changeAction', () => {
+ it('fires onChange then changeAction with the new page', async () => {
+ const user = userEvent.setup();
+ const order: string[] = [];
+ const onChange = vi.fn(() => order.push('onChange'));
+ const changeAction = vi.fn(() => {
+ order.push('changeAction');
+ });
+ render(
+ ,
+ );
+ await user.click(screen.getByRole('button', {name: 'Go to next page'}));
+ expect(onChange).toHaveBeenCalledWith(2);
+ expect(changeAction).toHaveBeenCalledWith(2);
+ expect(order).toEqual(['onChange', 'changeAction']);
+ });
+
+ it('shows the optimistic page while changeAction is pending', async () => {
+ const user = userEvent.setup();
+ let resolveAction: (() => void) | undefined;
+ const changeAction = vi.fn(
+ async () =>
+ new Promise(resolve => {
+ resolveAction = resolve;
+ }),
+ );
+ render(
+ {}}
+ changeAction={changeAction}
+ totalPages={5}
+ variant="compact"
+ />,
+ );
+
+ // The committed `page` prop stays at 1, but the indicator optimistically
+ // reflects the page being navigated to.
+ await user.click(screen.getByRole('button', {name: 'Go to next page'}));
+ expect(changeAction).toHaveBeenCalledWith(2);
+ expect(screen.getByText('Page 2 of 5')).toBeInTheDocument();
+
+ await act(async () => {
+ resolveAction?.();
+ await Promise.resolve();
+ });
+ });
+
+ it('interrupts an in-flight action on rapid next clicks', async () => {
+ // Each click derives its target from the optimistic page, so clicking
+ // next twice before the action settles advances 1 -> 2 -> 3 instead of
+ // being dropped by a re-entry guard.
+ const resolvers: (() => void)[] = [];
+ const changeAction = vi.fn(
+ async () =>
+ new Promise(resolve => {
+ resolvers.push(resolve);
+ }),
+ );
+ render(
+ {}}
+ changeAction={changeAction}
+ totalPages={5}
+ variant="compact"
+ />,
+ );
+
+ const next = screen.getByRole('button', {name: 'Go to next page'});
+ await act(async () => {
+ fireEvent.click(next);
+ });
+ expect(screen.getByText('Page 2 of 5')).toBeInTheDocument();
+ await act(async () => {
+ fireEvent.click(next);
+ });
+ expect(screen.getByText('Page 3 of 5')).toBeInTheDocument();
+
+ expect(changeAction).toHaveBeenCalledTimes(2);
+ expect(changeAction).toHaveBeenNthCalledWith(1, 2);
+ expect(changeAction).toHaveBeenNthCalledWith(2, 3);
+
+ await act(async () => {
+ resolvers.forEach(resolve => resolve());
+ await Promise.resolve();
+ });
+ });
+
+ it('supports a synchronous changeAction', async () => {
+ const user = userEvent.setup();
+ const changeAction = vi.fn((_page: number) => {});
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ await user.click(
+ screen.getByRole('button', {name: 'Go to previous page'}),
+ );
+ expect(onChange).toHaveBeenCalledWith(1);
+ expect(changeAction).toHaveBeenCalledWith(1);
+ });
+
+ it('does not fire changeAction when disabled', async () => {
+ const user = userEvent.setup();
+ const changeAction = vi.fn();
+ render(
+ {}}
+ changeAction={changeAction}
+ totalPages={5}
+ isDisabled
+ />,
+ );
+ await user.click(screen.getByRole('button', {name: 'Go to next page'}));
+ expect(changeAction).not.toHaveBeenCalled();
+ });
+ });
+
// ---------------------------------------------------------------------------
// Boundary states
// ---------------------------------------------------------------------------
@@ -459,12 +593,7 @@ describe('Pagination', () => {
describe('disabled state', () => {
it('disables all page buttons when isDisabled', () => {
render(
- {}}
- totalPages={5}
- isDisabled
- />,
+ {}} totalPages={5} isDisabled />,
);
expect(
screen.getByRole('button', {name: 'Go to previous page'}),
@@ -480,12 +609,7 @@ describe('Pagination', () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
- ,
+ ,
);
// Disabled buttons can't be clicked
await user.click(screen.getByRole('button', {name: 'Go to page 1'}));
diff --git a/packages/core/src/Pagination/Pagination.tsx b/packages/core/src/Pagination/Pagination.tsx
index 6f3f68e0709db..3885ffdb603f4 100644
--- a/packages/core/src/Pagination/Pagination.tsx
+++ b/packages/core/src/Pagination/Pagination.tsx
@@ -19,7 +19,7 @@
* label, data-testid, xstyle
*/
-import {useTransition} from 'react';
+import {useOptimistic, useTransition} from 'react';
import * as stylex from '@stylexjs/stylex';
import {
colorVars,
@@ -349,16 +349,22 @@ export function Pagination({
style,
ref,
}: PaginationProps) {
- const [isPending, startTransition] = useTransition();
+ const [, startTransition] = useTransition();
+
+ // Track the page optimistically so rapid prev/next clicks advance from the
+ // in-flight target instead of stalling on the last committed page.
+ const [optimisticPage, setOptimisticPage] = useOptimistic(page);
// Compute pagination state
const computedTotalPages =
totalPagesProp ??
(totalItems != null ? Math.ceil(totalItems / pageSize) : undefined);
- const hasPrevious = page > 1;
+ const hasPrevious = optimisticPage > 1;
const hasNext =
- computedTotalPages != null ? page < computedTotalPages : (hasMore ?? false);
+ computedTotalPages != null
+ ? optimisticPage < computedTotalPages
+ : (hasMore ?? false);
// Return null for empty state
if (totalItems != null && totalItems <= 0) {
@@ -368,48 +374,47 @@ export function Pagination({
return null;
}
+ // Interruptible: re-clicking before the transition settles starts a fresh one
+ // with the next optimistic page rather than being dropped, so there is no
+ // re-entry guard.
const handlePageChange = (newPage: number) => {
- if (isDisabled || isPending) {
+ if (isDisabled) {
return;
}
+ // Keep onChange urgent so controlled page state updates in the same commit
+ // as the click; only the optimistic indicator and changeAction defer.
onChange(newPage);
- if (changeAction) {
- startTransition(async () => {
- await changeAction(newPage);
- });
- }
+ startTransition(async () => {
+ setOptimisticPage(newPage);
+ await changeAction?.(newPage);
+ });
};
const handlePrevious = () => {
if (hasPrevious) {
- handlePageChange(page - 1);
+ handlePageChange(optimisticPage - 1);
}
};
const handleNext = () => {
if (hasNext) {
- handlePageChange(page + 1);
+ handlePageChange(optimisticPage + 1);
}
};
const handlePageSizeChange = (value: string) => {
const newSize = Number(value);
onPageSizeChange?.(newSize);
- // Reset to page 1 when page size changes
- onChange(1);
- if (changeAction) {
- startTransition(async () => {
- await changeAction(1);
- });
- }
+ // Reset to page 1 when page size changes.
+ handlePageChange(1);
};
// Item range for count display
- const rangeStart = (page - 1) * pageSize + 1;
+ const rangeStart = (optimisticPage - 1) * pageSize + 1;
const rangeEnd =
totalItems != null
- ? Math.min(page * pageSize, totalItems)
- : page * pageSize;
+ ? Math.min(optimisticPage * pageSize, totalItems)
+ : optimisticPage * pageSize;
const buttonSize = size === 'sm' ? 'sm' : 'md';
const isSm = size === 'sm';
@@ -421,7 +426,7 @@ export function Pagination({
return null;
}
const pageRange = generatePageRange(
- page,
+ optimisticPage,
computedTotalPages,
siblingCount,
);
@@ -443,7 +448,7 @@ export function Pagination({
);
}
- const isActive = item === page;
+ const isActive = item === optimisticPage;
return (