diff --git a/benchmark/react/cases/006-fuzz-complex-1000/index.tsx b/benchmark/react/cases/006-fuzz-complex-1000/index.tsx new file mode 100644 index 0000000000..4dbbc46e45 --- /dev/null +++ b/benchmark/react/cases/006-fuzz-complex-1000/index.tsx @@ -0,0 +1,39 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { root, useEffect, useState } from '@lynx-js/react'; +import { process } from '@lynx-js/react/internal'; + +import { genRandom, genValue } from '../../plugins/gen.js'; + +const F = __GENERATE_JSX__(0, 1000)({ useState }, genRandom, genValue); + +function App() { + const [stopBenchmark, setStopBenchmark] = useState(false); + const [seed, setSeed] = useState(100); + useEffect(() => { + setTimeout(() => { + setSeed(101); + setStopBenchmark(true); + Codspeed.startBenchmark(); + process(); + Codspeed.stopBenchmark(); + Codspeed.setExecutedBenchmark( + `${__REPO_FILEPATH__}::${__webpack_chunkname__}-preactProcess`, + ); + }, 0); + }, []); + return ( + <> + + + + ); +} + +runAfterLoadScript(() => { + root.render( + , + ); +}); diff --git a/benchmark/react/cases/006-fuzz-complex-2000/index.tsx b/benchmark/react/cases/006-fuzz-complex-2000/index.tsx new file mode 100644 index 0000000000..2b6187f6f8 --- /dev/null +++ b/benchmark/react/cases/006-fuzz-complex-2000/index.tsx @@ -0,0 +1,39 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { root, useEffect, useState } from '@lynx-js/react'; +import { process } from '@lynx-js/react/internal'; + +import { genRandom, genValue } from '../../plugins/gen.js'; + +const F = __GENERATE_JSX__(0, 2000)({ useState }, genRandom, genValue); + +function App() { + const [stopBenchmark, setStopBenchmark] = useState(false); + const [seed, setSeed] = useState(100); + useEffect(() => { + setTimeout(() => { + setSeed(101); + setStopBenchmark(true); + Codspeed.startBenchmark(); + process(); + Codspeed.stopBenchmark(); + Codspeed.setExecutedBenchmark( + `${__REPO_FILEPATH__}::${__webpack_chunkname__}-preactProcess`, + ); + }, 0); + }, []); + return ( + <> + + + + ); +} + +runAfterLoadScript(() => { + root.render( + , + ); +}); diff --git a/benchmark/react/lynx.config.js b/benchmark/react/lynx.config.ts similarity index 83% rename from benchmark/react/lynx.config.js rename to benchmark/react/lynx.config.ts index d9b2b81b37..19fc3f98b9 100644 --- a/benchmark/react/lynx.config.js +++ b/benchmark/react/lynx.config.ts @@ -6,6 +6,7 @@ import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'; import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'; import { defineConfig } from '@lynx-js/rspeedy'; +import { pluginGenJSX } from './plugins/pluginGenJSX.mjs'; import { pluginRepoFilePath } from './plugins/pluginRepoFilePath.mjs'; import { pluginScriptLoad } from './plugins/pluginScriptLoad.mjs'; @@ -45,9 +46,18 @@ export default defineConfig({ '005-load-script': [ './cases/005-load-script/index.tsx', ], + '006-fuzz-complex-1000': [ + './src/patchProfile.ts', + './cases/006-fuzz-complex-1000/index.tsx', + ], + '006-fuzz-complex-2000': [ + './src/patchProfile.ts', + './cases/006-fuzz-complex-2000/index.tsx', + ], }, }, plugins: [ + pluginGenJSX(), pluginRepoFilePath(), pluginReactLynx({ enableParallelElement: false, diff --git a/benchmark/react/package.json b/benchmark/react/package.json index 8218d17611..25171b0b07 100644 --- a/benchmark/react/package.json +++ b/benchmark/react/package.json @@ -10,6 +10,8 @@ "bench:003-hello-list": "benchx_cli run dist/003-hello-list.lynx.bundle --wait-for-id=stop-benchmark-true", "bench:004-various-update": "benchx_cli run dist/004-various-update.lynx.bundle --wait-for-id=stop-benchmark-true", "bench:005-load-script": "benchx_cli run dist/005-load-script.lynx.bundle", + "bench:006-fuzz-complex-1000": "benchx_cli run dist/006-fuzz-complex-1000.lynx.bundle --wait-for-id=stop-benchmark-true", + "bench:006-fuzz-complex-2000": "benchx_cli run dist/006-fuzz-complex-2000.lynx.bundle --wait-for-id=stop-benchmark-true", "build": "rspeedy build", "dev": "rspeedy dev", "perfetto": "pnpm run --sequential --stream --aggregate-output '/^perfetto:.*/'", @@ -18,6 +20,8 @@ "perfetto:003-hello-list": "benchx_cli -o dist/003-hello-list.ptrace run dist/003-hello-list.lynx.bundle --wait-for-id=stop-benchmark-true", "perfetto:004-various-update": "benchx_cli -o dist/004-various-update.ptrace run dist/004-various-update.lynx.bundle --wait-for-id=stop-benchmark-true", "perfetto:005-load-script": "benchx_cli -o dist/005-load-script.ptrace run dist/005-load-script.lynx.bundle", + "perfetto:006-fuzz-complex-1000": "benchx_cli -o dist/006-fuzz-complex-1000.ptrace run dist/006-fuzz-complex-1000.lynx.bundle --wait-for-id=stop-benchmark-true", + "perfetto:006-fuzz-complex-2000": "benchx_cli -o dist/006-fuzz-complex-2000.ptrace run dist/006-fuzz-complex-2000.lynx.bundle --wait-for-id=stop-benchmark-true", "test": "echo 'No tests specified'" }, "dependencies": { @@ -34,6 +38,7 @@ "@lynx-js/rspeedy": "workspace:*", "@lynx-js/type-element-api": "0.0.2", "@lynx-js/types": "3.4.11", - "@types/react": "^18.3.23" + "@types/react": "^18.3.23", + "human-id": "4.1.1" } } diff --git a/benchmark/react/plugins/gen.ts b/benchmark/react/plugins/gen.ts new file mode 100644 index 0000000000..e087503a2b --- /dev/null +++ b/benchmark/react/plugins/gen.ts @@ -0,0 +1,464 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { humanId } from 'human-id'; + +const DYNAMIC_ATTR_RATE = 0.3; +const DYNAMIC_ATTR_COUNT_MAX = 10; +const DYNAMIC_TEXT_RATE = 0.7; +const COMPONENT_RATE = 0.2; +const COMPONENT_PROPS_RATE = 0.5; + +const genStr = (r: Random) => { + const oldRandom = Math.random; + try { + Math.random = () => r.random(); + return humanId({ adjectiveCount: 0, addAdverb: false }); + } finally { + Math.random = oldRandom; + } +}; + +const genValue = (r: Random, refVar: RefVar): string => { + if (refVar.type === 'string') { + return `"${genStr(r)}"`; + } else if (refVar.type === 'boolean') { + return pickRandomly(r, ['true', 'false']); + } else if (refVar.type === 'number') { + return ~~(2 ** 31 * r.random()) + ''; + } + + throw new Error('unreachable'); +}; + +const genJsIdentifier = (r: Random, prefix = 'value') => { + const oldRandom = Math.random; + try { + Math.random = () => r.random(); + return `${prefix}${humanId({ adjectiveCount: 1 })}`; + } finally { + Math.random = oldRandom; + } +}; + +interface RefVar { + id: string; + type: 'number' | 'string' | 'boolean'; +} + +interface Decl { + id: string; + code: string; +} + +interface Gen { + code: string; + decls: Array; + refVars: Array; + usedComplexity: number; +} + +const ATTR_TYPES = [ + 'Attr', + 'Dataset', + 'Event', + 'MT_Event', + 'Class', + 'Id', +] as const; + +interface Random { + random: () => number; +} + +const pickRandomly = (r: Random, items: readonly T[]): T => { + return items[~~(r.random() * items.length)]!; +}; + +const divideRandomly = ( + r: Random, + items: readonly T[], + p: number, +): [T[], T[]] => { + const a1: T[] = []; + const a2: T[] = []; + for (const item of items) { + if (r.random() < p) { + a1.push(item); + } else { + a2.push(item); + } + } + + return [a1, a2]; +}; + +const clamp = (num: number, min: number, max: number): number => { + return Math.min(Math.max(num, min), max); +}; + +const genAttr = (r: Random): Gen & { key: string } => { + const refVars: RefVar[] = []; + const addToRefVars = (type: RefVar['type']): string => { + const id = genJsIdentifier(r); + refVars.push({ id, type }); + return id; + }; + + const isDynamic = r.random() < DYNAMIC_ATTR_RATE; + const attrType = pickRandomly(r, [...ATTR_TYPES, 'Attr', 'Attr', 'Attr']); + + switch (attrType) { + case 'Attr': { + const key = `attr-${genStr(r)}`; + if (isDynamic) { + return { + code: `${key}={${addToRefVars('string')}}`, + refVars, + decls: [], + usedComplexity: 2, + key, + }; + } else { + return { + code: `${key}="${genStr(r)}"`, + refVars: [], + decls: [], + usedComplexity: 1, + key, + }; + } + } + case 'Dataset': { + const key = `data-${genStr(r)}`; + if (isDynamic) { + return { + code: `${key}={${addToRefVars('string')}}`, + refVars, + decls: [], + usedComplexity: 2, + key, + }; + } else { + return { + code: `${key}="${genStr(r)}"`, + refVars: [], + decls: [], + usedComplexity: 1, + key, + }; + } + } + case 'Id': { + if (isDynamic) { + return { + code: `id={${addToRefVars('string')}}`, + refVars, + decls: [], + usedComplexity: 2, + key: 'id', + }; + } else { + return { + code: `id="${genStr(r)}"`, + refVars: [], + decls: [], + usedComplexity: 1, + key: 'id', + }; + } + } + case 'Event': { + const eventType = pickRandomly( + r, + [ + 'bind', + 'catch', + 'capture-bind', + 'capture-catch', + 'global-bind', + ] as const, + ); + const key = `${eventType}${genStr(r)}`; + return { + code: `${key}={() => {}}`, + refVars: [], + decls: [], + usedComplexity: 2, + key, + }; + } + case 'MT_Event': { + const eventType = pickRandomly( + r, + [ + 'bind', + 'catch', + 'capture-bind', + 'capture-catch', + 'global-bind', + ] as const, + ); + const key = `main-thread:${eventType}${genStr(r)}`; + return { + code: `${key}={() => { 'main-thread' }}`, + refVars: [], + decls: [], + usedComplexity: 2, + key, + }; + } + case 'Class': + if (isDynamic) { + return { + code: `className={${addToRefVars('string')}}`, + refVars, + decls: [], + usedComplexity: 2, + key: 'className', + }; + } else { + return { + code: `className="${genStr(r)}"`, + refVars: [], + decls: [], + usedComplexity: 1, + key: 'className', + }; + } + default: + throw new Error('unreachable'); + } +}; + +const genFC = (r: Random, complexity: number, inner?: string): Gen => { + let usedComplexity = 0; + + complexity -= 1; + usedComplexity += 1; + + const fcName = genJsIdentifier(r, ''); + + if (complexity <= 0) { + return { + code: `<${fcName} />`, + decls: [{ + code: `const ${fcName} = () => null`, + id: fcName, + }], + refVars: [], + usedComplexity, + }; + } + + if (inner) { + const jsx1 = genJSX(r, ~~(complexity / 2), 1); + const jsx2 = genJSX(r, ~~(complexity / 2), 1); + const [refVars, refStates] = divideRandomly(r, [ + ...jsx1.refVars, + ...jsx2.refVars, + ], COMPONENT_PROPS_RATE); + usedComplexity += jsx1.usedComplexity + jsx2.usedComplexity; + return { + code: `<${fcName} ${ + refVars.map((v) => `${v.id}={${v.id}}`).join(' ') + }>${inner}`, + refVars, + decls: [...jsx1.decls, ...jsx2.decls, { + code: `const ${fcName} = ({ children, ${ + refVars.map(v => v.id).join(', ') + } }) => { ${ + refStates.map(v => `const [${v.id},] = useState(${genValue(r, v)});`) + .join( + '', + ) + } return (${ + pickRandomly(r, [ + () => `{children}${jsx1.code}${jsx2.code}`, + () => `${jsx1.code}{children}${jsx2.code}`, + () => `${jsx1.code}${jsx2.code}{children}`, + ])() + }) }`, + id: fcName, + }], + usedComplexity, + }; + } else { + const jsx = genJSX(r, complexity, 0); + const [refVars, refStates] = divideRandomly( + r, + jsx.refVars, + COMPONENT_PROPS_RATE, + ); + usedComplexity += jsx.usedComplexity; + return { + code: `<${fcName} ${ + refVars.map((v) => `${v.id}={${v.id}}`).join(' ') + } />`, + refVars, + decls: [...jsx.decls, { + code: `const ${fcName} = ({ ${ + refVars.map(v => v.id).join(', ') + } }) => { ${ + refStates.map(v => `const [${v.id},] = useState(${genValue(r, v)});`) + .join( + '', + ) + } return (${jsx.code}) }`, + id: fcName, + }], + usedComplexity, + }; + } +}; + +const genJSX = (r: Random, complexity: number, depth: number): Gen => { + if (complexity <= 0) { + return { + code: '', + decls: [], + refVars: [], + usedComplexity: 0, + }; + } + + let usedComplexity = 0; + let code = ''; + + // const tag = pickRandomly(r, ['view', 'text']); + const tag = 'view'; + complexity -= 1; + usedComplexity += 1; + + code += `<${tag}`; + + const refVars: RefVar[] = []; + const decls: Decl[] = []; + + if (complexity > 0) { + const attrCount = clamp( + ~~(r.random() * DYNAMIC_ATTR_COUNT_MAX), + 0, + ~~(complexity / 2), + ); + const attrKeys = new Set(); + for (let i = 0; i < attrCount;) { + const attr = genAttr(r); + if (attrKeys.has(attr.key)) { + continue; + } + + i++; + code += ` ${attr.code}`; + refVars.push(...attr.refVars); + attrKeys.add(attr.key); + complexity -= attr.usedComplexity; + usedComplexity += attr.usedComplexity; + if (complexity <= 0) { + complexity = 0; + break; + } + } + } + + code += '>'; + + if (complexity > 0) { + while (true) { + // The more deeper the nesting, the more likely to generate a + // ending (a Function Component without children or a text node) + if (r.random() > 1 / 2 ** depth) { + // generate a ending + if (r.random() < COMPONENT_RATE) { + const fc = genFC(r, complexity, undefined); + code += fc.code; + refVars.push(...fc.refVars); + decls.push(...fc.decls); + complexity -= fc.usedComplexity; + usedComplexity += fc.usedComplexity; + } else { + if (r.random() < DYNAMIC_TEXT_RATE && complexity > 2) { + const id = genJsIdentifier(r); + code += `{${id}}`; + refVars.push({ id, type: 'string' }); + complexity -= 2; + usedComplexity += 2; + } else if (complexity > 1) { + code += `${genStr(r)}`; + complexity -= 1; + usedComplexity += 1; + } + } + } else { + // generate a nesting + const jsx = genJSX(r, ~~(complexity * r.random()), depth + 1); + complexity -= jsx.usedComplexity; + usedComplexity += jsx.usedComplexity; + if (r.random() < COMPONENT_RATE) { + const fc = genFC(r, complexity, jsx.code); + code += fc.code; + refVars.push(...fc.refVars, ...jsx.refVars); + decls.push(...fc.decls, ...jsx.decls); + complexity -= fc.usedComplexity; + usedComplexity += fc.usedComplexity; + } else { + code += jsx.code; + refVars.push(...jsx.refVars); + decls.push(...jsx.decls); + } + } + + if (complexity <= 0) { + break; + } + } + } + + code += ``; + + return { + code, + refVars, + decls, + usedComplexity, + }; +}; + +const genRandom = function(randomSeed: number) { + let seed = randomSeed; + return function() { + // Robert Jenkins' 32 bit integer hash function. + seed = ((seed + 0x7ed55d16) + (seed << 12)) & 0xffffffff; + seed = ((seed ^ 0xc761c23c) ^ (seed >>> 19)) & 0xffffffff; + seed = ((seed + 0x165667b1) + (seed << 5)) & 0xffffffff; + seed = ((seed + 0xd3a2646c) ^ (seed << 9)) & 0xffffffff; + seed = ((seed + 0xfd7046c5) + (seed << 3)) & 0xffffffff; + seed = ((seed ^ 0xb55a4f09) ^ (seed >>> 16)) & 0xffffffff; + return (seed & 0xfffffff) / 0x10000000; + }; +}; + +const gen = (randomSeed: number, complexity: number): string => { + const r = { + random: genRandom(randomSeed), + }; + + const g = genJSX(r, complexity, 0); + + return `(({useState}, genRandom, genValue) => { +${g.decls.map(d => d.code).join('\n')} +return ({seed}) => { + const r = { random: genRandom(seed) } + ${ + g.refVars.map(v => `const ${v.id} = genValue(r, { type: "${v.type}" });`) + .join( + '\n', + ) + } + return (${g.code}) +}; +})`; +}; + +export { gen, genValue, genRandom }; diff --git a/benchmark/react/plugins/pluginGenJSX.mjs b/benchmark/react/plugins/pluginGenJSX.mjs new file mode 100644 index 0000000000..1ad2ea13c0 --- /dev/null +++ b/benchmark/react/plugins/pluginGenJSX.mjs @@ -0,0 +1,35 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import MagicString from 'magic-string'; + +import { gen } from './gen.js'; + +/** + * @returns {import("@lynx-js/rspeedy").RsbuildPlugin} + */ +export const pluginGenJSX = () => ({ + name: 'pluginGenJSX', + /** + * @param {import("@lynx-js/rspeedy").RsbuildPluginAPI} api + */ + setup(api) { + api.transform({}, (context) => { + const code = new MagicString(context.code); + code.replace(/__GENERATE_JSX__\((\d+), ?(\d+)\)/g, (_, $1, $2) => { + return gen(parseInt($1, 10), parseInt($2, 10)); + }); + const sourceMap = code.generateMap({ + hires: true, + includeContent: true, + source: context.resourcePath, + }); + + return { + code: code.toString(), + map: sourceMap, + }; + }); + }, +}); diff --git a/benchmark/react/rspeedy-env.d.ts b/benchmark/react/rspeedy-env.d.ts index b840e7dccf..f92a681117 100644 --- a/benchmark/react/rspeedy-env.d.ts +++ b/benchmark/react/rspeedy-env.d.ts @@ -21,3 +21,11 @@ declare const Codspeed: { declare function runAfterLoadScript(cb: () => void): void; declare const __REPO_FILEPATH__: string; +declare const __GENERATE_JSX__: ( + seed: number, + complexity: number, +) => ( + apis: Partial, + genRandom: typeof import('./plugins/gen.ts').genRandom, + genValue: typeof import('./plugins/gen.ts').genValue, +) => import('react').FC<{ seed: number }>; diff --git a/benchmark/react/scripts/tryGen.ts b/benchmark/react/scripts/tryGen.ts new file mode 100644 index 0000000000..616df18ae0 --- /dev/null +++ b/benchmark/react/scripts/tryGen.ts @@ -0,0 +1,18 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/* eslint-disable n/file-extension-in-import */ + +import { readFileSync } from 'node:fs'; + +import { gen } from '../plugins/gen.ts'; + +console.log( + readFileSync(0).toString().replace( + /__GENERATE_JSX__\((\d+), ?(\d+)\)/g, + (_, $1: string, $2: string) => { + return gen(parseInt($1, 10), parseInt($2, 10)); + }, + ), +); diff --git a/benchmark/react/tsconfig.json b/benchmark/react/tsconfig.json index de35ffbbd9..00ff35a1f7 100644 --- a/benchmark/react/tsconfig.json +++ b/benchmark/react/tsconfig.json @@ -8,11 +8,14 @@ "allowJs": true, "checkJs": true, "isolatedDeclarations": false, + "allowImportingTsExtensions": true, }, "include": [ "rspeedy-env.d.ts", "src", - "lynx.config.js", + "plugins", + "scripts", + "lynx.config.ts", "cases", "vitest.config.ts", ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 847c86d988..70c18993bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,9 @@ importers: '@types/react': specifier: ^18.3.23 version: 18.3.23 + human-id: + specifier: 4.1.1 + version: 4.1.1 examples/react: dependencies: