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

feat: ssr improvements #2237

wants to merge 3 commits into from

Conversation

MarioSimou
Copy link

@MarioSimou MarioSimou commented Jan 22, 2025

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 MarioSimou changed the title feat: ssr improrvements feat: ssr improvements Jan 22, 2025
@KyleAMathews
Copy link
Contributor

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:

@MarioSimou
Copy link
Author

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.

@KyleAMathews
Copy link
Contributor

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.

@MarioSimou
Copy link
Author

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?

@KyleAMathews
Copy link
Contributor

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

@MarioSimou
Copy link
Author

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

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.

2 participants