Skip to content

Commit 1aa50ee

Browse files
authored
Frames: out-of-order streaming server rendering, selective hydration, composable async UI and granular refresh (#11048)
1 parent c22a05d commit 1aa50ee

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+8121
-1604
lines changed
Lines changed: 35 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,40 @@
1-
import { type Handle, hydrationRoot } from 'remix/component'
1+
import { type Handle, clientEntry } from 'remix/component'
22

33
import { routes } from '../routes.ts'
44

5-
export const CartButton = hydrationRoot(
6-
routes.assets.href({ path: 'cart-button.js#CartButton' }),
7-
function CartButton(handle: Handle) {
8-
let pending = false
5+
let moduleUrl = routes.assets.href({ path: 'cart-button.js#CartButton' })
96

10-
return ({ inCart, id, slug }: { inCart: boolean; id: string; slug: string }) => {
11-
let route = inCart ? routes.cart.api.remove : routes.cart.api.add
12-
let method = route.method.toUpperCase()
7+
export const CartButton = clientEntry(moduleUrl, (handle: Handle) => {
8+
let pending = false
139

14-
return (
15-
<form
16-
method="POST"
17-
action={route.href()}
18-
on={{
19-
submit: () => {
20-
// Show pending state, let browser submit normally
21-
pending = true
22-
handle.update()
23-
},
24-
}}
25-
>
26-
{method !== 'POST' && <input type="hidden" name="_method" value={method} />}
27-
<input type="hidden" name="bookId" value={id} />
28-
<input type="hidden" name="slug" value={slug} />
29-
<button type="submit" class="btn" style={{ opacity: pending ? 0.5 : 1 }}>
30-
{inCart ? 'Remove from Cart' : 'Add to Cart'}
31-
</button>
32-
</form>
33-
)
34-
}
35-
},
36-
)
10+
return ({ inCart, id, slug }: { inCart: boolean; id: string; slug: string }) => (
11+
<button
12+
type="button"
13+
on={{
14+
async click(_event, signal) {
15+
pending = true
16+
handle.update()
17+
18+
let formData = new FormData()
19+
formData.set('bookId', id)
20+
formData.set('slug', slug)
21+
22+
await fetch(routes.api.cartToggle.href(), {
23+
method: 'POST',
24+
body: formData,
25+
signal,
26+
})
27+
28+
await handle.frame.reload()
29+
await new Promise((resolve) => setTimeout(resolve, 500))
30+
if (signal.aborted) return
31+
pending = false
32+
handle.update()
33+
},
34+
}}
35+
class="btn"
36+
>
37+
{pending ? 'Saving...' : inCart ? 'Remove from Cart' : 'Add to Cart'}
38+
</button>
39+
)
40+
})
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { type Handle, clientEntry } from 'remix/component'
2+
3+
import { routes } from '../routes.ts'
4+
5+
let moduleUrl = routes.assets.href({ path: 'cart-items.js#CartItems' })
6+
7+
type CartItem = {
8+
bookId: string
9+
slug: string
10+
title: string
11+
price: number
12+
quantity: number
13+
}
14+
15+
type CartItemsProps = {
16+
items: CartItem[]
17+
total: number
18+
canCheckout: boolean
19+
}
20+
21+
type PendingAction = {
22+
type: 'update' | 'remove'
23+
bookId: string
24+
} | null
25+
26+
export let CartItems = clientEntry(moduleUrl, (handle: Handle) => {
27+
let pendingAction: PendingAction = null
28+
29+
let submit = async (form: HTMLFormElement, signal: AbortSignal, nextAction: PendingAction) => {
30+
if (pendingAction) return
31+
32+
pendingAction = nextAction
33+
handle.update()
34+
35+
try {
36+
let formData = new FormData(form)
37+
formData.set('redirect', 'none')
38+
39+
await fetch(form.action, {
40+
method: 'POST',
41+
body: formData,
42+
signal,
43+
})
44+
45+
if (signal.aborted) return
46+
47+
await handle.frame.reload()
48+
} finally {
49+
pendingAction = null
50+
handle.update()
51+
}
52+
}
53+
54+
return ({ items, total, canCheckout }: CartItemsProps) => {
55+
let isPending = pendingAction !== null
56+
let totalLabel = isPending ? '---' : `$${total.toFixed(2)}`
57+
58+
return (
59+
<>
60+
{isPending ? (
61+
<p css={{ marginBottom: '1rem', fontSize: '0.9rem', color: '#666' }}>
62+
Updating your cart...
63+
</p>
64+
) : null}
65+
66+
<table>
67+
<thead>
68+
<tr>
69+
<th>Book</th>
70+
<th>Price</th>
71+
<th>Quantity</th>
72+
<th>Subtotal</th>
73+
<th>Actions</th>
74+
</tr>
75+
</thead>
76+
<tbody>
77+
{items.map((item) => {
78+
let isUpdating =
79+
pendingAction?.type === 'update' && pendingAction.bookId === item.bookId
80+
let isRemoving =
81+
pendingAction?.type === 'remove' && pendingAction.bookId === item.bookId
82+
83+
return (
84+
<tr key={item.bookId}>
85+
<td>
86+
<a href={routes.books.show.href({ slug: item.slug })}>{item.title}</a>
87+
</td>
88+
89+
<td>${item.price.toFixed(2)}</td>
90+
91+
<td>
92+
<form
93+
method="POST"
94+
action={routes.cart.api.update.href()}
95+
on={{
96+
async submit(event, signal) {
97+
event.preventDefault()
98+
await submit(event.currentTarget, signal, {
99+
type: 'update',
100+
bookId: item.bookId,
101+
})
102+
},
103+
}}
104+
css={{ display: 'inline-flex', gap: '0.5rem', alignItems: 'center' }}
105+
>
106+
<input type="hidden" name="_method" value="PUT" />
107+
<input type="hidden" name="bookId" value={item.bookId} />
108+
109+
<input
110+
type="number"
111+
name="quantity"
112+
value={item.quantity}
113+
min="1"
114+
disabled={isPending}
115+
css={{ width: '70px' }}
116+
/>
117+
118+
<button
119+
type="submit"
120+
disabled={isPending}
121+
class="btn btn-secondary"
122+
css={{
123+
fontSize: '0.875rem',
124+
padding: '0.25rem 0.5rem',
125+
minWidth: '6.25rem',
126+
textAlign: 'center',
127+
}}
128+
>
129+
{isUpdating ? 'Saving...' : 'Update'}
130+
</button>
131+
</form>
132+
</td>
133+
134+
<td>${(item.price * item.quantity).toFixed(2)}</td>
135+
136+
<td>
137+
<form
138+
method="POST"
139+
action={routes.cart.api.remove.href()}
140+
on={{
141+
async submit(event, signal) {
142+
event.preventDefault()
143+
await submit(event.currentTarget, signal, {
144+
type: 'remove',
145+
bookId: item.bookId,
146+
})
147+
},
148+
}}
149+
css={{ display: 'inline' }}
150+
>
151+
<input type="hidden" name="_method" value="DELETE" />
152+
<input type="hidden" name="bookId" value={item.bookId} />
153+
154+
<button
155+
type="submit"
156+
disabled={isPending}
157+
class="btn btn-danger"
158+
css={{
159+
fontSize: '0.875rem',
160+
padding: '0.25rem 0.5rem',
161+
minWidth: '7rem',
162+
textAlign: 'center',
163+
}}
164+
>
165+
{isRemoving ? 'Removing...' : 'Remove'}
166+
</button>
167+
</form>
168+
</td>
169+
</tr>
170+
)
171+
})}
172+
</tbody>
173+
</table>
174+
175+
<div css={{ marginTop: '2rem', display: 'flex', alignItems: 'center', gap: '1rem' }}>
176+
<p css={{ margin: 0, fontSize: '1.25rem', fontWeight: 'bold', marginRight: 'auto' }}>
177+
Total: {totalLabel}
178+
</p>
179+
180+
<a href={routes.books.index.href()} class="btn btn-secondary">
181+
Continue Shopping
182+
</a>
183+
184+
{canCheckout ? (
185+
<a href={routes.checkout.index.href()} class="btn">
186+
Proceed to Checkout
187+
</a>
188+
) : (
189+
<a href={routes.auth.login.index.href()} class="btn">
190+
Login to Checkout
191+
</a>
192+
)}
193+
</div>
194+
</>
195+
)
196+
}
197+
})
Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
1-
import { hydrate } from 'remix/component'
1+
import { run } from 'remix/component'
22

3-
let root = hydrate({
4-
async loadModule(moduleUrl, exportName) {
3+
let app = run(document, {
4+
async loadModule(moduleUrl: string, exportName: string) {
55
let mod = await import(moduleUrl)
6-
let Component = mod[exportName]
6+
let Component = (mod as any)[exportName]
77
if (!Component) {
88
throw new Error(`Unknown component: ${moduleUrl}#${exportName}`)
99
}
1010
return Component
1111
},
12+
async resolveFrame(src, signal) {
13+
let response = await fetch(src, { headers: { accept: 'text/html' }, signal })
14+
if (!response.ok) {
15+
return `<pre>Frame error: ${response.status} ${response.statusText}</pre>`
16+
}
17+
// let text = await response.text()
18+
// console.log(text)
19+
// return text
20+
if (response.body) return response.body
21+
return response.text()
22+
},
1223
})
1324

14-
root.addEventListener('error', (event) => {
15-
console.error('Hydration error:', event.error)
25+
app.ready().catch((error: unknown) => {
26+
console.error('Frame adoption failed:', error)
1627
})

demos/bookstore/app/assets/image-carousel.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { type Handle, hydrationRoot } from 'remix/component'
1+
import { type Handle, clientEntry } from 'remix/component'
22

33
import { routes } from '../routes.ts'
44

5-
export const ImageCarousel = hydrationRoot(
5+
export const ImageCarousel = clientEntry(
66
routes.assets.href({ path: 'image-carousel.js#ImageCarousel' }),
77
function ImageCarousel(handle: Handle, setup?: { startIndex?: number }) {
88
let index = setup?.startIndex ?? 0

demos/bookstore/app/books.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ describe('books handlers', () => {
1111
assert.equal(response.status, 200)
1212
let html = await response.text()
1313
assertContains(html, 'Browse Books')
14-
assertContains(html, 'Ash & Smoke')
14+
assertContains(html, 'Ash &amp; Smoke')
1515
assertContains(html, 'Heavy Metal Guitar Riffs')
1616
assertContains(html, 'Three Ways to Change Your Life')
1717
})
@@ -21,7 +21,7 @@ describe('books handlers', () => {
2121

2222
assert.equal(response.status, 200)
2323
let html = await response.text()
24-
assertContains(html, 'Ash & Smoke')
24+
assertContains(html, 'Ash &amp; Smoke')
2525
assertContains(html, 'Rusty Char-Broil')
2626
assertContains(html, 'Add to Cart')
2727
})

demos/bookstore/app/books.tsx

Lines changed: 4 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Controller } from 'remix/fetch-router'
2+
import { Frame } from 'remix/component'
23

34
import { routes } from './routes.ts'
45
import { getAllBooks, getBookBySlug, getBooksByGenre, getAvailableGenres } from './models/books.ts'
@@ -172,39 +173,9 @@ export default {
172173
</div>
173174

174175
{book.inStock ? (
175-
inCart ? (
176-
<form
177-
method="POST"
178-
action={routes.cart.api.remove.href()}
179-
css={{ marginTop: '2rem' }}
180-
>
181-
<input type="hidden" name="_method" value="DELETE" />
182-
<input type="hidden" name="bookId" value={book.id} />
183-
<button
184-
type="submit"
185-
class="btn"
186-
css={{ fontSize: '1.1rem', padding: '0.75rem 1.5rem' }}
187-
>
188-
Remove from Cart
189-
</button>
190-
</form>
191-
) : (
192-
<form
193-
method="POST"
194-
action={routes.cart.api.add.href()}
195-
css={{ marginTop: '2rem' }}
196-
>
197-
<input type="hidden" name="bookId" value={book.id} />
198-
<input type="hidden" name="slug" value={book.slug} />
199-
<button
200-
type="submit"
201-
class="btn"
202-
css={{ fontSize: '1.1rem', padding: '0.75rem 1.5rem' }}
203-
>
204-
Add to Cart
205-
</button>
206-
</form>
207-
)
176+
<div css={{ marginTop: '2rem' }}>
177+
<Frame src={routes.fragments.cartButton.href({ bookId: book.id })} />
178+
</div>
208179
) : (
209180
<p css={{ color: '#e74c3c', fontWeight: 500 }}>
210181
This book is currently out of stock.

0 commit comments

Comments
 (0)