算法艺术创作
algorithmic-art
通过代码和可调参数创作生成艺术与交互式视觉效果。
frontend-remix-patterns【设计与创意】Use this skill when the user says 'Remix pattern', 'Remix form validation', 'Remix optimistic UI', 'Remix error boundary', 'Remix SEO', 'Remix caching', 'Remix PWA'. This skill enforces: server-side form validation with Zod, route-level error boundaries, meta exports for SEO, Cache-Control strategies, optimistic updates with useFetcher, and service w…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Apply production-grade patterns to Remix applications: server validation, error boundaries per route, SEO metadata, caching, optimistic UI, and PWA support.
Exact user phrases: "Remix pattern", "Remix form validation", "Remix optimistic UI", "Remix error boundary", "Remix SEO", "Remix caching", "Remix PWA".
Before activating, verify:
No file output. Produces code patterns for validation, error handling, SEO, caching, optimistic updates, and PWA.
Code examples only. Show action/loader with validation, error boundary, meta export, cache headers.
No preamble. No postamble. No explanations. No filler/hedging/transitions. Compress output — why use many token when few do trick.
Code: 15 lines per example. Unlimited patterns.
| Approach | Trade-off | When to Use |
|---|---|---|
| Server-side validation only | Simpler, no JS needed for form to work | Basic forms, login, signup |
| Server + client validation with useActionData | Better UX, instant feedback | Forms with many fields |
| Optimistic UI with useFetcher | Instant feedback, rollback on error | Likes, stars, add to cart |
| Route ErrorBoundary | Scoped error handling | Per-route error recovery |
| Root ErrorBoundary | Global fallback | Unexpected errors, network failures |
| Meta per route | SEO per page | Blog posts, product pages |
| Resource route sitemap | Dynamic sitemap generation | Sites with dynamic content |
Is the form simple (email + password)?
├── Yes -> Server validation with Zod
└── No (many fields, complex rules) ->
├── Server validation (required)
└── + Client enhancement with useActionData
Does the mutation need instant feedback?
├── No -> Standard <Form> with pending state
└── Yes -> useFetcher + local state
├── Can you easily rollback? -> Optimistic update
└── Risk of race conditions? -> Use useFetcher data for confirmation
Is this an expected error (404, 403)?
├── Yes -> throw Response in loader, use CatchBoundary (v1) or ErrorBoundary (v2)
└── No -> ErrorBoundary for unexpected errors
├── Route-level -> Scoped error recovery
└── Root-level -> Global fallback in root.tsx
Is the data user-specific?
├── Yes -> private, no-store (never cache)
└── No -> Is it frequently updated?
├── Yes -> max-age=60, s-maxage=300
└── No -> max-age=3600, s-maxage=86400
Does the app need offline support?
├── No -> Skip service worker, standard web app
└── Yes -> Service worker with cache-first strategy
├── Full offline -> Cache all routes on first visit
└── Partial offline -> Cache static assets only
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const intent = formData.get('intent')
switch (intent) {
case 'create': {
const result = createSchema.safeParse(Object.fromEntries(formData))
if (!result.success) return json({ errors: result.error.flatten().fieldErrors, intent }, { status: 400 })
await db.post.create({ data: result.data })
return redirect('/posts')
}
case 'delete': {
const id = formData.get('id')
await db.post.delete({ where: { id: String(id) } })
return json({ ok: true })
}
case 'toggle-pin': {
const id = formData.get('id')
const post = await db.post.findUnique({ where: { id: String(id) } })
await db.post.update({ where: { id: String(id) }, data: { pinned: !post?.pinned } })
return json({ ok: true })
}
default:
throw new Response('Invalid intent', { status: 400 })
}
}
// app/routes/sitemap[.]xml.tsx
import { generateSitemap } from '@remix-run/sitemap'
import { db } from '~/db'
export async function loader({ request }: LoaderFunctionArgs) {
const posts = await db.post.findMany({ select: { slug: true, updatedAt: true } })
const postEntries = posts.map(p => ({
route: `/blog/${p.slug}`,
lastmod: p.updatedAt.toISOString(),
changefreq: 'weekly' as const,
priority: 0.7,
}))
return generateSitemap(request, [
{ route: '/', priority: 1.0 },
{ route: '/about', priority: 0.5 },
...postEntries,
])
}
export const meta: MetaFunction<typeof loader> = ({ data, params, location }) => {
const product = data?.product
if (!product) return [{ title: 'Product Not Found' }]
return [
{ title: product.name },
{ name: 'description', content: product.description?.slice(0, 160) },
{ property: 'og:title', content: product.name },
{ property: 'og:description', content: product.description?.slice(0, 160) },
{ property: 'og:image', content: product.image },
{ property: 'og:url', content: `https://example.com${location.pathname}` },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: product.name },
{ tagName: 'link', rel: 'canonical', href: `https://example.com${location.pathname}` },
{ script: [{ type: 'application/ld+json', children: JSON.stringify(productSchema(product)) }] },
]
}
// app/routes/robots[.]txt.tsx
export function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url)
return new Response(
`User-agent: *\nAllow: /\nSitemap: ${url.origin}/sitemap.xml\nDisallow: /admin`,
{ headers: { 'Content-Type': 'text/plain' } }
)
}
// app/root.tsx
import { ScrollRestoration } from '@remix-run/react'
export default function Root() {
return (
<html>
<head />
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
function GlobalPendingIndicator() {
const navigation = useNavigation()
const isPending = navigation.state !== 'idle'
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: 3,
background: isPending ? 'blue' : 'transparent',
transition: 'background 0.3s',
zIndex: 9999,
}}
/>
)
}
In Remix, most state lives on the server. The patterns below handle the limited client state needed.
The primary state management pattern: loaders return data, components consume it:
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await getUserId(request)
const [profile, notifications, posts] = await Promise.all([
db.profile.findUnique({ where: { userId } }),
db.notification.findMany({ where: { userId, read: false } }),
db.post.findMany({ where: { authorId: userId }, orderBy: { createdAt: 'desc' } }),
])
return json({ profile, notifications, posts })
}
export default function Dashboard() {
const { profile, notifications, posts } = useLoaderData<typeof loader>()
return (/* render */)
}
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url)
const filters = {
search: url.searchParams.get('q') || '',
category: url.searchParams.get('category') || '',
sort: url.searchParams.get('sort') || 'date',
page: Number(url.searchParams.get('page')) || 1,
}
// ...
return json({ products, filters })
}
export default function Products() {
const { filters } = useLoaderData<typeof loader>()
const [searchParams, setSearchParams] = useSearchParams()
return (
<select
value={filters.sort}
onChange={(e) => setSearchParams(prev => {
prev.set('sort', e.target.value)
return prev
})}
>
<option value="date">Newest</option>
<option value="price">Price</option>
</select>
)
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
try {
await doSomething()
session.flash('success', 'Done!')
return redirect('/success', {
headers: { 'Set-Cookie': await commitSession(session) },
})
} catch (e) {
session.flash('error', 'Failed')
return redirect('/error', {
headers: { 'Set-Cookie': await commitSession(session) },
})
}
}
// In route component:
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
return json({
flash: session.get('success') || session.get('error') || null,
}, {
headers: { 'Set-Cookie': await commitSession(session) },
})
}
function NotificationBell() {
const fetcher = useFetcher()
const [count, setCount] = useState(0)
useEffect(() => {
if (fetcher.data?.count !== undefined) setCount(fetcher.data.count)
}, [fetcher.data])
return (
<button onClick={() => fetcher.load('/api/notifications/count')}>
{count} notifications
</button>
)
}
Zod validation on every action has a cost. For very large forms, consider parsing with .safeParseAsync() and using z.object({...}).parse() only on required fields. Schemas with 20+ fields should be optimized with .strict() to reject unexpected fields.
| Cache Header | Effect |
|---|---|
public, max-age=300 | Browser caches for 5 minutes |
s-maxage=3600 | CDN caches for 1 hour |
stale-while-revalidate=60 | Serves stale for 60s while refetching |
private, no-store | Never cache (auth routes) |
Optimistic updates should be lightweight DOM-only changes. Avoid recalculating lists or triggering expensive operations in the optimistic callback.
ErrorBoundary components are included in the route's client bundle. They are small (1-2KB) but should not contain heavy UI libraries.
<Link prefetch="intent" to="/products">Products</Link> // prefetch on hover/touch
<Link prefetch="render" to="/dashboard">Dashboard</Link> // prefetch when rendered
<Link prefetch="viewport" to="/contact">Contact</Link> // prefetch when in viewport
<Link prefetch="none" to="/logout">Logout</Link> // never prefetch
// vite.config.ts
import { vitePlugin as remix } from '@remix-run/dev'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [remix()],
build: {
target: 'es2022',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: (id) => {
if (id.includes('node_modules')) return 'vendor'
},
},
},
},
server: { port: 3000 },
})
build/
client/ -- Client bundles (JS, CSS, assets)
server/ -- Server bundle with loaders/actions
Run with: remix-serve build/server/index.js
links export for automatic code splittingapp/root.tsx via links export@tailwindcss/vite plugin, import in root CSS*.module.css, import as import styles from './styles.module.css'// Server-only (in loaders/actions): process.env.DATABASE_URL
// Client-exposed: pass through loader
export async function loader() {
return json({ publicKey: process.env.PUBLIC_STRIPE_KEY })
}
// __tests__/schemas.test.ts
import { describe, it, expect } from 'vitest'
import { z } from 'zod'
const createProductSchema = z.object({
name: z.string().min(1, 'Name required'),
price: z.coerce.number().positive(),
category: z.enum(['electronics', 'clothing', 'food']),
})
describe('createProductSchema', () => {
it('accepts valid product data', () => {
const result = createProductSchema.parse({ name: 'Widget', price: '10', category: 'electronics' })
expect(result.price).toBe(10)
})
it('rejects missing name', () => {
expect(() => createProductSchema.parse({ price: '10', category: 'electronics' }))
.toThrow('Name required')
})
it('rejects negative price', () => {
expect(() => createProductSchema.parse({ name: 'Widget', price: '-5', category: 'electronics' }))
.toThrow()
})
})
// __tests__/settings.action.test.ts
import { describe, it, expect } from 'vitest'
import { action } from '../app/routes/settings'
describe('settings action', () => {
it('validates name length', async () => {
const formData = new FormData()
formData.set('name', 'A')
formData.set('email', 'test@test.com')
const response = await action({
request: new Request('http://localhost/settings', { method: 'POST', body: formData }),
params: {},
context: {},
})
expect(response.status).toBe(400)
const data = await response.json()
expect(data.errors.name).toBeDefined()
})
it('redirects on success', async () => {
const formData = new FormData()
formData.set('name', 'John')
formData.set('email', 'john@test.com')
const response = await action({ /* ... same pattern */ })
expect(response.status).toBe(302)
expect(response.headers.get('Location')).toBe('/settings')
})
})
// __tests__/ErrorBoundary.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { ErrorBoundary } from '../app/routes/products.$id'
describe('Product ErrorBoundary', () => {
it('renders error message', () => {
render(<ErrorBoundary error={new Error('Product not found')} />)
expect(screen.getByText('Product not found')).toBeDefined()
})
})
// e2e/contact.spec.ts
import { test, expect } from '@playwright/test'
test('submits contact form without JS', async ({ page }) => {
await page.goto('/contact')
await page.fill('[name="email"]', 'test@test.com')
await page.fill('[name="message"]', 'Hello')
await page.click('button[type="submit"]')
await expect(page).toHaveURL(/\/thanks/)
})
test('shows validation errors', async ({ page }) => {
await page.goto('/contact')
await page.click('button[type="submit"]')
await expect(page.locator('[aria-invalid="true"]')).toHaveCount(2)
})
// Before: Express
app.get('/api/products', async (req, res) => {
const products = await db.product.findMany()
res.json(products)
})
// After: Remix resource route
export async function loader() {
return json(await db.product.findMany())
}
// Before: SPA with client data fetching
function ProductPage() {
const { id } = useParams()
const [product, setProduct] = useState(null)
useEffect(() => { fetch(`/api/products/${id}`).then(r => r.json()).then(setProduct) }, [id])
if (!product) return <Spinner />
return <ProductDetail product={product} />
}
// After: Remix
export async function loader({ params }) {
const product = await db.product.findUnique({ where: { id: params.id } })
if (!product) throw new Response(null, { status: 404 })
return json(product)
}
export default function ProductPage() {
const product = useLoaderData<typeof loader>()
return <ProductDetail product={product} />
}
// Before: Client-side fetch
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const res = await fetch('/api/contact', {
method: 'POST',
body: new FormData(e.currentTarget as HTMLFormElement),
})
if (res.ok) navigate('/thanks')
}
// After: Remix
export async function action({ request }) {
const formData = await request.formData()
// validate and process
return redirect('/thanks')
}
// Component: <Form method="post">...</Form>
// Anti-pattern: validation only on client
function handleSubmit(e) {
e.preventDefault()
if (!email.includes('@')) return setError('Invalid email')
fetch('/api/contact', { method: 'POST', body: new FormData(e.target) })
}
// Correct: validate on server always
export async function action({ request }) {
const result = schema.safeParse(formData)
if (!result.success) return json({ errors: result.error.flatten().fieldErrors }, { status: 400 })
}
// Anti-pattern: client-side fetch in component
useEffect(() => { fetch('/api/products').then(r => r.json()).then(setProducts) }, [])
// Correct: loader
export async function loader() { return json(await db.product.findMany()) }
// Anti-pattern: returning 200 with error
return json({ error: 'Not found' }) // status 200
// Correct
throw new Response('Not found', { status: 404 })
Don't have overlapping route patterns between Remix routes and external API routes. If /api/products is a Remix resource route, don't also proxy to an external API at the same path.
// Anti-pattern: all meta in one route
// meta should be per-route, specific to the page content
After form submission with errors, focus should move to the first invalid field. Remix doesn't do this automatically:
useEffect(() => {
if (actionData?.errors) {
const firstError = document.querySelector('[aria-invalid="true"]')
if (firstError instanceof HTMLElement) firstError.focus()
}
}, [actionData])
useFetcher does not update the URL. For mutations that should change the URL (create, delete that redirects), use <Form> instead.
Client validation is an enhancement. Server validation is mandatory. Always validate in the action.
Returning validation errors without useActionData forces a full page reload and no inline error display. Always return errors as JSON with 4xx status.
Never Cache-Control authenticated routes. Use private, no-store or omit the header.
Using a single action with intent field is cleaner than multiple routes. Wrap in a switch statement for readability.
Without route-level ErrorBoundary, an error in one component crashes the entire page. Add ErrorBoundary to every layout route at minimum.
useFetcher has idle, loading, and submitting states. Check fetcher.state before displaying data to avoid showing stale or undefined values.
Remix validates on the server natively; React Hook Form is client-first. Remix works without JS; React Hook Form requires JS. For Remix projects, Zod + server validation is the idiomatic approach.
Both support optimistic updates. Remix's useFetcher approach is simpler (form-based) but less flexible. TanStack Query has richer cache invalidation and retry logic but requires more setup.
Remix's meta export is a function that receives loader data, making it truly dynamic per request. Next.js's generateMetadata is similar but Remix's approach is more explicit about the data dependency.
| Library | Purpose |
|---|---|
| zod | Schema validation for actions |
| remix-validated-form | Declarative form validation |
| @remix-run/node | Session storage, cookie management |
| @remix-run/react | Client hooks (useActionData, useFetcher) |
| Tool | Purpose |
|---|---|
| @remix-run/sitemap | Sitemap generation |
| robots.txt resource route | Crawler directives |
| JSON-LD in | Structured data for rich snippets |
| Tool | Purpose |
|---|---|
| Cache-Control headers | HTTP caching |
| CDN (Cloudflare, Fastly) | Edge caching |
| Arcache | Remix cache utility |
| remix-cache | Cache management library |
@remix-run/testing for loader/action unit tests.const schema = z.object({ email: z.string().email(), password: z.string().min(8) })
export async function action({ request }: ActionFunctionArgs) {
const formData = Object.fromEntries(await request.formData())
const result = schema.safeParse(formData)
if (!result.success) return json({ errors: result.error.flatten().fieldErrors }, { status: 400 })
await createUser(result.data)
return redirect('/dashboard')
}
Use useActionData in the component to display field-level errors. aria-invalid on inputs.
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return <div><h1>Error</h1><p>{error.message}</p></div>
}
Catch boundary for 404/403: throw new Response('Not Found', { status: 404 }). Root error boundary in root.tsx catches all unhandled errors.
export const meta: MetaFunction<typeof loader> = ({ data }) => [
{ title: data?.product?.name ?? 'Not Found' },
{ name: 'description', content: data?.product?.description?.slice(0, 160) },
{ property: 'og:title', content: data?.product?.name },
{ tagName: 'link', rel: 'canonical', href: `https://site.com${location.pathname}` },
]
Sitemap as resource route sitemap[.]xml.tsx. Robots.txt as resource route. JSON-LD in component via <script>.
export async function loader({ request }: LoaderFunctionArgs) {
return json(data, {
headers: { 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=60' },
})
}
Use s-maxage for CDN cache. stale-while-revalidate for background refresh. No caching for authenticated routes.
function LikeButton({ postId }: { postId: string }) {
const fetcher = useFetcher()
const optimisticLiked = fetcher.formData?.get('liked') === 'true'
return (
<fetcher.Form method="post" action="/api/like">
<input type="hidden" name="liked" value={String(!optimisticLiked)} />
<button type="submit">{optimisticLiked ? 'Unlike' : 'Like'}</button>
</fetcher.Form>
)
}
Rollback: compare fetcher.data with optimistic value; revert on error.
Place sw.js in public/. Register from root.tsx <Scripts> after. Manifest as resource route returning JSON. Offline page as route with service worker cache-first strategy.
function SubmitButton() {
const navigation = useNavigation()
const isPending = navigation.state === 'submitting'
return <button type="submit" disabled={isPending}>{isPending ? 'Saving...' : 'Save'}</button>
}
No artifact produced. Next skill: frontend-react-architecture for shared React patterns (component composition, hooks, state management). Carry forward: validation schemas, cache strategy, error boundary patterns.