Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(json)!: add json.stringify: 'auto' and make that the default #18303

Merged
merged 9 commits into from
Oct 23, 2024
6 changes: 3 additions & 3 deletions docs/config/shared-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,12 +343,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.
bluwy marked this conversation as resolved.
Show resolved Hide resolved

## esbuild

Expand Down
114 changes: 112 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,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(')
})
})
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
74 changes: 59 additions & 15 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.
* @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
Expand All @@ -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,
}),
Expand All @@ -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,
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
5 changes: 4 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,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',
)
})

Expand Down