diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts index 7d5147ad48..a4b26c0d97 100644 --- a/integration/client-data-test.ts +++ b/integration/client-data-test.ts @@ -315,7 +315,6 @@ test.describe("Client Data", () => { childClientLoader: false, childClientLoaderHydrate: false, }), - // Blow away parent.child.tsx with our own deferred version "app/routes/parent.child.tsx": js` import * as React from 'react'; import { defer, json } from '@remix-run/node' @@ -404,7 +403,60 @@ test.describe("Client Data", () => { expect(html).toMatch("Child Client Loader"); }); - test("clientLoader.hydrate is automatically implied when no server loader exists", async ({ + test("HydrateFallback is not rendered if clientLoader.hydrate is not set (w/server loader)", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from '@remix-run/node'; + import { useLoaderData } from '@remix-run/react'; + export function loader() { + return json({ + message: "Child Server Loader Data", + }); + } + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + return { + message: "Child Client Loader Data", + }; + } + export function HydrateFallback() { + return

SHOULD NOT SEE ME

+ } + export default function Component() { + let data = useLoaderData(); + return

{data.message}

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + // Ensure initial document request contains the child fallback _and_ the + // subsequent streamed/resolved deferred data + let doc = await fixture.requestDocument("/parent/child"); + let html = await doc.text(); + expect(html).toMatch("Child Server Loader Data"); + expect(html).not.toMatch("SHOULD NOT SEE ME"); + + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Child Server Loader Data"); + }); + + test("clientLoader.hydrate is automatically implied when no server loader exists (w HydrateFallback)", async ({ page, }) => { appFixture = await createAppFixture( @@ -416,7 +468,6 @@ test.describe("Client Data", () => { childClientLoader: false, childClientLoaderHydrate: false, }), - // Blow away parent.child.tsx with our own version without a server loader "app/routes/parent.child.tsx": js` import * as React from 'react'; import { useLoaderData } from '@remix-run/react'; @@ -447,6 +498,232 @@ test.describe("Client Data", () => { html = await app.getHtml("main"); expect(html).toMatch("Loader Data (clientLoader only)"); }); + + test("clientLoader.hydrate is automatically implied when no server loader exists (w/o HydrateFallback)", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData } from '@remix-run/react'; + // Even without setting hydrate=true, this should run on hydration + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + return { + message: "Loader Data (clientLoader only)", + }; + } + export default function Component() { + let data = useLoaderData(); + return

{data.message}

; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml(); + expect(html).toMatch( + "💿 Hey developer 👋. You can provide a way better UX than this" + ); + expect(html).not.toMatch("child-data"); + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Loader Data (clientLoader only)"); + }); + + test("throws a 400 if you call serverLoader without a server loader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData, useRouteError } from '@remix-run/react'; + export async function clientLoader({ serverLoader }) { + return await serverLoader(); + } + export default function Component() { + return

Child

; + } + export function HydrateFallback() { + return

Loading...

; + } + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.status} {error.data}

; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverLoader() on a route that does " + + 'not have a server loader (routeId: "routes/parent.child")' + ); + }); + + test("initial hydration data check functions properly", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from '@remix-run/node'; + import { useLoaderData, useRevalidator } from '@remix-run/react'; + let isFirstCall = true; + export async function loader({ serverLoader }) { + if (isFirstCall) { + isFirstCall = false + return json({ + message: "Child Server Loader Data (1)", + }); + } + return json({ + message: "Child Server Loader Data (2+)", + }); + } + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + let serverData = await serverLoader(); + return { + message: serverData.message + " (mutated by client)", + }; + } + clientLoader.hydrate=true; + export default function Component() { + let data = useLoaderData(); + let revalidator = useRevalidator(); + return ( + <> +

{data.message}

+ + + ); + } + export function HydrateFallback() { + return

Loading...

+ } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml(); + expect(html).toMatch("Child Server Loader Data (1) (mutated by client)"); + app.clickElement("button"); + await page.waitForSelector(':has-text("Child Server Loader Data (2+)")'); + html = await app.getHtml("main"); + expect(html).toMatch("Child Server Loader Data (2+) (mutated by client)"); + }); + + test("initial hydration data check functions properly even if serverLoader isn't called on hydration", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from '@remix-run/node'; + import { useLoaderData, useRevalidator } from '@remix-run/react'; + let isFirstCall = true; + export async function loader({ serverLoader }) { + if (isFirstCall) { + isFirstCall = false + return json({ + message: "Child Server Loader Data (1)", + }); + } + return json({ + message: "Child Server Loader Data (2+)", + }); + } + let isFirstClientCall = true; + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + if (isFirstClientCall) { + isFirstClientCall = false; + // First time through - don't even call serverLoader + return { + message: "Child Client Loader Data", + }; + } + // Only call the serverLoader on subsequent calls and this + // should *not* return us the initialData any longer + let serverData = await serverLoader(); + return { + message: serverData.message + " (mutated by client)", + }; + } + clientLoader.hydrate=true; + export default function Component() { + let data = useLoaderData(); + let revalidator = useRevalidator(); + return ( + <> +

{data.message}

+ + + ); + } + export function HydrateFallback() { + return

Loading...

+ } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml(); + expect(html).toMatch("Child Client Loader Data"); + app.clickElement("button"); + await page.waitForSelector(':has-text("Child Server Loader Data (2+)")'); + html = await app.getHtml("main"); + expect(html).toMatch("Child Server Loader Data (2+) (mutated by client)"); + }); }); test.describe("clientLoader - lazy route module", () => { @@ -534,6 +811,50 @@ test.describe("Client Data", () => { expect(html).toMatch("Parent Server Loader (mutated by client)"); expect(html).toMatch("Child Server Loader (mutated by client"); }); + + test("throws a 400 if you call serverLoader without a server loader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData, useRouteError } from '@remix-run/react'; + export async function clientLoader({ serverLoader }) { + return await serverLoader(); + } + export default function Component() { + return

Child

; + } + export function HydrateFallback() { + return

Loading...

; + } + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.status} {error.data}

; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverLoader() on a route that does " + + 'not have a server loader (routeId: "routes/parent.child")' + ); + }); }); test.describe("clientAction - critical route module", () => { @@ -698,6 +1019,51 @@ test.describe("Client Data", () => { expect(html).toMatch("Child Server Loader (mutated by client)"); expect(html).toMatch("Child Server Action (mutated by client)"); }); + + test("throws a 400 if you call serverAction without a server action", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from '@remix-run/node'; + import { Form, useRouteError } from '@remix-run/react'; + export async function clientAction({ serverAction }) { + return await serverAction(); + } + export default function Component() { + return ( +
+ +
+ ); + } + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.status} {error.data}

; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverAction() on a route that does " + + 'not have a server action (routeId: "routes/parent.child")' + ); + }); }); test.describe("clientAction - lazy route module", () => { @@ -870,5 +1236,52 @@ test.describe("Client Data", () => { expect(html).toMatch("Child Server Loader (mutated by client)"); expect(html).toMatch("Child Server Action (mutated by client)"); }); + + test("throws a 400 if you call serverAction without a server action", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from '@remix-run/node'; + import { Form, useRouteError } from '@remix-run/react'; + export async function clientAction({ serverAction }) { + return await serverAction(); + } + export default function Component() { + return ( +
+ +
+ ); + } + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.status} {error.data}

; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.goto("/parent/child"); + await page.waitForSelector("form"); + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverAction() on a route that does " + + 'not have a server action (routeId: "routes/parent.child")' + ); + }); }); }); diff --git a/packages/remix-dev/CHANGELOG.md b/packages/remix-dev/CHANGELOG.md index d40c18eaf8..c0d1981a41 100644 --- a/packages/remix-dev/CHANGELOG.md +++ b/packages/remix-dev/CHANGELOG.md @@ -1,5 +1,158 @@ # `@remix-run/dev` +## 2.4.0 + +### Minor Changes + +- Vite: exclude modules within `.server` directories from client build ([#8154](https://github.com/remix-run/remix/pull/8154)) + +- Add support for `clientLoader`/`clientAction`/`HydrateFallback` route exports ([RFC](https://github.com/remix-run/remix/discussions/7634)) ([#8173](https://github.com/remix-run/remix/pull/8173)) + + Remix now supports loaders/actions that run on the client (in addition to, or instead of the loader/action that runs on the server). While we still recommend server loaders/actions for the majority of your data needs in a Remix app - these provide some levers you can pull for more advanced use-cases such as: + + - Leveraging a data source local to the browser (i.e., `localStorage`) + - Managing a client-side cache of server data (like `IndexedDB`) + - Bypassing the Remix server in a BFF setup and hitting your API directly from the browser + - Migrating a React Router SPA to a Remix application + + By default, `clientLoader` will not run on hydration, and will only run on subsequent client side navigations. + + If you wish to run your client loader on hydration, you can set `clientLoader.hydrate=true` to force Remix to execute it on initial page load. Keep in mind that Remix will still SSR your route component so you should ensure that there is no new _required_ data being added by your `clientLoader`. + + If your `clientLoader` needs to run on hydration and adds data you require to render the route component, you can export a `HydrateFallback` component that will render during SSR, and then your route component will not render until the `clientLoader` has executed on hydration. + + `clientAction` is simpler than `clientLoader` because it has no hydration use-cases. `clientAction` will only run on client-side navigations. + + For more information, please refer to the [`clientLoader`](https://remix.run/route/client-loader) and [`clientAction`](https://remix.run/route/client-action) documentation. + +- Vite: Strict route exports ([#8171](https://github.com/remix-run/remix/pull/8171)) + + With Vite, Remix gets stricter about which exports are allowed from your route modules. + Previously, the Remix compiler would allow any export from routes. + While this was convenient, it was also a common source of bugs that were hard to track down because they only surfaced at runtime. + + For more, see + +- Add a new `future.v3_relativeSplatPath` flag to implement a breaking bug fix to relative routing when inside a splat route. For more information, please see the React Router [`6.21.0` Release Notes](https://github.com/remix-run/react-router/blob/release-next/CHANGELOG.md#futurev7_relativesplatpath) and the [`useResolvedPath` docs](https://remix.run/hooks/use-resolved-path#splat-paths). ([#8216](https://github.com/remix-run/remix/pull/8216)) + +### Patch Changes + +- Upgrade Vite peer dependency range to v5 ([#8172](https://github.com/remix-run/remix/pull/8172)) + +- Support HMR for routes with `handle` export in Vite dev ([#8022](https://github.com/remix-run/remix/pull/8022)) + +- Fix flash of unstyled content for non-Express custom servers in Vite dev ([#8076](https://github.com/remix-run/remix/pull/8076)) + +- Bundle CSS imported in client entry file in Vite plugin ([#8143](https://github.com/remix-run/remix/pull/8143)) + +- Change Vite build output paths to fix a conflict between how Vite and the Remix compiler each manage the `public` directory. ([#8077](https://github.com/remix-run/remix/pull/8077)) + + **This is a breaking change for projects using the unstable Vite plugin.** + + The server is now compiled into `build/server` rather than `build`, and the client is now compiled into `build/client` rather than `public`. + + For more information on the changes and guidance on how to migrate your project, refer to the updated [Remix Vite documentation](https://remix.run/docs/en/main/future/vite). + +- Remove undocumented `legacyCssImports` option from Vite plugin due to issues with `?url` imports of CSS files not being processed correctly in Vite ([#8096](https://github.com/remix-run/remix/pull/8096)) + +- Vite: fix access to default `entry.{client,server}.tsx` within pnpm workspace on Windows ([#8057](https://github.com/remix-run/remix/pull/8057)) + +- Remove `unstable_createViteServer` and `unstable_loadViteServerBuild` which were only minimal wrappers around Vite's `createServer` and `ssrLoadModule` functions when using a custom server. ([#8120](https://github.com/remix-run/remix/pull/8120)) + + **This is a breaking change for projects using the unstable Vite plugin with a custom server.** + + Instead, we now provide `unstable_viteServerBuildModuleId` so that custom servers interact with Vite directly rather than via Remix APIs, for example: + + ```diff + -import { + - unstable_createViteServer, + - unstable_loadViteServerBuild, + -} from "@remix-run/dev"; + +import { unstable_viteServerBuildModuleId } from "@remix-run/dev"; + ``` + + Creating the Vite server in middleware mode: + + ```diff + const vite = + process.env.NODE_ENV === "production" + ? undefined + - : await unstable_createViteServer(); + + : await import("vite").then(({ createServer }) => + + createServer({ + + server: { + + middlewareMode: true, + + }, + + }) + + ); + ``` + + Loading the Vite server build in the request handler: + + ```diff + app.all( + "*", + createRequestHandler({ + build: vite + - ? () => unstable_loadViteServerBuild(vite) + + ? () => vite.ssrLoadModule(unstable_viteServerBuildModuleId) + : await import("./build/server/index.js"), + }) + ); + ``` + +- Pass request handler errors to `vite.ssrFixStacktrace` in Vite dev to ensure stack traces correctly map to the original source code ([#8066](https://github.com/remix-run/remix/pull/8066)) + +- Vite: Preserve names for exports from .client imports ([#8200](https://github.com/remix-run/remix/pull/8200)) + + Unlike `.server` modules, the main idea is not to prevent code from leaking into the server build + since the client build is already public. Rather, the goal is to isolate the SSR render from client-only code. + Routes need to import code from `.client` modules without compilation failing and then rely on runtime checks + to determine if the code is running on the server or client. + + Replacing `.client` modules with empty modules would cause the build to fail as ESM named imports are statically analyzed. + So instead, we preserve the named export but replace each exported value with an empty object. + That way, the import is valid at build time and the standard runtime checks can be used to determine if then + code is running on the server or client. + +- Add `@remix-run/node` to Vite's `optimizeDeps.include` array ([#8177](https://github.com/remix-run/remix/pull/8177)) + +- Improve Vite plugin performance ([#8121](https://github.com/remix-run/remix/pull/8121)) + + - Parallelize detection of route module exports + - Disable `server.preTransformRequests` in Vite child compiler since it's only used to process route modules + +- Remove automatic global Node polyfill installation from the built-in Vite dev server and instead allow explicit opt-in. ([#8119](https://github.com/remix-run/remix/pull/8119)) + + **This is a breaking change for projects using the unstable Vite plugin without a custom server.** + + If you're not using a custom server, you should call `installGlobals` in your Vite config instead. + + ```diff + import { unstable_vitePlugin as remix } from "@remix-run/dev"; + +import { installGlobals } from "@remix-run/node"; + import { defineConfig } from "vite"; + + +installGlobals(); + + export default defineConfig({ + plugins: [remix()], + }); + ``` + +- Vite: Errors at build-time when client imports .server default export ([#8184](https://github.com/remix-run/remix/pull/8184)) + + Remix already stripped .server file code before ensuring that server code never makes it into the client. + That results in errors when client code tries to import server code, which is exactly what we want! + But those errors were happening at runtime for default imports. + A better experience is to have those errors happen at build-time so that you guarantee that your users won't hit them. + +- Fix `request instanceof Request` checks when using Vite dev server ([#8062](https://github.com/remix-run/remix/pull/8062)) + +- Updated dependencies: + - `@remix-run/server-runtime@2.4.0` + - `@remix-run/node@2.4.0` + ## 2.3.1 ### Patch Changes diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index fb4e336f36..36a7a19f83 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -1,6 +1,6 @@ { "name": "@remix-run/dev", - "version": "2.3.1", + "version": "2.4.0", "description": "Dev tools and CLI for Remix", "homepage": "https://remix.run", "bugs": { @@ -28,9 +28,9 @@ "@babel/types": "^7.22.5", "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", - "@remix-run/node": "2.3.1", - "@remix-run/router": "1.14.0-pre.0", - "@remix-run/server-runtime": "2.3.1", + "@remix-run/node": "2.4.0", + "@remix-run/router": "1.14.0", + "@remix-run/server-runtime": "2.4.0", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", "arg": "^5.0.1", @@ -73,7 +73,7 @@ "ws": "^7.4.5" }, "devDependencies": { - "@remix-run/serve": "2.3.1", + "@remix-run/serve": "2.4.0", "@types/cacache": "^17.0.0", "@types/cross-spawn": "^6.0.2", "@types/gunzip-maybe": "^1.4.0", @@ -94,7 +94,7 @@ "vite": "^5.0.0" }, "peerDependencies": { - "@remix-run/serve": "^2.3.1", + "@remix-run/serve": "^2.4.0", "typescript": "^5.1.0", "vite": "^5.0.0" }, diff --git a/packages/remix-express/CHANGELOG.md b/packages/remix-express/CHANGELOG.md index 195b6d2541..dbe3546206 100644 --- a/packages/remix-express/CHANGELOG.md +++ b/packages/remix-express/CHANGELOG.md @@ -1,5 +1,12 @@ # `@remix-run/express` +## 2.4.0 + +### Patch Changes + +- Updated dependencies: + - `@remix-run/node@2.4.0` + ## 2.3.1 ### Patch Changes diff --git a/packages/remix-express/package.json b/packages/remix-express/package.json index e6bb777908..4c34569673 100644 --- a/packages/remix-express/package.json +++ b/packages/remix-express/package.json @@ -1,6 +1,6 @@ { "name": "@remix-run/express", - "version": "2.3.1", + "version": "2.4.0", "description": "Express server request handler for Remix", "bugs": { "url": "https://github.com/remix-run/remix/issues" @@ -14,7 +14,7 @@ "main": "dist/index.js", "typings": "dist/index.d.ts", "dependencies": { - "@remix-run/node": "2.3.1" + "@remix-run/node": "2.4.0" }, "devDependencies": { "@types/express": "^4.17.9", diff --git a/packages/remix-node/CHANGELOG.md b/packages/remix-node/CHANGELOG.md index c1b5452727..483e5e34d7 100644 --- a/packages/remix-node/CHANGELOG.md +++ b/packages/remix-node/CHANGELOG.md @@ -1,5 +1,17 @@ # `@remix-run/node` +## 2.4.0 + +### Minor Changes + +- Deprecate `DataFunctionArgs` in favor of `LoaderFunctionArgs`/`ActionFunctionArgs`. This is aimed at keeping the types aligned across server/client loaders/actions now that `clientLoader`/`clientActon` functions have `serverLoader`/`serverAction` parameters which differentiate `ClientLoaderFunctionArgs`/`ClientActionFunctionArgs`. ([#8173](https://github.com/remix-run/remix/pull/8173)) + +### Patch Changes + +- Update to `@remix-run/web-fetch@4.4.2` ([#8231](https://github.com/remix-run/remix/pull/8231)) +- Updated dependencies: + - `@remix-run/server-runtime@2.4.0` + ## 2.3.1 ### Patch Changes diff --git a/packages/remix-node/package.json b/packages/remix-node/package.json index 5b21c1218f..732f57685d 100644 --- a/packages/remix-node/package.json +++ b/packages/remix-node/package.json @@ -1,6 +1,6 @@ { "name": "@remix-run/node", - "version": "2.3.1", + "version": "2.4.0", "description": "Node.js platform abstractions for Remix", "bugs": { "url": "https://github.com/remix-run/remix/issues" @@ -17,8 +17,8 @@ "./install.js" ], "dependencies": { - "@remix-run/server-runtime": "2.3.1", - "@remix-run/web-fetch": "^4.4.1", + "@remix-run/server-runtime": "2.4.0", + "@remix-run/web-fetch": "^4.4.2", "@remix-run/web-file": "^3.1.0", "@remix-run/web-stream": "^1.1.0", "@web3-storage/multipart-parser": "^1.0.0", diff --git a/packages/remix-serve/CHANGELOG.md b/packages/remix-serve/CHANGELOG.md index 797d9a889a..cd77d1ee01 100644 --- a/packages/remix-serve/CHANGELOG.md +++ b/packages/remix-serve/CHANGELOG.md @@ -1,5 +1,14 @@ # `@remix-run/serve` +## 2.4.0 + +### Patch Changes + +- Fix source map loading when file has `?t=timestamp` suffix (rebuilds) ([#8174](https://github.com/remix-run/remix/pull/8174)) +- Updated dependencies: + - `@remix-run/node@2.4.0` + - `@remix-run/express@2.4.0` + ## 2.3.1 ### Patch Changes diff --git a/packages/remix-serve/package.json b/packages/remix-serve/package.json index b38a185b72..132af85573 100644 --- a/packages/remix-serve/package.json +++ b/packages/remix-serve/package.json @@ -1,6 +1,6 @@ { "name": "@remix-run/serve", - "version": "2.3.1", + "version": "2.4.0", "description": "Production application server for Remix", "bugs": { "url": "https://github.com/remix-run/remix/issues" @@ -15,8 +15,8 @@ "remix-serve": "dist/cli.js" }, "dependencies": { - "@remix-run/express": "2.3.1", - "@remix-run/node": "2.3.1", + "@remix-run/express": "2.4.0", + "@remix-run/node": "2.4.0", "chokidar": "^3.5.3", "compression": "^1.7.4", "express": "^4.17.1", diff --git a/packages/remix-server-runtime/CHANGELOG.md b/packages/remix-server-runtime/CHANGELOG.md index 404cc68872..908c1660bc 100644 --- a/packages/remix-server-runtime/CHANGELOG.md +++ b/packages/remix-server-runtime/CHANGELOG.md @@ -1,5 +1,37 @@ # `@remix-run/server-runtime` +## 2.4.0 + +### Minor Changes + +- Add support for `clientLoader`/`clientAction`/`HydrateFallback` route exports ([RFC](https://github.com/remix-run/remix/discussions/7634)). ([#8173](https://github.com/remix-run/remix/pull/8173)) + + Remix now supports loaders/actions that run on the client (in addition to, or instead of the loader/action that runs on the server). While we still recommend server loaders/actions for the majority of your data needs in a Remix app - these provide some levers you can pull for more advanced use-cases such as: + + - Leveraging a data source local to the browser (i.e., `localStorage`) + - Managing a client-side cache of server data (like `IndexedDB`) + - Bypassing the Remix server in a BFF setup and hitting your API directly from the browser + - Migrating a React Router SPA to a Remix application + + By default, `clientLoader` will not run on hydration, and will only run on subsequent client side navigations. + + If you wish to run your client loader on hydration, you can set `clientLoader.hydrate=true` to force Remix to execute it on initial page load. Keep in mind that Remix will still SSR your route component so you should ensure that there is no new _required_ data being added by your `clientLoader`. + + If your `clientLoader` needs to run on hydration and adds data you require to render the route component, you can export a `HydrateFallback` component that will render during SSR, and then your route component will not render until the `clientLoader` has executed on hydration. + + `clientAction` is simpler than `clientLoader` because it has no hydration use-cases. `clientAction` will only run on client-side navigations. + + For more information, please refer to the [`clientLoader`](https://remix.run/route/client-loader) and [`clientAction`](https://remix.run/route/client-action) documentation. + +- Deprecate `DataFunctionArgs` in favor of `LoaderFunctionArgs`/`ActionFunctionArgs`. This is aimed at keeping the types aligned across server/client loaders/actions now that `clientLoader`/`clientActon` functions have `serverLoader`/`serverAction` parameters which differentiate `ClientLoaderFunctionArgs`/`ClientActionFunctionArgs`. ([#8173](https://github.com/remix-run/remix/pull/8173)) + +- Add a new `future.v3_relativeSplatPath` flag to implement a breaking bug fix to relative routing when inside a splat route. For more information, please see the React Router [`6.21.0` Release Notes](https://github.com/remix-run/react-router/blob/release-next/CHANGELOG.md#futurev7_relativesplatpath) and the [`useResolvedPath` docs](https://remix.run/hooks/use-resolved-path#splat-paths). ([#8216](https://github.com/remix-run/remix/pull/8216)) + +### Patch Changes + +- Fix flash of unstyled content for non-Express custom servers in Vite dev ([#8076](https://github.com/remix-run/remix/pull/8076)) +- Pass request handler errors to `vite.ssrFixStacktrace` in Vite dev to ensure stack traces correctly map to the original source code ([#8066](https://github.com/remix-run/remix/pull/8066)) + ## 2.3.1 No significant changes to this package were made in this release. [See the repo `CHANGELOG.md`](https://github.com/remix-run/remix/blob/main/CHANGELOG.md) for an overview of all changes in v2.3.1. diff --git a/packages/remix-server-runtime/jsonify.ts b/packages/remix-server-runtime/jsonify.ts index 80a6c2b5af..80e524ff57 100644 --- a/packages/remix-server-runtime/jsonify.ts +++ b/packages/remix-server-runtime/jsonify.ts @@ -21,6 +21,9 @@ export type Jsonify = T extends Number ? number : T extends Boolean ? boolean : + // Promises JSON.stringify to an empty object + T extends Promise ? EmptyObject : + // Map & Set T extends Map ? EmptyObject : T extends Set ? EmptyObject : @@ -119,6 +122,7 @@ type _tests = [ Expect, string>>, Expect, number>>, Expect, boolean>>, + Expect>, EmptyObject>>, // Map & Set Expect>, EmptyObject>>, @@ -251,7 +255,7 @@ type NeverToNull = [T] extends [never] ? null : T; // adapted from https://github.com/sindresorhus/type-fest/blob/main/source/empty-object.d.ts declare const emptyObjectSymbol: unique symbol; -type EmptyObject = { [emptyObjectSymbol]?: never }; +export type EmptyObject = { [emptyObjectSymbol]?: never }; // adapted from https://github.com/type-challenges/type-challenges/blob/main/utils/index.d.ts type IsAny = 0 extends 1 & T ? true : false; diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index 380565f5bc..7801ae9809 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@remix-run/server-runtime", - "version": "2.3.1", + "version": "2.4.0", "description": "Server runtime for Remix", "bugs": { "url": "https://github.com/remix-run/remix/issues" @@ -16,7 +16,7 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "1.14.0-pre.0", + "@remix-run/router": "1.14.0", "@types/cookie": "^0.5.3", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.5.0", diff --git a/packages/remix-server-runtime/routeModules.ts b/packages/remix-server-runtime/routeModules.ts index f778335dc3..71c838d397 100644 --- a/packages/remix-server-runtime/routeModules.ts +++ b/packages/remix-server-runtime/routeModules.ts @@ -54,7 +54,7 @@ type ClientActionFunction = ( * Arguments passed to a route `clientAction` function * @private Public API is exported from @remix-run/react */ -type ClientActionFunctionArgs = RRActionFunctionArgs & { +export type ClientActionFunctionArgs = RRActionFunctionArgs & { serverAction: () => Promise>; }; @@ -87,7 +87,7 @@ type ClientLoaderFunction = (( * Arguments passed to a route `clientLoader` function * @private Public API is exported from @remix-run/react */ -type ClientLoaderFunctionArgs = RRLoaderFunctionArgs & { +export type ClientLoaderFunctionArgs = RRLoaderFunctionArgs & { serverLoader: () => Promise>; }; diff --git a/packages/remix-server-runtime/routes.ts b/packages/remix-server-runtime/routes.ts index 4acbcb469c..d25c5098de 100644 --- a/packages/remix-server-runtime/routes.ts +++ b/packages/remix-server-runtime/routes.ts @@ -27,6 +27,8 @@ export interface Route { export interface EntryRoute extends Route { hasAction: boolean; hasLoader: boolean; + hasClientAction: boolean; + hasClientLoader: boolean; hasErrorBoundary: boolean; imports?: string[]; css?: string[]; diff --git a/packages/remix-server-runtime/serialize.ts b/packages/remix-server-runtime/serialize.ts index 4a2a8a3a79..c3d4822f49 100644 --- a/packages/remix-server-runtime/serialize.ts +++ b/packages/remix-server-runtime/serialize.ts @@ -1,21 +1,60 @@ -import type { Jsonify } from "./jsonify"; +import type { EmptyObject, Jsonify } from "./jsonify"; import type { TypedDeferredData, TypedResponse } from "./responses"; +import type { + ClientActionFunctionArgs, + ClientLoaderFunctionArgs, +} from "./routeModules"; import { expectType } from "./typecheck"; import { type Expect, type Equal } from "./typecheck"; // prettier-ignore /** - * Infer JSON serialized data type returned by a loader or action. + * Infer JSON serialized data type returned by a loader or action, while + * avoiding deserialization if the input type if it's a clientLoader or + * clientAction that returns a non-Response * * For example: * `type LoaderData = SerializeFrom` */ export type SerializeFrom = - T extends (...args: any[]) => infer Output ? Serialize> : + T extends (...args: any[]) => infer Output ? + Parameters extends [ClientLoaderFunctionArgs | ClientActionFunctionArgs] ? + // Client data functions may not serialize + SerializeClient> + : + // Serialize responses + Serialize> + : // Back compat: manually defined data type, not inferred from loader nor action Jsonify> ; +// note: cannot be inlined as logic requires union distribution +// prettier-ignore +type SerializeClient = + Output extends TypedDeferredData ? + // top-level promises + & { + [K in keyof U as K extends symbol + ? never + : Promise extends U[K] + ? K + : never]: DeferValueClient; // use generic to distribute over union + } + // non-promises + & { + [K in keyof U as Promise extends U[K] ? never : K]: U[K]; + } + : + Output extends TypedResponse ? Jsonify : + Awaited + +// prettier-ignore +type DeferValueClient = + T extends undefined ? undefined : + T extends Promise ? Promise> : + T; + // note: cannot be inlined as logic requires union distribution // prettier-ignore type Serialize = @@ -49,16 +88,45 @@ type DeferValue = type Pretty = { [K in keyof T]: T[K] }; -type Loader = () => Promise< - | TypedResponse // returned responses - | TypedResponse // thrown responses ->; +type Loader = () => Promise>; type LoaderDefer> = () => Promise< - | TypedDeferredData // returned responses - | TypedResponse // thrown responses + TypedDeferredData +>; + +type LoaderBoth< + T1 extends Record, + T2 extends Record +> = () => Promise | TypedDeferredData>; + +type ClientLoaderRaw> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise; // returned non-Response + +type ClientLoaderResponse> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise>; // returned responses + +type ClientLoaderDefer> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise>; // returned responses + +type ClientLoaderResponseAndDefer< + T1 extends Record, + T2 extends Record +> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise< + TypedResponse | TypedDeferredData >; +type ClientLoaderRawAndDefer< + T1 extends Record, + T2 extends Record +> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise>; + // prettier-ignore // eslint-disable-next-line @typescript-eslint/no-unused-vars type _tests = [ @@ -78,7 +146,27 @@ type _tests = [ Expect>>, {a: string, name: number, data: boolean}>>, // defer top-level promises - Expect}>> extends {a: string, lazy: Promise<{ b: number }>} ? true : false> + Expect}>> extends {a: string, lazy: Promise<{ b: number }>} ? true : false>, + + // conditional defer or json + Expect }, { c: string; lazy: Promise<{ d: number }>}>> extends { a: string, b: EmptyObject } | { c: string; lazy: Promise<{ d: number }> } ? true : false>, + + // clientLoader raw JSON + Expect>>, {a: string}>>, + Expect }>>>, {a: Date, b: Map}>>, + + // clientLoader json() Response + Expect>>, {a: string}>>, + Expect>>, {a: string}>>, + + // clientLoader defer() data + Expect}>> extends {a: string, lazy: Promise<{ b: number }>} ? true : false>, + + // clientLoader conditional defer or json + Expect }, { c: string; lazy: Promise<{ d: number }>}>> extends { a: string, b: EmptyObject } | { c: string; lazy: Promise<{ d: number }> } ? true : false>, + + // clientLoader conditional defer or raw + Expect }, { c: string; lazy: Promise<{ d: number }>}>> extends { a: string, b: Promise } | { c: string; lazy: Promise<{ d: number }> } ? true : false>, ]; // recursive diff --git a/packages/remix-testing/CHANGELOG.md b/packages/remix-testing/CHANGELOG.md index a6daaec094..fa4b7d2609 100644 --- a/packages/remix-testing/CHANGELOG.md +++ b/packages/remix-testing/CHANGELOG.md @@ -1,5 +1,18 @@ # `@remix-run/testing` +## 2.4.0 + +### Minor Changes + +- Add support for `clientLoader`/`clientAction`/`HydrateFallback` route exports ([RFC](https://github.com/remix-run/remix/discussions/7634)). ([#8173](https://github.com/remix-run/remix/pull/8173)) +- Add a new `future.v3_relativeSplatPath` flag to implement a breaking bug fix to relative routing when inside a splat route. For more information, please see the React Router [`6.21.0` Release Notes](https://github.com/remix-run/react-router/blob/release-next/CHANGELOG.md#futurev7_relativesplatpath) and the [`useResolvedPath` docs](https://remix.run/hooks/use-resolved-path#splat-paths). ([#8216](https://github.com/remix-run/remix/pull/8216)) + +### Patch Changes + +- Updated dependencies: + - `@remix-run/react@2.4.0` + - `@remix-run/node@2.4.0` + ## 2.3.1 ### Patch Changes diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json index e8d1cc7ced..740eec8fad 100644 --- a/packages/remix-testing/package.json +++ b/packages/remix-testing/package.json @@ -1,6 +1,6 @@ { "name": "@remix-run/testing", - "version": "2.3.1", + "version": "2.4.0", "description": "Testing utilities for Remix apps", "homepage": "https://remix.run", "bugs": { @@ -16,10 +16,10 @@ "typings": "./dist/index.d.ts", "module": "./dist/esm/index.js", "dependencies": { - "@remix-run/node": "2.3.1", - "@remix-run/react": "2.3.1", - "@remix-run/router": "1.14.0-pre.0", - "react-router-dom": "6.21.0-pre.0" + "@remix-run/node": "2.4.0", + "@remix-run/react": "2.4.0", + "@remix-run/router": "1.14.0", + "react-router-dom": "6.21.0" }, "devDependencies": { "@types/node": "^18.17.1",