diff --git a/docs/config/shared-options.md b/docs/config/shared-options.md index 3502d31a26ffb2..b803e36cc166ca 100644 --- a/docs/config/shared-options.md +++ b/docs/config/shared-options.md @@ -345,12 +345,12 @@ Whether to support named imports from `.json` files. ## json.stringify -- **Type:** `boolean` -- **Default:** `false` +- **Type:** `boolean | 'auto'` +- **Default:** `'auto'` If set to `true`, imported JSON will be transformed into `export default JSON.parse("...")` which is significantly more performant than Object literals, especially when the JSON file is large. -Enabling this disables named imports. +If set to `'auto'`, the data will be stringified only if [the data is bigger than 10kB](https://v8.dev/blog/cost-of-javascript-2019#json:~:text=A%20good%20rule%20of%20thumb%20is%20to%20apply%20this%20technique%20for%20objects%20of%2010%20kB%20or%20larger). ## esbuild diff --git a/packages/vite/src/node/__tests__/plugins/json.spec.ts b/packages/vite/src/node/__tests__/plugins/json.spec.ts index b95d31eefa4942..18438a007f2bec 100644 --- a/packages/vite/src/node/__tests__/plugins/json.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/json.spec.ts @@ -1,5 +1,9 @@ -import { expect, test } from 'vitest' -import { extractJsonErrorPosition } from '../../plugins/json' +import { describe, expect, test } from 'vitest' +import { + type JsonOptions, + extractJsonErrorPosition, + jsonPlugin, +} from '../../plugins/json' const getErrorMessage = (input: string) => { try { @@ -24,3 +28,109 @@ test('can extract json error position', () => { ) } }) + +describe('transform', () => { + const transform = (input: string, opts: JsonOptions, isBuild: boolean) => { + const plugin = jsonPlugin(opts, isBuild) + return (plugin.transform! as Function)(input, 'test.json').code + } + + test('namedExports: true, stringify: false', () => { + const actual = transform( + '{"a":1,\n"🫠": "",\n"const": false}', + { namedExports: true, stringify: false }, + false, + ) + expect(actual).toMatchInlineSnapshot(` + "export const a = 1; + export default { + a: a, + "🫠": "", + "const": false + }; + " + `) + }) + + test('namedExports: false, stringify: false', () => { + const actual = transform( + '{"a":1,\n"🫠": "",\n"const": false}', + { namedExports: false, stringify: false }, + false, + ) + expect(actual).toMatchInlineSnapshot(` + "export default { + a: 1, + "🫠": "", + "const": false + };" + `) + }) + + test('namedExports: true, stringify: true', () => { + const actual = transform( + '{"a":1,\n"🫠": "",\n"const": false}', + { namedExports: true, stringify: true }, + false, + ) + expect(actual).toMatchInlineSnapshot(` + "export const a = 1; + export default { + a, + "🫠": "", + "const": false, + }; + " + `) + }) + + test('namedExports: false, stringify: true', () => { + const actualDev = transform( + '{"a":1,\n"🫠": "",\n"const": false}', + { namedExports: false, stringify: true }, + false, + ) + expect(actualDev).toMatchInlineSnapshot( + `"export default JSON.parse("{\\"a\\":1,\\n\\"🫠\\": \\"\\",\\n\\"const\\": false}")"`, + ) + + const actualBuild = transform( + '{"a":1,\n"🫠": "",\n"const": false}', + { namedExports: false, stringify: true }, + true, + ) + expect(actualBuild).toMatchInlineSnapshot( + `"export default JSON.parse("{\\"a\\":1,\\"🫠\\":\\"\\",\\"const\\":false}")"`, + ) + }) + + test("namedExports: true, stringify: 'auto'", () => { + const actualSmall = transform( + '{"a":1,\n"🫠": "",\n"const": false}', + { namedExports: true, stringify: 'auto' }, + false, + ) + expect(actualSmall).toMatchInlineSnapshot(` + "export const a = 1; + export default { + a, + "🫠": "", + "const": false, + }; + " + `) + const actualLargeNonObject = transform( + `{"a":1,\n"🫠": "${'vite'.repeat(3000)}",\n"const": false}`, + { namedExports: true, stringify: 'auto' }, + false, + ) + expect(actualLargeNonObject).not.toContain('JSON.parse(') + + const actualLarge = transform( + `{"a":1,\n"🫠": {\n"foo": "${'vite'.repeat(3000)}"\n},\n"const": false}`, + { namedExports: true, stringify: 'auto' }, + false, + ) + expect(actualLarge).toContain('JSON.parse(') + }) +}) diff --git a/packages/vite/src/node/plugins/index.ts b/packages/vite/src/node/plugins/index.ts index 7d4efd6a024b61..01e0c140f0bf3e 100644 --- a/packages/vite/src/node/plugins/index.ts +++ b/packages/vite/src/node/plugins/index.ts @@ -77,6 +77,7 @@ export async function resolvePlugins( jsonPlugin( { namedExports: true, + stringify: 'auto', ...config.json, }, isBuild, diff --git a/packages/vite/src/node/plugins/json.ts b/packages/vite/src/node/plugins/json.ts index 5de486cf59cbcd..261250ba0227f5 100644 --- a/packages/vite/src/node/plugins/json.ts +++ b/packages/vite/src/node/plugins/json.ts @@ -6,7 +6,7 @@ * https://github.com/rollup/plugins/blob/master/LICENSE */ -import { dataToEsm } from '@rollup/pluginutils' +import { dataToEsm, makeLegalIdentifier } from '@rollup/pluginutils' import { SPECIAL_QUERY_RE } from '../constants' import type { Plugin } from '../plugin' import { stripBomTag } from '../utils' @@ -19,10 +19,11 @@ export interface JsonOptions { namedExports?: boolean /** * Generate performant output as JSON.parse("stringified"). - * Enabling this will disable namedExports. - * @default false + * + * When set to 'auto', the data will be stringified only if the data is bigger than 10kB. + * @default 'auto' */ - stringify?: boolean + stringify?: boolean | 'auto' } // Custom json filter for vite @@ -47,24 +48,53 @@ export function jsonPlugin( json = stripBomTag(json) try { - if (options.stringify) { - if (isBuild) { + if (options.stringify !== false) { + if (options.namedExports) { + const parsed = JSON.parse(json) + if (typeof parsed === 'object' && parsed != null) { + const keys = Object.keys(parsed) + + let code = '' + let defaultObjectCode = '{\n' + for (const key of keys) { + if (key === makeLegalIdentifier(key)) { + code += `export const ${key} = ${serializeValue(parsed[key])};\n` + defaultObjectCode += ` ${key},\n` + } else { + defaultObjectCode += ` ${JSON.stringify(key)}: ${serializeValue(parsed[key])},\n` + } + } + defaultObjectCode += '}' + + code += `export default ${defaultObjectCode};\n` + return { + code, + map: { mappings: '' }, + } + } + } + + if ( + options.stringify === true || + // use 10kB as a threshold + // https://v8.dev/blog/cost-of-javascript-2019#:~:text=A%20good%20rule%20of%20thumb%20is%20to%20apply%20this%20technique%20for%20objects%20of%2010%20kB%20or%20larger + (options.stringify === 'auto' && json.length > 10 * 1000) + ) { + // during build, parse then double-stringify to remove all + // unnecessary whitespaces to reduce bundle size. + if (isBuild) { + json = JSON.stringify(JSON.parse(json)) + } + return { - // during build, parse then double-stringify to remove all - // unnecessary whitespaces to reduce bundle size. - code: `export default JSON.parse(${JSON.stringify( - JSON.stringify(JSON.parse(json)), - )})`, + code: `export default JSON.parse(${JSON.stringify(json)})`, map: { mappings: '' }, } - } else { - return `export default JSON.parse(${JSON.stringify(json)})` } } - const parsed = JSON.parse(json) return { - code: dataToEsm(parsed, { + code: dataToEsm(JSON.parse(json), { preferConst: true, namedExports: options.namedExports, }), @@ -81,6 +111,20 @@ export function jsonPlugin( } } +function serializeValue(value: unknown): string { + const valueAsString = JSON.stringify(value) + // use 10kB as a threshold + // https://v8.dev/blog/cost-of-javascript-2019#:~:text=A%20good%20rule%20of%20thumb%20is%20to%20apply%20this%20technique%20for%20objects%20of%2010%20kB%20or%20larger + if ( + typeof value === 'object' && + value != null && + valueAsString.length > 10 * 1000 + ) { + return `JSON.parse(${JSON.stringify(valueAsString)})` + } + return valueAsString +} + export function extractJsonErrorPosition( errorMessage: string, inputLength: number, diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 29244d0b54b8df..ab15ac5a4e4b0e 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -542,7 +542,13 @@ export async function _createServer( url: string, originalCode = code, ) { - return ssrTransform(code, inMap, url, originalCode, server.config) + return ssrTransform(code, inMap, url, originalCode, { + json: { + stringify: + config.json?.stringify === true && + config.json.namedExports !== true, + }, + }) }, // environment.transformRequest and .warmupRequest don't take an options param for now, // so the logic and error handling needs to be duplicated here. diff --git a/packages/vite/src/node/server/transformRequest.ts b/packages/vite/src/node/server/transformRequest.ts index 666521867a1149..66e81559bf4aca 100644 --- a/packages/vite/src/node/server/transformRequest.ts +++ b/packages/vite/src/node/server/transformRequest.ts @@ -408,14 +408,15 @@ async function loadAndTransform( if (environment._closing && environment.config.dev.recoverable) throwClosedServerError() + const topLevelConfig = environment.getTopLevelConfig() const result = environment.config.dev.moduleRunnerTransform - ? await ssrTransform( - code, - normalizedMap, - url, - originalCode, - environment.getTopLevelConfig(), - ) + ? await ssrTransform(code, normalizedMap, url, originalCode, { + json: { + stringify: + topLevelConfig.json?.stringify === true && + topLevelConfig.json.namedExports !== true, + }, + }) : ({ code, map: normalizedMap, diff --git a/playground/json/__tests__/ssr/json-ssr.spec.ts b/playground/json/__tests__/ssr/json-ssr.spec.ts index 5efbeac7da12d6..374e7ca027c6fe 100644 --- a/playground/json/__tests__/ssr/json-ssr.spec.ts +++ b/playground/json/__tests__/ssr/json-ssr.spec.ts @@ -11,7 +11,10 @@ beforeEach(async () => { test('load json module', async () => { await untilUpdated( () => page.textContent('.fetch-json-module pre'), - 'export default JSON.parse("{\\n \\"hello\\": \\"hi\\"\\n}\\n")', + 'export const hello = "hi";\n' + + 'export default {\n' + + ' hello,\n' + + '};\n', ) })