nanojsx / nano

🎯 SSR first, lightweight 1kB JSX library.
http://nanojsx.io
MIT License
1.44k stars 39 forks source link

Add async tree rendering #5

Closed Mati365 closed 3 years ago

Mati365 commented 3 years ago

Add parallel async rendering, it might be killer feature

yandeu commented 3 years ago

What exactly do you mean by that?

You can, for example, render every part of you app in a new JavaScript task. Like here.

You can also use Nano.render() inside a componenet, to start rendering another component. Nano JSX is very flexible in that manner.

Mati365 commented 3 years ago

try adding something like Query component inside tree. That component should execute promise and render its result into children. It would be nice to support it also in SSR.

example: https://www.apollographql.com/docs/react/data/queries/

yandeu commented 3 years ago

I see. I have just tried to implement async rendering on the server. And it did work well. I will make some more tests in the next days, but I guess this is a nice feature to add.

I will also try to implement something similar on the client side.

The example below works as expected. The App Component returns a promise that will resolve 1 second later.

// mock fetch request
const fetch = (_url: string): Promise<any> => {
  const data = [{ title: 'cool title' }, { title: 'nano jsx' }]

  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(data)
    }, 1000)
  })
}

class App extends Component {
  async fetch() {
    const data = await fetch('https://someurl.com')
    return (
      <ol>
        {data.map((d: any) => (
          <li>{d.title}</li>
        ))}
      </ol>
    )
  }

  // @ts-ignore
  render() {
    return this.fetch()
  }
}

renderSSRAsync(<App />).then((html) => {
  console.log(html)
})
Mati365 commented 3 years ago

Have you tried doing something like it:

  <>
     <QueryA />
     <QueryB />
  </>

is it executing parallel or QueryB is waiting for QueryA to be done?

yandeu commented 3 years ago

Well, if queryA and queryB return a Promise you should be able to use Promise.all(). JSX is not different han JavaScript. You can do everything you want. For example this:

<>
  <QueryAll>
    <QueryA />
    <QueryB />
  <QueryAll>
</>

But, I will have a look and see what I can implement.

yandeu commented 3 years ago

I think it would maybe make sense to switch to async rendering by default. For now, a component can return fragments, arrays, functional components, and class components. With async rendering, it would be possible to also return Promises and Promis Arrays.

I think this would be a great feature :D

yisar commented 3 years ago

What we usually call asynchronous rendering is not Promise.all. It is similar to react fiber and Suspense. Instead of any promises, it uses queues to achieve concurrency.

yandeu commented 3 years ago

react fiber and Suspense

I don't know react fiber and I have never used Suspense.


Since Nano JSX does not use a vDOM and does not keep track of the tree, I don't think I would implement a global render queue. Isn't promise a good idea?

Take a look at the example below (my quick try of a Suspense implementation). You just need to pass a promise and a fallback to <Suspense />. Once the promise resolves, <Suspense /> renders its children and passes the resolved promise as a data props to them. <Comments /> simply has to work with props.data. What do you think? I think it is a clean solution and the tree stops rendering until the promise resolves.

import * as Nano from '../core'
import { Component } from '../component'

// mock a data fetch
const fetchComments = (): Promise<string[]> => {
  const comments = ['comment one', 'comment two']

  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(comments)
    }, 1000)
  })
}

// the suspense component
class Suspense extends Component {
  data: any = null

  async didMount() {
    // resolve the promise
    this.data = await this.props.promise

    // add data as props to children
    this.props.children.forEach((child: any) => {
      if (child.props) child.props = { ...child.props, data: this.data }
    })

    // update the component
    this.update()
  }

  render() {
    return !this.data ? this.props.fallback : this.props.children
  }
}

// Comments component
const Comments = (props: { data?: string[] }) => {
  console.log('Render Comments')
  const comments = props.data?.map((c) => <li>{c}</li>)
  return <ul>{comments}</ul>
}

// Loading component
const Loading = () => <div>loading...</div>

// App component
const App = () => (
  <div>
    <h2>Comments</h2>
    <Suspense promise={fetchComments()} fallback={<Loading />}>
      <Comments />
    </Suspense>
  </div>
)

// Render to the root element
Nano.render(<App />, document.getElementById('root'))
yandeu commented 3 years ago

This looks even cleaner: <Suspense /> gets the comments as promise and passes it to <Comments /> as props.

I'm really happy with that approach.

const Comments = ({ comments }) => {
  const tmp = comments?.map((c) => <li>{c}</li>)
  return <ul>{tmp}</ul>
}

const Loading = () => <div>loading...</div>

const App = () => (
  <div>
    <h2>Comments</h2>
    <Suspense comments={fetchComments()} fallback={<Loading />}>
      <Comments />
    </Suspense>
  </div>
)
yisar commented 3 years ago

Well, that's not what I'm trying to say.

Because I need isomorphism, now I have implemented fiber asynchronous rendering on the client. If promise is used on the server, the rendering on the client will be destroyed.

Isomorphism requires that the rendering mechanism of client and server will not interfere with each other.

yandeu commented 3 years ago

Because I need isomorphism, now I have implemented fiber asynchronous rendering on the client. If promise is used on the server, the rendering on the client will be destroyed.

I don't understand what you mean by that.


I just made <Suspense /> fully isomorphic. Take a look: The App component is nice and clean and works 100% isomorphic.

// fetch comments
export const fetchComments = (): Promise<string[]> => {
  const comments = ['comment one', 'comment two']

  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(comments)
    }, 1000)
  })
}

// App Component
export class App extends Component {
  static fetchComments(): any {}

  render() {
    return (
      <div>
        <h2>Comments</h2>
        <Suspense cache comments={App.fetchComments() || fetchComments} fallback={<Loading />}>
          <Comments />
        </Suspense>
      </div>
    )
  }
}

Client-Side:

Nano.render(<App />, document.getElementById('root'))

Server-Side:

const server = async () => {
  // prefetch comments
  const comments = await fetchComments()

  // add to static method
  App.fetchComments = () => () => comments

  // render
  renderSSR(<App />)
}

server()
yandeu commented 3 years ago

I'm very happy with the current implementation of <Suspense /> and will not continue working on another async rendering method for now.

See some <Suspense /> examples here: https://nanojsx.github.io/examples.html