bippy 0.5.42 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core.cjs +1 -1
- package/dist/core.d.cts +28 -313
- package/dist/core.d.ts +28 -313
- package/dist/core.js +1 -1
- package/dist/core2.d.cts +3 -2
- package/dist/core2.d.ts +3 -2
- package/dist/get-source.cjs +19 -0
- package/dist/get-source.js +19 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.iife.js +1 -1
- package/dist/index.js +1 -1
- package/dist/install-hook-only.cjs +1 -1
- package/dist/install-hook-only.iife.js +1 -1
- package/dist/install-hook-only.js +1 -1
- package/dist/rdt-hook.cjs +1 -1
- package/dist/rdt-hook.js +1 -1
- package/dist/react-refresh.cjs +9 -0
- package/dist/react-refresh.d.cts +66 -0
- package/dist/react-refresh.d.ts +66 -0
- package/dist/react-refresh.js +9 -0
- package/dist/source.cjs +4 -19
- package/dist/source.d.cts +35 -27
- package/dist/source.d.ts +35 -27
- package/dist/source.js +4 -19
- package/dist/unsubscribe.d.cts +298 -0
- package/dist/unsubscribe.d.ts +298 -0
- package/package.json +14 -3
- package/src/core.ts +312 -334
- package/src/rdt-hook.ts +57 -16
- package/src/react-refresh/constants.ts +9 -0
- package/src/react-refresh/detect-hmr-transport.ts +33 -0
- package/src/react-refresh/index.ts +173 -0
- package/src/react-refresh/metro-hmr-transport.ts +188 -0
- package/src/react-refresh/next-webpack-hmr-transport.ts +72 -0
- package/src/react-refresh/normalize-hmr-file-path.ts +24 -0
- package/src/react-refresh/types.ts +7 -0
- package/src/react-refresh/vite-hmr-transport.ts +116 -0
- package/src/source/constants.ts +11 -0
- package/src/source/get-display-name-from-source.ts +3 -3
- package/src/source/get-source.ts +53 -9
- package/src/source/index.ts +17 -8
- package/src/source/inspect-hooks.ts +7 -4
- package/src/source/owner-stack.ts +193 -29
- package/src/source/parse-debug-stack.ts +133 -0
- package/src/source/parse-stack.ts +6 -105
- package/src/source/symbolication.ts +48 -13
- package/src/types.ts +20 -7
- package/src/unsubscribe.ts +17 -0
package/src/rdt-hook.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// make sure you import this file first before anything else (particularly React)
|
|
5
5
|
|
|
6
6
|
import type { ReactDevToolsGlobalHook, ReactRenderer } from "./types.js";
|
|
7
|
+
import { toUnsubscribe, type Unsubscribe } from "./unsubscribe.js";
|
|
7
8
|
|
|
8
9
|
export const version = process.env.VERSION;
|
|
9
10
|
export const BIPPY_INSTRUMENTATION_STRING = `bippy-${version}`;
|
|
@@ -16,9 +17,9 @@ const NO_OP = () => {
|
|
|
16
17
|
/**/
|
|
17
18
|
};
|
|
18
19
|
|
|
19
|
-
const checkDCE = (
|
|
20
|
+
const checkDCE = (functionToCheck: unknown): void => {
|
|
20
21
|
try {
|
|
21
|
-
const code = Function.prototype.toString.call(
|
|
22
|
+
const code = Function.prototype.toString.call(functionToCheck);
|
|
22
23
|
if (code.indexOf("^_^") > -1) {
|
|
23
24
|
setTimeout(() => {
|
|
24
25
|
throw new Error(
|
|
@@ -52,27 +53,67 @@ export const isReactRefresh = (
|
|
|
52
53
|
return Boolean(injectFnStr?.includes("(injected)"));
|
|
53
54
|
};
|
|
54
55
|
|
|
55
|
-
const
|
|
56
|
+
export const _onActiveListeners = new Set<() => unknown>();
|
|
56
57
|
|
|
57
58
|
export const _renderers = new Set<ReactRenderer>();
|
|
58
59
|
|
|
60
|
+
const rendererInjectListeners = new Set<(renderer: ReactRenderer) => void>();
|
|
61
|
+
// re-wrapping inject (e.g. after the hook is replaced) leaves the old
|
|
62
|
+
// wrapper in the call chain, so notifications are deduped per renderer
|
|
63
|
+
const notifiedRenderers = new WeakSet<ReactRenderer>();
|
|
64
|
+
let notifyingInject: ReactDevToolsGlobalHook["inject"] | null = null;
|
|
65
|
+
|
|
66
|
+
const ensureInjectNotifiesListeners = (rdtHook: ReactDevToolsGlobalHook): void => {
|
|
67
|
+
if (rdtHook.inject === notifyingInject) return;
|
|
68
|
+
const prevInject = rdtHook.inject;
|
|
69
|
+
const nextInject = (renderer: ReactRenderer) => {
|
|
70
|
+
const rendererId = prevInject.call(rdtHook, renderer);
|
|
71
|
+
if (!notifiedRenderers.has(renderer)) {
|
|
72
|
+
notifiedRenderers.add(renderer);
|
|
73
|
+
for (const listener of rendererInjectListeners) {
|
|
74
|
+
listener(renderer);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return rendererId;
|
|
78
|
+
};
|
|
79
|
+
rdtHook.inject = nextInject;
|
|
80
|
+
notifyingInject = nextInject;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Subscribes to future renderer injections into the DevTools hook. The
|
|
85
|
+
* single shared inject wrapper lets multiple consumers (override methods,
|
|
86
|
+
* react-refresh, user code) observe renderers without stacking patches
|
|
87
|
+
* whose restore order matters. Returns an unsubscribe function.
|
|
88
|
+
*/
|
|
89
|
+
export const onRendererInject = (listener: (renderer: ReactRenderer) => void): Unsubscribe => {
|
|
90
|
+
ensureInjectNotifiesListeners(getRDTHook());
|
|
91
|
+
rendererInjectListeners.add(listener);
|
|
92
|
+
return toUnsubscribe(() => {
|
|
93
|
+
rendererInjectListeners.delete(listener);
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
59
97
|
export const installRDTHook = (onActive?: () => unknown): ReactDevToolsGlobalHook => {
|
|
98
|
+
if (onActive) {
|
|
99
|
+
_onActiveListeners.add(onActive);
|
|
100
|
+
}
|
|
60
101
|
const renderers = new Map<number, ReactRenderer>();
|
|
61
|
-
let
|
|
102
|
+
let rendererIdCounter = 0;
|
|
62
103
|
let rdtHook: ReactDevToolsGlobalHook = {
|
|
63
104
|
_instrumentationIsActive: false,
|
|
64
105
|
_instrumentationSource: BIPPY_INSTRUMENTATION_STRING,
|
|
65
106
|
checkDCE,
|
|
66
107
|
hasUnsupportedRendererAttached: false,
|
|
67
108
|
inject(renderer) {
|
|
68
|
-
const
|
|
69
|
-
renderers.set(
|
|
109
|
+
const nextRendererId = ++rendererIdCounter;
|
|
110
|
+
renderers.set(nextRendererId, renderer);
|
|
70
111
|
_renderers.add(renderer);
|
|
71
112
|
if (!rdtHook._instrumentationIsActive) {
|
|
72
113
|
rdtHook._instrumentationIsActive = true;
|
|
73
|
-
|
|
114
|
+
_onActiveListeners.forEach((listener) => listener());
|
|
74
115
|
}
|
|
75
|
-
return
|
|
116
|
+
return nextRendererId;
|
|
76
117
|
},
|
|
77
118
|
on: NO_OP,
|
|
78
119
|
onCommitFiberRoot: NO_OP,
|
|
@@ -132,7 +173,7 @@ export const installRDTHook = (onActive?: () => unknown): ReactDevToolsGlobalHoo
|
|
|
132
173
|
|
|
133
174
|
export const patchRDTHook = (onActive?: () => unknown): void => {
|
|
134
175
|
if (onActive) {
|
|
135
|
-
|
|
176
|
+
_onActiveListeners.add(onActive);
|
|
136
177
|
}
|
|
137
178
|
try {
|
|
138
179
|
const rdtHook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
@@ -151,7 +192,7 @@ export const patchRDTHook = (onActive?: () => unknown): void => {
|
|
|
151
192
|
}
|
|
152
193
|
if (rdtHook.renderers.size) {
|
|
153
194
|
rdtHook._instrumentationIsActive = true;
|
|
154
|
-
|
|
195
|
+
_onActiveListeners.forEach((listener) => listener());
|
|
155
196
|
return;
|
|
156
197
|
}
|
|
157
198
|
const prevInject = rdtHook.inject;
|
|
@@ -160,24 +201,24 @@ export const patchRDTHook = (onActive?: () => unknown): void => {
|
|
|
160
201
|
isReactRefreshOverride = true;
|
|
161
202
|
// but since the underlying implementation doens't care,
|
|
162
203
|
// it's ok: https://github.com/facebook/react/blob/18eaf51bd51fed8dfed661d64c306759101d0bfd/packages/react-refresh/src/ReactFreshRuntime.js#L430
|
|
163
|
-
const
|
|
204
|
+
const injectedRendererId = rdtHook.inject({
|
|
164
205
|
scheduleRefresh() {},
|
|
165
206
|
} as unknown as ReactRenderer);
|
|
166
|
-
if (
|
|
207
|
+
if (injectedRendererId) {
|
|
167
208
|
rdtHook._instrumentationIsActive = true;
|
|
168
209
|
}
|
|
169
210
|
}
|
|
170
211
|
rdtHook.inject = (renderer) => {
|
|
171
|
-
const
|
|
212
|
+
const rendererId = prevInject(renderer);
|
|
172
213
|
_renderers.add(renderer);
|
|
173
214
|
if (isRefresh) {
|
|
174
215
|
// react refresh doesn't inject this properly
|
|
175
216
|
// https://github.com/facebook/react/blob/18eaf51bd51fed8dfed661d64c306759101d0bfd/packages/react-refresh/src/ReactFreshRuntime.js#L430
|
|
176
|
-
rdtHook.renderers.set(
|
|
217
|
+
rdtHook.renderers.set(rendererId, renderer);
|
|
177
218
|
}
|
|
178
219
|
rdtHook._instrumentationIsActive = true;
|
|
179
|
-
|
|
180
|
-
return
|
|
220
|
+
_onActiveListeners.forEach((listener) => listener());
|
|
221
|
+
return rendererId;
|
|
181
222
|
};
|
|
182
223
|
}
|
|
183
224
|
if (
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const HMR_RECONNECT_DELAY_MS = 1000;
|
|
2
|
+
|
|
3
|
+
export const PENDING_HOT_UPDATE_MAX_AGE_MS = 10_000;
|
|
4
|
+
|
|
5
|
+
export const VITE_WS_TOKEN_REGEX = /wsToken = "([^"]+)"/;
|
|
6
|
+
|
|
7
|
+
export const HMR_SOURCE_FILE_EXTENSION_REGEX = /\.(?:tsx|ts|jsx|js|mjs|cjs|css)$/;
|
|
8
|
+
|
|
9
|
+
export const BUNDLER_LAYER_PREFIX_REGEX = /^(?:\.\/)?\/?\([a-z][a-z0-9-]*\)\//;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isClientEnvironment } from "../rdt-hook.js";
|
|
2
|
+
|
|
3
|
+
import { createMetroHmrTransport } from "./metro-hmr-transport.js";
|
|
4
|
+
import { createNextWebpackHmrTransport } from "./next-webpack-hmr-transport.js";
|
|
5
|
+
import { HmrTransport, HmrUpdateHandler } from "./types.js";
|
|
6
|
+
import { createViteHmrTransport } from "./vite-hmr-transport.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Detects the dev server's HMR transport (Next.js webpack, then Metro for
|
|
10
|
+
* React Native, then Vite) and subscribes `onHmrUpdate` to hot updates.
|
|
11
|
+
* Resolves `null` on the server (SSR) and when no known transport is
|
|
12
|
+
* available (production builds, unsupported bundlers — Turbopack exposes
|
|
13
|
+
* `window.TURBOPACK_CHUNK_UPDATE_LISTENERS` but its update payload shape
|
|
14
|
+
* has not been validated, so it is not wired up yet).
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* const transport = await detectHmrTransport((filePaths) => {
|
|
19
|
+
* console.log("hot updated:", filePaths);
|
|
20
|
+
* });
|
|
21
|
+
* transport?.dispose();
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export const detectHmrTransport = async (
|
|
25
|
+
onHmrUpdate: HmrUpdateHandler,
|
|
26
|
+
): Promise<HmrTransport | null> => {
|
|
27
|
+
if (!isClientEnvironment()) return null;
|
|
28
|
+
const webpackTransport = createNextWebpackHmrTransport(onHmrUpdate);
|
|
29
|
+
if (webpackTransport) return webpackTransport;
|
|
30
|
+
const metroTransport = createMetroHmrTransport(onHmrUpdate);
|
|
31
|
+
if (metroTransport) return metroTransport;
|
|
32
|
+
return createViteHmrTransport(onHmrUpdate);
|
|
33
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { getType, traverseFiber } from "../core.js";
|
|
2
|
+
import { getRDTHook, isClientEnvironment, onRendererInject } from "../rdt-hook.js";
|
|
3
|
+
import type { Fiber, FiberRoot, ReactRenderer } from "../types.js";
|
|
4
|
+
import { toUnsubscribe, type Unsubscribe } from "../unsubscribe.js";
|
|
5
|
+
import { PENDING_HOT_UPDATE_MAX_AGE_MS } from "./constants.js";
|
|
6
|
+
import { detectHmrTransport } from "./detect-hmr-transport.js";
|
|
7
|
+
import { HmrTransport } from "./types.js";
|
|
8
|
+
|
|
9
|
+
export interface ReactRefreshUpdate {
|
|
10
|
+
/**
|
|
11
|
+
* hot-updated source file paths reported by the bundler's HMR transport
|
|
12
|
+
* (auto-detected: Next.js webpack, Metro, Vite). Best-effort: empty when
|
|
13
|
+
* the bundler does not expose a transport (e.g. Turbopack) or when the
|
|
14
|
+
* transport message has not arrived yet (e.g. Metro delivers updates on
|
|
15
|
+
* an independent socket).
|
|
16
|
+
*/
|
|
17
|
+
filePaths: string[];
|
|
18
|
+
root: FiberRoot;
|
|
19
|
+
/** new component types that were remounted, losing state */
|
|
20
|
+
staleComponents: unknown[];
|
|
21
|
+
/** mounted fibers whose component types were remounted */
|
|
22
|
+
staleFibers: Fiber[];
|
|
23
|
+
/** new component types that re-rendered preserving state */
|
|
24
|
+
updatedComponents: unknown[];
|
|
25
|
+
/** mounted fibers whose component types re-rendered preserving state */
|
|
26
|
+
updatedFibers: Fiber[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ReactRefreshHandler {
|
|
30
|
+
(update: ReactRefreshUpdate): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReactRefreshInstrumentationOptions {
|
|
34
|
+
onRefresh?: ReactRefreshHandler;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const collectFibersByComponentType = (root: FiberRoot, componentTypes: Set<unknown>): Fiber[] => {
|
|
38
|
+
if (componentTypes.size === 0 || !root.current) return [];
|
|
39
|
+
const matchedFibers: Fiber[] = [];
|
|
40
|
+
traverseFiber(root.current, (fiber) => {
|
|
41
|
+
// memo/forwardRef fibers carry the wrapper as fiber.type, while the
|
|
42
|
+
// refresh families can register either the wrapper or the inner type
|
|
43
|
+
if (componentTypes.has(fiber.type) || componentTypes.has(getType(fiber.type))) {
|
|
44
|
+
matchedFibers.push(fiber);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return matchedFibers;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const refreshHandlers = new Set<ReactRefreshHandler>();
|
|
51
|
+
const refreshWrappedRenderers = new WeakSet<ReactRenderer>();
|
|
52
|
+
|
|
53
|
+
let pendingFilePaths: string[] = [];
|
|
54
|
+
let pendingFilePathsReceivedAtMs = 0;
|
|
55
|
+
|
|
56
|
+
const bufferHotUpdateFilePaths = (filePaths: string[]): void => {
|
|
57
|
+
const nowMs = Date.now();
|
|
58
|
+
if (nowMs - pendingFilePathsReceivedAtMs > PENDING_HOT_UPDATE_MAX_AGE_MS) {
|
|
59
|
+
pendingFilePaths = [];
|
|
60
|
+
}
|
|
61
|
+
pendingFilePaths.push(...filePaths);
|
|
62
|
+
pendingFilePathsReceivedAtMs = nowMs;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const takeFreshFilePaths = (): string[] => {
|
|
66
|
+
if (Date.now() - pendingFilePathsReceivedAtMs > PENDING_HOT_UPDATE_MAX_AGE_MS) return [];
|
|
67
|
+
const freshFilePaths = [...pendingFilePaths];
|
|
68
|
+
// performReactRefresh calls scheduleRefresh synchronously once per
|
|
69
|
+
// mounted root, so clear the pending paths only after the whole
|
|
70
|
+
// refresh pass instead of on the first root
|
|
71
|
+
queueMicrotask(() => {
|
|
72
|
+
pendingFilePaths = [];
|
|
73
|
+
});
|
|
74
|
+
return freshFilePaths;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const wrapRendererScheduleRefresh = (renderer: ReactRenderer): void => {
|
|
78
|
+
if (refreshWrappedRenderers.has(renderer)) return;
|
|
79
|
+
const originalScheduleRefresh = renderer.scheduleRefresh;
|
|
80
|
+
if (typeof originalScheduleRefresh !== "function") return;
|
|
81
|
+
refreshWrappedRenderers.add(renderer);
|
|
82
|
+
renderer.scheduleRefresh = (root, update) => {
|
|
83
|
+
originalScheduleRefresh.call(renderer, root, update);
|
|
84
|
+
if (refreshHandlers.size === 0) return;
|
|
85
|
+
const staleComponents = Array.from(update.staleFamilies, (family) => family.current);
|
|
86
|
+
const updatedComponents = Array.from(update.updatedFamilies, (family) => family.current);
|
|
87
|
+
const refreshUpdate: ReactRefreshUpdate = {
|
|
88
|
+
filePaths: takeFreshFilePaths(),
|
|
89
|
+
root,
|
|
90
|
+
staleComponents,
|
|
91
|
+
staleFibers: collectFibersByComponentType(root, new Set(staleComponents)),
|
|
92
|
+
updatedComponents,
|
|
93
|
+
updatedFibers: collectFibersByComponentType(root, new Set(updatedComponents)),
|
|
94
|
+
};
|
|
95
|
+
for (const handler of refreshHandlers) {
|
|
96
|
+
handler(refreshUpdate);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
let isRefreshWired = false;
|
|
102
|
+
|
|
103
|
+
const ensureRefreshWired = (): void => {
|
|
104
|
+
if (isRefreshWired) return;
|
|
105
|
+
isRefreshWired = true;
|
|
106
|
+
const rdtHook = getRDTHook();
|
|
107
|
+
for (const renderer of rdtHook.renderers.values()) {
|
|
108
|
+
wrapRendererScheduleRefresh(renderer);
|
|
109
|
+
}
|
|
110
|
+
onRendererInject(wrapRendererScheduleRefresh);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
let activeTransport: HmrTransport | null = null;
|
|
114
|
+
|
|
115
|
+
// the bundler's HMR global may not exist yet when an early subscriber
|
|
116
|
+
// arrives, so detection is retried whenever the first subscriber (re)appears
|
|
117
|
+
const detectTransportForNewSubscriber = (): void => {
|
|
118
|
+
void detectHmrTransport(bufferHotUpdateFilePaths).then((detectedTransport) => {
|
|
119
|
+
if (!detectedTransport) return;
|
|
120
|
+
activeTransport?.dispose();
|
|
121
|
+
activeTransport = detectedTransport;
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Subscribes to react-refresh (fast refresh) updates by wrapping
|
|
127
|
+
* `scheduleRefresh` on every renderer injected into the React DevTools
|
|
128
|
+
* global hook. The react-refresh runtime calls `scheduleRefresh` after each
|
|
129
|
+
* hot update, so this works with any bundler that uses react-refresh (Vite,
|
|
130
|
+
* Next.js webpack, Next.js Turbopack, Metro) without bundler-specific code.
|
|
131
|
+
* Returns an unsubscribe function (a no-op in non-client environments like
|
|
132
|
+
* SSR, so callers never need an environment check). The returned function
|
|
133
|
+
* is also a `Disposable`, so it composes with other bippy subscriptions
|
|
134
|
+
* through `using`.
|
|
135
|
+
*
|
|
136
|
+
* The bundler's HMR transport is auto-detected and each refresh update is
|
|
137
|
+
* augmented with the hot-updated source file paths it reported.
|
|
138
|
+
*
|
|
139
|
+
* The handler runs after React has re-rendered with the new component
|
|
140
|
+
* types, so the refreshed root's fiber tree already carries them;
|
|
141
|
+
* `updatedFibers`/`staleFibers` are the mounted fibers matching the
|
|
142
|
+
* hot-swapped component types.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* ```ts
|
|
146
|
+
* const unsubscribe = instrumentReactRefresh({
|
|
147
|
+
* onRefresh(update) {
|
|
148
|
+
* for (const fiber of update.updatedFibers) {
|
|
149
|
+
* console.log("hot updated:", getDisplayName(fiber.type));
|
|
150
|
+
* }
|
|
151
|
+
* console.log("changed files:", update.filePaths);
|
|
152
|
+
* },
|
|
153
|
+
* });
|
|
154
|
+
* unsubscribe();
|
|
155
|
+
* ```
|
|
156
|
+
*
|
|
157
|
+
* Pair with `getSource(fiber)` from `bippy/source` to symbolicate the
|
|
158
|
+
* source locations of `updatedFibers` when needed.
|
|
159
|
+
*/
|
|
160
|
+
export const instrumentReactRefresh = (
|
|
161
|
+
options: ReactRefreshInstrumentationOptions,
|
|
162
|
+
): Unsubscribe => {
|
|
163
|
+
const { onRefresh } = options;
|
|
164
|
+
if (!onRefresh || !isClientEnvironment()) return toUnsubscribe(() => {});
|
|
165
|
+
ensureRefreshWired();
|
|
166
|
+
if (refreshHandlers.size === 0) {
|
|
167
|
+
detectTransportForNewSubscriber();
|
|
168
|
+
}
|
|
169
|
+
refreshHandlers.add(onRefresh);
|
|
170
|
+
return toUnsubscribe(() => {
|
|
171
|
+
refreshHandlers.delete(onRefresh);
|
|
172
|
+
});
|
|
173
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { HMR_RECONNECT_DELAY_MS } from "./constants.js";
|
|
2
|
+
import { HmrTransport, HmrUpdateHandler } from "./types.js";
|
|
3
|
+
|
|
4
|
+
declare global {
|
|
5
|
+
var __turboModuleProxy: ((moduleName: string) => unknown) | undefined;
|
|
6
|
+
var nativeModuleProxy: Record<string, unknown> | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const getScriptUrlFromSourceCodeModule = (sourceCodeModule: unknown): string | null => {
|
|
10
|
+
if (typeof sourceCodeModule !== "object" || sourceCodeModule === null) return null;
|
|
11
|
+
if (!("getConstants" in sourceCodeModule)) return null;
|
|
12
|
+
const getConstants = sourceCodeModule.getConstants;
|
|
13
|
+
if (typeof getConstants !== "function") return null;
|
|
14
|
+
let constants: unknown;
|
|
15
|
+
try {
|
|
16
|
+
constants = getConstants.call(sourceCodeModule);
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (typeof constants !== "object" || constants === null) return null;
|
|
21
|
+
if (!("scriptURL" in constants)) return null;
|
|
22
|
+
const scriptUrl = constants.scriptURL;
|
|
23
|
+
return typeof scriptUrl === "string" ? scriptUrl : null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolves the URL the running React Native bundle was loaded from, using
|
|
28
|
+
* the same `SourceCode` native module React Native's own dev tooling reads
|
|
29
|
+
* (via the TurboModule proxy globals, so react-native is not imported).
|
|
30
|
+
* Returns `null` outside a Metro-served React Native runtime.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* getMetroBundleUrl();
|
|
35
|
+
* // "http://localhost:8081/index.bundle?platform=ios&dev=true"
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export const getMetroBundleUrl = (): string | null => {
|
|
39
|
+
if (typeof globalThis.__turboModuleProxy === "function") {
|
|
40
|
+
let sourceCodeModule: unknown;
|
|
41
|
+
try {
|
|
42
|
+
sourceCodeModule = globalThis.__turboModuleProxy("SourceCode");
|
|
43
|
+
} catch {
|
|
44
|
+
sourceCodeModule = null;
|
|
45
|
+
}
|
|
46
|
+
const scriptUrl = getScriptUrlFromSourceCodeModule(sourceCodeModule);
|
|
47
|
+
if (scriptUrl) return scriptUrl;
|
|
48
|
+
}
|
|
49
|
+
const legacySourceCodeModule = globalThis.nativeModuleProxy?.SourceCode;
|
|
50
|
+
return getScriptUrlFromSourceCodeModule(legacySourceCodeModule);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// HACK: module sourceURLs may be JSC-safe URLs where "//&" stands in for
|
|
54
|
+
// "?" (iOS 16.4 stack traces strip query strings), so the query separator
|
|
55
|
+
// must be normalized before URL parsing.
|
|
56
|
+
const normalizeJscSafeUrl = (jscSafeUrl: string): string => jscSafeUrl.replace("//&", "?");
|
|
57
|
+
|
|
58
|
+
const getSourcePathFromSourceUrl = (sourceUrl: string): string | null => {
|
|
59
|
+
let parsedUrl: URL;
|
|
60
|
+
try {
|
|
61
|
+
parsedUrl = new URL(normalizeJscSafeUrl(sourceUrl));
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
let sourcePath = decodeURIComponent(parsedUrl.pathname);
|
|
66
|
+
if (sourcePath.startsWith("/")) sourcePath = sourcePath.slice(1);
|
|
67
|
+
// Metro rewrites each module's real extension to ".bundle" when building
|
|
68
|
+
// hot-update sourceURLs, so the original extension is unrecoverable.
|
|
69
|
+
if (sourcePath.endsWith(".bundle")) sourcePath = sourcePath.slice(0, -".bundle".length);
|
|
70
|
+
return sourcePath.length > 0 ? sourcePath : null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const collectModuleSourcePaths = (hmrModules: unknown, filePaths: string[]) => {
|
|
74
|
+
if (!Array.isArray(hmrModules)) return;
|
|
75
|
+
for (const hmrModule of hmrModules) {
|
|
76
|
+
if (typeof hmrModule !== "object" || hmrModule === null) continue;
|
|
77
|
+
if (!("sourceURL" in hmrModule) || typeof hmrModule.sourceURL !== "string") continue;
|
|
78
|
+
const sourcePath = getSourcePathFromSourceUrl(hmrModule.sourceURL);
|
|
79
|
+
if (!sourcePath || sourcePath.includes("node_modules")) continue;
|
|
80
|
+
filePaths.push(sourcePath);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extracts the updated source file paths from a raw Metro HMR WebSocket
|
|
86
|
+
* message. Paths are project-relative but extension-less (`src/app`, not
|
|
87
|
+
* `src/app.tsx`) because Metro rewrites module extensions to `.bundle`.
|
|
88
|
+
* The initial update replayed on connect is skipped. Returns an empty
|
|
89
|
+
* array for any other message shape.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* parseMetroUpdatePaths(rawMessageData);
|
|
94
|
+
* // ["src/app"]
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export const parseMetroUpdatePaths = (rawMessageData: string): string[] => {
|
|
98
|
+
let message: unknown;
|
|
99
|
+
try {
|
|
100
|
+
message = JSON.parse(rawMessageData);
|
|
101
|
+
} catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
if (typeof message !== "object" || message === null) return [];
|
|
105
|
+
if (!("type" in message) || message.type !== "update") return [];
|
|
106
|
+
if (!("body" in message) || typeof message.body !== "object" || message.body === null) return [];
|
|
107
|
+
const updateBody = message.body;
|
|
108
|
+
if ("isInitialUpdate" in updateBody && updateBody.isInitialUpdate === true) return [];
|
|
109
|
+
const filePaths: string[] = [];
|
|
110
|
+
if ("added" in updateBody) collectModuleSourcePaths(updateBody.added, filePaths);
|
|
111
|
+
if ("modified" in updateBody) collectModuleSourcePaths(updateBody.modified, filePaths);
|
|
112
|
+
return filePaths;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export interface MetroHmrTransportOptions {
|
|
116
|
+
bundleUrl?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Subscribes to the Metro dev server's `/hot` HMR WebSocket (as a second
|
|
121
|
+
* client alongside React Native's own) and invokes `onHmrUpdate` with the
|
|
122
|
+
* updated file paths on every hot update. Reconnects automatically when
|
|
123
|
+
* the dev server restarts. Returns `null` when no Metro bundle URL can be
|
|
124
|
+
* resolved (production builds, non-Metro runtimes).
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* const transport = createMetroHmrTransport((filePaths) => {
|
|
129
|
+
* console.log("hot updated:", filePaths);
|
|
130
|
+
* });
|
|
131
|
+
* transport?.dispose();
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export const createMetroHmrTransport = (
|
|
135
|
+
onHmrUpdate: HmrUpdateHandler,
|
|
136
|
+
options: MetroHmrTransportOptions = {},
|
|
137
|
+
): HmrTransport | null => {
|
|
138
|
+
if (typeof WebSocket === "undefined") return null;
|
|
139
|
+
const bundleUrl = options.bundleUrl ?? getMetroBundleUrl();
|
|
140
|
+
if (!bundleUrl) return null;
|
|
141
|
+
|
|
142
|
+
let hotSocketUrl: string;
|
|
143
|
+
try {
|
|
144
|
+
const parsedBundleUrl = new URL(bundleUrl);
|
|
145
|
+
const socketProtocol = parsedBundleUrl.protocol === "https:" ? "wss" : "ws";
|
|
146
|
+
hotSocketUrl = `${socketProtocol}://${parsedBundleUrl.host}/hot`;
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let isDisposed = false;
|
|
152
|
+
let socket: WebSocket | null = null;
|
|
153
|
+
let reconnectTimerId: ReturnType<typeof setTimeout> | undefined;
|
|
154
|
+
|
|
155
|
+
const scheduleReconnect = () => {
|
|
156
|
+
if (isDisposed) return;
|
|
157
|
+
reconnectTimerId = setTimeout(connect, HMR_RECONNECT_DELAY_MS);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const connect = () => {
|
|
161
|
+
if (isDisposed) return;
|
|
162
|
+
const connectedSocket = new WebSocket(hotSocketUrl);
|
|
163
|
+
socket = connectedSocket;
|
|
164
|
+
connectedSocket.onopen = () => {
|
|
165
|
+
connectedSocket.send(
|
|
166
|
+
JSON.stringify({ type: "register-entrypoints", entryPoints: [bundleUrl] }),
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
connectedSocket.onmessage = (event) => {
|
|
170
|
+
const filePaths = parseMetroUpdatePaths(String(event.data));
|
|
171
|
+
if (filePaths.length > 0) onHmrUpdate(filePaths);
|
|
172
|
+
};
|
|
173
|
+
connectedSocket.onclose = scheduleReconnect;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
connect();
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
dispose: () => {
|
|
180
|
+
isDisposed = true;
|
|
181
|
+
clearTimeout(reconnectTimerId);
|
|
182
|
+
if (socket) {
|
|
183
|
+
socket.onclose = null;
|
|
184
|
+
socket.close();
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { HMR_SOURCE_FILE_EXTENSION_REGEX } from "./constants.js";
|
|
2
|
+
import { normalizeHmrFilePath } from "./normalize-hmr-file-path.js";
|
|
3
|
+
import { HmrTransport, HmrUpdateHandler } from "./types.js";
|
|
4
|
+
|
|
5
|
+
interface WebpackHotUpdateGlobal {
|
|
6
|
+
(chunkId: unknown, updatedModules: Record<string, unknown> | undefined, runtime: unknown): void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
declare global {
|
|
10
|
+
interface Window {
|
|
11
|
+
webpackHotUpdate_N_E?: WebpackHotUpdateGlobal;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Normalizes webpack hot-update module keys into project-relative source
|
|
17
|
+
* file paths, dropping node_modules entries and non-source keys (e.g.
|
|
18
|
+
* webpack runtime helpers).
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* normalizeWebpackModulePaths(["(app-pages-browser)/./app/page.tsx"]);
|
|
23
|
+
* // ["app/page.tsx"]
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export const normalizeWebpackModulePaths = (moduleKeys: string[]): string[] => {
|
|
27
|
+
const filePaths: string[] = [];
|
|
28
|
+
for (const moduleKey of moduleKeys) {
|
|
29
|
+
if (moduleKey.includes("node_modules")) continue;
|
|
30
|
+
const filePath = normalizeHmrFilePath(moduleKey);
|
|
31
|
+
if (!HMR_SOURCE_FILE_EXTENSION_REGEX.test(filePath)) continue;
|
|
32
|
+
filePaths.push(filePath);
|
|
33
|
+
}
|
|
34
|
+
return filePaths;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Subscribes to Next.js webpack hot updates by wrapping the
|
|
39
|
+
* `webpackHotUpdate_N_E` global and invokes `onHmrUpdate` with the updated
|
|
40
|
+
* file paths. Returns `null` when the page is not a Next.js webpack dev
|
|
41
|
+
* build.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* const transport = createNextWebpackHmrTransport((filePaths) => {
|
|
46
|
+
* console.log("hot updated:", filePaths);
|
|
47
|
+
* });
|
|
48
|
+
* transport?.dispose();
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export const createNextWebpackHmrTransport = (
|
|
52
|
+
onHmrUpdate: HmrUpdateHandler,
|
|
53
|
+
): HmrTransport | null => {
|
|
54
|
+
if (typeof window === "undefined") return null;
|
|
55
|
+
const originalHotUpdate = window.webpackHotUpdate_N_E;
|
|
56
|
+
if (typeof originalHotUpdate !== "function") return null;
|
|
57
|
+
|
|
58
|
+
const wrappedHotUpdate: WebpackHotUpdateGlobal = (chunkId, updatedModules, runtime) => {
|
|
59
|
+
const filePaths = normalizeWebpackModulePaths(Object.keys(updatedModules ?? {}));
|
|
60
|
+
if (filePaths.length > 0) onHmrUpdate(filePaths);
|
|
61
|
+
originalHotUpdate(chunkId, updatedModules, runtime);
|
|
62
|
+
};
|
|
63
|
+
window.webpackHotUpdate_N_E = wrappedHotUpdate;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
dispose: () => {
|
|
67
|
+
if (window.webpackHotUpdate_N_E === wrappedHotUpdate) {
|
|
68
|
+
window.webpackHotUpdate_N_E = originalHotUpdate;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { normalizeFileName } from "../source/get-source.js";
|
|
2
|
+
|
|
3
|
+
import { BUNDLER_LAYER_PREFIX_REGEX } from "./constants.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes a bundler module key or HMR update path into a plain,
|
|
7
|
+
* project-relative file path. Strips URL schemes (via
|
|
8
|
+
* {@link normalizeFileName}), bundler layer prefixes like
|
|
9
|
+
* `(app-pages-browser)/`, and leading `./` segments.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* normalizeHmrFilePath("(app-pages-browser)/./app/page.tsx");
|
|
14
|
+
* // "app/page.tsx"
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export const normalizeHmrFilePath = (filePath: string): string => {
|
|
18
|
+
let normalizedFilePath = normalizeFileName(filePath);
|
|
19
|
+
normalizedFilePath = normalizedFilePath.replace(BUNDLER_LAYER_PREFIX_REGEX, "");
|
|
20
|
+
if (normalizedFilePath.startsWith("./")) {
|
|
21
|
+
normalizedFilePath = normalizedFilePath.slice(2);
|
|
22
|
+
}
|
|
23
|
+
return normalizedFilePath;
|
|
24
|
+
};
|