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"