From b3045cc7a92f15fdb6f0082fe60b41360f207d3a Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Sun, 27 Jan 2019 16:12:17 +0100 Subject: [PATCH] Implement circular JSON err.sh link (#6149) * Implement circular JSON err.sh link * Add test for getInitialProps returning circular json * Make test warn less * Fix tests * Add reference to original tests --- errors/circular-structure.md | 11 +++++ packages/next/package.json | 1 - packages/next/pages/_document.js | 14 ++++-- packages/next/server/htmlescape.ts | 16 +++++++ test/integration/basic/next.config.js | 2 +- .../basic/pages/circular-json-error.js | 17 ++++++++ test/integration/basic/test/rendering.js | 6 +++ test/unit/htmlescape.test.js | 43 +++++++++++++++++++ yarn.lock | 5 --- 9 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 errors/circular-structure.md create mode 100644 packages/next/server/htmlescape.ts create mode 100644 test/integration/basic/pages/circular-json-error.js create mode 100644 test/unit/htmlescape.test.js diff --git a/errors/circular-structure.md b/errors/circular-structure.md new file mode 100644 index 00000000..e154def3 --- /dev/null +++ b/errors/circular-structure.md @@ -0,0 +1,11 @@ +# Circular structure in "getInitialProps" result + +#### Why This Error Occurred + +`getInitialProps` is serialized to JSON using `JSON.stringify` and sent to the client side for hydrating the page. + +However, the result returned from `getInitialProps` can't be serialized when it has a circular structure. + +#### Possible Ways to Fix It + +Circular structures are not supported, so the way to fix this error is removing the circular structure from the object that is returned from `getInitialProps`. diff --git a/packages/next/package.json b/packages/next/package.json index 20e91e0d..95e5902b 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -66,7 +66,6 @@ "friendly-errors-webpack-plugin": "1.7.0", "glob": "7.1.2", "hoist-non-react-statics": "3.2.0", - "htmlescape": "1.1.1", "http-status": "1.0.1", "launch-editor": "2.2.1", "loader-utils": "1.1.0", diff --git a/packages/next/pages/_document.js b/packages/next/pages/_document.js index 560c402f..5d653d5d 100644 --- a/packages/next/pages/_document.js +++ b/packages/next/pages/_document.js @@ -1,7 +1,7 @@ /* eslint-disable */ import React, { Component } from 'react' import PropTypes from 'prop-types' -import htmlescape from 'htmlescape' +import {htmlEscapeJsonString} from '../server/htmlescape' import flush from 'styled-jsx/server' const Fragment = React.Fragment || function Fragment ({ children }) { @@ -193,8 +193,16 @@ export class NextScript extends Component { } static getInlineScriptSource (documentProps) { - const { __NEXT_DATA__ } = documentProps - return htmlescape(__NEXT_DATA__) + const {__NEXT_DATA__} = documentProps + try { + const data = JSON.stringify(__NEXT_DATA__) + return htmlEscapeJsonString(data) + } catch(err) { + if(err.message.indexOf('circular structure')) { + throw new Error(`Circular structure in "getInitialProps" result of page "${__NEXT_DATA__.page}". https://err.sh/zeit/next.js/circular-structure`) + } + throw err + } } render () { diff --git a/packages/next/server/htmlescape.ts b/packages/next/server/htmlescape.ts new file mode 100644 index 00000000..da15dc6c --- /dev/null +++ b/packages/next/server/htmlescape.ts @@ -0,0 +1,16 @@ +// This utility is based on https://github.com/zertosh/htmlescape +// License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE + +const ESCAPE_LOOKUP: {[match: string]: string} = { + '&': '\\u0026', + '>': '\\u003e', + '<': '\\u003c', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +} + +const ESCAPE_REGEX = /[&><\u2028\u2029]/g + +export function htmlEscapeJsonString(str: string) { + return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]) +} diff --git a/test/integration/basic/next.config.js b/test/integration/basic/next.config.js index 738c742d..01dc78cd 100644 --- a/test/integration/basic/next.config.js +++ b/test/integration/basic/next.config.js @@ -6,7 +6,7 @@ module.exports = { }, webpack (config) { config.module.rules.push({ - test: /pages[\\/]hmr/, + test: /pages[\\/]hmr[\\/]about/, loader: path.join(__dirname, 'warning-loader.js') }) diff --git a/test/integration/basic/pages/circular-json-error.js b/test/integration/basic/pages/circular-json-error.js new file mode 100644 index 00000000..6d8f7815 --- /dev/null +++ b/test/integration/basic/pages/circular-json-error.js @@ -0,0 +1,17 @@ +function CircularJSONErrorPage () { + return
This won't render
+} + +CircularJSONErrorPage.getInitialProps = () => { + // This creates a circular JSON object + const object = {} + object.arr = [ + object, object + ] + object.arr.push(object.arr) + object.obj = object + + return object +} + +export default CircularJSONErrorPage diff --git a/test/integration/basic/test/rendering.js b/test/integration/basic/test/rendering.js index 2ee98c1e..c89c5a05 100644 --- a/test/integration/basic/test/rendering.js +++ b/test/integration/basic/test/rendering.js @@ -120,6 +120,12 @@ export default function ({ app }, suiteName, render, fetch) { expect(link.text()).toBe('About') }) + test('getInitialProps circular structure', async () => { + const $ = await get$('/circular-json-error') + const expectedErrorMessage = 'Circular structure in "getInitialProps" result of page "/circular-json-error".' + expect($('pre').text().includes(expectedErrorMessage)).toBeTruthy() + }) + test('getInitialProps should be class method', async () => { const $ = await get$('/instance-get-initial-props') const expectedErrorMessage = '"InstanceInitialPropsPage.getInitialProps()" is defined as an instance method - visit https://err.sh/zeit/next.js/get-initial-props-as-an-instance-method for more information.' diff --git a/test/unit/htmlescape.test.js b/test/unit/htmlescape.test.js new file mode 100644 index 00000000..41a72feb --- /dev/null +++ b/test/unit/htmlescape.test.js @@ -0,0 +1,43 @@ +/* eslint-env jest */ +// These tests are based on https://github.com/zertosh/htmlescape/blob/3e6cf0614dd0f778fd0131e69070b77282150c15/test/htmlescape-test.js +// License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE +import {htmlEscapeJsonString} from 'next/dist/server/htmlescape' +import vm from 'vm' + +describe('htmlescape', () => { + test('with angle brackets should escape', () => { + const evilObj = {evil: ''} + expect(htmlEscapeJsonString(JSON.stringify(evilObj))).toBe('{"evil":"\\u003cscript\\u003e\\u003c/script\\u003e"}') + }) + + test('with angle brackets should parse back', () => { + const evilObj = {evil: ''} + expect(JSON.parse(htmlEscapeJsonString(JSON.stringify(evilObj)))).toMatchObject(evilObj) + }) + + test('with ampersands should escape', () => { + const evilObj = {evil: '&'} + expect(htmlEscapeJsonString(JSON.stringify(evilObj))).toBe('{"evil":"\\u0026"}') + }) + + test('with ampersands should parse back', () => { + const evilObj = {evil: '&'} + expect(JSON.parse(htmlEscapeJsonString(JSON.stringify(evilObj)))).toMatchObject(evilObj) + }) + + test('with "LINE SEPARATOR" and "PARAGRAPH SEPARATOR" should escape', () => { + const evilObj = {evil: '\u2028\u2029'} + expect(htmlEscapeJsonString(JSON.stringify(evilObj))).toBe('{"evil":"\\u2028\\u2029"}') + }) + + test('with "LINE SEPARATOR" and "PARAGRAPH SEPARATOR" should parse back', () => { + const evilObj = {evil: '\u2028\u2029'} + expect(JSON.parse(htmlEscapeJsonString(JSON.stringify(evilObj)))).toMatchObject(evilObj) + }) + + test('escaped line terminators should work', () => { + expect(() => { + vm.runInNewContext('(' + htmlEscapeJsonString(JSON.stringify({evil: '\u2028\u2029'})) + ')') + }).not.toThrow() + }) +}) diff --git a/yarn.lock b/yarn.lock index 7d8f4df7..24ca82ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5898,11 +5898,6 @@ html-entities@^1.2.0: resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f" integrity sha1-DfKTUfByEWNRXfueVUPl9u7VFi8= -htmlescape@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/htmlescape/-/htmlescape-1.1.1.tgz#3a03edc2214bca3b66424a3e7959349509cb0351" - integrity sha1-OgPtwiFLyjtmQko+eVk0lQnLA1E= - htmlparser2@^3.9.1: version "3.10.0" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.0.tgz#5f5e422dcf6119c0d983ed36260ce9ded0bee464"