diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abc44c4d..d31e5f97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,4 +32,6 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm run ${{ matrix.task }} - if: matrix.task == 'build' - run: pnpm --filter conformance exec tsx scripts/check-built.ts + run: | + pnpm --filter conformance exec tsx scripts/check-built.ts + pnpm --filter conformance bench:smoke 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/call-listener.ts b/packages/bippy/src/call-listener.ts new file mode 100644 index 00000000..2577fbc4 --- /dev/null +++ b/packages/bippy/src/call-listener.ts @@ -0,0 +1,13 @@ +export const callListener = ( + listener: (...listenerArguments: Arguments) => unknown, + receiver: unknown, + ...listenerArguments: Arguments +): void => { + try { + listener.apply(receiver, listenerArguments); + } catch (error) { + try { + console.error("Bippy instrumentation encountered an error:", error); + } catch {} + } +}; diff --git a/packages/bippy/src/core.ts b/packages/bippy/src/core.ts index 1aeaef27..cb0f63e9 100644 --- a/packages/bippy/src/core.ts +++ b/packages/bippy/src/core.ts @@ -1,5 +1,6 @@ // React must remain a type-only import because this module loads immediately after the DevTools hook. import type * as React from "react"; +import { callListener } from "./call-listener.js"; import type { Fiber, @@ -458,131 +459,91 @@ const releaseFiberId = (fiber: Fiber): void => { } }; -const mountFiberRecursively = ( +const getRenderedChild = (fiber: Fiber, skipPrimaryWrapper: boolean): Fiber | null => { + const workTags = getReactWorkTagsForFiber(fiber); + if (fiber.tag !== workTags.SuspenseComponent) return fiber.child; + if (fiber.memoizedState !== null) return fiber.child?.sibling?.child ?? null; + return skipPrimaryWrapper && workTags.OffscreenComponent !== -1 + ? (fiber.child?.child ?? null) + : fiber.child; +}; + +const mountFiberTree = ( onRender: RenderHandler, firstChild: Fiber, traverseSiblings: boolean, ): void => { + const pendingSiblings: Fiber[] = []; let fiber: Fiber | null = firstChild; - - while (fiber !== null) { + while (fiber) { getFiberId(fiber); - const shouldIncludeInTree = !shouldFilterFiber(fiber); - if (shouldIncludeInTree && didFiberRender(fiber)) { - onRender(fiber, "mount"); - } - - if (fiber.tag === getReactWorkTagsForFiber(fiber).SuspenseComponent) { - const isTimedOut = fiber.memoizedState !== null; - if (isTimedOut) { - // Special case: if Suspense mounts in a timed-out state, - // get the fallback child from the inner fragment and mount - // it as if it was our own child. Updates handle this too. - const primaryChildFragment = fiber.child; - const fallbackChildFragment = primaryChildFragment ? primaryChildFragment.sibling : null; - if (fallbackChildFragment) { - const fallbackChild = fallbackChildFragment.child; - if (fallbackChild !== null) { - mountFiberRecursively(onRender, fallbackChild, true); - } - } - } else { - const primaryChild = fiber.child?.child ?? null; - if (primaryChild !== null) { - mountFiberRecursively(onRender, primaryChild, true); - } - } - } else if (fiber.child !== null) { - mountFiberRecursively(onRender, fiber.child, true); - } - fiber = traverseSiblings ? fiber.sibling : null; + if (!shouldFilterFiber(fiber) && didFiberRender(fiber)) onRender(fiber, "mount"); + if (traverseSiblings && fiber.sibling) pendingSiblings.push(fiber.sibling); + fiber = getRenderedChild(fiber, true) ?? pendingSiblings.pop() ?? null; + traverseSiblings = true; } }; -const updateFiberRecursively = ( +interface FiberUpdate { + fiber: Fiber; + previousFiber: Fiber | null; + traverseSiblings: boolean; +} + +const updateFiberTree = ( onRender: RenderHandler, nextFiber: Fiber, prevFiber: Fiber | null, ): void => { - getFiberId(nextFiber); - if (!prevFiber) return; - getFiberId(prevFiber); - - const isSuspense = nextFiber.tag === getReactWorkTagsForFiber(nextFiber).SuspenseComponent; - - const shouldIncludeInTree = !shouldFilterFiber(nextFiber); - if (shouldIncludeInTree && didFiberRender(nextFiber)) { - onRender(nextFiber, "update"); - } - - // The behavior of timed-out Suspense trees is unique. - // Rather than unmount the timed out content (and possibly lose important state), - // React re-parents this content within a hidden Fragment while the fallback is showing. - // This behavior doesn't need to be observable in the DevTools though. - // It might even result in a bad user experience for e.g. node selection in the Elements panel. - // The easiest fix is to strip out the intermediate Fragment fibers, - // so the Elements panel and Profiler don't need to special case them. - // Suspense components only have a non-null memoizedState if they're timed-out. - const prevDidTimeout = isSuspense && prevFiber.memoizedState !== null; - const nextDidTimeOut = isSuspense && nextFiber.memoizedState !== null; - - // The logic below is inspired by the code paths in updateSuspenseComponent() - // inside ReactFiberBeginWork in the React source code. - if (prevDidTimeout && nextDidTimeOut) { - // Fallback -> Fallback: - // 1. Reconcile fallback set. - const nextFallbackChildSet = nextFiber.child?.sibling ?? null; - // Note: We can't use nextFiber.child.sibling.alternate - // because the set is special and alternate may not exist. - const prevFallbackChildSet = prevFiber.child?.sibling ?? null; - - if (nextFallbackChildSet !== null && prevFallbackChildSet !== null) { - updateFiberRecursively(onRender, nextFallbackChildSet, prevFallbackChildSet); - } - } else if (prevDidTimeout && !nextDidTimeOut) { - // Fallback -> Primary: - // 1. Unmount fallback set - // Note: don't emulate fallback unmount because React actually did it. - // 2. Mount primary set - const nextPrimaryChildSet = nextFiber.child; - - if (nextPrimaryChildSet !== null) { - mountFiberRecursively(onRender, nextPrimaryChildSet, true); + if (!prevFiber) { + getFiberId(nextFiber); + return; + } + const pendingUpdates: FiberUpdate[] = [ + { fiber: nextFiber, previousFiber: prevFiber, traverseSiblings: false }, + ]; + let update: FiberUpdate | undefined; + while ((update = pendingUpdates.pop())) { + const { fiber, previousFiber, traverseSiblings } = update; + if (traverseSiblings && fiber.sibling) { + pendingUpdates.push({ + fiber: fiber.sibling, + previousFiber: fiber.sibling.alternate, + traverseSiblings: true, + }); } - } else if (!prevDidTimeout && nextDidTimeOut) { - // Primary -> Fallback: - // 1. Hide primary set - // This is not a real unmount, so it won't get reported by React. - // We need to manually walk the previous tree and record unmounts. - unmountFiberChildrenRecursively(onRender, prevFiber); - - // 2. Mount fallback set - const nextFallbackChildSet = nextFiber.child?.sibling ?? null; - - if (nextFallbackChildSet !== null) { - mountFiberRecursively(onRender, nextFallbackChildSet, true); + if (!previousFiber) { + mountFiberTree(onRender, fiber, false); + continue; } - } else if (nextFiber.child !== prevFiber.child) { - // Common case: Primary -> Primary. - // This is the same code path as for non-Suspense fibers. - - // If the first child is different, we need to traverse them. - // Each next child will be either a new child (mount) or an alternate (update). - let nextChild = nextFiber.child; - - while (nextChild) { - // We already know children will be referentially different because - // they are either new mounts or alternates of previous children. - // Schedule updates and mounts depending on whether alternates exist. - // We don't track deletions here because they are reported separately. - if (nextChild.alternate) { - updateFiberRecursively(onRender, nextChild, nextChild.alternate); - } else { - mountFiberRecursively(onRender, nextChild, false); + getFiberId(fiber); + getFiberId(previousFiber); + if (!shouldFilterFiber(fiber) && didFiberRender(fiber)) onRender(fiber, "update"); + const isSuspense = fiber.tag === getReactWorkTagsForFiber(fiber).SuspenseComponent; + const wasTimedOut = isSuspense && previousFiber.memoizedState !== null; + const isTimedOut = isSuspense && fiber.memoizedState !== null; + if (wasTimedOut && isTimedOut) { + const nextFallback = fiber.child?.sibling; + const previousFallback = previousFiber.child?.sibling; + if (nextFallback && previousFallback) { + pendingUpdates.push({ + fiber: nextFallback, + previousFiber: previousFallback, + traverseSiblings: false, + }); } - - // Try the next child. - nextChild = nextChild.sibling; + } else if (wasTimedOut && !isTimedOut) { + if (fiber.child) mountFiberTree(onRender, fiber.child, true); + } else if (!wasTimedOut && isTimedOut) { + unmountFiberChildren(onRender, previousFiber); + const fallback = fiber.child?.sibling; + if (fallback) mountFiberTree(onRender, fallback, true); + } else if (fiber.child && fiber.child !== previousFiber.child) { + pendingUpdates.push({ + fiber: fiber.child, + previousFiber: fiber.child.alternate, + traverseSiblings: true, + }); } } }; @@ -595,30 +556,18 @@ const unmountFiber = (onRender: RenderHandler, fiber: Fiber): void => { } }; -const unmountFiberChildrenRecursively = (onRender: RenderHandler, fiber: Fiber): void => { - // We might meet a nested Suspense on our way. - const isTimedOutSuspense = - fiber.tag === getReactWorkTagsForFiber(fiber).SuspenseComponent && fiber.memoizedState !== null; - let child = fiber.child; - - if (isTimedOutSuspense) { - // If it's showing fallback tree, let's traverse it instead. - const primaryChildFragment = fiber.child; - const fallbackChildFragment = primaryChildFragment?.sibling ?? null; - - // Skip over to the real Fiber child. - child = fallbackChildFragment?.child ?? null; - } - - while (child !== null) { - // Record simulated unmounts children-first. - // We skip nodes without return because those are real unmounts. +const unmountFiberChildren = (onRender: RenderHandler, fiber: Fiber): void => { + const pendingSiblings: Fiber[] = []; + let child = getRenderedChild(fiber, false); + while (child) { + if (child.sibling) pendingSiblings.push(child.sibling); if (child.return !== null) { unmountFiber(onRender, child); - unmountFiberChildrenRecursively(onRender, child); + child = getRenderedChild(child, false); + } else { + child = null; } - - child = child.sibling; + child ??= pendingSiblings.pop() ?? null; } }; @@ -668,14 +617,14 @@ export const traverseRenderedFibers = (root: Fiber | FiberRoot, onRender: Render const isMounted = isRootFiberMounted(fiber); if (!wasMounted && isMounted) { - mountFiberRecursively(onRender, fiber, false); + mountFiberTree(onRender, fiber, false); } else if (wasMounted && isMounted) { - updateFiberRecursively(onRender, fiber, fiber.alternate); + updateFiberTree(onRender, fiber, fiber.alternate); } else if (wasMounted && !isMounted) { unmountFiber(onRender, fiber); } } else { - mountFiberRecursively(onRender, fiber, true); + mountFiberTree(onRender, fiber, true); } rootInstance.prevFiber = fiber; @@ -735,7 +684,7 @@ const setHookEventDispatchers = (rdtHook: ReactDevToolsGlobalHook): void => { didError, ) => { if (prevOnCommitFiberRoot) { - prevOnCommitFiberRoot.call(rdtHook, rendererID, root, priority, didError); + callListener(prevOnCommitFiberRoot, rdtHook, rendererID, root, priority, didError); } if (hookDispatchers.get(rdtHook)?.onCommitFiberRoot !== dispatchCommitFiberRoot) return; setReactWorkTagsForFiber(root.current, rdtHook.renderers.get(rendererID)); @@ -752,7 +701,7 @@ const setHookEventDispatchers = (rdtHook: ReactDevToolsGlobalHook): void => { } for (const { options, target } of instrumentationSubscriptions) { if (target === hookTargets.get(rdtHook) && options.onCommitFiberRoot) { - options.onCommitFiberRoot(rendererID, root, priority, didError); + callListener(options.onCommitFiberRoot, options, rendererID, root, priority, didError); } } }; @@ -771,7 +720,7 @@ const setHookEventDispatchers = (rdtHook: ReactDevToolsGlobalHook): void => { ) => { setReactWorkTagsForFiber(fiber, rdtHook.renderers.get(rendererID)); if (prevOnCommitFiberUnmount) { - prevOnCommitFiberUnmount.call(rdtHook, rendererID, fiber); + callListener(prevOnCommitFiberUnmount, rdtHook, rendererID, fiber); } if (hookDispatchers.get(rdtHook)?.onCommitFiberUnmount !== dispatchCommitFiberUnmount) { return; @@ -779,7 +728,7 @@ const setHookEventDispatchers = (rdtHook: ReactDevToolsGlobalHook): void => { try { for (const { options, target } of instrumentationSubscriptions) { if (target === hookTargets.get(rdtHook) && options.onCommitFiberUnmount) { - options.onCommitFiberUnmount(rendererID, fiber); + callListener(options.onCommitFiberUnmount, options, rendererID, fiber); } } } finally { @@ -800,14 +749,14 @@ const setHookEventDispatchers = (rdtHook: ReactDevToolsGlobalHook): void => { root, ) => { if (prevOnPostCommitFiberRoot) { - prevOnPostCommitFiberRoot.call(rdtHook, rendererID, root); + callListener(prevOnPostCommitFiberRoot, rdtHook, rendererID, root); } if (hookDispatchers.get(rdtHook)?.onPostCommitFiberRoot !== dispatchPostCommitFiberRoot) { return; } for (const { options, target } of instrumentationSubscriptions) { if (target === hookTargets.get(rdtHook) && options.onPostCommitFiberRoot) { - options.onPostCommitFiberRoot(rendererID, root); + callListener(options.onPostCommitFiberRoot, options, rendererID, root); } } }; @@ -826,12 +775,12 @@ const setHookEventDispatchers = (rdtHook: ReactDevToolsGlobalHook): void => { children, ) => { if (prevOnScheduleFiberRoot) { - prevOnScheduleFiberRoot.call(rdtHook, rendererID, root, children); + callListener(prevOnScheduleFiberRoot, rdtHook, rendererID, root, children); } if (hookDispatchers.get(rdtHook)?.onScheduleFiberRoot !== dispatchScheduleFiberRoot) return; for (const { options, target } of instrumentationSubscriptions) { if (target === hookTargets.get(rdtHook) && options.onScheduleFiberRoot) { - options.onScheduleFiberRoot(rendererID, root, children); + callListener(options.onScheduleFiberRoot, options, rendererID, root, children); } } }; diff --git a/packages/bippy/src/rdt-hook.ts b/packages/bippy/src/rdt-hook.ts index 348b2918..d6b4f976 100644 --- a/packages/bippy/src/rdt-hook.ts +++ b/packages/bippy/src/rdt-hook.ts @@ -1,5 +1,6 @@ // This module must load before React so renderers can inject into the hook. +import { callListener } from "./call-listener.js"; import type { FiberRoot, ReactDevToolsGlobalHook, ReactRenderer } from "./react-internals/index.js"; export interface Unsubscribe extends Disposable { @@ -117,7 +118,7 @@ export const removeActiveListener = ( const notifyActiveListeners = (target: ReactDevToolsTarget): void => { for (const listener of _onActiveListeners) { - if (activeListenerTargets.get(listener)?.has(target)) listener(); + if (activeListenerTargets.get(listener)?.has(target)) callListener(listener, undefined); } }; @@ -126,7 +127,7 @@ const notifyRendererInjectListeners = ( renderer: ReactRenderer, ): void => { for (const subscription of rendererInjectSubscriptions) { - if (subscription.target === target) subscription.listener(renderer); + if (subscription.target === target) callListener(subscription.listener, subscription, renderer); } }; @@ -135,7 +136,7 @@ const notifyRDTHookReplaceListeners = ( target: ReactDevToolsTarget, ): void => { for (const listener of rdtHookReplaceListeners) { - listener(rdtHook, target); + callListener(listener, undefined, rdtHook, target); } }; @@ -326,7 +327,7 @@ export const patchRDTHook = ( }; } if (!didNotifyActiveListeners && (renderers.size || rdtHook._instrumentationIsActive)) { - onActive?.(); + if (onActive) callListener(onActive, undefined); } }; diff --git a/packages/bippy/src/react-internals/index.ts b/packages/bippy/src/react-internals/index.ts index 433f6b16..fa63390e 100644 --- a/packages/bippy/src/react-internals/index.ts +++ b/packages/bippy/src/react-internals/index.ts @@ -18,8 +18,24 @@ export type { } from "./generated/react-work-tags.js"; export * from "./types.js"; +interface InheritedWorkTags { + workTags: Readonly; + generation: number; +} + const defaultReactWorkTags = getReactWorkTags(); const fiberReactWorkTags = new WeakMap>(); +const inheritedFiberWorkTags = new WeakMap(); +let workTagGeneration = 0; + +const getCachedWorkTags = (fiber: Fiber): Readonly | undefined => { + const assignedWorkTags = fiberReactWorkTags.get(fiber); + if (assignedWorkTags) return assignedWorkTags; + const inheritedWorkTags = inheritedFiberWorkTags.get(fiber); + return inheritedWorkTags?.generation === workTagGeneration + ? inheritedWorkTags.workTags + : undefined; +}; // React's experimental channel historically reported "0.0.0-experimental-" // as the runtime version; those builds use modern work tags, not the 16.x rows @@ -39,30 +55,36 @@ export const getReactWorkTagsForRenderer = ( export const setReactWorkTagsForFiber = (fiber: Fiber, renderer?: ReactRenderer): void => { const workTags = getReactWorkTagsForRenderer(renderer); + if ( + getCachedWorkTags(fiber) !== workTags || + (fiber.alternate && getCachedWorkTags(fiber.alternate) !== workTags) + ) { + workTagGeneration++; + } fiberReactWorkTags.set(fiber, workTags); if (fiber.alternate) fiberReactWorkTags.set(fiber.alternate, workTags); }; export const getReactWorkTagsForFiber = (fiber: Fiber): Readonly => { - const cachedWorkTags = fiberReactWorkTags.get(fiber); - if (cachedWorkTags) return cachedWorkTags; - - const traversedFibers: Fiber[] = [fiber]; - let rootFiber = fiber; - let workTags = defaultReactWorkTags; - while (rootFiber.return) { - rootFiber = rootFiber.return; - const ancestorWorkTags = fiberReactWorkTags.get(rootFiber); - if (ancestorWorkTags) { - workTags = ancestorWorkTags; + let workTags = getCachedWorkTags(fiber); + if (workTags) return workTags; + const traversedFibers: Fiber[] = []; + let ancestor = fiber; + while (!workTags) { + traversedFibers.push(ancestor); + const parent = ancestor.return; + if (!parent) { + workTags = inheritedFiberWorkTags.get(ancestor)?.workTags ?? defaultReactWorkTags; break; } - traversedFibers.push(rootFiber); + ancestor = parent; + workTags = getCachedWorkTags(ancestor); } + const inheritedWorkTags = { workTags, generation: workTagGeneration }; for (const traversedFiber of traversedFibers) { - fiberReactWorkTags.set(traversedFiber, workTags); + inheritedFiberWorkTags.set(traversedFiber, inheritedWorkTags); if (traversedFiber.alternate) { - fiberReactWorkTags.set(traversedFiber.alternate, workTags); + inheritedFiberWorkTags.set(traversedFiber.alternate, inheritedWorkTags); } } return workTags; diff --git a/packages/bippy/src/react.ts b/packages/bippy/src/react.ts index a7daa3f1..f9211bc8 100644 --- a/packages/bippy/src/react.ts +++ b/packages/bippy/src/react.ts @@ -2,6 +2,7 @@ import "./install-hook-only.js"; import React from "react"; import { isFiber, traverseFiber } from "./core.js"; import { _renderers } from "./rdt-hook.js"; +import { getCurrentFiberFromRoot } from "./react-internals/current-fiber.js"; import type { Fiber } from "./react-internals/index.js"; export type { Fiber } from "./react-internals/index.js"; @@ -115,6 +116,12 @@ const getRenderingFiberFromRoot = ( return null; } const renderingRoot = root.current.alternate; + const renderingFiber = getCurrentFiberFromRoot(fiber)?.alternate; + if (renderingFiber) { + let ancestor = renderingFiber; + while (ancestor.return) ancestor = ancestor.return; + if (ancestor === renderingRoot) return renderingFiber; + } return traverseFiber(renderingRoot, (candidate) => { if (candidate !== fiber && candidate !== fiber.alternate) return false; let parent = candidate; @@ -147,6 +154,9 @@ export const useFiber = (): Fiber | undefined => { : knownFiber.alternate && hasRenderMarker(knownFiber.alternate, renderMarker) ? knownFiber.alternate : getRenderingFiberFromRoot(knownCapture, renderMarker)); - if (fiber) fiberRef.current = { fiber, queue: knownCapture.queue }; + if (fiber) { + knownCapture.fiber = fiber; + fiberRef.current = knownCapture; + } return fiber ?? undefined; }; 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 4910a141..d90419c2 100644 --- a/packages/conformance/README.md +++ b/packages/conformance/README.md @@ -18,6 +18,9 @@ pnpm --filter conformance test --project conformance pnpm --filter conformance typecheck pnpm --filter conformance coverage pnpm --filter conformance test:built +pnpm --filter conformance bench:use-fiber +pnpm --filter conformance bench +pnpm --filter conformance bench:smoke ``` Library coverage retains its original unit-test scope and writes reports to `coverage/` here. The normal test command runs all projects, including exact upstream stack assertions, without coverage instrumentation. @@ -61,30 +64,110 @@ The source-backed audit reproduced and fixed: - **`useFiber` capture fragility:** restored early React updates without ref-parity guessing, fixed early CommonJS/ESM interop, rejected unrelated bound objects, and removed the dependency on external-store subscription binds. Development can capture through DevTools with locked `bind`; all tested versions can update after a successful capture without patching `bind`. - **Production Node crash:** the inherited browser DCE diagnostic scheduled a fatal exception for React's intentionally unbundled Node entrypoints. Node now skips that diagnostic; browser behavior remains tested. Packaged checks do not disable `checkDCE`. - **Inspection corruption:** hook replay mutated committed compiler-cache slots/indexes, and nested inspection stole outer hook state/logs. Replay now uses copied slots and an independent index, rejects reentrancy, and cleans up dependency-resolution errors. -- **Traversal failures:** `traverseFiber` overflowed on deep/wide trees; cyclic/deep type wrappers also overflowed. Both use iterative traversal, with cycle detection for wrappers. Suspense mount traversal now includes every visible sibling. +- **Traversal failures:** `traverseFiber` and `traverseRenderedFibers` overflowed on deep trees; cyclic/deep type wrappers also overflowed. These now use iterative traversal, with cycle detection for wrappers. Rendered-phase tests cover 20,000-deep and 20,000-wide mounts, updates, and simulated unmounts while preserving the existing visitation order. Suspense primary mounts also handle React 16's unwrapped children; live tests cover primary/fallback siblings across the version/build matrix. - **Incorrect identity checks:** forged/coercible element markers were accepted, and unchanged falsy host props were reported as renders. Element markers now use global symbol identity; prop comparison preserves falsy values. - **Excessive work:** cached work-tag lookup still walked to the root, current-fiber lookup scanned unrelated subtrees, and invalid host keys polluted the lookup cache. Operation-count regressions cover reductions from 1,001,000 to 2,000 parent reads, 2,000 to zero unrelated child reads, and 500 to zero poisoned-key reads in their respective fixtures. The audit also replaced the unchecked standalone inspection copy, removed obsolete recursion helpers, aligned the coverage provider with Vitest, and added type/published-entry checks to CI. Public aliases/constants were retained; internal disuse alone does not justify breaking exports. +### Runtime isolation and cache invalidation + +Synchronous errors from Bippy's activation, renderer-injection, hook-replacement, and instrumentation listeners are reported through `console.error` without stopping later listeners. Existing commit/schedule/post-commit/unmount hook callbacks receive the same isolation and retain their receiver. Reporting failures are contained too. Root tracking and Fiber-ID cleanup continue after callback failures. This intentionally replaces the old throw-and-stop behavior: React catches injection errors before retaining its hook, which otherwise disconnects future commits. Live tests cover development, production, and profiling builds across the version matrix. Rejected promises and failures inside a foreign hook's `inject` implementation are not isolated. + +Work-tag lookups distinguish explicit associations from inherited cache entries. Association changes invalidate inherited entries by generation; unchanged associations retain the fast path. Revalidation preserves explicit subtree/root associations and cached metadata on detached Fibers. A changed association can require unrelated inherited entries to walk their ancestors once again; ordinary commits with unchanged tags do not invalidate them. + ## Audit: remaining defects and limits -1. **High — listener error isolation.** Commit and injection dispatch stop on the first throwing callback. Existing library tests explicitly expect later listeners not to run. React catches injection failures before retaining its hook reference, so an injection listener can prevent future commit delivery. This needs an explicit error-reporting/isolation contract. -2. **Medium — stale work tags after late renderer association.** Reading a child before associating its root with a React 16 renderer leaves cached FunctionComponent tag `0` on the child while the root reports `1`. The ancestor fast path does not invalidate those caches. -3. **Medium — rendered-phase traversal depth.** `traverseRenderedFibers` still uses recursive mount/update/unmount visitors; a synthetic 20,000-deep tree throws `RangeError`. The stack-safety fix applies to `traverseFiber`, not these visitors. -4. **Medium — inspection is not a sandbox.** Replayed user code can mutate refs, props, or objects inside cached slots. Slot-array copying does not isolate the reachable object graph. `useFiber` may temporarily patch global `Function.prototype.bind` for an initial capture; production mounts with an already-locked intrinsic remain a constraint. -5. **API/version mismatches.** The React peer range begins at 16.0, but hooks require at least 16.8. The `useFiber` version, attack, and fuzz matrices include 16.8.6, 16.12.0, 16.13.0, 16.14, 17, 18, 19, canary, and experimental. This is not exhaustive patch-version coverage. `Fiber` props are typed as objects despite real null/primitive values; generated work-tag numbers are not directly assignable to `Fiber.tag`. The async traversal overload also promises a Promise for a null fiber, although runtime returns null: `await` works, `.then()` does not. -6. **Coverage gaps.** Some older direct ports still disable type checking or hardcode development expectations. Existing React 19 compatibility skips remain visible. Istanbul currently changes standalone inspection stack names (`Component` becomes `renderFunction`), so those exact assertions do not pass under coverage instrumentation. They remain enabled in normal runs. Full reconciler, scheduler, DOM, hydration, streaming SSR, Flight, compiler, native, and feature-gate suites are not ported. Per-renderer/version checks do not establish the same coverage for every API. -7. **Setup gaps.** The combined suite can emit localhost:3000 connection-refused errors while passing and needs a hermetic network audit. Installation still reports Detox/expect and playground Vite/plugin peer mismatches. Broad dependency ranges need review on lockfile refresh. `publint` suggests declaring supported Node versions and reviewing side effects; blindly setting `sideEffects: false` would break hook installation. -8. **Export scope.** The runtime inventory covers `bippy` and `bippy/source`, and packaged checks exercise `bippy/install-hook-only`. Public `./dist/*` patterns expose additional implementation chunks; removing them requires a compatibility decision. +1. **Medium — inspection is not a sandbox.** Replayed user code can mutate refs, props, or objects inside cached slots. Slot-array copying does not isolate the reachable object graph. `useFiber` may temporarily patch global `Function.prototype.bind` for an initial capture; production mounts with an already-locked intrinsic remain a constraint. +2. **API/version mismatches.** The React peer range begins at 16.0, but hooks require at least 16.8. The `useFiber` version, attack, and fuzz matrices include 16.8.6, 16.12.0, 16.13.0, 16.14, 17, 18, 19, canary, and experimental. This is not exhaustive patch-version coverage. `Fiber` props are typed as objects despite real null/primitive values; generated work-tag numbers are not directly assignable to `Fiber.tag`. The async traversal overload also promises a Promise for a null fiber, although runtime returns null: `await` works, `.then()` does not. +3. **Coverage gaps.** Some older direct ports still disable type checking or hardcode development expectations. Existing React 19 compatibility skips remain visible. Istanbul currently changes standalone inspection stack names (`Component` becomes `renderFunction`), so those exact assertions do not pass under coverage instrumentation. They remain enabled in normal runs. Full reconciler, scheduler, DOM, hydration, streaming SSR, Flight, compiler, native, and feature-gate suites are not ported. Per-renderer/version checks do not establish the same coverage for every API. +4. **Setup gaps.** The combined suite can emit localhost:3000 connection-refused errors while passing and needs a hermetic network audit. Installation still reports Detox/expect and playground Vite/plugin peer mismatches. Broad dependency ranges need review on lockfile refresh. `publint` suggests declaring supported Node versions and reviewing side effects; blindly setting `sideEffects: false` would break hook installation. +5. **Export scope.** The runtime inventory covers `bippy` and `bippy/source`, and packaged checks exercise `bippy/install-hook-only`. Public `./dist/*` patterns expose additional implementation chunks; removing them requires a compatibility decision. + +Next priorities are the API/version mismatches, unchecked ports, and broader runtime coverage. The audit was verified locally on Node 24; browser/Detox and CI's Node 22 runtime were not run locally. + +## Performance checks + +`pnpm --filter conformance bench:use-fiber` builds production ESM, loads it against isolated React versions, and compares mounts/updates with and without `useFiber`. It covers all nine React fixtures at 100/1,000 components with 0/32 preceding hooks. Add `--cjs` for CommonJS. It reports medians of five samples after warm-up, with five updates per sample. Timing is diagnostic, not a CI threshold. + +One local Node 24.20.0/Happy DOM run, with 1,000 null-rendering components and no preceding hooks, measured these milliseconds per full update: + +| React | Before the `useFiber` optimization | After | After, without `useFiber` | +| ------ | ---------------------------------- | ----- | ------------------------- | +| 16.8.6 | 2.719 | 0.262 | 0.130 | +| 18 | 0.239 | 0.210 | 0.116 | +| 19 | 0.237 | 0.195 | 0.142 | + +The meaningful change is removal of repeated root searches on ordinary early-React updates, which could make updating many `useFiber` components quadratic in tree size. Modern-React differences are small enough to treat as timing noise rather than a promised speedup. The capture record is also reused instead of allocating a replacement on every update. These are synthetic measurements, not browser/mobile guarantees. + +`use-fiber-performance.test.ts` checks zero unrelated-subtree reads and exact rendering-Fiber identity across the version/build matrix. Other operation-count tests retain linear ancestor lookup and avoid unrelated current-fiber searches. `useFiber` still scans hook lists for its marker, and ambiguous early-React topology retains a full-tree fallback; it is not universally constant-time. Initial production captures still allocate a bind proxy. Updates do not patch `bind` or schedule passive effects. Published-entry checks also enforce the `"use no memo"` directive in the exported ESM/CJS function. + +### Full public-export benchmarks + +`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 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` 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. + +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. 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. + +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 | +| --------------------------------------------------------------- | ------: | +| Inspect 128 state hooks | 3.2 ms | +| Inspect 128 custom-hook calls (384 primitives) | 9.6 ms | +| Traverse 10,000 updated Fibers | 1.1 ms | +| Simulate hiding 10,000 primary Fibers | 1.1 ms | +| Dispatch a commit to 1,000 listeners | 23 µs | +| Source-content lookup at the tail of 10,000 synthetic filenames | 185 µs | +| Function-name lookup at the tail of 10,000 mapping rows | 33 µs | +| Cold in-memory fetch and decode of a 1,001-line map | 100 µs | +| Cached source-map fetch | 0.13 µs | + +For 1,000 null-rendering React 19 components, update medians were 0.244 ms without `useFiber` and 0.307 ms with it. With 32 preceding refs, the corresponding values were 0.764 and 1.070 ms. React 16.8.6 without preceding hooks measured 0.193 and 0.381 ms. Differences between separate medians are diagnostic, not isolated per-hook costs; direct capture timers also include clock overhead. + +Hook inspection is the standout cost: avoid replaying every component's hooks on every render/commit. Large reverse source-map lookups and ancestor/root searches remain linear-work candidates for follow-up profiling. Warm direct lookups and listener dispatch are much cheaper in these fixtures. Cache writes and allocation-heavy cold cases show wider sample ranges. + +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. -Prioritize listener isolation, cache invalidation, and rendered-phase stack safety, then replace unchecked ports and expand the runtime matrix. The audit was verified locally on Node 24; browser/Detox and CI's Node 22 runtime were not run locally. +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. -React [16.8.6](https://github.com/facebook/react/blob/v16.8.6/packages/react-reconciler/src/ReactFiberHooks.js#L410-L415) through [16.12.0](https://github.com/facebook/react/blob/v16.12.0/packages/react-reconciler/src/ReactFiberHooks.js#L477-L482) attach hook state after the component returns. For these versions, the retained reducer queue proves the hook ran, and an iterative walk of the work-in-progress root locates the rendering alternate. This is not ref-parity guessing. The marker regression introduced in `e2b879b` is covered by mount, update, Strict Mode, hydration, bailout, suspension, render-phase retry, and fuzz cases on early React. Default React imports also fix the old CommonJS namespace interop failure; focused tests exercise both import and require paths through `tsx`. +React [16.8.6](https://github.com/facebook/react/blob/v16.8.6/packages/react-reconciler/src/ReactFiberHooks.js#L410-L415) through [16.12.0](https://github.com/facebook/react/blob/v16.12.0/packages/react-reconciler/src/ReactFiberHooks.js#L477-L482) attach hook state after the component returns. For these versions, the retained reducer queue proves the hook ran. React-derived current-fiber reflection now locates the rendering alternate on ordinary updates, after validating that its parent chain reaches the work-in-progress root. An iterative root search remains for ambiguous topology. This is not ref-parity guessing. The marker regression introduced in `e2b879b` is covered by mount, update, Strict Mode, hydration, bailout, suspension, render-phase retry, and fuzz cases on early React. Default React imports also fix the old CommonJS namespace interop failure; focused tests exercise both import and require paths through `tsx`. `use-fiber-capture-contract.test.ts` makes `useSyncExternalStore` throw if called, injects a decoy Fiber and an opaque bound argument through a dispatcher wrapper, and checks exact Fiber identity across development, production, and profiling builds. It also checks zero bind assignments on updates. The Native static suite checks reducer binding in the six shipped renderer bundles; it is not a Native runtime test. diff --git a/packages/conformance/benchmarks/.gitignore b/packages/conformance/benchmarks/.gitignore new file mode 100644 index 00000000..6628455c --- /dev/null +++ b/packages/conformance/benchmarks/.gitignore @@ -0,0 +1 @@ +/results/ 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 new file mode 100644 index 00000000..713b84f0 --- /dev/null +++ b/packages/conformance/benchmarks/core.ts @@ -0,0 +1,465 @@ +import assert from "node:assert/strict"; +import type { Fiber, FiberRoot, ReactDevToolsTarget, ReactRenderer } from "bippy"; +import { + benchmarkCase, + createBenchmarkSuite, + equals, + type BenchmarkCase, + type BenchmarkContext, +} from "./harness.js"; +import { + Component, + createFiber, + createTree, + linkChildren, + pairTrees, + type FiberTree, +} from "./fixtures.js"; + +interface SuspenseCommit { + root: FiberRoot; + nextRootFiber: Fiber; +} + +interface TypeWrapper { + type?: unknown; +} + +const treeShapes: Array<"deep" | "wide"> = ["deep", "wide"]; + +export const createCoreBenchmarks = ({ + Bippy, + React, + ReactDOM, + ReactDOMClient, +}: BenchmarkContext): BenchmarkCase[] => { + const { cases, add } = createBenchmarkSuite("bippy"); + const renderer: ReactRenderer = { + version: "19.2.4", + rendererPackageName: "benchmark", + bundleType: 1, + }; + const workTags = Bippy.getReactWorkTags(); + const component = createFiber({ type: Component }); + const host = createFiber({ tag: workTags.HostComponent, type: "div" }); + const element = React.createElement("div"); + const forgedElement = { $$typeof: Symbol.for("not-react") }; + cases.push(benchmarkCase("harness/sync-baseline", [], () => true, equals(true))); + add("isValidElement", "valid", () => Bippy.isValidElement(element), equals(true)); + add("isValidElement", "forged-symbol", () => Bippy.isValidElement(forgedElement), equals(false)); + add("isFiber", "valid", () => Bippy.isFiber(component), equals(true)); + add("isHostFiber", "host", () => Bippy.isHostFiber(host), equals(true)); + add("isCompositeFiber", "component", () => Bippy.isCompositeFiber(component), equals(true)); + add("didFiberRender", "performed-work", () => Bippy.didFiberRender(component), equals(true)); + const unchanged = createFiber({ + alternate: host, + tag: workTags.HostComponent, + memoizedProps: host.memoizedProps, + }); + add("didFiberRender", "unchanged-host", () => Bippy.didFiberRender(unchanged), equals(false)); + const memoized = createFiber(); + Reflect.set(memoized, "updateQueue", { memoCache: { data: [], index: 0 } }); + add("hasMemoCache", "present", () => Bippy.hasMemoCache(memoized), equals(true)); + add( + "compareSemver", + "prerelease", + () => Bippy.compareSemver("19.2.4-canary.10", "19.2.4-canary.2"), + equals(1), + ); + add( + "getReactWorkTags", + "versioned", + () => Bippy.getReactWorkTags("16.0.0"), + equals(Bippy.getReactWorkTags("16.0.0")), + ); + add( + "getReactWorkTagsForRenderer", + "versioned", + () => Bippy.getReactWorkTagsForRenderer(renderer), + equals(workTags), + ); + add( + "detectReactBuildType", + "development", + () => Bippy.detectReactBuildType(renderer), + equals("development"), + ); + const identifier = Bippy.getFiberId(component); + add("getFiberId", "warm", () => Bippy.getFiberId(component), equals(identifier)); + cases.push( + benchmarkCase( + "setFiberId/existing-id", + ["bippy#setFiberId"], + () => Bippy.setFiberId(component, identifier), + () => assert.equal(Bippy.getFiberId(component), identifier), + { maxIterations: 128 }, + ), + ); + let coldFibers: Fiber[] = []; + cases.push( + benchmarkCase( + "getFiberId/cold", + ["bippy#getFiberId"], + (iteration) => Bippy.getFiberId(coldFibers[iteration]), + (value) => assert.equal(typeof value, "number"), + { + prepare: (iterations) => { + coldFibers = Array.from({ length: iterations }, () => createFiber()); + }, + maxIterations: 128, + }, + ), + ); + add("getFiberById", "hit", () => Bippy.getFiberById(identifier), equals(component)); + add("getFiberById", "miss", () => Bippy.getFiberById(-1), equals(null)); + add("getLatestFiber", "no-alternate", () => Bippy.getLatestFiber(component), equals(component)); + add("getType", "plain", () => Bippy.getType(Component), equals(Component)); + add("getDisplayName", "plain", () => Bippy.getDisplayName(Component), equals("Component")); + for (const depth of [10, 1000]) { + let wrapper: unknown = Component; + for (let index = 0; index < depth; index++) wrapper = { type: wrapper }; + add("getType", `wrappers-${depth}`, () => Bippy.getType(wrapper), equals(Component)); + add( + "getDisplayName", + `wrappers-${depth}`, + () => Bippy.getDisplayName(wrapper), + equals("Component"), + ); + } + const cyclic: TypeWrapper = {}; + cyclic.type = cyclic; + add("getType", "cycle", () => Bippy.getType(cyclic), equals(null)); + + for (const size of [100, 1000, 10000]) { + for (const shape of treeShapes) { + 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(nextTree.root.current, (fiber) => fiber === selected), + equals(selected), + ); + add( + "traverseFiber", + `${shape}-${size}-miss`, + () => Bippy.traverseFiber(nextTree.root.current, () => false), + equals(null), + ); + add( + "getLatestFiber", + `${shape}-${size}-alternate`, + () => Bippy.getLatestFiber(leaf), + equals(selected), + ); + Bippy.setReactWorkTagsForFiber(previousTree.root.current, renderer); + Bippy.getReactWorkTagsForFiber(leaf); + add( + "getReactWorkTagsForFiber", + `${shape}-${size}-warm`, + () => Bippy.getReactWorkTagsForFiber(leaf), + equals(workTags), + ); + const currentRoot = nextTree.root; + cases.push( + benchmarkCase( + `traverseRenderedFibers/${shape}-${size}-update`, + ["bippy#traverseRenderedFibers"], + () => { + currentRoot.current = currentRoot.current.alternate ?? currentRoot.current; + let visitedFiberCount = 0; + Bippy.traverseRenderedFibers(currentRoot, (_fiber, phase) => { + if (phase === "update") visitedFiberCount++; + }); + return visitedFiberCount; + }, + equals(size + 1), + { + prepare: () => { + Bippy.traverseRenderedFibers(currentRoot, () => {}); + }, + units: size + 1, + }, + ), + ); + } + } + for (const size of [100, 1000, 10000]) { + 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(previousTree.fibers[size - 1]), + equals(nextTree.fibers[size - 1]), + ); + let commits: SuspenseCommit[] = []; + cases.push( + benchmarkCase( + `traverseRenderedFibers/suspense-hide-${size}`, + ["bippy#traverseRenderedFibers"], + (iteration) => { + const commit = commits[iteration]; + commit.root.current = commit.nextRootFiber; + let unmountedFiberCount = 0; + Bippy.traverseRenderedFibers(commit.root, (_fiber, phase) => { + if (phase === "unmount") unmountedFiberCount++; + }); + return unmountedFiberCount; + }, + equals(size), + { + prepare: (iterations) => { + commits = Array.from({ length: iterations }, () => { + const tree = createTree(size, "wide"); + const boundary = createFiber({ + tag: workTags.SuspenseComponent, + return: tree.root.current, + }); + const offscreen = createFiber({ + tag: workTags.OffscreenComponent, + return: boundary, + child: tree.root.current.child, + }); + boundary.child = offscreen; + tree.root.current.child = boundary; + for (const fiber of tree.fibers) fiber.return = offscreen; + const nextRootFiber = createFiber({ + tag: workTags.HostRoot, + alternate: tree.root.current, + memoizedState: tree.root.current.memoizedState, + }); + nextRootFiber.child = createFiber({ + tag: workTags.SuspenseComponent, + alternate: boundary, + return: nextRootFiber, + memoizedState: { memoizedState: null, next: null }, + }); + Bippy.traverseRenderedFibers(tree.root, () => {}); + return { root: tree.root, nextRootFiber }; + }); + }, + maxIterations: 4, + units: size, + }, + ), + ); + } + const ascending = createTree(1000, "deep"); + add( + "traverseFiber", + "ascending-1000", + () => + Bippy.traverseFiber(ascending.fibers[999], (fiber) => fiber === ascending.root.current, true), + equals(ascending.root.current), + ); + cases.push( + benchmarkCase( + "traverseFiber/async-1000", + ["bippy#traverseFiber"], + () => Bippy.traverseFiber(ascending.root.current, async () => false), + equals(null), + { isAsync: true, units: 1001 }, + ), + ); + + for (const size of [100, 1000]) { + let tagTrees: FiberTree[] = []; + cases.push( + benchmarkCase( + `getReactWorkTagsForFiber/deep-${size}-cold`, + ["bippy#getReactWorkTagsForFiber", "bippy#setReactWorkTagsForFiber"], + (iteration) => Bippy.getReactWorkTagsForFiber(tagTrees[iteration].fibers[size - 1]), + equals(workTags), + { + prepare: (iterations) => { + 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 visitedFiberCount = 0; + Bippy.traverseRenderedFibers(mountTrees[iteration].root, (_fiber, phase) => { + if (phase === "mount") visitedFiberCount++; + }); + return visitedFiberCount; + }, + equals(size + 1), + { + prepare: (iterations) => { + mountTrees = Array.from({ length: iterations }, () => createTree(size, "wide")); + }, + maxIterations: 16, + units: size + 1, + }, + ), + ); + } + const tagTree = createTree(1000, "deep"); + let revision = 0; + add( + "setReactWorkTagsForFiber", + "unchanged", + () => Bippy.setReactWorkTagsForFiber(component, renderer), + () => assert.equal(Bippy.getReactWorkTagsForFiber(component), workTags), + ); + add( + "setReactWorkTagsForFiber", + "change-and-revalidate-1000", + () => { + revision++; + const version = revision % 2 ? "16.0.0" : "19.2.4"; + Bippy.setReactWorkTagsForFiber(tagTree.root.current, { ...renderer, version }); + return Bippy.getReactWorkTagsForFiber(tagTree.fibers[999]); + }, + (value) => assert.equal(value, Bippy.getReactWorkTags(revision % 2 ? "16.0.0" : "19.2.4")), + ); + + const target: ReactDevToolsTarget = {}; + const hook = Bippy.getRDTHook(undefined, target); + const trackedTree = createTree(1000, "deep"); + let rendererId: number | undefined; + cases.push( + benchmarkCase( + "getRenderer/deep-1000-warm", + ["bippy#getRenderer"], + () => 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: () => { + if (rendererId !== undefined) { + hook.getFiberRoots?.(rendererId).delete(trackedTree.root); + hook.renderers.delete(rendererId); + } + Bippy._fiberRoots.delete(trackedTree.root); + Bippy._renderers.delete(renderer); + rendererId = undefined; + }, + }, + ), + ); + 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); + const nativeTree = createTree(size, "wide"); + const nativeFiber = createFiber({ tag: workTags.HostComponent, stateNode: { _nativeTag: 42 } }); + nativeTree.fibers[size - 1] = nativeFiber; + linkChildren(nativeTree.root.current, nativeTree.fibers); + nativeHook.renderers.set(1, renderer); + nativeHook.getFiberRoots?.(1).add(nativeTree.root); + add( + "getFiber", + `native-tag-root-search-${size}`, + () => Bippy.getFiber(42, nativeTarget), + equals(nativeFiber), + ); + } + const hostInstance = { __reactFiber$benchmark: host }; + assert.equal(Bippy.getFiberFromHostInstance, Bippy.getFiber); + cases.push( + benchmarkCase( + "getFiber/property-warm", + ["bippy#getFiber", "bippy#getFiberFromHostInstance"], + () => Bippy.getFiber(hostInstance, target), + equals(host), + ), + ); + const missingHost = {}; + const emptyTarget: ReactDevToolsTarget = {}; + add("getFiber", "miss", () => Bippy.getFiber(missingHost, emptyTarget), equals(null)); + const properties = Object.fromEntries( + Array.from({ length: 1000 }, (_, index) => [`property${index}`, index]), + ); + cases.push( + benchmarkCase( + "getFiber/enumerate-1000-properties-miss", + ["bippy#getFiber"], + () => Bippy.getFiber(properties, emptyTarget), + equals(null), + ), + ); + + const constructors = [ + { name: "BippyError", constructor: Bippy.BippyError }, + { name: "BippyHookInspectionError", constructor: Bippy.BippyHookInspectionError }, + { name: "BippyHookRenderError", constructor: Bippy.BippyHookRenderError }, + { name: "BippySourceMapError", constructor: Bippy.BippySourceMapError }, + { name: "BippyUnsupportedHookError", constructor: Bippy.BippyUnsupportedHookError }, + ]; + for (const entry of constructors) { + cases.push( + benchmarkCase( + `${entry.name}/construct`, + [`bippy#${entry.name}`, `bippy/source#${entry.name}`], + () => new entry.constructor("benchmark"), + (value) => assert.ok(value instanceof entry.constructor), + ), + ); + } + add( + "BippyError", + "materialize-stack", + () => new Bippy.BippyError("benchmark").stack, + (value) => assert.equal(typeof value, "string"), + ); + return cases; +}; 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 new file mode 100644 index 00000000..86ec71de --- /dev/null +++ b/packages/conformance/benchmarks/fixtures.ts @@ -0,0 +1,59 @@ +import type { Fiber, FiberRoot } from "bippy"; +import { getReactWorkTags } from "../../bippy/src/react-internals/generated/react-work-tags.js"; +import { createFiber, linkChildren } from "../tests/fiber-fixture.js"; + +export { createFiber, linkChildren } from "../tests/fiber-fixture.js"; + +export interface FiberTree { + root: FiberRoot; + fibers: Fiber[]; +} + +export const Component = (): null => null; + +export const createTree = (size: number, shape: "deep" | "wide"): FiberTree => { + const root: FiberRoot = { + current: createFiber({ + tag: getReactWorkTags().HostRoot, + memoizedState: { element: {}, memoizedState: null, next: null }, + }), + }; + root.current.stateNode = root; + const fibers: Fiber[] = []; + let parent = root.current; + for (let index = 0; index < size; index++) { + const fiber = createFiber({ type: Component, elementType: Component, return: parent }); + if (shape === "deep") { + parent.child = fiber; + parent = fiber; + } + fibers.push(fiber); + } + if (shape === "wide") linkChildren(root.current, fibers); + return { root, fibers }; +}; + +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; + }); + previousTree.root.current.stateNode = nextTree.root; + nextTree.root.current.stateNode = nextTree.root; +}; + +export const createDebugStack = (depth = 1): Error => { + const error = new Error("react-stack-top-frame"); + error.stack = [ + "Error: react-stack-top-frame", + " at jsx (https://bench.example/jsx.js:1:1)", + ...Array.from( + { length: depth }, + (_, index) => ` at Component${index} (https://bench.example/bundle.js:1:1)`, + ), + " at react-stack-bottom-frame (https://bench.example/react.js:1:1)", + ].join("\n"); + return 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 new file mode 100644 index 00000000..23af2acd --- /dev/null +++ b/packages/conformance/benchmarks/harness.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { getSampleStatistics } from "./statistics.js"; + +export interface BenchmarkCase { + id: string; + apis: string[]; + run: (iteration: number) => unknown; + verify: (value: unknown) => void; + prepare?: (iterations: number) => void | Promise; + cleanup?: () => void | Promise; + isAsync?: boolean; + maxIterations?: number; + units?: number; +} + +export interface BenchmarkOptions { + samples: number; + targetMs: number; + maxIterations: number; +} + +export interface BenchmarkResult { + id: string; + apis: string[]; + iterations: number; + samples: number; + units: number; + medianUs: number; + minUs: number; + maxUs: number; + sampleUs: number[]; +} + +export interface BenchmarkContext { + Bippy: typeof import("bippy"); + Source: typeof import("bippy/source"); + React: typeof import("react"); + ReactDOM: typeof import("react-dom"); + ReactDOMClient: typeof import("react-dom/client"); +} + +const resultsSink: unknown[] = Array.from({ length: 256 }); + +const measureSync = (benchmark: BenchmarkCase, iterations: number): number => { + const start = performance.now(); + for (let iteration = 0; iteration < iterations; iteration++) { + resultsSink[iteration % resultsSink.length] = benchmark.run(iteration); + } + return performance.now() - start; +}; + +const measureAsync = async (benchmark: BenchmarkCase, iterations: number): Promise => { + const start = performance.now(); + for (let iteration = 0; iteration < iterations; iteration++) { + resultsSink[iteration % resultsSink.length] = await benchmark.run(iteration); + } + return performance.now() - start; +}; + +export const runBenchmark = async ( + benchmark: BenchmarkCase, + options: BenchmarkOptions, +): Promise => { + assert.ok(options.samples > 0 && Number.isInteger(options.samples)); + assert.ok(options.targetMs >= 0 && Number.isFinite(options.targetMs)); + assert.ok(options.maxIterations > 0 && Number.isInteger(options.maxIterations)); + const limit = Math.min(options.maxIterations, benchmark.maxIterations ?? options.maxIterations); + assert.ok(limit > 0 && Number.isInteger(limit)); + const measure = async (iterations: number): Promise => { + resultsSink.fill(undefined); + // 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.isAsync + ? await measureAsync(benchmark, iterations) + : measureSync(benchmark, iterations); + benchmark.verify(resultsSink[(iterations - 1) % resultsSink.length]); + return elapsed; + }; + try { + await benchmark.prepare?.(1); + const initialValue = benchmark.run(0); + const isAsync = + initialValue !== null && + (typeof initialValue === "object" || typeof initialValue === "function") && + typeof Reflect.get(initialValue, "then") === "function"; + assert.equal( + isAsync, + Boolean(benchmark.isAsync), + `${benchmark.id}: incorrect async declaration`, + ); + benchmark.verify(await initialValue); + let iterations = 1; + while (true) { + const elapsed = await measure(iterations); + if (elapsed >= options.targetMs || iterations >= limit) break; + iterations = Math.min(limit, iterations * 4); + } + const sampleUs: number[] = []; + for (let sample = 0; sample < options.samples; sample++) { + sampleUs.push(((await measure(iterations)) * 1000) / iterations); + } + const statistics = getSampleStatistics(sampleUs); + return { + id: benchmark.id, + apis: benchmark.apis, + units: benchmark.units ?? 1, + iterations, + samples: options.samples, + medianUs: statistics.median, + minUs: statistics.min, + maxUs: statistics.max, + sampleUs, + }; + } finally { + resultsSink.fill(undefined); + await benchmark.cleanup?.(); + } +}; + +export const benchmarkCase = ( + benchmarkId: string, + apis: string[], + run: BenchmarkCase["run"], + verify: BenchmarkCase["verify"], + options: Partial< + 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 new file mode 100644 index 00000000..43f3cf21 --- /dev/null +++ b/packages/conformance/benchmarks/hooks.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +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 verifyStateValues = + (count: number) => + (value: unknown): void => { + assert.ok(Array.isArray(value)); + const pending: unknown[] = [...value]; + const states: number[] = []; + while (pending.length) { + const hook = pending.pop(); + 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.deepEqual( + states.sort((first, second) => first - second), + Array.from({ length: count }, (_, index) => index), + ); + }; + +export const createHookBenchmarks = ({ + Bippy, + Source, + React, + ReactDOM, + ReactDOMClient, +}: BenchmarkContext): BenchmarkCase[] => { + const cases: BenchmarkCase[] = []; + for (const count of [1, 16, 128]) { + 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"], + () => { + 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(); + root = undefined; + fiber = null; + }, + }, + ), + ); + cases.push( + benchmarkCase( + `inspectHooks/${scenario}`, + ["bippy/source#inspectHooks"], + () => Source.inspectHooks(Render, {}), + verifyStateValues(count), + { units: count }, + ), + ); + } + } + return cases; +}; 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 new file mode 100644 index 00000000..0af99762 --- /dev/null +++ b/packages/conformance/benchmarks/instrumentation.ts @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; +import type { ReactDevToolsTarget, ReactRenderer, Unsubscribe } from "bippy"; +import { + benchmarkCase, + createBenchmarkSuite, + equals, + type BenchmarkCase, + type BenchmarkContext, +} from "./harness.js"; +import { createFiber, createTree } from "./fixtures.js"; + +const createRenderer = (): ReactRenderer => ({ + version: "19.2.4", + rendererPackageName: "benchmark", + bundleType: 1, +}); +const hookInstallers: Array<"installRDTHook" | "getRDTHook"> = ["installRDTHook", "getRDTHook"]; +const events: Array<"commit" | "unmount" | "post-commit" | "schedule"> = [ + "commit", + "unmount", + "post-commit", + "schedule", +]; + +export const createInstrumentationBenchmarks = ({ Bippy }: BenchmarkContext): BenchmarkCase[] => { + const target: ReactDevToolsTarget = {}; + const hook = Bippy.getRDTHook(undefined, target); + const { cases, add } = createBenchmarkSuite("bippy"); + add("getRDTHook", "warm", () => Bippy.getRDTHook(undefined, target), equals(hook)); + add( + "patchRDTHook", + "already-patched", + () => Bippy.patchRDTHook(undefined, target), + () => assert.equal(target.__REACT_DEVTOOLS_GLOBAL_HOOK__, hook), + ); + add("hasRDTHook", "present", () => Bippy.hasRDTHook(target), equals(true)); + add("isRealReactDevtools", "bippy-hook", () => Bippy.isRealReactDevtools(hook), equals(false)); + add("isReactRefresh", "bippy-hook", () => Bippy.isReactRefresh(hook), equals(false)); + add( + "isInstrumentationActive", + "inactive", + () => Bippy.isInstrumentationActive(target), + equals(false), + ); + const root = createTree(0, "wide").root; + add("isFiberRootUnmounted", "mounted", () => Bippy.isFiberRootUnmounted(root), equals(false)); + add("instrument", "subscribe-dispose", () => Bippy.instrument({ target })(), equals(undefined)); + add( + "onRendererInject", + "subscribe-dispose", + () => Bippy.onRendererInject(() => {}, target)(), + equals(undefined), + ); + for (const name of hookInstallers) { + let targets: ReactDevToolsTarget[] = []; + cases.push( + benchmarkCase( + `${name}/cold-target`, + [`bippy#${name}`], + (iteration) => Bippy[name](undefined, targets[iteration]), + (value) => { + assert.equal(typeof value, "object"); + assert.ok(value && Reflect.get(value, "supportsFiber")); + }, + { + prepare: (iterations) => { + targets = Array.from({ length: iterations }, () => ({})); + }, + maxIterations: 1024, + }, + ), + ); + } + for (const count of [0, 1, 10, 100, 1000]) { + for (const event of events) { + const eventTarget: ReactDevToolsTarget = {}; + const eventHook = Bippy.getRDTHook(undefined, eventTarget); + const eventRoot = createTree(0, "wide").root; + const fiber = createFiber({ return: eventRoot.current }); + const subscriptions: Unsubscribe[] = []; + let callCount = 0; + cases.push( + benchmarkCase( + `instrument/${event}-${count}-listeners`, + ["bippy#instrument"], + () => { + 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 callCount; + }, + equals(count), + { + prepare: () => { + if (subscriptions.length) return; + subscriptions.push(Bippy.instrument({ target: eventTarget })); + for (let index = 0; index < count; index++) { + const listener = () => { + callCount++; + }; + subscriptions.push( + Bippy.instrument({ + target: eventTarget, + onCommitFiberRoot: listener, + onCommitFiberUnmount: listener, + onPostCommitFiberRoot: listener, + onScheduleFiberRoot: listener, + }), + ); + } + }, + cleanup: () => { + subscriptions.forEach((unsubscribe) => unsubscribe()); + Bippy._fiberRoots.delete(eventRoot); + }, + }, + ), + ); + } + } + const injectionTarget: ReactDevToolsTarget = {}; + const injectionHook = Bippy.getRDTHook(undefined, injectionTarget); + let renderers: ReactRenderer[] = []; + let unsubscribeInjection: Unsubscribe | undefined; + let injections = 0; + const clearRenderers = () => { + renderers.forEach((renderer) => Bippy._renderers.delete(renderer)); + injectionHook.renderers.clear(); + }; + cases.push( + benchmarkCase( + "onRendererInject/dispatch-new-renderer", + ["bippy#onRendererInject"], + (iteration) => { + injections = 0; + injectionHook.inject(renderers[iteration]); + return injections; + }, + equals(1), + { + prepare: (iterations) => { + unsubscribeInjection ??= Bippy.onRendererInject(() => { + injections++; + }, injectionTarget); + clearRenderers(); + renderers = Array.from({ length: iterations }, createRenderer); + }, + cleanup: () => { + unsubscribeInjection?.(); + clearRenderers(); + }, + maxIterations: 128, + }, + ), + ); + const throwingTarget: ReactDevToolsTarget = {}; + const throwingHook = Bippy.getRDTHook(undefined, throwingTarget); + const originalError = console.error; + let unsubscribeThrowing: Unsubscribe | undefined; + let reportCount = 0; + const listenerError = new Error("benchmark listener error"); + cases.push( + benchmarkCase( + "instrument/throwing-listener-stubbed-reporter", + ["bippy#instrument"], + () => { + reportCount = 0; + throwingHook.onCommitFiberRoot(1, root, undefined); + return reportCount; + }, + equals(1), + { + prepare: () => { + console.error = () => { + reportCount++; + }; + unsubscribeThrowing ??= Bippy.instrument({ + target: throwingTarget, + onCommitFiberRoot: () => { + throw listenerError; + }, + }); + }, + cleanup: () => { + console.error = originalError; + unsubscribeThrowing?.(); + Bippy._fiberRoots.delete(root); + }, + }, + ), + ); + return cases; +}; 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 new file mode 100644 index 00000000..15a283e2 --- /dev/null +++ b/packages/conformance/benchmarks/report.ts @@ -0,0 +1,228 @@ +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; + callable: string[]; + data: string[]; +} + +export interface WorkerReport extends BenchmarkVariant { + group: string; + reactVersion: string; + exports: ExportInventory[]; + results: BenchmarkResult[]; + maxRssBytes: number; +} + +export interface UseFiberResult { + react: string; + reactVersion: string; + components: number; + precedingHooks: number; + baselineMountMs: number; + useFiberMountMs: number; + baselineUpdateMs: number; + useFiberUpdateMs: number; + mountCaptureMicroseconds: number; + updateCaptureMicroseconds: number; +} + +export interface UseFiberReport { + 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, +): void => { + const callable = exports.flatMap(({ entry, callable }) => + callable.map((name) => `${entry}#${name}`), + ); + assert.deepEqual( + [...measured].sort(), + callable.sort(), + "Every callable export must be benchmarked; aliases must be verified", + ); +}; + +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); + 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 new file mode 100644 index 00000000..24ffaa1d --- /dev/null +++ b/packages/conformance/benchmarks/source.ts @@ -0,0 +1,370 @@ +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, + 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.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, +}: 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};"; + const mappings: SourceMapSegment[][] = Array.from({ length: 1001 }, (_, index) => [ + [0, 0, index === 0 ? 0 : 1, 0, 0], + ]); + const rawMap = JSON.stringify({ + version: 3, + names: ["MappedComponent"], + sources: ["src/component.tsx"], + sourcesContent: [sourceContent], + mappings: encode(mappings), + }); + const sourceFetch: SourceFetch = async (url) => + new Response( + url.endsWith(".map") ? rawMap : "const bundled = 1;\n//# sourceMappingURL=bundle.js.map", + ); + const parent = createFiber({ type: Component }); + const child = createFiber({ _debugOwner: parent, _debugStack: createDebugStack() }); + linkChildren(parent, [child]); + add( + "hasDebugStack", + "present", + () => Source.hasDebugStack(child), + (value) => assert.equal(value, true), + ); + add("getRawSource", "debug-stack-warm", () => Source.getRawSource(child), assertFrame); + add( + "getDefinitionFrameFromOwnedChild", + "direct-child", + () => Source.getDefinitionFrameFromOwnedChild(parent), + assertFrame, + ); + add( + "getSource", + "symbolicated-warm", + () => Source.getSource(child, true, sourceFetch), + (value) => { + assert.ok(value && typeof value === "object"); + assert.ok("fileName" in value); + assert.equal(value.fileName, "src/component.tsx"); + }, + true, + ); + add( + "getDisplayNameFromSource", + "mapped-declaration", + () => Source.getDisplayNameFromSource(parent, true, sourceFetch), + (value) => assert.equal(value, "MappedComponent"), + true, + ); + const legacy = createFiber({ + _debugSource: { fileName: "src/component.tsx", lineNumber: 2, columnNumber: 1 }, + }); + add("getRawSource", "legacy-debug-source", () => Source.getRawSource(legacy), assertFrame); + add("getSource", "legacy-debug-source", () => Source.getSource(legacy), assertFrame, true); + add( + "normalizeFileName", + "webpack", + () => Source.normalizeFileName("webpack-internal:///(app-pages-browser)/./src/component.tsx"), + (value) => assert.equal(typeof value, "string"), + ); + add( + "isSourceFile", + "tsx", + () => Source.isSourceFile("/src/component.tsx"), + (value) => assert.equal(value, true), + ); + + for (const size of [10, 1000]) { + const stack = createDebugStack(size).stack ?? ""; + add( + "parseStack", + `v8-${size}`, + () => Source.parseStack(stack), + (value) => { + assert.ok(Array.isArray(value)); + assert.equal(value.length, size + 2); + }, + ); + const safari = Array.from( + { length: size }, + (_, index) => `Component${index}@https://bench.example/bundle.js:${index + 1}:1`, + ).join("\n"); + add( + "parseStack", + `safari-${size}`, + () => Source.parseStack(safari), + (value) => { + assert.ok(Array.isArray(value)); + assert.equal(value.length, size); + }, + ); + add( + "formatOwnerStack", + `frames-${size}`, + () => Source.formatOwnerStack(stack), + (value) => { + assert.equal(typeof value, "string"); + assert.ok(String(value).includes("Component0")); + }, + ); + } + for (const depth of [10, 100]) { + const tree = createTree(depth, "deep"); + tree.fibers.forEach((fiber, index) => { + fiber._debugOwner = index === 0 ? tree.root.current : tree.fibers[index - 1]; + fiber._debugStack = createDebugStack(); + const ThrowingComponent = () => { + throw new Error("benchmark frame"); + }; + Object.defineProperty(ThrowingComponent, "name", { value: `Ancestor${index}` }); + fiber.type = ThrowingComponent; + }); + const leaf = tree.fibers[depth - 1]; + add( + "getRawOwnerStack", + `owners-${depth}-warm`, + () => Source.getRawOwnerStack(leaf), + assertFrames, + ); + add( + "getOwnerStack", + `owners-${depth}-symbolicated`, + () => Source.getOwnerStack(leaf, true, sourceFetch), + assertFrames, + true, + ); + add( + "getFallbackParentStack", + `parents-${depth}-warm`, + () => Source.getFallbackParentStack(leaf), + (value) => assert.ok(typeof value === "string" && value.includes("Ancestor")), + ); + add( + "getParentStack", + `parents-${depth}-symbolicated`, + () => Source.getParentStack(leaf, true, sourceFetch), + assertFrames, + true, + ); + } + let coldFibers: Fiber[] = []; + cases.push( + benchmarkCase( + "getRawSource/debug-stack-cold", + ["bippy/source#getRawSource"], + (iteration) => Source.getRawSource(coldFibers[iteration]), + assertFrame, + { + prepare: (iterations) => { + coldFibers = Array.from({ length: iterations }, () => + createFiber({ _debugOwner: parent, _debugStack: createDebugStack() }), + ); + }, + maxIterations: 128, + }, + ), + ); + const wide = createTree(1000, "wide"); + wide.fibers[999]._debugOwner = wide.root.current; + wide.fibers[999]._debugStack = createDebugStack(); + add( + "getDefinitionFrameFromOwnedChild", + "wide-1000-tail", + () => Source.getDefinitionFrameFromOwnedChild(wide.root.current), + assertFrame, + ); + + for (const size of [100, 10000]) { + const sourceMap: SourceMap = { + version: 3, + sources: ["src/component.tsx"], + names: ["first", "last"], + sourcesContent: [sourceContent], + mappings: Array.from({ length: size }, (_, index) => [ + [0, 0, index, 0, index === size - 1 ? 1 : 0], + ]), + }; + add( + "getSourceFromSourceMap", + `lines-${size}`, + () => Source.getSourceFromSourceMap(sourceMap, size, 0), + (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) => { + assert.deepEqual(value, { + columnNumber: 0, + fileName: "src/component.tsx", + functionName: "last", + isIgnoreListed: false, + lineNumber: size, + }); + }, + ); + const segmented: SourceMap = { + ...sourceMap, + mappings: [Array.from({ length: size }, (_, index) => [index, 0, 0, index])], + }; + add( + "getSourceFromSourceMap", + `segments-${size}`, + () => Source.getSourceFromSourceMap(segmented, 1, size - 1), + (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 }, (_, index) => `export const value = ${index};`), + }; + add( + "getSourceContentFromSourceMap", + `tail-${size}`, + () => Source.getSourceContentFromSourceMap(contentMap, `source${size - 1}.tsx`), + (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 = { + version: 3, + sources: [], + mappings: [], + sections: Array.from({ length: 1000 }, (_, index) => ({ + offset: { line: index, column: 0 }, + map: { version: 3, sources: ["src/component.tsx"], mappings: [[[0, 0, 0, 0]]] }, + })), + }; + add( + "getSourceFromSourceMap", + "indexed-1000-tail", + () => Source.getSourceFromSourceMap(indexed, 1000, 0), + assertFrame, + ); + const assertMap = (value: unknown): void => { + assert.ok(value && typeof value === "object"); + assert.ok("mappings" in value); + const decodedMappings = value.mappings; + assert.ok(Array.isArray(decodedMappings) && decodedMappings.length === 1001); + }; + add( + "getSourceMap", + "in-memory-fetch-decode-cold", + () => Source.getSourceMap(bundleUrl, false, sourceFetch), + assertMap, + true, + ); + add( + "getSourceMap", + "cache-hit", + () => Source.getSourceMap(bundleUrl, true, sourceFetch), + assertMap, + true, + ); + const frames: StackFrame[] = Array.from({ length: 100 }, () => ({ + fileName: bundleUrl, + lineNumber: 1, + columnNumber: 0, + })); + const assertSymbolicated = (value: unknown): void => { + assert.ok(Array.isArray(value)); + assert.equal(value.length, frames.length); + assert.ok(value.every((frame) => frame.isSymbolicated === true)); + }; + add( + "symbolicateStack", + "100-frames-warm", + () => Source.symbolicateStack(frames, true, sourceFetch), + assertSymbolicated, + true, + ); + add( + "symbolicateStack", + "100-frames-cold-deduplicated", + () => Source.symbolicateStack(frames, true, async (url) => sourceFetch(url)), + assertSymbolicated, + true, + ); + for (const size of [10, 100]) { + const hooks: HooksTree = Array.from({ length: size }, (_, index) => ({ + id: index, + name: "State", + value: 0, + isStateEditable: true, + subHooks: [], + debugInfo: null, + hookSource: { + fileName: bundleUrl, + lineNumber: index + 2, + columnNumber: 1, + functionName: "MappedComponent", + }, + })); + add( + "parseHookNames", + `hooks-${size}`, + () => Source.parseHookNames(hooks, sourceFetch), + (value) => { + assert.ok(value instanceof Map); + assert.equal(value.size, size); + assert.ok([...value.values()].every((name) => name === "count")); + }, + true, + ); + } + return cases; +}; 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 new file mode 100644 index 00000000..a80a66f4 --- /dev/null +++ b/packages/conformance/benchmarks/use-fiber-fixtures.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { verifyRecord } from "./report.js"; +import { + earlyReactVersionFixtures, + reactVersionFixtures, +} from "../tests/unit/isolated-react-runtime.js"; + +export interface UseFiberConfiguration { + components: number; + precedingHooks: number; +} + +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 }) => !isQuickMode || label === "19", + ); + +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/package.json b/packages/conformance/package.json index cc80d7de..9c671040 100644 --- a/packages/conformance/package.json +++ b/packages/conformance/package.json @@ -6,6 +6,9 @@ "test": "vp test", "test:all": "pnpm --workspace-root test && pnpm --workspace-root typecheck && pnpm test:built", "test:built": "pnpm --filter bippy build && tsx scripts/check-built.ts", + "bench:use-fiber": "pnpm --filter bippy build && tsx scripts/benchmark-use-fiber.ts", + "bench": "pnpm --filter bippy build && tsx scripts/benchmark-all.ts", + "bench:smoke": "tsx scripts/benchmark-all.ts --quick", "typecheck": "tsc --noEmit", "check:upstream": "tsx scripts/check-upstream.ts", "sync:devtools": "tsx scripts/sync-devtools.ts", diff --git a/packages/conformance/scripts/benchmark-all.ts b/packages/conformance/scripts/benchmark-all.ts new file mode 100644 index 00000000..c17601d9 --- /dev/null +++ b/packages/conformance/scripts/benchmark-all.ts @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +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 BenchmarkRunReport, + type RunMetadata, +} from "../benchmarks/report.js"; +import { getSampleStatistics } from "../benchmarks/statistics.js"; +import { runUseFiberBenchmarks } from "../benchmarks/use-fiber.js"; +import { getExpectedExports, repositoryDirectory } from "./test-inventory.js"; + +const isQuickMode = process.argv.includes("--quick"); +const builtDirectory = new URL("../../bippy/dist/", import.meta.url); +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( + isQuickMode + ? "Smoke validation only; timings are not performance results." + : "Benchmarking built output sequentially; no real network requests.", +); +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, + ), + ); + 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), + ); + 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 = Array.from({ length: isQuickMode ? 1 : 7 }, () => + Number(runBenchmarkProcess(nativeProbe, [format, entryUrl.href], reactBuild, true).trim()), + ); + const result = { + entry, + format, + reactBuild, + sampleUs, + medianUs: getSampleStatistics(sampleUs).median, + }; + journal.append({ kind: "import", data: result }); + report.imports.push(result); + } + } + 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`, + ); + }), + ); +} +const inventory = report.reports[0].exports; +for (const format of benchmarkFormats) { + for (const reactBuild of benchmarkBuilds) { + const measured = new Set( + report.reports + .filter((result) => result.format === format && result.reactBuild === reactBuild) + .flatMap((result) => result.results.flatMap(({ apis }) => apis)), + ); + verifyBenchmarkCoverage( + inventory.map((entry) => ({ + ...entry, + callable: entry.callable.filter((name) => name !== "useFiber"), + })), + measured, + ); + } +} +const allMeasured = new Set( + report.reports.flatMap((result) => result.results.flatMap(({ apis }) => apis)), +); +assert.ok(report.useFiber.every((result) => result.results.length > 0)); +allMeasured.add("bippy#useFiber"); +verifyBenchmarkCoverage(inventory, allMeasured); +report.scope.callableExports = inventory.flatMap(({ entry, callable }) => + callable.map((name) => `${entry}#${name}`), +); +report.scope.nonCallableExports = inventory.flatMap(({ entry, data }) => + data.map((name) => `${entry}#${name}`), +); +journal.complete(report); +console.log( + `Verified ${allMeasured.size} callable exports; reports: ${fileURLToPath(journal.directory)}`, +); diff --git a/packages/conformance/scripts/benchmark-use-fiber.ts b/packages/conformance/scripts/benchmark-use-fiber.ts new file mode 100644 index 00000000..49520bed --- /dev/null +++ b/packages/conformance/scripts/benchmark-use-fiber.ts @@ -0,0 +1,11 @@ +import { runUseFiberBenchmarks } from "../benchmarks/use-fiber.js"; +import { writeReport } from "../benchmarks/report.js"; + +const isQuickMode = process.argv.includes("--quick"); +const format = process.argv.includes("--cjs") ? "cjs" : "esm"; +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 new file mode 100644 index 00000000..087e6d99 --- /dev/null +++ b/packages/conformance/scripts/benchmark-worker.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +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 [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); +try { + 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: isQuickMode ? 1 : 7, + targetMs: isQuickMode ? 0 : 8, + maxIterations: isQuickMode ? 1 : 262144, + }), + ); + } catch (error) { + throw new Error(`Benchmark failed: ${benchmark.id}`, { cause: error }); + } + } + assert.equal(networkAttempts, 0, "Benchmarks must use fixture fetches only"); + 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/fiber-fixture.ts b/packages/conformance/tests/fiber-fixture.ts index cf2c1347..a4d9ee1c 100644 --- a/packages/conformance/tests/fiber-fixture.ts +++ b/packages/conformance/tests/fiber-fixture.ts @@ -1,4 +1,5 @@ -import { getReactWorkTags, type Fiber } from "bippy"; +import { getReactWorkTags } from "../../bippy/src/react-internals/generated/react-work-tags.js"; +import type { Fiber } from "../../bippy/src/react-internals/types.js"; interface FiberOverrides extends Partial> { tag?: number; 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 new file mode 100644 index 00000000..5575b362 --- /dev/null +++ b/packages/conformance/tests/unit/benchmark-harness.test.ts @@ -0,0 +1,120 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; +import { benchmarkCase, runBenchmark } from "../../benchmarks/harness.js"; +import { verifyBenchmarkCoverage, verifyWorkerReport } from "../../benchmarks/report.js"; + +afterEach(() => vi.restoreAllMocks()); + +it("excludes setup and verification from timed batches", async () => { + let clock = 0; + vi.spyOn(performance, "now").mockImplementation(() => clock); + const result = await runBenchmark( + benchmarkCase( + "sync", + [], + () => { + clock++; + return 1; + }, + (value) => { + expect(value).toBe(1); + clock += 1000; + }, + { + prepare: () => { + clock += 1000; + }, + }, + ), + { samples: 3, targetMs: 0, maxIterations: 4 }, + ); + expect(result.iterations).toBe(1); + expect(result.sampleUs).toEqual([1000, 1000, 1000]); + expect(result.medianUs).toBe(1000); +}); + +it("awaits async work and validates every batch result", async () => { + const verify = vi.fn((value) => expect(value).toBe("complete")); + const cleanup = vi.fn(); + const result = await runBenchmark( + benchmarkCase( + "async", + [], + async () => { + await Promise.resolve(); + return "complete"; + }, + verify, + { isAsync: true, cleanup }, + ), + { samples: 2, targetMs: 0, maxIterations: 1 }, + ); + expect(result.samples).toBe(2); + expect(verify).toHaveBeenCalledTimes(4); + expect(cleanup).toHaveBeenCalledOnce(); +}); + +it("rejects incorrectly declared async benchmarks and still cleans up", async () => { + const cleanup = vi.fn(); + await expect( + runBenchmark( + benchmarkCase( + "incorrect", + [], + () => Promise.resolve(1), + () => {}, + { cleanup }, + ), + { + samples: 1, + targetMs: 0, + maxIterations: 1, + }, + ), + ).rejects.toThrow("incorrect async declaration"); + expect(cleanup).toHaveBeenCalledOnce(); +}); + +it("calibrates up to the allocation cap without including setup", async () => { + let clock = 0; + vi.spyOn(performance, "now").mockImplementation(() => clock); + const result = await runBenchmark( + benchmarkCase( + "capped", + [], + () => { + clock += 0.1; + return true; + }, + () => {}, + { maxIterations: 4 }, + ), + { + samples: 1, + targetMs: 10, + maxIterations: 100, + }, + ); + expect(result.iterations).toBe(4); + expect(result.medianUs).toBeCloseTo(100); +}); + +it("fails coverage accounting for missing or invented callable exports", () => { + const inventory = [{ entry: "bippy", callable: ["useFiber"], data: ["version"] }]; + expect(() => verifyBenchmarkCoverage(inventory, new Set())).toThrow(); + expect(() => verifyBenchmarkCoverage(inventory, new Set(["bippy#invented"]))).toThrow(); + expect(() => verifyBenchmarkCoverage(inventory, new Set(["bippy#useFiber"]))).not.toThrow(); +}); + +it("rejects invalid measurement reports", () => { + expect(() => + verifyWorkerReport({ + group: "empty", + format: "esm", + reactBuild: "production", + reactVersion: "19", + exports: [], + results: [], + maxRssBytes: 0, + }), + ).toThrow("Empty benchmark group"); +}); 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/instrument-isolation-process.test.ts b/packages/conformance/tests/unit/instrument-isolation-process.test.ts new file mode 100644 index 00000000..67ed0303 --- /dev/null +++ b/packages/conformance/tests/unit/instrument-isolation-process.test.ts @@ -0,0 +1,99 @@ +import { afterAll, describe, expect, it } from "vite-plus/test"; +import { + createBrowserBootstrapScript, + createIsolatedReactRuntime, + earlyReactVersionFixtures, + reactVersionFixtures, + removeIsolatedReactRuntimes, + type ReactBuildMode, +} from "./isolated-react-runtime.js"; +import { runNodeScript } from "./run-node-script.js"; + +const buildModes: ReactBuildMode[] = ["development", "production", "profiling"]; + +afterAll(removeIsolatedReactRuntimes); + +describe.each([...earlyReactVersionFixtures, ...reactVersionFixtures])( + "React $label listener isolation", + (fixture) => { + it.each(buildModes)( + "keeps injection and future commits connected in %s", + (mode) => { + const runtime = createIsolatedReactRuntime(fixture); + const script = ` + import assert from "node:assert/strict"; + ${createBrowserBootstrapScript()} + const Bippy = await import(${JSON.stringify(runtime.bippyEntryUrl)}); + const listenerError = new Error("listener failure"); + let failures = 0; + let reportedErrors = 0; + let activations = 0; + let injections = 0; + let commits = 0; + let unmounts = 0; + console.error = (...values) => { if (values.includes(listenerError)) reportedErrors++; }; + const fail = () => { failures++; throw listenerError; }; + const unsubscribeFailure = Bippy.instrument({ + onActive: fail, + onCommitFiberRoot: fail, + onCommitFiberUnmount: fail, + onPostCommitFiberRoot: fail, + onScheduleFiberRoot: fail, + }); + const unsubscribeInjectionFailure = Bippy.onRendererInject(fail); + const unsubscribeLater = Bippy.instrument({ + onActive: () => { activations++; }, + onCommitFiberRoot: () => { commits++; }, + onCommitFiberUnmount: () => { unmounts++; }, + }); + const unsubscribeInjectionLater = Bippy.onRendererInject(() => { injections++; }); + const ReactModule = await import(${JSON.stringify(runtime.reactUrl)}); + const React = ReactModule.default ?? ReactModule; + const ReactDOMModule = await import(${JSON.stringify(mode === "profiling" ? runtime.reactDOMProfilingUrl : runtime.reactDOMUrl)}); + const ReactDOM = ReactDOMModule.default ?? ReactDOMModule; + const ReactDOMClientModule = ${fixture.major >= 18 ? (mode === "profiling" ? "ReactDOMModule" : `await import(${JSON.stringify(runtime.reactDOMClientUrl)})`) : "null"}; + const ReactDOMClient = ReactDOMClientModule?.default ?? ReactDOMClientModule; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = ReactDOMClient?.createRoot(container); + const Probe = ({ label }) => { + React.useEffect(() => () => {}, []); + return React.createElement("span", null, label); + }; + for (const label of ["mount", "update"]) { + ReactDOM.flushSync(() => { + const element = React.createElement(Probe, { label }); + if (root) root.render(element); + else ReactDOM.render(element, container); + }); + assert.equal(container.textContent, label); + assert.equal(commits, label === "mount" ? 1 : 2); + assert.equal(Bippy._fiberRoots.size, 1); + } + ReactDOM.flushSync(() => { + if (root) root.unmount(); + else ReactDOM.unmountComponentAtNode(container); + }); + assert.equal(commits, 3); + assert.equal(activations, 1); + assert.equal(injections, 1); + assert.ok(unmounts > 0); + assert.ok(failures >= 5); + assert.equal(reportedErrors, failures); + assert.equal(Bippy._fiberRoots.size, 0); + unsubscribeFailure(); + unsubscribeInjectionFailure(); + unsubscribeLater(); + unsubscribeInjectionLater(); + process.exit(0); + `; + const result = runNodeScript(script, { + environment: { NODE_ENV: mode === "development" ? "development" : "production" }, + timeout: 15000, + }); + expect(result.status, result.stderr).toBe(0); + }, + 20000, + ); + }, +); diff --git a/packages/conformance/tests/unit/instrument-isolation.test.ts b/packages/conformance/tests/unit/instrument-isolation.test.ts new file mode 100644 index 00000000..9511bf78 --- /dev/null +++ b/packages/conformance/tests/unit/instrument-isolation.test.ts @@ -0,0 +1,107 @@ +import { expect, it, vi } from "vite-plus/test"; +import { + _fiberRoots, + getFiberById, + getFiberId, + getRDTHook, + instrument, + type FiberRoot, + type ReactDevToolsTarget, +} from "../../../bippy/src/index.js"; +import { onRDTHookReplace } from "../../../bippy/src/rdt-hook.js"; +import { createFiber } from "../fiber-fixture.js"; + +it.each([false, true])( + "isolates all event callbacks when error reporting throws: %s", + (doesReportingThrow) => { + const target: ReactDevToolsTarget = {}; + const hook = getRDTHook(undefined, target); + const listenerError = new Error("listener failure"); + const fail = vi.fn(() => { + throw listenerError; + }); + using reportError = vi.spyOn(console, "error").mockImplementation(() => { + if (doesReportingThrow) throw new Error("reporting failure"); + }); + hook.onCommitFiberRoot = fail; + hook.onCommitFiberUnmount = fail; + hook.onPostCommitFiberRoot = fail; + hook.onScheduleFiberRoot = fail; + using _unsubscribeFailure = instrument({ + target, + onCommitFiberRoot: fail, + onCommitFiberUnmount: fail, + onPostCommitFiberRoot: fail, + onScheduleFiberRoot: fail, + }); + const laterListener = vi.fn(); + using _unsubscribeLater = instrument({ + target, + onCommitFiberRoot: laterListener, + onCommitFiberUnmount: laterListener, + onPostCommitFiberRoot: laterListener, + onScheduleFiberRoot: laterListener, + }); + const root: FiberRoot = { + current: createFiber({ memoizedState: { element: {}, memoizedState: null, next: null } }), + }; + const child = createFiber({ return: root.current }); + const fiberId = getFiberId(child); + hook.onScheduleFiberRoot?.(1, root, "children"); + hook.onCommitFiberRoot(1, root, undefined, true); + expect(_fiberRoots.has(root)).toBe(true); + hook.onPostCommitFiberRoot(1, root); + hook.onCommitFiberUnmount(1, child); + expect(getFiberById(fiberId)).toBeNull(); + root.current.memoizedState = { element: null, memoizedState: null, next: null }; + hook.onCommitFiberRoot(1, root, undefined, false); + expect(_fiberRoots.has(root)).toBe(false); + expect(laterListener.mock.calls).toEqual([ + [1, root, "children"], + [1, root, undefined, true], + [1, root], + [1, child], + [1, root, undefined, false], + ]); + expect(fail).toHaveBeenCalledTimes(10); + expect(reportError).toHaveBeenCalledTimes(10); + expect(reportError).toHaveBeenCalledWith( + "Bippy instrumentation encountered an error:", + listenerError, + ); + }, +); + +it("still registers commit handlers when immediate activation throws", () => { + const target: ReactDevToolsTarget = {}; + const hook = getRDTHook(undefined, target); + hook.inject({ version: "19.2.4", rendererPackageName: "test", bundleType: 1 }); + using _reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + const onCommitFiberRoot = vi.fn(); + using _unsubscribe = instrument({ + target, + onActive: () => { + throw new Error("active failure"); + }, + onCommitFiberRoot, + }); + const root: FiberRoot = { + current: createFiber({ memoizedState: { element: null, memoizedState: null, next: null } }), + }; + hook.onCommitFiberRoot(1, root, undefined); + expect(onCommitFiberRoot).toHaveBeenCalledOnce(); +}); + +it("continues hook replacement notifications after a listener throws", () => { + const target: ReactDevToolsTarget = {}; + const hook = getRDTHook(undefined, target); + const laterListener = vi.fn(); + using _reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + using _unsubscribeFailure = onRDTHookReplace(() => { + throw new Error("replacement failure"); + }); + using _unsubscribeLater = onRDTHookReplace(laterListener); + const replacement = { ...hook }; + target.__REACT_DEVTOOLS_GLOBAL_HOOK__ = replacement; + expect(laterListener).toHaveBeenCalledWith(replacement, target); +}); diff --git a/packages/conformance/tests/unit/instrument.test.tsx b/packages/conformance/tests/unit/instrument.test.tsx index fd2df2b7..c8cf454e 100644 --- a/packages/conformance/tests/unit/instrument.test.tsx +++ b/packages/conformance/tests/unit/instrument.test.tsx @@ -161,7 +161,7 @@ it("unsubscribe removes only this call's handlers", () => { unsubscribeActive(); }); -it("propagates React DevTools callback failures", () => { +it("isolates React DevTools callback failures", () => { const committedRootRef: FiberRootRef = { current: null }; const unsubscribeCapture = instrument({ onCommitFiberRoot: (_rendererId, root) => { @@ -182,19 +182,25 @@ it("propagates React DevTools callback failures", () => { throw devToolsError; }; const unsubscribe = instrument({ onCommitFiberRoot: laterListener }); + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); try { - expect(() => rdtHook.onCommitFiberRoot(rendererId, committedRoot, undefined, false)).toThrow( + expect(() => + rdtHook.onCommitFiberRoot(rendererId, committedRoot, undefined, false), + ).not.toThrow(); + expect(laterListener).toHaveBeenCalledOnce(); + expect(reportError).toHaveBeenCalledWith( + "Bippy instrumentation encountered an error:", devToolsError, ); - expect(laterListener).not.toHaveBeenCalled(); } finally { + reportError.mockRestore(); unsubscribe(); rdtHook.onCommitFiberRoot = previousOnCommitFiberRoot; } }); -it("propagates instrumentation callback failures and stops dispatch", () => { +it("isolates instrumentation callback failures and continues dispatch", () => { const committedRootRef: FiberRootRef = { current: null }; const unsubscribeCapture = instrument({ onCommitFiberRoot: (_rendererId, root) => { @@ -217,13 +223,19 @@ it("propagates instrumentation callback failures and stops dispatch", () => { }, }); const unsubscribeLaterListener = instrument({ onCommitFiberRoot: laterListener }); + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); try { - expect(() => rdtHook.onCommitFiberRoot(rendererId, committedRoot, undefined, false)).toThrow( + expect(() => + rdtHook.onCommitFiberRoot(rendererId, committedRoot, undefined, false), + ).not.toThrow(); + expect(laterListener).toHaveBeenCalledOnce(); + expect(reportError).toHaveBeenCalledWith( + "Bippy instrumentation encountered an error:", instrumentationError, ); - expect(laterListener).not.toHaveBeenCalled(); } finally { + reportError.mockRestore(); unsubscribeThrowingListener(); unsubscribeLaterListener(); } diff --git a/packages/conformance/tests/unit/rdt-hook-install.test.ts b/packages/conformance/tests/unit/rdt-hook-install.test.ts index 0b9cc897..28ff8d5b 100644 --- a/packages/conformance/tests/unit/rdt-hook-install.test.ts +++ b/packages/conformance/tests/unit/rdt-hook-install.test.ts @@ -150,12 +150,17 @@ it("assigning a new hook should merge existing renderers into it", () => { expect(onActive.mock.calls.length).toBeGreaterThan(callCountBeforeReplacement); }); -it("propagates active and renderer-injection listener failures", () => { +it("isolates active and renderer-injection listener failures", () => { + using reportError = vi.spyOn(console, "error").mockImplementation(() => {}); const activeListenerError = new Error("active listener failure"); const throwingActiveListener = () => { throw activeListenerError; }; - expect(() => getRDTHook(throwingActiveListener)).toThrow(activeListenerError); + expect(() => getRDTHook(throwingActiveListener)).not.toThrow(); + expect(reportError).toHaveBeenCalledWith( + "Bippy instrumentation encountered an error:", + activeListenerError, + ); _onActiveListeners.delete(throwingActiveListener); const laterListener = vi.fn(); @@ -165,8 +170,12 @@ it("propagates active and renderer-injection listener failures", () => { }); const unsubscribeLaterListener = onRendererInject(laterListener); const renderer = createFakeRenderer(); - expect(() => getRDTHook().inject(renderer)).toThrow(rendererListenerError); - expect(laterListener).not.toHaveBeenCalled(); + expect(() => getRDTHook().inject(renderer)).not.toThrow(); + expect(laterListener).toHaveBeenCalledWith(renderer); + expect(reportError).toHaveBeenCalledWith( + "Bippy instrumentation encountered an error:", + rendererListenerError, + ); unsubscribeThrowingListener(); unsubscribeLaterListener(); }); diff --git a/packages/conformance/tests/unit/react-internals.test.ts b/packages/conformance/tests/unit/react-internals.test.ts index d542cbe2..99f7ef93 100644 --- a/packages/conformance/tests/unit/react-internals.test.ts +++ b/packages/conformance/tests/unit/react-internals.test.ts @@ -1,4 +1,5 @@ -import { expect, expectTypeOf, it } from "vite-plus/test"; +import { expect, expectTypeOf, it, vi } from "vite-plus/test"; +import { createFiber, linkChildren } from "../fiber-fixture.js"; import { compareSemver, getReactWorkTagsForFiber, @@ -18,6 +19,90 @@ import type { RendererDispatcherRef, } from "../../../bippy/src/index.js"; +const createRenderer = (version: string): ReactRenderer => ({ + bundleType: 1, + rendererPackageName: "test-renderer", + version, +}); + +it("updates cached descendants and alternates after late renderer association", () => { + const previousRoot = createFiber(); + const nextRoot = createFiber({ alternate: previousRoot }); + previousRoot.alternate = nextRoot; + const previousChild = createFiber(); + const nextChild = createFiber({ alternate: previousChild }); + previousChild.alternate = nextChild; + linkChildren(previousRoot, [previousChild]); + linkChildren(nextRoot, [nextChild]); + const grandchild = createFiber(); + linkChildren(nextChild, [grandchild]); + + expect(getReactWorkTagsForFiber(grandchild)).toBe(getReactWorkTags()); + expect(getReactWorkTagsForFiber(previousChild)).toBe(getReactWorkTags()); + setReactWorkTagsForFiber(nextRoot, createRenderer("16.0.0")); + for (const fiber of [previousRoot, nextRoot, previousChild, nextChild, grandchild]) { + expect(getReactWorkTagsForFiber(fiber)).toBe(getReactWorkTags("16.0.0")); + } +}); + +it("refreshes inherited tags when a renderer association changes", () => { + const root = createFiber(); + const child = createFiber(); + linkChildren(root, [child]); + for (const version of ["19.2.4", "16.0.0", "17.0.1", "19.2.4"]) { + setReactWorkTagsForFiber(root, createRenderer(version)); + expect(getReactWorkTagsForFiber(child)).toBe(getReactWorkTags(version)); + } +}); + +it("keeps explicit subtree and unrelated root associations independent", () => { + const root = createFiber(); + const child = createFiber(); + const sibling = createFiber(); + const grandchild = createFiber(); + const unrelatedRoot = createFiber(); + const unrelatedChild = createFiber(); + linkChildren(root, [child, sibling]); + linkChildren(child, [grandchild]); + linkChildren(unrelatedRoot, [unrelatedChild]); + setReactWorkTagsForFiber(root, createRenderer("19.2.4")); + setReactWorkTagsForFiber(unrelatedRoot, createRenderer("17.0.1")); + expect(getReactWorkTagsForFiber(grandchild)).toBe(getReactWorkTags()); + expect(getReactWorkTagsForFiber(unrelatedChild)).toBe(getReactWorkTags("17.0.1")); + + setReactWorkTagsForFiber(child, createRenderer("16.0.0")); + expect(getReactWorkTagsForFiber(grandchild)).toBe(getReactWorkTags("16.0.0")); + expect(getReactWorkTagsForFiber(sibling)).toBe(getReactWorkTags()); + expect(getReactWorkTagsForFiber(unrelatedChild)).toBe(getReactWorkTags("17.0.1")); + setReactWorkTagsForFiber(root, createRenderer("17.0.1")); + expect(getReactWorkTagsForFiber(grandchild)).toBe(getReactWorkTags("16.0.0")); + expect(getReactWorkTagsForFiber(sibling)).toBe(getReactWorkTags("17.0.1")); +}); + +it("retains detached tags when another root is associated", () => { + const root = createFiber(); + const child = createFiber(); + linkChildren(root, [child]); + setReactWorkTagsForFiber(root, createRenderer("16.0.0")); + expect(getReactWorkTagsForFiber(child)).toBe(getReactWorkTags("16.0.0")); + child.return = null; + setReactWorkTagsForFiber(createFiber(), createRenderer("19.2.4")); + expect(getReactWorkTagsForFiber(child)).toBe(getReactWorkTags("16.0.0")); +}); + +it("does not invalidate inherited tags for unchanged renderer associations", () => { + const root = createFiber(); + const child = createFiber(); + const getParent = vi.fn(() => root); + Object.defineProperty(child, "return", { get: getParent }); + setReactWorkTagsForFiber(root, createRenderer("16.0.0")); + getReactWorkTagsForFiber(child); + getParent.mockClear(); + setReactWorkTagsForFiber(root, createRenderer("16.0.0")); + expect(getReactWorkTagsForFiber(child)).toBe(getReactWorkTags("16.0.0")); + expect(getParent).not.toHaveBeenCalled(); +}); + it("exports React internals from the main entry point", () => { expect(compareSemver("18.0.0", "19.0.0")).toBe(-1); expect(ReactBuildType.Production).toBe(0); 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"); +}); diff --git a/packages/conformance/tests/unit/traverse-rendered-fibers-process.test.ts b/packages/conformance/tests/unit/traverse-rendered-fibers-process.test.ts new file mode 100644 index 00000000..4f18f859 --- /dev/null +++ b/packages/conformance/tests/unit/traverse-rendered-fibers-process.test.ts @@ -0,0 +1,77 @@ +import { afterAll, describe, expect, it } from "vite-plus/test"; +import { + createBrowserBootstrapScript, + createIsolatedReactRuntime, + createReactImportScript, + earlyReactVersionFixtures, + reactVersionFixtures, + removeIsolatedReactRuntimes, + type ReactBuildMode, +} from "./isolated-react-runtime.js"; +import { runNodeScript } from "./run-node-script.js"; + +const buildModes: ReactBuildMode[] = ["development", "production", "profiling"]; + +afterAll(removeIsolatedReactRuntimes); + +describe.each([...earlyReactVersionFixtures, ...reactVersionFixtures])( + "React $label rendered traversal", + (fixture) => { + it.each(buildModes)( + "visits every visible Suspense sibling in %s", + (mode) => { + const runtime = createIsolatedReactRuntime(fixture); + const script = ` + import assert from "node:assert/strict"; + ${createBrowserBootstrapScript()} + ${createReactImportScript(runtime, fixture, mode)} + const observed = []; + const Leaf = () => null; + const never = new Promise(() => {}); + const Suspend = () => { throw never; }; + const unsubscribe = Bippy.instrument({ onCommitFiberRoot: (_rendererId, root) => { + Bippy.traverseRenderedFibers(root, (fiber, phase) => { + if (fiber.type === Leaf) observed.push({ label: fiber.memoizedProps.label, phase }); + }); + } }); + for (const shouldSuspend of [false, true]) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = ReactDOMClient?.createRoot(container); + for (let revision = 0; revision < 2; revision++) { + observed.length = 0; + const createLeaves = (prefix) => ["first", "second"].map((label) => React.createElement(Leaf, { + key: label, + label: prefix + "-" + label + "-" + revision, + })); + const element = React.createElement(React.Suspense, { fallback: createLeaves("fallback") }, + ...createLeaves("primary"), shouldSuspend ? React.createElement(Suspend, { key: "suspend" }) : null); + ReactDOM.flushSync(() => { + if (root) root.render(element); + else ReactDOM.render(element, container); + }); + const prefix = shouldSuspend ? "fallback" : "primary"; + assert.deepEqual(observed, ["first", "second"].map((label) => ({ + label: prefix + "-" + label + "-" + revision, + phase: revision === 0 ? "mount" : "update", + }))); + } + ReactDOM.flushSync(() => { + if (root) root.unmount(); + else ReactDOM.unmountComponentAtNode(container); + }); + container.remove(); + } + unsubscribe(); + process.exit(0); + `; + const result = runNodeScript(script, { + environment: { NODE_ENV: mode === "development" ? "development" : "production" }, + timeout: 15000, + }); + expect(result.status, result.stderr).toBe(0); + }, + 20000, + ); + }, +); diff --git a/packages/conformance/tests/unit/traverse-rendered-fibers.test.ts b/packages/conformance/tests/unit/traverse-rendered-fibers.test.ts index a4025a0b..1a36dcaa 100644 --- a/packages/conformance/tests/unit/traverse-rendered-fibers.test.ts +++ b/packages/conformance/tests/unit/traverse-rendered-fibers.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { traverseRenderedFibers } from "../../../bippy/src/index.js"; +import { + getReactWorkTags, + setReactWorkTagsForFiber, + traverseRenderedFibers, +} from "../../../bippy/src/index.js"; import type { Fiber, FiberRoot } from "../../../bippy/src/react-internals/index.js"; import { latestReactWorkTags } from "./react-work-tags.js"; @@ -65,6 +69,116 @@ const commitUpdate = ( traverseRenderedFibers(root, onRenderWithoutRootWrapper as never); }; +interface TraversalTree { + root: Fiber; + fibers: Fiber[]; +} + +const createTraversalTree = (shape: "deep" | "wide", previous?: TraversalTree): TraversalTree => { + const root = createMountedRootFiber(null, previous?.root ?? null); + if (previous) previous.root.alternate = root; + const fibers: Fiber[] = []; + for (let index = 0; index < 20000; index++) { + const previousSibling = fibers[index - 1]; + const parent = shape === "deep" ? (previousSibling ?? root) : root; + const fiber = createMockFiber({ return: parent, alternate: previous?.fibers[index] ?? null }); + if (fiber.alternate) fiber.alternate.alternate = fiber; + if (shape === "wide" && previousSibling) previousSibling.sibling = fiber; + else parent.child = fiber; + fibers.push(fiber); + } + return { root, fibers }; +}; + +const treeShapes: Array<"deep" | "wide"> = ["deep", "wide"]; + +describe.each(treeShapes)("20,000-fiber %s trees", (shape) => { + it("mounts each fiber once in depth-first order", () => { + const tree = createTraversalTree(shape); + const observed: Fiber[] = []; + const phases = new Set(); + traverseRenderedFibers(tree.root, (fiber, phase) => { + if (fiber !== tree.root) observed.push(fiber); + phases.add(phase); + }); + expect(observed).toEqual(tree.fibers); + expect(phases).toEqual(new Set(["mount"])); + }); + + it("updates each alternate once in depth-first order", () => { + const previous = createTraversalTree(shape); + const next = createTraversalTree(shape, previous); + const root: FiberRoot = { current: previous.root }; + previous.root.child = null; + traverseRenderedFibers(root, () => {}); + previous.root.child = previous.fibers[0]; + root.current = next.root; + const observed: Fiber[] = []; + const phases = new Set(); + traverseRenderedFibers(root, (fiber, phase) => { + if (fiber !== next.root) observed.push(fiber); + phases.add(phase); + }); + expect(observed).toEqual(next.fibers); + expect(phases).toEqual(new Set(["update"])); + }); + + it("simulates unmounts without changing the existing parent-first order", () => { + const tree = createTraversalTree(shape); + const previousSuspense = createMockFiber({ tag: latestReactWorkTags.SuspenseComponent }); + const offscreen = createMockFiber({ + tag: latestReactWorkTags.OffscreenComponent, + return: previousSuspense, + }); + previousSuspense.child = offscreen; + const previousRoot = createMountedRootFiber(previousSuspense); + const root: FiberRoot = { current: previousRoot }; + traverseRenderedFibers(root, () => {}); + offscreen.child = tree.fibers[0]; + for (const fiber of shape === "wide" ? tree.fibers : [tree.fibers[0]]) fiber.return = offscreen; + const nextSuspense = createMockFiber({ + tag: latestReactWorkTags.SuspenseComponent, + alternate: previousSuspense, + memoizedState: {}, + }); + root.current = createMountedRootFiber(nextSuspense, previousRoot); + const unmounted: Fiber[] = []; + traverseRenderedFibers(root, (fiber, phase) => { + if (phase === "unmount") unmounted.push(fiber); + }); + expect(unmounted).toEqual(tree.fibers); + }); +}); + +it.each(["16.8.6", "16.14.0", "17.0.2", "19.2.4"])( + "mounts all primary Suspense children with React %s work tags", + (version) => { + const workTags = getReactWorkTags(version); + const firstChild = createMockFiber({ tag: workTags.FunctionComponent }); + const secondChild = createMockFiber({ tag: workTags.FunctionComponent }); + firstChild.sibling = secondChild; + const primary = + workTags.OffscreenComponent === -1 + ? firstChild + : createMockFiber({ tag: workTags.OffscreenComponent, child: firstChild }); + const suspense = createMockFiber({ tag: workTags.SuspenseComponent, child: primary }); + const root = createMountedRootFiber(suspense); + setReactWorkTagsForFiber(root, { version, rendererPackageName: "test", bundleType: 1 }); + suspense.return = root; + primary.return = suspense; + firstChild.return = primary === firstChild ? suspense : primary; + secondChild.return = firstChild.return; + const onRender = vi.fn(); + traverseRenderedFibers(root, onRender); + expect(onRender.mock.calls.map(([fiber]) => fiber)).toEqual([ + root, + suspense, + firstChild, + secondChild, + ]); + }, +); + describe("mount commits", () => { it("should mount children and siblings", () => { const childFiber = createMockFiber(); diff --git a/packages/conformance/tests/unit/use-fiber-performance.test.ts b/packages/conformance/tests/unit/use-fiber-performance.test.ts new file mode 100644 index 00000000..85a2b94c --- /dev/null +++ b/packages/conformance/tests/unit/use-fiber-performance.test.ts @@ -0,0 +1,101 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterAll, describe, expect, it } from "vite-plus/test"; +import { + createBrowserBootstrapScript, + createIsolatedReactRuntime, + createReactImportScript, + earlyReactVersionFixtures, + reactVersionFixtures, + removeIsolatedReactRuntimes, + type ReactBuildMode, +} from "./isolated-react-runtime.js"; +import { runNodeScript } from "./run-node-script.js"; + +const buildModes: ReactBuildMode[] = ["development", "production", "profiling"]; +const oracleUrl = pathToFileURL( + resolve(dirname(fileURLToPath(import.meta.url)), "use-fiber-oracle.ts"), +).href; + +afterAll(removeIsolatedReactRuntimes); + +describe.each([...earlyReactVersionFixtures, ...reactVersionFixtures])( + "React $label useFiber hot paths", + (fixture) => { + it.each(buildModes)( + "does not visit unrelated subtrees during %s updates", + (mode) => { + const runtime = createIsolatedReactRuntime(fixture); + const script = ` + import assert from "node:assert/strict"; + ${createBrowserBootstrapScript()} + ${createReactImportScript(runtime, fixture, mode)} + const { checkCallingFiber, createFiberRootRegistry, matchByProps } = await import(${JSON.stringify(oracleUrl)}); + const registry = createFiberRootRegistry(); + const container = document.createElement("div"); + document.body.appendChild(container); + registry.addContainer(container); + const root = ReactDOMClient?.createRoot(container); + let isCapturing = false; + let unrelatedReads = 0; + let checkedFibers = 0; + const watched = new WeakSet(); + const watchSubtree = (fiber) => { + if (!watched.has(fiber)) { + watched.add(fiber); + let child = fiber.child; + Object.defineProperty(fiber, "child", { + configurable: true, + get: () => { if (isCapturing) unrelatedReads++; return child; }, + set: (nextChild) => { child = nextChild; }, + }); + } + for (let child = fiber.child; child; child = child.sibling) watchSubtree(child); + }; + const Leaf = () => null; + const Unrelated = React.memo(() => Array.from({ length: 100 }, (_, index) => React.createElement(Leaf, { key: index }))); + const unsubscribe = Bippy.instrument({ + onCommitFiberRoot: (_rendererId, committedRoot) => { + const unrelated = committedRoot.current.child; + if (unrelated?.type === Unrelated || unrelated?.elementType === Unrelated) watchSubtree(unrelated); + }, + }); + const Probe = (props) => { + for (let hookIndex = 0; hookIndex < 32; hookIndex++) React.useRef(null); + isCapturing = true; + let fiber; + try { fiber = Bippy.useFiber(); } finally { isCapturing = false; } + assert.equal(checkCallingFiber(registry, matchByProps(Probe, props), fiber, ${mode === "development"}), null); + checkedFibers++; + return null; + }; + for (let revision = 0; revision < 5; revision++) { + const elements = [ + React.createElement(Unrelated, { key: "unrelated" }), + ...Array.from({ length: 20 }, (_, index) => React.createElement(Probe, { key: index, revision })), + ]; + ReactDOM.flushSync(() => { + if (root) root.render(elements); + else ReactDOM.render(elements, container); + }); + } + assert.equal(checkedFibers, 100); + assert.equal(unrelatedReads, 0, "useFiber scanned an unrelated subtree"); + assert.ok(watched.has(registry.listRoots()[0].current.child), "unrelated subtree was not instrumented"); + ReactDOM.flushSync(() => { + if (root) root.unmount(); + else ReactDOM.unmountComponentAtNode(container); + }); + unsubscribe(); + process.exit(0); + `; + const result = runNodeScript(script, { + environment: { NODE_ENV: mode === "development" ? "development" : "production" }, + timeout: 15000, + }); + expect(result.status, result.stderr).toBe(0); + }, + 20000, + ); + }, +);