diff --git a/examples/nextjs-app-router/app/layout.tsx b/examples/nextjs-app-router/app/layout.tsx
index f3d4978..c4fd231 100644
--- a/examples/nextjs-app-router/app/layout.tsx
+++ b/examples/nextjs-app-router/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from 'next';
import './globals.css';
+import { Providers } from '~/src/providers';
export const metadata: Metadata = {
title: 'Create Next App',
@@ -13,7 +14,9 @@ export default function RootLayout({
}>) {
return (
-
{children}
+
+ {children}
+
);
}
diff --git a/examples/nextjs-app-router/app/overlay/page.tsx b/examples/nextjs-app-router/app/overlay/page.tsx
new file mode 100644
index 0000000..97ac467
--- /dev/null
+++ b/examples/nextjs-app-router/app/overlay/page.tsx
@@ -0,0 +1,10 @@
+import { Suspense } from 'react';
+import { OverlayFunnel } from '../../src/overlay/OverlayCaseFunnel';
+
+export default function Page() {
+ return (
+
+ ;
+
+ );
+}
diff --git a/examples/nextjs-app-router/app/page.tsx b/examples/nextjs-app-router/app/page.tsx
index d90362e..7306d99 100644
--- a/examples/nextjs-app-router/app/page.tsx
+++ b/examples/nextjs-app-router/app/page.tsx
@@ -1,9 +1,10 @@
-'use client';
-import dynamic from 'next/dynamic';
-const TestAppRouterFunnel = dynamic(() =>
- import('../src/funnel').then(({ TestAppRouterFunnel }) => TestAppRouterFunnel),
-);
+import { Suspense } from 'react';
+import { TestAppRouterFunnel } from '~/src/funnel';
+
export default function Home() {
- //A pre-render error occurs in @use-funnel/browser 0.0.5 version.
- return ;
+ return (
+
+
+
+ );
}
diff --git a/examples/nextjs-app-router/e2e/app-router-funnel.spec.ts b/examples/nextjs-app-router/e2e/app-router-funnel.spec.ts
index 7271ac1..0ce3f86 100644
--- a/examples/nextjs-app-router/e2e/app-router-funnel.spec.ts
+++ b/examples/nextjs-app-router/e2e/app-router-funnel.spec.ts
@@ -16,4 +16,17 @@ test('can move the steps of the funnel using history.push.', async ({ page }) =>
await page.goBack();
await expect(page.getByText('start')).toBeVisible();
+
+ await page.getByRole('button', { name: 'navigate to overlay funnel' }).click();
+
+ await expect(page.getByText(/Select Your School/)).toBeVisible();
+ await page.getByRole('button', { name: /school next/ }).click();
+ await expect(page.getByText(/overlay next/)).toBeVisible();
+ await expect(page.getByText(/school next/)).toBeVisible();
+
+ await page.click('input[type="date"]');
+ await page.fill('input[type="date"]', '2024-01-01');
+ await page.getByRole('button', { name: 'overlay next' }).click();
+ await expect(page.getByText(/school: A/)).toBeVisible();
+ await expect(page.getByText(/startDate: 2024-01-01/)).toBeVisible();
});
diff --git a/examples/nextjs-app-router/package.json b/examples/nextjs-app-router/package.json
index a882a1b..e298f3b 100644
--- a/examples/nextjs-app-router/package.json
+++ b/examples/nextjs-app-router/package.json
@@ -10,9 +10,10 @@
"e2e": "pnpm exec playwright test"
},
"dependencies": {
- "@use-funnel/browser": "workspace:^",
"@use-funnel/core": "workspace:^",
+ "@use-funnel/next-app-router": "workspace:^",
"next": "14.2.13",
+ "overlay-kit": "^1.4.1",
"react": "^18",
"react-dom": "^18"
},
diff --git a/examples/nextjs-app-router/src/funnel.tsx b/examples/nextjs-app-router/src/funnel.tsx
index d0afc2c..62f07ae 100644
--- a/examples/nextjs-app-router/src/funnel.tsx
+++ b/examples/nextjs-app-router/src/funnel.tsx
@@ -1,19 +1,23 @@
'use client';
-import { useFunnel } from '@use-funnel/browser';
+import { useFunnel } from '@use-funnel/next-app-router';
+import { useRouter } from 'next/navigation';
export const TestAppRouterFunnel = () => {
const funnel = useFunnel({ id: FUNNEL_ID, initial: { step: 'start', context: {} } });
+ const router = useRouter();
return (
- (
-
-
start
-
-
-
- )}
- end={() => end
}
- />
+ <>
+ (
+
+
start
+
+
+ )}
+ end={() => end
}
+ />
+
+ >
);
};
diff --git a/examples/nextjs-app-router/src/overlay/OverlayCaseFunnel.tsx b/examples/nextjs-app-router/src/overlay/OverlayCaseFunnel.tsx
new file mode 100644
index 0000000..a1d34f3
--- /dev/null
+++ b/examples/nextjs-app-router/src/overlay/OverlayCaseFunnel.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import { useFunnel } from '@use-funnel/next-app-router';
+import { SchoolInput } from './SchoolInput';
+import { StartDate } from './StartDate';
+
+export const OverlayFunnel = () => {
+ const funnel = useFunnel<{
+ SelectSchool: { school?: string };
+ StartDate: { school: string; startDate?: string };
+ Confirm: { school: string; startDate: string };
+ }>({ id: 'general', initial: { context: {}, step: 'SelectSchool' } });
+
+ return (
+ history.push('StartDate', { school: school })} />}
+ StartDate={funnel.Render.overlay({
+ render: ({ history, context }) => (
+ history.push('Confirm', { school: context.school, startDate: startDate })}
+ />
+ ),
+ })}
+ Confirm={({ context }) => (
+
+
school: {context.school}
+
startDate: {context.startDate}
+
+ )}
+ />
+ );
+};
diff --git a/examples/nextjs-app-router/src/overlay/SchoolInput.tsx b/examples/nextjs-app-router/src/overlay/SchoolInput.tsx
new file mode 100644
index 0000000..07148e1
--- /dev/null
+++ b/examples/nextjs-app-router/src/overlay/SchoolInput.tsx
@@ -0,0 +1,18 @@
+import { useState } from 'react';
+
+interface Props {
+ onNext: (school: string) => void;
+}
+
+export function SchoolInput({ onNext }: Props) {
+ const [school, setSchool] = useState('A');
+ return (
+
+
Select Your School
+ setSchool(e.target.value)} />
+ setSchool(e.target.value)} />
+ setSchool(e.target.value)} />
+
+
+ );
+}
diff --git a/examples/nextjs-app-router/src/overlay/StartDate.tsx b/examples/nextjs-app-router/src/overlay/StartDate.tsx
new file mode 100644
index 0000000..a376494
--- /dev/null
+++ b/examples/nextjs-app-router/src/overlay/StartDate.tsx
@@ -0,0 +1,12 @@
+import { ReactNode, useState } from 'react';
+
+export const StartDate = ({ startDate, onNext }: { startDate?: string; onNext: (startDate: string) => void }) => {
+ const [date, setDate] = useState(startDate ?? '');
+
+ return (
+
+ setDate(e.target.value)} />
+
+
+ );
+};
diff --git a/examples/nextjs-app-router/src/providers.tsx b/examples/nextjs-app-router/src/providers.tsx
new file mode 100644
index 0000000..3aa771d
--- /dev/null
+++ b/examples/nextjs-app-router/src/providers.tsx
@@ -0,0 +1,5 @@
+'use client';
+import { OverlayProvider } from 'overlay-kit';
+export const Providers = ({ children }: { children: React.ReactNode }) => {
+ return {children};
+};
diff --git a/packages/next-app-router/package.json b/packages/next-app-router/package.json
new file mode 100644
index 0000000..33148be
--- /dev/null
+++ b/packages/next-app-router/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@use-funnel/next-app-router",
+ "version": "0.0.0",
+ "description": "",
+ "type": "module",
+ "main": "./dist/index.js",
+ "publishConfig": {
+ "access": "public",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "module": "./dist/index.js",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./package.json": "./package.json"
+ }
+ },
+ "files": [
+ "dist",
+ "package.json"
+ ],
+ "scripts": {
+ "test": "vitest run",
+ "test:unit": "vitest --root test/",
+ "build": "rimraf dist && concurrently \"pnpm:build:*\"",
+ "build:dist": "tsup",
+ "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly",
+ "prepublish": "pnpm test && pnpm build"
+ },
+ "keywords": [],
+ "author": "",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/toss/use-funnel.git",
+ "directory": "packages/next-app-router"
+ },
+ "license": "MIT",
+ "homepage": "https://use-funnel.slash.page/",
+ "bugs": "https://github.com/toss/use-funnel/issues",
+ "dependencies": {
+ "@use-funnel/core": "workspace:^"
+ },
+ "devDependencies": {
+ "@testing-library/react": "^15.0.7",
+ "@testing-library/user-event": "^14.5.2",
+ "@types/react": "^18.3.2",
+ "@types/react-dom": "^18.3.0",
+ "concurrently": "^8.2.2",
+ "globals": "^15.3.0",
+ "jsdom": "^24.1.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "rimraf": "^5.0.7",
+ "tsup": "^8.0.2",
+ "typescript": "^5.1.6",
+ "vitest": "^1.6.0"
+ },
+ "peerDependencies": {
+ "next": ">=13",
+ "react": ">=18.2"
+ },
+ "sideEffects": false
+}
diff --git a/packages/next-app-router/src/index.ts b/packages/next-app-router/src/index.ts
new file mode 100644
index 0000000..335bddb
--- /dev/null
+++ b/packages/next-app-router/src/index.ts
@@ -0,0 +1,90 @@
+'use client';
+import { createUseFunnel } from '@use-funnel/core';
+import { useSearchParams } from 'next/navigation';
+import { useLayoutEffect, useMemo, useState } from 'react';
+
+export const useFunnel = createUseFunnel(({ id, initialState }) => {
+ const searchParams = useSearchParams();
+ const [state, setState] = useState>({});
+ useLayoutEffect(() => {
+ if (typeof window !== 'undefined') {
+ setState(window.history.state);
+ }
+
+ function handlePopState(event: PopStateEvent) {
+ setState(event.state);
+ }
+ window.addEventListener('popstate', handlePopState);
+ return () => {
+ window.removeEventListener('popstate', handlePopState);
+ };
+ }, []);
+
+ const currentStep = searchParams.get(`${id}.step`);
+ const currentContext = state?.[`${id}.context`];
+
+ const currentState = useMemo(() => {
+ return currentStep != null && currentContext != null
+ ? ({
+ step: currentStep,
+ context: currentContext,
+ } as typeof initialState)
+ : initialState;
+ }, [currentStep, currentContext, initialState]);
+
+ const history: (typeof initialState)[] = useMemo(
+ () => state?.[`${id}.histories`] ?? [currentState],
+ [state, currentState],
+ );
+
+ const currentIndex = history.length - 1;
+ return useMemo(
+ () => ({
+ history,
+ currentIndex,
+ currentState,
+ push(newState) {
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.set(`${id}.step`, newState.step);
+ const newHistoryState = {
+ ...state,
+ [`${id}.context`]: newState.context,
+ [`${id}.histories`]: [...(history ?? []), newState],
+ };
+ window.history.pushState(newHistoryState, '', `?${newSearchParams.toString()}`);
+
+ setState((prevHistoryState) => {
+ const newHistoryState = {
+ ...prevHistoryState,
+ [`${id}.context`]: newState.context,
+ [`${id}.histories`]: [...(history ?? []), newState],
+ };
+ return newHistoryState;
+ });
+ },
+ replace(newState) {
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.set(`${id}.step`, newState.step);
+ const newHistoryState = {
+ ...state,
+ [`${id}.context`]: newState.context,
+ [`${id}.histories`]: [...(history ?? []), newState],
+ };
+ window.history.replaceState(newHistoryState, '', `?${newSearchParams.toString()}`);
+
+ setState((prevHistoryState) => {
+ const newHistoryState = {
+ ...prevHistoryState,
+ [`${id}.context`]: newState.context,
+ [`${id}.histories`]: [...(history ?? []), newState],
+ };
+ return newHistoryState;
+ });
+ },
+ go(index) {
+ window.history.go(index);
+ },
+ }),
+ [history, currentIndex, currentState, searchParams, id, state],
+ );
+});
diff --git a/packages/next-app-router/test/index.test.tsx b/packages/next-app-router/test/index.test.tsx
new file mode 100644
index 0000000..792b798
--- /dev/null
+++ b/packages/next-app-router/test/index.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import { describe, expect, test } from 'vitest';
+
+import { useFunnel } from '../src/index.js';
+
+describe('Test useFunnel next-app-router router', () => {
+ test('should work', async () => {
+ function FunnelTest() {
+ const funnel = useFunnel<{
+ A: { id?: string };
+ B: { id: string };
+ }>({
+ id: 'vitest',
+ initial: {
+ step: 'A',
+ context: {},
+ },
+ });
+ switch (funnel.step) {
+ case 'A': {
+ return ;
+ }
+ case 'B': {
+ return (
+
+
+
{funnel.context.id}
+
+ );
+ }
+ default: {
+ throw new Error('Invalid step');
+ }
+ }
+ }
+
+ render();
+
+ expect(screen.queryByText('Go B')).not.toBeNull();
+
+ const user = userEvent.setup();
+ await user.click(screen.getByText('Go B'));
+
+ expect(screen.queryByText('vitest')).not.toBeNull();
+ await user.click(screen.getByText('Go Back'));
+
+ expect(screen.queryByText('vitest')).toBeNull();
+ expect(screen.queryByText('Go B')).not.toBeNull();
+ });
+
+ test('hello' , async () => {
+
+ })
+});
diff --git a/packages/next-app-router/tsconfig.build.json b/packages/next-app-router/tsconfig.build.json
new file mode 100644
index 0000000..2772261
--- /dev/null
+++ b/packages/next-app-router/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["test"]
+}
diff --git a/packages/next-app-router/tsconfig.json b/packages/next-app-router/tsconfig.json
new file mode 100644
index 0000000..f04f541
--- /dev/null
+++ b/packages/next-app-router/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "module": "esnext",
+ "target": "esnext",
+ "moduleResolution": "bundler",
+ "allowJs": true,
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "experimentalDecorators": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "resolveJsonModule": true,
+ "noFallthroughCasesInSwitch": true,
+ "forceConsistentCasingInFileNames": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "outDir": "dist"
+ },
+ "include": ["src", "test"]
+}
diff --git a/packages/next-app-router/tsup.config.js b/packages/next-app-router/tsup.config.js
new file mode 100644
index 0000000..a6b9aca
--- /dev/null
+++ b/packages/next-app-router/tsup.config.js
@@ -0,0 +1,6 @@
+import { defineConfig } from 'tsup'
+
+export default defineConfig({
+ entry: ['src/index.ts'],
+ format: ['esm'],
+})
diff --git a/packages/next-app-router/vitest.config.js b/packages/next-app-router/vitest.config.js
new file mode 100644
index 0000000..93b8cf9
--- /dev/null
+++ b/packages/next-app-router/vitest.config.js
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['vitestSetup.ts'],
+ },
+});
diff --git a/packages/next-app-router/vitestSetup.ts b/packages/next-app-router/vitestSetup.ts
new file mode 100644
index 0000000..0ba2a2a
--- /dev/null
+++ b/packages/next-app-router/vitestSetup.ts
@@ -0,0 +1,36 @@
+import { beforeEach, beforeAll, vi, afterEach } from 'vitest';
+
+let currentUrl = new URL('http://localhost/');
+
+export const setMockUrl = (url: string) => {
+ currentUrl = new URL(url);
+};
+
+export const mockSearchParamsGet = vi.fn((param: string) => {
+ return currentUrl.searchParams.get(param);
+});
+
+beforeAll(() => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ href: currentUrl.href,
+ search: currentUrl.search,
+ },
+ writable: true,
+ });
+
+ vi.mock('next/navigation', () => ({
+ useSearchParams: () => ({
+ get: mockSearchParamsGet,
+ }),
+ }));
+});
+
+beforeEach(() => {
+ setMockUrl('http://localhost/');
+ vi.resetAllMocks();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9ccd357..f7750a2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -108,15 +108,18 @@ importers:
examples/nextjs-app-router:
dependencies:
- '@use-funnel/browser':
- specifier: workspace:^
- version: link:../../packages/browser
'@use-funnel/core':
specifier: workspace:^
version: link:../../packages/core
+ '@use-funnel/next-app-router':
+ specifier: workspace:^
+ version: link:../../packages/next-app-router
next:
specifier: 14.2.13
version: 14.2.13(@playwright/test@1.47.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ overlay-kit:
+ specifier: ^1.4.1
+ version: 1.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react:
specifier: ^18
version: 18.3.1
@@ -329,6 +332,55 @@ importers:
specifier: ^1.6.0
version: 1.6.0(@types/node@20.14.9)(jsdom@24.1.0)(terser@5.31.1)
+ packages/next-app-router:
+ dependencies:
+ '@use-funnel/core':
+ specifier: workspace:^
+ version: link:../core
+ next:
+ specifier: '>=13'
+ version: 14.2.13(@playwright/test@1.47.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ devDependencies:
+ '@testing-library/react':
+ specifier: ^15.0.7
+ version: 15.0.7(@types/react@18.3.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@testing-library/user-event':
+ specifier: ^14.5.2
+ version: 14.5.2(@testing-library/dom@10.1.0)
+ '@types/react':
+ specifier: ^18.3.2
+ version: 18.3.2
+ '@types/react-dom':
+ specifier: ^18.3.0
+ version: 18.3.0
+ concurrently:
+ specifier: ^8.2.2
+ version: 8.2.2
+ globals:
+ specifier: ^15.3.0
+ version: 15.3.0
+ jsdom:
+ specifier: ^24.1.0
+ version: 24.1.0
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ rimraf:
+ specifier: ^5.0.7
+ version: 5.0.7
+ tsup:
+ specifier: ^8.0.2
+ version: 8.0.2(postcss@8.4.38)(ts-node@10.9.2(@types/node@20.14.9)(typescript@5.4.5))(typescript@5.4.5)
+ typescript:
+ specifier: ^5.1.6
+ version: 5.4.5
+ vitest:
+ specifier: ^1.6.0
+ version: 1.6.0(@types/node@20.14.9)(jsdom@24.1.0)(terser@5.31.1)
+
packages/react-navigation-native:
dependencies:
'@use-funnel/core':
@@ -5653,6 +5705,12 @@ packages:
outvariant@1.4.0:
resolution: {integrity: sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==}
+ overlay-kit@1.4.1:
+ resolution: {integrity: sha512-BSzpilw1pM1j6/r04Qwc/FHnWe0Hio0qbhdepJuihx352q+n/FPUAr297MqjKanniH722VDPxFdq7JFjErIXdQ==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
+
p-filter@2.1.0:
resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==}
engines: {node: '>=8'}
@@ -14191,6 +14249,12 @@ snapshots:
outvariant@1.4.0: {}
+ overlay-kit@1.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ use-sync-external-store: 1.2.2(react@18.3.1)
+
p-filter@2.1.0:
dependencies:
p-map: 2.1.0