Skip to content

Commit 1ec52e0

Browse files
authored
Merge branch 'main' into fix/issue-2859
2 parents b715a4f + f4db3bd commit 1ec52e0

8 files changed

Lines changed: 476 additions & 2 deletions
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { render } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import ScrollRestoration from './ScrollRestoration';
5+
6+
const mockUsePathname = vi.fn();
7+
8+
vi.mock('next/navigation', () => ({
9+
usePathname: () => mockUsePathname(),
10+
}));
11+
12+
describe('ScrollRestoration massive scaling behavior', () => {
13+
beforeEach(() => {
14+
vi.clearAllMocks();
15+
sessionStorage.clear();
16+
mockUsePathname.mockReturnValue('/');
17+
window.scrollTo = vi.fn();
18+
});
19+
20+
it('renders repeatedly without crashing under high mount volume', () => {
21+
expect(() => {
22+
for (let index = 0; index < 100; index++) {
23+
render(<ScrollRestoration />);
24+
}
25+
}).not.toThrow();
26+
});
27+
28+
it('handles many stored scroll positions without breaking restoration', () => {
29+
for (let index = 0; index < 500; index++) {
30+
sessionStorage.setItem(`scroll-position-/page-${index}`, String(index));
31+
}
32+
33+
mockUsePathname.mockReturnValue('/page-499');
34+
35+
render(<ScrollRestoration />);
36+
37+
expect(window.scrollTo).toHaveBeenCalledWith(0, 499);
38+
});
39+
40+
it('stores scroll position correctly after many scroll events', () => {
41+
render(<ScrollRestoration />);
42+
43+
for (let index = 0; index < 100; index++) {
44+
Object.defineProperty(window, 'scrollY', {
45+
value: index,
46+
configurable: true,
47+
});
48+
49+
window.dispatchEvent(new Event('scroll'));
50+
}
51+
52+
expect(sessionStorage.getItem('scroll-position-/')).toBe('99');
53+
});
54+
55+
it('keeps pathname-specific keys isolated at scale', () => {
56+
for (let index = 0; index < 50; index++) {
57+
mockUsePathname.mockReturnValue(`/dashboard-${index}`);
58+
59+
const { unmount } = render(<ScrollRestoration />);
60+
61+
Object.defineProperty(window, 'scrollY', {
62+
value: index * 10,
63+
configurable: true,
64+
});
65+
66+
window.dispatchEvent(new Event('scroll'));
67+
unmount();
68+
}
69+
70+
expect(sessionStorage.getItem('scroll-position-/dashboard-0')).toBe('0');
71+
expect(sessionStorage.getItem('scroll-position-/dashboard-49')).toBe('490');
72+
});
73+
it('removes scroll listener safely after many unmount cycles', () => {
74+
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
75+
76+
for (let index = 0; index < 25; index++) {
77+
const { unmount } = render(<ScrollRestoration />);
78+
unmount();
79+
}
80+
81+
expect(removeEventListenerSpy).toHaveBeenCalledTimes(25);
82+
});
83+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, it, expect, expectTypeOf } from 'vitest';
2+
import { z } from 'zod';
3+
4+
// Test suite: verify TypeScript compiler stability and runtime schema boundaries
5+
describe('Contributors loading type & schema compiler checks (Variation 10)', () => {
6+
// Baseline expected contributor shape (matches ContributorsClient internal shape)
7+
type ExpectedContributor = {
8+
id: number;
9+
login: string;
10+
avatar_url: string;
11+
contributions: number;
12+
html_url: string;
13+
};
14+
15+
it('Case 1: Enforce field property configuration types match expected baseline structures exactly', () => {
16+
// A matching shape should be exactly equal to the expected contributor type
17+
type Matching = {
18+
id: number;
19+
login: string;
20+
avatar_url: string;
21+
contributions: number;
22+
html_url: string;
23+
};
24+
25+
expectTypeOf<Matching>().toEqualTypeOf<ExpectedContributor>();
26+
});
27+
28+
it('Case 2: Assert that invalid parameter shapes are structurally incompatible', () => {
29+
// Wrong types / missing required fields should not match ExpectedContributor
30+
type InvalidContributor = {
31+
id: string; // wrong type
32+
login?: string; // optional where required
33+
};
34+
35+
expectTypeOf<InvalidContributor>().not.toMatchTypeOf<ExpectedContributor>();
36+
});
37+
38+
it('Case 3: Verify custom configurations accept optional parameters without compiler warnings', () => {
39+
// Define an expected loading config with optional parameters
40+
type LoadingConfig = {
41+
limit?: number;
42+
showAvatars?: boolean;
43+
filters?: { org?: string } | undefined;
44+
};
45+
46+
// A custom consumer may provide only a subset of optional parameters
47+
const customConfig = { limit: 6 } satisfies LoadingConfig;
48+
49+
// runtime assertion to keep TS happy and ensure the optional property works
50+
expect(customConfig.limit).toBe(6);
51+
52+
// compile-time: ensure the custom minimal shape is assignable to LoadingConfig
53+
type CustomMinimal = typeof customConfig;
54+
expectTypeOf<CustomMinimal>().toMatchTypeOf<LoadingConfig>();
55+
});
56+
57+
it('Case 4: Runtime validation schemas flag structural boundary anomalies with strict error reports', () => {
58+
const contributorSchema = z
59+
.object({
60+
id: z.number(),
61+
login: z.string(),
62+
avatar_url: z.string(),
63+
contributions: z.number(),
64+
html_url: z.string(),
65+
})
66+
.strict();
67+
68+
const badPayload = {
69+
id: 1,
70+
login: 'alice',
71+
avatar_url: 'https://example.com/a.png',
72+
contributions: 42,
73+
html_url: 'https://github.com/alice',
74+
unexpected_extra: 'surprise',
75+
};
76+
77+
try {
78+
contributorSchema.parse(badPayload);
79+
// If parse does not throw, force a failure
80+
expect(false).toBe(true);
81+
} catch (err) {
82+
// ZodError -> inspect issues for unrecognized keys
83+
const zErr = err as z.ZodError;
84+
const hasUnrecognized = zErr.issues.some((i) => i.code === z.ZodIssueCode.unrecognized_keys);
85+
expect(hasUnrecognized).toBe(true);
86+
}
87+
});
88+
89+
it('Case 5: Ensure type parameters resolve down to stable readonly or structured object contracts', () => {
90+
type ReadonlyContributor = Readonly<ExpectedContributor>;
91+
92+
// Expected readonly contract
93+
type ExplicitReadonly = {
94+
readonly id: number;
95+
readonly login: string;
96+
readonly avatar_url: string;
97+
readonly contributions: number;
98+
readonly html_url: string;
99+
};
100+
101+
expectTypeOf<ReadonlyContributor>().toEqualTypeOf<ExplicitReadonly>();
102+
});
103+
});

app/customize/page.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
Language,
2121
Timezone,
2222
} from './types';
23+
import { useDebounce } from '@/hooks/useDebounce';
2324
import { getExportSnippet, buildQueryParams } from './utils';
2425

2526
// ─── Main Page ────────────────────────────────────────────────────────────────
@@ -54,6 +55,7 @@ function CustomizePageInner(): ReactElement {
5455
const [svgState, setSvgState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle');
5556
const [errorMessage, setErrorMessage] = useState<string | null>(null);
5657
const trimmedUsername = username.trim();
58+
const debouncedUsername = useDebounce(trimmedUsername, 400);
5759
const hasUsername = trimmedUsername.length > 0;
5860
const isRandomTheme = theme === 'random';
5961

@@ -146,7 +148,32 @@ function CustomizePageInner(): ReactElement {
146148
language,
147149
timezone,
148150
});
149-
const previewSrc = `/api/streak?${queryString}`;
151+
152+
const previewQueryString = buildQueryParams({
153+
username: debouncedUsername,
154+
theme,
155+
bgHex,
156+
accentHex,
157+
textHex,
158+
scale,
159+
speed,
160+
font,
161+
year,
162+
radius,
163+
size,
164+
hideTitle,
165+
hideBackground,
166+
hideStats,
167+
viewMode,
168+
deltaFormat,
169+
badgeWidth,
170+
badgeHeight,
171+
grace,
172+
language,
173+
timezone,
174+
});
175+
176+
const previewSrc = `/api/streak?${previewQueryString}`;
150177

151178
// On change sync state to URL
152179
useEffect(() => {
@@ -169,6 +196,13 @@ function CustomizePageInner(): ReactElement {
169196
return;
170197
}
171198

199+
if (!validateGitHubUsername(debouncedUsername)) {
200+
setSvgContent('');
201+
setSvgState('error');
202+
setErrorMessage("That doesn't look like a valid GitHub username");
203+
return;
204+
}
205+
172206
setSvgState('loading');
173207
const controller = new AbortController();
174208

@@ -235,7 +269,7 @@ function CustomizePageInner(): ReactElement {
235269
});
236270

237271
return () => controller.abort();
238-
}, [previewSrc, hasUsername, trimmedUsername]);
272+
}, [previewSrc, hasUsername, debouncedUsername]);
239273

240274
const exportSnippet = getExportSnippet(exportFormat, queryString);
241275

lib/hooks/useDebounce.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { renderHook, act } from '@testing-library/react';
2+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3+
import { useDebounce } from './useDebounce';
4+
5+
describe('useDebounce', () => {
6+
beforeEach(() => {
7+
vi.useFakeTimers();
8+
});
9+
10+
afterEach(() => {
11+
vi.useRealTimers();
12+
});
13+
14+
it('updates only after the delay', () => {
15+
const { result, rerender } = renderHook(({ value }) => useDebounce(value, 400), {
16+
initialProps: { value: 'a' },
17+
});
18+
19+
expect(result.current).toBe('a');
20+
21+
rerender({ value: 'ab' });
22+
rerender({ value: 'abc' });
23+
24+
act(() => {
25+
vi.advanceTimersByTime(399);
26+
});
27+
28+
expect(result.current).toBe('a');
29+
30+
act(() => {
31+
vi.advanceTimersByTime(1);
32+
});
33+
34+
expect(result.current).toBe('abc');
35+
});
36+
37+
it('resets the timer when value changes repeatedly', () => {
38+
const { result, rerender } = renderHook(({ value }) => useDebounce(value, 400), {
39+
initialProps: { value: 'a' },
40+
});
41+
42+
rerender({ value: 'ab' });
43+
44+
act(() => {
45+
vi.advanceTimersByTime(200);
46+
});
47+
48+
rerender({ value: 'abc' });
49+
50+
act(() => {
51+
vi.advanceTimersByTime(200);
52+
});
53+
54+
expect(result.current).toBe('a');
55+
56+
act(() => {
57+
vi.advanceTimersByTime(200);
58+
});
59+
60+
expect(result.current).toBe('abc');
61+
});
62+
});

lib/hooks/useDebounce.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useEffect, useState } from 'react';
2+
3+
export function useDebounce<T>(value: T, delay: number) {
4+
const [debouncedValue, setDebouncedValue] = useState(value);
5+
6+
useEffect(() => {
7+
const timer = setTimeout(() => {
8+
setDebouncedValue(value);
9+
}, delay);
10+
11+
return () => clearTimeout(timer);
12+
}, [value, delay]);
13+
14+
return debouncedValue;
15+
}

0 commit comments

Comments
 (0)