diff --git a/.github/workflows/integration-full.yml b/.github/workflows/integration-full.yml index fd2f79bfa9..205cc2b7f9 100644 --- a/.github/workflows/integration-full.yml +++ b/.github/workflows/integration-full.yml @@ -31,7 +31,7 @@ jobs: uses: ./.github/workflows/shared-integration.yml with: os: "ubuntu-latest" - node_version: "[20, 22]" + node_version: "[22, 24]" browser: '["chromium", "firefox"]' integration-windows: @@ -40,7 +40,7 @@ jobs: uses: ./.github/workflows/shared-integration.yml with: os: "windows-latest" - node_version: "[20, 22]" + node_version: "[22, 24]" browser: '["msedge"]' integration-macos: @@ -49,5 +49,5 @@ jobs: uses: ./.github/workflows/shared-integration.yml with: os: "macos-latest" - node_version: "[20, 22]" + node_version: "[22, 24]" browser: '["webkit"]' diff --git a/.github/workflows/integration-pr-ubuntu.yml b/.github/workflows/integration-pr-ubuntu.yml index ac25713e08..056f0d1e46 100644 --- a/.github/workflows/integration-pr-ubuntu.yml +++ b/.github/workflows/integration-pr-ubuntu.yml @@ -31,5 +31,5 @@ jobs: uses: ./.github/workflows/shared-integration.yml with: os: "ubuntu-latest" - node_version: "[22]" + node_version: "[24]" browser: '["chromium"]' diff --git a/.github/workflows/integration-pr-windows-macos.yml b/.github/workflows/integration-pr-windows-macos.yml index a36aef144d..4423d376b4 100644 --- a/.github/workflows/integration-pr-windows-macos.yml +++ b/.github/workflows/integration-pr-windows-macos.yml @@ -21,7 +21,7 @@ jobs: uses: ./.github/workflows/shared-integration.yml with: os: "ubuntu-latest" - node_version: "[22]" + node_version: "[24]" browser: '["firefox"]' integration-msedge: @@ -29,10 +29,10 @@ jobs: if: github.repository == 'remix-run/react-router' uses: ./.github/workflows/shared-integration.yml with: - os: "windows-latest" - node_version: "[22]" + os: "windows-2025" + node_version: "[24]" browser: '["msedge"]' - timeout: 60 + timeout: 120 integration-webkit: name: "👀 Integration Test" @@ -40,5 +40,5 @@ jobs: uses: ./.github/workflows/shared-integration.yml with: os: "macos-latest" - node_version: "[22]" + node_version: "[24]" browser: '["webkit"]' diff --git a/.github/workflows/shared-integration.yml b/.github/workflows/shared-integration.yml index e7adb0dd47..4dd3df2cfd 100644 --- a/.github/workflows/shared-integration.yml +++ b/.github/workflows/shared-integration.yml @@ -9,7 +9,7 @@ on: node_version: required: true # this is limited to string | boolean | number (https://github.community/t/can-action-inputs-be-arrays/16457) - # but we want to pass an array (node_version: "[20, 22]"), + # but we want to pass an array (node_version: "[22, 24]"), # so we'll need to manually stringify it for now type: string browser: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af709fdaaa..5eda492b5f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,8 +26,8 @@ jobs: fail-fast: false matrix: node: - - 20 - 22 + - 24 runs-on: ubuntu-latest diff --git a/.nvmrc b/.nvmrc index 2edeafb09d..8fdd954df9 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 \ No newline at end of file +22 \ No newline at end of file diff --git a/integration/fetcher-test.ts b/integration/fetcher-test.ts index f021ea36f3..f0d36a9110 100644 --- a/integration/fetcher-test.ts +++ b/integration/fetcher-test.ts @@ -247,7 +247,7 @@ test.describe("useFetcher", () => { // a
 but Edge puts it in some weird code editor markup:
       // 
       //   
-      expect(await app.getHtml()).toContain(LUNCH);
+      await page.getByText(LUNCH);
     });
 
     test("Form can hit an action", async ({ page }) => {
@@ -264,7 +264,7 @@ test.describe("useFetcher", () => {
       // a 
 but Edge puts it in some weird code editor markup:
       // 
       //   
-      expect(await app.getHtml()).toContain(CHEESESTEAK);
+      await page.getByText(CHEESESTEAK);
     });
   });
 
@@ -288,9 +288,7 @@ test.describe("useFetcher", () => {
     await page.fill("#fetcher-input", "input value");
     await app.clickElement("#fetcher-submit-json");
     await page.waitForSelector(`#fetcher-idle`);
-    expect(await app.getHtml()).toMatch(
-      'ACTION (application/json) input value"'
-    );
+    await page.getByText('ACTION (application/json) input value"');
   });
 
   test("submit can hit an action with null json", async ({ page }) => {
@@ -299,7 +297,7 @@ test.describe("useFetcher", () => {
     await app.clickElement("#fetcher-submit-json-null");
     await new Promise((r) => setTimeout(r, 1000));
     await page.waitForSelector(`#fetcher-idle`);
-    expect(await app.getHtml()).toMatch('ACTION (application/json) null"');
+    await page.getByText('ACTION (application/json) null"');
   });
 
   test("submit can hit an action with text", async ({ page }) => {
@@ -308,9 +306,7 @@ test.describe("useFetcher", () => {
     await page.fill("#fetcher-input", "input value");
     await app.clickElement("#fetcher-submit-text");
     await page.waitForSelector(`#fetcher-idle`);
-    expect(await app.getHtml()).toMatch(
-      'ACTION (text/plain;charset=UTF-8) input value"'
-    );
+    await page.getByText('ACTION (text/plain;charset=UTF-8) input value"');
   });
 
   test("submit can hit an action with empty text", async ({ page }) => {
@@ -319,7 +315,7 @@ test.describe("useFetcher", () => {
     await app.clickElement("#fetcher-submit-text-empty");
     await new Promise((r) => setTimeout(r, 1000));
     await page.waitForSelector(`#fetcher-idle`);
-    expect(await app.getHtml()).toMatch('ACTION (text/plain;charset=UTF-8) "');
+    await page.getByText('ACTION (text/plain;charset=UTF-8) "');
   });
 
   test("submit can hit an action only route", async ({ page }) => {
@@ -360,21 +356,19 @@ test.describe("useFetcher", () => {
     let app = new PlaywrightFixture(appFixture, page);
 
     await app.goto("/fetcher-echo", true);
-    expect(await app.getHtml("pre")).toMatch(
-      JSON.stringify(["idle/undefined"])
-    );
+    await page.getByText(JSON.stringify(["idle/undefined"]));
 
     await page.fill("#fetcher-input", "1");
     await app.clickElement("#fetcher-load");
     await page.waitForSelector("#fetcher-idle");
-    expect(await app.getHtml("pre")).toMatch(
+    await page.getByText(
       JSON.stringify(["idle/undefined", "loading/undefined", "idle/LOADER 1"])
     );
 
     await page.fill("#fetcher-input", "2");
     await app.clickElement("#fetcher-load");
     await page.waitForSelector("#fetcher-idle");
-    expect(await app.getHtml("pre")).toMatch(
+    await page.getByText(
       JSON.stringify([
         "idle/undefined",
         "loading/undefined",
@@ -391,14 +385,12 @@ test.describe("useFetcher", () => {
     let app = new PlaywrightFixture(appFixture, page);
 
     await app.goto("/fetcher-echo", true);
-    expect(await app.getHtml("pre")).toMatch(
-      JSON.stringify(["idle/undefined"])
-    );
+    await page.getByText(JSON.stringify(["idle/undefined"]));
 
     await page.fill("#fetcher-input", "1");
     await app.clickElement("#fetcher-submit");
     await page.waitForSelector("#fetcher-idle");
-    expect(await app.getHtml("pre")).toMatch(
+    await page.getByText(
       JSON.stringify([
         "idle/undefined",
         "submitting/undefined",
@@ -410,7 +402,7 @@ test.describe("useFetcher", () => {
     await page.fill("#fetcher-input", "2");
     await app.clickElement("#fetcher-submit");
     await page.waitForSelector("#fetcher-idle");
-    expect(await app.getHtml("pre")).toMatch(
+    await page.getByText(
       JSON.stringify([
         "idle/undefined",
         "submitting/undefined",
diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts
index 85fbe6fc26..f935390bd8 100644
--- a/integration/helpers/create-fixture.ts
+++ b/integration/helpers/create-fixture.ts
@@ -337,6 +337,7 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) {
       return new Promise(async (accept) => {
         let port = await getPort();
         let app = express();
+        app.use(express.static(path.join(fixture.projectDir, "public")));
         app.use(
           "/client",
           express.static(path.join(fixture.projectDir, "build/client"))
@@ -460,7 +461,7 @@ function parcelBuild(
 ) {
   let parcelBin = "node_modules/parcel/lib/bin.js";
 
-  let buildArgs: string[] = [parcelBin, "build"];
+  let buildArgs: string[] = [parcelBin, "build", "--no-cache"];
 
   let buildSpawn = spawnSync("node", buildArgs, {
     cwd: projectDir,
diff --git a/integration/helpers/rsc-parcel-framework/public/favicon.ico b/integration/helpers/rsc-parcel-framework/public/favicon.ico
new file mode 100644
index 0000000000..5dbdfcddcb
Binary files /dev/null and b/integration/helpers/rsc-parcel-framework/public/favicon.ico differ
diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts
index 707b323d1b..f30f6b699c 100644
--- a/integration/helpers/vite.ts
+++ b/integration/helpers/vite.ts
@@ -1,4 +1,5 @@
-import { spawn, spawnSync, type ChildProcess } from "node:child_process";
+import type { ChildProcess } from "node:child_process";
+import { sync as spawnSync, spawn } from "cross-spawn";
 import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
 import { createRequire } from "node:module";
 import { platform } from "node:os";
diff --git a/integration/playwright.config.ts b/integration/playwright.config.ts
index da6159be6b..965aacd57d 100644
--- a/integration/playwright.config.ts
+++ b/integration/playwright.config.ts
@@ -18,7 +18,8 @@ const config: PlaywrightTestConfig = {
   },
   /* Maximum time one test can run for. */
   timeout: isWindows ? 60_000 : 30_000,
-  fullyParallel: true,
+  fullyParallel: !(isWindows && process.env.CI),
+  workers: isWindows && process.env.CI ? 1 : undefined,
   expect: {
     /* Maximum time expect() should wait for the condition to be met. */
     timeout: isWindows ? 10_000 : 5_000,
diff --git a/integration/revalidate-test.ts b/integration/revalidate-test.ts
index b14c73ff5d..5ccdfa79b8 100644
--- a/integration/revalidate-test.ts
+++ b/integration/revalidate-test.ts
@@ -77,7 +77,7 @@ test.describe("Revalidation", () => {
                 let data = useLoaderData();
                 return (
                   <>
-                    

{'Value:' + data.value}

+

{'Value:' + data.value}

); @@ -122,7 +122,7 @@ test.describe("Revalidation", () => { let revalidator = useRevalidator(); return ( <> -

{'Value:' + data.value}

+

{'Value:' + data.value}

@@ -168,37 +168,59 @@ test.describe("Revalidation", () => { // Should call parent (first load) await app.clickLink("/parent"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); // Should call child (first load) but not parent (no param) await app.clickLink("/parent/child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); // Should call neither await app.clickLink("/parent/child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); // Should call both await app.clickLink("/parent/child?revalidate=parent,child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:2"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); // Should call parent only await app.clickLink("/parent/child?revalidate=parent"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); // Should call child only await app.clickLink("/parent/child?revalidate=child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); }); test("Revalidates according to shouldRevalidate (submission navigations)", async ({ @@ -210,32 +232,52 @@ test.describe("Revalidation", () => { // Should call both (first load) await app.clickLink("/parent/child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); // Should call neither await app.clickElement("#submit-neither"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); // Should call both await app.clickElement("#submit-both"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:2"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); // Should call parent only await app.clickElement("#submit-parent"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); // Should call child only await app.clickElement("#submit-child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); }); test("Revalidates on demand with useRevalidator", async ({ page }) => { @@ -245,49 +287,81 @@ test.describe("Revalidation", () => { // Should call both (first load) await app.clickLink("/parent/child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); // Should call neither on manual revalidate (no params) await app.clickElement("#revalidate"); await page.waitForSelector("#revalidation-idle", { state: "visible" }); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); // Should call both await app.clickLink("/parent/child?revalidate=parent,child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:2"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); // Should call both on manual revalidate await app.clickElement("#revalidate"); await page.waitForSelector("#revalidation-idle", { state: "visible" }); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); // Should call parent only await app.clickLink("/parent/child?revalidate=parent"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:4"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:4" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); // Should call parent only on manual revalidate await app.clickElement("#revalidate"); await page.waitForSelector("#revalidation-idle", { state: "visible" }); - expect(await app.getHtml("#parent-data")).toMatch("Value:5"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:5" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); // Should call child only await app.clickLink("/parent/child?revalidate=child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:5"); - expect(await app.getHtml("#child-data")).toMatch("Value:4"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:5" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:4" }) + ).toBeAttached(); // Should call child only on manual revalidate await app.clickElement("#revalidate"); await page.waitForSelector("#revalidation-idle", { state: "visible" }); - expect(await app.getHtml("#parent-data")).toMatch("Value:5"); - expect(await app.getHtml("#child-data")).toMatch("Value:5"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:5" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:5" }) + ).toBeAttached(); }); }); diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts index 5233aa1d3b..bede94ff57 100644 --- a/integration/rsc/rsc-test.ts +++ b/integration/rsc/rsc-test.ts @@ -1,5 +1,6 @@ -import { spawnSync } from "node:child_process"; +import * as os from "node:os"; import { test, expect } from "@playwright/test"; +import { sync as spawnSync } from "cross-spawn"; import getPort from "get-port"; import { @@ -22,43 +23,50 @@ type Implementation = { }; // Run tests against vite and parcel to ensure our code is bundler agnostic -const implementations: Implementation[] = [ - { - name: "vite", - template: "rsc-vite", - build: ({ cwd }: { cwd: string }) => - spawnSync("node_modules/.bin/vite", ["build"], { cwd }), - run: ({ cwd, port }) => - createDev(["server.js", "-p", String(port)])({ - cwd, - port, - }), - dev: ({ cwd, port }) => - createDev(["node_modules/vite/bin/vite.js", "--port", String(port)])({ - cwd, - port, - }), - }, - { - name: "parcel", - template: "rsc-parcel", - build: ({ cwd }: { cwd: string }) => - spawnSync("node_modules/.bin/parcel", ["build"], { cwd }), - run: ({ cwd, port }) => - // FIXME: Parcel prod builds seems to have dup copies of react in them :/ - // Not reproducible in the playground though - only in integration/helpers... - implementations.find((i) => i.name === "parcel")!.dev({ cwd, port }), - dev: ({ cwd, port }) => - createDev(["node_modules/parcel/lib/bin.js"])({ - // Since we run through parcels dev server we can't use `-p` because that - // only changes the dev server and doesn't pass through to the internal - // server. So we setup the internal server to choose from `RR_PORT` - env: { RR_PORT: String(port) }, - cwd, - port, - }), - }, -]; +const implementations: Implementation[] = ( + [ + { + name: "vite", + template: "rsc-vite", + build: ({ cwd }: { cwd: string }) => + spawnSync("pnpm", ["build"], { cwd }), + run: ({ cwd, port }) => + createDev(["server.js", "-p", String(port)])({ + cwd, + port, + }), + dev: ({ cwd, port }) => + createDev(["node_modules/vite/bin/vite.js", "--port", String(port)])({ + cwd, + port, + }), + }, + { + name: "parcel", + template: "rsc-parcel", + build: ({ cwd }: { cwd: string }) => + spawnSync("pnpm", ["build"], { cwd }), + run: ({ cwd, port }) => + // FIXME: Parcel prod builds seems to have dup copies of react in them :/ + // Not reproducible in the playground though - only in integration/helpers... + implementations.find((i) => i.name === "parcel")!.dev({ cwd, port }), + dev: ({ cwd, port }) => + createDev(["node_modules/parcel/lib/bin.js"])({ + // Since we run through parcels dev server we can't use `-p` because that + // only changes the dev server and doesn't pass through to the internal + // server. So we setup the internal server to choose from `RR_PORT` + env: { RR_PORT: String(port) }, + cwd, + port, + }), + }, + ] as Implementation[] +).filter((imp) => { + if (imp.name === "vite" && os.platform() === "win32") { + return false; + } + return true; +}); async function setupRscTest({ implementation, @@ -73,12 +81,13 @@ async function setupRscTest({ }) { let cwd = await createProject(files, implementation.template); - let { status, stderr, stdout } = implementation.build({ cwd }); + let { error, status, stderr, stdout } = implementation.build({ cwd }); if (status !== 0) { console.error("Error building project", { status, - stdout: stdout.toString(), - stderr: stderr.toString(), + error, + stdout: stdout?.toString(), + stderr: stderr?.toString(), }); throw new Error("Error building project"); }