From 8443a809f3ff2b2a2707550f8accc8b53c87181a Mon Sep 17 00:00:00 2001 From: Joe Haddad Date: Mon, 9 Mar 2020 13:30:44 -0400 Subject: [PATCH] Verify GS(S)P Serializability (#10857) * Verify GS(S)P Serializability * Add support for cyclic refs * Add unit tests * Test for error in development mode * Fix prerender preview tests * Fix gssp preview tests * fix 2x test cases * Add desired test * fix some more tests * Fix route manifest expect * Fix test expects * Test that `getServerSideProps` does not error in production * Test that getStaticProps is not checked in production * Test serialization check during build * Fix export detection for serverless * Update test/unit/is-serializable-props.test.js Co-Authored-By: JJ Kasper Co-authored-by: JJ Kasper --- .../webpack/loaders/next-serverless-loader.ts | 7 +- packages/next/export/worker.js | 10 +- packages/next/lib/is-serializable-props.ts | 138 +++++++++++++ .../next/next-server/server/next-server.ts | 4 +- packages/next/next-server/server/render.tsx | 22 ++ .../getserversideprops-preview/pages/index.js | 8 +- .../test/index.test.js | 10 +- .../getserversideprops/pages/index.js | 4 + .../getserversideprops/pages/non-json.js | 11 + .../getserversideprops/test/index.test.js | 49 ++++- .../prerender-preview/pages/index.js | 8 +- .../prerender-preview/test/index.test.js | 8 +- test/integration/prerender/pages/index.js | 4 + .../prerender/pages/non-json/[p].js | 21 ++ test/integration/prerender/test/index.test.js | 81 ++++++++ test/unit/is-serializable-props.test.js | 190 ++++++++++++++++++ 16 files changed, 549 insertions(+), 26 deletions(-) create mode 100644 packages/next/lib/is-serializable-props.ts create mode 100644 test/integration/getserversideprops/pages/non-json.js create mode 100644 test/integration/prerender/pages/non-json/[p].js create mode 100644 test/unit/is-serializable-props.test.js diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index c50f932c8bf56d4..55ee67059859ff4 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -232,7 +232,8 @@ const nextServerlessLoader: loader.Loader = function() { export const config = ComponentInfo['confi' + 'g'] || {} export const _app = App - export async function renderReqToHTML(req, res, fromExport, _renderOpts, _params) { + export async function renderReqToHTML(req, res, renderMode, _renderOpts, _params) { + const fromExport = renderMode === 'export' || renderMode === true; ${ basePath ? ` @@ -327,7 +328,7 @@ const nextServerlessLoader: loader.Loader = function() { let result = await renderToHTML(req, res, "${page}", Object.assign({}, getStaticProps ? {} : parsedUrl.query, nowParams ? nowParams : params, _params, isFallback ? { __nextFallback: 'true' } : {}), renderOpts) - if (_nextData && !fromExport) { + if (_nextData && !renderMode) { const payload = JSON.stringify(renderOpts.pageData) res.setHeader('Content-Type', 'application/json') res.setHeader('Content-Length', Buffer.byteLength(payload)) @@ -349,7 +350,7 @@ const nextServerlessLoader: loader.Loader = function() { ) } - if (fromExport) return { html: result, renderOpts } + if (renderMode) return { html: result, renderOpts } return result } catch (err) { if (err.code === 'ENOENT') { diff --git a/packages/next/export/worker.js b/packages/next/export/worker.js index a5169bf8d62e06d..55770c11800c6e0 100644 --- a/packages/next/export/worker.js +++ b/packages/next/export/worker.js @@ -154,7 +154,13 @@ export default async function({ } renderMethod = mod.renderReqToHTML - const result = await renderMethod(req, res, true, { ampPath }, params) + const result = await renderMethod( + req, + res, + 'export', + { ampPath }, + params + ) curRenderOpts = result.renderOpts || {} html = result.html } @@ -227,7 +233,7 @@ export default async function({ let ampHtml if (serverless) { req.url += (req.url.includes('?') ? '&' : '?') + 'amp=1' - ampHtml = (await renderMethod(req, res, true)).html + ampHtml = (await renderMethod(req, res, 'export')).html } else { ampHtml = await renderMethod( req, diff --git a/packages/next/lib/is-serializable-props.ts b/packages/next/lib/is-serializable-props.ts new file mode 100644 index 000000000000000..6053b064be6d8ce --- /dev/null +++ b/packages/next/lib/is-serializable-props.ts @@ -0,0 +1,138 @@ +const regexpPlainIdentifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +function isPlainObject(value: any): boolean { + if (Object.prototype.toString.call(value) !== '[object Object]') { + return false + } + + const prototype = Object.getPrototypeOf(value) + return prototype === null || prototype === Object.prototype +} + +export function isSerializableProps( + page: string, + method: string, + input: any +): true { + if (!isPlainObject(input)) { + throw new SerializableError( + page, + method, + '', + `Props must be returned as a plain object from ${method}: \`{ props: { ... } }\`.` + ) + } + + const visited = new WeakSet() + + function visit(value: any, path: string) { + if (visited.has(value)) { + throw new SerializableError( + page, + method, + path, + 'Circular references cannot be expressed in JSON.' + ) + } + + visited.add(value) + } + + function isSerializable(value: any, path: string): true { + const type = typeof value + if ( + // `null` can be serialized, but not `undefined`. + value === null || + // n.b. `bigint`, `function`, `symbol`, and `undefined` cannot be + // serialized. + // + // `object` is special-cased below, as it may represent `null`, an Array, + // a plain object, a class, et al. + type === 'boolean' || + type === 'number' || + type === 'string' + ) { + return true + } + + if (type === 'undefined') { + throw new SerializableError( + page, + method, + path, + '`undefined` cannot be serialized as JSON. Please use `null` or omit this value all together.' + ) + } + + if (isPlainObject(value)) { + visit(value, path) + + if ( + Object.entries(value).every(([key, value]) => { + const nextPath = regexpPlainIdentifier.test(key) + ? `${path}.${key}` + : `${path}[${JSON.stringify(key)}]` + + return ( + isSerializable(key, nextPath) && isSerializable(value, nextPath) + ) + }) + ) { + return true + } + + throw new SerializableError( + page, + method, + path, + `invariant: Unknown error encountered in Object.` + ) + } + + if (Array.isArray(value)) { + visit(value, path) + + if ( + value.every((value, index) => + isSerializable(value, `${path}[${index}]`) + ) + ) { + return true + } + + throw new SerializableError( + page, + method, + path, + `invariant: Unknown error encountered in Array.` + ) + } + + // None of these can be expressed as JSON: + // const type: "bigint" | "symbol" | "object" | "function" + throw new SerializableError( + page, + method, + path, + '`' + + type + + '`' + + (type === 'object' + ? ` ("${Object.prototype.toString.call(value)}")` + : '') + + ' cannot be serialized as JSON. Please only return JSON serializable data types.' + ) + } + + return isSerializable(input, '') +} + +export class SerializableError extends Error { + constructor(page: string, method: string, path: string, message: string) { + super( + path + ? `Error serializing \`${path}\` returned from \`${method}\` in "${page}".\nReason: ${message}` + : `Error serializing props returned from \`${method}\` in "${page}".\nReason: ${message}` + ) + } +} diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index d5869ef61b9023f..294c6abf3eaa028 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -925,7 +925,7 @@ export default class Server { const renderResult = await (components.Component as any).renderReqToHTML( req, res, - true + 'passthrough' ) sendPayload( @@ -1028,7 +1028,7 @@ export default class Server { renderResult = await (components.Component as any).renderReqToHTML( req, res, - true + 'passthrough' ) html = renderResult.html diff --git a/packages/next/next-server/server/render.tsx b/packages/next/next-server/server/render.tsx index 65a6d7e51213dff..f36786bfcf78586 100644 --- a/packages/next/next-server/server/render.tsx +++ b/packages/next/next-server/server/render.tsx @@ -8,6 +8,7 @@ import { SERVER_PROPS_SSG_CONFLICT, SSG_GET_INITIAL_PROPS_CONFLICT, } from '../../lib/constants' +import { isSerializableProps } from '../../lib/is-serializable-props' import { isInAmpMode } from '../lib/amp' import { AmpStateContext } from '../lib/amp-context' import { @@ -329,6 +330,7 @@ export async function renderToHTML( delete query.__nextFallback const isSSG = !!getStaticProps + const isBuildTimeSSG = isSSG && renderOpts.nextExport const defaultAppGetInitialProps = App.getInitialProps === (App as any).origGetInitialProps @@ -508,6 +510,16 @@ export async function renderToHTML( throw new Error(invalidKeysMsg('getStaticProps', invalidKeys)) } + if ( + (dev || isBuildTimeSSG) && + !isSerializableProps(pathname, 'getStaticProps', data.props) + ) { + // this fn should throw an error instead of ever returning `false` + throw new Error( + 'invariant: getStaticProps did not return valid props. Please report this.' + ) + } + if (typeof data.revalidate === 'number') { if (!Number.isInteger(data.revalidate)) { throw new Error( @@ -567,6 +579,16 @@ export async function renderToHTML( throw new Error(invalidKeysMsg('getServerSideProps', invalidKeys)) } + if ( + (dev || isBuildTimeSSG) && + !isSerializableProps(pathname, 'getServerSideProps', data.props) + ) { + // this fn should throw an error instead of ever returning `false` + throw new Error( + 'invariant: getServerSideProps did not return valid props. Please report this.' + ) + } + props.pageProps = data.props ;(renderOpts as any).pageData = props } diff --git a/test/integration/getserversideprops-preview/pages/index.js b/test/integration/getserversideprops-preview/pages/index.js index 848b6a14138e384..bc556a1e7dc1632 100644 --- a/test/integration/getserversideprops-preview/pages/index.js +++ b/test/integration/getserversideprops-preview/pages/index.js @@ -1,5 +1,11 @@ export function getServerSideProps({ preview, previewData }) { - return { props: { hasProps: true, preview, previewData } } + return { + props: { + hasProps: true, + preview: !!preview, + previewData: previewData || null, + }, + } } export default function({ hasProps, preview, previewData }) { diff --git a/test/integration/getserversideprops-preview/test/index.test.js b/test/integration/getserversideprops-preview/test/index.test.js index d210e6611ed7ea9..289160dfa3df223 100644 --- a/test/integration/getserversideprops-preview/test/index.test.js +++ b/test/integration/getserversideprops-preview/test/index.test.js @@ -53,14 +53,14 @@ function runTests(startServer = nextStart) { const html = await renderViaHTTP(appPort, '/') const { nextData, pre } = getData(html) expect(nextData).toMatchObject({ isFallback: false }) - expect(pre).toBe('undefined and undefined') + expect(pre).toBe('false and null') }) it('should return page on second request', async () => { const html = await renderViaHTTP(appPort, '/') const { nextData, pre } = getData(html) expect(nextData).toMatchObject({ isFallback: false }) - expect(pre).toBe('undefined and undefined') + expect(pre).toBe('false and null') }) let previewCookieString @@ -198,9 +198,7 @@ function runTests(startServer = nextStart) { await browser.get(`http://localhost:${appPort}/`) await browser.waitForElementByCss('#props-pre') - expect(await browser.elementById('props-pre').text()).toBe( - 'undefined and undefined' - ) + expect(await browser.elementById('props-pre').text()).toBe('false and null') }) afterAll(async () => { @@ -219,7 +217,7 @@ const startServerlessEmulator = async (dir, port) => { return initNextServerScript(scriptPath, /ready on/i, env) } -describe('Prerender Preview Mode', () => { +describe('ServerSide Props Preview Mode', () => { describe('Development Mode', () => { beforeAll(async () => { await fs.remove(nextConfigPath) diff --git a/test/integration/getserversideprops/pages/index.js b/test/integration/getserversideprops/pages/index.js index 80ce2375b131c27..7db7e529a58bfbf 100644 --- a/test/integration/getserversideprops/pages/index.js +++ b/test/integration/getserversideprops/pages/index.js @@ -14,6 +14,10 @@ const Page = ({ world, time }) => { <>

hello {world}

time: {time} + + to non-json + +
to another diff --git a/test/integration/getserversideprops/pages/non-json.js b/test/integration/getserversideprops/pages/non-json.js new file mode 100644 index 000000000000000..dc418a32cf22b34 --- /dev/null +++ b/test/integration/getserversideprops/pages/non-json.js @@ -0,0 +1,11 @@ +export async function getServerSideProps() { + return { + props: { time: new Date() }, + } +} + +const Page = ({ time }) => { + return

hello {time.toString()}

+} + +export default Page diff --git a/test/integration/getserversideprops/test/index.test.js b/test/integration/getserversideprops/test/index.test.js index 613e58760a1c45f..1f3e58cd4c10144 100644 --- a/test/integration/getserversideprops/test/index.test.js +++ b/test/integration/getserversideprops/test/index.test.js @@ -1,21 +1,23 @@ /* eslint-env jest */ /* global jasmine */ -import fs from 'fs-extra' -import { join } from 'path' -import webdriver from 'next-webdriver' import cheerio from 'cheerio' import escapeRegex from 'escape-string-regexp' +import fs from 'fs-extra' import { - renderViaHTTP, + check, fetchViaHTTP, findPort, - launchApp, + getBrowserBodyText, killApp, - waitFor, + launchApp, nextBuild, nextStart, normalizeRegEx, + renderViaHTTP, + waitFor, } from 'next-test-utils' +import webdriver from 'next-webdriver' +import { join } from 'path' jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2 const appDir = join(__dirname, '..') @@ -76,6 +78,12 @@ const expectedManifestRoutes = () => [ ), page: '/invalid-keys', }, + { + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/non-json.json$` + ), + page: '/non-json', + }, { dataRouteRegex: normalizeRegEx( `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/something.json$` @@ -369,6 +377,23 @@ const runTests = (dev = false) => { `Keys that need to be moved: world, query, params, time, random` ) }) + + it('should show error for invalid JSON returned from getServerSideProps', async () => { + const html = await renderViaHTTP(appPort, '/non-json') + expect(html).toContain( + 'Error serializing `.time` returned from `getServerSideProps`' + ) + }) + + it('should show error for invalid JSON returned from getStaticProps on CST', async () => { + const browser = await webdriver(appPort, '/') + await browser.elementByCss('#non-json').click() + + await check( + () => getBrowserBodyText(browser), + /Error serializing `.time` returned from `getServerSideProps`/ + ) + }) } else { it('should not fetch data on mount', async () => { const browser = await webdriver(appPort, '/blog/post-100') @@ -396,6 +421,18 @@ const runTests = (dev = false) => { ) expect(res.headers.get('cache-control')).toContain('no-cache') }) + + it('should not show error for invalid JSON returned from getServerSideProps', async () => { + const html = await renderViaHTTP(appPort, '/non-json') + expect(html).not.toContain('Error serializing') + expect(html).toContain('hello ') + }) + + it('should not show error for invalid JSON returned from getStaticProps on CST', async () => { + const browser = await webdriver(appPort, '/') + await browser.elementByCss('#non-json').click() + await check(() => getBrowserBodyText(browser), /hello /) + }) } } diff --git a/test/integration/prerender-preview/pages/index.js b/test/integration/prerender-preview/pages/index.js index 8fa4d03b6b519a8..26d417d3b2b5b96 100644 --- a/test/integration/prerender-preview/pages/index.js +++ b/test/integration/prerender-preview/pages/index.js @@ -1,5 +1,11 @@ export function getStaticProps({ preview, previewData }) { - return { props: { hasProps: true, preview, previewData } } + return { + props: { + hasProps: true, + preview: !!preview, + previewData: previewData || null, + }, + } } export default function({ hasProps, preview, previewData }) { diff --git a/test/integration/prerender-preview/test/index.test.js b/test/integration/prerender-preview/test/index.test.js index 9c40640c7921222..a8abd24ccfc71b3 100644 --- a/test/integration/prerender-preview/test/index.test.js +++ b/test/integration/prerender-preview/test/index.test.js @@ -53,14 +53,14 @@ function runTests(startServer = nextStart) { const html = await renderViaHTTP(appPort, '/') const { nextData, pre } = getData(html) expect(nextData).toMatchObject({ isFallback: false }) - expect(pre).toBe('undefined and undefined') + expect(pre).toBe('false and null') }) it('should return prerendered page on second request', async () => { const html = await renderViaHTTP(appPort, '/') const { nextData, pre } = getData(html) expect(nextData).toMatchObject({ isFallback: false }) - expect(pre).toBe('undefined and undefined') + expect(pre).toBe('false and null') }) it('should throw error when setting too large of preview data', async () => { @@ -198,9 +198,7 @@ function runTests(startServer = nextStart) { await browser.get(`http://localhost:${appPort}/`) await browser.waitForElementByCss('#props-pre') - expect(await browser.elementById('props-pre').text()).toBe( - 'undefined and undefined' - ) + expect(await browser.elementById('props-pre').text()).toBe('false and null') }) afterAll(async () => { diff --git a/test/integration/prerender/pages/index.js b/test/integration/prerender/pages/index.js index b1e2a18e4dab11d..59b333fddfe53d4 100644 --- a/test/integration/prerender/pages/index.js +++ b/test/integration/prerender/pages/index.js @@ -15,6 +15,10 @@ const Page = ({ world, time }) => { {/*
idk
*/}

hello {world}

time: {time} + + to non-json + +
to another diff --git a/test/integration/prerender/pages/non-json/[p].js b/test/integration/prerender/pages/non-json/[p].js new file mode 100644 index 000000000000000..e4f27b95f65c9bc --- /dev/null +++ b/test/integration/prerender/pages/non-json/[p].js @@ -0,0 +1,21 @@ +import { useRouter } from 'next/router' + +export async function getStaticProps() { + return { + props: { time: new Date() }, + } +} + +export async function getStaticPaths() { + return { paths: [], fallback: true } +} + +const Page = ({ time }) => { + const { isFallback } = useRouter() + + if (isFallback) return null + + return

hello {time.toString()}

+} + +export default Page diff --git a/test/integration/prerender/test/index.test.js b/test/integration/prerender/test/index.test.js index dc9a62a4521556f..13cff47a1ffc1c6 100644 --- a/test/integration/prerender/test/index.test.js +++ b/test/integration/prerender/test/index.test.js @@ -6,7 +6,9 @@ import fs from 'fs-extra' import { check, fetchViaHTTP, + File, findPort, + getBrowserBodyText, getReactErrorOverlayContent, initNextServerScript, killApp, @@ -682,6 +684,40 @@ const runTests = (dev = false, looseMode = false) => { const curRandom = await browser.elementByCss('#random').text() expect(curRandom).toBe(initialRandom + '') }) + + it('should show fallback before invalid JSON is returned from getStaticProps', async () => { + const html = await renderViaHTTP(appPort, '/non-json/foobar') + expect(html).toContain('"isFallback":true') + }) + + it('should show error for invalid JSON returned from getStaticProps on SSR', async () => { + const browser = await webdriver(appPort, '/non-json/direct') + + // FIXME: enable this + // expect(await getReactErrorOverlayContent(browser)).toMatch( + // /Error serializing `.time` returned from `getStaticProps`/ + // ) + + // FIXME: disable this + expect(await getReactErrorOverlayContent(browser)).toMatch( + /Failed to load static props/ + ) + }) + + it('should show error for invalid JSON returned from getStaticProps on CST', async () => { + const browser = await webdriver(appPort, '/') + await browser.elementByCss('#non-json').click() + + // FIXME: enable this + // expect(await getReactErrorOverlayContent(browser)).toMatch( + // /Error serializing `.time` returned from `getStaticProps`/ + // ) + + // FIXME: disable this + expect(await getReactErrorOverlayContent(browser)).toMatch( + /Failed to load static props/ + ) + }) } else { if (!looseMode) { it('should should use correct caching headers for a no-revalidate page', async () => { @@ -692,6 +728,18 @@ const runTests = (dev = false, looseMode = false) => { const initialHtml = await initialRes.text() expect(initialHtml).toMatch(/hello.*?world/) }) + + it('should not show error for invalid JSON returned from getStaticProps on SSR', async () => { + const browser = await webdriver(appPort, '/non-json/direct') + + await check(() => getBrowserBodyText(browser), /hello /) + }) + + it('should show error for invalid JSON returned from getStaticProps on CST', async () => { + const browser = await webdriver(appPort, '/') + await browser.elementByCss('#non-json').click() + await check(() => getBrowserBodyText(browser), /hello /) + }) } it('outputs dataRoutes in routes-manifest correctly', async () => { @@ -762,6 +810,14 @@ const runTests = (dev = false, looseMode = false) => { ), page: '/default-revalidate', }, + { + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapeRegex( + buildId + )}\\/non\\-json\\/([^\\/]+?)\\.json$` + ), + page: '/non-json/[p]', + }, { dataRouteRegex: normalizeRegEx( `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/something.json$` @@ -817,6 +873,14 @@ const runTests = (dev = false, looseMode = false) => { '^\\/blog\\/([^\\/]+?)\\/([^\\/]+?)(?:\\/)?$' ), }, + '/non-json/[p]': { + dataRoute: `/_next/data/${buildId}/non-json/[p].json`, + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapedBuildId}\\/non\\-json\\/([^\\/]+?)\\.json$` + ), + fallback: '/non-json/[p].html', + routeRegex: normalizeRegEx('^\\/non\\-json\\/([^\\/]+?)(?:\\/)?$'), + }, '/user/[user]/profile': { fallback: '/user/[user]/profile.html', dataRoute: `/_next/data/${buildId}/user/[user]/profile.json`, @@ -1083,6 +1147,23 @@ describe('SSG Prerender', () => { 'You can not use getInitialProps with getStaticProps' ) }) + + it('should show serialization error during build', async () => { + await fs.remove(join(appDir, '.next')) + + const nonJsonPage = join(appDir, 'pages/non-json/[p].js') + const f = new File(nonJsonPage) + try { + f.replace('paths: []', `paths: [{ params: { p: 'testing' } }]`) + + const { stderr } = await nextBuild(appDir, [], { stderr: true }) + expect(stderr).toContain( + 'Error serializing `.time` returned from `getStaticProps` in "/non-json/[p]".' + ) + } finally { + f.restore() + } + }) }) describe('enumlated serverless mode', () => { diff --git a/test/unit/is-serializable-props.test.js b/test/unit/is-serializable-props.test.js new file mode 100644 index 000000000000000..55c1a325126cf17 --- /dev/null +++ b/test/unit/is-serializable-props.test.js @@ -0,0 +1,190 @@ +/* eslint-env jest */ +import { isSerializableProps } from 'next/dist/lib/is-serializable-props' + +describe('isSerializableProps', () => { + it('handles null and undefined props', () => { + expect(() => isSerializableProps('/', 'test', null)) + .toThrowErrorMatchingInlineSnapshot(` +"Error serializing props returned from \`test\` in \\"/\\". +Reason: Props must be returned as a plain object from test: \`{ props: { ... } }\`." +`) + + expect(() => isSerializableProps('/', 'test', undefined)) + .toThrowErrorMatchingInlineSnapshot(` +"Error serializing props returned from \`test\` in \\"/\\". +Reason: Props must be returned as a plain object from test: \`{ props: { ... } }\`." +`) + }) + + it('allows empty props', () => { + expect(isSerializableProps('/', 'test', {})).toBe(true) + }) + + it('allows all different types of props', () => { + expect( + isSerializableProps('/', 'test', { + str: 'foobar', + bool: true, + bool2: false, + num: 0, + numn1: -1, + num5: 5, + noop: null, + arr: [ + 'f', + true, + false, + -5, + -1, + 0, + 1, + 5, + null, + {}, + { + str: 'foobar', + bool: true, + bool2: false, + num: 0, + numn1: -1, + num5: 5, + noop: null, + }, + ], + obj1: { + str: 'foobar', + bool: true, + bool2: false, + num: 0, + numn1: -1, + num5: 5, + noop: null, + arr: [ + 'f', + true, + false, + -5, + -1, + 0, + 1, + 5, + null, + {}, + { + str: 'foobar', + bool: true, + bool2: false, + num: 0, + numn1: -1, + num5: 5, + noop: null, + }, + ], + }, + }) + ).toBe(true) + }) + + it('disallows top-level non-serializable types', () => { + expect(() => isSerializableProps('/', 'test', { toplevel: new Date() })) + .toThrowErrorMatchingInlineSnapshot(` +"Error serializing \`.toplevel\` returned from \`test\` in \\"/\\". +Reason: \`object\` (\\"[object Date]\\") cannot be serialized as JSON. Please only return JSON serializable data types." +`) + + expect(() => isSerializableProps('/', 'test', { toplevel: class A {} })) + .toThrowErrorMatchingInlineSnapshot(` +"Error serializing \`.toplevel\` returned from \`test\` in \\"/\\". +Reason: \`function\` cannot be serialized as JSON. Please only return JSON serializable data types." +`) + + expect(() => isSerializableProps('/', 'test', { toplevel: undefined })) + .toThrowErrorMatchingInlineSnapshot(` +"Error serializing \`.toplevel\` returned from \`test\` in \\"/\\". +Reason: \`undefined\` cannot be serialized as JSON. Please use \`null\` or omit this value all together." +`) + + expect(() => + isSerializableProps('/', 'test', { toplevel: Symbol('FOOBAR') }) + ).toThrowErrorMatchingInlineSnapshot(` +"Error serializing \`.toplevel\` returned from \`test\` in \\"/\\". +Reason: \`symbol\` cannot be serialized as JSON. Please only return JSON serializable data types." +`) + + expect(() => isSerializableProps('/', 'test', { toplevel: function() {} })) + .toThrowErrorMatchingInlineSnapshot(` +"Error serializing \`.toplevel\` returned from \`test\` in \\"/\\". +Reason: \`function\` cannot be serialized as JSON. Please only return JSON serializable data types." +`) + }) + + it('diallows nested non-serializable types', () => { + expect(() => + isSerializableProps('/', 'test', { k: { a: [1, { n: new Date() }] } }) + ).toThrowErrorMatchingInlineSnapshot(` +"Error serializing \`.k.a[1].n\` returned from \`test\` in \\"/\\". +Reason: \`object\` (\\"[object Date]\\") cannot be serialized as JSON. Please only return JSON serializable data types." +`) + + expect(() => + isSerializableProps('/', 'test', { k: { a: [1, { n: class A {} }] } }) + ).toThrowErrorMatchingInlineSnapshot(` +"Error serializing \`.k.a[1].n\` returned from \`test\` in \\"/\\". +Reason: \`function\` cannot be serialized as JSON. Please only return JSON serializable data types." +`) + + expect(() => isSerializableProps('/', 'test', { k: { a: [1, undefined] } })) + .toThrowErrorMatchingInlineSnapshot(` +"Error serializing \`.k.a[1]\` returned from \`test\` in \\"/\\". +Reason: \`undefined\` cannot be serialized as JSON. Please use \`null\` or omit this value all together." +`) + + expect(() => + isSerializableProps('/', 'test', { k: { n: Symbol('FOOBAR') } }) + ).toThrowErrorMatchingInlineSnapshot(` +"Error serializing \`.k.n\` returned from \`test\` in \\"/\\". +Reason: \`symbol\` cannot be serialized as JSON. Please only return JSON serializable data types." +`) + + expect(() => + isSerializableProps('/', 'test', { k: { a: [function() {}] } }) + ).toThrowErrorMatchingInlineSnapshot(` +"Error serializing \`.k.a[0]\` returned from \`test\` in \\"/\\". +Reason: \`function\` cannot be serialized as JSON. Please only return JSON serializable data types." +`) + }) + + it('can handle obj circular refs', () => { + const obj = { foo: 'bar', test: true } + obj.child = obj + + expect(() => isSerializableProps('/', 'test', obj)) + .toThrowErrorMatchingInlineSnapshot(` +"Error serializing \`.child\` returned from \`test\` in \\"/\\". +Reason: Circular references cannot be expressed in JSON." +`) + + expect(() => isSerializableProps('/', 'test', { k: [obj] })) + .toThrowErrorMatchingInlineSnapshot(` +"Error serializing \`.k[0].child\` returned from \`test\` in \\"/\\". +Reason: Circular references cannot be expressed in JSON." +`) + }) + + it('can handle arr circular refs', () => { + const arr = [{ foo: 'bar' }, true] + arr.push(arr) + + expect(() => isSerializableProps('/', 'test', { arr })) + .toThrowErrorMatchingInlineSnapshot(` +"Error serializing \`.arr[2]\` returned from \`test\` in \\"/\\". +Reason: Circular references cannot be expressed in JSON." +`) + + expect(() => isSerializableProps('/', 'test', { k: [{ arr }] })) + .toThrowErrorMatchingInlineSnapshot(` +"Error serializing \`.k[0].arr[2]\` returned from \`test\` in \\"/\\". +Reason: Circular references cannot be expressed in JSON." +`) + }) +})