Skip to content

Commit

Permalink
feat: add remix basic example (#246)
Browse files Browse the repository at this point in the history
I followed the "Optimistic add to list" example here:
remix-run/remix#7698

The meat of the code is in
[`app/routes/_index.tsx`](https://github.com/electric-sql/electric-next/pull/246/files#diff-a293c58f3ef9dd65aea8e9b911e909f873b9b5a3a7bfa2f09ff79ffa2d30d36d)

Demo — where the browser on the left is throttled to slow 3g and the
right browser is unthrottled.


https://github.com/user-attachments/assets/3d3008b0-fff4-4e26-9b41-9e4e8a90c009
  • Loading branch information
KyleAMathews authored Aug 6, 2024
1 parent 5f3a7e3 commit b9d42cd
Show file tree
Hide file tree
Showing 27 changed files with 2,920 additions and 5 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ caching/nginx_cache
.vscode
.DS_store
**/dist/**
build
5 changes: 5 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"trailingComma": "es5",
"semi": false,
"tabWidth": 2
}
5 changes: 5 additions & 0 deletions examples/basic-example/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"trailingComma": "es5",
"semi": false,
"tabWidth": 2
}
1 change: 0 additions & 1 deletion examples/basic-example/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,4 @@ import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
envPrefix: `ELECTRIC_`,
})
1 change: 0 additions & 1 deletion examples/linearlite/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import svgr from 'vite-plugin-svgr'

// https://vitejs.dev/config/
export default defineConfig({
envPrefix: `ELECTRIC_`,
plugins: [
react(),
svgr({
Expand Down
41 changes: 41 additions & 0 deletions examples/remix-basic/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
`eslint:recommended`,
`plugin:@typescript-eslint/recommended`,
`plugin:prettier/recommended`,
],
parserOptions: {
ecmaVersion: 2022,
requireConfigFile: false,
sourceType: `module`,
ecmaFeatures: {
jsx: true,
},
},
parser: `@typescript-eslint/parser`,
plugins: [`prettier`],
rules: {
quotes: [`error`, `backtick`],
"no-unused-vars": `off`,
"@typescript-eslint/no-unused-vars": [
`error`,
{
argsIgnorePattern: `^_`,
varsIgnorePattern: `^_`,
caughtErrorsIgnorePattern: `^_`,
},
],
},
ignorePatterns: [
`**/node_modules/**`,
`**/dist/**`,
`tsup.config.ts`,
`vitest.config.ts`,
`.eslintrc.js`,
],
};
2 changes: 2 additions & 0 deletions examples/remix-basic/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
.env.local
5 changes: 5 additions & 0 deletions examples/remix-basic/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"trailingComma": "es5",
"semi": false,
"tabWidth": 2
}
22 changes: 22 additions & 0 deletions examples/remix-basic/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Basic Remix example

## Setup

1. Make sure you've installed all dependencies for the monorepo and built packages

From the root directory:

- `pnpm i`
- `pnpm run -r build`

2. Start the docker containers

`pnpm run backend:up`

3. Start the dev server

`pnpm run dev`

4. When done, tear down the backend containers so you can run other examples

`pnpm run backend:down`
25 changes: 25 additions & 0 deletions examples/remix-basic/app/App.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.App {
text-align: center;
}

.App-logo {
height: min(160px, 30vmin);
pointer-events: none;
margin-top: min(30px, 5vmin);
margin-bottom: min(30px, 5vmin);
}

.App-header {
background-color: #1c1e20;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: top;
justify-content: top;
font-size: calc(10px + 2vmin);
color: white;
}

.App-link {
color: #61dafb;
}
41 changes: 41 additions & 0 deletions examples/remix-basic/app/Example.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
.controls {
margin-bottom: 1.5rem;
}

.button {
display: inline-block;
line-height: 1.3;
text-align: center;
text-decoration: none;
vertical-align: middle;
cursor: pointer;
user-select: none;
width: calc(15vw + 100px);
margin-right: 0.5rem !important;
margin-left: 0.5rem !important;
border-radius: 32px;
text-shadow: 2px 6px 20px rgba(0, 0, 0, 0.4);
box-shadow: rgba(0, 0, 0, 0.5) 1px 2px 8px 0px;
background: #1e2123;
border: 2px solid #229089;
color: #f9fdff;
font-size: 16px;
font-weight: 500;
padding: 10px 18px;
}

.item {
display: block;
line-height: 1.3;
text-align: center;
vertical-align: middle;
width: calc(30vw - 1.5rem + 200px);
margin-right: auto;
margin-left: auto;
border-radius: 32px;
border: 1.5px solid #bbb;
box-shadow: rgba(0, 0, 0, 0.3) 1px 2px 8px 0px;
color: #f9fdff;
font-size: 13px;
padding: 10px 18px;
}
14 changes: 14 additions & 0 deletions examples/remix-basic/app/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import pgPkg from "pg"
const { Client } = pgPkg

const db = new Client({
host: `localhost`,
port: 54321,
password: `password`,
user: `postgres`,
database: `electric`,
})

db.connect()

export { db }
42 changes: 42 additions & 0 deletions examples/remix-basic/app/match-stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ShapeStream, ChangeMessage } from "@electric-sql/next"

export async function matchStream({
stream,
operations,
matchFn,
timeout = 10000,
}: {
stream: ShapeStream
operations: Array<`insert` | `update` | `delete`>
matchFn: ({
operationType,
message,
}: {
operationType: string
message: ChangeMessage<any>
}) => boolean
timeout?: number
}): Promise<ChangeMessage<any>> {
return new Promise((resolve, reject) => {
const unsubscribe = stream.subscribe((messages) => {
for (const message of messages) {
if (`key` in message && operations.includes(message.headers.action)) {
if (matchFn({ operationType: message.headers.action, message })) {
return finish(message)
}
}
}
})

const timeoutId = setTimeout(() => {
console.error(`matchStream timed out after ${timeout}ms`)
reject(`matchStream timed out after ${timeout}ms`)
}, timeout)

function finish(message: ChangeMessage<any>) {
clearTimeout(timeoutId)
unsubscribe()
return resolve(message)
}
})
}
42 changes: 42 additions & 0 deletions examples/remix-basic/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import "./style.css"
import "./App.css"
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react"

import { ShapesProvider } from "@electric-sql/react"

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body style={{ margin: 0, padding: 0 }}>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}

export default function App() {
return (
<div className="App">
<header className="App-header">
<img src="/logo.svg" className="App-logo" alt="logo" />
<ShapesProvider>
<Outlet />
</ShapesProvider>
</header>
</div>
)
}
101 changes: 101 additions & 0 deletions examples/remix-basic/app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { useShape, preloadShape, getShapeStream } from "@electric-sql/react"
import { useFetchers, Form } from "@remix-run/react"
import { v4 as uuidv4 } from "uuid"
import type { ClientActionFunctionArgs } from "@remix-run/react"
import "../Example.css"
import { matchStream } from "../match-stream"

const itemShape = () => {
return {
url: new URL(`/shape-proxy/items`, window.location.origin).href,
}
}

export const clientLoader = async () => {
return await preloadShape(itemShape())
}

export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
const body = await request.formData()

const itemsStream = getShapeStream(itemShape())

if (body.get(`intent`) === `add`) {
// Match the insert
const findUpdatePromise = matchStream({
stream: itemsStream,
operations: [`insert`],
matchFn: ({ message }) => message.value.id === body.get(`new-id`),
})

// Generate new UUID and post to backend
const fetchPromise = fetch(`/api/items`, {
method: `POST`,
body: JSON.stringify({ uuid: body.get(`new-id`) }),
})

return await Promise.all([findUpdatePromise, fetchPromise])
} else if (body.get(`intent`) === `clear`) {
// Match the delete
const findUpdatePromise = matchStream({
stream: itemsStream,
operations: [`delete`],
// First delete will match
matchFn: () => true,
})
// Post to backend to delete everything
const fetchPromise = fetch(`/api/items`, {
method: `DELETE`,
})

return await Promise.all([findUpdatePromise, fetchPromise])
}
}

type Item = { id: string }

export default function Example() {
const { data: items } = useShape(itemShape()) as unknown as { data: Item[] }

const submissions = useFetchers()
.filter((fetcher) => fetcher.formData?.get(`intent`) === `add`)
.map((fetcher) => {
return { id: fetcher.formData?.get(`new-id`) } as Item
})

const isClearing = useFetchers().some(
(fetcher) => fetcher.formData?.get(`intent`) === `clear`
)

// Merge data from shape & optimistic data from fetchers. This removes
// possible duplicates as there's a potential race condition where
// useShape updates from the stream slightly before the action has finished.
const itemsMap = new Map()
items.concat(submissions).forEach((item) => {
itemsMap.set(item.id, { ...itemsMap.get(item.id), ...item })
})
return (
<div>
<Form navigate={false} method="POST" className="controls">
<input type="hidden" name="new-id" value={uuidv4()} />
<button className="button" name="intent" value="add">
Add
</button>
<button className="button" name="intent" value="clear">
Clear
</button>
</Form>
{isClearing
? ``
: [...itemsMap.values()].map((item: Item, index: number) => (
<p key={index} className="item">
<code>{item.id}</code>
</p>
))}
</div>
)
}

export function HydrateFallback() {
return ``
}
22 changes: 22 additions & 0 deletions examples/remix-basic/app/routes/api.items.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import nodePkg from "@remix-run/node"
const { json } = nodePkg
import type { ActionFunctionArgs } from "@remix-run/node"
import { db } from "../db"

export async function action({ request }: ActionFunctionArgs) {
if (request.method === `POST`) {
const body = await request.json()
const result = await db.query(
`INSERT INTO items (id)
VALUES ($1) RETURNING id;`,
[body.uuid]
)
return json({ id: result.rows[0].id })
}

if (request.method === `DELETE`) {
await db.query(`DELETE FROM items;`)

return `ok`
}
}
Loading

0 comments on commit b9d42cd

Please sign in to comment.