-
Notifications
You must be signed in to change notification settings - Fork 597
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
Asynchronous render #645
Comments
The way we've resolved this in our app is that all renders are sync -- if we need data from an async source in order to do a render, we either pre-load it into the page when it's emitted from the server (using app.use(function (state, emitter) {
state.somethingIneed = false
emitter.on('get:somethingIneed', () => {
const data = await getSomethingAsync()
state.somethingIneed = data
emit('render')
})
})
app.route('/', view)
function view (state, emit) {
if (!state.somethingIneed) {
emit('get:somethingIneed')
}
return html`<div>${state.somethingIneed || ''}</div>`
} This removes the need for complicated render cycles, and SSR doesn't wait for anything, since we can supply a well-formed state object other ways: const app = require('express')()
const fe = require('./front-end')
app.get('/', async (req, res) => {
const data = await getDataAsync()
const state = {
somethingIneed: data
}
res.send(fe.toString('/', state))
}) |
I see your point but, in many cases you don't know which data to fetch before render because the route/render-path defines which data will be required. Another typical problem is code splitting, with this async render strategy one could do: const lazyLoadedRoute = async (state, emit) => {
const component = await import('./lazyLoadedComponent')
return h`
<div>
${component(state, emit)}
</div>
`
} There are no complicated render cycles, just straightforward code that works both on server and client. |
To make it more interesting, async data loading based on a route param with lazy component: const lazyLoadedRoute = async (state, emit) => {
const [ component, data ] = await Promise.all([
import('./lazyLoadedComponent'),
getRouteData(state.params.id)
])
return h`
<div>
${component(data)}
</div>
`
} |
Both of the examples provided you have the route/render-path before you're asking for the data. The data isn't fetched until the route is being rendered on both the server and client examples. If you don't have the information you need in order to obtain the data you want by that point, there's no where else to get it :).
function someView (state, emit) {
const componentId = state.params.id
let showLoader = false
if (!state.components[componentId]) {
emit('get:component', componentId)
}
if (showLoader) {
return html`<div class="spinner">Yeah i'm fetching data for you it's cool</div>`
} else {
return html`<div>${state.components[componentId]}</div>`
}
} As far as lazy-loading routes, the general consensus has been to have that done at the router level, rather than in the component level. There's even a module for it that is under way currently: choo-lazy-route! There are a couple of solutions to run both network requests simultaneously, including a listener on the app.use(function (state, emitter) {
emitter.on('navigate', (route) => {
const id = route.match(/component\/(.+?)$/)[1]
if (id && !state.components[id]) {
emitter.emit('get:component', id)
}
})
}) Or by overriding the specific click event for the navigation (or likely several other patterns) |
I should state I'm not against the concept here -- there are already patterns that exist within choo that prevent us from having to deal with Promises (or async/await) directly in core. Other @choojs/trainspotters might have different opinions on this however! |
For complex components, you might want to consider something like https://github.com/choojs/nanocomponent, which gives more control over conditional rerendering, or halting the update cycle altogether and manually changing DOM elements in cases where that's needed (e.g. third party mapping library). For asynchronous I/O, in many ecosystems (react/redux, angular, ember, backbone) the convention I've seen is to render an intermediary loading state and fire an event when the async op is finished with a success or failure state, and rerender the component as needed. my 2 cents :) |
The last one doesn't, if you look the const view = async (state, emit) => {
const data = await getRouteData(state.params.id)
return h`
<div>
${component(data)}
</div>
`
} Regarding your example of lazy-loading routes:
|
Thanks for the note @ungoldman. That's has been the way of doing it with React despite all the SSR pain although, the React team is moving to async render very soon: https://www.youtube.com/watch?v=v6iR3Zk4oDY |
I did! if (!state.components[componentId]) {
emit('get:component', componentId)
showLoader = true
}
I am not storing functions on the state at all. In the examples above I'm essentially expecting
That would be up to the server-side http thing you're using, but the idea is essentially the same? const app = require('express'()
app.get('/component/:id', (req, res) => {
const data = await getData(req.params.id)
// same code as the other SSR example
})
I'm not sure how my example didn't cover that, or can't be easily extrapolated from that? It looks like you're asking that you need fetch very specific data for a given route & other information from that route. You have access to all the route information on the state object in the view, so can make a call that provides both things back. const view = async (state, emit) => {
if (!state.data[state.params.id]) {
emit('get:data', {id: state.params.id, route: state.href})
}
return h`
<div>
${component(state.data[state.href][state.params.id])}
</div>
`
} One of the things I'm noticing is that your views aren't using data attached to the As @ungoldman said above, the level of complexity you're looking for is likely better accomplished via nanocomponent which provides some semblance of local state, as well as lifecycle hooks. Choo's default views are designed to be pretty bare-bones and basic (however, that is not set in stone!). |
@toddself, a couple of notes: Thanks for fixing I understood you were putting components inside
I think it's completely different for 2 reasons: With your approach you need to declare your routes twice, in Choo router and Express. My approach only uses Choo router: const server = require('express')
server.get('*', (req, res) => {
const html = await app.toString(req.url, state)
// ...
}) With your approach you have to know beforehand which data to fetch for every route, and you have to declare it twice: in the Express handler and somewhere else in your Choo app. With my approach, the rendering path will define which data to fetch and you declare it only once: const view = async (state, emit) => {
const data = await getDataForThisView(state.params.id)
return h`
<div>${dummyComponent(data)}</div>
`
}
I believe your example won't render the data on the server. There is only one render pass on the server so what will happen is something like:
So the output of it will be an empty view, or a loading screen, a spinner, etc
I over simplified the data fetching functions for demo purposes, in fact what happens is more like: async function getData(state, emit, id) {
if (state.data[id]) {
return state.data[id]
} else {
const data = await fetchFromAPI(id)
emit('data:store', id, data) // will make state.data[id] = data
return data
}
}
const view = async (state, emit) => {
const data = await getData(state, emit, state.params.id)
return h`
<div>
${component(data)}
</div>
`
} |
I worry that having Promises arbitrarily deeply in the tree will be a major footgun. for example: async function someElement () {
var response = await fetch('/some-data')
var data = await response.json()
return html`<p>${data.something}</p>`
} This would do an HTTP request on every rerender and wait for it to complete before morphing. With Imo when a store doesn't fit the use case it's also fine to patch the app in a normal function, like var app = choo()
require('./async-render')(app) Alternatively, you could extend Choo, as it is Just Prototypes: module.exports = class AsyncChoo extends Choo {
async start () {
}
async toString () {
}
}
// app.js
var app = new AsyncChoo() Neither of those are ideal since you would have to copy quite a lot of choo code, but it may be helpful to bridge the gap until we land something that can support this. |
I still posit you have everything you need at this point to do this without making the view load the data directly:
const server = require('express')
server.get('*', (req, res) => {
const state = {}
state[component][id] = await getDataAsync(req.url)
const html = await app.toString(req.url, state)
// ...
}) All of the information you need about the request you're processing is there -- query string params, route info, etc. There is nothing at all that makes this pattern not work from the information you've shown me.
It does (I'm using this literally right now). The server never fires the const data = await getDataAsync()
const state = {
somethingIneed: data
}
res.send(fe.toString('/', state)) function view (state, emit) {
if (!state.somethingIneed) {
emit('get:somethingIneed')
}
return html`<div>${state.somethingIneed || ''}</div>`
} It only tries to get the data if the object doesn't exist on the state. In the original example, that key gets populated before the state object is passed to choo. |
@goto-bus-stop I understand your concern, but with purely synchronous render one could still do: function someElement (state, emit) {
while(true) {}
return html`<p>you'll never see me</p>`
}
function someElement (state, emit) {
reallyLongBlockingCall()
return html`<p>you'll have to wait to see me</p>`
} There are also many ways to shoot yourself with sync render. IMO is pretty much the same as promises that never resolve or doing expensive async calls every render. Another aspect is, there's not really a way to only allow top level handlers to return promises, from the moment they do then is async land for whoever is down the function. Regarding your suggestion, I also thought of that and I decorated the bel const body = children => (state, emit) => html`
<body>
${children.map(child => child(state, emit))}
</body>
`
const section = (name, child) => (state, emit) => html`
<section>
<h1>${name}</h1>
${child(state, emit)}
</section>
`
// the only 'async' one
const item = id => async (state, emit) => {
// getData doesn't necessarily trigger a fetch, can read from store if data available
const data = await getData(state, emit, id)
return html`
<div>
<h3>${data.title}</h3>
<p>${data.body}</p>
</div>
`
}
app.route('/', body([
section('First section', item(1)),
section('Second section', item(9)),
])) About Thanks for the tip regarding patching Choo. I was able to patch Choo using a store but I had to lock to version Please take a look at this simple PWA that I've built with Choo using this async approach, it has some interesting features like:
Demo: https://choo-pwa.xyz |
Had a conversation with @yoshuawuyts about this last weekend. In general I think that making views asynchronous in order to support remote data fetching is a bit of a red herring. IMO data fetching should be represented by an explicit loading state in the app, thereby keeping rendering an immediate, pure, and idempotent representation of the state object. Initially I was in favor of keeping views synchronous in order to recommend this pattern. (I threw together nanofetcher to help manage data fetching as a separate part of the component lifecycle.) However, @yoshuawuyts convinced me there were a few benefits of asynchronous rendering:
Still: I think there's a distinction between making rendering asynchronous (which makes sense for the above reasons) vs. encouraging data fetching and/or initialization to happen as part of the render (which I think should be discouraged in favor of separate component lifecycle events) Edit: apparently I was missing the simple way of stating all of this:
|
@s3ththompson I appreciate your comment a lot. I share the same view that rendering should be a pure and idempotent representation of the state, might not agree with the immediate. AFAIK that doesn't necessarily happen with right now in Choo. Correct me if I'm wrong but due to the synchronous nature of I also have to say that the biggest benefit for me is in fact the isomorphic lazy loading of views. The data fetching is a big plus but not necessarily the main point. Given that you mentioned I like Choo for its simplicity and minimalism. I agree that anti-patterns shouldn't be encouraged but given the minimal nature of the framework there will be always space for them. I believe that can only be avoided by building best-practices. |
It seems to me that this matters too:
If you're running a NodeJS server that's handling requests from multiple clients and not using worker threads to render (i.e generate a full HTML document from components/templates), the handling of request B might be completely blocked by the "rendering" of the HTML for request A, and assembling a string representing an entire HTML document is likely the longest callstack of synchronous code a web server ever executes. Being able to occasionally yield to other waiting request handlers would result in a slightly longer minimum response time (in situations where only one request is incoming at a time) and could result in a slightly longer average response time per request (for a low-traffic app), but would drastically decrease the maximum response time. There's probably a difficult middle ground here: you wouldn't want to yield after every component is evaluated (say, per link in a navigation menu), but being able to split document compilation/rendering into ~5 stages could be advantageous. One way to do this would be to be able to opt-in to async rendering (by which I mean yielding immediately before or immediately after evaluating a component's resulting HTML) on a per-component basis, allowing application offers to experiment with the right frequency of yields. You could do this by wrapping async template evaluation in some sort of
where This is meant to be more food for thought than a choo/nanohtml-ready solution, but I mean to cast a vote for the notion that, in Node, not blocking the main thread with long synchronous "render" operations for servers meant to handle multiple simultaneous threads is kind of a big concern. I've implemented a naive version of this for non-choo SSR and measured notable performance improvements under heavier loads. |
Rendering in Choo is a completely synchronous process, thought this makes things way simpler there are a few downsides from 2 perspectives:
I believe the later is the most relevant when it comes to SSR. One of the biggest issues of sync SSR is related to data loading and code-splitting. There are several workaround strategies to deal with async I/O on a sync rendering process, most of them require a first-pass render to load or collect something you can wait on and signal when the data is available to read synchronously. Besides being a waste of CPU cycles it's also not very elegant and adds unnecessary complexity to the code. One could just not have SSR but being able to have truly isomorphic or universal app is a requirement for most content-driven apps.
When I think of async rendering I think of composing the view using sync or async components and then one of two things can happen:
Ideally both scenarios should be allowed, giving that choice to the developer, but I believe the first one is easier to start with given the current Choo architecture. Overall it should look somehow like this:
Given that there are a few necessary steps:
_prerender
function on Choo await for the promise returned by the render to resolvebel
/nanohtml
also accept promisesmount
start
,toString
methods on Choo async since they rely on_prerender
There are a few open-ended questions at the moment:
render
event is triggered within a render callThe text was updated successfully, but these errors were encountered: