Skip to content

Commit

Permalink
feat(json)!: add json.stringify: 'auto' and make that the default (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
sapphi-red authored Oct 23, 2024
1 parent a514330 commit b80daa7
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 29 deletions.
6 changes: 3 additions & 3 deletions docs/config/shared-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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 @@ -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.
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

0 comments on commit b80daa7

Please sign in to comment.