From 59714db16deee949b426af3184f38ee243c89b8d Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Wed, 16 Feb 2022 16:32:24 -0600 Subject: [PATCH] Update server-only changes HMR handling (#34298) * Update server-only changes HMR handling * Add failing tests for GS(S)P server only changes * update test * normalize backslashes * Update to xor the chunk hashes * remove test change * remove other test change --- packages/next/server/dev/hot-reloader.ts | 65 +++- packages/next/types/webpack.d.ts | 1 + .../gssp-ssr-change-reloading/lib/data.json | 3 + .../pages/another/index.js | 0 .../pages/gsp-blog/[post].js | 0 .../pages/gssp-blog/[post].js | 0 .../gssp-ssr-change-reloading/pages/index.js | 2 + .../test/index.test.ts | 309 ++++++++++++++++++ .../test/index.test.js | 197 ----------- 9 files changed, 365 insertions(+), 212 deletions(-) create mode 100644 test/development/basic/gssp-ssr-change-reloading/lib/data.json rename test/{integration => development/basic}/gssp-ssr-change-reloading/pages/another/index.js (100%) rename test/{integration => development/basic}/gssp-ssr-change-reloading/pages/gsp-blog/[post].js (100%) rename test/{integration => development/basic}/gssp-ssr-change-reloading/pages/gssp-blog/[post].js (100%) rename test/{integration => development/basic}/gssp-ssr-change-reloading/pages/index.js (89%) create mode 100644 test/development/basic/gssp-ssr-change-reloading/test/index.test.ts delete mode 100644 test/integration/gssp-ssr-change-reloading/test/index.test.js diff --git a/packages/next/server/dev/hot-reloader.ts b/packages/next/server/dev/hot-reloader.ts index a5b9391be164e..92847d189629c 100644 --- a/packages/next/server/dev/hot-reloader.ts +++ b/packages/next/server/dev/hot-reloader.ts @@ -3,7 +3,7 @@ import { IncomingMessage, ServerResponse } from 'http' import { WebpackHotMiddleware } from './hot-middleware' import { join, relative, isAbsolute } from 'path' import { UrlObject } from 'url' -import { webpack } from 'next/dist/compiled/webpack/webpack' +import { webpack, StringXor } from 'next/dist/compiled/webpack/webpack' import type { webpack5 } from 'next/dist/compiled/webpack/webpack' import { createEntrypoints, @@ -594,21 +594,56 @@ export default class HotReloader { const trackPageChanges = (pageHashMap: Map, changedItems: Set) => (stats: webpack5.Compilation) => { - stats.entrypoints.forEach((entry, key) => { - if (key.startsWith('pages/')) { - // TODO this doesn't handle on demand loaded chunks - entry.chunks.forEach((chunk: any) => { - if (chunk.id === key) { - const prevHash = pageHashMap.get(key) - - if (prevHash && prevHash !== chunk.hash) { - changedItems.add(key) + try { + stats.entrypoints.forEach((entry, key) => { + if (key.startsWith('pages/')) { + // TODO this doesn't handle on demand loaded chunks + entry.chunks.forEach((chunk) => { + if (chunk.id === key) { + const modsIterable: any = + stats.chunkGraph.getChunkModulesIterable(chunk) + + let chunksHash = new StringXor() + + modsIterable.forEach((mod: any) => { + if ( + mod.resource && + mod.resource.replace(/\\/g, '/').includes(key) + ) { + // use original source to calculate hash since mod.hash + // includes the source map in development which changes + // every time for both server and client so we calculate + // the hash without the source map for the page module + const hash = require('crypto') + .createHash('sha256') + .update(mod.originalSource().buffer()) + .digest() + .toString('hex') + + chunksHash.add(hash) + } else { + // for non-pages we can use the module hash directly + const hash = stats.chunkGraph.getModuleHash( + mod, + chunk.runtime + ) + chunksHash.add(hash) + } + }) + const prevHash = pageHashMap.get(key) + const curHash = chunksHash.toString() + + if (prevHash && prevHash !== curHash) { + changedItems.add(key) + } + pageHashMap.set(key, curHash) } - pageHashMap.set(key, chunk.hash) - } - }) - } - }) + }) + } + }) + } catch (err) { + console.error(err) + } } multiCompiler.compilers[0].hooks.emit.tap( diff --git a/packages/next/types/webpack.d.ts b/packages/next/types/webpack.d.ts index cd3f07453c22d..5cbda79547b0f 100644 --- a/packages/next/types/webpack.d.ts +++ b/packages/next/types/webpack.d.ts @@ -35,6 +35,7 @@ declare module 'next/dist/compiled/webpack/webpack' { export let BasicEvaluatedExpression: any export let GraphHelpers: any export let sources: typeof webpackSources + export let StringXor: any // TODO change this to webpack5 export { webpack4 as webpack, loader, webpack4, webpack5 } } diff --git a/test/development/basic/gssp-ssr-change-reloading/lib/data.json b/test/development/basic/gssp-ssr-change-reloading/lib/data.json new file mode 100644 index 0000000000000..f2a886f39de7d --- /dev/null +++ b/test/development/basic/gssp-ssr-change-reloading/lib/data.json @@ -0,0 +1,3 @@ +{ + "hello": "world" +} diff --git a/test/integration/gssp-ssr-change-reloading/pages/another/index.js b/test/development/basic/gssp-ssr-change-reloading/pages/another/index.js similarity index 100% rename from test/integration/gssp-ssr-change-reloading/pages/another/index.js rename to test/development/basic/gssp-ssr-change-reloading/pages/another/index.js diff --git a/test/integration/gssp-ssr-change-reloading/pages/gsp-blog/[post].js b/test/development/basic/gssp-ssr-change-reloading/pages/gsp-blog/[post].js similarity index 100% rename from test/integration/gssp-ssr-change-reloading/pages/gsp-blog/[post].js rename to test/development/basic/gssp-ssr-change-reloading/pages/gsp-blog/[post].js diff --git a/test/integration/gssp-ssr-change-reloading/pages/gssp-blog/[post].js b/test/development/basic/gssp-ssr-change-reloading/pages/gssp-blog/[post].js similarity index 100% rename from test/integration/gssp-ssr-change-reloading/pages/gssp-blog/[post].js rename to test/development/basic/gssp-ssr-change-reloading/pages/gssp-blog/[post].js diff --git a/test/integration/gssp-ssr-change-reloading/pages/index.js b/test/development/basic/gssp-ssr-change-reloading/pages/index.js similarity index 89% rename from test/integration/gssp-ssr-change-reloading/pages/index.js rename to test/development/basic/gssp-ssr-change-reloading/pages/index.js index 679405ddf80ad..3f2fe4523faa2 100644 --- a/test/integration/gssp-ssr-change-reloading/pages/index.js +++ b/test/development/basic/gssp-ssr-change-reloading/pages/index.js @@ -1,4 +1,5 @@ import { useRouter } from 'next/router' +import data from '../lib/data.json' export default function Gsp(props) { if (useRouter().isFallback) { @@ -19,6 +20,7 @@ export const getStaticProps = async () => { return { props: { count, + data, random: Math.random(), }, } diff --git a/test/development/basic/gssp-ssr-change-reloading/test/index.test.ts b/test/development/basic/gssp-ssr-change-reloading/test/index.test.ts new file mode 100644 index 0000000000000..828dc2adb2fe4 --- /dev/null +++ b/test/development/basic/gssp-ssr-change-reloading/test/index.test.ts @@ -0,0 +1,309 @@ +/* eslint-env jest */ + +import { join } from 'path' +import webdriver from 'next-webdriver' +import { createNext, FileRef } from 'e2e-utils' +import { check, getRedboxHeader, hasRedbox } from 'next-test-utils' +import { NextInstance } from 'test/lib/next-modes/base' + +const installCheckVisible = (browser) => { + return browser.eval(`(function() { + window.checkInterval = setInterval(function() { + let watcherDiv = document.querySelector('#__next-build-watcher') + watcherDiv = watcherDiv.shadowRoot || watcherDiv + window.showedBuilder = window.showedBuilder || ( + watcherDiv.querySelector('div').className.indexOf('visible') > -1 + ) + if (window.showedBuilder) clearInterval(window.checkInterval) + }, 50) + })()`) +} + +describe('GS(S)P Server-Side Change Reloading', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, '../pages')), + lib: new FileRef(join(__dirname, '../lib')), + }, + }) + }) + afterAll(() => next.destroy()) + + it('should not reload page when client-side is changed too GSP', async () => { + const browser = await webdriver(next.url, '/gsp-blog/first') + await check(() => browser.elementByCss('#change').text(), 'change me') + await browser.eval(`window.beforeChange = 'hi'`) + + const props = JSON.parse(await browser.elementByCss('#props').text()) + + const page = 'pages/gsp-blog/[post].js' + const originalContent = await next.readFile(page) + await next.patchFile(page, originalContent.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) + + await next.patchFile(page, originalContent) + await check(() => browser.elementByCss('#change').text(), 'change me') + }) + + it('should update page when getStaticProps is changed only', async () => { + const browser = await webdriver(next.url, '/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 = 'pages/gsp-blog/[post].js' + const originalContent = await next.readFile(page) + await next.patchFile( + page, + originalContent.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') + await next.patchFile(page, originalContent) + + await check( + async () => + JSON.parse(await browser.elementByCss('#props').text()).count + '', + '1' + ) + }) + + it('should show indicator when re-fetching data', async () => { + const browser = await webdriver(next.url, '/gsp-blog/second') + await installCheckVisible(browser) + await browser.eval(`window.beforeChange = 'hi'`) + + const props = JSON.parse(await browser.elementByCss('#props').text()) + expect(props.count).toBe(1) + + const page = 'pages/gsp-blog/[post].js' + const originalContent = await next.readFile(page) + await next.patchFile( + page, + originalContent.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') + expect(await browser.eval(`window.showedBuilder`)).toBe(true) + + await next.patchFile(page, originalContent) + 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(next.url, '/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 = 'pages/gsp-blog/[post].js' + const originalContent = await next.readFile(page) + await next.patchFile( + page, + originalContent.replace('paths = 1', 'paths = 2') + ) + + expect(await browser.eval('window.beforeChange')).toBe('hi') + await next.patchFile(page, originalContent) + }) + + it('should update page when getStaticProps is changed only for /index', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval(`window.beforeChange = 'hi'`) + + const props = JSON.parse(await browser.elementByCss('#props').text()) + expect(props.count).toBe(1) + + const page = 'pages/index.js' + const originalContent = await next.readFile(page) + await next.patchFile( + page, + originalContent.replace('count = 1', 'count = 2') + ) + + expect(await browser.eval('window.beforeChange')).toBe('hi') + await next.patchFile(page, originalContent) + }) + + it('should update page when getStaticProps is changed only for /another/index', async () => { + const browser = await webdriver(next.url, '/another') + await browser.eval(`window.beforeChange = 'hi'`) + + const props = JSON.parse(await browser.elementByCss('#props').text()) + expect(props.count).toBe(1) + + const page = 'pages/another/index.js' + const originalContent = await next.readFile(page) + await next.patchFile( + page, + originalContent.replace('count = 1', 'count = 2') + ) + + expect(await browser.eval('window.beforeChange')).toBe('hi') + await next.patchFile(page, originalContent) + }) + + it('should not reload page when client-side is changed too GSSP', async () => { + const browser = await webdriver(next.url, '/gssp-blog/first') + await check(() => browser.elementByCss('#change').text(), 'change me') + await browser.eval(`window.beforeChange = 'hi'`) + + const props = JSON.parse(await browser.elementByCss('#props').text()) + + const page = 'pages/gssp-blog/[post].js' + const originalContent = await next.readFile(page) + await next.patchFile(page, originalContent.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) + + await next.patchFile(page, originalContent) + await check(() => browser.elementByCss('#change').text(), 'change me') + }) + + it('should update page when getServerSideProps is changed only', async () => { + const browser = await webdriver(next.url, '/gssp-blog/first') + await check( + async () => + JSON.parse(await browser.elementByCss('#props').text()).count + '', + '1' + ) + await browser.eval(`window.beforeChange = 'hi'`) + + const props = JSON.parse(await browser.elementByCss('#props').text()) + expect(props.count).toBe(1) + + const page = 'pages/gssp-blog/[post].js' + const originalContent = await next.readFile(page) + await next.patchFile( + page, + originalContent.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') + await next.patchFile(page, originalContent) + + await check( + async () => + JSON.parse(await browser.elementByCss('#props').text()).count + '', + '1' + ) + }) + + it('should update on props error in getStaticProps', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval(`window.beforeChange = 'hi'`) + + const props = JSON.parse(await browser.elementByCss('#props').text()) + expect(props.count).toBe(1) + + const page = 'pages/index.js' + const originalContent = await next.readFile(page) + + try { + await next.patchFile(page, originalContent.replace('props:', 'propss:')) + expect(await hasRedbox(browser, true)).toBe(true) + expect(await getRedboxHeader(browser)).toContain( + 'Additional keys were returned from' + ) + + await next.patchFile(page, originalContent) + expect(await hasRedbox(browser, false)).toBe(false) + } finally { + await next.patchFile(page, originalContent) + } + }) + + it('should update on thrown error in getStaticProps', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval(`window.beforeChange = 'hi'`) + + const props = JSON.parse(await browser.elementByCss('#props').text()) + expect(props.count).toBe(1) + + const page = 'pages/index.js' + const originalContent = await next.readFile(page) + + try { + await next.patchFile( + page, + originalContent.replace( + 'const count', + 'throw new Error("custom oops"); const count' + ) + ) + expect(await hasRedbox(browser, true)).toBe(true) + expect(await getRedboxHeader(browser)).toContain('custom oops') + + await next.patchFile(page, originalContent) + expect(await hasRedbox(browser, false)).toBe(false) + } finally { + await next.patchFile(page, originalContent) + } + }) + + it('should refresh data when server import is updated', async () => { + const browser = await webdriver(next.url, '/') + await browser.eval(`window.beforeChange = 'hi'`) + + const props = JSON.parse(await browser.elementByCss('#props').text()) + expect(props.count).toBe(1) + expect(props.data).toEqual({ hello: 'world' }) + + const page = 'lib/data.json' + const originalContent = await next.readFile(page) + + try { + await next.patchFile(page, JSON.stringify({ hello: 'replaced!!' })) + await check(async () => { + const props = JSON.parse(await browser.elementByCss('#props').text()) + return props.count === 1 && props.data.hello === 'replaced!!' + ? 'success' + : JSON.stringify(props) + }, 'success') + expect(await browser.eval('window.beforeChange')).toBe('hi') + + await next.patchFile(page, originalContent) + await check(async () => { + const props = JSON.parse(await browser.elementByCss('#props').text()) + return props.count === 1 && props.data.hello === 'world' + ? 'success' + : JSON.stringify(props) + }, 'success') + } finally { + await next.patchFile(page, originalContent) + } + }) +}) diff --git a/test/integration/gssp-ssr-change-reloading/test/index.test.js b/test/integration/gssp-ssr-change-reloading/test/index.test.js deleted file mode 100644 index 3fcd32e4a23d9..0000000000000 --- a/test/integration/gssp-ssr-change-reloading/test/index.test.js +++ /dev/null @@ -1,197 +0,0 @@ -/* eslint-env jest */ - -import { join } from 'path' -import webdriver from 'next-webdriver' -import { killApp, findPort, launchApp, File, check } from 'next-test-utils' - -const appDir = join(__dirname, '..') - -let appPort -let app - -const installCheckVisible = (browser) => { - return browser.eval(`(function() { - window.checkInterval = setInterval(function() { - let watcherDiv = document.querySelector('#__next-build-watcher') - watcherDiv = watcherDiv.shadowRoot || watcherDiv - window.showedBuilder = window.showedBuilder || ( - watcherDiv.querySelector('div').className.indexOf('visible') > -1 - ) - if (window.showedBuilder) clearInterval(window.checkInterval) - }, 50) - })()`) -} - -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 check(() => browser.elementByCss('#change').text(), 'change me') - 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 show indicator when re-fetching data', async () => { - const browser = await webdriver(appPort, '/gsp-blog/second') - await installCheckVisible(browser) - 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') - expect(await browser.eval(() => window.showedBuilder)).toBe(true) - 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 update page when getStaticProps is changed only for /index', async () => { - const browser = await webdriver(appPort, '/') - 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/index.js')) - page.replace('count = 1', 'count = 2') - - expect(await browser.eval('window.beforeChange')).toBe('hi') - page.restore() - }) - - it('should update page when getStaticProps is changed only for /another/index', async () => { - const browser = await webdriver(appPort, '/another') - 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/another/index.js')) - page.replace('count = 1', 'count = 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 check(() => browser.elementByCss('#change').text(), 'change me') - 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 check( - async () => - JSON.parse(await browser.elementByCss('#props').text()).count + '', - '1' - ) - 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' - ) - }) -})