Last active
February 2, 2025 17:24
-
-
Save markmals/f798e2089ed950ce90f4f6df7fd97d47 to your computer and use it in GitHub Desktop.
Sketch of a server-first meta-framework for ivi
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { html, component, useState, getProps } from 'ivi'; | |
import { cache, route, serverFunction, useActionState, use, Suspense } from 'ivi-router'; | |
import { db, Messages } from 'db.server'; | |
import { eq } from 'drizzle-orm'; | |
// Block virtual DOM | |
// Compiler optimizations | |
// Small vendor & component bundle sizes | |
// Tagged template literals | |
// Server components | |
// Stateful hybrid routing | |
const Counter = component(c => { | |
const [count, setCount] = useState(c, 0); | |
return () => html` | |
<div>${count()}</div> | |
<button @click=${() => setCount(count() + 1)}>Increment</button> | |
`; | |
}); | |
const message = serverFunction(async (id: number) => { | |
const message = await db.select().from(Messages).where(eq(Messages.id, id)).get(); | |
return message ?? { localizedDescription: 'Message not found' }; | |
}); | |
const cachedMessage = cache(message); | |
const MessageClient = component<{ clientId: number }>(c => { | |
let { clientId } = getProps(c); | |
const resolvedMessage = use(c, () => cachedMessage(clientId)); | |
return props => { | |
clientId = props.clientId; | |
return html` | |
${Counter()} | |
<span>${resolvedMessage().localizedDescription}</span> | |
`; | |
}; | |
}); | |
const MessageServer = async ({ clientId }: { clientId: number }) => { | |
const resolvedMessage = await cachedMessage(clientId); | |
return html` | |
<!-- This component will be hydrated --> | |
${Counter()} | |
<!-- This vDOM node will just be statically rendered in the JSON payload --> | |
<!-- the initial values from the initial server load. --> | |
<!-- It will never be hydrated because it's not a client component. --> | |
<span>${resolvedMessage.localizedDescription}</span> | |
`; | |
}; | |
const echo = serverFunction(async (message: string) => { | |
await new Promise(resolve => setTimeout(resolve, 1000)); | |
return { message }; | |
}); | |
const Echo = component(c => { | |
const [state, dispatch, isPending] = useActionState(c, echo, { | |
message: 'Awaiting Message', | |
}); | |
const handleChange = (event: Event & { currentTarget: HTMLInputElement }) => { | |
dispatch({ message: event.currentTarget.value }); | |
}; | |
return () => html` | |
<input type="text" @change=${handleChange} /> | |
${!isPending() && state().message} | |
`; | |
}); | |
const login = serverFunction(async (data: FormData) => { | |
await new Promise(resolve => setTimeout(resolve, 1000)); | |
const username = data.get('username'); | |
if (username === 'admin') { | |
return Response.redirect('/admin'); | |
} else { | |
throw Response.redirect('/home', 301); | |
} | |
}); | |
export default route({ | |
params: ['id', 'brand?'], | |
head: { | |
links: () => [{ rel: 'stylesheet', href: 'styles.url' }], | |
meta: () => [{ title: 'My Route' }], | |
}, | |
loader: async ({ params }) => { | |
const id = parseInt(params.id); | |
await cachedMessage(id); | |
return html` | |
<form action="${login}"> | |
${MessageServer({ clientId: id })} | |
${Suspense(html` | |
<span>Message client component:</span> | |
${MessageClient({ clientId: id })} | |
`)} | |
<label for="username">Username:</label> | |
<input type="text" name="username" /> | |
<input type="submit" value="submit" /> | |
</form> | |
`; | |
}, | |
error: async ({ error }) => | |
html`<div>Received error: ${(error as any).localizedDescription}</div>`, | |
}); | |
// TODO: useFormStatus example | |
// TODO: useOptimistic example | |
// TODO: Nested routes with Outlet() or with children props? |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment