Skip to content

Commit

Permalink
feat: Better debugging story
Browse files Browse the repository at this point in the history
Having two bundles is burdensome, their interop exposed
the emitter and didn't allow using Symbols, but moreover
they didn't allow switching debug info at runtime without
rebuilding/redeploying the app, which is inconvenient.

Instead, log lines are only rendered to string for user timings
and logged in rich format to the console if there is a special
key in localStorage, that follows the `debug` package convention,
without importing all the bells and whistles around it.

The patched marker has also been replaced by the package version,
in order to facilitate debugging even further by looking which version
is being used in production builds.
  • Loading branch information
franky47 committed Oct 26, 2023
1 parent 6e10925 commit 879012b
Show file tree
Hide file tree
Showing 17 changed files with 283 additions and 1,896 deletions.
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,22 +444,26 @@ See issue #259 for more testing-related discussions.

## Debugging

You can enable debug logs by importing from `next-usequerystate/debug`:
You can enable debug logs in the browser by setting the `debug` item in localStorage
to `next-usequerystate` (or any string containing it), and reload the page.

```ts
import { useQueryState } from 'next-usequerystate/debug'

// API is unchanged.
// Parsers can still be imported server-side from `next-usequerystate/parsers`
// as they don't print out logs.
```js
// In your devtools:
localStorage.setItem('debug', 'next-usequerystate')
```

> Note: unlike the `debug` package, this will not work with wildcards, but
> you can combine it: `localStorage.setItem('debug', '*,next-usequerystate')`
Log lines will be prefixed with `[nuqs]` for `useQueryState` and `[nuq+]` for
`useQueryStates`.
`useQueryStates`, along with other internal debug logs.

User timings markers are also recorded, for advanced performance analysis using
your browser's devtools.

Providing debug logs when opening an [issue](https://github.com/47ng/next-usequerystate/issues)
is always appreciated. 🙏

## Caveats

Because the Next.js **pages router** is not available in an SSR context, this
Expand Down
1 change: 0 additions & 1 deletion packages/next-usequerystate/ambient.d.ts

This file was deleted.

7 changes: 0 additions & 7 deletions packages/next-usequerystate/debug.d.ts

This file was deleted.

17 changes: 3 additions & 14 deletions packages/next-usequerystate/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,15 @@
"types": "./dist/parsers.d.ts",
"import": "./dist/parsers.js",
"require": "./dist/parsers.cjs"
},
"./debug": {
"types": "./dist/debug/index.d.ts",
"import": "./dist/debug/index.js",
"require": "./dist/debug/index.cjs"
}
},
"scripts": {
"dev": "tsup --format esm --watch --external=react",
"build": "run-p build:*",
"build:prod": "NODE_ENV=production tsup --clean --external=react",
"build:debug": "NODE_ENV=development tsup --clean --external=react",
"build": "tsup --clean --external=react",
"test": "run-p test:*",
"test:types": "tsd",
"test:unit": "vitest run",
"prepack": "cp -f ../../README.md ../../LICENSE ."
"prepack": "./scripts/prepack.sh"
},
"peerDependencies": {
"next": "^13.4"
Expand All @@ -68,21 +61,17 @@
"mitt": "^3.0.1"
},
"devDependencies": {
"@types/jest": "^29.5.6",
"@types/debug": "^4.1.10",
"@types/node": "^20.6.3",
"@types/react": "^18.2.22",
"@types/react-dom": "^18.2.8",
"jest": "^29.7.0",
"next": "^13.5",
"npm-run-all": "^4.1.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"tsd": "^0.29.0",
"tsup": "^7.2.0",
"typescript": "^5.2.2",
"vite": "^4.4.11",
"vitest": "^0.34.6"
},
"tsd": {
Expand Down
13 changes: 13 additions & 0 deletions packages/next-usequerystate/scripts/prepack.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash

set -e

# Place ourselves in the package directory
cd "$(dirname "$0")/.."

# Copy the README & License from the root of the repository
cp -f ../../README.md ../../LICENSE ./

# Patch the version from package.json
VERSION=$(node -p "require('./package.json').version")
sed -i '' "s/0.0.0-inject-version-here/${VERSION}/g" dist/index.{js,cjs}
32 changes: 32 additions & 0 deletions packages/next-usequerystate/src/debug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, test } from 'vitest'
import { sprintf } from './debug'

describe('debug/sprintf', () => {
test('%s', () => {
expect(sprintf('%s', 'foo')).toBe('foo')
expect(sprintf('%s', 1)).toBe('1')
expect(sprintf('%s', true)).toBe('true')
expect(sprintf('%s', null)).toBe('null')
expect(sprintf('%s', undefined)).toBe('undefined')
expect(sprintf('%s', {})).toBe('[object Object]')
expect(sprintf('%s', [])).toBe('')
})
test('%O', () => {
expect(sprintf('%O', 'foo')).toBe('"foo"')
expect(sprintf('%O', 1)).toBe('1')
expect(sprintf('%O', true)).toBe('true')
expect(sprintf('%O', null)).toBe('null')
expect(sprintf('%O', undefined)).toBe('undefined')
expect(sprintf('%O', {})).toBe('{}')
expect(sprintf('%O', [])).toBe('[]')
expect(sprintf('%O', { hello: 'world' })).toBe('{hello:"world"}')
})
test('All together now', () => {
expect(sprintf('%s %O', 'foo', { hello: 'world' })).toBe(
'foo {hello:"world"}'
)
expect(sprintf('%O %s', { hello: 'world' }, 'foo')).toBe(
'{hello:"world"} foo'
)
})
})
24 changes: 24 additions & 0 deletions packages/next-usequerystate/src/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const enabled =
(typeof window === 'object' &&
localStorage.getItem('debug')?.includes('next-usequerystate')) ??
false

export function debug(message: string, ...args: any[]) {
if (!enabled) {
return
}
const msg = sprintf(message, ...args)
performance.mark(msg)
console.debug(message, ...args)
}

export function sprintf(base: string, ...args: any[]) {
return base.replace(/%[sfdO]/g, match => {
const arg = args.shift()
if (match === '%O' && arg) {
return JSON.stringify(arg).replace(/"([^"]+)":/g, '$1:')
} else {
return String(arg)
}
})
}
61 changes: 25 additions & 36 deletions packages/next-usequerystate/src/sync.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Emitter as MittEmitter } from 'mitt'
import Mitt from 'mitt'
import { debug } from './debug'

export const SYNC_EVENT_KEY = '__nextUseQueryState__SYNC__'
export const SYNC_EVENT_KEY = Symbol('__nextUseQueryState__SYNC__')
export const NOSYNC_MARKER = '__nextUseQueryState__NO_SYNC__'
const NOTIFY_EVENT_KEY = '__nextUseQueryState__NOTIFY__'
const NOTIFY_EVENT_KEY = Symbol('__nextUseQueryState__NOTIFY__')

export type QueryUpdateSource = 'internal' | 'external'
export type QueryUpdateNotificationArgs = {
Expand All @@ -17,17 +17,23 @@ type EventMap = {
[key: string]: any
}

type Emitter = MittEmitter<EventMap>
export const emitter = Mitt<EventMap>()

declare global {
interface History {
__nextUseQueryState_emitter?: Emitter
__nextUseQueryState_patched?: string
}
}

if (typeof history === 'object' && !history.__nextUseQueryState_emitter) {
__DEBUG__ && console.debug('Patching history')
const emitter = Mitt<EventMap>()
export function subscribeToQueryUpdates(
callback: (args: QueryUpdateNotificationArgs) => void
) {
emitter.on(NOTIFY_EVENT_KEY, callback)
return () => emitter.off(NOTIFY_EVENT_KEY, callback)
}

if (typeof history === 'object' && !history.__nextUseQueryState_patched) {
debug('Patching history')
for (const method of ['pushState', 'replaceState'] as const) {
const original = history[method].bind(history)
history[method] = function nextUseQueryState_patchedHistory(
Expand All @@ -38,16 +44,12 @@ if (typeof history === 'object' && !history.__nextUseQueryState_emitter) {
if (!url) {
// Null URL is only used for state changes,
// we're not interested in reacting to those.
__DEBUG__ &&
performance.mark(`[nuqs] history.${method}(null) (${title})`) &&
console.debug(`[nuqs] history.${method}(null) (${title}) %O`, state)
debug('[nuqs] history.%s(null) (%s) %O', method, title, state)
return original(state, title, url)
}
const source = title === NOSYNC_MARKER ? 'internal' : 'external'
const search = new URL(url, location.origin).searchParams
__DEBUG__ &&
performance.mark(`[nuqs] history.${method}(${url}) (${source})`) &&
console.debug(`[nuqs] history.${method}(${url}) (${source}) %O`, state)
debug(`[nuqs] history.%s(%s) (%s) %O`, method, url, source, state)
// If someone else than our hooks have updated the URL,
// send out a signal for them to sync their internal state.
if (source === 'external') {
Expand All @@ -62,13 +64,11 @@ if (typeof history === 'object' && !history.__nextUseQueryState_emitter) {
// parsed query string to the hooks so they don't need
// to rely on the URL being up to date.
setTimeout(() => {
__DEBUG__ &&
performance.mark(
`[nuqs] External history.${method} call: triggering sync with ${search.toString()}`
) &&
console.debug(
`[nuqs] External history.${method} call: triggering sync with ${search.toString()}`
)
debug(
'[nuqs] External history.%s call: triggering sync with %s',
method,
search
)
emitter.emit(SYNC_EVENT_KEY, search)
emitter.emit(NOTIFY_EVENT_KEY, { search, source })
}, 0)
Expand All @@ -77,26 +77,15 @@ if (typeof history === 'object' && !history.__nextUseQueryState_emitter) {
emitter.emit(NOTIFY_EVENT_KEY, { search, source })
}, 0)
}

return original(state, title === NOSYNC_MARKER ? '' : title, url)
}
}
Object.defineProperty(history, '__nextUseQueryState_emitter', {
value: emitter,
// This is replaced by the package.json version in the prepack script
// after semantic-release has done updating the version number.
Object.defineProperty(history, '__nextUseQueryState_patched', {
value: '0.0.0-inject-version-here',
writable: false,
enumerable: false,
configurable: false
})
}

export const emitter: Emitter =
typeof history === 'object'
? history.__nextUseQueryState_emitter ?? Mitt<EventMap>()
: Mitt<EventMap>()

export function subscribeToQueryUpdates(
callback: (args: QueryUpdateNotificationArgs) => void
) {
emitter.on(NOTIFY_EVENT_KEY, callback)
return () => emitter.off(NOTIFY_EVENT_KEY, callback)
}
23 changes: 5 additions & 18 deletions packages/next-usequerystate/src/update-queue.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { debug } from './debug'
import type { Options, Router } from './defs'
import { NOSYNC_MARKER } from './sync'
import { renderQueryString } from './url-encoding'
Expand All @@ -24,14 +25,7 @@ export function enqueueQueryStringUpdate<Value>(
options: Options
) {
const serializedOrNull = value === null ? null : serialize(value)
__DEBUG__ &&
performance.mark(`[nuqs queue] Enqueueing ${key}=${serializedOrNull}`) &&
console.debug(
'[nuqs queue] Enqueueing %s=%s %O',
key,
serializedOrNull,
options
)
debug('[nuqs queue] Enqueueing %s=%s %O', key, serializedOrNull, options)
updateQueue.set(key, serializedOrNull)
// Any item can override an option for the whole batch of updates
if (options.history === 'push') {
Expand Down Expand Up @@ -68,9 +62,7 @@ export function flushToURL(router: Router) {
0,
Math.min(FLUSH_RATE_LIMIT_MS, FLUSH_RATE_LIMIT_MS - timeSinceLastFlush)
)
__DEBUG__ &&
performance.mark(`[nuqs queue] Scheduling flush in ${flushInMs} ms`) &&
console.debug('[nuqs queue] Scheduling flush in %f ms', flushInMs)
debug('[nuqs queue] Scheduling flush in %f ms', flushInMs)
setTimeout(() => {
lastFlushTimestamp = performance.now()
const search = flushUpdateQueue(router)
Expand Down Expand Up @@ -99,10 +91,7 @@ function flushUpdateQueue(router: Router) {
queueOptions.scroll = false
queueOptions.shallow = true
updateQueue.clear()
__DEBUG__ &&
performance.mark('[nuqs queue] Flushing queue') &&
console.debug('[nuqs queue] Flushing queue %O', items)

debug('[nuqs queue] Flushing queue %O', items)
for (const [key, value] of items) {
if (value === null) {
search.delete(key)
Expand All @@ -119,9 +108,7 @@ function flushUpdateQueue(router: Router) {
// otherwise using a relative URL works just fine.
// todo: Does it when using the router with `shallow: false` on dynamic paths?
const url = query ? `?${query}${hash}` : `${path}${hash}`
__DEBUG__ &&
performance.mark(`[nuqs queue] Updating url: ${url}`) &&
console.debug('[nuqs queue] Updating url: %s', url)
debug('[nuqs queue] Updating url: %s', url)
try {
if (options.shallow) {
const updateUrl =
Expand Down
Loading

0 comments on commit 879012b

Please sign in to comment.