@flotrace/runtime-core 2.2.0 → 2.2.2

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
@@ -1,33 +1,45 @@
1
1
  # @flotrace/runtime-core
2
2
 
3
- Platform-agnostic core for the FloTrace runtime — fiber walker, hook/effect inspectors, state-store trackers, serializer, and WebSocket client. Shared by [`@flotrace/runtime`](https://www.npmjs.com/package/@flotrace/runtime) (web) and [`@flotrace/runtime-native`](https://www.npmjs.com/package/@flotrace/runtime-native) (React Native).
3
+ Platform-agnostic core for [FloTrace](https://flotrace.dev) — fiber walker, hook/effect inspectors, state-store trackers (Zustand / Redux / TanStack Query), serializer, and WebSocket client. Shared between [`@flotrace/runtime`](https://www.npmjs.com/package/@flotrace/runtime) (web) and [`@flotrace/runtime-native`](https://www.npmjs.com/package/@flotrace/runtime-native) (React Native).
4
4
 
5
- > **You probably don't want this package directly.**
6
- > Install `@flotrace/runtime` for a web React app or `@flotrace/runtime-native` for React Native. Those adapters depend on `runtime-core` and provide the wiring (provider, network tracker, platform-specific hooks) you need.
5
+ > **You almost certainly want one of the adapter packages instead.**
6
+ >
7
+ > - **Web React app?** → [`@flotrace/runtime`](https://www.npmjs.com/package/@flotrace/runtime)
8
+ > - **React Native (Expo / bare)?** → [`@flotrace/runtime-native`](https://www.npmjs.com/package/@flotrace/runtime-native)
9
+ >
10
+ > The adapters depend on `runtime-core` and provide the wiring (provider component, network tracker, platform-specific hooks) you actually need. This package on its own does nothing useful at runtime.
7
11
 
8
- `runtime-core` is published as a public package so adapters can pin a compatible version. It has no runtime dependency on `window` / `document` / `XMLHttpRequest` — all platform-specific features live in the adapters.
12
+ `runtime-core` is published publicly so adapters can pin a compatible version and so users can audit the open-source half of FloTrace. It has zero runtime dependency on `window` / `document` / `XMLHttpRequest` — all platform-specific features live in the adapters.
9
13
 
10
14
  ## What's inside
11
15
 
12
16
  | Module | Purpose |
13
17
  |---|---|
14
- | `fiberTreeWalker` | Incremental fiber walk, diffed tree emission, pluggable `pruneSubtree` / `frameworkComponentNames` / `hostComponentSkipPrefixes` options for platform adapters. |
15
- | `hookInspector` / `effectInspector` | Classify hooks and effects from a fiber; diff deps between commits. |
16
- | `zustandTracker` / `reduxTracker` / `tanstackQueryTracker` | Duck-typed subscribers for the major state libraries. |
17
- | `timelineTracker` | Per-component lifecycle events. |
18
- | `cascadeAnalyzer` / `propDrillingAnalyzer` | Render-cascade tracing + prop-drilling chain detection. |
19
- | `serializer` | Safe JSON serialization (depth 5, circular refs, truncation). |
20
- | `websocketClient` | Singleton WS client with reconnect, batching, auth-token support. |
18
+ | `fiberTreeWalker` | Incremental fiber walk, diffed tree emission. Pluggable `pruneSubtree` / `frameworkComponentNames` / `hostComponentSkipPrefixes` for platform adapters. |
19
+ | `hookInspector` / `effectInspector` | Classify hooks (14 types) and effects from a fiber; diff deps between commits. |
20
+ | `zustandTracker` / `reduxTracker` / `tanstackQueryTracker` | Duck-typed subscribers for the major state libraries — no peer-dep bloat. |
21
+ | `timelineTracker` | Per-component lifecycle events (mount, unmount, update, prop diff). |
22
+ | `cascadeAnalyzer` / `propDrillingAnalyzer` | Render-cascade tracing + prop-drilling DFS chain detection with severity scoring. |
23
+ | `serializer` | Safe JSON serialization (depth 5, circular-ref guard, truncation). |
24
+ | `websocketClient` | Singleton WS client with exponential backoff reconnect, message batching, optional auth token. |
21
25
 
22
26
  ## Version compatibility
23
27
 
24
- `@flotrace/runtime-core@0.1.x` is the companion release for:
28
+ `@flotrace/runtime-core@2.x` is the companion release for:
25
29
 
26
- - `@flotrace/runtime@0.2.x`
27
- - `@flotrace/runtime-native@0.1.x`
30
+ - `@flotrace/runtime@2.x`
31
+ - `@flotrace/runtime-native@2.x`
28
32
 
29
- Use matching minor versions when pinning across all three.
33
+ All three are released in lockstep — pin the same major.minor across the trio. The desktop app and runtime versions are independent (the WebSocket protocol is versioned).
34
+
35
+ ## Why open?
36
+
37
+ The runtime is what lives inside your app. Open-source means you can read every byte of the code that touches your fibers, audit the WebSocket payloads, and fork if FloTrace ever disappears. The desktop app is closed-source commercial — that's the bit we charge for. See [flotrace.dev/security](https://flotrace.dev/security) for the full threat model.
38
+
39
+ ## Contributing
40
+
41
+ Issues and PRs welcome at [github.com/flotrace](https://github.com/flotrace). The runtime packages target Hermes, V8 (Chromium), and JavaScriptCore — please test against all three when changing fiber-walker or serializer code.
30
42
 
31
43
  ## License
32
44
 
33
- MIT
45
+ MIT.
package/dist/index.d.mts CHANGED
@@ -48,6 +48,13 @@ interface RuntimeReadyMessage {
48
48
  /** React Native version from `Platform.constants.reactNativeVersion`, formatted
49
49
  * as "major.minor.patch". Native-only; web adapter leaves this undefined. */
50
50
  reactNativeVersion?: string;
51
+ /** Version of the @flotrace/runtime or @flotrace/runtime-native package the
52
+ * user installed in their app. Lets the desktop diagnose runtime/desktop
53
+ * drift (e.g., user pinned an older runtime that lacks a new feature).
54
+ * Read from the adapter's own package.json at build time. The lockstep
55
+ * release script keeps runtime-core pinned identically, so a separate
56
+ * core-version field would be redundant. */
57
+ runtimeVersion?: string;
51
58
  }
52
59
  interface RuntimeRenderMessage {
53
60
  type: 'runtime:render';
@@ -867,10 +874,14 @@ interface FloTraceConfig {
867
874
  /** React Native version from `Platform.constants.reactNativeVersion`, formatted
868
875
  * "major.minor.patch". Native adapter only. */
869
876
  reactNativeVersion?: string;
877
+ /** Version of the active runtime adapter (`@flotrace/runtime` on web,
878
+ * `@flotrace/runtime-native` on RN). Adapters auto-populate from their
879
+ * own package.json. Surfaced on `runtime:ready` for diagnostics. */
880
+ runtimeVersion?: string;
870
881
  }
871
882
  /** Keys that stay optional in DEFAULT_CONFIG. These are populated by adapters (web/native)
872
883
  * at call-time — the default object should not pretend to know a platform or LAN token. */
873
- type OptionalConfigKeys = 'getAppUrl' | 'platform' | 'appId' | 'appVersion' | 'host' | 'authToken' | 'userOnlyStrict' | 'userAllowPatterns' | 'frameworkName' | 'frameworkVersion' | 'reactNativeVersion';
884
+ type OptionalConfigKeys = 'getAppUrl' | 'platform' | 'appId' | 'appVersion' | 'host' | 'authToken' | 'userOnlyStrict' | 'userAllowPatterns' | 'frameworkName' | 'frameworkVersion' | 'reactNativeVersion' | 'runtimeVersion';
874
885
  type ResolvedFloTraceConfig = Required<Omit<FloTraceConfig, OptionalConfigKeys>> & Pick<FloTraceConfig, OptionalConfigKeys>;
875
886
  /**
876
887
  * Default configuration
@@ -1589,7 +1600,7 @@ declare function getChangedKeys(prev: Record<string, unknown> | undefined, next:
1589
1600
  * State lives here — in platform-agnostic core — so both the web and native
1590
1601
  * network trackers can write to the same registry that the analyzers read.
1591
1602
  */
1592
- /** Tag an object and its nested children (depth ≤ 2) with the requestId. */
1603
+ /** Tag an object and its nested children (depth ≤ FETCH_ORIGIN_SCAN_DEPTH) with the requestId. */
1593
1604
  declare function tagFetchData(obj: unknown, requestId: string, depth?: number): void;
1594
1605
  /** Returns true if any API request's response data is currently tagged (within TTL window). */
1595
1606
  declare function hasActiveTags(): boolean;
@@ -1600,13 +1611,23 @@ declare function hasActiveTags(): boolean;
1600
1611
  */
1601
1612
  declare function clearFetchOriginTags(): void;
1602
1613
  /**
1603
- * Scan an object (and nested children up to depth 2) for a WeakMap-tagged fetch origin.
1604
- * Called by Zustand/Redux trackers synchronously in their subscribe callbacks.
1605
- * Returns the requestId if this object was the result of a tracked fetch within the TTL
1606
- * window, else undefined. TTL prevents stale entries from matching on later store updates
1607
- * that reuse the same object references (immutable store pattern).
1614
+ * Scan an object (and nested children up to FETCH_ORIGIN_SCAN_DEPTH) for a
1615
+ * WeakMap-tagged fetch origin. Returns the requestId if this object was the
1616
+ * result of a tracked fetch, else undefined.
1617
+ *
1618
+ * By default the result must be within FETCH_ORIGIN_TTL_MS of the original
1619
+ * fetch. Network-panel features (live API → Store correlation in
1620
+ * `storeUtils.buildCorrelatedRequests`, `tanstackQueryTracker.updateQueryTracking`,
1621
+ * `fiberTreeWalker.scanFiberStateForOrigin`) rely on this gate to avoid
1622
+ * mis-attributing later mutations.
1623
+ *
1624
+ * Pass `{ ignoreTTL: true }` to bypass the gate. Used by the value-lineage
1625
+ * resolver, which wants the *causal* origin regardless of how long ago the
1626
+ * fetch resolved (the trace UI displays an "expired" hint for old origins).
1608
1627
  */
1609
- declare function findFetchOrigin(obj: unknown, depth?: number): string | undefined;
1628
+ declare function findFetchOrigin(obj: unknown, options?: {
1629
+ ignoreTTL?: boolean;
1630
+ }): string | undefined;
1610
1631
 
1611
1632
  /**
1612
1633
  * Next.js App Router client-side detection.
package/dist/index.d.ts CHANGED
@@ -48,6 +48,13 @@ interface RuntimeReadyMessage {
48
48
  /** React Native version from `Platform.constants.reactNativeVersion`, formatted
49
49
  * as "major.minor.patch". Native-only; web adapter leaves this undefined. */
50
50
  reactNativeVersion?: string;
51
+ /** Version of the @flotrace/runtime or @flotrace/runtime-native package the
52
+ * user installed in their app. Lets the desktop diagnose runtime/desktop
53
+ * drift (e.g., user pinned an older runtime that lacks a new feature).
54
+ * Read from the adapter's own package.json at build time. The lockstep
55
+ * release script keeps runtime-core pinned identically, so a separate
56
+ * core-version field would be redundant. */
57
+ runtimeVersion?: string;
51
58
  }
52
59
  interface RuntimeRenderMessage {
53
60
  type: 'runtime:render';
@@ -867,10 +874,14 @@ interface FloTraceConfig {
867
874
  /** React Native version from `Platform.constants.reactNativeVersion`, formatted
868
875
  * "major.minor.patch". Native adapter only. */
869
876
  reactNativeVersion?: string;
877
+ /** Version of the active runtime adapter (`@flotrace/runtime` on web,
878
+ * `@flotrace/runtime-native` on RN). Adapters auto-populate from their
879
+ * own package.json. Surfaced on `runtime:ready` for diagnostics. */
880
+ runtimeVersion?: string;
870
881
  }
871
882
  /** Keys that stay optional in DEFAULT_CONFIG. These are populated by adapters (web/native)
872
883
  * at call-time — the default object should not pretend to know a platform or LAN token. */
873
- type OptionalConfigKeys = 'getAppUrl' | 'platform' | 'appId' | 'appVersion' | 'host' | 'authToken' | 'userOnlyStrict' | 'userAllowPatterns' | 'frameworkName' | 'frameworkVersion' | 'reactNativeVersion';
884
+ type OptionalConfigKeys = 'getAppUrl' | 'platform' | 'appId' | 'appVersion' | 'host' | 'authToken' | 'userOnlyStrict' | 'userAllowPatterns' | 'frameworkName' | 'frameworkVersion' | 'reactNativeVersion' | 'runtimeVersion';
874
885
  type ResolvedFloTraceConfig = Required<Omit<FloTraceConfig, OptionalConfigKeys>> & Pick<FloTraceConfig, OptionalConfigKeys>;
875
886
  /**
876
887
  * Default configuration
@@ -1589,7 +1600,7 @@ declare function getChangedKeys(prev: Record<string, unknown> | undefined, next:
1589
1600
  * State lives here — in platform-agnostic core — so both the web and native
1590
1601
  * network trackers can write to the same registry that the analyzers read.
1591
1602
  */
1592
- /** Tag an object and its nested children (depth ≤ 2) with the requestId. */
1603
+ /** Tag an object and its nested children (depth ≤ FETCH_ORIGIN_SCAN_DEPTH) with the requestId. */
1593
1604
  declare function tagFetchData(obj: unknown, requestId: string, depth?: number): void;
1594
1605
  /** Returns true if any API request's response data is currently tagged (within TTL window). */
1595
1606
  declare function hasActiveTags(): boolean;
@@ -1600,13 +1611,23 @@ declare function hasActiveTags(): boolean;
1600
1611
  */
1601
1612
  declare function clearFetchOriginTags(): void;
1602
1613
  /**
1603
- * Scan an object (and nested children up to depth 2) for a WeakMap-tagged fetch origin.
1604
- * Called by Zustand/Redux trackers synchronously in their subscribe callbacks.
1605
- * Returns the requestId if this object was the result of a tracked fetch within the TTL
1606
- * window, else undefined. TTL prevents stale entries from matching on later store updates
1607
- * that reuse the same object references (immutable store pattern).
1614
+ * Scan an object (and nested children up to FETCH_ORIGIN_SCAN_DEPTH) for a
1615
+ * WeakMap-tagged fetch origin. Returns the requestId if this object was the
1616
+ * result of a tracked fetch, else undefined.
1617
+ *
1618
+ * By default the result must be within FETCH_ORIGIN_TTL_MS of the original
1619
+ * fetch. Network-panel features (live API → Store correlation in
1620
+ * `storeUtils.buildCorrelatedRequests`, `tanstackQueryTracker.updateQueryTracking`,
1621
+ * `fiberTreeWalker.scanFiberStateForOrigin`) rely on this gate to avoid
1622
+ * mis-attributing later mutations.
1623
+ *
1624
+ * Pass `{ ignoreTTL: true }` to bypass the gate. Used by the value-lineage
1625
+ * resolver, which wants the *causal* origin regardless of how long ago the
1626
+ * fetch resolved (the trace UI displays an "expired" hint for old origins).
1608
1627
  */
1609
- declare function findFetchOrigin(obj: unknown, depth?: number): string | undefined;
1628
+ declare function findFetchOrigin(obj: unknown, options?: {
1629
+ ignoreTTL?: boolean;
1630
+ }): string | undefined;
1610
1631
 
1611
1632
  /**
1612
1633
  * Next.js App Router client-side detection.
package/dist/index.js CHANGED
@@ -84,7 +84,13 @@ module.exports = __toCommonJS(index_exports);
84
84
  var DEFAULT_CONFIG = {
85
85
  port: 3457,
86
86
  appName: "React App",
87
- enabled: globalThis.process?.env?.NODE_ENV === "development",
87
+ // Default-on unless an explicit `process.env.NODE_ENV === 'production'` is
88
+ // detected. The previous heuristic (`=== 'development'`) silently disabled
89
+ // the runtime in any browser context that doesn't shim `process` (e.g. Vite,
90
+ // Webpack 5 with `node: false`, Rsbuild) — making the README quickstart fail
91
+ // for everyone. Production safety is handled by users gating the import via
92
+ // the dynamic-import pattern documented in the runtime READMEs.
93
+ enabled: globalThis.process?.env?.NODE_ENV !== "production",
88
94
  autoReconnect: true,
89
95
  reconnectInterval: 2e3,
90
96
  trackAllRenders: true,
@@ -309,7 +315,8 @@ var _FloTraceWebSocketClient = class _FloTraceWebSocketClient {
309
315
  appVersion: this.config.appVersion,
310
316
  frameworkName: this.config.frameworkName,
311
317
  frameworkVersion: this.config.frameworkVersion,
312
- reactNativeVersion: this.config.reactNativeVersion
318
+ reactNativeVersion: this.config.reactNativeVersion,
319
+ runtimeVersion: this.config.runtimeVersion
313
320
  });
314
321
  this.flush();
315
322
  };
@@ -1878,12 +1885,16 @@ function uninstallRscPayloadInterceptor() {
1878
1885
  var fetchDataOrigin = /* @__PURE__ */ new WeakMap();
1879
1886
  var requestTagTimestamps = /* @__PURE__ */ new Map();
1880
1887
  var FETCH_ORIGIN_TTL_MS = 3e3;
1888
+ var FETCH_ORIGIN_SCAN_DEPTH = 4;
1889
+ var FETCH_ORIGIN_TAG_ARRAY_LIMIT = 50;
1890
+ var FETCH_ORIGIN_SCAN_ARRAY_LIMIT = 20;
1881
1891
  function tagFetchData(obj, requestId, depth = 0) {
1882
- if (depth > 2 || obj === null || typeof obj !== "object") return;
1892
+ if (depth > FETCH_ORIGIN_SCAN_DEPTH || obj === null || typeof obj !== "object") return;
1883
1893
  fetchDataOrigin.set(obj, requestId);
1884
1894
  if (depth === 0) requestTagTimestamps.set(requestId, Date.now());
1885
1895
  if (Array.isArray(obj)) {
1886
- for (let i = 0; i < Math.min(obj.length, 50); i++) tagFetchData(obj[i], requestId, depth + 1);
1896
+ const limit = Math.min(obj.length, FETCH_ORIGIN_TAG_ARRAY_LIMIT);
1897
+ for (let i = 0; i < limit; i++) tagFetchData(obj[i], requestId, depth + 1);
1887
1898
  } else {
1888
1899
  for (const val of Object.values(obj)) tagFetchData(val, requestId, depth + 1);
1889
1900
  }
@@ -1894,24 +1905,29 @@ function hasActiveTags() {
1894
1905
  function clearFetchOriginTags() {
1895
1906
  requestTagTimestamps.clear();
1896
1907
  }
1897
- function findFetchOrigin(obj, depth = 0) {
1898
- if (depth > 2 || obj === null || typeof obj !== "object") return void 0;
1908
+ function findFetchOrigin(obj, options) {
1909
+ return scanForOrigin(obj, 0, options?.ignoreTTL === true);
1910
+ }
1911
+ function scanForOrigin(obj, depth, ignoreTTL) {
1912
+ if (depth > FETCH_ORIGIN_SCAN_DEPTH || obj === null || typeof obj !== "object") return void 0;
1899
1913
  const rid = fetchDataOrigin.get(obj);
1900
1914
  if (rid) {
1915
+ if (ignoreTTL) return rid;
1901
1916
  const tagTime = requestTagTimestamps.get(rid);
1902
1917
  if (tagTime && Date.now() - tagTime <= FETCH_ORIGIN_TTL_MS) return rid;
1903
1918
  requestTagTimestamps.delete(rid);
1904
1919
  }
1905
1920
  if (Array.isArray(obj)) {
1906
- for (let i = 0; i < Math.min(obj.length, 20); i++) {
1907
- const found = findFetchOrigin(obj[i], depth + 1);
1908
- if (found) return found;
1909
- }
1910
- } else {
1911
- for (const val of Object.values(obj)) {
1912
- const found = findFetchOrigin(val, depth + 1);
1921
+ const limit = Math.min(obj.length, FETCH_ORIGIN_SCAN_ARRAY_LIMIT);
1922
+ for (let i = 0; i < limit; i++) {
1923
+ const found = scanForOrigin(obj[i], depth + 1, ignoreTTL);
1913
1924
  if (found) return found;
1914
1925
  }
1926
+ return void 0;
1927
+ }
1928
+ for (const val of Object.values(obj)) {
1929
+ const found = scanForOrigin(val, depth + 1, ignoreTTL);
1930
+ if (found) return found;
1915
1931
  }
1916
1932
  return void 0;
1917
1933
  }
@@ -3706,7 +3722,7 @@ function safeCall(fn, fallback) {
3706
3722
 
3707
3723
  // src/valueTraceResolver.ts
3708
3724
  var FIBER_TAG_CONTEXT_PROVIDER = 10;
3709
- var BUDGET_MS = 50;
3725
+ var BUDGET_MS = 100;
3710
3726
  var SCAN_DEPTH = 3;
3711
3727
  var MAX_PROP_CHAIN_DEPTH = 30;
3712
3728
  function now() {
@@ -3730,34 +3746,49 @@ function getHookValueAt(fiber, hookIndex) {
3730
3746
  if (!hook) return void 0;
3731
3747
  return hook.memoizedState;
3732
3748
  }
3733
- function valuesMatch(target, targetFp, candidate) {
3749
+ function cachedFp(value, cache) {
3750
+ if (value === null || typeof value !== "object") return valueFingerprint(value);
3751
+ const cached = cache.get(value);
3752
+ if (cached !== void 0) return cached;
3753
+ const fp = valueFingerprint(value);
3754
+ cache.set(value, fp);
3755
+ return fp;
3756
+ }
3757
+ function valuesMatch(target, targetFp, candidate, cache) {
3734
3758
  const targetIsObject = target !== null && typeof target === "object";
3735
3759
  const candidateIsObject = candidate !== null && typeof candidate === "object";
3736
3760
  if (targetIsObject && candidateIsObject && target === candidate) return "exact";
3737
3761
  if (!shouldFlagRename(target) || !shouldFlagRename(candidate)) return null;
3738
- if (valueFingerprint(candidate) === targetFp) return "fingerprint-match";
3762
+ if (cachedFp(candidate, cache) === targetFp) return "fingerprint-match";
3739
3763
  return null;
3740
3764
  }
3741
- function findMatchingPathInObject(target, targetFp, container, currentPath, depth, deadline) {
3765
+ function findReferenceMatchAtTopLevel(target, container) {
3766
+ if (target === null || typeof target !== "object") return null;
3767
+ for (const key of Object.keys(container)) {
3768
+ if (container[key] === target) return key;
3769
+ }
3770
+ return null;
3771
+ }
3772
+ function findMatchingPathInObject(target, targetFp, container, currentPath, depth, deadline, cache) {
3742
3773
  if (now() > deadline) return null;
3743
3774
  if (depth > SCAN_DEPTH) return null;
3744
3775
  if (container === null || typeof container !== "object") return null;
3745
- const selfMatch = valuesMatch(target, targetFp, container);
3776
+ const selfMatch = valuesMatch(target, targetFp, container, cache);
3746
3777
  if (selfMatch) return { path: [...currentPath], confidence: selfMatch };
3747
3778
  if (Array.isArray(container)) {
3748
3779
  for (let i = 0; i < Math.min(container.length, 50); i++) {
3749
3780
  const child = container[i];
3750
- const directMatch = valuesMatch(target, targetFp, child);
3781
+ const directMatch = valuesMatch(target, targetFp, child, cache);
3751
3782
  if (directMatch) return { path: [...currentPath, String(i)], confidence: directMatch };
3752
- const nested = findMatchingPathInObject(target, targetFp, child, [...currentPath, String(i)], depth + 1, deadline);
3783
+ const nested = findMatchingPathInObject(target, targetFp, child, [...currentPath, String(i)], depth + 1, deadline, cache);
3753
3784
  if (nested) return nested;
3754
3785
  }
3755
3786
  } else {
3756
3787
  for (const key of Object.keys(container)) {
3757
3788
  const child = container[key];
3758
- const directMatch = valuesMatch(target, targetFp, child);
3789
+ const directMatch = valuesMatch(target, targetFp, child, cache);
3759
3790
  if (directMatch) return { path: [...currentPath, key], confidence: directMatch };
3760
- const nested = findMatchingPathInObject(target, targetFp, child, [...currentPath, key], depth + 1, deadline);
3791
+ const nested = findMatchingPathInObject(target, targetFp, child, [...currentPath, key], depth + 1, deadline, cache);
3761
3792
  if (nested) return nested;
3762
3793
  }
3763
3794
  }
@@ -3770,6 +3801,59 @@ function buildFiberToNodeIdMap() {
3770
3801
  }
3771
3802
  return reverse;
3772
3803
  }
3804
+ function retreatToEnclosingObject(fiber, propPath, rootValue) {
3805
+ const isObject = rootValue !== null && typeof rootValue === "object";
3806
+ if (isObject || propPath.length <= 1 || !fiber.memoizedProps) {
3807
+ return { rootValue, trailingSubPath: [] };
3808
+ }
3809
+ let path = propPath.slice();
3810
+ let value = rootValue;
3811
+ const trail = [];
3812
+ while (path.length > 1 && (value === null || typeof value !== "object")) {
3813
+ trail.unshift(path[path.length - 1]);
3814
+ path = path.slice(0, -1);
3815
+ value = walkPath(fiber.memoizedProps, path);
3816
+ }
3817
+ if (value === null || typeof value !== "object") {
3818
+ return { rootValue, trailingSubPath: [] };
3819
+ }
3820
+ return { rootValue: value, trailingSubPath: trail };
3821
+ }
3822
+ function isDebugEnabled() {
3823
+ try {
3824
+ return !!globalThis.__FLOTRACE_DEBUG__;
3825
+ } catch {
3826
+ return false;
3827
+ }
3828
+ }
3829
+ function findFetchOriginUpKeyPath(stateRoot, keyPath) {
3830
+ const ancestors = [stateRoot];
3831
+ let cursor = stateRoot;
3832
+ for (const segment of keyPath) {
3833
+ if (cursor === null || typeof cursor !== "object") break;
3834
+ cursor = cursor[segment];
3835
+ ancestors.push(cursor);
3836
+ }
3837
+ for (let i = ancestors.length - 1; i >= 0; i--) {
3838
+ const value = ancestors[i];
3839
+ if (value === null || typeof value !== "object") continue;
3840
+ const rid = findFetchOrigin(value, { ignoreTTL: true });
3841
+ if (rid) {
3842
+ if (isDebugEnabled()) {
3843
+ console.debug("[FloTrace] origin via keyPath retreat", {
3844
+ keyPath,
3845
+ depthHit: i,
3846
+ requestId: rid
3847
+ });
3848
+ }
3849
+ return rid;
3850
+ }
3851
+ }
3852
+ return void 0;
3853
+ }
3854
+ function resolveOriginViaTagOrKeyPath(matchedValue, stateRoot, keyPath) {
3855
+ return findFetchOrigin(matchedValue, { ignoreTTL: true }) ?? findFetchOriginUpKeyPath(stateRoot, keyPath);
3856
+ }
3773
3857
  function resolveValueTrace(input) {
3774
3858
  const startedAt = now();
3775
3859
  const deadline = startedAt + BUDGET_MS;
@@ -3798,9 +3882,13 @@ function resolveValueTrace(input) {
3798
3882
  if (rootValue === void 0) {
3799
3883
  return { ...base, error: "value-not-found", resolvedAtMs: now() };
3800
3884
  }
3885
+ const retreated = input.propPath ? retreatToEnclosingObject(fiber, input.propPath, rootValue) : { rootValue, trailingSubPath: [] };
3886
+ rootValue = retreated.rootValue;
3887
+ const trailingSubPath = retreated.trailingSubPath;
3801
3888
  const rootFp = valueFingerprint(rootValue);
3802
3889
  const fiberToNodeId = buildFiberToNodeIdMap();
3803
3890
  const rootComponentName = getComponentNameFromFiber(fiber) ?? "Unknown";
3891
+ const fpCache = /* @__PURE__ */ new WeakMap();
3804
3892
  if (input.propPath) {
3805
3893
  steps.push({
3806
3894
  kind: "prop",
@@ -3829,8 +3917,17 @@ function resolveValueTrace(input) {
3829
3917
  if (current.tag !== FIBER_TAG_CONTEXT_PROVIDER) {
3830
3918
  const props = current.memoizedProps;
3831
3919
  if (props) {
3832
- const match = findMatchingPathInObject(rootValue, rootFp, props, [], 0, deadline);
3833
- if (match) {
3920
+ const refKey = findReferenceMatchAtTopLevel(rootValue, props);
3921
+ let matchPath = refKey !== null ? [refKey] : null;
3922
+ let matchConfidence = "exact";
3923
+ if (matchPath === null) {
3924
+ const match = findMatchingPathInObject(rootValue, rootFp, props, [], 0, deadline, fpCache);
3925
+ if (match) {
3926
+ matchPath = match.path;
3927
+ matchConfidence = match.confidence;
3928
+ }
3929
+ }
3930
+ if (matchPath !== null) {
3834
3931
  const ancestorNodeId = fiberToNodeId.get(current);
3835
3932
  const ancestorName = getComponentNameFromFiber(current) ?? "Unknown";
3836
3933
  if (ancestorNodeId) {
@@ -3838,10 +3935,28 @@ function resolveValueTrace(input) {
3838
3935
  kind: "prop",
3839
3936
  nodeId: ancestorNodeId,
3840
3937
  componentName: ancestorName,
3841
- propPath: match.path,
3842
- confidence: match.confidence
3938
+ propPath: trailingSubPath.length > 0 ? [...matchPath, ...trailingSubPath] : matchPath,
3939
+ confidence: matchConfidence
3843
3940
  });
3844
3941
  }
3942
+ } else {
3943
+ const hookMatch = findMatchingHookState(current, rootValue, rootFp, fpCache);
3944
+ if (hookMatch) {
3945
+ const ancestorNodeId = fiberToNodeId.get(current);
3946
+ const ancestorName = getComponentNameFromFiber(current) ?? "Unknown";
3947
+ if (ancestorNodeId) {
3948
+ steps.push({
3949
+ kind: "hook-state",
3950
+ nodeId: ancestorNodeId,
3951
+ componentName: ancestorName,
3952
+ hookIndex: hookMatch.hookIndex,
3953
+ hookType: hookMatch.hookType,
3954
+ subPath: trailingSubPath.length > 0 ? trailingSubPath : void 0,
3955
+ confidence: hookMatch.confidence
3956
+ });
3957
+ return { ...base, steps, resolvedAtMs: now() };
3958
+ }
3959
+ }
3845
3960
  }
3846
3961
  }
3847
3962
  }
@@ -3850,7 +3965,7 @@ function resolveValueTrace(input) {
3850
3965
  }
3851
3966
  }
3852
3967
  if (input.hookPath) {
3853
- const origin = findFetchOrigin(rootValue);
3968
+ const origin = findFetchOrigin(rootValue, { ignoreTTL: true });
3854
3969
  if (origin) {
3855
3970
  steps.push({
3856
3971
  kind: "api",
@@ -3862,15 +3977,15 @@ function resolveValueTrace(input) {
3862
3977
  return { ...base, steps, resolvedAtMs: now() };
3863
3978
  }
3864
3979
  }
3865
- const derivedMatch = findDerivationMatch(fiber, rootValue, rootFp, rootComponentName);
3980
+ const derivedMatch = findDerivationMatch(fiber, rootValue, rootFp, rootComponentName, fpCache);
3866
3981
  if (derivedMatch) {
3867
3982
  steps.push({ ...derivedMatch, nodeId: input.nodeId });
3868
3983
  return { ...base, steps, resolvedAtMs: now() };
3869
3984
  }
3870
- const contextMatch = findContextMatch(fiber, rootValue, rootFp, fiberToNodeId);
3985
+ const contextMatch = findContextMatch(fiber, rootValue, rootFp, fiberToNodeId, fpCache);
3871
3986
  if (contextMatch) {
3872
3987
  steps.push(contextMatch.step);
3873
- const providerStoreMatch = findStoreMatch(contextMatch.providerValue, valueFingerprint(contextMatch.providerValue), deadline);
3988
+ const providerStoreMatch = findStoreMatch(contextMatch.providerValue, cachedFp(contextMatch.providerValue, fpCache), deadline, fpCache);
3874
3989
  if (providerStoreMatch) {
3875
3990
  steps.push({
3876
3991
  kind: "store",
@@ -3879,19 +3994,23 @@ function resolveValueTrace(input) {
3879
3994
  keyPath: providerStoreMatch.keyPath,
3880
3995
  confidence: providerStoreMatch.confidence
3881
3996
  });
3882
- const origin = findFetchOrigin(providerStoreMatch.matchedValue);
3997
+ const origin = resolveOriginViaTagOrKeyPath(
3998
+ providerStoreMatch.matchedValue,
3999
+ providerStoreMatch.stateRoot,
4000
+ providerStoreMatch.keyPath
4001
+ );
3883
4002
  if (origin) {
3884
4003
  steps.push({ kind: "api", requestId: origin, method: "UNKNOWN", urlPath: "", ageMs: 0 });
3885
4004
  }
3886
4005
  } else {
3887
- const origin = findFetchOrigin(contextMatch.providerValue);
4006
+ const origin = findFetchOrigin(contextMatch.providerValue, { ignoreTTL: true });
3888
4007
  if (origin) {
3889
4008
  steps.push({ kind: "api", requestId: origin, method: "UNKNOWN", urlPath: "", ageMs: 0 });
3890
4009
  }
3891
4010
  }
3892
4011
  return { ...base, steps, resolvedAtMs: now() };
3893
4012
  }
3894
- const storeMatch = findStoreMatch(rootValue, rootFp, deadline);
4013
+ const storeMatch = findStoreMatch(rootValue, rootFp, deadline, fpCache);
3895
4014
  if (storeMatch) {
3896
4015
  steps.push({
3897
4016
  kind: "store",
@@ -3900,7 +4019,11 @@ function resolveValueTrace(input) {
3900
4019
  keyPath: storeMatch.keyPath,
3901
4020
  confidence: storeMatch.confidence
3902
4021
  });
3903
- const origin = findFetchOrigin(storeMatch.matchedValue);
4022
+ const origin = resolveOriginViaTagOrKeyPath(
4023
+ storeMatch.matchedValue,
4024
+ storeMatch.stateRoot,
4025
+ storeMatch.keyPath
4026
+ );
3904
4027
  if (origin) {
3905
4028
  steps.push({
3906
4029
  kind: "api",
@@ -3913,12 +4036,12 @@ function resolveValueTrace(input) {
3913
4036
  }
3914
4037
  return { ...base, steps, resolvedAtMs: now() };
3915
4038
  }
3916
- function findContextMatch(consumer, target, targetFp, fiberToNodeId) {
4039
+ function findContextMatch(consumer, target, targetFp, fiberToNodeId, cache) {
3917
4040
  const deps = consumer.dependencies?.firstContext;
3918
4041
  if (!deps) return null;
3919
4042
  let dep = deps;
3920
4043
  while (dep) {
3921
- const match = valuesMatch(target, targetFp, dep.memoizedValue);
4044
+ const match = valuesMatch(target, targetFp, dep.memoizedValue, cache);
3922
4045
  if (match) {
3923
4046
  const provider = findNearestProvider(consumer, dep.context);
3924
4047
  const step = {
@@ -3934,14 +4057,14 @@ function findContextMatch(consumer, target, targetFp, fiberToNodeId) {
3934
4057
  }
3935
4058
  return null;
3936
4059
  }
3937
- function findDerivationMatch(fiber, target, targetFp, componentName) {
4060
+ function findDerivationMatch(fiber, target, targetFp, componentName, cache) {
3938
4061
  let hook = fiber.memoizedState;
3939
4062
  let index = 0;
3940
4063
  while (hook) {
3941
4064
  const ms = hook.memoizedState;
3942
4065
  if (Array.isArray(ms) && ms.length === 2 && Array.isArray(ms[1])) {
3943
4066
  const [computed, deps] = ms;
3944
- const match = valuesMatch(target, targetFp, computed);
4067
+ const match = valuesMatch(target, targetFp, computed, cache);
3945
4068
  if (match) {
3946
4069
  const hookType = typeof computed === "function" ? "useCallback" : "useMemo";
3947
4070
  return {
@@ -3960,6 +4083,25 @@ function findDerivationMatch(fiber, target, targetFp, componentName) {
3960
4083
  }
3961
4084
  return null;
3962
4085
  }
4086
+ function findMatchingHookState(fiber, target, targetFp, cache) {
4087
+ let hook = fiber.memoizedState;
4088
+ let index = 0;
4089
+ while (hook) {
4090
+ const ms = hook.memoizedState;
4091
+ const isMemoTuple = Array.isArray(ms) && ms.length === 2 && Array.isArray(ms[1]);
4092
+ const isEffectShape2 = ms !== null && typeof ms === "object" && "create" in ms && "deps" in ms;
4093
+ const isRefShape = ms !== null && typeof ms === "object" && Object.keys(ms).length === 1 && "current" in ms;
4094
+ if (!isMemoTuple && !isEffectShape2 && !isRefShape) {
4095
+ const match = valuesMatch(target, targetFp, ms, cache);
4096
+ if (match) {
4097
+ return { hookIndex: index, hookType: "useState", confidence: match };
4098
+ }
4099
+ }
4100
+ hook = hook.next;
4101
+ index++;
4102
+ }
4103
+ return null;
4104
+ }
3963
4105
  function findNearestProvider(consumer, contextObj) {
3964
4106
  let current = consumer.return;
3965
4107
  let hops = 0;
@@ -3973,44 +4115,47 @@ function findNearestProvider(consumer, contextObj) {
3973
4115
  }
3974
4116
  return null;
3975
4117
  }
3976
- function findStoreMatch(target, targetFp, deadline) {
4118
+ function findStoreMatch(target, targetFp, deadline, cache) {
3977
4119
  for (const [storeName, state] of getZustandSnapshot()) {
3978
4120
  if (now() > deadline) return null;
3979
- const hit = findMatchingPathInObject(target, targetFp, state, [], 0, deadline);
4121
+ const hit = findMatchingPathInObject(target, targetFp, state, [], 0, deadline, cache);
3980
4122
  if (hit) {
3981
4123
  return {
3982
4124
  source: "zustand",
3983
4125
  storeName,
3984
4126
  keyPath: hit.path,
3985
4127
  confidence: hit.confidence,
3986
- matchedValue: walkPath(state, hit.path)
4128
+ matchedValue: walkPath(state, hit.path),
4129
+ stateRoot: state
3987
4130
  };
3988
4131
  }
3989
4132
  }
3990
4133
  const redux = getReduxSnapshot();
3991
4134
  if (redux) {
3992
4135
  if (now() > deadline) return null;
3993
- const hit = findMatchingPathInObject(target, targetFp, redux, [], 0, deadline);
4136
+ const hit = findMatchingPathInObject(target, targetFp, redux, [], 0, deadline, cache);
3994
4137
  if (hit) {
3995
4138
  return {
3996
4139
  source: "redux",
3997
4140
  storeName: "redux",
3998
4141
  keyPath: hit.path,
3999
4142
  confidence: hit.confidence,
4000
- matchedValue: walkPath(redux, hit.path)
4143
+ matchedValue: walkPath(redux, hit.path),
4144
+ stateRoot: redux
4001
4145
  };
4002
4146
  }
4003
4147
  }
4004
4148
  for (const [queryHash, entry] of getTanstackSnapshot()) {
4005
4149
  if (now() > deadline) return null;
4006
- const hit = findMatchingPathInObject(target, targetFp, entry.data, [], 0, deadline);
4150
+ const hit = findMatchingPathInObject(target, targetFp, entry.data, [], 0, deadline, cache);
4007
4151
  if (hit) {
4008
4152
  return {
4009
4153
  source: "tanstack-query",
4010
4154
  storeName: queryHash,
4011
4155
  keyPath: hit.path,
4012
4156
  confidence: hit.confidence,
4013
- matchedValue: walkPath(entry.data, hit.path)
4157
+ matchedValue: walkPath(entry.data, hit.path),
4158
+ stateRoot: entry.data
4014
4159
  };
4015
4160
  }
4016
4161
  }
package/dist/index.mjs CHANGED
@@ -2,7 +2,13 @@
2
2
  var DEFAULT_CONFIG = {
3
3
  port: 3457,
4
4
  appName: "React App",
5
- enabled: globalThis.process?.env?.NODE_ENV === "development",
5
+ // Default-on unless an explicit `process.env.NODE_ENV === 'production'` is
6
+ // detected. The previous heuristic (`=== 'development'`) silently disabled
7
+ // the runtime in any browser context that doesn't shim `process` (e.g. Vite,
8
+ // Webpack 5 with `node: false`, Rsbuild) — making the README quickstart fail
9
+ // for everyone. Production safety is handled by users gating the import via
10
+ // the dynamic-import pattern documented in the runtime READMEs.
11
+ enabled: globalThis.process?.env?.NODE_ENV !== "production",
6
12
  autoReconnect: true,
7
13
  reconnectInterval: 2e3,
8
14
  trackAllRenders: true,
@@ -227,7 +233,8 @@ var _FloTraceWebSocketClient = class _FloTraceWebSocketClient {
227
233
  appVersion: this.config.appVersion,
228
234
  frameworkName: this.config.frameworkName,
229
235
  frameworkVersion: this.config.frameworkVersion,
230
- reactNativeVersion: this.config.reactNativeVersion
236
+ reactNativeVersion: this.config.reactNativeVersion,
237
+ runtimeVersion: this.config.runtimeVersion
231
238
  });
232
239
  this.flush();
233
240
  };
@@ -1796,12 +1803,16 @@ function uninstallRscPayloadInterceptor() {
1796
1803
  var fetchDataOrigin = /* @__PURE__ */ new WeakMap();
1797
1804
  var requestTagTimestamps = /* @__PURE__ */ new Map();
1798
1805
  var FETCH_ORIGIN_TTL_MS = 3e3;
1806
+ var FETCH_ORIGIN_SCAN_DEPTH = 4;
1807
+ var FETCH_ORIGIN_TAG_ARRAY_LIMIT = 50;
1808
+ var FETCH_ORIGIN_SCAN_ARRAY_LIMIT = 20;
1799
1809
  function tagFetchData(obj, requestId, depth = 0) {
1800
- if (depth > 2 || obj === null || typeof obj !== "object") return;
1810
+ if (depth > FETCH_ORIGIN_SCAN_DEPTH || obj === null || typeof obj !== "object") return;
1801
1811
  fetchDataOrigin.set(obj, requestId);
1802
1812
  if (depth === 0) requestTagTimestamps.set(requestId, Date.now());
1803
1813
  if (Array.isArray(obj)) {
1804
- for (let i = 0; i < Math.min(obj.length, 50); i++) tagFetchData(obj[i], requestId, depth + 1);
1814
+ const limit = Math.min(obj.length, FETCH_ORIGIN_TAG_ARRAY_LIMIT);
1815
+ for (let i = 0; i < limit; i++) tagFetchData(obj[i], requestId, depth + 1);
1805
1816
  } else {
1806
1817
  for (const val of Object.values(obj)) tagFetchData(val, requestId, depth + 1);
1807
1818
  }
@@ -1812,24 +1823,29 @@ function hasActiveTags() {
1812
1823
  function clearFetchOriginTags() {
1813
1824
  requestTagTimestamps.clear();
1814
1825
  }
1815
- function findFetchOrigin(obj, depth = 0) {
1816
- if (depth > 2 || obj === null || typeof obj !== "object") return void 0;
1826
+ function findFetchOrigin(obj, options) {
1827
+ return scanForOrigin(obj, 0, options?.ignoreTTL === true);
1828
+ }
1829
+ function scanForOrigin(obj, depth, ignoreTTL) {
1830
+ if (depth > FETCH_ORIGIN_SCAN_DEPTH || obj === null || typeof obj !== "object") return void 0;
1817
1831
  const rid = fetchDataOrigin.get(obj);
1818
1832
  if (rid) {
1833
+ if (ignoreTTL) return rid;
1819
1834
  const tagTime = requestTagTimestamps.get(rid);
1820
1835
  if (tagTime && Date.now() - tagTime <= FETCH_ORIGIN_TTL_MS) return rid;
1821
1836
  requestTagTimestamps.delete(rid);
1822
1837
  }
1823
1838
  if (Array.isArray(obj)) {
1824
- for (let i = 0; i < Math.min(obj.length, 20); i++) {
1825
- const found = findFetchOrigin(obj[i], depth + 1);
1826
- if (found) return found;
1827
- }
1828
- } else {
1829
- for (const val of Object.values(obj)) {
1830
- const found = findFetchOrigin(val, depth + 1);
1839
+ const limit = Math.min(obj.length, FETCH_ORIGIN_SCAN_ARRAY_LIMIT);
1840
+ for (let i = 0; i < limit; i++) {
1841
+ const found = scanForOrigin(obj[i], depth + 1, ignoreTTL);
1831
1842
  if (found) return found;
1832
1843
  }
1844
+ return void 0;
1845
+ }
1846
+ for (const val of Object.values(obj)) {
1847
+ const found = scanForOrigin(val, depth + 1, ignoreTTL);
1848
+ if (found) return found;
1833
1849
  }
1834
1850
  return void 0;
1835
1851
  }
@@ -3624,7 +3640,7 @@ function safeCall(fn, fallback) {
3624
3640
 
3625
3641
  // src/valueTraceResolver.ts
3626
3642
  var FIBER_TAG_CONTEXT_PROVIDER = 10;
3627
- var BUDGET_MS = 50;
3643
+ var BUDGET_MS = 100;
3628
3644
  var SCAN_DEPTH = 3;
3629
3645
  var MAX_PROP_CHAIN_DEPTH = 30;
3630
3646
  function now() {
@@ -3648,34 +3664,49 @@ function getHookValueAt(fiber, hookIndex) {
3648
3664
  if (!hook) return void 0;
3649
3665
  return hook.memoizedState;
3650
3666
  }
3651
- function valuesMatch(target, targetFp, candidate) {
3667
+ function cachedFp(value, cache) {
3668
+ if (value === null || typeof value !== "object") return valueFingerprint(value);
3669
+ const cached = cache.get(value);
3670
+ if (cached !== void 0) return cached;
3671
+ const fp = valueFingerprint(value);
3672
+ cache.set(value, fp);
3673
+ return fp;
3674
+ }
3675
+ function valuesMatch(target, targetFp, candidate, cache) {
3652
3676
  const targetIsObject = target !== null && typeof target === "object";
3653
3677
  const candidateIsObject = candidate !== null && typeof candidate === "object";
3654
3678
  if (targetIsObject && candidateIsObject && target === candidate) return "exact";
3655
3679
  if (!shouldFlagRename(target) || !shouldFlagRename(candidate)) return null;
3656
- if (valueFingerprint(candidate) === targetFp) return "fingerprint-match";
3680
+ if (cachedFp(candidate, cache) === targetFp) return "fingerprint-match";
3657
3681
  return null;
3658
3682
  }
3659
- function findMatchingPathInObject(target, targetFp, container, currentPath, depth, deadline) {
3683
+ function findReferenceMatchAtTopLevel(target, container) {
3684
+ if (target === null || typeof target !== "object") return null;
3685
+ for (const key of Object.keys(container)) {
3686
+ if (container[key] === target) return key;
3687
+ }
3688
+ return null;
3689
+ }
3690
+ function findMatchingPathInObject(target, targetFp, container, currentPath, depth, deadline, cache) {
3660
3691
  if (now() > deadline) return null;
3661
3692
  if (depth > SCAN_DEPTH) return null;
3662
3693
  if (container === null || typeof container !== "object") return null;
3663
- const selfMatch = valuesMatch(target, targetFp, container);
3694
+ const selfMatch = valuesMatch(target, targetFp, container, cache);
3664
3695
  if (selfMatch) return { path: [...currentPath], confidence: selfMatch };
3665
3696
  if (Array.isArray(container)) {
3666
3697
  for (let i = 0; i < Math.min(container.length, 50); i++) {
3667
3698
  const child = container[i];
3668
- const directMatch = valuesMatch(target, targetFp, child);
3699
+ const directMatch = valuesMatch(target, targetFp, child, cache);
3669
3700
  if (directMatch) return { path: [...currentPath, String(i)], confidence: directMatch };
3670
- const nested = findMatchingPathInObject(target, targetFp, child, [...currentPath, String(i)], depth + 1, deadline);
3701
+ const nested = findMatchingPathInObject(target, targetFp, child, [...currentPath, String(i)], depth + 1, deadline, cache);
3671
3702
  if (nested) return nested;
3672
3703
  }
3673
3704
  } else {
3674
3705
  for (const key of Object.keys(container)) {
3675
3706
  const child = container[key];
3676
- const directMatch = valuesMatch(target, targetFp, child);
3707
+ const directMatch = valuesMatch(target, targetFp, child, cache);
3677
3708
  if (directMatch) return { path: [...currentPath, key], confidence: directMatch };
3678
- const nested = findMatchingPathInObject(target, targetFp, child, [...currentPath, key], depth + 1, deadline);
3709
+ const nested = findMatchingPathInObject(target, targetFp, child, [...currentPath, key], depth + 1, deadline, cache);
3679
3710
  if (nested) return nested;
3680
3711
  }
3681
3712
  }
@@ -3688,6 +3719,59 @@ function buildFiberToNodeIdMap() {
3688
3719
  }
3689
3720
  return reverse;
3690
3721
  }
3722
+ function retreatToEnclosingObject(fiber, propPath, rootValue) {
3723
+ const isObject = rootValue !== null && typeof rootValue === "object";
3724
+ if (isObject || propPath.length <= 1 || !fiber.memoizedProps) {
3725
+ return { rootValue, trailingSubPath: [] };
3726
+ }
3727
+ let path = propPath.slice();
3728
+ let value = rootValue;
3729
+ const trail = [];
3730
+ while (path.length > 1 && (value === null || typeof value !== "object")) {
3731
+ trail.unshift(path[path.length - 1]);
3732
+ path = path.slice(0, -1);
3733
+ value = walkPath(fiber.memoizedProps, path);
3734
+ }
3735
+ if (value === null || typeof value !== "object") {
3736
+ return { rootValue, trailingSubPath: [] };
3737
+ }
3738
+ return { rootValue: value, trailingSubPath: trail };
3739
+ }
3740
+ function isDebugEnabled() {
3741
+ try {
3742
+ return !!globalThis.__FLOTRACE_DEBUG__;
3743
+ } catch {
3744
+ return false;
3745
+ }
3746
+ }
3747
+ function findFetchOriginUpKeyPath(stateRoot, keyPath) {
3748
+ const ancestors = [stateRoot];
3749
+ let cursor = stateRoot;
3750
+ for (const segment of keyPath) {
3751
+ if (cursor === null || typeof cursor !== "object") break;
3752
+ cursor = cursor[segment];
3753
+ ancestors.push(cursor);
3754
+ }
3755
+ for (let i = ancestors.length - 1; i >= 0; i--) {
3756
+ const value = ancestors[i];
3757
+ if (value === null || typeof value !== "object") continue;
3758
+ const rid = findFetchOrigin(value, { ignoreTTL: true });
3759
+ if (rid) {
3760
+ if (isDebugEnabled()) {
3761
+ console.debug("[FloTrace] origin via keyPath retreat", {
3762
+ keyPath,
3763
+ depthHit: i,
3764
+ requestId: rid
3765
+ });
3766
+ }
3767
+ return rid;
3768
+ }
3769
+ }
3770
+ return void 0;
3771
+ }
3772
+ function resolveOriginViaTagOrKeyPath(matchedValue, stateRoot, keyPath) {
3773
+ return findFetchOrigin(matchedValue, { ignoreTTL: true }) ?? findFetchOriginUpKeyPath(stateRoot, keyPath);
3774
+ }
3691
3775
  function resolveValueTrace(input) {
3692
3776
  const startedAt = now();
3693
3777
  const deadline = startedAt + BUDGET_MS;
@@ -3716,9 +3800,13 @@ function resolveValueTrace(input) {
3716
3800
  if (rootValue === void 0) {
3717
3801
  return { ...base, error: "value-not-found", resolvedAtMs: now() };
3718
3802
  }
3803
+ const retreated = input.propPath ? retreatToEnclosingObject(fiber, input.propPath, rootValue) : { rootValue, trailingSubPath: [] };
3804
+ rootValue = retreated.rootValue;
3805
+ const trailingSubPath = retreated.trailingSubPath;
3719
3806
  const rootFp = valueFingerprint(rootValue);
3720
3807
  const fiberToNodeId = buildFiberToNodeIdMap();
3721
3808
  const rootComponentName = getComponentNameFromFiber(fiber) ?? "Unknown";
3809
+ const fpCache = /* @__PURE__ */ new WeakMap();
3722
3810
  if (input.propPath) {
3723
3811
  steps.push({
3724
3812
  kind: "prop",
@@ -3747,8 +3835,17 @@ function resolveValueTrace(input) {
3747
3835
  if (current.tag !== FIBER_TAG_CONTEXT_PROVIDER) {
3748
3836
  const props = current.memoizedProps;
3749
3837
  if (props) {
3750
- const match = findMatchingPathInObject(rootValue, rootFp, props, [], 0, deadline);
3751
- if (match) {
3838
+ const refKey = findReferenceMatchAtTopLevel(rootValue, props);
3839
+ let matchPath = refKey !== null ? [refKey] : null;
3840
+ let matchConfidence = "exact";
3841
+ if (matchPath === null) {
3842
+ const match = findMatchingPathInObject(rootValue, rootFp, props, [], 0, deadline, fpCache);
3843
+ if (match) {
3844
+ matchPath = match.path;
3845
+ matchConfidence = match.confidence;
3846
+ }
3847
+ }
3848
+ if (matchPath !== null) {
3752
3849
  const ancestorNodeId = fiberToNodeId.get(current);
3753
3850
  const ancestorName = getComponentNameFromFiber(current) ?? "Unknown";
3754
3851
  if (ancestorNodeId) {
@@ -3756,10 +3853,28 @@ function resolveValueTrace(input) {
3756
3853
  kind: "prop",
3757
3854
  nodeId: ancestorNodeId,
3758
3855
  componentName: ancestorName,
3759
- propPath: match.path,
3760
- confidence: match.confidence
3856
+ propPath: trailingSubPath.length > 0 ? [...matchPath, ...trailingSubPath] : matchPath,
3857
+ confidence: matchConfidence
3761
3858
  });
3762
3859
  }
3860
+ } else {
3861
+ const hookMatch = findMatchingHookState(current, rootValue, rootFp, fpCache);
3862
+ if (hookMatch) {
3863
+ const ancestorNodeId = fiberToNodeId.get(current);
3864
+ const ancestorName = getComponentNameFromFiber(current) ?? "Unknown";
3865
+ if (ancestorNodeId) {
3866
+ steps.push({
3867
+ kind: "hook-state",
3868
+ nodeId: ancestorNodeId,
3869
+ componentName: ancestorName,
3870
+ hookIndex: hookMatch.hookIndex,
3871
+ hookType: hookMatch.hookType,
3872
+ subPath: trailingSubPath.length > 0 ? trailingSubPath : void 0,
3873
+ confidence: hookMatch.confidence
3874
+ });
3875
+ return { ...base, steps, resolvedAtMs: now() };
3876
+ }
3877
+ }
3763
3878
  }
3764
3879
  }
3765
3880
  }
@@ -3768,7 +3883,7 @@ function resolveValueTrace(input) {
3768
3883
  }
3769
3884
  }
3770
3885
  if (input.hookPath) {
3771
- const origin = findFetchOrigin(rootValue);
3886
+ const origin = findFetchOrigin(rootValue, { ignoreTTL: true });
3772
3887
  if (origin) {
3773
3888
  steps.push({
3774
3889
  kind: "api",
@@ -3780,15 +3895,15 @@ function resolveValueTrace(input) {
3780
3895
  return { ...base, steps, resolvedAtMs: now() };
3781
3896
  }
3782
3897
  }
3783
- const derivedMatch = findDerivationMatch(fiber, rootValue, rootFp, rootComponentName);
3898
+ const derivedMatch = findDerivationMatch(fiber, rootValue, rootFp, rootComponentName, fpCache);
3784
3899
  if (derivedMatch) {
3785
3900
  steps.push({ ...derivedMatch, nodeId: input.nodeId });
3786
3901
  return { ...base, steps, resolvedAtMs: now() };
3787
3902
  }
3788
- const contextMatch = findContextMatch(fiber, rootValue, rootFp, fiberToNodeId);
3903
+ const contextMatch = findContextMatch(fiber, rootValue, rootFp, fiberToNodeId, fpCache);
3789
3904
  if (contextMatch) {
3790
3905
  steps.push(contextMatch.step);
3791
- const providerStoreMatch = findStoreMatch(contextMatch.providerValue, valueFingerprint(contextMatch.providerValue), deadline);
3906
+ const providerStoreMatch = findStoreMatch(contextMatch.providerValue, cachedFp(contextMatch.providerValue, fpCache), deadline, fpCache);
3792
3907
  if (providerStoreMatch) {
3793
3908
  steps.push({
3794
3909
  kind: "store",
@@ -3797,19 +3912,23 @@ function resolveValueTrace(input) {
3797
3912
  keyPath: providerStoreMatch.keyPath,
3798
3913
  confidence: providerStoreMatch.confidence
3799
3914
  });
3800
- const origin = findFetchOrigin(providerStoreMatch.matchedValue);
3915
+ const origin = resolveOriginViaTagOrKeyPath(
3916
+ providerStoreMatch.matchedValue,
3917
+ providerStoreMatch.stateRoot,
3918
+ providerStoreMatch.keyPath
3919
+ );
3801
3920
  if (origin) {
3802
3921
  steps.push({ kind: "api", requestId: origin, method: "UNKNOWN", urlPath: "", ageMs: 0 });
3803
3922
  }
3804
3923
  } else {
3805
- const origin = findFetchOrigin(contextMatch.providerValue);
3924
+ const origin = findFetchOrigin(contextMatch.providerValue, { ignoreTTL: true });
3806
3925
  if (origin) {
3807
3926
  steps.push({ kind: "api", requestId: origin, method: "UNKNOWN", urlPath: "", ageMs: 0 });
3808
3927
  }
3809
3928
  }
3810
3929
  return { ...base, steps, resolvedAtMs: now() };
3811
3930
  }
3812
- const storeMatch = findStoreMatch(rootValue, rootFp, deadline);
3931
+ const storeMatch = findStoreMatch(rootValue, rootFp, deadline, fpCache);
3813
3932
  if (storeMatch) {
3814
3933
  steps.push({
3815
3934
  kind: "store",
@@ -3818,7 +3937,11 @@ function resolveValueTrace(input) {
3818
3937
  keyPath: storeMatch.keyPath,
3819
3938
  confidence: storeMatch.confidence
3820
3939
  });
3821
- const origin = findFetchOrigin(storeMatch.matchedValue);
3940
+ const origin = resolveOriginViaTagOrKeyPath(
3941
+ storeMatch.matchedValue,
3942
+ storeMatch.stateRoot,
3943
+ storeMatch.keyPath
3944
+ );
3822
3945
  if (origin) {
3823
3946
  steps.push({
3824
3947
  kind: "api",
@@ -3831,12 +3954,12 @@ function resolveValueTrace(input) {
3831
3954
  }
3832
3955
  return { ...base, steps, resolvedAtMs: now() };
3833
3956
  }
3834
- function findContextMatch(consumer, target, targetFp, fiberToNodeId) {
3957
+ function findContextMatch(consumer, target, targetFp, fiberToNodeId, cache) {
3835
3958
  const deps = consumer.dependencies?.firstContext;
3836
3959
  if (!deps) return null;
3837
3960
  let dep = deps;
3838
3961
  while (dep) {
3839
- const match = valuesMatch(target, targetFp, dep.memoizedValue);
3962
+ const match = valuesMatch(target, targetFp, dep.memoizedValue, cache);
3840
3963
  if (match) {
3841
3964
  const provider = findNearestProvider(consumer, dep.context);
3842
3965
  const step = {
@@ -3852,14 +3975,14 @@ function findContextMatch(consumer, target, targetFp, fiberToNodeId) {
3852
3975
  }
3853
3976
  return null;
3854
3977
  }
3855
- function findDerivationMatch(fiber, target, targetFp, componentName) {
3978
+ function findDerivationMatch(fiber, target, targetFp, componentName, cache) {
3856
3979
  let hook = fiber.memoizedState;
3857
3980
  let index = 0;
3858
3981
  while (hook) {
3859
3982
  const ms = hook.memoizedState;
3860
3983
  if (Array.isArray(ms) && ms.length === 2 && Array.isArray(ms[1])) {
3861
3984
  const [computed, deps] = ms;
3862
- const match = valuesMatch(target, targetFp, computed);
3985
+ const match = valuesMatch(target, targetFp, computed, cache);
3863
3986
  if (match) {
3864
3987
  const hookType = typeof computed === "function" ? "useCallback" : "useMemo";
3865
3988
  return {
@@ -3878,6 +4001,25 @@ function findDerivationMatch(fiber, target, targetFp, componentName) {
3878
4001
  }
3879
4002
  return null;
3880
4003
  }
4004
+ function findMatchingHookState(fiber, target, targetFp, cache) {
4005
+ let hook = fiber.memoizedState;
4006
+ let index = 0;
4007
+ while (hook) {
4008
+ const ms = hook.memoizedState;
4009
+ const isMemoTuple = Array.isArray(ms) && ms.length === 2 && Array.isArray(ms[1]);
4010
+ const isEffectShape2 = ms !== null && typeof ms === "object" && "create" in ms && "deps" in ms;
4011
+ const isRefShape = ms !== null && typeof ms === "object" && Object.keys(ms).length === 1 && "current" in ms;
4012
+ if (!isMemoTuple && !isEffectShape2 && !isRefShape) {
4013
+ const match = valuesMatch(target, targetFp, ms, cache);
4014
+ if (match) {
4015
+ return { hookIndex: index, hookType: "useState", confidence: match };
4016
+ }
4017
+ }
4018
+ hook = hook.next;
4019
+ index++;
4020
+ }
4021
+ return null;
4022
+ }
3881
4023
  function findNearestProvider(consumer, contextObj) {
3882
4024
  let current = consumer.return;
3883
4025
  let hops = 0;
@@ -3891,44 +4033,47 @@ function findNearestProvider(consumer, contextObj) {
3891
4033
  }
3892
4034
  return null;
3893
4035
  }
3894
- function findStoreMatch(target, targetFp, deadline) {
4036
+ function findStoreMatch(target, targetFp, deadline, cache) {
3895
4037
  for (const [storeName, state] of getZustandSnapshot()) {
3896
4038
  if (now() > deadline) return null;
3897
- const hit = findMatchingPathInObject(target, targetFp, state, [], 0, deadline);
4039
+ const hit = findMatchingPathInObject(target, targetFp, state, [], 0, deadline, cache);
3898
4040
  if (hit) {
3899
4041
  return {
3900
4042
  source: "zustand",
3901
4043
  storeName,
3902
4044
  keyPath: hit.path,
3903
4045
  confidence: hit.confidence,
3904
- matchedValue: walkPath(state, hit.path)
4046
+ matchedValue: walkPath(state, hit.path),
4047
+ stateRoot: state
3905
4048
  };
3906
4049
  }
3907
4050
  }
3908
4051
  const redux = getReduxSnapshot();
3909
4052
  if (redux) {
3910
4053
  if (now() > deadline) return null;
3911
- const hit = findMatchingPathInObject(target, targetFp, redux, [], 0, deadline);
4054
+ const hit = findMatchingPathInObject(target, targetFp, redux, [], 0, deadline, cache);
3912
4055
  if (hit) {
3913
4056
  return {
3914
4057
  source: "redux",
3915
4058
  storeName: "redux",
3916
4059
  keyPath: hit.path,
3917
4060
  confidence: hit.confidence,
3918
- matchedValue: walkPath(redux, hit.path)
4061
+ matchedValue: walkPath(redux, hit.path),
4062
+ stateRoot: redux
3919
4063
  };
3920
4064
  }
3921
4065
  }
3922
4066
  for (const [queryHash, entry] of getTanstackSnapshot()) {
3923
4067
  if (now() > deadline) return null;
3924
- const hit = findMatchingPathInObject(target, targetFp, entry.data, [], 0, deadline);
4068
+ const hit = findMatchingPathInObject(target, targetFp, entry.data, [], 0, deadline, cache);
3925
4069
  if (hit) {
3926
4070
  return {
3927
4071
  source: "tanstack-query",
3928
4072
  storeName: queryHash,
3929
4073
  keyPath: hit.path,
3930
4074
  confidence: hit.confidence,
3931
- matchedValue: walkPath(entry.data, hit.path)
4075
+ matchedValue: walkPath(entry.data, hit.path),
4076
+ stateRoot: entry.data
3932
4077
  };
3933
4078
  }
3934
4079
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flotrace/runtime-core",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "Platform-agnostic core for FloTrace runtime — fiber walker, analyzers, trackers. Shared by @flotrace/runtime (web) and @flotrace/runtime-native (React Native).",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",