diff --git a/examples/nextjs/app/items-list.tsx b/examples/nextjs/app/items-list.tsx index 38c20df3f6..af9c4ab80f 100644 --- a/examples/nextjs/app/items-list.tsx +++ b/examples/nextjs/app/items-list.tsx @@ -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" @@ -49,34 +51,74 @@ async function clearItems() { export function ItemsList() { const shapeOptions = getClientShapeOptions() - const { data: rows } = useShape(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) - - return Object.values(uniqueItems) - }) + const { data: items } = useShape(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 + ) + + 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 + // ) + + // return Object.values(uniqueItems) + // }) const handleAdd = async () => { - const id = uuidv4() + const id = crypto.randomUUID() startTransition(async () => { updateOptimisticItems({ newId: id }) await createItem(id) diff --git a/examples/nextjs/app/items.tsx b/examples/nextjs/app/items.tsx index 3b203c130e..e674cc9e73 100644 --- a/examples/nextjs/app/items.tsx +++ b/examples/nextjs/app/items.tsx @@ -1,4 +1,3 @@ -import { preloadShape } from "@electric-sql/react" import { ShapeStreamOptions } from "@electric-sql/client" // Server-side shape configuration @@ -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 -} diff --git a/examples/nextjs/app/layout.tsx b/examples/nextjs/app/layout.tsx index dded27eb6a..dc6dd7d09c 100644 --- a/examples/nextjs/app/layout.tsx +++ b/examples/nextjs/app/layout.tsx @@ -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`, @@ -14,12 +16,14 @@ export default function RootLayout({ return ( -
-
- logo - {children} -
-
+ +
+
+ logo + {children} +
+
+
) diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx index 21bf0f38a5..6811458889 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -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(itemShapeOptions) -export default function Page() { - return + return } diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index b6ef1252eb..cd230b73bf 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -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", diff --git a/examples/nextjs/pages/_app.tsx b/examples/nextjs/pages/_app.tsx new file mode 100644 index 0000000000..831d244b6b --- /dev/null +++ b/examples/nextjs/pages/_app.tsx @@ -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 ( + +
+
+ logo + +
+
+
+ ) +} diff --git a/examples/nextjs/pages/ssr/index.tsx b/examples/nextjs/pages/ssr/index.tsx new file mode 100644 index 0000000000..21e0c6eff0 --- /dev/null +++ b/examples/nextjs/pages/ssr/index.tsx @@ -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(itemShapeOptions) + + return { + props: {}, + } +} + +export default function Page() { + return +} diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json index d221fae93c..e7df909bf6 100644 --- a/packages/react-hooks/package.json +++ b/packages/react-hooks/package.json @@ -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": [ diff --git a/packages/react-hooks/src/hydration-boundary.tsx b/packages/react-hooks/src/hydration-boundary.tsx new file mode 100644 index 0000000000..0fc67ab3e2 --- /dev/null +++ b/packages/react-hooks/src/hydration-boundary.tsx @@ -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>[] +} + +const ElectricScript = ({ shapes }: ElectricScriptProps) => { + if (!isSSR) { + return null + } + + const isLoading = shapes.some((shape) => shape.isLoading()) + + if (isLoading) { + return null + } + + const hydratedShapes = shapes.reduce( + (hydratedShapes, shape) => { + return [...hydratedShapes, hydrateShape(shape)] + }, + [] + ) + + return ( +