Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion .github/workflows/pack-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,16 +165,44 @@ jobs:
if: ${{ !matrix.settings.docker }}
shell: bash

hmr_e2e:
name: utoopack-ci-hmr-e2e
if: (github.event_name == 'push' && contains(github.event.head_commit.message, '(pack)')) || (github.event_name == 'pull_request' && contains(github.event.pull_request.title, '(pack)'))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Init git submodules
run: git submodule update --init --recursive --depth 1
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: 22.14.0
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: nightly-2026-05-15
- name: Setup Utoo
uses: utooland/setup-utoo@v1
- name: Install dependencies
run: utoo install
- name: Build @utoo/pack
run: npm run build:local --workspace @utoo/pack
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Run HMR browser regression tests
run: npm run test:e2e:pack-hmr

success:
name: utoopack-ci-success
runs-on: ubuntu-latest
if: always()
needs:
- test
- hmr_e2e
steps:
- name: Check success
run: |
if [ "${{ needs.test.result }}" == "failure" ] || [ "${{ needs.test.result }}" == "cancelled" ]; then
if [ "${{ needs.test.result }}" == "failure" ] || [ "${{ needs.test.result }}" == "cancelled" ] || [ "${{ needs.hmr_e2e.result }}" == "failure" ] || [ "${{ needs.hmr_e2e.result }}" == "cancelled" ]; then
echo "Tests failed or were cancelled"
exit 1
else
Expand Down
26 changes: 20 additions & 6 deletions crates/pack-api/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1167,11 +1167,15 @@ impl Project {

#[turbo_tasks::function]
pub(super) async fn client_compile_time_info(&self) -> Result<Vc<CompileTimeInfo>> {
let watch = self.watch.enable;
let hot = self.config.dev_server().await?.hot.unwrap_or_default();
Ok(get_client_compile_time_info(
(*self.config.target().await?).clone(),
client_define_env(*self.config, self.process_env).await?,
self.config.mode(),
self.config.provider_config(),
Vc::cell(watch),
Vc::cell(hot),
))
}

Expand All @@ -1190,12 +1194,18 @@ impl Project {
pub(super) async fn compile_time_info_for_platform(&self) -> Result<Vc<CompileTimeInfo>> {
let target = (*self.config.target().await?).clone();
match &*self.config.platform().await? {
Platform::Web => Ok(get_client_compile_time_info(
target,
client_define_env(*self.config, self.process_env).await?,
self.config.mode(),
self.config.provider_config(),
)),
Platform::Web => {
let watch = self.watch.enable;
let hot = self.config.dev_server().await?.hot.unwrap_or_default();
Ok(get_client_compile_time_info(
target,
client_define_env(*self.config, self.process_env).await?,
self.config.mode(),
self.config.provider_config(),
Vc::cell(watch),
Vc::cell(hot),
))
}
Platform::Node => Ok(get_server_compile_time_info(
target,
self.config.define_env(),
Expand All @@ -1209,13 +1219,17 @@ impl Project {
pub async fn client_chunking_context(self: Vc<Self>) -> Result<Vc<Box<dyn ChunkingContext>>> {
let mode = self.mode();
let config = self.config();
let watch = self.await?.watch.enable;
let hot = config.dev_server().await?.hot.unwrap_or_default();
let source_maps = if *config.source_maps().await? {
SourceMapsType::Full
} else {
SourceMapsType::None
};
Ok(get_client_chunking_context(ClientChunkingContextOptions {
mode,
watch: Vc::cell(watch),
hot: Vc::cell(hot),
root_path: self.project_path().owned().await?,
client_root: self.client_root().owned().await?,
client_root_to_root_path: rcstr!("/ROOT"),
Expand Down
133 changes: 108 additions & 25 deletions crates/pack-core/js/src/hmr/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ type WebSocketMessage =
let source: WebSocket | null = null;
let eventCallbacks: Array<(event: WebSocketMessage) => void> = [];

const INITIAL_RECONNECT_DELAY_MS = 500;
const MAX_RECONNECT_DELAY_MS = 5_000;

// Helper function to dispatch messages to all event callbacks
function dispatchMessage(message: WebSocketMessage) {
for (const eventCallback of eventCallbacks) {
Expand Down Expand Up @@ -72,21 +75,89 @@ let serverSessionId: number | null = null;

// This is not used by Next.js, but it is used by the standalone turbopack-cli
export function connectHMR(options: HMROptions) {
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempts = 0;
let pageIsUnloading = false;

function clearReconnectTimer() {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}

function closeSocket(socket: WebSocket) {
socket.onopen = null;
socket.onerror = null;
socket.onclose = null;
socket.onmessage = null;

if (source === socket) {
source = null;
}

if (
socket.readyState === WebSocket.CONNECTING ||
socket.readyState === WebSocket.OPEN
) {
socket.close();
}
}

function scheduleReconnect() {
if (reconnectTimer !== null || pageIsUnloading || reloading) {
return;
}

const delay = Math.min(
INITIAL_RECONNECT_DELAY_MS * 2 ** reconnectAttempts,
MAX_RECONNECT_DELAY_MS,
);
reconnectAttempts += 1;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
init();
}, delay);
}

function init() {
if (source) source.close();
if (pageIsUnloading || reloading) {
return;
}

if (source) {
closeSocket(source);
}

console.log("[HMR] connecting...");

let socket: WebSocket;
try {
socket = new WebSocket(`${getSocketUrl()}${options.path}`);
} catch (error) {
console.error("[HMR] Failed to create WebSocket:", error);
scheduleReconnect();
return;
}

source = socket;

function handleOnline() {
window.console.log("[HMR] connected");
if (source !== socket || pageIsUnloading || reloading) {
closeSocket(socket);
return;
}

// Send the turbopack-connected message to trigger handleSocketConnected
const connected: WebSocketMessage = { type: "turbopack-connected" };
dispatchMessage(connected);
reconnectAttempts = 0;
window.console.log("[HMR] connected");
// Direct turbopack-dev-server does not send a separate connected frame.
// Utoo does, but the socket-open notification is sufficient to restore
// subscriptions in both cases.
dispatchMessage({ type: "turbopack-connected" });
}

function handleMessage(event: MessageEvent<string>) {
if (reloading) {
if (source !== socket || reloading) {
return;
}

Expand All @@ -99,22 +170,21 @@ export function connectHMR(options: HMROptions) {
serverSessionId !== null &&
serverSessionId !== msg.data.sessionId
) {
window.location.reload();
reloading = true;
window.location.reload();
return;
}

serverSessionId = msg.data.sessionId;

// Convert to turbopack format and trigger handleSocketConnected
const connected: WebSocketMessage = { type: "turbopack-connected" };
dispatchMessage(connected);
// Socket open already restored subscriptions. This frame only carries
// the Utoo server session id used to detect server restarts.
return;
}

if (msg.action === "reload") {
window.location.reload();
reloading = true;
window.location.reload();
return;
}

Expand Down Expand Up @@ -146,27 +216,40 @@ export function connectHMR(options: HMROptions) {
}
}

function handleDisconnect(event?: Event) {
if (event && event.target !== source) {
function handleDisconnect(event: Event) {
if (event.target !== socket || source !== socket) {
return;
}

if (source) {
source.onerror = null;
source.onclose = null;
source.close();
source = null;
}

closeSocket(socket);
window.console.warn("[HMR] disconnected");
scheduleReconnect();
}

source = new WebSocket(`${getSocketUrl()}${options.path}`);
source.onopen = handleOnline;
source.onerror = handleDisconnect;
source.onclose = handleDisconnect;
source.onmessage = handleMessage;
socket.onopen = handleOnline;
socket.onerror = handleDisconnect;
socket.onclose = handleDisconnect;
socket.onmessage = handleMessage;
}

// `pagehide` is not fired when a beforeunload prompt is cancelled, so it is
// safe to use as the point where reconnects should stop.
window.addEventListener("pagehide", () => {
pageIsUnloading = true;
clearReconnectTimer();
if (source) {
closeSocket(source);
}
});

// A page restored from the back-forward cache needs a fresh HMR transport.
window.addEventListener("pageshow", (event) => {
if (event.persisted) {
pageIsUnloading = false;
reconnectAttempts = 0;
init();
}
});

init();
}
17 changes: 13 additions & 4 deletions crates/pack-core/src/client/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,14 @@ pub async fn get_client_compile_time_info(
define_env: Vc<EnvMap>,
mode: Vc<Mode>,
provider_config: Vc<ProviderConfig>,
watch: Vc<bool>,
hot: Vc<bool>,
) -> Result<Vc<CompileTimeInfo>> {
let mode_ref = mode.await?;
let mut define_env = (*define_env.await?).clone();
define_env.extend([(
"process.env.NODE_ENV".into(),
serde_json::to_string(mode.await?.node_env())
.unwrap()
.into(),
serde_json::to_string(mode_ref.node_env()).unwrap().into(),
)]);
let define_env = Vc::cell(define_env);
let environment = BrowserEnvironment {
Expand All @@ -117,6 +118,7 @@ pub async fn get_client_compile_time_info(
)
.defines(defines(define_env).to_resolved().await?)
.free_var_references(free_vars(define_env, provider_config).to_resolved().await?)
.hot_module_replacement_enabled(mode_ref.is_development() && *watch.await? && *hot.await?)
.cell()
.await
}
Expand Down Expand Up @@ -555,6 +557,8 @@ pub async fn get_client_resolve_options_context(
#[derive(Clone, Debug, PartialEq, Eq, Hash, TraceRawVcs, Encode, Decode)]
pub struct ClientChunkingContextOptions {
pub mode: Vc<Mode>,
pub watch: Vc<bool>,
pub hot: Vc<bool>,
pub root_path: FileSystemPath,
pub client_root: FileSystemPath,
pub client_root_to_root_path: RcStr,
Expand All @@ -580,6 +584,8 @@ pub async fn get_client_chunking_context(
) -> Result<Vc<Box<dyn ChunkingContext>>> {
let ClientChunkingContextOptions {
mode,
watch,
hot,
root_path,
client_root,
client_root_to_root_path,
Expand Down Expand Up @@ -685,9 +691,12 @@ pub async fn get_client_chunking_context(

if mode.is_development() {
builder = builder
.hot_module_replacement()
.source_map_source_type(SourceMapSourceType::AbsoluteFileUri)
.dynamic_chunk_content_loading(true);

if *watch.await? && *hot.await? {
builder = builder.hot_module_replacement().dynamic_hmr_chunk_lists();
}
} else {
let split_chunks = &config.optimization().await?.split_chunks;
let style_groups_algorithm = config.css_chunking_algorithm().owned().await?;
Expand Down
Loading
Loading