diff --git a/.gitignore b/.gitignore index 7f7e15bc..918e298f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ package-lock.json .next/ .turbo/ .vercel +.tsbuildinfo diff --git a/errors/NUQS-404.md b/errors/NUQS-404.md new file mode 100644 index 00000000..26ed0117 --- /dev/null +++ b/errors/NUQS-404.md @@ -0,0 +1,46 @@ +# `nuqs` requires an adapter to work with your framework + +## Probable cause + +You haven't wrapped the components calling `useQueryState(s)` with +an adapter. + +Adapters are based on React Context, and provide nuqs hooks with +the interfaces to work with your framework. + +## Possible solutions + +Follow the setup instructions to import and wrap your application +using a suitable adapter. + +Example, for Next.js (app router) + +```tsx +// src/app/layout.tsx +import React from 'react' +import { NuqsAdapter } from 'nuqs/adapters/next' + +export default function RootLayout({ + children +}: { + children: React.ReactNode +}) { + return ( + + + {children} + + + ) +} +``` + +### Test adapter + +If you encounter this error outside of the browser, like in a test +runner, you may use the test adapter from `nuqs/adapters/test` +to mock the context and access setup/assertion testing facilities. + +```tsx + +``` diff --git a/packages/docs/content/docs/options.mdx b/packages/docs/content/docs/options.mdx index b7bf1dee..32070e08 100644 --- a/packages/docs/content/docs/options.mdx +++ b/packages/docs/content/docs/options.mdx @@ -130,7 +130,7 @@ to get loading states while the server is re-rendering server components with the updated URL. Pass in the `startTransition` function from `useTransition` to the options -to enable this behaviour _(this will set `shallow: false{:ts}` automatically for you)_: +to enable this behaviour: In `nuqs@2.0.0`, passing `startTransition` will no longer automatically set `shallow: false{:ts}`. @@ -148,7 +148,7 @@ function ClientComponent({ data }) { const [query, setQuery] = useQueryState( 'query', // 2. Pass the `startTransition` as an option: - parseAsString().withOptions({ startTransition }) + parseAsString().withOptions({ startTransition, shallow: false }) ) // 3. `isLoading` will be true while the server is re-rendering // and streaming RSC payloads, when the query is updated via `setQuery`. @@ -161,6 +161,13 @@ function ClientComponent({ data }) { } ``` + + In `nuqs@1.x.x`, passing `startTransition` as an option automatically sets + `shallow: false{:ts}`. + + This is no longer the case in `nuqs@>=2.0.0`: you'll need to set it explicitly. + + ## Clear on default By default, when the state is set to the default value, the search parameter is diff --git a/packages/docs/src/app/playground/_demos/throttling/client.tsx b/packages/docs/src/app/playground/_demos/throttling/client.tsx index fdefdc47..6a0fb557 100644 --- a/packages/docs/src/app/playground/_demos/throttling/client.tsx +++ b/packages/docs/src/app/playground/_demos/throttling/client.tsx @@ -13,6 +13,7 @@ export function Client() { const [serverDelay, setServerDelay] = useQueryState( 'serverDelay', delayParser.withOptions({ + shallow: false, startTransition: startDelayTransition }) ) @@ -23,6 +24,7 @@ export function Client() { const [q, setQ] = useQueryState( 'q', queryParser.withOptions({ + shallow: false, throttleMs: clientDelay, startTransition: startQueryTransition }) diff --git a/packages/docs/src/components/ui/toggle-group.tsx b/packages/docs/src/components/ui/toggle-group.tsx index 0a50680d..9e4d263c 100644 --- a/packages/docs/src/components/ui/toggle-group.tsx +++ b/packages/docs/src/components/ui/toggle-group.tsx @@ -1,17 +1,17 @@ -"use client" +'use client' -import * as React from "react" -import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" -import { VariantProps } from "class-variance-authority" +import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group' +import { VariantProps } from 'class-variance-authority' +import * as React from 'react' -import { cn } from "@/src/lib/utils" -import { toggleVariants } from "@/src/components/ui/toggle" +import { toggleVariants } from '@/src/components/ui/toggle' +import { cn } from '@/src/lib/utils' const ToggleGroupContext = React.createContext< VariantProps >({ - size: "default", - variant: "default", + size: 'default', + variant: 'default' }) const ToggleGroup = React.forwardRef< @@ -21,11 +21,11 @@ const ToggleGroup = React.forwardRef< >(({ className, variant, size, children, ...props }, ref) => ( - {children} + <>{children} )) @@ -45,7 +45,7 @@ const ToggleGroupItem = React.forwardRef< className={cn( toggleVariants({ variant: context.variant || variant, - size: context.size || size, + size: context.size || size }), className )} diff --git a/packages/e2e/src/app/app/transitions/client.tsx b/packages/e2e/src/app/app/transitions/client.tsx index 7212c76a..817bc136 100644 --- a/packages/e2e/src/app/app/transitions/client.tsx +++ b/packages/e2e/src/app/app/transitions/client.tsx @@ -1,14 +1,17 @@ 'use client' import { parseAsInteger, useQueryState } from 'nuqs' -import React from 'react' +import { useTransition } from 'react' import { HydrationMarker } from '../../../components/hydration-marker' export function Client() { - const [isLoading, startTransition] = React.useTransition() + const [isLoading, startTransition] = useTransition() const [counter, setCounter] = useQueryState( 'counter', - parseAsInteger.withDefault(0).withOptions({ startTransition }) + parseAsInteger.withDefault(0).withOptions({ + shallow: false, + startTransition + }) ) return ( <> diff --git a/packages/e2e/src/app/layout.tsx b/packages/e2e/src/app/layout.tsx index 895150ad..26dccf06 100644 --- a/packages/e2e/src/app/layout.tsx +++ b/packages/e2e/src/app/layout.tsx @@ -1,3 +1,4 @@ +import { NuqsAdapter } from 'nuqs/adapters/next' import React, { Suspense } from 'react' import { HydrationMarker } from '../components/hydration-marker' @@ -16,7 +17,7 @@ export default function RootLayout({ - {children} + {children} ) diff --git a/packages/e2e/src/pages/_app.tsx b/packages/e2e/src/pages/_app.tsx new file mode 100644 index 00000000..e10f3834 --- /dev/null +++ b/packages/e2e/src/pages/_app.tsx @@ -0,0 +1,10 @@ +import type { AppProps } from 'next/app' +import { NuqsAdapter } from 'nuqs/adapters/next' + +export default function MyApp({ Component, pageProps }: AppProps) { + return ( + + + + ) +} diff --git a/packages/nuqs/adapters/next.d.ts b/packages/nuqs/adapters/next.d.ts new file mode 100644 index 00000000..ec103aab --- /dev/null +++ b/packages/nuqs/adapters/next.d.ts @@ -0,0 +1,7 @@ +// This file is needed for projects that have `moduleResolution` set to `node` +// in their tsconfig.json to be able to `import {} from 'nuqs/adpaters/next'`. +// Other module resolutions strategies will look for the `exports` in `package.json`, +// but with `node`, TypeScript will look for a .d.ts file with that name at the +// root of the package. + +export * from '../dist/adapters/next' diff --git a/packages/nuqs/adapters/react.d.ts b/packages/nuqs/adapters/react.d.ts new file mode 100644 index 00000000..1c9f9da1 --- /dev/null +++ b/packages/nuqs/adapters/react.d.ts @@ -0,0 +1,7 @@ +// This file is needed for projects that have `moduleResolution` set to `node` +// in their tsconfig.json to be able to `import {} from 'nuqs/adapters/react'`. +// Other module resolutions strategies will look for the `exports` in `package.json`, +// but with `node`, TypeScript will look for a .d.ts file with that name at the +// root of the package. + +export * from '../dist/adapters/react' diff --git a/packages/nuqs/adapters/testing.d.ts b/packages/nuqs/adapters/testing.d.ts new file mode 100644 index 00000000..5e7194ce --- /dev/null +++ b/packages/nuqs/adapters/testing.d.ts @@ -0,0 +1,7 @@ +// This file is needed for projects that have `moduleResolution` set to `node` +// in their tsconfig.json to be able to `import {} from 'nuqs/adpaters/testing'`. +// Other module resolutions strategies will look for the `exports` in `package.json`, +// but with `node`, TypeScript will look for a .d.ts file with that name at the +// root of the package. + +export * from '../dist/adapters/testing' diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index 55210491..889bb668 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -28,8 +28,10 @@ }, "files": [ "dist/", - "parsers.d.ts", - "server.d.ts" + "server.d.ts", + "adapters/react.d.ts", + "adapters/next.d.ts", + "adapters/testing.d.ts" ], "type": "module", "sideEffects": false, @@ -43,11 +45,24 @@ "./server": { "types": "./dist/server.d.ts", "import": "./dist/server.js" + }, + "./adapters/react": { + "types": "./dist/adapters/react.d.ts", + "import": "./dist/adapters/react.js" + }, + "./adapters/next": { + "types": "./dist/adapters/next.d.ts", + "import": "./dist/adapters/next.js" + }, + "./adapters/testing": { + "types": "./dist/adapters/testing.d.ts", + "import": "./dist/adapters/testing.js" } }, "scripts": { - "dev": "tsup --watch --external=react", - "build": "tsup --clean --external=react", + "dev": "tsup --watch", + "prebuild": "rm -rf dist", + "build": "tsup", "postbuild": "size-limit --json > size.json", "test": "pnpm run --parallel --stream '/^test:/'", "test:types": "tsd", @@ -56,20 +71,23 @@ "prepack": "./scripts/prepack.sh" }, "peerDependencies": { - "next": ">= 14.1.2", "react": ">= 18.2.0" }, "dependencies": { "mitt": "^3.0.1" }, + "optionalDependencies": { + "next": ">= 14.1.2" + }, "devDependencies": { + "@microsoft/api-extractor": "^7.47.9", "@size-limit/preset-small-lib": "^11.1.6", "@types/node": "^22.7.5", - "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", + "@types/react": "^18.3.11", "next": "14.2.15", - "react": "catalog:react19rc", "react-dom": "catalog:react19rc", + "react": "catalog:react19rc", "size-limit": "^11.1.6", "tsafe": "^1.7.5", "tsd": "^0.31.2", @@ -86,7 +104,8 @@ "path": "dist/index.js", "limit": "5 kB", "ignore": [ - "react" + "react", + "next" ] }, { @@ -94,7 +113,8 @@ "path": "dist/server.js", "limit": "2 kB", "ignore": [ - "react" + "react", + "next" ] } ] diff --git a/packages/nuqs/src/adapters/defs.ts b/packages/nuqs/src/adapters/defs.ts new file mode 100644 index 00000000..4986ead9 --- /dev/null +++ b/packages/nuqs/src/adapters/defs.ts @@ -0,0 +1,16 @@ +import type { Options } from '../defs' + +export type AdapterOptions = Pick + +export type UpdateUrlFunction = ( + search: URLSearchParams, + options: Required +) => void + +export type UseAdapterHook = () => AdapterInterface + +export type AdapterInterface = { + searchParams: URLSearchParams + updateUrl: UpdateUrlFunction + rateLimitFactor?: number +} diff --git a/packages/nuqs/src/adapters/internal.context.ts b/packages/nuqs/src/adapters/internal.context.ts new file mode 100644 index 00000000..c30c8555 --- /dev/null +++ b/packages/nuqs/src/adapters/internal.context.ts @@ -0,0 +1,31 @@ +import { createContext, createElement, useContext, type ReactNode } from 'react' +import { error } from '../errors' +import type { UseAdapterHook } from './defs' + +export type AdapterContext = { + useAdapter: UseAdapterHook +} + +export const context = createContext({ + useAdapter() { + throw new Error(error(404)) + } +}) +context.displayName = 'NuqsAdapterContext' + +export function createAdapterProvider(useAdapter: UseAdapterHook) { + return ({ children, ...props }: { children: ReactNode }) => + createElement( + context.Provider, + { ...props, value: { useAdapter } }, + children + ) +} + +export function useAdapter() { + const value = useContext(context) + if (!('useAdapter' in value)) { + throw new Error(error(404)) + } + return value.useAdapter() +} diff --git a/packages/nuqs/src/adapters/next.ts b/packages/nuqs/src/adapters/next.ts new file mode 100644 index 00000000..487ac548 --- /dev/null +++ b/packages/nuqs/src/adapters/next.ts @@ -0,0 +1,86 @@ +import { useRouter, useSearchParams } from 'next/navigation' +import type { NextRouter } from 'next/router' +import { useCallback } from 'react' +import { debug } from '../debug' +import { renderQueryString } from '../url-encoding' +import type { AdapterInterface, UpdateUrlFunction } from './defs' +import { createAdapterProvider } from './internal.context' + +declare global { + interface Window { + next?: { + version: string + router?: NextRouter & { + state: { + asPath: string + } + } + } + } +} + +function useNuqsNextAdapter(): AdapterInterface { + const router = useRouter() + const searchParams = useSearchParams() + const updateUrl: UpdateUrlFunction = useCallback((search, options) => { + // While the Next.js team doesn't recommend using internals like this, + // we need access to the pages router here to let it know about non-shallow + // updates, as going through the window.history API directly will make it + // miss pushed history updates. + // The router adapter imported from next/navigation also doesn't support + // passing an asPath, causing issues in dynamic routes in the pages router. + const nextRouter = window.next?.router + const isPagesRouter = typeof nextRouter?.state?.asPath === 'string' + if (isPagesRouter) { + const url = renderURL(nextRouter.state.asPath.split('?')[0] ?? '', search) + debug('[nuqs queue (pages)] Updating url: %s', url) + const method = + options.history === 'push' ? nextRouter.push : nextRouter.replace + method.call(nextRouter, url, url, { + scroll: options.scroll, + shallow: options.shallow + }) + } else { + // App router + const url = renderURL(location.origin + location.pathname, search) + debug('[nuqs queue (app)] Updating url: %s', url) + // First, update the URL locally without triggering a network request, + // this allows keeping a reactive URL if the network is slow. + const updateMethod = + options.history === 'push' ? history.pushState : history.replaceState + updateMethod.call( + history, + // In next@14.1.0, useSearchParams becomes reactive to shallow updates, + // but only if passing `null` as the history state. + null, + '', + url + ) + if (options.scroll) { + window.scrollTo(0, 0) + } + if (!options.shallow) { + // Call the Next.js router to perform a network request + // and re-render server components. + router.replace(url, { + scroll: false + }) + } + } + }, []) + return { + searchParams, + updateUrl, + // See: https://github.com/47ng/nuqs/issues/603#issuecomment-2317057128 + rateLimitFactor: 2 + } +} + +export const NuqsAdapter = createAdapterProvider(useNuqsNextAdapter) + +function renderURL(base: string, search: URLSearchParams) { + const hashlessBase = base.split('#')[0] ?? '' + const query = renderQueryString(search) + const hash = location.hash + return hashlessBase + query + hash +} diff --git a/packages/nuqs/src/adapters/react.ts b/packages/nuqs/src/adapters/react.ts new file mode 100644 index 00000000..4b530b30 --- /dev/null +++ b/packages/nuqs/src/adapters/react.ts @@ -0,0 +1,44 @@ +import mitt from 'mitt' +import { useEffect, useState } from 'react' +import type { AdapterOptions } from './defs' +import { createAdapterProvider } from './internal.context' + +const emitter = mitt<{ update: URLSearchParams }>() + +function updateUrl(search: URLSearchParams, options: AdapterOptions) { + const url = new URL(location.href) + url.search = search.toString() + const href = url.toString() + const method = + options.history === 'push' ? history.pushState : history.replaceState + method.call(history, history.state, '', href) + emitter.emit('update', search) +} + +function useNuqsReactAdapter() { + const [searchParams, setSearchParams] = useState(() => { + if (typeof location === 'undefined') { + return new URLSearchParams() + } + return new URLSearchParams(location.search) + }) + useEffect(() => { + // Popstate event is only fired when the user navigates + // via the browser's back/forward buttons. + const onPopState = () => { + setSearchParams(new URLSearchParams(location.search)) + } + emitter.on('update', setSearchParams) + window.addEventListener('popstate', onPopState) + return () => { + emitter.off('update', setSearchParams) + window.removeEventListener('popstate', onPopState) + } + }, []) + return { + searchParams, + updateUrl + } +} + +export const NuqsAdapter = createAdapterProvider(useNuqsReactAdapter) diff --git a/packages/nuqs/src/adapters/testing.ts b/packages/nuqs/src/adapters/testing.ts new file mode 100644 index 00000000..edd4c2ea --- /dev/null +++ b/packages/nuqs/src/adapters/testing.ts @@ -0,0 +1,36 @@ +import { createElement, type ReactNode } from 'react' +import { renderQueryString } from '../url-encoding' +import type { AdapterInterface, AdapterOptions } from './defs' +import { context } from './internal.context' + +export type UrlUpdateEvent = { + searchParams: URLSearchParams + queryString: string + options: Required +} + +type TestingAdapterProps = { + searchParams?: string | Record | URLSearchParams + onUrlUpdate?: (event: UrlUpdateEvent) => void + rateLimitFactor?: number + children: ReactNode +} + +export function NuqsTestingAdapter(props: TestingAdapterProps) { + const useAdapter = (): AdapterInterface => ({ + searchParams: new URLSearchParams(props.searchParams), + updateUrl(search, options) { + props.onUrlUpdate?.({ + searchParams: search, + queryString: renderQueryString(search), + options + }) + }, + rateLimitFactor: props.rateLimitFactor ?? 0 + }) + return createElement( + context.Provider, + { value: { useAdapter } }, + props.children + ) +} diff --git a/packages/nuqs/src/cache.ts b/packages/nuqs/src/cache.ts index a54eab64..d34183db 100644 --- a/packages/nuqs/src/cache.ts +++ b/packages/nuqs/src/cache.ts @@ -1,3 +1,4 @@ +// @ts-ignore import { cache } from 'react' import { error } from './errors' import type { ParserBuilder, inferParserType } from './parsers' diff --git a/packages/nuqs/src/defs.ts b/packages/nuqs/src/defs.ts index 7e46cb06..1afdf32e 100644 --- a/packages/nuqs/src/defs.ts +++ b/packages/nuqs/src/defs.ts @@ -1,18 +1,8 @@ -import { useRouter } from 'next/navigation.js' // https://github.com/47ng/nuqs/discussions/352 import type { TransitionStartFunction } from 'react' -export type Router = ReturnType - export type HistoryOptions = 'replace' | 'push' -// prettier-ignore -type StartTransition = T extends false - ? TransitionStartFunction - : T extends true - ? never - : TransitionStartFunction - -export type Options = { +export type Options = { /** * How the query update affects page history * @@ -37,7 +27,7 @@ export type Options = { * Setting it to `false` will trigger a network request to the server with * the updated querystring. */ - shallow?: Extract + shallow?: boolean /** * Maximum amount of time (ms) to wait between updates of the URL query string. @@ -51,15 +41,14 @@ export type Options = { throttleMs?: number /** - * Opt-in to observing Server Component loading states when doing - * non-shallow updates by passing a `startTransition` from the + * In RSC frameworks, opt-in to observing Server Component loading states when + * doing non-shallow updates by passing a `startTransition` from the * `React.useTransition()` hook. * - * Using this will set the `shallow` setting to `false` automatically. - * As a result, you can't set both `shallow: true` and `startTransition` - * in the same Options object. + * In other frameworks, navigation events triggered by a query update can also + * be wrapped in a transition this way (e.g. `React.startTransition`). */ - startTransition?: StartTransition + startTransition?: TransitionStartFunction /** * Clear the key-value pair from the URL query string when setting the state diff --git a/packages/nuqs/src/errors.ts b/packages/nuqs/src/errors.ts index 155cb2de..946e5494 100644 --- a/packages/nuqs/src/errors.ts +++ b/packages/nuqs/src/errors.ts @@ -1,4 +1,5 @@ export const errors = { + 404: 'nuqs requires an adapter to work with your framework.', 409: 'Multiple versions of the library are loaded. This may lead to unexpected behavior. Currently using `%s`, but `%s` was about to load on top.', 429: 'URL update rate-limited by the browser. Consider increasing `throttleMs` for key(s) `%s`. %O', 500: "Empty search params cache. Search params can't be accessed in Layouts.", diff --git a/packages/nuqs/src/index.ts b/packages/nuqs/src/index.ts index f04640d6..e6efb707 100644 --- a/packages/nuqs/src/index.ts +++ b/packages/nuqs/src/index.ts @@ -1,5 +1,3 @@ -'use client' - export type { HistoryOptions, Options } from './defs' export * from './parsers' export { createSerializer } from './serializer' diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index 6a88305b..84a8e78e 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -38,7 +38,7 @@ export type ParserBuilder = Required> & * Note that you can override those options in individual calls to the * state updater function. */ - withOptions(this: This, options: Options): This + withOptions(this: This, options: Options): This /** * Specifying a default value makes the hook state non-nullable when the diff --git a/packages/nuqs/src/tests/parsers.test-d.ts b/packages/nuqs/src/tests/parsers.test-d.ts index 2ba2a16f..9087d45e 100644 --- a/packages/nuqs/src/tests/parsers.test-d.ts +++ b/packages/nuqs/src/tests/parsers.test-d.ts @@ -1,6 +1,5 @@ -import React from 'react' import { assert, type Equals } from 'tsafe' -import { expectError, expectType } from 'tsd' +import { expectType } from 'tsd' import { parseAsInteger, parseAsString, type inferParserType } from '../../dist' { @@ -30,53 +29,6 @@ import { parseAsInteger, parseAsString, type inferParserType } from '../../dist' expectType(p.parseServerSide(undefined)) } -// Shallow / startTransition interaction -{ - type RSTF = React.TransitionStartFunction - type MaybeBool = boolean | undefined - type MaybeRSTF = RSTF | undefined - - expectType(parseAsString.withOptions({}).shallow) - expectType(parseAsString.withOptions({}).startTransition) - expectType(parseAsString.withOptions({ shallow: true }).shallow) - expectType( - parseAsString.withOptions({ shallow: true }).startTransition - ) - expectType(parseAsString.withOptions({ shallow: false }).shallow) - expectType( - parseAsString.withOptions({ shallow: false }).startTransition - ) - expectType( - parseAsString.withOptions({ startTransition: () => {} }).shallow - ) - expectType( - parseAsString.withOptions({ startTransition: () => {} }).startTransition - ) - // Allowed - parseAsString.withOptions({ - shallow: false, - startTransition: () => {} - }) - // Not allowed - expectError(() => { - parseAsString.withOptions({ - shallow: true, - startTransition: () => {} - }) - }) - expectError(() => { - parseAsString.withOptions({ - shallow: {} - }) - }) - - expectError(() => { - parseAsString.withOptions({ - startTransition: {} - }) - }) -} - // Type inference assert, string | null>>() const withDefault = parseAsString.withDefault('') diff --git a/packages/nuqs/src/tests/useQueryState.test-d.ts b/packages/nuqs/src/tests/useQueryState.test-d.ts index 6bda0991..1fb9b5f3 100644 --- a/packages/nuqs/src/tests/useQueryState.test-d.ts +++ b/packages/nuqs/src/tests/useQueryState.test-d.ts @@ -290,17 +290,3 @@ import { expectError(() => setFoo(() => undefined)) expectError(() => setBar(() => undefined)) } - -// Shallow & startTransition interaction -{ - const [, set] = useQueryState('foo') - set('ok', { shallow: true }) - set('ok', { shallow: false }) - set('ok', { startTransition: () => {} }) - expectError(() => { - set('nope', { - shallow: true, - startTransition: () => {} - }) - }) -} diff --git a/packages/nuqs/src/update-queue.ts b/packages/nuqs/src/update-queue.ts index 6f22bf7f..6c13abc8 100644 --- a/packages/nuqs/src/update-queue.ts +++ b/packages/nuqs/src/update-queue.ts @@ -1,8 +1,7 @@ -import type { NextRouter } from 'next/router' +import type { UpdateUrlFunction } from './adapters/defs' import { debug } from './debug' -import type { Options, Router } from './defs' +import type { Options } from './defs' import { error } from './errors' -import { renderQueryString } from './url-encoding' import { getDefaultThrottle } from './utils' export const FLUSH_RATE_LIMIT_MS = getDefaultThrottle() @@ -45,10 +44,7 @@ export function enqueueQueryStringUpdate( queueOptions.shallow = false } if (options.startTransition) { - // Providing a startTransition function will - // cause the update to be non-shallow. transitionsQueue.add(options.startTransition) - queueOptions.shallow = false } queueOptions.throttleMs = Math.max( options.throttleMs ?? FLUSH_RATE_LIMIT_MS, @@ -67,7 +63,10 @@ export function enqueueQueryStringUpdate( * * @returns a Promise to the URLSearchParams that have been applied. */ -export function scheduleFlushToURL(router: Router) { +export function scheduleFlushToURL( + updateUrl: UpdateUrlFunction, + rateLimitFactor: number +) { if (flushPromiseCache === null) { flushPromiseCache = new Promise((resolve, reject) => { if (!Number.isFinite(queueOptions.throttleMs)) { @@ -81,7 +80,7 @@ export function scheduleFlushToURL(router: Router) { } function flushNow() { lastFlushTimestamp = performance.now() - const [search, error] = flushUpdateQueue(router) + const [search, error] = flushUpdateQueue(updateUrl) if (error === null) { resolve(search) } else { @@ -95,10 +94,9 @@ export function scheduleFlushToURL(router: Router) { const now = performance.now() const timeSinceLastFlush = now - lastFlushTimestamp const throttleMs = queueOptions.throttleMs - const flushInMs = Math.max( - 0, - Math.min(throttleMs, throttleMs - timeSinceLastFlush) - ) + const flushInMs = + rateLimitFactor * + Math.max(0, Math.min(throttleMs, throttleMs - timeSinceLastFlush)) debug( '[nuqs queue] Scheduling flush in %f ms. Throttled at %f ms', flushInMs, @@ -118,20 +116,9 @@ export function scheduleFlushToURL(router: Router) { return flushPromiseCache } -declare global { - interface Window { - next?: { - version: string - router?: NextRouter & { - state: { - asPath: string - } - } - } - } -} - -function flushUpdateQueue(router: Router): [URLSearchParams, null | unknown] { +function flushUpdateQueue( + updateUrl: UpdateUrlFunction +): [URLSearchParams, null | unknown] { const search = new URLSearchParams(location.search) if (updateQueue.size === 0) { return [search, null] @@ -156,52 +143,13 @@ function flushUpdateQueue(router: Router): [URLSearchParams, null | unknown] { } } try { - // While the Next.js team doesn't recommend using internals like this, - // we need access to the pages router here to let it know about non-shallow - // updates, as going through the window.history API directly will make it - // miss pushed history updates. - // The router adapter imported from next/navigation also doesn't support - // passing an asPath, causing issues in dynamic routes in the pages router. - const nextRouter = window.next?.router - const isPagesRouter = typeof nextRouter?.state?.asPath === 'string' - if (isPagesRouter) { - const url = renderURL(nextRouter.state.asPath.split('?')[0] ?? '', search) - debug('[nuqs queue (pages)] Updating url: %s', url) - const method = - options.history === 'push' ? nextRouter.push : nextRouter.replace - method.call(nextRouter, url, url, { + compose(transitions, () => { + updateUrl(search, { + history: options.history, scroll: options.scroll, shallow: options.shallow }) - } else { - // App router - const url = renderURL(location.origin + location.pathname, search) - debug('[nuqs queue (app)] Updating url: %s', url) - // First, update the URL locally without triggering a network request, - // this allows keeping a reactive URL if the network is slow. - const updateMethod = - options.history === 'push' ? history.pushState : history.replaceState - updateMethod.call( - history, - // In next@14.1.0, useSearchParams becomes reactive to shallow updates, - // but only if passing `null` as the history state. - null, - '', - url - ) - if (options.scroll) { - window.scrollTo(0, 0) - } - if (!options.shallow) { - compose(transitions, () => { - // Call the Next.js router to perform a network request - // and re-render server components. - router.replace(url, { - scroll: false - }) - }) - } - } + }) return [search, null] } catch (err) { // This may fail due to rate-limiting of history methods, @@ -211,13 +159,6 @@ function flushUpdateQueue(router: Router): [URLSearchParams, null | unknown] { } } -function renderURL(base: string, search: URLSearchParams) { - const hashlessBase = base.split('#')[0] ?? '' - const query = renderQueryString(search) - const hash = location.hash - return hashlessBase + query + hash -} - export function compose( fns: React.TransitionStartFunction[], final: () => void diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index 6b13cdc2..42b5f73b 100644 --- a/packages/nuqs/src/useQueryState.ts +++ b/packages/nuqs/src/useQueryState.ts @@ -1,5 +1,11 @@ -import { useRouter, useSearchParams } from 'next/navigation.js' // https://github.com/47ng/nuqs/discussions/352 -import React from 'react' +import { + useCallback, + useEffect, + useInsertionEffect, + useRef, + useState +} from 'react' +import { useAdapter } from './adapters/internal.context' import { debug } from './debug' import type { Options } from './defs' import type { Parser } from './parsers' @@ -17,14 +23,14 @@ export type UseQueryStateReturn = [ Default extends undefined ? Parsed | null // value can't be null if default is specified : Parsed, - ( + ( value: | null | Parsed | (( old: Default extends Parsed ? Parsed : Parsed | null ) => Parsed | null), - options?: Options + options?: Options ) => Promise ] @@ -221,17 +227,18 @@ export function useQueryState( defaultValue: undefined } ) { - const router = useRouter() // Not reactive, but available on the server and on page load - const initialSearchParams = useSearchParams() - const queryRef = React.useRef( - initialSearchParams?.get(key) ?? null - ) - const [internalState, setInternalState] = React.useState(() => { + const { + searchParams: initialSearchParams, + updateUrl, + rateLimitFactor = 1 + } = useAdapter() + const queryRef = useRef(initialSearchParams?.get(key) ?? null) + const [internalState, setInternalState] = useState(() => { const query = initialSearchParams?.get(key) ?? null return query === null ? null : safeParse(parse, query, key) }) - const stateRef = React.useRef(internalState) + const stateRef = useRef(internalState) debug( '[nuqs `%s`] render - state: %O, iSP: %s', key, @@ -239,7 +246,7 @@ export function useQueryState( initialSearchParams?.get(key) ?? null ) - React.useEffect(() => { + useEffect(() => { const query = initialSearchParams.get(key) ?? null if (query === queryRef.current) { return @@ -252,7 +259,7 @@ export function useQueryState( }, [initialSearchParams?.get(key), key]) // Sync all hooks together & with external URL changes - React.useInsertionEffect(() => { + useInsertionEffect(() => { function updateInternalState({ state, query }: CrossHookSyncPayload) { debug('[nuqs `%s`] updateInternalState %O', key, state) stateRef.current = state @@ -267,7 +274,7 @@ export function useQueryState( } }, [key]) - const update = React.useCallback( + const update = useCallback( (stateUpdater: React.SetStateAction, options: Options = {}) => { let newValue: T | null = isUpdaterFunction(stateUpdater) ? stateUpdater(stateRef.current ?? defaultValue ?? null) @@ -290,9 +297,18 @@ export function useQueryState( }) // Sync all hooks state (including this one) emitter.emit(key, { state: newValue, query: queryRef.current }) - return scheduleFlushToURL(router) + return scheduleFlushToURL(updateUrl, rateLimitFactor) }, - [key, history, shallow, scroll, throttleMs, startTransition] + [ + key, + history, + shallow, + scroll, + throttleMs, + startTransition, + updateUrl, + rateLimitFactor + ] ) return [internalState ?? defaultValue ?? null, update] } diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index cef01f37..f35a7603 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -1,9 +1,12 @@ import { - ReadonlyURLSearchParams, - useRouter, - useSearchParams -} from 'next/navigation.js' // https://github.com/47ng/nuqs/discussions/352 -import React from 'react' + useCallback, + useEffect, + useInsertionEffect, + useMemo, + useRef, + useState +} from 'react' +import { useAdapter } from './adapters/internal.context' import { debug } from './debug' import type { Nullable, Options } from './defs' import type { Parser } from './parsers' @@ -81,29 +84,30 @@ export function useQueryStates( ): UseQueryStatesReturn { type V = Values const stateKeys = Object.keys(keyMap).join(',') - const resolvedUrlKeys = React.useMemo( + const resolvedUrlKeys = useMemo( () => Object.fromEntries( Object.keys(keyMap).map(key => [key, urlKeys[key] ?? key]) ), [stateKeys, urlKeys] ) - - const router = useRouter() - // Not reactive, but available on the server and on page load - const initialSearchParams = useSearchParams() - const queryRef = React.useRef>({}) + const { + searchParams: initialSearchParams, + updateUrl, + rateLimitFactor = 1 + } = useAdapter() + const queryRef = useRef>({}) // Initialise the queryRef with the initial values if (Object.keys(queryRef.current).length !== Object.keys(keyMap).length) { queryRef.current = Object.fromEntries(initialSearchParams?.entries() ?? []) } - const [internalState, setInternalState] = React.useState(() => { + const [internalState, setInternalState] = useState(() => { const source = initialSearchParams ?? new URLSearchParams() return parseMap(keyMap, urlKeys, source) }) - const stateRef = React.useRef(internalState) + const stateRef = useRef(internalState) debug( '[nuq+ `%s`] render - state: %O, iSP: %s', stateKeys, @@ -111,7 +115,7 @@ export function useQueryStates( initialSearchParams ) - React.useEffect(() => { + useEffect(() => { const state = parseMap( keyMap, urlKeys, @@ -127,7 +131,7 @@ export function useQueryStates( ]) // Sync all hooks together & with external URL changes - React.useInsertionEffect(() => { + useInsertionEffect(() => { function updateInternalState(state: V) { debug('[nuq+ `%s`] updateInternalState %O', stateKeys, state) stateRef.current = state @@ -177,7 +181,7 @@ export function useQueryStates( } }, [keyMap, resolvedUrlKeys]) - const update = React.useCallback>( + const update = useCallback>( (stateUpdater, callOptions = {}) => { const newState: Partial> = typeof stateUpdater === 'function' @@ -228,7 +232,7 @@ export function useQueryStates( query: queryRef.current[urlKey] ?? null }) } - return scheduleFlushToURL(router) + return scheduleFlushToURL(updateUrl, rateLimitFactor) }, [ keyMap, @@ -237,7 +241,9 @@ export function useQueryStates( scroll, throttleMs, startTransition, - resolvedUrlKeys + resolvedUrlKeys, + updateUrl, + rateLimitFactor ] ) return [internalState, update] @@ -248,7 +254,7 @@ export function useQueryStates( function parseMap( keyMap: KeyMap, urlKeys: Partial>, - searchParams: URLSearchParams | ReadonlyURLSearchParams, + searchParams: URLSearchParams, cachedQuery?: Record, cachedState?: Values ) { diff --git a/packages/nuqs/tsconfig.client.json b/packages/nuqs/tsconfig.client.json new file mode 100644 index 00000000..1eea9b82 --- /dev/null +++ b/packages/nuqs/tsconfig.client.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["./src/index.server.ts"] +} diff --git a/packages/nuqs/tsconfig.server.json b/packages/nuqs/tsconfig.server.json new file mode 100644 index 00000000..bd02c1d0 --- /dev/null +++ b/packages/nuqs/tsconfig.server.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["./src/index.ts"] +} diff --git a/packages/nuqs/tsup.config.ts b/packages/nuqs/tsup.config.ts index 1dd6c87c..02cfd446 100644 --- a/packages/nuqs/tsup.config.ts +++ b/packages/nuqs/tsup.config.ts @@ -1,13 +1,50 @@ -import { defineConfig } from 'tsup' +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { defineConfig, type Options } from 'tsup' -export default defineConfig({ - entry: { - index: 'src/index.ts', - server: 'src/index.server.ts' - }, +const commonConfig = { format: ['esm'], - dts: true, + experimentalDts: true, outDir: 'dist', + external: ['next', 'react'], splitting: true, treeshake: true -}) +} satisfies Options + +const entrypoints = { + client: { + index: 'src/index.ts', + 'adapters/react': 'src/adapters/react.ts', + 'adapters/next': 'src/adapters/next.ts', + 'adapters/testing': 'src/adapters/testing.ts' + }, + server: { + server: 'src/index.server.ts' + } +} + +export default defineConfig([ + // Client bundles + { + ...commonConfig, + entry: entrypoints.client, + async onSuccess() { + await Promise.all( + Object.keys(entrypoints.client).map(async entry => { + const filePath = join(commonConfig.outDir, `${entry}.js`) + const fileContents = await readFile(filePath, 'utf-8') + const withUseClientDirective = `'use client';\n\n${fileContents}` + await writeFile(filePath, withUseClientDirective) + console.info( + `Successfully prepended "use client" directive to ${entry}.` + ) + }) + ) + } + }, + // Server bundle + { + ...commonConfig, + entry: entrypoints.server + } +]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98d6c0a6..15930e24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,7 +234,14 @@ importers: mitt: specifier: ^3.0.1 version: 3.0.1 + optionalDependencies: + next: + specifier: '>= 14.1.2' + version: 14.2.15(@babel/core@7.25.7)(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-d5bba18b-20241009(react@19.0.0-rc-d5bba18b-20241009))(react@19.0.0-rc-d5bba18b-20241009) devDependencies: + '@microsoft/api-extractor': + specifier: ^7.47.9 + version: 7.47.9(@types/node@22.7.5) '@size-limit/preset-small-lib': specifier: ^11.1.6 version: 11.1.6(size-limit@11.1.6) @@ -247,9 +254,6 @@ importers: '@types/react-dom': specifier: ^18.3.0 version: 18.3.0 - next: - specifier: 14.2.15 - version: 14.2.15(@babel/core@7.25.7)(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-d5bba18b-20241009(react@19.0.0-rc-d5bba18b-20241009))(react@19.0.0-rc-d5bba18b-20241009) react: specifier: catalog:react19rc version: 19.0.0-rc-d5bba18b-20241009 @@ -349,18 +353,10 @@ packages: resolution: {integrity: sha512-FPGAkJmyoChQeM+ruBGIDyrT2tKfZJO8NcxdC+CWNJi7N8/rZpSxK7yvBJ5O/nF1gfu5KzN7VKG3YVSLFfRSxQ==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.24.7': - resolution: {integrity: sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.25.7': resolution: {integrity: sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.24.7': - resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.25.7': resolution: {integrity: sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==} engines: {node: '>=6.9.0'} @@ -394,10 +390,6 @@ packages: resolution: {integrity: sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==} engines: {node: '>=6.9.0'} - '@babel/types@7.24.7': - resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==} - engines: {node: '>=6.9.0'} - '@babel/types@7.25.7': resolution: {integrity: sha512-vwIVdXG+j+FOpkwqHRcBgHLYNL7XMkufrlaFvL9o6Ai9sJn9+PdyIL5qa0XzTZw084c+u9LOls53eoZWP/W5WQ==} engines: {node: '>=6.9.0'} @@ -6647,9 +6639,6 @@ packages: peerDependencies: zod: ^3.18.0 - zod@3.23.6: - resolution: {integrity: sha512-RTHJlZhsRbuA8Hmp/iNL7jnfc4nZishjsanDAfEY1QpDQZCahUp3xDzl+zfweE9BklxMUcgBgS1b7Lvie/ZVwA==} - zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -6694,7 +6683,7 @@ snapshots: '@babel/generator@7.2.0': dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.25.7 jsesc: 2.5.2 lodash: 4.17.21 source-map: 0.5.7 @@ -6739,12 +6728,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.24.7': {} - '@babel/helper-string-parser@7.25.7': {} - '@babel/helper-validator-identifier@7.24.7': {} - '@babel/helper-validator-identifier@7.25.7': {} '@babel/helper-validator-option@7.25.7': {} @@ -6787,12 +6772,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.24.7': - dependencies: - '@babel/helper-string-parser': 7.24.7 - '@babel/helper-validator-identifier': 7.24.7 - to-fast-properties: 2.0.0 - '@babel/types@7.25.7': dependencies: '@babel/helper-string-parser': 7.25.7 @@ -7309,7 +7288,6 @@ snapshots: '@rushstack/node-core-library': 5.9.0(@types/node@22.7.5) transitivePeerDependencies: - '@types/node' - optional: true '@microsoft/api-extractor@7.47.9(@types/node@22.7.5)': dependencies: @@ -7328,7 +7306,6 @@ snapshots: typescript: 5.4.2 transitivePeerDependencies: - '@types/node' - optional: true '@microsoft/tsdoc-config@0.17.0': dependencies: @@ -7336,10 +7313,8 @@ snapshots: ajv: 8.12.0 jju: 1.4.0 resolve: 1.22.8 - optional: true - '@microsoft/tsdoc@0.15.0': - optional: true + '@microsoft/tsdoc@0.15.0': {} '@next/env@14.2.15': {} @@ -8404,13 +8379,11 @@ snapshots: semver: 7.5.4 optionalDependencies: '@types/node': 22.7.5 - optional: true '@rushstack/rig-package@0.5.3': dependencies: resolve: 1.22.8 strip-json-comments: 3.1.1 - optional: true '@rushstack/terminal@0.14.2(@types/node@22.7.5)': dependencies: @@ -8418,7 +8391,6 @@ snapshots: supports-color: 8.1.1 optionalDependencies: '@types/node': 22.7.5 - optional: true '@rushstack/ts-command-line@4.22.8(@types/node@22.7.5)': dependencies: @@ -8428,7 +8400,6 @@ snapshots: string-argv: 0.3.2 transitivePeerDependencies: - '@types/node' - optional: true '@sec-ant/readable-stream@0.4.1': {} @@ -8895,8 +8866,7 @@ snapshots: dependencies: '@types/estree': 1.0.6 - '@types/argparse@1.0.38': - optional: true + '@types/argparse@1.0.38': {} '@types/connect@3.4.36': dependencies: @@ -9205,12 +9175,10 @@ snapshots: ajv-draft-04@1.0.0(ajv@8.13.0): optionalDependencies: ajv: 8.13.0 - optional: true ajv-formats@3.0.1(ajv@8.13.0): optionalDependencies: ajv: 8.13.0 - optional: true ajv-keywords@3.5.2(ajv@6.12.6): dependencies: @@ -9229,7 +9197,6 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 uri-js: 4.4.1 - optional: true ajv@8.13.0: dependencies: @@ -9237,7 +9204,6 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 uri-js: 4.4.1 - optional: true ajv@8.17.1: dependencies: @@ -9356,12 +9322,12 @@ snapshots: babel-plugin-react-compiler@0.0.0-experimental-696af53-20240625: dependencies: '@babel/generator': 7.2.0 - '@babel/types': 7.24.7 + '@babel/types': 7.25.7 chalk: 4.1.2 invariant: 2.2.4 pretty-format: 24.9.0 - zod: 3.23.6 - zod-validation-error: 2.1.0(zod@3.23.6) + zod: 3.23.8 + zod-validation-error: 2.1.0(zod@3.23.8) bail@2.0.2: {} @@ -9389,7 +9355,6 @@ snapshots: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - optional: true brace-expansion@2.0.1: dependencies: @@ -9648,8 +9613,7 @@ snapshots: compute-scroll-into-view@3.1.0: {} - concat-map@0.0.1: - optional: true + concat-map@0.0.1: {} config-chain@1.1.13: dependencies: @@ -10357,7 +10321,6 @@ snapshots: graceful-fs: 4.2.11 jsonfile: 4.0.0 universalify: 0.1.2 - optional: true fs-extra@9.1.0: dependencies: @@ -10766,8 +10729,7 @@ snapshots: cjs-module-lexer: 1.4.1 module-details-from-path: 1.0.3 - import-lazy@4.0.0: - optional: true + import-lazy@4.0.0: {} import-meta-resolve@4.1.0: {} @@ -10920,8 +10882,7 @@ snapshots: jiti@2.3.3: {} - jju@1.4.0: - optional: true + jju@1.4.0: {} joi@17.13.3: dependencies: @@ -10996,7 +10957,6 @@ snapshots: jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 - optional: true jsonfile@6.1.0: dependencies: @@ -11689,7 +11649,6 @@ snapshots: minimatch@3.0.8: dependencies: brace-expansion: 1.1.11 - optional: true minimatch@8.0.4: dependencies: @@ -12593,7 +12552,6 @@ snapshots: semver@7.5.4: dependencies: lru-cache: 6.0.0 - optional: true semver@7.6.3: {} @@ -12769,8 +12727,7 @@ snapshots: streamsearch@1.1.0: {} - string-argv@0.3.2: - optional: true + string-argv@0.3.2: {} string-width@4.2.3: dependencies: @@ -12817,8 +12774,7 @@ snapshots: strip-json-comments@2.0.1: {} - strip-json-comments@3.1.1: - optional: true + strip-json-comments@3.1.1: {} style-to-object@0.4.4: dependencies: @@ -13137,8 +13093,7 @@ snapshots: type-fest@4.26.1: {} - typescript@5.4.2: - optional: true + typescript@5.4.2: {} typescript@5.6.3: {} @@ -13205,8 +13160,7 @@ snapshots: universal-user-agent@7.0.2: {} - universalify@0.1.2: - optional: true + universalify@0.1.2: {} universalify@0.2.0: {} @@ -13537,11 +13491,9 @@ snapshots: yoctocolors@2.1.1: {} - zod-validation-error@2.1.0(zod@3.23.6): + zod-validation-error@2.1.0(zod@3.23.8): dependencies: - zod: 3.23.6 - - zod@3.23.6: {} + zod: 3.23.8 zod@3.23.8: {} diff --git a/turbo.json b/turbo.json index 0ab37b51..6c4c5e5d 100644 --- a/turbo.json +++ b/turbo.json @@ -9,7 +9,7 @@ "dependsOn": ["^build"] }, "nuqs#build": { - "outputs": ["dist/**", "size.json"] + "outputs": ["dist/**", "size.json", ".tsup"] }, "e2e#build": { "outputs": [".next/**", "!.next/cache/**", "cypress/**"],