Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ import { useEffect } from 'react';
import { Toaster } from 'react-hot-toast';
import { createBrowserRouter, RouterProvider } from 'react-router';
import AppErrorBoundary from './components/common/AppErrorBoundary';
import { useNavigationTiming } from './hooks/useNavigationTiming';
import { routes } from './routes';
import { useRouteChangeLogging } from './hooks/useRouteChangeLogging';

const router = createBrowserRouter(routes);

function App() {
useNavigationTiming();
useRouteChangeLogging();

useEffect(() => {
Expand Down
52 changes: 30 additions & 22 deletions src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ExternalLink,
} from 'lucide-react';
import { Sparkline } from '@/components/ui/sparkline';
import SectionErrorBoundary from '@/components/common/SectionErrorBoundary';
import {
DropdownMenu,
DropdownMenuContent,
Expand Down Expand Up @@ -367,29 +368,36 @@ const CreatorCard: React.FC<CreatorCardProps> = ({
else if (latest < earliest) lineColor = '#ef4444';

return (
<div className="mt-3">
<Sparkline
data={creator.priceHistory}
color={lineColor}
/>
<table id={priceChartDescriptionId} className="sr-only">
<caption>{priceChartAccessibility.summary}</caption>
<thead>
<tr>
<th scope="col">Point</th>
<th scope="col">Key price</th>
</tr>
</thead>
<tbody>
{priceChartAccessibility.points.map(point => (
<tr key={point.label}>
<th scope="row">{point.label}</th>
<td>{point.value}</td>
<SectionErrorBoundary
sectionName="bonding curve chart"
title="Chart unavailable — try refreshing"
description=""
minHeight={40}
>
<div className="mt-3">
<Sparkline
data={creator.priceHistory}
color={lineColor}
/>
<table id={priceChartDescriptionId} className="sr-only">
<caption>{priceChartAccessibility.summary}</caption>
<thead>
<tr>
<th scope="col">Point</th>
<th scope="col">Key price</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{priceChartAccessibility.points.map(point => (
<tr key={point.label}>
<th scope="row">{point.label}</th>
<td>{point.value}</td>
</tr>
))}
</tbody>
</table>
</div>
</SectionErrorBoundary>
);
})()}

Expand Down
11 changes: 11 additions & 0 deletions src/components/common/CreatorMarketplaceInfiniteList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default function CreatorMarketplaceInfiniteList({
hasMore,
isLoadingFirstPage,
isFetchingNextPage,
isRefreshing,
fetchNextPage,
} = useInfiniteCreatorMarketplace(params);

Expand All @@ -44,6 +45,16 @@ export default function CreatorMarketplaceInfiniteList({

return (
<div data-testid="creator-marketplace-infinite-list">
{isRefreshing && (
<div
data-testid="creator-marketplace-refreshing-indicator"
role="status"
aria-live="polite"
className="mb-3 text-xs text-muted-foreground"
>
Refreshing…
</div>
)}
<div className="grid w-full grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{creators.map(creator => (
<CreatorCard key={creator.id} creator={creator} />
Expand Down
27 changes: 20 additions & 7 deletions src/components/common/SectionErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ interface Props {
sectionName?: string;
minHeight?: string | number;
className?: string;
/**
* Overrides the default "Something went wrong in this section" heading.
* Use for a short, section-specific message (e.g. "Chart unavailable —
* try refreshing").
*/
title?: string;
/**
* Overrides the default explanatory paragraph. Pass an empty string to
* show only the title (no secondary copy).
*/
description?: string;
}

interface State {
Expand Down Expand Up @@ -48,14 +59,16 @@ class SectionErrorBoundary extends Component<Props, State> {
<div className="flex flex-col items-center gap-2">
<AlertCircle className="h-10 w-10 text-destructive" />
<h3 className="text-lg font-semibold">
Something went wrong in this section
{this.props.title ?? 'Something went wrong in this section'}
</h3>
<p className="max-w-md text-sm text-muted-foreground">
{this.props.sectionName
? `We encountered an error while loading the ${this.props.sectionName}.`
: 'We encountered an error while loading this content.'}
Please try again or contact support if the issue persists.
</p>
{this.props.description !== '' && (
<p className="max-w-md text-sm text-muted-foreground">
{this.props.description ??
(this.props.sectionName
? `We encountered an error while loading the ${this.props.sectionName}. Please try again or contact support if the issue persists.`
: 'We encountered an error while loading this content. Please try again or contact support if the issue persists.')}
</p>
)}
</div>
<Button
variant="outline"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const baseHookReturn = {
hasMore: false,
isLoadingFirstPage: false,
isFetchingNextPage: false,
isRefreshing: false,
fetchNextPage: vi.fn(),
error: null,
};
Expand Down Expand Up @@ -139,4 +140,49 @@ describe('CreatorMarketplaceInfiniteList', () => {
expect(call.enabled).toBe(false);
expect(call.hasMore).toBe(true);
});

describe('background refresh indicator (#691)', () => {
it('shows a "Refreshing…" indicator while isRefreshing is true', () => {
mockUseInfiniteCreatorMarketplace.mockReturnValue({
...baseHookReturn,
creators: [makeCreator('a')],
isRefreshing: true,
});

render(<CreatorMarketplaceInfiniteList />);

expect(
screen.getByTestId('creator-marketplace-refreshing-indicator')
).toBeInTheDocument();
expect(screen.getByText('Refreshing…')).toBeInTheDocument();
});

it('does not show the refreshing indicator when isRefreshing is false', () => {
mockUseInfiniteCreatorMarketplace.mockReturnValue({
...baseHookReturn,
creators: [makeCreator('a')],
isRefreshing: false,
});

render(<CreatorMarketplaceInfiniteList />);

expect(
screen.queryByTestId('creator-marketplace-refreshing-indicator')
).not.toBeInTheDocument();
});

it('still renders cached creators (no spinner) while refreshing in the background', () => {
mockUseInfiniteCreatorMarketplace.mockReturnValue({
...baseHookReturn,
creators: [makeCreator('a'), makeCreator('b')],
isRefreshing: true,
isLoadingFirstPage: false,
});

render(<CreatorMarketplaceInfiniteList />);

expect(screen.queryByTestId('creator-marketplace-initial-skeleton')).not.toBeInTheDocument();
expect(screen.getAllByRole('article')).toHaveLength(2);
});
});
});
82 changes: 82 additions & 0 deletions src/components/common/__tests__/SectionErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,86 @@ describe('SectionErrorBoundary', () => {
expect(alert).toHaveStyle('min-height: 500px');
expect(alert).toHaveClass('custom-class');
});

it('renders a custom title when provided', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});

render(
<SectionErrorBoundary title="Chart unavailable — try refreshing">
<BuggyComponent shouldThrow={true} />
</SectionErrorBoundary>
);

expect(
screen.getByText('Chart unavailable — try refreshing')
).toBeInTheDocument();
expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument();
});

it('omits the description paragraph when description is an empty string', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});

render(
<SectionErrorBoundary title="Chart unavailable — try refreshing" description="">
<BuggyComponent shouldThrow={true} />
</SectionErrorBoundary>
);

expect(
screen.getByText('Chart unavailable — try refreshing')
).toBeInTheDocument();
expect(
screen.queryByText(/we encountered an error/i)
).not.toBeInTheDocument();
});

it('renders a custom description when provided', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});

render(
<SectionErrorBoundary description="Custom explanation text">
<BuggyComponent shouldThrow={true} />
</SectionErrorBoundary>
);

expect(screen.getByText('Custom explanation text')).toBeInTheDocument();
});

it('does not retry automatically after repeated failures (manual retry only)', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});

const { rerender } = render(
<SectionErrorBoundary>
<BuggyComponent shouldThrow={true} />
</SectionErrorBoundary>
);

expect(screen.getByRole('alert')).toBeInTheDocument();

// Re-rendering with the same throwing child (e.g. parent re-render)
// must NOT clear the error state on its own — only clicking Retry does.
rerender(
<SectionErrorBoundary>
<BuggyComponent shouldThrow={true} />
</SectionErrorBoundary>
);

expect(screen.getByRole('alert')).toBeInTheDocument();
});

it('logs the caught error via componentDidCatch when an error occurs', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

render(
<SectionErrorBoundary sectionName="bonding curve chart">
<BuggyComponent shouldThrow={true} />
</SectionErrorBoundary>
);

expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('bonding curve chart'),
expect.any(Error),
expect.anything()
);
});
});
49 changes: 39 additions & 10 deletions src/hooks/__tests__/tradeCacheInvalidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { renderHook, waitFor } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { useTradeMutation } from '../useWallet';
import { queryKeys } from '@/lib/queryKeys';

vi.mock('@/utils/toast.util', () => ({
default: {
Expand All @@ -17,12 +18,14 @@ vi.mock('@/utils/toast.util', () => ({
},
}));

function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
function createWrapper(queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})) {
return {
queryClient,
Wrapper: function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
},
};
}

Expand All @@ -32,8 +35,8 @@ describe('useTradeMutation cache invalidation log (#636)', () => {
process.env.NODE_ENV = 'development';
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});

const wrapper = createWrapper();
const { result } = renderHook(() => useTradeMutation('GWALLET'), { wrapper });
const { Wrapper } = createWrapper();
const { result } = renderHook(() => useTradeMutation('GWALLET'), { wrapper: Wrapper });

result.current.mutate({
creatorId: 'creator-1',
Expand Down Expand Up @@ -61,8 +64,8 @@ describe('useTradeMutation cache invalidation log (#636)', () => {
it('does not emit the log in test environment', async () => {
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});

const wrapper = createWrapper();
const { result } = renderHook(() => useTradeMutation('GWALLET'), { wrapper });
const { Wrapper } = createWrapper();
const { result } = renderHook(() => useTradeMutation('GWALLET'), { wrapper: Wrapper });

result.current.mutate({
creatorId: 'creator-2',
Expand All @@ -77,4 +80,30 @@ describe('useTradeMutation cache invalidation log (#636)', () => {

debugSpy.mockRestore();
});

it('invalidates the marketplace list cache after a trade settles (#691)', async () => {
const { queryClient, Wrapper } = createWrapper();

queryClient.setQueryData(queryKeys.creators.infiniteList(undefined), {
pages: [{ items: [], page: 1, hasMore: false }],
pageParams: [1],
});

const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');

const { result } = renderHook(() => useTradeMutation('GWALLET'), { wrapper: Wrapper });

result.current.mutate({
creatorId: 'creator-3',
amount: 2,
priceStroops: 250_000,
price: 0.03,
});

await waitFor(() => expect(result.current.isSuccess).toBe(true), { timeout: 3000 });

expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: queryKeys.creators.all }),
);
});
});
Loading
Loading