diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4a1b877e0..6c0691d6c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -224,14 +224,18 @@ jobs: strategy: matrix: platform: - - { toolchain: 1.86.0 } - { toolchain: beta } + # - { toolchain: 1.86.0 } steps: # Do our best to cache the toolchain and node install steps - uses: actions/checkout@v5 - name: Free Disk Space if: ${{ matrix.platform.os == 'ubuntu-24.04' || matrix.platform.os == 'ubuntu-24.04-arm' || matrix.platform.os == 'blacksmith-4vcpu-ubuntu-2404' || matrix.platform.os == 'blacksmith-4vcpu-ubuntu-2404-arm' }} uses: ./.github/actions/free-disk-space + - uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: glib-networking glib-networking-common glib-networking-services libaa1 libabsl20220623t64 libass9 libasyncns0 libavc1394-0 libavcodec60 libavfilter9 libavformat60 libavtp0 libavutil58 libblas3 libbluray2 libbs2b0 libcaca0 libcairo-script-interpreter2 libcdparanoia0 libchromaprint1 libcjson1 libcodec2-1.2 libdav1d7 libdc1394-25 libdca0 libdecor-0-0 libdirectfb-1.7-7t64 libdv4t64 libdvdnav4 libdvdread8t64 libfaad2 libflac12t64 libfluidsynth3 libfreeaptx0 libgav1-1 libgme0 libgraphene-1.0-0 libgsm1 libgssdp-1.6-0 libgstreamer-plugins-good1.0-0 libgtk-4-common libgupnp-1.6-0 libgupnp-igd-1.6-0 libhwy1t64 libiec61883-0 libimath-3-1-29t64 libinstpatch-1.0-2 libjack-jackd2-0 libjxl0.7 liblapack3 liblc3-1 libldacbt-enc2 liblilv-0-0 liblrdf0 libltc11 libmbedcrypto7t64 libmfx1 libmjpegutils-2.1-0t64 libmodplug1 libmp3lame0 libmpcdec6 libmpeg2encpp-2.1-0t64 libmpg123-0t64 libmplex2-2.1-0t64 libmysofa1 libneon27t64 libnice10 libogg0 libopenal-data libopenal1 libopenexr-3-1-30 libopenh264-7 libopenmpt0t64 libopenni2-0 liborc-0.4-0t64 libpipewire-0.3-0t64 libplacebo338 libpocketsphinx3 libpostproc57 libproxy1v5 libpulse0 libqrencode4 libraptor2-0 librav1e0 libraw1394-11 librist4 librubberband2 libsamplerate0 libsbc1 libsdl2-2.0-0 libsecret-common libserd-0-0 libshine3 libshout3 libsndfile1 libsndio7.0 libsord-0-0 libsoundtouch1 libsoup-3.0-0 libsoup-3.0-common libsoxr0 libspa-0.2-modules libspandsp2t64 libspeex1 libsphinxbase3t64 libsratom-0-0 libsrt1.5-gnutls libsrtp2-1 libssh-gcrypt-4 libsvtav1enc1d1 libswresample4 libswscale7 libtag1v5 libtag1v5-vanilla libtheora0 libtwolame0 libudfread0 libunibreak5 libv4l-0t64 libv4lconvert0t64 libva-drm2 libva-x11-2 libva2 libvdpau1 libvidstab1.1 libvisual-0.4-0 libvo-aacenc0 libvo-amrwbenc0 libvorbis0a libvorbisenc2 libvorbisfile3 libvpl2 libwavpack1 libwebrtc-audio-processing1 libwildmidi2 libx265-199 libxcb-xkb1 libxkbcommon-x11-0 libxml2-dev libxvidcore4 libyuv0 libzbar0t64 libzimg2 libzix-0-0 libzvbi-common libzvbi0t64 libzxing3 ocl-icd-libopencl1 timgm6mb-soundfont xfonts-encodings xfonts-utils binutils lld binutils-devel binutils-gold + version: 1.0 - uses: actions/setup-node@v4 with: node-version: 24 @@ -254,6 +258,7 @@ jobs: # The hot patch test requires incremental compilation CARGO_INCREMENTAL: 1 run: | + export PATH=$PATH:/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin npm ci npm install -D @playwright/test npx playwright install --with-deps diff --git a/.gitignore b/.gitignore index 2fbbcaf05..a95c90b44 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ /packages/playwright-tests/fullstack/dist /packages/playwright-tests/test-results /packages/playwright-tests/web-hot-patch-temp +/packages/playwright-tests/web-hot-patch-fullstack-temp +/packages/playwright-tests/web-hot-patch/Cargo.lock +/packages/playwright-tests/web-hot-patch-fullstack/Cargo.lock /dist .DS_Store /examples/assets/test_video.mp4 diff --git a/packages/cli/src/build/builder.rs b/packages/cli/src/build/builder.rs index 0064b77d2..33c4fa83c 100644 --- a/packages/cli/src/build/builder.rs +++ b/packages/cli/src/build/builder.rs @@ -715,7 +715,7 @@ impl AppBuilder { let changed_file = changed_files.first().unwrap(); tracing::info!( - "Hot-patching: {NOTE_STYLE}{}{NOTE_STYLE:#} took {GLOW_STYLE}{:?}ms{GLOW_STYLE:#} ({})", + "Hot-patching: {NOTE_STYLE}{}{NOTE_STYLE:#} took {GLOW_STYLE}{:?}ms{GLOW_STYLE:#}", changed_file .display() .to_string() @@ -723,8 +723,7 @@ impl AppBuilder { SystemTime::now() .duration_since(res.time_start) .unwrap() - .as_millis(), - triple + .as_millis() ); self.patches.push(jump_table.clone()); diff --git a/packages/cli/src/serve/mod.rs b/packages/cli/src/serve/mod.rs index 93af8d979..7e54e8abf 100644 --- a/packages/cli/src/serve/mod.rs +++ b/packages/cli/src/serve/mod.rs @@ -175,27 +175,18 @@ pub(crate) async fn serve_all(args: ServeArgs, tracer: &TraceController) -> Resu } BuilderUpdate::BuildReady { bundle } => match bundle.mode { BuildMode::Thin { ref cache, .. } => { - let elapsed = - bundle.time_end.duration_since(bundle.time_start).unwrap(); - match builder.hotpatch(&bundle, id, cache).await { - Ok(jumptable) => { - let pid = match id { - BuildId::CLIENT => builder.client.pid, - _ => builder.server.as_ref().and_then(|s| s.pid), - }; - devserver.send_patch(jumptable, elapsed, id, pid).await - } - Err(err) => { - tracing::error!("Failed to hot-patch app: {err}"); + if let Err(err) = + builder.hotpatch(&bundle, id, cache, &mut devserver).await + { + tracing::error!("Failed to hot-patch app: {err}"); - if let Some(_patching) = - err.downcast_ref::() - { - tracing::info!("Starting full rebuild: {err}"); - builder.full_rebuild().await; - devserver.send_reload_start().await; - devserver.start_build().await; - } + if let Some(_patching) = + err.downcast_ref::() + { + tracing::info!("Starting full rebuild: {err}"); + builder.full_rebuild().await; + devserver.send_reload_start().await; + devserver.start_build().await; } } } diff --git a/packages/cli/src/serve/runner.rs b/packages/cli/src/serve/runner.rs index ba19a3c52..211e5b0b1 100644 --- a/packages/cli/src/serve/runner.rs +++ b/packages/cli/src/serve/runner.rs @@ -28,7 +28,6 @@ use std::{ sync::Arc, time::Duration, }; -use subsecond_types::JumpTable; use syn::spanned::Spanned; use tokio::process::Command; @@ -53,7 +52,7 @@ pub(crate) struct AppServer { pub(crate) watcher_rx: UnboundedReceiver, // Tracked state related to open builds and hot reloading - pub(crate) applied_hot_reload_message: HotReloadMsg, + pub(crate) applied_client_hot_reload_message: HotReloadMsg, pub(crate) file_map: HashMap, // Resolved args related to how we go about processing the rebuilds and logging @@ -185,7 +184,7 @@ impl AppServer { // Create the runner let mut runner = Self { file_map: Default::default(), - applied_hot_reload_message: Default::default(), + applied_client_hot_reload_message: Default::default(), automatic_rebuilds: true, watch_fs, use_hotpatch_engine, @@ -709,27 +708,66 @@ impl AppServer { pub(crate) async fn hotpatch( &mut self, - res: &BuildArtifacts, + bundle: &BuildArtifacts, id: BuildId, cache: &HotpatchModuleCache, - ) -> Result { + devserver: &mut WebServer, + ) -> Result<()> { + let elapsed = bundle + .time_end + .duration_since(bundle.time_start) + .unwrap_or_default(); + let jump_table = match id { - BuildId::CLIENT => self.client.hotpatch(res, cache).await, + BuildId::CLIENT => self.client.hotpatch(bundle, cache).await, BuildId::SERVER => { self.server .as_mut() .context("Server not found")? - .hotpatch(res, cache) + .hotpatch(bundle, cache) .await } _ => bail!("Invalid build id"), }?; if id == BuildId::CLIENT { - self.applied_hot_reload_message.jump_table = self.client.patches.last().cloned(); + self.applied_client_hot_reload_message.jump_table = self.client.patches.last().cloned(); } - Ok(jump_table) + // If no server, just send the patch immediately + let Some(server) = self.server.as_mut() else { + devserver + .send_patch(jump_table, elapsed, id, self.client.pid) + .await; + return Ok(()); + }; + + // If we have a server, we need to wait until both the client and server are ready + // Otherwise we end up with an annoying race condition where the client can't actually load the patch + if self.client.stage == BuildStage::Success && server.stage == BuildStage::Success { + let client_jump_table = self + .client + .patches + .last() + .cloned() + .context("Missing client jump table")?; + + let server_jump_table = server + .patches + .last() + .cloned() + .context("Missing server jump table")?; + + devserver + .send_patch(server_jump_table, elapsed, BuildId::SERVER, server.pid) + .await; + + devserver + .send_patch(client_jump_table, elapsed, BuildId::CLIENT, self.client.pid) + .await; + } + + Ok(()) } pub(crate) fn get_build(&self, id: BuildId) -> Option<&AppBuilder> { @@ -751,7 +789,7 @@ impl AppServer { /// Get any hot reload changes that have been applied since the last full rebuild pub(crate) fn applied_hot_reload_changes(&mut self, build: BuildId) -> HotReloadMsg { - let mut msg = self.applied_hot_reload_message.clone(); + let mut msg = self.applied_client_hot_reload_message.clone(); if build == BuildId::CLIENT { msg.jump_table = self.client.patches.last().cloned(); @@ -773,7 +811,7 @@ impl AppServer { /// Clear the hot reload changes. This should be called any time a new build is starting pub(crate) fn clear_hot_reload_changes(&mut self) { - self.applied_hot_reload_message = Default::default(); + self.applied_client_hot_reload_message = Default::default(); } pub(crate) fn clear_patches(&mut self) { @@ -836,7 +874,7 @@ impl AppServer { /// Store the hot reload changes for any future clients that connect fn add_hot_reload_message(&mut self, msg: &HotReloadMsg) { - let applied = &mut self.applied_hot_reload_message; + let applied = &mut self.applied_client_hot_reload_message; // Merge the assets, unknown files, and templates // We keep the newer change if there is both a old and new change diff --git a/packages/playwright-tests/playwright.config.js b/packages/playwright-tests/playwright.config.js index 2f1c95723..733a75586 100644 --- a/packages/playwright-tests/playwright.config.js +++ b/packages/playwright-tests/playwright.config.js @@ -207,5 +207,13 @@ module.exports = defineConfig({ reuseExistingServer: !process.env.CI, stdout: "pipe", }, + { + command: + 'rm -rf ./web-hot-patch-fullstack-temp && cp -r ./web-hot-patch-fullstack ./web-hot-patch-fullstack-temp && cd web-hot-patch-fullstack-temp && cargo run --manifest-path ../../cli/Cargo.toml --release -- serve --verbose --force-sequential --web --addr "127.0.0.1" --port 9981 --hot-patch --exit-on-error', + port: 9981, + timeout: 50 * 60 * 1000, + reuseExistingServer: !process.env.CI, + stdout: "pipe", + }, ], }); diff --git a/packages/playwright-tests/web-hot-patch-fullstack/.gitignore b/packages/playwright-tests/web-hot-patch-fullstack/.gitignore new file mode 100644 index 000000000..283c01d61 --- /dev/null +++ b/packages/playwright-tests/web-hot-patch-fullstack/.gitignore @@ -0,0 +1,2 @@ +dist +target diff --git a/packages/playwright-tests/web-hot-patch-fullstack/Cargo.toml b/packages/playwright-tests/web-hot-patch-fullstack/Cargo.toml new file mode 100644 index 000000000..c03540e1a --- /dev/null +++ b/packages/playwright-tests/web-hot-patch-fullstack/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dioxus-playwright-fullstack-patch-test" +version = "0.0.1" +edition = "2021" +description = "Playwright test for Dioxus Web" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { path = "../../dioxus", features = ["fullstack"] } + +[features] +web = ["dioxus/web"] +server = ["dioxus/server"] + +[workspace] diff --git a/packages/playwright-tests/web-hot-patch-fullstack/assets/alternative-style.css b/packages/playwright-tests/web-hot-patch-fullstack/assets/alternative-style.css new file mode 100644 index 000000000..bd621de2d --- /dev/null +++ b/packages/playwright-tests/web-hot-patch-fullstack/assets/alternative-style.css @@ -0,0 +1,3 @@ +body { + background-color: rgb(100, 100, 100); +} diff --git a/packages/playwright-tests/web-hot-patch-fullstack/assets/style.css b/packages/playwright-tests/web-hot-patch-fullstack/assets/style.css new file mode 100644 index 000000000..8dcca08c7 --- /dev/null +++ b/packages/playwright-tests/web-hot-patch-fullstack/assets/style.css @@ -0,0 +1,3 @@ +#main { + background-color: red; +} diff --git a/packages/playwright-tests/web-hot-patch-fullstack/assets/toasts.png b/packages/playwright-tests/web-hot-patch-fullstack/assets/toasts.png new file mode 100644 index 000000000..382298f9c Binary files /dev/null and b/packages/playwright-tests/web-hot-patch-fullstack/assets/toasts.png differ diff --git a/packages/playwright-tests/web-hot-patch-fullstack/src/main.rs b/packages/playwright-tests/web-hot-patch-fullstack/src/main.rs new file mode 100644 index 000000000..e265c0104 --- /dev/null +++ b/packages/playwright-tests/web-hot-patch-fullstack/src/main.rs @@ -0,0 +1,36 @@ +use dioxus::prelude::*; + +const CSS: Asset = asset!("/assets/style.css"); +const IMAGE: Asset = asset!("/assets/toasts.png"); + +fn app() -> Element { + let mut num = use_signal(|| 0); + + rsx! { + document::Link { + href: CSS, + rel: "stylesheet", + } + img { + id: "toasts", + src: IMAGE, + } + button { + id: "increment-button", + onclick: move |_| async move { + let increment_amount = get_count().await.unwrap(); + *num.write() += increment_amount; + }, + "Click me! Count: {num}" + } + } +} + +#[post("/api/get_count")] +async fn get_count() -> Result { + Ok(1) +} + +fn main() { + dioxus::launch(app); +} diff --git a/packages/playwright-tests/web-hot-patch/Cargo.toml b/packages/playwright-tests/web-hot-patch/Cargo.toml index bcfeee4a5..e5adc75f1 100644 --- a/packages/playwright-tests/web-hot-patch/Cargo.toml +++ b/packages/playwright-tests/web-hot-patch/Cargo.toml @@ -7,6 +7,6 @@ license = "MIT OR Apache-2.0" publish = false [dependencies] -dioxus = { path = "../../dioxus", features = ["web"]} +dioxus = { path = "../../dioxus", features = ["web"] } -[workspace] \ No newline at end of file +[workspace] diff --git a/packages/playwright-tests/web-patch-fullstack.spec.js b/packages/playwright-tests/web-patch-fullstack.spec.js new file mode 100644 index 000000000..5061594ee --- /dev/null +++ b/packages/playwright-tests/web-patch-fullstack.spec.js @@ -0,0 +1,113 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); + +const hotPatchTimeout = { + timeout: 1000 * 60 * 2, // 2 minute +}; + +test("button click", async ({ page }) => { + const fs = require("fs"); + const mainPath = "web-hot-patch-fullstack-temp/src/main.rs"; + var mainContent = fs.readFileSync(mainPath, "utf8"); + const stylePath = "web-hot-patch-fullstack-temp/assets/style.css"; + var styleContent = fs.readFileSync(stylePath, "utf8"); + + // Reset any changes made to the main.rs and style.css files. + mainContent = mainContent.replace(/Ok\(\s*2\s*\)/g, "Ok(1)"); + mainContent = mainContent.replace("Click button! Count:", "Click me! Count:"); + mainContent = mainContent.replace("asset!('/assets/alternative-style.css')", "asset!('/assets/style.css')"); + fs.writeFileSync(mainPath, mainContent); + styleContent = styleContent.replace( + "background-color: blue;", + "background-color: red;" + ); + fs.writeFileSync(stylePath, styleContent); + + await page.goto("http://localhost:9981"); + + + // wait a sec for the serverfn to process + // give the page time to finish loading resources + await page.waitForLoadState("networkidle"); + await page.waitForTimeout(2000); + + // ** First test make sure the initial fat build is working ** + // Expect the page to contain the counter text. + const main = page.locator("#main"); + await expect(main).toContainText("Click me! Count: 0"); + + // Click the increment button. + let button = page.locator("button#increment-button"); + await button.click(); + + await page.waitForTimeout(1000); + + // Expect the page to contain the updated counter text. + await expect(main).toContainText("Click me! Count: 1"); + + // Make sure the css is applied correctly. + await expect(main).toHaveCSS("background-color", "rgb(255, 0, 0)"); + + // Make sure the image is loaded. + const headerImage = page.locator("#toasts"); + // expect the attribute src to start with /assets/toasts- + await expect(headerImage).toHaveAttribute("src", /\/assets\/toasts-/); + + // ** Then make sure the hot patch is working ** + // Then change the file to increment by 2. + const updatedContent = mainContent.replace(/Ok\(\s*1\s*\)/g, "Ok(2)"); + // Change the click me text to reflect the new increment. + const updatedContentWithText = updatedContent.replace( + "Click me! Count:", + "Click button! Count:" + ); + fs.writeFileSync(mainPath, updatedContentWithText); + + // Wait for the page to update and show the new text. + await expect(main).toContainText("Click button! Count: 1", hotPatchTimeout); + + // Now click the button again. + await button.click(); + + // wait a sec for the serverfn to process + await page.waitForTimeout(1000); + + // Expect the count to update by 2. + await expect(main).toContainText("Click button! Count: 3"); + + // Next change just the css file to change the background color to blue. + const updatedStyleContent = styleContent.replace( + "background-color: red;", + "background-color: blue;" + ); + fs.writeFileSync(stylePath, updatedStyleContent); + + // Wait for the page to update the background color. + await expect(main).toHaveCSS( + "background-color", + "rgb(0, 0, 255)", + hotPatchTimeout + ); + + // Make sure the image is still loaded. + // expect the attribute src to start with /assets/toasts- + await expect(headerImage).toHaveAttribute("src", /\/assets\/toasts-/); + + // ** Then add a new asset to the page ** + // Switch from style to alternative-style + const updatedContentWithAlternativeStyle = updatedContentWithText.replace( + 'asset!("/assets/style.css")', + 'asset!("/assets/alternative-style.css")' + ); + fs.writeFileSync(mainPath, updatedContentWithAlternativeStyle); + + // Assert the page has the new alternative style applied. + const body = page.locator("body"); + // Log the page content to debug if needed. + console.log(await page.content()); + await expect(body).toHaveCSS( + "background-color", + "rgb(100, 100, 100)", + hotPatchTimeout + ); +}); diff --git a/packages/subsecond/subsecond/src/lib.rs b/packages/subsecond/subsecond/src/lib.rs index d43044150..2c857b276 100644 --- a/packages/subsecond/subsecond/src/lib.rs +++ b/packages/subsecond/subsecond/src/lib.rs @@ -573,6 +573,16 @@ pub unsafe fn apply_patch(mut table: JumpTable) -> Result<(), PatchError> { // Wait for the fetch to complete - we need the wasm module size in bytes to reserve in the memory let response: web_sys::Response = JsFuture::from(response).await.unwrap().unchecked_into(); + + // If the status is not success, we bail + if !response.ok() { + panic!( + "Failed to patch wasm module at {} - response failed with: {}", + path, + response.status_text() + ); + } + let dl_bytes: ArrayBuffer = JsFuture::from(response.array_buffer().unwrap()) .await .unwrap()