diff --git a/.changeset/spa-mode.md b/.changeset/spa-mode.md new file mode 100644 index 00000000000..efb09301a3f --- /dev/null +++ b/.changeset/spa-mode.md @@ -0,0 +1,42 @@ +--- +"@remix-run/dev": minor +"@remix-run/react": minor +"@remix-run/server-runtime": minor +"@remix-run/testing": minor +--- + +Add unstable support for "SPA Mode" + +You can opt into SPA Mode by setting `unstable_ssr: false` in your Remix Vite plugin config: + +```js +// vite.config.ts +import { unstable_vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + remix({ unstable_ssr: false }), + ], +}); +``` + +Development in SPA Mode is just like a normal Remix app, and still uses the Remix dev server for HMR/HDR: + +```sh +remix vite:dev +``` + +Building in SPA Mode will generate an `index.html` file in your client assets directory: + +```sh +remix vite:build +``` + +To run your SPA, you serve your client assets directory via an HTTP server: + +```sh +npx http-server build/client +``` + +For more information, please refer to the [SPA Mode docs][https://reactrouter.com/en/main/guides/spa-mode]. \ No newline at end of file diff --git a/docs/guides/client-data.md b/docs/guides/client-data.md index 88da8d4c856..48257b5cf58 100644 --- a/docs/guides/client-data.md +++ b/docs/guides/client-data.md @@ -12,7 +12,7 @@ These new exports are a bit of a sharp knife and are not recommended as your _pr - **Fullstack State:** Augment server data with client data for your full set of loader data - **One or the Other:** Sometimes you use server loaders, sometimes you use client loaders, but not both on one route - **Client Cache:** Cache server loader data in the client and avoid some server calls -- **Migration:** Ease your migration from React Router -> Remix SPA -> Remix SSR (once Remix supports [SPA mode][rfc-spa]) +- **Migration:** Ease your migration from React Router -> Remix SPA -> Remix SSR (once Remix supports [SPA Mode][rfc-spa]) Please use these new exports with caution! If you're not careful - it's easy to get your UI out of sync. Remix out of the box tries _very_ hard to ensure that this doesn't happen - but once you take control over your own client-side cache, and potentially prevent Remix from performing it's normal server `fetch` calls - then Remix can no longer guarantee your UI remains in sync. @@ -226,13 +226,13 @@ export async function clientAction({ ## Migration -We expect to write up a separate guide for migrations once [SPA mode][rfc-spa] lands, but for now we expect that the process will be something like: +We expect to write up a separate guide for migrations once [SPA Mode][rfc-spa] lands, but for now we expect that the process will be something like: 1. Introduce data patterns in your React Router SPA by moving to `createBrowserRouter`/`RouterProvider` 2. Move your SPA to use Vite to better prepare for the Remix migration 3. Incrementally move to file-based route definitions via the use of a Vite plugin (not yet provided) -4. Migrate your React Router SPA to Remix SPA mode where all current file-based `loader` function act as `clientLoader` -5. Opt out of Remix SPA mode (and into Remix SSR mode) and find/replace your `loader` functions to `clientLoader` +4. Migrate your React Router SPA to Remix SPA Mode where all current file-based `loader` function act as `clientLoader` +5. Opt out of Remix SPA Mode (and into Remix SSR mode) and find/replace your `loader` functions to `clientLoader` - You're now running an SSR app but all your data loading is still happening in the client via `clientLoader` 6. Incrementally start moving `clientLoader -> loader` to start moving data loading to the server diff --git a/docs/guides/spa-mode.md b/docs/guides/spa-mode.md new file mode 100644 index 00000000000..cec28e80f6a --- /dev/null +++ b/docs/guides/spa-mode.md @@ -0,0 +1,124 @@ +--- +title: SPA Mode +--- + +# SPA Mode + +From the beginning, Remix's opinion has always been that you own your server architecture. This is why Remix is built on top of the [Web Fetch API][fetch] and can run on any modern [runtime][runtimes] via built-in (or community-provided) adapters. While we believe that having a server provides the best UX/Performance/SEO/etc. for _most_ apps, it is also undeniable that there exist plenty of valid use cases for a Single Page Application in the real world: + +- You prefer to deploy your app via static files on Github Pages or another CDN +- You don't want to manage a server, or run a Node.js server +- You're developing a special type of embedded app that can't be server rendered +- "Your boss couldn't care less about the UX ceiling of SPA architecture and won't give your dev teams time/capacity to re-architect things" [- Kent C. Dodds][kent-tweet] + +That's why we added support for **SPA Mode** in [2.5.0][2.5.0] (per this [RFC][rfc]), which builds heavily on top of the [Client Data][client-data] APIs. + +## What is SPA Mode? + +SPA Mode is basically what you'd get if you had your own [React Router + Vite][rr-setup] setup using `createBrowserRouter`/`RouterProvider`, but along with some extra Remix goodies: + +- File-based routing (or config-based via [`routes()`][routes-config]) +- Automatic route-based code-spitting via [`route.lazy`][route-lazy] +- `` management via Remix [``][meta]/[``][links] APIs + - you don't _have_ to do this if your app doesn't warrant it - you can still just render and hydrate a `
` with some minor changes to `root.tsx` and `entry.client.tsx` + +SPA Mode tells Remix that you do not plan on running a Remix server at runtime and that you wish to generate a static `index.html` file at build time and you will only use [Client Data](https://remix.run/docs/en/main/guides/client-data) APIs for data loading and mutations. + +The `index.html` is generated from your `root.tsx` route. You **should** include a `HydrateFallback` component in `root.tsx` containing the app shell/initial loading state. The initial "render" to generate the `index.html` will not include any routes deeper than root. This ensures that the `index.html` file can be served/hydrated for paths beyond `/` (i.e., `/about`) if you configure your CDN/server to do so. + +## Usage + +You can opt into SPA Mode by setting `unstable_ssr: false` in your Remix Vite plugin config: + +```js +// vite.config.ts +import { unstable_vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + remix({ + unstable_ssr: false, + }), + ], +}); +``` + +### Development + +In SPA Mode, you develop the same way you would for a traditional Remix SSR app, and you actually use a running Remix dev server in order to enable HMR/HDR: + +```sh +remix vite:dev +``` + +### Production + +When you build your app in SPA Mode, Remix will call the server handler for the `/` route and save the rendered HTML in an `index.html` file alongside your client side assets (by default `build/client/index.html`). + +```sh +remix vite:build +``` + +To run your SPA, you serve your client assets directory via any HTTP server you wish, for example: + +```sh +npx http-server build/client/ +``` + +Or, if you are serving via an `express` server (although at that point you may want to consider just running Remix in SSR mode 😉): + +```js +app.use("/assets", express.static("build/client/assets")); +app.get("*", (req, res, next) => + res.sendFile( + path.join(process.cwd(), "build/client/index.html"), + next + ) +); +``` + +## Notes/Caveats + +- You cannot use server APIs such as `headers`, `loader`, and `action` -- the build will throw an error if you export them + +- You can only export a `HydrateFallback` from your `root.tsx` in SPA Mode -- the build will throw an error if you export one from any other routes. + +- You cannot call `serverLoader`/`serverAction` from your `clientLoader`/`clientAction` methods since there is no running server -- those will throw a runtime error if called + +## Migrating from React Router + +We also expect SPA Mode to be useful in helping folks migrate existing React router apps over to Remix apps (SPA or not!). + +The first step towards this migration is getting your current React Router app running on `vite`, so that you've got whatever plugins you need for your non-JS code (i.e., CSS, SVG, etc.). + +**If you are currently using `BrowserRouter`** + +Once you're using vite, you should be able to drop your `BrowserRouter` app into a catch-all Remix route per the steps in the [this guide][migrating-rr]. + +**If you are currently using `RouterProvider`** + +If you are currently using `RouterProvider`, then the best approach is to move your routes to individual files and load them via `route.lazy`: + +- Name these files according to the Remix file conventions to make the move to Remix (SPA) easier +- Export your route components as a named `Component` export (for RR) and also a `default` export (for eventual use by Remix) + +Once you've got all your routes living in their own files, you can: + +- Move those files over into the Remix `app/` directory +- Enable SPA Mode +- Rename all `loader`/`action` function to `clientLoader`/`clientAction` +- Add a `root.tsx` with a `default` export and a `HydrateFallback` - this replaces the `index.html` file from your React Router app + +[rfc]: https://github.com/remix-run/remix/discussions/7638 +[client-data]: ./client-data +[2.5.0]: https://github.com/remix-run/remix/blob/main/CHANGELOG.md#v250 +[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API +[runtimes]: ../discussion/runtimes +[kent-tweet]: https://twitter.com/kentcdodds/status/1743030378334708017 +[rr-setup]: https://reactrouter.com/en/main/start/tutorial#setup +[routes-config]: ../file-conventions/remix-config#routes +[route-lazy]: https://reactrouter.com/en/main/route/lazy +[meta]: ../components/meta +[links]: ../components/links +[migrating-rr]: https://remix.run/docs/en/main/guides/migrating-react-router-app diff --git a/integration/compiler-test.ts b/integration/compiler-test.ts index dc70fa09be7..a9ff47aa2cf 100644 --- a/integration/compiler-test.ts +++ b/integration/compiler-test.ts @@ -252,7 +252,7 @@ test.describe("compiler", () => { ); let routeModule = await fixture.getBrowserAsset( - fixture.build.assets.routes["routes/built-ins"].module + fixture.build!.assets.routes["routes/built-ins"].module ); // does not include `import bla from "node:path"` in the output bundle expect(routeModule).not.toMatch(/from\s*"path/); @@ -271,7 +271,7 @@ test.describe("compiler", () => { ); let routeModule = await fixture.getBrowserAsset( - fixture.build.assets.routes["routes/built-ins-polyfill"].module + fixture.build!.assets.routes["routes/built-ins-polyfill"].module ); // does not include `import bla from "node:path"` in the output bundle expect(routeModule).not.toMatch(/from\s*"path/); diff --git a/integration/flat-routes-test.ts b/integration/flat-routes-test.ts index 53eae110e1d..e45f4803e31 100644 --- a/integration/flat-routes-test.ts +++ b/integration/flat-routes-test.ts @@ -160,7 +160,7 @@ test.describe("flat routes", () => { } test("allows ignoredRouteFiles to be configured", async () => { - let routeIds = Object.keys(fixture.build.routes); + let routeIds = Object.keys(fixture.build!.routes); expect(routeIds).not.toContain(IGNORED_ROUTE); }); diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 56317b192fd..99baf9396d4 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -51,6 +51,38 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { compiler === "vite" ? "build/server/index.js" : "build/index.js" ) ).href; + + let getBrowserAsset = async (asset: string) => { + return fse.readFile( + path.join(projectDir, "public", asset.replace(/^\//, "")), + "utf8" + ); + }; + + let isSpaMode = + compiler === "vite" && + fse.existsSync(path.join(projectDir, "build/client/index.html")); + + if (isSpaMode) { + return { + projectDir, + build: null, + isSpaMode, + compiler, + requestDocument: () => { + throw new Error("Cannot requestDocument in SPA Mode tests"); + }, + requestData: () => { + throw new Error("Cannot requestData in SPA Mode tests"); + }, + postDocument: () => { + throw new Error("Cannot postDocument in SPA Mode tests"); + }, + getBrowserAsset, + useRemixServe: init.useRemixServe, + }; + } + let app: ServerBuild = await import(buildPath); let handler = createRequestHandler(app, mode || ServerMode.Production); @@ -89,16 +121,10 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { }); }; - let getBrowserAsset = async (asset: string) => { - return fse.readFile( - path.join(projectDir, "public", asset.replace(/^\//, "")), - "utf8" - ); - }; - return { projectDir, build: app, + isSpaMode, compiler, requestDocument, requestData, @@ -175,6 +201,22 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { }); } + if (fixture.isSpaMode) { + return new Promise(async (accept) => { + let port = await getPort(); + let app = express(); + app.use(express.static(path.join(fixture.projectDir, "build/client"))); + app.get("*", (_, res, next) => + res.sendFile( + path.join(process.cwd(), "build/client/index.html"), + next + ) + ); + let server = app.listen(port); + accept({ stop: server.close.bind(server), port }); + }); + } + return new Promise(async (accept) => { let port = await getPort(); let app = express(); @@ -281,7 +323,7 @@ export async function createFixtureProject( at the same time, unless the \`remix.config.js\` file contains a reference to the \`global.INJECTED_FIXTURE_REMIX_CONFIG\` placeholder so it can accept the injected config values. Either move all config values into - \`remix.config.js\` file, or spread the injected config, + \`remix.config.js\` file, or spread the injected config, e.g. \`export default { ...global.INJECTED_FIXTURE_REMIX_CONFIG }\`. `); } diff --git a/integration/spa-mode-test.ts b/integration/spa-mode-test.ts new file mode 100644 index 00000000000..de003833b2b --- /dev/null +++ b/integration/spa-mode-test.ts @@ -0,0 +1,362 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import { createProject, viteBuild } from "./helpers/vite.js"; + +// SSR'd useId value we can assert against pre- and post-hydration +const USE_ID_VALUE = ":R1:"; + +test.describe("SPA Mode", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + compiler: "vite", + files: { + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { unstable_vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix({ unstable_ssr: false })], + }); + `, + "app/root.tsx": js` + import * as React from "react"; + import { Form, Link, Links, Meta, Outlet, Scripts } from "@remix-run/react"; + + export default function Root() { + let id = React.useId(); + return ( + + + + + + +

Root

+
{id}
+ + + + + + ); + } + + export function HydrateFallback() { + const id = React.useId(); + const [hydrated, setHydrated] = React.useState(false); + React.useEffect(() => setHydrated(true), []); + + return ( + + + + + + +

Loading SPA...

+
{id}
+ {hydrated ?

Hydrated

: null} + + + + ); + } + `, + "app/routes/_index.tsx": js` + import * as React from "react"; + import { useLoaderData } from "@remix-run/react"; + + export function meta({ data }) { + return [{ + title: "Index Title: " + data + }]; + } + + export async function clientLoader({ request }) { + if (new URL(request.url).searchParams.has('slow')) { + await new Promise(r => setTimeout(r, 500)); + } + return "Index Loader Data"; + } + + export default function Component() { + let data = useLoaderData(); + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => setMounted(true), []); + + return ( + <> +

Index

+

{data}

+ {!mounted ?

Unmounted

:

Mounted

} + + ); + } + `, + "app/routes/about.tsx": js` + import { useActionData, useLoaderData } from "@remix-run/react"; + + export function meta({ data }) { + return [{ + title: "About Title: " + data + }]; + } + + export function clientLoader() { + return "About Loader Data"; + } + + export function clientAction() { + return "About Action Data"; + } + + export default function Component() { + let data = useLoaderData(); + let actionData = useActionData(); + + return ( + <> +

About

+

{data}

+

{actionData}

+ + ); + } + `, + "app/routes/error.tsx": js` + import { useRouteError } from "@remix-run/react"; + + export async function clientLoader({ serverLoader }) { + await serverLoader(); + return null; + } + + export async function clientAction({ serverAction }) { + await serverAction(); + return null; + } + + export default function Component() { + return

Error

; + } + + export function ErrorBoundary() { + let error = useRouteError(); + return
{error.data}
+ } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.describe("builds", () => { + test("errors on server-only exports", async () => { + let cwd = await createProject({ + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { unstable_vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix({ unstable_ssr: false })], + }); + `, + "app/routes/invalid-exports.tsx": String.raw` + // Invalid exports + export function headers() {} + export function loader() {} + export function action() {} + + // Valid exports + export function clientLoader() {} + export function clientAction() {} + export default function Component() {} + `, + }); + let result = viteBuild({ cwd }); + let stderr = result.stderr.toString("utf8"); + expect(stderr).toMatch( + "SPA Mode: 3 invalid route export(s) in `routes/invalid-exports.tsx`: " + + "`headers`, `loader`, `action`. See https://remix.run/guides/spa-mode " + + "for more information." + ); + }); + + test("errors on HydrateFallback export from non-root route", async () => { + let cwd = await createProject({ + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { unstable_vitePlugin as remix } from "@remix-run/dev"; + + export default defineConfig({ + plugins: [remix({ unstable_ssr: false })], + }); + `, + "app/routes/invalid-exports.tsx": String.raw` + // Invalid exports + export function HydrateFallback() {} + + // Valid exports + export function clientLoader() {} + export function clientAction() {} + export default function Component() {} + `, + }); + let result = viteBuild({ cwd }); + let stderr = result.stderr.toString("utf8"); + expect(stderr).toMatch( + "SPA Mode: Invalid `HydrateFallback` export found in `routes/invalid-exports.tsx`. " + + "`HydrateFallback` is only permitted on the root route in SPA Mode. " + + "See https://remix.run/guides/spa-mode for more information." + ); + }); + }); + + test.describe("javascript disabled", () => { + test.use({ javaScriptEnabled: false }); + + test("renders the root HydrateFallback", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("[data-loading]").textContent()).toBe( + "Loading SPA..." + ); + expect(await page.locator("[data-use-id]").textContent()).toBe( + USE_ID_VALUE + ); + expect(await page.locator("title").textContent()).toBe( + "Index Title: undefined" + ); + }); + }); + + test.describe("javascript enabled", () => { + test("hydrates", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + expect(await page.locator("[data-loader-data]").textContent()).toBe( + "Index Loader Data" + ); + expect(await page.locator("[data-mounted]").textContent()).toBe( + "Mounted" + ); + expect(await page.locator("title").textContent()).toBe( + "Index Title: Index Loader Data" + ); + }); + + test("hydrates a proper useId value", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/?slow"); + + // We should hydrate the same useId value in HydrateFallback that we + // rendered on the server above + await page.waitForSelector("[data-hydrated]"); + expect(await page.locator("[data-use-id]").textContent()).toBe( + USE_ID_VALUE + ); + + // Once hydrated, we should get a different useId value from the root component + await page.waitForSelector("[data-route]"); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + expect(await page.locator("[data-use-id]").textContent()).not.toBe( + USE_ID_VALUE + ); + }); + + test("navigates and calls loaders", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + + await app.clickLink("/about"); + await page.waitForSelector('[data-route]:has-text("About")'); + expect(await page.locator("[data-route]").textContent()).toBe("About"); + expect(await page.locator("[data-loader-data]").textContent()).toBe( + "About Loader Data" + ); + expect(await page.locator("title").textContent()).toBe( + "About Title: About Loader Data" + ); + }); + + test("navigates and calls actions/loaders", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + + await app.clickSubmitButton("/about"); + await page.waitForSelector('[data-route]:has-text("About")'); + expect(await page.locator("[data-route]").textContent()).toBe("About"); + expect(await page.locator("[data-action-data]").textContent()).toBe( + "About Action Data" + ); + expect(await page.locator("[data-loader-data]").textContent()).toBe( + "About Loader Data" + ); + expect(await page.locator("title").textContent()).toBe( + "About Title: About Loader Data" + ); + }); + + test("errors if you call serverLoader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + + await app.clickLink("/error"); + await page.waitForSelector("[data-error]"); + expect(await page.locator("[data-error]").textContent()).toBe( + 'Error: You cannot call serverLoader() in SPA Mode (routeId: "routes/error")' + ); + }); + + test("errors if you call serverAction", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await page.locator("[data-route]").textContent()).toBe("Index"); + + await app.clickSubmitButton("/error"); + await page.waitForSelector("[data-error]"); + expect(await page.locator("[data-error]").textContent()).toBe( + 'Error: You cannot call serverAction() in SPA Mode (routeId: "routes/error")' + ); + }); + }); +}); diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts index 1115c7bb6db..73dc8ff944d 100644 --- a/packages/remix-dev/__tests__/readConfig-test.ts +++ b/packages/remix-dev/__tests__/readConfig-test.ts @@ -39,6 +39,7 @@ describe("readConfig", () => { "v3_fetcherPersist": false, "v3_relativeSplatPath": false, }, + "isSpaMode": false, "mdx": undefined, "postcss": true, "publicPath": "/build/", diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index bb78f13005f..a3608263128 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -161,8 +161,8 @@ export interface AppConfig { serverPlatform?: ServerPlatform; /** - * Whether to support Tailwind functions and directives in CSS files if `tailwindcss` is installed. - * Defaults to `true`. + * Whether to support Tailwind functions and directives in CSS files if + * `tailwindcss` is installed. Defaults to `true`. */ tailwind?: boolean; @@ -174,7 +174,8 @@ export interface AppConfig { ignoredRouteFiles?: string[]; /** - * A function for defining custom directories to watch while running `remix dev`, in addition to `appDirectory`. + * A function for defining custom directories to watch while running `remix dev`, + * in addition to `appDirectory`. */ watchPaths?: | string @@ -337,6 +338,14 @@ export interface RemixConfig { */ serverPlatform: ServerPlatform; + /** + * Enable SPA Mode. Default's to `false`. + * + * This is the inverse of the user-level `unstable_ssr` config and used throughout + * the codebase to avoid confusion with Vite's `ssr` config + */ + isSpaMode: boolean; + /** * Whether to support Tailwind functions and directives in CSS files if `tailwindcss` is installed. * Defaults to `true`. @@ -407,9 +416,11 @@ export async function resolveConfig( { rootDirectory, serverMode = ServerMode.Production, + isSpaMode = false, }: { rootDirectory: string; serverMode?: ServerMode; + isSpaMode?: boolean; } ): Promise { if (!isValidServerMode(serverMode)) { @@ -462,7 +473,17 @@ export async function resolveConfig( let pkgJson = await PackageJson.load(rootDirectory); let deps = pkgJson.content.dependencies ?? {}; - if (userEntryServerFile) { + if (isSpaMode) { + // This is a super-simple default since we don't need streaming in SPA Mode. + // We can include this in a remix-spa template, but right now `npx remix reveal` + // will still expose the streaming template since that command doesn't have + // access to the `unstable_ssr:false` flag in the vite config (the streaming template + // works just fine so maybe instea dof having this we _only have this version + // in the template...). We let users manage an entry.server file in SPA Mode + // so they can de ide if they want to hydrate the full document or just an + // embedded `
` or whatever. + entryServerFile = "entry.server.spa.tsx"; + } else if (userEntryServerFile) { entryServerFile = userEntryServerFile; } else { let serverRuntime = deps["@remix-run/deno"] @@ -646,6 +667,7 @@ export async function resolveConfig( serverMode, serverModuleFormat, serverNodeBuiltinsPolyfill, + isSpaMode, browserNodeBuiltinsPolyfill, serverPlatform, mdx, diff --git a/packages/remix-dev/config/defaults/entry.server.spa.tsx b/packages/remix-dev/config/defaults/entry.server.spa.tsx new file mode 100644 index 00000000000..3f63a9ab70e --- /dev/null +++ b/packages/remix-dev/config/defaults/entry.server.spa.tsx @@ -0,0 +1,19 @@ +import type { AppLoadContext, EntryContext } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import * as React from "react"; +import { renderToString } from "react-dom/server"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + let html = renderToString( + + ); + return new Response(html, { + headers: { "Content-Type": "text/html" }, + status: responseStatusCode, + }); +} diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index a13494d8fb0..644d7af9e12 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -3,6 +3,7 @@ import type * as Vite from "vite"; import { type BinaryLike, createHash } from "node:crypto"; import * as path from "node:path"; +import * as url from "node:url"; import * as fse from "fs-extra"; import babel from "@babel/core"; import { @@ -114,6 +115,13 @@ export type RemixVitePluginOptions = RemixConfigJsdocOverrides & * bundle's directory name within the server build directory. */ unstable_serverBundles?: ServerBundlesFunction; + /** + * Enable server-side rendering for your application. Disable to use Remix in + * "SPA Mode", which will request the `/` path at build-time and save it as + * an `index.html` file with your assets so your application can be deployed + * as a SPA without server-rendering. Default's to `true`. + */ + unstable_ssr?: boolean; }; export type ResolvedRemixVitePluginConfig = Pick< @@ -124,6 +132,7 @@ export type ResolvedRemixVitePluginConfig = Pick< | "entryClientFilePath" | "entryServerFilePath" | "future" + | "isSpaMode" | "publicPath" | "relativeAssetsBuildDirectory" | "routes" @@ -370,6 +379,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { serverBuildDirectory: "build/server", serverBuildFile: "index.js", publicPath: "/", + unstable_ssr: true, } as const satisfies Partial; let pluginConfig = { @@ -380,9 +390,14 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { let rootDirectory = viteUserConfig.root ?? process.env.REMIX_ROOT ?? process.cwd(); + let isSpaMode = pluginConfig.unstable_ssr === false; + let resolvedRemixConfig = await resolveConfig( pick(pluginConfig, supportedRemixConfigKeys), - { rootDirectory } + { + rootDirectory, + isSpaMode, + } ); // Only select the Remix config options that the Vite plugin uses @@ -405,6 +420,18 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { ...resolveServerBuildConfig(), }; + // Log warning for incompatible vite config flags + if (isSpaMode && unstable_serverBundles) { + console.warn( + colors.yellow( + colors.bold("⚠️ SPA Mode: ") + + "the `unstable_serverBundles` config is invalid with " + + "`unstable_ssr:false` and will be ignored`" + ) + ); + unstable_serverBundles = undefined; + } + return { appDirectory, rootDirectory, @@ -417,6 +444,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { serverBuildFile, serverBundles: unstable_serverBundles, serverModuleFormat, + isSpaMode, relativeAssetsBuildDirectory, future, }; @@ -445,6 +473,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { pluginConfig.relativeAssetsBuildDirectory )}; export const future = ${JSON.stringify(pluginConfig.future)}; + export const isSpaMode = ${pluginConfig.isSpaMode === true}; export const publicPath = ${JSON.stringify(pluginConfig.publicPath)}; export const entry = { module: entryServer }; export const routes = { @@ -896,8 +925,12 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { invariant(cachedPluginConfig); invariant(viteConfig); - let { assetsBuildDirectory, serverBuildDirectory, rootDirectory } = - cachedPluginConfig; + let { + assetsBuildDirectory, + serverBuildDirectory, + serverBuildFile, + rootDirectory, + } = cachedPluginConfig; let ssrViteManifest = await loadViteManifest(serverBuildDirectory); let clientViteManifest = await loadViteManifest(assetsBuildDirectory); @@ -957,6 +990,15 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { ].join("\n") ); } + + if (cachedPluginConfig.isSpaMode) { + await handleSpaMode( + path.join(rootDirectory, serverBuildDirectory), + serverBuildFile, + assetsBuildDirectory, + viteConfig + ); + } }, }, async buildEnd() { @@ -1131,6 +1173,34 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { throw Error(message); } + if (pluginConfig.isSpaMode) { + let serverOnlyExports = esModuleLexer(code)[1] + .map((exp) => exp.n) + .filter((exp) => SERVER_ONLY_EXPORTS.includes(exp)); + if (serverOnlyExports.length > 0) { + let str = serverOnlyExports.map((e) => `\`${e}\``).join(", "); + let message = + `SPA Mode: ${serverOnlyExports.length} invalid route export(s) in ` + + `\`${route.file}\`: ${str}. See https://remix.run/guides/spa-mode ` + + `for more information.`; + throw Error(message); + } + + if (route.id !== "root") { + let hasHydrateFallback = esModuleLexer(code)[1] + .map((exp) => exp.n) + .some((exp) => exp === "HydrateFallback"); + if (hasHydrateFallback) { + let message = + `SPA Mode: Invalid \`HydrateFallback\` export found in ` + + `\`${route.file}\`. \`HydrateFallback\` is only permitted on ` + + `the root route in SPA Mode. See https://remix.run/guides/spa-mode ` + + `for more information.`; + throw Error(message); + } + } + } + return { code: removeExports(code, SERVER_ONLY_EXPORTS), map: null, @@ -1437,3 +1507,33 @@ async function getRouteMetadata( }; return info; } + +async function handleSpaMode( + serverBuildDirectoryPath: string, + serverBuildFile: string, + assetsBuildDirectory: string, + viteConfig: Vite.ResolvedConfig +) { + // Create a handler and call it for the `/` path - rendering down to the + // proper HydrateFallback ... or not! Maybe they have a static landing page + // generated from routes/_index.tsx. + let serverBuildPath = path.join(serverBuildDirectoryPath, serverBuildFile); + let build = await import(url.pathToFileURL(serverBuildPath).toString()); + let { createRequestHandler: createHandler } = await import("@remix-run/node"); + let handler = createHandler(build, viteConfig.mode); + let response = await handler(new Request("http://localhost/")); + invariant(response.status === 200, "Error generating the index.html file"); + + // Write out the index.html file for the SPA + let htmlPath = path.join(assetsBuildDirectory, "index.html"); + await fse.writeFile(htmlPath, await response.text()); + + viteConfig.logger.info( + "SPA Mode: index.html has been written to your " + + colors.bold(path.relative(process.cwd(), assetsBuildDirectory)) + + " directory" + ); + + // Cleanup - we no longer need the server build assets + fse.removeSync(serverBuildDirectoryPath); +} diff --git a/packages/remix-dev/vite/static/refresh-utils.cjs b/packages/remix-dev/vite/static/refresh-utils.cjs index ec8a3484557..e7745e04b12 100644 --- a/packages/remix-dev/vite/static/refresh-utils.cjs +++ b/packages/remix-dev/vite/static/refresh-utils.cjs @@ -48,7 +48,8 @@ const enqueueUpdate = debounce(async () => { needsRevalidation, manifest.routes, window.__remixRouteModules, - window.__remixContext.future + window.__remixContext.future, + window.__remixContext.isSpaMode ); __remixRouter._internalSetRoutes(routes); routeUpdates.clear(); diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 9718a29289a..4de97c16476 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -27,6 +27,7 @@ declare global { state: HydrationState; criticalCss?: string; future: FutureConfig; + isSpaMode: boolean; // The number of active deferred keys rendered on the server a?: number; dev?: { @@ -155,7 +156,8 @@ if (import.meta && import.meta.hot) { assetsManifest.routes, window.__remixRouteModules, window.__remixContext.state, - window.__remixContext.future + window.__remixContext.future, + window.__remixContext.isSpaMode ); // This is temporary API and will be more granular before release @@ -198,7 +200,10 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { // towards determining the route matches. let initialPathname = window.__remixContext.url; let hydratedPathname = window.location.pathname; - if (initialPathname !== hydratedPathname) { + if ( + initialPathname !== hydratedPathname && + !window.__remixContext.isSpaMode + ) { let errorMsg = `Initial URL (${initialPathname}) does not match URL at time of hydration ` + `(${hydratedPathname}), reloading page...`; @@ -213,48 +218,56 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { window.__remixManifest.routes, window.__remixRouteModules, window.__remixContext.state, - window.__remixContext.future + window.__remixContext.future, + window.__remixContext.isSpaMode ); - // Create a shallow clone of `loaderData` we can mutate for partial hydration. - // When a route exports a `clientLoader` and a `HydrateFallback`, the SSR will - // render the fallback so we need the client to do the same for hydration. - // The server loader data has already been exposed to these route `clientLoader`'s - // in `createClientRoutes` above, so we need to clear out the version we pass to - // `createBrowserRouter` so it initializes and runs the client loaders. - let hydrationData = { - ...window.__remixContext.state, - loaderData: { ...window.__remixContext.state.loaderData }, - }; - let initialMatches = matchRoutes(routes, window.location); - if (initialMatches) { - for (let match of initialMatches) { - let routeId = match.route.id; - let route = window.__remixRouteModules[routeId]; - let manifestRoute = window.__remixManifest.routes[routeId]; - // Clear out the loaderData to avoid rendering the route component when the - // route opted into clientLoader hydration and either: - // * gave us a HydrateFallback - // * or doesn't have a server loader and we have no data to render - if ( - route && - shouldHydrateRouteLoader(manifestRoute, route) && - (route.HydrateFallback || !manifestRoute.hasLoader) - ) { - hydrationData.loaderData[routeId] = undefined; - } else if (manifestRoute && !manifestRoute.hasLoader) { - // Since every Remix route gets a `loader` on the client side to load - // the route JS module, we need to add a `null` value to `loaderData` - // for any routes that don't have server loaders so our partial - // hydration logic doesn't kick off the route module loaders during - // hydration - hydrationData.loaderData[routeId] = null; + let hydrationData = undefined; + if (!window.__remixContext.isSpaMode) { + // Create a shallow clone of `loaderData` we can mutate for partial hydration. + // When a route exports a `clientLoader` and a `HydrateFallback`, the SSR will + // render the fallback so we need the client to do the same for hydration. + // The server loader data has already been exposed to these route `clientLoader`'s + // in `createClientRoutes` above, so we need to clear out the version we pass to + // `createBrowserRouter` so it initializes and runs the client loaders. + hydrationData = { + ...window.__remixContext.state, + loaderData: { ...window.__remixContext.state.loaderData }, + }; + let initialMatches = matchRoutes(routes, window.location); + if (initialMatches) { + for (let match of initialMatches) { + let routeId = match.route.id; + let route = window.__remixRouteModules[routeId]; + let manifestRoute = window.__remixManifest.routes[routeId]; + // Clear out the loaderData to avoid rendering the route component when the + // route opted into clientLoader hydration and either: + // * gave us a HydrateFallback + // * or doesn't have a server loader and we have no data to render + if ( + route && + shouldHydrateRouteLoader( + manifestRoute, + route, + window.__remixContext.isSpaMode + ) && + (route.HydrateFallback || !manifestRoute.hasLoader) + ) { + hydrationData.loaderData[routeId] = undefined; + } else if (manifestRoute && !manifestRoute.hasLoader) { + // Since every Remix route gets a `loader` on the client side to load + // the route JS module, we need to add a `null` value to `loaderData` + // for any routes that don't have server loaders so our partial + // hydration logic doesn't kick off the route module loaders during + // hydration + hydrationData.loaderData[routeId] = null; + } } } - } - if (hydrationData && hydrationData.errors) { - hydrationData.errors = deserializeErrors(hydrationData.errors); + if (hydrationData && hydrationData.errors) { + hydrationData.errors = deserializeErrors(hydrationData.errors); + } } // We don't use createBrowserRouter here because we need fine-grained control @@ -336,6 +349,7 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { routeModules: window.__remixRouteModules, future: window.__remixContext.future, criticalCss, + isSpaMode: window.__remixContext.isSpaMode, }} > diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 94aa83fde47..c80696b90d1 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -605,12 +605,18 @@ export type ScriptProps = Omit< * @see https://remix.run/components/scripts */ export function Scripts(props: ScriptProps) { - let { manifest, serverHandoffString, abortDelay, serializeError } = + let { manifest, serverHandoffString, abortDelay, serializeError, isSpaMode } = useRemixContext(); let { router, static: isStatic, staticContext } = useDataRouterContext(); - let { matches } = useDataRouterStateContext(); + let { matches: dontUseTheseMatches } = useDataRouterStateContext(); let navigation = useNavigation(); + // Use these `matches` instead :) + // In SPA Mode we only want to import root on the critical path, since we + // want the generated HTML file to be able to be hydrated at non-/ paths as + // well. This lets the router handle initial match loads via lazy(). + let matches = isSpaMode ? [dontUseTheseMatches[0]] : dontUseTheseMatches; + React.useEffect(() => { isHydrated = true; }, []); diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts index 93b93efaa75..a3366cc451d 100644 --- a/packages/remix-react/entry.ts +++ b/packages/remix-react/entry.ts @@ -15,6 +15,7 @@ export interface RemixContextObject { criticalCss?: string; serverHandoffString?: string; future: FutureConfig; + isSpaMode: boolean; abortDelay?: number; serializeError?(error: Error): SerializedError; } diff --git a/packages/remix-react/links.ts b/packages/remix-react/links.ts index c8d3a10e292..d93f420bbfe 100644 --- a/packages/remix-react/links.ts +++ b/packages/remix-react/links.ts @@ -217,7 +217,7 @@ export function getKeyedLinksForMatches( let route = manifest.routes[match.route.id]; return [ route.css ? route.css.map((href) => ({ rel: "stylesheet", href })) : [], - module.links?.() || [], + module?.links?.() || [], ]; }) .flat(2); diff --git a/packages/remix-react/routeModules.ts b/packages/remix-react/routeModules.ts index 0e9f2994475..5be0ac83df9 100644 --- a/packages/remix-react/routeModules.ts +++ b/packages/remix-react/routeModules.ts @@ -16,7 +16,7 @@ import type { LinkDescriptor } from "./links"; import type { EntryRoute } from "./routes"; export interface RouteModules { - [routeId: string]: RouteModule; + [routeId: string]: RouteModule | undefined; } export interface RouteModule { @@ -169,7 +169,7 @@ export async function loadRouteModule( routeModulesCache: RouteModules ): Promise { if (route.id in routeModulesCache) { - return routeModulesCache[route.id]; + return routeModulesCache[route.id] as RouteModule; } try { @@ -181,6 +181,17 @@ export async function loadRouteModule( // asset we're trying to import! Reload from the server and the user // (should) get the new manifest--unless the developer purged the static // assets, the manifest path, but not the documents 😬 + if ( + window.__remixContext.isSpaMode && + typeof import.meta.hot !== "undefined" + ) { + // In SPA Mode (which implies vite) we don't want to perform a hard reload + // on dev-time errors since it's a vite compilation error and a reload is + // just going to fail with the same issue. Let the UI bubble to the error + // boundary and let them see the error in the overlay or the dev server log + console.error(`Error loading route module \`${route.module}\`:`, error); + throw error; + } window.location.reload(); return new Promise(() => { // check out of this hook cause the DJs never gonna re[s]olve this diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index be04b36db7f..0170c8edc04 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -24,6 +24,7 @@ import type { FutureConfig } from "./entry"; import { prefetchStyleLinks } from "./links"; import { RemixRootDefaultErrorBoundary } from "./errorBoundaries"; import { RemixRootDefaultHydrateFallback } from "./fallback"; +import invariant from "./invariant"; export interface RouteManifest { [routeId: string]: Route; @@ -71,22 +72,30 @@ export function createServerRoutes( manifest: RouteManifest, routeModules: RouteModules, future: FutureConfig, + isSpaMode: boolean, parentId: string = "", routesByParentId: Record< string, Omit[] - > = groupRoutesByParentId(manifest) + > = groupRoutesByParentId(manifest), + spaModeLazyPromise = Promise.resolve({ Component: () => null }) ): DataRouteObject[] { return (routesByParentId[parentId] || []).map((route) => { let routeModule = routeModules[route.id]; + invariant( + routeModule, + "No `routeModule` available to create server routes" + ); let dataRoute: DataRouteObject = { caseSensitive: route.caseSensitive, Component: getRouteModuleComponent(routeModule), - HydrateFallback: routeModule.HydrateFallback - ? routeModule.HydrateFallback - : route.id === "root" - ? RemixRootDefaultHydrateFallback - : undefined, + // HydrateFallback can only exist on the root route in SPA Mode + HydrateFallback: + routeModule.HydrateFallback && (!isSpaMode || route.id === "root") + ? routeModule.HydrateFallback + : route.id === "root" + ? RemixRootDefaultHydrateFallback + : undefined, ErrorBoundary: routeModule.ErrorBoundary ? routeModule.ErrorBoundary : route.id === "root" @@ -95,7 +104,12 @@ export function createServerRoutes( id: route.id, index: route.index, path: route.path, - handle: routeModules[route.id].handle, + handle: routeModule.handle, + // For SPA Mode, all routes are lazy except root. We don't need a full + // implementation here though - just need a `lazy` prop to tell the RR + // rendering where to stop + lazy: + isSpaMode && route.id !== "root" ? () => spaModeLazyPromise : undefined, // For partial hydration rendering, we need to indicate when the route // has a loader/clientLoader, but it won't ever be called during the static // render, so just give it a no-op function so we can render down to the @@ -109,8 +123,10 @@ export function createServerRoutes( manifest, routeModules, future, + isSpaMode, route.id, - routesByParentId + routesByParentId, + spaModeLazyPromise ); if (children.length > 0) dataRoute.children = children; return dataRoute; @@ -122,26 +138,56 @@ export function createClientRoutesWithHMRRevalidationOptOut( manifest: RouteManifest, routeModulesCache: RouteModules, initialState: HydrationState, - future: FutureConfig + future: FutureConfig, + isSpaMode: boolean ) { return createClientRoutes( manifest, routeModulesCache, initialState, future, + isSpaMode, "", groupRoutesByParentId(manifest), needsRevalidation ); } -function getNoServerHandlerError(type: "action" | "loader", routeId: string) { +function preventInvalidServerHandlerCall( + type: "action" | "loader", + route: Omit, + isSpaMode: boolean +) { + if (isSpaMode) { + let fn = type === "action" ? "serverAction()" : "serverLoader()"; + let msg = `You cannot call ${fn} in SPA Mode (routeId: "${route.id}")`; + console.error(msg); + throw new ErrorResponse(400, "Bad Request", new Error(msg), true); + } + let fn = type === "action" ? "serverAction()" : "serverLoader()"; let msg = `You are trying to call ${fn} on a route that does not have a server ` + - `${type} (routeId: "${routeId}")`; + `${type} (routeId: "${route.id}")`; + if ( + (type === "loader" && !route.hasLoader) || + (type === "action" && !route.hasAction) + ) { + console.error(msg); + throw new ErrorResponse(400, "Bad Request", new Error(msg), true); + } +} + +function noActionDefinedError( + type: "action" | "clientAction", + routeId: string +) { + let article = type === "clientAction" ? "a" : "an"; + let msg = + `Route "${routeId}" does not have ${article} ${type}, but you are trying to ` + + `submit to it. To fix this, please add ${article} \`${type}\` function to the route`; console.error(msg); - throw new ErrorResponse(400, "Bad Request", new Error(msg), true); + throw new ErrorResponse(405, "Method Not Allowed", new Error(msg), true); } export function createClientRoutes( @@ -149,6 +195,7 @@ export function createClientRoutes( routeModulesCache: RouteModules, initialState: HydrationState, future: FutureConfig, + isSpaMode: boolean, parentId: string = "", routesByParentId: Record< string, @@ -166,28 +213,21 @@ export function createClientRoutes( async function fetchServerAction(request: Request) { if (!route.hasAction) { - let msg = - `Route "${route.id}" does not have an action, but you are trying ` + - `to submit to it. To fix this, please add an \`action\` function to the route`; - console.error(msg); - throw new ErrorResponse( - 405, - "Method Not Allowed", - new Error(msg), - true - ); + throw noActionDefinedError("action", route.id); } - return fetchServerHandler(request, route); } async function prefetchStylesAndCallHandler( handler: () => Promise ) { - // Only prefetch links if we've been loaded into the cache, route.lazy - // will handle initial loads - let linkPrefetchPromise = routeModulesCache[route.id] - ? prefetchStyleLinks(route, routeModulesCache[route.id]) + // Only prefetch links if we exist in the routeModulesCache (critical modules + // and navigating back to pages previously loaded via route.lazy). Initial + // execution of route.lazy (when the module is not in the cache) will handle + // prefetching style links via loadRouteModuleWithBlockingLinks. + let cachedModule = routeModulesCache[route.id]; + let linkPrefetchPromise = cachedModule + ? prefetchStyleLinks(route, cachedModule) : Promise.resolve(); try { return handler(); @@ -207,11 +247,13 @@ export function createClientRoutes( Object.assign(dataRoute, { ...dataRoute, Component: getRouteModuleComponent(routeModule), - HydrateFallback: routeModule.HydrateFallback - ? routeModule.HydrateFallback - : route.id === "root" - ? RemixRootDefaultHydrateFallback - : undefined, + // HydrateFallback can only exist on the root route in SPA Mode + HydrateFallback: + routeModule.HydrateFallback && (!isSpaMode || route.id === "root") + ? routeModule.HydrateFallback + : route.id === "root" + ? RemixRootDefaultHydrateFallback + : undefined, ErrorBoundary: routeModule.ErrorBoundary ? routeModule.ErrorBoundary : route.id === "root" @@ -236,7 +278,12 @@ export function createClientRoutes( dataRoute.loader = async ({ request, params }: LoaderFunctionArgs) => { try { let result = await prefetchStylesAndCallHandler(async () => { + invariant( + routeModule, + "No `routeModule` available for critical-route loader" + ); if (!routeModule.clientLoader) { + if (isSpaMode) return null; // Call the server when no client loader exists return fetchServerLoader(request); } @@ -245,9 +292,7 @@ export function createClientRoutes( request, params, async serverLoader() { - if (!route.hasLoader) { - throw getNoServerHandlerError("loader", route.id); - } + preventInvalidServerHandlerCall("loader", route, isSpaMode); // On the first call, resolve with the server result if (isHydrationRequest) { @@ -273,11 +318,22 @@ export function createClientRoutes( }; // Let React Router know whether to run this on hydration - dataRoute.loader.hydrate = shouldHydrateRouteLoader(route, routeModule); + dataRoute.loader.hydrate = shouldHydrateRouteLoader( + route, + routeModule, + isSpaMode + ); dataRoute.action = ({ request, params }: ActionFunctionArgs) => { return prefetchStylesAndCallHandler(async () => { + invariant( + routeModule, + "No `routeModule` available for critical-route action" + ); if (!routeModule.clientAction) { + if (isSpaMode) { + throw noActionDefinedError("clientAction", route.id); + } return fetchServerAction(request); } @@ -285,9 +341,7 @@ export function createClientRoutes( request, params, async serverAction() { - if (!route.hasAction) { - throw getNoServerHandlerError("action", route.id); - } + preventInvalidServerHandlerCall("action", route, isSpaMode); let result = await fetchServerAction(request); let unwrapped = await unwrapServerResponse(result); return unwrapped; @@ -301,11 +355,19 @@ export function createClientRoutes( // loader/action as static props on the route if (!route.hasClientLoader) { dataRoute.loader = ({ request }: LoaderFunctionArgs) => - prefetchStylesAndCallHandler(() => fetchServerLoader(request)); + prefetchStylesAndCallHandler(() => { + if (isSpaMode) return Promise.resolve(null); + return fetchServerLoader(request); + }); } if (!route.hasClientAction) { dataRoute.action = ({ request }: ActionFunctionArgs) => - prefetchStylesAndCallHandler(() => fetchServerAction(request)); + prefetchStylesAndCallHandler(() => { + if (isSpaMode) { + throw noActionDefinedError("clientAction", route.id); + } + return fetchServerAction(request); + }); } // Load all other modules via route.lazy() @@ -322,9 +384,7 @@ export function createClientRoutes( clientLoader({ ...args, async serverLoader() { - if (!route.hasLoader) { - throw getNoServerHandlerError("loader", route.id); - } + preventInvalidServerHandlerCall("loader", route, isSpaMode); let response = await fetchServerLoader(args.request); let result = await unwrapServerResponse(response); return result; @@ -338,9 +398,7 @@ export function createClientRoutes( clientAction({ ...args, async serverAction() { - if (!route.hasAction) { - throw getNoServerHandlerError("action", route.id); - } + preventInvalidServerHandlerCall("action", route, isSpaMode); let response = await fetchServerAction(args.request); let result = await unwrapServerResponse(response); return result; @@ -373,6 +431,7 @@ export function createClientRoutes( routeModulesCache, initialState, future, + isSpaMode, route.id, routesByParentId, needsRevalidation @@ -497,10 +556,12 @@ function getRouteModuleComponent(routeModule: RouteModule) { export function shouldHydrateRouteLoader( route: EntryRoute, - routeModule: RouteModule + routeModule: RouteModule, + isSpaMode: boolean ) { return ( - routeModule.clientLoader != null && - (routeModule.clientLoader.hydrate === true || route.hasLoader !== true) + (isSpaMode && route.id !== "root") || + (routeModule.clientLoader != null && + (routeModule.clientLoader.hydrate === true || route.hasLoader !== true)) ); } diff --git a/packages/remix-react/server.tsx b/packages/remix-react/server.tsx index 69cf0b7991e..08630ec5bed 100644 --- a/packages/remix-react/server.tsx +++ b/packages/remix-react/server.tsx @@ -34,7 +34,8 @@ export function RemixServer({ let routes = createServerRoutes( manifest.routes, routeModules, - context.future + context.future, + context.isSpaMode ); // Create a shallow clone of `loaderData` we can mutate for partial hydration. @@ -55,7 +56,7 @@ export function RemixServer({ // * or doesn't have a server loader and we have no data to render if ( route && - shouldHydrateRouteLoader(manifestRoute, route) && + shouldHydrateRouteLoader(manifestRoute, route, context.isSpaMode) && (route.HydrateFallback || !manifestRoute.hasLoader) ) { context.staticHandlerContext.loaderData[routeId] = undefined; @@ -77,6 +78,7 @@ export function RemixServer({ criticalCss, serverHandoffString, future: context.future, + isSpaMode: context.isSpaMode, serializeError: context.serializeError, abortDelay, }} diff --git a/packages/remix-server-runtime/build.ts b/packages/remix-server-runtime/build.ts index af3626fce59..fee433ebad8 100644 --- a/packages/remix-server-runtime/build.ts +++ b/packages/remix-server-runtime/build.ts @@ -16,6 +16,7 @@ export interface ServerBuild { publicPath: string; assetsBuildDirectory: string; future: FutureConfig; + isSpaMode: boolean; } export interface HandleDocumentRequestFunction { diff --git a/packages/remix-server-runtime/entry.ts b/packages/remix-server-runtime/entry.ts index 6b0b0799322..7c6b4916b96 100644 --- a/packages/remix-server-runtime/entry.ts +++ b/packages/remix-server-runtime/entry.ts @@ -11,6 +11,7 @@ export interface EntryContext { serverHandoffString?: string; staticHandlerContext: StaticHandlerContext; future: FutureConfig; + isSpaMode: boolean; serializeError(error: Error): SerializedError; } diff --git a/packages/remix-server-runtime/routeModules.ts b/packages/remix-server-runtime/routeModules.ts index 71c838d397f..7a027b9fc8d 100644 --- a/packages/remix-server-runtime/routeModules.ts +++ b/packages/remix-server-runtime/routeModules.ts @@ -13,7 +13,7 @@ import type { LinkDescriptor } from "./links"; import type { SerializeFrom } from "./serialize"; export interface RouteModules { - [routeId: string]: RouteModule; + [routeId: string]: RouteModule | undefined; } /** diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 01a19a31aa7..e205828dc02 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -300,8 +300,10 @@ async function handleDocumentRequestRR( errors: serializeErrors(context.errors, serverMode), }, future: build.future, + isSpaMode: build.isSpaMode, }), future: build.future, + isSpaMode: build.isSpaMode, serializeError: (err) => serializeError(err, serverMode), }; @@ -341,6 +343,7 @@ async function handleDocumentRequestRR( errors: serializeErrors(context.errors, serverMode), }, future: build.future, + isSpaMode: build.isSpaMode, }), }; diff --git a/packages/remix-server-runtime/serverHandoff.ts b/packages/remix-server-runtime/serverHandoff.ts index 85aed8e376f..6d5dd252b3b 100644 --- a/packages/remix-server-runtime/serverHandoff.ts +++ b/packages/remix-server-runtime/serverHandoff.ts @@ -22,6 +22,7 @@ export function createServerHandoffString(serverHandoff: { criticalCss?: string; url: string; future: FutureConfig; + isSpaMode: boolean; }): string { // Uses faster alternative of jsesc to escape data returned from the loaders. // This string is inserted directly into the HTML in the `` element. diff --git a/packages/remix-testing/create-remix-stub.tsx b/packages/remix-testing/create-remix-stub.tsx index b5fd4222e81..e9f763256a1 100644 --- a/packages/remix-testing/create-remix-stub.tsx +++ b/packages/remix-testing/create-remix-stub.tsx @@ -114,6 +114,7 @@ export function createRemixStub( version: "", }, routeModules: {}, + isSpaMode: false, }; // Update the routes to include context in the loader/action and populate diff --git a/templates/spa/.eslintrc.cjs b/templates/spa/.eslintrc.cjs new file mode 100644 index 00000000000..8f2bbcd8af9 --- /dev/null +++ b/templates/spa/.eslintrc.cjs @@ -0,0 +1,83 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + + // Base config + extends: ["eslint:recommended"], + + overrides: [ + // React + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + "import/resolver": { + typescript: {}, + }, + }, + }, + + // Typescript + { + files: ["**/*.{ts,tsx}"], + plugins: ["@typescript-eslint", "import"], + parser: "@typescript-eslint/parser", + settings: { + "import/internal-regex": "^~/", + "import/resolver": { + node: { + extensions: [".ts", ".tsx"], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + extends: [ + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + ], + }, + + // Node + { + files: [".eslintrc.js"], + env: { + node: true, + }, + }, + ], +}; diff --git a/templates/spa/.gitignore b/templates/spa/.gitignore new file mode 100644 index 00000000000..3f7bf98da3e --- /dev/null +++ b/templates/spa/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/templates/spa/README.md b/templates/spa/README.md new file mode 100644 index 00000000000..caf0f5bc15e --- /dev/null +++ b/templates/spa/README.md @@ -0,0 +1,37 @@ +# templates/spa + +This template leverages [Remix SPA Mode](https://remix.run/docs/en/main/guides/spa-mode) to build your app as a Single-Page Application using [Client Data](https://remix.run/docs/en/main/guides/client-data) for all of you data loads and mutations. + +⚠️ This is built on top of the Remix Vite template. Remix support for Vite is currently unstable and not recommended for production. + +📖 See the [Remix Vite docs][remix-vite-docs] for details on supported features. + +## Setup + +```shellscript +npx create-remix@latest --template remix-run/remix/templates/spa +``` + +## Development + +You can develop your SPA app just like you would a normal Remix app, via: + +```shellscript +npm run dev +``` + +## Production + +When you are ready yo build a production version of your app, `npm run build` will generate your assets and an `index.html` for the SPA. + +```shellscript +npm run build +``` + +You can serve this from any server of your choosing, for a simple example, you could use [http-server](https://www.npmjs.com/package/http-server): + +```shellscript +npx http-server build/client/ +``` + +[remix-vite-docs]: https://remix.run/docs/en/main/future/vite diff --git a/templates/spa/app/entry.client.tsx b/templates/spa/app/entry.client.tsx new file mode 100644 index 00000000000..999c0a128c1 --- /dev/null +++ b/templates/spa/app/entry.client.tsx @@ -0,0 +1,12 @@ +import { RemixBrowser } from "@remix-run/react"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; + +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); diff --git a/templates/spa/app/entry.server.tsx b/templates/spa/app/entry.server.tsx new file mode 100644 index 00000000000..1bc305a7b00 --- /dev/null +++ b/templates/spa/app/entry.server.tsx @@ -0,0 +1,18 @@ +import type { EntryContext } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import { renderToString } from "react-dom/server"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + const html = renderToString( + + ); + return new Response(html, { + headers: { "Content-Type": "text/html" }, + status: responseStatusCode, + }); +} diff --git a/templates/spa/app/root.tsx b/templates/spa/app/root.tsx new file mode 100644 index 00000000000..344ecfdbc93 --- /dev/null +++ b/templates/spa/app/root.tsx @@ -0,0 +1,44 @@ +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +export default function App() { + return ( + + + + + + + + + + + + + + + ); +} + +export function HydrateFallback() { + return ( + + + + + + + + +

Loading...

+ + + + ); +} diff --git a/templates/spa/app/routes/_index.tsx b/templates/spa/app/routes/_index.tsx new file mode 100644 index 00000000000..a7884796d47 --- /dev/null +++ b/templates/spa/app/routes/_index.tsx @@ -0,0 +1,32 @@ +import type { MetaFunction } from "@remix-run/node"; + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix SPA" }, + { name: "description", content: "Welcome to Remix (SPA Mode)!" }, + ]; +}; + +export default function Index() { + return ( +
+

Welcome to Remix (SPA Mode)

+ +
+ ); +} diff --git a/templates/spa/env.d.ts b/templates/spa/env.d.ts new file mode 100644 index 00000000000..78ed2345c6e --- /dev/null +++ b/templates/spa/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/templates/spa/package.json b/templates/spa/package.json new file mode 100644 index 00000000000..27517953e64 --- /dev/null +++ b/templates/spa/package.json @@ -0,0 +1,38 @@ +{ + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "remix vite:build", + "dev": "remix vite:dev", + "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", + "start": "http-server -p 3000 build/client/", + "typecheck": "tsc" + }, + "dependencies": { + "@remix-run/node": "*", + "@remix-run/react": "*", + "http-server": "^14.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@remix-run/dev": "*", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.7.4", + "eslint": "^8.38.0", + "eslint-config-prettier": "^9.0.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "typescript": "^5.1.6", + "vite": "^5.0.0", + "vite-tsconfig-paths": "^4.2.1" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/templates/spa/public/favicon.ico b/templates/spa/public/favicon.ico new file mode 100644 index 00000000000..8830cf6821b Binary files /dev/null and b/templates/spa/public/favicon.ico differ diff --git a/templates/spa/tsconfig.json b/templates/spa/tsconfig.json new file mode 100644 index 00000000000..269c0cc0fce --- /dev/null +++ b/templates/spa/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +} diff --git a/templates/spa/vite.config.ts b/templates/spa/vite.config.ts new file mode 100644 index 00000000000..783ef07bce4 --- /dev/null +++ b/templates/spa/vite.config.ts @@ -0,0 +1,7 @@ +import { unstable_vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [remix({ unstable_ssr: false }), tsconfigPaths()], +});