Skip to content

Commit e4021e5

Browse files
authored
feat: add support for metro HMR (#2)
This PR implements full Hot Module Replacement (HMR) support for preview components in Metro, enabling reliable updates, additions, and deletions during development without requiring DevTools refreshes. - Cleaned up legacy behavior: Removed old preview-added, preview-list, and preview-clear message handlers and replaced them with a single registry-updated event. - On any preview registration change (add/update/delete), the entire preview registry is pushed to DevTools automatically. - No more manual DevTools refresh to remove stale previews. Clean separation of internal vs. external API (`__registerPreviewInternal` used only with `module` passed from Babel).
1 parent 9ef3d58 commit e4021e5

File tree

9 files changed

+192
-160
lines changed

9 files changed

+192
-160
lines changed

README.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,6 @@ export default function App() {
7676

7777
https://github.com/user-attachments/assets/dffe5803-fb6a-4b45-9621-48cbbdb25ad2
7878

79-
## Known Issues
80-
81-
- **HMR Support**: Hot Module Replacement (HMR) is not fully supported yet. You need to refresh the DevTools to see changes in deleted previews. Adding or modifying previews works for now.
82-
8379
## API
8480

8581
- `registerPreview(name: string, component: React.ComponentType)`

babel-plugin/preview-babel-plugin-metadata.cjs

Lines changed: 56 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,17 @@
11
const path = require("path");
2-
const crypto = require("crypto");
2+
33
const { isInsideReactComponent } = require("./react-helper.cjs");
44

55
/**
6-
* Babel plugin that automatically injects the file path and other metadata
7-
* to registerPreview() calls imported from "rozenite-preview"
6+
* Babel plugin that automatically injects file path, relative filename,
7+
* React component context, name, call site location, and other metadata
8+
* into registerPreview() calls imported from "rozenite-preview".
9+
* Also injects the Metro "module" object as the first argument.
810
*/
911
const ROZENITE_PREVIEW_MODULE = "rozenite-preview";
1012
const TARGET_FUNCTION = "registerPreview";
1113
const EXPECTED_ARGS_COUNT = 2;
1214

13-
const cache = {};
14-
15-
function getOrCreateId(file, line) {
16-
const key = `${file}:${line}`;
17-
if (cache[key]) return cache[key];
18-
19-
const hash = crypto.createHash("md5").update(key).digest("hex").slice(0, 6);
20-
const id = `${path
21-
.basename(file, path.extname(file))
22-
.toLowerCase()}_${line}_${hash}`;
23-
cache[key] = id;
24-
return id;
25-
}
26-
2715
module.exports = function ({ types: t }) {
2816
return {
2917
visitor: {
@@ -34,12 +22,18 @@ module.exports = function ({ types: t }) {
3422
return;
3523
}
3624

37-
injectFilePathIntoRegisterPreviewCalls(
38-
path,
39-
state,
40-
rozenitePreviewImports,
41-
t
42-
);
25+
path.traverse({
26+
CallExpression(callPath) {
27+
if (
28+
!isTargetRegisterPreviewCall(callPath, rozenitePreviewImports)
29+
) {
30+
return;
31+
}
32+
33+
injectMetadataIntoRegisterPreviewCalls(callPath, state, t);
34+
injectMetroModuleIntoRegisterPreviewCalls(callPath, t);
35+
},
36+
});
4337
},
4438
},
4539
};
@@ -94,67 +88,41 @@ function isValidImportSpecifier(specifier, t) {
9488
}
9589

9690
/**
97-
* Traverses the AST and injects file paths into registerPreview calls
98-
* @param {Object} programPath - The program AST path
91+
* Traverses the AST and injects metadata into registerPreview calls
92+
* @param {Object} callPath - The call expression AST path
9993
* @param {Object} state - Babel plugin state
100-
* @param {Set} rozenitePreviewImports - Set of imported identifiers from rozenite-preview
10194
* @param {Object} t - Babel types helper
10295
*/
103-
function injectFilePathIntoRegisterPreviewCalls(
104-
programPath,
105-
state,
106-
rozenitePreviewImports,
107-
t
108-
) {
96+
function injectMetadataIntoRegisterPreviewCalls(callPath, state, t) {
10997
const filename = state.file.opts.filename || "";
11098
const relativeFilename = path.relative(process.cwd(), filename);
11199

112-
programPath.traverse({
113-
CallExpression(callPath) {
114-
if (isTargetRegisterPreviewCall(callPath, rozenitePreviewImports)) {
115-
const metadata = getMetadata(callPath);
116-
const filePath = t.stringLiteral(filename);
117-
const id = getOrCreateId(relativeFilename, metadata.line);
118-
const argument = t.objectExpression([
119-
t.objectProperty(t.identifier("id"), t.stringLiteral(id)),
120-
t.objectProperty(t.identifier("filePath"), filePath),
121-
t.objectProperty(
122-
t.identifier("relativeFilename"),
123-
t.stringLiteral(relativeFilename)
124-
),
125-
t.objectProperty(
126-
t.identifier("isInsideReactComponent"),
127-
t.booleanLiteral(isInsideReactComponent(callPath))
128-
),
129-
t.objectProperty(
130-
t.identifier("name"),
131-
t.stringLiteral(metadata.name)
132-
),
133-
t.objectProperty(
134-
t.identifier("nameType"),
135-
t.stringLiteral(metadata.nameType)
136-
),
137-
t.objectProperty(
138-
t.identifier("callId"),
139-
t.stringLiteral(metadata.callId)
140-
),
141-
t.objectProperty(
142-
t.identifier("componentType"),
143-
t.stringLiteral(metadata.componentType)
144-
),
145-
t.objectProperty(
146-
t.identifier("line"),
147-
t.numericLiteral(metadata.line)
148-
),
149-
t.objectProperty(
150-
t.identifier("column"),
151-
t.numericLiteral(metadata.column)
152-
),
153-
]);
154-
callPath.node.arguments.push(argument);
155-
}
156-
},
157-
});
100+
const metadata = getMetadata(callPath);
101+
const filePath = t.stringLiteral(filename);
102+
const argument = t.objectExpression([
103+
t.objectProperty(t.identifier("filePath"), filePath),
104+
t.objectProperty(
105+
t.identifier("relativeFilename"),
106+
t.stringLiteral(relativeFilename)
107+
),
108+
t.objectProperty(
109+
t.identifier("isInsideReactComponent"),
110+
t.booleanLiteral(isInsideReactComponent(callPath))
111+
),
112+
t.objectProperty(t.identifier("name"), t.stringLiteral(metadata.name)),
113+
t.objectProperty(
114+
t.identifier("nameType"),
115+
t.stringLiteral(metadata.nameType)
116+
),
117+
t.objectProperty(t.identifier("callId"), t.stringLiteral(metadata.callId)),
118+
t.objectProperty(
119+
t.identifier("componentType"),
120+
t.stringLiteral(metadata.componentType)
121+
),
122+
t.objectProperty(t.identifier("line"), t.numericLiteral(metadata.line)),
123+
t.objectProperty(t.identifier("column"), t.numericLiteral(metadata.column)),
124+
]);
125+
callPath.node.arguments.push(argument);
158126
}
159127

160128
/**
@@ -228,3 +196,13 @@ function isTargetRegisterPreviewCall(callPath, rozenitePreviewImports) {
228196
callPath.node.arguments.length === EXPECTED_ARGS_COUNT
229197
);
230198
}
199+
200+
/**
201+
* Injects the Metro module into all registerPreview calls as the first argument.
202+
* @param callPath - The call expression AST path
203+
* @param t
204+
*/
205+
function injectMetroModuleIntoRegisterPreviewCalls(callPath, t) {
206+
const { node } = callPath;
207+
node.arguments.unshift(t.identifier("module"));
208+
}

src/react-native/preview-host.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,14 @@ export const PreviewHostImpl = (props: PreviewHostProps): JSX.Element => {
3939
}
4040
);
4141

42-
const removePreviewClearListener = client.onMessage("preview-clear", () => {
42+
const showMainAppListener = client.onMessage("show-main-app", () => {
4343
setPreviewName(null);
4444
setComponent(null);
4545
});
4646

4747
return () => {
4848
removePreviewSelectListener.remove();
49-
removePreviewClearListener.remove();
49+
showMainAppListener.remove();
5050
};
5151
}, [client]);
5252

Lines changed: 57 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,80 @@
1-
import { Metadata, Preview } from "../shared/types";
2-
import { getClient } from "./setup-plugin";
1+
import { ComponentType } from "react";
2+
import { Metadata, MetroModule, Preview } from "../shared/types";
3+
import { createSignalMap } from "../utils/signal-map";
4+
import { client } from "./setup-plugin";
35

4-
const registry = new Map<string, Preview>();
6+
const registry = createSignalMap<number, Preview[]>(() => {
7+
const previews = getPreviewComponents();
8+
client?.send("registry-updated", previews);
9+
});
510

6-
export async function registerPreview(
7-
name: string,
8-
component: React.ComponentType
9-
): Promise<void>;
11+
function flattenRegistryEntries() {
12+
return Array.from(registry.values()).flat();
13+
}
14+
15+
export function getPreviewComponents(): Preview[] {
16+
return flattenRegistryEntries();
17+
}
18+
19+
export function getComponentByName(name: string) {
20+
return flattenRegistryEntries().find((entry) => entry.name === name)?.component || null;
21+
}
1022

1123
/**
12-
* Register a preview component.
13-
* @param name Preview name
14-
* @param component React component
24+
*
25+
* @internal
1526
*/
16-
export async function registerPreview(
27+
const __registerPreviewInternal = (
28+
module: MetroModule,
1729
name: string,
18-
component: React.ComponentType,
19-
/**
20-
* This is injected by babel plugin
21-
* @internal
22-
*/
30+
component: ComponentType,
2331
metadata?: Metadata
24-
) {
25-
if (process.env.NODE_ENV !== "development") {
32+
) => {
33+
if (
34+
process.env.NODE_ENV !== "development" ||
35+
!module.hot ||
36+
module.id === undefined
37+
) {
38+
console.warn(
39+
`Cannot register preview "${name}" in production or in non Metro environment.`
40+
);
2641
return;
2742
}
2843

2944
if (metadata?.isInsideReactComponent) {
3045
console.error(
31-
`Cannot register preview "${name}" from inside a React component. Please call it at the top level of your module.`
46+
'Do not call "registerPreview" inside a React lifecycle. Use it at the top level of your module.'
3247
);
3348
return;
3449
}
3550

36-
const entry: Preview = {
37-
name,
38-
component,
39-
metadata,
40-
};
51+
let moduleId = module.id;
4152

42-
registry.set(name, entry);
53+
const originalDisposeCallback = module.hot._disposeCallback;
4354

44-
const client = await getClient();
55+
module.hot._disposeCallback = () => {
56+
originalDisposeCallback?.();
57+
registry.delete(moduleId);
58+
};
4559

46-
if (!client) {
47-
console.warn(
48-
`No Rozenite DevTools client found! Cannot register preview: ${name}`
49-
);
50-
return;
51-
}
60+
const current = registry.get(module.id) || [];
5261

53-
client.send("preview-added", entry);
54-
}
62+
const updated = [
63+
...current.filter((entry) => entry.name !== name),
64+
{ name, component, metadata },
65+
];
5566

56-
export function getPreviewComponents(): Preview[] {
57-
return Array.from(registry.values());
58-
}
67+
registry.set(module.id, updated);
68+
};
5969

60-
export function getComponentByName(name: string) {
61-
return registry.get(name)?.component;
70+
/**
71+
* Register a preview component.
72+
*
73+
* @param name Preview name
74+
* @param component React component
75+
*/
76+
export function registerPreview(name: string, component: React.ComponentType) {
77+
__registerPreviewInternal(
78+
...(arguments as unknown as [MetroModule, string, ComponentType, Metadata])
79+
);
6280
}

src/react-native/setup-plugin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { getPreviewComponents } from "./preview-registry";
88

99
export let client: RozeniteDevToolsClient<PreviewPluginEventMap> | null = null;
1010

11-
export const getClient = () => {
11+
const getClient = () => {
1212
return getRozeniteDevToolsClient<PreviewPluginEventMap>(PREVIEW_PLUGIN_ID);
1313
};
1414

@@ -19,7 +19,7 @@ async function setupPlugin() {
1919

2020
existingClient.onMessage("request-initial-data", () => {
2121
const previews = getPreviewComponents();
22-
existingClient.send("preview-list", previews);
22+
existingClient.send("registry-updated", previews);
2323
});
2424
}
2525

src/shared/messaging.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ import { Preview } from "./types";
33

44
export type PreviewPluginEventMap = {
55
"request-initial-data": unknown;
6-
"preview-clear": unknown;
7-
"preview-list": Preview[];
8-
"preview-added": Preview;
6+
"show-main-app": unknown;
7+
"registry-updated": Preview[];
98
"preview-select": {
109
name: string;
1110
};

src/shared/types.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export interface Metadata {
88
componentType: string | null;
99
relativeFilename: string | null;
1010
filePath: string | null;
11-
isInsideReactComponent: boolean;
11+
isInsideReactComponent: boolean;
1212
}
1313

1414
export interface Preview {
@@ -17,14 +17,25 @@ export interface Preview {
1717
metadata?: Metadata;
1818
}
1919

20-
export type DevToolsActionType =
21-
| "preview:list" // DevTools → App: Request list of previewable components
22-
| "preview:list-response" // App → DevTools: Send back the list
23-
| "preview:select" // DevTools → App: Select and render a specific component
24-
| "preview:clear" // DevTools → App: Clear the current preview
25-
| "preview:update-props" // DevTools → App: Update props passed to previewed component
26-
| "preview:get-current" // DevTools → App: Ask which component is currently previewed
27-
| "preview:current-response" // App → DevTools: Respond with currently previewed component
28-
| "preview:error"; // App → DevTools: Report an error (e.g. component not found)
29-
3020
export const PREVIEW_PLUGIN_ID = "rozenite-preview";
21+
22+
type HotModuleReloadingCallback = () => void;
23+
24+
type HotModuleReloadingData = {
25+
_acceptCallback?: HotModuleReloadingCallback;
26+
_disposeCallback?: HotModuleReloadingCallback;
27+
_didAccept: boolean;
28+
accept: (callback?: HotModuleReloadingCallback) => void;
29+
dispose: (callback?: HotModuleReloadingCallback) => void;
30+
};
31+
32+
type ModuleID = number;
33+
34+
type Exports = any;
35+
36+
// Ref: https://github.com/facebook/metro/blob/a81c99cf103be00181aa635fef94c6e3385a47bb/packages/metro-runtime/src/polyfills/require.js#L51
37+
export type MetroModule = {
38+
id?: ModuleID;
39+
exports: Exports;
40+
hot?: HotModuleReloadingData;
41+
};

0 commit comments

Comments
 (0)