Skip to content

Commit

Permalink
feat: Spy on all query changes
Browse files Browse the repository at this point in the history
Whether internal or external, and specify the source in the callback
along with the new value.

Add query spy component to all pages for browsers
where changes in the URL are not easily visible (eg: on mobile).
  • Loading branch information
franky47 committed Sep 13, 2023
1 parent e11250d commit b9a24cc
Show file tree
Hide file tree
Showing 9 changed files with 99 additions and 17 deletions.
2 changes: 1 addition & 1 deletion src/app/demos/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DemoPageLayout } from '../../layouts/demo-page'
import { DemoPageLayout } from '../../components/demo-page-layout'

export const metadata = {
title: 'next-usequerystate demos'
Expand Down
2 changes: 1 addition & 1 deletion src/app/demos/subscribeToQueryUpdates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default function BuilderPatternDemoPage() {
)

React.useEffect(() => {
const off = subscribeToQueryUpdates(search =>
const off = subscribeToQueryUpdates(({ search }) =>
console.log(search.toString())
)
return off
Expand Down
14 changes: 14 additions & 0 deletions src/app/e2e/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { QuerySpy } from '../../../components/query-spy'

export default function E2EPageLayout({
children
}: {
children: React.ReactNode
}) {
return (
<>
<QuerySpy />
{children}
</>
)
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import Link from 'next/link'
import { QuerySpy } from './query-spy'

export function DemoPageLayout({ children }: { children: React.ReactNode }) {
return (
<main>
<Link href="/">⬅️ Home</Link>
<QuerySpy />
{children}
</main>
)
Expand Down
49 changes: 49 additions & 0 deletions src/components/query-spy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use client'

import { useSearchParams } from 'next/navigation'
import React from 'react'
import { subscribeToQueryUpdates } from '../../dist'

export const QuerySpy: React.FC = () => {
const initialSearchParams = useSearchParams()
const [search, setSearch] = React.useState<URLSearchParams>(() => {
if (typeof location !== 'object') {
// SSR
const out = new URLSearchParams()
if (!initialSearchParams) {
return out
}
for (const [key, value] of initialSearchParams) {
out.set(key, value)
}
return out
} else {
return new URLSearchParams(location.search)
}
})

React.useLayoutEffect(
() => subscribeToQueryUpdates(({ search }) => setSearch(search)),
[]
)
const qs = search.toString()

return (
<pre
aria-label="Querystring spy"
aria-description="For browsers where the query is hard to see (eg: on mobile)"
style={{
padding: '4px 6px',
border: 'solid 1px gray',
borderRadius: '4px',
overflow: 'auto'
}}
>
{qs ? (
'?' + qs
) : (
<span style={{ fontStyle: 'italic' }}>{'<empty query>'}</span>
)}
</pre>
)
}
1 change: 1 addition & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export type { HistoryOptions, Options } from './defs'
export * from './deprecated'
export * from './parsers'
export { subscribeToQueryUpdates } from './sync'
export type { QueryUpdateNotificationArgs, QueryUpdateSource } from './sync'
export * from './useQueryState'
export * from './useQueryStates'
41 changes: 29 additions & 12 deletions src/lib/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@ import Mitt from 'mitt'

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

export type QueryUpdateSource = 'internal' | 'external'
export type QueryUpdateNotificationArgs = {
search: URLSearchParams
source: QueryUpdateSource
}

type EventMap = {
[SYNC_EVENT_KEY]: URLSearchParams
[NOTIFY_EVENT_KEY]: URLSearchParams
[NOTIFY_EVENT_KEY]: QueryUpdateNotificationArgs
[key: string]: any
}

export const emitter = Mitt<EventMap>()

export function subscribeToQueryUpdates(
callback: (search: URLSearchParams) => void
callback: (args: QueryUpdateNotificationArgs) => void
) {
emitter.on(NOTIFY_EVENT_KEY, callback)
return () => emitter.off(NOTIFY_EVENT_KEY, callback)
Expand All @@ -30,17 +36,20 @@ if (!patched && typeof window === 'object') {
title: string,
url?: string | URL | null
) {
if (!url) {
// Null URL is only used for state changes,
// we're not interested in reacting to those.
__DEBUG__ &&
console.debug(`history.${method}(null) (${title}) %O`, state)
return original(state, title, url)
}
const source = title === NOSYNC_MARKER ? 'internal' : 'external'
const search = new URL(url, location.origin).searchParams
__DEBUG__ &&
console.debug(
`history.${method}(${url}) (${
title === NOSYNC_MARKER ? 'internal' : 'external'
}) %O`,
state
)
console.debug(`history.${method}(${url}) (${source}) %O`, state)
// If someone else than our hooks have updated the URL,
// send out a signal for them to sync their internal state.
if (title !== NOSYNC_MARKER && url) {
const search = new URL(url, location.origin).searchParams
if (source === 'external') {
__DEBUG__ && console.debug(`Triggering sync with ${search.toString()}`)
// Here we're delaying application to next tick to avoid:
// `Warning: useInsertionEffect must not schedule updates.`
Expand All @@ -52,8 +61,16 @@ if (!patched && typeof window === 'object') {
// have been applied by then, we're also sending the
// parsed query string to the hooks so they don't need
// to rely on the URL being up to date.
setTimeout(() => emitter.emit(SYNC_EVENT_KEY, search), 0)
setTimeout(() => {
emitter.emit(SYNC_EVENT_KEY, search)
emitter.emit(NOTIFY_EVENT_KEY, { search, source })
}, 0)
} else {
setTimeout(() => {
emitter.emit(NOTIFY_EVENT_KEY, { search, source })
}, 0)
}

return original(state, title === NOSYNC_MARKER ? '' : title, url)
}
}
Expand Down
3 changes: 1 addition & 2 deletions src/lib/update-queue.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Options, Router } from './defs'
import { NOSYNC_MARKER, NOTIFY_EVENT_KEY, emitter } from './sync'
import { NOSYNC_MARKER } from './sync'

// 50ms between calls to the history API seems to satisfy Chrome and Firefox.
// Safari remains annoying with at most 100 calls in 30 seconds. #wontfix
Expand Down Expand Up @@ -131,7 +131,6 @@ function flushUpdateQueue(router: Router) {
options.history === 'push' ? router.push : router.replace
updateUrl.call(router, url, { scroll: options.scroll })
}
emitter.emit(NOTIFY_EVENT_KEY, search)
return search
} catch (error) {
console.error(
Expand Down
2 changes: 1 addition & 1 deletion src/pages/demos/pages/server-side-counter.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { GetServerSideProps, InferGetServerSidePropsType } from 'next'
import { parseAsInteger, useQueryState } from '../../../../dist'
import { DemoPageLayout } from '../../../layouts/demo-page'
import { DemoPageLayout } from '../../../components/demo-page-layout'

export default function ServerSideCounterPage({
counter: serverSideCounter
Expand Down

0 comments on commit b9a24cc

Please sign in to comment.