diff --git a/README.md b/README.md index 50a39735..ac8a15c9 100644 --- a/README.md +++ b/README.md @@ -295,14 +295,7 @@ These definitions follow React’s private implementation and may change between ### `getSource` -Returns the source location for a Fiber from these renderers: - -- DOM -- Native -- Terminal -- Canvas -- PDF -- Custom +Returns the source location for a Fiber. ```typescript import { getSource } from "bippy/source"; diff --git a/packages/bippy/src/source/inspect-hooks.ts b/packages/bippy/src/source/inspect-hooks.ts index b5fbd18e..93bcadac 100644 --- a/packages/bippy/src/source/inspect-hooks.ts +++ b/packages/bippy/src/source/inspect-hooks.ts @@ -14,7 +14,7 @@ import { BippyUnsupportedHookError, } from "../errors.js"; import { getReactWorkTagsForFiber } from "../react-internals/index.js"; -import { parseStack, type StackFrame } from "./parse-stack.js"; +import { createStackParser, parseStack, type StackFrame } from "./parse-stack.js"; import { getRendererDispatcherRefs, readDispatcher, @@ -604,8 +604,9 @@ const findPrimitiveIndex = (hookStack: StackFrame[], hook: HookLogEntry): number const parseTrimmedStack = ( rootStack: StackFrame[], hook: HookLogEntry, + parseHookStack: (stack: string) => StackFrame[], ): [StackFrame | null, StackFrame[] | null] => { - const hookStack = parseErrorStack(hook.stackError); + const hookStack = parseHookStack(hook.stackError.stack || ""); const rootIndex = findCommonAncestorIndex(rootStack, hookStack); const primitiveIndex = findPrimitiveIndex(hookStack, hook); if (rootIndex === -1 || primitiveIndex === -1 || rootIndex - primitiveIndex < 2) { @@ -626,13 +627,14 @@ const NON_ID_HOOK_PRIMITIVES = new Set([ const buildTree = (rootStack: StackFrame[], capturedHookLog: HookLogEntry[]): HooksTree => { const rootChildren: HooksNode[] = []; + const parseHookStack = createStackParser(); let previousStack: StackFrame[] | null = null; let levelChildren = rootChildren; let nativeHookID = 0; const childrenStack: HooksNode[][] = []; for (const hook of capturedHookLog) { - const [primitiveFrame, stack] = parseTrimmedStack(rootStack, hook); + const [primitiveFrame, stack] = parseTrimmedStack(rootStack, hook, parseHookStack); let displayName = hook.displayName; if (displayName === null && primitiveFrame !== null) { const primitiveName = parseHookName(primitiveFrame.functionName); diff --git a/packages/bippy/src/source/parse-stack.ts b/packages/bippy/src/source/parse-stack.ts index 1bfff24c..ceb064e2 100644 --- a/packages/bippy/src/source/parse-stack.ts +++ b/packages/bippy/src/source/parse-stack.ts @@ -28,16 +28,14 @@ export const parseStack = (stackString: string, options?: ParseOptions): StackFr const frames: StackFrame[] = []; for (const rawLine of lines) { if (/^\s*at\s+/.test(rawLine)) { - const parsed = parseV8OrIeString(rawLine)[0]; - if (parsed) frames.push(parsed); + if (CHROME_IE_STACK_REGEXP.test(rawLine)) frames.push(parseV8Line(rawLine)); } else if (/^\s*in\s+/.test(rawLine)) { const elementName = rawLine .replace(/^\s*in\s+/, "") .replace(/\s*(?:\(at .*\)|\[[^\]]+\])$/, ""); frames.push({ functionName: elementName, source: rawLine }); } else if (rawLine.match(FIREFOX_SAFARI_STACK_REGEXP)) { - const parsed = parseFFOrSafariString(rawLine)[0]; - if (parsed) frames.push(parsed); + if (!SAFARI_NATIVE_CODE_REGEXP.test(rawLine)) frames.push(parseSafariLine(rawLine)); } } return frames; @@ -48,6 +46,18 @@ export const parseStack = (stackString: string, options?: ParseOptions): StackFr return parseFFOrSafariString(stackString); }; +const getPositionIndex = (location: string, endIndex: number): number => { + let positionIndex = endIndex - 1; + while (positionIndex >= 0) { + const character = location.charCodeAt(positionIndex); + if (character < 48 || character > 57) break; + positionIndex--; + } + return positionIndex < endIndex - 1 && location.charCodeAt(positionIndex) === 58 + ? positionIndex + : -1; +}; + export const extractLocation = ( urlLike: string, ): [string, string | undefined, string | undefined] => { @@ -59,77 +69,115 @@ export const extractLocation = ( const isWrappedLocation = urlLike.startsWith("(") && /:\d+\)$/.test(urlLike); const sanitizedResult = isWrappedLocation ? urlLike.slice(1, -1) : urlLike; - const regExp = /(.+?)(?::(\d+))?(?::(\d+))?$/; - const parts = regExp.exec(sanitizedResult); - if (!parts) return [sanitizedResult, undefined, undefined]; - return [parts[1], parts[2] || undefined, parts[3] || undefined] as const; -}; + if (/[\n\r\u2028\u2029]/.test(sanitizedResult)) { + const parts = /(.+?)(?::(\d+))?(?::(\d+))?$/.exec(sanitizedResult); + return parts + ? [parts[1], parts[2] || undefined, parts[3] || undefined] + : [sanitizedResult, undefined, undefined]; + } -export const parseV8OrIeString = (stack: string): StackFrame[] => { - const filteredLines = stack.split("\n").filter((line) => { - return !!line.match(CHROME_IE_STACK_REGEXP); - }); - - return filteredLines.map((line): StackFrame => { - let currentLine = line; - if (currentLine.includes("(eval ")) { - currentLine = currentLine - .replace(/eval code/g, "eval") - .replace(/(\(eval at [^()]*)|(,.*$)/g, ""); - } - let sanitizedLine = currentLine - .replace(/^\s+/, "") - .replace(/\(eval code/g, "(") - .replace(/^.*?\s+/, ""); + const lastPositionIndex = getPositionIndex(sanitizedResult, sanitizedResult.length); + if (lastPositionIndex <= 0) return [sanitizedResult, undefined, undefined]; + const previousPositionIndex = getPositionIndex(sanitizedResult, lastPositionIndex); + if (previousPositionIndex <= 0) { + return [ + sanitizedResult.slice(0, lastPositionIndex), + sanitizedResult.slice(lastPositionIndex + 1), + undefined, + ]; + } + return [ + sanitizedResult.slice(0, previousPositionIndex), + sanitizedResult.slice(previousPositionIndex + 1, lastPositionIndex), + sanitizedResult.slice(lastPositionIndex + 1), + ]; +}; - const locationMatch = sanitizedLine.match(/ (\(.+\)$)/); +const parseV8Line = (line: string): StackFrame => { + let currentLine = line; + if (currentLine.includes("(eval ")) { + currentLine = currentLine + .replace(/eval code/g, "eval") + .replace(/(\(eval at [^()]*)|(,.*$)/g, ""); + } + let sanitizedLine = currentLine + .replace(/^\s+/, "") + .replace(/\(eval code/g, "(") + .replace(/^.*?\s+/, ""); + + const locationMatch = sanitizedLine.match(/ (\(.+\)$)/); + + sanitizedLine = locationMatch ? sanitizedLine.replace(locationMatch[0], "") : sanitizedLine; + + const locationParts = extractLocation(locationMatch ? locationMatch[1] : sanitizedLine); + const functionName = (locationMatch && sanitizedLine) || undefined; + const fileName = ["eval", "", "(native)"].includes(locationParts[0]) + ? undefined + : locationParts[0]; + + return { + functionName, + fileName, + lineNumber: locationParts[1] ? +locationParts[1] : undefined, + columnNumber: locationParts[2] ? +locationParts[2] : undefined, + source: currentLine, + }; +}; - sanitizedLine = locationMatch ? sanitizedLine.replace(locationMatch[0], "") : sanitizedLine; +const parseSafariLine = (line: string): StackFrame => { + let currentLine = line; + if (currentLine.includes(" > eval")) + currentLine = currentLine.replace(/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g, ":$1"); - const locationParts = extractLocation(locationMatch ? locationMatch[1] : sanitizedLine); - const functionName = (locationMatch && sanitizedLine) || undefined; - const fileName = ["eval", "", "(native)"].includes(locationParts[0]) - ? undefined - : locationParts[0]; + if (!currentLine.includes("@") && !currentLine.includes(":")) { + return { + functionName: currentLine, + }; + } else { + const functionNameRegex = + /(([^\n\r"\u2028\u2029]*".[^\n\r"\u2028\u2029]*"[^\n\r@\u2028\u2029]*(?:@[^\n\r"\u2028\u2029]*"[^\n\r@\u2028\u2029]*)*(?:[\n\r\u2028\u2029][^@]*)?)?[^@]*)@/; + const matches = currentLine.match(functionNameRegex); + const functionName = matches && matches[1] ? matches[1] : undefined; + const locationParts = extractLocation(currentLine.replace(functionNameRegex, "")); return { functionName, - fileName, + fileName: locationParts[0], lineNumber: locationParts[1] ? +locationParts[1] : undefined, columnNumber: locationParts[2] ? +locationParts[2] : undefined, source: currentLine, }; - }); + } }; -export const parseFFOrSafariString = (stack: string): StackFrame[] => { - const filteredLines = stack.split("\n").filter((line) => { - return !line.match(SAFARI_NATIVE_CODE_REGEXP); - }); - - return filteredLines.map((line): StackFrame => { - let currentLine = line; - if (currentLine.includes(" > eval")) - currentLine = currentLine.replace(/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g, ":$1"); - - if (!currentLine.includes("@") && !currentLine.includes(":")) { - return { - functionName: currentLine, - }; - } else { - const functionNameRegex = - /(([^\n\r"\u2028\u2029]*".[^\n\r"\u2028\u2029]*"[^\n\r@\u2028\u2029]*(?:@[^\n\r"\u2028\u2029]*"[^\n\r@\u2028\u2029]*)*(?:[\n\r\u2028\u2029][^@]*)?)?[^@]*)@/; - const matches = currentLine.match(functionNameRegex); - const functionName = matches && matches[1] ? matches[1] : undefined; - const locationParts = extractLocation(currentLine.replace(functionNameRegex, "")); - - return { - functionName, - fileName: locationParts[0], - lineNumber: locationParts[1] ? +locationParts[1] : undefined, - columnNumber: locationParts[2] ? +locationParts[2] : undefined, - source: currentLine, - }; +const parseLines = ( + stack: string, + isV8: boolean, + cache?: Map, +): StackFrame[] => { + const frames: StackFrame[] = []; + for (const line of stack.split("\n")) { + let frame = cache?.get(line); + if (!frame) { + if (isV8 ? !CHROME_IE_STACK_REGEXP.test(line) : SAFARI_NATIVE_CODE_REGEXP.test(line)) + continue; + frame = isV8 ? parseV8Line(line) : parseSafariLine(line); + cache?.set(line, frame); } - }); + frames.push(frame); + } + return frames; +}; + +export const parseV8OrIeString = (stack: string): StackFrame[] => parseLines(stack, true); + +export const parseFFOrSafariString = (stack: string): StackFrame[] => parseLines(stack, false); + +export const createStackParser = () => { + const v8Frames = new Map(); + const safariFrames = new Map(); + return (stack: string): StackFrame[] => { + const isV8 = CHROME_IE_STACK_REGEXP.test(stack); + return parseLines(stack, isV8, isV8 ? v8Frames : safariFrames); + }; }; diff --git a/packages/bippy/src/source/symbolication.ts b/packages/bippy/src/source/symbolication.ts index 6c23ffd4..78958cac 100644 --- a/packages/bippy/src/source/symbolication.ts +++ b/packages/bippy/src/source/symbolication.ts @@ -207,6 +207,13 @@ export const getSourceFromSourceMap = ( ); }; +const getStringIndex = (values: string[], target: string): number => { + for (let valueIndex = 0; valueIndex < values.length; valueIndex++) { + if (values[valueIndex] === target) return valueIndex; + } + return -1; +}; + const getSourceFromMappingsByFunctionName = ( mappings: SourceMapMappings, sources: string[], @@ -215,13 +222,17 @@ const getSourceFromMappingsByFunctionName = ( ignoredSourceIndices?: Set, ): StackFrame | null => { if (!names) return null; - const functionNameIndex = names.indexOf(functionName); + const functionNameIndex = getStringIndex(names, functionName); if (functionNameIndex === -1) return null; let ignoredSource: StackFrame | null = null; - for (const lineMapping of mappings) { - for (const segment of lineMapping) { + for (let lineIndex = 0; lineIndex < mappings.length; lineIndex++) { + const lineMapping = mappings[lineIndex]; + for (let segmentIndex = 0; segmentIndex < lineMapping.length; segmentIndex++) { + const segment = lineMapping[segmentIndex]; if (segment[4] !== functionNameIndex) continue; + if (ignoredSource && segment[1] !== undefined && ignoredSourceIndices?.has(segment[1])) + continue; const source = getSourceFromSegment(segment, sources, ignoredSourceIndices, names); if (!source) continue; if (!source.isIgnoreListed) return source; @@ -267,7 +278,7 @@ const findSourceContentByFileName = ( fileName: string, ): string | null => { if (!sourcesContent) return null; - const sourceIndex = sources.indexOf(fileName); + const sourceIndex = getStringIndex(sources, fileName); return sourceIndex === -1 ? null : (sourcesContent[sourceIndex] ?? null); }; diff --git a/packages/conformance/README.md b/packages/conformance/README.md index d778d360..d90419c2 100644 --- a/packages/conformance/README.md +++ b/packages/conformance/README.md @@ -106,21 +106,23 @@ The meaningful change is removal of repeated root searches on ordinary early-Rea `pnpm --filter conformance bench` builds Bippy and benchmarks all **65 function/constructor exports** from `bippy` and `bippy/source`. Aliases are verified rather than presented as independent implementations. Data exports are inventoried, not timed as functions. Coverage checks reuse the canonical export reader and reject missing or invented exports. -The suite produces 676 microbenchmark rows (169 scenarios across ESM/CJS × development/production), 72 production `useFiber` configurations, and 12 cold-import measurements: +The suite produces 708 microbenchmark rows (177 scenarios across ESM/CJS × development/production), 72 production `useFiber` configurations, and 12 cold-import measurements: - Core helpers, wrapper cycles/depth, IDs, alternate reflection and root-search fallback; deep/wide trees up to 10,000 nodes; mounted/updated trees and Suspense simulated unmounts. - Cold/warm work-tag caches, changed associations, live DOM host/renderer lookup, and synthetic Native-tag root searches. - Hook installation, subscription churn, activation, renderer injection, commit/unmount/post-commit/schedule fan-out through 1,000 listeners, and throwing listeners with a stubbed reporter. - Synthetic debug/owner/parent stacks, V8/Safari parsing, source-map lookup and decoding, indexed maps, symbolication, and hook names. Fetching uses in-memory responses; attempted default network requests fail the worker. -- `getFiberHooks` and standalone `inspectHooks` on real React roots at 1/16/128 state hooks or custom-hook calls. Each custom hook contains state, memo, and ref primitives. +- `getFiberHooks` and standalone `inspectHooks` at 1/16/128 state hooks, custom-hook calls, or distinct state-call sites. The distinct fixture keeps all 128 call sites explicit in a typechecked TypeScript file rather than generating executable code with `new Function`. Each custom hook contains state, memo, and ref primitives. Live inspection roots are mounted only for their owning case and unmounted even after verification failures. - Production `useFiber` mounts/updates across nine React fixtures, with/without-hook baselines, exact component/props identity checks, and render-count assertions. - Fresh-process native Node imports of all three entrypoints; runtime bundle sizes, gzip sizes, and SHA-256 hashes. -Full runs write `benchmarks/results/latest.json` and `latest.md`; generated results are ignored by Git. JSON retains raw microbenchmark samples, calibrated iteration counts, min/median/max microseconds, `useFiber` summary medians, environment metadata, export accounting, and worker peak RSS. Workers run sequentially; synchronous operations do not pay an `await` per call. Preparation and result validation occur outside timing, allocation-heavy cases cap batch sizes, and a bounded result sink consumes return values. GC is not forced; yielding between batches lets WeakRef targets become collectible. Small measurements include harness overhead. Peak RSS includes fixtures, harness, and runtime, not just library allocations. +Each run writes validated results incrementally to `benchmarks/results/run-*/progress.jsonl`. Completed runs also write `report.json` and `report.md` there and replace `latest.json` / `latest.md` (or `smoke.*`). Interrupted runs retain their completed groups without replacing the last successful report. Generated results are ignored by Git. JSON retains raw microbenchmark samples, calibrated iteration counts, min/median/max microseconds, `useFiber` summary medians, environment metadata, export accounting, and worker peak RSS. Workers run sequentially; synchronous operations do not pay an `await` per call. Preparation and result validation occur outside timing, allocation-heavy cases cap batch sizes, and a bounded result sink consumes return values. GC is not forced; yielding between batches lets WeakRef targets become collectible. Small measurements include harness overhead. Peak RSS includes fixtures, harness, and runtime, not just library allocations. -`bench:smoke` requires existing build output. CI runs it after the packaged checks to validate fixtures, measurements, and export accounting, not to enforce timing thresholds. Smoke mode uses one iteration/sample and a small React 19 `useFiber` configuration; its timings are not benchmark results. Harness unit tests cover async waiting, timing boundaries, cleanup, calibration caps, and coverage failures. +`bench:smoke` requires existing build output. CI runs it after the packaged checks to validate fixtures, measurements, and export accounting, not to enforce timing thresholds. Smoke mode uses one iteration/sample and a small React 19 `useFiber` configuration; its timings are not benchmark results. Harness unit tests cover async waiting, timing boundaries, cleanup, calibration caps, and coverage failures. Report tests reject malformed values, null/nonfinite timings, inconsistent summaries, and duplicate IDs. Fixture tests check independent cleanup and distinct source contents. Journaling tests verify that interrupted runs preserve completed results. -A local Apple M5 Max / Node 24.20.0 / Happy DOM run of production ESM showed these approximate per-operation medians: +Workers are checked-in TypeScript files. The cold-import probe is compiled before launching native Node, so its timings exclude both compilation and the `tsx` loader. Case registration, variants, groups, statistics, process execution, and report encoding each have a shared implementation. Hook benchmarks verify state values, not just hook counts. + +Before the source hot-path optimization below, a local Apple M5 Max / Node 24.20.0 / Happy DOM run of production ESM showed these approximate per-operation medians: | Workload | Time | | --------------------------------------------------------------- | ------: | @@ -140,6 +142,27 @@ Hook inspection is the standout cost: avoid replaying every component's hooks on These are workload snapshots, not universal API costs or a before/after comparison with the earlier optimization table. Core/source/inspection timing uses the installed React version; only `useFiber` spans all nine fixtures. Profiling-build timing, browser/mobile renderers, locked intrinsics, every private `dist/*` chunk, network latency, first-ever inspection initialization, and allocation/leak profiling are not covered. Synthetic Native-tag lookup is not a Native renderer benchmark. +### Source hot-path optimization + +CPU profiles identified repeated stack parsing and location extraction in hook inspection. Location parsing now scans numeric suffixes from the end, retaining the previous handling of line terminators. Stack parsers consume lines directly instead of splitting/filtering each already-split line. Inspection reuses parsed frames within one tree build, including shared frames across distinct hook call sites; it does not reuse hook values or inspection results. Public `parseStack` calls still return fresh frames. + +Reverse source-map lookups use indexed loops and avoid constructing later ignored candidates once a valid ignored fallback exists. No persistent reverse index is used: callers can mutate names, sources, mappings, contents, and ignore sets. First-duplicate semantics and application-source preference remain intact. + +Historical paired production runs against the pre-optimization bundles from `35fe6a6`, using the same expanded fixtures on Node 24.20.0 / Apple M5 Max. The distinct-call-site row used the former generated fixture; the current checked-in fixture preserves the distinct sites but changes stack layout, so its absolute timings are not directly comparable: + +| Workload | ESM before → after | CJS before → after | +| ------------------------------------------------- | -----------------: | -----------------: | +| Inspect 128 state hooks | 3.21 → 2.28 ms | 3.74 → 2.83 ms | +| Inspect 128 custom-hook calls / 384 primitives | 9.70 → 6.92 ms | 11.39 → 8.50 ms | +| Inspect 128 distinct state-call sites | 3.82 → 3.03 ms | 4.36 → 3.56 ms | +| Parse 1,000 V8 frames | 655 → 464 µs | 633 → 470 µs | +| Tail source-content lookup / 10,000 filenames | 183 → 40 µs | 181 → 40 µs | +| Function-name lookup past 10,000 ignored mappings | 121 → 59 µs | 125 → 63 µs | + +`source-hot-paths.test.ts` checks 17,027 location comparisons against the previous parser, per-parser frame reuse/isolation, fresh public frames, mutable source maps, duplicate sources, and ignored-candidate work. The ignored-candidate regression reproduces 1,001 name reads before the fix versus three afterward. Existing inspection ports continue to check hook values, nesting, IDs, names, and cleanup. + +These timings are diagnostic. Other local runs varied with machine load; native Error capture and Node's source-map-aware stack formatting still consume substantial inspection time. Replay is not safe to put indiscriminately on every commit, and these changes do not make reverse lookup or deep ancestor traversal constant-time. Runtime changes are limited to source parsing, symbolication, and inspection; `useFiber` capture behavior is unchanged. + ## `useFiber` capture contract Each call writes a unique memo marker and reducer marker. Development capture uses the renderer's `getCurrentFiber` only when that Fiber contains the current memo marker. Otherwise, an initial reducer bind can capture the Fiber, but only alongside the queue carrying this call's reducer marker. Bound argument positions are not hardcoded. Updates select the alternate containing the memo marker without patching `bind` or scheduling passive effects. diff --git a/packages/conformance/benchmarks/browser.ts b/packages/conformance/benchmarks/browser.ts new file mode 100644 index 00000000..8eabf355 --- /dev/null +++ b/packages/conformance/benchmarks/browser.ts @@ -0,0 +1,13 @@ +import { Window } from "happy-dom"; + +export const createBrowser = (): Window => { + const browser = new Window({ url: "https://bench.example" }); + Reflect.set(globalThis, "window", browser); + Reflect.set(globalThis, "document", browser.document); + Reflect.set(globalThis, "Node", browser.Node); + Reflect.set(globalThis, "HTMLElement", browser.HTMLElement); + Reflect.set(globalThis, "Element", browser.Element); + Reflect.set(globalThis, "requestAnimationFrame", browser.requestAnimationFrame.bind(browser)); + Reflect.set(globalThis, "cancelAnimationFrame", browser.cancelAnimationFrame.bind(browser)); + return browser; +}; diff --git a/packages/conformance/benchmarks/configuration.ts b/packages/conformance/benchmarks/configuration.ts new file mode 100644 index 00000000..60ffa14f --- /dev/null +++ b/packages/conformance/benchmarks/configuration.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; + +export interface BenchmarkVariant { + format: "esm" | "cjs"; + reactBuild: "development" | "production"; +} + +export const benchmarkFormats: BenchmarkVariant["format"][] = ["esm", "cjs"]; +export const benchmarkBuilds: BenchmarkVariant["reactBuild"][] = ["development", "production"]; + +export const getBenchmarkVariant = (format: unknown, reactBuild: unknown): BenchmarkVariant => { + assert.ok(format === "esm" || format === "cjs", "Invalid bundle format"); + assert.ok(reactBuild === "development" || reactBuild === "production", "Invalid React build"); + return { format, reactBuild }; +}; diff --git a/packages/conformance/benchmarks/core.ts b/packages/conformance/benchmarks/core.ts index fe8b119a..713b84f0 100644 --- a/packages/conformance/benchmarks/core.ts +++ b/packages/conformance/benchmarks/core.ts @@ -1,6 +1,12 @@ import assert from "node:assert/strict"; import type { Fiber, FiberRoot, ReactDevToolsTarget, ReactRenderer } from "bippy"; -import { benchmarkCase, type BenchmarkCase, type BenchmarkContext } from "./harness.js"; +import { + benchmarkCase, + createBenchmarkSuite, + equals, + type BenchmarkCase, + type BenchmarkContext, +} from "./harness.js"; import { Component, createFiber, @@ -12,7 +18,7 @@ import { interface SuspenseCommit { root: FiberRoot; - next: Fiber; + nextRootFiber: Fiber; } interface TypeWrapper { @@ -20,10 +26,6 @@ interface TypeWrapper { } const treeShapes: Array<"deep" | "wide"> = ["deep", "wide"]; -const equals = - (expected: unknown) => - (value: unknown): void => - assert.equal(value, expected); export const createCoreBenchmarks = ({ Bippy, @@ -31,13 +33,7 @@ export const createCoreBenchmarks = ({ ReactDOM, ReactDOMClient, }: BenchmarkContext): BenchmarkCase[] => { - const cases: BenchmarkCase[] = []; - const add = ( - name: string, - scenario: string, - run: BenchmarkCase["run"], - verify: BenchmarkCase["verify"], - ) => cases.push(benchmarkCase(`${name}/${scenario}`, [`bippy#${name}`], run, verify)); + const { cases, add } = createBenchmarkSuite("bippy"); const renderer: ReactRenderer = { version: "19.2.4", rendererPackageName: "benchmark", @@ -136,21 +132,21 @@ export const createCoreBenchmarks = ({ for (const size of [100, 1000, 10000]) { for (const shape of treeShapes) { - const previous = createTree(size, shape); - const next = createTree(size, shape); - pairTrees(previous, next); - const leaf = previous.fibers[size - 1]; - const selected = next.fibers[size - 1]; + const previousTree = createTree(size, shape); + const nextTree = createTree(size, shape); + pairTrees(previousTree, nextTree); + const leaf = previousTree.fibers[size - 1]; + const selected = nextTree.fibers[size - 1]; add( "traverseFiber", `${shape}-${size}-tail`, - () => Bippy.traverseFiber(next.root.current, (fiber) => fiber === selected), + () => Bippy.traverseFiber(nextTree.root.current, (fiber) => fiber === selected), equals(selected), ); add( "traverseFiber", `${shape}-${size}-miss`, - () => Bippy.traverseFiber(next.root.current, () => false), + () => Bippy.traverseFiber(nextTree.root.current, () => false), equals(null), ); add( @@ -159,7 +155,7 @@ export const createCoreBenchmarks = ({ () => Bippy.getLatestFiber(leaf), equals(selected), ); - Bippy.setReactWorkTagsForFiber(previous.root.current, renderer); + Bippy.setReactWorkTagsForFiber(previousTree.root.current, renderer); Bippy.getReactWorkTagsForFiber(leaf); add( "getReactWorkTagsForFiber", @@ -167,18 +163,18 @@ export const createCoreBenchmarks = ({ () => Bippy.getReactWorkTagsForFiber(leaf), equals(workTags), ); - const currentRoot = next.root; + const currentRoot = nextTree.root; cases.push( benchmarkCase( `traverseRenderedFibers/${shape}-${size}-update`, ["bippy#traverseRenderedFibers"], () => { currentRoot.current = currentRoot.current.alternate ?? currentRoot.current; - let visited = 0; + let visitedFiberCount = 0; Bippy.traverseRenderedFibers(currentRoot, (_fiber, phase) => { - if (phase === "update") visited++; + if (phase === "update") visitedFiberCount++; }); - return visited; + return visitedFiberCount; }, equals(size + 1), { @@ -192,15 +188,15 @@ export const createCoreBenchmarks = ({ } } for (const size of [100, 1000, 10000]) { - const previous = createTree(size, "wide"); - const next = createTree(size, "wide"); - pairTrees(previous, next); - previous.root.current.alternate = null; + const previousTree = createTree(size, "wide"); + const nextTree = createTree(size, "wide"); + pairTrees(previousTree, nextTree); + previousTree.root.current.alternate = null; add( "getLatestFiber", `synthetic-root-search-${size}`, - () => Bippy.getLatestFiber(previous.fibers[size - 1]), - equals(next.fibers[size - 1]), + () => Bippy.getLatestFiber(previousTree.fibers[size - 1]), + equals(nextTree.fibers[size - 1]), ); let commits: SuspenseCommit[] = []; cases.push( @@ -209,12 +205,12 @@ export const createCoreBenchmarks = ({ ["bippy#traverseRenderedFibers"], (iteration) => { const commit = commits[iteration]; - commit.root.current = commit.next; - let unmounted = 0; + commit.root.current = commit.nextRootFiber; + let unmountedFiberCount = 0; Bippy.traverseRenderedFibers(commit.root, (_fiber, phase) => { - if (phase === "unmount") unmounted++; + if (phase === "unmount") unmountedFiberCount++; }); - return unmounted; + return unmountedFiberCount; }, equals(size), { @@ -233,19 +229,19 @@ export const createCoreBenchmarks = ({ boundary.child = offscreen; tree.root.current.child = boundary; for (const fiber of tree.fibers) fiber.return = offscreen; - const next = createFiber({ + const nextRootFiber = createFiber({ tag: workTags.HostRoot, alternate: tree.root.current, memoizedState: tree.root.current.memoizedState, }); - next.child = createFiber({ + nextRootFiber.child = createFiber({ tag: workTags.SuspenseComponent, alternate: boundary, - return: next, + return: nextRootFiber, memoizedState: { memoizedState: null, next: null }, }); Bippy.traverseRenderedFibers(tree.root, () => {}); - return { root: tree.root, next }; + return { root: tree.root, nextRootFiber }; }); }, maxIterations: 4, @@ -268,43 +264,45 @@ export const createCoreBenchmarks = ({ ["bippy#traverseFiber"], () => Bippy.traverseFiber(ascending.root.current, async () => false), equals(null), - { async: true, units: 1001 }, + { isAsync: true, units: 1001 }, ), ); for (const size of [100, 1000]) { - let trees: FiberTree[] = []; + let tagTrees: FiberTree[] = []; cases.push( benchmarkCase( `getReactWorkTagsForFiber/deep-${size}-cold`, ["bippy#getReactWorkTagsForFiber", "bippy#setReactWorkTagsForFiber"], - (iteration) => Bippy.getReactWorkTagsForFiber(trees[iteration].fibers[size - 1]), + (iteration) => Bippy.getReactWorkTagsForFiber(tagTrees[iteration].fibers[size - 1]), equals(workTags), { prepare: (iterations) => { - trees = Array.from({ length: iterations }, () => createTree(size, "deep")); - for (const tree of trees) Bippy.setReactWorkTagsForFiber(tree.root.current, renderer); + tagTrees = Array.from({ length: iterations }, () => createTree(size, "deep")); + for (const tree of tagTrees) + Bippy.setReactWorkTagsForFiber(tree.root.current, renderer); }, maxIterations: 16, units: size, }, ), ); + let mountTrees: FiberTree[] = []; cases.push( benchmarkCase( `traverseRenderedFibers/wide-${size}-mount`, ["bippy#traverseRenderedFibers"], (iteration) => { - let visited = 0; - Bippy.traverseRenderedFibers(trees[iteration].root, (_fiber, phase) => { - if (phase === "mount") visited++; + let visitedFiberCount = 0; + Bippy.traverseRenderedFibers(mountTrees[iteration].root, (_fiber, phase) => { + if (phase === "mount") visitedFiberCount++; }); - return visited; + return visitedFiberCount; }, equals(size + 1), { prepare: (iterations) => { - trees = Array.from({ length: iterations }, () => createTree(size, "wide")); + mountTrees = Array.from({ length: iterations }, () => createTree(size, "wide")); }, maxIterations: 16, units: size + 1, @@ -334,47 +332,71 @@ export const createCoreBenchmarks = ({ const target: ReactDevToolsTarget = {}; const hook = Bippy.getRDTHook(undefined, target); - const rendererId = hook.inject(renderer); - const tracked = createTree(1000, "deep"); - hook.getFiberRoots?.(rendererId).add(tracked.root); - Bippy.getRenderer(tracked.fibers[999], target); + const trackedTree = createTree(1000, "deep"); + let rendererId: number | undefined; cases.push( benchmarkCase( "getRenderer/deep-1000-warm", ["bippy#getRenderer"], - () => Bippy.getRenderer(tracked.fibers[999], target), + () => Bippy.getRenderer(trackedTree.fibers[999], target), equals(renderer), { + prepare: () => { + if (rendererId !== undefined) return; + rendererId = hook.inject(renderer); + hook.getFiberRoots?.(rendererId).add(trackedTree.root); + Bippy.getRenderer(trackedTree.fibers[999], target); + }, cleanup: () => { - Bippy._fiberRoots.delete(tracked.root); + if (rendererId !== undefined) { + hook.getFiberRoots?.(rendererId).delete(trackedTree.root); + hook.renderers.delete(rendererId); + } + Bippy._fiberRoots.delete(trackedTree.root); Bippy._renderers.delete(renderer); + rendererId = undefined; }, }, ), ); - const container = document.createElement("div"); - document.body.appendChild(container); - const domRoot = ReactDOMClient.createRoot(container); - ReactDOM.flushSync(() => domRoot.render(React.createElement("span"))); - const domFiber = Bippy.getFiber(container.firstChild); - assert.ok(domFiber); - const domRenderer = Bippy.getRenderer(domFiber); - assert.ok(domRenderer); - add("getFiber", "live-dom", () => Bippy.getFiber(container.firstChild), equals(domFiber)); - cases.push( - benchmarkCase( - "getRenderer/live-dom-warm", - ["bippy#getRenderer"], - () => Bippy.getRenderer(domFiber), - equals(domRenderer), - { - cleanup: () => { - ReactDOM.flushSync(() => domRoot.unmount()); - container.remove(); + for (const name of ["getFiber", "getRenderer"]) { + let container: HTMLDivElement | undefined; + let domRoot: ReturnType | undefined; + let domFiber: Fiber | null = null; + let expected: Fiber | ReactRenderer | null = null; + cases.push( + benchmarkCase( + `${name}/live-dom`, + [`bippy#${name}`], + () => { + assert.ok(domFiber); + return name === "getFiber" + ? Bippy.getFiber(container?.firstChild) + : Bippy.getRenderer(domFiber); }, - }, - ), - ); + (value) => assert.equal(value, expected), + { + prepare: () => { + if (domRoot) return; + container = document.createElement("div"); + document.body.appendChild(container); + domRoot = ReactDOMClient.createRoot(container); + ReactDOM.flushSync(() => domRoot?.render(React.createElement("span"))); + domFiber = Bippy.getFiber(container.firstChild); + assert.ok(domFiber); + expected = name === "getFiber" ? domFiber : Bippy.getRenderer(domFiber); + assert.ok(expected); + }, + cleanup: () => { + ReactDOM.flushSync(() => domRoot?.unmount()); + container?.remove(); + domRoot = undefined; + domFiber = null; + }, + }, + ), + ); + } for (const size of [100, 1000]) { const nativeTarget: ReactDevToolsTarget = {}; const nativeHook = Bippy.getRDTHook(undefined, nativeTarget); diff --git a/packages/conformance/benchmarks/distinct-hook-fixture.ts b/packages/conformance/benchmarks/distinct-hook-fixture.ts new file mode 100644 index 00000000..57149ba9 --- /dev/null +++ b/packages/conformance/benchmarks/distinct-hook-fixture.ts @@ -0,0 +1,136 @@ +import type { BenchmarkContext } from "./harness.js"; + +export const createDistinctHookComponent = + (React: BenchmarkContext["React"], count: number) => () => { + React.useState(0); + if (count === 1) return null; + React.useState(1); + React.useState(2); + React.useState(3); + React.useState(4); + React.useState(5); + React.useState(6); + React.useState(7); + React.useState(8); + React.useState(9); + React.useState(10); + React.useState(11); + React.useState(12); + React.useState(13); + React.useState(14); + React.useState(15); + if (count === 16) return null; + React.useState(16); + React.useState(17); + React.useState(18); + React.useState(19); + React.useState(20); + React.useState(21); + React.useState(22); + React.useState(23); + React.useState(24); + React.useState(25); + React.useState(26); + React.useState(27); + React.useState(28); + React.useState(29); + React.useState(30); + React.useState(31); + React.useState(32); + React.useState(33); + React.useState(34); + React.useState(35); + React.useState(36); + React.useState(37); + React.useState(38); + React.useState(39); + React.useState(40); + React.useState(41); + React.useState(42); + React.useState(43); + React.useState(44); + React.useState(45); + React.useState(46); + React.useState(47); + React.useState(48); + React.useState(49); + React.useState(50); + React.useState(51); + React.useState(52); + React.useState(53); + React.useState(54); + React.useState(55); + React.useState(56); + React.useState(57); + React.useState(58); + React.useState(59); + React.useState(60); + React.useState(61); + React.useState(62); + React.useState(63); + React.useState(64); + React.useState(65); + React.useState(66); + React.useState(67); + React.useState(68); + React.useState(69); + React.useState(70); + React.useState(71); + React.useState(72); + React.useState(73); + React.useState(74); + React.useState(75); + React.useState(76); + React.useState(77); + React.useState(78); + React.useState(79); + React.useState(80); + React.useState(81); + React.useState(82); + React.useState(83); + React.useState(84); + React.useState(85); + React.useState(86); + React.useState(87); + React.useState(88); + React.useState(89); + React.useState(90); + React.useState(91); + React.useState(92); + React.useState(93); + React.useState(94); + React.useState(95); + React.useState(96); + React.useState(97); + React.useState(98); + React.useState(99); + React.useState(100); + React.useState(101); + React.useState(102); + React.useState(103); + React.useState(104); + React.useState(105); + React.useState(106); + React.useState(107); + React.useState(108); + React.useState(109); + React.useState(110); + React.useState(111); + React.useState(112); + React.useState(113); + React.useState(114); + React.useState(115); + React.useState(116); + React.useState(117); + React.useState(118); + React.useState(119); + React.useState(120); + React.useState(121); + React.useState(122); + React.useState(123); + React.useState(124); + React.useState(125); + React.useState(126); + React.useState(127); + return null; + }; diff --git a/packages/conformance/benchmarks/fixtures.ts b/packages/conformance/benchmarks/fixtures.ts index 7db6afbf..86ec71de 100644 --- a/packages/conformance/benchmarks/fixtures.ts +++ b/packages/conformance/benchmarks/fixtures.ts @@ -33,15 +33,15 @@ export const createTree = (size: number, shape: "deep" | "wide"): FiberTree => { return { root, fibers }; }; -export const pairTrees = (previous: FiberTree, next: FiberTree): void => { - previous.root.current.alternate = next.root.current; - next.root.current.alternate = previous.root.current; - previous.fibers.forEach((fiber, index) => { - fiber.alternate = next.fibers[index]; - next.fibers[index].alternate = fiber; +export const pairTrees = (previousTree: FiberTree, nextTree: FiberTree): void => { + previousTree.root.current.alternate = nextTree.root.current; + nextTree.root.current.alternate = previousTree.root.current; + previousTree.fibers.forEach((fiber, index) => { + fiber.alternate = nextTree.fibers[index]; + nextTree.fibers[index].alternate = fiber; }); - previous.root.current.stateNode = next.root; - next.root.current.stateNode = next.root; + previousTree.root.current.stateNode = nextTree.root; + nextTree.root.current.stateNode = nextTree.root; }; export const createDebugStack = (depth = 1): Error => { diff --git a/packages/conformance/benchmarks/groups.ts b/packages/conformance/benchmarks/groups.ts new file mode 100644 index 00000000..0c77e28d --- /dev/null +++ b/packages/conformance/benchmarks/groups.ts @@ -0,0 +1,11 @@ +import { createCoreBenchmarks } from "./core.js"; +import { createHookBenchmarks } from "./hooks.js"; +import { createInstrumentationBenchmarks } from "./instrumentation.js"; +import { createSourceBenchmarks } from "./source.js"; + +export const benchmarkGroups = [ + { name: "core", create: createCoreBenchmarks }, + { name: "instrumentation", create: createInstrumentationBenchmarks }, + { name: "source", create: createSourceBenchmarks }, + { name: "hooks", create: createHookBenchmarks }, +]; diff --git a/packages/conformance/benchmarks/harness.ts b/packages/conformance/benchmarks/harness.ts index 9803c785..23af2acd 100644 --- a/packages/conformance/benchmarks/harness.ts +++ b/packages/conformance/benchmarks/harness.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { getSampleStatistics } from "./statistics.js"; export interface BenchmarkCase { id: string; @@ -7,7 +8,7 @@ export interface BenchmarkCase { verify: (value: unknown) => void; prepare?: (iterations: number) => void | Promise; cleanup?: () => void | Promise; - async?: boolean; + isAsync?: boolean; maxIterations?: number; units?: number; } @@ -70,7 +71,7 @@ export const runBenchmark = async ( // HACK: Yield between batches so WeakRef targets from the previous batch can be collected. await new Promise((resolve) => setImmediate(resolve)); await benchmark.prepare?.(iterations); - const elapsed = benchmark.async + const elapsed = benchmark.isAsync ? await measureAsync(benchmark, iterations) : measureSync(benchmark, iterations); benchmark.verify(resultsSink[(iterations - 1) % resultsSink.length]); @@ -83,7 +84,11 @@ export const runBenchmark = async ( initialValue !== null && (typeof initialValue === "object" || typeof initialValue === "function") && typeof Reflect.get(initialValue, "then") === "function"; - assert.equal(isAsync, Boolean(benchmark.async), `${benchmark.id}: incorrect async declaration`); + assert.equal( + isAsync, + Boolean(benchmark.isAsync), + `${benchmark.id}: incorrect async declaration`, + ); benchmark.verify(await initialValue); let iterations = 1; while (true) { @@ -95,16 +100,16 @@ export const runBenchmark = async ( for (let sample = 0; sample < options.samples; sample++) { sampleUs.push(((await measure(iterations)) * 1000) / iterations); } - const ordered = sampleUs.toSorted((first, second) => first - second); + const statistics = getSampleStatistics(sampleUs); return { id: benchmark.id, apis: benchmark.apis, units: benchmark.units ?? 1, iterations, samples: options.samples, - medianUs: ordered[Math.floor(ordered.length / 2)], - minUs: ordered[0], - maxUs: ordered[ordered.length - 1], + medianUs: statistics.median, + minUs: statistics.min, + maxUs: statistics.max, sampleUs, }; } finally { @@ -119,6 +124,27 @@ export const benchmarkCase = ( run: BenchmarkCase["run"], verify: BenchmarkCase["verify"], options: Partial< - Pick + Pick > = {}, ): BenchmarkCase => ({ id: benchmarkId, apis, run, verify, ...options }); + +export const equals = + (expected: unknown) => + (actual: unknown): void => + assert.equal(actual, expected); + +export const createBenchmarkSuite = (entry: string) => { + const cases: BenchmarkCase[] = []; + const add = ( + name: string, + scenario: string, + run: BenchmarkCase["run"], + verify: BenchmarkCase["verify"], + isAsync = false, + ): void => { + cases.push( + benchmarkCase(`${name}/${scenario}`, [`${entry}#${name}`], run, verify, { isAsync }), + ); + }; + return { cases, add }; +}; diff --git a/packages/conformance/benchmarks/hook-fixtures.ts b/packages/conformance/benchmarks/hook-fixtures.ts new file mode 100644 index 00000000..df54f78a --- /dev/null +++ b/packages/conformance/benchmarks/hook-fixtures.ts @@ -0,0 +1,26 @@ +import type { BenchmarkContext } from "./harness.js"; +import { createDistinctHookComponent } from "./distinct-hook-fixture.js"; + +export interface HookFixtureConfiguration { + count: number; + kind: "state" | "custom" | "distinct"; +} + +export const createHookComponent = ( + React: BenchmarkContext["React"], + { count, kind }: HookFixtureConfiguration, +) => { + if (kind === "distinct") return createDistinctHookComponent(React, count); + const useCustomValue = (index: number): number => { + const [value] = React.useState(index); + React.useRef(value); + return React.useMemo(() => value, [value]); + }; + return () => { + for (let index = 0; index < count; index++) { + if (kind === "custom") useCustomValue(index); + else React.useState(index); + } + return null; + }; +}; diff --git a/packages/conformance/benchmarks/hooks.ts b/packages/conformance/benchmarks/hooks.ts index 152b7f98..43f3cf21 100644 --- a/packages/conformance/benchmarks/hooks.ts +++ b/packages/conformance/benchmarks/hooks.ts @@ -1,26 +1,35 @@ import assert from "node:assert/strict"; -import type { FiberRoot } from "bippy"; +import type { Fiber, FiberRoot } from "bippy"; import { benchmarkCase, type BenchmarkCase, type BenchmarkContext } from "./harness.js"; +import { createHookComponent, type HookFixtureConfiguration } from "./hook-fixtures.js"; + +const hookKinds: HookFixtureConfiguration["kind"][] = ["state", "custom", "distinct"]; interface RootCapture { current: FiberRoot | null; } -const verifyStateCount = +const verifyStateValues = (count: number) => (value: unknown): void => { assert.ok(Array.isArray(value)); const pending: unknown[] = [...value]; - let states = 0; + const states: number[] = []; while (pending.length) { const hook = pending.pop(); - assert.ok(hook && typeof hook === "object"); - const subHooks: unknown = Reflect.get(hook, "subHooks"); - assert.ok(Array.isArray(subHooks)); - if (subHooks.length === 0 && Reflect.get(hook, "name") === "State") states++; - pending.push(...subHooks); + assert.ok( + hook && typeof hook === "object" && "subHooks" in hook && Array.isArray(hook.subHooks), + ); + if (hook.subHooks.length === 0 && "name" in hook && hook.name === "State") { + assert.ok("value" in hook && typeof hook.value === "number"); + states.push(hook.value); + } + pending.push(...hook.subHooks); } - assert.equal(states, count); + assert.deepEqual( + states.sort((first, second) => first - second), + Array.from({ length: count }, (_, index) => index), + ); }; export const createHookBenchmarks = ({ @@ -32,48 +41,51 @@ export const createHookBenchmarks = ({ }: BenchmarkContext): BenchmarkCase[] => { const cases: BenchmarkCase[] = []; for (const count of [1, 16, 128]) { - for (const custom of [false, true]) { - const useBenchValue = (index: number) => { - const [value] = React.useState(index); - React.useRef(value); - return React.useMemo(() => value, [value]); - }; - const Render = () => { - for (let index = 0; index < count; index++) { - if (custom) useBenchValue(index); - else React.useState(index); - } - return null; - }; - const container = document.createElement("div"); - document.body.appendChild(container); - const root = ReactDOMClient.createRoot(container); - const capture: RootCapture = { current: null }; - const unsubscribe = Bippy.instrument({ - onCommitFiberRoot: (_rendererId, fiberRoot) => { - capture.current = fiberRoot; - }, - }); - ReactDOM.flushSync(() => root.render(React.createElement(Render))); - unsubscribe(); - assert.ok(capture.current); - const fiber = Bippy.traverseFiber( - capture.current.current, - (candidate) => candidate.type === Render, - ); - assert.ok(fiber); - const scenario = `${custom ? "custom" : "state"}-${count}`; + for (const kind of hookKinds) { + const Render = createHookComponent(React, { count, kind }); + const scenario = `${kind}-${count}`; + let container: HTMLDivElement | undefined; + let root: ReturnType | undefined; + let fiber: Fiber | null = null; cases.push( benchmarkCase( `getFiberHooks/${scenario}`, ["bippy/source#getFiberHooks"], - () => Source.getFiberHooks(fiber), - verifyStateCount(count), + () => { + assert.ok(fiber); + return Source.getFiberHooks(fiber); + }, + verifyStateValues(count), { units: count, + prepare: () => { + if (root) return; + container = document.createElement("div"); + document.body.appendChild(container); + root = ReactDOMClient.createRoot(container); + const capture: RootCapture = { current: null }; + const unsubscribe = Bippy.instrument({ + onCommitFiberRoot: (_rendererId, fiberRoot) => { + capture.current = fiberRoot; + }, + }); + try { + ReactDOM.flushSync(() => root?.render(React.createElement(Render))); + assert.ok(capture.current); + fiber = Bippy.traverseFiber( + capture.current.current, + (candidate) => candidate.type === Render, + ); + assert.ok(fiber); + } finally { + unsubscribe(); + } + }, cleanup: () => { - ReactDOM.flushSync(() => root.unmount()); - container.remove(); + ReactDOM.flushSync(() => root?.unmount()); + container?.remove(); + root = undefined; + fiber = null; }, }, ), @@ -83,7 +95,7 @@ export const createHookBenchmarks = ({ `inspectHooks/${scenario}`, ["bippy/source#inspectHooks"], () => Source.inspectHooks(Render, {}), - verifyStateCount(count), + verifyStateValues(count), { units: count }, ), ); diff --git a/packages/conformance/benchmarks/import-worker.ts b/packages/conformance/benchmarks/import-worker.ts new file mode 100644 index 00000000..b536ea28 --- /dev/null +++ b/packages/conformance/benchmarks/import-worker.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const [format, entry] = process.argv.slice(2); +assert.ok(format === "esm" || format === "cjs"); +assert.ok(entry); +const require = createRequire(import.meta.url); +const entryPath = fileURLToPath(entry); +const startTime = performance.now(); +if (format === "esm") await import(entry); +else require(entryPath); +console.log((performance.now() - startTime) * 1000); diff --git a/packages/conformance/benchmarks/instrumentation.ts b/packages/conformance/benchmarks/instrumentation.ts index 8468f9d2..0af99762 100644 --- a/packages/conformance/benchmarks/instrumentation.ts +++ b/packages/conformance/benchmarks/instrumentation.ts @@ -1,12 +1,14 @@ import assert from "node:assert/strict"; import type { ReactDevToolsTarget, ReactRenderer, Unsubscribe } from "bippy"; -import { benchmarkCase, type BenchmarkCase, type BenchmarkContext } from "./harness.js"; +import { + benchmarkCase, + createBenchmarkSuite, + equals, + type BenchmarkCase, + type BenchmarkContext, +} from "./harness.js"; import { createFiber, createTree } from "./fixtures.js"; -const equals = - (expected: unknown) => - (value: unknown): void => - assert.equal(value, expected); const createRenderer = (): ReactRenderer => ({ version: "19.2.4", rendererPackageName: "benchmark", @@ -23,13 +25,7 @@ const events: Array<"commit" | "unmount" | "post-commit" | "schedule"> = [ export const createInstrumentationBenchmarks = ({ Bippy }: BenchmarkContext): BenchmarkCase[] => { const target: ReactDevToolsTarget = {}; const hook = Bippy.getRDTHook(undefined, target); - const cases: BenchmarkCase[] = []; - const add = ( - name: string, - scenario: string, - run: BenchmarkCase["run"], - verify: BenchmarkCase["verify"], - ) => cases.push(benchmarkCase(`${name}/${scenario}`, [`bippy#${name}`], run, verify)); + const { cases, add } = createBenchmarkSuite("bippy"); add("getRDTHook", "warm", () => Bippy.getRDTHook(undefined, target), equals(hook)); add( "patchRDTHook", @@ -55,8 +51,8 @@ export const createInstrumentationBenchmarks = ({ Bippy }: BenchmarkContext): Be () => Bippy.onRendererInject(() => {}, target)(), equals(undefined), ); - let targets: ReactDevToolsTarget[] = []; for (const name of hookInstallers) { + let targets: ReactDevToolsTarget[] = []; cases.push( benchmarkCase( `${name}/cold-target`, @@ -82,18 +78,18 @@ export const createInstrumentationBenchmarks = ({ Bippy }: BenchmarkContext): Be const eventRoot = createTree(0, "wide").root; const fiber = createFiber({ return: eventRoot.current }); const subscriptions: Unsubscribe[] = []; - let calls = 0; + let callCount = 0; cases.push( benchmarkCase( `instrument/${event}-${count}-listeners`, ["bippy#instrument"], () => { - calls = 0; + callCount = 0; if (event === "commit") eventHook.onCommitFiberRoot(1, eventRoot, undefined); else if (event === "unmount") eventHook.onCommitFiberUnmount(1, fiber); else if (event === "post-commit") eventHook.onPostCommitFiberRoot(1, eventRoot); else eventHook.onScheduleFiberRoot?.(1, eventRoot, null); - return calls; + return callCount; }, equals(count), { @@ -102,7 +98,7 @@ export const createInstrumentationBenchmarks = ({ Bippy }: BenchmarkContext): Be subscriptions.push(Bippy.instrument({ target: eventTarget })); for (let index = 0; index < count; index++) { const listener = () => { - calls++; + callCount++; }; subscriptions.push( Bippy.instrument({ @@ -163,22 +159,22 @@ export const createInstrumentationBenchmarks = ({ Bippy }: BenchmarkContext): Be const throwingHook = Bippy.getRDTHook(undefined, throwingTarget); const originalError = console.error; let unsubscribeThrowing: Unsubscribe | undefined; - let reports = 0; + let reportCount = 0; const listenerError = new Error("benchmark listener error"); cases.push( benchmarkCase( "instrument/throwing-listener-stubbed-reporter", ["bippy#instrument"], () => { - reports = 0; + reportCount = 0; throwingHook.onCommitFiberRoot(1, root, undefined); - return reports; + return reportCount; }, equals(1), { prepare: () => { console.error = () => { - reports++; + reportCount++; }; unsubscribeThrowing ??= Bippy.instrument({ target: throwingTarget, diff --git a/packages/conformance/benchmarks/journal.ts b/packages/conformance/benchmarks/journal.ts new file mode 100644 index 00000000..a3d09f2a --- /dev/null +++ b/packages/conformance/benchmarks/journal.ts @@ -0,0 +1,35 @@ +import { appendFileSync, mkdirSync, mkdtempSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { formatBenchmarkReport, type BenchmarkRunReport, type RunMetadata } from "./report.js"; + +export interface JournalEntry { + kind: "metadata" | "worker" | "import" | "useFiber" | "complete"; + data: unknown; +} + +export const createBenchmarkJournal = (outputDirectory: URL, metadata: RunMetadata) => { + mkdirSync(outputDirectory, { recursive: true }); + const directory = pathToFileURL(mkdtempSync(join(fileURLToPath(outputDirectory), "run-")) + "/"); + const progress = new URL("progress.jsonl", directory); + const append = (entry: JournalEntry): void => + appendFileSync(progress, JSON.stringify(entry) + "\n"); + append({ kind: "metadata", data: metadata }); + const complete = (report: BenchmarkRunReport): void => { + const name = metadata.isQuickMode ? "smoke" : "latest"; + const json = JSON.stringify(report, null, 2) + "\n"; + const markdown = formatBenchmarkReport(report); + writeFileSync(new URL("report.json", directory), json); + writeFileSync(new URL("report.md", directory), markdown); + for (const [extension, content] of [ + ["json", json], + ["md", markdown], + ]) { + const temporary = new URL(`${name}.${extension}.tmp`, directory); + writeFileSync(temporary, content); + renameSync(temporary, new URL(`${name}.${extension}`, outputDirectory)); + } + append({ kind: "complete", data: { json: "report.json", markdown: "report.md" } }); + }; + return { directory, progress, append, complete }; +}; diff --git a/packages/conformance/benchmarks/process.ts b/packages/conformance/benchmarks/process.ts new file mode 100644 index 00000000..0bd0db1b --- /dev/null +++ b/packages/conformance/benchmarks/process.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { conformanceDirectory } from "../scripts/test-inventory.js"; + +export const runBenchmarkProcess = ( + entry: URL, + arguments_: string[], + reactBuild: string, + isNative = false, +): string => { + const result = spawnSync( + process.execPath, + [...(isNative ? [] : ["--import", "tsx"]), fileURLToPath(entry), ...arguments_], + { + cwd: conformanceDirectory, + env: { + ...process.env, + NODE_ENV: reactBuild, + TSX_TSCONFIG_PATH: fileURLToPath(new URL("../tsconfig-built.json", import.meta.url)), + }, + encoding: "utf8", + timeout: 180000, + maxBuffer: 20 * 1024 * 1024, + }, + ); + assert.equal( + result.status, + 0, + `${entry.href}\n${result.error ?? ""}\n${result.stdout}\n${result.stderr}`, + ); + return result.stdout; +}; diff --git a/packages/conformance/benchmarks/report.ts b/packages/conformance/benchmarks/report.ts index 79be3f96..15a283e2 100644 --- a/packages/conformance/benchmarks/report.ts +++ b/packages/conformance/benchmarks/report.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; import type { BenchmarkResult } from "./harness.js"; +import { getBenchmarkVariant, type BenchmarkVariant } from "./configuration.js"; +import { getSampleStatistics } from "./statistics.js"; export interface ExportInventory { entry: string; @@ -7,10 +9,8 @@ export interface ExportInventory { data: string[]; } -export interface WorkerReport { +export interface WorkerReport extends BenchmarkVariant { group: string; - format: string; - reactBuild: string; reactVersion: string; exports: ExportInventory[]; results: BenchmarkResult[]; @@ -31,10 +31,69 @@ export interface UseFiberResult { } export interface UseFiberReport { - format: string; + format: BenchmarkVariant["format"]; results: UseFiberResult[]; } +export interface ImportResult extends BenchmarkVariant { + entry: string; + sampleUs: number[]; + medianUs: number; +} + +export interface BundleInfo { + name: string; + bytes: number; + gzipBytes: number; + sha256: string; +} + +export interface RunMetadata { + timestamp: string; + isQuickMode: boolean; + node: string; + platform: string; + arch: string; + cpu?: string; + gitRevision: string; + lockfileSha256: string; + bundles: BundleInfo[]; +} + +export interface BenchmarkRunReport { + schemaVersion: number; + metadata: RunMetadata; + scope: { + callableExports: string[]; + nonCallableExports: string[]; + useFiberReactBuild: "production"; + syntheticSourceFetch: boolean; + }; + imports: ImportResult[]; + reports: WorkerReport[]; + useFiber: UseFiberReport[]; +} + +interface UnknownRecord { + [name: string]: unknown; +} + +export const verifyRecord: (value: unknown) => asserts value is UnknownRecord = (value) => { + assert.ok(value && typeof value === "object" && !Array.isArray(value), "Expected an object"); +}; +const verifyStrings: (value: unknown) => asserts value is string[] = (value) => { + assert.ok(Array.isArray(value) && value.every((entry) => typeof entry === "string")); +}; +const verifyNumbers: (value: unknown) => asserts value is number[] = (value) => { + assert.ok( + Array.isArray(value) && + value.every((entry) => typeof entry === "number" && Number.isFinite(entry) && entry >= 0), + ); +}; +const verifyInteger = (value: unknown, minimum: number): void => { + assert.ok(typeof value === "number" && Number.isInteger(value) && value >= minimum); +}; + export const verifyBenchmarkCoverage = ( exports: ExportInventory[], measured: Set, @@ -49,17 +108,121 @@ export const verifyBenchmarkCoverage = ( ); }; -export const verifyWorkerReport = (report: WorkerReport): void => { - assert.ok(report.results.length > 0, "Empty benchmark group"); - assert.equal( - new Set(report.results.map(({ id: benchmarkId }) => benchmarkId)).size, - report.results.length, - ); - for (const result of report.results) { - assert.ok(result.iterations > 0 && Number.isInteger(result.iterations)); +export const verifyWorkerReport: (value: unknown) => asserts value is WorkerReport = (value) => { + verifyRecord(value); + getBenchmarkVariant(value.format, value.reactBuild); + assert.ok(typeof value.group === "string" && typeof value.reactVersion === "string"); + verifyInteger(value.maxRssBytes, 0); + assert.ok(Array.isArray(value.exports)); + const exports: unknown[] = value.exports; + for (const entry of exports) { + verifyRecord(entry); + assert.ok(typeof entry.entry === "string"); + verifyStrings(entry.callable); + verifyStrings(entry.data); + } + assert.ok(Array.isArray(value.results) && value.results.length > 0, "Empty benchmark group"); + const results: unknown[] = value.results; + const identifiers = new Set(); + for (const result of results) { + verifyRecord(result); + assert.ok( + typeof result.id === "string" && !identifiers.has(result.id), + "Duplicate or invalid benchmark ID", + ); + identifiers.add(result.id); + verifyStrings(result.apis); + verifyInteger(result.iterations, 1); + verifyInteger(result.units, 1); + verifyInteger(result.samples, 1); + verifyNumbers(result.sampleUs); assert.equal(result.samples, result.sampleUs.length); - assert.ok(result.samples > 0); - assert.ok(result.sampleUs.every((duration) => Number.isFinite(duration) && duration >= 0)); - assert.ok(result.minUs <= result.medianUs && result.medianUs <= result.maxUs); + const statistics = getSampleStatistics(result.sampleUs); + assert.equal(result.minUs, statistics.min, "Invalid minimum"); + assert.equal(result.medianUs, statistics.median, "Invalid median"); + assert.equal(result.maxUs, statistics.max, "Invalid maximum"); + } +}; + +export const verifyUseFiberResult: (value: unknown) => asserts value is UseFiberResult = ( + value, +) => { + verifyRecord(value); + assert.ok(typeof value.react === "string" && typeof value.reactVersion === "string"); + verifyInteger(value.components, 1); + verifyInteger(value.precedingHooks, 0); + verifyNumbers([ + value.baselineMountMs, + value.useFiberMountMs, + value.baselineUpdateMs, + value.useFiberUpdateMs, + value.mountCaptureMicroseconds, + value.updateCaptureMicroseconds, + ]); +}; + +const reportPrefix = "__REPORT__"; +export const writeReport = (report: unknown): void => + console.log(reportPrefix + JSON.stringify(report)); +export const readReport = (stdout: string): unknown => { + const reports = stdout.split("\n").filter((line) => line.startsWith(reportPrefix)); + assert.equal(reports.length, 1, "Worker must produce exactly one report"); + return JSON.parse(reports[0].slice(reportPrefix.length)); +}; + +export const formatBenchmarkReport = (report: BenchmarkRunReport): string => { + const { metadata } = report; + const markdown = [ + "# Benchmark report", + "", + metadata.isQuickMode + ? "Smoke validation only; timings are not performance results." + : `${metadata.node}; ${metadata.cpu}; ${metadata.platform}/${metadata.arch}.`, + "", + "Timings are per complete operation. Setup and validation are outside timing. GC is not forced; small timings include harness overhead. Worker RSS includes fixtures and runtime, not just library allocations. Source fetching uses in-memory responses, not real network latency. These are not browser/mobile guarantees.", + "", + ]; + for (const worker of report.reports) { + markdown.push( + `## ${worker.format} / ${worker.reactBuild} / ${worker.group}`, + "", + `React ${worker.reactVersion}; peak worker RSS ${(worker.maxRssBytes / 1024 / 1024).toFixed(1)} MiB.`, + "", + "| Operation | Median µs | Min µs | Max µs | Iterations/sample |", + "| --- | ---: | ---: | ---: | ---: |", + ); + for (const result of worker.results) + markdown.push( + `| ${result.id} | ${result.medianUs.toFixed(3)} | ${result.minUs.toFixed(3)} | ${result.maxUs.toFixed(3)} | ${result.iterations} |`, + ); + markdown.push(""); + } + markdown.push( + "## Cold imports", + "", + "Native Node with cold module caches; filesystem caches are not flushed. Process startup and probe compilation are excluded.", + "", + "| Format | React build | Entrypoint | Median ms |", + "| --- | --- | --- | ---: |", + ); + for (const result of report.imports) + markdown.push( + `| ${result.format} | ${result.reactBuild} | ${result.entry} | ${(result.medianUs / 1000).toFixed(3)} |`, + ); + markdown.push( + "", + "## useFiber", + "", + "Production React only. Null-rendering components with 0/32 preceding refs. Full update times include reconciliation; compare the matching without-hook baseline.", + "", + "| Format | React | Components | Preceding refs | Baseline mount ms | Hook mount ms | Baseline update ms | Hook update ms |", + "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |", + ); + for (const worker of report.useFiber) { + for (const result of worker.results) + markdown.push( + `| ${worker.format} | ${result.react} (${result.reactVersion}) | ${result.components} | ${result.precedingHooks} | ${result.baselineMountMs} | ${result.useFiberMountMs} | ${result.baselineUpdateMs} | ${result.useFiberUpdateMs} |`, + ); } + return markdown.join("\n") + "\n"; }; diff --git a/packages/conformance/benchmarks/source.ts b/packages/conformance/benchmarks/source.ts index 35ca2513..24ffaa1d 100644 --- a/packages/conformance/benchmarks/source.ts +++ b/packages/conformance/benchmarks/source.ts @@ -2,29 +2,24 @@ import assert from "node:assert/strict"; import { encode, type SourceMapSegment } from "@jridgewell/sourcemap-codec"; import type { Fiber } from "bippy"; import type { HooksTree, SourceFetch, SourceMap, StackFrame } from "bippy/source"; -import { benchmarkCase, type BenchmarkCase, type BenchmarkContext } from "./harness.js"; +import { + benchmarkCase, + createBenchmarkSuite, + type BenchmarkCase, + type BenchmarkContext, +} from "./harness.js"; import { Component, createDebugStack, createFiber, createTree, linkChildren } from "./fixtures.js"; const assertFrame = (value: unknown): void => { assert.ok(value && typeof value === "object"); - assert.equal(typeof Reflect.get(value, "fileName"), "string"); + assert.ok("fileName" in value && typeof value.fileName === "string"); }; const assertFrames = (value: unknown): void => assert.ok(Array.isArray(value) && value.length > 0); -export const createSourceBenchmarks = ({ Source }: BenchmarkContext): BenchmarkCase[] => { - const cases: BenchmarkCase[] = []; - const add = ( - name: string, - scenario: string, - run: BenchmarkCase["run"], - verify: BenchmarkCase["verify"], - isAsync = false, - ) => - cases.push( - benchmarkCase(`${name}/${scenario}`, [`bippy/source#${name}`], run, verify, { - async: isAsync, - }), - ); +export const createSourceBenchmarks = ({ + Source, +}: Pick): BenchmarkCase[] => { + const { cases, add } = createBenchmarkSuite("bippy/source"); const bundleUrl = "https://bench.example/bundle.js"; const sourceContent = "export const MappedComponent = () => {\nconst [count, setCount] = useState(0);\nreturn count;\n};"; @@ -64,7 +59,8 @@ export const createSourceBenchmarks = ({ Source }: BenchmarkContext): BenchmarkC () => Source.getSource(child, true, sourceFetch), (value) => { assert.ok(value && typeof value === "object"); - assert.equal(Reflect.get(value, "fileName"), "src/component.tsx"); + assert.ok("fileName" in value); + assert.equal(value.fileName, "src/component.tsx"); }, true, ); @@ -207,15 +203,27 @@ export const createSourceBenchmarks = ({ Source }: BenchmarkContext): BenchmarkC "getSourceFromSourceMap", `lines-${size}`, () => Source.getSourceFromSourceMap(sourceMap, size, 0), - assertFrame, + (value) => + assert.deepEqual(value, { + columnNumber: 0, + fileName: "src/component.tsx", + functionName: "last", + isIgnoreListed: false, + lineNumber: size, + }), ); add( "getSourceFromSourceMapByFunctionName", `tail-${size}`, () => Source.getSourceFromSourceMapByFunctionName(sourceMap, "last"), (value) => { - assertFrame(value); - assert.equal(Reflect.get(Object(value), "lineNumber"), size); + assert.deepEqual(value, { + columnNumber: 0, + fileName: "src/component.tsx", + functionName: "last", + isIgnoreListed: false, + lineNumber: size, + }); }, ); const segmented: SourceMap = { @@ -226,18 +234,50 @@ export const createSourceBenchmarks = ({ Source }: BenchmarkContext): BenchmarkC "getSourceFromSourceMap", `segments-${size}`, () => Source.getSourceFromSourceMap(segmented, 1, size - 1), - assertFrame, + (value) => + assert.deepEqual(value, { + columnNumber: size - 1, + fileName: "src/component.tsx", + functionName: undefined, + isIgnoreListed: false, + lineNumber: 1, + }), ); const contentMap: SourceMap = { ...sourceMap, sources: Array.from({ length: size }, (_, index) => `source${index}.tsx`), - sourcesContent: Array.from({ length: size }, () => sourceContent), + sourcesContent: Array.from({ length: size }, (_, index) => `export const value = ${index};`), }; add( "getSourceContentFromSourceMap", `tail-${size}`, () => Source.getSourceContentFromSourceMap(contentMap, `source${size - 1}.tsx`), - (value) => assert.equal(value, sourceContent), + (value) => assert.equal(value, `export const value = ${size - 1};`), + ); + } + for (const hasApplicationSource of [false, true]) { + const ignoredMap: SourceMap = { + version: 3, + sources: ["vendor.tsx", "app.tsx"], + names: ["Target"], + ignoredSourceIndices: new Set([0]), + mappings: Array.from({ length: 10000 }, (_, index) => [ + [0, hasApplicationSource && index === 9999 ? 1 : 0, index, 0, 0], + ]), + }; + add( + "getSourceFromSourceMapByFunctionName", + `ignored-10000-${hasApplicationSource ? "application-tail" : "fallback"}`, + () => Source.getSourceFromSourceMapByFunctionName(ignoredMap, "Target"), + (value) => { + assert.deepEqual(value, { + columnNumber: 0, + fileName: hasApplicationSource ? "app.tsx" : "vendor.tsx", + functionName: "Target", + isIgnoreListed: !hasApplicationSource, + lineNumber: hasApplicationSource ? 10000 : 1, + }); + }, ); } const indexed: SourceMap = { @@ -257,7 +297,8 @@ export const createSourceBenchmarks = ({ Source }: BenchmarkContext): BenchmarkC ); const assertMap = (value: unknown): void => { assert.ok(value && typeof value === "object"); - const decodedMappings: unknown = Reflect.get(value, "mappings"); + assert.ok("mappings" in value); + const decodedMappings = value.mappings; assert.ok(Array.isArray(decodedMappings) && decodedMappings.length === 1001); }; add( diff --git a/packages/conformance/benchmarks/statistics.ts b/packages/conformance/benchmarks/statistics.ts new file mode 100644 index 00000000..e7c8ac50 --- /dev/null +++ b/packages/conformance/benchmarks/statistics.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; + +export interface SampleStatistics { + median: number; + min: number; + max: number; +} + +export const getSampleStatistics = (samples: number[]): SampleStatistics => { + assert.ok( + samples.length > 0 && samples.every((sample) => Number.isFinite(sample) && sample >= 0), + ); + const ordered = samples.toSorted((first, second) => first - second); + const middleIndex = Math.floor(ordered.length / 2); + return { + median: + ordered.length % 2 + ? ordered[middleIndex] + : (ordered[middleIndex - 1] + ordered[middleIndex]) / 2, + min: ordered[0], + max: ordered[ordered.length - 1], + }; +}; diff --git a/packages/conformance/benchmarks/use-fiber-fixtures.ts b/packages/conformance/benchmarks/use-fiber-fixtures.ts index e4293698..a80a66f4 100644 --- a/packages/conformance/benchmarks/use-fiber-fixtures.ts +++ b/packages/conformance/benchmarks/use-fiber-fixtures.ts @@ -1,3 +1,5 @@ +import assert from "node:assert/strict"; +import { verifyRecord } from "./report.js"; import { earlyReactVersionFixtures, reactVersionFixtures, @@ -8,13 +10,40 @@ export interface UseFiberConfiguration { precedingHooks: number; } -export const getUseFiberFixtures = (quick: boolean) => +export interface UseFiberWorkerConfiguration extends UseFiberConfiguration { + react: string; + builtEntryUrl: string; + reactUrl: string; + reactDOMUrl: string; + reactDOMClientUrl?: string; + sampleCount: number; + updateCount: number; +} + +export const verifyUseFiberConfiguration: ( + value: unknown, +) => asserts value is UseFiberWorkerConfiguration = (value) => { + verifyRecord(value); + for (const name of ["react", "builtEntryUrl", "reactUrl", "reactDOMUrl"]) + assert.equal(typeof value[name], "string"); + assert.ok(value.reactDOMClientUrl === undefined || typeof value.reactDOMClientUrl === "string"); + for (const name of ["components", "sampleCount", "updateCount", "precedingHooks"]) { + const count = value[name]; + assert.ok( + typeof count === "number" && + Number.isInteger(count) && + count >= (name === "precedingHooks" ? 0 : 1), + ); + } +}; + +export const getUseFiberFixtures = (isQuickMode: boolean) => [...earlyReactVersionFixtures, ...reactVersionFixtures].filter( - ({ label }) => !quick || label === "19", + ({ label }) => !isQuickMode || label === "19", ); -export const getUseFiberConfigurations = (quick: boolean): UseFiberConfiguration[] => - quick +export const getUseFiberConfigurations = (isQuickMode: boolean): UseFiberConfiguration[] => + isQuickMode ? [{ components: 10, precedingHooks: 0 }] : [100, 1000].flatMap((components) => [0, 32].map((precedingHooks) => ({ components, precedingHooks })), diff --git a/packages/conformance/benchmarks/use-fiber-worker.ts b/packages/conformance/benchmarks/use-fiber-worker.ts new file mode 100644 index 00000000..7aec0a70 --- /dev/null +++ b/packages/conformance/benchmarks/use-fiber-worker.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import type { ReactNode } from "react"; +import type { BenchmarkContext } from "./harness.js"; +import { createBrowser } from "./browser.js"; +import { getSampleStatistics } from "./statistics.js"; +import { verifyUseFiberConfiguration } from "./use-fiber-fixtures.js"; +import { verifyUseFiberResult, writeReport, type UseFiberResult } from "./report.js"; + +interface LegacyReactDOM { + flushSync: BenchmarkContext["ReactDOM"]["flushSync"]; + render?: (element: ReactNode, container: Element) => unknown; + unmountComponentAtNode?: (container: Element) => boolean; +} + +interface RevisionProps { + revision: number; +} + +interface CaptureSample { + isEnabled: boolean; + mountMs: number; + updateMs: number; + mountCaptureMicroseconds: number; + updateCaptureMicroseconds: number; +} + +const configuration: unknown = JSON.parse(process.argv[2]); +verifyUseFiberConfiguration(configuration); +const browser = createBrowser(); +try { + const BippyModule = await import(configuration.builtEntryUrl); + const Bippy: BenchmarkContext["Bippy"] = BippyModule.default ?? BippyModule; + const React: BenchmarkContext["React"] = (await import(configuration.reactUrl)).default; + const ReactDOM: LegacyReactDOM = (await import(configuration.reactDOMUrl)).default; + const ReactDOMClient: BenchmarkContext["ReactDOMClient"] | null = configuration.reactDOMClientUrl + ? (await import(configuration.reactDOMClientUrl)).default + : null; + const samples: CaptureSample[] = []; + for (let trial = 0; trial <= configuration.sampleCount; trial++) { + for (const isEnabled of trial % 2 === 0 ? [false, true] : [true, false]) { + let captureTime = 0; + let invalidFiberCount = 0; + let renderCount = 0; + const Probe = (props: RevisionProps) => { + for (let hookIndex = 0; hookIndex < configuration.precedingHooks; hookIndex++) + React.useRef(null); + const captureStart = performance.now(); + const fiber = isEnabled ? Bippy.useFiber() : null; + captureTime += performance.now() - captureStart; + renderCount++; + if (isEnabled && (!fiber || fiber.type !== Probe || fiber.pendingProps !== props)) + invalidFiberCount++; + return null; + }; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = ReactDOMClient?.createRoot(container); + const render = (revision: number): void => { + const elements = Array.from({ length: configuration.components }, (_, index) => + React.createElement(Probe, { key: index, revision }), + ); + ReactDOM.flushSync(() => { + if (root) root.render(elements); + else { + assert.ok(ReactDOM.render); + ReactDOM.render(elements, container); + } + }); + }; + try { + const mountStart = performance.now(); + render(0); + const mountMs = performance.now() - mountStart; + const mountCaptureMicroseconds = (captureTime * 1000) / configuration.components; + captureTime = 0; + const updateStart = performance.now(); + for (let revision = 1; revision <= configuration.updateCount; revision++) render(revision); + const updateMs = (performance.now() - updateStart) / configuration.updateCount; + const updateCaptureMicroseconds = + (captureTime * 1000) / (configuration.updateCount * configuration.components); + assert.equal(invalidFiberCount, 0); + assert.equal(renderCount, configuration.components * (configuration.updateCount + 1)); + if (trial > 0) + samples.push({ + isEnabled, + mountMs, + updateMs, + mountCaptureMicroseconds, + updateCaptureMicroseconds, + }); + } finally { + ReactDOM.flushSync(() => { + if (root) root.unmount(); + else ReactDOM.unmountComponentAtNode?.(container); + }); + container.remove(); + } + } + } + const baselineSamples = samples.filter(({ isEnabled }) => !isEnabled); + const captureSamples = samples.filter(({ isEnabled }) => isEnabled); + const getMedian = (values: number[]): number => + Number(getSampleStatistics(values).median.toFixed(3)); + const result: UseFiberResult = { + react: configuration.react, + reactVersion: React.version, + components: configuration.components, + precedingHooks: configuration.precedingHooks, + baselineMountMs: getMedian(baselineSamples.map(({ mountMs }) => mountMs)), + useFiberMountMs: getMedian(captureSamples.map(({ mountMs }) => mountMs)), + baselineUpdateMs: getMedian(baselineSamples.map(({ updateMs }) => updateMs)), + useFiberUpdateMs: getMedian(captureSamples.map(({ updateMs }) => updateMs)), + mountCaptureMicroseconds: getMedian( + captureSamples.map(({ mountCaptureMicroseconds }) => mountCaptureMicroseconds), + ), + updateCaptureMicroseconds: getMedian( + captureSamples.map(({ updateCaptureMicroseconds }) => updateCaptureMicroseconds), + ), + }; + verifyUseFiberResult(result); + writeReport(result); +} finally { + await browser.happyDOM.close(); +} + +// HACK: Early React schedulers retain MessagePorts; exit only after stdout has flushed. +process.stdout.end(() => process.exit(0)); diff --git a/packages/conformance/benchmarks/use-fiber.ts b/packages/conformance/benchmarks/use-fiber.ts new file mode 100644 index 00000000..36254477 --- /dev/null +++ b/packages/conformance/benchmarks/use-fiber.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { cpSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { + createIsolatedReactRuntime, + removeIsolatedReactRuntimes, +} from "../tests/unit/isolated-react-runtime.js"; +import { + getUseFiberConfigurations, + getUseFiberFixtures, + type UseFiberWorkerConfiguration, +} from "./use-fiber-fixtures.js"; +import { + readReport, + verifyUseFiberResult, + type UseFiberReport, + type UseFiberResult, +} from "./report.js"; +import { runBenchmarkProcess } from "./process.js"; +import type { BenchmarkVariant } from "./configuration.js"; + +export const runUseFiberBenchmarks = ( + format: BenchmarkVariant["format"], + isQuickMode: boolean, + onResult: (result: UseFiberResult) => void, +): UseFiberReport => { + const results: UseFiberResult[] = []; + try { + for (const fixture of getUseFiberFixtures(isQuickMode)) { + const runtime = createIsolatedReactRuntime(fixture); + const builtEntry = new URL( + `../dist/index.${format === "esm" ? "js" : "cjs"}`, + runtime.bippyEntryUrl, + ); + cpSync( + fileURLToPath(new URL("../../bippy/dist/", import.meta.url)), + fileURLToPath(new URL("./", builtEntry)), + { recursive: true }, + ); + for (const configuration of getUseFiberConfigurations(isQuickMode)) { + const request: UseFiberWorkerConfiguration = { + ...configuration, + react: fixture.label, + builtEntryUrl: builtEntry.href, + reactUrl: runtime.reactUrl, + reactDOMUrl: runtime.reactDOMUrl, + reactDOMClientUrl: fixture.major >= 18 ? runtime.reactDOMClientUrl : undefined, + sampleCount: isQuickMode ? 1 : 5, + updateCount: isQuickMode ? 2 : 5, + }; + const result = readReport( + runBenchmarkProcess( + new URL("./use-fiber-worker.ts", import.meta.url), + [JSON.stringify(request)], + "production", + ), + ); + verifyUseFiberResult(result); + assert.equal(result.react, fixture.label); + assert.equal(result.components, configuration.components); + assert.equal(result.precedingHooks, configuration.precedingHooks); + results.push(result); + onResult(result); + } + } + return { format, results }; + } finally { + removeIsolatedReactRuntimes(); + } +}; diff --git a/packages/conformance/scripts/benchmark-all.ts b/packages/conformance/scripts/benchmark-all.ts index 95d9cbef..c17601d9 100644 --- a/packages/conformance/scripts/benchmark-all.ts +++ b/packages/conformance/scripts/benchmark-all.ts @@ -1,174 +1,143 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { readFileSync, readdirSync, writeFileSync } from "node:fs"; import { cpus } from "node:os"; import { fileURLToPath } from "node:url"; import { gzipSync } from "node:zlib"; +import ts from "typescript"; +import { benchmarkBuilds, benchmarkFormats } from "../benchmarks/configuration.js"; +import { benchmarkGroups } from "../benchmarks/groups.js"; +import { createBenchmarkJournal } from "../benchmarks/journal.js"; +import { runBenchmarkProcess } from "../benchmarks/process.js"; import { + readReport, verifyBenchmarkCoverage, verifyWorkerReport, - type UseFiberReport, - type UseFiberResult, - type WorkerReport, + type BenchmarkRunReport, + type RunMetadata, } from "../benchmarks/report.js"; -import { - getUseFiberConfigurations, - getUseFiberFixtures, -} from "../benchmarks/use-fiber-fixtures.js"; -import { conformanceDirectory, getExpectedExports, repositoryDirectory } from "./test-inventory.js"; - -interface ImportResult { - entry: string; - format: string; - reactBuild: string; - sampleUs: number[]; - medianUs: number; -} +import { getSampleStatistics } from "../benchmarks/statistics.js"; +import { runUseFiberBenchmarks } from "../benchmarks/use-fiber.js"; +import { getExpectedExports, repositoryDirectory } from "./test-inventory.js"; -const quick = process.argv.includes("--quick"); -const formats = ["esm", "cjs"]; -const environments = ["development", "production"]; -const groups = ["core", "instrumentation", "source", "hooks"]; -const reports: WorkerReport[] = []; -const useFiber: UseFiberReport[] = []; -const imports: ImportResult[] = []; +const isQuickMode = process.argv.includes("--quick"); const builtDirectory = new URL("../../bippy/dist/", import.meta.url); -const run = (arguments_: string[], environment: string): string => { - const result = spawnSync(process.execPath, arguments_, { - cwd: conformanceDirectory, - env: { - ...process.env, - NODE_ENV: environment, - TSX_TSCONFIG_PATH: fileURLToPath(new URL("../tsconfig-built.json", import.meta.url)), - }, - encoding: "utf8", - timeout: 180000, - maxBuffer: 20 * 1024 * 1024, - }); - assert.equal( - result.status, - 0, - `${arguments_.join(" ")}\n${result.error ?? ""}\n${result.stdout}\n${result.stderr}`, - ); - return result.stdout; +const gitRevision = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repositoryDirectory, + encoding: "utf8", +}); +assert.equal(gitRevision.status, 0, gitRevision.stderr); +const metadata: RunMetadata = { + timestamp: new Date().toISOString(), + isQuickMode, + node: process.version, + platform: process.platform, + arch: process.arch, + cpu: cpus()[0]?.model, + gitRevision: gitRevision.stdout.trim(), + lockfileSha256: createHash("sha256") + .update(readFileSync(new URL("../../../pnpm-lock.yaml", import.meta.url))) + .digest("hex"), + bundles: readdirSync(builtDirectory) + .filter((name) => /\.(js|cjs)$/.test(name)) + .sort() + .map((name) => { + const content = readFileSync(new URL(name, builtDirectory)); + return { + name, + bytes: content.byteLength, + gzipBytes: gzipSync(content).byteLength, + sha256: createHash("sha256").update(content).digest("hex"), + }; + }), }; - +const journal = createBenchmarkJournal( + new URL("../benchmarks/results/", import.meta.url), + metadata, +); console.log( - quick - ? "Benchmark smoke validation only; timings are not performance results." + isQuickMode + ? "Smoke validation only; timings are not performance results." : "Benchmarking built output sequentially; no real network requests.", ); -for (const format of formats) { - for (const environment of environments) { - for (const group of groups) { - const stdout = run( - [ - "--import", - "tsx", - fileURLToPath(new URL("./benchmark-worker.ts", import.meta.url)), - format, - group, - ...(quick ? ["quick"] : []), - ], - environment, +console.log(`Progress journal: ${fileURLToPath(journal.progress)}`); +const nativeProbe = new URL("import-worker.mjs", journal.directory); +writeFileSync( + nativeProbe, + ts.transpileModule( + readFileSync(new URL("../benchmarks/import-worker.ts", import.meta.url), "utf8"), + { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }, + ).outputText, +); +const report: BenchmarkRunReport = { + schemaVersion: 2, + metadata, + imports: [], + reports: [], + useFiber: [], + scope: { + callableExports: [], + nonCallableExports: [], + useFiberReactBuild: "production", + syntheticSourceFetch: true, + }, +}; +for (const format of benchmarkFormats) { + for (const reactBuild of benchmarkBuilds) { + for (const group of benchmarkGroups) { + const result = readReport( + runBenchmarkProcess( + new URL("./benchmark-worker.ts", import.meta.url), + [format, group.name, ...(isQuickMode ? ["--quick"] : [])], + reactBuild, + ), ); - const reportLine = stdout.split("\n").find((line) => line.startsWith("__REPORT__")); - assert.ok(reportLine, "Worker did not produce a report"); - const report: WorkerReport = JSON.parse(reportLine.slice("__REPORT__".length)); - verifyWorkerReport(report); - assert.equal(report.group, group); - assert.equal(report.format, format); - assert.equal(report.reactBuild, environment); - for (const inventory of report.exports) { + verifyWorkerReport(result); + assert.equal(result.format, format); + assert.equal(result.reactBuild, reactBuild); + assert.equal(result.group, group.name); + for (const inventory of result.exports) assert.deepEqual( [...inventory.callable, ...inventory.data].sort(), getExpectedExports(inventory.entry), ); - } - reports.push(report); - console.log(`${format}/${environment}/${group}: ${report.results.length} cases verified`); + journal.append({ kind: "worker", data: result }); + report.reports.push(result); + console.log(`${format}/${reactBuild}/${group.name}: ${result.results.length} cases verified`); } for (const entry of ["index", "source", "install-hook-only"]) { const entryUrl = new URL(`${entry}.${format === "esm" ? "js" : "cjs"}`, builtDirectory); - const sampleUs: number[] = []; - for (let sample = 0; sample < (quick ? 1 : 7); sample++) { - const stdout = run( - [ - "--input-type=module", - "--eval", - ` - import { createRequire } from "node:module"; - const require = createRequire(import.meta.url); - const start = performance.now(); - ${format === "esm" ? `await import(${JSON.stringify(entryUrl.href)});` : `require(${JSON.stringify(fileURLToPath(entryUrl))});`} - console.log((performance.now() - start) * 1000); - `, - ], - environment, - ); - const duration = Number(stdout.trim()); - assert.ok(Number.isFinite(duration) && duration >= 0); - sampleUs.push(duration); - } - imports.push({ + const sampleUs = Array.from({ length: isQuickMode ? 1 : 7 }, () => + Number(runBenchmarkProcess(nativeProbe, [format, entryUrl.href], reactBuild, true).trim()), + ); + const result = { entry, format, - reactBuild: environment, + reactBuild, sampleUs, - medianUs: sampleUs.toSorted((first, second) => first - second)[ - Math.floor(sampleUs.length / 2) - ], - }); + medianUs: getSampleStatistics(sampleUs).median, + }; + journal.append({ kind: "import", data: result }); + report.imports.push(result); } } - const stdout = run( - [ - "--import", - "tsx", - fileURLToPath(new URL("./benchmark-use-fiber.ts", import.meta.url)), - ...(quick ? ["--quick"] : []), - ...(format === "cjs" ? ["--cjs"] : []), - ], - "production", - ); - const results: UseFiberResult[] = stdout - .split("\n") - .filter((line) => line.startsWith("{")) - .map((line) => JSON.parse(line)); - const expectedConfigurations = getUseFiberFixtures(quick).flatMap(({ label }) => - getUseFiberConfigurations(quick).map( - ({ components, precedingHooks }) => `${label}/${components}/${precedingHooks}`, - ), - ); - assert.deepEqual( - results - .map(({ react, components, precedingHooks }) => `${react}/${components}/${precedingHooks}`) - .sort(), - expectedConfigurations.sort(), + report.useFiber.push( + runUseFiberBenchmarks(format, isQuickMode, (result) => { + journal.append({ kind: "useFiber", data: { format, result } }); + console.log( + `${format}/production/useFiber/${result.react}: ${result.components} components, ${result.precedingHooks} preceding refs verified`, + ); + }), ); - for (const result of results) { - assert.ok(result.components > 0 && result.react && result.reactVersion); - assert.ok( - [ - result.useFiberMountMs, - result.useFiberUpdateMs, - result.baselineMountMs, - result.baselineUpdateMs, - ].every((duration) => Number.isFinite(duration) && duration >= 0), - ); - } - useFiber.push({ format, results }); - console.log(`${format}/production/useFiber: ${results.length} configurations verified`); } - -const inventory = reports[0].exports; -for (const format of formats) { - for (const environment of environments) { +const inventory = report.reports[0].exports; +for (const format of benchmarkFormats) { + for (const reactBuild of benchmarkBuilds) { const measured = new Set( - reports - .filter((report) => report.format === format && report.reactBuild === environment) - .flatMap((report) => report.results.flatMap(({ apis }) => apis)), + report.reports + .filter((result) => result.format === format && result.reactBuild === reactBuild) + .flatMap((result) => result.results.flatMap(({ apis }) => apis)), ); verifyBenchmarkCoverage( inventory.map((entry) => ({ @@ -180,123 +149,18 @@ for (const format of formats) { } } const allMeasured = new Set( - reports.flatMap((report) => report.results.flatMap(({ apis }) => apis)), + report.reports.flatMap((result) => result.results.flatMap(({ apis }) => apis)), ); -assert.ok(useFiber.every((report) => report.results.length > 0)); +assert.ok(report.useFiber.every((result) => result.results.length > 0)); allMeasured.add("bippy#useFiber"); verifyBenchmarkCoverage(inventory, allMeasured); -const bundles = readdirSync(builtDirectory) - .filter((name) => /\.(js|cjs)$/.test(name)) - .sort() - .map((name) => { - const content = readFileSync(new URL(name, builtDirectory)); - return { - name, - bytes: content.byteLength, - gzipBytes: gzipSync(content).byteLength, - sha256: createHash("sha256").update(content).digest("hex"), - }; - }); -const gitRevision = spawnSync("git", ["rev-parse", "HEAD"], { - cwd: repositoryDirectory, - encoding: "utf8", -}); -const output = new URL(`../benchmarks/results/${quick ? "smoke" : "latest"}.json`, import.meta.url); -mkdirSync(new URL("./", output), { recursive: true }); -writeFileSync( - output, - JSON.stringify( - { - schemaVersion: 1, - metadata: { - timestamp: new Date().toISOString(), - quick, - node: process.version, - platform: process.platform, - arch: process.arch, - cpu: cpus()[0]?.model, - gitRevision: gitRevision.stdout.trim(), - lockfileSha256: createHash("sha256") - .update(readFileSync(new URL("../../../pnpm-lock.yaml", import.meta.url))) - .digest("hex"), - bundles, - }, - scope: { - callableExports: inventory.flatMap(({ entry, callable }) => - callable.map((name) => `${entry}#${name}`), - ), - nonCallableExports: inventory.flatMap(({ entry, data }) => - data.map((name) => `${entry}#${name}`), - ), - useFiberReactBuild: "production", - syntheticSourceFetch: true, - }, - imports, - reports, - useFiber, - }, - null, - 2, - ) + "\n", +report.scope.callableExports = inventory.flatMap(({ entry, callable }) => + callable.map((name) => `${entry}#${name}`), ); -const markdown: string[] = [ - "# Benchmark report", - "", - quick - ? "Smoke validation only. These timings are not performance results." - : `Node ${process.version}; ${cpus()[0]?.model}; ${process.platform}/${process.arch}.`, - "", - "All timings are per complete operation, not per node/hook. Fixture setup and verification are outside the timer. Batches run sequentially; GC is not forced. Small measurements include harness overhead.", - "", - "Core/source/inspection/listener cases use installed React in development and production. useFiber uses nine React fixtures in production only (one small fixture in smoke mode). Source fetching uses in-memory responses, not network latency. Peak worker RSS includes the harness, fixtures, and runtime; it is not a library allocation or leak measurement.", - "", -]; -for (const report of reports) { - markdown.push( - `## ${report.format} / ${report.reactBuild} / ${report.group}`, - "", - `React ${report.reactVersion}; peak worker RSS ${(report.maxRssBytes / 1024 / 1024).toFixed(1)} MiB.`, - "", - "| Operation | Median µs | Min µs | Max µs | Iterations/sample |", - "| --- | ---: | ---: | ---: | ---: |", - ); - for (const result of report.results) { - markdown.push( - `| ${result.id} | ${result.medianUs.toFixed(3)} | ${result.minUs.toFixed(3)} | ${result.maxUs.toFixed(3)} | ${result.iterations} |`, - ); - } - markdown.push(""); -} -markdown.push( - "## Cold entrypoint imports", - "", - "Fresh native Node processes; filesystem and dependency loading included, process startup excluded. Filesystem caches are not flushed. These are not browser download times.", - "", - "| Format | React build | Entrypoint | Median ms |", - "| --- | --- | --- | ---: |", +report.scope.nonCallableExports = inventory.flatMap(({ entry, data }) => + data.map((name) => `${entry}#${name}`), ); -for (const result of imports) - markdown.push( - `| ${result.format} | ${result.reactBuild} | ${result.entry} | ${(result.medianUs / 1000).toFixed(3)} |`, - ); -markdown.push( - "", - "## useFiber", - "", - "Null-rendering component workloads. Update times include React reconciliation; compare with the corresponding without-hook baseline. Custom 32-preceding-hook cases use 32 refs before useFiber.", - "", - "| Format | React | Components | Preceding hooks | Baseline mount ms | Hook mount ms | Baseline update ms | Hook update ms |", - "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |", -); -for (const report of useFiber) { - for (const result of report.results) - markdown.push( - `| ${report.format} | ${result.react} (${result.reactVersion}) | ${result.components} | ${result.precedingHooks} | ${result.baselineMountMs} | ${result.useFiberMountMs} | ${result.baselineUpdateMs} | ${result.useFiberUpdateMs} |`, - ); -} -const markdownOutput = new URL(`./${quick ? "smoke" : "latest"}.md`, output); -writeFileSync(markdownOutput, markdown.join("\n") + "\n"); +journal.complete(report); console.log( - `Verified ${inventory.reduce((count, entry) => count + entry.callable.length, 0)} callable exports; results: ${fileURLToPath(output)}`, + `Verified ${allMeasured.size} callable exports; reports: ${fileURLToPath(journal.directory)}`, ); -console.log(`Readable report: ${fileURLToPath(markdownOutput)}`); diff --git a/packages/conformance/scripts/benchmark-use-fiber.ts b/packages/conformance/scripts/benchmark-use-fiber.ts index 27715682..49520bed 100644 --- a/packages/conformance/scripts/benchmark-use-fiber.ts +++ b/packages/conformance/scripts/benchmark-use-fiber.ts @@ -1,141 +1,11 @@ -import assert from "node:assert/strict"; -import { cpSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { - createBrowserBootstrapScript, - createIsolatedReactRuntime, - removeIsolatedReactRuntimes, -} from "../tests/unit/isolated-react-runtime.js"; -import { runNodeScript } from "../tests/unit/run-node-script.js"; -import { - getUseFiberConfigurations, - getUseFiberFixtures, -} from "../benchmarks/use-fiber-fixtures.js"; +import { runUseFiberBenchmarks } from "../benchmarks/use-fiber.js"; +import { writeReport } from "../benchmarks/report.js"; -interface BenchmarkSample { - enabled: boolean; - mountMs: number; - updateMs: number; - mountCaptureMicroseconds: number; - updateCaptureMicroseconds: number; -} - -interface BenchmarkVersionSamples { - reactVersion: string; - samples: BenchmarkSample[]; -} - -const getMedian = (values: number[]): number => { - const sorted = values.toSorted((first, second) => first - second); - return Number(sorted[Math.floor(sorted.length / 2)].toFixed(3)); -}; - -const quick = process.argv.includes("--quick"); +const isQuickMode = process.argv.includes("--quick"); const format = process.argv.includes("--cjs") ? "cjs" : "esm"; -const fixtures = getUseFiberFixtures(quick); -const sampleCount = quick ? 1 : 5; -const updateCount = quick ? 2 : 5; -const configurations = getUseFiberConfigurations(quick); -const builtDirectory = fileURLToPath(new URL("../../bippy/dist/", import.meta.url)); - -console.log( - `Production ${format}; ${process.version}; median of ${sampleCount} samples after warm-up; ${updateCount} updates per sample`, -); -try { - for (const fixture of fixtures) { - const runtime = createIsolatedReactRuntime(fixture); - const builtEntry = new URL( - `../dist/index.${format === "esm" ? "js" : "cjs"}`, - runtime.bippyEntryUrl, - ); - cpSync(builtDirectory, fileURLToPath(new URL("./", builtEntry)), { recursive: true }); - for (const configuration of configurations) { - const result = runNodeScript( - ` - import assert from "node:assert/strict"; - ${createBrowserBootstrapScript()} - const BippyModule = await import(${JSON.stringify(builtEntry.href)}); - const Bippy = BippyModule.default ?? BippyModule; - const React = (await import(${JSON.stringify(runtime.reactUrl)})).default; - const ReactDOM = (await import(${JSON.stringify(runtime.reactDOMUrl)})).default; - const ReactDOMClient = ${fixture.major >= 18 ? `(await import(${JSON.stringify(runtime.reactDOMClientUrl)})).default` : "null"}; - const samples = []; - for (let trial = 0; trial <= ${sampleCount}; trial++) { - for (const enabled of trial % 2 === 0 ? [false, true] : [true, false]) { - let captureTime = 0; - let invalidFibers = 0; - let renders = 0; - const Probe = (props) => { - for (let hookIndex = 0; hookIndex < ${configuration.precedingHooks}; hookIndex++) React.useRef(null); - const captureStart = performance.now(); - const fiber = enabled ? Bippy.useFiber() : null; - captureTime += performance.now() - captureStart; - renders++; - if (enabled && (!fiber || fiber.type !== Probe || fiber.pendingProps !== props)) invalidFibers++; - return null; - }; - const container = document.createElement("div"); - document.body.appendChild(container); - const root = ReactDOMClient?.createRoot(container); - const render = (revision) => { - const elements = Array.from({ length: ${configuration.components} }, (_, index) => - React.createElement(Probe, { key: index, revision })); - ReactDOM.flushSync(() => { - if (root) root.render(elements); - else ReactDOM.render(elements, container); - }); - }; - const mountStart = performance.now(); - render(0); - const mountMs = performance.now() - mountStart; - const mountCaptureMicroseconds = captureTime * 1000 / ${configuration.components}; - captureTime = 0; - const updateStart = performance.now(); - for (let revision = 1; revision <= ${updateCount}; revision++) render(revision); - const updateMs = (performance.now() - updateStart) / ${updateCount}; - const updateCaptureMicroseconds = captureTime * 1000 / (${updateCount} * ${configuration.components}); - ReactDOM.flushSync(() => { - if (root) root.unmount(); - else ReactDOM.unmountComponentAtNode(container); - }); - container.remove(); - assert.equal(invalidFibers, 0); - assert.equal(renders, ${configuration.components} * (${updateCount} + 1)); - if (trial > 0) samples.push({ enabled, mountMs, updateMs, mountCaptureMicroseconds, updateCaptureMicroseconds }); - } - } - console.log("__REPORT__" + JSON.stringify({ reactVersion: React.version, samples })); - process.exit(0); - `, - { environment: { NODE_ENV: "production" }, timeout: 120000 }, - ); - assert.equal(result.status, 0, result.stderr); - const reportLine = result.stdout.split("\n").find((line) => line.startsWith("__REPORT__")); - assert.ok(reportLine); - const { samples, reactVersion }: BenchmarkVersionSamples = JSON.parse( - reportLine.slice("__REPORT__".length), - ); - const baseline = samples.filter(({ enabled }) => !enabled); - const captured = samples.filter(({ enabled }) => enabled); - console.log( - JSON.stringify({ - react: fixture.label, - reactVersion, - ...configuration, - baselineMountMs: getMedian(baseline.map(({ mountMs }) => mountMs)), - useFiberMountMs: getMedian(captured.map(({ mountMs }) => mountMs)), - baselineUpdateMs: getMedian(baseline.map(({ updateMs }) => updateMs)), - useFiberUpdateMs: getMedian(captured.map(({ updateMs }) => updateMs)), - mountCaptureMicroseconds: getMedian( - captured.map(({ mountCaptureMicroseconds }) => mountCaptureMicroseconds), - ), - updateCaptureMicroseconds: getMedian( - captured.map(({ updateCaptureMicroseconds }) => updateCaptureMicroseconds), - ), - }), - ); - } - } -} finally { - removeIsolatedReactRuntimes(); -} +const report = runUseFiberBenchmarks(format, isQuickMode, (result) => { + console.log( + `${format}/${result.react}: ${result.components} components, ${result.precedingHooks} preceding refs verified`, + ); +}); +writeReport(report); diff --git a/packages/conformance/scripts/benchmark-worker.ts b/packages/conformance/scripts/benchmark-worker.ts index 7aa52e8a..087e6d99 100644 --- a/packages/conformance/scripts/benchmark-worker.ts +++ b/packages/conformance/scripts/benchmark-worker.ts @@ -1,67 +1,55 @@ import assert from "node:assert/strict"; import { createRequire } from "node:module"; -import { Window } from "happy-dom"; -import { createCoreBenchmarks } from "../benchmarks/core.js"; -import { createHookBenchmarks } from "../benchmarks/hooks.js"; -import { createInstrumentationBenchmarks } from "../benchmarks/instrumentation.js"; -import { createSourceBenchmarks } from "../benchmarks/source.js"; +import { createBrowser } from "../benchmarks/browser.js"; +import { getBenchmarkVariant } from "../benchmarks/configuration.js"; +import { benchmarkGroups } from "../benchmarks/groups.js"; import { runBenchmark, type BenchmarkContext, type BenchmarkResult, } from "../benchmarks/harness.js"; +import { verifyWorkerReport, writeReport, type WorkerReport } from "../benchmarks/report.js"; -const [format, group, quick] = process.argv.slice(2); -assert.ok(format === "esm" || format === "cjs"); -const browser = new Window({ url: "https://bench.example" }); -Reflect.set(globalThis, "window", browser); -Reflect.set(globalThis, "document", browser.document); +const [formatArgument, groupName] = process.argv.slice(2); +const variant = getBenchmarkVariant(formatArgument, process.env.NODE_ENV); +const isQuickMode = process.argv.includes("--quick"); +const group = benchmarkGroups.find((candidate) => candidate.name === groupName); +assert.ok(group, "Unknown benchmark group"); +const browser = createBrowser(); let networkAttempts = 0; globalThis.fetch = async () => { networkAttempts++; throw new Error("Benchmark attempted a real network request"); }; const require = createRequire(import.meta.url); -for (const entry of ["bippy", "bippy/source"]) { - const resolved = format === "esm" ? import.meta.resolve(entry) : require.resolve(entry); - assert.match(resolved, /[/\\]dist[/\\]/, `${entry} must resolve to built output`); -} -const Bippy: BenchmarkContext["Bippy"] = - format === "esm" ? await import("bippy") : require("bippy"); -const Source: BenchmarkContext["Source"] = - format === "esm" ? await import("bippy/source") : require("bippy/source"); -const React: BenchmarkContext["React"] = require("react"); -const ReactDOM: BenchmarkContext["ReactDOM"] = require("react-dom"); -const ReactDOMClient: BenchmarkContext["ReactDOMClient"] = require("react-dom/client"); -const context: BenchmarkContext = { Bippy, Source, React, ReactDOM, ReactDOMClient }; -assert.equal(Bippy.getFiber, Bippy.getFiberFromHostInstance); -for (const name of Object.keys(Source).filter((name) => name.startsWith("Bippy"))) { - assert.equal(Reflect.get(Source, name), Reflect.get(Bippy, name)); -} -const factories = { - core: createCoreBenchmarks, - instrumentation: createInstrumentationBenchmarks, - source: createSourceBenchmarks, - hooks: createHookBenchmarks, -}; -assert.ok( - group === "core" || group === "instrumentation" || group === "source" || group === "hooks", -); -const results: BenchmarkResult[] = []; try { - const cases = factories[group](context); - assert.equal( - new Set(cases.map(({ id: benchmarkId }) => benchmarkId)).size, - cases.length, - "Duplicate benchmark IDs", - ); + for (const entry of ["bippy", "bippy/source"]) { + const resolved = variant.format === "esm" ? import.meta.resolve(entry) : require.resolve(entry); + assert.match(resolved, /[/\\]dist[/\\]/, `${entry} must resolve to built output`); + } + const Bippy: BenchmarkContext["Bippy"] = + variant.format === "esm" ? await import("bippy") : require("bippy"); + const Source: BenchmarkContext["Source"] = + variant.format === "esm" ? await import("bippy/source") : require("bippy/source"); + const context: BenchmarkContext = { + Bippy, + Source, + React: require("react"), + ReactDOM: require("react-dom"), + ReactDOMClient: require("react-dom/client"), + }; + assert.equal(Bippy.getFiber, Bippy.getFiberFromHostInstance); + for (const name of Object.keys(Source).filter((name) => name.startsWith("Bippy"))) + assert.equal(Reflect.get(Source, name), Reflect.get(Bippy, name)); + const cases = group.create(context); + const results: BenchmarkResult[] = []; for (const benchmark of cases) { try { results.push( await runBenchmark(benchmark, { - samples: quick ? 1 : 7, - targetMs: quick ? 0 : 8, - maxIterations: quick ? 1 : 262144, + samples: isQuickMode ? 1 : 7, + targetMs: isQuickMode ? 0 : 8, + maxIterations: isQuickMode ? 1 : 262144, }), ); } catch (error) { @@ -69,32 +57,29 @@ try { } } assert.equal(networkAttempts, 0, "Benchmarks must use fixture fetches only"); - const exports = [ - { entry: "bippy", module: Bippy }, - { entry: "bippy/source", module: Source }, - ].map(({ entry, module }) => ({ - entry, - callable: Object.entries(module) - .filter(([, value]) => typeof value === "function") - .map(([name]) => name) - .sort(), - data: Object.entries(module) - .filter(([, value]) => typeof value !== "function") - .map(([name]) => name) - .sort(), - })); - console.log( - "__REPORT__" + - JSON.stringify({ - group, - format, - reactBuild: process.env.NODE_ENV, - reactVersion: React.version, - exports, - results, - maxRssBytes: process.resourceUsage().maxRSS * 1024, - }), - ); + const report: WorkerReport = { + ...variant, + group: group.name, + reactVersion: context.React.version, + results, + maxRssBytes: process.resourceUsage().maxRSS * 1024, + exports: [ + { entry: "bippy", module: Bippy }, + { entry: "bippy/source", module: Source }, + ].map(({ entry, module }) => ({ + entry, + callable: Object.entries(module) + .filter(([, value]) => typeof value === "function") + .map(([name]) => name) + .sort(), + data: Object.entries(module) + .filter(([, value]) => typeof value !== "function") + .map(([name]) => name) + .sort(), + })), + }; + verifyWorkerReport(report); + writeReport(report); } finally { await browser.happyDOM.close(); } diff --git a/packages/conformance/tests/unit/benchmark-fixtures.test.ts b/packages/conformance/tests/unit/benchmark-fixtures.test.ts new file mode 100644 index 00000000..bb0c4c4b --- /dev/null +++ b/packages/conformance/tests/unit/benchmark-fixtures.test.ts @@ -0,0 +1,78 @@ +import { expect, it, vi } from "vite-plus/test"; +import * as Bippy from "../../../bippy/src/index.js"; +import * as Source from "../../../bippy/src/source/index.js"; +import React from "react"; +import * as ReactDOM from "react-dom"; +import * as ReactDOMClient from "react-dom/client"; +import { createHookBenchmarks } from "../../benchmarks/hooks.js"; +import { createHookComponent } from "../../benchmarks/hook-fixtures.js"; +import { createCoreBenchmarks } from "../../benchmarks/core.js"; +import { runBenchmark } from "../../benchmarks/harness.js"; + +const context = { Bippy, Source, React, ReactDOM, ReactDOMClient }; +const options = { samples: 1, targetMs: 0, maxIterations: 1 }; + +it.each([1, 16, 128])("retains %i distinct, typed state-call sites", (count) => { + const Render = createHookComponent(React, { count, kind: "distinct" }); + const hooks = Source.inspectHooks(Render, {}); + expect(hooks.map(({ value }) => value)).toEqual( + Array.from({ length: count }, (_, index) => index), + ); + const locations = hooks.map( + ({ hookSource }) => + hookSource && `${hookSource.fileName}:${hookSource.lineNumber}:${hookSource.columnNumber}`, + ); + expect(locations.every(Boolean)).toBe(true); + expect(new Set(locations).size).toBe(count); +}); + +it("mounts hook fixtures lazily and cleans them before another case runs", async () => { + const createRoot = vi.fn(ReactDOMClient.createRoot); + const childCount = document.body.childElementCount; + const cases = createHookBenchmarks({ + ...context, + ReactDOMClient: { ...ReactDOMClient, createRoot }, + }); + expect(createRoot).not.toHaveBeenCalled(); + await runBenchmark(cases[0], options); + expect(createRoot).toHaveBeenCalledOnce(); + expect(document.body.childElementCount).toBe(childCount); + await runBenchmark(cases[1], options); + expect(createRoot).toHaveBeenCalledOnce(); + expect(document.body.childElementCount).toBe(childCount); +}); + +it("cleans mounted fixtures when verification throws", async () => { + const childCount = document.body.childElementCount; + const benchmark = createHookBenchmarks(context)[0]; + await expect( + runBenchmark( + { + ...benchmark, + verify: () => { + throw new Error("verification failure"); + }, + }, + options, + ), + ).rejects.toThrow("verification failure"); + expect(document.body.childElementCount).toBe(childCount); + await runBenchmark(benchmark, options); + expect(document.body.childElementCount).toBe(childCount); +}); + +it("owns DOM lookup fixtures independently, including reversed execution order", async () => { + const childCount = document.body.childElementCount; + const createRoot = vi.fn(ReactDOMClient.createRoot); + const cases = createCoreBenchmarks({ + ...context, + ReactDOMClient: { ...ReactDOMClient, createRoot }, + }).filter(({ id: benchmarkId }) => benchmarkId.endsWith("/live-dom")); + expect(createRoot).not.toHaveBeenCalled(); + expect(cases).toHaveLength(2); + for (const benchmark of cases.reverse()) { + await runBenchmark(benchmark, options); + expect(document.body.childElementCount).toBe(childCount); + } + expect(createRoot).toHaveBeenCalledTimes(2); +}); diff --git a/packages/conformance/tests/unit/benchmark-harness.test.ts b/packages/conformance/tests/unit/benchmark-harness.test.ts index efe2883b..5575b362 100644 --- a/packages/conformance/tests/unit/benchmark-harness.test.ts +++ b/packages/conformance/tests/unit/benchmark-harness.test.ts @@ -44,7 +44,7 @@ it("awaits async work and validates every batch result", async () => { return "complete"; }, verify, - { async: true, cleanup }, + { isAsync: true, cleanup }, ), { samples: 2, targetMs: 0, maxIterations: 1 }, ); diff --git a/packages/conformance/tests/unit/benchmark-report.test.ts b/packages/conformance/tests/unit/benchmark-report.test.ts new file mode 100644 index 00000000..260dbf3b --- /dev/null +++ b/packages/conformance/tests/unit/benchmark-report.test.ts @@ -0,0 +1,146 @@ +import { expect, it } from "vite-plus/test"; +import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import * as Source from "../../../bippy/src/source/index.js"; +import { createSourceBenchmarks } from "../../benchmarks/source.js"; +import { createBenchmarkJournal } from "../../benchmarks/journal.js"; +import { + readReport, + verifyWorkerReport, + verifyUseFiberResult, + type WorkerReport, + type RunMetadata, + type BenchmarkRunReport, +} from "../../benchmarks/report.js"; +import { getSampleStatistics } from "../../benchmarks/statistics.js"; + +const createReport = (): WorkerReport => ({ + format: "esm", + reactBuild: "production", + group: "core", + reactVersion: "19", + exports: [], + maxRssBytes: 1, + results: [ + { + id: "test", + apis: [], + units: 1, + iterations: 1, + samples: 3, + sampleUs: [1, 2, 3], + minUs: 1, + medianUs: 2, + maxUs: 3, + }, + ], +}); + +it("verifies summary statistics against samples, not JavaScript coercion", () => { + const report = createReport(); + expect(() => verifyWorkerReport(report)).not.toThrow(); + for (const field of ["minUs", "medianUs", "maxUs"]) { + for (const value of [null, undefined, NaN, Infinity, -1, "2", 999]) { + expect(() => + verifyWorkerReport({ ...report, results: [{ ...report.results[0], [field]: value }] }), + ).toThrow(); + } + } +}); + +it("rejects invalid report structure, samples and duplicate identifiers", () => { + for (const value of [ + null, + [], + {}, + { ...createReport(), exports: [null] }, + { ...createReport(), format: "typo" }, + ]) + expect(() => verifyWorkerReport(value)).toThrow(); + for (const sampleUs of [[], [NaN], [Infinity], [null], ["1"], [-1]]) { + expect(() => + verifyWorkerReport({ + ...createReport(), + results: [{ ...createReport().results[0], sampleUs }], + }), + ).toThrow(); + } + expect(() => + verifyWorkerReport({ + ...createReport(), + results: [createReport().results[0], createReport().results[0]], + }), + ).toThrow(); + expect(() => + verifyUseFiberResult({ + react: "19", + reactVersion: "19", + components: 1, + precedingHooks: 0, + baselineMountMs: null, + }), + ).toThrow(); +}); + +it("uses one report envelope and one statistics implementation", () => { + expect(readReport('progress\n__REPORT__{"value":1}\n')).toEqual({ value: 1 }); + expect(() => readReport("progress")).toThrow(); + expect(() => readReport("__REPORT__{}\n__REPORT__{}")).toThrow(); + expect(getSampleStatistics([3, 1, 2])).toEqual({ min: 1, median: 2, max: 3 }); + expect(getSampleStatistics([4, 1])).toEqual({ min: 1, median: 2.5, max: 4 }); +}); + +it("cannot pass a tail source-content benchmark by returning the first source", () => { + const benchmark = createSourceBenchmarks({ Source }).find( + ({ id: benchmarkId }) => benchmarkId === "getSourceContentFromSourceMap/tail-10000", + ); + expect(benchmark).toBeDefined(); + if (!benchmark) throw new Error("Missing content benchmark"); + expect(() => benchmark.verify("export const value = 0;")).toThrow(); + expect(() => benchmark.verify(benchmark.run(0))).not.toThrow(); +}); + +it("keeps completed groups after failure and only replaces latest output on completion", () => { + const directory = pathToFileURL(mkdtempSync(join(tmpdir(), "bippy-benchmark-journal-")) + "/"); + const metadata: RunMetadata = { + timestamp: "test", + isQuickMode: false, + node: "test", + platform: "test", + arch: "test", + gitRevision: "test", + lockfileSha256: "test", + bundles: [], + }; + try { + const journal = createBenchmarkJournal(directory, metadata); + journal.append({ kind: "worker", data: createReport() }); + expect(readFileSync(journal.progress, "utf8").trim().split("\n")).toHaveLength(2); + expect(existsSync(new URL("latest.json", directory))).toBe(false); + const report: BenchmarkRunReport = { + schemaVersion: 2, + metadata, + reports: [createReport()], + imports: [], + useFiber: [], + scope: { + callableExports: [], + nonCallableExports: [], + useFiberReactBuild: "production", + syntheticSourceFetch: true, + }, + }; + journal.complete(report); + const successful = readFileSync(new URL("latest.json", directory), "utf8"); + const interrupted = createBenchmarkJournal(directory, metadata); + interrupted.append({ kind: "worker", data: createReport() }); + expect(readFileSync(new URL("latest.json", directory), "utf8")).toBe(successful); + expect(readFileSync(interrupted.progress, "utf8")).toContain('"kind":"worker"'); + expect(readFileSync(interrupted.progress, "utf8")).not.toContain('"kind":"complete"'); + expect(readFileSync(journal.progress, "utf8")).toContain('"kind":"complete"'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/conformance/tests/unit/benchmark-worker-process.test.ts b/packages/conformance/tests/unit/benchmark-worker-process.test.ts new file mode 100644 index 00000000..df2c4d41 --- /dev/null +++ b/packages/conformance/tests/unit/benchmark-worker-process.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { conformanceDirectory } from "../../scripts/test-inventory.js"; +import { it } from "vite-plus/test"; +import { readReport, verifyUseFiberResult } from "../../benchmarks/report.js"; +import type { UseFiberWorkerConfiguration } from "../../benchmarks/use-fiber-fixtures.js"; +import { + createIsolatedReactRuntime, + earlyReactVersionFixtures, + removeIsolatedReactRuntimes, +} from "./isolated-react-runtime.js"; + +it("flushes the typed worker report and exits despite early React scheduler ports", () => { + const fixture = earlyReactVersionFixtures[0]; + const runtime = createIsolatedReactRuntime(fixture); + const configuration: UseFiberWorkerConfiguration = { + react: fixture.label, + builtEntryUrl: runtime.bippyEntryUrl, + reactUrl: runtime.reactUrl, + reactDOMUrl: runtime.reactDOMUrl, + components: 2, + precedingHooks: 0, + sampleCount: 1, + updateCount: 1, + }; + try { + const result = spawnSync( + process.execPath, + [ + "--import", + "tsx", + join(conformanceDirectory, "benchmarks/use-fiber-worker.ts"), + JSON.stringify(configuration), + ], + { + env: { + ...process.env, + NODE_ENV: "production", + TSX_TSCONFIG_PATH: join(conformanceDirectory, "tsconfig-built.json"), + }, + encoding: "utf8", + timeout: 10000, + }, + ); + assert.equal(result.status, 0, `${result.error ?? ""}\n${result.stdout}\n${result.stderr}`); + const report = readReport(result.stdout); + verifyUseFiberResult(report); + assert.equal(report.reactVersion, fixture.label); + assert.equal(report.components, 2); + } finally { + removeIsolatedReactRuntimes(); + } +}, 15000); diff --git a/packages/conformance/tests/unit/source-hot-paths.test.ts b/packages/conformance/tests/unit/source-hot-paths.test.ts new file mode 100644 index 00000000..5b17a5c3 --- /dev/null +++ b/packages/conformance/tests/unit/source-hot-paths.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { it } from "vite-plus/test"; +import { + createStackParser, + extractLocation, + parseStack, +} from "../../../bippy/src/source/parse-stack.js"; +import { + getSourceContentFromSourceMap, + getSourceFromSourceMapByFunctionName, + type SourceMap, +} from "../../../bippy/src/source/symbolication.js"; + +const getPreviousLocation = ( + location: string, +): [string, string | undefined, string | undefined] => { + if (!location.includes(":")) return [location, undefined, undefined]; + const sanitized = + location.startsWith("(") && /:\d+\)$/.test(location) ? location.slice(1, -1) : location; + const parts = /(.+?)(?::(\d+))?(?::(\d+))?$/.exec(sanitized); + return parts + ? [parts[1], parts[2] || undefined, parts[3] || undefined] + : [sanitized, undefined, undefined]; +}; + +it("preserves location extraction for ports, route groups, empty prefixes, and line terminators", () => { + const prefixes = [ + "", + ":", + "::", + "file", + "http://localhost:3000/(route)/file.tsx", + "C:\\src\\file.tsx", + "a\nb", + "a\rb", + "a\u2028b", + "a\u2029b", + "a:".repeat(1000), + ]; + const suffixes = ["", ":", ":0", ":01", ":1:2", ":1:2:3", ":-1:2", ":1:", ":1:2\n", ":١:٢"]; + for (const prefix of prefixes) { + for (const suffix of suffixes) { + for (const location of [prefix + suffix, `(${prefix}${suffix})`]) + assert.deepEqual(extractLocation(location), getPreviousLocation(location), location); + } + } + const characters = ["a", ":", "0", "1", "(", ")", "\n"]; + for (let combination = 0; combination < characters.length ** 5; combination++) { + let remaining = combination; + let location = ""; + for (let index = 0; index < 5; index++) { + location += characters[remaining % characters.length]; + remaining = Math.floor(remaining / characters.length); + } + assert.deepEqual(extractLocation(location), getPreviousLocation(location), location); + } +}); + +it("reuses parsed frames only inside one inspection parser and preserves mixed stack formats", () => { + const parseCached = createStackParser(); + const shared = " at Shared (http://localhost:3000/(route)/file.tsx:2:3)"; + const first = parseCached(`Error\n at First (first.ts:1:2)\n${shared}`); + const second = parseCached(`Error\n at Second (second.ts:3:4)\n${shared}`); + assert.equal(first[1], second[1]); + assert.notEqual(first[1], createStackParser()(shared)[0]); + for (const stack of [ + shared, + "hook@file.ts:1:2", + "Error\nplain", + "[native code]", + `hook@file.ts:1:2\n${shared}`, + " at eval (eval at Render (file.ts:1:2), :3:4)", + " in Component", + "", + ]) { + assert.deepEqual(parseCached(stack), parseStack(stack, { includeInElement: false })); + } + const frames = parseStack(shared); + frames[0].fileName = "mutated"; + assert.equal(parseStack(shared)[0].fileName, "http://localhost:3000/(route)/file.tsx"); +}); + +it("does not materialize every ignored candidate when resolving a function name", () => { + let nameReads = 0; + const names = new Proxy(["Target"], { + get: (target, property, receiver) => { + if (property === "0") nameReads++; + return Reflect.get(target, property, receiver); + }, + }); + const sourceMap: SourceMap = { + version: 3, + names, + sources: ["vendor.tsx", "app.tsx"], + ignoredSourceIndices: new Set([0]), + mappings: Array.from({ length: 1000 }, (_, index) => [[0, index === 999 ? 1 : 0, index, 0, 0]]), + }; + assert.equal(getSourceFromSourceMapByFunctionName(sourceMap, "Target")?.fileName, "app.tsx"); + assert.equal(nameReads, 3); + nameReads = 0; + sourceMap.mappings[999] = [[0, 0, 999, 0, 0]]; + assert.equal(getSourceFromSourceMapByFunctionName(sourceMap, "Target")?.lineNumber, 1); + assert.equal(nameReads, 2); +}); + +it("keeps reverse source lookups live and preserves first duplicate semantics", () => { + const sourceMap: SourceMap = { + version: 3, + mappings: [ + [ + [0, 0, 0, 0, 0], + [1, 1, 1, 0, 0], + ], + ], + names: ["Component"], + sources: ["same.tsx", "same.tsx"], + sourcesContent: [null, "second"], + }; + assert.equal(getSourceContentFromSourceMap(sourceMap, "same.tsx"), null); + sourceMap.sourcesContent = ["first", "second"]; + assert.equal(getSourceContentFromSourceMap(sourceMap, "same.tsx"), "first"); + sourceMap.sources[0] = "renamed.tsx"; + assert.equal(getSourceContentFromSourceMap(sourceMap, "same.tsx"), "second"); + assert.equal( + getSourceFromSourceMapByFunctionName(sourceMap, "Component")?.fileName, + "renamed.tsx", + ); + sourceMap.ignoredSourceIndices = new Set([0]); + assert.equal(getSourceFromSourceMapByFunctionName(sourceMap, "Component")?.fileName, "same.tsx"); + sourceMap.mappings[0][1] = [1]; + assert.equal( + getSourceFromSourceMapByFunctionName(sourceMap, "Component")?.fileName, + "renamed.tsx", + ); + sourceMap.names = ["Updated"]; + assert.equal(getSourceFromSourceMapByFunctionName(sourceMap, "Component"), null); + assert.equal(getSourceFromSourceMapByFunctionName(sourceMap, "Updated")?.functionName, "Updated"); + sourceMap.sourcesContent[1] = "updated"; + assert.equal(getSourceContentFromSourceMap(sourceMap, "same.tsx"), "updated"); +});