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

Next.js SSR example #1596

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open

Next.js SSR example #1596

wants to merge 16 commits into from

Conversation

balegas
Copy link
Contributor

@balegas balegas commented Aug 29, 2024

Items example using SSR.

Load the entire shape on the server and send it down to the client, so that the client gets the initial state of the shape on first response and can resume replication from the tip of the shape stream.

First time with nextjs, I still don't know how some things work. I suppose this is good forn an example. I'm happy to receive feedback from nextjs experts :D

Copy link
Contributor

@KyleAMathews KyleAMathews left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few comments.

Don't you want to use either https://nextjs.org/docs/pages/building-your-application/data-fetching/get-server-side-props or https://nextjs.org/docs/pages/building-your-application/data-fetching/get-static-props here? Right now I don't see that any data is being cached during server rendering.

EDIT: Oh I see... this is RSC stuff

examples/nextjs-ssr-example/app/Home.tsx Outdated Show resolved Hide resolved
examples/nextjs-ssr-example/app/Home.tsx Outdated Show resolved Hide resolved
examples/nextjs-ssr-example/app/shape.tsx Outdated Show resolved Hide resolved
packages/typescript-client/src/client.ts Outdated Show resolved Hide resolved
examples/nextjs-ssr-example/app/page.tsx Outdated Show resolved Hide resolved
@balegas balegas force-pushed the balegas/ssr-example branch from 64f3703 to 2d933e8 Compare October 3, 2024 00:10
Copy link

netlify bot commented Oct 3, 2024

Deploy Preview for electric-next ready!

Name Link
🔨 Latest commit dc9da79
🔍 Latest deploy log https://app.netlify.com/sites/electric-next/deploys/673b23a1b567730008140042
😎 Deploy Preview https://deploy-preview-1596--electric-next.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

@samwillis
Copy link
Contributor

First, I think there is going to be two ways people want to do SSR with useShape:

  1. Fetch the full snapshot on the server, render it, and then include the snapshot in the HTML sent to the client. This allows the client to just continue from where it was in the stream (just as this PR does). This would make sense for smaller shapes - such as a single issue + comments on a detail view.

  2. Render a subset of the data on the server, not include the snapshot in the HTML sent to the client, and then fetch the full snapshot on the client. This would make sense for larger shapes - such as a very long list of issues, just rendering the "above the fold" on the server. Refetching already rendered data is ok in this situation.

I think we want to provide tooling for both of these cases.


On 1 - snapshot of the shape and resume

While the pattern in this PR works, having to pass the shape snapshot to the component using useShape via props is a little messy. It would be great if we could provide a cleaner API that "just works" with SSR.

I think this is doable if we create a <ShapeSsrStore> (or similar) component that the user wraps their app in. This component will keep track of all useShape calls done during SSR and then inject a <script> tag with the JSON of those shapes as a global (keyed by shapeId?). This can then be read back and used when hydrating the useShapes on the client.

We are somewhat restricted as we cant use a react context, but something like this POC is possible: https://github.com/samwillis/ssr-shape-poc (I've purposely not used any electric, just wanted to test the concept for injection)

By the user adding <ShapeSsrStore>, all useShapes would SSR with automatic resume. (We could make this opt in with a flag to the useShape options)

This is also not tied to Next, and should work with all React SSR frameworks.

image

On 2 - a minimal subset on the server

I think we should add a getServerSnapshot option to useShape that is only run on the server. The user can configure this to do a direct Postgres query to fore a subset of data for SSR. This would override the shape URL can just any snapshotting from option 1 above.

useShape({
  url: `http://localhost:3000/v1/shape/foo`,
  getServerSnapshot: () => {
    // Do and return a Postgres query
  },
})

@thruflo
Copy link
Contributor

thruflo commented Oct 8, 2024

Great stuff @samwillis :)

On 2, I wonder if there's a way of supporting order_by and limit of the shape results? An arbitrary query is powerful but means the user needs to format the results in the same way as the shape data. This will become harder as and when we support nested associations. So is there an approach where the user could order/limit/(filter?) the shape when convenient and then drop down to a function when that's not expressive enough?

@balegas balegas force-pushed the balegas/ssr-example branch from 02ffd0d to dd64a75 Compare October 25, 2024 11:59
Copy link
Contributor Author

@balegas balegas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@samwillis inspired on your example and Nextjs docs, I gave another try. The idea here is to have a context around the component that takes the server-sent data from props and initializes the shapes on the client --- totally transparent on the client component!

Left some comments

examples/nextjs-ssr-example/app/ssr-shapes-provider.ts Outdated Show resolved Hide resolved
packages/react-hooks/src/react-hooks.tsx Show resolved Hide resolved
examples/nextjs-ssr-example/app/page.tsx Outdated Show resolved Hide resolved
packages/typescript-client/src/shape.ts Show resolved Hide resolved
examples/nextjs-ssr-example/app/Home.tsx Outdated Show resolved Hide resolved
@balegas
Copy link
Contributor Author

balegas commented Oct 25, 2024

Render a subset of the data on the server,

@samwillis, @thruflo my initial idea was to call the database directly on the server. This is a powerful pattern to show, but because of the problems with ordering and paging, I thought it would be better to build just on Shapes. The benefit of Shapes is that requests go through the cache and doesn't put load on the server db.

A naive way of rendering the subset of data on the server is to just drop the data that doesn't fit into the initial page rendering :)

@balegas balegas force-pushed the balegas/ssr-example branch from 887f224 to 2d8330e Compare November 9, 2024 00:14
@balegas
Copy link
Contributor Author

balegas commented Nov 12, 2024

@samwillis , I did a DX refactoring addressing your concerns with it being simple to specify the shapes across server and client. Have a look at it or ping me to see together.

I also want to replace the base nextjs example with this one (cc @KyleAMathews)

@balegas balegas self-assigned this Nov 12, 2024
@balegas balegas force-pushed the balegas/ssr-example branch from 0eccfa5 to 1b3880d Compare November 13, 2024 00:04
@balegas balegas force-pushed the balegas/ssr-example branch from 1b3880d to dc9da79 Compare November 18, 2024 11:23
@balegas balegas marked this pull request as ready for review November 18, 2024 11:23
Copy link
Contributor

@@ -62,7 +62,7 @@ jobs:


- name: Deploy NextJs example
working-directory: examples/nextjs-example
working-directory: examples/nextjs-ssr-example
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a naming nit: there's no need to suffix with -example, that's communicated by the parent folder name, so I would suggest examples/nextjs-ssr.

@@ -0,0 +1,22 @@
# Next.js SSR example
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally, the README for an example should ideally communicate a bit about what it's trying to show you, i.e.: what is the example, why is it interesting, what can you achieve with the pattern it demonstrates, etc. Plus perhaps some signposts into key parts of the code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will work on that when we decide to publish

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're purple nowadays/

} from '@electric-sql/client'
import React from 'react'
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector.js'

type UnknownShape = Shape<Row<unknown>>
type UnknownShapeStream = ShapeStream<Row<unknown>>
export type SerializedShape = {
offset: Offset
shapeHandle: string | undefined
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another very minor nit but seeings how this is in the react-hooks library not just the example, I've been trying to move us towards just using handle rather than shapeHandle.

Below when you getSerialisedShape you can also just read it from shape.handle rather than shapeStream.shapeHandle.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, i started this a while back, things like the offset were not public at the time. Will cleanup.

const shape = getShape(shapeStream)
return {
shapeHandle: shapeStream.shapeHandle,
offset: shapeStream.offset,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does offset work? Is the property not lastOffset?

@@ -245,6 +245,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
return this.#shapeHandle
}

get offset() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is duplicated now -- we already expose lastOffset as a public getter.

@gruckion
Copy link

gruckion commented Jan 7, 2025

I’ve read over the comments on the front page. Not dug into the files changed yet.

I believe this approach should be using hydration and suspense.

https://react.dev/reference/react-dom/client/hydrateRoot

https://react.dev/reference/react/Suspense

An excellent reference / role model would be Tanstack Query. Currently I use it and tRPC.

My current solution using Tanstack Query and tRPC is;

import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
import { HydrateClient, tRPCServerApi } from "@/trpc/server";

import { Suspense } from "react";
import { SiteDataTable } from "../components/site-table/site-data-table";

import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Company Sites",
  description: "Company sites."
};

export default async function CompanySites(props: {
  params: Promise<{ id: string }>;
}) {
  const params = await props.params;
  await tRPCServerApi.site.getAllCompanySites.prefetch({
    companyId: params.id
  });

  return (
    <HydrateClient>
      <Suspense
        fallback={
          <div className="space-y-4 p-2 pt-8">
            <DataTableSkeleton
              columnCount={5}
              searchableColumnCount={1}
              filterableColumnCount={1}
              cellWidths={["12rem", "14rem"]}
              withPagination={true}
              shrinkZero
            />
          </div>
        }
      >
        <SiteDataTable companyId={params.id} />
      </Suspense>
    </HydrateClient>
  );
}

Here you can see this page.tsx allows me to grab the data whilst the page it’s self is requested. The page is sent to the client immediately with the fullback. Once the data is obtained on the server it is streamed to the frontend and gets hydrated.

I also use the new partial pre-rendering. Which works very well in this case. As the initial page and the fallback is stored in a CDN and the page loads instantly. Again, because the request for the page is handled by the server sending back the CDN result and later hydrating the client works seamlessly.

Inside the SiteDataTable, which is a client component, we use the normal useQuery hook. But since we’re using it from within the HydrateClient boundary when the useQuery triggers on the client instead of performing a round trip to the server to fetch the data it instead caches the hydration cache and loads the data from there.

Again because the data was requested at the same time as the page and streamed into the hydration cache the data is already in the client ready to be hydrated.

  const [data] = trpcClient.site.getAllCompanySites.useSuspenseQuery({
    companyId
  });

Then in the client the useQuery call is actually done using useSuspenseQuery. As this will look in the hydration cache. If the prefetch call wasn’t made it will just fetch the data as usual as well.

At the moment I am considering ElectricSQL vs tRPC streaming both use server sent events (SSE) and currently given the above code I already use tRPC offers a nice fit with the existing codebase and a very nice API.

https://trpc.io/docs/server/subscriptions

Copy link

@gruckion gruckion left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work 👍

@@ -0,0 +1,120 @@
"use client"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -0,0 +1,120 @@
"use client"

import { v4 as uuidv4 } from "uuid"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid external dependencies. You do not need one for a cryptographically secure uuid anymore.

https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID

https://x.com/benlesh/status/1857451931427352654?s=46

}

export default function Home() {
const { data: items } = useShape(getProxiedOptions(options)) as unknown as {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As unknown as any? Can generics not be used here?

const itemsMap = new Map()
state
.concat([{ id: newId, created_at: new Date().getTime() }])
.forEach((item) => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear
</button>
</form>
{optimisticItems
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid using sort and map directly in the render response. Use a cost above with useMemo.

Also do not use index as the key.

https://react.dev/learn/rendering-lists#why-does-react-need-keys

description: `Example application with forms and Postgres.`,
}

export default function RootLayout({
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React.PropsWithChildren

table: `items`,
}

const Page = async () => (
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this async? Does the shape can use react hydrate?

children: React.JSX.Element
options: SerializedShapeOptions[]
}) {
const clientShapes: SerializedShape[] = []
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use useMemo

@@ -0,0 +1,44 @@
export async function GET(request: Request) {
const url = new URL(request.url)
const originUrl = new URL(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleaner to just throw if the environment variable is missing or use an escape guard.

// ensure shape is not syncing on the server
const serverOptions: Partial<ShapeStreamOptions> = {}
if (typeof window === `undefined`) {
const controller = new AbortController()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using fetch and AbortControllers adds a lot of bloat. Maybe use useQuery which has solved this problems already and provides a beautiful API.

Tanstack react query

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants