Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ssr improvements #2237

Draft
wants to merge 3 commits into
base: ssr
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 72 additions & 30 deletions examples/nextjs/app/items-list.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"use client"

import { v4 as uuidv4 } from "uuid"
import { useOptimistic, startTransition } from "react"
import { useShape, getShapeStream } from "@electric-sql/react"
import { startTransition, useState, useCallback, useEffect } from "react"
import {
HydratedShapeData,
getShapeStream,
useShape,
} from "@electric-sql/react"
import { ItemsView } from "./items-view"
import { matchStream } from "./match-stream"
import { type Item } from "./types"
Expand Down Expand Up @@ -49,34 +51,74 @@ async function clearItems() {

export function ItemsList() {
const shapeOptions = getClientShapeOptions()
const { data: rows } = useShape<Item>(shapeOptions)
const [optimisticItems, updateOptimisticItems] = useOptimistic<
Item[],
{ newId?: string; isClear?: boolean }
>(rows, (state, { newId, isClear }) => {
// If clearing, return empty array
if (isClear) {
return []
}

// Create a new array combining all sources
const allItems = [...rows, ...state]
if (newId) {
const newItem = { id: newId, value: `Item ${newId.slice(0, 4)}` }
allItems.push(newItem)
}

// Deduplicate by id, keeping the last occurrence of each id
const uniqueItems = allItems.reduce((acc, item) => {
acc[item.id] = item
return acc
}, {} as Record<string, Item>)

return Object.values(uniqueItems)
})
const { data: items } = useShape<Item>(shapeOptions)

const [optimisticItems, setOptimisticItems] = useState(items)

useEffect(() => {
setOptimisticItems(items)
}, [items])

const updateOptimisticItems = useCallback(
({ newId, isClear }: Partial<{ newId: string; isClear: boolean }>) =>
setOptimisticItems((items) => {
// If clearing, return empty array
if (isClear) {
return []
}

// Create a new array combining all sources
const allItems = [...items, ...optimisticItems]
if (newId) {
const newItem = { id: newId, value: `Item ${newId.slice(0, 4)}` }
allItems.push(newItem)
}

// Deduplicate by id, keeping the last occurrence of each id
const uniqueItems = allItems.reduce(
(acc, item) => {
acc[item.id] = item
return acc
},
{} as Record<string, Item>
)

return Object.values(uniqueItems)
}),
[]
)

// Pages can't use useOptimistic hook, so I'm using useState for now
// const [optimisticItems, updateOptimisticItems] = useOptimistic<
// Item[],
// { newId?: string; isClear?: boolean }
// >(items, (state, { newId, isClear }) => {
// // If clearing, return empty array
// if (isClear) {
// return []
// }

// // Create a new array combining all sources
// const allItems = [...items, ...state]
// if (newId) {
// const newItem = { id: newId, value: `Item ${newId.slice(0, 4)}` }
// allItems.push(newItem)
// }

// // Deduplicate by id, keeping the last occurrence of each id
// const uniqueItems = allItems.reduce(
// (acc, item) => {
// acc[item.id] = item
// return acc
// },
// {} as Record<string, Item>
// )

// return Object.values(uniqueItems)
// })

const handleAdd = async () => {
const id = uuidv4()
const id = crypto.randomUUID()
startTransition(async () => {
updateOptimisticItems({ newId: id })
await createItem(id)
Expand Down
8 changes: 0 additions & 8 deletions examples/nextjs/app/items.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { preloadShape } from "@electric-sql/react"
import { ShapeStreamOptions } from "@electric-sql/client"

// Server-side shape configuration
Expand All @@ -16,10 +15,3 @@ export const getClientShapeOptions = (): ShapeStreamOptions => {
url: `http://localhost:5173/shape-proxy`,
}
}

// Server component to prefetch shape data
export async function ItemsData() {
// Prefetch shape data during SSR
await preloadShape(itemShapeOptions)
return null
}
18 changes: 11 additions & 7 deletions examples/nextjs/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import "./style.css"
import "./App.css"
import "./Example.css"
import "./style.css"
import { HydrationBoundary } from "@electric-sql/react/HydrationBoundary"

export const metadata = {
title: `Next.js Forms Example`,
Expand All @@ -14,12 +16,14 @@ export default function RootLayout({
return (
<html lang="en">
<body>
<div className="App">
<header className="App-header">
<img src="/logo.svg" className="App-logo" alt="logo" />
{children}
</header>
</div>
<HydrationBoundary>
<div className="App">
<header className="App-header">
<img src="/logo.svg" className="App-logo" alt="logo" />
{children}
</header>
</div>
</HydrationBoundary>
</body>
</html>
)
Expand Down
18 changes: 7 additions & 11 deletions examples/nextjs/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import dynamic from 'next/dynamic'
import "./Example.css"
import { ItemsList } from "./items-list"
import type { Item } from "./types"
import { preloadShape } from "@electric-sql/react"
import { itemShapeOptions } from "./items"

// Dynamic import of ItemsList with SSR disabled
const ClientItemsList = dynamic(
() => import('./items-list').then(mod => mod.ItemsList),
{
ssr: false,
}
)
export default async function Page() {
await preloadShape<Item>(itemShapeOptions)

export default function Page() {
return <ClientItemsList />
return <ItemsList />
}
2 changes: 1 addition & 1 deletion examples/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"backend:up": "PROJECT_NAME=nextjs-example pnpm -C ../../ run example-backend:up && pnpm db:migrate",
"backend:down": "PROJECT_NAME=nextjs-example pnpm -C ../../ run example-backend:down",
"db:migrate": "dotenv -e ../../.env.dev -- pnpm exec pg-migrations apply --directory ./db/migrations",
"dev": "next dev --turbo -p 5174",
"dev": "next dev --turbo -p 5173",
"build": "next build",
"start": "next start",
"format": "eslint . --fix",
Expand Down
18 changes: 18 additions & 0 deletions examples/nextjs/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { AppProps } from "next/app"
import { HydrationBoundary } from "@electric-sql/react/HydrationBoundary"
import "@/app/style.css"
import "@/app/App.css"
import "@/app/Example.css"

export default function App({ Component, pageProps }: AppProps) {
return (
<HydrationBoundary>
<div className="App">
<header className="App-header">
<img src="/logo.svg" className="App-logo" alt="logo" />
<Component {...pageProps} />
</header>
</div>
</HydrationBoundary>
)
}
19 changes: 19 additions & 0 deletions examples/nextjs/pages/ssr/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { GetServerSideProps } from "next"
import { preloadShape } from "@electric-sql/react"
import { itemShapeOptions } from "@/app/items"
import { Item } from "@/app/types"
import "@/app/App.css"
import "@/app/style.css"
import { ItemsList } from "@/app/items-list"

export const getServerSideProps: GetServerSideProps = async () => {
await preloadShape<Item>(itemShapeOptions)

return {
props: {},
}
}

export default function Page() {
return <ItemsList />
}
10 changes: 10 additions & 0 deletions packages/react-hooks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
},
"./HydrationBoundary": {
"import": {
"types": "./dist/hydrationBoundary.d.ts",
"default": "./dist/hydrationBoundary.mjs"
},
"require": {
"types": "./dist/cjs/hydrationBoundary.d.cts",
"default": "./dist/cjs/hydrationBoundary.cjs"
}
}
},
"files": [
Expand Down
79 changes: 79 additions & 0 deletions packages/react-hooks/src/hydration-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use client'
import React from 'react'
import { hydrateShape, dehydrateShape, HydratedShapeData } from './hydration'
import { shapeCache, streamCache, sortedOptionsHash } from './react-hooks'
import { Shape, ShapeStream } from '@electric-sql/client'
import type { Row } from '@electric-sql/client'

const isSSR = typeof window === 'undefined' || 'Deno' in globalThis

type ElectricScriptProps = {
shapes: Shape<Row<unknown>>[]
}

const ElectricScript = ({ shapes }: ElectricScriptProps) => {
if (!isSSR) {
return null
}

const isLoading = shapes.some((shape) => shape.isLoading())

if (isLoading) {
return null
}

const hydratedShapes = shapes.reduce<HydratedShapeData[]>(
(hydratedShapes, shape) => {
return [...hydratedShapes, hydrateShape(shape)]
},
[]
)

return (
<script
id="__ELECTRIC_SSR_STATE__"
type="application/json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(hydratedShapes),
}}
/>
)
}

export function HydrationBoundary({ children }: { children: React.ReactNode }) {
const shapeCacheRef = React.useRef<typeof shapeCache>(shapeCache)
const streamCacheRef = React.useRef<typeof streamCache>(streamCache)
const isHydratedRef = React.useRef<boolean>(false)

if (!isSSR && !isHydratedRef.current) {
const ssrData = document?.getElementById('__ELECTRIC_SSR_STATE__')
const hydratedShapes: Array<HydratedShapeData> =
JSON.parse(ssrData?.textContent ?? '[]') ?? []

for (const hydratedShape of hydratedShapes) {
const isEmpty = Object.keys(hydratedShape.value).length === 0

if (isEmpty) {
continue
}

const shape = dehydrateShape(hydratedShape)
const stream = shape.stream as ShapeStream<Row<unknown>>

const hash = sortedOptionsHash(shape.stream.options)
streamCacheRef.current.set(hash, stream)
shapeCacheRef.current.set(stream, shape)
}

isHydratedRef.current = true
}

const shapes = Array.from(shapeCacheRef.current.values())

return (
<>
{children}
<ElectricScript shapes={shapes} />
</>
)
}
37 changes: 37 additions & 0 deletions packages/react-hooks/src/hydration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type {
Row,
ShapeStreamOptions,
GetExtensions,
} from '@electric-sql/client'
import { ShapeStream, Shape } from '@electric-sql/client'

export type HydratedShapeData<SourceData extends Row<unknown> = Row<unknown>> =
{
value: Record<string, SourceData>
options: ShapeStreamOptions<GetExtensions<SourceData>>
}

export const hydrateShape = <SourceData extends Row<unknown>>(
shape: Shape<SourceData>
): HydratedShapeData<SourceData> => {
return {
value: Object.fromEntries(shape.currentValue),
options: {
...shape.options,
handle: shape.handle,
offset: shape.offset,
},
}
}

export const dehydrateShape = <SourceData extends Row<unknown>>(
hydratedShape: HydratedShapeData<SourceData>
): Shape<SourceData> => {
const stream = new ShapeStream<SourceData>({
...hydratedShape.options,
live: true,
})
const shape = new Shape<SourceData>(stream)
shape.currentValue = new Map(Object.entries(hydratedShape.value))
return shape
}
Loading