UNPKG

bippy

Version:

hack into react internals

174 lines (159 loc) 6.58 kB
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); }); };