├── .nvmrc ├── jsconfig.json ├── .gitignore ├── examples └── parent-child-demo │ ├── jsconfig.json │ ├── public │ └── index.html │ ├── src │ ├── components │ │ ├── SimpleButton.js │ │ ├── LabeledCheckbox.js │ │ └── Tagged.js │ ├── index.js │ ├── App.js │ ├── style.scss │ ├── samples │ │ ├── Legacy.js │ │ └── New.js │ └── Main.js │ └── package.json ├── .npmignore ├── src ├── components │ ├── SimpleButton.jsx │ ├── LogEntries.jsx │ ├── LifecyclePanel.jsx │ └── Log.jsx ├── index.js ├── redux │ ├── VisualizerProvider.jsx │ ├── reducer.js │ └── actionCreators.js ├── util.js ├── constants.js ├── react-lifecycle-visualizer.scss └── traceLifecycle.jsx ├── test ├── setup.js ├── TracedLegacyUnsafeChild.jsx ├── typescript │ ├── tsconfig.json │ └── react-lifecycle-visualizer-typings-test.tsx ├── Wrapper.jsx ├── util.js ├── TracedLegacyChild.jsx ├── TracedChild.jsx ├── unsafe.test.jsx └── integration.test.jsx ├── public └── index.html ├── .github └── workflows │ └── build-test.yml ├── .babelrc ├── LICENSE ├── index.d.ts ├── webpack.config.js ├── scripts └── tag-release.sh ├── .eslintrc.js ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.10.0 2 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/* 3 | /dist-demo/* 4 | npm-debug.log 5 | react-lifecycle-visualizer-*.tgz 6 | -------------------------------------------------------------------------------- /examples/parent-child-demo/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "experimentalDecorators": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/parent-child-demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .history 2 | dist-demo 3 | examples 4 | images 5 | public 6 | src 7 | .babelrc 8 | .eslintrc.js 9 | .gitignore 10 | jsconfig.json 11 | react-lifecycle-visualizer-*.tgz 12 | webpack.config.js 13 | -------------------------------------------------------------------------------- /src/components/SimpleButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const SimpleButton = ({value, onClick}) => ( 4 | {value} 5 | ); 6 | 7 | export default SimpleButton; 8 | -------------------------------------------------------------------------------- /examples/parent-child-demo/src/components/SimpleButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const SimpleButton = ({value, onClick}) => ( 4 | {value} 5 | ); 6 | 7 | export default SimpleButton; 8 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './react-lifecycle-visualizer.scss'; 2 | import Log from './components/Log'; 3 | import traceLifecycle, { resetInstanceIdCounters } from './traceLifecycle'; 4 | import VisualizerProvider, { clearLog } from './redux/VisualizerProvider'; 5 | 6 | export { clearLog, Log, resetInstanceIdCounters, traceLifecycle, VisualizerProvider }; 7 | -------------------------------------------------------------------------------- /examples/parent-child-demo/src/components/LabeledCheckbox.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const LabeledCheckbox = ({label, checked, onChange}) => ( 4 | 8 | ); 9 | 10 | export default LabeledCheckbox; 11 | -------------------------------------------------------------------------------- /examples/parent-child-demo/src/index.js: -------------------------------------------------------------------------------- 1 | /* global document:false */ 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom/client'; 4 | import { VisualizerProvider } from 'react-lifecycle-visualizer'; 5 | 6 | import App from './App'; 7 | 8 | const root = ReactDOM.createRoot(document.getElementById('root')); 9 | root.render( 10 | 11 | 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /examples/parent-child-demo/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { resetInstanceIdCounters, Log } from 'react-lifecycle-visualizer'; 3 | 4 | import './style.scss'; 5 | import Main from './Main'; 6 | 7 | resetInstanceIdCounters(); // clear instance counters on hot reload 8 | 9 | const App = () => ( 10 |
11 |
12 | 13 |
14 | ); 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import { cleanup } from '@testing-library/react'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | 4 | import { clearLog, resetInstanceIdCounters} from '../src'; 5 | 6 | jest.useFakeTimers(); 7 | 8 | afterEach(() => { 9 | // Explicitly call cleanup, run timers and clear the log, or unmount will get logged on next test. 10 | cleanup(); 11 | jest.runAllTimers(); 12 | clearLog(); 13 | resetInstanceIdCounters(); 14 | }); 15 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React-lifecycle-visualizer demo 7 | 8 | 9 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and run tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build-test: 7 | name: Build and test package 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: [16.x, 17.x, 18.x] 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | cache: 'npm' 19 | - run: npm ci 20 | - run: npm test 21 | - run: npm run build-lib 22 | - run: npm run build-demo 23 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", "@babel/react"], 3 | "plugins": [ 4 | // NOTE: plugin-proposal-decorators needs to precede plugin-proposal-class-properties. 5 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 6 | ["@babel/plugin-proposal-class-properties", { "loose": false }] 7 | ], 8 | "env": { 9 | "production": { 10 | "plugins": [ 11 | [ 12 | "transform-rename-import", 13 | { 14 | "original": "./react-lifecycle-visualizer.scss", 15 | "replacement": "./react-lifecycle-visualizer.css" 16 | } 17 | ] 18 | ] 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/TracedLegacyUnsafeChild.jsx: -------------------------------------------------------------------------------- 1 | /* eslint camelcase: 0 */ 2 | import { Component } from 'react'; 3 | import { traceLifecycle } from '../src'; 4 | 5 | class LegacyUnsafeChild extends Component { 6 | UNSAFE_componentWillMount() { 7 | this.props.trace('custom:UNSAFE_componentWillMount'); 8 | } 9 | 10 | UNSAFE_componentWillReceiveProps() { 11 | this.props.trace('custom:UNSAFE_componentWillReceiveProps'); 12 | } 13 | 14 | UNSAFE_componentWillUpdate() { 15 | this.props.trace('custom:UNSAFE_componentWillUpdate'); 16 | } 17 | 18 | render() { 19 | return ''; 20 | } 21 | } 22 | 23 | const TracedLegacyUnsafeChild = traceLifecycle(LegacyUnsafeChild); 24 | 25 | export default TracedLegacyUnsafeChild; 26 | -------------------------------------------------------------------------------- /test/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "react-lifecycle-visualizer-typings-test.tsx" 4 | ], 5 | "compilerOptions": { 6 | "module": "commonjs", 7 | "lib": [ 8 | "es6", 9 | "dom" 10 | ], 11 | "noImplicitAny": true, 12 | "noImplicitThis": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "experimentalDecorators": true, 16 | "baseUrl": "../..", 17 | "jsx": "react", 18 | "typeRoots": [ 19 | "../.." 20 | ], 21 | "types": [], 22 | "noEmit": true, 23 | "paths": { 24 | "react-lifecycle-visualizer": ["index.d.ts"] 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /examples/parent-child-demo/src/components/Tagged.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Tagged = ({name, showProps, children}) => { 4 | const shownProps = !showProps ? '' : ' ' + 5 | Object.entries(showProps).map(([key, val]) => 6 | key + '={' + JSON.stringify(val) + '}' 7 | ).join(' '); 8 | 9 | return ( 10 |
11 | { (React.Children.count(children) === 0) 12 | ? {'<' + name + shownProps + '/>'} 13 | : <> 14 | {'<' + name + shownProps + '>'} 15 |
{children}
16 | {''} 17 | 18 | } 19 |
20 | ); 21 | }; 22 | 23 | export default Tagged; 24 | -------------------------------------------------------------------------------- /examples/parent-child-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-lifecycle-visualizer-demo", 3 | "version": "1.0", 4 | "description": "Dummy configuration for CodeSandbox deployment. Don't use this to build the demo; use the top-level package.json instead", 5 | "main": "src/index.jsx", 6 | "homepage": "https://codesandbox.io/s/github/Oblosys/react-lifecycle-visualizer/tree/master/examples/parent-child-demo?file=/src/samples/New.js", 7 | "scripts": { 8 | "start": "echo \"Error: This dummy package.json is only for CodeSandbox deployment. Use top-level package.json instead.\" && exit 1" 9 | }, 10 | "devDependencies": { 11 | "react-scripts": "^5.0.1" 12 | }, 13 | "dependencies": { 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0", 16 | "react-lifecycle-visualizer": "3.1.1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/redux/VisualizerProvider.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Provider } from 'react-redux'; 4 | import { applyMiddleware, createStore } from 'redux'; 5 | import thunk from 'redux-thunk'; 6 | 7 | import * as actionCreators from './actionCreators'; 8 | import { reducer } from './reducer'; 9 | 10 | // Context for the lifecycle-visualizer store 11 | export const LifecycleVisualizerContext = React.createContext(); 12 | 13 | export const store = createStore( 14 | reducer, 15 | applyMiddleware(thunk) 16 | ); 17 | 18 | export const clearLog = () => store.dispatch(actionCreators.clearLog()); 19 | // The store never changes, so we can safely export this bound function. 20 | 21 | const VisualizerProvider = ({children}) => ( 22 | {children} 23 | ); 24 | 25 | VisualizerProvider.propTypes = { 26 | children: PropTypes.element.isRequired, 27 | }; 28 | 29 | export default VisualizerProvider; 30 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | export const padZeroes = (width, n) => ('' + n).padStart(width, '0'); 2 | 3 | export const getTimeStamp = () => { 4 | const now = new Date(); 5 | return `[${padZeroes(2, now.getHours())}:${padZeroes(2, now.getMinutes())}:` + 6 | `${padZeroes(2, now.getSeconds())}.${padZeroes(3, now.getMilliseconds())}]`; 7 | }; 8 | 9 | const shownWarningLabels = []; 10 | 11 | export const withDeprecationWarning = (warningLabel, fn) => (...args) => { 12 | if (!shownWarningLabels.includes(warningLabel)) { 13 | let message; 14 | switch (warningLabel) { 15 | // case constants.DEPRECATED_DEPRECATED_FEATURE: 16 | // message = 'DEPRECATED_FEATURE is deprecated, please use FEATURE instead.'; 17 | // break; 18 | default: 19 | message = 'Unspecified warning.'; 20 | } 21 | 22 | // eslint-disable-next-line no-console 23 | console.warn(`WARNING: react-lifecycle-visualizer: ${message}`); 24 | shownWarningLabels.push(warningLabel); 25 | } 26 | return fn(...args); 27 | }; 28 | -------------------------------------------------------------------------------- /test/Wrapper.jsx: -------------------------------------------------------------------------------- 1 | import React, {useState } from 'react'; 2 | import { Log, VisualizerProvider } from '../src'; 3 | 4 | const StateToggle = ({testId, checked, setChecked}) => { 5 | const onChange = ({currentTarget}) => setChecked(currentTarget.checked); 6 | 7 | return ; 8 | }; 9 | 10 | // Wrapper to add VisualizerProvider and log, with buttons for showing/hiding child and updating child prop. 11 | export const Wrapper = ({renderChild}) => { 12 | const [isShowingChild, setIsShowingChild] = useState(true); 13 | const [propValue, setPropValue] = useState(false); 14 | return ( 15 | 16 |
17 | 18 | 19 | {isShowingChild && renderChild({prop: true})} 20 | 21 |
22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Martijn Schrage 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export function clearLog(): void; 4 | 5 | export function resetInstanceIdCounters(): void; 6 | 7 | export class VisualizerProvider extends React.Component<{}, {}> {} 8 | 9 | export class Log extends React.Component<{}, {}> {} 10 | 11 | export interface TraceProps { 12 | trace: (msg: string) => void, 13 | LifecyclePanel : () => JSX.Element 14 | } 15 | 16 | // Diff / Omit from https://github.com/Microsoft/TypeScript/issues/12215#issuecomment-311923766 17 | type Diff = 18 | ({ [P in T]: P } & { [P in U]: never } & { [x: string]: never })[T]; 19 | 20 | type Omit = Pick> 21 | 22 | // Simpler TypeScript 2.8+ definition of Omit (disabled for now to support lower TypeScript versions) 23 | // export type Omit = Pick>; 24 | 25 | // Due to https://github.com/DefinitelyTyped/DefinitelyTyped/issues/20796, we cannot use traceLifecycle as a decorator 26 | // in TypeScript, so just do `const TracedComponent = traceLifecycle(ComponentToTrace)` instead. 27 | export function traceLifecycle

( 28 | component: React.ComponentClass

29 | ): React.ComponentClass>; 30 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const ESLintPlugin = require('eslint-webpack-plugin'); 3 | 4 | const devServerPort = 8000; 5 | 6 | module.exports = { 7 | entry: path.join(__dirname, 'examples/parent-child-demo/src/index.js'), 8 | output: { 9 | path: path.join(__dirname, 'dist-demo'), 10 | filename: 'client-bundle.js', 11 | publicPath: '/dist-demo/' 12 | }, 13 | resolve: { 14 | alias: { 15 | 'react-lifecycle-visualizer': path.join(__dirname, 'src'), 16 | }, 17 | extensions: ['.js', '.jsx'] 18 | }, 19 | plugins: [new ESLintPlugin({ emitWarning: true })], 20 | module: { 21 | rules: [{ 22 | test: /\.(js|jsx)$/, 23 | use: [{ 24 | loader: 'babel-loader', 25 | options: { configFile: './.babelrc' } 26 | }], 27 | exclude: /node_modules/ 28 | }, { 29 | test: /\.(css|scss)$/, 30 | use: [ 31 | 'style-loader', 32 | 'css-loader', 33 | { 34 | loader: 'sass-loader', 35 | options: { 36 | // eslint-disable-next-line global-require 37 | implementation: require('sass') 38 | } 39 | } 40 | ] 41 | }] 42 | }, 43 | devServer: { 44 | host: '0.0.0.0', 45 | port: devServerPort, 46 | static: [path.join(__dirname, 'public')] 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | import { prettyDOM, screen } from '@testing-library/react'; 2 | 3 | const showLabel = (label) => (label ? ` (${label})` : ''); 4 | 5 | // Console-log all log entries for debugging. 6 | export const debugShowLogEntries = (label) => { 7 | const entries = [...screen.getByTestId('log-entries').querySelectorAll('.entry')]; 8 | // eslint-disable-next-line no-console 9 | console.log(`log entries${showLabel(label)}:`, entries.map((node) => node.textContent)); 10 | }; 11 | 12 | // Console-log all lifecycle-panel method names for debugging. 13 | export const debugShowPanelMethods = (label) => { 14 | const entries = [...screen.getByTestId('lifecycle-panel').querySelectorAll('.lifecycle-method')]; 15 | // eslint-disable-next-line no-console 16 | console.log(`panel methods${showLabel(label)}:`, entries.map((node) => node.textContent)); 17 | }; 18 | 19 | // Console-log log-entries DOM. 20 | export const debugShowLogEntriesDom = (label) => { 21 | // eslint-disable-next-line no-console 22 | console.log(`Log-entries DOM${showLabel(label)}:`, prettyDOM(screen.getByTestId('log-entries'))); 23 | }; 24 | 25 | // Console-log lifecycle-panel DOM. 26 | export const debugShowLifecyclePanelDom = (label) => { 27 | // eslint-disable-next-line no-console 28 | console.log(`Lifecycle panel DOM${showLabel(label)}:`, prettyDOM(screen.getByTestId('lifecycle-panel'))); 29 | }; 30 | -------------------------------------------------------------------------------- /src/redux/reducer.js: -------------------------------------------------------------------------------- 1 | /* global sessionStorage:false */ 2 | import * as constants from '../constants'; 3 | 4 | const sessionReplayTimerDelay = sessionStorage.getItem(constants.sessionReplayTimerDelayKey); 5 | 6 | const initialState = { 7 | logEntries: [], 8 | highlightedIndex: null, 9 | replayTimerId: null, 10 | replayTimerDelay: sessionReplayTimerDelay ? +sessionReplayTimerDelay : constants.delayValues[1], 11 | }; 12 | 13 | // eslint-disable-next-line default-param-last 14 | export const reducer = (state = initialState, action) => { 15 | // console.log('reducing', action, state); 16 | switch (action.type) { 17 | case 'ADD_LOG_ENTRY': { 18 | const {componentName, instanceId, methodName} = action; 19 | return { 20 | ...state, 21 | logEntries: [...state.logEntries, {componentName, instanceId, methodName}] 22 | }; 23 | } 24 | case 'CLEAR_LOG_ENTRIES': { 25 | return {...state, logEntries: []}; 26 | } 27 | case 'SET_HIGHLIGHT': { 28 | return {...state, highlightedIndex: action.highlightedIndex}; 29 | } 30 | case 'SET_REPLAY_TIMER_ID': { 31 | return {...state, replayTimerId: action.replayTimerId}; 32 | } 33 | case 'SET_REPLAY_TIMER_DELAY': { 34 | return {...state, replayTimerDelay: action.replayTimerDelay}; 35 | } 36 | default: { 37 | return state; 38 | } 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/LogEntries.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | export default class LogEntries extends Component { 4 | highlight = (index) => { 5 | this.props.highlight(index); 6 | }; 7 | 8 | componentDidUpdate(prevProps) { 9 | if (prevProps.entries.length !== this.props.entries.length) { 10 | this.messagesElt.scrollTop = this.messagesElt.scrollHeight - this.messagesElt.clientHeight; 11 | } 12 | } 13 | 14 | render() { 15 | const indexWidth = Math.max(2, 1 + Math.log10(this.props.entries.length)); 16 | const componentNameWidth = 2 + 17 | Math.max(...this.props.entries.map( 18 | ({componentName, instanceId}) => componentName.length + ('' + instanceId).length + 1) 19 | ); 20 | return ( 21 |

{ this.messagesElt = elt; }}> 22 | { this.props.entries.map(({componentName, instanceId, methodName}, i) => ( 23 |
24 |
this.highlight(i)} 28 | > 29 | { ('' + i).padStart(indexWidth) + ' ' + 30 | (componentName + '-' + instanceId + ':').padEnd(componentNameWidth) + 31 | methodName } 32 |
33 |
34 | )) 35 | } 36 |
37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/TracedLegacyChild.jsx: -------------------------------------------------------------------------------- 1 | /* eslint react/no-deprecated: 0 */ 2 | import React, { Component } from 'react'; 3 | import { traceLifecycle } from '../src'; 4 | 5 | class LegacyChild extends Component { 6 | state = {}; 7 | 8 | constructor(props, context) { 9 | super(props, context); 10 | props.trace('custom:constructor'); 11 | } 12 | 13 | componentWillMount() { 14 | this.props.trace('custom:componentWillMount'); 15 | } 16 | 17 | componentWillReceiveProps() { 18 | this.props.trace('custom:componentWillReceiveProps'); 19 | } 20 | 21 | shouldComponentUpdate() { 22 | this.props.trace('custom:shouldComponentUpdate'); 23 | return true; 24 | } 25 | 26 | componentWillUpdate() { 27 | this.props.trace('custom:componentWillUpdate'); 28 | } 29 | 30 | render() { 31 | this.props.trace('custom:render'); 32 | return ( 33 |
34 | 35 | 36 |
37 | ); 38 | } 39 | 40 | componentDidMount() { 41 | this.props.trace('custom:componentDidMount'); 42 | } 43 | 44 | componentDidUpdate() { 45 | this.props.trace('custom:componentDidUpdate'); 46 | } 47 | 48 | componentWillUnmount() { 49 | this.props.trace('custom:componentWillUnmount'); 50 | } 51 | 52 | updateState = () => { 53 | this.setState(() => { 54 | this.props.trace('custom:setState update fn'); 55 | return {}; 56 | }, () => { 57 | this.props.trace('custom:setState callback'); 58 | }); 59 | }; 60 | } 61 | 62 | const TracedLegacyChild = traceLifecycle(LegacyChild); 63 | 64 | export default TracedLegacyChild; 65 | -------------------------------------------------------------------------------- /test/typescript/react-lifecycle-visualizer-typings-test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { resetInstanceIdCounters, clearLog, Log, traceLifecycle, VisualizerProvider, TraceProps } 4 | from 'react-lifecycle-visualizer'; 5 | 6 | // Basic test to check if the typings are consistent. Whether these types correspond to the actual JavaScript 7 | // implementation is currently not tested, as we'd need to do a TypeScript webpack build for that. 8 | 9 | resetInstanceIdCounters(); 10 | clearLog(); 11 | 12 | interface ComponentToTraceProps extends TraceProps {}; 13 | interface ComponentToTraceState {} 14 | 15 | class ComponentToTrace extends React.Component { 16 | constructor(props: ComponentToTraceProps, context?: any) { 17 | props.trace('before super(props)'); 18 | super(props, context); 19 | this.props.trace('after super(props)'); 20 | } 21 | 22 | static getDerivedStateFromProps(nextProps : ComponentToTraceProps, nextState: ComponentToTraceState) { 23 | nextProps.trace('deriving'); 24 | return null; 25 | } 26 | 27 | render() { 28 | return ; 29 | } 30 | } 31 | 32 | // Due to https://github.com/DefinitelyTyped/DefinitelyTyped/issues/20796, we cannot use `traceLifecycle` as a decorator 33 | // in TypeScript, so we just apply it as a function. 34 | const TracedComponent = traceLifecycle(ComponentToTrace); 35 | 36 | const ProvidedComponent = () => ( 37 | 38 |
39 | 40 | 41 |
42 |
43 | ); 44 | render(, document.getElementById('root')); 45 | -------------------------------------------------------------------------------- /test/TracedChild.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { traceLifecycle } from '../src'; 3 | 4 | class Child extends Component { 5 | state = {}; 6 | 7 | static staticProperty = 'a static property'; 8 | 9 | constructor(props, context) { 10 | super(props, context); 11 | props.trace('custom:constructor'); 12 | } 13 | 14 | // eslint-disable-next-line no-unused-vars 15 | static getDerivedStateFromProps(nextProps, prevState) { 16 | nextProps.trace('custom:getDerivedStateFromProps'); 17 | return null; 18 | } 19 | 20 | shouldComponentUpdate() { 21 | this.props.trace('custom:shouldComponentUpdate'); 22 | return true; 23 | } 24 | 25 | render() { 26 | this.props.trace('custom:render'); 27 | return ( 28 |
29 | 30 | 31 |
32 | ); 33 | } 34 | 35 | componentDidMount() { 36 | this.props.trace('custom:componentDidMount'); 37 | } 38 | 39 | getSnapshotBeforeUpdate() { 40 | this.props.trace('custom:getSnapshotBeforeUpdate'); 41 | return null; 42 | } 43 | 44 | componentDidUpdate() { 45 | this.props.trace('custom:componentDidUpdate'); 46 | } 47 | 48 | componentWillUnmount() { 49 | this.props.trace('custom:componentWillUnmount'); 50 | } 51 | 52 | updateState = () => { 53 | this.setState(() => { 54 | this.props.trace('custom:setState update fn'); 55 | return {}; 56 | }, () => { 57 | this.props.trace('custom:setState callback'); 58 | }); 59 | }; 60 | } 61 | 62 | const TracedChild = traceLifecycle(Child); 63 | 64 | export default TracedChild; 65 | -------------------------------------------------------------------------------- /scripts/tag-release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Bump patch version in package.json & examples/parent-child-demo/package.json, commit as "Release X.Y.Z", 4 | # and add git tag "vX.Y.Z" with message "Release X.Y.Z". 5 | 6 | set -o errexit -o pipefail 7 | 8 | if [[ !($1 =~ ^major|minor|patch$) ]]; then 9 | echo "Usage: tag-release.sh [major|minor|patch]" 10 | exit 1 11 | fi 12 | 13 | set -o nounset 14 | 15 | gitStatus=$(git status -s) 16 | if [[ -n $gitStatus ]]; then 17 | echo "ERROR: Working tree has modified/untracked files:" 18 | echo "${gitStatus}" 19 | exit 1 20 | fi 21 | 22 | if [[ `cat package.json` =~ .*\"version\":\ *\"([0-9]+)\.([0-9]+)\.([0-9]+)\" ]] 23 | then 24 | vMajor=${BASH_REMATCH[1]} 25 | vMinor=${BASH_REMATCH[2]} 26 | vPatch=${BASH_REMATCH[3]} 27 | vMajorNew=$vMajor 28 | vMinorNew=$vMinor 29 | vPatchNew=$vPatch 30 | if [[ $1 == major ]]; then vMajorNew=$(( $vMajor + 1 )); vMinorNew=0; vPatchNew=0; fi 31 | if [[ $1 == minor ]]; then vMinorNew=$(( $vMinor + 1 )); vPatchNew=0; fi 32 | if [[ $1 == patch ]]; then vPatchNew=$(( $vPatch + 1 )); fi 33 | newVersion=$vMajorNew.$vMinorNew.$vPatchNew 34 | echo "Bumping version from ${vMajor}.${vMinor}.${vPatch} to ${newVersion}" 35 | 36 | sed -i '' -e "s/\(\"version\": *\"\).*\(\".*\)$/\1${newVersion}\2/" package.json 37 | # Demo dependency is updated manually after release, when the new version has been verified to run on CodeSandbox. 38 | # sed -i '' -e "s/\(\"react-lifecycle-visualizer\": *\"[\^~]\{0,1\}\).*\(\".*\)$/\1${newVersion}\2/" examples/parent-child-demo/package.json 39 | npm i 40 | git add package.json package-lock.json examples/parent-child-demo/package.json 41 | git commit -m "Release ${newVersion}" 42 | git tag -a "v${newVersion}" -m "Release ${newVersion}" 43 | else 44 | echo "ERROR: No \"version\" found in package.json" 45 | exit 1 46 | fi 47 | -------------------------------------------------------------------------------- /examples/parent-child-demo/src/style.scss: -------------------------------------------------------------------------------- 1 | $codeFont: Consolas, monospace; 2 | $uiFont: arial, sans-serif; 3 | 4 | body { 5 | font-family: $uiFont; 6 | font-size: 13px; 7 | height: 100vh; 8 | width: 100vw; 9 | margin: 0; 10 | display: flex; 11 | } 12 | 13 | #root { 14 | margin: 0; 15 | flex-grow: 1; 16 | display: flex; 17 | } 18 | 19 | .app { 20 | flex-grow: 1; 21 | display: flex; 22 | } 23 | 24 | .main { 25 | flex-grow: 1; 26 | display: flex; 27 | flex-direction: column; 28 | border-right: solid #ddd 1px; 29 | 30 | .header { 31 | padding: 5px 5px 0 10px; 32 | border-bottom: solid #ccc 1px; 33 | flex-shrink: 0; 34 | display: flex; 35 | flex-wrap: wrap; 36 | background-color: #f0f0f0; 37 | 38 | > * { 39 | display: flex; 40 | align-items: center; 41 | white-space: nowrap; 42 | margin-bottom: 5px; 43 | } 44 | > *:last-child { 45 | flex-grow: 1; 46 | } 47 | > * > * { 48 | margin-right: 10px; 49 | } 50 | 51 | .simple-button { 52 | font-family: $codeFont; 53 | } 54 | 55 | .github-link { 56 | flex-grow: 1; 57 | text-align: right; 58 | } 59 | } 60 | 61 | .traced-component { 62 | padding: 5px; 63 | flex-grow: 1; 64 | display: flex; 65 | overflow-y: scroll; 66 | } 67 | } 68 | 69 | .controls { 70 | display: flex; 71 | } 72 | 73 | .labeled-checkbox { 74 | display: inline-flex; 75 | align-items: center; 76 | height: 17px; 77 | } 78 | 79 | .simple-button { 80 | display: inline-block; 81 | padding: 1px 3px 0 3px; 82 | font-size: 12px; 83 | border: solid #aaa 1px; 84 | border-radius: 4px; 85 | user-select: none; 86 | background-color: white; 87 | } 88 | .simple-button:not(:last-child) { 89 | margin-right: 5px; 90 | } 91 | .simple-button:hover { 92 | background-color: #ddd; 93 | } 94 | 95 | .tagged { 96 | font-family: $codeFont; 97 | } 98 | 99 | .indented { 100 | padding-left: 1.5em; 101 | } 102 | -------------------------------------------------------------------------------- /src/components/LifecyclePanel.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import * as constants from '../constants'; 5 | import { LifecycleVisualizerContext } from '../redux/VisualizerProvider'; 6 | 7 | const LifecyclePanel = (props) => { 8 | const {componentName, isLegacy, instanceId, highlightedMethod, implementedMethods} = props; 9 | const lifecycleMethodNames = isLegacy ? constants.lifecycleMethodNamesLegacyNoUnsafe : constants.lifecycleMethodNames; 10 | 11 | return ( 12 |
13 |
14 |
{componentName + '-' + instanceId}
15 | { lifecycleMethodNames.map((methodName) => ( 16 | 24 | )) 25 | } 26 |
27 |
28 | ); 29 | }; 30 | 31 | const isHighlighted = (hlMethod, method) => ( 32 | hlMethod !== null && 33 | hlMethod.componentName === method.componentName && 34 | hlMethod.instanceId === method.instanceId && 35 | hlMethod.methodName.startsWith(method.methodName) // for handling 'setState:update fn' & 'setState:callback' 36 | ); 37 | 38 | const LifecycleMethod = (props) => { 39 | const {highlightedMethod, componentName, instanceId, methodName, methodIsImplemented} = props; 40 | const methodIsHighlighted = isHighlighted(highlightedMethod, {componentName, instanceId, methodName}); 41 | return ( 42 |
47 | { methodName } 48 |
49 | ); 50 | }; 51 | 52 | const mapStateToProps = ({logEntries, highlightedIndex}) => ({ 53 | highlightedMethod: highlightedIndex !== null && logEntries[highlightedIndex] 54 | ? logEntries[highlightedIndex] 55 | : null 56 | }); 57 | 58 | export default connect(mapStateToProps, null, null, {context: LifecycleVisualizerContext})(LifecyclePanel); 59 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["airbnb", "plugin:jest/recommended"], 3 | parser: "@babel/eslint-parser", 4 | parserOptions: { 5 | requireConfigFile: false, 6 | ecmaFeatures: { 7 | legacyDecorators: true 8 | }, 9 | babelOptions: { configFile: './.babelrc' }, 10 | }, 11 | rules: { 12 | "linebreak-style": [1, "unix"], 13 | quotes: [1, "single"], 14 | "max-len": [1, 120], 15 | "no-multiple-empty-lines": [1, { max: 2, maxBOF: 0, maxEOF: 0 }], 16 | "no-trailing-spaces": 1, 17 | "react/jsx-tag-spacing": [1, { beforeSelfClosing: "never" }], 18 | "arrow-parens": [1, "always"], 19 | "import/no-extraneous-dependencies": [1, { devDependencies: true }], 20 | "react/jsx-filename-extension": [1, { extensions: [".js", ".jsx"] }], 21 | "no-unused-vars": 1, 22 | "import/first": 1, 23 | "import/newline-after-import": 1, 24 | semi: 1, 25 | "react/prefer-stateless-function": 1, 26 | "block-spacing": 1, 27 | "comma-spacing": 1, 28 | "semi-spacing": 1, 29 | "padded-blocks": 1, 30 | "key-spacing": 1, 31 | "no-plusplus": [1, { allowForLoopAfterthoughts: true }], 32 | "eol-last": 1, 33 | 34 | "no-underscore-dangle": 0, 35 | "jsx-filename-extension": 0, 36 | "comma-dangle": 0, 37 | "jsx-quotes": [0, "prefer-single"], 38 | "object-curly-spacing": 0, 39 | "no-multi-spaces": 0, 40 | "import/prefer-default-export": 0, 41 | "import/no-duplicates": 0, 42 | "react/no-array-index-key": 0, 43 | "react/sort-comp": 0, 44 | "class-methods-use-this": 0, 45 | "default-case": 0, 46 | "no-mixed-operators": 0, 47 | "operator-linebreak": 0, 48 | "react/destructuring-assignment": 0, 49 | "implicit-arrow-linebreak": 0, 50 | "react/jsx-one-expression-per-line": 0, 51 | "react/jsx-props-no-multi-spaces": 0, 52 | "react/jsx-wrap-multilines": 0, 53 | "react/state-in-constructor": 0, 54 | "react/jsx-curly-newline": 0, 55 | "react/function-component-definition": 0, 56 | 57 | // Maybe enable later: 58 | "jsx-a11y/click-events-have-key-events": 0, 59 | "jsx-a11y/label-has-associated-control": 0, 60 | "jsx-a11y/label-has-for": 0, 61 | "jsx-a11y/no-static-element-interactions": 0, 62 | "prefer-template": 0, 63 | indent: 0, 64 | "function-paren-newline": 0, 65 | "object-curly-newline": 0, 66 | "react/jsx-indent": 0, 67 | "react/prop-types": 0, 68 | "react/forbid-prop-types": 0, 69 | 70 | // For importing react-lifecycle-visualizer from /src instead of /node_modules: 71 | "import/no-unresolved": [2, { ignore: ["^react-lifecycle-visualizer$"] }], 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const shouldLogInConsole = false; 2 | 3 | export const delayValues = [0.25, 0.5, 1, 2, 5, 10]; 4 | 5 | export const MConstructor = 'constructor'; 6 | export const MDidMount = 'componentDidMount'; 7 | export const MShouldUpdate = 'shouldComponentUpdate'; 8 | export const MRender = 'render'; 9 | export const MDidUpdate = 'componentDidUpdate'; 10 | export const MWillUnmount = 'componentWillUnmount'; 11 | export const MSetState = 'setState'; 12 | 13 | export const MGetDerivedState = 'static getDerivedStateFromProps'; 14 | export const MGetSnapshot = 'getSnapshotBeforeUpdate'; 15 | 16 | export const MWillMount = 'componentWillMount'; 17 | export const MWillReceiveProps = 'componentWillReceiveProps'; 18 | export const MWillUpdate = 'componentWillUpdate'; 19 | 20 | export const MUnsafeWillMount = 'UNSAFE_componentWillMount'; 21 | export const MUnsafeWillReceiveProps = 'UNSAFE_componentWillReceiveProps'; 22 | export const MUnsafeWillUpdate = 'UNSAFE_componentWillUpdate'; 23 | 24 | const lifecycleMethods = [ 25 | {isLegacy: false, isNew: false, name: MConstructor}, 26 | {isLegacy: true, isNew: false, name: MWillMount}, 27 | {isLegacy: true, isNew: false, name: MUnsafeWillMount}, 28 | {isLegacy: false, isNew: true, name: MGetDerivedState}, 29 | {isLegacy: true, isNew: false, name: MWillReceiveProps}, 30 | {isLegacy: true, isNew: false, name: MUnsafeWillReceiveProps}, 31 | {isLegacy: false, isNew: false, name: MShouldUpdate}, 32 | {isLegacy: true, isNew: false, name: MWillUpdate}, 33 | {isLegacy: true, isNew: false, name: MUnsafeWillUpdate}, 34 | {isLegacy: false, isNew: false, name: MRender}, 35 | {isLegacy: false, isNew: false, name: MDidMount}, 36 | {isLegacy: false, isNew: true, name: MGetSnapshot}, 37 | {isLegacy: false, isNew: false, name: MDidUpdate}, 38 | {isLegacy: false, isNew: false, name: MWillUnmount}, 39 | {isLegacy: false, isNew: false, name: MSetState} 40 | ]; 41 | 42 | export const lifecycleMethodNames = 43 | lifecycleMethods.filter((mthd) => !mthd.isLegacy).map(({name}) => name); 44 | 45 | // We don't show 'UNSAFE_..' in the panel, but just use the shorter old names. 46 | export const lifecycleMethodNamesLegacyNoUnsafe = 47 | lifecycleMethods.filter( 48 | (mthd) => !mthd.isNew && !mthd.name.startsWith('UNSAFE_') 49 | ).map(({name}) => name); 50 | 51 | export const lifecycleMethodNamesNewOnly = 52 | lifecycleMethods.filter((mthd) => mthd.isNew).map(({name}) => name); 53 | 54 | export const lifecycleMethodNamesLegacyOnly = 55 | lifecycleMethods.filter((mthd) => mthd.isLegacy).map(({name}) => name); 56 | 57 | const sessionStorageKey = '@@react-lifecycle-visualizer--persistent-state:'; 58 | export const sessionReplayTimerDelayKey = sessionStorageKey + 'replayTimerDelay'; 59 | -------------------------------------------------------------------------------- /examples/parent-child-demo/src/samples/Legacy.js: -------------------------------------------------------------------------------- 1 | /* eslint arrow-body-style: 0, max-classes-per-file: 0, no-unused-vars: [1, { "args": "none" }] */ 2 | /* eslint react/no-deprecated: 0 */ 3 | import React, { Component } from 'react'; 4 | import { traceLifecycle } from 'react-lifecycle-visualizer'; 5 | 6 | import SimpleButton from '../components/SimpleButton'; 7 | import LabeledCheckbox from '../components/LabeledCheckbox'; 8 | import Tagged from '../components/Tagged'; 9 | 10 | @traceLifecycle 11 | class Parent extends Component { 12 | state = { 13 | showLastChild: true, 14 | x: 42 15 | }; 16 | 17 | onCheckboxChange = (evt) => { 18 | this.setState({ 19 | showLastChild: evt.currentTarget.checked 20 | }); 21 | }; 22 | 23 | incX = () => { 24 | this.props.trace('Custom message, calling incX'); 25 | this.setState(({x}) => { 26 | return {x: x + 1}; 27 | }); 28 | }; 29 | 30 | componentWillMount() { 31 | this.props.trace('Don\'t use componentWillMount!'); 32 | } 33 | 34 | render() { 35 | return ( 36 | 37 |
state = {JSON.stringify(this.state)}
38 |
39 | this.forceUpdate()}/> 40 | 41 | 46 |
47 | 48 | 49 | { this.state.showLastChild && 50 | } 51 |
52 | ); 53 | } 54 | } 55 | 56 | @traceLifecycle 57 | class Child extends Component { 58 | state = { 59 | y: 1, 60 | squaredX: this.props.x ** 2 61 | }; 62 | 63 | incY = () => { 64 | this.setState((prevState) => { 65 | return {y: prevState.y + 1}; 66 | }); 67 | }; 68 | 69 | componentWillReceiveProps(nextProps) { 70 | this.setState({squaredX: nextProps.x ** 2}); 71 | } 72 | 73 | render() { 74 | return ( 75 | 76 |
state = {JSON.stringify(this.state)}
77 |
78 | this.forceUpdate()}/> 79 | this.props.incX()}/> 80 | this.incY()}/> 81 | { this.incY(); this.props.incX(); }}/> 82 |
83 | 84 |
85 | ); 86 | } 87 | } 88 | 89 | export default Parent; 90 | -------------------------------------------------------------------------------- /examples/parent-child-demo/src/samples/New.js: -------------------------------------------------------------------------------- 1 | /* eslint arrow-body-style: 0, max-classes-per-file: 0, no-unused-vars: [1, { "args": "none" }] */ 2 | import React, { Component } from 'react'; 3 | import { traceLifecycle } from 'react-lifecycle-visualizer'; 4 | 5 | import SimpleButton from '../components/SimpleButton'; 6 | import LabeledCheckbox from '../components/LabeledCheckbox'; 7 | import Tagged from '../components/Tagged'; 8 | 9 | @traceLifecycle 10 | class Parent extends Component { 11 | state = { 12 | showLastChild: true, 13 | x: 42 14 | }; 15 | 16 | onCheckboxChange = (evt) => { 17 | this.setState({ 18 | showLastChild: evt.currentTarget.checked 19 | }); 20 | }; 21 | 22 | incX = () => { 23 | this.props.trace('Custom message, calling incX'); 24 | this.setState(({x}) => { 25 | return {x: x + 1}; 26 | }); 27 | }; 28 | 29 | render() { 30 | return ( 31 | 32 |
state = {JSON.stringify(this.state)}
33 |
34 | this.forceUpdate()}/> 35 | 36 | 41 |
42 | 43 | 44 | { this.state.showLastChild && 45 | } 46 |
47 | ); 48 | } 49 | } 50 | 51 | @traceLifecycle 52 | class Child extends Component { 53 | state = { 54 | y: 1 55 | }; 56 | 57 | incY = () => { 58 | this.setState((prevState) => { 59 | return {y: prevState.y + 1}; 60 | }); 61 | }; 62 | 63 | static getDerivedStateFromProps(nextProps, prevState) { 64 | nextProps.trace('nextProps: ' + JSON.stringify(nextProps)); 65 | return {squaredX: nextProps.x ** 2}; 66 | } 67 | 68 | getSnapshotBeforeUpdate(prevProps, prevState) { 69 | return null; 70 | } 71 | 72 | componentDidUpdate(prevProps, prevState) { 73 | } 74 | 75 | render() { 76 | return ( 77 | 78 |
state = {JSON.stringify(this.state)}
79 |
80 | this.forceUpdate()}/> 81 | this.props.incX()}/> 82 | this.incY()}/> 83 | { this.incY(); this.props.incX(); }}/> 84 |
85 | 86 |
87 | ); 88 | } 89 | } 90 | 91 | export default Parent; 92 | -------------------------------------------------------------------------------- /src/components/Log.jsx: -------------------------------------------------------------------------------- 1 | /* global document:false */ 2 | import React, { Component } from 'react'; 3 | import { connect } from 'react-redux'; 4 | 5 | import * as constants from '../constants'; 6 | import * as ActionCreators from '../redux/actionCreators'; 7 | import { LifecycleVisualizerContext } from '../redux/VisualizerProvider'; 8 | import LogEntries from './LogEntries'; 9 | import SimpleButton from './SimpleButton'; 10 | 11 | const DelaySelector = ({value, onChange}) => ( 12 | 18 | ); 19 | 20 | class Log extends Component { 21 | onKeyDown = (evt) => { 22 | if (evt.shiftKey) { // Require shift to prevent interference with scrolling 23 | switch (evt.code) { 24 | case 'ArrowUp': 25 | this.props.stepLog(-1); 26 | break; 27 | case 'ArrowDown': 28 | this.props.stepLog(1); 29 | break; 30 | } 31 | } 32 | }; 33 | 34 | componentDidMount() { 35 | document.addEventListener('keydown', this.onKeyDown); 36 | } 37 | 38 | componentWillUnmount() { 39 | document.removeEventListener('keydown', this.onKeyDown); 40 | } 41 | 42 | render() { 43 | return ( 44 |
45 |
46 |
47 |
Log
48 |
49 | this.props.clearLog()}/>{' '} 50 | this.props.stepLog(-1)}>{'\u23EA'} 51 | { this.props.replayTimerId === null 52 | ? this.props.startReplay()}>{'\u25B6\uFE0F'} 53 | : this.props.pauseReplay()}>{'\u23F8'} } 54 | this.props.stepLog(1)}>{'\u23E9'} 55 |
56 |
57 | Delay:{' '} 58 | this.props.setDelay(+evt.currentTarget.value)} 61 | /> 62 |
63 |
64 |
65 | (hover to highlight, shift-up/down to navigate) 66 |
67 |
68 | 73 |
74 | ); 75 | } 76 | } 77 | 78 | const mapStateToProps = ({logEntries, highlightedIndex, replayTimerId, replayTimerDelay}) => 79 | ({logEntries, highlightedIndex, replayTimerId, replayTimerDelay}); 80 | 81 | export default connect(mapStateToProps, ActionCreators, null, {context: LifecycleVisualizerContext})(Log); 82 | -------------------------------------------------------------------------------- /src/react-lifecycle-visualizer.scss: -------------------------------------------------------------------------------- 1 | $codeFont: Consolas, monospace; 2 | $uiFont: arial, sans-serif; 3 | 4 | .log { 5 | padding: 3px 8px 5px 8px; 6 | flex-shrink: 0; 7 | display: flex; 8 | flex-direction: column; 9 | 10 | .header { 11 | font-family: $uiFont; 12 | flex-basis: 3ex; 13 | flex-shrink: 0; 14 | user-select: none; 15 | 16 | .controls { 17 | display: flex; 18 | align-items: center; 19 | justify-content: space-between; 20 | line-height: 22px; 21 | 22 | .title { 23 | font-size: 130%; 24 | font-weight: bold; 25 | } 26 | 27 | .buttons { 28 | display: flex; 29 | align-items: center; 30 | 31 | .simple-button { 32 | position: relative; 33 | top: 1px; 34 | } 35 | .emoji-button { 36 | font-size: 140%; 37 | } 38 | } 39 | } 40 | 41 | .hint { 42 | padding-top: 2px; 43 | font-family: $codeFont; 44 | line-height: 20px; 45 | text-align: left; 46 | } 47 | } 48 | 49 | .entries { 50 | font-family: $codeFont; 51 | flex-grow: 1; 52 | overflow-y: scroll; 53 | background-color: #f0f0f0; 54 | border: solid #ccc 1px; 55 | 56 | .entry-wrapper { 57 | animation-name: emphasize; 58 | animation-duration: 10s; 59 | } 60 | 61 | @keyframes emphasize { 62 | from {background-color: lightblue;} 63 | to {background-color: inherit;} 64 | } 65 | 66 | .entry { 67 | padding-left: 2px; 68 | padding-right: 2px; 69 | white-space: pre; 70 | cursor: default; 71 | } 72 | .entry[data-is-highlighted='true'] { 73 | background-color: yellow; 74 | } 75 | } 76 | 77 | .simple-button { 78 | display: inline-block; 79 | padding: 1px 3px 0 3px; 80 | margin-bottom: 2px; 81 | height: 13px; 82 | line-height: 12px; 83 | font-size: 12px; 84 | border: solid #aaa 1px; 85 | border-radius: 4px; 86 | user-select: none; 87 | background-color: white; 88 | } 89 | .simple-button:hover { 90 | background-color: #ddd; 91 | } 92 | } 93 | 94 | .lifecycle-panel { 95 | padding: 2px 0 2px 0; 96 | } 97 | .lifecycle-panel-inner { 98 | font-family: $codeFont; 99 | display: inline-flex; 100 | flex-shrink: 0; 101 | flex-direction: column; 102 | align-items: flex-start; 103 | background-color: #dde; 104 | padding: 4px; 105 | border: solid #888 1px; 106 | border-radius: 4px; 107 | position: relative; 108 | 109 | .component-instance { 110 | color: #666; 111 | position: absolute; 112 | /* align-self: flex-end; */ 113 | top: 1px; 114 | right: 0px; 115 | padding-right: 4px; 116 | } 117 | 118 | .lifecycle-method::BEFORE { 119 | content: '-'; 120 | } 121 | .lifecycle-method { 122 | border-radius: 4px; 123 | } 124 | .lifecycle-method[data-is-implemented='false'] { 125 | color: #666; 126 | } 127 | .lifecycle-method[data-is-highlighted='false'] { 128 | background-color: inherit; 129 | transition: background-color 0.3s; 130 | } 131 | .lifecycle-method[data-is-highlighted='true'] { 132 | background-color: yellow; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /examples/parent-child-demo/src/Main.js: -------------------------------------------------------------------------------- 1 | /* global sessionStorage:false */ 2 | import React, { Component } from 'react'; 3 | import { clearLog } from 'react-lifecycle-visualizer'; 4 | 5 | import SimpleButton from './components/SimpleButton'; 6 | import LabeledCheckbox from './components/LabeledCheckbox'; 7 | import SampleNew from './samples/New'; 8 | import SampleLegacy from './samples/Legacy'; 9 | 10 | const sampleComponents = [ 11 | { label: 'New lifecycle methods', component: SampleNew, filename: 'New.js' }, 12 | { label: 'Legacy lifecycle methods', component: SampleLegacy, filename: 'Legacy.js' } 13 | ]; 14 | 15 | const getCodeSandboxUrl = (filename) => 16 | 'https://codesandbox.io/s/github/Oblosys/react-lifecycle-visualizer/tree/master/' + 17 | 'examples/parent-child-demo?file=/src/samples/' + filename; 18 | 19 | const sessionStorageKey = '@@react-lifecycle-visualizer-demo--persistent-state:'; 20 | export const sessionSelectedSampleIxKey = sessionStorageKey + 'selectedSampleIx'; 21 | const sessionSelectedSampleIx = sessionStorage.getItem(sessionSelectedSampleIxKey); 22 | 23 | const SampleSelector = ({value, onChange}) => ( 24 | 30 | ); 31 | 32 | export default class Main extends Component { 33 | state = { 34 | selectedSampleIx: sessionSelectedSampleIx ? +sessionSelectedSampleIx : 0, 35 | isShowingParent: true 36 | }; 37 | 38 | onCheckboxChange = (evt) => { 39 | this.setState({ 40 | isShowingParent: evt.currentTarget.checked 41 | }); 42 | }; 43 | 44 | onSelectSample = (evt) => { 45 | const selectedSampleIx = +evt.currentTarget.value; 46 | this.setState({selectedSampleIx}); 47 | sessionStorage.setItem(sessionSelectedSampleIxKey, selectedSampleIx); 48 | clearLog(); 49 | }; 50 | 51 | render() { 52 | const selectedSample = sampleComponents[this.state.selectedSampleIx]; 53 | const SelectedSample = selectedSample.component; 54 | return ( 55 |
56 |
57 |
58 | 59 | {'Sample: '} 60 | 64 | 65 | edit source 66 |
67 |
68 | this.forceUpdate()}/> 69 | 74 | 80 | GitHub 81 | 82 |
83 |
84 |
85 | { this.state.isShowingParent && } 86 |
87 |
88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/redux/actionCreators.js: -------------------------------------------------------------------------------- 1 | /* global sessionStorage:false */ 2 | /* eslint no-unused-vars: [1, { "args": "none" }] */ 3 | import * as constants from '../constants'; 4 | import * as util from '../util'; 5 | 6 | // Primitive actions: 7 | 8 | const addLogEntry = (componentName, instanceId, methodName) => ( 9 | {type: 'ADD_LOG_ENTRY', componentName, instanceId, methodName} 10 | ); 11 | 12 | const clearLogEntries = () => ( 13 | {type: 'CLEAR_LOG_ENTRIES'} 14 | ); 15 | 16 | const setHighlight = (highlightedIndex) => ( 17 | {type: 'SET_HIGHLIGHT', highlightedIndex} 18 | ); 19 | 20 | const setReplayTimerId = (replayTimerId) => ( 21 | {type: 'SET_REPLAY_TIMER_ID', replayTimerId} 22 | ); 23 | 24 | const setReplayTimerDelayPrim = (replayTimerDelay) => ( 25 | {type: 'SET_REPLAY_TIMER_DELAY', replayTimerDelay} 26 | ); 27 | 28 | // Thunk actions: 29 | 30 | export const pauseReplay = () => (dispatch, getState) => { 31 | const {replayTimerId} = getState(); 32 | if (replayTimerId !== null) { 33 | clearInterval(replayTimerId); 34 | dispatch(setReplayTimerId(null)); 35 | } 36 | }; 37 | 38 | const replayStep = () => (dispatch, getState) => { 39 | const {highlightedIndex, logEntries} = getState(); 40 | if (highlightedIndex < logEntries.length - 1) { 41 | dispatch(setHighlight(highlightedIndex + 1)); 42 | } else { 43 | dispatch(pauseReplay()); 44 | } 45 | }; 46 | 47 | export const startReplay = () => (dispatch, getState) => { 48 | const {replayTimerId, replayTimerDelay} = getState(); 49 | if (replayTimerId === null) { 50 | const timerId = setInterval( 51 | () => dispatch(replayStep()), 52 | replayTimerDelay * 1000 53 | ); 54 | dispatch(setReplayTimerId(timerId)); 55 | } 56 | }; 57 | 58 | export const highlight = (highlightedIndex) => (dispatch, getState) => { 59 | dispatch(pauseReplay()); 60 | dispatch(setHighlight(highlightedIndex)); 61 | }; 62 | 63 | export const stepLog = (step) => (dispatch, getState) => { 64 | const {highlightedIndex, logEntries} = getState(); 65 | dispatch(pauseReplay()); 66 | const newIndex = highlightedIndex + step; 67 | const clippedIndex = Math.min(logEntries.length - 1, Math.max(0, newIndex)); 68 | dispatch(setHighlight(clippedIndex)); 69 | }; 70 | 71 | export const trace = (componentName, instanceId, methodName) => (dispatch, getState) => { 72 | if (constants.shouldLogInConsole) { 73 | /* eslint no-console: 0 */ 74 | console.log(`${util.getTimeStamp()} ${componentName}-${instanceId}: ${methodName}`); 75 | } 76 | 77 | setTimeout(() => { // Async, so we can log from render 78 | const {logEntries, replayTimerId} = getState(); 79 | dispatch(addLogEntry(componentName, instanceId, '' + methodName)); 80 | if (replayTimerId === null) { 81 | dispatch(setHighlight(logEntries.length)); 82 | dispatch(startReplay()); 83 | } 84 | }, 0); 85 | }; 86 | 87 | export const clearLog = () => (dispatch, getState) => { 88 | dispatch(pauseReplay()); 89 | dispatch(clearLogEntries()); 90 | }; 91 | 92 | export const setReplayTimerDelay = (replayTimerDelay) => (dispatch, getState) => { 93 | sessionStorage.setItem(constants.sessionReplayTimerDelayKey, replayTimerDelay); 94 | dispatch(setReplayTimerDelayPrim(replayTimerDelay)); 95 | }; 96 | 97 | export const setDelay = (replayTimerDelay) => (dispatch, getState) => { 98 | dispatch(setReplayTimerDelay(replayTimerDelay)); 99 | const {replayTimerId} = getState(); 100 | if (replayTimerId !== null) { 101 | dispatch(pauseReplay()); 102 | const timerId = setInterval(() => dispatch(replayStep()), replayTimerDelay * 1000); 103 | dispatch(setReplayTimerId(timerId)); 104 | } 105 | }; 106 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-lifecycle-visualizer", 3 | "author": "Martijn Schrage (https://www.oblomov.com)", 4 | "version": "3.1.1", 5 | "description": "Visualizer for React lifecycle methods", 6 | "main": "./dist/index.js", 7 | "typings": "./index.d.ts", 8 | "scripts": { 9 | "lint": "eslint --ext js,jsx --max-warnings 0 src test examples", 10 | "test": "npm run lint && npm run test-typings && jest", 11 | "test-typings": "tsc -p test/typescript", 12 | "start": "webpack-dev-server --mode development", 13 | "build-demo": "webpack --mode development", 14 | "clean-lib": "rm -rf dist", 15 | "build-lib": "npm run clean-lib && sass --embed-sources src/react-lifecycle-visualizer.scss dist/react-lifecycle-visualizer.css && BABEL_ENV=production babel src -d dist --ignore react-lifecycle-visualizer.scss --copy-files", 16 | "prepublishOnly": "npm run test && npm run build-lib" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/Oblosys/react-lifecycle-visualizer.git" 21 | }, 22 | "keywords": [ 23 | "react", 24 | "reactjs", 25 | "lifecycle", 26 | "trace", 27 | "visualize", 28 | "animated" 29 | ], 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/Oblosys/react-lifecycle-visualizer/issues" 33 | }, 34 | "homepage": "https://github.com/Oblosys/react-lifecycle-visualizer#readme", 35 | "husky": { 36 | "hooks": { 37 | "pre-commit": "npm run lint", 38 | "pre-push": "npm test" 39 | } 40 | }, 41 | "jest": { 42 | "testEnvironment": "jsdom", 43 | "setupFilesAfterEnv": [ 44 | "/test/setup.js" 45 | ], 46 | "moduleNameMapper": { 47 | "\\.(scss)$": "/node_modules/jest-css-modules" 48 | } 49 | }, 50 | "devDependencies": { 51 | "@babel/cli": "^7.21.5", 52 | "@babel/core": "^7.21.8", 53 | "@babel/eslint-parser": "^7.21.8", 54 | "@babel/plugin-proposal-class-properties": "^7.18.6", 55 | "@babel/plugin-proposal-decorators": "^7.21.0", 56 | "@babel/plugin-transform-react-jsx-source": "^7.19.6", 57 | "@babel/preset-env": "^7.21.5", 58 | "@babel/preset-react": "^7.18.6", 59 | "@babel/preset-stage-2": "^7.8.3", 60 | "@testing-library/jest-dom": "^5.16.5", 61 | "@testing-library/react": "^14.0.0", 62 | "@testing-library/user-event": "^14.4.3", 63 | "@types/react": "^18.2.5", 64 | "@types/react-dom": "^18.2.4", 65 | "babel-loader": "^9.1.2", 66 | "babel-plugin-transform-rename-import": "^2.3.0", 67 | "css-loader": "^6.7.3", 68 | "eslint": "^8.39.0", 69 | "eslint-config-airbnb": "^19.0.4", 70 | "eslint-plugin-import": "^2.27.5", 71 | "eslint-plugin-jest": "^27.2.1", 72 | "eslint-plugin-jsx-a11y": "^6.7.1", 73 | "eslint-plugin-react": "^7.32.2", 74 | "eslint-plugin-react-hooks": "^4.6.0", 75 | "eslint-webpack-plugin": "^4.0.1", 76 | "husky": "^8.0.3", 77 | "jest": "^29.5.0", 78 | "jest-css-modules": "^2.1.0", 79 | "jest-environment-jsdom": "^29.5.0", 80 | "react": "^18.2.0", 81 | "react-dom": "^18.2.0", 82 | "sass": "^1.62.1", 83 | "sass-loader": "^13.2.2", 84 | "style-loader": "^3.3.2", 85 | "typescript": "^5.0.4", 86 | "webpack": "^5.82.0", 87 | "webpack-cli": "^5.0.2", 88 | "webpack-dev-server": "^4.13.3" 89 | }, 90 | "dependencies": { 91 | "hoist-non-react-statics": "^3.3.2", 92 | "prop-types": "^15.8.1", 93 | "react-redux": "^8.0.5", 94 | "redux": "^4.2.1", 95 | "redux-thunk": "^2.4.2" 96 | }, 97 | "peerDependencies": { 98 | "react": "^16.3.0 || ^17.0.0 || ^18.00", 99 | "react-dom": "^16.3.0 || ^17.0.0 || ^18.00" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /test/unsafe.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint camelcase: 0, max-classes-per-file: 0, lines-between-class-members: 0, react/no-deprecated: 0 */ 2 | import React, { Component } from 'react'; 3 | import { render } from '@testing-library/react'; 4 | import ReactTestUtils from 'react-dom/test-utils'; 5 | 6 | import { traceLifecycle } from '../src'; 7 | 8 | // React Testing Library discourages tests on instances, but these test are extremely tedious to express without them, 9 | // as there is no observable difference between the legacy methods and their UNSAFE_ counterparts. 10 | 11 | // Return the TracedComponent instance for traceLifecycle(Comp). 12 | const getTracedComponentInstance = (Comp) => { 13 | let tracingComponentInstance; // Need TracingComponent instance as root for ReactTestUtils.findAllInRenderedTree(). 14 | const TracingComp = traceLifecycle(Comp); 15 | class SpyComp extends TracingComp { 16 | constructor(props, context) { 17 | super(props, context); 18 | tracingComponentInstance = this; 19 | } 20 | } 21 | 22 | /* eslint-disable no-console */ 23 | // Disable console.warn to suppress React warnings about using legacy methods (emitted once per method). 24 | const consoleWarn = console.warn; 25 | console.warn = () => {}; 26 | render(); 27 | console.warn = consoleWarn; 28 | /* eslint-enable no-console */ 29 | 30 | const [tracedInstance] = 31 | ReactTestUtils.findAllInRenderedTree(tracingComponentInstance, (c) => c.constructor.name === 'TracedComponent'); 32 | return tracedInstance; 33 | }; 34 | 35 | describe('unsafe', () => { 36 | it('Traces old methods if only old methods are defined', () => { 37 | class Comp extends Component { 38 | componentWillMount() {} 39 | componentWillReceiveProps() {} 40 | componentWillUpdate() {} 41 | render() { 42 | return ''; 43 | } 44 | } 45 | 46 | const tracedInstance = getTracedComponentInstance(Comp); 47 | expect(tracedInstance).toHaveProperty('componentWillMount'); 48 | expect(tracedInstance).toHaveProperty('componentWillReceiveProps'); 49 | expect(tracedInstance).toHaveProperty('componentWillUpdate'); 50 | expect(tracedInstance).not.toHaveProperty('UNSAFE_componentWillMount'); 51 | expect(tracedInstance).not.toHaveProperty('UNSAFE_componentWillReceiveProps'); 52 | expect(tracedInstance).not.toHaveProperty('UNSAFE_componentWillUpdate'); 53 | }); 54 | 55 | it('Traces UNSAFE_ methods if only UNSAFE_ methods are defined', () => { 56 | class Comp extends Component { 57 | UNSAFE_componentWillMount() {} 58 | UNSAFE_componentWillReceiveProps() {} 59 | UNSAFE_componentWillUpdate() {} 60 | render() { 61 | return ''; 62 | } 63 | } 64 | 65 | const tracedInstance = getTracedComponentInstance(Comp); 66 | expect(tracedInstance).not.toHaveProperty('componentWillMount'); 67 | expect(tracedInstance).not.toHaveProperty('componentWillReceiveProps'); 68 | expect(tracedInstance).not.toHaveProperty('componentWillUpdate'); 69 | expect(tracedInstance).toHaveProperty('UNSAFE_componentWillMount'); 70 | expect(tracedInstance).toHaveProperty('UNSAFE_componentWillReceiveProps'); 71 | expect(tracedInstance).toHaveProperty('UNSAFE_componentWillUpdate'); 72 | }); 73 | 74 | it('Traces UNSAFE_ methods if neither old nor UNSAFE_ methods are defined (1)', () => { 75 | // Need two tests, since we need to define at least one UNSAFE_ method to turn it into a legacy component. 76 | class Comp extends Component { 77 | UNSAFE_componentWillMount() {} 78 | render() { 79 | return ''; 80 | } 81 | } 82 | 83 | const tracedInstance = getTracedComponentInstance(Comp); 84 | expect(tracedInstance).not.toHaveProperty('componentWillReceiveProps'); 85 | expect(tracedInstance).not.toHaveProperty('componentWillUpdate'); 86 | expect(tracedInstance).toHaveProperty('UNSAFE_componentWillReceiveProps'); 87 | expect(tracedInstance).toHaveProperty('UNSAFE_componentWillUpdate'); 88 | }); 89 | 90 | it('Traces UNSAFE_ methods if neither old nor UNSAFE_ methods are defined (2)', () => { 91 | class Comp extends Component { 92 | UNSAFE_componentWillReceiveProps() {} 93 | render() { 94 | return ''; 95 | } 96 | } 97 | 98 | const tracedInstance = getTracedComponentInstance(Comp); 99 | expect(tracedInstance).not.toHaveProperty('componentWillMount'); 100 | expect(tracedInstance).toHaveProperty('UNSAFE_componentWillMount'); 101 | }); 102 | 103 | it('Traces both old and UNSAFE_ methods if both are defined', () => { 104 | // Kind of silly, but this is allowed by React. 105 | class Comp extends Component { 106 | UNSAFE_componentWillMount() {} 107 | UNSAFE_componentWillReceiveProps() {} 108 | UNSAFE_componentWillUpdate() {} 109 | componentWillMount() {} 110 | componentWillReceiveProps() {} 111 | componentWillUpdate() {} 112 | render() { 113 | return ''; 114 | } 115 | } 116 | 117 | const tracedInstance = getTracedComponentInstance(Comp); 118 | expect(tracedInstance).toHaveProperty('componentWillMount'); 119 | expect(tracedInstance).toHaveProperty('componentWillReceiveProps'); 120 | expect(tracedInstance).toHaveProperty('componentWillUpdate'); 121 | expect(tracedInstance).toHaveProperty('UNSAFE_componentWillMount'); 122 | expect(tracedInstance).toHaveProperty('UNSAFE_componentWillReceiveProps'); 123 | expect(tracedInstance).toHaveProperty('UNSAFE_componentWillUpdate'); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Lifecycle Visualizer [![Npm version](https://img.shields.io/npm/v/react-lifecycle-visualizer.svg?style=flat)](https://www.npmjs.com/package/react-lifecycle-visualizer) [![Build status](https://img.shields.io/github/actions/workflow/status/Oblosys/react-lifecycle-visualizer/build-test.yml?branch=master)](https://github.com/Oblosys/react-lifecycle-visualizer/actions/workflows/build-test.yml?query=branch/master) 2 | 3 | An npm package ([`react-lifecycle-visualizer`](https://www.npmjs.com/package/react-lifecycle-visualizer)) for tracing & visualizing lifecycle methods of React class components. (For function components and hooks, check out [`react-hook-tracer`](https://github.com/Oblosys/react-hook-tracer#readme) instead.) 4 | 5 | To trace a component, apply the higher-order component `traceLifecycle` to it, and all its lifecycle-method calls will show up in a replayable log component. Additionally, traced components may include a `` element in their rendering to show a panel with lifecycle methods, which are highlighted when the corresponding log entry is selected. 6 | 7 |

8 | 9 | Parent-child demo 14 | 15 |

16 | 17 | ## Usage 18 | 19 | The easiest way to get started is to 20 | open the [CodeSandbox playground](https://codesandbox.io/s/github/Oblosys/react-lifecycle-visualizer/tree/master/examples/parent-child-demo?file=/src/samples/New.js) and edit the sample components in `src/samples`. (For a better view of the log, press the 'Open in New Window' button in the top-right corner.) 21 | 22 | The panel shows the new React 16.3 lifecycle methods, unless the component defines at least one legacy method and no new methods. On a component that has both legacy and new methods, React ignores the legacy methods, so the panel shows the new methods. 23 | 24 | Though technically not lifecycle methods, `setState` & `render` are also traced. A single `setState(update, [callback])` call may generate up to three log entries: 25 | 26 | 1. `'setState'` for the call itself. 27 | 2. If `update` is a function instead of an object, `'setState:update fn'` is logged when that function is evaluated. 28 | 3. If a `callback` function is provided, `'setState:callback'` is logged when it's called. 29 | 30 | To save space, the lifecycle panel only contains `setState`, which gets highlighted on any of the three events above. 31 | 32 | 33 | ## Run the demo locally 34 | 35 | To run a local copy of the CodeSandbox demo, simply clone the repo, and run `npm install` & `npm start`: 36 | 37 | ``` 38 | git clone git@github.com:Oblosys/react-lifecycle-visualizer.git 39 | cd react-lifecycle-visualizer 40 | npm install 41 | npm start 42 | ``` 43 | 44 | The demo runs on http://localhost:8000/. 45 | 46 | 47 | ## Using the npm package 48 | 49 | ```sh 50 | $ npm i react-lifecycle-visualizer 51 | ``` 52 | 53 | #### Setup 54 | 55 | To set up tracing, wrap the root or some other ancestor component in a `` and include the `` component somewhere. For example: 56 | 57 | ```jsx 58 | import { Log, VisualizerProvider } from 'react-lifecycle-visualizer'; 59 | 60 | ReactDom.render( 61 | 62 |
63 | 64 | 65 |
66 |
, 67 | document.getElementById('root') 68 | ); 69 | ``` 70 | 71 | If you're using a WebPack dev-server with hot reloading, you can include a call to `resetInstanceIdCounters` in the module where you set up hot reloading: 72 | 73 | ```jsx 74 | import { resetInstanceIdCounters } from 'react-lifecycle-visualizer'; 75 | .. 76 | resetInstanceIdCounters(); // reset instance counters on hot reload 77 | .. 78 | ``` 79 | 80 | This isn't strictly necessary, but without it, instance counters will keep increasing on each hot reload, making the log less readable. 81 | 82 | #### Tracing components 83 | 84 | To trace a component (e.g. `ComponentToTrace`,) apply the `traceLifecycle` HOC to it. This is most easily done with a decorator. 85 | 86 | ```jsx 87 | import { traceLifecycle } from 'react-lifecycle-visualizer'; 88 | .. 89 | @traceLifecycle 90 | class ComponentToTrace extends React.Component { 91 | .. 92 | render() { 93 | return ( 94 | .. 95 | 96 | .. 97 | ); 98 | } 99 | } 100 | ``` 101 | 102 | Alternatively, apply `traceLifecycle` directly to the class, like this: 103 | 104 | ```jsx 105 | const ComponentToTrace = traceLifecycle(class ComponentToTrace extends React.Component {...}); 106 | ``` 107 | 108 | or 109 | 110 | ```jsx 111 | class ComponentToTraceOrg extends React.Component {...} 112 | const ComponentToTrace = traceLifecycle(ComponentToTraceOrg); 113 | ``` 114 | 115 | #### Traced component props: `LifecyclePanel` and `trace` 116 | 117 | The traced component receives two additional props: `LifecyclePanel` and `trace`. The `LifecyclePanel` prop is a component that can be included in the rendering with `` to display the lifecycle methods of the traced component. 118 | 119 | ```jsx 120 | render() { 121 | return ( 122 | .. 123 | 124 | .. 125 | ); 126 | } 127 | ``` 128 | 129 | The `trace` prop is a function of type `(msg: string) => void` that can be used to log custom messages: 130 | 131 | ```jsx 132 | componentDidUpdate(prevProps, prevState) { 133 | this.props.trace('prevProps: ' + JSON.stringify(prevProps)); 134 | } 135 | ``` 136 | 137 | In the constructor we can use `this.props.trace` after the call to `super`, or access `trace` on the `props` parameter: 138 | 139 | ```jsx 140 | constructor(props) { 141 | props.trace('before super(props)'); 142 | super(props); 143 | this.props.trace('after super(props)'); 144 | } 145 | ``` 146 | 147 | In the static `getDerivedStateFromProps` we cannot use `this` to refer to the component instance, but we can access `trace` on the `nextProps` parameter: 148 | 149 | ```jsx 150 | static getDerivedStateFromProps(nextProps, prevState) { 151 | nextProps.trace('nextProps: ' + JSON.stringify(nextProps)); 152 | .. 153 | } 154 | ``` 155 | 156 | ## TypeScript 157 | 158 | There's no need to install additional TypeScript typings, as these are already included in the package. The interface `TraceProps` declares the `trace` and `LifecyclePanel` props. Its definition is 159 | 160 | ```typescript 161 | export interface TraceProps { 162 | trace: (msg: string) => void, 163 | LifecyclePanel : React.SFC 164 | } 165 | ``` 166 | 167 | With the exception of tracing a component, the TypeScript setup is the same as the JavaScript setup above. Here's an example of a traced component in TypeScript: 168 | 169 | 170 | ```jsx 171 | import { traceLifecycle, TraceProps } from 'react-lifecycle-visualizer'; 172 | .. 173 | interface ComponentToTraceProps extends TraceProps {}; // add trace & LifecyclePanel props 174 | interface ComponentToTraceState {} 175 | 176 | class ComponentToTrace extends React.Component { 177 | constructor(props: ComponentToTraceProps, context?: any) { 178 | props.trace('before super(props)'); 179 | super(props, context); 180 | this.props.trace('after super(props)'); 181 | } 182 | 183 | static getDerivedStateFromProps(nextProps : ComponentToTraceProps, nextState: ComponentToTraceState) { 184 | nextProps.trace('deriving'); 185 | return null; 186 | } 187 | 188 | render() { 189 | return ; 190 | } 191 | } 192 | 193 | ``` 194 | 195 | The only difference is that we cannot use `traceLifecycle` as a decorator in TypeScript, because it changes the signature of the parameter class (see this [issue](https://github.com/DefinitelyTyped/DefinitelyTyped/issues/20796)). Instead, we simply apply it as a function: 196 | 197 | ```tsx 198 | const TracedComponent = traceLifecycle(ComponentToTrace); 199 | ``` 200 | -------------------------------------------------------------------------------- /src/traceLifecycle.jsx: -------------------------------------------------------------------------------- 1 | /* eslint max-classes-per-file: 0, react/jsx-props-no-spreading: 0, react/static-property-placement: 0 */ 2 | import React, { Component } from 'react'; 3 | import hoistStatics from 'hoist-non-react-statics'; 4 | 5 | import * as constants from './constants'; 6 | import * as ActionCreators from './redux/actionCreators'; 7 | import LifecyclePanel from './components/LifecyclePanel'; 8 | import { MConstructor, MShouldUpdate, MRender, MDidMount, 9 | MDidUpdate, MWillUnmount, MSetState, MGetDerivedState, MGetSnapshot, 10 | MWillMount, MWillReceiveProps, MWillUpdate, 11 | MUnsafeWillMount, MUnsafeWillReceiveProps, MUnsafeWillUpdate} from './constants'; 12 | import { store as lifecycleVisualizerStore } from './redux/VisualizerProvider'; 13 | 14 | const instanceIdCounters = {}; 15 | 16 | export const resetInstanceIdCounters = () => { 17 | Object.keys(instanceIdCounters).forEach((k) => delete instanceIdCounters[k]); 18 | }; 19 | 20 | const mkInstanceId = (componentName) => { 21 | if (!Object.prototype.hasOwnProperty.call(instanceIdCounters, componentName)) { 22 | instanceIdCounters[componentName] = 0; 23 | } 24 | instanceIdCounters[componentName] += 1; 25 | return instanceIdCounters[componentName]; 26 | }; 27 | 28 | export default function traceLifecycle(ComponentToTrace) { 29 | const componentToTraceName = ComponentToTrace.displayName || ComponentToTrace.name || 'Component'; 30 | 31 | const superMethods = Object.getOwnPropertyNames(ComponentToTrace.prototype).concat( 32 | ComponentToTrace.getDerivedStateFromProps ? [MGetDerivedState] : [] 33 | ); 34 | 35 | const isLegacy = // component is legacy if it includes one of the legacy methods and no new methods. 36 | superMethods.some((member) => constants.lifecycleMethodNamesLegacyOnly.includes(member)) && 37 | superMethods.every((member) => !constants.lifecycleMethodNamesNewOnly.includes(member)); 38 | 39 | const implementedMethods = [...superMethods, MSetState]; 40 | 41 | class TracedComponent extends ComponentToTrace { 42 | constructor(props, context) { 43 | props.trace(MConstructor); 44 | super(props, context); 45 | if (!isLegacy && typeof this.state === 'undefined') { 46 | this.state = {}; 47 | // Initialize state if it is undefined, otherwise the addition of getDerivedStateFromProps will cause a warning. 48 | } 49 | } 50 | 51 | componentWillMount() { 52 | this.props.trace(MWillMount); 53 | if (super.componentWillMount) { 54 | super.componentWillMount(); 55 | } 56 | } 57 | 58 | UNSAFE_componentWillMount() { // eslint-disable-line camelcase 59 | this.props.trace(MWillMount); // trace it as 'componentWillMount' for brevity 60 | if (super.UNSAFE_componentWillMount) { 61 | super.UNSAFE_componentWillMount(); 62 | } 63 | } 64 | 65 | static getDerivedStateFromProps(nextProps, prevState) { 66 | nextProps.trace(MGetDerivedState); 67 | return ComponentToTrace.getDerivedStateFromProps 68 | ? ComponentToTrace.getDerivedStateFromProps(nextProps, prevState) 69 | : null; 70 | } 71 | 72 | componentDidMount() { 73 | this.props.trace(MDidMount); 74 | if (super.componentDidMount) { 75 | super.componentDidMount(); 76 | } 77 | } 78 | 79 | componentWillUnmount() { 80 | this.props.trace(MWillUnmount); 81 | if (super.componentWillUnmount) { 82 | super.componentWillUnmount(); 83 | } 84 | } 85 | 86 | componentWillReceiveProps(...args) { 87 | this.props.trace(MWillReceiveProps); 88 | if (super.componentWillReceiveProps) { 89 | super.componentWillReceiveProps(...args); 90 | } 91 | } 92 | 93 | UNSAFE_componentWillReceiveProps(...args) { // eslint-disable-line camelcase 94 | this.props.trace(MWillReceiveProps); // trace it as 'componentWillReceiveProps' for brevity 95 | if (super.UNSAFE_componentWillReceiveProps) { 96 | super.UNSAFE_componentWillReceiveProps(...args); 97 | } 98 | } 99 | 100 | shouldComponentUpdate(...args) { 101 | this.props.trace(MShouldUpdate); 102 | return super.shouldComponentUpdate 103 | ? super.shouldComponentUpdate(...args) 104 | : true; 105 | } 106 | 107 | componentWillUpdate(...args) { 108 | this.props.trace(MWillUpdate); 109 | if (super.componentWillUpdate) { 110 | super.componentWillUpdate(...args); 111 | } 112 | } 113 | 114 | UNSAFE_componentWillUpdate(...args) { // eslint-disable-line camelcase 115 | this.props.trace(MWillUpdate); // trace it as 'componentWillUpdate' for brevity 116 | if (super.UNSAFE_componentWillUpdate) { 117 | super.UNSAFE_componentWillUpdate(...args); 118 | } 119 | } 120 | 121 | render() { 122 | if (super.render) { 123 | this.props.trace(MRender); 124 | return super.render(); 125 | } 126 | return undefined; // There's no super.render, which will trigger a React error 127 | } 128 | 129 | getSnapshotBeforeUpdate(...args) { 130 | this.props.trace(MGetSnapshot); 131 | return super.getSnapshotBeforeUpdate 132 | ? super.getSnapshotBeforeUpdate(...args) 133 | : null; 134 | } 135 | 136 | componentDidUpdate(...args) { 137 | this.props.trace(MDidUpdate); 138 | if (super.componentDidUpdate) { 139 | super.componentDidUpdate(...args); 140 | } 141 | } 142 | 143 | setState(updater, callback) { 144 | this.props.trace(MSetState); 145 | 146 | // Unlike the lifecycle methods we only trace the update function and callback when they are actually defined. 147 | const tracingUpdater = typeof updater !== 'function' ? updater : (...args) => { 148 | this.props.trace(MSetState + ':update fn'); 149 | return updater(...args); 150 | }; 151 | 152 | const tracingCallback = !callback ? undefined : (...args) => { 153 | this.props.trace(MSetState + ':callback'); 154 | callback(...args); 155 | }; 156 | super.setState(tracingUpdater, tracingCallback); 157 | } 158 | 159 | static displayName = componentToTraceName; 160 | } 161 | 162 | class TracingComponent extends Component { 163 | constructor(props, context) { 164 | super(props, context); 165 | 166 | const instanceId = mkInstanceId(ComponentToTrace.name); 167 | 168 | // eslint-disable-next-line react/no-unstable-nested-components 169 | const WrappedLifecyclePanel = () => ( 170 | 176 | ); 177 | this.LifecyclePanel = WrappedLifecyclePanel; 178 | 179 | this.trace = (methodName) => { 180 | // Just dispatch on lifecycleVisualizerStore directly, rather than introducing complexity by using context. 181 | lifecycleVisualizerStore.dispatch( 182 | ActionCreators.trace(componentToTraceName, instanceId, methodName) 183 | ); 184 | }; 185 | } 186 | 187 | render() { 188 | return ; 189 | } 190 | 191 | static displayName = `traceLifecycle(${componentToTraceName})`; 192 | } 193 | 194 | // Removing the inappropriate methods is simpler than adding appropriate methods to prototype. 195 | if (isLegacy) { 196 | delete TracedComponent.getDerivedStateFromProps; 197 | delete TracedComponent.prototype.getSnapshotBeforeUpdate; 198 | 199 | // Only keep the tracer method corresponding to the implemented super method, unless neither the old or the 200 | // UNSAFE_ method is implemented, in which case we keep the UNSAFE_ method. 201 | // NOTE: This allows both the old method and the UNSAFE_ version to be traced, but this is correct, as React calls 202 | // both. 203 | const deleteOldOrUnsafe = (method, unsafeMethod) => { 204 | if (!superMethods.includes(method)) { 205 | delete TracedComponent.prototype[method]; 206 | } else if (!superMethods.includes(unsafeMethod)) { 207 | delete TracedComponent.prototype[unsafeMethod]; 208 | } 209 | }; 210 | 211 | deleteOldOrUnsafe(MWillMount, MUnsafeWillMount); 212 | deleteOldOrUnsafe(MWillReceiveProps, MUnsafeWillReceiveProps); 213 | deleteOldOrUnsafe(MWillUpdate, MUnsafeWillUpdate); 214 | } else { 215 | delete TracedComponent.prototype.componentWillMount; 216 | delete TracedComponent.prototype.componentWillReceiveProps; 217 | delete TracedComponent.prototype.componentWillUpdate; 218 | delete TracedComponent.prototype.UNSAFE_componentWillMount; 219 | delete TracedComponent.prototype.UNSAFE_componentWillReceiveProps; 220 | delete TracedComponent.prototype.UNSAFE_componentWillUpdate; 221 | } 222 | 223 | return hoistStatics(TracingComponent, ComponentToTrace); 224 | } 225 | -------------------------------------------------------------------------------- /test/integration.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { act, render, screen, within } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | import { clearLog, resetInstanceIdCounters } from '../src'; 6 | 7 | import { Wrapper } from './Wrapper'; 8 | import TracedChild from './TracedChild'; 9 | import TracedLegacyChild from './TracedLegacyChild'; 10 | import TracedLegacyUnsafeChild from './TracedLegacyUnsafeChild'; 11 | 12 | const nNewLifecyclePanelMethods = 9; // Non-legacy panel has 9 lifecycle methods 13 | const nLegacyLifecyclePanelMethods = 10; // Legacy panel has 10 lifecycle methods 14 | 15 | // Return array of length `n` which is 'true' at index `i` and 'false' everywhere else. 16 | const booleanStringListOnlyTrueAt = (n, i) => Array.from({length: n}, (_undefined, ix) => `${ix === i}`); 17 | 18 | const formatLogEntries = (instanceName, logMethods) => logMethods.map((e, i) => 19 | ('' + i).padStart(2) + ` ${instanceName}: ` + e // NOTE: padding assumes <=100 entries 20 | ); 21 | 22 | const setupUser = () => userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); 23 | 24 | describe('traceLifecycle', () => { 25 | it('preserves static properties', () => { 26 | expect(TracedChild.staticProperty).toBe('a static property'); 27 | }); 28 | }); 29 | 30 | describe('LifecyclePanel', () => { 31 | it('shows which methods are implemented', () => { 32 | render( }/>); 33 | const methods = [...screen.getByTestId('lifecycle-panel').querySelectorAll('.lifecycle-method')]; 34 | 35 | methods.forEach((node) => { 36 | expect(node).toHaveAttribute('data-is-implemented', 'true'); 37 | }); 38 | }); 39 | 40 | it('shows new methods for non-legacy component', () => { 41 | render( }/>); 42 | const methods = [...screen.getByTestId('lifecycle-panel').querySelectorAll('.lifecycle-method')]; 43 | 44 | expect(methods).toHaveLength(nNewLifecyclePanelMethods); 45 | }); 46 | 47 | it('shows legacy methods for legacy component', () => { 48 | /* eslint-disable no-console */ 49 | // Disable console.warn to suppress React warnings about using legacy methods (emitted once per method). 50 | const consoleWarn = console.warn; 51 | console.warn = () => {}; 52 | render( }/>); 53 | console.warn = consoleWarn; 54 | /* eslint-enable no-console */ 55 | 56 | const methods = [...screen.getByTestId('lifecycle-panel').querySelectorAll('.lifecycle-method')]; 57 | 58 | expect(methods).toHaveLength(nLegacyLifecyclePanelMethods); 59 | }); 60 | }); 61 | 62 | describe('Log', () => { 63 | it('sequentially highlights log entries', () => { 64 | render( }/>); 65 | act(() => jest.runOnlyPendingTimers()); // log entries are generated asynchronously, so run timers once 66 | 67 | const entries = [...screen.getByTestId('log-entries').querySelectorAll('.entry')]; 68 | const nLogEntries = entries.length; 69 | 70 | expect(nLogEntries).toBeGreaterThan(0); 71 | 72 | for (let i = 0; i < nLogEntries; i++) { 73 | expect(entries.map((node) => node.getAttribute('data-is-highlighted'))).toEqual( 74 | booleanStringListOnlyTrueAt(nLogEntries, i) 75 | ); 76 | act(() => jest.runOnlyPendingTimers()); // not necessary for last iteration, but harmless 77 | } 78 | }); 79 | 80 | it('highlights the corresponding panel method', async () => { 81 | const user = setupUser(); 82 | render( }/>); 83 | const logEntries = within(screen.getByTestId('log-entries')); 84 | const panel = within(screen.getByTestId('lifecycle-panel')); 85 | act(() => jest.runOnlyPendingTimers()); // log entries are generated asynchronously, so run timers once 86 | 87 | expect(panel.getByText('render')).toHaveAttribute('data-is-highlighted', 'false'); 88 | await user.hover(logEntries.getByText('4 Child-1: render')); 89 | expect(panel.getByText('render')).toHaveAttribute('data-is-highlighted', 'true'); 90 | 91 | expect(panel.getByText('constructor')).toHaveAttribute('data-is-highlighted', 'false'); 92 | await user.hover(logEntries.getByText('0 Child-1: constructor')); 93 | expect(panel.getByText('constructor')).toHaveAttribute('data-is-highlighted', 'true'); 94 | }); 95 | 96 | it('logs all new lifecycle methods', async () => { 97 | const user = setupUser(); 98 | render( }/>); // Mount TracedChild 99 | await user.click(screen.getByTestId('prop-value-checkbox')); // Update TracedChild prop 100 | await user.click(screen.getByTestId('state-update-button')); // Update TracedChild state 101 | await user.click(screen.getByTestId('show-child-checkbox')); // Unmount TracedChild 102 | act(() => jest.runOnlyPendingTimers()); 103 | 104 | const expectedLogEntries = [ 105 | // Mount TracedChild 106 | 'constructor', 107 | 'custom:constructor', 108 | 'static getDerivedStateFromProps', 109 | 'custom:getDerivedStateFromProps', 110 | 'render', 111 | 'custom:render', 112 | 'componentDidMount', 113 | 'custom:componentDidMount', 114 | 115 | // Update TracedChild prop 116 | 'static getDerivedStateFromProps', 117 | 'custom:getDerivedStateFromProps', 118 | 'shouldComponentUpdate', 119 | 'custom:shouldComponentUpdate', 120 | 'render', 121 | 'custom:render', 122 | 'getSnapshotBeforeUpdate', 123 | 'custom:getSnapshotBeforeUpdate', 124 | 'componentDidUpdate', 125 | 'custom:componentDidUpdate', 126 | 127 | // Update TracedChild state 128 | 'setState', 129 | 'setState:update fn', 130 | 'custom:setState update fn', 131 | 'static getDerivedStateFromProps', 132 | 'custom:getDerivedStateFromProps', 133 | 'shouldComponentUpdate', 134 | 'custom:shouldComponentUpdate', 135 | 'render', 136 | 'custom:render', 137 | 'getSnapshotBeforeUpdate', 138 | 'custom:getSnapshotBeforeUpdate', 139 | 'componentDidUpdate', 140 | 'custom:componentDidUpdate', 141 | 'setState:callback', 142 | 'custom:setState callback', 143 | 144 | // Unmount TracedChild 145 | 'componentWillUnmount', 146 | 'custom:componentWillUnmount', 147 | ]; 148 | 149 | const entries = [...screen.getByTestId('log-entries').querySelectorAll('.entry')]; 150 | expect(entries.map((node) => node.textContent)) 151 | .toEqual(formatLogEntries('Child-1', expectedLogEntries) 152 | ); 153 | }); 154 | 155 | it('logs all legacy lifecycle methods', async () => { 156 | const user = setupUser(); 157 | 158 | /* eslint-disable no-console */ 159 | // Disable console.warn to suppress React warnings about using legacy methods. 160 | const consoleWarn = console.warn; 161 | console.warn = () => {}; 162 | render( }/>); // Mount TracedLegacyChild 163 | console.warn = consoleWarn; 164 | /* eslint-enable no-console */ 165 | 166 | await user.click(screen.getByTestId('prop-value-checkbox')); // Update TracedLegacyChild prop 167 | await user.click(screen.getByTestId('state-update-button')); // Update TracedLegacyChild state 168 | await user.click(screen.getByTestId('show-child-checkbox')); // Unmount TracedLegacyChild 169 | act(() => jest.runOnlyPendingTimers()); 170 | 171 | const expectedLogEntries = [ 172 | // Mount TracedLegacyChild 173 | 'constructor', 174 | 'custom:constructor', 175 | 'componentWillMount', 176 | 'custom:componentWillMount', 177 | 'render', 178 | 'custom:render', 179 | 'componentDidMount', 180 | 'custom:componentDidMount', 181 | 182 | // Update TracedLegacyChild prop 183 | 'componentWillReceiveProps', 184 | 'custom:componentWillReceiveProps', 185 | 'shouldComponentUpdate', 186 | 'custom:shouldComponentUpdate', 187 | 'componentWillUpdate', 188 | 'custom:componentWillUpdate', 189 | 'render', 190 | 'custom:render', 191 | 'componentDidUpdate', 192 | 'custom:componentDidUpdate', 193 | 194 | // Update TracedLegacyChild state 195 | 'setState', 196 | 'setState:update fn', 197 | 'custom:setState update fn', 198 | 'shouldComponentUpdate', 199 | 'custom:shouldComponentUpdate', 200 | 'componentWillUpdate', 201 | 'custom:componentWillUpdate', 202 | 'render', 203 | 'custom:render', 204 | 'componentDidUpdate', 205 | 'custom:componentDidUpdate', 206 | 'setState:callback', 207 | 'custom:setState callback', 208 | 209 | // Unmount TracedLegacyChild 210 | 'componentWillUnmount', 211 | 'custom:componentWillUnmount', 212 | ]; 213 | 214 | const entries = [...screen.getByTestId('log-entries').querySelectorAll('.entry')]; 215 | expect(entries.map((node) => node.textContent)) 216 | .toEqual(formatLogEntries('LegacyChild-1', expectedLogEntries) 217 | ); 218 | }); 219 | 220 | it('logs all legacy UNSAFE_ lifecycle methods', async () => { 221 | const user = setupUser(); 222 | // Mount TracedLegacyUnsafeChild 223 | render( }/>); 224 | await user.click(screen.getByTestId('prop-value-checkbox')); // Update TracedLegacyUnsafeChild prop 225 | act(() => jest.runOnlyPendingTimers()); 226 | 227 | const expectedLogEntries = [ 228 | // Mount TracedLegacyUnsafeChild 229 | 'constructor', 230 | 'componentWillMount', 231 | 'custom:UNSAFE_componentWillMount', 232 | 'render', 233 | 'componentDidMount', 234 | 235 | // Update TracedLegacyUnsafeChild prop 236 | 'componentWillReceiveProps', 237 | 'custom:UNSAFE_componentWillReceiveProps', 238 | 'shouldComponentUpdate', 239 | 'componentWillUpdate', 240 | 'custom:UNSAFE_componentWillUpdate', 241 | 'render', 242 | 'componentDidUpdate', 243 | ]; 244 | 245 | const entries = [...screen.getByTestId('log-entries').querySelectorAll('.entry')]; 246 | expect(entries.map((node) => node.textContent)) 247 | .toEqual(formatLogEntries('LegacyUnsafeChild-1', expectedLogEntries) 248 | ); 249 | }); 250 | 251 | it('is cleared by clearLog()', () => { 252 | render( }/>); 253 | act(() => jest.runOnlyPendingTimers()); 254 | 255 | const entries = [...screen.getByTestId('log-entries').querySelectorAll('.entry')]; 256 | expect(entries).not.toHaveLength(0); 257 | 258 | act(() => clearLog()); 259 | 260 | const clearedEntries = [...screen.getByTestId('log-entries').querySelectorAll('.entry')]; 261 | expect(clearedEntries).toHaveLength(0); 262 | }); 263 | }); 264 | 265 | describe('instanceId counter', () => { 266 | it('starts at 1', () => { 267 | render( }/>); 268 | act(() => jest.runOnlyPendingTimers()); 269 | 270 | const entries = [...screen.getByTestId('log-entries').querySelectorAll('.entry')]; 271 | expect(entries[0]).toHaveTextContent(/^ ?\d+ Child-1/); 272 | }); 273 | 274 | it('increments on remount', async () => { 275 | const user = setupUser(); 276 | render( }/>); // Mount TracedChild 277 | await user.click(screen.getByTestId('show-child-checkbox')); // Unmount TracedChild 278 | act(() => jest.runOnlyPendingTimers()); 279 | act(() => clearLog()); 280 | await user.click(screen.getByTestId('show-child-checkbox')); // Mount TracedChild 281 | act(() => jest.runOnlyPendingTimers()); 282 | 283 | const entries = [...screen.getByTestId('log-entries').querySelectorAll('.entry')]; 284 | expect(entries[0]).toHaveTextContent(/^ ?\d+ Child-2/); 285 | }); 286 | 287 | it('is reset by resetInstanceIdCounters', async () => { 288 | const user = setupUser(); 289 | render( }/>); // Mount TracedChild 290 | await user.click(screen.getByTestId('show-child-checkbox')); // Unmount TracedChild 291 | act(() => jest.runOnlyPendingTimers()); 292 | act(() => clearLog()); 293 | 294 | resetInstanceIdCounters(); 295 | 296 | await user.click(screen.getByTestId('show-child-checkbox')); // Mount TracedChild 297 | act(() => jest.runOnlyPendingTimers()); 298 | 299 | const entries = [...screen.getByTestId('log-entries').querySelectorAll('.entry')]; 300 | expect(entries[0]).toHaveTextContent(/^ ?\d+ Child-1/); 301 | }); 302 | }); 303 | --------------------------------------------------------------------------------