├── .changeset ├── README.md └── config.json ├── .github ├── assets │ └── bippy.png └── workflows │ ├── pkg-pr-new.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── biome.json ├── package.json ├── packages ├── bippy │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── scripts │ │ └── append-banner.ts │ ├── src │ │ ├── core.ts │ │ ├── experiments │ │ │ └── inspect.tsx │ │ ├── index.ts │ │ ├── install-hook-script-string.ts │ │ ├── jsx-dev-runtime.ts │ │ ├── jsx-runtime.ts │ │ ├── rdt-hook.ts │ │ ├── source.ts │ │ ├── test │ │ │ ├── components.tsx │ │ │ ├── core │ │ │ │ ├── fiber.test.tsx │ │ │ │ ├── instrument.test.tsx │ │ │ │ ├── traversal.test.tsx │ │ │ │ └── type.test.tsx │ │ │ └── development │ │ │ │ ├── multiple-on-active.test.tsx │ │ │ │ ├── post-react-devtools.test.tsx │ │ │ │ ├── post-react-refresh.test.tsx │ │ │ │ ├── post-react.test.tsx │ │ │ │ ├── pre-react-devtools.test.tsx │ │ │ │ ├── pre-react-refresh.test.tsx │ │ │ │ └── pre-react.test.tsx │ │ └── types.ts │ ├── tsconfig.json │ ├── tsdown.config.ts │ └── vitest.config.ts ├── kitchen-sink │ ├── index.html │ ├── package.json │ ├── public │ │ ├── bippy.png │ │ └── thumbnail.png │ ├── src │ │ ├── app.tsx │ │ ├── main.css │ │ └── main.tsx │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vercel.json │ └── vite.config.ts └── next-kitchen-sink │ ├── .gitignore │ ├── README.md │ ├── app │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ └── page.tsx │ ├── components │ └── fiber.tsx │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ ├── file.svg │ ├── globe.svg │ ├── next.svg │ ├── vercel.svg │ └── window.svg │ ├── scripts │ └── move-dist.ts │ └── tsconfig.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json", 3 | "changelog": "@changesets/changelog-git", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": ["@bippy/kitchen-sink", "@bippy/next-kitchen-sink"] 11 | } 12 | -------------------------------------------------------------------------------- /.github/assets/bippy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenybai/bippy/c50e3c21cd4599b8cd4a97448c08b9c0e496427d/.github/assets/bippy.png -------------------------------------------------------------------------------- /.github/workflows/pkg-pr-new.yml: -------------------------------------------------------------------------------- 1 | name: Publish Any Commit 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v4 11 | 12 | - run: npm i -g --force corepack && corepack enable 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | cache: 'pnpm' 17 | - name: Install dependencies 18 | run: pnpm install 19 | 20 | - name: Build 21 | run: pnpm build 22 | 23 | - name: Publish 24 | run: pnpx pkg-pr-new publish --compact ./packages/bippy 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - run: npm i -g --force corepack && corepack enable 11 | - uses: actions/checkout@v4 12 | - uses: pnpm/action-setup@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | cache: 'pnpm' 17 | - run: pnpm install 18 | - run: pnpm test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .env 4 | dist 5 | **/*.tgz 6 | coverage 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Aiden Bai 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > ⚠️⚠️⚠️ **this project may break production apps and cause unexpected behavior** ⚠️⚠️⚠️ 3 | > 4 | > this project uses react internals, which can change at any time. it is not recommended to depend on internals unless you really, _really_ have to. by proceeding, you acknowledge the risk of breaking your own code or apps that use your code. 5 | 6 | # bippy 7 | 8 | [![size](https://img.shields.io/bundlephobia/minzip/bippy?label=gzip&style=flat&colorA=000000&colorB=000000)](https://bundlephobia.com/package/bippy) 9 | [![version](https://img.shields.io/npm/v/bippy?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/bippy) 10 | [![downloads](https://img.shields.io/npm/dt/bippy.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/bippy) 11 | 12 | bippy is a toolkit to **hack into react internals** 13 | 14 | by default, you cannot access react internals. bippy bypasses this by "pretending" to be react devtools, giving you access to the fiber tree and other internals. 15 | 16 | - works outside of react – no react code modification needed 17 | - utility functions that work across modern react (v17-19) 18 | - no prior react source code knowledge required 19 | 20 | ```jsx 21 | import { onCommitFiberRoot, traverseFiber } from 'bippy'; 22 | 23 | onCommitFiberRoot((root) => { 24 | traverseFiber(root.current, (fiber) => { 25 | // prints every fiber in the current React tree 26 | console.log('fiber:', fiber); 27 | }); 28 | }); 29 | ``` 30 | 31 | ## how it works & motivation 32 | 33 | bippy allows you to **access** and **use** react fibers **outside** of react components. 34 | 35 | a react fiber is a "unit of execution." this means react will do something based on the data in a fiber. each fiber either represents a composite (function/class component) or a host (dom element). 36 | 37 | > here is a [live visualization](https://jser.pro/ddir/rie?reactVersion=18.3.1&snippetKey=hq8jm2ylzb9u8eh468) of what the fiber tree looks like, and here is a [deep dive article](https://jser.dev/2023-07-18-how-react-rerenders/). 38 | 39 | fibers are useful because they contain information about the react app (component props, state, contexts, etc.). a simplified version of a fiber looks roughly like this: 40 | 41 | ```typescript 42 | interface Fiber { 43 | // component type (function/class) 44 | type: any; 45 | 46 | child: Fiber | null; 47 | sibling: Fiber | null; 48 | 49 | // stateNode is the host fiber (e.g. DOM element) 50 | stateNode: Node | null; 51 | 52 | // parent fiber 53 | return: Fiber | null; 54 | 55 | // the previous or current version of the fiber 56 | alternate: Fiber | null; 57 | 58 | // saved props input 59 | memoizedProps: any; 60 | 61 | // state (useState, useReducer, useSES, etc.) 62 | memoizedState: any; 63 | 64 | // contexts (useContext) 65 | dependencies: Dependencies | null; 66 | 67 | // effects (useEffect, useLayoutEffect, etc.) 68 | updateQueue: any; 69 | } 70 | ``` 71 | 72 | here, the `child`, `sibling`, and `return` properties are pointers to other fibers in the tree. 73 | 74 | additionally, `memoizedProps`, `memoizedState`, and `dependencies` are the fiber's props, state, and contexts. 75 | 76 | while all of the information is there, it's not super easy to work with, and changes frequently across different versions of react. bippy simplifies this by providing utility functions like: 77 | 78 | - `traverseRenderedFibers` to detect renders and `traverseFiber` to traverse the overall fiber tree 79 | - _(instead of `child`, `sibling`, and `return` pointers)_ 80 | - `traverseProps`, `traverseState`, and `traverseContexts` to traverse the fiber's props, state, and contexts 81 | - _(instead of `memoizedProps`, `memoizedState`, and `dependencies`)_ 82 | 83 | however, fibers aren't directly accessible by the user. so, we have to hack our way around to accessing it. 84 | 85 | luckily, react [reads from a property](https://github.com/facebook/react/blob/6a4b46cd70d2672bc4be59dcb5b8dede22ed0cef/packages/react-reconciler/src/reactFiberDevToolsHook.js#L48) in the window object: `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` and runs handlers on it when certain events happen. this property must exist before react's bundle is executed. this is intended for react devtools, but we can use it to our advantage. 86 | 87 | here's what it roughly looks like: 88 | 89 | ```typescript 90 | interface __REACT_DEVTOOLS_GLOBAL_HOOK__ { 91 | // list of renderers (react-dom, react-native, etc.) 92 | renderers: Map; 93 | 94 | // called when react has rendered everything for an update and the fiber tree is fully built and ready to 95 | // apply changes to the host tree (e.g. DOM mutations) 96 | onCommitFiberRoot: ( 97 | rendererID: RendererID, 98 | root: FiberRoot, 99 | commitPriority?: number 100 | ) => void; 101 | 102 | // called when effects run 103 | onPostCommitFiberRoot: (rendererID: RendererID, root: FiberRoot) => void; 104 | 105 | // called when a specific fiber unmounts 106 | onCommitFiberUnmount: (rendererID: RendererID, fiber: Fiber) => void; 107 | } 108 | ``` 109 | 110 | bippy works by monkey-patching `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` with our own custom handlers. bippy simplifies this by providing utility functions like: 111 | 112 | - `instrument` to safely patch `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` 113 | - _(instead of directly mutating `onCommitFiberRoot`, ...)_ 114 | - `secure` to wrap your handlers in a try/catch and determine if handlers are safe to run 115 | - _(instead of rawdogging `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` handlers, which may crash your app)_ 116 | - `traverseRenderedFibers` to traverse the fiber tree and determine which fibers have actually rendered 117 | - _(instead of `child`, `sibling`, and `return` pointers)_ 118 | - `traverseFiber` to traverse the fiber tree, regardless of whether it has rendered 119 | - _(instead of `child`, `sibling`, and `return` pointers)_ 120 | - `setFiberId` / `getFiberId` to set and get a fiber's id 121 | - _(instead of anonymous fibers with no identity)_ 122 | 123 | ## how to use 124 | 125 | you can either install via a npm (recommended) or a script tag. 126 | 127 | this package should be imported before a React app runs. this will add a special object to the global which is used by React for providing its internals to the tool for analysis (React Devtools does the same). as soon as React library is loaded and attached to the tool, bippy starts collecting data about what is going on in React's internals. 128 | 129 | ```shell 130 | npm install bippy 131 | ``` 132 | 133 | or, use via script tag: 134 | 135 | ```html 136 | 137 | ``` 138 | 139 | > this will cause bippy to be accessible under a `window.Bippy` global. 140 | 141 | next, you can use the api to get data about the fiber tree. below is a (useful) subset of the api. for the full api, read the [source code](https://github.com/aidenybai/bippy/blob/main/src/core.ts). 142 | 143 | ### onCommitFiberRoot 144 | 145 | a utility function that wraps the `instrument` function and sets the `onCommitFiberRoot` hook. 146 | 147 | ```typescript 148 | import { onCommitFiberRoot } from 'bippy'; 149 | 150 | onCommitFiberRoot((root) => { 151 | console.log('root ready to commit', root); 152 | }); 153 | ``` 154 | 155 | ### instrument 156 | 157 | > the underlying implementation for the `onCommitFiberRoot()` function. this is optional, unless you want to plug into more less common, advanced functionality. 158 | 159 | patches `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` with your handlers. must be imported before react, and must be initialized to properly run any other methods. 160 | 161 | > use with the `secure` function to prevent uncaught errors from crashing your app. 162 | 163 | ```typescript 164 | import { instrument, secure } from 'bippy'; // must be imported BEFORE react 165 | import * as React from 'react'; 166 | 167 | instrument( 168 | secure({ 169 | onCommitFiberRoot(rendererID, root) { 170 | console.log('root ready to commit', root); 171 | }, 172 | onPostCommitFiberRoot(rendererID, root) { 173 | console.log('root with effects committed', root); 174 | }, 175 | onCommitFiberUnmount(rendererID, fiber) { 176 | console.log('fiber unmounted', fiber); 177 | }, 178 | }) 179 | ); 180 | ``` 181 | 182 | ### getRDTHook 183 | 184 | returns the `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` object. great for advanced use cases, such as accessing or modifying the `renderers` property. 185 | 186 | ```typescript 187 | import { getRDTHook } from 'bippy'; 188 | 189 | const hook = getRDTHook(); 190 | console.log(hook); 191 | ``` 192 | 193 | ### traverseRenderedFibers 194 | 195 | not every fiber in the fiber tree renders. `traverseRenderedFibers` allows you to traverse the fiber tree and determine which fibers have actually rendered. 196 | 197 | ```typescript 198 | import { instrument, secure, traverseRenderedFibers } from 'bippy'; // must be imported BEFORE react 199 | import * as React from 'react'; 200 | 201 | instrument( 202 | secure({ 203 | onCommitFiberRoot(rendererID, root) { 204 | traverseRenderedFibers(root, (fiber) => { 205 | console.log('fiber rendered', fiber); 206 | }); 207 | }, 208 | }) 209 | ); 210 | ``` 211 | 212 | ### traverseFiber 213 | 214 | calls a callback on every fiber in the fiber tree. 215 | 216 | ```typescript 217 | import { instrument, secure, traverseFiber } from 'bippy'; // must be imported BEFORE react 218 | import * as React from 'react'; 219 | 220 | instrument( 221 | secure({ 222 | onCommitFiberRoot(rendererID, root) { 223 | traverseFiber(root.current, (fiber) => { 224 | console.log(fiber); 225 | }); 226 | }, 227 | }) 228 | ); 229 | ``` 230 | 231 | ### traverseProps 232 | 233 | traverses the props of a fiber. 234 | 235 | ```typescript 236 | import { traverseProps } from 'bippy'; 237 | 238 | // ... 239 | 240 | traverseProps(fiber, (propName, next, prev) => { 241 | console.log(propName, next, prev); 242 | }); 243 | ``` 244 | 245 | ### traverseState 246 | 247 | traverses the state (useState, useReducer, etc.) and effects that set state of a fiber. 248 | 249 | ```typescript 250 | import { traverseState } from 'bippy'; 251 | 252 | // ... 253 | 254 | traverseState(fiber, (next, prev) => { 255 | console.log(next, prev); 256 | }); 257 | ``` 258 | 259 | ### traverseContexts 260 | 261 | traverses the contexts (useContext) of a fiber. 262 | 263 | ```typescript 264 | import { traverseContexts } from 'bippy'; 265 | 266 | // ... 267 | 268 | traverseContexts(fiber, (next, prev) => { 269 | console.log(next, prev); 270 | }); 271 | ``` 272 | 273 | ### setFiberId / getFiberId 274 | 275 | set and get a persistent identity for a fiber. by default, fibers are anonymous and have no identity. 276 | 277 | ```typescript 278 | import { setFiberId, getFiberId } from 'bippy'; 279 | 280 | // ... 281 | 282 | setFiberId(fiber); 283 | console.log('unique id for fiber:', getFiberId(fiber)); 284 | ``` 285 | 286 | ### isHostFiber 287 | 288 | returns `true` if the fiber is a host fiber (e.g., a DOM node in react-dom). 289 | 290 | ```typescript 291 | import { isHostFiber } from 'bippy'; 292 | 293 | if (isHostFiber(fiber)) { 294 | console.log('fiber is a host fiber'); 295 | } 296 | ``` 297 | 298 | ### isCompositeFiber 299 | 300 | returns `true` if the fiber is a composite fiber. composite fibers represent class components, function components, memoized components, and so on (anything that can actually render output). 301 | 302 | ```typescript 303 | import { isCompositeFiber } from 'bippy'; 304 | 305 | if (isCompositeFiber(fiber)) { 306 | console.log('fiber is a composite fiber'); 307 | } 308 | ``` 309 | 310 | ### getDisplayName 311 | 312 | returns the display name of the fiber's component, falling back to the component's function or class name if available. 313 | 314 | ```typescript 315 | import { getDisplayName } from 'bippy'; 316 | 317 | console.log(getDisplayName(fiber)); 318 | ``` 319 | 320 | ### getType 321 | 322 | returns the underlying type (the component definition) for a given fiber. for example, this could be a function component or class component. 323 | 324 | ```jsx 325 | import { getType } from 'bippy'; 326 | import { memo } from 'react'; 327 | 328 | const RealComponent = () => { 329 | return
hello
; 330 | }; 331 | const MemoizedComponent = memo(() => { 332 | return
hello
; 333 | }); 334 | 335 | console.log(getType(fiberForMemoizedComponent) === RealComponent); 336 | ``` 337 | 338 | ### getNearestHostFiber / getNearestHostFibers 339 | 340 | getNearestHostFiber returns the closest host fiber above or below a given fiber. getNearestHostFibers(fiber) returns all host fibers associated with the provided fiber and its subtree. 341 | 342 | ```jsx 343 | import { getNearestHostFiber, getNearestHostFibers } from 'bippy'; 344 | 345 | // ... 346 | 347 | function Component() { 348 | return ( 349 | <> 350 |
hello
351 |
world
352 | 353 | ); 354 | } 355 | 356 | console.log(getNearestHostFiber(fiberForComponent)); //
hello
357 | console.log(getNearestHostFibers(fiberForComponent)); // [
hello
,
world
] 358 | ``` 359 | 360 | ### getTimings 361 | 362 | returns the self and total render times for the fiber. 363 | 364 | ```typescript 365 | // timings don't exist in react production builds 366 | if (fiber.actualDuration !== undefined) { 367 | const { selfTime, totalTime } = getTimings(fiber); 368 | console.log(selfTime, totalTime); 369 | } 370 | ``` 371 | 372 | ### getFiberStack 373 | 374 | returns an array representing the stack of fibers from the current fiber up to the root. 375 | 376 | ```typescript 377 | [fiber, fiber.return, fiber.return.return, ...] 378 | ``` 379 | 380 | ### getMutatedHostFibers 381 | 382 | returns an array of all host fibers that have committed and rendered in the provided fiber's subtree. 383 | 384 | ```typescript 385 | import { getMutatedHostFibers } from 'bippy'; 386 | 387 | console.log(getMutatedHostFibers(fiber)); 388 | ``` 389 | 390 | ### isValidFiber 391 | 392 | returns `true` if the given object is a valid React Fiber (i.e., has a tag, stateNode, return, child, sibling, etc.). 393 | 394 | ```typescript 395 | import { isValidFiber } from 'bippy'; 396 | 397 | console.log(isValidFiber(fiber)); 398 | ``` 399 | 400 | ## getFiberFromHostInstance 401 | 402 | returns the fiber associated with a given host instance (e.g., a DOM element). 403 | 404 | ```typescript 405 | import { getFiberFromHostInstance } from 'bippy'; 406 | 407 | const fiber = getFiberFromHostInstance(document.querySelector('div')); 408 | console.log(fiber); 409 | ``` 410 | 411 | ## getLatestFiber 412 | 413 | returns the latest fiber (since it may be double-buffered). usually use this in combination with `getFiberFromHostInstance`. 414 | 415 | ```typescript 416 | import { getLatestFiber } from 'bippy'; 417 | 418 | const latestFiber = getLatestFiber( 419 | getFiberFromHostInstance(document.querySelector('div')) 420 | ); 421 | console.log(latestFiber); 422 | ``` 423 | 424 | ## examples 425 | 426 | the best way to understand bippy is to [read the source code](https://github.com/aidenybai/bippy/blob/main/src/core.ts). here are some examples of how you can use it: 427 | 428 | ### a mini react-scan 429 | 430 | here's a mini toy version of [`react-scan`](https://github.com/aidenybai/react-scan) that highlights renders in your app. 431 | 432 | ```javascript 433 | import { 434 | instrument, 435 | isHostFiber, 436 | getNearestHostFiber, 437 | traverseRenderedFibers, 438 | } from 'bippy'; // must be imported BEFORE react 439 | 440 | const highlightFiber = (fiber) => { 441 | if (!(fiber.stateNode instanceof HTMLElement)) return; 442 | // fiber.stateNode is a DOM element 443 | const rect = fiber.stateNode.getBoundingClientRect(); 444 | const highlight = document.createElement('div'); 445 | highlight.style.border = '1px solid red'; 446 | highlight.style.position = 'fixed'; 447 | highlight.style.top = `${rect.top}px`; 448 | highlight.style.left = `${rect.left}px`; 449 | highlight.style.width = `${rect.width}px`; 450 | highlight.style.height = `${rect.height}px`; 451 | highlight.style.zIndex = 999999999; 452 | document.documentElement.appendChild(highlight); 453 | setTimeout(() => { 454 | document.documentElement.removeChild(highlight); 455 | }, 100); 456 | }; 457 | 458 | /** 459 | * `instrument` is a function that installs the react DevTools global 460 | * hook and allows you to set up custom handlers for react fiber events. 461 | */ 462 | instrument( 463 | /** 464 | * `secure` is a function that wraps your handlers in a try/catch 465 | * and prevents it from crashing the app. it also prevents it from 466 | * running on unsupported react versions and during production. 467 | * 468 | * this is not required but highly recommended to provide "safeguards" 469 | * in case something breaks. 470 | */ 471 | secure({ 472 | /** 473 | * `onCommitFiberRoot` is a handler that is called when react is 474 | * ready to commit a fiber root. this means that react is has 475 | * rendered your entire app and is ready to apply changes to 476 | * the host tree (e.g. via DOM mutations). 477 | */ 478 | onCommitFiberRoot(rendererID, root) { 479 | /** 480 | * `traverseRenderedFibers` traverses the fiber tree and determines which 481 | * fibers have actually rendered. 482 | * 483 | * A fiber tree contains many fibers that may have not rendered. this 484 | * can be because it bailed out (e.g. `useMemo`) or because it wasn't 485 | * actually rendered (if re-rendered, then didn't 486 | * actually render, but exists in the fiber tree). 487 | */ 488 | traverseRenderedFibers(root, (fiber) => { 489 | /** 490 | * `getNearestHostFiber` is a utility function that finds the 491 | * nearest host fiber to a given fiber. 492 | * 493 | * a host fiber for `react-dom` is a fiber that has a DOM element 494 | * as its `stateNode`. 495 | */ 496 | const hostFiber = getNearestHostFiber(fiber); 497 | highlightFiber(hostFiber); 498 | }); 499 | }, 500 | }) 501 | ); 502 | ``` 503 | 504 | ### a mini why-did-you-render 505 | 506 | here's a mini toy version of [`why-did-you-render`](https://github.com/welldone-software/why-did-you-render) that logs why components re-render. 507 | 508 | ```typescript 509 | import { 510 | instrument, 511 | isHostFiber, 512 | traverseRenderedFibers, 513 | isCompositeFiber, 514 | getDisplayName, 515 | traverseProps, 516 | traverseContexts, 517 | traverseState, 518 | } from 'bippy'; // must be imported BEFORE react 519 | 520 | instrument( 521 | secure({ 522 | onCommitFiberRoot(rendererID, root) { 523 | traverseRenderedFibers(root, (fiber) => { 524 | /** 525 | * `isCompositeFiber` is a utility function that checks if a fiber is a composite fiber. 526 | * a composite fiber is a fiber that represents a function or class component. 527 | */ 528 | if (!isCompositeFiber(fiber)) return; 529 | 530 | /** 531 | * `getDisplayName` is a utility function that gets the display name of a fiber. 532 | */ 533 | const displayName = getDisplayName(fiber); 534 | if (!displayName) return; 535 | 536 | const changes = []; 537 | 538 | /** 539 | * `traverseProps` is a utility function that traverses the props of a fiber. 540 | */ 541 | traverseProps(fiber, (propName, next, prev) => { 542 | if (next !== prev) { 543 | changes.push({ 544 | name: `prop ${propName}`, 545 | prev, 546 | next, 547 | }); 548 | } 549 | }); 550 | 551 | let contextId = 0; 552 | /** 553 | * `traverseContexts` is a utility function that traverses the contexts of a fiber. 554 | * Contexts don't have a "name" like props, so we use an id to identify them. 555 | */ 556 | traverseContexts(fiber, (next, prev) => { 557 | if (next !== prev) { 558 | changes.push({ 559 | name: `context ${contextId}`, 560 | prev, 561 | next, 562 | contextId, 563 | }); 564 | } 565 | contextId++; 566 | }); 567 | 568 | let stateId = 0; 569 | /** 570 | * `traverseState` is a utility function that traverses the state of a fiber. 571 | * 572 | * State don't have a "name" like props, so we use an id to identify them. 573 | */ 574 | traverseState(fiber, (value, prevValue) => { 575 | if (next !== prev) { 576 | changes.push({ 577 | name: `state ${stateId}`, 578 | prev, 579 | next, 580 | }); 581 | } 582 | stateId++; 583 | }); 584 | 585 | console.group( 586 | `%c${displayName}`, 587 | 'background: hsla(0,0%,70%,.3); border-radius:3px; padding: 0 2px;' 588 | ); 589 | for (const { name, prev, next } of changes) { 590 | console.log(`${name}:`, prev, '!==', next); 591 | } 592 | console.groupEnd(); 593 | }); 594 | }, 595 | }) 596 | ); 597 | ``` 598 | 599 | ## glossary 600 | 601 | - fiber: a "unit of execution" in react, representing a component or dom element 602 | - commit: the process of applying changes to the host tree (e.g. DOM mutations) 603 | - render: the process of building the fiber tree by executing component function/classes 604 | - host tree: the tree of UI elements that react mutates (e.g. DOM elements) 605 | - reconciler (or "renderer"): custom bindings for react, e.g. react-dom, react-native, react-three-fiber, etc to mutate the host tree 606 | - `rendererID`: the id of the reconciler, starting at 1 (can be from multiple reconciler instances) 607 | - `root`: a special `FiberRoot` type that contains the container fiber (the one you pass to `ReactDOM.createRoot`) in the `current` property 608 | - `onCommitFiberRoot`: called when react is ready to commit a fiber root 609 | - `onPostCommitFiberRoot`: called when react has committed a fiber root and effects have run 610 | - `onCommitFiberUnmount`: called when a fiber unmounts 611 | 612 | ## development 613 | 614 | pre-requisite: you should understand how react works internally. if you don't, please give this [series of articles](https://jser.dev/series/react-source-code-walkthrough) a read. 615 | 616 | we use a pnpm monorepo, get started by running: 617 | 618 | ```shell 619 | pnpm install 620 | # create dev builds 621 | pnpm run dev 622 | # run unit tests 623 | pnpm run test 624 | ``` 625 | 626 | you can ad-hoc test by running `pnpm run dev` in the `/kitchen-sink` directory. 627 | 628 | ```shell 629 | cd kitchen-sink 630 | pnpm run dev 631 | ``` 632 | 633 | ## misc 634 | 635 | we use this project internally in [react-scan](https://github.com/aidenybai/react-scan), which is deployed with proper safeguards to ensure it's only used in development or error-guarded in production. 636 | 637 | while i maintain this specifically for react-scan, those seeking more robust solutions might consider [its-fine](https://github.com/pmndrs/its-fine) for accessing fibers within react using hooks, or [react-devtools-inline](https://www.npmjs.com/package/react-devtools-inline) for a headful interface. 638 | 639 | if you plan to use this project beyond experimentation, please review [react-scan's source code](https://github.com/aidenybai/react-scan) to understand our safeguarding practices. 640 | 641 | the original bippy character is owned and created by [@dairyfreerice](https://www.instagram.com/dairyfreerice). this project is not related to the bippy brand, i just think the character is cute. 642 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "include": ["packages/**/*.ts", "packages/**/*.tsx", "*.ts", "*.tsx"], 11 | "ignore": ["node_modules", "dist", "coverage", "css-to-tailwind.ts"] 12 | }, 13 | "formatter": { 14 | "enabled": true, 15 | "indentStyle": "space", 16 | "indentWidth": 2, 17 | "lineWidth": 80, 18 | "lineEnding": "lf" 19 | }, 20 | "javascript": { 21 | "formatter": { 22 | "quoteStyle": "single", 23 | "trailingCommas": "all" 24 | } 25 | }, 26 | "organizeImports": { 27 | "enabled": true 28 | }, 29 | "linter": { 30 | "enabled": true, 31 | "rules": { 32 | "recommended": true, 33 | "suspicious": { 34 | "noConsoleLog": { 35 | "level": "warn", 36 | "fix": "unsafe" 37 | } 38 | }, 39 | "correctness": { 40 | "noUnusedFunctionParameters": { 41 | "level": "warn", 42 | "fix": "unsafe" 43 | }, 44 | "noUnusedImports": { 45 | "level": "warn", 46 | "fix": "unsafe" 47 | }, 48 | "noUnusedLabels": { 49 | "level": "warn", 50 | "fix": "unsafe" 51 | }, 52 | "noUnusedPrivateClassMembers": { 53 | "level": "warn", 54 | "fix": "unsafe" 55 | }, 56 | "noUnusedVariables": { 57 | "level": "warn", 58 | "fix": "unsafe" 59 | } 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bippy/monorepo", 3 | "scripts": { 4 | "dev": "pnpm --filter=./packages/bippy run dev", 5 | "build": "pnpm --filter=./packages/bippy run build", 6 | "test": "pnpm --filter=./packages/bippy run test", 7 | "publint": "pnpm --filter=./packages/bippy run publint", 8 | "lint": "pnpm biome lint --write", 9 | "sherif": "sherif --fix", 10 | "format": "pnpm biome format --write", 11 | "check": "pnpm biome check --write", 12 | "bump": "changeset && changeset version", 13 | "release": "changeset publish" 14 | }, 15 | "packageManager": "pnpm@10.11.0", 16 | "private": true, 17 | "devDependencies": { 18 | "@biomejs/biome": "1.9.4", 19 | "@changesets/changelog-git": "^0.2.0", 20 | "@changesets/cli": "^2.27.11", 21 | "sherif": "^1.2.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/bippy/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # bippy 2 | 3 | ## 0.3.15 4 | 5 | ### Patch Changes 6 | 7 | - fix \_source 8 | 9 | ## 0.3.14 10 | 11 | ### Patch Changes 12 | 13 | - tsdown 14 | 15 | ## 0.3.13 16 | 17 | ### Patch Changes 18 | 19 | - remove file protocol 20 | 21 | ## 0.3.12 22 | 23 | ### Patch Changes 24 | 25 | - fix: getFiberSource on next.js 26 | 27 | ## 0.3.11 28 | 29 | ### Patch Changes 30 | 31 | - remove react bundle from bippy/source 32 | 33 | ## 0.3.10 34 | 35 | ### Patch Changes 36 | 37 | - upgrade getFiberSource 38 | 39 | ## 0.3.9 40 | 41 | ### Patch Changes 42 | 43 | - fix getLatestFiber bug 44 | 45 | ## 0.3.7 46 | 47 | ### Patch Changes 48 | 49 | - fix inspect mounting 50 | 51 | ## 0.3.6 52 | 53 | ### Patch Changes 54 | 55 | - fix side effects 56 | 57 | ## 0.3.5 58 | 59 | ### Patch Changes 60 | 61 | - fix instrument 62 | 63 | ## 0.3.4 64 | 65 | ### Patch Changes 66 | 67 | - fix inspect 68 | 69 | ## 0.3.3 70 | 71 | ### Patch Changes 72 | 73 | - remove log in inspect 74 | 75 | ## 0.3.2 76 | 77 | ### Patch Changes 78 | 79 | - fix #27 80 | 81 | ## 0.3.1 82 | 83 | ### Patch Changes 84 | 85 | - fix source 86 | 87 | ## 0.3.0 88 | 89 | ### Minor Changes 90 | 91 | - adds getFiberSource for bippy/source 92 | 93 | ## 0.2.24 94 | 95 | ### Patch Changes 96 | 97 | - fix devtools fail 98 | 99 | ## 0.2.23 100 | 101 | ### Patch Changes 102 | 103 | - fix onActive listeners 104 | 105 | ## 0.2.22 106 | 107 | ### Patch Changes 108 | 109 | - Fix readme (again 2) 110 | 111 | ## 0.2.21 112 | 113 | ### Patch Changes 114 | 115 | - ebfdac3: Fix README (again) 116 | - 1dcefaa: Fix README 117 | 118 | ## 0.2.20 119 | 120 | ### Patch Changes 121 | 122 | - 5af368d: Add tests 123 | -------------------------------------------------------------------------------- /packages/bippy/README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > ⚠️⚠️⚠️ **this project may break production apps and cause unexpected behavior** ⚠️⚠️⚠️ 3 | > 4 | > this project uses react internals, which can change at any time. it is not recommended to depend on internals unless you really, _really_ have to. by proceeding, you acknowledge the risk of breaking your own code or apps that use your code. 5 | 6 | # bippy 7 | 8 | [![size](https://img.shields.io/bundlephobia/minzip/bippy?label=gzip&style=flat&colorA=000000&colorB=000000)](https://bundlephobia.com/package/bippy) 9 | [![version](https://img.shields.io/npm/v/bippy?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/bippy) 10 | [![downloads](https://img.shields.io/npm/dt/bippy.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/bippy) 11 | 12 | bippy is a toolkit to **hack into react internals** 13 | 14 | by default, you cannot access react internals. bippy bypasses this by "pretending" to be react devtools, giving you access to the fiber tree and other internals. 15 | 16 | - works outside of react – no react code modification needed 17 | - utility functions that work across modern react (v17-19) 18 | - no prior react source code knowledge required 19 | 20 | ```jsx 21 | import { onCommitFiberRoot, traverseFiber } from 'bippy'; 22 | 23 | onCommitFiberRoot((root) => { 24 | traverseFiber(root.current, (fiber) => { 25 | // prints every fiber in the current React tree 26 | console.log('fiber:', fiber); 27 | }); 28 | }); 29 | ``` 30 | 31 | ## how it works & motivation 32 | 33 | bippy allows you to **access** and **use** react fibers **outside** of react components. 34 | 35 | a react fiber is a "unit of execution." this means react will do something based on the data in a fiber. each fiber either represents a composite (function/class component) or a host (dom element). 36 | 37 | > here is a [live visualization](https://jser.pro/ddir/rie?reactVersion=18.3.1&snippetKey=hq8jm2ylzb9u8eh468) of what the fiber tree looks like, and here is a [deep dive article](https://jser.dev/2023-07-18-how-react-rerenders/). 38 | 39 | fibers are useful because they contain information about the react app (component props, state, contexts, etc.). a simplified version of a fiber looks roughly like this: 40 | 41 | ```typescript 42 | interface Fiber { 43 | // component type (function/class) 44 | type: any; 45 | 46 | child: Fiber | null; 47 | sibling: Fiber | null; 48 | 49 | // stateNode is the host fiber (e.g. DOM element) 50 | stateNode: Node | null; 51 | 52 | // parent fiber 53 | return: Fiber | null; 54 | 55 | // the previous or current version of the fiber 56 | alternate: Fiber | null; 57 | 58 | // saved props input 59 | memoizedProps: any; 60 | 61 | // state (useState, useReducer, useSES, etc.) 62 | memoizedState: any; 63 | 64 | // contexts (useContext) 65 | dependencies: Dependencies | null; 66 | 67 | // effects (useEffect, useLayoutEffect, etc.) 68 | updateQueue: any; 69 | } 70 | ``` 71 | 72 | here, the `child`, `sibling`, and `return` properties are pointers to other fibers in the tree. 73 | 74 | additionally, `memoizedProps`, `memoizedState`, and `dependencies` are the fiber's props, state, and contexts. 75 | 76 | while all of the information is there, it's not super easy to work with, and changes frequently across different versions of react. bippy simplifies this by providing utility functions like: 77 | 78 | - `traverseRenderedFibers` to detect renders and `traverseFiber` to traverse the overall fiber tree 79 | - _(instead of `child`, `sibling`, and `return` pointers)_ 80 | - `traverseProps`, `traverseState`, and `traverseContexts` to traverse the fiber's props, state, and contexts 81 | - _(instead of `memoizedProps`, `memoizedState`, and `dependencies`)_ 82 | 83 | however, fibers aren't directly accessible by the user. so, we have to hack our way around to accessing it. 84 | 85 | luckily, react [reads from a property](https://github.com/facebook/react/blob/6a4b46cd70d2672bc4be59dcb5b8dede22ed0cef/packages/react-reconciler/src/reactFiberDevToolsHook.js#L48) in the window object: `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` and runs handlers on it when certain events happen. this property must exist before react's bundle is executed. this is intended for react devtools, but we can use it to our advantage. 86 | 87 | here's what it roughly looks like: 88 | 89 | ```typescript 90 | interface __REACT_DEVTOOLS_GLOBAL_HOOK__ { 91 | // list of renderers (react-dom, react-native, etc.) 92 | renderers: Map; 93 | 94 | // called when react has rendered everything for an update and the fiber tree is fully built and ready to 95 | // apply changes to the host tree (e.g. DOM mutations) 96 | onCommitFiberRoot: ( 97 | rendererID: RendererID, 98 | root: FiberRoot, 99 | commitPriority?: number 100 | ) => void; 101 | 102 | // called when effects run 103 | onPostCommitFiberRoot: (rendererID: RendererID, root: FiberRoot) => void; 104 | 105 | // called when a specific fiber unmounts 106 | onCommitFiberUnmount: (rendererID: RendererID, fiber: Fiber) => void; 107 | } 108 | ``` 109 | 110 | bippy works by monkey-patching `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` with our own custom handlers. bippy simplifies this by providing utility functions like: 111 | 112 | - `instrument` to safely patch `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` 113 | - _(instead of directly mutating `onCommitFiberRoot`, ...)_ 114 | - `secure` to wrap your handlers in a try/catch and determine if handlers are safe to run 115 | - _(instead of rawdogging `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` handlers, which may crash your app)_ 116 | - `traverseRenderedFibers` to traverse the fiber tree and determine which fibers have actually rendered 117 | - _(instead of `child`, `sibling`, and `return` pointers)_ 118 | - `traverseFiber` to traverse the fiber tree, regardless of whether it has rendered 119 | - _(instead of `child`, `sibling`, and `return` pointers)_ 120 | - `setFiberId` / `getFiberId` to set and get a fiber's id 121 | - _(instead of anonymous fibers with no identity)_ 122 | 123 | ## how to use 124 | 125 | you can either install via a npm (recommended) or a script tag. 126 | 127 | this package should be imported before a React app runs. this will add a special object to the global which is used by React for providing its internals to the tool for analysis (React Devtools does the same). as soon as React library is loaded and attached to the tool, bippy starts collecting data about what is going on in React's internals. 128 | 129 | ```shell 130 | npm install bippy 131 | ``` 132 | 133 | or, use via script tag: 134 | 135 | ```html 136 | 137 | ``` 138 | 139 | > this will cause bippy to be accessible under a `window.Bippy` global. 140 | 141 | next, you can use the api to get data about the fiber tree. below is a (useful) subset of the api. for the full api, read the [source code](https://github.com/aidenybai/bippy/blob/main/src/core.ts). 142 | 143 | ### onCommitFiberRoot 144 | 145 | a utility function that wraps the `instrument` function and sets the `onCommitFiberRoot` hook. 146 | 147 | ```typescript 148 | import { onCommitFiberRoot } from 'bippy'; 149 | 150 | onCommitFiberRoot((root) => { 151 | console.log('root ready to commit', root); 152 | }); 153 | ``` 154 | 155 | ### instrument 156 | 157 | > the underlying implementation for the `onCommitFiberRoot()` function. this is optional, unless you want to plug into more less common, advanced functionality. 158 | 159 | patches `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` with your handlers. must be imported before react, and must be initialized to properly run any other methods. 160 | 161 | > use with the `secure` function to prevent uncaught errors from crashing your app. 162 | 163 | ```typescript 164 | import { instrument, secure } from 'bippy'; // must be imported BEFORE react 165 | import * as React from 'react'; 166 | 167 | instrument( 168 | secure({ 169 | onCommitFiberRoot(rendererID, root) { 170 | console.log('root ready to commit', root); 171 | }, 172 | onPostCommitFiberRoot(rendererID, root) { 173 | console.log('root with effects committed', root); 174 | }, 175 | onCommitFiberUnmount(rendererID, fiber) { 176 | console.log('fiber unmounted', fiber); 177 | }, 178 | }) 179 | ); 180 | ``` 181 | 182 | ### getRDTHook 183 | 184 | returns the `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` object. great for advanced use cases, such as accessing or modifying the `renderers` property. 185 | 186 | ```typescript 187 | import { getRDTHook } from 'bippy'; 188 | 189 | const hook = getRDTHook(); 190 | console.log(hook); 191 | ``` 192 | 193 | ### traverseRenderedFibers 194 | 195 | not every fiber in the fiber tree renders. `traverseRenderedFibers` allows you to traverse the fiber tree and determine which fibers have actually rendered. 196 | 197 | ```typescript 198 | import { instrument, secure, traverseRenderedFibers } from 'bippy'; // must be imported BEFORE react 199 | import * as React from 'react'; 200 | 201 | instrument( 202 | secure({ 203 | onCommitFiberRoot(rendererID, root) { 204 | traverseRenderedFibers(root, (fiber) => { 205 | console.log('fiber rendered', fiber); 206 | }); 207 | }, 208 | }) 209 | ); 210 | ``` 211 | 212 | ### traverseFiber 213 | 214 | calls a callback on every fiber in the fiber tree. 215 | 216 | ```typescript 217 | import { instrument, secure, traverseFiber } from 'bippy'; // must be imported BEFORE react 218 | import * as React from 'react'; 219 | 220 | instrument( 221 | secure({ 222 | onCommitFiberRoot(rendererID, root) { 223 | traverseFiber(root.current, (fiber) => { 224 | console.log(fiber); 225 | }); 226 | }, 227 | }) 228 | ); 229 | ``` 230 | 231 | ### traverseProps 232 | 233 | traverses the props of a fiber. 234 | 235 | ```typescript 236 | import { traverseProps } from 'bippy'; 237 | 238 | // ... 239 | 240 | traverseProps(fiber, (propName, next, prev) => { 241 | console.log(propName, next, prev); 242 | }); 243 | ``` 244 | 245 | ### traverseState 246 | 247 | traverses the state (useState, useReducer, etc.) and effects that set state of a fiber. 248 | 249 | ```typescript 250 | import { traverseState } from 'bippy'; 251 | 252 | // ... 253 | 254 | traverseState(fiber, (next, prev) => { 255 | console.log(next, prev); 256 | }); 257 | ``` 258 | 259 | ### traverseContexts 260 | 261 | traverses the contexts (useContext) of a fiber. 262 | 263 | ```typescript 264 | import { traverseContexts } from 'bippy'; 265 | 266 | // ... 267 | 268 | traverseContexts(fiber, (next, prev) => { 269 | console.log(next, prev); 270 | }); 271 | ``` 272 | 273 | ### setFiberId / getFiberId 274 | 275 | set and get a persistent identity for a fiber. by default, fibers are anonymous and have no identity. 276 | 277 | ```typescript 278 | import { setFiberId, getFiberId } from 'bippy'; 279 | 280 | // ... 281 | 282 | setFiberId(fiber); 283 | console.log('unique id for fiber:', getFiberId(fiber)); 284 | ``` 285 | 286 | ### isHostFiber 287 | 288 | returns `true` if the fiber is a host fiber (e.g., a DOM node in react-dom). 289 | 290 | ```typescript 291 | import { isHostFiber } from 'bippy'; 292 | 293 | if (isHostFiber(fiber)) { 294 | console.log('fiber is a host fiber'); 295 | } 296 | ``` 297 | 298 | ### isCompositeFiber 299 | 300 | returns `true` if the fiber is a composite fiber. composite fibers represent class components, function components, memoized components, and so on (anything that can actually render output). 301 | 302 | ```typescript 303 | import { isCompositeFiber } from 'bippy'; 304 | 305 | if (isCompositeFiber(fiber)) { 306 | console.log('fiber is a composite fiber'); 307 | } 308 | ``` 309 | 310 | ### getDisplayName 311 | 312 | returns the display name of the fiber's component, falling back to the component's function or class name if available. 313 | 314 | ```typescript 315 | import { getDisplayName } from 'bippy'; 316 | 317 | console.log(getDisplayName(fiber)); 318 | ``` 319 | 320 | ### getType 321 | 322 | returns the underlying type (the component definition) for a given fiber. for example, this could be a function component or class component. 323 | 324 | ```jsx 325 | import { getType } from 'bippy'; 326 | import { memo } from 'react'; 327 | 328 | const RealComponent = () => { 329 | return
hello
; 330 | }; 331 | const MemoizedComponent = memo(() => { 332 | return
hello
; 333 | }); 334 | 335 | console.log(getType(fiberForMemoizedComponent) === RealComponent); 336 | ``` 337 | 338 | ### getNearestHostFiber / getNearestHostFibers 339 | 340 | getNearestHostFiber returns the closest host fiber above or below a given fiber. getNearestHostFibers(fiber) returns all host fibers associated with the provided fiber and its subtree. 341 | 342 | ```jsx 343 | import { getNearestHostFiber, getNearestHostFibers } from 'bippy'; 344 | 345 | // ... 346 | 347 | function Component() { 348 | return ( 349 | <> 350 |
hello
351 |
world
352 | 353 | ); 354 | } 355 | 356 | console.log(getNearestHostFiber(fiberForComponent)); //
hello
357 | console.log(getNearestHostFibers(fiberForComponent)); // [
hello
,
world
] 358 | ``` 359 | 360 | ### getTimings 361 | 362 | returns the self and total render times for the fiber. 363 | 364 | ```typescript 365 | // timings don't exist in react production builds 366 | if (fiber.actualDuration !== undefined) { 367 | const { selfTime, totalTime } = getTimings(fiber); 368 | console.log(selfTime, totalTime); 369 | } 370 | ``` 371 | 372 | ### getFiberStack 373 | 374 | returns an array representing the stack of fibers from the current fiber up to the root. 375 | 376 | ```typescript 377 | [fiber, fiber.return, fiber.return.return, ...] 378 | ``` 379 | 380 | ### getMutatedHostFibers 381 | 382 | returns an array of all host fibers that have committed and rendered in the provided fiber's subtree. 383 | 384 | ```typescript 385 | import { getMutatedHostFibers } from 'bippy'; 386 | 387 | console.log(getMutatedHostFibers(fiber)); 388 | ``` 389 | 390 | ### isValidFiber 391 | 392 | returns `true` if the given object is a valid React Fiber (i.e., has a tag, stateNode, return, child, sibling, etc.). 393 | 394 | ```typescript 395 | import { isValidFiber } from 'bippy'; 396 | 397 | console.log(isValidFiber(fiber)); 398 | ``` 399 | 400 | ## getFiberFromHostInstance 401 | 402 | returns the fiber associated with a given host instance (e.g., a DOM element). 403 | 404 | ```typescript 405 | import { getFiberFromHostInstance } from 'bippy'; 406 | 407 | const fiber = getFiberFromHostInstance(document.querySelector('div')); 408 | console.log(fiber); 409 | ``` 410 | 411 | ## getLatestFiber 412 | 413 | returns the latest fiber (since it may be double-buffered). usually use this in combination with `getFiberFromHostInstance`. 414 | 415 | ```typescript 416 | import { getLatestFiber } from 'bippy'; 417 | 418 | const latestFiber = getLatestFiber( 419 | getFiberFromHostInstance(document.querySelector('div')) 420 | ); 421 | console.log(latestFiber); 422 | ``` 423 | 424 | ## examples 425 | 426 | the best way to understand bippy is to [read the source code](https://github.com/aidenybai/bippy/blob/main/src/core.ts). here are some examples of how you can use it: 427 | 428 | ### a mini react-scan 429 | 430 | here's a mini toy version of [`react-scan`](https://github.com/aidenybai/react-scan) that highlights renders in your app. 431 | 432 | ```javascript 433 | import { 434 | instrument, 435 | isHostFiber, 436 | getNearestHostFiber, 437 | traverseRenderedFibers, 438 | } from 'bippy'; // must be imported BEFORE react 439 | 440 | const highlightFiber = (fiber) => { 441 | if (!(fiber.stateNode instanceof HTMLElement)) return; 442 | // fiber.stateNode is a DOM element 443 | const rect = fiber.stateNode.getBoundingClientRect(); 444 | const highlight = document.createElement('div'); 445 | highlight.style.border = '1px solid red'; 446 | highlight.style.position = 'fixed'; 447 | highlight.style.top = `${rect.top}px`; 448 | highlight.style.left = `${rect.left}px`; 449 | highlight.style.width = `${rect.width}px`; 450 | highlight.style.height = `${rect.height}px`; 451 | highlight.style.zIndex = 999999999; 452 | document.documentElement.appendChild(highlight); 453 | setTimeout(() => { 454 | document.documentElement.removeChild(highlight); 455 | }, 100); 456 | }; 457 | 458 | /** 459 | * `instrument` is a function that installs the react DevTools global 460 | * hook and allows you to set up custom handlers for react fiber events. 461 | */ 462 | instrument( 463 | /** 464 | * `secure` is a function that wraps your handlers in a try/catch 465 | * and prevents it from crashing the app. it also prevents it from 466 | * running on unsupported react versions and during production. 467 | * 468 | * this is not required but highly recommended to provide "safeguards" 469 | * in case something breaks. 470 | */ 471 | secure({ 472 | /** 473 | * `onCommitFiberRoot` is a handler that is called when react is 474 | * ready to commit a fiber root. this means that react is has 475 | * rendered your entire app and is ready to apply changes to 476 | * the host tree (e.g. via DOM mutations). 477 | */ 478 | onCommitFiberRoot(rendererID, root) { 479 | /** 480 | * `traverseRenderedFibers` traverses the fiber tree and determines which 481 | * fibers have actually rendered. 482 | * 483 | * A fiber tree contains many fibers that may have not rendered. this 484 | * can be because it bailed out (e.g. `useMemo`) or because it wasn't 485 | * actually rendered (if re-rendered, then didn't 486 | * actually render, but exists in the fiber tree). 487 | */ 488 | traverseRenderedFibers(root, (fiber) => { 489 | /** 490 | * `getNearestHostFiber` is a utility function that finds the 491 | * nearest host fiber to a given fiber. 492 | * 493 | * a host fiber for `react-dom` is a fiber that has a DOM element 494 | * as its `stateNode`. 495 | */ 496 | const hostFiber = getNearestHostFiber(fiber); 497 | highlightFiber(hostFiber); 498 | }); 499 | }, 500 | }) 501 | ); 502 | ``` 503 | 504 | ### a mini why-did-you-render 505 | 506 | here's a mini toy version of [`why-did-you-render`](https://github.com/welldone-software/why-did-you-render) that logs why components re-render. 507 | 508 | ```typescript 509 | import { 510 | instrument, 511 | isHostFiber, 512 | traverseRenderedFibers, 513 | isCompositeFiber, 514 | getDisplayName, 515 | traverseProps, 516 | traverseContexts, 517 | traverseState, 518 | } from 'bippy'; // must be imported BEFORE react 519 | 520 | instrument( 521 | secure({ 522 | onCommitFiberRoot(rendererID, root) { 523 | traverseRenderedFibers(root, (fiber) => { 524 | /** 525 | * `isCompositeFiber` is a utility function that checks if a fiber is a composite fiber. 526 | * a composite fiber is a fiber that represents a function or class component. 527 | */ 528 | if (!isCompositeFiber(fiber)) return; 529 | 530 | /** 531 | * `getDisplayName` is a utility function that gets the display name of a fiber. 532 | */ 533 | const displayName = getDisplayName(fiber); 534 | if (!displayName) return; 535 | 536 | const changes = []; 537 | 538 | /** 539 | * `traverseProps` is a utility function that traverses the props of a fiber. 540 | */ 541 | traverseProps(fiber, (propName, next, prev) => { 542 | if (next !== prev) { 543 | changes.push({ 544 | name: `prop ${propName}`, 545 | prev, 546 | next, 547 | }); 548 | } 549 | }); 550 | 551 | let contextId = 0; 552 | /** 553 | * `traverseContexts` is a utility function that traverses the contexts of a fiber. 554 | * Contexts don't have a "name" like props, so we use an id to identify them. 555 | */ 556 | traverseContexts(fiber, (next, prev) => { 557 | if (next !== prev) { 558 | changes.push({ 559 | name: `context ${contextId}`, 560 | prev, 561 | next, 562 | contextId, 563 | }); 564 | } 565 | contextId++; 566 | }); 567 | 568 | let stateId = 0; 569 | /** 570 | * `traverseState` is a utility function that traverses the state of a fiber. 571 | * 572 | * State don't have a "name" like props, so we use an id to identify them. 573 | */ 574 | traverseState(fiber, (value, prevValue) => { 575 | if (next !== prev) { 576 | changes.push({ 577 | name: `state ${stateId}`, 578 | prev, 579 | next, 580 | }); 581 | } 582 | stateId++; 583 | }); 584 | 585 | console.group( 586 | `%c${displayName}`, 587 | 'background: hsla(0,0%,70%,.3); border-radius:3px; padding: 0 2px;' 588 | ); 589 | for (const { name, prev, next } of changes) { 590 | console.log(`${name}:`, prev, '!==', next); 591 | } 592 | console.groupEnd(); 593 | }); 594 | }, 595 | }) 596 | ); 597 | ``` 598 | 599 | ## glossary 600 | 601 | - fiber: a "unit of execution" in react, representing a component or dom element 602 | - commit: the process of applying changes to the host tree (e.g. DOM mutations) 603 | - render: the process of building the fiber tree by executing component function/classes 604 | - host tree: the tree of UI elements that react mutates (e.g. DOM elements) 605 | - reconciler (or "renderer"): custom bindings for react, e.g. react-dom, react-native, react-three-fiber, etc to mutate the host tree 606 | - `rendererID`: the id of the reconciler, starting at 1 (can be from multiple reconciler instances) 607 | - `root`: a special `FiberRoot` type that contains the container fiber (the one you pass to `ReactDOM.createRoot`) in the `current` property 608 | - `onCommitFiberRoot`: called when react is ready to commit a fiber root 609 | - `onPostCommitFiberRoot`: called when react has committed a fiber root and effects have run 610 | - `onCommitFiberUnmount`: called when a fiber unmounts 611 | 612 | ## development 613 | 614 | pre-requisite: you should understand how react works internally. if you don't, please give this [series of articles](https://jser.dev/series/react-source-code-walkthrough) a read. 615 | 616 | we use a pnpm monorepo, get started by running: 617 | 618 | ```shell 619 | pnpm install 620 | # create dev builds 621 | pnpm run dev 622 | # run unit tests 623 | pnpm run test 624 | ``` 625 | 626 | you can ad-hoc test by running `pnpm run dev` in the `/kitchen-sink` directory. 627 | 628 | ```shell 629 | cd kitchen-sink 630 | pnpm run dev 631 | ``` 632 | 633 | ## misc 634 | 635 | we use this project internally in [react-scan](https://github.com/aidenybai/react-scan), which is deployed with proper safeguards to ensure it's only used in development or error-guarded in production. 636 | 637 | while i maintain this specifically for react-scan, those seeking more robust solutions might consider [its-fine](https://github.com/pmndrs/its-fine) for accessing fibers within react using hooks, or [react-devtools-inline](https://www.npmjs.com/package/react-devtools-inline) for a headful interface. 638 | 639 | if you plan to use this project beyond experimentation, please review [react-scan's source code](https://github.com/aidenybai/react-scan) to understand our safeguarding practices. 640 | 641 | the original bippy character is owned and created by [@dairyfreerice](https://www.instagram.com/dairyfreerice). this project is not related to the bippy brand, i just think the character is cute. 642 | -------------------------------------------------------------------------------- /packages/bippy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bippy", 3 | "version": "0.3.15", 4 | "description": "hack into react internals", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "keywords": [ 9 | "bippy", 10 | "react", 11 | "react instrumentation", 12 | "react devtools", 13 | "react fiber", 14 | "fiber", 15 | "internals" 16 | ], 17 | "homepage": "https://bippy.dev", 18 | "bugs": { 19 | "url": "https://github.com/aidenybai/bippy/issues" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/aidenybai/bippy.git" 24 | }, 25 | "license": "MIT", 26 | "author": { 27 | "name": "Aiden Bai", 28 | "email": "aiden@million.dev" 29 | }, 30 | "type": "module", 31 | "exports": { 32 | "./package.json": "./package.json", 33 | ".": { 34 | "import": { 35 | "types": "./dist/index.d.ts", 36 | "default": "./dist/index.js" 37 | }, 38 | "require": { 39 | "types": "./dist/index.d.cts", 40 | "default": "./dist/index.cjs" 41 | } 42 | }, 43 | "./core": { 44 | "import": { 45 | "types": "./dist/core.d.ts", 46 | "default": "./dist/core.js" 47 | }, 48 | "require": { 49 | "types": "./dist/core.d.cts", 50 | "default": "./dist/core.cjs" 51 | } 52 | }, 53 | "./source": { 54 | "import": { 55 | "types": "./dist/source.d.ts", 56 | "default": "./dist/source.js" 57 | }, 58 | "require": { 59 | "types": "./dist/source.d.cts", 60 | "default": "./dist/source.cjs" 61 | } 62 | }, 63 | "./jsx-runtime": { 64 | "import": { 65 | "types": "./dist/jsx-runtime.d.ts", 66 | "default": "./dist/jsx-runtime.js" 67 | }, 68 | "require": { 69 | "types": "./dist/jsx-runtime.d.cts", 70 | "default": "./dist/jsx-runtime.cjs" 71 | } 72 | }, 73 | "./jsx-dev-runtime": { 74 | "import": { 75 | "types": "./dist/jsx-dev-runtime.d.ts", 76 | "default": "./dist/jsx-dev-runtime.js" 77 | }, 78 | "require": { 79 | "types": "./dist/jsx-dev-runtime.d.cts", 80 | "default": "./dist/jsx-dev-runtime.cjs" 81 | } 82 | }, 83 | "./experiments/inspect": { 84 | "import": { 85 | "types": "./dist/experiments/inspect.d.ts", 86 | "default": "./dist/experiments/inspect.js" 87 | }, 88 | "require": { 89 | "types": "./dist/experiments/inspect.d.cts", 90 | "default": "./dist/experiments/inspect.cjs" 91 | } 92 | }, 93 | "./dist/*": "./dist/*.js", 94 | "./dist/*.js": "./dist/*.js", 95 | "./dist/*.cjs": "./dist/*.cjs" 96 | }, 97 | "main": "dist/index.js", 98 | "module": "dist/index.js", 99 | "browser": "dist/index.iife.js", 100 | "types": "dist/index.d.ts", 101 | "files": [ 102 | "dist", 103 | "bin", 104 | "package.json", 105 | "README.md", 106 | "LICENSE" 107 | ], 108 | "scripts": { 109 | "build": "NODE_ENV=production tsdown && bun scripts/append-banner.ts", 110 | "dev": "NODE_ENV=development tsdown --watch", 111 | "publint": "publint", 112 | "test": "vitest", 113 | "coverage": "vitest run --coverage", 114 | "prepublishOnly": "cp ../../README.md . && pnpm build" 115 | }, 116 | "devDependencies": { 117 | "@biomejs/biome": "1.9.4", 118 | "@testing-library/dom": "^10.4.0", 119 | "@testing-library/react": "^16.1.0", 120 | "@types/node": "^20", 121 | "@types/react": "^19.0.4", 122 | "@types/react-dom": "^19.0.2", 123 | "@vitest/coverage-istanbul": "2.1.8", 124 | "error-stack-parser-es": "^1.0.5", 125 | "esbuild": "^0.24.2", 126 | "happy-dom": "^15.11.7", 127 | "pkg-pr-new": "^0.0.39", 128 | "publint": "^0.2.12", 129 | "react": "19.0.0", 130 | "react-devtools-inline": "^6.0.1", 131 | "react-dom": "19.0.0", 132 | "react-inspector": "^6.0.2", 133 | "react-reconciler": "^0.31.0", 134 | "react-refresh": "^0.16.0", 135 | "source-map-js": "^1.2.1", 136 | "terser": "^5.36.0", 137 | "tsdown": "^0.12.3", 138 | "vitest": "^2.1.8" 139 | }, 140 | "peerDependencies": { 141 | "react": ">=17.0.1" 142 | }, 143 | "dependencies": { 144 | "@types/react-reconciler": "^0.28.9" 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /packages/bippy/scripts/append-banner.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 6 | 7 | const banner = `/** 8 | * @license bippy 9 | * 10 | * Copyright (c) Aiden Bai 11 | * 12 | * This source code is licensed under the MIT license found in the 13 | * LICENSE file in the root directory of this source tree. 14 | */`; 15 | 16 | const distDir = path.join(__dirname, '..', 'dist'); 17 | 18 | const appendBannerToFile = (filePath: string) => { 19 | const content = fs.readFileSync(filePath, 'utf8'); 20 | 21 | if (content.startsWith(banner)) { 22 | return; 23 | } 24 | 25 | const newContent = `${banner}\n${content}`; 26 | fs.writeFileSync(filePath, newContent, 'utf8'); 27 | }; 28 | 29 | const processDirectory = (dir: string) => { 30 | const files = fs.readdirSync(dir); 31 | 32 | for (const file of files) { 33 | const filePath = path.join(dir, file); 34 | const stat = fs.statSync(filePath); 35 | 36 | if (stat.isDirectory()) { 37 | processDirectory(filePath); 38 | } else if (stat.isFile()) { 39 | if (file.endsWith('.js') || file.endsWith('.cjs')) { 40 | appendBannerToFile(filePath); 41 | } 42 | } 43 | } 44 | }; 45 | 46 | processDirectory(distDir); 47 | -------------------------------------------------------------------------------- /packages/bippy/src/core.ts: -------------------------------------------------------------------------------- 1 | // Note: do not import React in this file 2 | // since it will be executed before the react devtools hook is created 3 | 4 | import type * as React from 'react'; 5 | import { 6 | BIPPY_INSTRUMENTATION_STRING, 7 | getRDTHook, 8 | hasRDTHook, 9 | isReactRefresh, 10 | isRealReactDevtools, 11 | } from './rdt-hook.js'; 12 | import type { 13 | ContextDependency, 14 | Fiber, 15 | FiberRoot, 16 | MemoizedState, 17 | ReactDevToolsGlobalHook, 18 | ReactRenderer, 19 | } from './types.js'; 20 | 21 | // https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactWorkTags.js 22 | export const FunctionComponentTag = 0; 23 | export const ClassComponentTag = 1; 24 | export const HostRootTag = 3; 25 | export const HostComponentTag = 5; 26 | export const HostTextTag = 6; 27 | export const FragmentTag = 7; 28 | export const ContextConsumerTag = 9; 29 | export const ForwardRefTag = 11; 30 | export const SuspenseComponentTag = 13; 31 | export const MemoComponentTag = 14; 32 | export const SimpleMemoComponentTag = 15; 33 | export const DehydratedSuspenseComponentTag = 18; 34 | export const OffscreenComponentTag = 22; 35 | export const LegacyHiddenComponentTag = 23; 36 | export const HostHoistableTag = 26; 37 | export const HostSingletonTag = 27; 38 | 39 | export const CONCURRENT_MODE_NUMBER = 0xeacf; 40 | export const ELEMENT_TYPE_SYMBOL_STRING = 'Symbol(react.element)'; 41 | export const TRANSITIONAL_ELEMENT_TYPE_SYMBOL_STRING = 42 | 'Symbol(react.transitional.element)'; 43 | export const CONCURRENT_MODE_SYMBOL_STRING = 'Symbol(react.concurrent_mode)'; 44 | export const DEPRECATED_ASYNC_MODE_SYMBOL_STRING = 'Symbol(react.async_mode)'; 45 | 46 | // https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberFlags.js 47 | const PerformedWork = 0b1; 48 | const Placement = 0b10; 49 | const Hydrating = 0b1000000000000; 50 | const Update = 0b100; 51 | const Cloned = 0b1000; 52 | const ChildDeletion = 0b10000; 53 | const ContentReset = 0b100000; 54 | const Snapshot = 0b10000000000; 55 | const Visibility = 0b10000000000000; 56 | const MutationMask = 57 | Placement | 58 | Update | 59 | ChildDeletion | 60 | ContentReset | 61 | Hydrating | 62 | Visibility | 63 | Snapshot; 64 | 65 | /** 66 | * Returns `true` if object is a React Element. 67 | * 68 | * @see https://react.dev/reference/react/isValidElement 69 | */ 70 | export const isValidElement = ( 71 | element: unknown, 72 | ): element is React.ReactElement => 73 | typeof element === 'object' && 74 | element != null && 75 | '$$typeof' in element && 76 | // react 18 uses Symbol.for('react.element'), react 19 uses Symbol.for('react.transitional.element') 77 | [ 78 | ELEMENT_TYPE_SYMBOL_STRING, 79 | TRANSITIONAL_ELEMENT_TYPE_SYMBOL_STRING, 80 | ].includes(String(element.$$typeof)); 81 | 82 | /** 83 | * Returns `true` if object is a React Fiber. 84 | */ 85 | export const isValidFiber = (fiber: unknown): fiber is Fiber => 86 | typeof fiber === 'object' && 87 | fiber != null && 88 | 'tag' in fiber && 89 | 'stateNode' in fiber && 90 | 'return' in fiber && 91 | 'child' in fiber && 92 | 'sibling' in fiber && 93 | 'flags' in fiber; 94 | 95 | /** 96 | * Returns `true` if fiber is a host fiber. Host fibers are DOM nodes in react-dom, `View` in react-native, etc. 97 | * 98 | * @see https://reactnative.dev/architecture/glossary#host-view-tree-and-host-view 99 | */ 100 | export const isHostFiber = (fiber: Fiber): boolean => { 101 | switch (fiber.tag) { 102 | case HostComponentTag: 103 | // @ts-expect-error: it exists 104 | case HostHoistableTag: 105 | // @ts-expect-error: it exists 106 | case HostSingletonTag: 107 | return true; 108 | default: 109 | return typeof fiber.type === 'string'; 110 | } 111 | }; 112 | /** 113 | * Returns `true` if fiber is a composite fiber. Composite fibers are fibers that can render (like functional components, class components, etc.) 114 | * 115 | * @see https://reactnative.dev/architecture/glossary#react-composite-components 116 | */ 117 | export const isCompositeFiber = (fiber: Fiber): boolean => { 118 | switch (fiber.tag) { 119 | case FunctionComponentTag: 120 | case ClassComponentTag: 121 | case SimpleMemoComponentTag: 122 | case MemoComponentTag: 123 | case ForwardRefTag: 124 | return true; 125 | default: 126 | return false; 127 | } 128 | }; 129 | 130 | /** 131 | * Traverses up or down a {@link Fiber}'s contexts, return `true` to stop and select the current and previous context value. 132 | */ 133 | export const traverseContexts = ( 134 | fiber: Fiber, 135 | selector: ( 136 | nextValue: ContextDependency | null | undefined, 137 | prevValue: ContextDependency | null | undefined, 138 | // biome-ignore lint/suspicious/noConfusingVoidType: optional return 139 | ) => boolean | void, 140 | ): boolean => { 141 | try { 142 | const nextDependencies = fiber.dependencies; 143 | const prevDependencies = fiber.alternate?.dependencies; 144 | 145 | if (!nextDependencies || !prevDependencies) return false; 146 | if ( 147 | typeof nextDependencies !== 'object' || 148 | !('firstContext' in nextDependencies) || 149 | typeof prevDependencies !== 'object' || 150 | !('firstContext' in prevDependencies) 151 | ) { 152 | return false; 153 | } 154 | let nextContext: ContextDependency | null | undefined = 155 | nextDependencies.firstContext; 156 | let prevContext: ContextDependency | null | undefined = 157 | prevDependencies.firstContext; 158 | while ( 159 | (nextContext && 160 | typeof nextContext === 'object' && 161 | 'memoizedValue' in nextContext) || 162 | (prevContext && 163 | typeof prevContext === 'object' && 164 | 'memoizedValue' in prevContext) 165 | ) { 166 | if (selector(nextContext, prevContext) === true) return true; 167 | 168 | nextContext = nextContext?.next; 169 | prevContext = prevContext?.next; 170 | } 171 | } catch {} 172 | return false; 173 | }; 174 | 175 | /** 176 | * Traverses up or down a {@link Fiber}'s states, return `true` to stop and select the current and previous state value. This stores both state values and effects. 177 | */ 178 | export const traverseState = ( 179 | fiber: Fiber, 180 | selector: ( 181 | nextValue: MemoizedState | null | undefined, 182 | prevValue: MemoizedState | null | undefined, 183 | // biome-ignore lint/suspicious/noConfusingVoidType: optional return 184 | ) => boolean | void, 185 | ): boolean => { 186 | try { 187 | let nextState: MemoizedState | null | undefined = fiber.memoizedState; 188 | let prevState: MemoizedState | null | undefined = 189 | fiber.alternate?.memoizedState; 190 | 191 | while (nextState || prevState) { 192 | if (selector(nextState, prevState) === true) return true; 193 | 194 | nextState = nextState?.next; 195 | prevState = prevState?.next; 196 | } 197 | } catch {} 198 | return false; 199 | }; 200 | 201 | /** 202 | * Traverses up or down a {@link Fiber}'s props, return `true` to stop and select the current and previous props value. 203 | */ 204 | export const traverseProps = ( 205 | fiber: Fiber, 206 | selector: ( 207 | propName: string, 208 | nextValue: unknown, 209 | prevValue: unknown, 210 | // biome-ignore lint/suspicious/noConfusingVoidType: may or may not exist 211 | ) => boolean | void, 212 | ): boolean => { 213 | try { 214 | const nextProps = fiber.memoizedProps; 215 | const prevProps = fiber.alternate?.memoizedProps || {}; 216 | 217 | const allKeys = new Set([ 218 | ...Object.keys(prevProps), 219 | ...Object.keys(nextProps), 220 | ]); 221 | 222 | for (const propName of allKeys) { 223 | const prevValue = prevProps?.[propName]; 224 | const nextValue = nextProps?.[propName]; 225 | 226 | if (selector(propName, nextValue, prevValue) === true) return true; 227 | } 228 | } catch {} 229 | return false; 230 | }; 231 | 232 | /** 233 | * Returns `true` if the {@link Fiber} has rendered. Note that this does not mean the fiber has rendered in the current commit, just that it has rendered in the past. 234 | */ 235 | export const didFiberRender = (fiber: Fiber): boolean => { 236 | const nextProps = fiber.memoizedProps; 237 | const prevProps = fiber.alternate?.memoizedProps || {}; 238 | const flags = 239 | fiber.flags ?? (fiber as unknown as { effectTag: number }).effectTag ?? 0; 240 | 241 | switch (fiber.tag) { 242 | case ClassComponentTag: 243 | case FunctionComponentTag: 244 | case ContextConsumerTag: 245 | case ForwardRefTag: 246 | case MemoComponentTag: 247 | case SimpleMemoComponentTag: { 248 | return (flags & PerformedWork) === PerformedWork; 249 | } 250 | default: 251 | // Host nodes (DOM, root, etc.) 252 | if (!fiber.alternate) return true; 253 | return ( 254 | prevProps !== nextProps || 255 | fiber.alternate.memoizedState !== fiber.memoizedState || 256 | fiber.alternate.ref !== fiber.ref 257 | ); 258 | } 259 | }; 260 | 261 | /** 262 | * Returns `true` if the {@link Fiber} has committed. Note that this does not mean the fiber has committed in the current commit, just that it has committed in the past. 263 | */ 264 | export const didFiberCommit = (fiber: Fiber): boolean => { 265 | return Boolean( 266 | (fiber.flags & (MutationMask | Cloned)) !== 0 || 267 | (fiber.subtreeFlags & (MutationMask | Cloned)) !== 0, 268 | ); 269 | }; 270 | 271 | /** 272 | * Returns all host {@link Fiber}s that have committed and rendered. 273 | */ 274 | export const getMutatedHostFibers = (fiber: Fiber): Fiber[] => { 275 | const mutations: Fiber[] = []; 276 | const stack: Fiber[] = [fiber]; 277 | 278 | while (stack.length) { 279 | const node = stack.pop(); 280 | if (!node) continue; 281 | 282 | if (isHostFiber(node) && didFiberCommit(node) && didFiberRender(node)) { 283 | mutations.push(node); 284 | } 285 | 286 | if (node.child) stack.push(node.child); 287 | if (node.sibling) stack.push(node.sibling); 288 | } 289 | 290 | return mutations; 291 | }; 292 | 293 | /** 294 | * Returns the stack of {@link Fiber}s from the current fiber to the root fiber. 295 | * 296 | * @example 297 | * ```ts 298 | * [fiber, fiber.return, fiber.return.return, ...] 299 | * ``` 300 | */ 301 | export const getFiberStack = (fiber: Fiber): Fiber[] => { 302 | const stack: Fiber[] = []; 303 | let currentFiber = fiber; 304 | while (currentFiber.return) { 305 | stack.push(currentFiber); 306 | currentFiber = currentFiber.return; 307 | } 308 | return stack; 309 | }; 310 | 311 | /** 312 | * Returns `true` if the {@link Fiber} should be filtered out during reconciliation. 313 | */ 314 | export const shouldFilterFiber = (fiber: Fiber): boolean => { 315 | switch (fiber.tag) { 316 | case DehydratedSuspenseComponentTag: 317 | // TODO: ideally we would show dehydrated Suspense immediately. 318 | // However, it has some special behavior (like disconnecting 319 | // an alternate and turning into real Suspense) which breaks DevTools. 320 | // For now, ignore it, and only show it once it gets hydrated. 321 | // https://github.com/bvaughn/react-devtools-experimental/issues/197 322 | return true; 323 | 324 | case HostTextTag: 325 | case FragmentTag: 326 | case LegacyHiddenComponentTag: 327 | case OffscreenComponentTag: 328 | return true; 329 | 330 | case HostRootTag: 331 | // It is never valid to filter the root element. 332 | return false; 333 | 334 | default: { 335 | const symbolOrNumber = 336 | typeof fiber.type === 'object' && fiber.type !== null 337 | ? fiber.type.$$typeof 338 | : fiber.type; 339 | 340 | const typeSymbol = 341 | typeof symbolOrNumber === 'symbol' 342 | ? symbolOrNumber.toString() 343 | : symbolOrNumber; 344 | 345 | switch (typeSymbol) { 346 | case CONCURRENT_MODE_NUMBER: 347 | case CONCURRENT_MODE_SYMBOL_STRING: 348 | case DEPRECATED_ASYNC_MODE_SYMBOL_STRING: 349 | return true; 350 | 351 | default: 352 | return false; 353 | } 354 | } 355 | } 356 | }; 357 | 358 | /** 359 | * Returns the nearest host {@link Fiber} to the current {@link Fiber}. 360 | */ 361 | export const getNearestHostFiber = ( 362 | fiber: Fiber, 363 | ascending = false, 364 | ): Fiber | null => { 365 | let hostFiber = traverseFiber(fiber, isHostFiber, ascending); 366 | if (!hostFiber) { 367 | hostFiber = traverseFiber(fiber, isHostFiber, !ascending); 368 | } 369 | return hostFiber; 370 | }; 371 | 372 | /** 373 | * Returns all host {@link Fiber}s in the tree that are associated with the current {@link Fiber}. 374 | */ 375 | export const getNearestHostFibers = (fiber: Fiber): Fiber[] => { 376 | const hostFibers: Fiber[] = []; 377 | const stack: Fiber[] = []; 378 | 379 | if (isHostFiber(fiber)) { 380 | hostFibers.push(fiber); 381 | } else if (fiber.child) { 382 | stack.push(fiber.child); 383 | } 384 | 385 | while (stack.length) { 386 | const currentNode = stack.pop(); 387 | if (!currentNode) break; 388 | if (isHostFiber(currentNode)) { 389 | hostFibers.push(currentNode); 390 | } else if (currentNode.child) { 391 | stack.push(currentNode.child); 392 | } 393 | 394 | if (currentNode.sibling) { 395 | stack.push(currentNode.sibling); 396 | } 397 | } 398 | 399 | return hostFibers; 400 | }; 401 | 402 | /** 403 | * Traverses up or down a {@link Fiber}, return `true` to stop and select a node. 404 | */ 405 | export const traverseFiber = ( 406 | fiber: Fiber | null, 407 | // biome-ignore lint/suspicious/noConfusingVoidType: may or may not exist 408 | selector: (node: Fiber) => boolean | void, 409 | ascending = false, 410 | ): Fiber | null => { 411 | if (!fiber) return null; 412 | if (selector(fiber) === true) return fiber; 413 | 414 | let child = ascending ? fiber.return : fiber.child; 415 | while (child) { 416 | const match = traverseFiber(child, selector, ascending); 417 | if (match) return match; 418 | 419 | child = ascending ? null : child.sibling; 420 | } 421 | return null; 422 | }; 423 | 424 | /** 425 | * Returns the timings of the {@link Fiber}. 426 | * 427 | * @example 428 | * ```ts 429 | * const { selfTime, totalTime } = getTimings(fiber); 430 | * console.log(selfTime, totalTime); 431 | * ``` 432 | */ 433 | export const getTimings = ( 434 | fiber?: Fiber | null | undefined, 435 | ): { selfTime: number; totalTime: number } => { 436 | const totalTime = fiber?.actualDuration ?? 0; 437 | let selfTime = totalTime; 438 | // TODO: calculate a DOM time, which is just host component summed up 439 | let child = fiber?.child ?? null; 440 | while (totalTime > 0 && child != null) { 441 | selfTime -= child.actualDuration ?? 0; 442 | child = child.sibling; 443 | } 444 | return { selfTime, totalTime }; 445 | }; 446 | 447 | /** 448 | * Returns `true` if the {@link Fiber} uses React Compiler's memo cache. 449 | */ 450 | export const hasMemoCache = (fiber: Fiber): boolean => { 451 | return Boolean( 452 | (fiber.updateQueue as unknown as { memoCache: unknown })?.memoCache, 453 | ); 454 | }; 455 | 456 | type FiberType = 457 | | React.ComponentType 458 | | React.ForwardRefExoticComponent 459 | | React.MemoExoticComponent>; 460 | 461 | /** 462 | * Returns the type (e.g. component definition) of the {@link Fiber} 463 | */ 464 | export const getType = (type: unknown): React.ComponentType | null => { 465 | const currentType = type as FiberType; 466 | if (typeof currentType === 'function') { 467 | return currentType; 468 | } 469 | if (typeof currentType === 'object' && currentType) { 470 | // memo / forwardRef case 471 | return getType( 472 | (currentType as React.MemoExoticComponent>) 473 | .type || 474 | (currentType as { render: React.ComponentType }).render, 475 | ); 476 | } 477 | return null; 478 | }; 479 | 480 | /** 481 | * Returns the display name of the {@link Fiber} type. 482 | */ 483 | export const getDisplayName = (type: unknown): string | null => { 484 | const currentType = type as FiberType; 485 | if (typeof currentType === 'string') { 486 | return currentType; 487 | } 488 | if ( 489 | typeof currentType !== 'function' && 490 | !(typeof currentType === 'object' && currentType) 491 | ) { 492 | return null; 493 | } 494 | const name = currentType.displayName || currentType.name || null; 495 | if (name) return name; 496 | const unwrappedType = getType(currentType); 497 | if (!unwrappedType) return null; 498 | return unwrappedType.displayName || unwrappedType.name || null; 499 | }; 500 | 501 | /** 502 | * Returns the build type of the React renderer. 503 | */ 504 | export const detectReactBuildType = ( 505 | renderer: ReactRenderer, 506 | ): 'development' | 'production' => { 507 | try { 508 | if (typeof renderer.version === 'string' && renderer.bundleType > 0) { 509 | return 'development'; 510 | } 511 | } catch {} 512 | return 'production'; 513 | }; 514 | 515 | /** 516 | * Returns `true` if bippy's instrumentation is active. 517 | */ 518 | export const isInstrumentationActive = (): boolean => { 519 | const rdtHook = getRDTHook(); 520 | return ( 521 | Boolean(rdtHook._instrumentationIsActive) || 522 | isRealReactDevtools() || 523 | isReactRefresh() 524 | ); 525 | }; 526 | 527 | /** 528 | * Returns the latest fiber (since it may be double-buffered). 529 | */ 530 | export const getLatestFiber = (fiber: Fiber): Fiber => { 531 | const alternate = fiber.alternate; 532 | if (!alternate) return fiber; 533 | if (alternate.actualStartTime && fiber.actualStartTime) { 534 | return alternate.actualStartTime > fiber.actualStartTime 535 | ? alternate 536 | : fiber; 537 | } 538 | for (const root of _fiberRoots) { 539 | const latestFiber = traverseFiber(root.current, (innerFiber) => { 540 | if (innerFiber === fiber) return true; 541 | }); 542 | if (latestFiber) return latestFiber; 543 | } 544 | return fiber; 545 | }; 546 | 547 | export type RenderPhase = 'mount' | 'update' | 'unmount'; 548 | 549 | export type RenderHandler = ( 550 | fiber: Fiber, 551 | phase: RenderPhase, 552 | state?: S, 553 | ) => unknown; 554 | 555 | let fiberId = 0; 556 | export const fiberIdMap = new WeakMap(); 557 | 558 | export const setFiberId = (fiber: Fiber, id: number = fiberId++): void => { 559 | fiberIdMap.set(fiber, id); 560 | }; 561 | 562 | // react fibers are double buffered, so the alternate fiber may 563 | // be switched to the current fiber and vice versa. 564 | // fiber === fiber.alternate.alternate 565 | export const getFiberId = (fiber: Fiber): number => { 566 | let id = fiberIdMap.get(fiber); 567 | if (!id && fiber.alternate) { 568 | id = fiberIdMap.get(fiber.alternate); 569 | } 570 | if (!id) { 571 | id = fiberId++; 572 | setFiberId(fiber, id); 573 | } 574 | return id; 575 | }; 576 | 577 | export const mountFiberRecursively = ( 578 | onRender: RenderHandler, 579 | firstChild: Fiber, 580 | traverseSiblings: boolean, 581 | ): void => { 582 | let fiber: Fiber | null = firstChild; 583 | 584 | while (fiber != null) { 585 | if (!fiberIdMap.has(fiber)) { 586 | getFiberId(fiber); 587 | } 588 | const shouldIncludeInTree = !shouldFilterFiber(fiber); 589 | if (shouldIncludeInTree && didFiberRender(fiber)) { 590 | onRender(fiber, 'mount'); 591 | } 592 | 593 | if (fiber.tag === SuspenseComponentTag) { 594 | const isTimedOut = fiber.memoizedState !== null; 595 | if (isTimedOut) { 596 | // Special case: if Suspense mounts in a timed-out state, 597 | // get the fallback child from the inner fragment and mount 598 | // it as if it was our own child. Updates handle this too. 599 | const primaryChildFragment = fiber.child; 600 | const fallbackChildFragment = primaryChildFragment 601 | ? primaryChildFragment.sibling 602 | : null; 603 | if (fallbackChildFragment) { 604 | const fallbackChild = fallbackChildFragment.child; 605 | if (fallbackChild !== null) { 606 | mountFiberRecursively(onRender, fallbackChild, false); 607 | } 608 | } 609 | } else { 610 | let primaryChild: Fiber | null = null; 611 | const areSuspenseChildrenConditionallyWrapped = 612 | (OffscreenComponentTag as number) === -1; 613 | if (areSuspenseChildrenConditionallyWrapped) { 614 | primaryChild = fiber.child; 615 | } else if (fiber.child !== null) { 616 | primaryChild = fiber.child.child; 617 | } 618 | if (primaryChild !== null) { 619 | mountFiberRecursively(onRender, primaryChild, false); 620 | } 621 | } 622 | } else if (fiber.child != null) { 623 | mountFiberRecursively(onRender, fiber.child, true); 624 | } 625 | fiber = traverseSiblings ? fiber.sibling : null; 626 | } 627 | }; 628 | 629 | export const updateFiberRecursively = ( 630 | onRender: RenderHandler, 631 | nextFiber: Fiber, 632 | prevFiber: Fiber, 633 | parentFiber: Fiber | null, 634 | ): void => { 635 | if (!fiberIdMap.has(nextFiber)) { 636 | getFiberId(nextFiber); 637 | } 638 | if (!prevFiber) return; 639 | if (!fiberIdMap.has(prevFiber)) { 640 | getFiberId(prevFiber); 641 | } 642 | 643 | const isSuspense = nextFiber.tag === SuspenseComponentTag; 644 | 645 | const shouldIncludeInTree = !shouldFilterFiber(nextFiber); 646 | if (shouldIncludeInTree && didFiberRender(nextFiber)) { 647 | onRender(nextFiber, 'update'); 648 | } 649 | 650 | // The behavior of timed-out Suspense trees is unique. 651 | // Rather than unmount the timed out content (and possibly lose important state), 652 | // React re-parents this content within a hidden Fragment while the fallback is showing. 653 | // This behavior doesn't need to be observable in the DevTools though. 654 | // It might even result in a bad user experience for e.g. node selection in the Elements panel. 655 | // The easiest fix is to strip out the intermediate Fragment fibers, 656 | // so the Elements panel and Profiler don't need to special case them. 657 | // Suspense components only have a non-null memoizedState if they're timed-out. 658 | const prevDidTimeout = isSuspense && prevFiber.memoizedState !== null; 659 | const nextDidTimeOut = isSuspense && nextFiber.memoizedState !== null; 660 | 661 | // The logic below is inspired by the code paths in updateSuspenseComponent() 662 | // inside ReactFiberBeginWork in the React source code. 663 | if (prevDidTimeout && nextDidTimeOut) { 664 | // Fallback -> Fallback: 665 | // 1. Reconcile fallback set. 666 | const nextFallbackChildSet = nextFiber.child?.sibling ?? null; 667 | // Note: We can't use nextFiber.child.sibling.alternate 668 | // because the set is special and alternate may not exist. 669 | const prevFallbackChildSet = prevFiber.child?.sibling ?? null; 670 | 671 | if (nextFallbackChildSet !== null && prevFallbackChildSet !== null) { 672 | updateFiberRecursively( 673 | onRender, 674 | nextFallbackChildSet, 675 | prevFallbackChildSet, 676 | nextFiber, 677 | ); 678 | } 679 | } else if (prevDidTimeout && !nextDidTimeOut) { 680 | // Fallback -> Primary: 681 | // 1. Unmount fallback set 682 | // Note: don't emulate fallback unmount because React actually did it. 683 | // 2. Mount primary set 684 | const nextPrimaryChildSet = nextFiber.child; 685 | 686 | if (nextPrimaryChildSet !== null) { 687 | mountFiberRecursively(onRender, nextPrimaryChildSet, true); 688 | } 689 | } else if (!prevDidTimeout && nextDidTimeOut) { 690 | // Primary -> Fallback: 691 | // 1. Hide primary set 692 | // This is not a real unmount, so it won't get reported by React. 693 | // We need to manually walk the previous tree and record unmounts. 694 | unmountFiberChildrenRecursively(onRender, prevFiber); 695 | 696 | // 2. Mount fallback set 697 | const nextFallbackChildSet = nextFiber.child?.sibling ?? null; 698 | 699 | if (nextFallbackChildSet !== null) { 700 | mountFiberRecursively(onRender, nextFallbackChildSet, true); 701 | } 702 | } else if (nextFiber.child !== prevFiber.child) { 703 | // Common case: Primary -> Primary. 704 | // This is the same code path as for non-Suspense fibers. 705 | 706 | // If the first child is different, we need to traverse them. 707 | // Each next child will be either a new child (mount) or an alternate (update). 708 | let nextChild = nextFiber.child; 709 | 710 | while (nextChild) { 711 | // We already know children will be referentially different because 712 | // they are either new mounts or alternates of previous children. 713 | // Schedule updates and mounts depending on whether alternates exist. 714 | // We don't track deletions here because they are reported separately. 715 | if (nextChild.alternate) { 716 | const prevChild = nextChild.alternate; 717 | 718 | updateFiberRecursively( 719 | onRender, 720 | nextChild, 721 | prevChild, 722 | shouldIncludeInTree ? nextFiber : parentFiber, 723 | ); 724 | } else { 725 | mountFiberRecursively(onRender, nextChild, false); 726 | } 727 | 728 | // Try the next child. 729 | nextChild = nextChild.sibling; 730 | } 731 | } 732 | }; 733 | 734 | export const unmountFiber = (onRender: RenderHandler, fiber: Fiber): void => { 735 | const isRoot = fiber.tag === HostRootTag; 736 | 737 | if (isRoot || !shouldFilterFiber(fiber)) { 738 | onRender(fiber, 'unmount'); 739 | } 740 | }; 741 | 742 | export const unmountFiberChildrenRecursively = ( 743 | onRender: RenderHandler, 744 | fiber: Fiber, 745 | ): void => { 746 | // We might meet a nested Suspense on our way. 747 | const isTimedOutSuspense = 748 | fiber.tag === SuspenseComponentTag && fiber.memoizedState !== null; 749 | let child = fiber.child; 750 | 751 | if (isTimedOutSuspense) { 752 | // If it's showing fallback tree, let's traverse it instead. 753 | const primaryChildFragment = fiber.child; 754 | const fallbackChildFragment = primaryChildFragment?.sibling ?? null; 755 | 756 | // Skip over to the real Fiber child. 757 | child = fallbackChildFragment?.child ?? null; 758 | } 759 | 760 | while (child !== null) { 761 | // Record simulated unmounts children-first. 762 | // We skip nodes without return because those are real unmounts. 763 | if (child.return !== null) { 764 | unmountFiber(onRender, child); 765 | unmountFiberChildrenRecursively(onRender, child); 766 | } 767 | 768 | child = child.sibling; 769 | } 770 | }; 771 | 772 | let commitId = 0; 773 | const rootInstanceMap = new WeakMap< 774 | FiberRoot, 775 | { 776 | prevFiber: Fiber | null; 777 | id: number; 778 | } 779 | >(); 780 | 781 | /** 782 | * Creates a fiber visitor function. Must pass a fiber root and a render handler. 783 | * @example 784 | * traverseRenderedFibers(root, (fiber, phase) => { 785 | * console.log(phase) 786 | * }) 787 | */ 788 | export const traverseRenderedFibers = ( 789 | root: FiberRoot, 790 | onRender: RenderHandler, 791 | ): void => { 792 | const fiber = 'current' in root ? root.current : root; 793 | 794 | let rootInstance = rootInstanceMap.get(root); 795 | 796 | if (!rootInstance) { 797 | rootInstance = { prevFiber: null, id: commitId++ }; 798 | rootInstanceMap.set(root, rootInstance); 799 | } 800 | 801 | const { prevFiber } = rootInstance; 802 | // if fiberRoot don't have current instance, means it's been unmounted 803 | if (!fiber) { 804 | unmountFiber(onRender, fiber); 805 | } else if (prevFiber !== null) { 806 | const wasMounted = 807 | prevFiber && 808 | prevFiber.memoizedState != null && 809 | prevFiber.memoizedState.element != null && 810 | // A dehydrated root is not considered mounted 811 | prevFiber.memoizedState.isDehydrated !== true; 812 | const isMounted = 813 | fiber.memoizedState != null && 814 | fiber.memoizedState.element != null && 815 | // A dehydrated root is not considered mounted 816 | fiber.memoizedState.isDehydrated !== true; 817 | 818 | if (!wasMounted && isMounted) { 819 | mountFiberRecursively(onRender, fiber, false); 820 | } else if (wasMounted && isMounted) { 821 | updateFiberRecursively(onRender, fiber, fiber.alternate, null); 822 | } else if (wasMounted && !isMounted) { 823 | unmountFiber(onRender, fiber); 824 | } 825 | } else { 826 | mountFiberRecursively(onRender, fiber, true); 827 | } 828 | 829 | rootInstance.prevFiber = fiber; 830 | }; 831 | 832 | /** 833 | * @deprecated use `traverseRenderedFibers` instead 834 | */ 835 | export const createFiberVisitor = ({ 836 | onRender, 837 | }: { 838 | onRender: RenderHandler; 839 | onError: (error: unknown) => unknown; 840 | }): ((_rendererID: number, root: FiberRoot | Fiber, _state?: S) => void) => { 841 | return (_rendererID: number, root: FiberRoot | Fiber, _state?: S) => { 842 | traverseRenderedFibers(root, onRender); 843 | }; 844 | }; 845 | 846 | export interface InstrumentationOptions { 847 | onCommitFiberRoot?: ( 848 | rendererID: number, 849 | root: FiberRoot, 850 | // biome-ignore lint/suspicious/noConfusingVoidType: may be undefined 851 | priority: void | number, 852 | ) => unknown; 853 | onCommitFiberUnmount?: (rendererID: number, fiber: Fiber) => unknown; 854 | onPostCommitFiberRoot?: (rendererID: number, root: FiberRoot) => unknown; 855 | onActive?: () => unknown; 856 | name?: string; 857 | } 858 | 859 | /** 860 | * Instruments the DevTools hook. 861 | * @example 862 | * const hook = instrument({ 863 | * onActive() { 864 | * console.log('initialized'); 865 | * }, 866 | * onCommitFiberRoot(rendererID, root) { 867 | * console.log('fiberRoot', root.current) 868 | * }, 869 | * }); 870 | */ 871 | export const instrument = ( 872 | options: InstrumentationOptions, 873 | ): ReactDevToolsGlobalHook => { 874 | return getRDTHook(() => { 875 | const rdtHook = getRDTHook(); 876 | 877 | options.onActive?.(); 878 | 879 | rdtHook._instrumentationSource = 880 | options.name ?? BIPPY_INSTRUMENTATION_STRING; 881 | 882 | const prevOnCommitFiberRoot = rdtHook.onCommitFiberRoot; 883 | if (options.onCommitFiberRoot) { 884 | rdtHook.onCommitFiberRoot = ( 885 | rendererID: number, 886 | root: FiberRoot, 887 | // biome-ignore lint/suspicious/noConfusingVoidType: may be undefined 888 | priority: void | number, 889 | ) => { 890 | if (prevOnCommitFiberRoot) 891 | prevOnCommitFiberRoot(rendererID, root, priority); 892 | options.onCommitFiberRoot?.(rendererID, root, priority); 893 | }; 894 | } 895 | 896 | const prevOnCommitFiberUnmount = rdtHook.onCommitFiberUnmount; 897 | if (options.onCommitFiberUnmount) { 898 | rdtHook.onCommitFiberUnmount = (rendererID: number, root: FiberRoot) => { 899 | if (prevOnCommitFiberUnmount) 900 | prevOnCommitFiberUnmount(rendererID, root); 901 | options.onCommitFiberUnmount?.(rendererID, root); 902 | }; 903 | } 904 | 905 | const prevOnPostCommitFiberRoot = rdtHook.onPostCommitFiberRoot; 906 | if (options.onPostCommitFiberRoot) { 907 | rdtHook.onPostCommitFiberRoot = (rendererID: number, root: FiberRoot) => { 908 | if (prevOnPostCommitFiberRoot) 909 | prevOnPostCommitFiberRoot(rendererID, root); 910 | options.onPostCommitFiberRoot?.(rendererID, root); 911 | }; 912 | } 913 | }); 914 | }; 915 | 916 | export const getFiberFromHostInstance = (hostInstance: T): Fiber | null => { 917 | const rdtHook = getRDTHook(); 918 | for (const renderer of rdtHook.renderers.values()) { 919 | try { 920 | const fiber = renderer.findFiberByHostInstance?.(hostInstance); 921 | if (fiber) return fiber; 922 | } catch {} 923 | } 924 | 925 | if (typeof hostInstance === 'object' && hostInstance != null) { 926 | if ('_reactRootContainer' in hostInstance) { 927 | // biome-ignore lint/suspicious/noExplicitAny: OK 928 | return (hostInstance._reactRootContainer as any)?._internalRoot?.current 929 | ?.child; 930 | } 931 | 932 | for (const key in hostInstance) { 933 | if ( 934 | key.startsWith('__reactInternalInstance$') || 935 | key.startsWith('__reactFiber') 936 | ) { 937 | return (hostInstance[key] || null) as Fiber | null; 938 | } 939 | } 940 | } 941 | return null; 942 | }; 943 | 944 | export const INSTALL_ERROR = new Error(); 945 | 946 | export const _fiberRoots = new Set(); 947 | 948 | export const secure = ( 949 | options: InstrumentationOptions, 950 | secureOptions: { 951 | minReactMajorVersion?: number; 952 | dangerouslyRunInProduction?: boolean; 953 | onError?: (error?: unknown) => unknown; 954 | installCheckTimeout?: number; 955 | isProduction?: boolean; 956 | } = {}, 957 | ): InstrumentationOptions => { 958 | const onActive = options.onActive; 959 | const isRDTHookInstalled = hasRDTHook(); 960 | const isUsingRealReactDevtools = isRealReactDevtools(); 961 | const isUsingReactRefresh = isReactRefresh(); 962 | let timeout: number | undefined; 963 | let isProduction = secureOptions.isProduction ?? false; 964 | 965 | options.onActive = () => { 966 | clearTimeout(timeout); 967 | let isSecure = true; 968 | try { 969 | const rdtHook = getRDTHook(); 970 | 971 | for (const renderer of rdtHook.renderers.values()) { 972 | const [majorVersion] = renderer.version.split('.'); 973 | if (Number(majorVersion) < (secureOptions.minReactMajorVersion ?? 17)) { 974 | isSecure = false; 975 | } 976 | const buildType = detectReactBuildType(renderer); 977 | if (buildType !== 'development') { 978 | isProduction = true; 979 | if (!secureOptions.dangerouslyRunInProduction) { 980 | isSecure = false; 981 | } 982 | } 983 | } 984 | } catch (err) { 985 | secureOptions.onError?.(err); 986 | } 987 | 988 | if (!isSecure) { 989 | options.onCommitFiberRoot = undefined; 990 | options.onCommitFiberUnmount = undefined; 991 | options.onPostCommitFiberRoot = undefined; 992 | options.onActive = undefined; 993 | return; 994 | } 995 | onActive?.(); 996 | 997 | try { 998 | const onCommitFiberRoot = options.onCommitFiberRoot; 999 | if (onCommitFiberRoot) { 1000 | options.onCommitFiberRoot = (rendererID, root, priority) => { 1001 | if (!_fiberRoots.has(root)) { 1002 | _fiberRoots.add(root); 1003 | } 1004 | try { 1005 | onCommitFiberRoot(rendererID, root, priority); 1006 | } catch (err) { 1007 | secureOptions.onError?.(err); 1008 | } 1009 | }; 1010 | } 1011 | 1012 | const onCommitFiberUnmount = options.onCommitFiberUnmount; 1013 | if (onCommitFiberUnmount) { 1014 | options.onCommitFiberUnmount = (rendererID, root) => { 1015 | try { 1016 | onCommitFiberUnmount(rendererID, root); 1017 | } catch (err) { 1018 | secureOptions.onError?.(err); 1019 | } 1020 | }; 1021 | } 1022 | 1023 | const onPostCommitFiberRoot = options.onPostCommitFiberRoot; 1024 | if (onPostCommitFiberRoot) { 1025 | options.onPostCommitFiberRoot = (rendererID, root) => { 1026 | try { 1027 | onPostCommitFiberRoot(rendererID, root); 1028 | } catch (err) { 1029 | secureOptions.onError?.(err); 1030 | } 1031 | }; 1032 | } 1033 | } catch (err) { 1034 | secureOptions.onError?.(err); 1035 | } 1036 | }; 1037 | 1038 | if ( 1039 | !isRDTHookInstalled && 1040 | !isUsingRealReactDevtools && 1041 | !isUsingReactRefresh 1042 | ) { 1043 | timeout = setTimeout(() => { 1044 | if (!isProduction) { 1045 | secureOptions.onError?.(INSTALL_ERROR); 1046 | } 1047 | stop(); 1048 | }, secureOptions.installCheckTimeout ?? 100) as unknown as number; 1049 | } 1050 | 1051 | return options; 1052 | }; 1053 | 1054 | /** 1055 | * a wrapper around the {@link instrument} function that sets the `onCommitFiberRoot` hook. 1056 | * 1057 | * @example 1058 | * onCommitFiberRoot((root) => { 1059 | * console.log(root.current); 1060 | * }); 1061 | */ 1062 | export const onCommitFiberRoot = ( 1063 | handler: (root: FiberRoot) => void, 1064 | ): ReactDevToolsGlobalHook => { 1065 | return instrument( 1066 | secure({ 1067 | onCommitFiberRoot: (_, root) => { 1068 | handler(root); 1069 | }, 1070 | }), 1071 | ); 1072 | }; 1073 | 1074 | export * from './install-hook-script-string.js'; 1075 | export * from './rdt-hook.js'; 1076 | export type * from './types.js'; 1077 | -------------------------------------------------------------------------------- /packages/bippy/src/experiments/inspect.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | detectReactBuildType, 3 | type Fiber, 4 | getDisplayName, 5 | getFiberFromHostInstance, 6 | getLatestFiber, 7 | getRDTHook, 8 | hasRDTHook, 9 | isInstrumentationActive, 10 | } from '../index.js'; 11 | import { getFiberSource, type FiberSource } from '../source.js'; 12 | // biome-ignore lint/style/useImportType: needed for jsx 13 | import React, { 14 | useEffect, 15 | useState, 16 | useImperativeHandle as useImperativeHandleOriginal, 17 | forwardRef, 18 | useMemo, 19 | } from 'react'; 20 | import ReactDOM from 'react-dom'; 21 | import { Inspector as ReactInspector } from 'react-inspector'; 22 | 23 | const useImperativeHandlePolyfill = ( 24 | ref: React.RefCallback | React.RefObject, 25 | init: () => unknown, 26 | deps: React.DependencyList, 27 | ) => { 28 | // biome-ignore lint/correctness/useExhaustiveDependencies: biome is wrong 29 | useEffect(() => { 30 | if (ref) { 31 | if (typeof ref === 'function') { 32 | ref(init()); 33 | } else if (typeof ref === 'object' && 'current' in ref) { 34 | ref.current = init(); 35 | } 36 | } 37 | }, deps); 38 | }; 39 | 40 | const useImperativeHandle = 41 | useImperativeHandleOriginal || useImperativeHandlePolyfill; 42 | 43 | // biome-ignore lint/suspicious/noExplicitAny: OK 44 | const throttle = (fn: (...args: any[]) => void, wait: number) => { 45 | let timeout: ReturnType | null = null; 46 | return function (this: unknown) { 47 | if (!timeout) { 48 | timeout = setTimeout(() => { 49 | // biome-ignore lint/style/noArguments: perf 50 | fn.apply(this, arguments as unknown as unknown[]); 51 | timeout = null; 52 | }, wait); 53 | } 54 | }; 55 | }; 56 | 57 | // biome-ignore lint/suspicious/noExplicitAny: react-inspector types are wrong 58 | const theme: any = { 59 | BASE_FONT_FAMILY: 'Menlo, monospace', 60 | BASE_FONT_SIZE: '12px', 61 | BASE_LINE_HEIGHT: 1.2, 62 | 63 | BASE_BACKGROUND_COLOR: 'none', 64 | BASE_COLOR: '#FFF', 65 | 66 | OBJECT_PREVIEW_ARRAY_MAX_PROPERTIES: 10, 67 | OBJECT_PREVIEW_OBJECT_MAX_PROPERTIES: 5, 68 | OBJECT_NAME_COLOR: '#FFC799', 69 | OBJECT_VALUE_NULL_COLOR: '#A0A0A0', 70 | OBJECT_VALUE_UNDEFINED_COLOR: '#A0A0A0', 71 | OBJECT_VALUE_REGEXP_COLOR: '#FF8080', 72 | OBJECT_VALUE_STRING_COLOR: '#99FFE4', 73 | OBJECT_VALUE_SYMBOL_COLOR: '#FFC799', 74 | OBJECT_VALUE_NUMBER_COLOR: '#FFC799', 75 | OBJECT_VALUE_BOOLEAN_COLOR: '#FFC799', 76 | OBJECT_VALUE_FUNCTION_PREFIX_COLOR: '#FFC799', 77 | 78 | HTML_TAG_COLOR: '#FFC799', 79 | HTML_TAGNAME_COLOR: '#FFC799', 80 | HTML_TAGNAME_TEXT_TRANSFORM: 'lowercase', 81 | HTML_ATTRIBUTE_NAME_COLOR: '#A0A0A0', 82 | HTML_ATTRIBUTE_VALUE_COLOR: '#99FFE4', 83 | HTML_COMMENT_COLOR: '#8b8b8b94', 84 | HTML_DOCTYPE_COLOR: '#A0A0A0', 85 | 86 | ARROW_COLOR: '#A0A0A0', 87 | ARROW_MARGIN_RIGHT: 3, 88 | ARROW_FONT_SIZE: 12, 89 | ARROW_ANIMATION_DURATION: '0', 90 | 91 | TREENODE_FONT_FAMILY: 'Menlo, monospace', 92 | TREENODE_FONT_SIZE: '11px', 93 | TREENODE_LINE_HEIGHT: 1.2, 94 | TREENODE_PADDING_LEFT: 12, 95 | 96 | TABLE_BORDER_COLOR: '#282828', 97 | TABLE_TH_BACKGROUND_COLOR: '#161616', 98 | TABLE_TH_HOVER_COLOR: '#232323', 99 | TABLE_SORT_ICON_COLOR: '#A0A0A0', 100 | TABLE_DATA_BACKGROUND_IMAGE: 'none', 101 | TABLE_DATA_BACKGROUND_SIZE: '0', 102 | }; 103 | 104 | export interface InspectorProps { 105 | enabled?: boolean; 106 | children?: React.ReactNode; 107 | dangerouslyRunInProduction?: boolean; 108 | } 109 | 110 | export interface InspectorHandle { 111 | enable: () => void; 112 | disable: () => void; 113 | inspectElement: (element: Element) => void; 114 | } 115 | 116 | export const RawInspector = forwardRef( 117 | ( 118 | { enabled = true, dangerouslyRunInProduction = false }: InspectorProps, 119 | ref, 120 | ) => { 121 | const [element, setElement] = useState(null); 122 | const [currentFiber, setCurrentFiber] = useState(null); 123 | const [currentFiberSource, setCurrentFiberSource] = 124 | useState(null); 125 | const [rect, setRect] = useState(null); 126 | const [isActive, setIsActive] = useState(true); 127 | const [isEnabled, setIsEnabled] = useState(enabled); 128 | const [position, setPosition] = useState({ top: 0, left: 0 }); 129 | 130 | const currentCleanedFiber = useMemo(() => { 131 | if (!currentFiber) return null; 132 | const clonedFiber = { ...currentFiber }; 133 | for (const key in clonedFiber) { 134 | const value = clonedFiber[key as keyof Fiber]; 135 | if (!value) delete clonedFiber[key as keyof Fiber]; 136 | } 137 | return clonedFiber; 138 | }, [currentFiber]); 139 | 140 | useImperativeHandle(ref, () => ({ 141 | enable: () => setIsEnabled(true), 142 | disable: () => { 143 | setIsEnabled(false); 144 | setElement(null); 145 | setRect(null); 146 | }, 147 | inspectElement: (element: Element) => { 148 | if (!isEnabled) return; 149 | setElement(element); 150 | setRect(element.getBoundingClientRect()); 151 | }, 152 | })); 153 | 154 | useEffect(() => { 155 | (async () => { 156 | if (!element) return; 157 | const fiber = getFiberFromHostInstance(element); 158 | if (!fiber) return; 159 | const latestFiber = getLatestFiber(fiber); 160 | const source = await getFiberSource(latestFiber); 161 | setCurrentFiber(latestFiber); 162 | if (source) { 163 | setCurrentFiberSource(source); 164 | } 165 | })(); 166 | }, [element]); 167 | 168 | useEffect(() => { 169 | const handleMouseMove = (event: globalThis.MouseEvent) => { 170 | const isActive = isInstrumentationActive() || hasRDTHook(); 171 | if (!isActive) { 172 | setIsActive(false); 173 | return; 174 | } 175 | 176 | if (!dangerouslyRunInProduction) { 177 | const rdtHook = getRDTHook(); 178 | for (const renderer of rdtHook.renderers.values()) { 179 | const buildType = detectReactBuildType(renderer); 180 | if (buildType === 'production') { 181 | setIsActive(false); 182 | return; 183 | } 184 | } 185 | } 186 | 187 | if (!isEnabled) { 188 | setElement(null); 189 | setRect(null); 190 | return; 191 | } 192 | 193 | const element = document.elementFromPoint(event.clientX, event.clientY); 194 | if (!element) return; 195 | setElement(element); 196 | setRect(element.getBoundingClientRect()); 197 | }; 198 | 199 | const throttledMouseMove = throttle(handleMouseMove, 16); 200 | document.addEventListener('mousemove', throttledMouseMove); 201 | return () => 202 | document.removeEventListener('mousemove', throttledMouseMove); 203 | }, [isEnabled, dangerouslyRunInProduction]); 204 | 205 | useEffect(() => { 206 | if (!rect) return; 207 | 208 | const padding = 10; 209 | const inspectorWidth = 400; 210 | const inspectorHeight = 320; 211 | 212 | let left = rect.left + rect.width + padding; 213 | let top = rect.top; 214 | 215 | if (left + inspectorWidth > window.innerWidth) { 216 | left = Math.max(padding, rect.left - inspectorWidth - padding); 217 | } 218 | 219 | if (top >= rect.top && top <= rect.bottom) { 220 | if (rect.bottom + inspectorHeight + padding <= window.innerHeight) { 221 | top = rect.bottom + padding; 222 | } else if (rect.top - inspectorHeight - padding >= 0) { 223 | top = rect.top - inspectorHeight - padding; 224 | } else { 225 | top = window.innerHeight - inspectorHeight - padding; 226 | } 227 | } 228 | 229 | top = Math.max( 230 | padding, 231 | Math.min(top, window.innerHeight - inspectorHeight - padding), 232 | ); 233 | left = Math.max( 234 | padding, 235 | Math.min(left, window.innerWidth - inspectorWidth - padding), 236 | ); 237 | 238 | setPosition({ top, left }); 239 | }, [rect]); 240 | 241 | if (!rect || !isActive || !isEnabled) return null; 242 | 243 | if (!currentFiber) return null; 244 | 245 | return ( 246 | <> 247 |
269 | {currentFiber && ( 270 | 276 | )} 277 | 278 |
295 |
304 | {`<${getDisplayName(currentFiber.type) || 'unknown'}>`} 305 |
306 |
312 | {currentFiberSource ? ( 313 | <> 314 | {currentFiberSource.fileName.split('/').slice(-2).join('/')}{' '} 315 |
@ line {currentFiberSource.lineNumber}, column{' '} 316 | {currentFiberSource.columnNumber} 317 | 318 | ) : null} 319 |
320 |
321 |
322 | 338 |
352 | 353 | ); 354 | }, 355 | ); 356 | 357 | export const Inspector = forwardRef( 358 | (props, ref) => { 359 | const [root, setRoot] = useState(null); 360 | 361 | useEffect(() => { 362 | const div = document.createElement('div'); 363 | document.documentElement.appendChild(div); 364 | const shadowRoot = div.attachShadow({ mode: 'open' }); 365 | setRoot(shadowRoot); 366 | 367 | return () => { 368 | document.documentElement.removeChild(div); 369 | }; 370 | }, []); 371 | 372 | if (!root) return null; 373 | 374 | return ReactDOM.createPortal(, root); 375 | }, 376 | ); 377 | 378 | export default Inspector; 379 | -------------------------------------------------------------------------------- /packages/bippy/src/index.ts: -------------------------------------------------------------------------------- 1 | import { safelyInstallRDTHook } from './rdt-hook.js'; 2 | 3 | safelyInstallRDTHook(); 4 | 5 | export * from './core.js'; 6 | -------------------------------------------------------------------------------- /packages/bippy/src/install-hook-script-string.ts: -------------------------------------------------------------------------------- 1 | export const INSTALL_HOOK_SCRIPT_STRING = 2 | '(()=>{try{var t=()=>{};const n=new Map;let o=0;globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__={checkDCE:t,supportsFiber:!0,supportsFlight:!0,hasUnsupportedRendererAttached:!1,renderers:n,onCommitFiberRoot:t,onCommitFiberUnmount:t,onPostCommitFiberRoot:t,inject(t){var e=++o;return n.set(e,t),globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__._instrumentationIsActive=!0,e},_instrumentationIsActive:!1,_script:!0}}catch{}})()'; 3 | -------------------------------------------------------------------------------- /packages/bippy/src/jsx-dev-runtime.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Fragment, 3 | jsxDEV as jsxDEVImpl, 4 | type JSXSource, 5 | } from 'react/jsx-dev-runtime'; 6 | 7 | export * from 'react/jsx-dev-runtime'; 8 | 9 | export { Fragment }; 10 | 11 | export const jsxDEV = ( 12 | type: React.ElementType, 13 | originalProps: unknown, 14 | key: React.Key | undefined, 15 | isStatic: boolean, 16 | source?: JSXSource, 17 | self?: unknown 18 | ) => { 19 | let props = originalProps; 20 | try { 21 | if ( 22 | originalProps && 23 | typeof originalProps === 'object' && 24 | source && 25 | String(type) !== 'Symbol(react.fragment)' 26 | ) { 27 | props = { 28 | ...originalProps, 29 | _source: `${source.fileName}:${source.lineNumber}:${source.columnNumber}`, 30 | }; 31 | } 32 | } catch {} 33 | return jsxDEVImpl(type, props, key, isStatic, source, self); 34 | }; 35 | -------------------------------------------------------------------------------- /packages/bippy/src/jsx-runtime.ts: -------------------------------------------------------------------------------- 1 | import './index.js'; 2 | export * from 'react/jsx-runtime'; 3 | -------------------------------------------------------------------------------- /packages/bippy/src/rdt-hook.ts: -------------------------------------------------------------------------------- 1 | import type { ReactDevToolsGlobalHook, ReactRenderer } from './types.js'; 2 | 3 | export const version = process.env.VERSION; 4 | export const BIPPY_INSTRUMENTATION_STRING = `bippy-${version}`; 5 | 6 | const objectDefineProperty = Object.defineProperty; 7 | const objectHasOwnProperty = Object.prototype.hasOwnProperty; 8 | 9 | const NO_OP = () => { 10 | /**/ 11 | }; 12 | 13 | const checkDCE = (fn: unknown): void => { 14 | try { 15 | const code = Function.prototype.toString.call(fn); 16 | if (code.indexOf('^_^') > -1) { 17 | setTimeout(() => { 18 | throw new Error( 19 | 'React is running in production mode, but dead code ' + 20 | 'elimination has not been applied. Read how to correctly ' + 21 | 'configure React for production: ' + 22 | 'https://reactjs.org/link/perf-use-production-build', 23 | ); 24 | }); 25 | } 26 | } catch {} 27 | }; 28 | 29 | export const isRealReactDevtools = (rdtHook = getRDTHook()): boolean => { 30 | return 'getFiberRoots' in rdtHook; 31 | }; 32 | 33 | let isReactRefreshOverride = false; 34 | let injectFnStr: string | undefined = undefined; 35 | 36 | export const isReactRefresh = (rdtHook = getRDTHook()): boolean => { 37 | if (isReactRefreshOverride) return true; 38 | if (typeof rdtHook.inject === 'function') { 39 | injectFnStr = rdtHook.inject.toString(); 40 | } 41 | return Boolean(injectFnStr?.includes('(injected)')); 42 | }; 43 | 44 | const onActiveListeners = new Set<() => unknown>(); 45 | 46 | export const _renderers = new Set(); 47 | 48 | export const installRDTHook = ( 49 | onActive?: () => unknown, 50 | ): ReactDevToolsGlobalHook => { 51 | const renderers = new Map(); 52 | let i = 0; 53 | let rdtHook: ReactDevToolsGlobalHook = { 54 | checkDCE, 55 | supportsFiber: true, 56 | supportsFlight: true, 57 | hasUnsupportedRendererAttached: false, 58 | renderers, 59 | onCommitFiberRoot: NO_OP, 60 | onCommitFiberUnmount: NO_OP, 61 | onPostCommitFiberRoot: NO_OP, 62 | inject(renderer) { 63 | const nextID = ++i; 64 | renderers.set(nextID, renderer); 65 | _renderers.add(renderer); 66 | if (!rdtHook._instrumentationIsActive) { 67 | rdtHook._instrumentationIsActive = true; 68 | // biome-ignore lint/complexity/noForEach: prefer forEach for Set 69 | onActiveListeners.forEach((listener) => listener()); 70 | } 71 | return nextID; 72 | }, 73 | _instrumentationSource: BIPPY_INSTRUMENTATION_STRING, 74 | _instrumentationIsActive: false, 75 | }; 76 | try { 77 | objectDefineProperty(globalThis, '__REACT_DEVTOOLS_GLOBAL_HOOK__', { 78 | get() { 79 | return rdtHook; 80 | }, 81 | set(newHook) { 82 | if (newHook && typeof newHook === 'object') { 83 | const ourRenderers = rdtHook.renderers; 84 | rdtHook = newHook; 85 | if (ourRenderers.size > 0) { 86 | ourRenderers.forEach((renderer, id) => { 87 | _renderers.add(renderer); 88 | newHook.renderers.set(id, renderer); 89 | }); 90 | patchRDTHook(onActive); 91 | } 92 | } 93 | }, 94 | configurable: true, 95 | enumerable: true, 96 | }); 97 | // [!] this is a hack for chrome extensions - if we install before React DevTools, we could accidently prevent React DevTools from installing: 98 | // https://github.com/facebook/react/blob/18eaf51bd51fed8dfed661d64c306759101d0bfd/packages/react-devtools-extensions/src/contentScripts/installHook.js#L30C6-L30C27 99 | const originalWindowHasOwnProperty = window.hasOwnProperty; 100 | let hasRanHack = false; 101 | objectDefineProperty(window, 'hasOwnProperty', { 102 | value: function (this: unknown) { 103 | try { 104 | if ( 105 | !hasRanHack && 106 | // biome-ignore lint/style/noArguments: perf 107 | arguments[0] === '__REACT_DEVTOOLS_GLOBAL_HOOK__' 108 | ) { 109 | globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = undefined; 110 | // special falsy value to know that we've already installed before 111 | hasRanHack = true; 112 | return -0; 113 | } 114 | } catch {} 115 | // biome-ignore lint/suspicious/noExplicitAny: perf 116 | // biome-ignore lint/style/noArguments: perf 117 | return originalWindowHasOwnProperty.apply(this, arguments as any); 118 | }, 119 | configurable: true, 120 | writable: true, 121 | }); 122 | } catch { 123 | patchRDTHook(onActive); 124 | } 125 | return rdtHook; 126 | }; 127 | 128 | export const patchRDTHook = (onActive?: () => unknown): void => { 129 | if (onActive) { 130 | onActiveListeners.add(onActive); 131 | } 132 | try { 133 | const rdtHook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; 134 | if (!rdtHook) return; 135 | if (!rdtHook._instrumentationSource) { 136 | rdtHook.checkDCE = checkDCE; 137 | rdtHook.supportsFiber = true; 138 | rdtHook.supportsFlight = true; 139 | rdtHook.hasUnsupportedRendererAttached = false; 140 | rdtHook._instrumentationSource = BIPPY_INSTRUMENTATION_STRING; 141 | rdtHook._instrumentationIsActive = false; 142 | if (rdtHook.renderers.size) { 143 | rdtHook._instrumentationIsActive = true; 144 | // biome-ignore lint/complexity/noForEach: prefer forEach for Set 145 | onActiveListeners.forEach((listener) => listener()); 146 | return; 147 | } 148 | const prevInject = rdtHook.inject; 149 | if (isReactRefresh(rdtHook) && !isRealReactDevtools()) { 150 | isReactRefreshOverride = true; 151 | // but since the underlying implementation doens't care, 152 | // it's ok: https://github.com/facebook/react/blob/18eaf51bd51fed8dfed661d64c306759101d0bfd/packages/react-refresh/src/ReactFreshRuntime.js#L430 153 | const nextID = rdtHook.inject({ 154 | // @ts-expect-error this is not actually a ReactRenderer, 155 | scheduleRefresh() {}, 156 | }); 157 | if (nextID) { 158 | rdtHook._instrumentationIsActive = true; 159 | } 160 | } 161 | rdtHook.inject = (renderer) => { 162 | const id = prevInject(renderer); 163 | _renderers.add(renderer); 164 | rdtHook._instrumentationIsActive = true; 165 | // biome-ignore lint/complexity/noForEach: prefer forEach for Set 166 | onActiveListeners.forEach((listener) => listener()); 167 | return id; 168 | }; 169 | } 170 | if ( 171 | rdtHook.renderers.size || 172 | rdtHook._instrumentationIsActive || 173 | // depending on this to inject is unsafe, since inject could occur before and we wouldn't know 174 | isReactRefresh() 175 | ) { 176 | onActive?.(); 177 | } 178 | } catch {} 179 | }; 180 | 181 | export const hasRDTHook = (): boolean => { 182 | return objectHasOwnProperty.call( 183 | globalThis, 184 | '__REACT_DEVTOOLS_GLOBAL_HOOK__', 185 | ); 186 | }; 187 | 188 | /** 189 | * Returns the current React DevTools global hook. 190 | */ 191 | export const getRDTHook = ( 192 | onActive?: () => unknown, 193 | ): ReactDevToolsGlobalHook => { 194 | if (!hasRDTHook()) { 195 | return installRDTHook(onActive); 196 | } 197 | patchRDTHook(onActive); 198 | // must exist at this point 199 | return globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ as ReactDevToolsGlobalHook; 200 | }; 201 | 202 | export const isClientEnvironment = (): boolean => { 203 | return Boolean( 204 | typeof window !== 'undefined' && 205 | (window.document?.createElement || 206 | window.navigator?.product === 'ReactNative'), 207 | ); 208 | }; 209 | 210 | /** 211 | * Usually used purely for side effect 212 | */ 213 | export const safelyInstallRDTHook = () => { 214 | try { 215 | // __REACT_DEVTOOLS_GLOBAL_HOOK__ must exist before React is ever executed 216 | if (isClientEnvironment()) { 217 | getRDTHook(); 218 | } 219 | } catch {} 220 | }; 221 | -------------------------------------------------------------------------------- /packages/bippy/src/source.ts: -------------------------------------------------------------------------------- 1 | import { parseStack } from 'error-stack-parser-es/lite'; 2 | import { type RawSourceMap, SourceMapConsumer } from 'source-map-js'; 3 | import type { Fiber } from './types.js'; 4 | import { 5 | ClassComponentTag, 6 | getType, 7 | isCompositeFiber, 8 | isHostFiber, 9 | traverseFiber, 10 | getDisplayName, 11 | _renderers, 12 | getRDTHook, 13 | } from './index.js'; 14 | 15 | export interface FiberSource { 16 | fileName: string; 17 | lineNumber: number; 18 | columnNumber: number; 19 | } 20 | 21 | let reentry = false; 22 | 23 | const describeBuiltInComponentFrame = (name: string): string => { 24 | return `\n in ${name}`; 25 | }; 26 | 27 | const disableLogs = () => { 28 | const prev = { 29 | error: console.error, 30 | warn: console.warn, 31 | }; 32 | console.error = () => {}; 33 | console.warn = () => {}; 34 | return prev; 35 | }; 36 | 37 | const reenableLogs = (prev: { 38 | error: typeof console.error; 39 | warn: typeof console.warn; 40 | }) => { 41 | console.error = prev.error; 42 | console.warn = prev.warn; 43 | }; 44 | 45 | const INLINE_SOURCEMAP_REGEX = /^data:application\/json[^,]+base64,/; 46 | const SOURCEMAP_REGEX = 47 | /(?:\/\/[@#][ \t]+sourceMappingURL=([^\s'"]+?)[ \t]*$)|(?:\/\*[@#][ \t]+sourceMappingURL=([^*]+?)[ \t]*(?:\*\/)[ \t]*$)/; 48 | 49 | const getSourceMap = async (url: string, content: string) => { 50 | const lines = content.split('\n'); 51 | let sourceMapUrl: string | undefined; 52 | for (let i = lines.length - 1; i >= 0 && !sourceMapUrl; i--) { 53 | const result = lines[i].match(SOURCEMAP_REGEX); 54 | if (result) { 55 | sourceMapUrl = result[1]; 56 | } 57 | } 58 | 59 | if (!sourceMapUrl) { 60 | return null; 61 | } 62 | 63 | if ( 64 | !(INLINE_SOURCEMAP_REGEX.test(sourceMapUrl) || sourceMapUrl.startsWith('/')) 65 | ) { 66 | const parsedURL = url.split('/'); 67 | parsedURL[parsedURL.length - 1] = sourceMapUrl; 68 | sourceMapUrl = parsedURL.join('/'); 69 | } 70 | const response = await fetch(sourceMapUrl); 71 | const rawSourceMap: RawSourceMap = await response.json(); 72 | 73 | return new SourceMapConsumer(rawSourceMap); 74 | }; 75 | 76 | const getRemovedFileProtocolPath = (path: string): string => { 77 | const protocol = 'file://'; 78 | if (path.startsWith(protocol)) { 79 | return path.substring(protocol.length); 80 | } 81 | return path; 82 | }; 83 | 84 | // const getActualFileSource = (path: string): string => { 85 | // if (path.startsWith('file://')) { 86 | // return `/_build/@fs${path.substring('file://'.length)}`; 87 | // } 88 | // return path; 89 | // }; 90 | 91 | const parseStackFrame = async (frame: string): Promise => { 92 | const source = parseStack(frame); 93 | 94 | if (!source.length) { 95 | return null; 96 | } 97 | 98 | const { file: fileName, line: lineNumber, col: columnNumber = 0 } = source[0]; 99 | 100 | if (!fileName || !lineNumber) { 101 | return null; 102 | } 103 | 104 | try { 105 | const response = await fetch(fileName); 106 | if (response.ok) { 107 | const content = await response.text(); 108 | const sourcemap = await getSourceMap(fileName, content); 109 | 110 | if (sourcemap) { 111 | const result = sourcemap.originalPositionFor({ 112 | line: lineNumber, 113 | column: columnNumber, 114 | }); 115 | 116 | return { 117 | fileName: getRemovedFileProtocolPath(sourcemap.file || result.source), 118 | lineNumber: result.line, 119 | columnNumber: result.column, 120 | }; 121 | } 122 | } 123 | } catch {} 124 | return { 125 | fileName: getRemovedFileProtocolPath(fileName), 126 | lineNumber, 127 | columnNumber, 128 | }; 129 | }; 130 | 131 | // https://github.com/facebook/react/blob/f739642745577a8e4dcb9753836ac3589b9c590a/packages/react-devtools-shared/src/backend/shared/DevToolsComponentStackFrame.js#L22 132 | const describeNativeComponentFrame = ( 133 | fn: React.ComponentType, 134 | construct: boolean 135 | ): string => { 136 | if (!fn || reentry) { 137 | return ''; 138 | } 139 | 140 | const previousPrepareStackTrace = Error.prepareStackTrace; 141 | Error.prepareStackTrace = undefined; 142 | reentry = true; 143 | 144 | const previousDispatcher = getCurrentDispatcher(); 145 | setCurrentDispatcher(null); 146 | const prevLogs = disableLogs(); 147 | try { 148 | /** 149 | * Finding a common stack frame between sample and control errors can be 150 | * tricky given the different types and levels of stack trace truncation from 151 | * different JS VMs. So instead we'll attempt to control what that common 152 | * frame should be through this object method: 153 | * Having both the sample and control errors be in the function under the 154 | * `DescribeNativeComponentFrameRoot` property, + setting the `name` and 155 | * `displayName` properties of the function ensures that a stack 156 | * frame exists that has the method name `DescribeNativeComponentFrameRoot` in 157 | * it for both control and sample stacks. 158 | */ 159 | const RunInRootFrame = { 160 | DetermineComponentFrameRoot() { 161 | // biome-ignore lint/suspicious/noExplicitAny: OK 162 | let control: any; 163 | try { 164 | // This should throw. 165 | if (construct) { 166 | // Something should be setting the props in the constructor. 167 | // biome-ignore lint/complexity/useArrowFunction: OK 168 | const Fake = function () { 169 | throw Error(); 170 | }; 171 | // $FlowFixMe[prop-missing] 172 | Object.defineProperty(Fake.prototype, 'props', { 173 | // biome-ignore lint/complexity/useArrowFunction: OK 174 | set: function () { 175 | // We use a throwing setter instead of frozen or non-writable props 176 | // because that won't throw in a non-strict mode function. 177 | throw Error(); 178 | }, 179 | }); 180 | if (typeof Reflect === 'object' && Reflect.construct) { 181 | // We construct a different control for this case to include any extra 182 | // frames added by the construct call. 183 | try { 184 | Reflect.construct(Fake, []); 185 | } catch (x) { 186 | control = x; 187 | } 188 | Reflect.construct(fn, [], Fake); 189 | } else { 190 | try { 191 | // @ts-expect-error 192 | Fake.call(); 193 | } catch (x) { 194 | control = x; 195 | } 196 | // @ts-expect-error 197 | fn.call(Fake.prototype); 198 | } 199 | } else { 200 | try { 201 | throw Error(); 202 | } catch (x) { 203 | control = x; 204 | } 205 | // TODO(luna): This will currently only throw if the function component 206 | // tries to access React/ReactDOM/props. We should probably make this throw 207 | // in simple components too 208 | // @ts-expect-error 209 | const maybePromise = fn(); 210 | 211 | // If the function component returns a promise, it's likely an async 212 | // component, which we don't yet support. Attach a noop catch handler to 213 | // silence the error. 214 | // TODO: Implement component stacks for async client components? 215 | if (maybePromise && typeof maybePromise.catch === 'function') { 216 | maybePromise.catch(() => {}); 217 | } 218 | } 219 | // biome-ignore lint/suspicious/noExplicitAny: OK 220 | } catch (sample: any) { 221 | // This is inlined manually because closure doesn't do it for us. 222 | if (sample && control && typeof sample.stack === 'string') { 223 | return [sample.stack, control.stack]; 224 | } 225 | } 226 | return [null, null]; 227 | }, 228 | }; 229 | 230 | // @ts-expect-error 231 | RunInRootFrame.DetermineComponentFrameRoot.displayName = 232 | 'DetermineComponentFrameRoot'; 233 | const namePropDescriptor = Object.getOwnPropertyDescriptor( 234 | RunInRootFrame.DetermineComponentFrameRoot, 235 | 'name' 236 | ); 237 | // Before ES6, the `name` property was not configurable. 238 | if (namePropDescriptor?.configurable) { 239 | // V8 utilizes a function's `name` property when generating a stack trace. 240 | Object.defineProperty( 241 | RunInRootFrame.DetermineComponentFrameRoot, 242 | // Configurable properties can be updated even if its writable descriptor 243 | // is set to `false`. 244 | // $FlowFixMe[cannot-write] 245 | 'name', 246 | { value: 'DetermineComponentFrameRoot' } 247 | ); 248 | } 249 | 250 | const [sampleStack, controlStack] = 251 | RunInRootFrame.DetermineComponentFrameRoot(); 252 | if (sampleStack && controlStack) { 253 | // This extracts the first frame from the sample that isn't also in the control. 254 | // Skipping one frame that we assume is the frame that calls the two. 255 | const sampleLines = sampleStack.split('\n'); 256 | const controlLines = controlStack.split('\n'); 257 | let s = 0; 258 | let c = 0; 259 | while ( 260 | s < sampleLines.length && 261 | !sampleLines[s].includes('DetermineComponentFrameRoot') 262 | ) { 263 | s++; 264 | } 265 | while ( 266 | c < controlLines.length && 267 | !controlLines[c].includes('DetermineComponentFrameRoot') 268 | ) { 269 | c++; 270 | } 271 | // We couldn't find our intentionally injected common root frame, attempt 272 | // to find another common root frame by search from the bottom of the 273 | // control stack... 274 | if (s === sampleLines.length || c === controlLines.length) { 275 | s = sampleLines.length - 1; 276 | c = controlLines.length - 1; 277 | while (s >= 1 && c >= 0 && sampleLines[s] !== controlLines[c]) { 278 | // We expect at least one stack frame to be shared. 279 | // Typically this will be the root most one. However, stack frames may be 280 | // cut off due to maximum stack limits. In this case, one maybe cut off 281 | // earlier than the other. We assume that the sample is longer or the same 282 | // and there for cut off earlier. So we should find the root most frame in 283 | // the sample somewhere in the control. 284 | c--; 285 | } 286 | } 287 | for (; s >= 1 && c >= 0; s--, c--) { 288 | // Next we find the first one that isn't the same which should be the 289 | // frame that called our sample function and the control. 290 | if (sampleLines[s] !== controlLines[c]) { 291 | // In V8, the first line is describing the message but other VMs don't. 292 | // If we're about to return the first line, and the control is also on the same 293 | // line, that's a pretty good indicator that our sample threw at same line as 294 | // the control. I.e. before we entered the sample frame. So we ignore this result. 295 | // This can happen if you passed a class to function component, or non-function. 296 | if (s !== 1 || c !== 1) { 297 | do { 298 | s--; 299 | c--; 300 | // We may still have similar intermediate frames from the construct call. 301 | // The next one that isn't the same should be our match though. 302 | if (c < 0 || sampleLines[s] !== controlLines[c]) { 303 | // V8 adds a "new" prefix for native classes. Let's remove it to make it prettier. 304 | let frame = `\n${sampleLines[s].replace(' at new ', ' at ')}`; 305 | 306 | const displayName = getDisplayName(fn); 307 | // If our component frame is labeled "" 308 | // but we have a user-provided "displayName" 309 | // splice it in to make the stack more readable. 310 | if (displayName && frame.includes('')) { 311 | frame = frame.replace('', displayName); 312 | } 313 | // Return the line we found. 314 | return frame; 315 | } 316 | } while (s >= 1 && c >= 0); 317 | } 318 | break; 319 | } 320 | } 321 | } 322 | } finally { 323 | reentry = false; 324 | 325 | Error.prepareStackTrace = previousPrepareStackTrace; 326 | 327 | setCurrentDispatcher(previousDispatcher); 328 | reenableLogs(prevLogs); 329 | } 330 | 331 | const name = fn ? getDisplayName(fn) : ''; 332 | const syntheticFrame = name ? describeBuiltInComponentFrame(name) : ''; 333 | return syntheticFrame; 334 | }; 335 | 336 | export const getCurrentDispatcher = (): React.RefObject | null => { 337 | const rdtHook = getRDTHook(); 338 | for (const renderer of [ 339 | ...Array.from(_renderers), 340 | ...Array.from(rdtHook.renderers.values()), 341 | ]) { 342 | const currentDispatcherRef = renderer.currentDispatcherRef; 343 | if (currentDispatcherRef) { 344 | // @ts-expect-error 345 | return currentDispatcherRef.H || currentDispatcherRef.current; 346 | } 347 | } 348 | return null; 349 | }; 350 | 351 | export const setCurrentDispatcher = ( 352 | value: React.RefObject | null 353 | ): void => { 354 | for (const renderer of _renderers) { 355 | const currentDispatcherRef = renderer.currentDispatcherRef; 356 | if (currentDispatcherRef) { 357 | if ('H' in currentDispatcherRef) { 358 | currentDispatcherRef.H = value; 359 | } else { 360 | currentDispatcherRef.current = value; 361 | } 362 | } 363 | } 364 | }; 365 | 366 | export const getFiberSource = async ( 367 | fiber: Fiber 368 | ): Promise => { 369 | const debugSource = fiber._debugSource; 370 | if (debugSource) { 371 | const { fileName, lineNumber } = debugSource; 372 | return { 373 | fileName, 374 | lineNumber, 375 | columnNumber: 376 | 'columnNumber' in debugSource && 377 | typeof debugSource.columnNumber === 'number' 378 | ? debugSource.columnNumber 379 | : 0, 380 | }; 381 | } 382 | 383 | // passed by bippy's jsx-dev-runtime 384 | if (typeof fiber.memoizedProps?._source === 'string') { 385 | const [fileName, lineNumber, columnNumber] = 386 | fiber.memoizedProps._source.split(':'); 387 | return { 388 | fileName, 389 | lineNumber: Number.parseInt(lineNumber), 390 | columnNumber: Number.parseInt(columnNumber), 391 | }; 392 | } 393 | 394 | const currentDispatcherRef = getCurrentDispatcher(); 395 | 396 | if (!currentDispatcherRef) { 397 | return null; 398 | } 399 | 400 | const componentFunction = isHostFiber(fiber) 401 | ? getType( 402 | traverseFiber( 403 | fiber, 404 | (f) => { 405 | if (isCompositeFiber(f)) return true; 406 | }, 407 | true 408 | )?.type 409 | ) 410 | : getType(fiber.type); 411 | if (!componentFunction || reentry) { 412 | return null; 413 | } 414 | 415 | const frame = describeNativeComponentFrame( 416 | componentFunction, 417 | fiber.tag === ClassComponentTag 418 | ); 419 | return parseStackFrame(frame); 420 | }; 421 | 422 | export * from './index.js'; 423 | -------------------------------------------------------------------------------- /packages/bippy/src/test/components.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const CountContext = React.createContext(0); 4 | export const ExtraContext = React.createContext(0); 5 | 6 | export const BasicComponent = () => { 7 | return
Hello
; 8 | }; 9 | 10 | BasicComponent.displayName = 'BasicComponent'; 11 | 12 | export const BasicComponentWithEffect = () => { 13 | const [_shouldUnmount, _setShouldUnmount] = React.useState(true); 14 | React.useEffect(() => {}, []); 15 | return
Hello
; 16 | }; 17 | 18 | export const BasicComponentWithUnmount = () => { 19 | const [shouldUnmount, setShouldUnmount] = React.useState(true); 20 | React.useEffect(() => { 21 | setShouldUnmount(false); 22 | }, []); 23 | return shouldUnmount ?
Hello
: null; 24 | }; 25 | 26 | export const BasicComponentWithMutation = () => { 27 | const [element, setElement] = React.useState(
Hello
); 28 | React.useEffect(() => { 29 | setElement(
Bye
); 30 | }, []); 31 | return element; 32 | }; 33 | 34 | export const BasicComponentWithChildren = ({ 35 | children, 36 | }: { children: React.ReactNode }) => { 37 | return
{children}
; 38 | }; 39 | 40 | export const BasicComponentWithMultipleElements = () => { 41 | return ( 42 | <> 43 |
Hello
44 |
Hello
45 | 46 | ); 47 | }; 48 | 49 | export const SlowComponent = () => { 50 | for (let i = 0; i < 100; i++) {} // simulate slowdown 51 | return
Hello
; 52 | }; 53 | 54 | export const ForwardRefComponent = React.forwardRef(BasicComponent); 55 | export const MemoizedComponent = React.memo(BasicComponent); 56 | 57 | export class ClassComponent extends React.Component { 58 | render() { 59 | return
Hello
; 60 | } 61 | } 62 | 63 | export const ComplexComponent = ({ 64 | countProp = 0, 65 | }: { countProp?: number; extraProp?: unknown }) => { 66 | const countContextValue = React.useContext(CountContext); 67 | const _extraContextValue = React.useContext(ExtraContext); 68 | const [countState, setCountState] = React.useState(0); 69 | const [_extraState, _setExtraState] = React.useState(0); 70 | 71 | // biome-ignore lint/correctness/useExhaustiveDependencies: OK 72 | React.useEffect(() => { 73 | setCountState(countState + 1); 74 | }, []); 75 | 76 | return
{countContextValue + countState + countProp}
; 77 | }; 78 | -------------------------------------------------------------------------------- /packages/bippy/src/test/core/fiber.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { 3 | didFiberCommit, 4 | didFiberRender, 5 | getFiberFromHostInstance, 6 | getFiberStack, 7 | getMutatedHostFibers, 8 | getNearestHostFiber, 9 | getNearestHostFibers, 10 | getTimings, 11 | instrument, 12 | isCompositeFiber, 13 | isHostFiber, 14 | isValidFiber, 15 | traverseFiber, 16 | } from '../../index.js'; 17 | import type { Fiber } from '../../types.js'; 18 | // FIXME(Alexis): Both React and @testing-library/react should be after index.js 19 | // but the linter/import sorter keeps moving them on top 20 | // biome-ignore lint/correctness/noUnusedImports: needed for JSX 21 | import React from 'react'; 22 | import { render, screen } from '@testing-library/react'; 23 | import { 24 | BasicComponent, 25 | BasicComponentWithChildren, 26 | BasicComponentWithMultipleElements, 27 | BasicComponentWithMutation, 28 | BasicComponentWithUnmount, 29 | SlowComponent, 30 | } from '../components.js'; 31 | 32 | describe('isValidFiber', () => { 33 | it('should return true for a valid fiber', () => { 34 | let maybeFiber: Fiber | null = null; 35 | instrument({ 36 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 37 | maybeFiber = fiberRoot.current.child; 38 | }, 39 | }); 40 | render(); 41 | expect(isValidFiber(maybeFiber as unknown as Fiber)).toBe(true); 42 | }); 43 | 44 | it('should return false for a non-fiber', () => { 45 | expect(isValidFiber({})).toBe(false); 46 | }); 47 | }); 48 | 49 | describe('isHostFiber', () => { 50 | it('should return true for a host fiber', () => { 51 | let maybeHostFiber: Fiber | null = null; 52 | instrument({ 53 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 54 | maybeHostFiber = fiberRoot.current.child; 55 | }, 56 | }); 57 | render(
Hello
); 58 | expect(maybeHostFiber).not.toBeNull(); 59 | expect(isHostFiber(maybeHostFiber as unknown as Fiber)).toBe(true); 60 | }); 61 | 62 | it('should return false for a composite fiber', () => { 63 | let maybeHostFiber: Fiber | null = null; 64 | instrument({ 65 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 66 | maybeHostFiber = fiberRoot.current.child; 67 | }, 68 | }); 69 | render(); 70 | expect(maybeHostFiber).not.toBeNull(); 71 | expect(isHostFiber(maybeHostFiber as unknown as Fiber)).toBe(false); 72 | }); 73 | }); 74 | 75 | describe('isCompositeFiber', () => { 76 | it('should return true for a composite fiber', () => { 77 | let maybeCompositeFiber: Fiber | null = null; 78 | instrument({ 79 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 80 | maybeCompositeFiber = fiberRoot.current.child; 81 | }, 82 | }); 83 | render(); 84 | expect(maybeCompositeFiber).not.toBeNull(); 85 | expect(isCompositeFiber(maybeCompositeFiber as unknown as Fiber)).toBe( 86 | true, 87 | ); 88 | }); 89 | 90 | it('should return false for a host fiber', () => { 91 | let maybeCompositeFiber: Fiber | null = null; 92 | instrument({ 93 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 94 | maybeCompositeFiber = fiberRoot.current.child; 95 | }, 96 | }); 97 | render(
Hello
); 98 | expect(maybeCompositeFiber).not.toBeNull(); 99 | expect(isCompositeFiber(maybeCompositeFiber as unknown as Fiber)).toBe( 100 | false, 101 | ); 102 | }); 103 | }); 104 | 105 | describe('didFiberRender', () => { 106 | it('should return true for a fiber that has rendered', () => { 107 | let maybeRenderedFiber: Fiber | null = null; 108 | instrument({ 109 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 110 | maybeRenderedFiber = fiberRoot.current.child; 111 | }, 112 | }); 113 | render(); 114 | expect(maybeRenderedFiber).not.toBeNull(); 115 | expect(didFiberRender(maybeRenderedFiber as unknown as Fiber)).toBe(true); 116 | }); 117 | 118 | it("should return false for a fiber that hasn't rendered", () => { 119 | let maybeRenderedFiber: Fiber | null = null; 120 | instrument({ 121 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 122 | maybeRenderedFiber = fiberRoot.current.child; 123 | }, 124 | }); 125 | render( 126 |
127 | 128 |
, 129 | ); 130 | expect(maybeRenderedFiber).not.toBeNull(); 131 | expect(didFiberRender(maybeRenderedFiber as unknown as Fiber)).toBe(false); 132 | }); 133 | }); 134 | 135 | describe('didFiberCommit', () => { 136 | it('should return true for a fiber that has committed', () => { 137 | let maybeRenderedFiber: Fiber | null = null; 138 | instrument({ 139 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 140 | maybeRenderedFiber = fiberRoot.current.child; 141 | }, 142 | }); 143 | render(); 144 | expect(maybeRenderedFiber).not.toBeNull(); 145 | expect(didFiberCommit(maybeRenderedFiber as unknown as Fiber)).toBe(true); 146 | }); 147 | 148 | it("should return false for a fiber that hasn't committed", () => { 149 | let maybeRenderedFiber: Fiber | null = null; 150 | instrument({ 151 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 152 | maybeRenderedFiber = fiberRoot.current.child; 153 | }, 154 | }); 155 | render(); 156 | expect(maybeRenderedFiber).not.toBeNull(); 157 | expect(didFiberCommit(maybeRenderedFiber as unknown as Fiber)).toBe(false); 158 | }); 159 | }); 160 | 161 | describe('getMutatedHostFibers', () => { 162 | it('should return all host fibers that have committed and rendered', () => { 163 | let maybeFiber: Fiber | null = null; 164 | let mutatedHostFiber: Fiber | null = null; 165 | instrument({ 166 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 167 | maybeFiber = fiberRoot.current.child; 168 | mutatedHostFiber = fiberRoot.current.child.child; 169 | }, 170 | }); 171 | render(); 172 | const mutatedHostFibers = getMutatedHostFibers( 173 | maybeFiber as unknown as Fiber, 174 | ); 175 | expect(getMutatedHostFibers(maybeFiber as unknown as Fiber)).toHaveLength( 176 | 1, 177 | ); 178 | expect(mutatedHostFiber).toBe(mutatedHostFibers[0]); 179 | }); 180 | }); 181 | 182 | describe('getFiberStack', () => { 183 | it('should return the fiber stack', () => { 184 | let maybeFiber: Fiber | null = null; 185 | let manualFiberStack: Fiber[] = []; 186 | instrument({ 187 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 188 | manualFiberStack = []; 189 | maybeFiber = fiberRoot.current.child.child; 190 | manualFiberStack.push(fiberRoot.current.child.child); 191 | manualFiberStack.push(fiberRoot.current.child); 192 | }, 193 | }); 194 | render( 195 | 196 | 197 | , 198 | ); 199 | const fiberStack = getFiberStack(maybeFiber as unknown as Fiber); 200 | expect(fiberStack).toEqual(manualFiberStack); 201 | }); 202 | }); 203 | 204 | describe('getNearestHostFiber', () => { 205 | it('should return the nearest host fiber', () => { 206 | let maybeFiber: Fiber | null = null; 207 | let maybeHostFiber: Fiber | null = null; 208 | instrument({ 209 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 210 | maybeFiber = fiberRoot.current.child; 211 | maybeHostFiber = fiberRoot.current.child.child; 212 | }, 213 | }); 214 | render(); 215 | expect(getNearestHostFiber(maybeFiber as unknown as Fiber)).toBe( 216 | (maybeFiber as unknown as Fiber).child, 217 | ); 218 | expect(maybeHostFiber).toBe( 219 | getNearestHostFiber(maybeFiber as unknown as Fiber), 220 | ); 221 | }); 222 | 223 | it('should return null for unmounted fiber', () => { 224 | let maybeFiber: Fiber | null = null; 225 | instrument({ 226 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 227 | maybeFiber = fiberRoot.current.child; 228 | }, 229 | }); 230 | render(); 231 | expect(getNearestHostFiber(maybeFiber as unknown as Fiber)).toBe(null); 232 | }); 233 | }); 234 | 235 | describe('getNearestHostFibers', () => { 236 | it('should return all host fibers', () => { 237 | let maybeFiber: Fiber | null = null; 238 | instrument({ 239 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 240 | maybeFiber = fiberRoot.current.child; 241 | }, 242 | }); 243 | render(); 244 | expect(getNearestHostFibers(maybeFiber as unknown as Fiber)).toHaveLength( 245 | 2, 246 | ); 247 | }); 248 | }); 249 | 250 | describe('getTimings', () => { 251 | it('should return the timings of the fiber', () => { 252 | let maybeFiber: Fiber | null = null; 253 | instrument({ 254 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 255 | maybeFiber = fiberRoot.current.child; 256 | }, 257 | }); 258 | render(); 259 | const timings = getTimings(maybeFiber as unknown as Fiber); 260 | expect(timings.selfTime).toBeGreaterThan(0); 261 | expect(timings.totalTime).toBeGreaterThan(0); 262 | }); 263 | }); 264 | 265 | describe('traverseFiber', () => { 266 | it('should return the nearest host fiber', () => { 267 | let maybeFiber: Fiber | null = null; 268 | instrument({ 269 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 270 | maybeFiber = fiberRoot.current.child; 271 | }, 272 | }); 273 | render(); 274 | expect( 275 | traverseFiber( 276 | maybeFiber as unknown as Fiber, 277 | (fiber) => fiber.type === 'div', 278 | ), 279 | ).toBe((maybeFiber as unknown as Fiber)?.child); 280 | }); 281 | }); 282 | 283 | describe('getFiberFromHostInstance', () => { 284 | it('should return the fiber from the host instance', () => { 285 | render(
HostInstance
); 286 | const fiber = getFiberFromHostInstance(screen.getByText('HostInstance')); 287 | expect(fiber).not.toBeNull(); 288 | expect(fiber?.type).toBe('div'); 289 | }); 290 | }); 291 | -------------------------------------------------------------------------------- /packages/bippy/src/test/core/instrument.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import type { FiberRoot } from '../../types.js'; 3 | import { 4 | instrument, 5 | isInstrumentationActive, 6 | secure, 7 | onCommitFiberRoot, 8 | getRDTHook, 9 | } from '../../index.js'; 10 | // biome-ignore lint/correctness/noUnusedImports: needed for JSX 11 | import React from 'react'; 12 | import { render } from '@testing-library/react'; 13 | import { BasicComponent, BasicComponentWithEffect } from '../components.js'; 14 | 15 | describe('instrument', () => { 16 | it('should not fail if __REACT_DEVTOOLS_GLOBAL_HOOK__ exists already', () => { 17 | render(); 18 | const onCommitFiberRoot = vi.fn(); 19 | instrument( 20 | secure({ onCommitFiberRoot }, { dangerouslyRunInProduction: true }), 21 | ); 22 | render(); 23 | expect(onCommitFiberRoot).toHaveBeenCalled(); 24 | }); 25 | 26 | it('onActive is called', async () => { 27 | const onActive = vi.fn(); 28 | instrument({ onActive }); 29 | render(); 30 | expect(onActive).toHaveBeenCalled(); 31 | expect(isInstrumentationActive()).toBe(true); 32 | }); 33 | 34 | it('onCommitFiberRoot is called', () => { 35 | let currentFiberRoot: FiberRoot | null = null; 36 | const onCommitFiberRoot = vi.fn((_rendererID, fiberRoot) => { 37 | currentFiberRoot = fiberRoot; 38 | }); 39 | instrument({ onCommitFiberRoot }); 40 | expect(onCommitFiberRoot).not.toHaveBeenCalled(); 41 | render(); 42 | expect(onCommitFiberRoot).toHaveBeenCalled(); 43 | expect(currentFiberRoot?.current.child.type).toBe(BasicComponent); 44 | }); 45 | 46 | it('onPostCommitFiberRoot is called', async () => { 47 | let currentFiberRoot: FiberRoot | null = null; 48 | const onPostCommitFiberRoot = vi.fn((_rendererID, fiberRoot) => { 49 | currentFiberRoot = fiberRoot; 50 | }); 51 | instrument({ onPostCommitFiberRoot }); 52 | expect(onPostCommitFiberRoot).not.toHaveBeenCalled(); 53 | render(); 54 | expect(onPostCommitFiberRoot).not.toHaveBeenCalled(); 55 | // onPostCommitFiberRoot only called when there is a fiber root 56 | render(); 57 | expect(onPostCommitFiberRoot).toHaveBeenCalled(); 58 | expect(currentFiberRoot?.current.child.type).toBe(BasicComponentWithEffect); 59 | }); 60 | 61 | it('should safeguard if version <17 or in production', () => { 62 | render(); 63 | const rdtHook = getRDTHook(); 64 | rdtHook.renderers.set(1, { 65 | version: '16.0.0', 66 | bundleType: 0, 67 | }); 68 | const onCommitFiberRoot1 = vi.fn(); 69 | instrument(secure({ onCommitFiberRoot: onCommitFiberRoot1 })); 70 | render(); 71 | expect(onCommitFiberRoot1).not.toHaveBeenCalled(); 72 | instrument({ 73 | onCommitFiberRoot: onCommitFiberRoot1, 74 | }); 75 | render(); 76 | expect(onCommitFiberRoot1).toHaveBeenCalled(); 77 | 78 | const onCommitFiberRoot2 = vi.fn(); 79 | 80 | rdtHook.renderers.set(1, { 81 | version: '17.0.0', 82 | bundleType: 1, 83 | }); 84 | instrument(secure({ onCommitFiberRoot: onCommitFiberRoot2 })); 85 | render(); 86 | expect(onCommitFiberRoot2).toHaveBeenCalled(); 87 | }); 88 | }); 89 | 90 | describe('onCommitFiberRoot', () => { 91 | it('should call the handler with the fiber root', () => { 92 | const handler = vi.fn(); 93 | onCommitFiberRoot(handler); 94 | render(); 95 | expect(handler).toHaveBeenCalled(); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /packages/bippy/src/test/core/traversal.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import type { Fiber, ContextDependency } from '../../types.js'; 3 | import { 4 | traverseProps, 5 | traverseState, 6 | traverseContexts, 7 | instrument, 8 | } from '../../index.js'; 9 | // biome-ignore lint/correctness/noUnusedImports: needed for JSX 10 | import React from 'react'; 11 | import { render } from '@testing-library/react'; 12 | import { ComplexComponent, CountContext, ExtraContext } from '../components.js'; 13 | 14 | describe('traverseProps', () => { 15 | it('should return the props of the fiber', () => { 16 | let maybeFiber: Fiber | null = null; 17 | instrument({ 18 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 19 | maybeFiber = fiberRoot.current.child; 20 | }, 21 | }); 22 | render(); 23 | const selector = vi.fn(); 24 | traverseProps(maybeFiber as unknown as Fiber, selector); 25 | expect(selector).toHaveBeenCalledWith('countProp', 0, 0); 26 | }); 27 | 28 | it('should stop selector at the first prop', () => { 29 | let maybeFiber: Fiber | null = null; 30 | instrument({ 31 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 32 | maybeFiber = fiberRoot.current.child; 33 | }, 34 | }); 35 | render(); 36 | const selector = vi.fn(); 37 | traverseProps(maybeFiber as unknown as Fiber, selector); 38 | expect(selector).toBeCalledTimes(2); 39 | }); 40 | 41 | it('should stop selector at the first prop', () => { 42 | let maybeFiber: Fiber | null = null; 43 | instrument({ 44 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 45 | maybeFiber = fiberRoot.current.child; 46 | }, 47 | }); 48 | render(); 49 | const selector = vi.fn(() => true); 50 | traverseProps(maybeFiber as unknown as Fiber, selector); 51 | expect(selector).toBeCalledTimes(1); 52 | }); 53 | }); 54 | 55 | describe('traverseState', () => { 56 | it('should return the state of the fiber', () => { 57 | let maybeFiber: Fiber | null = null; 58 | instrument({ 59 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 60 | maybeFiber = fiberRoot.current.child; 61 | }, 62 | }); 63 | render(); 64 | const states: { next: unknown; prev: unknown }[] = []; 65 | const selector = vi.fn((nextState, prevState) => { 66 | states.push({ 67 | next: nextState.memoizedState, 68 | prev: prevState.memoizedState, 69 | }); 70 | }); 71 | traverseState(maybeFiber as unknown as Fiber, selector); 72 | expect(states[0].next).toEqual(1); 73 | expect(states[0].prev).toEqual(0); 74 | expect(states[1].next).toEqual(0); 75 | expect(states[1].prev).toEqual(0); 76 | }); 77 | 78 | it('should call selector many times for a fiber with multiple states', () => { 79 | let maybeFiber: Fiber | null = null; 80 | instrument({ 81 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 82 | maybeFiber = fiberRoot.current.child; 83 | }, 84 | }); 85 | render(); 86 | const selector = vi.fn(); 87 | traverseState(maybeFiber as unknown as Fiber, selector); 88 | expect(selector).toBeCalledTimes(3); 89 | }); 90 | 91 | it('should stop selector at the first state', () => { 92 | let maybeFiber: Fiber | null = null; 93 | instrument({ 94 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 95 | maybeFiber = fiberRoot.current.child; 96 | }, 97 | }); 98 | render(); 99 | const selector = vi.fn(() => true); 100 | traverseState(maybeFiber as unknown as Fiber, selector); 101 | expect(selector).toBeCalledTimes(1); 102 | }); 103 | }); 104 | 105 | describe('traverseContexts', () => { 106 | it('should return the contexts of the fiber', () => { 107 | let maybeFiber: Fiber | null = null; 108 | instrument({ 109 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 110 | maybeFiber = fiberRoot.current.child.child; 111 | }, 112 | }); 113 | render( 114 | 115 | 116 | , 117 | ); 118 | const contexts: ContextDependency[] = []; 119 | const selector = vi.fn((context) => { 120 | contexts.push(context); 121 | }); 122 | traverseContexts(maybeFiber as unknown as Fiber, selector); 123 | expect(contexts).toHaveLength(2); 124 | expect(contexts[0].context).toBe(CountContext); 125 | expect(contexts[0].memoizedValue).toBe(1); 126 | expect(contexts[1].context).toBe(ExtraContext); 127 | expect(contexts[1].memoizedValue).toBe(0); 128 | }); 129 | 130 | it('should stop selector at the first context', () => { 131 | let maybeFiber: Fiber | null = null; 132 | instrument({ 133 | onCommitFiberRoot: (_rendererID, fiberRoot) => { 134 | maybeFiber = fiberRoot.current.child; 135 | }, 136 | }); 137 | render(); 138 | const selector = vi.fn(() => true); 139 | traverseContexts(maybeFiber as unknown as Fiber, selector); 140 | expect(selector).toBeCalledTimes(1); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /packages/bippy/src/test/core/type.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { getType, getDisplayName } from '../../index.js'; 3 | import { 4 | BasicComponent, 5 | ClassComponent, 6 | ForwardRefComponent, 7 | MemoizedComponent, 8 | } from '../components.js'; 9 | 10 | describe('getType', () => { 11 | it('should return the type of the forwardRef component', () => { 12 | expect(getType(ForwardRefComponent)).toBe(BasicComponent); 13 | }); 14 | 15 | it('should return the type of the memoized component', () => { 16 | expect(getType(MemoizedComponent)).toBe(BasicComponent); 17 | }); 18 | 19 | it('should return same type for a normal component', () => { 20 | expect(getType(BasicComponent)).toBe(BasicComponent); 21 | }); 22 | 23 | it('should return the type of the class component', () => { 24 | expect(getType(ClassComponent)).toBe(ClassComponent); 25 | }); 26 | 27 | it('should return null for a non-fiber', () => { 28 | expect(getType({})).toBe(null); 29 | }); 30 | }); 31 | 32 | describe('getDisplayName', () => { 33 | it('should return the displayName of the forwardRef component', () => { 34 | expect(getDisplayName(ForwardRefComponent)).toBe('BasicComponent'); 35 | }); 36 | 37 | it('should return the displayName of the memoized component', () => { 38 | expect(getDisplayName(MemoizedComponent)).toBe('BasicComponent'); 39 | }); 40 | 41 | it('should return the displayName of the component', () => { 42 | expect(getDisplayName(BasicComponent)).toBe('BasicComponent'); 43 | }); 44 | 45 | it('should return the displayName of the class component', () => { 46 | expect(getDisplayName(ClassComponent)).toBe('ClassComponent'); 47 | }); 48 | 49 | it('should return null for a non-fiber', () => { 50 | expect(getDisplayName({})).toBe(null); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /packages/bippy/src/test/development/multiple-on-active.test.tsx: -------------------------------------------------------------------------------- 1 | import { instrument } from '../../index.js'; 2 | import { it, vi, expect } from 'vitest'; 3 | import React from 'react'; 4 | import { render } from '@testing-library/react'; 5 | import { BasicComponent } from '../components.js'; 6 | 7 | it('handle multiple onActive calls', () => { 8 | const onActive = vi.fn(); 9 | const onActive2 = vi.fn(); 10 | const onActive3 = vi.fn(); 11 | instrument({ onActive }); 12 | instrument({ onActive: onActive2 }); 13 | render(); 14 | instrument({ onActive: onActive3 }); 15 | expect(onActive).toHaveBeenCalledOnce(); 16 | expect(onActive2).toHaveBeenCalledOnce(); 17 | expect(onActive3).toHaveBeenCalledOnce(); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/bippy/src/test/development/post-react-devtools.test.tsx: -------------------------------------------------------------------------------- 1 | // import bippy, then react devtools 2 | 3 | import { expect, vi, it } from 'vitest'; 4 | const { instrument } = await import('../../index.js'); 5 | 6 | // @ts-ignore 7 | import { activate, initialize } from 'react-devtools-inline/backend'; 8 | // @ts-ignore 9 | import { initialize as initializeFrontend } from 'react-devtools-inline/frontend'; 10 | 11 | initialize(window); 12 | 13 | const DevTools = initializeFrontend(window); 14 | 15 | activate(window); 16 | // biome-ignore lint/correctness/noUnusedVariables: needed for JSX 17 | const React = await import('react'); 18 | const { render } = await import('@testing-library/react'); 19 | 20 | it('should be active', () => { 21 | render(
Hello
); 22 | render(); 23 | 24 | const onActive = vi.fn(); 25 | instrument({ 26 | onActive, 27 | }); 28 | expect(onActive).toHaveBeenCalled(); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/bippy/src/test/development/post-react-refresh.test.tsx: -------------------------------------------------------------------------------- 1 | // import bippy, then react refresh 2 | 3 | import { expect, vi, it } from 'vitest'; 4 | import { instrument } from '../../index.js'; 5 | 6 | declare global { 7 | interface Window { 8 | $RefreshReg$?: () => void; 9 | $RefreshSig$?: () => (type: T) => T; 10 | } 11 | } 12 | 13 | const runtime = require('react-refresh/runtime'); 14 | runtime.injectIntoGlobalHook(window); 15 | window.$RefreshReg$ = () => {}; 16 | window.$RefreshSig$ = () => (type) => type; 17 | 18 | it('should be active', () => { 19 | const onActive = vi.fn(); 20 | instrument({ 21 | onActive, 22 | }); 23 | expect(onActive).toHaveBeenCalled(); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/bippy/src/test/development/post-react.test.tsx: -------------------------------------------------------------------------------- 1 | // import bippy, then react 2 | 3 | import { expect, it, vi } from 'vitest'; 4 | import { instrument } from '../../index.js'; 5 | 6 | // biome-ignore lint/correctness/noUnusedImports: needed for JSX 7 | import React from 'react'; 8 | import { render } from '@testing-library/react'; 9 | 10 | it('should be active', () => { 11 | const onActive = vi.fn(); 12 | render(
Hello
); 13 | instrument({ 14 | onActive, 15 | }); 16 | expect(onActive).toHaveBeenCalled(); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/bippy/src/test/development/pre-react-devtools.test.tsx: -------------------------------------------------------------------------------- 1 | // import react devtools, then bippy 2 | 3 | import { expect, vi, it } from 'vitest'; 4 | 5 | // @ts-ignore 6 | import { activate, initialize } from 'react-devtools-inline/backend'; 7 | // @ts-ignore 8 | import { initialize as initializeFrontend } from 'react-devtools-inline/frontend'; 9 | 10 | initialize(window); 11 | 12 | // biome-ignore lint/correctness/noUnusedVariables: needed for JSX 13 | const React = await import('react'); 14 | 15 | const DevTools = initializeFrontend(window); 16 | 17 | activate(window); 18 | 19 | const { render } = await import('@testing-library/react'); 20 | const { instrument } = await import('../../index.js'); 21 | 22 | it('should be active', () => { 23 | render(
Hello
); 24 | render(); 25 | const onActive = vi.fn(); 26 | instrument({ 27 | onActive, 28 | }); 29 | expect(onActive).toHaveBeenCalled(); 30 | }); 31 | -------------------------------------------------------------------------------- /packages/bippy/src/test/development/pre-react-refresh.test.tsx: -------------------------------------------------------------------------------- 1 | // import react refresh, then bippy 2 | 3 | import { expect, vi, it } from 'vitest'; 4 | 5 | declare global { 6 | interface Window { 7 | $RefreshReg$?: () => void; 8 | $RefreshSig$?: () => (type: T) => T; 9 | } 10 | } 11 | 12 | const runtime = require('react-refresh/runtime'); 13 | runtime.injectIntoGlobalHook(window); 14 | window.$RefreshReg$ = () => {}; 15 | window.$RefreshSig$ = () => (type) => type; 16 | const { instrument } = await import('../../index.js'); 17 | 18 | it('should be active', () => { 19 | const onActive = vi.fn(); 20 | instrument({ 21 | onActive, 22 | }); 23 | expect(onActive).toHaveBeenCalled(); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/bippy/src/test/development/pre-react.test.tsx: -------------------------------------------------------------------------------- 1 | // import react, then bippy 2 | 3 | import { expect, it, vi } from 'vitest'; 4 | // biome-ignore lint/correctness/noUnusedImports: needed for JSX 5 | import React from 'react'; 6 | const { instrument } = await import('../../index.js'); // delay it 7 | import { render } from '@testing-library/react'; 8 | 9 | it('should not be active', () => { 10 | const onActive = vi.fn(); 11 | render(
Hello
); 12 | instrument({ 13 | onActive, 14 | }); 15 | expect(onActive).not.toHaveBeenCalled(); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/bippy/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | HostConfig, 3 | Thenable, 4 | RootTag, 5 | WorkTag, 6 | HookType, 7 | Source, 8 | LanePriority, 9 | Lanes, 10 | Flags, 11 | TypeOfMode, 12 | ReactProvider, 13 | ReactProviderType, 14 | ReactConsumer, 15 | ReactContext, 16 | ReactPortal, 17 | RefObject, 18 | Fiber as ReactFiber, 19 | FiberRoot, 20 | MutableSource, 21 | OpaqueHandle, 22 | OpaqueRoot, 23 | BundleType, 24 | DevToolsConfig, 25 | SuspenseHydrationCallbacks, 26 | TransitionTracingCallbacks, 27 | ComponentSelector, 28 | HasPseudoClassSelector, 29 | RoleSelector, 30 | TextSelector, 31 | TestNameSelector, 32 | Selector, 33 | React$AbstractComponent, 34 | } from 'react-reconciler'; 35 | 36 | export type { 37 | HostConfig, 38 | Thenable, 39 | RootTag, 40 | WorkTag, 41 | HookType, 42 | Source, 43 | LanePriority, 44 | Lanes, 45 | Flags, 46 | TypeOfMode, 47 | ReactProvider, 48 | ReactProviderType, 49 | ReactConsumer, 50 | ReactContext, 51 | ReactPortal, 52 | RefObject, 53 | FiberRoot, 54 | MutableSource, 55 | OpaqueHandle, 56 | OpaqueRoot, 57 | BundleType, 58 | DevToolsConfig, 59 | SuspenseHydrationCallbacks, 60 | TransitionTracingCallbacks, 61 | ComponentSelector, 62 | HasPseudoClassSelector, 63 | RoleSelector, 64 | TextSelector, 65 | TestNameSelector, 66 | Selector, 67 | React$AbstractComponent, 68 | }; 69 | 70 | export interface ReactDevToolsGlobalHook { 71 | checkDCE: (fn: unknown) => void; 72 | supportsFiber: boolean; 73 | supportsFlight: boolean; 74 | renderers: Map; 75 | hasUnsupportedRendererAttached: boolean; 76 | onCommitFiberRoot: ( 77 | rendererID: number, 78 | root: FiberRoot, 79 | // biome-ignore lint/suspicious/noConfusingVoidType: may or may not exist 80 | priority: void | number, 81 | ) => void; 82 | onCommitFiberUnmount: (rendererID: number, fiber: Fiber) => void; 83 | onPostCommitFiberRoot: (rendererID: number, root: FiberRoot) => void; 84 | inject: (renderer: ReactRenderer) => number; 85 | _instrumentationSource?: string; 86 | _instrumentationIsActive?: boolean; 87 | _sw?: boolean; 88 | } 89 | 90 | /** 91 | * Represents a react-internal Fiber node. 92 | */ 93 | // biome-ignore lint/suspicious/noExplicitAny: stateNode is not typed in react-reconciler 94 | export type Fiber = Omit< 95 | ReactFiber, 96 | | 'stateNode' 97 | | 'dependencies' 98 | | 'child' 99 | | 'sibling' 100 | | 'return' 101 | | 'alternate' 102 | | 'memoizedProps' 103 | | 'pendingProps' 104 | | 'memoizedState' 105 | | 'updateQueue' 106 | > & { 107 | stateNode: T; 108 | dependencies: Dependencies | null; 109 | child: Fiber | null; 110 | sibling: Fiber | null; 111 | return: Fiber | null; 112 | alternate: Fiber | null; 113 | memoizedProps: Props; 114 | pendingProps: Props; 115 | memoizedState: MemoizedState; 116 | updateQueue: { 117 | lastEffect: Effect | null; 118 | [key: string]: unknown; 119 | }; 120 | }; 121 | 122 | // https://github.com/facebook/react/blob/6a4b46cd70d2672bc4be59dcb5b8dede22ed0cef/packages/react-devtools-shared/src/backend/types.js 123 | export interface ReactRenderer { 124 | version: string; 125 | bundleType: 0 /* PROD */ | 1 /* DEV */; 126 | findFiberByHostInstance?: (hostInstance: unknown) => Fiber | null; 127 | currentDispatcherRef: React.RefObject; 128 | } 129 | 130 | export interface ContextDependency { 131 | context: ReactContext; 132 | memoizedValue: T; 133 | observedBits: number; 134 | next: ContextDependency | null; 135 | } 136 | 137 | export interface Dependencies { 138 | lanes: Lanes; 139 | firstContext: ContextDependency | null; 140 | } 141 | 142 | export interface Effect { 143 | next: Effect | null; 144 | create: (...args: unknown[]) => unknown; 145 | destroy: ((...args: unknown[]) => unknown) | null; 146 | deps: unknown[] | null; 147 | tag: number; 148 | [key: string]: unknown; 149 | } 150 | 151 | export interface MemoizedState { 152 | memoizedState: unknown; 153 | next: MemoizedState | null; 154 | [key: string]: unknown; 155 | } 156 | 157 | export interface Props { 158 | [key: string]: unknown; 159 | } 160 | 161 | declare global { 162 | var __REACT_DEVTOOLS_GLOBAL_HOOK__: ReactDevToolsGlobalHook | undefined; 163 | } 164 | -------------------------------------------------------------------------------- /packages/bippy/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "NodeNext", 5 | "esModuleInterop": true, 6 | "strictNullChecks": true, 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "lib": ["esnext", "dom"] 10 | }, 11 | "include": ["src", "tsdown.config.ts", "vitest.config.ts"], 12 | "exclude": ["**/node_modules/**", "**/dist/**"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/bippy/tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, type Options } from 'tsdown'; 2 | import fs from 'node:fs'; 3 | 4 | const DEFAULT_OPTIONS: Options = { 5 | entry: [], 6 | clean: false, 7 | outDir: './dist', 8 | sourcemap: false, 9 | format: [], 10 | target: 'esnext', 11 | platform: 'browser', 12 | treeshake: true, 13 | dts: true, 14 | minify: false, 15 | env: { 16 | NODE_ENV: process.env.NODE_ENV ?? 'development', 17 | VERSION: JSON.parse(fs.readFileSync('package.json', 'utf8')).version, 18 | }, 19 | external: ['react', 'react-dom', 'react-reconciler'], 20 | noExternal: ['error-stack-parser-es', 'source-map-js'], 21 | }; 22 | 23 | export default defineConfig([ 24 | { 25 | ...DEFAULT_OPTIONS, 26 | format: ['esm', 'cjs'], 27 | entry: [ 28 | './src/index.ts', 29 | './src/core.ts', 30 | './src/jsx-runtime.ts', 31 | './src/jsx-dev-runtime.ts', 32 | './src/experiments/inspect.tsx', 33 | './src/source.ts', 34 | ], 35 | clean: true, // only run on first entry 36 | }, 37 | { 38 | ...DEFAULT_OPTIONS, 39 | format: ['iife'], 40 | outDir: './dist', 41 | minify: process.env.NODE_ENV === 'production', 42 | globalName: 'Bippy', 43 | entry: ['./src/index.ts'], 44 | }, 45 | { 46 | ...DEFAULT_OPTIONS, 47 | format: ['iife'], 48 | outDir: './dist', 49 | minify: process.env.NODE_ENV === 'production', 50 | globalName: 'Bippy', 51 | entry: ['./src/source.ts'], 52 | }, 53 | ]); 54 | -------------------------------------------------------------------------------- /packages/bippy/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | provider: 'istanbul', 7 | reporter: ['text', 'json', 'html'], 8 | include: ['src/*.ts'], 9 | }, 10 | environment: 'happy-dom', 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /packages/kitchen-sink/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | bippy 28 | 29 | 30 |
31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /packages/kitchen-sink/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bippy/kitchen-sink", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "preview": "vite preview", 7 | "build": "vite build", 8 | "dev": "rm -rf node_modules/.vite && pnpm i && vite" 9 | }, 10 | "dependencies": { 11 | "bippy": "^0.3.15", 12 | "clsx": "^2.1.1", 13 | "react": "19.1.0", 14 | "react-dom": "19.1.0", 15 | "react-inspector": "^6.0.2", 16 | "sugar-high": "^0.7.5", 17 | "tailwind-merge": "^2.6.0" 18 | }, 19 | "devDependencies": { 20 | "@tailwindcss/vite": "4.0.0-beta.8", 21 | "@types/react": "^19.0.4", 22 | "@types/react-dom": "^19.0.2", 23 | "@vitejs/plugin-react": "^4.3.4", 24 | "tailwindcss": "4.0.0-beta.8", 25 | "typescript": "^5.7.3", 26 | "vite": "^6.0.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/kitchen-sink/public/bippy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenybai/bippy/c50e3c21cd4599b8cd4a97448c08b9c0e496427d/packages/kitchen-sink/public/bippy.png -------------------------------------------------------------------------------- /packages/kitchen-sink/public/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenybai/bippy/c50e3c21cd4599b8cd4a97448c08b9c0e496427d/packages/kitchen-sink/public/thumbnail.png -------------------------------------------------------------------------------- /packages/kitchen-sink/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { useState, type ReactNode, Fragment, type JSX } from 'react'; 2 | import { clsx } from 'clsx'; 3 | import { twMerge } from 'tailwind-merge'; 4 | import { highlight } from 'sugar-high'; 5 | import { getFiberSource } from 'bippy/dist/source'; 6 | import { getFiberFromHostInstance } from 'bippy'; 7 | 8 | declare const __VERSION__: string; 9 | 10 | interface TextProps { 11 | as?: keyof JSX.IntrinsicElements; 12 | children: ReactNode; 13 | className?: string; 14 | } 15 | 16 | interface LinkProps { 17 | children: ReactNode; 18 | className?: string; 19 | href?: string; 20 | onClick?: () => void; 21 | } 22 | 23 | interface ListProps { 24 | children: ReactNode; 25 | className?: string; 26 | } 27 | 28 | interface ListItemProps { 29 | children: ReactNode; 30 | } 31 | 32 | interface SideLayoutProps { 33 | children: ReactNode; 34 | } 35 | 36 | interface TabsProps { 37 | tabs: { value: T; label: string }[]; 38 | value: T; 39 | onChange: (value: T) => void; 40 | } 41 | 42 | setTimeout(async () => { 43 | const tabs = document.getElementById('tabs'); 44 | // @ts-ignore 45 | console.log(await getFiberSource(getFiberFromHostInstance(tabs))); 46 | }, 1000); 47 | 48 | function Tabs({ tabs, value, onChange }: TabsProps) { 49 | return ( 50 |
51 | {tabs.map((tab, i) => ( 52 | 53 | {i > 0 && ·} 54 | 64 | 65 | ))} 66 |
67 | ); 68 | } 69 | 70 | export function cn(...inputs: (string | undefined | boolean)[]) { 71 | return twMerge(clsx(inputs)); 72 | } 73 | 74 | function SideLayout({ children }: SideLayoutProps) { 75 | return ( 76 |
77 | {children} 78 |
79 | ); 80 | } 81 | 82 | function Text({ 83 | as: Component = 'p', 84 | children, 85 | className, 86 | ...props 87 | }: TextProps) { 88 | return ( 89 | 90 | {children} 91 | 92 | ); 93 | } 94 | 95 | function Link({ children, className, href, onClick, ...props }: LinkProps) { 96 | return ( 97 | 103 | {children} 104 | 105 | ); 106 | } 107 | 108 | function List({ children, className }: ListProps) { 109 | return ( 110 |
    116 | {children} 117 |
118 | ); 119 | } 120 | 121 | function ListItem({ children }: ListItemProps) { 122 | return
  • {children}
  • ; 123 | } 124 | 125 | export default function App() { 126 | const [imgSize, setImgSize] = useState(50); 127 | const [isSpinning, setIsSpinning] = useState(false); 128 | const [activeTab, setActiveTab] = useState<'basic' | 'inspect'>('basic'); 129 | 130 | const tabs = [ 131 | { value: 'basic' as const, label: '1. quick start' }, 132 | { value: 'inspect' as const, label: '2. use ' }, 133 | ]; 134 | 135 | return ( 136 |
    137 | 138 |
    139 |
    140 | bippy logo setImgSize(imgSize + 10)} 147 | onKeyDown={(e) => { 148 | if (e.key === 'Enter') { 149 | setImgSize(imgSize + 10); 150 | } 151 | }} 152 | onMouseEnter={() => setIsSpinning(true)} 153 | onMouseLeave={() => setIsSpinning(false)} 154 | /> 155 | 156 | bippy 157 | 158 |
    159 | 163 | {__VERSION__} 164 | 165 |
    166 | 167 | /github 168 | 169 |
    170 |
    171 | 172 |
    173 | 174 |
    175 | 176 | bippy is a toolkit to{' '} 177 | 178 | hack into react internals 179 | 180 | 181 |
    182 | 183 |
    184 | 185 | by default, you cannot access react internals. bippy bypasses this 186 | by "pretending" to be react devtools, giving you access to the fiber 187 | tree and other internals. 188 | 189 |
    190 | 191 | 192 | 193 | 194 | works outside of react – no react code modification needed 195 | 196 | 197 | 198 | 199 | utility functions that work across modern react (v17-19) 200 | 201 | 202 | 203 | 204 | no prior react source code knowledge required 205 | 206 | 207 | 208 | 209 |
    210 | 211 | you can get started in {'<'}6 lines of code: 212 | 213 |
    214 | 215 |
    216 |           
    217 | 218 |
    219 |
    220 | {activeTab === 'basic' && ( 221 | { 229 | traverseFiber(root.current, (fiber) => { 230 | console.log('fiber:', fiber); 231 | }); 232 | })`), 233 | }} 234 | /> 235 | )} 236 | {activeTab === 'inspect' && ( 237 | `), 245 | }} 246 | /> 247 | )} 248 |
    249 | 250 | 260 | 261 |
    262 |
    263 | 264 | 265 | ⚠️ warning:{' '} 266 | 267 | 268 | this project may break production apps and cause unexpected 269 | behavior 270 | 271 | 272 |
    273 |
    274 | 275 | this project uses react internals, which can change at any time. 276 | it is not recommended to depend on internals unless you really,{' '} 277 | 278 | really have to. 279 | {' '} 280 | by proceeding, you acknowledge the risk of breaking your own code 281 | or apps that use your code. 282 | 283 |
    284 |
    285 |
    286 |
    287 | ); 288 | } 289 | -------------------------------------------------------------------------------- /packages/kitchen-sink/src/main.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | html, 4 | body { 5 | font-family: "Geist Mono", Menlo, Consolas, Monaco, Liberation Mono, Lucida 6 | Console, monospace; 7 | } 8 | 9 | :root { 10 | color-scheme: dark; 11 | --sh-class: #f0f0f0; 12 | --sh-identifier: #f0f0f0; 13 | --sh-sign: #9b9b9b; 14 | --sh-property: #dfb58f; 15 | --sh-entity: #dfb58f; 16 | --sh-jsxliterals: #dfb58f; 17 | --sh-string: #9b9b9b; 18 | --sh-keyword: #9b9b9b; 19 | --sh-comment: #a19595; 20 | } 21 | 22 | .font-sans { 23 | font-family: "Geist", sans-serif; 24 | } 25 | 26 | pre code { 27 | counter-reset: sh-line-number; 28 | } 29 | 30 | .sh__line::before { 31 | counter-increment: sh-line-number 1; 32 | content: counter(sh-line-number); 33 | margin-right: 2ch; 34 | text-align: right; 35 | color: #9b9b9b71; 36 | } 37 | -------------------------------------------------------------------------------- /packages/kitchen-sink/src/main.tsx: -------------------------------------------------------------------------------- 1 | import 'bippy/dist/index'; 2 | import Inspector from 'bippy/dist/experiments/inspect'; 3 | 4 | import { StrictMode } from 'react'; 5 | import ReactDOM from 'react-dom/client'; 6 | import App from './app'; 7 | // @ts-ignore 8 | import './main.css'; 9 | 10 | const rootElement = document.getElementById('root'); 11 | if (rootElement && !rootElement.innerHTML) { 12 | const root = ReactDOM.createRoot(rootElement); 13 | root.render( 14 | 15 | 16 | 17 | , 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/kitchen-sink/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } -------------------------------------------------------------------------------- /packages/kitchen-sink/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsxImportSource": "bippy/dist" 4 | }, 5 | "files": [], 6 | "references": [ 7 | { "path": "./tsconfig.app.json" }, 8 | { "path": "./tsconfig.node.json" } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /packages/kitchen-sink/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } -------------------------------------------------------------------------------- /packages/kitchen-sink/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/challenge", "destination": "https://github.com/aidenybai/bippy/blob/main/challenge.md" }] 3 | } 4 | -------------------------------------------------------------------------------- /packages/kitchen-sink/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defineConfig } from 'vite'; 3 | import react from '@vitejs/plugin-react'; 4 | import tailwindcss from '@tailwindcss/vite'; 5 | import fs from 'node:fs'; 6 | 7 | export default defineConfig({ 8 | build: { 9 | minify: false, 10 | }, 11 | plugins: [ 12 | react({ 13 | // jsxImportSource: 'bippy/dist', 14 | // babel: { 15 | // plugins: [['babel-plugin-react-compiler', {}]], 16 | // }, 17 | }), 18 | tailwindcss(), 19 | ], 20 | define: { 21 | __VERSION__: `"v${JSON.parse(fs.readFileSync('../bippy/package.json', 'utf8')).version}"`, 22 | }, 23 | resolve: 24 | process.env.NODE_ENV === 'production' 25 | ? {} 26 | : { 27 | alias: { 28 | bippy: path.resolve(__dirname, '../bippy'), 29 | }, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidenybai/bippy/c50e3c21cd4599b8cd4a97448c08b9c0e496427d/packages/next-kitchen-sink/app/favicon.ico -------------------------------------------------------------------------------- /packages/next-kitchen-sink/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --background: #ffffff; 5 | --foreground: #171717; 6 | } 7 | 8 | @theme inline { 9 | --color-background: var(--background); 10 | --color-foreground: var(--foreground); 11 | --font-sans: var(--font-geist-sans); 12 | --font-mono: var(--font-geist-mono); 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | :root { 17 | --background: #0a0a0a; 18 | --foreground: #ededed; 19 | } 20 | } 21 | 22 | body { 23 | background: var(--background); 24 | color: var(--foreground); 25 | font-family: Arial, Helvetica, sans-serif; 26 | } 27 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Geist, Geist_Mono } from 'next/font/google'; 3 | import './globals.css'; 4 | 5 | const geistSans = Geist({ 6 | variable: '--font-geist-sans', 7 | subsets: ['latin'], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: '--font-geist-mono', 12 | subsets: ['latin'], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: 'Create Next App', 17 | description: 'Generated by create next app', 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | 30 | {children} 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { ClientFiber } from '../components/fiber'; 2 | 3 | export default function Home() { 4 | return ( 5 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/components/fiber.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { getFiberFromHostInstance } from '../dist/index'; 4 | import { getFiberSource } from '../dist/source'; 5 | 6 | export function ClientFiber() { 7 | setTimeout(async () => { 8 | if (typeof window === 'undefined') { 9 | return; 10 | } 11 | const fiber = getFiberFromHostInstance( 12 | // biome-ignore lint/style/noNonNullAssertion: 13 | document.getElementById('bippy-source')! 14 | ); 15 | if (fiber) { 16 | // biome-ignore lint/suspicious/noConsoleLog: 17 | console.log(fiber, await getFiberSource(fiber)); 18 | } 19 | }, 1000); 20 | return
    Fiber
    ; 21 | } 22 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bippy/next-kitchen-sink", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "rm -rf .next && bun scripts/move-dist.ts && next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "next": "15.3.2", 13 | "react": "^19.0.0", 14 | "react-dom": "^19.0.0" 15 | }, 16 | "devDependencies": { 17 | "@eslint/eslintrc": "^3", 18 | "@tailwindcss/postcss": "^4", 19 | "@types/node": "^20", 20 | "@types/react": "^19", 21 | "@types/react-dom": "^19", 22 | "eslint": "^9", 23 | "eslint-config-next": "15.3.2", 24 | "tailwindcss": "^4", 25 | "typescript": "^5" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/scripts/move-dist.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = path.dirname(__filename); 7 | 8 | const rootDir = path.resolve(__dirname, '../../..'); 9 | const bippyDistDir = path.join(rootDir, 'packages/bippy/dist'); 10 | const nextKitchenSinkDir = path.join( 11 | rootDir, 12 | 'packages/next-kitchen-sink/dist' 13 | ); 14 | const nextDir = path.join(nextKitchenSinkDir, '.next'); 15 | 16 | function copyDirRecursive(src: string, dest: string) { 17 | if (!fs.existsSync(dest)) { 18 | fs.mkdirSync(dest, { recursive: true }); 19 | } 20 | 21 | const entries = fs.readdirSync(src, { withFileTypes: true }); 22 | 23 | for (const entry of entries) { 24 | const srcPath = path.join(src, entry.name); 25 | const destPath = path.join(dest, entry.name); 26 | 27 | if (entry.isDirectory()) { 28 | copyDirRecursive(srcPath, destPath); 29 | } else { 30 | fs.copyFileSync(srcPath, destPath); 31 | } 32 | } 33 | } 34 | 35 | function deleteDirRecursive(dir: string) { 36 | if (fs.existsSync(dir)) { 37 | fs.rmSync(dir, { recursive: true, force: true }); 38 | } 39 | } 40 | 41 | // Execute operations 42 | // biome-ignore lint/suspicious/noConsoleLog: 43 | console.log(`Copying from ${bippyDistDir} to ${nextKitchenSinkDir}...`); 44 | copyDirRecursive(bippyDistDir, nextKitchenSinkDir); 45 | // biome-ignore lint/suspicious/noConsoleLog: 46 | console.log('Copy completed.'); 47 | 48 | // biome-ignore lint/suspicious/noConsoleLog: 49 | console.log(`Deleting ${nextDir}...`); 50 | deleteDirRecursive(nextDir); 51 | // biome-ignore lint/suspicious/noConsoleLog: 52 | console.log('All operations completed successfully.'); 53 | 54 | // delete packages/next-kitchen-sink/.next 55 | fs.rmSync(path.join(nextKitchenSinkDir, '.next'), { 56 | recursive: true, 57 | force: true, 58 | }); 59 | -------------------------------------------------------------------------------- /packages/next-kitchen-sink/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "jsxImportSource": "../dist", 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | --------------------------------------------------------------------------------