Skip to content

Commit 6108b9c

Browse files
committed
Rebuild the github-pages template on the Remix SPA preview
Switch from a Service Worker fetch handler to the client-side SPA router from remix-run/remix#11629: the router declares RouterTypes.output = RemixNode so handlers return JSX, and the SPA component from remix/ui/spa dispatches navigations and form submissions through the in-memory router. Frames stay: the guest book page is a <Frame name="welcome"> shell whose src resolves through the same router via createRoot's frameInit, with the frame name carried as the x-remix-target request header. Frames only re-resolve when their src changes, so the POST action stamps a freshness token into the src; the sign form is keyed by entry count so submissions mount a fresh form instead of morphing old values. The worker entry, Document shell, render middleware, and @pitlane/dev plugin are gone — the build is a plain Vite SPA (dist + 404.html). pnpm plumbing for the preview build: allowBuilds only matches git-hosted packages by exact resolved commit, so both workspace files pin the remix prepare (true) and decline the prebuilt @remix-run/* scripts (false); blockExoticSubdeps is disabled for the preview's git-tarball dependency graph, and the deploy workflow stops CI's frozen-lockfile default from leaking into the git checkout prepared during install.
1 parent 8894fa3 commit 6108b9c

18 files changed

Lines changed: 293 additions & 310 deletions

github-pages/.github/workflows/deploy.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,17 @@ jobs:
2727
with:
2828
cache: true
2929

30+
# Remix installs from a preview build prepared out of a git
31+
# checkout whose own lockfile is intentionally out of date; keep
32+
# this app's install frozen via the CLI flag, but stop CI's
33+
# frozen-lockfile default from leaking into that nested install.
3034
- run: vp install --frozen-lockfile
35+
env:
36+
npm_config_frozen_lockfile: "false"
3137

3238
# Project pages live under /<repo>/; delete BASE_PATH for user/org
3339
# pages (username.github.io) or a custom domain. The build task
34-
# also stages sw.js and the SPA 404 fallback into dist/client.
40+
# also stages the SPA 404 fallback into dist.
3541
- run: vp run build
3642
env:
3743
BASE_PATH: /${{ github.event.repository.name }}/
@@ -40,7 +46,7 @@ jobs:
4046

4147
- uses: actions/upload-pages-artifact@v3
4248
with:
43-
path: dist/client
49+
path: dist
4450

4551
- id: deployment
4652
uses: actions/deploy-pages@v4

github-pages/README.md

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
# Pitlane — GitHub Pages Template
22

3-
A [Remix 3](https://remix.run) guest book starter built with [`@pitlane/dev`](https://pitlane.tools/package/dev) that runs entirely in the browser: the Remix fetch router lives inside a Service Worker, and data persists locally in IndexedDB via [`idb-keyval`](https://github.com/jakearchibald/idb-keyval). Once built, it deploys as pure static files.
3+
A [Remix 3](https://remix.run) guest book starter that runs entirely in the browser: the Remix fetch router returns UI nodes directly — no server, no service worker, no HTTP — and the `SPA` component from `remix/ui/spa` renders them, intercepting navigations and form submissions. Frames resolve through the same in-memory router, and data persists locally in IndexedDB via [`idb-keyval`](https://github.com/jakearchibald/idb-keyval). Once built, it deploys as pure static files.
44

5-
| Runtime | Package manager | Database | Deploys to |
6-
| -------------- | --------------- | ------------------------ | ------------------------------------------- |
7-
| Service Worker | pnpm | IndexedDB (`idb-keyval`) | [GitHub Pages](https://pages.github.com) |
5+
| Runtime | Package manager | Database | Deploys to |
6+
| ------------- | --------------- | ------------------------ | ---------------------------------------- |
7+
| Browser (SPA) | pnpm | IndexedDB (`idb-keyval`) | [GitHub Pages](https://pages.github.com) |
8+
9+
> [!NOTE]
10+
> This template tracks a preview build of [remix-run/remix#11629](https://github.com/remix-run/remix/pull/11629)
11+
> (SPA routing with custom fetch router outputs). The `remix` dependency installs
12+
> straight from that PR's preview branch, and `pnpm-workspace.yaml` pins the
13+
> matching `allowBuilds` keys — pnpm only matches git-hosted packages by exact
14+
> commit, so regenerate those keys whenever the preview commit moves.
815
916
## Scaffold
1017

@@ -35,16 +42,32 @@ vp install # install dependencies
3542
vp dev # start the dev server
3643
```
3744

38-
There is no server and no server database: the router runs in a Service Worker, and
39-
every guest book entry is stored in the visitor's own browser through `AppStorage`
40-
([app/data/app-storage.ts](./app/data/app-storage.ts)), a small schema-validated
41-
KV layer over IndexedDB.
45+
## Architecture
46+
47+
The router declares `RouterTypes.output = RemixNode`, so handlers return JSX
48+
instead of Responses ([app/router.tsx](./app/router.tsx)). The browser entry
49+
([app/entry.browser.tsx](./app/entry.browser.tsx)) renders an `SPA` component
50+
that dispatches every same-origin navigation and form submission through the
51+
router — POST submissions arrive as an in-memory `Request` complete with
52+
`FormData`.
53+
54+
Frames work the same way they do on a server: the guest book page is a
55+
`<Frame name="welcome">` shell, and the frame's `src` resolves through the
56+
router with the frame name in the `x-remix-target` header
57+
([app/actions/guest-book.tsx](./app/actions/guest-book.tsx)). After a
58+
submission the action stamps a freshness token into the frame `src`, which is
59+
what tells the frame to re-resolve — old content stays visible until the new
60+
fragment lands.
61+
62+
Guest book entries live in the visitor's own browser through `AppStorage`
63+
([app/data/app-storage.ts](./app/data/app-storage.ts)), a small
64+
schema-validated KV layer over IndexedDB.
4265

4366
## Commands
4467

4568
```sh
4669
vp dev # dev server
47-
vp run build # production build (static site in dist/client, incl. sw.js)
70+
vp run build # production build (static site in dist, incl. 404.html)
4871
vp preview # serve the production build
4972
vp check # format, lint, and type-check
5073
vp run typecheck # typecheck using tsc
@@ -59,10 +82,10 @@ Short version:
5982
**GitHub Actions**.
6083
2. Push to `main`. The [workflow](./.github/workflows/deploy.yml) builds with
6184
`BASE_PATH=/<repo>/` (so the app works at `username.github.io/<repo>/`), adds the
62-
SPA `404.html` fallback, and uploads `dist/client` to Pages.
85+
SPA `404.html` fallback, and uploads `dist` to Pages.
6386

6487
Deploying to a **user/org page** (`username.github.io`) or a **custom domain**?
6588
Delete the `BASE_PATH` line from the workflow — the app then serves from `/`.
6689

67-
The service worker registers at the configured base, routes navigations through the
68-
Remix router, and passes asset requests straight to the Pages CDN.
90+
GitHub Pages serves `404.html` (a copy of the app shell) for unknown routes, so
91+
deep links boot the SPA and the router renders the matching page client-side.
Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,31 @@
1-
import { Document } from "#app/components/Document.tsx";
1+
import * as s from "remix/data-schema";
2+
import { createController } from "remix/router";
3+
import { Frame } from "remix/ui";
4+
25
import { Welcome } from "#app/components/Welcome.tsx";
36
import { CreateGuestBookEntry, GuestBook } from "#app/data/schemas.ts";
47
import { routes } from "#app/routes.ts";
5-
import * as s from "remix/data-schema";
6-
import { redirect } from "remix/response/redirect";
7-
import { createController } from "remix/router";
88

99
export default createController(routes.guestBook, {
1010
actions: {
11-
async index({ storage, headers, render, url }) {
12-
let entries = await storage.getMany(GuestBook);
13-
11+
async index({ storage, headers, url }) {
1412
if (headers.get("x-remix-target") === "welcome") {
15-
return render(<Welcome entries={entries} />);
13+
let entries = await storage.getMany(GuestBook);
14+
return <Welcome entries={entries} />;
1615
}
1716

18-
return render(<Document url={url} />);
17+
return <Frame name="welcome" src={url.toString()} />;
1918
},
20-
async action({ storage, formData }) {
19+
async action({ storage, formData, url }) {
2120
let payload = s.parse(CreateGuestBookEntry, formData);
2221
await storage.set(GuestBook, payload);
23-
return redirect(routes.guestBook.index.href());
22+
23+
// A frame only re-resolves when its src changes, so stamp the
24+
// submission time into the src. The old content stays visible
25+
// while the fresh guest book loads — no fallback flash.
26+
let src = new URL(url);
27+
src.searchParams.set("submitted", Date.now().toString());
28+
return <Frame name="welcome" src={src.toString()} />;
2429
},
2530
},
2631
});

github-pages/app/components/CharacterCounter.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { theme } from "#app/components/Theme.tsx";
2-
import { clientEntry, css, on } from "remix/ui";
1+
import { type Handle, css, on } from "remix/ui";
32
import { inputStyle } from "remix/ui/combobox";
43

4+
import { theme } from "#app/components/Theme.tsx";
5+
56
const MAX_LENGTH = 280;
67

7-
export let CharacterCounter = clientEntry(import.meta.url, handle => {
8+
export function CharacterCounter(handle: Handle) {
89
let count = 0;
910

1011
return () => {
@@ -58,4 +59,4 @@ export let CharacterCounter = clientEntry(import.meta.url, handle => {
5859
</div>
5960
);
6061
};
61-
});
62+
}

github-pages/app/components/Document.tsx

Lines changed: 0 additions & 46 deletions
This file was deleted.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { css } from "remix/ui";
2+
3+
import { theme } from "#app/components/Theme.tsx";
4+
import { routes } from "#app/routes.ts";
5+
6+
export function NotFound() {
7+
return () => (
8+
<main
9+
mix={css({
10+
display: "flex",
11+
flexDirection: "column",
12+
alignItems: "center",
13+
gap: theme.space.lg,
14+
minHeight: "100vh",
15+
justifyContent: "center",
16+
fontFamily: theme.fontFamily.sans,
17+
color: theme.colors.text.primary,
18+
padding: "4rem 1rem",
19+
})}
20+
>
21+
<h1
22+
mix={css({
23+
fontSize: theme.fontSize.xxl,
24+
fontWeight: theme.fontWeight.bold,
25+
})}
26+
>
27+
Page not found
28+
</h1>
29+
<a href={routes.guestBook.index.href()} mix={css({ color: theme.colors.text.muted })}>
30+
Back to the guest book
31+
</a>
32+
</main>
33+
);
34+
}

github-pages/app/components/Welcome.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import { css, type Handle } from "remix/ui";
2+
import { button } from "remix/ui/button";
3+
import { inputStyle } from "remix/ui/combobox";
4+
15
import type { GuestBookEntry } from "#app/data/schemas.ts";
26

37
import { CharacterCounter } from "#app/components/CharacterCounter.tsx";
48
import { theme } from "#app/components/Theme.tsx";
59
import { routes } from "#app/routes.ts";
6-
import { css, type Handle } from "remix/ui";
7-
import { button } from "remix/ui/button";
8-
import { inputStyle } from "remix/ui/combobox";
910

1011
export interface WelcomeProps {
1112
entries: GuestBookEntry[];
@@ -43,12 +44,12 @@ export function Welcome(handle: Handle<WelcomeProps>) {
4344
<picture>
4445
<source
4546
media="(prefers-color-scheme: dark)"
46-
srcSet="/remix-3-logo-dark.svg"
47+
srcSet={import.meta.env.BASE_URL + "remix-3-logo-dark.svg"}
4748
/>
4849
<img
4950
alt="Remix 3"
5051
mix={[css({ height: "2.5rem" })]}
51-
src="/remix-3-logo-light.svg"
52+
src={import.meta.env.BASE_URL + "remix-3-logo-light.svg"}
5253
/>
5354
</picture>
5455
<h1
@@ -158,8 +159,11 @@ export function Welcome(handle: Handle<WelcomeProps>) {
158159
)}
159160
</div>
160161

162+
{/* Keyed by entry count so each submission mounts a fresh,
163+
empty form instead of morphing the previous values. */}
161164
<form
162165
action={routes.guestBook.action.href()}
166+
key={`sign-form-${entries.length}`}
163167
method={routes.guestBook.action.method}
164168
mix={[
165169
css({
@@ -184,7 +188,6 @@ export function Welcome(handle: Handle<WelcomeProps>) {
184188
<CharacterCounter />
185189
<button
186190
mix={[button({ tone: "primary" }), css({ alignSelf: "flex-end" })]}
187-
rmx-target="welcome"
188191
type="submit"
189192
>
190193
Sign
@@ -208,7 +211,6 @@ function ResourceLink(handle: Handle<ResourceLinkProps>) {
208211
return (
209212
<a
210213
href={href}
211-
target="_blank"
212214
mix={[
213215
css({
214216
color: theme.colors.text.link,
@@ -219,6 +221,7 @@ function ResourceLink(handle: Handle<ResourceLinkProps>) {
219221
},
220222
}),
221223
]}
224+
target="_blank"
222225
>
223226
{label}
224227
</a>

0 commit comments

Comments
 (0)