@flotrace/runtime-core 2.2.4 → 2.3.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/README.md CHANGED
@@ -50,6 +50,100 @@ your React app ←→ @flotrace/runtime[-native] ←→ ws://localhost:3457
50
50
  | `serializer` | Safe JSON serialization (depth 5, circular-ref guard, truncation). |
51
51
  | `websocketClient` | Singleton WS client with exponential backoff reconnect, message batching, optional auth token. |
52
52
 
53
+ ## Optional: JSX runtime opt-in (source attribution upgrade)
54
+
55
+ `runtime-core` ships two additional subpath entries — `./jsx-runtime` and `./jsx-dev-runtime` — that you can opt into via a one-line `tsconfig.json` change. Doing so lets FloTrace attribute every JSX call site to its exact `file:line:column` with 100% confidence, even on stacks where React no longer carries that signal (Next.js 15 + SWC, React 19 with `_debugSource` removed).
56
+
57
+ **The opt-in is free, additive, and reverts cleanly** — your app continues to work in production unchanged, and the existing heuristic ladder still runs when the opt-in is off.
58
+
59
+ ### Setup
60
+
61
+ ```jsonc
62
+ // tsconfig.json
63
+ {
64
+ "compilerOptions": {
65
+ "jsx": "react-jsx",
66
+ "jsxImportSource": "@flotrace/runtime-core"
67
+ }
68
+ }
69
+ ```
70
+
71
+ Restart your dev server. That's it.
72
+
73
+ ### What you get
74
+
75
+ - **Click any node in FloTrace → IDE jumps to the exact JSX line** (column included).
76
+ - **Per-call-site render metrics** instead of per-component-type. `<Button/>` at `Header.tsx:23` and `<Button/>` at `Footer.tsx:7` are now separate rows in the Hot Call Sites tab — only the actually-hot one is flagged.
77
+ - **Inline-literal detection** — the runtime sees props *before* React processes them and tags fresh-each-render `onClick={() => ...}`, `style={{}}`, `items={[...]}` at the call site that created them. This signal is impossible to recover after React commits.
78
+ - **Conditional-render visibility** — a callsite nested in `{cond && <X/>}` gets a `~N%` chip showing how often it actually renders.
79
+ - **Duplicate-key warnings** with exact source location instead of grep-the-codebase.
80
+ - **Privacy-safe Copy-as-Prompt** — every component reference cites `(src/components/Header.tsx:42)`, project-relative, never `/Users/foo/...`.
81
+ - **Watches, Resolution Tracker, Value Lineage, Cascade, Prop Drilling** all gain `(file:line)` annotations and HMR-stable call-site identity.
82
+
83
+ ### Bundler matrix
84
+
85
+ | Bundler | Zero-config? | Notes |
86
+ |---|---|---|
87
+ | **Vite (+ SWC or Babel)** | ✅ | Honors `jsxImportSource` from tsconfig out of the box. |
88
+ | **Next.js 15 (Webpack)** | ✅ | SWC reads tsconfig. |
89
+ | **Next.js 15 (Turbopack)** | ✅ | Same. |
90
+ | **Remix / Vinxi** | ✅ | Same. |
91
+ | **Expo SDK 50+ (Metro + Babel)** | ✅ | Metro's Babel preset honors `jsxImportSource`. |
92
+ | **Bun** | ✅ | Built-in JSX transformer reads tsconfig. |
93
+ | **Create React App** | ⚠ Needs Babel snippet | CRA's locked Babel config doesn't honor `jsxImportSource` from tsconfig. Use CRACO (or `react-app-rewired`) to inject a Babel preset override. See snippet below. |
94
+
95
+ For CRA via CRACO, add to `craco.config.js`:
96
+
97
+ ```js
98
+ module.exports = {
99
+ babel: {
100
+ presets: [
101
+ ['@babel/preset-react', {
102
+ runtime: 'automatic',
103
+ importSource: '@flotrace/runtime-core',
104
+ development: true, // emits jsxDEV instead of jsx in dev builds
105
+ }],
106
+ ],
107
+ },
108
+ };
109
+ ```
110
+
111
+ ### How to verify it's working
112
+
113
+ 1. **Run your app in dev mode**, open browser DevTools → Console.
114
+ 2. Paste: `globalThis[Symbol.for('flotrace.jsx-runtime-active')]` → should return `true`.
115
+ 3. **Open React DevTools → Components tab**, select any user component, then click the wrench icon to view its props in the Console. Run:
116
+ ```js
117
+ $r.memoizedProps[Symbol.for('flotrace.source')]
118
+ ```
119
+ You should see `{ fileName, lineNumber, columnNumber, callSiteId }`.
120
+
121
+ If step 2 returns `undefined`, your bundler isn't picking up the tsconfig setting — see the bundler matrix above. The connection-status tooltip inside the FloTrace desktop app also shows **"JSX runtime: active ✓"** once the opt-in is wired up correctly.
122
+
123
+ If step 2 returns `true` but step 3 returns `undefined` on a specific component, that fiber was created via a path that bypasses `jsxDEV` (e.g. `React.createElement` direct call inside a vendored dependency, or a server-rendered component hydrated client-side). User-authored Client Components in `.tsx` / `.jsx` files always pass through.
124
+
125
+ ### Performance budget
126
+
127
+ | Cost | Target | Notes |
128
+ |---|---|---|
129
+ | Per-`jsxDEV` call overhead | **<10µs median** | Microbenchmark on V8. Path normalization + hash + inline detection + symbol-prop allocation. |
130
+ | Per-commit cumulative overhead | **<55ms** | For a 5000-element user-component tree. Well below React's own commit cost. |
131
+ | Symbol-prop memory | ~200 bytes per user-component fiber | ~1 MB for 5000 fibers. GC'd with the fiber on unmount. |
132
+ | Ring buffer memory | 60 timestamps × callSiteId count | ~2.4 MB worst case for 5000 distinct callsites. Cleared on walker uninstall. |
133
+
134
+ ### Privacy
135
+
136
+ The runtime captures **absolute paths** (needed so click-to-IDE can resolve files reliably across symlinks and workspaces). Absolute paths NEVER leave your machine via:
137
+
138
+ - **WebSocket** → bound to `127.0.0.1` only; the desktop app is also local.
139
+ - **Copy-as-Prompt** → the prompt builder calls `relativizePath(absPath, projectRoot)` before serialization. Output cites `src/Header.tsx:42`, never `/Users/foo/proj/src/Header.tsx:42`.
140
+
141
+ The opt-in is **dev-only** by design. The production entry (`@flotrace/runtime-core/jsx-runtime`) is a pure passthrough to `react/jsx-runtime` — zero runtime overhead, no symbol injection, no metadata captured.
142
+
143
+ ### Reverting
144
+
145
+ Delete the `jsxImportSource` line from `tsconfig.json` and restart your dev server. FloTrace falls back to its existing heuristic ladder (`_debugSource` → `_debugOwner` → `_debugStack`). No code changes needed in your app.
146
+
53
147
  ## Version compatibility
54
148
 
55
149
  `@flotrace/runtime-core@2.x` is the companion release for:
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Type declarations for `@flotrace/runtime-core/babel-plugin`.
3
+ *
4
+ * The plugin itself is a plain CommonJS module (`babel-plugin.js`) so Babel
5
+ * — which loads plugins via `require()` — can consume it without a build
6
+ * step. These declarations give the test suite and any TypeScript consumer
7
+ * a typed handle on the export.
8
+ */
9
+
10
+ import type * as BabelTypes from '@babel/types';
11
+ import type { NodePath, PluginObj } from '@babel/traverse';
12
+
13
+ export interface FlotraceBabelPluginOptions {
14
+ /** Set to `false` to no-op (e.g. for production builds). Default `true`. */
15
+ development?: boolean;
16
+ }
17
+
18
+ export interface FlotraceBabelPluginState {
19
+ opts: FlotraceBabelPluginOptions;
20
+ file: { opts: { filename?: string | null } };
21
+ }
22
+
23
+ declare const flotraceBabelPlugin: {
24
+ (api: { types: typeof BabelTypes }): PluginObj<FlotraceBabelPluginState> & {
25
+ visitor: {
26
+ JSXOpeningElement(
27
+ path: NodePath<BabelTypes.JSXOpeningElement>,
28
+ state: FlotraceBabelPluginState,
29
+ ): void;
30
+ };
31
+ };
32
+ /** Constant attribute name — `data-flotrace-src`. Avoid string drift. */
33
+ FLOTRACE_ATTR_NAME: string;
34
+ };
35
+
36
+ export default flotraceBabelPlugin;
@@ -0,0 +1,302 @@
1
+ /**
2
+ * @flotrace/runtime-core/babel-plugin
3
+ *
4
+ * Injects source attribution into user code in two complementary places so
5
+ * every fiber in the FloTrace tree gets click-to-IDE coverage:
6
+ *
7
+ * (A) `data-flotrace-src` JSX attribute on every JSX element.
8
+ * Read at runtime from `fiber.memoizedProps['data-flotrace-src']`.
9
+ * This covers every fiber that was created via user-written JSX
10
+ * (`<MyComponent />`) — the "call site" attribution.
11
+ *
12
+ * (B) `Component['data-flotrace-src'] = '{...}'` assignment after every
13
+ * PascalCase function / arrow / class declaration. Read at runtime
14
+ * from `fiber.type['data-flotrace-src']` as a fallback for fibers
15
+ * whose memoizedProps lacks the attribute — i.e. when the component
16
+ * was instantiated via `React.createElement(Component, ...)` from
17
+ * inside a library (react-navigation `<Stack.Screen component={X}>`,
18
+ * HOC-wrapped components, top-level App registered via
19
+ * AppRegistry.registerComponent). This is the "definition site"
20
+ * attribution.
21
+ *
22
+ * Together (A) + (B) ensure that every user component in the tree has a
23
+ * source — never a broken-state "no nav button" card.
24
+ *
25
+ * Why a string-keyed JSXAttribute (route A) instead of the JSX-runtime opt-in:
26
+ *
27
+ * 1. Coexists with any other `jsxImportSource` consumer (nativewind /
28
+ * react-native-css-interop, Solid-style runtimes, etc.) — those claim
29
+ * the single `importSource` slot per file; this plugin is independent.
30
+ *
31
+ * 2. Survives React 19's keyed-element prop clone. Symbol-keyed props get
32
+ * silently dropped by React 19's `for-in` config clone whenever an
33
+ * element has a `key` prop (list rows, `.map()` children, navigation
34
+ * screens — the high-value source-tracking sites). String-keyed props
35
+ * enumerate via for-in and survive.
36
+ *
37
+ * 3. `data-*` attributes are valid on every React DOM host element (no
38
+ * unknown-prop warning) and silently ignored by React Native's native
39
+ * renderer (no warning either).
40
+ *
41
+ * 4. JSON-encoded string value parses safely on Windows paths (`C:\...`)
42
+ * where a `file:line:col` delimiter would be ambiguous.
43
+ *
44
+ * Usage in user babel.config.js (single line, no other config changes):
45
+ *
46
+ * plugins: ['@flotrace/runtime-core/babel-plugin']
47
+ *
48
+ * Options:
49
+ * - development (boolean, default true): when false, the plugin no-ops so
50
+ * production bundles aren't bloated with source attributions.
51
+ *
52
+ * No conflict with `@babel/plugin-transform-react-jsx` (any runtime/source/
53
+ * importSource config) — this plugin only ADDS attributes and assignments,
54
+ * it doesn't touch the JSX transform itself.
55
+ */
56
+ 'use strict';
57
+
58
+ const FLOTRACE_ATTR_NAME = 'data-flotrace-src';
59
+
60
+ // React-component naming convention. Anything starting with an uppercase
61
+ // letter is treated as a component candidate. Underscores tolerated for
62
+ // generated names (`_AppComponent` etc.).
63
+ const PASCAL_CASE_RE = /^[A-Z][A-Za-z0-9_$]*$/;
64
+
65
+ /** Build the JSON payload string for the `data-flotrace-src` value. */
66
+ function buildPayload(filename, loc) {
67
+ return JSON.stringify({
68
+ f: filename,
69
+ l: loc.start.line,
70
+ c: loc.start.column + 1, // 1-indexed to match React's convention
71
+ });
72
+ }
73
+
74
+ /** Filename + loc validation gates shared by all visitors. */
75
+ function shouldVisit(state, loc) {
76
+ if (state.opts && state.opts.development === false) return null;
77
+ const filename = state.file && state.file.opts && state.file.opts.filename;
78
+ if (!filename) return null;
79
+ if (filename.indexOf('/node_modules/') !== -1) return null;
80
+ if (filename.indexOf('\\node_modules\\') !== -1) return null;
81
+ if (!loc || !loc.start) return null;
82
+ return filename;
83
+ }
84
+
85
+ /**
86
+ * Reject obviously-non-component initializer types. PascalCase + a function
87
+ * / class / HOC-call init IS a component (or close enough that tagging is
88
+ * harmless). PascalCase + a primitive / array / object literal is NOT.
89
+ */
90
+ function isComponentLikeInit(node) {
91
+ if (!node) return false;
92
+ switch (node.type) {
93
+ case 'NullLiteral':
94
+ case 'NumericLiteral':
95
+ case 'StringLiteral':
96
+ case 'BooleanLiteral':
97
+ case 'BigIntLiteral':
98
+ case 'ArrayExpression':
99
+ case 'ObjectExpression':
100
+ case 'TemplateLiteral':
101
+ case 'RegExpLiteral':
102
+ return false;
103
+ default:
104
+ // Component-shaped: ArrowFunctionExpression, FunctionExpression,
105
+ // ClassExpression, CallExpression (HOC wrap), Identifier (re-export),
106
+ // MemberExpression (`X.Y`), ConditionalExpression, etc.
107
+ return true;
108
+ }
109
+ }
110
+
111
+ module.exports = function flotraceSourceAttributionPlugin({ types: t }) {
112
+ /**
113
+ * Build the `Identifier['data-flotrace-src'] = '<payload>';` statement.
114
+ * Guarded with `if (Identifier)` so reassignments to `undefined`/`null`
115
+ * before the assignment runs don't throw at module load.
116
+ */
117
+ function buildDeclTaggingStatement(name, payload) {
118
+ const idRef = t.identifier(name);
119
+ const memberAccess = t.memberExpression(
120
+ t.identifier(name),
121
+ t.stringLiteral(FLOTRACE_ATTR_NAME),
122
+ /* computed */ true,
123
+ );
124
+ return t.ifStatement(
125
+ idRef,
126
+ t.expressionStatement(
127
+ t.assignmentExpression('=', memberAccess, t.stringLiteral(payload)),
128
+ ),
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Insert a sibling assignment immediately after a top-level declaration
134
+ * path. `path` here is the *outermost* statement we want to insert after
135
+ * — for an `export default function X() {}`, the outer path is the
136
+ * ExportDefaultDeclaration, not the FunctionDeclaration inside it.
137
+ * Returns true when injection happened (used by callers to mark the node
138
+ * as visited and prevent re-injection).
139
+ */
140
+ function insertDeclTagAfter(path, name, payload) {
141
+ // Idempotency: scan siblings for an existing assignment to the same
142
+ // identifier + same attribute. Babel can re-visit the path after other
143
+ // plugins mutate the program.
144
+ const siblings =
145
+ path.parentPath && path.parentPath.get('body')
146
+ ? [].concat(path.parentPath.get('body'))
147
+ : [];
148
+ for (let i = 0; i < siblings.length; i++) {
149
+ const sib = siblings[i].node;
150
+ if (
151
+ sib &&
152
+ sib.type === 'IfStatement' &&
153
+ sib.test &&
154
+ sib.test.type === 'Identifier' &&
155
+ sib.test.name === name &&
156
+ sib.consequent &&
157
+ sib.consequent.type === 'ExpressionStatement' &&
158
+ sib.consequent.expression &&
159
+ sib.consequent.expression.type === 'AssignmentExpression'
160
+ ) {
161
+ const left = sib.consequent.expression.left;
162
+ if (
163
+ left.type === 'MemberExpression' &&
164
+ left.computed &&
165
+ left.property.type === 'StringLiteral' &&
166
+ left.property.value === FLOTRACE_ATTR_NAME
167
+ ) {
168
+ return false;
169
+ }
170
+ }
171
+ }
172
+ path.insertAfter(buildDeclTaggingStatement(name, payload));
173
+ return true;
174
+ }
175
+
176
+ return {
177
+ name: 'flotrace-source-attribution',
178
+ visitor: {
179
+ // ────────────────────────────────────────────────────────────
180
+ // (A) Tag every JSX element with its call-site source.
181
+ // ────────────────────────────────────────────────────────────
182
+ JSXOpeningElement(path, state) {
183
+ const filename = shouldVisit(state, path.node.loc);
184
+ if (!filename) return;
185
+
186
+ // Idempotency: skip if a previous run already injected the attribute
187
+ // (Babel can re-traverse after another plugin mutates the node).
188
+ const attrs = path.node.attributes;
189
+ for (let i = 0; i < attrs.length; i++) {
190
+ const attr = attrs[i];
191
+ if (
192
+ attr.type === 'JSXAttribute' &&
193
+ attr.name &&
194
+ attr.name.type === 'JSXIdentifier' &&
195
+ attr.name.name === FLOTRACE_ATTR_NAME
196
+ ) {
197
+ return;
198
+ }
199
+ }
200
+
201
+ // Skip `<Fragment>` openings — Fragment doesn't accept arbitrary
202
+ // props, and React would warn on the unknown attribute. Note that
203
+ // `<></>` short-syntax is a `JSXFragment` AST node (not a
204
+ // `JSXOpeningElement`), so the visitor never fires for it — only
205
+ // the named forms reach this point.
206
+ const openingName = path.node.name;
207
+ if (openingName.type === 'JSXIdentifier' && openingName.name === 'Fragment') {
208
+ return;
209
+ }
210
+ // `<React.Fragment>` / `<MyNs.Fragment>` — JSXMemberExpression with
211
+ // a trailing `Fragment` identifier. (`React.Fragment` cannot appear
212
+ // as a plain JSXIdentifier — identifiers don't permit dots.)
213
+ if (
214
+ openingName.type === 'JSXMemberExpression' &&
215
+ openingName.property &&
216
+ openingName.property.name === 'Fragment'
217
+ ) {
218
+ return;
219
+ }
220
+
221
+ const payload = buildPayload(filename, path.node.loc);
222
+ // Wrap the JSON payload in a JSX expression container so the value
223
+ // uses JS-string-literal escaping rules. A bare `t.stringLiteral`
224
+ // here would render as `data-flotrace-src="..."` — but JSX string
225
+ // attributes don't support C-style escapes, so embedded `"` chars
226
+ // produce invalid JSX. The expression-container form (`name={...}`)
227
+ // sidesteps the whole quoting problem.
228
+ attrs.push(
229
+ t.jsxAttribute(
230
+ t.jsxIdentifier(FLOTRACE_ATTR_NAME),
231
+ t.jsxExpressionContainer(t.stringLiteral(payload)),
232
+ ),
233
+ );
234
+ },
235
+
236
+ // ────────────────────────────────────────────────────────────
237
+ // (B) Tag every PascalCase component definition with its declaration
238
+ // source. Covers fibers instantiated via `React.createElement`
239
+ // from inside a library (react-navigation screens, HOC-wrapped
240
+ // components, AppRegistry-registered roots).
241
+ // ────────────────────────────────────────────────────────────
242
+ FunctionDeclaration(path, state) {
243
+ const id = path.node.id;
244
+ if (!id || !PASCAL_CASE_RE.test(id.name)) return;
245
+ const filename = shouldVisit(state, path.node.loc);
246
+ if (!filename) return;
247
+ // If wrapped in `export default function X() {}` /
248
+ // `export function X() {}`, attach the assignment AFTER the export
249
+ // statement so it's evaluated at module-eval time (function decls
250
+ // are hoisted, so the identifier is defined by then).
251
+ const target =
252
+ path.parentPath.isExportDefaultDeclaration() ||
253
+ path.parentPath.isExportNamedDeclaration()
254
+ ? path.parentPath
255
+ : path;
256
+ const payload = buildPayload(filename, path.node.loc);
257
+ insertDeclTagAfter(target, id.name, payload);
258
+ },
259
+
260
+ ClassDeclaration(path, state) {
261
+ const id = path.node.id;
262
+ if (!id || !PASCAL_CASE_RE.test(id.name)) return;
263
+ const filename = shouldVisit(state, path.node.loc);
264
+ if (!filename) return;
265
+ const target =
266
+ path.parentPath.isExportDefaultDeclaration() ||
267
+ path.parentPath.isExportNamedDeclaration()
268
+ ? path.parentPath
269
+ : path;
270
+ const payload = buildPayload(filename, path.node.loc);
271
+ insertDeclTagAfter(target, id.name, payload);
272
+ },
273
+
274
+ VariableDeclarator(path, state) {
275
+ const id = path.node.id;
276
+ if (!id || id.type !== 'Identifier') return;
277
+ if (!PASCAL_CASE_RE.test(id.name)) return;
278
+ if (!isComponentLikeInit(path.node.init)) return;
279
+ const filename = shouldVisit(state, path.node.loc);
280
+ if (!filename) return;
281
+ // VariableDeclarator's parent is VariableDeclaration; that may be
282
+ // wrapped in ExportNamedDeclaration (`export const Foo = ...`).
283
+ // Walk up to the outermost statement so the assignment lands at
284
+ // the right scope.
285
+ let target = path.parentPath; // VariableDeclaration
286
+ if (
287
+ target.parentPath &&
288
+ (target.parentPath.isExportNamedDeclaration() ||
289
+ target.parentPath.isExportDefaultDeclaration())
290
+ ) {
291
+ target = target.parentPath;
292
+ }
293
+ const payload = buildPayload(filename, path.node.loc);
294
+ insertDeclTagAfter(target, id.name, payload);
295
+ },
296
+ },
297
+ };
298
+ };
299
+
300
+ // Re-export the attribute name so the runtime reader and tests have a
301
+ // single source of truth (no string drift between plugin and walker).
302
+ module.exports.FLOTRACE_ATTR_NAME = FLOTRACE_ATTR_NAME;