@flotrace/runtime-core 2.2.3 → 2.3.0

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,181 @@
1
+ // src/jsxRuntimeUtils.ts
2
+ var FLOTRACE_SOURCE = /* @__PURE__ */ Symbol.for("flotrace.source");
3
+ var JSX_RUNTIME_ACTIVE_KEY = /* @__PURE__ */ Symbol.for("flotrace.jsx-runtime-active");
4
+ function normalizeJsxSourcePath(fileName) {
5
+ let p = fileName;
6
+ if (p.startsWith("file://")) p = p.slice("file://".length);
7
+ if (p.startsWith("webpack-internal:///./")) p = p.slice("webpack-internal:///./".length);
8
+ if (p.startsWith("[project]/")) p = p.slice("[project]/".length);
9
+ if (p.startsWith("./")) p = p.slice(2);
10
+ if (/^\/[a-zA-Z]:[\\/]/.test(p)) p = p.slice(1);
11
+ if (/^[a-zA-Z]:[\\/]/.test(p)) p = p[0].toLowerCase() + p.slice(1);
12
+ return p;
13
+ }
14
+ function computeCallSiteId(source) {
15
+ const normPath = normalizeJsxSourcePath(source.fileName);
16
+ const key = `${normPath}:${source.lineNumber}:${source.columnNumber}`;
17
+ let hash = 2166136261;
18
+ for (let i = 0; i < key.length; i++) {
19
+ hash ^= key.charCodeAt(i);
20
+ hash = Math.imul(hash, 16777619);
21
+ }
22
+ return (hash >>> 0).toString(16).padStart(8, "0");
23
+ }
24
+ function readJsxSourceFromFiber(fiber) {
25
+ const props = fiber.memoizedProps;
26
+ if (!props) return void 0;
27
+ const raw = props[FLOTRACE_SOURCE];
28
+ if (!raw || typeof raw !== "object") return void 0;
29
+ const obj = raw;
30
+ if (typeof obj.fileName !== "string" || typeof obj.lineNumber !== "number" || typeof obj.columnNumber !== "number" || typeof obj.callSiteId !== "string") {
31
+ return void 0;
32
+ }
33
+ return raw;
34
+ }
35
+ var callSiteRenders = /* @__PURE__ */ new Map();
36
+ var RING_BUFFER_MAX = 60;
37
+ function recordCallSiteRender(callSiteId, now = performance.now()) {
38
+ const arr = callSiteRenders.get(callSiteId);
39
+ if (arr === void 0) {
40
+ callSiteRenders.set(callSiteId, [now]);
41
+ return;
42
+ }
43
+ arr.push(now);
44
+ if (arr.length > RING_BUFFER_MAX) arr.shift();
45
+ }
46
+ function getCallSiteRenders(callSiteId) {
47
+ return callSiteRenders.get(callSiteId) ?? [];
48
+ }
49
+ function getCallSiteRenderRate(callSiteId, windowMs = 5e3, now = performance.now()) {
50
+ const arr = callSiteRenders.get(callSiteId);
51
+ if (!arr || arr.length === 0) return 0;
52
+ const cutoff = now - windowMs;
53
+ let count = 0;
54
+ for (let i = arr.length - 1; i >= 0; i--) {
55
+ if (arr[i] >= cutoff) count++;
56
+ else break;
57
+ }
58
+ return count / windowMs * 1e3;
59
+ }
60
+ function clearCallSiteRenders() {
61
+ callSiteRenders.clear();
62
+ }
63
+ function computeCallSiteMetricsPayload(windowMs = 5e3, now = performance.now()) {
64
+ let out = null;
65
+ const cutoff = now - windowMs;
66
+ for (const [callSiteId, arr] of callSiteRenders) {
67
+ if (arr.length === 0) continue;
68
+ let count = 0;
69
+ for (let i = arr.length - 1; i >= 0; i--) {
70
+ if (arr[i] >= cutoff) count++;
71
+ else break;
72
+ }
73
+ if (count > 0) {
74
+ (out ?? (out = {}))[callSiteId] = count / windowMs * 1e3;
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+ var duplicateKeyEmitter = null;
80
+ function setDuplicateKeyEmitter(emitter) {
81
+ duplicateKeyEmitter = emitter;
82
+ }
83
+ var currentKeyBatch = /* @__PURE__ */ new Map();
84
+ var keyBatchFlushScheduled = false;
85
+ function recordJsxKey(source, key) {
86
+ const keyType = typeof key;
87
+ if (keyType !== "string" && keyType !== "number" && keyType !== "boolean") return;
88
+ const keyStr = String(key);
89
+ const batchKey = `${source.callSiteId}|${keyStr}`;
90
+ const entry = currentKeyBatch.get(batchKey);
91
+ if (entry !== void 0) {
92
+ entry.count++;
93
+ } else {
94
+ currentKeyBatch.set(batchKey, { count: 1, source });
95
+ }
96
+ if (!keyBatchFlushScheduled) {
97
+ keyBatchFlushScheduled = true;
98
+ queueMicrotask(flushDuplicateKeys);
99
+ }
100
+ }
101
+ function flushDuplicateKeys() {
102
+ keyBatchFlushScheduled = false;
103
+ const emitter = duplicateKeyEmitter;
104
+ if (emitter === null) {
105
+ currentKeyBatch.clear();
106
+ return;
107
+ }
108
+ for (const [batchKey, { count, source }] of currentKeyBatch) {
109
+ if (count < 2) continue;
110
+ const sep = batchKey.indexOf("|");
111
+ const duplicateKey = batchKey.slice(sep + 1);
112
+ emitter({
113
+ callSiteId: source.callSiteId,
114
+ fileName: source.fileName,
115
+ lineNumber: source.lineNumber,
116
+ columnNumber: source.columnNumber,
117
+ duplicateKey,
118
+ occurrences: count
119
+ });
120
+ }
121
+ currentKeyBatch.clear();
122
+ }
123
+ function markJsxRuntimeActive() {
124
+ globalThis[JSX_RUNTIME_ACTIVE_KEY] = true;
125
+ }
126
+ function isJsxRuntimeActive() {
127
+ return globalThis[JSX_RUNTIME_ACTIVE_KEY] === true;
128
+ }
129
+ var KNOWN_REACT_PROPS = /* @__PURE__ */ new Set(["key", "ref", "children", "className"]);
130
+ var REACT_ELEMENT_TYPEOF_LEGACY = /* @__PURE__ */ Symbol.for("react.element");
131
+ var REACT_ELEMENT_TYPEOF_R19 = /* @__PURE__ */ Symbol.for("react.transitional.element");
132
+ function isReactElement(v) {
133
+ const typeOf = v.$$typeof;
134
+ return typeOf === REACT_ELEMENT_TYPEOF_LEGACY || typeOf === REACT_ELEMENT_TYPEOF_R19;
135
+ }
136
+ function detectInlineLiterals(props) {
137
+ let out;
138
+ for (const k in props) {
139
+ if (KNOWN_REACT_PROPS.has(k)) continue;
140
+ const v = props[k];
141
+ if (typeof v === "function") {
142
+ if (!v.name) {
143
+ (out ?? (out = {}))[k] = "fn";
144
+ }
145
+ } else if (Array.isArray(v)) {
146
+ if (v.length > 0) {
147
+ (out ?? (out = {}))[k] = "arr";
148
+ }
149
+ } else if (v !== null && typeof v === "object" && // Skip elements processed by our own runtime (marker present).
150
+ !(FLOTRACE_SOURCE in v) && // Skip React elements processed by ANY runtime — `$$typeof` is set on
151
+ // every element regardless of which jsx-runtime created it, so this
152
+ // catches mixed-runtime codebases. Without this guard, an inline
153
+ // `<Outer child={<Inner/>} />` would false-positive on `child` when
154
+ // Inner went through a different jsxImportSource.
155
+ !isReactElement(v)) {
156
+ const proto = Object.getPrototypeOf(v);
157
+ if (proto === Object.prototype || proto === null) {
158
+ (out ?? (out = {}))[k] = "obj";
159
+ }
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+
165
+ export {
166
+ FLOTRACE_SOURCE,
167
+ JSX_RUNTIME_ACTIVE_KEY,
168
+ normalizeJsxSourcePath,
169
+ computeCallSiteId,
170
+ readJsxSourceFromFiber,
171
+ recordCallSiteRender,
172
+ getCallSiteRenders,
173
+ getCallSiteRenderRate,
174
+ clearCallSiteRenders,
175
+ computeCallSiteMetricsPayload,
176
+ setDuplicateKeyEmitter,
177
+ recordJsxKey,
178
+ markJsxRuntimeActive,
179
+ isJsxRuntimeActive,
180
+ detectInlineLiterals
181
+ };