react-state-flow 0.0.3 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -18
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +139 -75978
- package/dist/parser/index.d.ts +4 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +65 -0
- package/dist/parser/parse-file.d.ts +8 -0
- package/dist/parser/parse-file.d.ts.map +1 -0
- package/dist/parser/parse-file.js +251 -0
- package/dist/parser/types.d.ts +23 -0
- package/dist/parser/types.d.ts.map +1 -0
- package/dist/parser/types.js +1 -0
- package/dist/runtime/index.d.ts +22 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +109 -0
- package/package.json +35 -9
- package/ui/dist/assets/index-VkAtFIbS.js +62 -0
- package/{dist/ui → ui/dist}/index.html +1 -1
- package/dist/ui/assets/index-CMEcbN2L.js +0 -62
- /package/{dist/ui → ui/dist}/assets/index-BZV40eAE.css +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/parser/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAwB,MAAM,YAAY,CAAA;AAEjE,YAAY,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAqBjE,wBAAgB,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAgD3D"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join, relative } from 'path';
|
|
3
|
+
import { parseFile } from './parse-file.js';
|
|
4
|
+
const EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js'];
|
|
5
|
+
function collectFiles(dir) {
|
|
6
|
+
const results = [];
|
|
7
|
+
const IGNORE = ['node_modules', '.git', 'dist', 'build', '.next'];
|
|
8
|
+
for (const entry of readdirSync(dir)) {
|
|
9
|
+
if (IGNORE.includes(entry))
|
|
10
|
+
continue;
|
|
11
|
+
const full = join(dir, entry);
|
|
12
|
+
const stat = statSync(full);
|
|
13
|
+
if (stat.isDirectory()) {
|
|
14
|
+
results.push(...collectFiles(full));
|
|
15
|
+
}
|
|
16
|
+
else if (EXTENSIONS.some((ext) => full.endsWith(ext))) {
|
|
17
|
+
results.push(full);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return results;
|
|
21
|
+
}
|
|
22
|
+
export function parseProject(projectRoot) {
|
|
23
|
+
const files = collectFiles(projectRoot);
|
|
24
|
+
// Pass 1: collect all component node ids for cross-file edge resolution
|
|
25
|
+
const globalComponentSet = new Set();
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
const code = readFileSync(file, 'utf-8');
|
|
28
|
+
const relPath = relative(projectRoot, file);
|
|
29
|
+
const { nodes } = parseFile(code, relPath);
|
|
30
|
+
for (const n of nodes) {
|
|
31
|
+
if (n.type === 'component')
|
|
32
|
+
globalComponentSet.add(n.id);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Pass 2: full parse with global component set for cross-file edges
|
|
36
|
+
const allNodes = [];
|
|
37
|
+
const allEdges = [];
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
const code = readFileSync(file, 'utf-8');
|
|
40
|
+
const relPath = relative(projectRoot, file);
|
|
41
|
+
const { nodes, edges } = parseFile(code, relPath, globalComponentSet);
|
|
42
|
+
allNodes.push(...nodes);
|
|
43
|
+
allEdges.push(...edges);
|
|
44
|
+
}
|
|
45
|
+
// Deduplicate nodes by id (keep first occurrence)
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const uniqueNodes = allNodes.filter((n) => {
|
|
48
|
+
if (seen.has(n.id))
|
|
49
|
+
return false;
|
|
50
|
+
seen.add(n.id);
|
|
51
|
+
return true;
|
|
52
|
+
});
|
|
53
|
+
// Remove edges referencing unknown nodes
|
|
54
|
+
const nodeIds = new Set(uniqueNodes.map((n) => n.id));
|
|
55
|
+
const validEdges = allEdges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target));
|
|
56
|
+
// Deduplicate edges
|
|
57
|
+
const edgeSeen = new Set();
|
|
58
|
+
const uniqueEdges = validEdges.filter((e) => {
|
|
59
|
+
if (edgeSeen.has(e.id))
|
|
60
|
+
return false;
|
|
61
|
+
edgeSeen.add(e.id);
|
|
62
|
+
return true;
|
|
63
|
+
});
|
|
64
|
+
return { nodes: uniqueNodes, edges: uniqueEdges };
|
|
65
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { GraphNode, GraphEdge } from './types.js';
|
|
2
|
+
interface FileResult {
|
|
3
|
+
nodes: GraphNode[];
|
|
4
|
+
edges: GraphEdge[];
|
|
5
|
+
}
|
|
6
|
+
export declare function parseFile(code: string, filePath: string, externalComponents?: Set<string>): FileResult;
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=parse-file.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse-file.d.ts","sourceRoot":"","sources":["../../src/parser/parse-file.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAKtD,UAAU,UAAU;IAClB,KAAK,EAAE,SAAS,EAAE,CAAA;IAClB,KAAK,EAAE,SAAS,EAAE,CAAA;CACnB;AA8CD,wBAAgB,SAAS,CACvB,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,kBAAkB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAC/B,UAAU,CA8LZ"}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
import _traverse from '@babel/traverse';
|
|
3
|
+
import * as t from '@babel/types';
|
|
4
|
+
// @babel/traverse ESM interop
|
|
5
|
+
const traverse = _traverse.default ?? _traverse;
|
|
6
|
+
function isComponentName(name) {
|
|
7
|
+
return /^[A-Z]/.test(name);
|
|
8
|
+
}
|
|
9
|
+
function getStateSlots(path) {
|
|
10
|
+
const slots = [];
|
|
11
|
+
path.traverse({
|
|
12
|
+
CallExpression(innerPath) {
|
|
13
|
+
const callee = innerPath.node.callee;
|
|
14
|
+
const isHook = (t.isIdentifier(callee, { name: 'useState' }) ||
|
|
15
|
+
t.isIdentifier(callee, { name: 'useReducer' })) &&
|
|
16
|
+
t.isIdentifier(callee);
|
|
17
|
+
if (!isHook)
|
|
18
|
+
return;
|
|
19
|
+
// useState([initialValue]) → destructure [state, setState]
|
|
20
|
+
const parent = innerPath.parentPath;
|
|
21
|
+
if (parent?.isVariableDeclarator() &&
|
|
22
|
+
t.isArrayPattern(parent.node.id)) {
|
|
23
|
+
const first = parent.node.id.elements[0];
|
|
24
|
+
if (t.isIdentifier(first))
|
|
25
|
+
slots.push(first.name);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
return slots;
|
|
30
|
+
}
|
|
31
|
+
function getContextUsages(path) {
|
|
32
|
+
const names = [];
|
|
33
|
+
path.traverse({
|
|
34
|
+
CallExpression(innerPath) {
|
|
35
|
+
const callee = innerPath.node.callee;
|
|
36
|
+
if (!t.isIdentifier(callee, { name: 'useContext' }))
|
|
37
|
+
return;
|
|
38
|
+
const arg = innerPath.node.arguments[0];
|
|
39
|
+
if (t.isIdentifier(arg))
|
|
40
|
+
names.push(arg.name);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
return names;
|
|
44
|
+
}
|
|
45
|
+
// A1: Accept optional externalComponents for cross-file edge resolution
|
|
46
|
+
export function parseFile(code, filePath, externalComponents) {
|
|
47
|
+
const nodes = [];
|
|
48
|
+
const edges = [];
|
|
49
|
+
let ast;
|
|
50
|
+
try {
|
|
51
|
+
ast = parse(code, {
|
|
52
|
+
sourceType: 'module',
|
|
53
|
+
plugins: ['jsx', 'typescript', 'decorators-legacy'],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
58
|
+
console.warn(`[RSF] Failed to parse ${filePath}: ${msg}`);
|
|
59
|
+
return { nodes, edges };
|
|
60
|
+
}
|
|
61
|
+
// Map: context variable name → node id
|
|
62
|
+
const contextMap = new Map();
|
|
63
|
+
// Track component names found in this file for JSX child resolution
|
|
64
|
+
const componentSet = new Set();
|
|
65
|
+
// A5: Use Set for O(1) edge deduplication
|
|
66
|
+
const edgeIdSet = new Set();
|
|
67
|
+
function registerComponent(name, path, line) {
|
|
68
|
+
const stateSlots = getStateSlots(path);
|
|
69
|
+
const contextUsages = getContextUsages(path);
|
|
70
|
+
// Detect if this component renders a Context.Provider
|
|
71
|
+
let isContextProvider = false;
|
|
72
|
+
let contextName;
|
|
73
|
+
path.traverse({
|
|
74
|
+
JSXOpeningElement(innerPath) {
|
|
75
|
+
const el = innerPath.node.name;
|
|
76
|
+
// <XxxContext.Provider>
|
|
77
|
+
if (t.isJSXMemberExpression(el) &&
|
|
78
|
+
t.isJSXIdentifier(el.property, { name: 'Provider' }) &&
|
|
79
|
+
t.isJSXIdentifier(el.object)) {
|
|
80
|
+
isContextProvider = true;
|
|
81
|
+
contextName = el.object.name;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const node = {
|
|
86
|
+
id: name,
|
|
87
|
+
type: 'component',
|
|
88
|
+
label: name,
|
|
89
|
+
file: filePath,
|
|
90
|
+
line,
|
|
91
|
+
stateSlots,
|
|
92
|
+
isContextProvider,
|
|
93
|
+
contextName,
|
|
94
|
+
};
|
|
95
|
+
nodes.push(node);
|
|
96
|
+
componentSet.add(name);
|
|
97
|
+
// Add context-subscription edges
|
|
98
|
+
for (const ctxName of contextUsages) {
|
|
99
|
+
const ctxId = contextMap.get(ctxName) ?? ctxName;
|
|
100
|
+
const edgeId = `${ctxId}->${name}`;
|
|
101
|
+
if (!edgeIdSet.has(edgeId)) {
|
|
102
|
+
edgeIdSet.add(edgeId);
|
|
103
|
+
edges.push({
|
|
104
|
+
id: edgeId,
|
|
105
|
+
source: ctxId,
|
|
106
|
+
target: name,
|
|
107
|
+
type: 'context-subscription',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// A4: Create Provider → Context edge
|
|
112
|
+
if (isContextProvider && contextName) {
|
|
113
|
+
const ctxId = contextMap.get(contextName) ?? contextName;
|
|
114
|
+
const edgeId = `${name}->${ctxId}:provides`;
|
|
115
|
+
if (!edgeIdSet.has(edgeId)) {
|
|
116
|
+
edgeIdSet.add(edgeId);
|
|
117
|
+
edges.push({
|
|
118
|
+
id: edgeId,
|
|
119
|
+
source: name,
|
|
120
|
+
target: ctxId,
|
|
121
|
+
type: 'context-provision',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return node;
|
|
126
|
+
}
|
|
127
|
+
// First pass: find createContext calls to build contextMap
|
|
128
|
+
traverse(ast, {
|
|
129
|
+
VariableDeclarator(path) {
|
|
130
|
+
if (t.isCallExpression(path.node.init) &&
|
|
131
|
+
t.isIdentifier(path.node.init.callee, {
|
|
132
|
+
name: 'createContext',
|
|
133
|
+
}) &&
|
|
134
|
+
t.isIdentifier(path.node.id)) {
|
|
135
|
+
const ctxVarName = path.node.id.name;
|
|
136
|
+
const nodeId = ctxVarName;
|
|
137
|
+
contextMap.set(ctxVarName, nodeId);
|
|
138
|
+
nodes.push({
|
|
139
|
+
id: nodeId,
|
|
140
|
+
type: 'context',
|
|
141
|
+
label: ctxVarName,
|
|
142
|
+
file: filePath,
|
|
143
|
+
line: path.node.loc?.start.line ?? 0,
|
|
144
|
+
stateSlots: [],
|
|
145
|
+
isContextProvider: false,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
// Second pass: find component declarations
|
|
151
|
+
traverse(ast, {
|
|
152
|
+
// function MyComponent() { ... }
|
|
153
|
+
FunctionDeclaration(path) {
|
|
154
|
+
const name = path.node.id?.name;
|
|
155
|
+
if (name && isComponentName(name)) {
|
|
156
|
+
registerComponent(name, path, path.node.loc?.start.line ?? 0);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
// const MyComponent = () => { ... } or function() { ... } or memo(...) etc.
|
|
160
|
+
VariableDeclarator(path) {
|
|
161
|
+
if (!t.isIdentifier(path.node.id))
|
|
162
|
+
return;
|
|
163
|
+
const name = path.node.id.name;
|
|
164
|
+
if (!isComponentName(name))
|
|
165
|
+
return;
|
|
166
|
+
const init = path.node.init;
|
|
167
|
+
if (t.isArrowFunctionExpression(init) || t.isFunctionExpression(init)) {
|
|
168
|
+
registerComponent(name, path, path.node.loc?.start.line ?? 0);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// A2: HOC / memo / forwardRef wrapping a function
|
|
172
|
+
if (t.isCallExpression(init)) {
|
|
173
|
+
const firstArg = init.arguments[0];
|
|
174
|
+
if (firstArg &&
|
|
175
|
+
(t.isArrowFunctionExpression(firstArg) ||
|
|
176
|
+
t.isFunctionExpression(firstArg) ||
|
|
177
|
+
t.isIdentifier(firstArg))) {
|
|
178
|
+
registerComponent(name, path, path.node.loc?.start.line ?? 0);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
// A3: export default function() { ... } — derive name from filename
|
|
183
|
+
ExportDefaultDeclaration(path) {
|
|
184
|
+
const decl = path.node.declaration;
|
|
185
|
+
const isAnonFn = (t.isFunctionDeclaration(decl) || t.isArrowFunctionExpression(decl)) &&
|
|
186
|
+
!decl.id;
|
|
187
|
+
if (!isAnonFn)
|
|
188
|
+
return;
|
|
189
|
+
const base = filePath.split('/').pop() ?? filePath;
|
|
190
|
+
const name = base.replace(/\.[^.]+$/, '');
|
|
191
|
+
if (!isComponentName(name))
|
|
192
|
+
return;
|
|
193
|
+
registerComponent(name, path, path.node.loc?.start.line ?? 0);
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
// Third pass: find JSX parent→child relationships
|
|
197
|
+
traverse(ast, {
|
|
198
|
+
FunctionDeclaration(path) {
|
|
199
|
+
extractJSXChildren(path, edges, edgeIdSet, componentSet, externalComponents);
|
|
200
|
+
},
|
|
201
|
+
VariableDeclarator(path) {
|
|
202
|
+
if (!t.isIdentifier(path.node.id))
|
|
203
|
+
return;
|
|
204
|
+
const name = path.node.id.name;
|
|
205
|
+
if (!isComponentName(name))
|
|
206
|
+
return;
|
|
207
|
+
const init = path.node.init;
|
|
208
|
+
const isFn = t.isArrowFunctionExpression(init) || t.isFunctionExpression(init);
|
|
209
|
+
const isWrapped = t.isCallExpression(init);
|
|
210
|
+
if (isFn || isWrapped) {
|
|
211
|
+
extractJSXChildren(path, edges, edgeIdSet, componentSet, externalComponents, name);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
return { nodes, edges };
|
|
216
|
+
}
|
|
217
|
+
function extractJSXChildren(path, edges, edgeIdSet, componentSet, externalComponents, parentNameOverride) {
|
|
218
|
+
const parentName = parentNameOverride ??
|
|
219
|
+
path.node.id?.name;
|
|
220
|
+
if (!parentName || !isComponentName(parentName))
|
|
221
|
+
return;
|
|
222
|
+
// A1: Use externalComponents (global set) when available for cross-file resolution
|
|
223
|
+
const allKnown = externalComponents ?? componentSet;
|
|
224
|
+
path.traverse({
|
|
225
|
+
JSXOpeningElement(innerPath) {
|
|
226
|
+
const el = innerPath.node.name;
|
|
227
|
+
let childName;
|
|
228
|
+
if (t.isJSXIdentifier(el) && isComponentName(el.name)) {
|
|
229
|
+
childName = el.name;
|
|
230
|
+
}
|
|
231
|
+
// A6: Handle <Namespace.Component /> — use namespace as the component reference
|
|
232
|
+
if (!childName && t.isJSXMemberExpression(el)) {
|
|
233
|
+
const ns = t.isJSXIdentifier(el.object) ? el.object.name : undefined;
|
|
234
|
+
if (ns && allKnown.has(ns))
|
|
235
|
+
childName = ns;
|
|
236
|
+
}
|
|
237
|
+
if (!childName || !allKnown.has(childName))
|
|
238
|
+
return;
|
|
239
|
+
const edgeId = `${parentName}->${childName}`;
|
|
240
|
+
if (edgeIdSet.has(edgeId))
|
|
241
|
+
return; // A5: O(1) check
|
|
242
|
+
edgeIdSet.add(edgeId);
|
|
243
|
+
edges.push({
|
|
244
|
+
id: edgeId,
|
|
245
|
+
source: parentName,
|
|
246
|
+
target: childName,
|
|
247
|
+
type: 'parent-child',
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type NodeType = 'component' | 'context';
|
|
2
|
+
export interface GraphNode {
|
|
3
|
+
id: string;
|
|
4
|
+
type: NodeType;
|
|
5
|
+
label: string;
|
|
6
|
+
file: string;
|
|
7
|
+
line: number;
|
|
8
|
+
stateSlots: string[];
|
|
9
|
+
isContextProvider: boolean;
|
|
10
|
+
contextName?: string;
|
|
11
|
+
}
|
|
12
|
+
export type EdgeType = 'parent-child' | 'context-subscription' | 'context-provision';
|
|
13
|
+
export interface GraphEdge {
|
|
14
|
+
id: string;
|
|
15
|
+
source: string;
|
|
16
|
+
target: string;
|
|
17
|
+
type: EdgeType;
|
|
18
|
+
}
|
|
19
|
+
export interface GraphData {
|
|
20
|
+
nodes: GraphNode[];
|
|
21
|
+
edges: GraphEdge[];
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/parser/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,QAAQ,GAAG,WAAW,GAAG,SAAS,CAAA;AAE9C,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,QAAQ,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,iBAAiB,EAAE,OAAO,CAAA;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,MAAM,QAAQ,GAAG,cAAc,GAAG,sBAAsB,GAAG,mBAAmB,CAAA;AAEpF,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,QAAQ,CAAA;CACf;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,SAAS,EAAE,CAAA;IAClB,KAAK,EAAE,SAAS,EAAE,CAAA;CACnB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* react-state-flow/runtime
|
|
3
|
+
*
|
|
4
|
+
* Import này TRƯỚC khi React mount để hook vào React DevTools global hook.
|
|
5
|
+
* Sends render events via WebSocket to the RSF CLI server.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import 'react-state-flow/runtime' // top of main.tsx / index.tsx
|
|
9
|
+
*/
|
|
10
|
+
export interface RenderEvent {
|
|
11
|
+
type: 'render';
|
|
12
|
+
componentName: string;
|
|
13
|
+
renderCount: number;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
}
|
|
16
|
+
declare global {
|
|
17
|
+
interface Window {
|
|
18
|
+
__REACT_DEVTOOLS_GLOBAL_HOOK__: any;
|
|
19
|
+
__RSF_WS__: WebSocket | null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,QAAQ,CAAA;IACd,aAAa,EAAE,MAAM,CAAA;IACrB,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,8BAA8B,EAAE,GAAG,CAAA;QACnC,UAAU,EAAE,SAAS,GAAG,IAAI,CAAA;KAC7B;CACF"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* react-state-flow/runtime
|
|
3
|
+
*
|
|
4
|
+
* Import này TRƯỚC khi React mount để hook vào React DevTools global hook.
|
|
5
|
+
* Sends render events via WebSocket to the RSF CLI server.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import 'react-state-flow/runtime' // top of main.tsx / index.tsx
|
|
9
|
+
*/
|
|
10
|
+
const RSF_PORT = window.__RSF_PORT__ ?? 7272;
|
|
11
|
+
const WS_URL = `ws://localhost:${RSF_PORT}/runtime`;
|
|
12
|
+
// B2: Render counter per component with max-size cap to prevent memory leak
|
|
13
|
+
const renderCounts = new Map();
|
|
14
|
+
const MAX_RENDER_COUNT_ENTRIES = 500;
|
|
15
|
+
function incrementRenderCount(name) {
|
|
16
|
+
if (!renderCounts.has(name) && renderCounts.size >= MAX_RENDER_COUNT_ENTRIES) {
|
|
17
|
+
// Evict oldest entry (insertion order)
|
|
18
|
+
const firstKey = renderCounts.keys().next().value;
|
|
19
|
+
if (firstKey !== undefined)
|
|
20
|
+
renderCounts.delete(firstKey);
|
|
21
|
+
}
|
|
22
|
+
const count = (renderCounts.get(name) ?? 0) + 1;
|
|
23
|
+
renderCounts.set(name, count);
|
|
24
|
+
return count;
|
|
25
|
+
}
|
|
26
|
+
function send(event) {
|
|
27
|
+
if (!window.__RSF_WS__ || window.__RSF_WS__.readyState !== WebSocket.OPEN)
|
|
28
|
+
return;
|
|
29
|
+
window.__RSF_WS__.send(JSON.stringify(event));
|
|
30
|
+
}
|
|
31
|
+
// B3: Exponential backoff reconnect
|
|
32
|
+
let reconnectDelay = 2000;
|
|
33
|
+
function connect() {
|
|
34
|
+
const ws = new WebSocket(WS_URL);
|
|
35
|
+
window.__RSF_WS__ = ws;
|
|
36
|
+
ws.addEventListener('open', () => {
|
|
37
|
+
reconnectDelay = 2000; // reset on successful connection
|
|
38
|
+
renderCounts.clear(); // B2: sync counts with server on reconnect
|
|
39
|
+
console.debug('[RSF] Runtime connected');
|
|
40
|
+
});
|
|
41
|
+
ws.addEventListener('close', () => {
|
|
42
|
+
console.debug(`[RSF] Runtime disconnected, retrying in ${reconnectDelay / 1000}s...`);
|
|
43
|
+
setTimeout(connect, reconnectDelay);
|
|
44
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
|
45
|
+
});
|
|
46
|
+
ws.addEventListener('error', () => {
|
|
47
|
+
// suppress — will retry on close
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function hookIntoReact() {
|
|
51
|
+
// React checks for this global hook on load and calls it during reconciliation
|
|
52
|
+
const existing = window.__REACT_DEVTOOLS_GLOBAL_HOOK__ ?? {};
|
|
53
|
+
const originalOnCommitFiberRoot = existing.onCommitFiberRoot?.bind(existing) ?? (() => { });
|
|
54
|
+
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
|
|
55
|
+
...existing,
|
|
56
|
+
isDisabled: false,
|
|
57
|
+
supportsFiber: true,
|
|
58
|
+
onCommitFiberRoot(rendererID, root, priorityLevel) {
|
|
59
|
+
// Call through so React DevTools still works
|
|
60
|
+
originalOnCommitFiberRoot(rendererID, root, priorityLevel);
|
|
61
|
+
// B1: Pass per-commit Set to avoid counting sibling instances multiple times
|
|
62
|
+
try {
|
|
63
|
+
walkFiber(root.current, new Set());
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Never break the app
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function getFiberName(fiber) {
|
|
72
|
+
const type = fiber?.type;
|
|
73
|
+
if (!type)
|
|
74
|
+
return null;
|
|
75
|
+
if (typeof type === 'string')
|
|
76
|
+
return null; // DOM element
|
|
77
|
+
if (typeof type === 'function')
|
|
78
|
+
return type.displayName ?? type.name ?? null;
|
|
79
|
+
if (typeof type === 'object' && type !== null) {
|
|
80
|
+
// forwardRef, memo, etc.
|
|
81
|
+
return (type.displayName ??
|
|
82
|
+
type.render?.displayName ??
|
|
83
|
+
type.render?.name ??
|
|
84
|
+
null);
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
// B1: seenInCommit prevents counting sibling instances multiple times per commit
|
|
89
|
+
function walkFiber(fiber, seenInCommit) {
|
|
90
|
+
if (!fiber)
|
|
91
|
+
return;
|
|
92
|
+
const name = getFiberName(fiber);
|
|
93
|
+
if (name && /^[A-Z]/.test(name) && !seenInCommit.has(name)) {
|
|
94
|
+
seenInCommit.add(name);
|
|
95
|
+
const count = incrementRenderCount(name);
|
|
96
|
+
send({ type: 'render', componentName: name, renderCount: count, timestamp: Date.now() });
|
|
97
|
+
}
|
|
98
|
+
walkFiber(fiber.child, seenInCommit);
|
|
99
|
+
walkFiber(fiber.sibling, seenInCommit);
|
|
100
|
+
}
|
|
101
|
+
// Bootstrap — only in development
|
|
102
|
+
const isDev = typeof process !== 'undefined'
|
|
103
|
+
? process.env.NODE_ENV !== 'production'
|
|
104
|
+
: import.meta.env?.MODE !== 'production';
|
|
105
|
+
if (typeof window !== 'undefined' && isDev) {
|
|
106
|
+
hookIntoReact();
|
|
107
|
+
connect();
|
|
108
|
+
}
|
|
109
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,34 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-state-flow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Visualize React component hierarchy, state flow, and real-time render events",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
6
7
|
"bin": {
|
|
7
8
|
"react-state-flow": "./dist/index.js"
|
|
8
9
|
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts"
|
|
14
|
+
},
|
|
15
|
+
"./runtime": {
|
|
16
|
+
"import": "./dist/runtime/index.js",
|
|
17
|
+
"types": "./dist/runtime/index.d.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
9
20
|
"files": [
|
|
10
|
-
"dist"
|
|
21
|
+
"dist",
|
|
22
|
+
"ui/dist"
|
|
11
23
|
],
|
|
12
24
|
"engines": {
|
|
13
25
|
"node": ">=18"
|
|
14
26
|
},
|
|
15
27
|
"scripts": {
|
|
16
|
-
"
|
|
17
|
-
"build": "
|
|
28
|
+
"build": "npm run build:ts && npm run build:ui",
|
|
29
|
+
"build:ts": "tsc -p tsconfig.json",
|
|
30
|
+
"build:ui": "vite build --config ui/vite.config.ts",
|
|
31
|
+
"dev": "tsx src/index.ts",
|
|
32
|
+
"ui:dev": "vite --config ui/vite.config.ts",
|
|
33
|
+
"prepublishOnly": "npm run build"
|
|
18
34
|
},
|
|
19
35
|
"dependencies": {
|
|
36
|
+
"@babel/parser": "^7.24.0",
|
|
37
|
+
"@babel/traverse": "^7.24.0",
|
|
38
|
+
"@babel/types": "^7.24.0",
|
|
20
39
|
"chokidar": "^3.6.0",
|
|
21
40
|
"express": "^4.19.0",
|
|
22
|
-
"ws": "^8.17.0",
|
|
23
41
|
"open": "^10.1.0",
|
|
24
|
-
"picocolors": "^1.0.1"
|
|
42
|
+
"picocolors": "^1.0.1",
|
|
43
|
+
"ws": "^8.17.0"
|
|
25
44
|
},
|
|
26
45
|
"devDependencies": {
|
|
27
|
-
"@
|
|
46
|
+
"@dagrejs/dagre": "^1.0.0",
|
|
47
|
+
"@types/babel__traverse": "^7.20.6",
|
|
28
48
|
"@types/express": "^4.17.21",
|
|
49
|
+
"@types/react": "^18.3.0",
|
|
50
|
+
"@types/react-dom": "^18.3.0",
|
|
29
51
|
"@types/ws": "^8.5.10",
|
|
30
|
-
"
|
|
52
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
53
|
+
"@xyflow/react": "^12.0.0",
|
|
54
|
+
"react": "^18.3.0",
|
|
55
|
+
"react-dom": "^18.3.0",
|
|
31
56
|
"tsx": "^4.15.0",
|
|
32
|
-
"typescript": "^5.4.0"
|
|
57
|
+
"typescript": "^5.4.0",
|
|
58
|
+
"vite": "^5.3.0"
|
|
33
59
|
}
|
|
34
60
|
}
|