Skip to content

Commit

Permalink
SPA Mode (#8457)
Browse files Browse the repository at this point in the history
  • Loading branch information
brophdawg11 authored Jan 8, 2024
1 parent 8d14bf1 commit 095bfa0
Show file tree
Hide file tree
Showing 37 changed files with 1,237 additions and 120 deletions.
42 changes: 42 additions & 0 deletions .changeset/spa-mode.md
Original file line number Diff line number Diff line change
@@ -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].
8 changes: 4 additions & 4 deletions docs/guides/client-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
124 changes: 124 additions & 0 deletions docs/guides/spa-mode.md
Original file line number Diff line number Diff line change
@@ -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]
- `<head>` management via Remix [`<Meta>`][meta]/[`<Links>`][links] APIs
- you don't _have_ to do this if your app doesn't warrant it - you can still just render and hydrate a `<div id="app">` 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
4 changes: 2 additions & 2 deletions integration/compiler-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand All @@ -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/);
Expand Down
2 changes: 1 addition & 1 deletion integration/flat-routes-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
58 changes: 50 additions & 8 deletions integration/helpers/create-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 }\`.
`);
}
Expand Down
Loading

0 comments on commit 095bfa0

Please sign in to comment.