Skip to content

Commit 6077370

Browse files
committed
hmr
1 parent f125abf commit 6077370

File tree

10 files changed

+848
-146
lines changed

10 files changed

+848
-146
lines changed

crates/oxc_angular_compiler/src/hmr/initializer.rs

Lines changed: 104 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,17 @@ pub fn compile_hmr_initializer<'a>(
7373
);
7474

7575
// (m) => m.default && ɵɵreplaceMetadata(...)
76-
let replace_callback = arrow_fn(
76+
let replace_callback = OutputExpression::ArrowFunction(Box::new_in(
77+
ArrowFunctionExpr {
78+
params: Vec::from_iter_in([FnParam { name: Atom::from(module_name) }], allocator),
79+
body: ArrowFunctionBody::Expression(Box::new_in(
80+
binary_op(allocator, BinaryOperator::And, default_read, replace_call),
81+
allocator,
82+
)),
83+
source_span: None,
84+
},
7785
allocator,
78-
vec![module_name],
79-
binary_op(allocator, BinaryOperator::And, default_read.clone_in(allocator), replace_call),
80-
);
86+
));
8187

8288
// i0.ɵɵgetReplaceMetadataURL(id, timestamp, import.meta.url)
8389
let url = invoke_fn(
@@ -120,25 +126,31 @@ pub fn compile_hmr_initializer<'a>(
120126
));
121127

122128
// (d) => d.id === id && Cmp_HmrLoad(d.timestamp)
123-
let update_callback = arrow_fn(
124-
allocator,
125-
vec![data_name],
126-
binary_op(
127-
allocator,
128-
BinaryOperator::And,
129-
binary_op(
130-
allocator,
131-
BinaryOperator::Identical,
132-
read_prop(allocator, read_var(allocator, data_name), "id"),
133-
read_var(allocator, id_name),
134-
),
135-
invoke_fn(
129+
let update_callback = OutputExpression::ArrowFunction(Box::new_in(
130+
ArrowFunctionExpr {
131+
params: Vec::from_iter_in([FnParam { name: Atom::from(data_name) }], allocator),
132+
body: ArrowFunctionBody::Expression(Box::new_in(
133+
binary_op(
134+
allocator,
135+
BinaryOperator::And,
136+
binary_op(
137+
allocator,
138+
BinaryOperator::Identical,
139+
read_prop(allocator, read_var(allocator, data_name), "id"),
140+
read_var(allocator, id_name),
141+
),
142+
invoke_fn(
143+
allocator,
144+
read_var(allocator, &import_callback_name),
145+
vec![read_prop(allocator, read_var(allocator, data_name), "timestamp")],
146+
),
147+
),
136148
allocator,
137-
read_var(allocator, &import_callback_name),
138-
vec![read_prop(allocator, read_var(allocator, data_name), "timestamp")],
139-
),
140-
),
141-
);
149+
)),
150+
source_span: None,
151+
},
152+
allocator,
153+
));
142154

143155
// Cmp_HmrLoad(Date.now())
144156
let initial_call = invoke_fn(
@@ -162,6 +174,41 @@ pub fn compile_hmr_initializer<'a>(
162174
vec![literal_str(allocator, "angular:component-update"), update_callback],
163175
);
164176

177+
// (d) => d.id === id && location.reload()
178+
// Handles the angular:invalidate event sent when HMR fails
179+
let invalidate_callback = OutputExpression::ArrowFunction(Box::new_in(
180+
ArrowFunctionExpr {
181+
params: Vec::from_iter_in([FnParam { name: Atom::from(data_name) }], allocator),
182+
body: ArrowFunctionBody::Expression(Box::new_in(
183+
binary_op(
184+
allocator,
185+
BinaryOperator::And,
186+
binary_op(
187+
allocator,
188+
BinaryOperator::Identical,
189+
read_prop(allocator, read_var(allocator, data_name), "id"),
190+
read_var(allocator, id_name),
191+
),
192+
invoke_fn(
193+
allocator,
194+
read_prop(allocator, read_var(allocator, "location"), "reload"),
195+
vec![],
196+
),
197+
),
198+
allocator,
199+
)),
200+
source_span: None,
201+
},
202+
allocator,
203+
));
204+
205+
// import.meta.hot.on('angular:invalidate', invalidateCallback)
206+
let invalidate_listener = invoke_fn(
207+
allocator,
208+
read_prop(allocator, hot_read.clone_in(allocator), "on"),
209+
vec![literal_str(allocator, "angular:invalidate"), invalidate_callback],
210+
);
211+
165212
// Build the component ID - matches TypeScript's:
166213
// o.literal(encodeURIComponent(`${meta.filePath}@${meta.className}`))
167214
let component_id = encode_uri_component(&format!("{}@{}", meta.file_path, meta.class_name));
@@ -172,10 +219,41 @@ pub fn compile_hmr_initializer<'a>(
172219
// ngDevMode && Cmp_HmrLoad(Date.now());
173220
let guarded_initial_call = dev_only_guarded(allocator, initial_call);
174221

175-
// ngDevMode && import.meta.hot && import.meta.hot.on(...)
222+
// import.meta.hot.accept(() => {})
223+
// Creates an HMR boundary in Vite so that:
224+
// 1. import.meta.hot is available for custom event listeners
225+
// 2. Module changes don't propagate up the importer chain
226+
// The empty callback means we handle updates via custom events, not accept() itself
227+
let empty_callback = OutputExpression::ArrowFunction(Box::new_in(
228+
ArrowFunctionExpr {
229+
params: Vec::new_in(allocator),
230+
body: ArrowFunctionBody::Statements(Vec::new_in(allocator)),
231+
source_span: None,
232+
},
233+
allocator,
234+
));
235+
let hot_accept = invoke_fn(
236+
allocator,
237+
read_prop(allocator, hot_read.clone_in(allocator), "accept"),
238+
vec![empty_callback],
239+
);
240+
241+
// ngDevMode && import.meta.hot && import.meta.hot.accept(() => {})
242+
let guarded_accept = dev_only_guarded(
243+
allocator,
244+
binary_op(allocator, BinaryOperator::And, hot_read.clone_in(allocator), hot_accept),
245+
);
246+
247+
// ngDevMode && import.meta.hot && import.meta.hot.on('angular:component-update', ...)
176248
let guarded_listener = dev_only_guarded(
177249
allocator,
178-
binary_op(allocator, BinaryOperator::And, hot_read, hot_listener),
250+
binary_op(allocator, BinaryOperator::And, hot_read.clone_in(allocator), hot_listener),
251+
);
252+
253+
// ngDevMode && import.meta.hot && import.meta.hot.on('angular:invalidate', ...)
254+
let guarded_invalidate_listener = dev_only_guarded(
255+
allocator,
256+
binary_op(allocator, BinaryOperator::And, hot_read, invalidate_listener),
179257
);
180258

181259
// Build the IIFE: (() => { ... })()
@@ -184,7 +262,9 @@ pub fn compile_hmr_initializer<'a>(
184262
id_decl,
185263
import_callback,
186264
expr_stmt(allocator, guarded_initial_call),
265+
expr_stmt(allocator, guarded_accept),
187266
expr_stmt(allocator, guarded_listener),
267+
expr_stmt(allocator, guarded_invalidate_listener),
188268
],
189269
allocator,
190270
);
@@ -394,26 +474,6 @@ fn binary_op<'a>(
394474
))
395475
}
396476

397-
/// Create an arrow function expression.
398-
fn arrow_fn<'a>(
399-
allocator: &'a Allocator,
400-
param_names: std::vec::Vec<&str>,
401-
body: OutputExpression<'a>,
402-
) -> OutputExpression<'a> {
403-
let params: Vec<'a, FnParam<'a>> = Vec::from_iter_in(
404-
param_names.into_iter().map(|name| FnParam { name: Atom::from(allocator.alloc_str(name)) }),
405-
allocator,
406-
);
407-
OutputExpression::ArrowFunction(Box::new_in(
408-
ArrowFunctionExpr {
409-
params,
410-
body: ArrowFunctionBody::Expression(Box::new_in(body, allocator)),
411-
source_span: None,
412-
},
413-
allocator,
414-
))
415-
}
416-
417477
/// Create a variable declaration statement.
418478
fn var_decl<'a>(
419479
allocator: &'a Allocator,
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { test as base, expect, type Page } from "@playwright/test";
2+
import { readFile, writeFile } from "node:fs/promises";
3+
import { join } from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
6+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
7+
const PLAYGROUND_APP = join(__dirname, "../../playground/src/app");
8+
9+
/**
10+
* File modification utility for e2e tests.
11+
* Backs up files before modification and restores them after tests.
12+
*/
13+
export class FileModifier {
14+
private originalContents: Map<string, string> = new Map();
15+
16+
/**
17+
* Modify a file in the playground app directory.
18+
* Automatically backs up the original content for restoration.
19+
*/
20+
async modifyFile(
21+
filename: string,
22+
modifier: (content: string) => string,
23+
): Promise<void> {
24+
const filepath = join(PLAYGROUND_APP, filename);
25+
const content = await readFile(filepath, "utf-8");
26+
27+
if (!this.originalContents.has(filename)) {
28+
this.originalContents.set(filename, content);
29+
}
30+
31+
const modified = modifier(content);
32+
await writeFile(filepath, modified);
33+
}
34+
35+
/**
36+
* Restore a specific file to its original content.
37+
*/
38+
async restoreFile(filename: string): Promise<void> {
39+
const original = this.originalContents.get(filename);
40+
if (original) {
41+
const filepath = join(PLAYGROUND_APP, filename);
42+
await writeFile(filepath, original);
43+
this.originalContents.delete(filename);
44+
}
45+
}
46+
47+
/**
48+
* Restore all modified files to their original content.
49+
*/
50+
async restoreAll(): Promise<void> {
51+
for (const [filename] of this.originalContents) {
52+
await this.restoreFile(filename);
53+
}
54+
}
55+
}
56+
57+
/**
58+
* HMR detection utility.
59+
* Uses DOM sentinel approach to reliably detect HMR vs full page reload.
60+
*/
61+
export class HmrDetector {
62+
constructor(private page: Page) {}
63+
64+
/**
65+
* Add a DOM sentinel element that will survive HMR but be destroyed on full reload.
66+
* @returns The sentinel ID for later checking
67+
*/
68+
async addSentinel(): Promise<string> {
69+
const sentinelId = `hmr-sentinel-${Date.now()}`;
70+
await this.page.evaluate((id) => {
71+
const el = document.createElement("div");
72+
el.id = id;
73+
el.style.display = "none";
74+
document.body.appendChild(el);
75+
}, sentinelId);
76+
return sentinelId;
77+
}
78+
79+
/**
80+
* Check if a sentinel element still exists in the DOM.
81+
* - Exists: HMR occurred (DOM was mutated, not replaced)
82+
* - Gone: Full page reload (entire DOM was replaced)
83+
*/
84+
async sentinelExists(sentinelId: string): Promise<boolean> {
85+
return (await this.page.locator(`#${sentinelId}`).count()) > 0;
86+
}
87+
88+
/**
89+
* Set up listeners to capture HMR events from the page.
90+
* Call this before making file changes.
91+
* Note: We use addScriptTag to inject the listener code because
92+
* page.evaluate() cannot serialize import.meta.hot references.
93+
*/
94+
async setupEventListeners(): Promise<void> {
95+
await this.page.addScriptTag({
96+
content: `
97+
window.__hmrEvents = [];
98+
if (import.meta.hot) {
99+
import.meta.hot.on("angular:component-update", (data) => {
100+
window.__hmrEvents.push({
101+
type: "angular:component-update",
102+
data,
103+
timestamp: Date.now(),
104+
});
105+
});
106+
import.meta.hot.on("vite:beforeFullReload", () => {
107+
window.__hmrEvents.push({
108+
type: "vite:beforeFullReload",
109+
timestamp: Date.now(),
110+
});
111+
});
112+
}
113+
`,
114+
type: "module",
115+
});
116+
}
117+
118+
/**
119+
* Get all captured HMR events.
120+
*/
121+
async getEvents(): Promise<Array<{ type: string; data?: any; timestamp: number }>> {
122+
return await this.page.evaluate(() => (window as any).__hmrEvents || []);
123+
}
124+
125+
/**
126+
* Check if a specific event type was received.
127+
*/
128+
async hasEvent(eventType: string): Promise<boolean> {
129+
const events = await this.getEvents();
130+
return events.some((e) => e.type === eventType);
131+
}
132+
}
133+
134+
// Custom test fixtures
135+
type HmrTestFixtures = {
136+
fileModifier: FileModifier;
137+
hmrDetector: HmrDetector;
138+
waitForHmr: () => Promise<void>;
139+
};
140+
141+
export const test = base.extend<HmrTestFixtures>({
142+
// File modification utility with automatic cleanup
143+
fileModifier: async ({}, use) => {
144+
const modifier = new FileModifier();
145+
await use(modifier);
146+
// Always restore files after test
147+
await modifier.restoreAll();
148+
},
149+
150+
// HMR detection utility
151+
hmrDetector: async ({ page }, use) => {
152+
const detector = new HmrDetector(page);
153+
await use(detector);
154+
},
155+
156+
// Wait for HMR updates to stabilize
157+
waitForHmr: async ({ page }, use) => {
158+
const wait = async () => {
159+
// Give time for file watcher to detect change and HMR to propagate
160+
// Note: Don't use networkidle - it never completes when HMR is active
161+
await page.waitForTimeout(2000);
162+
};
163+
await use(wait);
164+
},
165+
});
166+
167+
export { expect };

0 commit comments

Comments
 (0)