fix(mobile): resolve user avatars, chat alignment, timestamp formatting, and payslips API
This commit is contained in:
1261
node_modules/bippy/src/core.ts
generated
vendored
Normal file
1261
node_modules/bippy/src/core.ts
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3
node_modules/bippy/src/index.ts
generated
vendored
Normal file
3
node_modules/bippy/src/index.ts
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import "./install-hook-only.js";
|
||||
|
||||
export * from "./core.js";
|
||||
3
node_modules/bippy/src/install-hook-only.ts
generated
vendored
Normal file
3
node_modules/bippy/src/install-hook-only.ts
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { safelyInstallRDTHook } from "./rdt-hook.js";
|
||||
|
||||
safelyInstallRDTHook();
|
||||
270
node_modules/bippy/src/rdt-hook.ts
generated
vendored
Normal file
270
node_modules/bippy/src/rdt-hook.ts
generated
vendored
Normal file
@@ -0,0 +1,270 @@
|
||||
// IMPORTANT:
|
||||
// this file is super important to load the __REACT_DEVTOOLS_GLOBAL_HOOK__ object
|
||||
// without this, we can't stub the React DevTools global hook, we don't have a way to instrument the application
|
||||
// make sure you import this file first before anything else (particularly React)
|
||||
|
||||
import type { ReactDevToolsGlobalHook, ReactRenderer } from "./types.js";
|
||||
import { toUnsubscribe, type Unsubscribe } from "./unsubscribe.js";
|
||||
|
||||
export const version = process.env.VERSION;
|
||||
export const BIPPY_INSTRUMENTATION_STRING = `bippy-${version}`;
|
||||
|
||||
const objectDefineProperty = Object.defineProperty;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const objectHasOwnProperty = Object.prototype.hasOwnProperty;
|
||||
|
||||
const NO_OP = () => {
|
||||
/**/
|
||||
};
|
||||
|
||||
const checkDCE = (functionToCheck: unknown): void => {
|
||||
try {
|
||||
const code = Function.prototype.toString.call(functionToCheck);
|
||||
if (code.indexOf("^_^") > -1) {
|
||||
setTimeout(() => {
|
||||
throw new Error(
|
||||
"React is running in production mode, but dead code " +
|
||||
"elimination has not been applied. Read how to correctly " +
|
||||
"configure React for production: " +
|
||||
"https://reactjs.org/link/perf-use-production-build",
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const isRealReactDevtools = (
|
||||
rdtHook: ReactDevToolsGlobalHook | undefined | null = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__,
|
||||
): boolean => {
|
||||
return Boolean(rdtHook && "getFiberRoots" in rdtHook);
|
||||
};
|
||||
|
||||
let isReactRefreshOverride = false;
|
||||
let injectFnStr: string | undefined = undefined;
|
||||
|
||||
export const isReactRefresh = (
|
||||
rdtHook: ReactDevToolsGlobalHook | undefined | null = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__,
|
||||
): boolean => {
|
||||
if (isReactRefreshOverride) return true;
|
||||
if (rdtHook && typeof rdtHook.inject === "function") {
|
||||
injectFnStr = rdtHook.inject.toString();
|
||||
}
|
||||
// https://github.com/facebook/react/blob/8f8b336734d7c807f5aa11b0f31540e63302d789/packages/react-refresh/src/ReactFreshRuntime.js#L459
|
||||
return Boolean(injectFnStr?.includes("(injected)"));
|
||||
};
|
||||
|
||||
export const _onActiveListeners = new Set<() => unknown>();
|
||||
|
||||
export const _renderers = new Set<ReactRenderer>();
|
||||
|
||||
const rendererInjectListeners = new Set<(renderer: ReactRenderer) => void>();
|
||||
// re-wrapping inject (e.g. after the hook is replaced) leaves the old
|
||||
// wrapper in the call chain, so notifications are deduped per renderer
|
||||
const notifiedRenderers = new WeakSet<ReactRenderer>();
|
||||
let notifyingInject: ReactDevToolsGlobalHook["inject"] | null = null;
|
||||
|
||||
const ensureInjectNotifiesListeners = (rdtHook: ReactDevToolsGlobalHook): void => {
|
||||
if (rdtHook.inject === notifyingInject) return;
|
||||
const prevInject = rdtHook.inject;
|
||||
const nextInject = (renderer: ReactRenderer) => {
|
||||
const rendererId = prevInject.call(rdtHook, renderer);
|
||||
if (!notifiedRenderers.has(renderer)) {
|
||||
notifiedRenderers.add(renderer);
|
||||
for (const listener of rendererInjectListeners) {
|
||||
listener(renderer);
|
||||
}
|
||||
}
|
||||
return rendererId;
|
||||
};
|
||||
rdtHook.inject = nextInject;
|
||||
notifyingInject = nextInject;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribes to future renderer injections into the DevTools hook. The
|
||||
* single shared inject wrapper lets multiple consumers (override methods,
|
||||
* react-refresh, user code) observe renderers without stacking patches
|
||||
* whose restore order matters. Returns an unsubscribe function.
|
||||
*/
|
||||
export const onRendererInject = (listener: (renderer: ReactRenderer) => void): Unsubscribe => {
|
||||
ensureInjectNotifiesListeners(getRDTHook());
|
||||
rendererInjectListeners.add(listener);
|
||||
return toUnsubscribe(() => {
|
||||
rendererInjectListeners.delete(listener);
|
||||
});
|
||||
};
|
||||
|
||||
export const installRDTHook = (onActive?: () => unknown): ReactDevToolsGlobalHook => {
|
||||
if (onActive) {
|
||||
_onActiveListeners.add(onActive);
|
||||
}
|
||||
const renderers = new Map<number, ReactRenderer>();
|
||||
let rendererIdCounter = 0;
|
||||
let rdtHook: ReactDevToolsGlobalHook = {
|
||||
_instrumentationIsActive: false,
|
||||
_instrumentationSource: BIPPY_INSTRUMENTATION_STRING,
|
||||
checkDCE,
|
||||
hasUnsupportedRendererAttached: false,
|
||||
inject(renderer) {
|
||||
const nextRendererId = ++rendererIdCounter;
|
||||
renderers.set(nextRendererId, renderer);
|
||||
_renderers.add(renderer);
|
||||
if (!rdtHook._instrumentationIsActive) {
|
||||
rdtHook._instrumentationIsActive = true;
|
||||
_onActiveListeners.forEach((listener) => listener());
|
||||
}
|
||||
return nextRendererId;
|
||||
},
|
||||
on: NO_OP,
|
||||
onCommitFiberRoot: NO_OP,
|
||||
onCommitFiberUnmount: NO_OP,
|
||||
onPostCommitFiberRoot: NO_OP,
|
||||
renderers,
|
||||
supportsFiber: true,
|
||||
supportsFlight: true,
|
||||
};
|
||||
try {
|
||||
objectDefineProperty(globalThis, "__REACT_DEVTOOLS_GLOBAL_HOOK__", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
return rdtHook;
|
||||
},
|
||||
set(newHook) {
|
||||
if (newHook && typeof newHook === "object") {
|
||||
const ourRenderers = rdtHook.renderers;
|
||||
rdtHook = newHook;
|
||||
if (ourRenderers.size > 0) {
|
||||
ourRenderers.forEach((renderer, id) => {
|
||||
_renderers.add(renderer);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
newHook.renderers.set(id, renderer);
|
||||
});
|
||||
patchRDTHook(onActive);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
// [!] this is a hack for chrome extensions - if we install before React DevTools, we could accidently prevent React DevTools from installing:
|
||||
// https://github.com/facebook/react/blob/18eaf51bd51fed8dfed661d64c306759101d0bfd/packages/react-devtools-extensions/src/contentScripts/installHook.js#L30C6-L30C27
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const originalWindowHasOwnProperty = window.hasOwnProperty;
|
||||
let hasRanHack = false;
|
||||
objectDefineProperty(window, "hasOwnProperty", {
|
||||
configurable: true,
|
||||
value: function (this: unknown, ...args: [PropertyKey]) {
|
||||
try {
|
||||
if (!hasRanHack && args[0] === "__REACT_DEVTOOLS_GLOBAL_HOOK__") {
|
||||
globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = undefined;
|
||||
// special falsy value to know that we've already installed before
|
||||
hasRanHack = true;
|
||||
return -0;
|
||||
}
|
||||
} catch {}
|
||||
return originalWindowHasOwnProperty.apply(this, args);
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
} catch {
|
||||
patchRDTHook(onActive);
|
||||
}
|
||||
return rdtHook;
|
||||
};
|
||||
|
||||
export const patchRDTHook = (onActive?: () => unknown): void => {
|
||||
if (onActive) {
|
||||
_onActiveListeners.add(onActive);
|
||||
}
|
||||
try {
|
||||
const rdtHook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
if (!rdtHook) return;
|
||||
if (!rdtHook._instrumentationSource) {
|
||||
rdtHook.checkDCE = checkDCE;
|
||||
rdtHook.supportsFiber = true;
|
||||
rdtHook.supportsFlight = true;
|
||||
rdtHook.hasUnsupportedRendererAttached = false;
|
||||
rdtHook._instrumentationSource = BIPPY_INSTRUMENTATION_STRING;
|
||||
rdtHook._instrumentationIsActive = false;
|
||||
// we need to be careful here (needs to be below _instrumentationSource) else it causes excessive recursion
|
||||
const isReactDevtools = isRealReactDevtools(rdtHook);
|
||||
if (!isReactDevtools) {
|
||||
rdtHook.on = NO_OP;
|
||||
}
|
||||
if (rdtHook.renderers.size) {
|
||||
rdtHook._instrumentationIsActive = true;
|
||||
_onActiveListeners.forEach((listener) => listener());
|
||||
return;
|
||||
}
|
||||
const prevInject = rdtHook.inject;
|
||||
const isRefresh = isReactRefresh(rdtHook);
|
||||
if (isRefresh && !isReactDevtools) {
|
||||
isReactRefreshOverride = true;
|
||||
// but since the underlying implementation doens't care,
|
||||
// it's ok: https://github.com/facebook/react/blob/18eaf51bd51fed8dfed661d64c306759101d0bfd/packages/react-refresh/src/ReactFreshRuntime.js#L430
|
||||
const injectedRendererId = rdtHook.inject({
|
||||
scheduleRefresh() {},
|
||||
} as unknown as ReactRenderer);
|
||||
if (injectedRendererId) {
|
||||
rdtHook._instrumentationIsActive = true;
|
||||
}
|
||||
}
|
||||
rdtHook.inject = (renderer) => {
|
||||
const rendererId = prevInject(renderer);
|
||||
_renderers.add(renderer);
|
||||
if (isRefresh) {
|
||||
// react refresh doesn't inject this properly
|
||||
// https://github.com/facebook/react/blob/18eaf51bd51fed8dfed661d64c306759101d0bfd/packages/react-refresh/src/ReactFreshRuntime.js#L430
|
||||
rdtHook.renderers.set(rendererId, renderer);
|
||||
}
|
||||
rdtHook._instrumentationIsActive = true;
|
||||
_onActiveListeners.forEach((listener) => listener());
|
||||
return rendererId;
|
||||
};
|
||||
}
|
||||
if (
|
||||
rdtHook.renderers.size ||
|
||||
rdtHook._instrumentationIsActive ||
|
||||
// depending on this to inject is unsafe, since inject could occur before and we wouldn't know
|
||||
isReactRefresh()
|
||||
) {
|
||||
onActive?.();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const hasRDTHook = (): boolean => {
|
||||
return objectHasOwnProperty.call(globalThis, "__REACT_DEVTOOLS_GLOBAL_HOOK__");
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the current React DevTools global hook.
|
||||
*/
|
||||
export const getRDTHook = (onActive?: () => unknown): ReactDevToolsGlobalHook => {
|
||||
if (!hasRDTHook()) {
|
||||
return installRDTHook(onActive);
|
||||
}
|
||||
|
||||
patchRDTHook(onActive);
|
||||
// must exist at this point
|
||||
return globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ as ReactDevToolsGlobalHook;
|
||||
};
|
||||
|
||||
export const isClientEnvironment = (): boolean => {
|
||||
return Boolean(
|
||||
typeof window !== "undefined" &&
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
(window.document?.createElement || window.navigator?.product === "ReactNative"),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Usually used purely for side effect
|
||||
*/
|
||||
export const safelyInstallRDTHook = () => {
|
||||
try {
|
||||
// __REACT_DEVTOOLS_GLOBAL_HOOK__ must exist before React is ever executed
|
||||
if (isClientEnvironment()) {
|
||||
getRDTHook();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
9
node_modules/bippy/src/react-refresh/constants.ts
generated
vendored
Normal file
9
node_modules/bippy/src/react-refresh/constants.ts
generated
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
export const HMR_RECONNECT_DELAY_MS = 1000;
|
||||
|
||||
export const PENDING_HOT_UPDATE_MAX_AGE_MS = 10_000;
|
||||
|
||||
export const VITE_WS_TOKEN_REGEX = /wsToken = "([^"]+)"/;
|
||||
|
||||
export const HMR_SOURCE_FILE_EXTENSION_REGEX = /\.(?:tsx|ts|jsx|js|mjs|cjs|css)$/;
|
||||
|
||||
export const BUNDLER_LAYER_PREFIX_REGEX = /^(?:\.\/)?\/?\([a-z][a-z0-9-]*\)\//;
|
||||
33
node_modules/bippy/src/react-refresh/detect-hmr-transport.ts
generated
vendored
Normal file
33
node_modules/bippy/src/react-refresh/detect-hmr-transport.ts
generated
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
import { isClientEnvironment } from "../rdt-hook.js";
|
||||
|
||||
import { createMetroHmrTransport } from "./metro-hmr-transport.js";
|
||||
import { createNextWebpackHmrTransport } from "./next-webpack-hmr-transport.js";
|
||||
import { HmrTransport, HmrUpdateHandler } from "./types.js";
|
||||
import { createViteHmrTransport } from "./vite-hmr-transport.js";
|
||||
|
||||
/**
|
||||
* Detects the dev server's HMR transport (Next.js webpack, then Metro for
|
||||
* React Native, then Vite) and subscribes `onHmrUpdate` to hot updates.
|
||||
* Resolves `null` on the server (SSR) and when no known transport is
|
||||
* available (production builds, unsupported bundlers — Turbopack exposes
|
||||
* `window.TURBOPACK_CHUNK_UPDATE_LISTENERS` but its update payload shape
|
||||
* has not been validated, so it is not wired up yet).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const transport = await detectHmrTransport((filePaths) => {
|
||||
* console.log("hot updated:", filePaths);
|
||||
* });
|
||||
* transport?.dispose();
|
||||
* ```
|
||||
*/
|
||||
export const detectHmrTransport = async (
|
||||
onHmrUpdate: HmrUpdateHandler,
|
||||
): Promise<HmrTransport | null> => {
|
||||
if (!isClientEnvironment()) return null;
|
||||
const webpackTransport = createNextWebpackHmrTransport(onHmrUpdate);
|
||||
if (webpackTransport) return webpackTransport;
|
||||
const metroTransport = createMetroHmrTransport(onHmrUpdate);
|
||||
if (metroTransport) return metroTransport;
|
||||
return createViteHmrTransport(onHmrUpdate);
|
||||
};
|
||||
173
node_modules/bippy/src/react-refresh/index.ts
generated
vendored
Normal file
173
node_modules/bippy/src/react-refresh/index.ts
generated
vendored
Normal file
@@ -0,0 +1,173 @@
|
||||
import { getType, traverseFiber } from "../core.js";
|
||||
import { getRDTHook, isClientEnvironment, onRendererInject } from "../rdt-hook.js";
|
||||
import type { Fiber, FiberRoot, ReactRenderer } from "../types.js";
|
||||
import { toUnsubscribe, type Unsubscribe } from "../unsubscribe.js";
|
||||
import { PENDING_HOT_UPDATE_MAX_AGE_MS } from "./constants.js";
|
||||
import { detectHmrTransport } from "./detect-hmr-transport.js";
|
||||
import { HmrTransport } from "./types.js";
|
||||
|
||||
export interface ReactRefreshUpdate {
|
||||
/**
|
||||
* hot-updated source file paths reported by the bundler's HMR transport
|
||||
* (auto-detected: Next.js webpack, Metro, Vite). Best-effort: empty when
|
||||
* the bundler does not expose a transport (e.g. Turbopack) or when the
|
||||
* transport message has not arrived yet (e.g. Metro delivers updates on
|
||||
* an independent socket).
|
||||
*/
|
||||
filePaths: string[];
|
||||
root: FiberRoot;
|
||||
/** new component types that were remounted, losing state */
|
||||
staleComponents: unknown[];
|
||||
/** mounted fibers whose component types were remounted */
|
||||
staleFibers: Fiber[];
|
||||
/** new component types that re-rendered preserving state */
|
||||
updatedComponents: unknown[];
|
||||
/** mounted fibers whose component types re-rendered preserving state */
|
||||
updatedFibers: Fiber[];
|
||||
}
|
||||
|
||||
export interface ReactRefreshHandler {
|
||||
(update: ReactRefreshUpdate): void;
|
||||
}
|
||||
|
||||
export interface ReactRefreshInstrumentationOptions {
|
||||
onRefresh?: ReactRefreshHandler;
|
||||
}
|
||||
|
||||
const collectFibersByComponentType = (root: FiberRoot, componentTypes: Set<unknown>): Fiber[] => {
|
||||
if (componentTypes.size === 0 || !root.current) return [];
|
||||
const matchedFibers: Fiber[] = [];
|
||||
traverseFiber(root.current, (fiber) => {
|
||||
// memo/forwardRef fibers carry the wrapper as fiber.type, while the
|
||||
// refresh families can register either the wrapper or the inner type
|
||||
if (componentTypes.has(fiber.type) || componentTypes.has(getType(fiber.type))) {
|
||||
matchedFibers.push(fiber);
|
||||
}
|
||||
});
|
||||
return matchedFibers;
|
||||
};
|
||||
|
||||
const refreshHandlers = new Set<ReactRefreshHandler>();
|
||||
const refreshWrappedRenderers = new WeakSet<ReactRenderer>();
|
||||
|
||||
let pendingFilePaths: string[] = [];
|
||||
let pendingFilePathsReceivedAtMs = 0;
|
||||
|
||||
const bufferHotUpdateFilePaths = (filePaths: string[]): void => {
|
||||
const nowMs = Date.now();
|
||||
if (nowMs - pendingFilePathsReceivedAtMs > PENDING_HOT_UPDATE_MAX_AGE_MS) {
|
||||
pendingFilePaths = [];
|
||||
}
|
||||
pendingFilePaths.push(...filePaths);
|
||||
pendingFilePathsReceivedAtMs = nowMs;
|
||||
};
|
||||
|
||||
const takeFreshFilePaths = (): string[] => {
|
||||
if (Date.now() - pendingFilePathsReceivedAtMs > PENDING_HOT_UPDATE_MAX_AGE_MS) return [];
|
||||
const freshFilePaths = [...pendingFilePaths];
|
||||
// performReactRefresh calls scheduleRefresh synchronously once per
|
||||
// mounted root, so clear the pending paths only after the whole
|
||||
// refresh pass instead of on the first root
|
||||
queueMicrotask(() => {
|
||||
pendingFilePaths = [];
|
||||
});
|
||||
return freshFilePaths;
|
||||
};
|
||||
|
||||
const wrapRendererScheduleRefresh = (renderer: ReactRenderer): void => {
|
||||
if (refreshWrappedRenderers.has(renderer)) return;
|
||||
const originalScheduleRefresh = renderer.scheduleRefresh;
|
||||
if (typeof originalScheduleRefresh !== "function") return;
|
||||
refreshWrappedRenderers.add(renderer);
|
||||
renderer.scheduleRefresh = (root, update) => {
|
||||
originalScheduleRefresh.call(renderer, root, update);
|
||||
if (refreshHandlers.size === 0) return;
|
||||
const staleComponents = Array.from(update.staleFamilies, (family) => family.current);
|
||||
const updatedComponents = Array.from(update.updatedFamilies, (family) => family.current);
|
||||
const refreshUpdate: ReactRefreshUpdate = {
|
||||
filePaths: takeFreshFilePaths(),
|
||||
root,
|
||||
staleComponents,
|
||||
staleFibers: collectFibersByComponentType(root, new Set(staleComponents)),
|
||||
updatedComponents,
|
||||
updatedFibers: collectFibersByComponentType(root, new Set(updatedComponents)),
|
||||
};
|
||||
for (const handler of refreshHandlers) {
|
||||
handler(refreshUpdate);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
let isRefreshWired = false;
|
||||
|
||||
const ensureRefreshWired = (): void => {
|
||||
if (isRefreshWired) return;
|
||||
isRefreshWired = true;
|
||||
const rdtHook = getRDTHook();
|
||||
for (const renderer of rdtHook.renderers.values()) {
|
||||
wrapRendererScheduleRefresh(renderer);
|
||||
}
|
||||
onRendererInject(wrapRendererScheduleRefresh);
|
||||
};
|
||||
|
||||
let activeTransport: HmrTransport | null = null;
|
||||
|
||||
// the bundler's HMR global may not exist yet when an early subscriber
|
||||
// arrives, so detection is retried whenever the first subscriber (re)appears
|
||||
const detectTransportForNewSubscriber = (): void => {
|
||||
void detectHmrTransport(bufferHotUpdateFilePaths).then((detectedTransport) => {
|
||||
if (!detectedTransport) return;
|
||||
activeTransport?.dispose();
|
||||
activeTransport = detectedTransport;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribes to react-refresh (fast refresh) updates by wrapping
|
||||
* `scheduleRefresh` on every renderer injected into the React DevTools
|
||||
* global hook. The react-refresh runtime calls `scheduleRefresh` after each
|
||||
* hot update, so this works with any bundler that uses react-refresh (Vite,
|
||||
* Next.js webpack, Next.js Turbopack, Metro) without bundler-specific code.
|
||||
* Returns an unsubscribe function (a no-op in non-client environments like
|
||||
* SSR, so callers never need an environment check). The returned function
|
||||
* is also a `Disposable`, so it composes with other bippy subscriptions
|
||||
* through `using`.
|
||||
*
|
||||
* The bundler's HMR transport is auto-detected and each refresh update is
|
||||
* augmented with the hot-updated source file paths it reported.
|
||||
*
|
||||
* The handler runs after React has re-rendered with the new component
|
||||
* types, so the refreshed root's fiber tree already carries them;
|
||||
* `updatedFibers`/`staleFibers` are the mounted fibers matching the
|
||||
* hot-swapped component types.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const unsubscribe = instrumentReactRefresh({
|
||||
* onRefresh(update) {
|
||||
* for (const fiber of update.updatedFibers) {
|
||||
* console.log("hot updated:", getDisplayName(fiber.type));
|
||||
* }
|
||||
* console.log("changed files:", update.filePaths);
|
||||
* },
|
||||
* });
|
||||
* unsubscribe();
|
||||
* ```
|
||||
*
|
||||
* Pair with `getSource(fiber)` from `bippy/source` to symbolicate the
|
||||
* source locations of `updatedFibers` when needed.
|
||||
*/
|
||||
export const instrumentReactRefresh = (
|
||||
options: ReactRefreshInstrumentationOptions,
|
||||
): Unsubscribe => {
|
||||
const { onRefresh } = options;
|
||||
if (!onRefresh || !isClientEnvironment()) return toUnsubscribe(() => {});
|
||||
ensureRefreshWired();
|
||||
if (refreshHandlers.size === 0) {
|
||||
detectTransportForNewSubscriber();
|
||||
}
|
||||
refreshHandlers.add(onRefresh);
|
||||
return toUnsubscribe(() => {
|
||||
refreshHandlers.delete(onRefresh);
|
||||
});
|
||||
};
|
||||
188
node_modules/bippy/src/react-refresh/metro-hmr-transport.ts
generated
vendored
Normal file
188
node_modules/bippy/src/react-refresh/metro-hmr-transport.ts
generated
vendored
Normal file
@@ -0,0 +1,188 @@
|
||||
import { HMR_RECONNECT_DELAY_MS } from "./constants.js";
|
||||
import { HmrTransport, HmrUpdateHandler } from "./types.js";
|
||||
|
||||
declare global {
|
||||
var __turboModuleProxy: ((moduleName: string) => unknown) | undefined;
|
||||
var nativeModuleProxy: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
const getScriptUrlFromSourceCodeModule = (sourceCodeModule: unknown): string | null => {
|
||||
if (typeof sourceCodeModule !== "object" || sourceCodeModule === null) return null;
|
||||
if (!("getConstants" in sourceCodeModule)) return null;
|
||||
const getConstants = sourceCodeModule.getConstants;
|
||||
if (typeof getConstants !== "function") return null;
|
||||
let constants: unknown;
|
||||
try {
|
||||
constants = getConstants.call(sourceCodeModule);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof constants !== "object" || constants === null) return null;
|
||||
if (!("scriptURL" in constants)) return null;
|
||||
const scriptUrl = constants.scriptURL;
|
||||
return typeof scriptUrl === "string" ? scriptUrl : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the URL the running React Native bundle was loaded from, using
|
||||
* the same `SourceCode` native module React Native's own dev tooling reads
|
||||
* (via the TurboModule proxy globals, so react-native is not imported).
|
||||
* Returns `null` outside a Metro-served React Native runtime.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* getMetroBundleUrl();
|
||||
* // "http://localhost:8081/index.bundle?platform=ios&dev=true"
|
||||
* ```
|
||||
*/
|
||||
export const getMetroBundleUrl = (): string | null => {
|
||||
if (typeof globalThis.__turboModuleProxy === "function") {
|
||||
let sourceCodeModule: unknown;
|
||||
try {
|
||||
sourceCodeModule = globalThis.__turboModuleProxy("SourceCode");
|
||||
} catch {
|
||||
sourceCodeModule = null;
|
||||
}
|
||||
const scriptUrl = getScriptUrlFromSourceCodeModule(sourceCodeModule);
|
||||
if (scriptUrl) return scriptUrl;
|
||||
}
|
||||
const legacySourceCodeModule = globalThis.nativeModuleProxy?.SourceCode;
|
||||
return getScriptUrlFromSourceCodeModule(legacySourceCodeModule);
|
||||
};
|
||||
|
||||
// HACK: module sourceURLs may be JSC-safe URLs where "//&" stands in for
|
||||
// "?" (iOS 16.4 stack traces strip query strings), so the query separator
|
||||
// must be normalized before URL parsing.
|
||||
const normalizeJscSafeUrl = (jscSafeUrl: string): string => jscSafeUrl.replace("//&", "?");
|
||||
|
||||
const getSourcePathFromSourceUrl = (sourceUrl: string): string | null => {
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(normalizeJscSafeUrl(sourceUrl));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let sourcePath = decodeURIComponent(parsedUrl.pathname);
|
||||
if (sourcePath.startsWith("/")) sourcePath = sourcePath.slice(1);
|
||||
// Metro rewrites each module's real extension to ".bundle" when building
|
||||
// hot-update sourceURLs, so the original extension is unrecoverable.
|
||||
if (sourcePath.endsWith(".bundle")) sourcePath = sourcePath.slice(0, -".bundle".length);
|
||||
return sourcePath.length > 0 ? sourcePath : null;
|
||||
};
|
||||
|
||||
const collectModuleSourcePaths = (hmrModules: unknown, filePaths: string[]) => {
|
||||
if (!Array.isArray(hmrModules)) return;
|
||||
for (const hmrModule of hmrModules) {
|
||||
if (typeof hmrModule !== "object" || hmrModule === null) continue;
|
||||
if (!("sourceURL" in hmrModule) || typeof hmrModule.sourceURL !== "string") continue;
|
||||
const sourcePath = getSourcePathFromSourceUrl(hmrModule.sourceURL);
|
||||
if (!sourcePath || sourcePath.includes("node_modules")) continue;
|
||||
filePaths.push(sourcePath);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the updated source file paths from a raw Metro HMR WebSocket
|
||||
* message. Paths are project-relative but extension-less (`src/app`, not
|
||||
* `src/app.tsx`) because Metro rewrites module extensions to `.bundle`.
|
||||
* The initial update replayed on connect is skipped. Returns an empty
|
||||
* array for any other message shape.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* parseMetroUpdatePaths(rawMessageData);
|
||||
* // ["src/app"]
|
||||
* ```
|
||||
*/
|
||||
export const parseMetroUpdatePaths = (rawMessageData: string): string[] => {
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(rawMessageData);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (typeof message !== "object" || message === null) return [];
|
||||
if (!("type" in message) || message.type !== "update") return [];
|
||||
if (!("body" in message) || typeof message.body !== "object" || message.body === null) return [];
|
||||
const updateBody = message.body;
|
||||
if ("isInitialUpdate" in updateBody && updateBody.isInitialUpdate === true) return [];
|
||||
const filePaths: string[] = [];
|
||||
if ("added" in updateBody) collectModuleSourcePaths(updateBody.added, filePaths);
|
||||
if ("modified" in updateBody) collectModuleSourcePaths(updateBody.modified, filePaths);
|
||||
return filePaths;
|
||||
};
|
||||
|
||||
export interface MetroHmrTransportOptions {
|
||||
bundleUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the Metro dev server's `/hot` HMR WebSocket (as a second
|
||||
* client alongside React Native's own) and invokes `onHmrUpdate` with the
|
||||
* updated file paths on every hot update. Reconnects automatically when
|
||||
* the dev server restarts. Returns `null` when no Metro bundle URL can be
|
||||
* resolved (production builds, non-Metro runtimes).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const transport = createMetroHmrTransport((filePaths) => {
|
||||
* console.log("hot updated:", filePaths);
|
||||
* });
|
||||
* transport?.dispose();
|
||||
* ```
|
||||
*/
|
||||
export const createMetroHmrTransport = (
|
||||
onHmrUpdate: HmrUpdateHandler,
|
||||
options: MetroHmrTransportOptions = {},
|
||||
): HmrTransport | null => {
|
||||
if (typeof WebSocket === "undefined") return null;
|
||||
const bundleUrl = options.bundleUrl ?? getMetroBundleUrl();
|
||||
if (!bundleUrl) return null;
|
||||
|
||||
let hotSocketUrl: string;
|
||||
try {
|
||||
const parsedBundleUrl = new URL(bundleUrl);
|
||||
const socketProtocol = parsedBundleUrl.protocol === "https:" ? "wss" : "ws";
|
||||
hotSocketUrl = `${socketProtocol}://${parsedBundleUrl.host}/hot`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let isDisposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectTimerId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (isDisposed) return;
|
||||
reconnectTimerId = setTimeout(connect, HMR_RECONNECT_DELAY_MS);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (isDisposed) return;
|
||||
const connectedSocket = new WebSocket(hotSocketUrl);
|
||||
socket = connectedSocket;
|
||||
connectedSocket.onopen = () => {
|
||||
connectedSocket.send(
|
||||
JSON.stringify({ type: "register-entrypoints", entryPoints: [bundleUrl] }),
|
||||
);
|
||||
};
|
||||
connectedSocket.onmessage = (event) => {
|
||||
const filePaths = parseMetroUpdatePaths(String(event.data));
|
||||
if (filePaths.length > 0) onHmrUpdate(filePaths);
|
||||
};
|
||||
connectedSocket.onclose = scheduleReconnect;
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
isDisposed = true;
|
||||
clearTimeout(reconnectTimerId);
|
||||
if (socket) {
|
||||
socket.onclose = null;
|
||||
socket.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
72
node_modules/bippy/src/react-refresh/next-webpack-hmr-transport.ts
generated
vendored
Normal file
72
node_modules/bippy/src/react-refresh/next-webpack-hmr-transport.ts
generated
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
import { HMR_SOURCE_FILE_EXTENSION_REGEX } from "./constants.js";
|
||||
import { normalizeHmrFilePath } from "./normalize-hmr-file-path.js";
|
||||
import { HmrTransport, HmrUpdateHandler } from "./types.js";
|
||||
|
||||
interface WebpackHotUpdateGlobal {
|
||||
(chunkId: unknown, updatedModules: Record<string, unknown> | undefined, runtime: unknown): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webpackHotUpdate_N_E?: WebpackHotUpdateGlobal;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes webpack hot-update module keys into project-relative source
|
||||
* file paths, dropping node_modules entries and non-source keys (e.g.
|
||||
* webpack runtime helpers).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* normalizeWebpackModulePaths(["(app-pages-browser)/./app/page.tsx"]);
|
||||
* // ["app/page.tsx"]
|
||||
* ```
|
||||
*/
|
||||
export const normalizeWebpackModulePaths = (moduleKeys: string[]): string[] => {
|
||||
const filePaths: string[] = [];
|
||||
for (const moduleKey of moduleKeys) {
|
||||
if (moduleKey.includes("node_modules")) continue;
|
||||
const filePath = normalizeHmrFilePath(moduleKey);
|
||||
if (!HMR_SOURCE_FILE_EXTENSION_REGEX.test(filePath)) continue;
|
||||
filePaths.push(filePath);
|
||||
}
|
||||
return filePaths;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribes to Next.js webpack hot updates by wrapping the
|
||||
* `webpackHotUpdate_N_E` global and invokes `onHmrUpdate` with the updated
|
||||
* file paths. Returns `null` when the page is not a Next.js webpack dev
|
||||
* build.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const transport = createNextWebpackHmrTransport((filePaths) => {
|
||||
* console.log("hot updated:", filePaths);
|
||||
* });
|
||||
* transport?.dispose();
|
||||
* ```
|
||||
*/
|
||||
export const createNextWebpackHmrTransport = (
|
||||
onHmrUpdate: HmrUpdateHandler,
|
||||
): HmrTransport | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const originalHotUpdate = window.webpackHotUpdate_N_E;
|
||||
if (typeof originalHotUpdate !== "function") return null;
|
||||
|
||||
const wrappedHotUpdate: WebpackHotUpdateGlobal = (chunkId, updatedModules, runtime) => {
|
||||
const filePaths = normalizeWebpackModulePaths(Object.keys(updatedModules ?? {}));
|
||||
if (filePaths.length > 0) onHmrUpdate(filePaths);
|
||||
originalHotUpdate(chunkId, updatedModules, runtime);
|
||||
};
|
||||
window.webpackHotUpdate_N_E = wrappedHotUpdate;
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
if (window.webpackHotUpdate_N_E === wrappedHotUpdate) {
|
||||
window.webpackHotUpdate_N_E = originalHotUpdate;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
24
node_modules/bippy/src/react-refresh/normalize-hmr-file-path.ts
generated
vendored
Normal file
24
node_modules/bippy/src/react-refresh/normalize-hmr-file-path.ts
generated
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
import { normalizeFileName } from "../source/get-source.js";
|
||||
|
||||
import { BUNDLER_LAYER_PREFIX_REGEX } from "./constants.js";
|
||||
|
||||
/**
|
||||
* Normalizes a bundler module key or HMR update path into a plain,
|
||||
* project-relative file path. Strips URL schemes (via
|
||||
* {@link normalizeFileName}), bundler layer prefixes like
|
||||
* `(app-pages-browser)/`, and leading `./` segments.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* normalizeHmrFilePath("(app-pages-browser)/./app/page.tsx");
|
||||
* // "app/page.tsx"
|
||||
* ```
|
||||
*/
|
||||
export const normalizeHmrFilePath = (filePath: string): string => {
|
||||
let normalizedFilePath = normalizeFileName(filePath);
|
||||
normalizedFilePath = normalizedFilePath.replace(BUNDLER_LAYER_PREFIX_REGEX, "");
|
||||
if (normalizedFilePath.startsWith("./")) {
|
||||
normalizedFilePath = normalizedFilePath.slice(2);
|
||||
}
|
||||
return normalizedFilePath;
|
||||
};
|
||||
7
node_modules/bippy/src/react-refresh/types.ts
generated
vendored
Normal file
7
node_modules/bippy/src/react-refresh/types.ts
generated
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface HmrUpdateHandler {
|
||||
(filePaths: string[]): void;
|
||||
}
|
||||
|
||||
export interface HmrTransport {
|
||||
dispose: () => void;
|
||||
}
|
||||
116
node_modules/bippy/src/react-refresh/vite-hmr-transport.ts
generated
vendored
Normal file
116
node_modules/bippy/src/react-refresh/vite-hmr-transport.ts
generated
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
import { HMR_RECONNECT_DELAY_MS, VITE_WS_TOKEN_REGEX } from "./constants.js";
|
||||
import { HmrTransport, HmrUpdateHandler } from "./types.js";
|
||||
|
||||
/**
|
||||
* Extracts the accepted file paths from a raw Vite HMR WebSocket message.
|
||||
* Only `js-update` entries are kept (a `css-update` swaps a stylesheet link
|
||||
* without re-running modules). Returns an empty array for any other message
|
||||
* shape.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* parseViteUpdatePaths(rawMessageData);
|
||||
* // ["/src/app.tsx"]
|
||||
* ```
|
||||
*/
|
||||
export const parseViteUpdatePaths = (rawMessageData: string): string[] => {
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(rawMessageData);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (typeof message !== "object" || message === null) return [];
|
||||
if (!("type" in message) || message.type !== "update") return [];
|
||||
if (!("updates" in message) || !Array.isArray(message.updates)) return [];
|
||||
const filePaths: string[] = [];
|
||||
for (const update of message.updates) {
|
||||
if (typeof update !== "object" || update === null) continue;
|
||||
if (!("type" in update) || update.type !== "js-update") continue;
|
||||
if (!("acceptedPath" in update) || typeof update.acceptedPath !== "string") continue;
|
||||
filePaths.push(update.acceptedPath);
|
||||
}
|
||||
return filePaths;
|
||||
};
|
||||
|
||||
// HACK: a standalone script is not a Vite module, so import.meta.hot is
|
||||
// unavailable; open a second HMR WebSocket using the wsToken scraped from
|
||||
// the dev server's own /@vite/client source.
|
||||
const fetchViteWsToken = async (): Promise<string | null> => {
|
||||
try {
|
||||
const response = await fetch("/@vite/client");
|
||||
if (!response.ok) return null;
|
||||
const clientSource = await response.text();
|
||||
return VITE_WS_TOKEN_REGEX.exec(clientSource)?.[1] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribes to the current page's Vite dev server HMR WebSocket and invokes
|
||||
* `onHmrUpdate` with the updated file paths on every hot update. Reconnects
|
||||
* automatically when the dev server restarts. Resolves `null` when the page
|
||||
* is not served by Vite.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const transport = await createViteHmrTransport((filePaths) => {
|
||||
* console.log("hot updated:", filePaths);
|
||||
* });
|
||||
* transport?.dispose();
|
||||
* ```
|
||||
*/
|
||||
export const createViteHmrTransport = async (
|
||||
onHmrUpdate: HmrUpdateHandler,
|
||||
): Promise<HmrTransport | null> => {
|
||||
if (typeof window === "undefined" || typeof WebSocket === "undefined") return null;
|
||||
const initialWsToken = await fetchViteWsToken();
|
||||
if (!initialWsToken) return null;
|
||||
|
||||
let isDisposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectTimerId: number | undefined;
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (isDisposed) return;
|
||||
reconnectTimerId = window.setTimeout(() => {
|
||||
void fetchViteWsToken().then((freshWsToken) => {
|
||||
if (isDisposed) return;
|
||||
if (freshWsToken) {
|
||||
connect(freshWsToken);
|
||||
} else {
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
}, HMR_RECONNECT_DELAY_MS);
|
||||
};
|
||||
|
||||
const connect = (wsToken: string) => {
|
||||
if (isDisposed) return;
|
||||
const socketProtocol = location.protocol === "https:" ? "wss" : "ws";
|
||||
const connectedSocket = new WebSocket(
|
||||
`${socketProtocol}://${location.host}/?token=${wsToken}`,
|
||||
"vite-hmr",
|
||||
);
|
||||
socket = connectedSocket;
|
||||
connectedSocket.onmessage = (event) => {
|
||||
const filePaths = parseViteUpdatePaths(String(event.data));
|
||||
if (filePaths.length > 0) onHmrUpdate(filePaths);
|
||||
};
|
||||
connectedSocket.onclose = scheduleReconnect;
|
||||
};
|
||||
|
||||
connect(initialWsToken);
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
isDisposed = true;
|
||||
window.clearTimeout(reconnectTimerId);
|
||||
if (socket) {
|
||||
socket.onclose = null;
|
||||
socket.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
39
node_modules/bippy/src/source/constants.ts
generated
vendored
Normal file
39
node_modules/bippy/src/source/constants.ts
generated
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
export const SCHEME_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
|
||||
|
||||
export const INTERNAL_SCHEME_PREFIXES = [
|
||||
"rsc://",
|
||||
"file:///",
|
||||
"webpack-internal://",
|
||||
"webpack://",
|
||||
"node:",
|
||||
"turbopack://",
|
||||
"metro://",
|
||||
"/app-pages-browser/",
|
||||
"/(app-pages-browser)/",
|
||||
] as const;
|
||||
|
||||
export const ABOUT_REACT_PREFIX = "about://React/";
|
||||
|
||||
export const SERVER_COMPONENT_URL_PREFIXES = ["rsc://", ABOUT_REACT_PREFIX] as const;
|
||||
|
||||
export const ANONYMOUS_FILE_PATTERNS = ["<anonymous>", "eval", ""] as const;
|
||||
|
||||
export const SOURCE_FILE_EXTENSION_REGEX = /\.(jsx|tsx|ts|js)$/;
|
||||
|
||||
export const BUNDLED_FILE_PATTERN_REGEX =
|
||||
/(\.min|bundle|chunk|vendor|vendors|runtime|polyfill|polyfills)\.(js|mjs|cjs)$|(chunk|bundle|vendor|vendors|runtime|polyfill|polyfills|framework|app|main|index)[-_.][A-Za-z0-9_-]{4,}\.(js|mjs|cjs)$|[\da-f]{8,}\.(js|mjs|cjs)$|[-_.][\da-f]{20,}\.(js|mjs|cjs)$|\/dist\/|\/build\/|\/.next\/|\/out\/|\/node_modules\/|\.webpack\.|\.vite\.|\.turbopack\./i;
|
||||
|
||||
export const QUERY_PARAMETER_PATTERN_REGEX = /^\?[\w~.-]+(?:=[^&#]*)?(?:&[\w~.-]+(?:=[^&#]*)?)*$/;
|
||||
|
||||
export const SERVER_FRAME_MARKER = "(at Server)";
|
||||
|
||||
export const SERVER_ENV_PATTERN = /\(at [^)]+\)$/;
|
||||
|
||||
export const REACT_STACK_BOTTOM_FRAME_PATTERNS = [
|
||||
"react_stack_bottom_frame",
|
||||
"react-stack-bottom-frame",
|
||||
] as const;
|
||||
|
||||
// the first frame of a _debugStack is the JSX factory itself (jsxDEV), never
|
||||
// user code, matching what react's own captureOwnerStack pops
|
||||
export const JSX_FACTORY_FRAME_COUNT = 1;
|
||||
93
node_modules/bippy/src/source/get-display-name-from-source.ts
generated
vendored
Normal file
93
node_modules/bippy/src/source/get-display-name-from-source.ts
generated
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Fiber } from "../types.js";
|
||||
import { getDisplayName } from "../core.js";
|
||||
import { getParentStack } from "./owner-stack.js";
|
||||
import { getSourceFromSourceMap, getSourceMap } from "./symbolication.js";
|
||||
import { StackFrame } from "./parse-stack.js";
|
||||
|
||||
const extractComponentNameFromSource = (
|
||||
sourceContent: string,
|
||||
lineNumber: number,
|
||||
): string | null => {
|
||||
const lines = sourceContent.split("\n");
|
||||
const targetLineIndex = lineNumber - 1;
|
||||
|
||||
if (targetLineIndex < 0 || targetLineIndex >= lines.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startLine = Math.max(0, targetLineIndex - 5);
|
||||
const endLine = Math.min(lines.length, targetLineIndex + 5);
|
||||
const contextLines = lines.slice(startLine, endLine).join("\n");
|
||||
|
||||
const arrowFunctionPattern = /(?:^|export\s+)(?:const|let|var)\s+(\w+)\s*=/m;
|
||||
const functionPattern = /(?:^|export\s+)function\s+(\w+)/m;
|
||||
const classPattern = /(?:^|export\s+)class\s+(\w+)/m;
|
||||
|
||||
const arrowMatch = contextLines.match(arrowFunctionPattern);
|
||||
if (arrowMatch?.[1]) {
|
||||
return arrowMatch[1];
|
||||
}
|
||||
|
||||
const functionMatch = contextLines.match(functionPattern);
|
||||
if (functionMatch?.[1]) {
|
||||
return functionMatch[1];
|
||||
}
|
||||
|
||||
const classMatch = contextLines.match(classPattern);
|
||||
if (classMatch?.[1]) {
|
||||
return classMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getDisplayNameFromSource = async (
|
||||
fiber: Fiber,
|
||||
cache = true,
|
||||
fetchFn?: (url: string) => Promise<Response>,
|
||||
): Promise<string | null> => {
|
||||
const parentStackFrames = await getParentStack(fiber, cache, fetchFn);
|
||||
const stackFrame = parentStackFrames.filter((innerFrame) => innerFrame.fileName)[0];
|
||||
|
||||
if (!stackFrame?.fileName) {
|
||||
return getDisplayName(fiber.type);
|
||||
}
|
||||
|
||||
const bundleSourceMap = await getSourceMap(stackFrame.fileName, cache, fetchFn);
|
||||
|
||||
if (!bundleSourceMap) {
|
||||
return getDisplayName(fiber.type);
|
||||
}
|
||||
|
||||
let source: StackFrame | null = null;
|
||||
|
||||
if (typeof stackFrame.lineNumber === "number" && typeof stackFrame.columnNumber === "number") {
|
||||
source = getSourceFromSourceMap(
|
||||
bundleSourceMap,
|
||||
stackFrame.lineNumber,
|
||||
stackFrame.columnNumber,
|
||||
);
|
||||
}
|
||||
|
||||
if (!source?.fileName || !source.lineNumber) {
|
||||
return getDisplayName(fiber.type);
|
||||
}
|
||||
|
||||
if (!bundleSourceMap.sourcesContent) {
|
||||
return getDisplayName(fiber.type);
|
||||
}
|
||||
|
||||
const sourceIndex = bundleSourceMap.sources.indexOf(source.fileName);
|
||||
if (sourceIndex === -1 || !bundleSourceMap.sourcesContent[sourceIndex]) {
|
||||
return getDisplayName(fiber.type);
|
||||
}
|
||||
|
||||
const sourceContent = bundleSourceMap.sourcesContent[sourceIndex];
|
||||
const extractedName = extractComponentNameFromSource(sourceContent, source.lineNumber);
|
||||
|
||||
if (extractedName) {
|
||||
return extractedName;
|
||||
}
|
||||
|
||||
return getDisplayName(fiber.type);
|
||||
};
|
||||
257
node_modules/bippy/src/source/get-source.ts
generated
vendored
Normal file
257
node_modules/bippy/src/source/get-source.ts
generated
vendored
Normal file
@@ -0,0 +1,257 @@
|
||||
import { Fiber } from "../types.js";
|
||||
|
||||
import { FiberSource } from "./types.js";
|
||||
import {
|
||||
SCHEME_REGEX,
|
||||
INTERNAL_SCHEME_PREFIXES,
|
||||
ABOUT_REACT_PREFIX,
|
||||
ANONYMOUS_FILE_PATTERNS,
|
||||
SOURCE_FILE_EXTENSION_REGEX,
|
||||
BUNDLED_FILE_PATTERN_REGEX,
|
||||
QUERY_PARAMETER_PATTERN_REGEX,
|
||||
} from "./constants.js";
|
||||
import { getDefinitionFrameFromOwnedChild, getParentStack, hasDebugStack } from "./owner-stack.js";
|
||||
import { parseDebugStack } from "./parse-debug-stack.js";
|
||||
import { StackFrame } from "./parse-stack.js";
|
||||
import { symbolicateStack } from "./symbolication.js";
|
||||
|
||||
export const hasDebugSource = (
|
||||
fiber: Fiber,
|
||||
): fiber is Fiber & {
|
||||
_debugSource: NonNullable<Fiber["_debugSource"]>;
|
||||
} => {
|
||||
const debugSource = fiber._debugSource;
|
||||
if (!debugSource) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof debugSource === "object" &&
|
||||
debugSource !== null &&
|
||||
"fileName" in debugSource &&
|
||||
typeof debugSource.fileName === "string" &&
|
||||
"lineNumber" in debugSource &&
|
||||
typeof debugSource.lineNumber === "number"
|
||||
);
|
||||
};
|
||||
|
||||
const toFiberSource = (stackFrame: StackFrame): FiberSource | null =>
|
||||
stackFrame.fileName
|
||||
? {
|
||||
fileName: stackFrame.fileName,
|
||||
lineNumber: stackFrame.lineNumber,
|
||||
columnNumber: stackFrame.columnNumber,
|
||||
functionName: stackFrame.functionName,
|
||||
}
|
||||
: null;
|
||||
|
||||
// the fiber's own _debugStack (react 19) is captured at its JSX creation
|
||||
// site, so its first user-space frame IS the usage site - no need to
|
||||
// re-invoke the component like the throwing trick does
|
||||
const getUsageFrameFromDebugStack = (fiber: Fiber): StackFrame | null => {
|
||||
if (!hasDebugStack(fiber)) {
|
||||
return null;
|
||||
}
|
||||
const { frames, isTrusted } = parseDebugStack(fiber._debugStack);
|
||||
if (!isTrusted) {
|
||||
return null;
|
||||
}
|
||||
for (const stackFrame of frames) {
|
||||
if (stackFrame.fileName) {
|
||||
return stackFrame;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the source of where the component is used. Available only in dev, for composite {@link Fiber}s.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. `_debugSource` (react <19, requires the JSX source babel transform)
|
||||
* 2. the fiber's own `_debugStack` (react 19) - the exact JSX creation site
|
||||
* 3. an owned child's `_debugStack` bottom frame (react 19) - a location
|
||||
* inside the component's own body; works for components that the throwing
|
||||
* trick cannot locate (no hooks, no props access)
|
||||
* 4. the legacy owner-stack path (throwing trick re-invocation)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* function Parent() {
|
||||
* const data = useData();
|
||||
* return <Child name={data.name} />; // <-- captures THIS line
|
||||
* }
|
||||
*
|
||||
* function Child({ name }) {
|
||||
* return <div>{name}</div>;
|
||||
* }
|
||||
*
|
||||
* const source = await getSource(fiber);
|
||||
* console.log(source.fileName, source.lineNumber);
|
||||
* ```
|
||||
*/
|
||||
export const getSource = async (
|
||||
fiber: Fiber,
|
||||
cache = true,
|
||||
fetchFn?: (url: string) => Promise<Response>,
|
||||
): Promise<FiberSource | null> => {
|
||||
if (hasDebugSource(fiber)) {
|
||||
const debugSource = fiber._debugSource;
|
||||
return debugSource || null;
|
||||
}
|
||||
|
||||
const debugStackFrame =
|
||||
getUsageFrameFromDebugStack(fiber) ?? getDefinitionFrameFromOwnedChild(fiber);
|
||||
if (debugStackFrame) {
|
||||
const [symbolicatedFrame] = await symbolicateStack([debugStackFrame], cache, fetchFn);
|
||||
const debugStackSource = toFiberSource(symbolicatedFrame);
|
||||
if (debugStackSource) {
|
||||
return debugStackSource;
|
||||
}
|
||||
}
|
||||
|
||||
const parentStackFrames = await getParentStack(fiber, cache, fetchFn);
|
||||
for (const stackFrame of parentStackFrames) {
|
||||
if (stackFrame.fileName) {
|
||||
return toFiberSource(stackFrame);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getPathSegmentCount = (path: string): number => path.split("/").filter(Boolean).length;
|
||||
|
||||
const getFirstPathSegment = (path: string): string | null => {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
return segments[0] ?? null;
|
||||
};
|
||||
|
||||
const stripSingleBasePathPrefix = (path: string): string => {
|
||||
const firstSlashIndex = path.indexOf("/", 1);
|
||||
if (firstSlashIndex === -1) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const basePath = path.slice(0, firstSlashIndex);
|
||||
if (getPathSegmentCount(basePath) !== 1) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const remainderPath = path.slice(firstSlashIndex);
|
||||
if (!SOURCE_FILE_EXTENSION_REGEX.test(remainderPath)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (getPathSegmentCount(remainderPath) < 2) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const firstRemainderSegment = getFirstPathSegment(remainderPath);
|
||||
if (!firstRemainderSegment) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (firstRemainderSegment.startsWith("@")) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (firstRemainderSegment.length > 4) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return remainderPath;
|
||||
};
|
||||
|
||||
export const normalizeFileName = (fileName: string): string => {
|
||||
if (!fileName) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (ANONYMOUS_FILE_PATTERNS.some((pattern) => pattern === fileName)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let normalizedFileName = fileName;
|
||||
|
||||
const isHttpUrl =
|
||||
normalizedFileName.startsWith("http://") || normalizedFileName.startsWith("https://");
|
||||
if (isHttpUrl) {
|
||||
try {
|
||||
const parsedUrl = new URL(normalizedFileName);
|
||||
normalizedFileName = parsedUrl.pathname;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (isHttpUrl) {
|
||||
normalizedFileName = stripSingleBasePathPrefix(normalizedFileName);
|
||||
}
|
||||
|
||||
if (normalizedFileName.startsWith(ABOUT_REACT_PREFIX)) {
|
||||
const remainder = normalizedFileName.slice(ABOUT_REACT_PREFIX.length);
|
||||
const slashIndex = remainder.indexOf("/");
|
||||
const colonIndex = remainder.indexOf(":");
|
||||
|
||||
if (slashIndex !== -1 && (colonIndex === -1 || slashIndex < colonIndex)) {
|
||||
normalizedFileName = remainder.slice(slashIndex + 1);
|
||||
} else {
|
||||
normalizedFileName = remainder;
|
||||
}
|
||||
}
|
||||
|
||||
let didStripPrefix = true;
|
||||
while (didStripPrefix) {
|
||||
didStripPrefix = false;
|
||||
for (const prefix of INTERNAL_SCHEME_PREFIXES) {
|
||||
if (normalizedFileName.startsWith(prefix)) {
|
||||
normalizedFileName = normalizedFileName.slice(prefix.length);
|
||||
|
||||
if (prefix === "file:///") {
|
||||
normalizedFileName = `/${normalizedFileName.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
didStripPrefix = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (SCHEME_REGEX.test(normalizedFileName)) {
|
||||
const schemeMatch = normalizedFileName.match(SCHEME_REGEX);
|
||||
if (schemeMatch) {
|
||||
normalizedFileName = normalizedFileName.slice(schemeMatch[0].length);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedFileName.startsWith("//")) {
|
||||
const firstPathSlashIndex = normalizedFileName.indexOf("/", 2);
|
||||
normalizedFileName =
|
||||
firstPathSlashIndex === -1 ? "" : normalizedFileName.slice(firstPathSlashIndex);
|
||||
}
|
||||
|
||||
const queryParameterIndex = normalizedFileName.indexOf("?");
|
||||
if (queryParameterIndex !== -1) {
|
||||
const potentialQueryParameters = normalizedFileName.slice(queryParameterIndex);
|
||||
if (QUERY_PARAMETER_PATTERN_REGEX.test(potentialQueryParameters)) {
|
||||
normalizedFileName = normalizedFileName.slice(0, queryParameterIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedFileName;
|
||||
};
|
||||
|
||||
export const isSourceFile = (fileName: string): boolean => {
|
||||
const normalizedFileName = normalizeFileName(fileName);
|
||||
|
||||
if (!normalizedFileName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SOURCE_FILE_EXTENSION_REGEX.test(normalizedFileName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (BUNDLED_FILE_PATTERN_REGEX.test(normalizedFileName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
17
node_modules/bippy/src/source/index.ts
generated
vendored
Normal file
17
node_modules/bippy/src/source/index.ts
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
export { formatOwnerStack, getOwnerStack, getParentStack, hasDebugStack } from "./owner-stack.js";
|
||||
export { getSource, isSourceFile, normalizeFileName } from "./get-source.js";
|
||||
export {
|
||||
getSourceFromSourceMap,
|
||||
getSourceMap,
|
||||
symbolicateStack,
|
||||
type DecodedSourceMapSection,
|
||||
type IndexSourceMap,
|
||||
type RawSourceMap,
|
||||
type SourceMap,
|
||||
type StandardSourceMap,
|
||||
} from "./symbolication.js";
|
||||
export type { FiberSource } from "./types.js";
|
||||
export { parseStack, type ParseOptions, type StackFrame } from "./parse-stack.js";
|
||||
export { getDisplayNameFromSource } from "./get-display-name-from-source.js";
|
||||
export { getFiberHooks, type HookSource, type HooksNode, type HooksTree } from "./inspect-hooks.js";
|
||||
export { parseHookNames, type HookNames } from "./parse-hook-names.js";
|
||||
882
node_modules/bippy/src/source/inspect-hooks.ts
generated
vendored
Normal file
882
node_modules/bippy/src/source/inspect-hooks.ts
generated
vendored
Normal file
@@ -0,0 +1,882 @@
|
||||
import type { Fiber, ContextDependency, MemoizedState, ReactContext } from "../types.js";
|
||||
import { parseStack, type StackFrame } from "./parse-stack.js";
|
||||
import { getRDTHook, _renderers } from "../rdt-hook.js";
|
||||
|
||||
const REACT_CONTEXT_TYPE = Symbol.for("react.context");
|
||||
const REACT_MEMO_CACHE_SENTINEL = Symbol.for("react.memo_cache_sentinel");
|
||||
|
||||
const FUNCTION_COMPONENT_TAG = 0;
|
||||
const CONTEXT_PROVIDER_TAG = 10;
|
||||
const FORWARD_REF_TAG = 11;
|
||||
const SIMPLE_MEMO_COMPONENT_TAG = 15;
|
||||
|
||||
export interface HookSource {
|
||||
lineNumber: number | null;
|
||||
columnNumber: number | null;
|
||||
fileName: string | null;
|
||||
functionName: string | null;
|
||||
}
|
||||
|
||||
export interface HooksNode {
|
||||
id: number | null;
|
||||
isStateEditable: boolean;
|
||||
name: string;
|
||||
value: unknown;
|
||||
subHooks: HooksNode[];
|
||||
hookSource: HookSource | null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface HooksTree extends Array<HooksNode> {}
|
||||
|
||||
interface HookLogEntry {
|
||||
displayName: string | null;
|
||||
primitive: string;
|
||||
stackError: Error;
|
||||
value: unknown;
|
||||
dispatcherHookName: string;
|
||||
}
|
||||
|
||||
interface DispatcherRefContainer {
|
||||
H?: unknown;
|
||||
current?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let hookLog: HookLogEntry[] = [];
|
||||
let primitiveStackCache: Map<string, StackFrame[]> | null = null;
|
||||
let currentFiber: Fiber | null = null;
|
||||
let currentHook: MemoizedState | null = null;
|
||||
let currentContextDependency: ContextDependency<unknown> | null = null;
|
||||
let currentThenableIndex = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let currentThenableState: any[] | null = null;
|
||||
|
||||
const SuspenseException: unknown = new Error(
|
||||
"Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render.",
|
||||
);
|
||||
|
||||
const parseErrorStack = (error: Error): StackFrame[] =>
|
||||
parseStack(error.stack || "", { includeInElement: false });
|
||||
|
||||
const nextHook = (): MemoizedState | null => {
|
||||
const hook = currentHook;
|
||||
if (hook !== null) currentHook = hook.next;
|
||||
return hook;
|
||||
};
|
||||
|
||||
const readContext = <T>(context: ReactContext<T>): T => {
|
||||
if (currentFiber === null) return context._currentValue;
|
||||
if (currentContextDependency === null) {
|
||||
throw new Error("Context reads do not line up with context dependencies.");
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(currentContextDependency, "memoizedValue")) {
|
||||
const value = currentContextDependency.memoizedValue as T;
|
||||
currentContextDependency = currentContextDependency.next;
|
||||
return value;
|
||||
}
|
||||
return context._currentValue;
|
||||
};
|
||||
|
||||
const getDispatcherRef = (): DispatcherRefContainer | null => {
|
||||
const rdtHook = getRDTHook();
|
||||
const allRenderers = [..._renderers, ...rdtHook.renderers.values()];
|
||||
for (const renderer of allRenderers) {
|
||||
const ref = renderer.currentDispatcherRef;
|
||||
if (ref && typeof ref === "object") return ref as DispatcherRefContainer;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getDispatcherFromRef = (ref: DispatcherRefContainer): unknown =>
|
||||
"H" in ref ? ref.H : ref.current;
|
||||
|
||||
const setDispatcherOnRef = (ref: DispatcherRefContainer, dispatcher: unknown): void => {
|
||||
if ("H" in ref) {
|
||||
ref.H = dispatcher;
|
||||
} else {
|
||||
ref.current = dispatcher;
|
||||
}
|
||||
};
|
||||
|
||||
const pushHookLogEntry = (
|
||||
primitive: string,
|
||||
value: unknown,
|
||||
dispatcherHookName: string,
|
||||
displayName: string | null = null,
|
||||
): void => {
|
||||
hookLog.push({
|
||||
displayName,
|
||||
primitive,
|
||||
stackError: new Error(),
|
||||
value,
|
||||
dispatcherHookName,
|
||||
});
|
||||
};
|
||||
|
||||
const dispatcherUse = (usable: unknown): unknown => {
|
||||
if (usable !== null && typeof usable === "object") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const asThenable = usable as any;
|
||||
if (typeof asThenable.then === "function") {
|
||||
const thenable =
|
||||
currentThenableState !== null && currentThenableIndex < currentThenableState.length
|
||||
? currentThenableState[currentThenableIndex++]
|
||||
: asThenable;
|
||||
|
||||
switch (thenable.status) {
|
||||
case "fulfilled": {
|
||||
pushHookLogEntry("Promise", thenable.value, "Use");
|
||||
return thenable.value;
|
||||
}
|
||||
case "rejected":
|
||||
throw thenable.reason;
|
||||
}
|
||||
pushHookLogEntry("Unresolved", thenable, "Use");
|
||||
throw SuspenseException;
|
||||
}
|
||||
if (asThenable.$$typeof === REACT_CONTEXT_TYPE && "_currentValue" in asThenable) {
|
||||
const context: ReactContext<unknown> = asThenable;
|
||||
const value = readContext(context);
|
||||
pushHookLogEntry("Context (use)", value, "Use", context.displayName || "Context");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
throw new Error("An unsupported type was passed to use(): " + String(usable));
|
||||
};
|
||||
|
||||
const dispatcherUseContext = (context: ReactContext<unknown>): unknown => {
|
||||
const value = readContext(context);
|
||||
pushHookLogEntry("Context", value, "Context", context.displayName || null);
|
||||
return value;
|
||||
};
|
||||
|
||||
const dispatcherUseState = (initialState: unknown): [unknown, () => void] => {
|
||||
const hook = nextHook();
|
||||
const state =
|
||||
hook !== null
|
||||
? hook.memoizedState
|
||||
: typeof initialState === "function"
|
||||
? (initialState as () => unknown)()
|
||||
: initialState;
|
||||
pushHookLogEntry("State", state, "State");
|
||||
return [state, () => {}];
|
||||
};
|
||||
|
||||
const dispatcherUseReducer = (
|
||||
_reducer: unknown,
|
||||
initialArg: unknown,
|
||||
init?: (arg: unknown) => unknown,
|
||||
): [unknown, () => void] => {
|
||||
const hook = nextHook();
|
||||
const state =
|
||||
hook !== null ? hook.memoizedState : init !== undefined ? init(initialArg) : initialArg;
|
||||
pushHookLogEntry("Reducer", state, "Reducer");
|
||||
return [state, () => {}];
|
||||
};
|
||||
|
||||
const dispatcherUseRef = (initialValue: unknown): { current: unknown } => {
|
||||
const hook = nextHook();
|
||||
const ref = hook !== null ? hook.memoizedState : { current: initialValue };
|
||||
pushHookLogEntry("Ref", (ref as { current: unknown }).current, "Ref");
|
||||
return ref as { current: unknown };
|
||||
};
|
||||
|
||||
const dispatcherUseCacheRefresh = (): (() => void) => {
|
||||
const hook = nextHook();
|
||||
pushHookLogEntry("CacheRefresh", hook !== null ? hook.memoizedState : () => {}, "CacheRefresh");
|
||||
return () => {};
|
||||
};
|
||||
|
||||
const dispatcherUseLayoutEffect = (create: () => void): void => {
|
||||
nextHook();
|
||||
pushHookLogEntry("LayoutEffect", create, "LayoutEffect");
|
||||
};
|
||||
|
||||
const dispatcherUseInsertionEffect = (create: () => unknown): void => {
|
||||
nextHook();
|
||||
pushHookLogEntry("InsertionEffect", create, "InsertionEffect");
|
||||
};
|
||||
|
||||
const dispatcherUseEffect = (create: () => void): void => {
|
||||
nextHook();
|
||||
pushHookLogEntry("Effect", create, "Effect");
|
||||
};
|
||||
|
||||
const dispatcherUseImperativeHandle = (ref: unknown): void => {
|
||||
nextHook();
|
||||
let instance: unknown;
|
||||
if (ref !== null && typeof ref === "object" && "current" in ref) {
|
||||
instance = ref.current;
|
||||
}
|
||||
pushHookLogEntry("ImperativeHandle", instance, "ImperativeHandle");
|
||||
};
|
||||
|
||||
const dispatcherUseDebugValue = (
|
||||
value: unknown,
|
||||
formatterFn?: (value: unknown) => unknown,
|
||||
): void => {
|
||||
pushHookLogEntry(
|
||||
"DebugValue",
|
||||
typeof formatterFn === "function" ? formatterFn(value) : value,
|
||||
"DebugValue",
|
||||
);
|
||||
};
|
||||
|
||||
const dispatcherUseCallback = (callback: unknown): unknown => {
|
||||
const hook = nextHook();
|
||||
pushHookLogEntry(
|
||||
"Callback",
|
||||
hook !== null ? (hook.memoizedState as unknown[])[0] : callback,
|
||||
"Callback",
|
||||
);
|
||||
return callback;
|
||||
};
|
||||
|
||||
const dispatcherUseMemo = (nextCreate: () => unknown): unknown => {
|
||||
const hook = nextHook();
|
||||
const value = hook !== null ? (hook.memoizedState as unknown[])[0] : nextCreate();
|
||||
pushHookLogEntry("Memo", value, "Memo");
|
||||
return value;
|
||||
};
|
||||
|
||||
const dispatcherUseSyncExternalStore = (
|
||||
_subscribe: unknown,
|
||||
getSnapshot: () => unknown,
|
||||
): unknown => {
|
||||
const hook = nextHook();
|
||||
nextHook();
|
||||
const value = hook !== null ? hook.memoizedState : getSnapshot();
|
||||
pushHookLogEntry("SyncExternalStore", value, "SyncExternalStore");
|
||||
return value;
|
||||
};
|
||||
|
||||
const dispatcherUseTransition = (): [boolean, () => void] => {
|
||||
const stateHook = nextHook();
|
||||
nextHook();
|
||||
const isPending = stateHook !== null ? (stateHook.memoizedState as boolean) : false;
|
||||
pushHookLogEntry("Transition", isPending, "Transition");
|
||||
return [isPending, () => {}];
|
||||
};
|
||||
|
||||
const dispatcherUseDeferredValue = (value: unknown): unknown => {
|
||||
const hook = nextHook();
|
||||
const previousValue = hook !== null ? hook.memoizedState : value;
|
||||
pushHookLogEntry("DeferredValue", previousValue, "DeferredValue");
|
||||
return previousValue;
|
||||
};
|
||||
|
||||
const dispatcherUseId = (): string => {
|
||||
const hook = nextHook();
|
||||
const identifier = hook !== null ? (hook.memoizedState as string) : "";
|
||||
pushHookLogEntry("Id", identifier, "Id");
|
||||
return identifier;
|
||||
};
|
||||
|
||||
const dispatcherUseMemoCache = (size: number): unknown[] => {
|
||||
const fiber = currentFiber;
|
||||
if (fiber === null || fiber === undefined) return [];
|
||||
|
||||
const memoCache = (
|
||||
fiber.updateQueue as { memoCache?: { data: unknown[][]; index: number } } | null
|
||||
)?.memoCache;
|
||||
if (memoCache === null || memoCache === undefined) return [];
|
||||
|
||||
let memoCacheSlots = memoCache.data[memoCache.index];
|
||||
if (memoCacheSlots === undefined) {
|
||||
memoCacheSlots = memoCache.data[memoCache.index] = Array.from(
|
||||
{ length: size },
|
||||
() => REACT_MEMO_CACHE_SENTINEL,
|
||||
);
|
||||
}
|
||||
|
||||
memoCache.index++;
|
||||
return memoCacheSlots;
|
||||
};
|
||||
|
||||
const dispatcherUseOptimistic = (passthrough: unknown): [unknown, () => void] => {
|
||||
const hook = nextHook();
|
||||
const state = hook !== null ? hook.memoizedState : passthrough;
|
||||
pushHookLogEntry("Optimistic", state, "Optimistic");
|
||||
return [state, () => {}];
|
||||
};
|
||||
|
||||
const inspectActionStateHook = (
|
||||
hook: MemoizedState | null,
|
||||
initialState: unknown,
|
||||
): { value: unknown; error: unknown } => {
|
||||
let value: unknown;
|
||||
let error: unknown = null;
|
||||
if (hook !== null) {
|
||||
const actionResult = hook.memoizedState;
|
||||
if (
|
||||
typeof actionResult === "object" &&
|
||||
actionResult !== null &&
|
||||
"then" in actionResult &&
|
||||
typeof actionResult.then === "function"
|
||||
) {
|
||||
const thenable = actionResult as { status?: string; value?: unknown; reason?: unknown };
|
||||
switch (thenable.status) {
|
||||
case "fulfilled":
|
||||
value = thenable.value;
|
||||
break;
|
||||
case "rejected":
|
||||
error = thenable.reason;
|
||||
break;
|
||||
default:
|
||||
error = SuspenseException;
|
||||
value = thenable;
|
||||
}
|
||||
} else {
|
||||
value = actionResult;
|
||||
}
|
||||
} else {
|
||||
value = initialState;
|
||||
}
|
||||
return { value, error };
|
||||
};
|
||||
|
||||
const createActionStateDispatcher =
|
||||
(primitive: string) =>
|
||||
(_action: unknown, initialState: unknown): [unknown, () => void, boolean] => {
|
||||
const hook = nextHook();
|
||||
nextHook();
|
||||
nextHook();
|
||||
const stackError = new Error();
|
||||
const { value, error } = inspectActionStateHook(hook, initialState);
|
||||
hookLog.push({
|
||||
displayName: null,
|
||||
primitive,
|
||||
stackError,
|
||||
value,
|
||||
dispatcherHookName: primitive,
|
||||
});
|
||||
if (error !== null) throw error;
|
||||
return [value, () => {}, false];
|
||||
};
|
||||
|
||||
const dispatcherUseActionState = createActionStateDispatcher("ActionState");
|
||||
const dispatcherUseFormState = createActionStateDispatcher("FormState");
|
||||
|
||||
const dispatcherUseHostTransitionStatus = (): unknown => {
|
||||
// HACK: creating a minimal fake context because useHostTransitionStatus reads from an internal context not available outside React
|
||||
const status = readContext({ _currentValue: null } as unknown as ReactContext<unknown>);
|
||||
pushHookLogEntry("HostTransitionStatus", status, "HostTransitionStatus");
|
||||
return status;
|
||||
};
|
||||
|
||||
const dispatcherUseEffectEvent = (callback: (...args: unknown[]) => unknown): typeof callback => {
|
||||
nextHook();
|
||||
pushHookLogEntry("EffectEvent", callback, "EffectEvent");
|
||||
return callback;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Dispatcher: Record<string, (...args: any[]) => any> = {
|
||||
readContext,
|
||||
use: dispatcherUse,
|
||||
useCallback: dispatcherUseCallback,
|
||||
useContext: dispatcherUseContext,
|
||||
useEffect: dispatcherUseEffect,
|
||||
useImperativeHandle: dispatcherUseImperativeHandle,
|
||||
useLayoutEffect: dispatcherUseLayoutEffect,
|
||||
useInsertionEffect: dispatcherUseInsertionEffect,
|
||||
useMemo: dispatcherUseMemo,
|
||||
useReducer: dispatcherUseReducer,
|
||||
useRef: dispatcherUseRef,
|
||||
useState: dispatcherUseState,
|
||||
useDebugValue: dispatcherUseDebugValue,
|
||||
useDeferredValue: dispatcherUseDeferredValue,
|
||||
useTransition: dispatcherUseTransition,
|
||||
useSyncExternalStore: dispatcherUseSyncExternalStore,
|
||||
useId: dispatcherUseId,
|
||||
useHostTransitionStatus: dispatcherUseHostTransitionStatus,
|
||||
useFormState: dispatcherUseFormState,
|
||||
useActionState: dispatcherUseActionState,
|
||||
useOptimistic: dispatcherUseOptimistic,
|
||||
useMemoCache: dispatcherUseMemoCache,
|
||||
useCacheRefresh: dispatcherUseCacheRefresh,
|
||||
useEffectEvent: dispatcherUseEffectEvent,
|
||||
};
|
||||
|
||||
const DispatcherProxy =
|
||||
typeof Proxy === "undefined"
|
||||
? Dispatcher
|
||||
: new Proxy(Dispatcher, {
|
||||
get(target, prop: string) {
|
||||
if (Object.prototype.hasOwnProperty.call(target, prop)) return target[prop];
|
||||
const error = new Error("Missing method in Dispatcher: " + prop);
|
||||
error.name = "ReactDebugToolsUnsupportedHookError";
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
const getPrimitiveStackCache = (): Map<string, StackFrame[]> => {
|
||||
if (primitiveStackCache !== null) return primitiveStackCache;
|
||||
|
||||
const cache = new Map<string, StackFrame[]>();
|
||||
let capturedHookLog: HookLogEntry[];
|
||||
|
||||
try {
|
||||
Dispatcher.useContext({ _currentValue: null });
|
||||
Dispatcher.useState(null);
|
||||
Dispatcher.useReducer((state: unknown) => state, null);
|
||||
Dispatcher.useRef(null);
|
||||
if (typeof Dispatcher.useCacheRefresh === "function") Dispatcher.useCacheRefresh();
|
||||
Dispatcher.useLayoutEffect(() => {});
|
||||
Dispatcher.useInsertionEffect(() => {});
|
||||
Dispatcher.useEffect(() => {});
|
||||
Dispatcher.useImperativeHandle(undefined, () => null);
|
||||
Dispatcher.useDebugValue(null);
|
||||
Dispatcher.useCallback(() => {});
|
||||
Dispatcher.useTransition();
|
||||
Dispatcher.useSyncExternalStore(
|
||||
() => () => {},
|
||||
() => null,
|
||||
() => null,
|
||||
);
|
||||
Dispatcher.useDeferredValue(null);
|
||||
Dispatcher.useMemo(() => null);
|
||||
Dispatcher.useOptimistic(null, (state: unknown) => state);
|
||||
Dispatcher.useFormState((state: unknown) => state, null);
|
||||
Dispatcher.useActionState((state: unknown) => state, null);
|
||||
Dispatcher.useHostTransitionStatus();
|
||||
if (typeof Dispatcher.useMemoCache === "function") Dispatcher.useMemoCache(0);
|
||||
if (typeof Dispatcher.use === "function") {
|
||||
Dispatcher.use({ $$typeof: REACT_CONTEXT_TYPE, _currentValue: null });
|
||||
Dispatcher.use({ then() {}, status: "fulfilled", value: null });
|
||||
try {
|
||||
Dispatcher.use({ then() {} });
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
Dispatcher.useId();
|
||||
if (typeof Dispatcher.useEffectEvent === "function") Dispatcher.useEffectEvent(() => {});
|
||||
} finally {
|
||||
capturedHookLog = hookLog;
|
||||
hookLog = [];
|
||||
}
|
||||
|
||||
for (const hook of capturedHookLog) {
|
||||
cache.set(hook.primitive, parseErrorStack(hook.stackError));
|
||||
}
|
||||
|
||||
primitiveStackCache = cache;
|
||||
return primitiveStackCache;
|
||||
};
|
||||
|
||||
let mostLikelyAncestorIndex = 0;
|
||||
|
||||
const findSharedIndex = (
|
||||
hookStack: StackFrame[],
|
||||
rootStack: StackFrame[],
|
||||
rootIndex: number,
|
||||
): number => {
|
||||
// mostLikelyAncestorIndex is cached across inspections, so it can exceed the
|
||||
// bounds of a later, shorter root stack (e.g. truncated Error.stackTraceLimit)
|
||||
if (rootIndex >= rootStack.length) return -1;
|
||||
const source = rootStack[rootIndex].source;
|
||||
hookSearch: for (let hookIndex = 0; hookIndex < hookStack.length; hookIndex++) {
|
||||
if (hookStack[hookIndex].source === source) {
|
||||
for (
|
||||
let rootOffset = rootIndex + 1, hookOffset = hookIndex + 1;
|
||||
rootOffset < rootStack.length && hookOffset < hookStack.length;
|
||||
rootOffset++, hookOffset++
|
||||
) {
|
||||
if (hookStack[hookOffset].source !== rootStack[rootOffset].source) continue hookSearch;
|
||||
}
|
||||
return hookIndex;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const findCommonAncestorIndex = (rootStack: StackFrame[], hookStack: StackFrame[]): number => {
|
||||
let rootIndex = findSharedIndex(hookStack, rootStack, mostLikelyAncestorIndex);
|
||||
if (rootIndex !== -1) return rootIndex;
|
||||
for (
|
||||
let candidateIndex = 0;
|
||||
candidateIndex < rootStack.length && candidateIndex < 5;
|
||||
candidateIndex++
|
||||
) {
|
||||
rootIndex = findSharedIndex(hookStack, rootStack, candidateIndex);
|
||||
if (rootIndex !== -1) {
|
||||
mostLikelyAncestorIndex = candidateIndex;
|
||||
return rootIndex;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const parseHookName = (functionName: string | undefined): string => {
|
||||
if (!functionName) return "";
|
||||
let startIndex = functionName.lastIndexOf("[as ");
|
||||
if (startIndex !== -1) {
|
||||
return parseHookName(functionName.slice(startIndex + "[as ".length, -1));
|
||||
}
|
||||
startIndex = functionName.lastIndexOf(".");
|
||||
startIndex = startIndex === -1 ? 0 : startIndex + 1;
|
||||
if (functionName.slice(startIndex).startsWith("unstable_")) startIndex += "unstable_".length;
|
||||
if (functionName.slice(startIndex).startsWith("experimental_"))
|
||||
startIndex += "experimental_".length;
|
||||
if (functionName.slice(startIndex, startIndex + 3) === "use") {
|
||||
if (functionName.length - startIndex === 3) return "Use";
|
||||
startIndex += 3;
|
||||
}
|
||||
return functionName.slice(startIndex);
|
||||
};
|
||||
|
||||
const isReactWrapper = (functionName: string | undefined, wrapperName: string): boolean => {
|
||||
const hookName = parseHookName(functionName);
|
||||
if (wrapperName === "HostTransitionStatus") {
|
||||
return hookName === wrapperName || hookName === "FormStatus";
|
||||
}
|
||||
return hookName === wrapperName;
|
||||
};
|
||||
|
||||
const findPrimitiveIndex = (hookStack: StackFrame[], hook: HookLogEntry): number => {
|
||||
const stackCache = getPrimitiveStackCache();
|
||||
const primitiveStack = stackCache.get(hook.primitive);
|
||||
if (primitiveStack === undefined) return -1;
|
||||
for (
|
||||
let frameIndex = 0;
|
||||
frameIndex < primitiveStack.length && frameIndex < hookStack.length;
|
||||
frameIndex++
|
||||
) {
|
||||
if (primitiveStack[frameIndex].source !== hookStack[frameIndex].source) {
|
||||
if (
|
||||
frameIndex < hookStack.length - 1 &&
|
||||
isReactWrapper(hookStack[frameIndex].functionName, hook.dispatcherHookName)
|
||||
) {
|
||||
frameIndex++;
|
||||
}
|
||||
if (
|
||||
frameIndex < hookStack.length - 1 &&
|
||||
isReactWrapper(hookStack[frameIndex].functionName, hook.dispatcherHookName)
|
||||
) {
|
||||
frameIndex++;
|
||||
}
|
||||
return frameIndex;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const parseTrimmedStack = (
|
||||
rootStack: StackFrame[],
|
||||
hook: HookLogEntry,
|
||||
): [StackFrame | null, StackFrame[] | null] => {
|
||||
const hookStack = parseErrorStack(hook.stackError);
|
||||
const rootIndex = findCommonAncestorIndex(rootStack, hookStack);
|
||||
const primitiveIndex = findPrimitiveIndex(hookStack, hook);
|
||||
if (rootIndex === -1 || primitiveIndex === -1 || rootIndex - primitiveIndex < 2) {
|
||||
if (primitiveIndex === -1) return [null, null];
|
||||
return [hookStack[primitiveIndex - 1] ?? null, null];
|
||||
}
|
||||
return [hookStack[primitiveIndex - 1] ?? null, hookStack.slice(primitiveIndex, rootIndex - 1)];
|
||||
};
|
||||
|
||||
const NON_ID_HOOK_PRIMITIVES = new Set([
|
||||
"Context",
|
||||
"Context (use)",
|
||||
"DebugValue",
|
||||
"Promise",
|
||||
"Unresolved",
|
||||
"HostTransitionStatus",
|
||||
]);
|
||||
|
||||
const buildTree = (rootStack: StackFrame[], capturedHookLog: HookLogEntry[]): HooksTree => {
|
||||
const rootChildren: HooksNode[] = [];
|
||||
let previousStack: StackFrame[] | null = null;
|
||||
let levelChildren = rootChildren;
|
||||
let nativeHookID = 0;
|
||||
const childrenStack: HooksNode[][] = [];
|
||||
|
||||
for (const hook of capturedHookLog) {
|
||||
const [primitiveFrame, stack] = parseTrimmedStack(rootStack, hook);
|
||||
let displayName = hook.displayName;
|
||||
if (displayName === null && primitiveFrame !== null) {
|
||||
displayName =
|
||||
parseHookName(primitiveFrame.functionName) || parseHookName(hook.dispatcherHookName);
|
||||
}
|
||||
|
||||
if (stack !== null) {
|
||||
let commonSteps = 0;
|
||||
if (previousStack !== null) {
|
||||
while (commonSteps < stack.length && commonSteps < previousStack.length) {
|
||||
const stackSource = stack[stack.length - commonSteps - 1].source;
|
||||
const previousSource = previousStack[previousStack.length - commonSteps - 1].source;
|
||||
if (stackSource !== previousSource) break;
|
||||
commonSteps++;
|
||||
}
|
||||
for (let popIndex = previousStack.length - 1; popIndex > commonSteps; popIndex--) {
|
||||
levelChildren = childrenStack.pop() ?? rootChildren;
|
||||
}
|
||||
}
|
||||
for (let stackIndex = stack.length - commonSteps - 1; stackIndex >= 1; stackIndex--) {
|
||||
const children: HooksNode[] = [];
|
||||
const stackFrame = stack[stackIndex];
|
||||
const levelChild: HooksNode = {
|
||||
id: null,
|
||||
isStateEditable: false,
|
||||
name: parseHookName(stack[stackIndex - 1].functionName),
|
||||
value: undefined,
|
||||
subHooks: children,
|
||||
hookSource: {
|
||||
lineNumber: stackFrame.lineNumber ?? null,
|
||||
columnNumber: stackFrame.columnNumber ?? null,
|
||||
functionName: stackFrame.functionName ?? null,
|
||||
fileName: stackFrame.fileName ?? null,
|
||||
},
|
||||
};
|
||||
levelChildren.push(levelChild);
|
||||
childrenStack.push(levelChildren);
|
||||
levelChildren = children;
|
||||
}
|
||||
previousStack = stack;
|
||||
}
|
||||
|
||||
const { primitive } = hook;
|
||||
const id = NON_ID_HOOK_PRIMITIVES.has(primitive) ? null : nativeHookID++;
|
||||
const isStateEditable = primitive === "Reducer" || primitive === "State";
|
||||
const name = displayName || primitive;
|
||||
|
||||
const firstStackFrame = stack?.[0];
|
||||
const hookSource: HookSource = {
|
||||
lineNumber: firstStackFrame?.lineNumber ?? null,
|
||||
columnNumber: firstStackFrame?.columnNumber ?? null,
|
||||
functionName: firstStackFrame?.functionName ?? null,
|
||||
fileName: firstStackFrame?.fileName ?? null,
|
||||
};
|
||||
|
||||
levelChildren.push({ id, isStateEditable, name, value: hook.value, subHooks: [], hookSource });
|
||||
}
|
||||
|
||||
processDebugValues(rootChildren, null);
|
||||
return rootChildren;
|
||||
};
|
||||
|
||||
const processDebugValues = (hooksTree: HooksTree, parentHooksNode: HooksNode | null): void => {
|
||||
const debugValueNodes: HooksNode[] = [];
|
||||
for (let nodeIndex = 0; nodeIndex < hooksTree.length; nodeIndex++) {
|
||||
const hooksNode = hooksTree[nodeIndex];
|
||||
if (hooksNode.name === "DebugValue" && hooksNode.subHooks.length === 0) {
|
||||
hooksTree.splice(nodeIndex, 1);
|
||||
nodeIndex--;
|
||||
debugValueNodes.push(hooksNode);
|
||||
} else {
|
||||
processDebugValues(hooksNode.subHooks, hooksNode);
|
||||
}
|
||||
}
|
||||
if (parentHooksNode !== null) {
|
||||
if (debugValueNodes.length === 1) {
|
||||
parentHooksNode.value = debugValueNodes[0].value;
|
||||
} else if (debugValueNodes.length > 1) {
|
||||
parentHooksNode.value = debugValueNodes.map(({ value }) => value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setupContexts = (contextMap: Map<ReactContext<unknown>, unknown>, fiber: Fiber): void => {
|
||||
let current: Fiber | null = fiber;
|
||||
while (current) {
|
||||
if (current.tag === CONTEXT_PROVIDER_TAG) {
|
||||
let context = current.type as ReactContext<unknown>;
|
||||
if ("_context" in context && context._context !== undefined) {
|
||||
context = context._context as ReactContext<unknown>;
|
||||
}
|
||||
if (!contextMap.has(context)) {
|
||||
contextMap.set(context, context._currentValue);
|
||||
context._currentValue = (current.memoizedProps as { value: unknown }).value;
|
||||
}
|
||||
}
|
||||
current = current.return;
|
||||
}
|
||||
};
|
||||
|
||||
const restoreContexts = (contextMap: Map<ReactContext<unknown>, unknown>): void => {
|
||||
contextMap.forEach((value, context) => {
|
||||
context._currentValue = value;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRenderFunctionError = (error: unknown): void => {
|
||||
if (error === SuspenseException) return;
|
||||
if (error instanceof Error && error.name === "ReactDebugToolsUnsupportedHookError") throw error;
|
||||
const wrapperError = new Error("Error rendering inspected component", { cause: error });
|
||||
wrapperError.name = "ReactDebugToolsRenderError";
|
||||
(wrapperError as { cause: unknown }).cause = error;
|
||||
throw wrapperError;
|
||||
};
|
||||
|
||||
const resolveDefaultProps = (
|
||||
Component: unknown,
|
||||
baseProps: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
if (
|
||||
Component &&
|
||||
typeof Component === "object" &&
|
||||
"defaultProps" in Component &&
|
||||
Component.defaultProps
|
||||
) {
|
||||
const props = { ...baseProps };
|
||||
const defaultProps = Component.defaultProps as Record<string, unknown>;
|
||||
for (const propName in defaultProps) {
|
||||
if (props[propName] === undefined) {
|
||||
props[propName] = defaultProps[propName];
|
||||
}
|
||||
}
|
||||
return props;
|
||||
}
|
||||
return baseProps;
|
||||
};
|
||||
|
||||
const suppressConsole = (): Record<string, unknown> => {
|
||||
const originalMethods: Record<string, unknown> = {};
|
||||
for (const method in console) {
|
||||
try {
|
||||
originalMethods[method] = (console as Record<string, unknown>)[method];
|
||||
(console as Record<string, unknown>)[method] = () => {};
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
return originalMethods;
|
||||
};
|
||||
|
||||
const restoreConsole = (originalMethods: Record<string, unknown>): void => {
|
||||
for (const method in originalMethods) {
|
||||
try {
|
||||
(console as Record<string, unknown>)[method] = originalMethods[method];
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const performDispatcherInspection = (
|
||||
dispatcherRef: DispatcherRefContainer,
|
||||
renderFn: () => void,
|
||||
): HooksTree => {
|
||||
const previousDispatcher = getDispatcherFromRef(dispatcherRef);
|
||||
setDispatcherOnRef(dispatcherRef, DispatcherProxy);
|
||||
|
||||
let capturedHookLog: HookLogEntry[] = [];
|
||||
let ancestorStackError: Error | undefined;
|
||||
|
||||
try {
|
||||
ancestorStackError = new Error();
|
||||
renderFn();
|
||||
} catch (renderError) {
|
||||
handleRenderFunctionError(renderError);
|
||||
} finally {
|
||||
capturedHookLog = hookLog;
|
||||
hookLog = [];
|
||||
setDispatcherOnRef(dispatcherRef, previousDispatcher);
|
||||
}
|
||||
|
||||
const rootStack = ancestorStackError !== undefined ? parseErrorStack(ancestorStackError) : [];
|
||||
return buildTree(rootStack, capturedHookLog);
|
||||
};
|
||||
|
||||
const requireDispatcherRef = (): DispatcherRefContainer => {
|
||||
const dispatcherRef = getDispatcherRef();
|
||||
if (!dispatcherRef) {
|
||||
throw new Error(
|
||||
"No React renderer found. Make sure React is loaded and bippy's hook is installed.",
|
||||
);
|
||||
}
|
||||
return dispatcherRef;
|
||||
};
|
||||
|
||||
const resolveContextDependency = (fiber: Fiber): void => {
|
||||
if (Object.prototype.hasOwnProperty.call(fiber, "dependencies")) {
|
||||
const dependencies = fiber.dependencies;
|
||||
currentContextDependency = dependencies !== null ? dependencies.firstContext : null;
|
||||
} else if (Object.prototype.hasOwnProperty.call(fiber, "dependencies_old")) {
|
||||
const dependencies = (fiber as unknown as { dependencies_old: typeof fiber.dependencies })
|
||||
.dependencies_old;
|
||||
currentContextDependency = dependencies !== null ? dependencies!.firstContext : null;
|
||||
} else if (Object.prototype.hasOwnProperty.call(fiber, "dependencies_new")) {
|
||||
const dependencies = (fiber as unknown as { dependencies_new: typeof fiber.dependencies })
|
||||
.dependencies_new;
|
||||
currentContextDependency = dependencies !== null ? dependencies!.firstContext : null;
|
||||
} else if (Object.prototype.hasOwnProperty.call(fiber, "contextDependencies")) {
|
||||
const contextDependencies = (
|
||||
fiber as unknown as {
|
||||
contextDependencies: { first: ContextDependency<unknown> | null } | null;
|
||||
}
|
||||
).contextDependencies;
|
||||
currentContextDependency = contextDependencies !== null ? contextDependencies.first : null;
|
||||
} else {
|
||||
throw new Error("Unsupported React version.");
|
||||
}
|
||||
};
|
||||
|
||||
export const getFiberHooks = (fiber: Fiber): HooksTree => {
|
||||
const dispatcherRef = requireDispatcherRef();
|
||||
|
||||
if (
|
||||
fiber.tag !== FUNCTION_COMPONENT_TAG &&
|
||||
fiber.tag !== SIMPLE_MEMO_COMPONENT_TAG &&
|
||||
fiber.tag !== FORWARD_REF_TAG
|
||||
) {
|
||||
throw new Error("Unknown Fiber. Needs to be a function component to inspect hooks.");
|
||||
}
|
||||
|
||||
getPrimitiveStackCache();
|
||||
|
||||
currentHook = fiber.memoizedState;
|
||||
currentFiber = fiber;
|
||||
|
||||
const debugThenableState =
|
||||
fiber.dependencies &&
|
||||
(fiber.dependencies as { _debugThenableState?: { thenables?: unknown[] } })._debugThenableState;
|
||||
const usedThenables = debugThenableState
|
||||
? debugThenableState.thenables || debugThenableState
|
||||
: null;
|
||||
currentThenableState = Array.isArray(usedThenables) ? usedThenables : null;
|
||||
currentThenableIndex = 0;
|
||||
|
||||
resolveContextDependency(fiber);
|
||||
|
||||
const type = fiber.type;
|
||||
let props = fiber.memoizedProps as Record<string, unknown>;
|
||||
if (type !== fiber.elementType) {
|
||||
props = resolveDefaultProps(type, props);
|
||||
}
|
||||
|
||||
const originalConsoleMethods = suppressConsole();
|
||||
const contextMap = new Map<ReactContext<unknown>, unknown>();
|
||||
|
||||
try {
|
||||
if (
|
||||
currentContextDependency !== null &&
|
||||
!Object.prototype.hasOwnProperty.call(currentContextDependency, "memoizedValue")
|
||||
) {
|
||||
setupContexts(contextMap, fiber);
|
||||
}
|
||||
|
||||
if (fiber.tag === FORWARD_REF_TAG) {
|
||||
return performDispatcherInspection(dispatcherRef, () => {
|
||||
(type as { render: (props: Record<string, unknown>, ref: unknown) => unknown }).render(
|
||||
props,
|
||||
fiber.ref,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return performDispatcherInspection(dispatcherRef, () => {
|
||||
(type as (props: Record<string, unknown>) => unknown)(props);
|
||||
});
|
||||
} finally {
|
||||
currentFiber = null;
|
||||
currentHook = null;
|
||||
currentContextDependency = null;
|
||||
currentThenableState = null;
|
||||
currentThenableIndex = 0;
|
||||
restoreContexts(contextMap);
|
||||
restoreConsole(originalConsoleMethods);
|
||||
}
|
||||
};
|
||||
725
node_modules/bippy/src/source/owner-stack.ts
generated
vendored
Normal file
725
node_modules/bippy/src/source/owner-stack.ts
generated
vendored
Normal file
@@ -0,0 +1,725 @@
|
||||
import {
|
||||
_renderers,
|
||||
ActivityComponentTag,
|
||||
ClassComponentTag,
|
||||
Fiber,
|
||||
ForwardRefTag,
|
||||
FunctionComponentTag,
|
||||
getRDTHook,
|
||||
HostComponentTag,
|
||||
HostHoistableTag,
|
||||
HostSingletonTag,
|
||||
LazyComponentTag,
|
||||
SimpleMemoComponentTag,
|
||||
SuspenseComponentTag,
|
||||
SuspenseListComponentTag,
|
||||
ViewTransitionComponentTag,
|
||||
getDisplayName,
|
||||
traverseFiber,
|
||||
} from "../core.js";
|
||||
import { ServerComponentInfo } from "../types.js";
|
||||
import {
|
||||
SERVER_FRAME_MARKER,
|
||||
SERVER_ENV_PATTERN,
|
||||
SERVER_COMPONENT_URL_PREFIXES,
|
||||
} from "./constants.js";
|
||||
|
||||
import { parseDebugStack } from "./parse-debug-stack.js";
|
||||
import { parseStack, StackFrame } from "./parse-stack.js";
|
||||
import { symbolicateStack } from "./symbolication.js";
|
||||
|
||||
export const hasDebugStack = (
|
||||
fiber: Fiber,
|
||||
): fiber is Fiber & {
|
||||
_debugStack: NonNullable<Fiber["_debugStack"]>;
|
||||
} => {
|
||||
return fiber._debugStack instanceof Error && typeof fiber._debugStack?.stack === "string";
|
||||
};
|
||||
|
||||
const isFiberOwner = (owner: Fiber | ServerComponentInfo): owner is Fiber =>
|
||||
typeof (owner as Fiber).tag === "number";
|
||||
|
||||
// react's typings (and bippy's Fiber, which mirrors them) declare _debugOwner
|
||||
// as a Fiber, but react 19 flight sets a ReactComponentInfo object for server
|
||||
// component owners
|
||||
const getDebugOwner = (fiber: Fiber): Fiber | ServerComponentInfo | undefined =>
|
||||
fiber._debugOwner as Fiber | ServerComponentInfo | undefined;
|
||||
|
||||
/**
|
||||
* Locates a frame inside the fiber's own function body without invoking it:
|
||||
* any child fiber owned by this fiber was created by JSX inside its body, so
|
||||
* the bottom user-space frame of that child's _debugStack sits in this
|
||||
* component. The enclosing line/column (V8 CallSite API) points at the
|
||||
* function definition start. Requires React 19 (_debugStack).
|
||||
*/
|
||||
export const getDefinitionFrameFromOwnedChild = (fiber: Fiber): StackFrame | null => {
|
||||
let ownedChildDebugStack: Error | null = null;
|
||||
traverseFiber(fiber, (childFiber) => {
|
||||
if (childFiber === fiber) {
|
||||
return false;
|
||||
}
|
||||
const childOwner = childFiber._debugOwner;
|
||||
if (
|
||||
(childOwner === fiber || (fiber.alternate !== null && childOwner === fiber.alternate)) &&
|
||||
childFiber._debugStack instanceof Error
|
||||
) {
|
||||
ownedChildDebugStack = childFiber._debugStack;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!ownedChildDebugStack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { frames, isTrusted } = parseDebugStack(ownedChildDebugStack);
|
||||
if (!isTrusted) {
|
||||
return null;
|
||||
}
|
||||
for (let frameIndex = frames.length - 1; frameIndex >= 0; frameIndex--) {
|
||||
const stackFrame = frames[frameIndex];
|
||||
if (!stackFrame.fileName) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
...stackFrame,
|
||||
lineNumber: stackFrame.enclosingLineNumber || stackFrame.lineNumber,
|
||||
columnNumber: stackFrame.enclosingColumnNumber || stackFrame.columnNumber,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getCurrentDispatcher = (): null | React.RefObject<unknown> => {
|
||||
const rdtHook = getRDTHook();
|
||||
for (const renderer of [...Array.from(_renderers), ...Array.from(rdtHook.renderers.values())]) {
|
||||
const currentDispatcherRef = renderer.currentDispatcherRef;
|
||||
if (currentDispatcherRef && typeof currentDispatcherRef === "object") {
|
||||
return "H" in currentDispatcherRef ? currentDispatcherRef.H : currentDispatcherRef.current;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const setCurrentDispatcher = (value: null | React.RefObject<unknown>): void => {
|
||||
for (const renderer of _renderers) {
|
||||
const currentDispatcherRef = renderer.currentDispatcherRef;
|
||||
if (currentDispatcherRef && typeof currentDispatcherRef === "object") {
|
||||
if ("H" in currentDispatcherRef) {
|
||||
currentDispatcherRef.H = value;
|
||||
} else {
|
||||
currentDispatcherRef.current = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const describeBuiltInComponentFrame = (name: string): string => {
|
||||
return `\n in ${name}`;
|
||||
};
|
||||
|
||||
export const describeDebugInfoFrame = (name: string, env?: string): string => {
|
||||
let frameDescription = describeBuiltInComponentFrame(name);
|
||||
if (env) {
|
||||
frameDescription += ` (at ${env})`;
|
||||
}
|
||||
return frameDescription;
|
||||
};
|
||||
|
||||
let reEntry = false;
|
||||
|
||||
// Computing a frame throws and parses two full error stacks, so cache per
|
||||
// component type like React DevTools does.
|
||||
const componentFrameCache = new WeakMap<React.ComponentType<unknown>, string>();
|
||||
|
||||
// https://github.com/facebook/react/blob/f739642745577a8e4dcb9753836ac3589b9c590a/packages/react-devtools-shared/src/backend/shared/DevToolsComponentStackFrame.js#L22
|
||||
const describeNativeComponentFrame = (
|
||||
component: React.ComponentType<unknown>,
|
||||
construct: boolean,
|
||||
): string => {
|
||||
if (!component || reEntry) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const cachedFrame = componentFrameCache.get(component);
|
||||
if (cachedFrame !== undefined) {
|
||||
return cachedFrame;
|
||||
}
|
||||
|
||||
const previousPrepareStackTrace = Error.prepareStackTrace;
|
||||
// HACK: V8 API allows undefined but bun-types declares it as non-optional
|
||||
(Error as { prepareStackTrace?: typeof Error.prepareStackTrace }).prepareStackTrace = undefined;
|
||||
reEntry = true;
|
||||
|
||||
const previousDispatcher = getCurrentDispatcher();
|
||||
setCurrentDispatcher(null);
|
||||
const previousConsoleError = console.error;
|
||||
const previousConsoleWarn = console.warn;
|
||||
console.error = () => {};
|
||||
console.warn = () => {};
|
||||
try {
|
||||
/**
|
||||
* Finding a common stack frame between sample and control errors can be
|
||||
* tricky given the different types and levels of stack trace truncation from
|
||||
* different JS VMs. So instead we'll attempt to control what that common
|
||||
* frame should be through this object method:
|
||||
* Having both the sample and control errors be in the function under the
|
||||
* `DescribeNativeComponentFrameRoot` property, + setting the `name` and
|
||||
* `displayName` properties of the function ensures that a stack
|
||||
* frame exists that has the method name `DescribeNativeComponentFrameRoot` in
|
||||
* it for both control and sample stacks.
|
||||
*/
|
||||
const RunInRootFrame = {
|
||||
DetermineComponentFrameRoot() {
|
||||
let control: unknown;
|
||||
try {
|
||||
// This should throw.
|
||||
if (construct) {
|
||||
// Something should be setting the props in the constructor.
|
||||
const ThrowingConstructor = function () {
|
||||
throw Error();
|
||||
};
|
||||
Object.defineProperty(ThrowingConstructor.prototype, "props", {
|
||||
set: function () {
|
||||
// We use a throwing setter instead of frozen or non-writable props
|
||||
// because that won't throw in a non-strict mode function.
|
||||
throw Error();
|
||||
},
|
||||
});
|
||||
if (typeof Reflect === "object" && Reflect.construct) {
|
||||
// We construct a different control for this case to include any extra
|
||||
// frames added by the construct call.
|
||||
try {
|
||||
Reflect.construct(ThrowingConstructor, []);
|
||||
} catch (caughtError) {
|
||||
control = caughtError;
|
||||
}
|
||||
Reflect.construct(component, [], ThrowingConstructor);
|
||||
} else {
|
||||
try {
|
||||
// @ts-expect-error -- ThrowingConstructor is a constructor function
|
||||
ThrowingConstructor.call();
|
||||
} catch (caughtError) {
|
||||
control = caughtError;
|
||||
}
|
||||
// @ts-expect-error -- ThrowingConstructor is a constructor function
|
||||
component.call(ThrowingConstructor.prototype);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
throw Error();
|
||||
} catch (caughtError) {
|
||||
control = caughtError;
|
||||
}
|
||||
// TODO(luna): This will currently only throw if the function component
|
||||
// tries to access React/ReactDOM/props. We should probably make this throw
|
||||
// in simple components too
|
||||
const maybePromise = (component as () => Promise<unknown>)();
|
||||
|
||||
// If the function component returns a promise, it's likely an async
|
||||
// component, which we don't yet support. Attach a noop catch handler to
|
||||
// silence the error.
|
||||
// TODO: Implement component stacks for async client components?
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- we literally check if this is a promise here
|
||||
if (maybePromise && typeof maybePromise.catch === "function") {
|
||||
maybePromise.catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (sample: unknown) {
|
||||
// This is inlined manually because closure doesn't do it for us.
|
||||
if (
|
||||
sample instanceof Error &&
|
||||
control instanceof Error &&
|
||||
typeof sample.stack === "string"
|
||||
) {
|
||||
return [sample.stack, control.stack];
|
||||
}
|
||||
}
|
||||
return [null, null];
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error --- displayName is not a property of the function
|
||||
RunInRootFrame.DetermineComponentFrameRoot.displayName = "DetermineComponentFrameRoot";
|
||||
const namePropDescriptor = Object.getOwnPropertyDescriptor(
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
RunInRootFrame.DetermineComponentFrameRoot,
|
||||
"name",
|
||||
);
|
||||
// Before ES6, the `name` property was not configurable.
|
||||
if (namePropDescriptor?.configurable) {
|
||||
// V8 utilizes a function's `name` property when generating a stack trace.
|
||||
Object.defineProperty(
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
RunInRootFrame.DetermineComponentFrameRoot,
|
||||
// Configurable properties can be updated even if its writable descriptor
|
||||
// is set to `false`.
|
||||
"name",
|
||||
{ value: "DetermineComponentFrameRoot" },
|
||||
);
|
||||
}
|
||||
|
||||
const [sampleStack, controlStack] = RunInRootFrame.DetermineComponentFrameRoot();
|
||||
if (sampleStack && controlStack) {
|
||||
// This extracts the first frame from the sample that isn't also in the control.
|
||||
// Skipping one frame that we assume is the frame that calls the two.
|
||||
const sampleLines = sampleStack.split("\n");
|
||||
const controlLines = controlStack.split("\n");
|
||||
let sampleIndex = 0;
|
||||
let controlIndex = 0;
|
||||
while (
|
||||
sampleIndex < sampleLines.length &&
|
||||
!sampleLines[sampleIndex].includes("DetermineComponentFrameRoot")
|
||||
) {
|
||||
sampleIndex++;
|
||||
}
|
||||
while (
|
||||
controlIndex < controlLines.length &&
|
||||
!controlLines[controlIndex].includes("DetermineComponentFrameRoot")
|
||||
) {
|
||||
controlIndex++;
|
||||
}
|
||||
// We couldn't find our intentionally injected common root frame, attempt
|
||||
// to find another common root frame by search from the bottom of the
|
||||
// control stack...
|
||||
if (sampleIndex === sampleLines.length || controlIndex === controlLines.length) {
|
||||
sampleIndex = sampleLines.length - 1;
|
||||
controlIndex = controlLines.length - 1;
|
||||
while (
|
||||
sampleIndex >= 1 &&
|
||||
controlIndex >= 0 &&
|
||||
sampleLines[sampleIndex] !== controlLines[controlIndex]
|
||||
) {
|
||||
// We expect at least one stack frame to be shared.
|
||||
// Typically this will be the root most one. However, stack frames may be
|
||||
// cut off due to maximum stack limits. In this case, one maybe cut off
|
||||
// earlier than the other. We assume that the sample is longer or the same
|
||||
// and there for cut off earlier. So we should find the root most frame in
|
||||
// the sample somewhere in the control.
|
||||
controlIndex--;
|
||||
}
|
||||
}
|
||||
for (; sampleIndex >= 1 && controlIndex >= 0; sampleIndex--, controlIndex--) {
|
||||
// Next we find the first one that isn't the same which should be the
|
||||
// frame that called our sample function and the control.
|
||||
if (sampleLines[sampleIndex] !== controlLines[controlIndex]) {
|
||||
// In V8, the first line is describing the message but other VMs don't.
|
||||
// If we're about to return the first line, and the control is also on the same
|
||||
// line, that's a pretty good indicator that our sample threw at same line as
|
||||
// the control. I.e. before we entered the sample frame. So we ignore this result.
|
||||
// This can happen if you passed a class to function component, or non-function.
|
||||
if (sampleIndex !== 1 || controlIndex !== 1) {
|
||||
do {
|
||||
sampleIndex--;
|
||||
controlIndex--;
|
||||
// We may still have similar intermediate frames from the construct call.
|
||||
// The next one that isn't the same should be our match though.
|
||||
if (controlIndex < 0 || sampleLines[sampleIndex] !== controlLines[controlIndex]) {
|
||||
// V8 adds a "new" prefix for native classes. Let's remove it to make it prettier.
|
||||
let stackFrame = `\n${sampleLines[sampleIndex].replace(" at new ", " at ")}`;
|
||||
|
||||
const displayName = getDisplayName(component);
|
||||
// If our component frame is labeled "<anonymous>"
|
||||
// but we have a user-provided "displayName"
|
||||
// splice it in to make the stack more readable.
|
||||
if (displayName && stackFrame.includes("<anonymous>")) {
|
||||
stackFrame = stackFrame.replace("<anonymous>", displayName);
|
||||
}
|
||||
// Return the line we found.
|
||||
componentFrameCache.set(component, stackFrame);
|
||||
return stackFrame;
|
||||
}
|
||||
} while (sampleIndex >= 1 && controlIndex >= 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reEntry = false;
|
||||
|
||||
Error.prepareStackTrace = previousPrepareStackTrace;
|
||||
|
||||
setCurrentDispatcher(previousDispatcher);
|
||||
console.error = previousConsoleError;
|
||||
console.warn = previousConsoleWarn;
|
||||
}
|
||||
|
||||
const componentName = component ? getDisplayName(component) : "";
|
||||
const syntheticFrame = componentName ? describeBuiltInComponentFrame(componentName) : "";
|
||||
componentFrameCache.set(component, syntheticFrame);
|
||||
return syntheticFrame;
|
||||
};
|
||||
|
||||
// https://github.com/facebook/react/blob/ac3e705a18696168acfcaed39dce0cfaa6be8836/packages/react-reconciler/src/ReactFiberComponentStack.js#L180
|
||||
export const describeFiber = (fiber: Fiber, childFiber: Fiber | null): string => {
|
||||
const tag = fiber.tag as number;
|
||||
let stackFrame = "";
|
||||
switch (tag) {
|
||||
case ActivityComponentTag:
|
||||
stackFrame = describeBuiltInComponentFrame("Activity");
|
||||
break;
|
||||
case ClassComponentTag:
|
||||
stackFrame = describeNativeComponentFrame(fiber.type, true);
|
||||
break;
|
||||
case ForwardRefTag:
|
||||
stackFrame = describeNativeComponentFrame(
|
||||
(fiber.type as { render: React.ComponentType<unknown> }).render,
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case FunctionComponentTag:
|
||||
case SimpleMemoComponentTag:
|
||||
stackFrame = describeNativeComponentFrame(fiber.type, false);
|
||||
break;
|
||||
case HostComponentTag:
|
||||
case HostHoistableTag:
|
||||
case HostSingletonTag:
|
||||
stackFrame = describeBuiltInComponentFrame(fiber.type as string);
|
||||
break;
|
||||
case LazyComponentTag:
|
||||
// TODO: When we support Thenables as component types we should rename this.
|
||||
stackFrame = describeBuiltInComponentFrame("Lazy");
|
||||
break;
|
||||
case SuspenseComponentTag:
|
||||
if (fiber.child !== childFiber && childFiber !== null) {
|
||||
// If we came from the second Fiber then we're in the Suspense Fallback.
|
||||
stackFrame = describeBuiltInComponentFrame("Suspense Fallback");
|
||||
} else {
|
||||
stackFrame = describeBuiltInComponentFrame("Suspense");
|
||||
}
|
||||
break;
|
||||
case SuspenseListComponentTag:
|
||||
stackFrame = describeBuiltInComponentFrame("SuspenseList");
|
||||
break;
|
||||
case ViewTransitionComponentTag:
|
||||
// Note: enableViewTransition feature flag is not available in this codebase,
|
||||
// so we'll always include ViewTransition
|
||||
stackFrame = describeBuiltInComponentFrame("ViewTransition");
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
return stackFrame;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a component-stack string by walking the fiber `return` chain, the
|
||||
* pre-react-19 substitute for `_debugStack` (which is why the frame locations
|
||||
* come from re-invocation in {@link describeFiber} rather than a real stack).
|
||||
*/
|
||||
export const getFallbackParentStack = (thisFiber: Fiber): string => {
|
||||
try {
|
||||
let componentStack = "";
|
||||
let currentFiber: Fiber | null = thisFiber;
|
||||
let previousFiber: Fiber | null = null;
|
||||
do {
|
||||
componentStack += describeFiber(currentFiber, previousFiber);
|
||||
|
||||
// Add any Server Component stack frames in reverse order (dev only).
|
||||
// Since we don't have __DEV__ in this codebase, we'll check for _debugInfo
|
||||
const debugInfo = currentFiber._debugInfo;
|
||||
if (debugInfo && Array.isArray(debugInfo)) {
|
||||
for (let i = debugInfo.length - 1; i >= 0; i--) {
|
||||
const debugEntry = debugInfo[i];
|
||||
if (typeof debugEntry.name === "string") {
|
||||
componentStack += describeDebugInfoFrame(debugEntry.name, debugEntry.env);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousFiber = currentFiber;
|
||||
currentFiber = currentFiber.return;
|
||||
} while (currentFiber);
|
||||
return componentStack;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return `\nError generating stack: ${error.message}\n${error.stack}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* takes Error.stack and formats it to only the React owner stack
|
||||
*
|
||||
* before:
|
||||
* ```
|
||||
* Error: react-stack-top-frame
|
||||
* at fakeJSXCallSite (http://localhost:3000/_next/static/chunks/<chunk-name>._.js:17665:16)
|
||||
* at TodoItem (rsc://React/Server/file:///path/to/project/.next/server/chunks/ssr/<chunk-name>._.js)
|
||||
* at react-stack-bottom-frame (http://localhost:3000/_next/static/chunks/<chunk-name>._.js:17984:89)
|
||||
* ```
|
||||
*
|
||||
* after:
|
||||
* ```
|
||||
* at TodoItem (rsc://React/Server/file:///path/to/project/.next/server/chunks/ssr/<chunk-name>._.js)
|
||||
* ```
|
||||
*
|
||||
* @see https://github.com/facebook/react/blob/main/packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js#L12
|
||||
*/
|
||||
export const formatOwnerStack = (stack: string): string => {
|
||||
const prevPrepareStackTrace = Error.prepareStackTrace;
|
||||
// HACK: V8 API allows undefined but bun-types declares it as non-optional
|
||||
(Error as { prepareStackTrace?: typeof Error.prepareStackTrace }).prepareStackTrace = undefined;
|
||||
let formattedStack = stack;
|
||||
if (!formattedStack) {
|
||||
return "";
|
||||
}
|
||||
Error.prepareStackTrace = prevPrepareStackTrace;
|
||||
|
||||
if (formattedStack.startsWith("Error: react-stack-top-frame\n")) {
|
||||
// V8's default formatting prefixes with the error message which we
|
||||
// don't want/need
|
||||
formattedStack = formattedStack.slice(29);
|
||||
}
|
||||
const firstNewlineIndex = formattedStack.indexOf("\n");
|
||||
if (firstNewlineIndex !== -1) {
|
||||
// pop the JSX frame
|
||||
formattedStack = formattedStack.slice(firstNewlineIndex + 1);
|
||||
}
|
||||
let bottomFrameIndex = Math.max(
|
||||
formattedStack.indexOf("react_stack_bottom_frame"),
|
||||
formattedStack.indexOf("react-stack-bottom-frame"),
|
||||
);
|
||||
if (bottomFrameIndex !== -1) {
|
||||
bottomFrameIndex = formattedStack.lastIndexOf("\n", bottomFrameIndex);
|
||||
}
|
||||
if (bottomFrameIndex !== -1) {
|
||||
// cut off everything after the bottom frame since it'll be internals.
|
||||
formattedStack = formattedStack.slice(0, bottomFrameIndex);
|
||||
} else {
|
||||
// we didn't find any internal callsite out to user space.
|
||||
// This means that this was called outside an owner or the owner is fully internal.
|
||||
// to keep things light we exclude the entire trace in this case.
|
||||
return "";
|
||||
}
|
||||
return formattedStack;
|
||||
};
|
||||
|
||||
interface DebugStackEntry {
|
||||
componentName: string;
|
||||
stackFrames: StackFrame[];
|
||||
}
|
||||
|
||||
const isReactServerComponentFrame = (stackFrame: StackFrame): boolean =>
|
||||
Boolean(
|
||||
stackFrame.functionName && stackFrame.fileName && isServerComponentUrl(stackFrame.fileName),
|
||||
);
|
||||
|
||||
const areStackFramesEqual = (firstFrame: StackFrame, secondFrame: StackFrame): boolean =>
|
||||
firstFrame.fileName === secondFrame.fileName &&
|
||||
firstFrame.lineNumber === secondFrame.lineNumber &&
|
||||
firstFrame.columnNumber === secondFrame.columnNumber;
|
||||
|
||||
const buildFunctionNameToRscFramesMap = (
|
||||
debugStackEntries: DebugStackEntry[],
|
||||
): Map<string, StackFrame[]> => {
|
||||
const functionNameToRscFrames = new Map<string, StackFrame[]>();
|
||||
|
||||
for (const debugStackEntry of debugStackEntries) {
|
||||
for (const stackFrame of debugStackEntry.stackFrames) {
|
||||
if (!isReactServerComponentFrame(stackFrame)) continue;
|
||||
|
||||
const functionName = stackFrame.functionName!;
|
||||
const framesForFunction = functionNameToRscFrames.get(functionName) ?? [];
|
||||
const isDuplicateFrame = framesForFunction.some((existingFrame) =>
|
||||
areStackFramesEqual(existingFrame, stackFrame),
|
||||
);
|
||||
|
||||
if (!isDuplicateFrame) {
|
||||
framesForFunction.push(stackFrame);
|
||||
functionNameToRscFrames.set(functionName, framesForFunction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return functionNameToRscFrames;
|
||||
};
|
||||
|
||||
const getEnrichedServerStackFrame = (
|
||||
serverFrame: StackFrame,
|
||||
functionNameToRscFrames: Map<string, StackFrame[]>,
|
||||
functionNameToUsageIndex: Map<string, number>,
|
||||
): StackFrame => {
|
||||
if (!serverFrame.functionName) {
|
||||
return { ...serverFrame, isServer: true };
|
||||
}
|
||||
|
||||
const availableRscFrames = functionNameToRscFrames.get(serverFrame.functionName);
|
||||
if (!availableRscFrames || availableRscFrames.length === 0) {
|
||||
return { ...serverFrame, isServer: true };
|
||||
}
|
||||
|
||||
const currentUsageIndex = functionNameToUsageIndex.get(serverFrame.functionName) ?? 0;
|
||||
const resolvedRscFrame = availableRscFrames[currentUsageIndex % availableRscFrames.length];
|
||||
functionNameToUsageIndex.set(serverFrame.functionName, currentUsageIndex + 1);
|
||||
|
||||
return {
|
||||
...serverFrame,
|
||||
isServer: true,
|
||||
fileName: resolvedRscFrame.fileName,
|
||||
lineNumber: resolvedRscFrame.lineNumber,
|
||||
columnNumber: resolvedRscFrame.columnNumber,
|
||||
source: serverFrame.source?.replace(
|
||||
SERVER_FRAME_MARKER,
|
||||
`(${resolvedRscFrame.fileName}:${resolvedRscFrame.lineNumber}:${resolvedRscFrame.columnNumber})`,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const isServerComponentUrl = (url: string): boolean =>
|
||||
SERVER_COMPONENT_URL_PREFIXES.some((prefix) => url.startsWith(prefix));
|
||||
|
||||
// flight installs fake server frames (rsc:// or about://React/ urls) into
|
||||
// client-side debug stacks, so server detection must be per-frame
|
||||
const markFlightServerFrame = (stackFrame: StackFrame): StackFrame =>
|
||||
!stackFrame.isServer && stackFrame.fileName && isServerComponentUrl(stackFrame.fileName)
|
||||
? { ...stackFrame, isServer: true }
|
||||
: stackFrame;
|
||||
|
||||
/**
|
||||
* Builds owner-chain stack frames from the _debugStack errors React 19
|
||||
* attaches at JSX creation, walking `_debugOwner` across both client fibers
|
||||
* and server components (ReactComponentInfo, which chains via `.owner` and
|
||||
* carries `.debugStack`). This is exact - no re-invoking components, no
|
||||
* name-matching heuristics - but requires React 19.
|
||||
*/
|
||||
const getOwnerStackFromDebugStacks = (fiber: Fiber): StackFrame[] => {
|
||||
const ownerStackFrames: StackFrame[] = [];
|
||||
let owner: Fiber | ServerComponentInfo | null | undefined = fiber;
|
||||
while (owner) {
|
||||
if (isFiberOwner(owner)) {
|
||||
const ownerFiber: Fiber = owner;
|
||||
owner = getDebugOwner(ownerFiber);
|
||||
if (owner && hasDebugStack(ownerFiber)) {
|
||||
const { frames, isTrusted } = parseDebugStack(ownerFiber._debugStack);
|
||||
if (isTrusted) {
|
||||
for (const stackFrame of frames) {
|
||||
ownerStackFrames.push(markFlightServerFrame(stackFrame));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const serverOwner: ServerComponentInfo = owner;
|
||||
owner = serverOwner.owner;
|
||||
// server stacks are captured and pre-trimmed by flight, so they carry
|
||||
// no bottom-frame sentinel and are trusted as-is
|
||||
if (owner && serverOwner.debugStack instanceof Error) {
|
||||
for (const serverFrame of parseDebugStack(serverOwner.debugStack).frames) {
|
||||
ownerStackFrames.push({ ...serverFrame, isServer: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ownerStackFrames;
|
||||
};
|
||||
|
||||
const getDebugStackEntries = (rootFiber: Fiber): DebugStackEntry[] => {
|
||||
const debugStackEntries: DebugStackEntry[] = [];
|
||||
|
||||
traverseFiber(
|
||||
rootFiber,
|
||||
(currentFiber) => {
|
||||
if (!hasDebugStack(currentFiber)) return;
|
||||
|
||||
const componentName =
|
||||
typeof currentFiber.type !== "string"
|
||||
? getDisplayName(currentFiber.type) || "<anonymous>"
|
||||
: currentFiber.type;
|
||||
|
||||
debugStackEntries.push({
|
||||
componentName,
|
||||
stackFrames: parseStack(formatOwnerStack(currentFiber._debugStack?.stack)),
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
return debugStackEntries;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a stack of ALL ancestor components in the render tree (the fiber's
|
||||
* `return` chain), including wrappers that render `{children}` without having
|
||||
* created this fiber's JSX. Locations come from re-invoking each component
|
||||
* with a throwing dispatcher; server frames are enriched from debug stacks by
|
||||
* name matching. Works on every React version.
|
||||
*/
|
||||
export const getParentStack = async (
|
||||
fiber: Fiber,
|
||||
shouldCache = true,
|
||||
fetchFunction?: (url: string) => Promise<Response>,
|
||||
): Promise<StackFrame[]> => {
|
||||
const debugStackEntries = getDebugStackEntries(fiber);
|
||||
const fallbackStackFrames = parseStack(getFallbackParentStack(fiber));
|
||||
const functionNameToRscFrames = buildFunctionNameToRscFramesMap(debugStackEntries);
|
||||
const functionNameToUsageIndex = new Map<string, number>();
|
||||
|
||||
const enrichedStackFrames = fallbackStackFrames.map((stackFrame): StackFrame => {
|
||||
const isServerFrame =
|
||||
(stackFrame.source?.includes(SERVER_FRAME_MARKER) ?? false) ||
|
||||
(stackFrame.source != null && SERVER_ENV_PATTERN.test(stackFrame.source));
|
||||
|
||||
if (isServerFrame) {
|
||||
return getEnrichedServerStackFrame(
|
||||
stackFrame,
|
||||
functionNameToRscFrames,
|
||||
functionNameToUsageIndex,
|
||||
);
|
||||
}
|
||||
|
||||
return stackFrame;
|
||||
});
|
||||
|
||||
const deduplicatedStackFrames = enrichedStackFrames.filter((stackFrame, index, frames) => {
|
||||
if (index === 0) return true;
|
||||
const previousFrame = frames[index - 1];
|
||||
return stackFrame.functionName !== previousFrame.functionName;
|
||||
});
|
||||
|
||||
return symbolicateStack(deduplicatedStackFrames, shouldCache, fetchFunction);
|
||||
};
|
||||
|
||||
// an owner frame is only actionable if it can point an editor somewhere:
|
||||
// it needs a file location and must not be ignore-listed bundler/framework code
|
||||
const isLocatableFrame = (stackFrame: StackFrame): boolean =>
|
||||
Boolean(stackFrame.fileName) && !stackFrame.isIgnoreListed;
|
||||
|
||||
/**
|
||||
* Returns the stack of components that CREATED this fiber's JSX (the
|
||||
* `_debugOwner` chain), with exact creation-site locations from React 19's
|
||||
* `_debugStack` errors - including server component owners. Wrappers that
|
||||
* merely render `{children}` do not appear; use {@link getParentStack} for
|
||||
* the full render-tree ancestry. Falls back to {@link getParentStack} on
|
||||
* React <19, when no trusted debug stacks exist, or when the owner chain
|
||||
* yields no locatable frames (so callers always get the most useful stack
|
||||
* available).
|
||||
*/
|
||||
export const getOwnerStack = async (
|
||||
fiber: Fiber,
|
||||
shouldCache = true,
|
||||
fetchFunction?: (url: string) => Promise<Response>,
|
||||
): Promise<StackFrame[]> => {
|
||||
const debugStackFrames = getOwnerStackFromDebugStacks(fiber);
|
||||
if (debugStackFrames.length > 0) {
|
||||
// the owner chain does not include the fiber itself, but bippy's stacks
|
||||
// always start with the fiber's own frame
|
||||
const selfFrame: StackFrame = getDefinitionFrameFromOwnedChild(fiber) ?? {};
|
||||
selfFrame.functionName = getDisplayName(fiber.type) ?? selfFrame.functionName;
|
||||
const symbolicatedFrames = await symbolicateStack(
|
||||
[selfFrame, ...debugStackFrames],
|
||||
shouldCache,
|
||||
fetchFunction,
|
||||
);
|
||||
const hasLocatableOwnerFrame = symbolicatedFrames.some(
|
||||
(stackFrame, frameIndex) => frameIndex > 0 && isLocatableFrame(stackFrame),
|
||||
);
|
||||
if (hasLocatableOwnerFrame) {
|
||||
return symbolicatedFrames;
|
||||
}
|
||||
}
|
||||
|
||||
return getParentStack(fiber, shouldCache, fetchFunction);
|
||||
};
|
||||
133
node_modules/bippy/src/source/parse-debug-stack.ts
generated
vendored
Normal file
133
node_modules/bippy/src/source/parse-debug-stack.ts
generated
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
import { JSX_FACTORY_FRAME_COUNT, REACT_STACK_BOTTOM_FRAME_PATTERNS } from "./constants.js";
|
||||
import { parseStack, StackFrame } from "./parse-stack.js";
|
||||
|
||||
interface V8CallSite {
|
||||
getFunctionName?: () => string | null;
|
||||
getScriptNameOrSourceURL?: () => string | null;
|
||||
getLineNumber?: () => number | null;
|
||||
getColumnNumber?: () => number | null;
|
||||
getEnclosingLineNumber?: () => number | null;
|
||||
getEnclosingColumnNumber?: () => number | null;
|
||||
getTypeName?: () => string | null;
|
||||
getMethodName?: () => string | null;
|
||||
getEvalOrigin?: () => string | null;
|
||||
isNative?: () => boolean;
|
||||
isEval?: () => boolean;
|
||||
toString: () => string;
|
||||
}
|
||||
|
||||
export interface ParsedDebugStack {
|
||||
frames: StackFrame[];
|
||||
// React appends a react-stack-bottom-frame sentinel to stacks captured
|
||||
// during render; without it the JSX was created outside a render and the
|
||||
// lower frames are arbitrary bootstrapping code
|
||||
isTrusted: boolean;
|
||||
}
|
||||
|
||||
const parsedDebugStackCache = new WeakMap<Error, ParsedDebugStack>();
|
||||
|
||||
const isReactBottomFrameName = (functionName: string): boolean =>
|
||||
REACT_STACK_BOTTOM_FRAME_PATTERNS.some((pattern) => functionName.includes(pattern));
|
||||
|
||||
const getCallSiteFunctionName = (callSite: V8CallSite): string => {
|
||||
const functionName = callSite.getFunctionName?.() ?? "";
|
||||
if (functionName) {
|
||||
return functionName;
|
||||
}
|
||||
const typeName = callSite.getTypeName?.() ?? "";
|
||||
const methodName = callSite.getMethodName?.() ?? "";
|
||||
if (typeName && methodName) {
|
||||
return `${typeName}.${methodName}`;
|
||||
}
|
||||
return methodName;
|
||||
};
|
||||
|
||||
const collectStructuredFrames = (callSites: V8CallSite[]): ParsedDebugStack => {
|
||||
const frames: StackFrame[] = [];
|
||||
for (
|
||||
let callSiteIndex = JSX_FACTORY_FRAME_COUNT;
|
||||
callSiteIndex < callSites.length;
|
||||
callSiteIndex++
|
||||
) {
|
||||
const callSite = callSites[callSiteIndex];
|
||||
const functionName = getCallSiteFunctionName(callSite);
|
||||
if (isReactBottomFrameName(functionName)) {
|
||||
return { frames, isTrusted: true };
|
||||
}
|
||||
if (callSite.isNative?.()) {
|
||||
frames.push({ functionName: functionName || undefined });
|
||||
continue;
|
||||
}
|
||||
let fileName = callSite.getScriptNameOrSourceURL?.() ?? "";
|
||||
if (!fileName && callSite.isEval?.()) {
|
||||
fileName = callSite.getEvalOrigin?.() ?? "";
|
||||
}
|
||||
frames.push({
|
||||
functionName: functionName && functionName !== "<anonymous>" ? functionName : undefined,
|
||||
fileName: fileName && fileName !== "<anonymous>" ? fileName : undefined,
|
||||
lineNumber: callSite.getLineNumber?.() ?? undefined,
|
||||
columnNumber: callSite.getColumnNumber?.() ?? undefined,
|
||||
enclosingLineNumber: callSite.getEnclosingLineNumber?.() ?? undefined,
|
||||
enclosingColumnNumber: callSite.getEnclosingColumnNumber?.() ?? undefined,
|
||||
source: ` at ${callSite.toString()}`,
|
||||
});
|
||||
}
|
||||
return { frames, isTrusted: false };
|
||||
};
|
||||
|
||||
const parseMaterializedStack = (stackString: string): ParsedDebugStack => {
|
||||
let bottomFrameIndex = -1;
|
||||
for (const pattern of REACT_STACK_BOTTOM_FRAME_PATTERNS) {
|
||||
bottomFrameIndex = stackString.indexOf(pattern);
|
||||
if (bottomFrameIndex !== -1) break;
|
||||
}
|
||||
const trimmedStack =
|
||||
bottomFrameIndex === -1
|
||||
? stackString
|
||||
: stackString.slice(0, stackString.lastIndexOf("\n", bottomFrameIndex));
|
||||
return {
|
||||
frames: parseStack(trimmedStack).slice(JSX_FACTORY_FRAME_COUNT),
|
||||
isTrusted: bottomFrameIndex !== -1,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a React `_debugStack` Error, preferring V8's structured CallSite API
|
||||
* (via Error.prepareStackTrace) over string parsing. Structured frames carry
|
||||
* enclosing line/column - the function definition start, not the call site -
|
||||
* and are immune to source-mapped `.stack` strings. Falls back to string
|
||||
* parsing on engines without CallSites (JSC, SpiderMonkey) or when the stack
|
||||
* was already materialized. The leading JSX factory frame (jsxDEV) and
|
||||
* everything at or below React's bottom-frame sentinel are dropped.
|
||||
*/
|
||||
export const parseDebugStack = (debugStack: Error): ParsedDebugStack => {
|
||||
const cachedResult = parsedDebugStackCache.get(debugStack);
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
let structuredResult: ParsedDebugStack | null = null;
|
||||
const collectFramesAndFormatStack = (error: Error, callSites: V8CallSite[]): string => {
|
||||
structuredResult = collectStructuredFrames(callSites);
|
||||
// this return value becomes error.stack permanently, so emit the default
|
||||
// V8 format for any later reader of the same error
|
||||
let stackString = `${error.name || "Error"}: ${error.message || ""}`;
|
||||
for (const callSite of callSites) {
|
||||
stackString += `\n at ${callSite.toString()}`;
|
||||
}
|
||||
return stackString;
|
||||
};
|
||||
const previousPrepareStackTrace = Error.prepareStackTrace;
|
||||
// node's CallSite typings disagree with browser-safe optional methods
|
||||
Error.prepareStackTrace = collectFramesAndFormatStack as typeof Error.prepareStackTrace;
|
||||
let stackString: string;
|
||||
try {
|
||||
stackString = String(debugStack.stack);
|
||||
} finally {
|
||||
Error.prepareStackTrace = previousPrepareStackTrace;
|
||||
}
|
||||
|
||||
const result = structuredResult ?? parseMaterializedStack(stackString);
|
||||
parsedDebugStackCache.set(debugStack, result);
|
||||
return result;
|
||||
};
|
||||
224
node_modules/bippy/src/source/parse-hook-names.ts
generated
vendored
Normal file
224
node_modules/bippy/src/source/parse-hook-names.ts
generated
vendored
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { HooksNode, HooksTree, HookSource } from "./inspect-hooks.js";
|
||||
import { getSourceMap, getSourceFromSourceMap, type SourceMap } from "./symbolication.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface HookNames extends Map<string, string> {}
|
||||
|
||||
const UNNAMED_HOOKS = new Set([
|
||||
"Effect",
|
||||
"LayoutEffect",
|
||||
"InsertionEffect",
|
||||
"ImperativeHandle",
|
||||
"DebugValue",
|
||||
]);
|
||||
|
||||
// HACK: matches `const/let/var [name, ...] = use...(...` or `const/let/var name = use...(...`
|
||||
// across up to 10 lines; handles TypeScript generics like `useState<T>(`
|
||||
const HOOK_DECLARATION_REGEX =
|
||||
/(?:const|let|var)\s+((?:\[[\s\S]*?\]|\w+))\s*=\s*(?:[\w$.]+\.)*use[A-Z]\w*\s*(?:<[\s\S]*?>)?\s*\(/g;
|
||||
|
||||
export const getHookSourceLocationKey = (hookSource: HookSource): string =>
|
||||
`${hookSource.fileName ?? ""}:${hookSource.lineNumber ?? 0}:${hookSource.columnNumber ?? 0}`;
|
||||
|
||||
const flattenHooksTree = (hooksTree: HooksTree): HooksNode[] => {
|
||||
const hooksList: HooksNode[] = [];
|
||||
const collectNamedHooks = (tree: HooksTree): void => {
|
||||
for (const hook of tree) {
|
||||
if (UNNAMED_HOOKS.has(hook.name)) continue;
|
||||
hooksList.push(hook);
|
||||
if (hook.subHooks.length > 0) collectNamedHooks(hook.subHooks);
|
||||
}
|
||||
};
|
||||
collectNamedHooks(hooksTree);
|
||||
return hooksList;
|
||||
};
|
||||
|
||||
const findSourceContentByFileName = (
|
||||
sources: string[],
|
||||
sourcesContent: string[] | undefined,
|
||||
fileName: string,
|
||||
): string | null => {
|
||||
if (!sourcesContent) return null;
|
||||
const sourceIndex = sources.indexOf(fileName);
|
||||
return sourceIndex !== -1 ? (sourcesContent[sourceIndex] ?? null) : null;
|
||||
};
|
||||
|
||||
const getSourceContentFromSourceMap = (
|
||||
sourceMap: SourceMap,
|
||||
originalFileName: string,
|
||||
): string | null => {
|
||||
const directResult = findSourceContentByFileName(
|
||||
sourceMap.sources,
|
||||
sourceMap.sourcesContent,
|
||||
originalFileName,
|
||||
);
|
||||
if (directResult) return directResult;
|
||||
|
||||
if (sourceMap.sections) {
|
||||
for (const section of sourceMap.sections) {
|
||||
const sectionResult = findSourceContentByFileName(
|
||||
section.map.sources,
|
||||
section.map.sourcesContent,
|
||||
originalFileName,
|
||||
);
|
||||
if (sectionResult) return sectionResult;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractVariableNameFromBinding = (binding: string): string | null => {
|
||||
const trimmed = binding.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
const match = trimmed.match(/\[\s*(\w+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
return /^\w+$/.test(trimmed) ? trimmed : null;
|
||||
};
|
||||
|
||||
export const extractHookVariableName = (
|
||||
sourceCode: string,
|
||||
lineNumber: number,
|
||||
columnNumber: number,
|
||||
): string | null => {
|
||||
const lines = sourceCode.split("\n");
|
||||
const hookLineIndex = lineNumber - 1;
|
||||
|
||||
if (hookLineIndex < 0 || hookLineIndex >= lines.length) return null;
|
||||
|
||||
const searchStartLine = Math.max(0, hookLineIndex - 10);
|
||||
const chunkLines = lines.slice(searchStartLine, hookLineIndex + 1);
|
||||
const sourceChunk = chunkLines.join("\n");
|
||||
|
||||
const allMatches = [...sourceChunk.matchAll(HOOK_DECLARATION_REGEX)];
|
||||
|
||||
const hookPositionInChunk = sourceChunk.lastIndexOf("\n") + 1 + columnNumber;
|
||||
const closestMatch = allMatches.filter((match) => match.index! <= hookPositionInChunk).at(-1);
|
||||
|
||||
if (closestMatch) {
|
||||
return extractVariableNameFromBinding(closestMatch[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface ResolvedSource {
|
||||
sourceCode: string;
|
||||
lineNumber: number;
|
||||
columnNumber: number;
|
||||
}
|
||||
|
||||
interface SourceResolutionContext {
|
||||
sourceMapsByFile: Map<string, SourceMap | null>;
|
||||
sourceContentCache: Map<string, string | null>;
|
||||
fetchFn?: (url: string) => Promise<Response>;
|
||||
}
|
||||
|
||||
const resolveOriginalSource = async (
|
||||
runtimeFileName: string,
|
||||
runtimeLine: number,
|
||||
runtimeColumn: number,
|
||||
context: SourceResolutionContext,
|
||||
): Promise<ResolvedSource | null> => {
|
||||
const { sourceMapsByFile, sourceContentCache, fetchFn } = context;
|
||||
|
||||
if (!sourceMapsByFile.has(runtimeFileName)) {
|
||||
sourceMapsByFile.set(runtimeFileName, await getSourceMap(runtimeFileName, true, fetchFn));
|
||||
}
|
||||
|
||||
const sourceMap = sourceMapsByFile.get(runtimeFileName) ?? null;
|
||||
|
||||
if (sourceMap) {
|
||||
const originalLocation = getSourceFromSourceMap(sourceMap, runtimeLine, runtimeColumn);
|
||||
if (originalLocation?.fileName && originalLocation.lineNumber !== undefined) {
|
||||
const cacheKey = `sourcemap:${runtimeFileName}:${originalLocation.fileName}`;
|
||||
if (!sourceContentCache.has(cacheKey)) {
|
||||
sourceContentCache.set(
|
||||
cacheKey,
|
||||
getSourceContentFromSourceMap(sourceMap, originalLocation.fileName),
|
||||
);
|
||||
}
|
||||
const originalSourceCode = sourceContentCache.get(cacheKey) ?? null;
|
||||
if (originalSourceCode) {
|
||||
return {
|
||||
sourceCode: originalSourceCode,
|
||||
lineNumber: originalLocation.lineNumber,
|
||||
columnNumber: originalLocation.columnNumber ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceContentCache.has(runtimeFileName)) {
|
||||
try {
|
||||
const fetchImpl = fetchFn ?? fetch;
|
||||
const response = await fetchImpl(runtimeFileName);
|
||||
sourceContentCache.set(runtimeFileName, response.ok ? await response.text() : null);
|
||||
} catch {
|
||||
sourceContentCache.set(runtimeFileName, null);
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeSourceCode = sourceContentCache.get(runtimeFileName) ?? null;
|
||||
if (runtimeSourceCode) {
|
||||
return {
|
||||
sourceCode: runtimeSourceCode,
|
||||
lineNumber: runtimeLine,
|
||||
columnNumber: runtimeColumn,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const parseHookNames = async (
|
||||
hooksTree: HooksTree,
|
||||
fetchFn?: (url: string) => Promise<Response>,
|
||||
): Promise<HookNames> => {
|
||||
const hookNames: HookNames = new Map();
|
||||
const hooksList = flattenHooksTree(hooksTree);
|
||||
|
||||
if (hooksList.length === 0) return hookNames;
|
||||
|
||||
const resolutionContext: SourceResolutionContext = {
|
||||
sourceMapsByFile: new Map(),
|
||||
sourceContentCache: new Map(),
|
||||
fetchFn,
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
hooksList.map(async (hook) => {
|
||||
const hookSource = hook.hookSource;
|
||||
if (
|
||||
!hookSource ||
|
||||
!hookSource.fileName ||
|
||||
hookSource.lineNumber === null ||
|
||||
hookSource.columnNumber === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = await resolveOriginalSource(
|
||||
hookSource.fileName,
|
||||
hookSource.lineNumber,
|
||||
hookSource.columnNumber,
|
||||
resolutionContext,
|
||||
);
|
||||
|
||||
if (!resolved) return;
|
||||
|
||||
const variableName = extractHookVariableName(
|
||||
resolved.sourceCode,
|
||||
resolved.lineNumber,
|
||||
resolved.columnNumber,
|
||||
);
|
||||
|
||||
if (variableName) {
|
||||
const locationKey = getHookSourceLocationKey(hookSource);
|
||||
hookNames.set(locationKey, variableName);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return hookNames;
|
||||
};
|
||||
150
node_modules/bippy/src/source/parse-stack.ts
generated
vendored
Normal file
150
node_modules/bippy/src/source/parse-stack.ts
generated
vendored
Normal file
@@ -0,0 +1,150 @@
|
||||
export interface StackFrame {
|
||||
args?: unknown[];
|
||||
columnNumber?: number;
|
||||
lineNumber?: number;
|
||||
// start of the enclosing function (the definition, not the call site);
|
||||
// only available from V8's structured CallSite API
|
||||
enclosingLineNumber?: number;
|
||||
enclosingColumnNumber?: number;
|
||||
fileName?: string;
|
||||
functionName?: string;
|
||||
source?: string;
|
||||
isServer?: boolean;
|
||||
isSymbolicated?: boolean;
|
||||
// the source map ignore-listed this frame's original source (x_google_ignoreList)
|
||||
isIgnoreListed?: boolean;
|
||||
}
|
||||
|
||||
export interface ParseOptions {
|
||||
slice?: number | [number, number];
|
||||
allowEmpty?: boolean;
|
||||
includeInElement?: boolean;
|
||||
}
|
||||
|
||||
const FIREFOX_SAFARI_STACK_REGEXP = /(^|@)\S+:\d+/;
|
||||
const CHROME_IE_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m;
|
||||
const SAFARI_NATIVE_CODE_REGEXP = /^(eval@)?(\[native code\])?$/;
|
||||
|
||||
export const parseStack = (stackString: string, options?: ParseOptions): StackFrame[] => {
|
||||
if (options?.includeInElement !== false) {
|
||||
const lines = stackString.split("\n");
|
||||
const frames: StackFrame[] = [];
|
||||
for (const rawLine of lines) {
|
||||
if (/^\s*at\s+/.test(rawLine)) {
|
||||
const parsed = parseV8OrIeString(rawLine, undefined)[0];
|
||||
if (parsed) frames.push(parsed);
|
||||
} else if (/^\s*in\s+/.test(rawLine)) {
|
||||
const elementName = rawLine.replace(/^\s*in\s+/, "").replace(/\s*\(at .*\)$/, "");
|
||||
frames.push({ functionName: elementName, source: rawLine });
|
||||
} else if (rawLine.match(FIREFOX_SAFARI_STACK_REGEXP)) {
|
||||
const parsed = parseFFOrSafariString(rawLine, undefined)[0];
|
||||
if (parsed) frames.push(parsed);
|
||||
}
|
||||
}
|
||||
return applySlice(frames, options);
|
||||
}
|
||||
if (stackString.match(CHROME_IE_STACK_REGEXP)) {
|
||||
return parseV8OrIeString(stackString, options);
|
||||
}
|
||||
return parseFFOrSafariString(stackString, options);
|
||||
};
|
||||
|
||||
export const extractLocation = (
|
||||
urlLike: string,
|
||||
): [string, string | undefined, string | undefined] => {
|
||||
if (!urlLike.includes(":")) return [urlLike, undefined, undefined];
|
||||
|
||||
// HACK: Chrome/V8 stack traces wrap location in parens: "(file.js:10:5)"
|
||||
// We need to strip these outer parens but preserve parens in paths (e.g., Next.js route groups like "(docs)")
|
||||
// Chrome format always ends with `:col)` where digit comes right before the closing paren
|
||||
const isWrappedLocation = urlLike.startsWith("(") && /:\d+\)$/.test(urlLike);
|
||||
const sanitizedResult = isWrappedLocation ? urlLike.slice(1, -1) : urlLike;
|
||||
|
||||
const regExp = /(.+?)(?::(\d+))?(?::(\d+))?$/;
|
||||
const parts = regExp.exec(sanitizedResult);
|
||||
if (!parts) return [sanitizedResult, undefined, undefined];
|
||||
return [parts[1], parts[2] || undefined, parts[3] || undefined] as const;
|
||||
};
|
||||
|
||||
const applySlice = <T>(lines: T[], options?: ParseOptions): T[] => {
|
||||
if (options && options.slice != null) {
|
||||
if (Array.isArray(options.slice)) return lines.slice(options.slice[0], options.slice[1]);
|
||||
return lines.slice(0, options.slice);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
export const parseV8OrIeString = (stack: string, options?: ParseOptions): StackFrame[] => {
|
||||
const filteredLines = applySlice(
|
||||
stack.split("\n").filter((line) => {
|
||||
return !!line.match(CHROME_IE_STACK_REGEXP);
|
||||
}),
|
||||
options,
|
||||
);
|
||||
|
||||
return filteredLines.map((line): StackFrame => {
|
||||
let currentLine = line;
|
||||
if (currentLine.includes("(eval ")) {
|
||||
currentLine = currentLine
|
||||
.replace(/eval code/g, "eval")
|
||||
.replace(/(\(eval at [^()]*)|(,.*$)/g, "");
|
||||
}
|
||||
let sanitizedLine = currentLine
|
||||
.replace(/^\s+/, "")
|
||||
.replace(/\(eval code/g, "(")
|
||||
.replace(/^.*?\s+/, "");
|
||||
|
||||
const locationMatch = sanitizedLine.match(/ (\(.+\)$)/);
|
||||
|
||||
sanitizedLine = locationMatch ? sanitizedLine.replace(locationMatch[0], "") : sanitizedLine;
|
||||
|
||||
const locationParts = extractLocation(locationMatch ? locationMatch[1] : sanitizedLine);
|
||||
const functionName = (locationMatch && sanitizedLine) || undefined;
|
||||
const fileName = ["eval", "<anonymous>"].includes(locationParts[0])
|
||||
? undefined
|
||||
: locationParts[0];
|
||||
|
||||
return {
|
||||
functionName,
|
||||
fileName,
|
||||
lineNumber: locationParts[1] ? +locationParts[1] : undefined,
|
||||
columnNumber: locationParts[2] ? +locationParts[2] : undefined,
|
||||
source: currentLine,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const parseFFOrSafariString = (stack: string, options?: ParseOptions): StackFrame[] => {
|
||||
const filteredLines = applySlice(
|
||||
stack.split("\n").filter((line) => {
|
||||
return !line.match(SAFARI_NATIVE_CODE_REGEXP);
|
||||
}),
|
||||
options,
|
||||
);
|
||||
|
||||
return filteredLines.map((line): StackFrame => {
|
||||
let currentLine = line;
|
||||
if (currentLine.includes(" > eval"))
|
||||
currentLine = currentLine.replace(/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g, ":$1");
|
||||
|
||||
if (!currentLine.includes("@") && !currentLine.includes(":")) {
|
||||
return {
|
||||
functionName: currentLine,
|
||||
};
|
||||
} else {
|
||||
const functionNameRegex =
|
||||
/(([^\n\r"\u2028\u2029]*".[^\n\r"\u2028\u2029]*"[^\n\r@\u2028\u2029]*(?:@[^\n\r"\u2028\u2029]*"[^\n\r@\u2028\u2029]*)*(?:[\n\r\u2028\u2029][^@]*)?)?[^@]*)@/;
|
||||
const matches = currentLine.match(functionNameRegex);
|
||||
const functionName = matches && matches[1] ? matches[1] : undefined;
|
||||
const locationParts = extractLocation(currentLine.replace(functionNameRegex, ""));
|
||||
|
||||
return {
|
||||
functionName,
|
||||
fileName: locationParts[0],
|
||||
lineNumber: locationParts[1] ? +locationParts[1] : undefined,
|
||||
columnNumber: locationParts[2] ? +locationParts[2] : undefined,
|
||||
source: currentLine,
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
398
node_modules/bippy/src/source/symbolication.ts
generated
vendored
Normal file
398
node_modules/bippy/src/source/symbolication.ts
generated
vendored
Normal file
@@ -0,0 +1,398 @@
|
||||
import { decode, SourceMapMappings, type SourceMapSegment } from "@jridgewell/sourcemap-codec";
|
||||
|
||||
import { StackFrame } from "./parse-stack.js";
|
||||
|
||||
export interface DecodedSourceMapSection {
|
||||
map: {
|
||||
file?: string;
|
||||
ignoredSourceIndices?: Set<number>;
|
||||
mappings: SourceMapSegment[][];
|
||||
names?: string[];
|
||||
sourceRoot?: string;
|
||||
sources: string[];
|
||||
sourcesContent?: string[];
|
||||
version: 3;
|
||||
};
|
||||
offset: {
|
||||
column: number;
|
||||
line: number;
|
||||
};
|
||||
}
|
||||
|
||||
// https://tc39.es/ecma426/#sec-index-source-map
|
||||
export interface IndexSourceMap {
|
||||
file?: string;
|
||||
sections: Array<{
|
||||
map: StandardSourceMap;
|
||||
offset: {
|
||||
column: number;
|
||||
line: number;
|
||||
};
|
||||
}>;
|
||||
version: 3;
|
||||
}
|
||||
|
||||
export type RawSourceMap = IndexSourceMap | StandardSourceMap;
|
||||
|
||||
export interface SourceMap {
|
||||
file?: string;
|
||||
ignoredSourceIndices?: Set<number>;
|
||||
mappings: SourceMapSegment[][];
|
||||
names?: string[];
|
||||
sections?: DecodedSourceMapSection[];
|
||||
sourceRoot?: string;
|
||||
sources: string[];
|
||||
sourcesContent?: string[];
|
||||
version: 3;
|
||||
}
|
||||
|
||||
// https://developer.chrome.com/blog/sourcemaps#the_anatomy_of_a_source_map
|
||||
export interface StandardSourceMap {
|
||||
file?: string;
|
||||
ignoreList?: number[];
|
||||
mappings: string;
|
||||
names?: string[];
|
||||
sourceRoot?: string;
|
||||
sources: string[];
|
||||
sourcesContent?: string[];
|
||||
version: 3;
|
||||
x_google_ignoreList?: number[];
|
||||
}
|
||||
|
||||
// has a scheme, e.g. http://, https://, file://, data:, etc.
|
||||
// https://datatracker.ietf.org/doc/html/rfc3986#section-3.1
|
||||
const SCHEME_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
|
||||
// inline sourcemap, e.g. data:application/json;base64,...
|
||||
const INLINE_SOURCEMAP_REGEX = /^data:application\/json[^,]+base64,/;
|
||||
// sourcemap url, e.g. //@ sourceMappingURL=... or /* @ sourceMappingURL=... */ at the end of the file
|
||||
const SOURCEMAP_REGEX =
|
||||
/(?:\/\/[@#][ \t]+sourceMappingURL=([^\s'"]+?)[ \t]*$)|(?:\/\*[@#][ \t]+sourceMappingURL=([^*]+?)[ \t]*(?:\*\/)[ \t]*$)/;
|
||||
|
||||
export const sourceMapCache = new Map<string, null | SourceMap>();
|
||||
interface SourceMapResult {
|
||||
sourceMap: null | SourceMap;
|
||||
isTransientFailure: boolean;
|
||||
}
|
||||
|
||||
const _pendingSourceMapRequests = new Map<string, Promise<SourceMapResult>>();
|
||||
|
||||
const getSourceFromMappings = (
|
||||
mappings: SourceMapMappings,
|
||||
sources: string[],
|
||||
lineIndexInMappings: number,
|
||||
column: number,
|
||||
ignoredSourceIndices?: Set<number>,
|
||||
): StackFrame | null => {
|
||||
if (lineIndexInMappings < 0 || lineIndexInMappings >= mappings.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineMapping = mappings[lineIndexInMappings];
|
||||
if (!lineMapping || lineMapping.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Segments within a line are sorted by generated column, so binary search for
|
||||
// the last segment at or before the column.
|
||||
let closestLineSegment: null | SourceMapSegment = null;
|
||||
let lowIndex = 0;
|
||||
let highIndex = lineMapping.length - 1;
|
||||
while (lowIndex <= highIndex) {
|
||||
const middleIndex = (lowIndex + highIndex) >> 1;
|
||||
if (lineMapping[middleIndex][0] <= column) {
|
||||
closestLineSegment = lineMapping[middleIndex];
|
||||
lowIndex = middleIndex + 1;
|
||||
} else {
|
||||
highIndex = middleIndex - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!closestLineSegment || closestLineSegment.length < 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, sourceIndex, sourceLine, sourceColumn] = closestLineSegment;
|
||||
|
||||
if (sourceIndex === undefined || sourceLine === undefined || sourceColumn === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName = sources[sourceIndex];
|
||||
|
||||
if (!fileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
columnNumber: sourceColumn,
|
||||
fileName,
|
||||
lineNumber: sourceLine + 1,
|
||||
isIgnoreListed: ignoredSourceIndices?.has(sourceIndex) ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
export const getSourceFromSourceMap = (
|
||||
sourceMap: SourceMap,
|
||||
line: number,
|
||||
column: number,
|
||||
): StackFrame | null => {
|
||||
if (sourceMap.sections) {
|
||||
// Section offsets are 0-based while stack trace lines are 1-based.
|
||||
const lineIndex = line - 1;
|
||||
let targetSection: DecodedSourceMapSection | null = null;
|
||||
|
||||
for (const section of sourceMap.sections) {
|
||||
if (
|
||||
lineIndex > section.offset.line ||
|
||||
(lineIndex === section.offset.line && column >= section.offset.column)
|
||||
) {
|
||||
targetSection = section;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativeLine = lineIndex - targetSection.offset.line;
|
||||
const relativeColumn =
|
||||
lineIndex === targetSection.offset.line ? column - targetSection.offset.column : column;
|
||||
|
||||
return getSourceFromMappings(
|
||||
targetSection.map.mappings,
|
||||
targetSection.map.sources,
|
||||
relativeLine,
|
||||
relativeColumn,
|
||||
targetSection.map.ignoredSourceIndices,
|
||||
);
|
||||
}
|
||||
|
||||
return getSourceFromMappings(
|
||||
sourceMap.mappings,
|
||||
sourceMap.sources,
|
||||
line - 1,
|
||||
column,
|
||||
sourceMap.ignoredSourceIndices,
|
||||
);
|
||||
};
|
||||
|
||||
const getSourceMapUrl = (url: string, content: string): null | string => {
|
||||
// Walk lines backwards without content.split("\n"), which would allocate a
|
||||
// string per line of the entire bundle.
|
||||
let sourceMapUrl: string | undefined;
|
||||
let searchEnd = content.length;
|
||||
while (searchEnd > 0 && !sourceMapUrl) {
|
||||
const lineStart = content.lastIndexOf("\n", searchEnd - 1) + 1;
|
||||
const regexMatch = content.slice(lineStart, searchEnd).match(SOURCEMAP_REGEX);
|
||||
if (regexMatch) {
|
||||
sourceMapUrl = regexMatch[1] || regexMatch[2];
|
||||
}
|
||||
searchEnd = lineStart - 1;
|
||||
}
|
||||
|
||||
if (!sourceMapUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasScheme = SCHEME_REGEX.test(sourceMapUrl);
|
||||
if (!(INLINE_SOURCEMAP_REGEX.test(sourceMapUrl) || hasScheme || sourceMapUrl.startsWith("/"))) {
|
||||
const urlSegments = url.split("/");
|
||||
urlSegments[urlSegments.length - 1] = sourceMapUrl;
|
||||
sourceMapUrl = urlSegments.join("/");
|
||||
}
|
||||
|
||||
return sourceMapUrl;
|
||||
};
|
||||
|
||||
const getIgnoredSourceIndices = (rawSourceMap: StandardSourceMap): Set<number> | undefined => {
|
||||
const ignoreList = rawSourceMap.ignoreList ?? rawSourceMap.x_google_ignoreList;
|
||||
return Array.isArray(ignoreList) && ignoreList.length > 0 ? new Set(ignoreList) : undefined;
|
||||
};
|
||||
|
||||
const decodeStandardSourceMap = (rawSourceMap: StandardSourceMap): SourceMap => ({
|
||||
file: rawSourceMap.file,
|
||||
ignoredSourceIndices: getIgnoredSourceIndices(rawSourceMap),
|
||||
mappings: decode(rawSourceMap.mappings),
|
||||
names: rawSourceMap.names,
|
||||
sourceRoot: rawSourceMap.sourceRoot,
|
||||
sources: rawSourceMap.sources,
|
||||
sourcesContent: rawSourceMap.sourcesContent,
|
||||
version: 3,
|
||||
});
|
||||
|
||||
const decodeIndexSourceMap = (rawSourceMap: IndexSourceMap): SourceMap => {
|
||||
const decodedSections: DecodedSourceMapSection[] = rawSourceMap.sections.map(
|
||||
({ map, offset }) => ({
|
||||
map: {
|
||||
...map,
|
||||
ignoredSourceIndices: getIgnoredSourceIndices(map),
|
||||
mappings: decode(map.mappings),
|
||||
},
|
||||
offset,
|
||||
}),
|
||||
);
|
||||
|
||||
const allSources = new Set<string>();
|
||||
for (const section of decodedSections) {
|
||||
for (const source of section.map.sources) {
|
||||
allSources.add(source);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file: rawSourceMap.file,
|
||||
mappings: [],
|
||||
names: [],
|
||||
sections: decodedSections,
|
||||
sourceRoot: undefined,
|
||||
sources: Array.from(allSources),
|
||||
sourcesContent: undefined,
|
||||
version: 3,
|
||||
};
|
||||
};
|
||||
|
||||
const isFetchableUrl = (url: string): boolean => {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmedUrl = url.trim();
|
||||
|
||||
if (!trimmedUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const schemeMatch = trimmedUrl.match(SCHEME_REGEX);
|
||||
|
||||
if (!schemeMatch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const scheme = schemeMatch[0].toLowerCase();
|
||||
|
||||
return scheme === "http:" || scheme === "https:";
|
||||
};
|
||||
|
||||
// Resolves a bundle's source map, or null when the bundle definitively has
|
||||
// none. A thrown fetch (network error or aborted request) is left to propagate
|
||||
// so getSourceMap can treat it as transient and avoid caching it: a non-ok
|
||||
// response, a missing sourceMappingURL, or an undecodable map are definitive and
|
||||
// return null, but a dropped connection is not and must stay retryable.
|
||||
export const getSourceMapImpl = async (
|
||||
bundleUrl: string,
|
||||
fetchFn: (url: string) => Promise<Response> = fetch,
|
||||
): Promise<null | SourceMap> => {
|
||||
if (!isFetchableUrl(bundleUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bundleResponse = await fetchFn(bundleUrl);
|
||||
if (!bundleResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
const bundleContent = await bundleResponse.text();
|
||||
if (!bundleContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceMapUrl = getSourceMapUrl(bundleUrl, bundleContent);
|
||||
|
||||
if (!sourceMapUrl) return null;
|
||||
// inline data: maps (vite dev, babel inline sourcemaps) are decoded by
|
||||
// fetch itself, so they bypass the network-url check
|
||||
if (!isFetchableUrl(sourceMapUrl) && !INLINE_SOURCEMAP_REGEX.test(sourceMapUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceMapResponse = await fetchFn(sourceMapUrl);
|
||||
if (!sourceMapResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawSourceMap = (await sourceMapResponse.json()) as RawSourceMap;
|
||||
|
||||
return "sections" in rawSourceMap
|
||||
? decodeIndexSourceMap(rawSourceMap)
|
||||
: decodeStandardSourceMap(rawSourceMap);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSourceMap = async (
|
||||
file: string,
|
||||
useCache = true,
|
||||
fetchFn?: (url: string) => Promise<Response>,
|
||||
): Promise<null | SourceMap> => {
|
||||
if (useCache && sourceMapCache.has(file)) {
|
||||
return sourceMapCache.get(file) ?? null;
|
||||
}
|
||||
|
||||
const pendingRequest = useCache ? _pendingSourceMapRequests.get(file) : undefined;
|
||||
if (pendingRequest) {
|
||||
return (await pendingRequest).sourceMap;
|
||||
}
|
||||
|
||||
// A transient fetch failure (aborted request or network error) rejects; a
|
||||
// definitive "no map" resolves to null. Only definitive results are cached:
|
||||
// caching a transient null would pin the bundle to a degraded result for the
|
||||
// rest of the page's lifetime, even after the network recovers.
|
||||
const fetchPromise: Promise<SourceMapResult> = getSourceMapImpl(file, fetchFn).then(
|
||||
(sourceMap) => ({ sourceMap, isTransientFailure: false }),
|
||||
() => ({ sourceMap: null, isTransientFailure: true }),
|
||||
);
|
||||
if (useCache) {
|
||||
_pendingSourceMapRequests.set(file, fetchPromise);
|
||||
}
|
||||
|
||||
const { sourceMap, isTransientFailure } = await fetchPromise;
|
||||
if (useCache) {
|
||||
_pendingSourceMapRequests.delete(file);
|
||||
if (!isTransientFailure) {
|
||||
sourceMapCache.set(file, sourceMap);
|
||||
}
|
||||
}
|
||||
|
||||
return sourceMap;
|
||||
};
|
||||
|
||||
export const symbolicateStack = async (
|
||||
stack: StackFrame[],
|
||||
cache = true,
|
||||
fetchFn?: (url: string) => Promise<Response>,
|
||||
): Promise<StackFrame[]> => {
|
||||
return await Promise.all(
|
||||
stack.map(async (stackFrame) => {
|
||||
if (!stackFrame.fileName) return stackFrame;
|
||||
const sourceMap = await getSourceMap(stackFrame.fileName, cache, fetchFn);
|
||||
if (
|
||||
!sourceMap ||
|
||||
typeof stackFrame.lineNumber !== "number" ||
|
||||
typeof stackFrame.columnNumber !== "number"
|
||||
) {
|
||||
return stackFrame;
|
||||
}
|
||||
const symbolicatedSource = getSourceFromSourceMap(
|
||||
sourceMap,
|
||||
stackFrame.lineNumber,
|
||||
stackFrame.columnNumber,
|
||||
);
|
||||
if (!symbolicatedSource) return stackFrame;
|
||||
return {
|
||||
...stackFrame,
|
||||
source:
|
||||
symbolicatedSource.fileName && stackFrame.source
|
||||
? stackFrame.source.replace(stackFrame.fileName, symbolicatedSource.fileName)
|
||||
: stackFrame.source,
|
||||
fileName: symbolicatedSource.fileName,
|
||||
lineNumber: symbolicatedSource.lineNumber,
|
||||
columnNumber: symbolicatedSource.columnNumber,
|
||||
isIgnoreListed: symbolicatedSource.isIgnoreListed,
|
||||
isSymbolicated: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
6
node_modules/bippy/src/source/types.ts
generated
vendored
Normal file
6
node_modules/bippy/src/source/types.ts
generated
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface FiberSource {
|
||||
columnNumber?: number;
|
||||
fileName: string;
|
||||
lineNumber?: number;
|
||||
functionName?: string;
|
||||
}
|
||||
438
node_modules/bippy/src/types.ts
generated
vendored
Normal file
438
node_modules/bippy/src/types.ts
generated
vendored
Normal file
@@ -0,0 +1,438 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// @types/react-reconciler uses `export = ReactReconciler` (CJS namespace),
|
||||
// which downstream bundlers can't resolve as ESM exports. These types are
|
||||
// inlined from @types/react-reconciler@0.28 to avoid the dependency.
|
||||
|
||||
// Simple type aliases — most are opaque in the original
|
||||
export type BundleType = 0 | 1;
|
||||
export type Flags = number;
|
||||
export type Lanes = number;
|
||||
export type TypeOfMode = number;
|
||||
export type RootTag = 0 | 1 | 2;
|
||||
export type LanePriority =
|
||||
| 0
|
||||
| 1
|
||||
| 2
|
||||
| 3
|
||||
| 4
|
||||
| 5
|
||||
| 6
|
||||
| 7
|
||||
| 8
|
||||
| 9
|
||||
| 10
|
||||
| 11
|
||||
| 12
|
||||
| 13
|
||||
| 14
|
||||
| 15
|
||||
| 16
|
||||
| 17;
|
||||
export type WorkTag =
|
||||
| 0
|
||||
| 1
|
||||
| 2
|
||||
| 3
|
||||
| 4
|
||||
| 5
|
||||
| 6
|
||||
| 7
|
||||
| 8
|
||||
| 9
|
||||
| 10
|
||||
| 11
|
||||
| 12
|
||||
| 13
|
||||
| 14
|
||||
| 15
|
||||
| 16
|
||||
| 17
|
||||
| 18
|
||||
| 19
|
||||
| 20
|
||||
| 21
|
||||
| 22
|
||||
| 23
|
||||
| 24
|
||||
| 25
|
||||
| 26
|
||||
| 27
|
||||
| 28
|
||||
| 29
|
||||
| 30
|
||||
| 31;
|
||||
export type HookType =
|
||||
| "useState"
|
||||
| "useReducer"
|
||||
| "useContext"
|
||||
| "useRef"
|
||||
| "useEffect"
|
||||
| "useLayoutEffect"
|
||||
| "useCallback"
|
||||
| "useMemo"
|
||||
| "useImperativeHandle"
|
||||
| "useDebugValue"
|
||||
| "useDeferredValue"
|
||||
| "useTransition"
|
||||
| "useMutableSource"
|
||||
| "useOpaqueIdentifier"
|
||||
| "useCacheRefresh";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type FiberRoot = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type MutableSource = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type OpaqueHandle = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type OpaqueRoot = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type React$AbstractComponent<_Config, _Instance = unknown> = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type HostConfig = Record<string, any>;
|
||||
|
||||
// Structural interfaces
|
||||
export interface Source {
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface RefObject {
|
||||
current: any;
|
||||
}
|
||||
export interface Thenable<T> {
|
||||
then(resolve: () => T, reject?: () => T): T;
|
||||
}
|
||||
|
||||
export interface ReactContext<T> {
|
||||
$$typeof: symbol | number;
|
||||
Consumer: ReactContext<T>;
|
||||
Provider: ReactProviderType<T>;
|
||||
_calculateChangedBits: ((a: T, b: T) => number) | null;
|
||||
_currentValue: T;
|
||||
_currentValue2: T;
|
||||
_threadCount: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
_currentRenderer?: Record<string, any> | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
_currentRenderer2?: Record<string, any> | null;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface ReactProviderType<T> {
|
||||
$$typeof: symbol | number;
|
||||
_context: ReactContext<T>;
|
||||
}
|
||||
export interface ReactProvider<T> {
|
||||
$$typeof: symbol | number;
|
||||
type: ReactProviderType<T>;
|
||||
key: null | string;
|
||||
ref: null;
|
||||
props: { value: T; children?: ReactNode };
|
||||
}
|
||||
export interface ReactConsumer<T> {
|
||||
$$typeof: symbol | number;
|
||||
type: ReactContext<T>;
|
||||
key: null | string;
|
||||
ref: null;
|
||||
props: { children: (value: T) => ReactNode; unstable_observedBits?: number };
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface ReactPortal {
|
||||
$$typeof: symbol | number;
|
||||
key: null | string;
|
||||
containerInfo: any;
|
||||
children: ReactNode;
|
||||
implementation: any;
|
||||
}
|
||||
|
||||
export interface ComponentSelector {
|
||||
$$typeof: symbol | number;
|
||||
value: React$AbstractComponent<never, unknown>;
|
||||
}
|
||||
export interface HasPseudoClassSelector {
|
||||
$$typeof: symbol | number;
|
||||
value: Selector[];
|
||||
}
|
||||
export interface RoleSelector {
|
||||
$$typeof: symbol | number;
|
||||
value: string;
|
||||
}
|
||||
export interface TextSelector {
|
||||
$$typeof: symbol | number;
|
||||
value: string;
|
||||
}
|
||||
export interface TestNameSelector {
|
||||
$$typeof: symbol | number;
|
||||
value: string;
|
||||
}
|
||||
export type Selector =
|
||||
| ComponentSelector
|
||||
| HasPseudoClassSelector
|
||||
| RoleSelector
|
||||
| TextSelector
|
||||
| TestNameSelector;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface DevToolsConfig<
|
||||
Instance = any,
|
||||
TextInstance = any,
|
||||
RendererInspectionConfig = any,
|
||||
> {
|
||||
bundleType: BundleType;
|
||||
version: string;
|
||||
rendererPackageName: string;
|
||||
findFiberByHostInstance?: (instance: Instance | TextInstance) => ReactFiber | null;
|
||||
rendererConfig?: RendererInspectionConfig;
|
||||
}
|
||||
|
||||
export interface SuspenseHydrationCallbacks<SuspenseInstance = unknown> {
|
||||
onHydrated?: (suspenseInstance: SuspenseInstance) => void;
|
||||
onDeleted?: (suspenseInstance: SuspenseInstance) => void;
|
||||
}
|
||||
|
||||
export interface TransitionTracingCallbacks {
|
||||
onTransitionStart?: (transitionName: string, startTime: number) => void;
|
||||
onTransitionProgress?: (
|
||||
transitionName: string,
|
||||
startTime: number,
|
||||
currentTime: number,
|
||||
pending: Array<{ name: null | string }>,
|
||||
) => void;
|
||||
onTransitionIncomplete?: (
|
||||
transitionName: string,
|
||||
startTime: number,
|
||||
deletions: Array<{ type: string; name?: string; newName?: string; endTime: number }>,
|
||||
) => void;
|
||||
onTransitionComplete?: (transitionName: string, startTime: number, endTime: number) => void;
|
||||
onMarkerProgress?: (
|
||||
transitionName: string,
|
||||
marker: string,
|
||||
startTime: number,
|
||||
currentTime: number,
|
||||
pending: Array<{ name: null | string }>,
|
||||
) => void;
|
||||
onMarkerIncomplete?: (
|
||||
transitionName: string,
|
||||
marker: string,
|
||||
startTime: number,
|
||||
deletions: Array<{ type: string; name?: string; newName?: string; endTime: number }>,
|
||||
) => void;
|
||||
onMarkerComplete?: (
|
||||
transitionName: string,
|
||||
marker: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
) => void;
|
||||
}
|
||||
|
||||
// The base Fiber interface from react-reconciler, used to derive bippy's Fiber below
|
||||
interface ReactFiber {
|
||||
tag: WorkTag;
|
||||
key: null | string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
elementType: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
stateNode: any;
|
||||
return: ReactFiber | null;
|
||||
child: ReactFiber | null;
|
||||
sibling: ReactFiber | null;
|
||||
index: number;
|
||||
ref: null | (((handle: unknown) => void) & { _stringRef?: string | null }) | RefObject;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
pendingProps: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
memoizedProps: any;
|
||||
updateQueue: unknown;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
memoizedState: any;
|
||||
dependencies: Dependencies | null;
|
||||
mode: TypeOfMode;
|
||||
flags: Flags;
|
||||
subtreeFlags: Flags;
|
||||
deletions: ReactFiber[] | null;
|
||||
nextEffect: ReactFiber | null;
|
||||
firstEffect: ReactFiber | null;
|
||||
lastEffect: ReactFiber | null;
|
||||
lanes: Lanes;
|
||||
childLanes: Lanes;
|
||||
alternate: ReactFiber | null;
|
||||
actualDuration?: number;
|
||||
actualStartTime?: number;
|
||||
selfBaseDuration?: number;
|
||||
treeBaseDuration?: number;
|
||||
_debugID?: number;
|
||||
_debugSource?: Source | null;
|
||||
_debugOwner?: ReactFiber | null;
|
||||
_debugIsCurrentlyTiming?: boolean;
|
||||
_debugNeedsRemount?: boolean;
|
||||
_debugHookTypes?: HookType[] | null;
|
||||
}
|
||||
|
||||
// ── bippy types (not from react-reconciler) ──
|
||||
|
||||
export interface ContextDependency<T> {
|
||||
context: ReactContext<T>;
|
||||
memoizedValue: T;
|
||||
next: ContextDependency<unknown> | null;
|
||||
observedBits: number;
|
||||
}
|
||||
|
||||
export interface Dependencies {
|
||||
firstContext: ContextDependency<unknown> | null;
|
||||
lanes: Lanes;
|
||||
}
|
||||
|
||||
export interface Effect {
|
||||
[key: string]: unknown;
|
||||
create: (...args: unknown[]) => unknown;
|
||||
deps: null | unknown[];
|
||||
destroy: ((...args: unknown[]) => unknown) | null;
|
||||
next: Effect | null;
|
||||
tag: number;
|
||||
}
|
||||
|
||||
export interface Family {
|
||||
current: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* React 19 flight metadata for a server component owner (ReactComponentInfo).
|
||||
* Unlike client owners it has no `tag`; the owner chain continues via `owner`.
|
||||
*/
|
||||
export interface ServerComponentInfo {
|
||||
name?: string;
|
||||
env?: string;
|
||||
owner?: Fiber | ServerComponentInfo | null;
|
||||
debugStack?: Error | null;
|
||||
}
|
||||
|
||||
export interface RendererRefreshUpdate {
|
||||
staleFamilies: Set<Family>;
|
||||
updatedFamilies: Set<Family>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a react-internal Fiber node.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type Fiber<T = any> = Omit<
|
||||
ReactFiber,
|
||||
| "alternate"
|
||||
| "child"
|
||||
| "dependencies"
|
||||
| "memoizedProps"
|
||||
| "memoizedState"
|
||||
| "pendingProps"
|
||||
| "return"
|
||||
| "sibling"
|
||||
| "stateNode"
|
||||
| "updateQueue"
|
||||
> & {
|
||||
_debugInfo?: Array<{
|
||||
debugLocation?: unknown;
|
||||
env?: string;
|
||||
name?: string;
|
||||
}>;
|
||||
_debugOwner?: Fiber;
|
||||
// react <19
|
||||
_debugSource?: {
|
||||
columnNumber?: number;
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
};
|
||||
// react 19+
|
||||
// https://github.com/facebook/react/issues/29092?utm_source=chatgpt.com
|
||||
_debugStack?: Error & { stack: string };
|
||||
alternate: Fiber | null;
|
||||
child: Fiber | null;
|
||||
dependencies: Dependencies | null;
|
||||
memoizedProps: Props;
|
||||
memoizedState: MemoizedState;
|
||||
pendingProps: Props;
|
||||
|
||||
return: Fiber | null;
|
||||
sibling: Fiber | null;
|
||||
stateNode: T;
|
||||
updateQueue: {
|
||||
[key: string]: unknown;
|
||||
lastEffect: Effect | null;
|
||||
};
|
||||
};
|
||||
|
||||
export interface MemoizedState {
|
||||
[key: string]: unknown;
|
||||
memoizedState: unknown;
|
||||
next: MemoizedState | null;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ReactDevToolsGlobalHook {
|
||||
_instrumentationIsActive?: boolean;
|
||||
_instrumentationSource?: string;
|
||||
checkDCE: (fn: unknown) => void;
|
||||
hasUnsupportedRendererAttached: boolean;
|
||||
inject: (renderer: ReactRenderer) => number;
|
||||
// https://github.com/aidenybai/bippy/issues/43
|
||||
on: () => void;
|
||||
onCommitFiberRoot: (rendererID: number, root: FiberRoot, priority: number | void) => void;
|
||||
onCommitFiberUnmount: (rendererID: number, fiber: Fiber) => void;
|
||||
onPostCommitFiberRoot: (rendererID: number, root: FiberRoot) => void;
|
||||
// called by dev builds of react-reconciler on root schedule; absent from
|
||||
// the hook react-refresh installs, so it stays optional
|
||||
onScheduleFiberRoot?: (rendererID: number, root: FiberRoot, children: ReactNode) => void;
|
||||
renderers: Map<number, ReactRenderer>;
|
||||
supportsFiber: boolean;
|
||||
|
||||
supportsFlight: boolean;
|
||||
}
|
||||
|
||||
// https://github.com/facebook/react/blob/6a4b46cd70d2672bc4be59dcb5b8dede22ed0cef/packages/react-devtools-shared/src/backend/types.js
|
||||
export interface ReactRenderer {
|
||||
bundleType: 0 /* PROD */ | 1 /* DEV */;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
currentDispatcherRef: any;
|
||||
// dev only: https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberReconciler.js#L842
|
||||
findFiberByHostInstance?: (hostInstance: unknown) => Fiber | null;
|
||||
// react devtools
|
||||
getCurrentFiber?: (fiber: Fiber) => Fiber | null;
|
||||
overrideContext?: (fiber: Fiber, contextType: unknown, path: string[], value: unknown) => void;
|
||||
|
||||
overrideHookState?: (fiber: Fiber, id: string, path: string[], value: unknown) => void;
|
||||
overrideHookStateDeletePath?: (fiber: Fiber, id: number, path: Array<number | string>) => void;
|
||||
overrideHookStateRenamePath?: (
|
||||
fiber: Fiber,
|
||||
id: number,
|
||||
oldPath: Array<number | string>,
|
||||
newPath: Array<number | string>,
|
||||
) => void;
|
||||
overrideProps?: (fiber: Fiber, path: string[], value: unknown) => void;
|
||||
overridePropsDeletePath?: (fiber: Fiber, path: Array<number | string>) => void;
|
||||
overridePropsRenamePath?: (
|
||||
fiber: Fiber,
|
||||
oldPath: Array<number | string>,
|
||||
newPath: Array<number | string>,
|
||||
) => void;
|
||||
reconcilerVersion: string;
|
||||
rendererPackageName: string;
|
||||
// react refresh
|
||||
scheduleRefresh?: (root: FiberRoot, update: RendererRefreshUpdate) => void;
|
||||
scheduleRoot?: (root: FiberRoot, element: React.ReactNode) => void;
|
||||
scheduleUpdate?: (fiber: Fiber) => void;
|
||||
|
||||
setErrorHandler?: (newShouldErrorImpl: (fiber: Fiber) => boolean) => void;
|
||||
setRefreshHandler?: (handler: ((fiber: Fiber) => Family | null) | null) => void;
|
||||
setSuspenseHandler?: (newShouldSuspendImpl: (suspenseInstance: unknown) => void) => void;
|
||||
|
||||
version: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __REACT_DEVTOOLS_GLOBAL_HOOK__: ReactDevToolsGlobalHook | undefined;
|
||||
}
|
||||
17
node_modules/bippy/src/unsubscribe.ts
generated
vendored
Normal file
17
node_modules/bippy/src/unsubscribe.ts
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface Unsubscribe extends Disposable {
|
||||
(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a teardown callback so it is both callable and a `Disposable`,
|
||||
* letting subscriptions compose through explicit resource management:
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* using instrumentation = instrument({ onCommitFiberRoot });
|
||||
* using refresh = instrumentReactRefresh({ onRefresh: handleRefresh });
|
||||
* // both torn down automatically at scope exit
|
||||
* ```
|
||||
*/
|
||||
export const toUnsubscribe = (dispose: () => void): Unsubscribe =>
|
||||
Object.assign(dispose, { [Symbol.dispose]: dispose });
|
||||
Reference in New Issue
Block a user