+ {/* Add this new container for reducer state */}
+ {/* {tooltipData.componentData.reducerState && (
+
+ )} */}
diff --git a/src/app/components/StateRoute/ComponentMap/ToolTipDataDisplay.tsx b/src/app/components/StateRoute/ComponentMap/ToolTipDataDisplay.tsx
index 452eb8c12..47bf67bc8 100644
--- a/src/app/components/StateRoute/ComponentMap/ToolTipDataDisplay.tsx
+++ b/src/app/components/StateRoute/ComponentMap/ToolTipDataDisplay.tsx
@@ -28,42 +28,41 @@ const colors = {
};
const ToolTipDataDisplay = ({ containerName, dataObj }) => {
- const printableObject = {}; // The key:value properties of printableObject will be rendered in the JSON Tree
+ const printableObject = {};
if (!dataObj) {
- // If state is null rather than an object, print "State: null" in tooltip
printableObject[containerName] = dataObj;
} else {
- /*
- Props often contain circular references.
- Messages from the backend must be sent as JSON objects (strings).
- JSON objects can't contain circular ref's, so the backend filters out problematic values by stringifying the values of object properties and ignoring any values that fail the conversion due to a circular ref. The following logic converts these values back to JS so they display clearly and are collapsible.
- */
const data = {};
- for (const key in dataObj) {
- if (typeof dataObj[key] === 'string') {
- try {
- data[key] = JSON.parse(dataObj[key]);
- } catch {
+
+ // Handle reducer state if present
+ if (containerName === 'State' && dataObj.reducerState) {
+ printableObject[containerName] = dataObj.reducerState;
+ }
+ // Otherwise handle normal state/props
+ else {
+ for (const key in dataObj) {
+ if (typeof dataObj[key] === 'string') {
+ try {
+ data[key] = JSON.parse(dataObj[key]);
+ } catch {
+ data[key] = dataObj[key];
+ }
+ } else {
data[key] = dataObj[key];
}
- } else {
- data[key] = dataObj[key];
}
+ printableObject[containerName] = data;
}
- /*
- Adds container name (State, Props, future different names for hooks) at top of object so everything nested in it will collapse when you click on it.
- */
- printableObject[containerName] = data;
}
return (
({ className: `tooltipData-JSONTree` }) }} // theme set to a base16 theme that has been extended to include "className: 'json-tree'"
- shouldExpandNodeInitially={() => true} // determines if node should be expanded when it first renders (root is expanded by default)
- hideRoot={true} // hides the root node
+ data={printableObject}
+ theme={{ extend: colors, tree: () => ({ className: `tooltipData-JSONTree` }) }}
+ shouldExpandNodeInitially={() => true}
+ hideRoot={true}
/>
);
diff --git a/src/backend/controllers/createTree.ts b/src/backend/controllers/createTree.ts
index 8a26bccaa..109d535a2 100644
--- a/src/backend/controllers/createTree.ts
+++ b/src/backend/controllers/createTree.ts
@@ -193,11 +193,8 @@ export default function createTree(currentFiberNode: Fiber): Tree {
// If user use setState to define/manage state, the state object will be stored in stateNode.state => grab the state object stored in the stateNode.state
// Example: for tic-tac-toe demo-app: Board is a stateful component that use setState to store state data.
if ((tag === ClassComponent || tag === IndeterminateComponent) && stateNode?.state) {
- // Save component's state and setState() function to our record for future time-travel state changing. Add record index to snapshot so we can retrieve.
componentData.index = componentActionsRecord.saveNew(stateNode);
- // Save state information in componentData.
componentData.state = stateNode.state;
- // Pass to front end
newState = componentData.state;
}
@@ -205,43 +202,39 @@ export default function createTree(currentFiberNode: Fiber): Tree {
// Check if currentFiberNode is a stateful functional component when user use useState hook.
// If user use useState to define/manage state, the state object will be stored in memoizedState => grab the state object & its update method (dispatch) from memoizedState
// Example: for Stateful buttons demo-app: Increment is a stateful component that use useState hook to store state data.
- if (
- (tag === FunctionComponent ||
- tag === IndeterminateComponent ||
- //TODO: Need reasoning for why we evaluate context provider
- /**
- * So far I haven't seen a case where hook data is stored for ContextProviders in memoized state. So far
- * I've seen some data a non-null memoize state on browser router, but queue is null. Routes has some good info on memoized props,
- * but that's not being addressed here. useContext providers also have null for memoized state.
- */
- tag === ContextProvider) &&
- memoizedState
- ) {
+ // Inside the _createTree function where we handle functional components
+ if ((tag === FunctionComponent || tag === IndeterminateComponent) && memoizedState) {
if (memoizedState.queue) {
try {
- // Obtain all hooksStates & the corresponding udpate method from memoizedState
const hooksStates = getHooksStateAndUpdateMethod(memoizedState);
- // Obtain variable names by parsing the function definition stored in elementType.
+ console.log('hook states', hooksStates);
+ // Get the hooks names by parsing the elementType
const hooksNames = getHooksNames(elementType.toString());
// Intialize state & index:
componentData.hooksState = {};
+ componentData.reducerState = null;
componentData.hooksIndex = [];
- hooksStates.forEach(({ state, component }, i) => {
- // Save component's state and dispatch() function to our record for future time-travel state changing. Add record index to snapshot so we can retrieve.
+ hooksStates.forEach(({ state, component, isReducer }, i) => {
componentData.hooksIndex.push(componentActionsRecord.saveNew(component));
- // Save state information in componentData.
- componentData.hooksState[hooksNames[i].varName] = state;
+
+ if (isReducer) {
+ // If it's a reducer, store its state
+ componentData.reducerState = state;
+ }
+ // Otherwise treat as useState
+ componentData.hooksState[hooksNames[i]?.varName || `Reducer: ${i}`] = state;
});
+
// Pass to front end
newState = componentData.hooksState;
+ console.log('new state', newState);
} catch (err) {
- // COMMENT OUT TO AVOID PRINTING ON THE CONSOLE OF USER - KEEP IT FOR DEBUGGING PURPOSE
- // console.log({
- // Message: 'Error in createTree during obtaining state from functionalComponent',
- // componentName,
- // err,
- // });
+ console.log('Error extracting functional component state:', {
+ componentName,
+ memoizedState,
+ error: err,
+ });
}
}
}
diff --git a/src/backend/controllers/statePropExtractors.ts b/src/backend/controllers/statePropExtractors.ts
index c01adfe21..5725c9f66 100644
--- a/src/backend/controllers/statePropExtractors.ts
+++ b/src/backend/controllers/statePropExtractors.ts
@@ -1,4 +1,5 @@
-import parse from 'html-react-parser';
+import { parse } from '@babel/parser';
+import { Node, CallExpression, MemberExpression, Identifier } from '@babel/types';
import { HookStateItem, Fiber } from '../types/backendTypes';
import { exclude } from '../models/filterConditions';
@@ -68,7 +69,18 @@ export function getHooksStateAndUpdateMethod(
): Array {
const hooksStates: Array = [];
while (memoizedState) {
- if (memoizedState.queue) {
+ // Check for useReducer hook
+ if (
+ memoizedState.queue &&
+ memoizedState.memoizedState &&
+ memoizedState.queue.lastRenderedReducer.name !== 'basicStateReducer' // only present in useState
+ ) {
+ hooksStates.push({
+ component: memoizedState.queue,
+ state: memoizedState.memoizedState,
+ isReducer: true,
+ });
+ } else if (memoizedState.queue) {
hooksStates.push({
component: memoizedState.queue,
state: memoizedState.memoizedState,
@@ -86,62 +98,83 @@ export function getHooksStateAndUpdateMethod(
* @returns - An array of objects with key: hookName (the name of setState method) | value: varName (the state variable name)
*/
export function getHooksNames(elementType: string): { hookName: string; varName: string }[] {
- // Initialize empty object to store the setters and getter
- // Abstract Syntax Tree
- let AST: any;
try {
- AST = parse(elementType).body;
- // Begin search for hook names, only if ast has a body property.
- // Statements get all the names of the hooks. For example: useCount, useWildcard, ...
+ const AST = parse(elementType, {
+ sourceType: 'module',
+ plugins: ['jsx', 'typescript'],
+ });
+
const statements: { hookName: string; varName: string }[] = [];
- /** All module exports always start off as a single 'FunctionDeclaration' type
- * Other types: "BlockStatement" / "ExpressionStatement" / "ReturnStatement"
- * Iterate through AST of every functional component declaration
- * Check within each functional component declaration if there are hook declarations & variable name declaration */
- AST.forEach((functionDec: any) => {
- let declarationBody: any;
- if (functionDec.expression?.body) declarationBody = functionDec.expression.body.body;
- // check if functionDec.expression.body exists, then set declarationBody to functionDec's body
- else declarationBody = functionDec.body?.body ?? [];
- // Traverse through the function's funcDecs and Expression Statements
- declarationBody.forEach((elem: any) => {
- // Hooks will always be contained in a variable declaration
- if (elem.type === 'VariableDeclaration') {
- // Obtain the declarations array from elem.
- const { declarations } = elem;
- // Obtain the reactHook:
- // Due to difference in babel transpilation in browser vs for jest test, expression is stored in differen location
- const expression =
- declarations[0]?.init?.callee?.expressions || //work for browser
- declarations[0]?.init?.arguments?.[0]?.callee?.expressions; //work for jest test;
- //For a functional definition that isn't a hook, it won't have the callee being searched for above. This line will cause this forEach execution to stop here in this case.
- if (expression === undefined) return;
- let reactHook: string;
- reactHook = expression[1].property?.name;
- if (reactHook === 'useState') {
- // Obtain the variable being set:
- let varName: string =
- // Points to second to last element of declarations because webpack adds an extra variable when converting files that use ES6
- declarations[declarations.length - 2]?.id?.name || // work react application;
- (Array.isArray(declarations[0]?.id?.elements)
- ? declarations[0]?.id?.elements[0]?.name
- : undefined); //work for nextJS application
- // Obtain the setState method:
- let hookName: string =
- //Points to last element of declarations because webpack adds an extra variable when converting files that use ES6
- declarations[declarations.length - 1]?.id?.name || // work react application;
- (Array.isArray(declarations[0]?.id?.elements)
- ? declarations[0]?.id?.elements[0]?.name
- : undefined); //work for nextJS & Remix
- // Push reactHook & varName to statements array
- statements.push({ hookName, varName });
+ const isIdentifierWithName = (node: any, name: string): boolean => {
+ return node?.type === 'Identifier' && node.name === name;
+ };
+
+ const processArrayPattern = (pattern: any): { setter: string; getter: string } | null => {
+ if (pattern.type === 'ArrayPattern' && pattern.elements.length === 2) {
+ const result = {
+ getter: pattern.elements[0].name,
+ setter: pattern.elements[1].name,
+ };
+ return result;
+ }
+ return null;
+ };
+
+ function traverse(node: Node) {
+ if (!node) return;
+
+ if (node.type === 'VariableDeclaration') {
+ node.declarations.forEach((declaration) => {
+ if (declaration.init?.type === 'CallExpression') {
+ // Check for Webpack transformed pattern: (0, react__WEBPACK_IMPORTED_MODULE_0__.useState)(0)
+ const isWebpackPattern =
+ declaration.init.callee?.type === 'SequenceExpression' &&
+ declaration.init.callee.expressions?.length === 2 &&
+ declaration.init.callee.expressions[1]?.type === 'MemberExpression' &&
+ declaration.init.callee.expressions[1].property &&
+ isIdentifierWithName(declaration.init.callee.expressions[1].property, 'useState');
+
+ // Check for direct useState pattern: useState("test")
+ const isDirectPattern =
+ declaration.init.callee?.type === 'Identifier' &&
+ declaration.init.callee.name === 'useState';
+
+ // Check for namespaced useState pattern: React.useState("test")
+ const isNamespacedPattern =
+ declaration.init.callee?.type === 'MemberExpression' &&
+ declaration.init.callee.property &&
+ isIdentifierWithName(declaration.init.callee.property, 'useState');
+
+ if (isWebpackPattern || isDirectPattern || isNamespacedPattern) {
+ const arrayPattern = processArrayPattern(declaration.id);
+ if (arrayPattern) {
+ statements.push({
+ hookName: arrayPattern.setter,
+ varName: arrayPattern.getter,
+ });
+ }
+ }
+ }
+ });
+ }
+
+ // Recursively traverse
+ for (const key in node) {
+ if (node[key] && typeof node[key] === 'object') {
+ if (Array.isArray(node[key])) {
+ node[key].forEach((child: Node) => traverse(child));
+ } else {
+ traverse(node[key] as Node);
}
}
- });
- });
+ }
+ }
+
+ traverse(AST);
return statements;
} catch (err) {
- throw new Error('getHooksNameError' + err.message);
+ console.error('AST Parsing Error:', err);
+ throw new Error('getHooksNameError: ' + err.message);
}
}
diff --git a/src/backend/controllers/timeJump.ts b/src/backend/controllers/timeJump.ts
index 788b2dfb5..4be99d1cd 100644
--- a/src/backend/controllers/timeJump.ts
+++ b/src/backend/controllers/timeJump.ts
@@ -46,7 +46,6 @@ export default function timeJumpInitiation(mode: Status) {
*
*/
async function updateReactFiberTree(
- //TypeScript Note: Adding a tree type to targetSnapshot throws errors for destructuring componentData below. Not sure how precisely to fix
targetSnapshot,
circularComponentTable: Set = new Set(),
): Promise {
@@ -58,47 +57,43 @@ async function updateReactFiberTree(
circularComponentTable.add(targetSnapshot);
}
// ------------------------STATELESS/ROOT COMPONENT-------------------------
- // Since stateless component has no data to update, continue to traverse its child nodes:
if (targetSnapshot.state === 'stateless' || targetSnapshot.state === 'root') {
targetSnapshot.children.forEach((child) => updateReactFiberTree(child, circularComponentTable));
return;
}
- // Destructure component data:
- const { index, state, hooksIndex, hooksState } = targetSnapshot.componentData;
+ const { index, state, hooksIndex, hooksState, reducerState } = targetSnapshot.componentData;
+
// ------------------------STATEFUL CLASS COMPONENT-------------------------
- // Check if it is a stateful class component
- // Index can be zero => falsy value => DO NOT REMOVE NULL
if (index !== null) {
- // Obtain the BOUND update method at the given index
const classComponent = componentActionsRecord.getComponentByIndex(index);
- // This conditional avoids the error that occurs when classComponent is undefined
if (classComponent !== undefined) {
- // Update component state
- await classComponent.setState(
- // prevState contains the states of the snapshots we are jumping FROM, not jumping TO
- (prevState) => state,
- );
+ await classComponent.setState(() => state);
}
- // Iterate through new children after state has been set
targetSnapshot.children.forEach((child) => updateReactFiberTree(child, circularComponentTable));
return;
}
// ----------------------STATEFUL FUNCTIONAL COMPONENT----------------------
- // Check if it is a stateful functional component
- // if yes, grab all relevant components for this snapshot by its index
- // call dispatch on each component passing in the corresponding currState value
- //index can be zero => falsy value => DO NOT REMOVE NULL
if (hooksIndex !== null) {
- // Obtain the array of BOUND update methods at the given indexes.
- // NOTE: each useState will be a separate update method. So if a component have 3 useState, we will obtain an array of 3 update methods.
const functionalComponent = componentActionsRecord.getComponentByIndexHooks(hooksIndex);
- // Update component state
- for (let i in functionalComponent) {
- await functionalComponent[i].dispatch(Object.values(hooksState)[i]);
+
+ // Handle reducer state if present
+ if (reducerState) {
+ try {
+ // For reducer components, update using the first dispatch function
+ await functionalComponent[0]?.dispatch(reducerState);
+ } catch (err) {
+ console.error('Error updating reducer state:', err);
+ }
+ } else {
+ // Handle normal useState components
+ for (let i in functionalComponent) {
+ if (functionalComponent[i]?.dispatch) {
+ await functionalComponent[i].dispatch(Object.values(hooksState)[i]);
+ }
+ }
}
- // Iterate through new children after state has been set
targetSnapshot.children.forEach((child) => updateReactFiberTree(child));
return;
}
diff --git a/src/backend/types/backendTypes.ts b/src/backend/types/backendTypes.ts
index 844e18e2d..d190953cb 100644
--- a/src/backend/types/backendTypes.ts
+++ b/src/backend/types/backendTypes.ts
@@ -67,6 +67,7 @@ export interface ComponentData {
index: number | null;
/** {functional component only} - An object contains all states of the current functional component */
hooksState: {} | null;
+ reducerState?: {} | null;
/** {functional component only} - An array of index of the bound dispatch method stored in `componentActionsRecord` */
hooksIndex: number[] | null;
/** An object contains all props of the current component */
@@ -88,6 +89,7 @@ export interface HookStateItem {
state: any;
/** an object contains bound dispatch method to update state of the current functional component */
component: any;
+ isReducer?: boolean;
}
export type WorkTag =