Skip to content

Commit

Permalink
fix: Push (#419)
Browse files Browse the repository at this point in the history
* test: Add push test

* chore: Add push test on pages router

* chore: Skip broken app router push test on latest

* fix: Use pages router via internals

Going through the history API only on shallow updates doesn't
notify the pages router, which can handle shallow updates directly.

Definitely a hack, but it allows also setting the asPath for dynamic
routes. There is still an issue with basePath being applied twice,
but that's an internal Next.js bug for another day.

* chore: Fix workflow

* chore: Restore test now that 14.0.4 has been released

* chore: Use 14.0.4 as baseline

* chore: Fix double basePath application

Disabling the bloom filter causing invalid collisions
between the pages and app routers.

* chore: Restore hard fail on CI

* test: Drop Next.js version injection (no longer needed)

* doc: Add note about internals usage
  • Loading branch information
franky47 authored Dec 10, 2023
1 parent 1c053a4 commit 54c1328
Show file tree
Hide file tree
Showing 7 changed files with 234 additions and 34 deletions.
90 changes: 90 additions & 0 deletions packages/e2e/cypress/e2e/push.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/// <reference types="cypress" />

describe('push', () => {
it('works in app router', () => {
cy.visit('/app/push')
cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
cy.get('#server-side').should('have.text', '0')
cy.get('#server').should('have.text', '0')
cy.get('#client').should('have.text', '0')

cy.get('button#server-incr').click()
cy.location('search').should('eq', '?server=1')
cy.get('#server-side').should('have.text', '1')
cy.get('#server').should('have.text', '1')
cy.get('#client').should('have.text', '0')

cy.get('button#client-incr').click()
cy.location('search').should('eq', '?server=1&client=1')
cy.get('#server-side').should('have.text', '1')
cy.get('#server').should('have.text', '1')
cy.get('#client').should('have.text', '1')

cy.go('back')
cy.location('search').should('eq', '?server=1')
cy.get('#server-side').should('have.text', '1')
cy.get('#server').should('have.text', '1')
cy.get('#client').should('have.text', '0')

cy.go('back')
cy.location('search').should('be.empty')
cy.get('#server-side').should('have.text', '0')
cy.get('#server').should('have.text', '0')
cy.get('#client').should('have.text', '0')

cy.go('forward')
cy.location('search').should('eq', '?server=1')
cy.get('#server-side').should('have.text', '1')
cy.get('#server').should('have.text', '1')
cy.get('#client').should('have.text', '0')

cy.go('forward')
cy.location('search').should('eq', '?server=1&client=1')
cy.get('#server-side').should('have.text', '1')
cy.get('#server').should('have.text', '1')
cy.get('#client').should('have.text', '1')
})

it('works in pages router', () => {
cy.visit('/pages/push')
cy.get('#server-side').should('have.text', '0')
cy.get('#server').should('have.text', '0')
cy.get('#client').should('have.text', '0')

cy.get('button#server-incr').click()
cy.location('search').should('eq', '?server=1')
cy.get('#server-side').should('have.text', '1')
cy.get('#server').should('have.text', '1')
cy.get('#client').should('have.text', '0')

cy.get('button#client-incr').click()
cy.location('search').should('eq', '?server=1&client=1')
cy.get('#server-side').should('have.text', '1')
cy.get('#server').should('have.text', '1')
cy.get('#client').should('have.text', '1')

cy.go('back')
cy.location('search').should('eq', '?server=1')
cy.get('#server-side').should('have.text', '1')
cy.get('#server').should('have.text', '1')
cy.get('#client').should('have.text', '0')

cy.go('back')
cy.location('search').should('be.empty')
cy.get('#server-side').should('have.text', '0')
cy.get('#server').should('have.text', '0')
cy.get('#client').should('have.text', '0')

cy.go('forward')
cy.location('search').should('eq', '?server=1')
cy.get('#server-side').should('have.text', '1')
cy.get('#server').should('have.text', '1')
cy.get('#client').should('have.text', '0')

cy.go('forward')
cy.location('search').should('eq', '?server=1&client=1')
cy.get('#server-side').should('have.text', '1')
cy.get('#server').should('have.text', '1')
cy.get('#client').should('have.text', '1')
})
})
7 changes: 5 additions & 2 deletions packages/e2e/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
const experimental =
process.env.WINDOW_HISTORY_SUPPORT === 'true'
? {
windowHistorySupport: true
windowHistorySupport: true,
clientRouterFilter: false
}
: {
clientRouterFilter: false
}
: undefined

const basePath =
process.env.BASE_PATH === '/' ? undefined : process.env.BASE_PATH
Expand Down
28 changes: 28 additions & 0 deletions packages/e2e/src/app/app/push/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client'

import { useQueryState } from 'next-usequerystate'
import { parser } from './searchParams'

export function Client() {
const [client, setClient] = useQueryState('client', parser)
const [server, setServer] = useQueryState(
'server',
parser.withOptions({ shallow: false })
)
return (
<>
<p>
Client: <span id="client">{client}</span>
</p>
<p>
Server: <span id="server">{server}</span>
</p>
<button id="client-incr" onClick={() => setClient(c => c + 1)}>
Client Incr
</button>
<button id="server-incr" onClick={() => setServer(c => c + 1)}>
Server Incr
</button>
</>
)
}
18 changes: 18 additions & 0 deletions packages/e2e/src/app/app/push/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Client } from './client'
import { parser } from './searchParams'

export default function Page({
searchParams
}: {
searchParams: Record<string, string | string[] | undefined>
}) {
const server = parser.parseServerSide(searchParams.server)
return (
<>
<p>
Server side: <span id="server-side">{server}</span>
</p>
<Client />
</>
)
}
5 changes: 5 additions & 0 deletions packages/e2e/src/app/app/push/searchParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { parseAsInteger } from 'next-usequerystate'

export const parser = parseAsInteger.withDefault(0).withOptions({
history: 'push'
})
25 changes: 25 additions & 0 deletions packages/e2e/src/pages/pages/push/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { GetServerSideProps } from 'next'
import { Client } from '../../../app/app/push/client'
import { parser } from '../../../app/app/push/searchParams'

export default function Page({ server }: { server: number }) {
return (
<>
<p>
Server side: <span id="server-side">{server}</span>
</p>
<Client />
</>
)
}

export const getServerSideProps = (async ctx => {
const server = parser.parseServerSide(ctx.query.server)
return {
props: {
server
}
}
}) satisfies GetServerSideProps<{
server: number
}>
95 changes: 63 additions & 32 deletions packages/next-usequerystate/src/update-queue.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { NextRouter } from 'next/router'
import { debug } from './debug'
import type { Options, Router } from './defs'
import { error } from './errors'
Expand Down Expand Up @@ -116,6 +117,18 @@ export function scheduleFlushToURL(router: Router) {
return flushPromiseCache
}

declare global {
interface Window {
next: {
router?: NextRouter & {
state: {
asPath: string
}
}
}
}
}

function flushUpdateQueue(router: Router): [URLSearchParams, null | unknown] {
const search = new URLSearchParams(location.search)
if (updateQueue.size === 0) {
Expand All @@ -140,37 +153,56 @@ function flushUpdateQueue(router: Router): [URLSearchParams, null | unknown] {
search.set(key, value)
}
}
const url = renderURL(search)
debug('[nuqs queue] Updating url: %s', url)

try {
// 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,
history.state,
// Our own updates have a marker to prevent syncing
// when the URL changes (we've already sync'd them up
// via `emitter.emit(key, newValue)` above, without
// going through the parsers).
NOSYNC_MARKER,
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,
// @ts-expect-error - pages router fix, but not exposed in navigation types
shallow: false
})
// 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.href.split('?')[0] ?? '', 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,
history.state,
// Our own updates have a marker to prevent syncing
// when the URL changes (we've already sync'd them up
// via `emitter.emit(key, newValue)` above, without
// going through the parsers).
NOSYNC_MARKER,
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,
// @ts-expect-error - pages router fix, but not exposed in navigation types
shallow: false
})
})
}
}
return [search, null]
} catch (err) {
Expand All @@ -181,11 +213,10 @@ function flushUpdateQueue(router: Router): [URLSearchParams, null | unknown] {
}
}

function renderURL(search: URLSearchParams) {
function renderURL(base: string, search: URLSearchParams) {
const query = renderQueryString(search)
const href = location.href.split('?')[0]
const hash = location.hash
return href + query + hash
return base + query + hash
}

export function compose(
Expand Down

0 comments on commit 54c1328

Please sign in to comment.