Skip to content

Commit 02109ff

Browse files
feature(next): add suspense option (#157)
* feature(next): Add suspense option * Update packages/next/src/useNextRouter.ts Co-authored-by: MinuKang <[email protected]> --------- Co-authored-by: MinuKang <[email protected]>
1 parent 1bd5891 commit 02109ff

File tree

6 files changed

+120
-4
lines changed

6 files changed

+120
-4
lines changed

.changeset/hot-candles-drive.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@use-funnel/next': patch
3+
---
4+
5+
next: add suspense option

packages/next/src/useFunnel.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createUseFunnel } from '@use-funnel/core';
2-
import { useRouter } from 'next/router.js';
32
import { useMemo, useRef } from 'react';
3+
import { useNextRouter } from './useNextRouter';
44
import { makePath, parseQueryJson, removeKeys, stringifyQueryJson } from './util';
55

66
const QS_KEY = 'funnel.';
@@ -23,6 +23,7 @@ interface NextPageFunnelOption {
2323
stepQueryName?: (id: string) => string;
2424
contextQueryName?: (id: string) => string;
2525
historyQueryName?: (id: string) => string;
26+
suspense?: boolean;
2627
}
2728

2829
export const useFunnel = createUseFunnel<NextPageRouteOption, NextPageFunnelOption>(({
@@ -32,8 +33,9 @@ export const useFunnel = createUseFunnel<NextPageRouteOption, NextPageFunnelOpti
3233
stepQueryName = (id) => `${QS_KEY}${id}${STEP_KEY}`,
3334
contextQueryName = (id) => `${QS_KEY}${id}${CONTEXT_KEY}`,
3435
historyQueryName = (id) => `${QS_KEY}${id}${HISTORY_KEY}`,
36+
suspense,
3537
}) => {
36-
const router = useRouter();
38+
const router = useNextRouter({ suspense });
3739

3840
const routerRef = useRef(router);
3941
routerRef.current = router;

packages/next/src/useNextRouter.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { useRouter } from 'next/router.js';
2+
import { waitForRouterReady } from './util';
3+
4+
interface Options {
5+
suspense?: boolean;
6+
}
7+
8+
export function useNextRouter(options: Options = { suspense: false }) {
9+
const router = useRouter();
10+
11+
if (options.suspense && !router.isReady) {
12+
throw waitForRouterReady();
13+
}
14+
15+
return router;
16+
}

packages/next/src/util.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { NextRouter } from 'next/router';
1+
import Router, { NextRouter } from 'next/router';
22

33
export const removeKeys = (_value: Record<string, any>, conditions: (string | ((key: string) => boolean))[]) => {
44
const value = { ..._value };
@@ -66,3 +66,9 @@ export function stringifyQueryJson(data: unknown) {
6666
return value;
6767
});
6868
}
69+
70+
export function waitForRouterReady() {
71+
return new Promise<void>((resolve) => {
72+
Router.ready(resolve);
73+
});
74+
}

packages/next/test/index.test.tsx

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { render, screen } from '@testing-library/react';
1+
import { render, screen, waitFor } from '@testing-library/react';
22
import { userEvent } from '@testing-library/user-event';
33
import mockRouter from 'next-router-mock';
44
import { describe, expect, test } from 'vitest';
55
import { useFunnel } from '../src/index.js';
6+
import { renderWithSuspense } from './utils.js';
67

78
describe('Test useFunnel next router', () => {
89
test('should work', async () => {
@@ -80,6 +81,71 @@ describe('Test useFunnel next router', () => {
8081
expect(JSON.parse(mockRouter.query['funnel.vitest.histories'] as string)).toEqual([{ step: 'A', context: {} }]);
8182
});
8283

84+
describe('should work with suspense option', async () => {
85+
function FunnelTest() {
86+
const funnel = useFunnel<{
87+
A: { id?: string };
88+
B: { id: string };
89+
C: { id: string; password: string; date: Date };
90+
}>({
91+
id: 'vitest',
92+
initial: {
93+
step: 'A',
94+
context: {},
95+
},
96+
suspense: true,
97+
});
98+
99+
switch (funnel.step) {
100+
case 'A': {
101+
return <div>A</div>;
102+
}
103+
case 'B': {
104+
return <div>B</div>;
105+
}
106+
case 'C': {
107+
return <div>C</div>;
108+
}
109+
default: {
110+
throw new Error('Invalid step');
111+
}
112+
}
113+
}
114+
115+
test('does not have the query parameter value when `isReady: false`', () => {
116+
mockRouter.isReady = false;
117+
118+
const { checkDidSuspend, withSuspense } = renderWithSuspense();
119+
render(withSuspense(<FunnelTest />, { fallback: null }));
120+
expect(checkDidSuspend()).toBe(true);
121+
});
122+
123+
test('returns the correct query parameter value when `isReady: true`', async () => {
124+
mockRouter.isReady = false;
125+
126+
const { checkDidSuspend, withSuspense } = renderWithSuspense();
127+
128+
const FunnelWithSuspense = withSuspense(<FunnelTest />, { fallback: <div>fallback</div> });
129+
const page = render(FunnelWithSuspense);
130+
131+
// set isReady true and rerender page
132+
const timer = setTimeout(() => {
133+
mockRouter.isReady = true;
134+
page.rerender(FunnelWithSuspense);
135+
clearTimeout(timer);
136+
}, 1000);
137+
138+
expect(screen.queryByText('fallback')).not.toBeNull();
139+
140+
await waitFor(() => {
141+
expect(screen.queryByText('C')).not.toBeNull();
142+
expect(mockRouter.query['funnel.vitest.step']).toBe('C');
143+
});
144+
145+
expect(checkDidSuspend()).toBe(true);
146+
});
147+
});
148+
83149
// TODO: Fix this test to sync next-router-mock and window.location.search
84150
test.skip('should work with sub funnel when is multiple used', async () => {
85151
function SubFunnel(props: { onNext(id: string): void }) {

packages/next/test/utils.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { render } from '@testing-library/react';
2+
import { ReactNode, Suspense, useEffect } from 'react';
3+
import { vi } from 'vitest';
4+
5+
export function renderWithSuspense() {
6+
const isSuspended = vi.fn(() => null);
7+
8+
const FallbackComponent = ({ children }: { children: ReactNode }) => {
9+
isSuspended();
10+
return <>{children}</>;
11+
};
12+
13+
const withSuspense = (children: ReactNode, { fallback }: { fallback: ReactNode }) => {
14+
return <Suspense fallback={<FallbackComponent>{fallback}</FallbackComponent>}>{children}</Suspense>;
15+
};
16+
17+
return {
18+
withSuspense,
19+
checkDidSuspend: () => isSuspended.mock.calls.length > 0,
20+
};
21+
}

0 commit comments

Comments
 (0)