Skip to content

Commit

Permalink
Add automatic reloading when editing GS(S)P methods (#16744)
Browse files Browse the repository at this point in the history
This adds initial support for reloading the page when `getStaticProps`, `getStaticPaths`, or `getServerSideProps` were changed for a page by triggering a reload when the server output for a page has changed but the client output has not since these methods aren't included in the client output. 

Closes: #13949
  • Loading branch information
ijjk committed Sep 2, 2020
1 parent f8d92a6 commit 4685332
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 3 deletions.
2 changes: 1 addition & 1 deletion packages/next/build/utils.ts
Expand Up @@ -475,7 +475,7 @@ async function computeFromManifest(
return lastCompute!
}

function difference<T>(main: T[], sub: T[]): T[] {
export function difference<T>(main: T[] | Set<T>, sub: T[] | Set<T>): T[] {
const a = new Set(main)
const b = new Set(sub)
return [...a].filter((x) => !b.has(x))
Expand Down
20 changes: 20 additions & 0 deletions packages/next/client/next-dev.js
Expand Up @@ -7,6 +7,7 @@ import initializeBuildWatcher from './dev/dev-build-watcher'
import initializePrerenderIndicator from './dev/prerender-indicator'
import { displayContent } from './dev/fouc'
import { getEventSourceWrapper } from './dev/error-overlay/eventsource'
import * as querystring from '../next-server/lib/router/utils/querystring'

// Temporary workaround for the issue described here:
// https://github.com/vercel/next.js/issues/3775#issuecomment-407438123
Expand Down Expand Up @@ -42,6 +43,25 @@ initNext({ webpackHMR })
.catch((err) => {
console.log(`Failed to fetch devPagesManifest`, err)
})
} else if (event.data.indexOf('serverOnlyChanges') !== -1) {
const { pages } = JSON.parse(event.data)
const router = window.next.router

if (pages.includes(router.pathname)) {
console.log('Refreshing page data due to server-side change')

router.replace(
router.pathname +
'?' +
String(
querystring.assign(
querystring.urlQueryToSearchParams(router.query),
new URLSearchParams(location.search)
)
),
router.asPath
)
}
}
}
devPagesManifestListener.unfiltered = true
Expand Down
57 changes: 55 additions & 2 deletions packages/next/server/hot-reloader.ts
Expand Up @@ -31,6 +31,7 @@ import { isWriteable } from '../build/is-writeable'
import { ClientPagesLoaderOptions } from '../build/webpack/loaders/next-client-pages-loader'
import { stringify } from 'querystring'
import { Rewrite } from '../lib/load-custom-routes'
import { difference } from '../build/utils'

export async function renderScriptError(
res: ServerResponse,
Expand Down Expand Up @@ -356,6 +357,42 @@ export default class HotReloader {

watchCompilers(multiCompiler.compilers[0], multiCompiler.compilers[1])

// Watch for changes to client/server page files so we can tell when just
// the server file changes and trigger a reload for GS(S)P pages
const changedClientPages = new Set<string>()
const changedServerPages = new Set<string>()
const prevClientPageHashes = new Map<string, string>()
const prevServerPageHashes = new Map<string, string>()

const trackPageChanges = (
pageHashMap: Map<string, string>,
changedItems: Set<string>
) => (stats: webpack.compilation.Compilation) => {
stats.entrypoints.forEach((entry, key) => {
if (key.startsWith('pages/')) {
entry.chunks.forEach((chunk: any) => {
if (chunk.id === key) {
const prevHash = pageHashMap.get(key)

if (prevHash && prevHash !== chunk.hash) {
changedItems.add(key)
}
pageHashMap.set(key, chunk.hash)
}
})
}
})
}

multiCompiler.compilers[0].hooks.emit.tap(
'NextjsHotReloaderForClient',
trackPageChanges(prevClientPageHashes, changedClientPages)
)
multiCompiler.compilers[1].hooks.emit.tap(
'NextjsHotReloaderForServer',
trackPageChanges(prevServerPageHashes, changedServerPages)
)

// This plugin watches for changes to _document.js and notifies the client side that it should reload the page
multiCompiler.compilers[1].hooks.failed.tap(
'NextjsHotReloaderForServer',
Expand All @@ -370,6 +407,20 @@ export default class HotReloader {
this.serverError = null
this.serverStats = stats

const serverOnlyChanges = difference<string>(
changedServerPages,
changedClientPages
)
changedClientPages.clear()
changedServerPages.clear()

if (serverOnlyChanges.length > 0) {
this.send({
event: 'serverOnlyChanges',
pages: serverOnlyChanges.map((pg) => pg.substr('pages'.length)),
})
}

const { compilation } = stats

// We only watch `_document` for changes on the server compilation
Expand Down Expand Up @@ -514,8 +565,10 @@ export default class HotReloader {
return []
}

public send(action?: string, ...args: any[]): void {
this.webpackHotMiddleware!.publish({ action, data: args })
public send(action?: string | any, ...args: any[]): void {
this.webpackHotMiddleware!.publish(
action && typeof action === 'object' ? action : { action, data: args }
)
}

public async ensurePage(page: string) {
Expand Down
@@ -0,0 +1,36 @@
import { useRouter } from 'next/router'

export default function Gsp(props) {
if (useRouter().isFallback) {
return 'Loading...'
}

return (
<>
<p id="change">change me</p>
<p id="props">{JSON.stringify(props)}</p>
</>
)
}

export const getStaticProps = ({ params }) => {
const count = 1

return {
props: {
count,
params,
random: Math.random(),
},
}
}

export const getStaticPaths = () => {
/* eslint-disable-next-line no-unused-vars */
const paths = 1

return {
paths: [{ params: { post: 'first' } }, { params: { post: 'second' } }],
fallback: true,
}
}
@@ -0,0 +1,20 @@
export default function Gssp(props) {
return (
<>
<p id="change">change me</p>
<p id="props">{JSON.stringify(props)}</p>
</>
)
}

export const getServerSideProps = ({ params }) => {
const count = 1

return {
props: {
count,
params,
random: Math.random(),
},
}
}
123 changes: 123 additions & 0 deletions test/integration/gssp-ssr-change-reloading/test/index.test.js
@@ -0,0 +1,123 @@
/* eslint-env jest */

import { join } from 'path'
import webdriver from 'next-webdriver'
import { killApp, findPort, launchApp, File, check } from 'next-test-utils'

jest.setTimeout(1000 * 60 * 2)
const appDir = join(__dirname, '..')

let appPort
let app

describe('GS(S)P Server-Side Change Reloading', () => {
beforeAll(async () => {
appPort = await findPort()
app = await launchApp(appDir, appPort)
})
afterAll(() => killApp(app))

it('should not reload page when client-side is changed too GSP', async () => {
const browser = await webdriver(appPort, '/gsp-blog/first')
await browser.eval(() => (window.beforeChange = 'hi'))

const props = JSON.parse(await browser.elementByCss('#props').text())

const page = new File(join(appDir, 'pages/gsp-blog/[post].js'))
page.replace('change me', 'changed')

await check(() => browser.elementByCss('#change').text(), 'changed')
expect(await browser.eval(() => window.beforeChange)).toBe('hi')

const props2 = JSON.parse(await browser.elementByCss('#props').text())
expect(props).toEqual(props2)

page.restore()

await check(() => browser.elementByCss('#change').text(), 'change me')
})

it('should update page when getStaticProps is changed only', async () => {
const browser = await webdriver(appPort, '/gsp-blog/first')
await browser.eval(() => (window.beforeChange = 'hi'))

const props = JSON.parse(await browser.elementByCss('#props').text())
expect(props.count).toBe(1)

const page = new File(join(appDir, 'pages/gsp-blog/[post].js'))
page.replace('count = 1', 'count = 2')

await check(
async () =>
JSON.parse(await browser.elementByCss('#props').text()).count + '',
'2'
)
expect(await browser.eval(() => window.beforeChange)).toBe('hi')
page.restore()

await check(
async () =>
JSON.parse(await browser.elementByCss('#props').text()).count + '',
'1'
)
})

it('should update page when getStaticPaths is changed only', async () => {
const browser = await webdriver(appPort, '/gsp-blog/first')
await browser.eval(() => (window.beforeChange = 'hi'))

const props = JSON.parse(await browser.elementByCss('#props').text())
expect(props.count).toBe(1)

const page = new File(join(appDir, 'pages/gsp-blog/[post].js'))
page.replace('paths = 1', 'paths = 2')

expect(await browser.eval('window.beforeChange')).toBe('hi')
page.restore()
})

it('should not reload page when client-side is changed too GSSP', async () => {
const browser = await webdriver(appPort, '/gssp-blog/first')
await browser.eval(() => (window.beforeChange = 'hi'))

const props = JSON.parse(await browser.elementByCss('#props').text())

const page = new File(join(appDir, 'pages/gssp-blog/[post].js'))
page.replace('change me', 'changed')

await check(() => browser.elementByCss('#change').text(), 'changed')
expect(await browser.eval(() => window.beforeChange)).toBe('hi')

const props2 = JSON.parse(await browser.elementByCss('#props').text())
expect(props).toEqual(props2)

page.restore()

await check(() => browser.elementByCss('#change').text(), 'change me')
})

it('should update page when getServerSideProps is changed only', async () => {
const browser = await webdriver(appPort, '/gssp-blog/first')
await browser.eval(() => (window.beforeChange = 'hi'))

const props = JSON.parse(await browser.elementByCss('#props').text())
expect(props.count).toBe(1)

const page = new File(join(appDir, 'pages/gssp-blog/[post].js'))
page.replace('count = 1', 'count = 2')

await check(
async () =>
JSON.parse(await browser.elementByCss('#props').text()).count + '',
'2'
)
expect(await browser.eval(() => window.beforeChange)).toBe('hi')
page.restore()

await check(
async () =>
JSON.parse(await browser.elementByCss('#props').text()).count + '',
'1'
)
})
})

0 comments on commit 4685332

Please sign in to comment.