frontarm / navi

🧭 Declarative, asynchronous routing for React.
https://frontarm.com/navi/
MIT License
2.07k stars 71 forks source link

navi with express for a universal react app #118

Open chinds185a opened 5 years ago

chinds185a commented 5 years ago

I am working on a universal react app, that uses express and react-router, the first request is rendered server side and then hydrates on the client side.

Is there an example of how navi could work with express for this application? I cant quite see how it works from looking at the static rendering docs. I am also not using CRA so wouldn't need navi-scripts?

jamesknelson commented 5 years ago

I'm actually building a Navi app with SSR right now, which I'll probably be open sourcing in a month or two. I'm using universal-react-scripts for the build system, but the process should be pretty similar regardless. Here's how it works.

I have an index.node.js file which is compiled along with the rest of the source into a commonjs module that can be require()d in the server. This exports the app's routes (which are also imported in the web bundle and used the usual way.

Then, in the server, you create a navigation object using createMemoryNavigation(), wait for it to be ready, and then synchronously render the app using it. This is basically how navi-scripts works internally, except that with SSR you don't need a list of all of your app's URLs, so the whole process is much simpler.

Instead of using express, my app works with plain (request, response) => { /*...*/ } functions (which are rendered by lambda functions), but the idea is basically the same with Express. Here's the gist:

import fs from 'fs'
import { createMemoryNavigation, NotFoundError } from 'navi'
import { renderToString } from 'react-dom/server'
import { NaviProvider, View } from 'react-navi'
import HelmetProvider from 'react-navi-helmet-async'
import routes from './routes'

const renderer = async (request, response) => {
  let backend, navigation, sheet

  // Read in a HTML template, into which we'll substitute React's rendered
  // content, styles, and Navi's route state.
  let template = fs.readFileSync(process.env.HTML_TEMPLATE_PATH, 'utf8')
  let [header, footer] = template.split('<div id="root">%RENDERED_CONTENT%')

  try {
    // Create a `backend` object through which routes and the React app can
    // access the API.
    backend = new Backend({ ssr: true })

    navigation = createMemoryNavigation({
      context: {
        backend,

        // We'll handle authentication in the client so that all rendered
        // pages are identical regardless of who views them. This allows for
        // anything we render to be cached, at least for a short while.
        currentUser: undefined,

        // Some thing don't need rendering when doing SSR.
        ssr: true,
      },
      request,
      routes,
    })
    sheet = new ServerStyleSheet()

    // Wait for Navi to get the page's data and route, so that everything can
    // be synchronously rendered with `renderToString`.
    let route = await navigation.getRoute()

    let body = renderToString(
      sheet.collectStyles(
        <HelmetProvider>
          <BackendContext.Provider value={backend}>
            {/* Our provider makes stripe available within hooks, while the
            stripe library's provider is required for its card form */}
            <StripeContext.Provider value={null}>
              <StripeProvider stripe={null}>
                <ThemeProvider theme={theme}>
                  <NaviProvider navigation={navigation}>
                    {/*
                      Putting the global styles any deeper in the tree causes
                      them to re-render on each navigation, even on production.
                      Unfortunately this means they have to be repeated across
                      the server and the client code.
                    */}
                    <GlobalResetStyle />
                    <GlobalIconFontStyle />

                    <View />
                  </NaviProvider>
                </ThemeProvider>
              </StripeProvider>
            </StripeContext.Provider>
          </BackendContext.Provider>
        </HelmetProvider>,
      ),
    )

    // Extract the navigation state into a script tag so that data doesn't need
    // to be fetched twice across server and client.
    let state = `<script>window.__NAVI_STATE__=${JSON.stringify(
      navigation.extractState() || {},
    ).replace(/</g, '\\u003c')};</script>`

    // Generate stylesheets containing the minimal CSS necessary to render the
    // page. The rest of the CSS will be loaded at runtime.
    let styleTags = sheet.getStyleTags()

    // Generate the complete HTML
    let html = header + state + styleTags + '<div id="root">' + body + footer

    // The route status defaults to `200`, but can be set to other statuses by
    // passing a `status` option to `route()`
    response.status(route.status).send(html)
  } catch (error) {
    // Render an empty page, letting the client actually generate the 404
    // message.
    if (error instanceof NotFoundError) {
      let html = header + '<div id="root">' + footer
      response.status(404).send(html)
      return
    }

    // Log an error, but only render it in development mode.
    let html
    console.error(error)
    if (process.env.NODE_ENV === 'production') {
      html = `<h1>500 Error - Something went wrong.</h1>`
    } else {
      html = `<h1>500 Error</h1><pre>${String(error)}</pre>` + header + footer
    }
    response.status(500).send(html)
  } finally {
    sheet.seal()
    backend.dispose()
    navigation.dispose()
  }
}

I mean to write some docs on this one day, but if you can get to it first I'm sure it'd help a lot of people out :-)

chinds185a commented 5 years ago

Hi, Thanks for this! Ill look into how I ca get this setup with how my app currently works. Will get back to you with more questions or to update the docs if it is useful :)

chinds185a commented 5 years ago

With the above example where are implementing the client side hydration?

jamesknelson commented 5 years ago

Hydration happens in an index.js file, whose scripts are added to the rendered HTML. It’s basically just plain Create React App with a special separate index.node.js file.

On Sun, Jul 28, 2019 at 20:09 Chris Hinds notifications@github.com wrote:

With the above example where are implementing the client side hydration?

β€” You are receiving this because you commented.

Reply to this email directly, view it on GitHub https://github.com/frontarm/navi/issues/118?email_source=notifications&email_token=AABHPK4R7CPXHP64GHALNILQBV47ZA5CNFSM4IHKJNUKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOD264M3A#issuecomment-515753580, or mute the thread https://github.com/notifications/unsubscribe-auth/AABHPK7ICXCNI6GXGLKJFFTQBV47ZANCNFSM4IHKJNUA .

chinds185a commented 5 years ago

Hydration happens in an index.js file, whose scripts are added to the rendered HTML. It’s basically just plain Create React App with a special separate index.node.js file. … On Sun, Jul 28, 2019 at 20:09 Chris Hinds @.***> wrote: With the above example where are implementing the client side hydration? β€” You are receiving this because you commented. Reply to this email directly, view it on GitHub <#118?email_source=notifications&email_token=AABHPK4R7CPXHP64GHALNILQBV47ZA5CNFSM4IHKJNUKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOD264M3A#issuecomment-515753580>, or mute the thread https://github.com/notifications/unsubscribe-auth/AABHPK7ICXCNI6GXGLKJFFTQBV47ZANCNFSM4IHKJNUA .

Thanks, Just managed to hook up my old hydration method. The next issue I am having is that when setting window.__NAVI_STATE__ this seems to be a near empty object.

I used to set my own data object here, so I know this process works.

Loging out window.__NAVI_STATE__ gives me:

image

Currently investigation.

I am so much further along than I was yesterday though! πŸ‘

chinds185a commented 5 years ago

Ok it seems like navigation.extractState() is returning the above response. at this point navigation gives me the full navigation object.

navigation doesn't seem to have an extractState() method attached.

const navigation = createMemoryNavigation({
          url,
          request,
          routes,
        });

let navi = navigation.extractState();
console.log(navi);

The above returns

{ __navi__:
   { requestDataWithoutState: { method: 'GET', headers: {}, body: undefined },
     revertTo: undefined } }
chinds185a commented 5 years ago

πŸ€¦β€β™‚ was not passing in the correct request object

jamesknelson commented 5 years ago

For what it's worth, you shouldn't have to pass in a request object at all. You can just pass in a URL:

  /**
   * The initial URL to match.
   */
  url?: string | Partial<URLDescriptor>
  request?: NavigateOptions

see MemoryNavigation.ts

I haven't really documented the SSR stuff yet as I hadn't actually used it until the last couple months. However, my experience has been that it is pretty solid once it is set up so I think it should be okay to start adding docs for this stuff. If you find anything that'd be helpful for others to know, it'd be a huge help if you could submit a PR to the docs :-)

chinds185a commented 5 years ago

So the SSR render is now working. However as soon as the hydration kicks in the app fails with props.navigation.getCurrentValue() from NaviProvider being undefined.

This is because the navigation object being passed into the provider during hydration is still just image

So it seems navigation.extractState() is still not returning what it should.

chinds185a commented 5 years ago

It strange, before stringifying the navigation object to it add window.__NAVI_STATE__ navigation.getCurrentValue() returns the expected data.

However as soon as the navigation object is pulled from the global window object getCurrentValue() is undefined

jamesknelson commented 5 years ago

I'm curious to see what exactly are you passing into <NavigationProvider> or <Router>?

jamesknelson commented 5 years ago

For reference, here's the relevant part of my index.js:

let navigation = createBrowserNavigation({
    routes,
    context,
})

ReactDOM.render(
  <NaviProvider navigation={navigation}>
    <GlobalResetStyle />
    <View />
  </NaviProvider>,
  node
)

I don't touch window.__NAVI_STATE__ at all.

chinds185a commented 5 years ago

Ahhhh I had complete forgotten to create the browser router πŸ€¦β€β™‚ so i was just passing the memory router object to the client.

chinds185a commented 5 years ago

is Suspense required here? Obviously suspense will not work on the server however I am getting an error thrown in the component.

Uncaught (in promise) Error: View suspended while rendering, but no fallback UI was specified.

Add a <Suspense fallback=...> component higher in the tree to provide a loading indicator or placeholder to display.
jamesknelson commented 5 years ago

Suspense is not required, in fact it won't actually work on the server side at all.

However, you will need to wait for the route to be ready before rendering, both on the client and the server.

It's exactly the same on the client as it is on the server. I accidentally skipped this part in the above example, so here's my whole client code:

async function main() {
  let backend = new Backend()
  let context = {
    backend,
    currentUser: undefined,

    // Hydrate expects the original content to be identical to the server
    // rendered content, so we'll start out with ssr: true and change it
    // after the hydrate.
    ssr: true,
  }
  let navigation = createBrowserNavigation({
    routes,
    context,
  })

  function updateContext(change) {
    context = { ...context, ...change }
    navigation.setContext(context)
  }

  let route = await navigation.getRoute()
  let renderer = route.type === 'ready' ? ReactDOM.hydrate : ReactDOM.render

  renderer(
    <HelmetProvider>
      <BackendContext.Provider value={backend}>
        {/* Our provider makes stripe available within hooks, while the
            stripe library's provider is required for its card form */}
        <StripeContext.Provider value={stripe}>
          <StripeProvider stripe={stripe}>
            <ThemeProvider theme={theme}>
              <NaviProvider navigation={navigation}>
                {/*
                  Putting the global styles any deeper in the tree causes them to
                  re-render on each navigation, even on production.
                */}
                <GlobalResetStyle />
                <View />
              </NaviProvider>
            </ThemeProvider>
          </StripeProvider>
        </StripeContext.Provider>
      </BackendContext.Provider>
    </HelmetProvider>,
    document.getElementById('root'),
  )

  // Immediately re-render without anything that can't be rendered via SSR,
  // now that we've hydrated the ssr-rendered content.
  updateContext({ ssr: false })

  let currentUser = await backend.currentUser.getCurrentValue()
  if (currentUser !== undefined) {
    updateContext({ currentUser })
  }
  backend.currentUser.subscribe(currentUser => {
    updateContext({ currentUser })
  })
}

main()
chinds185a commented 5 years ago

So I get that, but correct if I am wrong but waiting for the route to be ready of the client? what is show to the user in the main time? we do not need to fetch the api data again as this is saved in window.__NAVI_STATE__

Maybe it is just different with my setup as I have a separate file called client.js and this is the entry point of the app when on the client and for me this is where the hydration happens.

jamesknelson commented 5 years ago

I think my file above is pretty similar to your client.js - it handles hydration, and is separate to index.node.js which is what the server calls.

The reason you'll need to wait for the route be ready is that an async function will always return a promise, even if you're awaiting a promise that has always resolved. So even though getRoute() should resolve almost immediately, it's not quite immediately if there are async functions in your routes.

In the meantime, if you're doing SSR, then the server-rendered content is still visible so there's no visible delay to the user.

chinds185a commented 5 years ago

Got that all working πŸ‘ Just a small modification I had to make to my client.js file.

Once I am happy with this I'm happy to update the docs and maybe put together smaller/simpler example project.

jamesknelson commented 5 years ago

Awesome πŸ‘ can't wait to see what you put together.