bippy 0.5.42 → 0.6.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.
Files changed (50) hide show
  1. package/dist/core.cjs +1 -1
  2. package/dist/core.d.cts +28 -313
  3. package/dist/core.d.ts +28 -313
  4. package/dist/core.js +1 -1
  5. package/dist/core2.d.cts +3 -2
  6. package/dist/core2.d.ts +3 -2
  7. package/dist/get-source.cjs +19 -0
  8. package/dist/get-source.js +19 -0
  9. package/dist/index.cjs +1 -1
  10. package/dist/index.d.cts +3 -2
  11. package/dist/index.d.ts +3 -2
  12. package/dist/index.iife.js +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/install-hook-only.cjs +1 -1
  15. package/dist/install-hook-only.iife.js +1 -1
  16. package/dist/install-hook-only.js +1 -1
  17. package/dist/rdt-hook.cjs +1 -1
  18. package/dist/rdt-hook.js +1 -1
  19. package/dist/react-refresh.cjs +9 -0
  20. package/dist/react-refresh.d.cts +66 -0
  21. package/dist/react-refresh.d.ts +66 -0
  22. package/dist/react-refresh.js +9 -0
  23. package/dist/source.cjs +4 -19
  24. package/dist/source.d.cts +35 -27
  25. package/dist/source.d.ts +35 -27
  26. package/dist/source.js +4 -19
  27. package/dist/unsubscribe.d.cts +298 -0
  28. package/dist/unsubscribe.d.ts +298 -0
  29. package/package.json +14 -3
  30. package/src/core.ts +312 -334
  31. package/src/rdt-hook.ts +57 -16
  32. package/src/react-refresh/constants.ts +9 -0
  33. package/src/react-refresh/detect-hmr-transport.ts +33 -0
  34. package/src/react-refresh/index.ts +173 -0
  35. package/src/react-refresh/metro-hmr-transport.ts +188 -0
  36. package/src/react-refresh/next-webpack-hmr-transport.ts +72 -0
  37. package/src/react-refresh/normalize-hmr-file-path.ts +24 -0
  38. package/src/react-refresh/types.ts +7 -0
  39. package/src/react-refresh/vite-hmr-transport.ts +116 -0
  40. package/src/source/constants.ts +11 -0
  41. package/src/source/get-display-name-from-source.ts +3 -3
  42. package/src/source/get-source.ts +53 -9
  43. package/src/source/index.ts +17 -8
  44. package/src/source/inspect-hooks.ts +7 -4
  45. package/src/source/owner-stack.ts +193 -29
  46. package/src/source/parse-debug-stack.ts +133 -0
  47. package/src/source/parse-stack.ts +6 -105
  48. package/src/source/symbolication.ts +48 -13
  49. package/src/types.ts +20 -7
  50. package/src/unsubscribe.ts +17 -0
package/src/core.ts CHANGED
@@ -13,12 +13,15 @@ import type {
13
13
  } from "./types.js";
14
14
 
15
15
  import {
16
+ _onActiveListeners,
16
17
  BIPPY_INSTRUMENTATION_STRING,
17
18
  getRDTHook,
18
19
  hasRDTHook,
19
20
  isReactRefresh,
20
21
  isRealReactDevtools,
22
+ onRendererInject,
21
23
  } from "./rdt-hook.js";
24
+ import { toUnsubscribe, type Unsubscribe } from "./unsubscribe.js";
22
25
 
23
26
  // https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactWorkTags.js
24
27
  export const FunctionComponentTag = 0;
@@ -48,6 +51,8 @@ export const ELEMENT_TYPE_SYMBOL_STRING = "Symbol(react.element)";
48
51
  export const TRANSITIONAL_ELEMENT_TYPE_SYMBOL_STRING = "Symbol(react.transitional.element)";
49
52
  export const CONCURRENT_MODE_SYMBOL_STRING = "Symbol(react.concurrent_mode)";
50
53
  export const DEPRECATED_ASYNC_MODE_SYMBOL_STRING = "Symbol(react.async_mode)";
54
+ export const CONCURRENT_MODE_SYMBOL_DESCRIPTION = "react.concurrent_mode";
55
+ export const DEPRECATED_ASYNC_MODE_SYMBOL_DESCRIPTION = "react.async_mode";
51
56
 
52
57
  // https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberFlags.js
53
58
  const PerformedWork = 0b1;
@@ -135,13 +140,6 @@ export const isFiber = (maybeFiber: unknown): maybeFiber is Fiber => {
135
140
  return "pendingProps" in maybeFiber && !("containerInfo" in maybeFiber);
136
141
  };
137
142
 
138
- /**
139
- * Returns `true` if the two {@link Fiber}s are the same reference
140
- */
141
- export const areFiberEqual = (fiberA: Fiber, fiberB: Fiber): boolean => {
142
- return fiberA === fiberB || fiberA.alternate === fiberB || fiberB.alternate === fiberA;
143
- };
144
-
145
143
  /**
146
144
  * Traverses up or down a {@link Fiber}'s contexts, return `true` to stop and select the current and previous context value.
147
145
  */
@@ -215,13 +213,12 @@ export const traverseProps = (
215
213
  const nextProps = fiber.memoizedProps;
216
214
  const prevProps = fiber.alternate?.memoizedProps || {};
217
215
 
218
- const allKeys = new Set([...Object.keys(nextProps), ...Object.keys(prevProps)]);
219
-
220
- for (const propName of allKeys) {
221
- const prevValue = prevProps?.[propName];
222
- const nextValue = nextProps?.[propName];
223
-
224
- if (selector(propName, nextValue, prevValue) === true) return true;
216
+ for (const propName of Object.keys(nextProps)) {
217
+ if (selector(propName, nextProps[propName], prevProps[propName]) === true) return true;
218
+ }
219
+ for (const propName of Object.keys(prevProps)) {
220
+ if (propName in nextProps) continue;
221
+ if (selector(propName, nextProps[propName], prevProps[propName]) === true) return true;
225
222
  }
226
223
  } catch {}
227
224
  return false;
@@ -308,7 +305,7 @@ export const getFiberStack = (fiber: Fiber): Fiber[] => {
308
305
  /**
309
306
  * Returns `true` if the {@link Fiber} should be filtered out during reconciliation.
310
307
  */
311
- export const shouldFilterFiber = (fiber: Fiber): boolean => {
308
+ const shouldFilterFiber = (fiber: Fiber): boolean => {
312
309
  switch (fiber.tag) {
313
310
  case DehydratedSuspenseComponentTag:
314
311
  // TODO: ideally we would show dehydrated Suspense immediately.
@@ -332,10 +329,14 @@ export const shouldFilterFiber = (fiber: Fiber): boolean => {
332
329
  const symbolOrNumber =
333
330
  typeof fiber.type === "object" && fiber.type !== null ? fiber.type.$$typeof : fiber.type;
334
331
 
335
- const typeSymbol =
336
- typeof symbolOrNumber === "symbol" ? symbolOrNumber.toString() : symbolOrNumber;
332
+ if (typeof symbolOrNumber === "symbol") {
333
+ return (
334
+ symbolOrNumber.description === CONCURRENT_MODE_SYMBOL_DESCRIPTION ||
335
+ symbolOrNumber.description === DEPRECATED_ASYNC_MODE_SYMBOL_DESCRIPTION
336
+ );
337
+ }
337
338
 
338
- switch (typeSymbol) {
339
+ switch (symbolOrNumber) {
339
340
  case CONCURRENT_MODE_NUMBER:
340
341
  case CONCURRENT_MODE_SYMBOL_STRING:
341
342
  case DEPRECATED_ASYNC_MODE_SYMBOL_STRING:
@@ -439,7 +440,7 @@ export function traverseFiber(
439
440
  return null;
440
441
  }
441
442
 
442
- export const traverseFiberSync = (
443
+ const traverseFiberSync = (
443
444
  fiber: Fiber | null,
444
445
  selector: (node: Fiber) => boolean | void,
445
446
  ascending = false,
@@ -457,7 +458,7 @@ export const traverseFiberSync = (
457
458
  return null;
458
459
  };
459
460
 
460
- export const traverseFiberAsync = async (
461
+ const traverseFiberAsync = async (
461
462
  fiber: Fiber | null,
462
463
  selector: (node: Fiber) => Promise<boolean | void>,
463
464
  ascending = false,
@@ -568,6 +569,8 @@ export const isInstrumentationActive = (): boolean => {
568
569
  );
569
570
  };
570
571
 
572
+ export const _fiberRoots = new Set<FiberRoot>();
573
+
571
574
  /**
572
575
  * Returns the latest fiber (since it may be double-buffered).
573
576
  */
@@ -591,7 +594,7 @@ export type RenderHandler = <S>(fiber: Fiber, phase: RenderPhase, state?: S) =>
591
594
  export type RenderPhase = "mount" | "unmount" | "update";
592
595
 
593
596
  let fiberId = 0;
594
- export const fiberIdMap = new WeakMap<Fiber, number>();
597
+ const fiberIdMap = new WeakMap<Fiber, number>();
595
598
 
596
599
  export const setFiberId = (fiber: Fiber, id: number = fiberId++): void => {
597
600
  fiberIdMap.set(fiber, id);
@@ -612,7 +615,7 @@ export const getFiberId = (fiber: Fiber): number => {
612
615
  return id;
613
616
  };
614
617
 
615
- export const mountFiberRecursively = (
618
+ const mountFiberRecursively = (
616
619
  onRender: RenderHandler,
617
620
  firstChild: Fiber,
618
621
  traverseSiblings: boolean,
@@ -661,7 +664,7 @@ export const mountFiberRecursively = (
661
664
  }
662
665
  };
663
666
 
664
- export const updateFiberRecursively = (
667
+ const updateFiberRecursively = (
665
668
  onRender: RenderHandler,
666
669
  nextFiber: Fiber,
667
670
  prevFiber: Fiber,
@@ -761,7 +764,7 @@ export const updateFiberRecursively = (
761
764
  }
762
765
  };
763
766
 
764
- export const unmountFiber = (onRender: RenderHandler, fiber: Fiber): void => {
767
+ const unmountFiber = (onRender: RenderHandler, fiber: Fiber): void => {
765
768
  const isRoot = fiber.tag === HostRootTag;
766
769
 
767
770
  if (isRoot || !shouldFilterFiber(fiber)) {
@@ -769,7 +772,7 @@ export const unmountFiber = (onRender: RenderHandler, fiber: Fiber): void => {
769
772
  }
770
773
  };
771
774
 
772
- export const unmountFiberChildrenRecursively = (onRender: RenderHandler, fiber: Fiber): void => {
775
+ const unmountFiberChildrenRecursively = (onRender: RenderHandler, fiber: Fiber): void => {
773
776
  // We might meet a nested Suspense on our way.
774
777
  const isTimedOutSuspense = fiber.tag === SuspenseComponentTag && fiber.memoizedState !== null;
775
778
  let child = fiber.child;
@@ -852,80 +855,85 @@ export const traverseRenderedFibers = (root: FiberRoot, onRender: RenderHandler)
852
855
  rootInstance.prevFiber = fiber;
853
856
  };
854
857
 
855
- let _overrideProps: null | ReactRenderer["overrideProps"] = null;
856
- let _overrideHookState: null | ReactRenderer["overrideHookState"] = null;
857
- let _overrideContext: null | ReactRenderer["overrideContext"] = null;
858
+ const overrideRenderers = new Set<ReactRenderer>();
859
+ let areOverrideRenderersWired = false;
858
860
 
859
- export const injectOverrideMethods = () => {
860
- if (!hasRDTHook()) return null;
861
+ const wireOverrideRenderers = (): void => {
862
+ if (areOverrideRenderersWired) return;
863
+ if (!hasRDTHook()) return;
861
864
  const rdtHook = getRDTHook();
862
- if (!rdtHook?.renderers) return null;
865
+ if (!rdtHook?.renderers) return;
866
+ areOverrideRenderersWired = true;
863
867
 
864
- if (_overrideProps || _overrideHookState || _overrideContext) {
865
- return {
866
- overrideContext: _overrideContext,
867
- overrideHookState: _overrideHookState,
868
- overrideProps: _overrideProps,
869
- };
868
+ ensureHookDispatchesToListeners(rdtHook);
869
+ for (const renderer of rdtHook.renderers.values()) {
870
+ overrideRenderers.add(renderer);
870
871
  }
872
+ onRendererInject((renderer) => {
873
+ overrideRenderers.add(renderer);
874
+ });
875
+ };
871
876
 
872
- for (const [, renderer] of Array.from(rdtHook.renderers)) {
873
- try {
874
- if (_overrideHookState) {
875
- const prevOverrideHookState = _overrideHookState;
876
- _overrideHookState = (fiber: Fiber, id: string, path: string[], value: unknown) => {
877
- let current = fiber.memoizedState;
878
- for (let i = 0; i < Number(id); i++) {
879
- if (!current?.next) break;
880
- current = current.next;
881
- }
877
+ const getRootRenderer = (fiber: Fiber): ReactRenderer | null => {
878
+ if (!hasRDTHook()) return null;
879
+ let hostRootFiber = fiber;
880
+ while (hostRootFiber.return) {
881
+ hostRootFiber = hostRootFiber.return;
882
+ }
883
+ const rendererId = rootRendererIds.get(hostRootFiber.stateNode);
884
+ if (rendererId === undefined) return null;
885
+ return getRDTHook().renderers?.get(rendererId) ?? null;
886
+ };
882
887
 
883
- if (current?.queue) {
884
- const queue = current.queue;
885
- if (isPOJO(queue) && "dispatch" in queue) {
886
- const dispatch = queue.dispatch as (value: unknown) => void;
887
- dispatch(value);
888
- return;
889
- }
890
- }
888
+ // dispatch to the renderer that owns the fiber's root when known; renderers
889
+ // that never committed through bippy's hook patch fall back to "try all",
890
+ // which matches the pre-ownership behavior for single-renderer apps
891
+ const resolveOverrideRenderers = (fiber: Fiber): ReactRenderer[] => {
892
+ wireOverrideRenderers();
893
+ const rootRenderer = getRootRenderer(fiber);
894
+ if (rootRenderer) return [rootRenderer];
895
+ return Array.from(overrideRenderers);
896
+ };
891
897
 
892
- prevOverrideHookState(fiber, id, path, value);
893
- renderer.overrideHookState?.(fiber, id, path, value);
894
- };
895
- } else if (renderer.overrideHookState) {
896
- _overrideHookState = renderer.overrideHookState;
897
- }
898
+ const applyPropsOverride = (
899
+ renderers: ReactRenderer[],
900
+ fiber: Fiber,
901
+ path: string[],
902
+ value: unknown,
903
+ ): void => {
904
+ for (const renderer of renderers) {
905
+ try {
906
+ renderer.overrideProps?.(fiber, path, value);
907
+ } catch {}
908
+ }
909
+ };
898
910
 
899
- if (_overrideProps) {
900
- const prevOverrideProps = _overrideProps;
901
- _overrideProps = (fiber: Fiber, path: Array<string>, value: unknown) => {
902
- prevOverrideProps(fiber, path, value);
903
- renderer.overrideProps?.(fiber, path, value);
904
- };
905
- } else if (renderer.overrideProps) {
906
- _overrideProps = renderer.overrideProps;
907
- }
911
+ const getHookStateDispatch = (
912
+ fiber: Fiber,
913
+ hookIndex: number,
914
+ ): ((value: unknown) => void) | null => {
915
+ let hookState = fiber.memoizedState;
916
+ for (let i = 0; i < hookIndex; i++) {
917
+ if (!hookState?.next) return null;
918
+ hookState = hookState.next;
919
+ }
920
+ const queue = hookState?.queue;
921
+ if (isPOJO(queue) && typeof queue.dispatch === "function") {
922
+ return queue.dispatch as (value: unknown) => void;
923
+ }
924
+ return null;
925
+ };
908
926
 
909
- _overrideContext = (fiber: Fiber, contextType: unknown, path: string[], value: unknown) => {
910
- let current: Fiber | null = fiber;
911
- while (current) {
912
- const type = current.type as { Provider?: unknown };
913
- if (type === contextType || type?.Provider === contextType) {
914
- if (_overrideProps) {
915
- _overrideProps(current, ["value", ...path], value);
916
- if (current.alternate) {
917
- _overrideProps(current.alternate, ["value", ...path], value);
918
- }
919
- }
920
- break;
921
- }
922
- current = current.return;
923
- }
924
- };
925
- } catch {
926
- /**/
927
+ const findContextProviderFiber = (fiber: Fiber, contextType: unknown): Fiber | null => {
928
+ let currentFiber: Fiber | null = fiber;
929
+ while (currentFiber) {
930
+ const fiberType = currentFiber.type;
931
+ if (fiberType === contextType || fiberType?.Provider === contextType) {
932
+ return currentFiber;
927
933
  }
934
+ currentFiber = currentFiber.return;
928
935
  }
936
+ return null;
929
937
  };
930
938
 
931
939
  const isPOJO = (maybePOJO: unknown): maybePOJO is Record<string, unknown> => {
@@ -960,61 +968,54 @@ const buildPathsFromValue = (
960
968
  return paths;
961
969
  };
962
970
 
963
- export const overrideProps = (fiber: Fiber, partialValue: Record<string, unknown>) => {
964
- injectOverrideMethods();
965
-
966
- const paths = buildPathsFromValue(partialValue);
971
+ const buildValueWrites = (partialValue: unknown): Array<{ path: string[]; value: unknown }> =>
972
+ isPOJO(partialValue) ? buildPathsFromValue(partialValue) : [{ path: [], value: partialValue }];
967
973
 
968
- for (const { path, value } of paths) {
969
- try {
970
- _overrideProps?.(fiber, path, value);
971
- } catch {}
974
+ export const overrideProps = (fiber: Fiber, partialValue: Record<string, unknown>) => {
975
+ const renderers = resolveOverrideRenderers(fiber);
976
+ for (const { path, value } of buildValueWrites(partialValue)) {
977
+ applyPropsOverride(renderers, fiber, path, value);
972
978
  }
973
979
  };
974
980
 
975
- export const overrideHookState = (
976
- fiber: Fiber,
977
- id: number,
978
- partialValue: Record<string, unknown>,
979
- ) => {
980
- injectOverrideMethods();
981
-
982
- const hookId = String(id);
983
-
984
- if (isPOJO(partialValue)) {
985
- const paths = buildPathsFromValue(partialValue);
986
-
987
- for (const { path, value } of paths) {
988
- try {
989
- _overrideHookState?.(fiber, hookId, path, value);
990
- } catch {}
981
+ export const overrideHookState = (fiber: Fiber, id: number, partialValue: unknown) => {
982
+ const renderers = resolveOverrideRenderers(fiber).filter((renderer) =>
983
+ Boolean(renderer.overrideHookState),
984
+ );
985
+ const writes = buildValueWrites(partialValue);
986
+
987
+ if (renderers.length > 0) {
988
+ const hookId = String(id);
989
+ for (const renderer of renderers) {
990
+ for (const { path, value } of writes) {
991
+ try {
992
+ renderer.overrideHookState?.(fiber, hookId, path, value);
993
+ } catch {}
994
+ }
991
995
  }
992
- } else {
993
- try {
994
- _overrideHookState?.(fiber, hookId, [], partialValue);
995
- } catch {}
996
+ return;
996
997
  }
997
- };
998
998
 
999
- export const overrideContext = (
1000
- fiber: Fiber,
1001
- contextType: unknown,
1002
- partialValue: Record<string, unknown>,
1003
- ) => {
1004
- injectOverrideMethods();
1005
-
1006
- if (isPOJO(partialValue)) {
1007
- const paths = buildPathsFromValue(partialValue);
999
+ // production renderers don't expose overrideHookState; dispatching through
1000
+ // the hook's own queue still works there, but only for whole-value writes
1001
+ // (a path write through dispatch would replace the entire hook state)
1002
+ if (isPOJO(partialValue)) return;
1003
+ const dispatch = getHookStateDispatch(fiber, id);
1004
+ if (!dispatch) return;
1005
+ try {
1006
+ dispatch(partialValue);
1007
+ } catch {}
1008
+ };
1008
1009
 
1009
- for (const { path, value } of paths) {
1010
- try {
1011
- _overrideContext?.(fiber, contextType, path, value);
1012
- } catch {}
1010
+ export const overrideContext = (fiber: Fiber, contextType: unknown, partialValue: unknown) => {
1011
+ const providerFiber = findContextProviderFiber(fiber, contextType);
1012
+ if (!providerFiber) return;
1013
+ const renderers = resolveOverrideRenderers(providerFiber);
1014
+ for (const { path, value } of buildValueWrites(partialValue)) {
1015
+ applyPropsOverride(renderers, providerFiber, ["value", ...path], value);
1016
+ if (providerFiber.alternate) {
1017
+ applyPropsOverride(renderers, providerFiber.alternate, ["value", ...path], value);
1013
1018
  }
1014
- } else {
1015
- try {
1016
- _overrideContext?.(fiber, contextType, [], partialValue);
1017
- } catch {}
1018
1019
  }
1019
1020
  };
1020
1021
 
@@ -1027,10 +1028,133 @@ export interface InstrumentationOptions {
1027
1028
  onScheduleFiberRoot?: (rendererID: number, root: FiberRoot, children: React.ReactNode) => unknown;
1028
1029
  }
1029
1030
 
1031
+ const commitFiberRootListeners = new Set<
1032
+ NonNullable<InstrumentationOptions["onCommitFiberRoot"]>
1033
+ >();
1034
+ const commitFiberUnmountListeners = new Set<
1035
+ NonNullable<InstrumentationOptions["onCommitFiberUnmount"]>
1036
+ >();
1037
+ const postCommitFiberRootListeners = new Set<
1038
+ NonNullable<InstrumentationOptions["onPostCommitFiberRoot"]>
1039
+ >();
1040
+ const scheduleFiberRootListeners = new Set<
1041
+ NonNullable<InstrumentationOptions["onScheduleFiberRoot"]>
1042
+ >();
1043
+
1044
+ interface HookDispatchers {
1045
+ onCommitFiberRoot: ReactDevToolsGlobalHook["onCommitFiberRoot"];
1046
+ onCommitFiberUnmount: ReactDevToolsGlobalHook["onCommitFiberUnmount"];
1047
+ onPostCommitFiberRoot: ReactDevToolsGlobalHook["onPostCommitFiberRoot"];
1048
+ onScheduleFiberRoot: NonNullable<ReactDevToolsGlobalHook["onScheduleFiberRoot"]>;
1049
+ }
1050
+
1051
+ const hookDispatchers = new WeakMap<ReactDevToolsGlobalHook, Partial<HookDispatchers>>();
1052
+
1053
+ const rootRendererIds = new WeakMap<FiberRoot, number>();
1054
+
1055
+ // each hook event is dispatched from a single re-installable wrapper. If
1056
+ // something overwrites the hook method (devtools, direct assignment), the
1057
+ // next instrument() call installs a fresh wrapper over it; a superseded
1058
+ // wrapper still forwards the previous chain but skips the listeners so
1059
+ // they never fire twice.
1060
+ const ensureHookDispatchesToListeners = (rdtHook: ReactDevToolsGlobalHook): void => {
1061
+ const dispatchers = hookDispatchers.get(rdtHook) ?? {};
1062
+ hookDispatchers.set(rdtHook, dispatchers);
1063
+
1064
+ if (
1065
+ !dispatchers.onCommitFiberRoot ||
1066
+ rdtHook.onCommitFiberRoot !== dispatchers.onCommitFiberRoot
1067
+ ) {
1068
+ const prevOnCommitFiberRoot = rdtHook.onCommitFiberRoot;
1069
+ const dispatchCommitFiberRoot: HookDispatchers["onCommitFiberRoot"] = (
1070
+ rendererID,
1071
+ root,
1072
+ priority,
1073
+ ) => {
1074
+ prevOnCommitFiberRoot?.(rendererID, root, priority);
1075
+ if (hookDispatchers.get(rdtHook)?.onCommitFiberRoot !== dispatchCommitFiberRoot) return;
1076
+ _fiberRoots.add(root);
1077
+ rootRendererIds.set(root, rendererID);
1078
+ for (const listener of commitFiberRootListeners) {
1079
+ listener(rendererID, root, priority);
1080
+ }
1081
+ };
1082
+ dispatchers.onCommitFiberRoot = dispatchCommitFiberRoot;
1083
+ rdtHook.onCommitFiberRoot = dispatchCommitFiberRoot;
1084
+ }
1085
+
1086
+ if (
1087
+ !dispatchers.onCommitFiberUnmount ||
1088
+ rdtHook.onCommitFiberUnmount !== dispatchers.onCommitFiberUnmount
1089
+ ) {
1090
+ const prevOnCommitFiberUnmount = rdtHook.onCommitFiberUnmount;
1091
+ const dispatchCommitFiberUnmount: HookDispatchers["onCommitFiberUnmount"] = (
1092
+ rendererID,
1093
+ fiber,
1094
+ ) => {
1095
+ prevOnCommitFiberUnmount?.(rendererID, fiber);
1096
+ if (hookDispatchers.get(rdtHook)?.onCommitFiberUnmount !== dispatchCommitFiberUnmount) {
1097
+ return;
1098
+ }
1099
+ for (const listener of commitFiberUnmountListeners) {
1100
+ listener(rendererID, fiber);
1101
+ }
1102
+ };
1103
+ dispatchers.onCommitFiberUnmount = dispatchCommitFiberUnmount;
1104
+ rdtHook.onCommitFiberUnmount = dispatchCommitFiberUnmount;
1105
+ }
1106
+
1107
+ if (
1108
+ !dispatchers.onPostCommitFiberRoot ||
1109
+ rdtHook.onPostCommitFiberRoot !== dispatchers.onPostCommitFiberRoot
1110
+ ) {
1111
+ const prevOnPostCommitFiberRoot = rdtHook.onPostCommitFiberRoot;
1112
+ const dispatchPostCommitFiberRoot: HookDispatchers["onPostCommitFiberRoot"] = (
1113
+ rendererID,
1114
+ root,
1115
+ ) => {
1116
+ prevOnPostCommitFiberRoot?.(rendererID, root);
1117
+ if (hookDispatchers.get(rdtHook)?.onPostCommitFiberRoot !== dispatchPostCommitFiberRoot) {
1118
+ return;
1119
+ }
1120
+ for (const listener of postCommitFiberRootListeners) {
1121
+ listener(rendererID, root);
1122
+ }
1123
+ };
1124
+ dispatchers.onPostCommitFiberRoot = dispatchPostCommitFiberRoot;
1125
+ rdtHook.onPostCommitFiberRoot = dispatchPostCommitFiberRoot;
1126
+ }
1127
+
1128
+ if (
1129
+ !dispatchers.onScheduleFiberRoot ||
1130
+ rdtHook.onScheduleFiberRoot !== dispatchers.onScheduleFiberRoot
1131
+ ) {
1132
+ const prevOnScheduleFiberRoot = rdtHook.onScheduleFiberRoot;
1133
+ const dispatchScheduleFiberRoot: HookDispatchers["onScheduleFiberRoot"] = (
1134
+ rendererID,
1135
+ root,
1136
+ children,
1137
+ ) => {
1138
+ prevOnScheduleFiberRoot?.(rendererID, root, children);
1139
+ if (hookDispatchers.get(rdtHook)?.onScheduleFiberRoot !== dispatchScheduleFiberRoot) return;
1140
+ for (const listener of scheduleFiberRootListeners) {
1141
+ listener(rendererID, root, children);
1142
+ }
1143
+ };
1144
+ dispatchers.onScheduleFiberRoot = dispatchScheduleFiberRoot;
1145
+ rdtHook.onScheduleFiberRoot = dispatchScheduleFiberRoot;
1146
+ }
1147
+ };
1148
+
1030
1149
  /**
1031
- * Instruments the DevTools hook.
1150
+ * Instruments the DevTools hook. Each hook event is patched once and
1151
+ * dispatches to a set of listeners, so multiple `instrument` calls compose
1152
+ * without stacking patches. Returns an unsubscribe function that removes
1153
+ * exactly the handlers this call registered.
1154
+ * The returned function is also a `Disposable`, so it composes with other
1155
+ * bippy subscriptions through `using`.
1032
1156
  * @example
1033
- * const hook = instrument({
1157
+ * const unsubscribe = instrument({
1034
1158
  * onActive() {
1035
1159
  * console.log('initialized');
1036
1160
  * },
@@ -1038,51 +1162,45 @@ export interface InstrumentationOptions {
1038
1162
  * console.log('fiberRoot', root.current)
1039
1163
  * },
1040
1164
  * });
1165
+ * unsubscribe();
1041
1166
  */
1042
- export const instrument = (options: InstrumentationOptions): ReactDevToolsGlobalHook => {
1167
+ export const instrument = (options: InstrumentationOptions): Unsubscribe => {
1043
1168
  const rdtHook = getRDTHook(options.onActive);
1044
1169
 
1045
1170
  rdtHook._instrumentationSource = options.name ?? BIPPY_INSTRUMENTATION_STRING;
1046
1171
 
1047
- const prevOnCommitFiberRoot = rdtHook.onCommitFiberRoot;
1048
- if (options.onCommitFiberRoot) {
1049
- const nextOnCommitFiberRoot = (
1050
- rendererID: number,
1051
- root: FiberRoot,
1052
- priority: number | void,
1053
- ) => {
1054
- if (prevOnCommitFiberRoot === nextOnCommitFiberRoot) return;
1055
- // TODO: validate whether the bottom version is more correct here
1056
- // for preventing infinite loops
1057
- // if (rdtHook.onCommitFiberRoot !== handler) return;
1058
- prevOnCommitFiberRoot?.(rendererID, root, priority);
1059
- options.onCommitFiberRoot?.(rendererID, root, priority);
1060
- };
1061
- rdtHook.onCommitFiberRoot = nextOnCommitFiberRoot;
1062
- }
1063
-
1064
- const prevOnCommitFiberUnmount = rdtHook.onCommitFiberUnmount;
1065
- if (options.onCommitFiberUnmount) {
1066
- const handler = (rendererID: number, root: FiberRoot) => {
1067
- if (rdtHook.onCommitFiberUnmount !== handler) return;
1068
- prevOnCommitFiberUnmount?.(rendererID, root);
1069
- options.onCommitFiberUnmount?.(rendererID, root);
1070
- };
1071
- rdtHook.onCommitFiberUnmount = handler;
1072
- }
1172
+ ensureHookDispatchesToListeners(rdtHook);
1173
+
1174
+ const {
1175
+ onActive,
1176
+ onCommitFiberRoot,
1177
+ onCommitFiberUnmount,
1178
+ onPostCommitFiberRoot,
1179
+ onScheduleFiberRoot,
1180
+ } = options;
1181
+ if (onCommitFiberRoot) commitFiberRootListeners.add(onCommitFiberRoot);
1182
+ if (onCommitFiberUnmount) commitFiberUnmountListeners.add(onCommitFiberUnmount);
1183
+ if (onPostCommitFiberRoot) postCommitFiberRootListeners.add(onPostCommitFiberRoot);
1184
+ if (onScheduleFiberRoot) scheduleFiberRootListeners.add(onScheduleFiberRoot);
1185
+
1186
+ return toUnsubscribe(() => {
1187
+ if (onActive) _onActiveListeners.delete(onActive);
1188
+ if (onCommitFiberRoot) commitFiberRootListeners.delete(onCommitFiberRoot);
1189
+ if (onCommitFiberUnmount) commitFiberUnmountListeners.delete(onCommitFiberUnmount);
1190
+ if (onPostCommitFiberRoot) postCommitFiberRootListeners.delete(onPostCommitFiberRoot);
1191
+ if (onScheduleFiberRoot) scheduleFiberRootListeners.delete(onScheduleFiberRoot);
1192
+ });
1193
+ };
1073
1194
 
1074
- const prevOnPostCommitFiberRoot = rdtHook.onPostCommitFiberRoot;
1075
- if (options.onPostCommitFiberRoot) {
1076
- const handler = (rendererID: number, root: FiberRoot) => {
1077
- if (rdtHook.onPostCommitFiberRoot !== handler) return;
1078
- prevOnPostCommitFiberRoot?.(rendererID, root);
1079
- options.onPostCommitFiberRoot?.(rendererID, root);
1080
- };
1081
- rdtHook.onPostCommitFiberRoot = handler;
1082
- }
1195
+ // React stamps fibers under per-renderer random-suffix keys (`__reactFiber$<suffix>`);
1196
+ // caching discovered keys makes repeat lookups a single property read instead of a
1197
+ // scan over every own key of the element.
1198
+ const knownFiberPropertyKeys = new Set<string>();
1083
1199
 
1084
- return rdtHook;
1085
- };
1200
+ const isFiberPropertyKey = (key: string): boolean =>
1201
+ key.startsWith("__reactContainer$") ||
1202
+ key.startsWith("__reactInternalInstance$") ||
1203
+ key.startsWith("__reactFiber");
1086
1204
 
1087
1205
  export const getFiberFromHostInstance = <T>(hostInstance: T): Fiber | null => {
1088
1206
  const rdtHook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
@@ -1101,174 +1219,34 @@ export const getFiberFromHostInstance = <T>(hostInstance: T): Fiber | null => {
1101
1219
  return (hostInstance._reactRootContainer as any)?._internalRoot?.current?.child;
1102
1220
  }
1103
1221
 
1104
- for (const key in hostInstance) {
1105
- if (
1106
- key.startsWith("__reactContainer$") ||
1107
- key.startsWith("__reactInternalInstance$") ||
1108
- key.startsWith("__reactFiber")
1109
- ) {
1110
- return (hostInstance[key] || null) as Fiber | null;
1111
- }
1112
- }
1113
- }
1114
- return null;
1115
- };
1116
-
1117
- export const INSTALL_ERROR = new Error();
1118
-
1119
- export const _fiberRoots = new Set<FiberRoot>();
1120
-
1121
- export const secure = (
1122
- options: InstrumentationOptions,
1123
- secureOptions: {
1124
- dangerouslyRunInProduction?: boolean;
1125
- installCheckTimeout?: number;
1126
- isProduction?: boolean;
1127
- minReactMajorVersion?: number;
1128
- onError?: (error?: unknown) => unknown;
1129
- } = {},
1130
- ): InstrumentationOptions => {
1131
- const onActive = options.onActive;
1132
- const isRDTHookInstalled = hasRDTHook();
1133
- const isUsingRealReactDevtools = isRealReactDevtools();
1134
- const isUsingReactRefresh = isReactRefresh();
1135
- let timeout: number | undefined;
1136
- let isDevelopment = !secureOptions.isProduction;
1137
-
1138
- options.onActive = () => {
1139
- clearTimeout(timeout);
1140
- let isSecure = true;
1141
- try {
1142
- const rdtHook = getRDTHook();
1143
-
1144
- for (const renderer of rdtHook.renderers.values()) {
1145
- const [majorVersion] = renderer.version.split(".");
1146
- if (Number(majorVersion) < (secureOptions.minReactMajorVersion ?? 17)) {
1147
- isSecure = false;
1148
- }
1149
- const buildType = detectReactBuildType(renderer);
1150
- if (buildType === "development") {
1151
- isDevelopment = true;
1152
- } else if (!secureOptions.dangerouslyRunInProduction) {
1153
- isSecure = false;
1154
- }
1155
- }
1156
- } catch (err) {
1157
- secureOptions.onError?.(err);
1222
+ // React Native Fabric public instances (ReactNativeElement /
1223
+ // ReactFabricHostComponent) store their fiber as the "internal instance
1224
+ // handle" instead of a __reactFiber$-prefixed key
1225
+ const fabricInstanceHandle =
1226
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1227
+ (hostInstance as any).__internalInstanceHandle ??
1228
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1229
+ (hostInstance as any)._internalInstanceHandle;
1230
+ if (isFiber(fabricInstanceHandle)) {
1231
+ return fabricInstanceHandle;
1158
1232
  }
1159
1233
 
1160
- if (!isSecure) {
1161
- options.onCommitFiberRoot = undefined;
1162
- options.onCommitFiberUnmount = undefined;
1163
- options.onPostCommitFiberRoot = undefined;
1164
- options.onActive = undefined;
1165
- return;
1234
+ const hostInstanceRecord = hostInstance as Record<string, unknown>;
1235
+ for (const knownKey of knownFiberPropertyKeys) {
1236
+ const fiber = hostInstanceRecord[knownKey];
1237
+ if (fiber) return fiber as Fiber;
1166
1238
  }
1167
- onActive?.();
1168
1239
 
1169
- try {
1170
- const onCommitFiberRoot = options.onCommitFiberRoot;
1171
- if (onCommitFiberRoot) {
1172
- options.onCommitFiberRoot = (rendererID, root, priority) => {
1173
- if (!_fiberRoots.has(root)) {
1174
- _fiberRoots.add(root);
1175
- }
1176
- try {
1177
- onCommitFiberRoot(rendererID, root, priority);
1178
- } catch (err) {
1179
- secureOptions.onError?.(err);
1180
- }
1181
- };
1182
- }
1183
-
1184
- const onCommitFiberUnmount = options.onCommitFiberUnmount;
1185
- if (onCommitFiberUnmount) {
1186
- options.onCommitFiberUnmount = (rendererID, root) => {
1187
- try {
1188
- onCommitFiberUnmount(rendererID, root);
1189
- } catch (err) {
1190
- secureOptions.onError?.(err);
1191
- }
1192
- };
1240
+ for (const key of Object.keys(hostInstanceRecord)) {
1241
+ if (isFiberPropertyKey(key)) {
1242
+ knownFiberPropertyKeys.add(key);
1243
+ return (hostInstanceRecord[key] || null) as Fiber | null;
1193
1244
  }
1194
-
1195
- const onPostCommitFiberRoot = options.onPostCommitFiberRoot;
1196
- if (onPostCommitFiberRoot) {
1197
- options.onPostCommitFiberRoot = (rendererID, root) => {
1198
- try {
1199
- onPostCommitFiberRoot(rendererID, root);
1200
- } catch (err) {
1201
- secureOptions.onError?.(err);
1202
- }
1203
- };
1204
- }
1205
- } catch (err) {
1206
- secureOptions.onError?.(err);
1207
1245
  }
1208
- };
1209
-
1210
- if (!isRDTHookInstalled && !isUsingRealReactDevtools && !isUsingReactRefresh) {
1211
- timeout = setTimeout(() => {
1212
- if (isDevelopment) {
1213
- secureOptions.onError?.(INSTALL_ERROR);
1214
- }
1215
- stop();
1216
- }, secureOptions.installCheckTimeout ?? 100) as unknown as number;
1217
1246
  }
1218
-
1219
- return options;
1220
- };
1221
-
1222
- const swapFiberAndSchedule = (
1223
- fiber: Fiber,
1224
- nextType: React.ComponentType<unknown>,
1225
- renderer: ReactRenderer,
1226
- ): void => {
1227
- fiber.type = nextType;
1228
- if (fiber.alternate) {
1229
- fiber.alternate.type = nextType;
1230
- }
1231
- // HACK: shallow-clone memoizedProps so React sees oldProps !== newProps
1232
- // and skips the bailout path (same trick as overrideHookState in ReactFiberReconciler)
1233
- fiber.memoizedProps = { ...fiber.memoizedProps };
1234
-
1235
- if (renderer.scheduleUpdate) {
1236
- renderer.scheduleUpdate(fiber);
1237
- }
1238
- };
1239
-
1240
- /**
1241
- * Replaces every fiber whose type matches `prevType` with `nextType` and
1242
- * triggers a synchronous re-render for each one.
1243
- * The new function must follow the same Rules of Hooks as the original.
1244
- * DEV-only — `renderer.scheduleUpdate` is not available in production builds.
1245
- */
1246
- export const hotSwapFiberType = (fiber: Fiber, nextType: React.ComponentType<unknown>): void => {
1247
- const rdtHook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
1248
- if (!rdtHook?.renderers) return;
1249
-
1250
- const renderer = Array.from(rdtHook.renderers.values()).find(
1251
- (innerRenderer) => !!innerRenderer.scheduleUpdate,
1252
- );
1253
- if (!renderer) return;
1254
-
1255
- const prevType = getType(fiber.type);
1256
- if (!prevType) {
1257
- swapFiberAndSchedule(fiber, nextType, renderer);
1258
- return;
1259
- }
1260
-
1261
- let rootFiber: Fiber = fiber;
1262
- while (rootFiber.return) {
1263
- rootFiber = rootFiber.return;
1264
- }
1265
-
1266
- traverseFiberSync(rootFiber, (innerFiber) => {
1267
- if (getType(innerFiber.type) === prevType) {
1268
- swapFiberAndSchedule(innerFiber, nextType, renderer);
1269
- }
1270
- });
1247
+ return null;
1271
1248
  };
1272
1249
 
1273
1250
  export * from "./rdt-hook.js";
1251
+ export * from "./unsubscribe.js";
1274
1252
  export type * from "./types.js";