Skip to content
116 changes: 114 additions & 2 deletions packages/vite/src/node/__tests__/plugins/json.spec.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -24,3 +28,111 @@ 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 actualDev = transform(
'{"a":1,\n"🫠": "",\n"const": false}',
{ namedExports: true, stringify: true },
false,
)
expect(actualDev).toMatchInlineSnapshot(`
"const default_ = JSON.parse("{\\"a\\":1,\\n\\"🫠\\": \\"\\",\\n\\"const\\": false}");
export default default_;
export const a = default_.a;
"
`)

const actualBuild = transform(
'{"a":1,\n"🫠": "",\n"const": false}',
{ namedExports: true, stringify: true },
true,
)
expect(actualBuild).toMatchInlineSnapshot(`
"const default_ = JSON.parse("{\\"a\\":1,\\"🫠\\":\\"\\",\\"const\\":false}");
export default default_;
export const a = default_.a;
"
`)
})

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: a,
"🫠": "",
"const": false
};
"
`)
const actualLarge = transform(
`{"a":1,\n"🫠": "${'vite'.repeat(3000)}",\n"const": false}`,
{ namedExports: true, stringify: 'auto' },
false,
)
expect(actualLarge).toContain('JSON.parse(')
})
})
1 change: 1 addition & 0 deletions packages/vite/src/node/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export async function resolvePlugins(
jsonPlugin(
{
namedExports: true,
stringify: 'auto',
...config.json,
},
isBuild,
Expand Down
51 changes: 38 additions & 13 deletions packages/vite/src/node/plugins/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -19,10 +19,11 @@ export interface JsonOptions {
namedExports?: boolean
/**
* Generate performant output as JSON.parse("stringified").
* Enabling this will disable namedExports.
*
* When set to 'auto', the data will be stringified only if the data is bigger than 10kB.
* @default false
*/
stringify?: boolean
stringify?: boolean | 'auto'
}

// Custom json filter for vite
Expand All @@ -47,22 +48,46 @@ export function jsonPlugin(
json = stripBomTag(json)

try {
if (options.stringify) {
if (isBuild) {
return {
// during build, parse then double-stringify to remove all
const stringify =
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)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should benchmark this to find out whether the threshold has changed over the last five years.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran the benchmark: https://github.com/sapphi-red/json-parse-benchmark

It's difficult to measure where the threshold is due to the overhead of starting the JS engines. But the perf benefit still exists. Probably fine to go with 10kB for now.


const parsed = JSON.parse(json)

if (stringify) {
const contentCode = isBuild
? // 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)),
)})`,
map: { mappings: '' },
`JSON.parse(${JSON.stringify(JSON.stringify(parsed))})`
: `JSON.parse(${JSON.stringify(json)})`

let code: string
if (options.namedExports) {
let defaultKey = 'default_'
const keys = Object.keys(parsed)
const keysSet = new Set(keys)
while (keysSet.has(defaultKey)) {
defaultKey += '_'
}

code = `const ${defaultKey} = ${contentCode};\nexport default default_;\n`
for (const key of keys) {
if (key === makeLegalIdentifier(key)) {
code += `export const ${key} = ${defaultKey}.${key};\n`
}
}
} else {
return `export default JSON.parse(${JSON.stringify(json)})`
code = `export default ${contentCode}`
}

return {
code,
map: { mappings: '' },
}
}

const parsed = JSON.parse(json)
return {
code: dataToEsm(parsed, {
preferConst: true,
Expand Down
8 changes: 7 additions & 1 deletion packages/vite/src/node/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,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.
Expand Down
15 changes: 8 additions & 7 deletions packages/vite/src/node/server/transformRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion playground/json/__tests__/ssr/json-ssr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ beforeEach(async () => {
test('load json module', async () => {
await untilUpdated(
() => page.textContent('.fetch-json-module pre'),
'export default JSON.parse("{\\n \\"hello\\": \\"hi\\"\\n}\\n")',
'const default_ = JSON.parse("{\\n \\"hello\\": \\"hi\\"\\n}\\n");\n' +
'export default default_;\n' +
'export const hello = default_.hello;',
)
})

Expand Down