electric icon indicating copy to clipboard operation
electric copied to clipboard

feat: ssr improvements

Open MarioSimou opened this issue 10 months ago • 6 comments

I dedicated some time to experimenting with the code above and made several important enhancements:

  1. Refactoring ElectricScripts:

    • I moved ElectricScripts inside ElectricProvider. This change allows ElectricProvider to manage the following:
      • Server-Side: It ensures that a <script> tag containing __ELECTRIC_SSR_STATE__ is added to the document.
      • Client-Side: It guarantees that __ELECTRIC_SSR_STATE__ is correctly parsed on the client, allowing it to be set in the state and facilitating successful hydration.
      • Serialization Utilities: I included utilities for serialization and deserialization to streamline this process.
  2. Support for RSC:

    • I added support for React Server Components (RSC). When a server component is executed, we now preload its shape, serialize it, and pass it as a prop to a client component. The server component handles the remaining tasks by transferring the shape to the client.
  3. useShape

  • Improved the useShape hook allowing to accept an already initialized shape as a parameter.

Related to #2232 and #2219

Examples

Next.js

  • Pages Router
// @/pages/_app.tsx
export default function App({ Component, pageProps }: AppProps) {
  return (
    <ElectricProvider>
      <Component {...pageProps} />
    </ElectricProvider>
  )
}

// @/pages/ssr/index.tsx
export const getServerSideProps: GetServerSideProps<{
  shape: SerializedShapeData<Item>
}> = async () => {
  const shape = await preloadShape<Item>(itemShapeOptions)
  return {
    props: {
      shape: serializeShape(shape),
    },
  }
}
export default function Page(props: { shape: SerializedShapeData<Item> }) {
  return <ItemsList initialShape={props.shape} />
}

// @/app/items-list.tsx
'use client'
export function ItemsList({
  initialShape,
}: {
  initialShape: SerializedShapeData<Item>
}) {
  const { data: items } = useShape({
    ...getClientShapeOptions(),
    initialShape,
  })

  // load items
}

  • App Router
// @/app/page.tsx
export default async function Page() {
  const shape = await preloadShape<Item>(itemShapeOptions)

  return <ItemsList initialShape={serializeShape(shape)} />
}

// @/app/items-list.tsx
'use client'
export function ItemsList({
  initialShape,
}: {
  initialShape: SerializedShapeData<Item>
}) {
  const { data: items } = useShape({
    ...getClientShapeOptions(),
    initialShape,
  })

  // load items
}

Todo

  • When a shape is transmitted from a server to a client component, we should consider removing the explicit call to serializeShape. Instead, we could implement a toString method within the Shape class that internally calls serializeShape. This approach would streamline the process and enhance code readability.
  • Additionally, we need to refine the implementation in client.ts. Currently, I am passing the isLive parameter to indicate that the stream should switch to live mode immediately. I'm pretty sure there is better approach on this.

MarioSimou avatar Jan 22 '25 10:01 MarioSimou

Good stuff!

We're busy getting Electric Cloud ready for the first folks next week so won't time for a bit to give this serious attention but a few thoughts:

  • my thought on naming is to copy tanstack/query as that has mindshare (and their names are good) — so HydrationBoundary etc. https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr
  • a user shouldn't need to pass hydrated data into useShape — when creating shapes, we can just look at the SSRed shapes and use them if available. Automatic hydration is good!
  • we don't want to return data after preloading as the data is already stored in a global cache so you don't need to explicitly pass anything around — see e.g. the remix example. You just preload in the loader and then the data is available in the component https://github.com/electric-sql/electric/pull/2232/files#diff-b1f39209f5094c949b3ba874893a9ae5da10eeca1f644489f05654aa6b172965

KyleAMathews avatar Jan 23 '25 01:01 KyleAMathews

Here are some of my thoughts:

  • I agree. Adopting naming conventions similar to those used in @tanstack/query is a logical approach.
  • Yes, ideally we don't want to pass an initialShape to the useShape function and let the hook to automatically determine the correct shape. However, this is not that straightforward because we rely on the options as the cache key and can't predict the handle and offset properties during the useShape call. Perhaps we could generate a key ignoring those properties?
  • I believe there will be cases where users need to handle shapes on the server, and the preloadShape function simplifies this process by eliminating the need for them to write extra code to retrieve shapes from the cache.

MarioSimou avatar Jan 24 '25 21:01 MarioSimou

However, this is not that straightforward because we rely on the options as the cache key and can't predict the handle and offset properties during the useShape call. Perhaps we could generate a key ignoring those properties?

Ah yes — handle/offset aren't part of the shape definition — they're just parameters for traversing the shape log — so yes, we shouldn't use them when generating the shape key.

I believe there will be cases where users need to handle shapes on the server, and the preloadShape function simplifies this process by eliminating the need for them to write extra code to retrieve shapes from the cache.

Sure yeah this could happen. Generally though I assume they'll preload in the server component and then the client component will have a useShape and grab the data during SSR from the server cache.

KyleAMathews avatar Jan 24 '25 21:01 KyleAMathews

Sure yeah this could happen. Generally though I assume they'll preload in the server component and then the client component will have a useShape and grab the data during SSR from the server cache.

Yes, but I assume we will still allow access to the data on the server only?

MarioSimou avatar Jan 25 '25 08:01 MarioSimou

Yeah for sure. It seems how it should work is that only data you pass into the HydrationBoundary would get sent through.

KyleAMathews avatar Jan 25 '25 23:01 KyleAMathews

I updated the code which looks like that now:

  • Pages Router
// @/pages/_app.tsx
export default function App({ Component, pageProps }: AppProps) {
  return (
    <HydrationBoundary>
      <Component {...pageProps} />
    </HydrationBoundary>
  )
}

// @/pages/ssr/index.tsx
export const getServerSideProps: GetServerSideProps<{
  shape: SerializedShapeData<Item>
}> = async () => {
  await preloadShape<Item>(itemShapeOptions)

  return {
    props: {},
  }
}

export default function Page() {
  return <ItemsList />
}

// @/app/items-list.tsx
'use client'
export function ItemsList() {
  const { data: items } = useShape(getClientShapeOptions())

  // load items
}
  • App Router
// @app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <HydrationBoundary>
           {children}
        </HydrationBoundary>
      </body>
    </html>
  )
}


// @/app/page.tsx
export default async function Page() {
  await preloadShape<Item>(itemShapeOptions)

  return <ItemsList />
}

// @/app/items-list.tsx
'use client'
export function ItemsList() {
  const { data: items } = useShape(getClientShapeOptions())

  // load items
}

Here are some of my thoughts:

  • We should consider modularising the exports from the @electric-sql/react package. Previously, all exports were bundled together in src/index.tsx, resulting in a single import from the consuming app. However, this approach isn’t ideal with react server components (RSC). Client-side components must use the use client directive in the output bundle, which interferes with code that is used on a server component and nextjs complains. Maybe we can update our exports to follow a model like this: server only, client only and hybrid.

  • While loading items from cache works well for Pages Router, this design is a bit problematic for the App router. A server component won't wait for the hydration script to load and populate the cache, so there are cases where a component tries to load and the cache is empty. I'm not sure how much of an issue this will be (I don't get a hydration error), but sometimes I get the below logs.

    • Allowing the server component to pass a shape as a prop to the client component, and then having the client component utilise the useShape hook with this shape as its initial option, appears to be a clearer approach and removes the dependency on cache. As such, I would suggest to still keep the initialShape option in the useShape hook. image

MarioSimou avatar Jan 27 '25 11:01 MarioSimou

Thanks for the contribution, and sorry we've let it get stale. I've closed the "parent" PR for this branch because it was also very stale and is currently superseded by work in TanStack DB (as per @samwillis).

icehaunter avatar Sep 24 '25 08:09 icehaunter