Skip to content

Commit 3121bd8

Browse files
committed
suspense tests 1
1 parent 06591f3 commit 3121bd8

File tree

2 files changed

+364
-0
lines changed

2 files changed

+364
-0
lines changed

src/__tests__/suspense-fake-timers.test.tsx

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,184 @@ testGateReact19('resolves timer-controlled promise', async () => {
5959
expect(screen.getByTestId('sibling')).toBeOnTheScreen();
6060
expect(screen.queryByText('Loading...')).not.toBeOnTheScreen();
6161
});
62+
63+
function DelayedSuspending({ delay }: { delay: number }) {
64+
const promise = React.useMemo(() =>
65+
new Promise((resolve) => {
66+
setTimeout(() => resolve(`data-${delay}`), delay);
67+
}), [delay]
68+
);
69+
70+
const data = React.use(promise);
71+
return <View testID={`delayed-content-${data}`} />;
72+
}
73+
74+
testGateReact19('handles multiple delays with fake timers', async () => {
75+
await renderAsync(
76+
<View>
77+
<React.Suspense fallback={<Text>Fast Loading...</Text>}>
78+
<DelayedSuspending delay={50} />
79+
</React.Suspense>
80+
<React.Suspense fallback={<Text>Slow Loading...</Text>}>
81+
<DelayedSuspending delay={200} />
82+
</React.Suspense>
83+
</View>,
84+
);
85+
86+
expect(screen.getByText('Fast Loading...')).toBeOnTheScreen();
87+
expect(screen.getByText('Slow Loading...')).toBeOnTheScreen();
88+
89+
// Fast timer completes first
90+
expect(await screen.findByTestId('delayed-content-data-50')).toBeOnTheScreen();
91+
expect(screen.queryByText('Fast Loading...')).not.toBeOnTheScreen();
92+
expect(screen.getByText('Slow Loading...')).toBeOnTheScreen();
93+
94+
// Slow timer completes later
95+
expect(await screen.findByTestId('delayed-content-data-200')).toBeOnTheScreen();
96+
expect(screen.queryByText('Slow Loading...')).not.toBeOnTheScreen();
97+
});
98+
99+
function IntervalSuspending({ interval }: { interval: number }) {
100+
const [count, setCount] = React.useState(0);
101+
102+
React.useEffect(() => {
103+
const timer = setInterval(() => setCount(c => c + 1), interval);
104+
return () => clearInterval(timer);
105+
}, [interval]);
106+
107+
if (count < 3) {
108+
const promise = new Promise(() => {}); // Never resolves until count >= 3
109+
React.use(promise);
110+
}
111+
112+
return <View testID={`interval-content-${count}`} />;
113+
}
114+
115+
testGateReact19('handles interval-based suspense with fake timers', async () => {
116+
await renderAsync(
117+
<React.Suspense fallback={<Text>Interval Loading...</Text>}>
118+
<IntervalSuspending interval={100} />
119+
</React.Suspense>,
120+
);
121+
122+
expect(screen.getByText('Interval Loading...')).toBeOnTheScreen();
123+
124+
// Should resolve after enough intervals pass
125+
expect(await screen.findByTestId('interval-content-3')).toBeOnTheScreen();
126+
expect(screen.queryByText('Interval Loading...')).not.toBeOnTheScreen();
127+
});
128+
129+
function AnimationSuspending() {
130+
const [progress, setProgress] = React.useState(0);
131+
132+
React.useEffect(() => {
133+
const animate = () => {
134+
setProgress(p => {
135+
if (p >= 100) return 100;
136+
setTimeout(animate, 16); // 60fps
137+
return p + 1;
138+
});
139+
};
140+
animate();
141+
}, []);
142+
143+
if (progress < 100) {
144+
const promise = new Promise(() => {}); // Suspend until animation complete
145+
React.use(promise);
146+
}
147+
148+
return <View testID="animation-complete" />;
149+
}
150+
151+
testGateReact19('handles animation-like suspense with fake timers', async () => {
152+
await renderAsync(
153+
<React.Suspense fallback={<Text>Animating...</Text>}>
154+
<AnimationSuspending />
155+
</React.Suspense>,
156+
);
157+
158+
expect(screen.getByText('Animating...')).toBeOnTheScreen();
159+
expect(screen.queryByTestId('animation-complete')).not.toBeOnTheScreen();
160+
161+
// Should complete after animation finishes
162+
expect(await screen.findByTestId('animation-complete')).toBeOnTheScreen();
163+
expect(screen.queryByText('Animating...')).not.toBeOnTheScreen();
164+
});
165+
166+
function RetryingSuspending({ maxRetries = 3 }: { maxRetries?: number }) {
167+
const [retries, setRetries] = React.useState(0);
168+
169+
const promise = React.useMemo(() => {
170+
if (retries < maxRetries) {
171+
// Simulate a failing request that retries
172+
setTimeout(() => setRetries(r => r + 1), 100);
173+
return new Promise(() => {}); // Never resolves, will retry
174+
}
175+
// Success case
176+
return Promise.resolve('success');
177+
}, [retries, maxRetries]);
178+
179+
const data = React.use(promise);
180+
return <View testID={`retry-content-${data}`} />;
181+
}
182+
183+
testGateReact19('handles retry logic with fake timers', async () => {
184+
await renderAsync(
185+
<React.Suspense fallback={<Text>Retrying...</Text>}>
186+
<RetryingSuspending maxRetries={2} />
187+
</React.Suspense>,
188+
);
189+
190+
expect(screen.getByText('Retrying...')).toBeOnTheScreen();
191+
expect(screen.queryByTestId('retry-content-success')).not.toBeOnTheScreen();
192+
193+
// Should eventually succeed after retries
194+
expect(await screen.findByTestId('retry-content-success')).toBeOnTheScreen();
195+
expect(screen.queryByText('Retrying...')).not.toBeOnTheScreen();
196+
});
197+
198+
function CascadingSuspending({ level }: { level: number }) {
199+
const delay = level * 50;
200+
const promise = React.useMemo(() =>
201+
new Promise((resolve) => {
202+
setTimeout(() => resolve(level), delay);
203+
}), [delay, level]
204+
);
205+
206+
const data = React.use(promise);
207+
208+
if (level > 1) {
209+
return (
210+
<View>
211+
<View testID={`cascade-${data}`} />
212+
<React.Suspense fallback={<Text>Cascade Loading {level - 1}...</Text>}>
213+
<CascadingSuspending level={level - 1} />
214+
</React.Suspense>
215+
</View>
216+
);
217+
}
218+
219+
return <View testID={`cascade-${data}`} />;
220+
}
221+
222+
testGateReact19('handles cascading suspense with fake timers', async () => {
223+
await renderAsync(
224+
<React.Suspense fallback={<Text>Cascade Loading 3...</Text>}>
225+
<CascadingSuspending level={3} />
226+
</React.Suspense>,
227+
);
228+
229+
expect(screen.getByText('Cascade Loading 3...')).toBeOnTheScreen();
230+
231+
// Should resolve level by level
232+
expect(await screen.findByTestId('cascade-3')).toBeOnTheScreen();
233+
expect(screen.getByText('Cascade Loading 2...')).toBeOnTheScreen();
234+
235+
expect(await screen.findByTestId('cascade-2')).toBeOnTheScreen();
236+
expect(screen.getByText('Cascade Loading 1...')).toBeOnTheScreen();
237+
238+
expect(await screen.findByTestId('cascade-1')).toBeOnTheScreen();
239+
expect(screen.queryByText('Cascade Loading 1...')).not.toBeOnTheScreen();
240+
expect(screen.queryByText('Cascade Loading 2...')).not.toBeOnTheScreen();
241+
expect(screen.queryByText('Cascade Loading 3...')).not.toBeOnTheScreen();
242+
});

src/__tests__/suspense.test.tsx

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,186 @@ testGateReact19('resolves timer-controlled promise', async () => {
5656
expect(screen.getByTestId('sibling')).toBeOnTheScreen();
5757
expect(screen.queryByText('Loading...')).not.toBeOnTheScreen();
5858
});
59+
60+
function SuspendingWithError({ promise }: { promise: Promise<unknown> }) {
61+
React.use(promise);
62+
return <View testID="error-content" />;
63+
}
64+
65+
class ErrorBoundary extends React.Component<
66+
{ children: React.ReactNode; fallback?: React.ReactNode },
67+
{ hasError: boolean }
68+
> {
69+
constructor(props: { children: React.ReactNode; fallback?: React.ReactNode }) {
70+
super(props);
71+
this.state = { hasError: false };
72+
}
73+
74+
static getDerivedStateFromError() {
75+
return { hasError: true };
76+
}
77+
78+
render() {
79+
if (this.state.hasError) {
80+
return this.props.fallback || <Text>Something went wrong.</Text>;
81+
}
82+
83+
return this.props.children;
84+
}
85+
}
86+
87+
testGateReact19('handles promise rejection with error boundary', async () => {
88+
let rejectPromise: (error: Error) => void;
89+
const promise = new Promise<unknown>((_, reject) => {
90+
rejectPromise = reject;
91+
});
92+
93+
await renderAsync(
94+
<ErrorBoundary fallback={<Text>Error occurred</Text>}>
95+
<React.Suspense fallback={<Text>Loading...</Text>}>
96+
<SuspendingWithError promise={promise} />
97+
</React.Suspense>
98+
</ErrorBoundary>,
99+
);
100+
101+
expect(screen.getByText('Loading...')).toBeOnTheScreen();
102+
expect(screen.queryByTestId('error-content')).not.toBeOnTheScreen();
103+
104+
// eslint-disable-next-line require-await
105+
await act(async () => rejectPromise(new Error('Test error')));
106+
107+
expect(screen.getByText('Error occurred')).toBeOnTheScreen();
108+
expect(screen.queryByText('Loading...')).not.toBeOnTheScreen();
109+
expect(screen.queryByTestId('error-content')).not.toBeOnTheScreen();
110+
});
111+
112+
function NestedSuspending({ promise }: { promise: Promise<unknown> }) {
113+
React.use(promise);
114+
return (
115+
<React.Suspense fallback={<Text>Inner Loading...</Text>}>
116+
<View testID="inner-resolved" />
117+
</React.Suspense>
118+
);
119+
}
120+
121+
testGateReact19('handles nested suspense boundaries', async () => {
122+
let resolveOuterPromise: (value: unknown) => void;
123+
const outerPromise = new Promise((resolve) => {
124+
resolveOuterPromise = resolve;
125+
});
126+
127+
await renderAsync(
128+
<React.Suspense fallback={<Text>Outer Loading...</Text>}>
129+
<NestedSuspending promise={outerPromise} />
130+
</React.Suspense>,
131+
);
132+
133+
expect(screen.getByText('Outer Loading...')).toBeOnTheScreen();
134+
expect(screen.queryByText('Inner Loading...')).not.toBeOnTheScreen();
135+
136+
// eslint-disable-next-line require-await
137+
await act(async () => resolveOuterPromise(null));
138+
139+
expect(screen.getByTestId('inner-resolved')).toBeOnTheScreen();
140+
expect(screen.queryByText('Outer Loading...')).not.toBeOnTheScreen();
141+
expect(screen.queryByText('Inner Loading...')).not.toBeOnTheScreen();
142+
});
143+
144+
function MultipleSuspending({ promises }: { promises: Promise<unknown>[] }) {
145+
promises.forEach((promise) => React.use(promise));
146+
return <View testID="multiple-content" />;
147+
}
148+
149+
testGateReact19('handles multiple suspending promises in same boundary', async () => {
150+
let resolvePromise1: (value: unknown) => void;
151+
let resolvePromise2: (value: unknown) => void;
152+
153+
const promise1 = new Promise((resolve) => {
154+
resolvePromise1 = resolve;
155+
});
156+
const promise2 = new Promise((resolve) => {
157+
resolvePromise2 = resolve;
158+
});
159+
160+
await renderAsync(
161+
<React.Suspense fallback={<Text>Multiple Loading...</Text>}>
162+
<MultipleSuspending promises={[promise1, promise2]} />
163+
</React.Suspense>,
164+
);
165+
166+
expect(screen.getByText('Multiple Loading...')).toBeOnTheScreen();
167+
expect(screen.queryByTestId('multiple-content')).not.toBeOnTheScreen();
168+
169+
// Resolve first promise - should still be loading
170+
// eslint-disable-next-line require-await
171+
await act(async () => resolvePromise1(null));
172+
expect(screen.getByText('Multiple Loading...')).toBeOnTheScreen();
173+
expect(screen.queryByTestId('multiple-content')).not.toBeOnTheScreen();
174+
175+
// Resolve second promise - should now render content
176+
// eslint-disable-next-line require-await
177+
await act(async () => resolvePromise2(null));
178+
expect(screen.getByTestId('multiple-content')).toBeOnTheScreen();
179+
expect(screen.queryByText('Multiple Loading...')).not.toBeOnTheScreen();
180+
});
181+
182+
function ConditionalSuspending({ shouldSuspend }: { shouldSuspend: boolean }) {
183+
if (shouldSuspend) {
184+
const promise = new Promise(() => {}); // Never resolves
185+
React.use(promise);
186+
}
187+
return <View testID="conditional-content" />;
188+
}
189+
190+
testGateReact19('handles conditional suspense', async () => {
191+
const result = await renderAsync(
192+
<React.Suspense fallback={<Text>Conditional Loading...</Text>}>
193+
<ConditionalSuspending shouldSuspend={false} />
194+
</React.Suspense>,
195+
);
196+
197+
// Should render immediately when not suspending
198+
expect(screen.getByTestId('conditional-content')).toBeOnTheScreen();
199+
expect(screen.queryByText('Conditional Loading...')).not.toBeOnTheScreen();
200+
201+
// Re-render with suspense
202+
await result.rerenderAsync(
203+
<React.Suspense fallback={<Text>Conditional Loading...</Text>}>
204+
<ConditionalSuspending shouldSuspend={true} />
205+
</React.Suspense>,
206+
);
207+
208+
expect(screen.getByText('Conditional Loading...')).toBeOnTheScreen();
209+
expect(screen.queryByTestId('conditional-content')).not.toBeOnTheScreen();
210+
});
211+
212+
function SuspendingWithState() {
213+
const [count, setCount] = React.useState(0);
214+
215+
React.useEffect(() => {
216+
const timer = setTimeout(() => setCount(1), 50);
217+
return () => clearTimeout(timer);
218+
}, []);
219+
220+
if (count === 0) {
221+
const promise = new Promise(() => {}); // Never resolves
222+
React.use(promise);
223+
}
224+
225+
return <View testID={`state-content-${count}`} />;
226+
}
227+
228+
testGateReact19('handles suspense with state updates', async () => {
229+
await renderAsync(
230+
<React.Suspense fallback={<Text>State Loading...</Text>}>
231+
<SuspendingWithState />
232+
</React.Suspense>,
233+
);
234+
235+
expect(screen.getByText('State Loading...')).toBeOnTheScreen();
236+
expect(screen.queryByTestId('state-content-0')).not.toBeOnTheScreen();
237+
238+
// Wait for state update to resolve suspense
239+
expect(await screen.findByTestId('state-content-1')).toBeOnTheScreen();
240+
expect(screen.queryByText('State Loading...')).not.toBeOnTheScreen();
241+
});

0 commit comments

Comments
 (0)