From 0e498abb3f59a947f87b3d554ee2fe4399ee5cec Mon Sep 17 00:00:00 2001 From: Josh Rose <1677846+JoshTheWanderer@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:57:08 +0200 Subject: [PATCH 1/7] feat: add AppPagination named export for app router Extract the presentational component into `src/pagination.tsx` and inject a routing adapter from per-router wrappers: - `PagesPagination` (default export) uses `next/router` + `next/head` - `AppPagination` uses `next/navigation` and omits the `` SEO hints (client components cannot write to ``) The default export keeps pointing at `PagesPagination` for backwards compatibility. Both named exports ship from the single entry point. Postbuild fixes for microbundle-crl: - Prepend `'use client'` so Next.js treats the bundled module as a client component (the directive is stripped during rollup bundling) - Inject `__esModule` marker so CJS consumers resolve the default export via `.default` instead of getting the whole exports object Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- scripts/add-use-client.js | 37 +++++++ src/adapters/app.tsx | 38 +++++++ src/adapters/pages.tsx | 24 ++++ src/index.test.ts | 8 +- src/index.tsx | 218 +------------------------------------ src/pagination.tsx | 223 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 335 insertions(+), 215 deletions(-) create mode 100644 scripts/add-use-client.js create mode 100644 src/adapters/app.tsx create mode 100644 src/adapters/pages.tsx create mode 100644 src/pagination.tsx diff --git a/package.json b/package.json index c95f7bd..2a8145b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "node": "^20 || ^22 || ^24" }, "scripts": { - "build": "microbundle-crl --no-compress --format modern,cjs", + "build": "microbundle-crl --no-compress --format modern,cjs && node scripts/add-use-client.js", "start": "microbundle-crl watch --no-compress --format modern,cjs", "prepare": "run-s build", "test": "run-s test:types test:unit", diff --git a/scripts/add-use-client.js b/scripts/add-use-client.js new file mode 100644 index 0000000..83c7763 --- /dev/null +++ b/scripts/add-use-client.js @@ -0,0 +1,37 @@ +const fs = require('fs') +const path = require('path') + +const DIRECTIVE = "'use client';\n" +const ESM_MARKER = "Object.defineProperty(exports, '__esModule', { value: true });\n" + +function prepend(file, snippet) { + const contents = fs.readFileSync(file, 'utf8') + if (contents.includes(snippet.trim())) return contents + const next = snippet + contents + fs.writeFileSync(file, next) + return next +} + +for (const rel of ['dist/index.js', 'dist/index.modern.js']) { + const file = path.join(__dirname, '..', rel) + if (!fs.existsSync(file)) continue + let contents = fs.readFileSync(file, 'utf8') + if (!contents.startsWith("'use client'") && !contents.startsWith('"use client"')) { + contents = DIRECTIVE + contents + fs.writeFileSync(file, contents) + } +} + +// CJS output needs the __esModule marker so `import X from 'pkg'` picks up +// `.default` instead of the whole exports object when both default + named +// exports exist. microbundle-crl doesn't emit it. +const cjs = path.join(__dirname, '..', 'dist/index.js') +if (fs.existsSync(cjs)) { + const contents = fs.readFileSync(cjs, 'utf8') + if (!contents.includes("__esModule")) { + const lines = contents.split('\n') + const insertAt = lines[0].startsWith("'use client'") || lines[0].startsWith('"use client"') ? 1 : 0 + lines.splice(insertAt, 0, ESM_MARKER.trim()) + fs.writeFileSync(cjs, lines.join('\n')) + } +} diff --git a/src/adapters/app.tsx b/src/adapters/app.tsx new file mode 100644 index 0000000..a85360e --- /dev/null +++ b/src/adapters/app.tsx @@ -0,0 +1,38 @@ +'use client' + +import React from 'react' +import { useRouter, usePathname, useSearchParams } from 'next/navigation' + +import Pagination, { PaginationProps } from '../pagination' + +const AppPagination = (props: PaginationProps) => { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const query: Record = {} + if (searchParams) { + const seen: Record = {} + searchParams.forEach((_value, key) => { + if (seen[key]) return + seen[key] = true + const values = searchParams.getAll(key) + query[key] = values.length > 1 ? values : values[0] + }) + } + + return ( + { + router.push(url) + } + }} + /> + ) +} + +export default AppPagination diff --git a/src/adapters/pages.tsx b/src/adapters/pages.tsx new file mode 100644 index 0000000..bb59f10 --- /dev/null +++ b/src/adapters/pages.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { useRouter } from 'next/router' +import Head from 'next/head' + +import Pagination, { PaginationProps } from '../pagination' + +const PagesPagination = (props: PaginationProps) => { + const router = useRouter() + return ( + { + router.push(url) + }, + Head + }} + /> + ) +} + +export default PagesPagination diff --git a/src/index.test.ts b/src/index.test.ts index e914fb3..e622bfa 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,4 +1,4 @@ -import Pagination from '.' +import Pagination, { PagesPagination, AppPagination } from '.' import { getSizes } from './lib/sizes' import assert from 'assert' @@ -6,6 +6,12 @@ describe('Pagination', () => { it('is truthy', () => { expect(Pagination).toBeTruthy() }) + it('default export is PagesPagination', () => { + expect(Pagination).toBe(PagesPagination) + }) + it('exports AppPagination', () => { + expect(AppPagination).toBeTruthy() + }) }) // NOTE: [20,40,60,80,100] <= default sizes diff --git a/src/index.tsx b/src/index.tsx index 9f2f06c..fd6cfe6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,215 +1,7 @@ -import React, { EventHandler, useEffect, useState } from 'react' -import { useRouter } from 'next/router' -import NextLink from 'next/link' -import Head from 'next/head' -import queryString from 'query-string' -import pickBy from 'lodash/pickBy' -import isEmpty from 'lodash/isEmpty' +import PagesPagination from './adapters/pages' -import List from './components/List' -import Item from './components/Item' -import Link from './components/Link' -import Icon from './components/Icon' -import Select from './components/Select' +export { default as PagesPagination } from './adapters/pages' +export { default as AppPagination } from './adapters/app' +export type { PaginationProps } from './pagination' -import { getSizes } from './lib/sizes' -import { getPageNumbers } from './lib/get-page-numbers' - -import defaultTheme from './index.module.css' - -interface PaginationProps { - /** - * The total number of pages - */ - total: number - /** - * A CSS modules style object - */ - theme?: { [key: string]: any } - /** - * An array of page size numbers - */ - sizes?: number[] - /** - * Label for the page size dropdown - */ - perPageText?: string - /** - * Label for the invisible page size button - */ - setPageSizeText?: string - /** - * Extra props to pass to the link component - */ - linkProps?: { [key: string]: any } - /** - * Component used to render navigation links. Defaults to next/link. - * Must accept `href` and a single anchor child (legacy-behaviour style). - */ - linkComponent?: React.ComponentType -} - -const Pagination = ({ - total, - theme, - sizes, - perPageText, - setPageSizeText, - linkProps, - linkComponent: LinkComponent = NextLink -}: PaginationProps) => { - const styles = theme || defaultTheme - const router = useRouter() - const [hasRouter, setHasRouter] = useState(false) - useEffect(() => { - setHasRouter(true) - }, [router]) - - if (!hasRouter) return null - const query = pickBy({ ...(router.query || {}) }, (q) => !isEmpty(q)) - const currentPage = Number(query.page || 1) - // default|custom => evaluated sizes - const cSizes = getSizes(sizes) - const pageSize = Number(query.size) || cSizes[0] - const isLastPage = currentPage * pageSize >= total - const pageNumbers = getPageNumbers({ currentPage, pageSize, total }) - - const path = router.pathname - - const url = (page: number | string) => - `?${queryString.stringify({ - ...query, - page - })}` - - return ( - - ) -} - -Pagination.defaultProps = { - total: 0, - perPageText: 'per page', - setPageSizeText: 'Set page size', - sizes: undefined, - linkProps: {}, - linkComponent: undefined -} - -export default Pagination +export default PagesPagination diff --git a/src/pagination.tsx b/src/pagination.tsx new file mode 100644 index 0000000..70592a8 --- /dev/null +++ b/src/pagination.tsx @@ -0,0 +1,223 @@ +import React, { useEffect, useState } from 'react' +import NextLink from 'next/link' +import queryString from 'query-string' +import pickBy from 'lodash/pickBy' +import isEmpty from 'lodash/isEmpty' + +import List from './components/List' +import Item from './components/Item' +import Link from './components/Link' +import Icon from './components/Icon' +import Select from './components/Select' + +import { getSizes } from './lib/sizes' +import { getPageNumbers } from './lib/get-page-numbers' + +import defaultTheme from './index.module.css' + +export interface PaginationProps { + /** + * The total number of pages + */ + total: number + /** + * A CSS modules style object + */ + theme?: { [key: string]: any } + /** + * An array of page size numbers + */ + sizes?: number[] + /** + * Label for the page size dropdown + */ + perPageText?: string + /** + * Label for the invisible page size button + */ + setPageSizeText?: string + /** + * Extra props to pass to the link component + */ + linkProps?: { [key: string]: any } + /** + * Component used to render navigation links. Defaults to next/link. + * Must accept `href` and a single anchor child (legacy-behaviour style). + */ + linkComponent?: React.ComponentType +} + +export interface RoutingAdapter { + pathname: string + query: Record + push: (url: string) => void + /** + * Optional component used to inject SEO `` into the + * document head. Pages router passes `next/head`; app router omits it + * because client components cannot write to ``. + */ + Head?: React.ComponentType<{ children: React.ReactNode }> +} + +interface InternalProps extends PaginationProps { + routing: RoutingAdapter +} + +const Pagination = ({ + total, + theme, + sizes, + perPageText = 'per page', + setPageSizeText = 'Set page size', + linkProps = {}, + linkComponent: LinkComponent = NextLink, + routing +}: InternalProps) => { + const styles = theme || defaultTheme + const [mounted, setMounted] = useState(false) + useEffect(() => { + setMounted(true) + }, []) + + if (!mounted) return null + const query = pickBy({ ...routing.query }, (q) => !isEmpty(q)) + const currentPage = Number(query.page || 1) + const cSizes = getSizes(sizes) + const pageSize = Number(query.size) || cSizes[0] + const isLastPage = currentPage * pageSize >= total + const pageNumbers = getPageNumbers({ currentPage, pageSize, total }) + + const path = routing.pathname + + const url = (page: number | string) => + `?${queryString.stringify({ + ...query, + page + })}` + + const Head = routing.Head + + return ( + + ) +} + +export default Pagination From faa8031a30658e5c78fc6318f75d4ad2ecf873e0 Mon Sep 17 00:00:00 2001 From: Josh Rose <1677846+JoshTheWanderer@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:57:15 +0200 Subject: [PATCH 2/7] docs(example): demo AppPagination under an app router route Add an `app/` tree alongside the existing `pages/` directory so the demo covers both routers: - `app/layout.jsx` root layout - `app/app-example/page.jsx` client component rendering `AppPagination` inside `` (required for static export with useSearchParams) - link to `/app-example` from the pages index Switch the build to `output: 'export'` since `next export` no longer supports the app router, and rename `out` back to `build` so the `gh-pages` deploy keeps working. Co-Authored-By: Claude Opus 4.7 (1M context) --- example/app/app-example/page.jsx | 30 ++++++++++++++++++++++++++++++ example/app/layout.jsx | 16 ++++++++++++++++ example/next.config.js | 1 + example/package.json | 2 +- example/pages/index.js | 3 +++ 5 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 example/app/app-example/page.jsx create mode 100644 example/app/layout.jsx diff --git a/example/app/app-example/page.jsx b/example/app/app-example/page.jsx new file mode 100644 index 0000000..5dd32ea --- /dev/null +++ b/example/app/app-example/page.jsx @@ -0,0 +1,30 @@ +'use client' + +import React, { Suspense } from 'react' +import { AppPagination } from '@etchteam/next-pagination/dist' + +import theme from '../../styles/theme.module.scss' + +export default function AppExample() { + return ( +
+

App Router Pagination

+

+ This page uses the AppPagination named export, which reads + the route and search params from next/navigation instead of{' '} + next/router. +

+ + +

Default theme

+ + +

Custom theme

+ + +

Custom page sizes

+ +
+
+ ) +} diff --git a/example/app/layout.jsx b/example/app/layout.jsx new file mode 100644 index 0000000..81f9cc9 --- /dev/null +++ b/example/app/layout.jsx @@ -0,0 +1,16 @@ +import React from 'react' + +import '@etchteam/next-pagination/dist/index.css' +import '../styles/main.css' + +export const metadata = { + title: 'Next pagination (App Router)' +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/example/next.config.js b/example/next.config.js index c382bd9..9f80c45 100644 --- a/example/next.config.js +++ b/example/next.config.js @@ -4,6 +4,7 @@ const withSourceMaps = require('@zeit/next-source-maps')() const isProd = (process.env.NODE_ENV || 'production') === 'production' module.exports = withSourceMaps({ + output: 'export', assetPrefix: isProd ? '/next-pagination' : undefined, basePath: isProd ? '/next-pagination' : undefined }) diff --git a/example/package.json b/example/package.json index 953c99a..b0db5f1 100644 --- a/example/package.json +++ b/example/package.json @@ -13,7 +13,7 @@ }, "scripts": { "dev": "next", - "build": "next build && next export -o build", + "build": "next build && rm -rf build && mv out build", "start": "next start" }, "browserslist": [ diff --git a/example/pages/index.js b/example/pages/index.js index 73b1fae..62a41f9 100644 --- a/example/pages/index.js +++ b/example/pages/index.js @@ -62,6 +62,9 @@ export default function Home() { Dynamic pagination

+

+ App Router pagination +

) } From 32be4a13e141a827fd45dd58bd01f0d74cfddcf9 Mon Sep 17 00:00:00 2001 From: Josh Rose <1677846+JoshTheWanderer@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:59:07 +0200 Subject: [PATCH 3/7] test: add playwright e2e suite covering both routers Boots the example dev server via playwright's webServer and exercises the rendered pagination for the pages router (`/`) and the app router (`/app-example`): first-page state, clicking a page number updates the URL and current marker, and the page-size select resets to page 1 with the new size. Run locally with `npm run test:e2e`. Chromium only; CI wiring is not included in this commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 6 +++ package-lock.json | 74 ++++++++++++++++++++++++++++++++++ package.json | 6 ++- playwright.config.ts | 23 +++++++++++ tests/e2e/app-router.spec.ts | 28 +++++++++++++ tests/e2e/pages-router.spec.ts | 35 ++++++++++++++++ 6 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/app-router.spec.ts create mode 100644 tests/e2e/pages-router.spec.ts diff --git a/.gitignore b/.gitignore index e502a14..872d66b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ yarn-debug.log* yarn-error.log* .next + +# playwright +test-results +playwright-report +blob-report +.playwright diff --git a/package-lock.json b/package-lock.json index 79d76c4..d1d1e09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "query-string": "6.12.1" }, "devDependencies": { + "@playwright/test": "1.59.1", "@types/jest": "^27.5.0", "@types/lodash": "^4.14.182", "@types/node": "^17.0.31", @@ -3564,6 +3565,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz", @@ -15578,6 +15595,38 @@ "node": ">=4" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/postcss": { "version": "7.0.39", "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", @@ -24945,6 +24994,15 @@ "fastq": "^1.6.0" } }, + "@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "requires": { + "playwright": "1.59.1" + } + }, "@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz", @@ -33938,6 +33996,22 @@ } } }, + "playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.59.1" + } + }, + "playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true + }, "postcss": { "version": "7.0.39", "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", diff --git a/package.json b/package.json index 2a8145b..46e21b8 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,13 @@ "test:unit": "cross-env CI=1 react-scripts test --env=jsdom", "test:watch": "react-scripts test --env=jsdom", "test:types": "tsc", + "test:e2e": "playwright test", "predeploy": "cd example && npm install && npm run build && touch build/.nojekyll", "deploy": "gh-pages -t -d example/build" }, "peerDependencies": { - "react": ">=16", - "next": ">=10" + "next": ">=10", + "react": ">=16" }, "dependencies": { "classnames": "^2.3.1", @@ -32,6 +33,7 @@ "query-string": "6.12.1" }, "devDependencies": { + "@playwright/test": "1.59.1", "@types/jest": "^27.5.0", "@types/lodash": "^4.14.182", "@types/node": "^17.0.31", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5b4e1f1 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from '@playwright/test' + +const PORT = 3000 +const BASE_URL = `http://localhost:${PORT}` + +export default defineConfig({ + testDir: 'tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: BASE_URL, + trace: 'on-first-retry' + }, + projects: [{ name: 'chromium', use: devices['Desktop Chrome'] }], + webServer: { + command: 'npm --prefix example run dev', + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 120_000 + } +}) diff --git a/tests/e2e/app-router.spec.ts b/tests/e2e/app-router.spec.ts new file mode 100644 index 0000000..673e2fa --- /dev/null +++ b/tests/e2e/app-router.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test' + +test.describe('app router', () => { + test('renders the first page with previous disabled', async ({ page }) => { + await page.goto('/app-example') + const nav = page.getByRole('navigation', { name: 'pagination' }).first() + await expect(nav).toBeVisible() + await expect(nav.getByLabel('Page 1, current page')).toBeVisible() + await expect(nav.getByLabel('No previous page available')).toBeVisible() + }) + + test('clicking a page number updates the URL and current page', async ({ page }) => { + await page.goto('/app-example') + const nav = page.getByRole('navigation', { name: 'pagination' }).first() + await nav.getByLabel('Page 2', { exact: true }).click() + await expect(page).toHaveURL(/\?page=2/) + await expect(nav.getByLabel('Page 2, current page')).toBeVisible() + await expect(nav.getByLabel('Previous page')).toBeVisible() + }) + + test('page size selector pushes size + resets to page 1', async ({ page }) => { + await page.goto('/app-example?page=3') + const nav = page.getByRole('navigation', { name: 'pagination' }).first() + await nav.getByLabel('per page').selectOption('40') + await expect(page).toHaveURL(/size=40/) + await expect(page).toHaveURL(/page=1/) + }) +}) diff --git a/tests/e2e/pages-router.spec.ts b/tests/e2e/pages-router.spec.ts new file mode 100644 index 0000000..ef9e622 --- /dev/null +++ b/tests/e2e/pages-router.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test' + +test.describe('pages router', () => { + test('renders the first page with previous disabled', async ({ page }) => { + await page.goto('/') + const nav = page.getByRole('navigation', { name: 'pagination' }).first() + await expect(nav).toBeVisible() + await expect(nav.getByLabel('Page 1, current page')).toBeVisible() + await expect(nav.getByLabel('No previous page available')).toBeVisible() + }) + + test('clicking a page number updates the URL and current page', async ({ page }) => { + await page.goto('/') + const nav = page.getByRole('navigation', { name: 'pagination' }).first() + await nav.getByLabel('Page 2', { exact: true }).click() + await expect(page).toHaveURL(/\?page=2/) + await expect(nav.getByLabel('Page 2, current page')).toBeVisible() + await expect(nav.getByLabel('Previous page')).toBeVisible() + }) + + test('page size selector pushes size + resets to page 1', async ({ page }) => { + await page.goto('/?page=3') + const nav = page.getByRole('navigation', { name: 'pagination' }).first() + await nav.getByLabel('per page').selectOption('40') + await expect(page).toHaveURL(/size=40/) + await expect(page).toHaveURL(/page=1/) + }) + + test('links to the app router example', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: 'App Router pagination' }).click() + await expect(page).toHaveURL('/app-example') + await expect(page.getByRole('heading', { name: 'App Router Pagination' })).toBeVisible() + }) +}) From 250b9a4e62123e1a4e1e006abd34d28176762fc8 Mon Sep 17 00:00:00 2001 From: Josh Rose <1677846+JoshTheWanderer@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:18:49 +0200 Subject: [PATCH 4/7] ci: run playwright e2e suite on pull requests Build the lib, install the example and chromium, then run `npm run test:e2e`. Upload the playwright report as an artifact so failures can be inspected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a631e8a..c8857fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,26 @@ jobs: cache: npm - run: npm ci - run: npm test + e2e: + name: 🎭 E2E + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run build + - run: npm --prefix example ci + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report + retention-days: 7 lint: name: 🧹 Lint runs-on: ubuntu-latest From b148f11b647a4d9abd4ebc59a262730d1f531a34 Mon Sep 17 00:00:00 2001 From: Josh Rose <1677846+JoshTheWanderer@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:14:53 +0200 Subject: [PATCH 5/7] refactor: address PR #140 review feedback - src/adapters/app.tsx: simplify searchParams dedup with Set.forEach instead of an ad-hoc seen map. - scripts/add-use-client.js: collapse the two passes into a single prefix-then-write helper so the directive and __esModule marker can no longer interleave incorrectly. The CJS bundle gets both prefixes; the ESM bundle only needs the directive. - playwright + example: run the e2e suite against the production bundle (`next build` + `next start`) when CI is set, matching what users ship. Locally we keep `next dev` for fast iteration. The deploy build now gates `output: 'export'` and basePath/assetPrefix on EXAMPLE_DEPLOY=true so the test build uses plain server output. Co-Authored-By: Claude Opus 4.7 (1M context) --- example/next.config.js | 8 +++---- example/package.json | 3 ++- playwright.config.ts | 8 +++++-- scripts/add-use-client.js | 46 +++++++++++++++------------------------ src/adapters/app.tsx | 7 +++--- 5 files changed, 33 insertions(+), 39 deletions(-) diff --git a/example/next.config.js b/example/next.config.js index 9f80c45..63c1c54 100644 --- a/example/next.config.js +++ b/example/next.config.js @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-var-requires */ const withSourceMaps = require('@zeit/next-source-maps')() -const isProd = (process.env.NODE_ENV || 'production') === 'production' +const isDeploy = process.env.EXAMPLE_DEPLOY === 'true' module.exports = withSourceMaps({ - output: 'export', - assetPrefix: isProd ? '/next-pagination' : undefined, - basePath: isProd ? '/next-pagination' : undefined + output: isDeploy ? 'export' : undefined, + assetPrefix: isDeploy ? '/next-pagination' : undefined, + basePath: isDeploy ? '/next-pagination' : undefined }) diff --git a/example/package.json b/example/package.json index b0db5f1..81a52d9 100644 --- a/example/package.json +++ b/example/package.json @@ -13,7 +13,8 @@ }, "scripts": { "dev": "next", - "build": "next build && rm -rf build && mv out build", + "build": "EXAMPLE_DEPLOY=true next build && rm -rf build && mv out build", + "build:server": "next build", "start": "next start" }, "browserslist": [ diff --git a/playwright.config.ts b/playwright.config.ts index 5b4e1f1..171b80d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,9 +15,13 @@ export default defineConfig({ }, projects: [{ name: 'chromium', use: devices['Desktop Chrome'] }], webServer: { - command: 'npm --prefix example run dev', + // CI runs the production bundle (build + start) so the suite exercises + // the same output users ship; local dev keeps the fast `next dev` loop. + command: process.env.CI + ? 'npm --prefix example run build:server && npm --prefix example run start' + : 'npm --prefix example run dev', url: BASE_URL, reuseExistingServer: !process.env.CI, - timeout: 120_000 + timeout: 180_000 } }) diff --git a/scripts/add-use-client.js b/scripts/add-use-client.js index 83c7763..aa4df50 100644 --- a/scripts/add-use-client.js +++ b/scripts/add-use-client.js @@ -4,34 +4,24 @@ const path = require('path') const DIRECTIVE = "'use client';\n" const ESM_MARKER = "Object.defineProperty(exports, '__esModule', { value: true });\n" -function prepend(file, snippet) { +function patch(file, { addDirective, addEsModule }) { + if (!fs.existsSync(file)) return const contents = fs.readFileSync(file, 'utf8') - if (contents.includes(snippet.trim())) return contents - const next = snippet + contents - fs.writeFileSync(file, next) - return next + const hasDirective = + contents.startsWith("'use client'") || contents.startsWith('"use client"') + const hasEsModule = contents.includes('__esModule') + let prefix = '' + if (addDirective && !hasDirective) prefix += DIRECTIVE + if (addEsModule && !hasEsModule) prefix += ESM_MARKER + if (!prefix) return + fs.writeFileSync(file, prefix + contents) } -for (const rel of ['dist/index.js', 'dist/index.modern.js']) { - const file = path.join(__dirname, '..', rel) - if (!fs.existsSync(file)) continue - let contents = fs.readFileSync(file, 'utf8') - if (!contents.startsWith("'use client'") && !contents.startsWith('"use client"')) { - contents = DIRECTIVE + contents - fs.writeFileSync(file, contents) - } -} - -// CJS output needs the __esModule marker so `import X from 'pkg'` picks up -// `.default` instead of the whole exports object when both default + named -// exports exist. microbundle-crl doesn't emit it. -const cjs = path.join(__dirname, '..', 'dist/index.js') -if (fs.existsSync(cjs)) { - const contents = fs.readFileSync(cjs, 'utf8') - if (!contents.includes("__esModule")) { - const lines = contents.split('\n') - const insertAt = lines[0].startsWith("'use client'") || lines[0].startsWith('"use client"') ? 1 : 0 - lines.splice(insertAt, 0, ESM_MARKER.trim()) - fs.writeFileSync(cjs, lines.join('\n')) - } -} +const root = path.join(__dirname, '..') +// CJS bundle needs both: 'use client' for Next.js to treat the module as a +// client component (microbundle strips the source directive), and the +// __esModule marker so consumers' default-import interop resolves +// `exports.default` instead of the whole exports object. +patch(path.join(root, 'dist/index.js'), { addDirective: true, addEsModule: true }) +// ESM bundle only needs the directive; named/default exports work natively. +patch(path.join(root, 'dist/index.modern.js'), { addDirective: true, addEsModule: false }) diff --git a/src/adapters/app.tsx b/src/adapters/app.tsx index a85360e..ca9a401 100644 --- a/src/adapters/app.tsx +++ b/src/adapters/app.tsx @@ -12,10 +12,9 @@ const AppPagination = (props: PaginationProps) => { const query: Record = {} if (searchParams) { - const seen: Record = {} - searchParams.forEach((_value, key) => { - if (seen[key]) return - seen[key] = true + const keys = new Set() + searchParams.forEach((_value, key) => keys.add(key)) + keys.forEach((key) => { const values = searchParams.getAll(key) query[key] = values.length > 1 ? values : values[0] }) From 5a0cde61fad74d50c90072f67a268a4d556169e1 Mon Sep 17 00:00:00 2001 From: Josh Rose <1677846+JoshTheWanderer@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:30:30 +0200 Subject: [PATCH 6/7] fix: address critical review feedback on app-router PR Bundle split via subpath exports (/app, /pages) so app-router consumers no longer pull in next/router and pages-router consumers can opt out of the 'use client' directive. Root entry keeps both adapters for back-compat. Replace add-use-client.js with postbuild.js: handles multi-entry rename, d.ts flatten, stale cleanup, per-entry directive patching. Throws on missing dist instead of silently no-opping. Clamp invalid ?page=/?size=/total values silently in the shared core (parseInt + Number.isFinite + Math.min, handles string[] query values from the app adapter). Guard null searchParams/pathname in AppPagination with a dev warning, replacing the silent ?? '' fallback that masked missing wrappers. Force CI to re-pack the example's file:.. dep on every run by switching from npm ci to npm install --no-package-lock. Restore unit coverage with src/pagination.test.tsx (core behaviour via stub adapter) and src/adapters/app.test.tsx (mocked next/navigation). Document AppPagination, the entrypoint table, the requirement, and clamping behaviour in the README. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 4 +- README.md | 57 +++-- example/app/app-example/page.jsx | 2 +- example/pages/[dynamic].js | 2 +- example/pages/index.js | 2 +- package-lock.json | 420 +++++++++++++++++++++++++++++++ package.json | 25 +- scripts/add-use-client.js | 27 -- scripts/postbuild.js | 125 +++++++++ src/adapters/app.test.tsx | 98 ++++++++ src/adapters/app.tsx | 28 ++- src/app.tsx | 6 + src/index.test.ts | 7 +- src/pages.tsx | 6 + src/pagination.test.tsx | 274 ++++++++++++++++++++ src/pagination.tsx | 25 +- 16 files changed, 1043 insertions(+), 65 deletions(-) delete mode 100644 scripts/add-use-client.js create mode 100644 scripts/postbuild.js create mode 100644 src/adapters/app.test.tsx create mode 100644 src/app.tsx create mode 100644 src/pages.tsx create mode 100644 src/pagination.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8857fe..829a199 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,9 @@ jobs: cache: npm - run: npm ci - run: npm run build - - run: npm --prefix example ci + # `file:..` deps can be served from npm's cache; `install --no-package-lock` + # forces npm to re-pack the freshly built dist/ on every CI run instead. + - run: npm --prefix example install --no-package-lock - run: npx playwright install --with-deps chromium - run: npm run test:e2e - uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index 2e11b21..6e88ccc 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ TL;DR Just show me the [DEMO](https://etchteam.github.io/next-pagination) - **Responsive.** Works on all devices. - **Themeable.** Make it look however you want. - **Self contained.** There's only one required prop to get going. The rest of the logic is handled for you. -- **Works with Next.** Integrated with the Next.js router. +- **Works with Next.** Supports both the pages router and the app router. ## Install @@ -26,19 +26,15 @@ npm install --save @etchteam/next-pagination ## Usage This component is fairly self contained. You will need to pass the **total number of potential results** in order to calculate the number of pages to show. -```jsx -import React, { Component } from 'react' - -import Pagination from '@etchteam/next-pagination' +The package ships three entrypoints: -class Example extends Component { - render() { - return - } -} -``` +| Import path | Export(s) | Use when | +| ---------------------------------------- | ----------------------------------------------- | ------------------------------ | +| `@etchteam/next-pagination` | default `PagesPagination`, named `PagesPagination` + `AppPagination` | Either router (back-compat). | +| `@etchteam/next-pagination/pages` | default `PagesPagination` | Pages router only — avoids pulling in `next/navigation` and the `'use client'` directive. | +| `@etchteam/next-pagination/app` | default `AppPagination` | App router only — avoids pulling in `next/router` / `next/head`. | -You will need to import the CSS, either in your `_app.js`, or in your Sass build. +You will need to import the CSS, either in your `_app.js`, in your app router root layout, or in your Sass build. ```jsx import '@etchteam/next-pagination/dist/index.css' @@ -49,12 +45,45 @@ When used, the pagination component will reload the same route with added pagina - `page` for the page number the user is on. - `size` for the number of results per page. -e.g. ?page=4&size=20 +e.g. `?page=4&size=20` -The **default page** is 1. The **default size** is 20. +The **default page** is 1. The **default size** is 20. Invalid values in the URL (`?page=abc`, `?page=-1`, `?page=99999`) are clamped to the valid range. You'll need to load the actual data from your API yourself. We're only here for the front-end! +### Pages router + +```jsx +import Pagination from '@etchteam/next-pagination/pages' + +export default function Example() { + return +} +``` + +The pages-router adapter also injects `` SEO hints into `` via `next/head`. + +### App router + +`AppPagination` reads the route via `next/navigation` and must be rendered inside a `` boundary (a Next.js requirement of `useSearchParams`). + +```jsx +'use client' + +import { Suspense } from 'react' +import { AppPagination } from '@etchteam/next-pagination/app' + +export default function Example() { + return ( + + + + ) +} +``` + +`AppPagination` does **not** emit `` SEO hints — `next/head` is pages-router only and Next's metadata APIs are server-only, so prev/next links can't be written from a client component. If you need them, render them server-side from your route's `generateMetadata`. + ## Props | Name | Type | Description | diff --git a/example/app/app-example/page.jsx b/example/app/app-example/page.jsx index 5dd32ea..ff54990 100644 --- a/example/app/app-example/page.jsx +++ b/example/app/app-example/page.jsx @@ -1,7 +1,7 @@ 'use client' import React, { Suspense } from 'react' -import { AppPagination } from '@etchteam/next-pagination/dist' +import { AppPagination } from '@etchteam/next-pagination/app' import theme from '../../styles/theme.module.scss' diff --git a/example/pages/[dynamic].js b/example/pages/[dynamic].js index 9b17522..013935a 100644 --- a/example/pages/[dynamic].js +++ b/example/pages/[dynamic].js @@ -1,5 +1,5 @@ import React from 'react' -import Pagination from '@etchteam/next-pagination/dist' +import Pagination from '@etchteam/next-pagination/pages' export default function Dynamic() { return ( diff --git a/example/pages/index.js b/example/pages/index.js index 62a41f9..c13beb5 100644 --- a/example/pages/index.js +++ b/example/pages/index.js @@ -1,7 +1,7 @@ import React from 'react' import Link from 'next/link' -import Pagination from '@etchteam/next-pagination/dist' +import Pagination from '@etchteam/next-pagination/pages' import theme from '../styles/theme.module.scss' diff --git a/package-lock.json b/package-lock.json index d1d1e09..70dba18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ }, "devDependencies": { "@playwright/test": "1.59.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^14.3.1", "@types/jest": "^27.5.0", "@types/lodash": "^4.14.182", "@types/node": "^17.0.31", @@ -46,6 +48,13 @@ "react": ">=16" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", @@ -4073,6 +4082,155 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true }, + "node_modules/@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/dom/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@testing-library/react": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", + "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^9.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -4091,6 +4249,13 @@ "node": ">=10.13.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.0", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz", @@ -4354,6 +4519,16 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, "node_modules/@types/resolve": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", @@ -7528,6 +7703,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssdb": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.4.1.tgz", @@ -8080,6 +8262,13 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -10953,6 +11142,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/indexes-of": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", @@ -14119,6 +14318,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -14457,6 +14666,16 @@ "node": ">=6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mini-css-extract-plugin": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.2.tgz", @@ -18634,6 +18853,20 @@ "node": ">=6.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -20247,6 +20480,19 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -22632,6 +22878,12 @@ } }, "dependencies": { + "@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true + }, "@ampproject/remapping": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", @@ -25311,6 +25563,112 @@ } } }, + "@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "requires": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "dependencies": { + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + } + } + }, + "@testing-library/react": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", + "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^9.0.0", + "@types/react-dom": "^18.0.0" + } + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -25323,6 +25681,12 @@ "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", "dev": true }, + "@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true + }, "@types/babel__core": { "version": "7.20.0", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz", @@ -25586,6 +25950,13 @@ "csstype": "^3.0.2" } }, + "@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "requires": {} + }, "@types/resolve": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", @@ -27931,6 +28302,12 @@ "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", "dev": true }, + "css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "cssdb": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.4.1.tgz", @@ -28365,6 +28742,12 @@ "esutils": "^2.0.2" } }, + "dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + }, "dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -30537,6 +30920,12 @@ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, "indexes-of": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", @@ -32914,6 +33303,12 @@ "yallist": "^3.0.2" } }, + "lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true + }, "magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -33176,6 +33571,12 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true }, + "min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true + }, "mini-css-extract-plugin": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.2.tgz", @@ -36124,6 +36525,16 @@ "minimatch": "^3.0.5" } }, + "redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "requires": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + } + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -37395,6 +37806,15 @@ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true }, + "strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "requires": { + "min-indent": "^1.0.0" + } + }, "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", diff --git a/package.json b/package.json index 46e21b8..dd89d8e 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,31 @@ "main": "dist/index.js", "module": "dist/index.modern.js", "source": "src/index.tsx", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.modern.js", + "require": "./dist/index.js" + }, + "./app": { + "types": "./dist/app.d.ts", + "import": "./dist/app.modern.js", + "require": "./dist/app.js" + }, + "./pages": { + "types": "./dist/pages.d.ts", + "import": "./dist/pages.modern.js", + "require": "./dist/pages.js" + }, + "./dist/index.css": "./dist/index.css", + "./package.json": "./package.json" + }, "engines": { "node": "^20 || ^22 || ^24" }, "scripts": { - "build": "microbundle-crl --no-compress --format modern,cjs && node scripts/add-use-client.js", - "start": "microbundle-crl watch --no-compress --format modern,cjs", + "build": "rimraf dist && microbundle-crl --no-compress --format modern,cjs -i src/index.tsx -i src/app.tsx -i src/pages.tsx && node scripts/postbuild.js", + "start": "microbundle-crl watch --no-compress --format modern,cjs -i src/index.tsx -i src/app.tsx -i src/pages.tsx", "prepare": "run-s build", "test": "run-s test:types test:unit", "test:unit": "cross-env CI=1 react-scripts test --env=jsdom", @@ -34,6 +53,8 @@ }, "devDependencies": { "@playwright/test": "1.59.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^14.3.1", "@types/jest": "^27.5.0", "@types/lodash": "^4.14.182", "@types/node": "^17.0.31", diff --git a/scripts/add-use-client.js b/scripts/add-use-client.js deleted file mode 100644 index aa4df50..0000000 --- a/scripts/add-use-client.js +++ /dev/null @@ -1,27 +0,0 @@ -const fs = require('fs') -const path = require('path') - -const DIRECTIVE = "'use client';\n" -const ESM_MARKER = "Object.defineProperty(exports, '__esModule', { value: true });\n" - -function patch(file, { addDirective, addEsModule }) { - if (!fs.existsSync(file)) return - const contents = fs.readFileSync(file, 'utf8') - const hasDirective = - contents.startsWith("'use client'") || contents.startsWith('"use client"') - const hasEsModule = contents.includes('__esModule') - let prefix = '' - if (addDirective && !hasDirective) prefix += DIRECTIVE - if (addEsModule && !hasEsModule) prefix += ESM_MARKER - if (!prefix) return - fs.writeFileSync(file, prefix + contents) -} - -const root = path.join(__dirname, '..') -// CJS bundle needs both: 'use client' for Next.js to treat the module as a -// client component (microbundle strips the source directive), and the -// __esModule marker so consumers' default-import interop resolves -// `exports.default` instead of the whole exports object. -patch(path.join(root, 'dist/index.js'), { addDirective: true, addEsModule: true }) -// ESM bundle only needs the directive; named/default exports work natively. -patch(path.join(root, 'dist/index.modern.js'), { addDirective: true, addEsModule: false }) diff --git a/scripts/postbuild.js b/scripts/postbuild.js new file mode 100644 index 0000000..214fc72 --- /dev/null +++ b/scripts/postbuild.js @@ -0,0 +1,125 @@ +const fs = require('fs') +const path = require('path') + +const dist = path.join(__dirname, '..', 'dist') + +const renameMap = [ + ['index.tsx.js', 'index.js'], + ['index.tsx.js.map', 'index.js.map'], + ['index.tsx.modern.js', 'index.modern.js'], + ['index.tsx.modern.js.map', 'index.modern.js.map'], + ['index.tsx.css', 'index.css'], + ['app.tsx.js', 'app.js'], + ['app.tsx.js.map', 'app.js.map'], + ['app.tsx.modern.js', 'app.modern.js'], + ['app.tsx.modern.js.map', 'app.modern.js.map'], + ['pages.tsx.js', 'pages.js'], + ['pages.tsx.js.map', 'pages.js.map'], + ['pages.tsx.modern.js', 'pages.modern.js'], + ['pages.tsx.modern.js.map', 'pages.modern.js.map'] +] + +function renameBundles() { + for (const [from, to] of renameMap) { + const src = path.join(dist, from) + if (!fs.existsSync(src)) continue + fs.renameSync(src, path.join(dist, to)) + } + for (const file of fs.readdirSync(dist)) { + if (!file.endsWith('.map')) continue + const p = path.join(dist, file) + const json = JSON.parse(fs.readFileSync(p, 'utf8')) + if (typeof json.file === 'string') { + json.file = json.file.replace(/\.tsx\./g, '.') + fs.writeFileSync(p, JSON.stringify(json)) + } + } +} + +function copyDeclarations() { + const srcDir = path.join(dist, 'src') + if (!fs.existsSync(srcDir)) return + + const walk = (dir, base) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const from = path.join(dir, entry.name) + const rel = path.join(base, entry.name) + const to = path.join(dist, rel) + if (entry.isDirectory()) { + fs.mkdirSync(to, { recursive: true }) + walk(from, rel) + } else if (entry.name.endsWith('.d.ts')) { + fs.copyFileSync(from, to) + } + } + } + walk(srcDir, '') +} + +function cleanStale() { + const staleEntries = ['src', 'tests', 'playwright.config.d.ts'] + for (const name of staleEntries) { + const p = path.join(dist, name) + if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }) + } + const walk = (dir) => { + if (!fs.existsSync(dir)) return + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(p) + } else if (entry.name.endsWith('.test.d.ts')) { + fs.rmSync(p) + } + } + } + walk(dist) +} + +const DIRECTIVE = "'use client';\n" +const ESM_MARKER = "Object.defineProperty(exports, '__esModule', { value: true });\n" + +function patch(file, { addDirective, addEsModule }) { + if (!fs.existsSync(file)) { + throw new Error( + `postbuild: expected build output at ${file}. ` + + 'Did the microbundle build run? Did its output filename change?' + ) + } + const contents = fs.readFileSync(file, 'utf8') + const hasDirective = + contents.startsWith("'use client'") || contents.startsWith('"use client"') + const hasEsModule = contents.includes('__esModule') + let prefix = '' + if (addDirective && !hasDirective) prefix += DIRECTIVE + if (addEsModule && !hasEsModule) prefix += ESM_MARKER + if (!prefix) { + console.log(`postbuild: ${path.relative(process.cwd(), file)} already patched`) + return + } + fs.writeFileSync(file, prefix + contents) + console.log(`postbuild: patched ${path.relative(process.cwd(), file)}`) +} + +renameBundles() +copyDeclarations() +cleanStale() + +// Root bundle re-exports both adapters. Keep `'use client'` so consumers using +// `import { AppPagination } from '@etchteam/next-pagination'` (the back-compat +// path) get a valid client-component boundary. Pages-only consumers should +// import from `@etchteam/next-pagination/pages` to opt out of the directive. +// CJS bundles need the `__esModule` marker so default-import interop resolves +// `exports.default` instead of the whole exports object. +patch(path.join(dist, 'index.js'), { addDirective: true, addEsModule: true }) +patch(path.join(dist, 'index.modern.js'), { addDirective: true, addEsModule: false }) + +// App-router-only entry: same as root but no cross-router footprint. +patch(path.join(dist, 'app.js'), { addDirective: true, addEsModule: true }) +patch(path.join(dist, 'app.modern.js'), { addDirective: true, addEsModule: false }) + +// Pages-router-only entry: no `'use client'` (pages router has no server/client +// component split). CJS still needs the `__esModule` marker for default-import +// interop. +patch(path.join(dist, 'pages.js'), { addDirective: false, addEsModule: true }) +patch(path.join(dist, 'pages.modern.js'), { addDirective: false, addEsModule: false }) diff --git a/src/adapters/app.test.tsx b/src/adapters/app.test.tsx new file mode 100644 index 0000000..cab1056 --- /dev/null +++ b/src/adapters/app.test.tsx @@ -0,0 +1,98 @@ +import React from 'react' +import { render, screen, fireEvent, act } from '@testing-library/react' +import '@testing-library/jest-dom' + +const mockPush = jest.fn() +let mockSearchParams: URLSearchParams | null +let mockPathname: string | null + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), + usePathname: () => mockPathname, + useSearchParams: () => mockSearchParams +})) + +import AppPagination from './app' + +beforeEach(() => { + mockPush.mockReset() + mockSearchParams = new URLSearchParams() + mockPathname = '/list' +}) + +describe('AppPagination', () => { + it('renders page 1 with default search params', async () => { + await act(async () => { + render() + }) + expect(screen.getByLabelText('Page 1, current page')).toBeInTheDocument() + }) + + it('reads a single-valued page param', async () => { + mockSearchParams = new URLSearchParams('page=2') + await act(async () => { + render() + }) + expect(screen.getByLabelText('Page 2, current page')).toBeInTheDocument() + }) + + it('preserves multi-valued query params (does not collapse to last value)', async () => { + mockSearchParams = new URLSearchParams('tag=a&tag=b&page=2') + await act(async () => { + render() + }) + expect(screen.getByLabelText('Page 2, current page')).toBeInTheDocument() + }) + + it('clamps invalid page values silently', async () => { + mockSearchParams = new URLSearchParams('page=abc') + await act(async () => { + render() + }) + expect(screen.getByLabelText('Page 1, current page')).toBeInTheDocument() + }) + + it('calls router.push with the expected URL when changing page size', async () => { + mockSearchParams = new URLSearchParams('page=3') + await act(async () => { + render() + }) + const select = screen.getByLabelText('per page') as HTMLSelectElement + fireEvent.change(select, { target: { value: '40' } }) + expect(mockPush).toHaveBeenCalledTimes(1) + const url = mockPush.mock.calls[0][0] as string + expect(url).toContain('size=40') + expect(url).toContain('page=1') + expect(url.startsWith('/list?')).toBe(true) + }) + + describe('null context', () => { + let warnSpy: jest.SpyInstance + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('renders nothing and warns when useSearchParams returns null', async () => { + mockSearchParams = null + const { container } = await act(async () => + render() + ) + expect(container.firstChild).toBeNull() + expect(warnSpy).toHaveBeenCalled() + }) + + it('renders nothing and warns when usePathname returns null', async () => { + mockPathname = null + const { container } = await act(async () => + render() + ) + expect(container.firstChild).toBeNull() + expect(warnSpy).toHaveBeenCalled() + }) + }) +}) diff --git a/src/adapters/app.tsx b/src/adapters/app.tsx index ca9a401..e249958 100644 --- a/src/adapters/app.tsx +++ b/src/adapters/app.tsx @@ -10,21 +10,31 @@ const AppPagination = (props: PaginationProps) => { const pathname = usePathname() const searchParams = useSearchParams() - const query: Record = {} - if (searchParams) { - const keys = new Set() - searchParams.forEach((_value, key) => keys.add(key)) - keys.forEach((key) => { - const values = searchParams.getAll(key) - query[key] = values.length > 1 ? values : values[0] - }) + if (searchParams === null || pathname === null) { + if (process.env.NODE_ENV !== 'production') { + console.warn( + '[next-pagination] AppPagination rendered without an app-router ' + + 'search-params/pathname context. Wrap it in . ' + + 'See https://nextjs.org/docs/app/api-reference/functions/use-search-params' + ) + } + return null } + const query: Record = {} + const seen = new Set() + searchParams.forEach((_value, key) => { + if (seen.has(key)) return + seen.add(key) + const values = searchParams.getAll(key) + query[key] = values.length > 1 ? values : values[0] + }) + return ( { router.push(url) diff --git a/src/app.tsx b/src/app.tsx new file mode 100644 index 0000000..0cb612b --- /dev/null +++ b/src/app.tsx @@ -0,0 +1,6 @@ +import AppPagination from './adapters/app' + +export { default as AppPagination } from './adapters/app' +export type { PaginationProps } from './pagination' + +export default AppPagination diff --git a/src/index.test.ts b/src/index.test.ts index e622bfa..ac5526b 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -3,14 +3,11 @@ import { getSizes } from './lib/sizes' import assert from 'assert' describe('Pagination', () => { - it('is truthy', () => { - expect(Pagination).toBeTruthy() - }) it('default export is PagesPagination', () => { expect(Pagination).toBe(PagesPagination) }) - it('exports AppPagination', () => { - expect(AppPagination).toBeTruthy() + it('exposes AppPagination as a distinct named export', () => { + expect(AppPagination).not.toBe(PagesPagination) }) }) diff --git a/src/pages.tsx b/src/pages.tsx new file mode 100644 index 0000000..23d202e --- /dev/null +++ b/src/pages.tsx @@ -0,0 +1,6 @@ +import PagesPagination from './adapters/pages' + +export { default as PagesPagination } from './adapters/pages' +export type { PaginationProps } from './pagination' + +export default PagesPagination diff --git a/src/pagination.test.tsx b/src/pagination.test.tsx new file mode 100644 index 0000000..51b6f8b --- /dev/null +++ b/src/pagination.test.tsx @@ -0,0 +1,274 @@ +import React from 'react' +import { render, screen, fireEvent, act } from '@testing-library/react' +import '@testing-library/jest-dom' + +import Pagination, { RoutingAdapter } from './pagination' + +const stubAdapter = (overrides: Partial = {}): RoutingAdapter => ({ + pathname: '/list', + query: {}, + push: jest.fn(), + ...overrides +}) + +describe('Pagination core', () => { + it('renders the navigation landmark with both adapters absent before mount, then visible', async () => { + await act(async () => { + render() + }) + expect(screen.getByRole('navigation', { name: 'pagination' })).toBeInTheDocument() + }) + + describe('page-1 boundary', () => { + it('disables the previous-page link', async () => { + await act(async () => { + render() + }) + expect(screen.getByLabelText('No previous page available')).toBeInTheDocument() + expect(screen.queryByLabelText('Previous page')).not.toBeInTheDocument() + }) + + it('marks page 1 as the current page', async () => { + await act(async () => { + render() + }) + expect(screen.getByLabelText('Page 1, current page')).toBeInTheDocument() + }) + }) + + describe('last-page boundary', () => { + it('disables the next-page link when on the last page', async () => { + await act(async () => { + render( + + ) + }) + expect(screen.getByLabelText('No next page available')).toBeInTheDocument() + expect(screen.queryByLabelText('Next page')).not.toBeInTheDocument() + }) + }) + + describe('invalid ?page= values are clamped silently', () => { + it('treats non-numeric page as page 1', async () => { + await act(async () => { + render( + + ) + }) + expect(screen.getByLabelText('Page 1, current page')).toBeInTheDocument() + expect(screen.getByLabelText('No previous page available')).toBeInTheDocument() + }) + + it('clamps page beyond range to the last page', async () => { + await act(async () => { + render( + + ) + }) + expect(screen.getByLabelText('Page 5, current page')).toBeInTheDocument() + expect(screen.getByLabelText('No next page available')).toBeInTheDocument() + }) + + it('treats negative page as page 1', async () => { + await act(async () => { + render( + + ) + }) + expect(screen.getByLabelText('Page 1, current page')).toBeInTheDocument() + }) + + it('treats page=0 as page 1', async () => { + await act(async () => { + render( + + ) + }) + expect(screen.getByLabelText('Page 1, current page')).toBeInTheDocument() + }) + + it('uses the first value of a multi-valued page param', async () => { + await act(async () => { + render( + + ) + }) + expect(screen.getByLabelText('Page 2, current page')).toBeInTheDocument() + }) + }) + + describe('invalid ?size= values fall back to default', () => { + it('non-numeric size falls back to first valid size', async () => { + await act(async () => { + render( + + ) + }) + const select = screen.getByLabelText('per page') as HTMLSelectElement + expect(select.value).toBe('20') + }) + + it('size not in sizes list falls back to first valid size', async () => { + await act(async () => { + render( + + ) + }) + const select = screen.getByLabelText('per page') as HTMLSelectElement + expect(select.value).toBe('20') + }) + }) + + describe('invalid total renders an empty pagination strip', () => { + it('treats negative total as zero', async () => { + await act(async () => { + render() + }) + expect(screen.getByLabelText('No previous page available')).toBeInTheDocument() + expect(screen.getByLabelText('No next page available')).toBeInTheDocument() + }) + + it('treats undefined total as zero', async () => { + await act(async () => { + render( + + ) + }) + expect(screen.getByLabelText('No previous page available')).toBeInTheDocument() + expect(screen.getByLabelText('No next page available')).toBeInTheDocument() + }) + }) + + describe('label overrides', () => { + it('renders perPageText and setPageSizeText overrides', async () => { + await act(async () => { + render( + + ) + }) + expect(screen.getByText('por página')).toBeInTheDocument() + expect(screen.getByText('Establecer')).toBeInTheDocument() + }) + }) + + describe('custom linkComponent', () => { + it('receives href, prefetch, passHref, legacyBehavior, plus linkProps spread', async () => { + const seen: Record[] = [] + const Custom = (props: Record) => { + seen.push(props) + return <>{props.children as React.ReactNode} + } + await act(async () => { + render( + + ) + }) + const sample = seen.find((p) => p.href === '?page=1') + expect(sample).toBeDefined() + expect(sample).toMatchObject({ + prefetch: false, + passHref: true, + legacyBehavior: true, + 'data-extra': 'yes' + }) + }) + }) + + describe('page-size onChange', () => { + it('calls routing.push with page=1 and the new size', async () => { + const push = jest.fn() + await act(async () => { + render( + + ) + }) + const select = screen.getByLabelText('per page') as HTMLSelectElement + fireEvent.change(select, { target: { value: '40' } }) + expect(push).toHaveBeenCalledTimes(1) + const url = push.mock.calls[0][0] as string + expect(url).toContain('size=40') + expect(url).toContain('page=1') + expect(url.startsWith('/list?')).toBe(true) + }) + }) + + describe('SEO Head injection (pages-router-shaped adapter)', () => { + it('renders on page 2 via adapter Head', async () => { + const captured: React.ReactNode[] = [] + const Head: RoutingAdapter['Head'] = ({ children }) => { + captured.push(children) + return null + } + await act(async () => { + render( + + ) + }) + const flat = captured.flat() as React.ReactElement[] + const prev = flat.find( + (el) => React.isValidElement(el) && (el.props as { rel?: string }).rel === 'prev' + ) + expect(prev).toBeDefined() + }) + + it('does not render prev link on page 1', async () => { + const captured: React.ReactNode[] = [] + const Head: RoutingAdapter['Head'] = ({ children }) => { + captured.push(children) + return null + } + await act(async () => { + render() + }) + const flat = captured.flat() as React.ReactElement[] + const prev = flat.find( + (el) => React.isValidElement(el) && (el.props as { rel?: string }).rel === 'prev' + ) + expect(prev).toBeUndefined() + }) + }) +}) diff --git a/src/pagination.tsx b/src/pagination.tsx index 70592a8..36aa294 100644 --- a/src/pagination.tsx +++ b/src/pagination.tsx @@ -81,11 +81,28 @@ const Pagination = ({ if (!mounted) return null const query = pickBy({ ...routing.query }, (q) => !isEmpty(q)) - const currentPage = Number(query.page || 1) const cSizes = getSizes(sizes) - const pageSize = Number(query.size) || cSizes[0] - const isLastPage = currentPage * pageSize >= total - const pageNumbers = getPageNumbers({ currentPage, pageSize, total }) + const safeTotal = + Number.isFinite(total) && total >= 0 ? Math.floor(total) : 0 + + const rawSize = Array.isArray(query.size) ? query.size[0] : query.size + const parsedSize = Number.parseInt(rawSize ?? '', 10) + const pageSize = cSizes.includes(parsedSize) ? parsedSize : cSizes[0] + + const lastPage = Math.max(1, Math.ceil(safeTotal / pageSize)) + const rawPage = Array.isArray(query.page) ? query.page[0] : query.page + const parsedPage = Number.parseInt(rawPage ?? '', 10) + const currentPage = + Number.isFinite(parsedPage) && parsedPage >= 1 + ? Math.min(parsedPage, lastPage) + : 1 + + const isLastPage = currentPage * pageSize >= safeTotal + const pageNumbers = getPageNumbers({ + currentPage, + pageSize, + total: safeTotal + }) const path = routing.pathname From d23af9aec0f4460666b10a623479f1fca52c0b64 Mon Sep 17 00:00:00 2001 From: Josh Rose <1677846+JoshTheWanderer@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:32:55 +0200 Subject: [PATCH 7/7] ci: also trigger on PRs targeting modernize The long-running modernize branch is the base for #140 and future modernization PRs, so CI needs to fire on PRs into it as well as main. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 829a199..7fb201b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: 👮 CI on: pull_request: - branches: [main] + branches: [main, modernize] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true