diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index c0a5dba6e291030..6c5e894c59ac497 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -475,7 +475,7 @@ async function computeFromManifest( return lastCompute! } -function difference(main: T[], sub: T[]): T[] { +export function difference(main: T[] | Set, sub: T[] | Set): T[] { const a = new Set(main) const b = new Set(sub) return [...a].filter((x) => !b.has(x)) diff --git a/packages/next/client/next-dev.js b/packages/next/client/next-dev.js index 2475c984fd0ad76..3d7b64d7ba46806 100644 --- a/packages/next/client/next-dev.js +++ b/packages/next/client/next-dev.js @@ -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 @@ -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 diff --git a/packages/next/server/hot-reloader.ts b/packages/next/server/hot-reloader.ts index ba14fcb18e0e83b..c7fc19ff00fdfbe 100644 --- a/packages/next/server/hot-reloader.ts +++ b/packages/next/server/hot-reloader.ts @@ -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, @@ -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() + const changedServerPages = new Set() + const prevClientPageHashes = new Map() + const prevServerPageHashes = new Map() + + const trackPageChanges = ( + pageHashMap: Map, + changedItems: Set + ) => (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', @@ -370,6 +407,20 @@ export default class HotReloader { this.serverError = null this.serverStats = stats + const serverOnlyChanges = difference( + 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 @@ -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) { diff --git a/test/integration/gssp-ssr-change-reloading/pages/gsp-blog/[post].js b/test/integration/gssp-ssr-change-reloading/pages/gsp-blog/[post].js new file mode 100644 index 000000000000000..187308da7565490 --- /dev/null +++ b/test/integration/gssp-ssr-change-reloading/pages/gsp-blog/[post].js @@ -0,0 +1,36 @@ +import { useRouter } from 'next/router' + +export default function Gsp(props) { + if (useRouter().isFallback) { + return 'Loading...' + } + + return ( + <> +

change me

+

{JSON.stringify(props)}

+ + ) +} + +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, + } +} diff --git a/test/integration/gssp-ssr-change-reloading/pages/gssp-blog/[post].js b/test/integration/gssp-ssr-change-reloading/pages/gssp-blog/[post].js new file mode 100644 index 000000000000000..d6a6ef8d184043c --- /dev/null +++ b/test/integration/gssp-ssr-change-reloading/pages/gssp-blog/[post].js @@ -0,0 +1,20 @@ +export default function Gssp(props) { + return ( + <> +

change me

+

{JSON.stringify(props)}

+ + ) +} + +export const getServerSideProps = ({ params }) => { + const count = 1 + + return { + props: { + count, + params, + random: Math.random(), + }, + } +} diff --git a/test/integration/gssp-ssr-change-reloading/test/index.test.js b/test/integration/gssp-ssr-change-reloading/test/index.test.js new file mode 100644 index 000000000000000..c6bfb6e58b4b9ff --- /dev/null +++ b/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' + ) + }) +})