redwoodjs / redwood

The App Framework for Startups
https://redwoodjs.com
MIT License
17.07k stars 979 forks source link

[RFC]: Render Modes - your input needed! #6760

Open dac09 opened 1 year ago

dac09 commented 1 year ago

đź‘‹ Hello Redwood community, this is your chance to shape one of the major features in upcoming versions of Redwood! Let's see if we can build this feature together!

How you can help!

  1. Be Kind! This isn’t about Redwood vs X - we love other frameworks in the JS community - they all bring something unique, and we hope that Redwood adds to it. There’s no need to choose sides!
  2. Read through the RFC - do the APIs and concepts it introduces feel intuitive to you?
  3. Tell us about how you would use it on your project - go into detail about why you can’t achieve this without render modes - and how you see this improving your product/workflow/dx. Tell us about what you are building and your existing workarounds!
  4. Do you use something similar in another framework that you think is worth us exploring?
  5. Are we missing any features you were expecting?
  6. Look through part IV - there’s some fundamental questions here about the concepts - we would love your views!

I. Summary

Introduce the concept of “render modes” - a prop in the Routes file to determine how a Route (i.e. page) will be rendered. Render modes change two things:

  1. Where the route is rendered - server or client
  2. How the route is rendered - as HTML-only, a hydrated JS page, a hydrated JS page with dynamic HTML <head> tags
// web/src/Routes.tsx

<Routes>                  {/*👇 set mode */}
  <Route name="home" component={HomePage} renderMode="static"/>
  <Route name="dashboard" component={Dash} renderMode="server"/>
</Routes>

Proposed render modes are:

The reason we offer render modes, is so that you can choose the level of complexity you want in your application. Everything in software is a tradeoff!

The more you render on the server, the higher the complexity is - you need to make sure your page (and all of its downstream components) can be rendered server-side correctly - just like prerendering.

Somewhat ironically too - although server-rendering can improve performance in most cases, as your app gets popular unless your infrastructure can keep up with the number of requests, it may actually degrade performance.

II. **Other new concepts:**

Route hooks & Server data

In Redwood V3 we introduced the concept of route hooks to provide routeParameters when you are prerendering a route with a dynamic route parameter. Prerender runs at build time, so before a route is rendered, the build process will call the routeParameters function, before trying to render the route.

When doing server or meta rendering - a similar process happens, but this time during runtime. i.e. During the request → hook → render → response cycle. You may choose to run some additional code on your server, before rendering your route. This is analogous to getServerSideProps in NextJS and dataLoaders in Remix - where you can run a function to define some values, eventually supplied as props to your component. In Redwood though, we’re proposing receiving these values with a hook.

Untitled-2022-10-28-1625

This has two parts to it:

  1. serverData - a function defined in your page.routeHooks.ts file that will return whatever you want to provide to your page. This function runs right before your route is rendering but **on the server.** This means you have direct access to your DB, or can do fun stuff like dynamic OG image generation. But careful…. you could just break rendering if your serverData hook is computationally too expensive!
  2. useServerData - a react hook, that lets you consume the data in your components. Remember that server data is provided at page or route level, so if you use the useServerData in a nested component, it’ll just load your route’s data.

There are some edge-cases here that need fleshing out - and is covered in section IV-V

Preload directives & Injecting the correct chunk

I’ve done a bit of research into how best to inject the correct bundle on first render with Vite, and to preload chunks where required. The rough idea around this is:

A. at build time, create a map of route to relevant code-split chunk e.g.

{
 '/dashboard/teams': 'Teams.aslknasdg.js',
 '/dashboard/jobs': 'Jobs.alkn135.js'
}

**B.** When rendering on the server, ensure that the correct bundle is injected (alongside any main/base bundle)

C. If the page contains links to other pages, we need to hold on to this list (new feature on RWRouter), and add preload directives onto the page for each of them

D. On hover of a link, we can also load the relevant bundle


III. Prerequisites


IV. Outstanding Conceptual Questions aka Help wanted!

  1. Where do you set cache control headers?

Any render modes involving the server benefit from setting cache control headers (and allow doing things like stale-while-revalidate). Options:

a) Define it with additional props on the Route

// web/src/Routes.tsx

<Routes>
<Route 
  renderMode="static" 
  name="home" 
  component={HomePage} 
  // either as object or string
  cacheHeaders="Cache-Control: max-age=1, stale-while-revalidate=59"
/>
<Route
  ...
/>
<Route
  ...
/>

b) In the routesHook file

// web/src/pages/HomePage/HomePage.routeHooks.ts

// getCacheHeaders essentially - called after rendering the page
export const cacheHeaders = (req, html) => {
  return {
    'Cache-Control': 'max-age=1, stale-while-revalidate=59',
  }
}

In both cases, we could abstract the specific header names - but not sure if there’s much value in abstracting them.

  1. What is the benefit of prerendering server-rendered pages?

I’m not sure if there’s any advantage to this (apart from the first request maybe). Curious if the community has any thoughts/usecases that might make this worth it? Otherwise we will just disable prerendering, unless your renderMode is client

It may be an anti-pattern to serve prerendered files via a server too - especially in serverless where we would access the filesystem for each request. Serverful would allow us to load all the content once.

  1. Suggestions for the names of the render modes

Do they make sense to you? Does it sufficiently describe the choice you are making? Some other terms I’ve considered:

  1. Automatically Composing serverData hooks I’m not sure if this is even desired. But let’s say you have /dashboard/profile and /dashboard/team . Each page can have a different routeHook - but what if I want to share the same hook? You can always call another serverData hook in your currnet Hook

This would require the router to support nested routing - which has certain pitfalls we've intentionally avoided for a while.

  1. Capacity based rendering

A huge downside of server-rendering that often gets overlooked is “what happens if my rendering server starts choking?”. This can happen for any number of reasons - e.g. maybe the database you’re accessing during the render has run out of connections. Or maybe, one of the pages has a memory leak and crashes - does this mean all your users should suffer?

I’d like to explore how we can fallback to client side rendering - we can leverage fastify plugins for this potentially, when our server is running out of capacity. Importantly - what does this mean in terms of how you build your app? If you are expecting to do SSR, maybe you are using routeHooks to provide some data to your page, what happens to this data if we fallback to client rendering?

  1. Auth & Server-side rendering

Currently all supported Redwood auth providers except dbAuth stores auth tokens in the browser storage which is only available client side. We may need to transition to using cookies to be able to render authenticated pages on the server. Concepts to consider:

  1. Deploying to CloudFlare/V8 Isolates

My knowledge on this is very shallow. I’d like to understand a bit more about what it would take to run a Fastify server with React running on Vercel’s middleware, or Cloudflare’s workers.

Importantly, from a Redwood user’s perspective it should all be transparent and JustWork™. One of our core tenants has been to not distract the user from doing what they want to do - build their app - by introducing concerns/complexity around infrastructure until they are ready to deal with it.

  1. Where do we draw the line between keeping logic in GraphQL vs serverData hooks?

~I’m not clear on this - and I worry that we may make it confusing for users.~

See update on this below https://github.com/redwoodjs/redwood/issues/6760#issuecomment-1347989389

V. Other Practical Things

  1. The code for loading chunks during navigation has to be refactored in the router - this is a long standing problem, even with prerendering.

    Currently, even when the page has been rendered by the server, it gets ****re-rendered**** on the client (presumably because JS on the client side will set a blank loader while it loads the chunk)

  2. We need to refactor the Router to load pages under Suspense. This will simplify the server-side rendering process significantly (as we’ll not need to introduce additional babel code and vite plugins to handle require statements).

  3. We need to measure the impact of server side rendering

    Not super clear to me how we do this yet, perhaps we use a tool like k6. Things we would need to check in particular:

    • Nested Cells (i.e. waterfalls) - and how this impacts performance / scalability. Render modes can remove waterfalls completely - because all the cells in the render path can run their queries on the server…. but we need to measure at what cost.
    • Serving static routes from a frontend server with cache headers vs serving the same file from CDN.
    • Running things like playwright in the serverData hook, or an external service to give you a custom OG image
  4. Handle <Redirects/>

    It’s not currently possible with rwjs/router to Redirect a user with the correct HTTP status code with the Router. What we’ll need to do is gather all the Redirects in a render path, and at the end send a 301/302 with the final path.

  5. Under the current proposal the FE server is separate from the Graphql/Backend server

    This has certain advantages e.g. if you change your SDL for your GraphQL your frontend does not need to restart - this is a common problem in the Next/Remix world (I think!). The architecture is more resilient as well - if your frontend server crashes, there’s no reason your mobile apps shouldn’t continue working with the API.

    But it does bring more complexity - we are deploying two separate apps in essence - deployments can go out of sync and we are blurring the lines between FE and BE. If we use workspaces (like we currently do) - we may need to think about how to communicate that features like service caching are independent and may not be available to the frontend server.

Are you interested in working on this?

joseph-lozano commented 1 year ago

Is there a proposal for a default rendering mode? Or would this be a required option for every route?

ehowey commented 1 year ago

First of all huge kudos for that write up - it was clear and thorough!

FWIW I haven't used Redwood yet in production at all, played around with it lots but my production stuff is all Gatsby/NextJs as I tend to do small/medium projects as a solo dev. So take this feedback with a grain of salt, probably not worth as much as someone who has a big project running in it.

At first glance I really like your naming choices, except for "meta" - that brings up connotations of the company for me. You suggested "head" as an alternate; I wonder about specifying what it does - "seo" since this is the purpose of that rendering mode. I actually find the breakdown of "server" and "client" easier to understand than "ssr" and "csr". You could do "server" and "browser" as well but I think that is a toss up for me, client and browser both work well. I know when I first getting back into webdev a few years ago the client, server, ssg, ssr, isr lingo all really confused the hell out of me.

One of the use cases I have simmering in the background are some ideas around a fully integrated electronic health record and user facing website - think Shopify but targeted at psychologists, social workers, and other allied health professionals (OT, SLP, PT, etc.). This is my actual field of work (mental health) and the software we have to use is AWFUL. I have picked away at this idea a bit both using Redwood and NextJS. The use case I am most curious about is mixing a marketing site (mostly static, should be SSG) and an app site (mostly dynamic, should be CSR or SSR). Right now it seems like it might be easier to build the "app" in Redwood and the marketing site in NextJS. Which I know is an option but I like where this proposal is heading, and especially if we get the option to add more "sides" in the future it really improves the DX for these kinds of situations.

The other thing reading through your info got me thinking about was i18n - the last project I completed we had to handle a few different languages, and multiple languages per region/locale. I used middleware from NextJS to deal with this and it was a good UX and DX doing it that way. Is this sort of like what the server hooks would achieve? Intercept the request and do something based on that request? This is how I imagine using them anyways.

Keep up the great work!!

dac09 commented 1 year ago

Is there a proposal for a default rendering mode?

@joseph-lozano Thank for bringin this up and its a good point. The default rendering mode will be client (like it is right now). The idea behind it is that you only opt-in to more complexity, only when you need it.

As far needing a FE server - the server isn't something you'll have to configure:

It is more complexity, but not much - in every case all you need to do is setup a deploy with yarn rw setup deploy {provider}. Let me know if this makes the idea more palatable for you!


@ehowey - thanks for the feedback, very useful! Appreciate you spelling out your thoughts in such detail ✌️

the last project I completed we had to handle a few different languages, and multiple languages per region/locale

Yes - you could use serverData hooks to achieve the same. Using edge functions/middleware might still be useful though - because these functions run closer to your user. Maybe when renderModes is in alpha I could ask you to take it for a spin for this use

The use case I am most curious about is mixing a marketing site (mostly static, should be SSG) and an app site (mostly dynamic, should be CSR or SSR)

This is exactly the use case render modes are perfect for.

joseph-lozano commented 1 year ago

@dac09 Yeah, after thinking about it a little, I came around that a FE server is probably necessary for RSC(which is why I deleted that part of the comment, though I probably should have just edited it).

I still find it strange that the request will go from the browser -> FE server -> API Server -> Database before making the round trip. I am not running Redwood in production, but some additional concerns are how to scale the FE server separate from the API server (on render and fly), and whether this would significantly increase costs (for serverless deployments including Vercel)

I wonder if it would be feasible for this to be something to opt out of entirely (i.e., run a static FE instead of a server FE) or if that would increase the matrix of deployment options past unmaintainability.

banjeremy commented 1 year ago

Nice work! I can drop some thoughts as I read through.

Where do you set cache control headers?

routesHook feels cleaner to me. A function with the signature like in your example. Maybe generalize and call it headers?

Suggestions for the names of the render modes

static, server, and client are the most intuitive to me. Can <MetaTags/> render other <head> elements like <link> and <script>? If no, maybe meta is more accurate than head.

I hadn't considered only rendering the meta tags server side. Though for the Open Graph use case, I might still reach for full server-side rendering. Currently when I'm rendering Open Graph tags, I end up using the same (or similar) query in the page body. It would feel wasteful to stop at </head> only to query it again client side.

dac09 commented 1 year ago

I hadn't considered only rendering the meta tags server side. Though for the Open Graph use case, I might still reach for full server-side rendering. Currently when I'm rendering Open Graph tags, I end up using the same (or similar) query in the page body. It would feel wasteful to stop at only to query it again client side.

This is really great insight @banjeremy! I agree that the metaTags or meta function should receive the data returned from the serverData hook. Basically the flow would look like request -> serverData() -> metaTags(serverData)

However, this begs a couple of questions I'm struggling with:

  1. if you fetch data using a cell, then update, lets say the title and description meta tags - meta rendering with a routeHook would not have the desired effect. This is because the Cell would fetch the data only on full SSR (like you were suggesting), not on meta rendering. However, if you used a serverData hook to pass the data (without a Cell) - it would. I'm not liking the two competing patterns here.

Perhaps we shouldn't have meta rendering in the first iteration, and add it when we discover more usecases.

  1. The concept of fetching data with Cells seems at odds with what feels natural when you have a serverData hook. The temptation is just to call your DB or a service in the serverData hook right? But then... you've locked yourself into not having an API when you want to build a similar interface on your mobile app/other frontend/desktop app.

I'm wondering if you have any thoughts on this? Just to note however, I think Cells make total sense conceptually in the server components/streaming world.... feels like we're a little in limbo here with the two patterns developing in the React ecosystem.


More updates/clarification on this later today!

dac09 commented 1 year ago

Updates on Mon 14 Nov 22:

Changelog and explanation below:

Deployment partner support

I) Not all providers may support server rendering

It looks like you can configure netlify to work with a server (see Remix https://docs.netlify.com/integrations/frameworks/remix/)

When/how Server data is triggered

Navigation & fallback of server data*

After a bit more research with other frameworks and workflows - Next, Remix, vite-plugin-ssr - I’m leaning towards keeping the concept the same.

Different scenarios for when/how the serverData hook is triggered:

In all cases, the serverData hook runs in the FE server's NodeJS context. It just gets called differently either as part of the render path, or via an XHR request

Clarifications on how auth will work

It makes sense to centralize the auth validator logic in the top level ****app.routeHooks.ts**** file.

Example flow:

Home
  Click on /dashboard
    Fetch (/dashboard?_routeHook={params}) <-- send JWT token from Auth0
      Run serverData hooks for app.routeHooks.ts AND dashboard.routeHooks.ts
            // app.routeHooks.ts

      // Top level app level serverData hook
      export serverData = ({request}) => {
        const jwtToken = request.params.authToken

        const decodedToken = auth0.verify(jwtToken)

        return createOrUpdateCookie({
          userId: decodedToken.id,
          email: decodedToken.email
          //...
        })
      }

✅ This means that we don’t need to repeat the auth verification code in every routeHook

⚠️ This means we need to compose serverData hooks (atleast one level) -

  1. run const appData = app.routeHooks.ts[’serverData’](req)
  2. run const dashboardData = dashboard.routeHooks.ts['serverData'](request, appData)

❓ I still need to look into more how we can share the auth validation logic with the api side

dac09 commented 1 year ago

Updates on 13 Dec 22:

I have a little more clarify after having spent some more time on this with Kris and Tobbe, and just letting the ideas percolate a little!

Are serverData hooks just for making GraphQL queries?

I finally have a good example of where you would use a serverData hook, not just to fetch data from your GraphQL API - content and internationalisation.

If you are hosting your content on an external CMS (like Contentful, Storyblok, Strapi) - this data has to be fetched from some sort of an API. This is where serverData hooks would come in really handy, and you could significantly reduce the rendering time, by fetching your content in the serverData hook instead of a Cell.

Prepopulating Cell data using the server data hook

When you use Remix’s data loaders, or Next’s getServerSideProps , you create a function that will be run before navigating to your page i.e. a route hook. Importantly this code always run on the server -

a) If you are navigating to the URL for the first time (or a hard refresh), the flow would look like:

image

So the browser essentially waits till both the serverData hook finishes fetching data and rendering is done by the server

b) You just clicked on a link on a page, so it does client side navigation. In this case, your data has to be fetched using an async API call (i.e. fetch)

image

What’s happening here is that your serverData hook is still executed on the server, sends some data back as JSON, which then gets injected into your components before they are rendered on the client.

So how would you get your Cell content as a serverData hook? You need to “register” your query in the server data hook:

// DashboardPage.routeHooks.ts

import { QUERY as MyCellQuery } from 'src/components/MyCell' 

import { makeGqlQuery } from '@redwoodjs/web'

export const serverData = ({ params }) => {
    // 👇 Key step
    const cellData = await makeGqlQuery(MyCellQuery, { variables: { id: params.id } })

    const content = await fetchTextFromCMS('dashboard.content.main')

    return { 
        data: {
            content, 
        // 👇 pass it through here, name of variable TBC
            cellData
       }

   }

}

When you Cell is rendered on the client side, internally it will check if it’s data is already present, and render the success component.

But why aren’t you just rendering all Cells on the server?

One of the things we’re trying to achieve is to the fetching-data-step independant of the rendering-step, so that we can fetch your data using an API call when doing client side navigation. i.e. Get your Cell data, without having to render the whole page upto and including the cell.

If you do not add the lines to cache your cell query, your Cell will be rendered in the loading state by default, and fetch data from the browser (like it does now).

Why bother rendering the Cell at all?

In the majority of cases when you want a dynamic meta tag (e.g. the title of the page changes based on the article’s title) - you want to use the data that you’d normally use your Cell to fetch.

Why have a Cell in the first place? In theory I could pull data directly from my database in the serverData hook

Totally valid question - the main advantage of having your data exposed via the GraphQL API is that it works for more than one client. If you have multiple web apps, or a mobile app, this data remains easy to access! If you had your data queried directly via the database, you’d have to expose the data via GraphQL or a custom function!

Questions for you

thedavidprice commented 1 year ago

Big Update

Danny & Co. have a released an experimental version of Render Modes. You can check out the documentation and take it for a spin over here on the Forums: https://community.redwoodjs.com/t/render-modes-ssr-streaming-experimental/4858

Feedback wanted!