gravity-ui / rfc

Gravity RFC is a process for proposing and implementing changes in our ecosystem
MIT License
3 stars 0 forks source link

App-layout and HTML Streaming #7

Closed Volodomor closed 3 months ago

Volodomor commented 4 months ago

Objective

Now in the app-layout there is an opportunity to transfer preloaded data in the data parameter and this is a very good mechanism. We use it in the tracker. When opening the ticket page, we try to immediately request basic ticket data from the backend. If we did not manage to receive them within 600ms, then we simply return the layout and send a request for ticket data from the browser, no matter how long it is. But this only happens with "heavy" tickets. For the most part, data can be obtained immediately for a regular ticket. And this gives a significant boost to the speed parameters of the ticket page.

But the app-layout has one problem - the render function is synchronous. This means that it cannot be used simultaneously with html streaming. In the case of the ticket page, the user is guaranteed to see not even the loader, but a white screen within 600ms, since the markup with the loader is transmitted only when we received (or did not receive) data from the backend, passed it to the render function, received the finished html and sent it to the client.

We make an experiment in the tracker: We took a fork of the app-layout and made the render function asynchronous. Now it can accept the dataAsync parameter - the promise of receiving data. Also, this function calls a callback with the generated part of the html before it starts waiting for the dataAsync promise to be executed, and then through the same callback it returns the remaining part of the html, which is generated after receiving data from the promise. And we send both of these chunks to the client via html streaming. This gives a significant increase in speed, since firstly the user's browser immediately starts downloading and parsing static resources that are specified in the html header, and secondly the user does not see a white screen, but immediately receives a loader display.

Solution Proposal

I suggest making the following improvements in the app-layout: 1) make the createRenderFunction function deprecated, since through the asynchronous function it will also be possible to work as before - to get the entire generated html. You just need to call it with await. 2) add a new createRenderAsyncFunction function that can accept three new parameters: dataAsync - a promise to receive data. You can make this parameter accept both promis and ready-made data, then the data parameter can be deprecated dataAsyncPlacement - where to place the script with the received data in the final html. Now the data is written to the header, but it may be convenient for this data to be written to the end of the body, then the user will receive almost the entire html page, including the preloader. onChunkRendered - a callback that returns the generated html chunk

The new render function will not differ much from the old one, except that it will now be async. In the end, it will also return all the generated html, so you can work with it the same way as before, only with the addition of await. But it will also call a callback with the generated html chunk before and after waiting for data to be received. This will allow the application to send these chunks via html streaming as they are received.

Definition of done

Added a new createRenderAsyncFunction function that creates an asynchronous html generation function.

SeqviriouM commented 4 months ago

@ValeraS what do you think?

obenjiro commented 4 months ago

Additionally, it's worth noting that in is an instance of Server-Side Rendering (SSR) architecture, or server preloading, where data is preloaded on the server, this approach can yield an average improvement of up to 300ms essentially "for free," without requiring significant modifications to the existing codebase.

This benefit becomes even more pronounced on slower networks, where the sequence of asset delivery and the elimination of delays are crucial. In such scenarios, HTML Streaming can provide an advantage of several seconds.

ogonkov commented 4 months ago

createRenderFunction returns function that actually renders html. I guess we need to add parameter, that returns "stream-compatible", instead of deprecating it completely. Like,

const renderLayout = createRenderFunction({output: 'stream'});

Returned function could accept callbacks like onChunk and onComplete,

const renderLayout = createRenderFunction({output: 'stream'});

function renderIndexHtml(req: Request, res: Response) {
  renderLayout({}, (chunk: string) => {
    res.write(chunk);
  }, () => {
    res.status(200).end();
  });
}

Not sure about dataAsyncPlacement param, it seems not related to stream-compatible render, and i guess could be added to current code base.

ykamendrovskiy commented 4 months ago

@melikhov-dev what do you think on the subject?

melikhov-dev commented 4 months ago

@ykamendrovskiy I like it. I see no reason to disagree.

ValeraS commented 4 months ago

I'm thinking of not providing a new rendering function, but rather helpers for creating the page. Something like this:

import {
    createDefaultPlugins,
    generateRenderContent,
    renderHeadContent,
    renderBodyContent,
    renderFinalScripts
} from '@gravity-ui/app-layout';

const plugins = createDefaultPlugins();

app.get('/', async function (req, res) {
    res.type('html');
    res.writeHead(200);

    const content = generateRenderContent(
        plugins,
        {
            title: 'Home page',
        }
    });

    res.write(`
        <!DOCTYPE html>
        <html ${content.renderHelpers.attrs({...content.htmlAttributes})}>
        <head>
            ${renderHeadContent(content)}
        </head>
        <body ${content.renderHelpers.attrs(content.bodyContent.attributes)}>
            ${renderBodyContent(content.bodyContent)}
    `);

    const data = await getUserData();

    res.write(`
            ${content.renderHelpers.renderInlineScript(`
                window.__DATA__ = ${htmlescape(data)};
            `)}
            ${renderFinalScripts(content)}
        </body>
        </html>
    `);
    res.end();
});
Volodomor commented 4 months ago

I liked the option that Valera suggested. Here is the Pull Request https://github.com/gravity-ui/app-layout/pull/38