preact icon indicating copy to clipboard operation
preact copied to clipboard

async rendering

Open cucar opened this issue 2 years ago • 13 comments

Hello Jason,

First of all, let me say that I am a big fan of Preact. Great job. The only problem I have is that Google page speed insights keep telling me that our sites have too much blocking time. The solution is of course async rendering. It's not a new concept. It's similar to React Fiber but the implementation is quite different. Here's the basic idea:

  • Async rendering is turned on by an option so that it won't impact the existing renders.
  • Initial and component render are async functions but when render does not wait for promises in blocking render mode.
  • Blocking render should be unchanged in terms of performance.
  • When async rendering is turned on, the diff functions are replaced with generator functions.
  • The main diff routine checks for remaining time from idle callback and if the time allotted is exceeded, it yields a promise that's based on the request idle callback. That in turn triggers the await in the render routines and that's how we interrupt the rendering.

I tried to minimize the amount of changes to the code but it's still quite a bit. Below are some architectural issues that require your attention.

  • I added a polyfill for requestIdleCallback for Safari from here: https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API - not sure if that's something you want to let build tools take care of.
  • It may be possible to implement everything using callbacks but it would make it very difficult to work with. That's why I did not choose that route.
  • Adding any kind of async, await, generator slows down the blocking render. Async rendering had to use the same logic without replicating code. Refactoring the whole thing to be able to use yield in the right place seems extremely difficult. I did try it but ended up with bugs, so I abandoned that route but you may do a better job if that's what you choose to do.
  • In order to avoid adding async/await, I kept the async/await only at the render routines and did not use them for diff routines. Diff routines are called many times and having async/await there significantly slows it down. Using generators is much faster. It's still slower than blocking rendering by about 15-20% but that should be acceptable for async rendering.
  • In order to avoid adding generators for blocking render, I create the generator functions dynamically from existing blocking diff functions. There are only a few places that need yield and they are replaced during generation of generator functions.
  • I had to move all dependencies to be arguments in diff functions because the generator versions of them cannot have access to the same scope. They can't recognize dependencies like Component, assign, etc.
  • Preact build does not work and it needs your help. When I built it with current options gzipped size blew up from ~3900 bytes to ~5900 bytes. I suspect that a significant part of that is due to async/generator transformations by babel. I tried to disable them but I ended up getting (buble plugin) CompileError: Transforming generators is not implemented. Use transforms: { generator: false } to skip transformation and disable this error. Since the changes to blocking render code is minimal, I'm hoping it would be possible to create 2 preact builds: one that supports blocking render (bigger size) and one that does not (smaller size). That would require some changes to the build process and potentially to the microbundle. I'm hoping you can do that.

Few final notes: I tested this by bundling preact with our own code and it works great! You can see it here: https://webdigital.com It works on Safari, Chrome, Firefox and Edge. If you test it with lighthouse, it shows blocking time as 30ms. I think this happens due to the thread exceeding time allotted slightly. I left 1ms buffer in the allotted time check. In any case, I think it should be good enough for most people.

cucar avatar Dec 27 '21 19:12 cucar

📊 Tachometer Benchmark Results

Summary

⏳ Benchmarks are currently running. Results below are out of date.

duration

  • 02_replace1k: unsure 🔍 -1% - +5% (-2.51ms - +8.86ms)
    preact-local vs preact-master
  • 03_update10th1k_x16: unsure 🔍 -7% - +1% (-2.16ms - +0.24ms)
    preact-local vs preact-master
  • 07_create10k: unsure 🔍 -1% - +1% (-12.36ms - +22.59ms)
    preact-local vs preact-master
  • filter_list: unsure 🔍 -2% - +1% (-0.51ms - +0.23ms)
    preact-local vs preact-master
  • hydrate1k: faster ✔ 2% - 12% (4.71ms - 27.48ms)
    preact-local vs preact-master
  • many_updates: slower ❌ 0% - 7% (0.11ms - 1.91ms)
    preact-local vs preact-master
  • text_update: unsure 🔍 -2% - +4% (-0.06ms - +0.13ms)
    preact-local vs preact-master

usedJSHeapSize

  • 02_replace1k: unsure 🔍 -0% - +0% (-0.00ms - +0.01ms)
    preact-local vs preact-master
  • 03_update10th1k_x16: unsure 🔍 +0% - +0% (+0.00ms - +0.01ms)
    preact-local vs preact-master
  • 07_create10k: unsure 🔍 +0% - +0% (+0.01ms - +0.01ms)
    preact-local vs preact-master
  • filter_list: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-master
  • hydrate1k: unsure 🔍 -1% - +0% (-0.06ms - +0.02ms)
    preact-local vs preact-master
  • many_updates: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-master
  • text_update: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-master

Results

⏳ Benchmarks are currently running. Results below are out of date.
02_replace1k
  • Browser: chrome-headless 96.0.4664.110
  • Sample size: 80
  • Built by: Benchmarks #616
  • Commit: 7c9ca12

duration

VersionAvg timevs preact-mastervs preact-local
preact-master180.49ms - 185.86ms-unsure 🔍
-5% - +1%
-8.86ms - +2.51ms
preact-local181.34ms - 191.36msunsure 🔍
-1% - +5%
-2.51ms - +8.86ms
-

usedJSHeapSize

VersionAvg timevs preact-mastervs preact-local
preact-master3.47ms - 3.49ms-unsure 🔍
-0% - +0%
-0.01ms - +0.00ms
preact-local3.48ms - 3.49msunsure 🔍
-0% - +0%
-0.00ms - +0.01ms
-

run-warmup-0

VersionAvg timevs preact-mastervs preact-local
preact-master61.76ms - 63.92ms-unsure 🔍
-3% - +2%
-1.89ms - +1.50ms
preact-local61.73ms - 64.34msunsure 🔍
-2% - +3%
-1.50ms - +1.89ms
-

run-warmup-1

VersionAvg timevs preact-mastervs preact-local
preact-master88.86ms - 93.53ms-unsure 🔍
-4% - +2%
-3.85ms - +2.00ms
preact-local90.36ms - 93.89msunsure 🔍
-2% - +4%
-2.00ms - +3.85ms
-

run-warmup-2

VersionAvg timevs preact-mastervs preact-local
preact-master83.30ms - 91.28ms-unsure 🔍
-9% - +3%
-8.52ms - +2.91ms
preact-local86.00ms - 94.19msunsure 🔍
-3% - +10%
-2.91ms - +8.52ms
-

run-warmup-3

VersionAvg timevs preact-mastervs preact-local
preact-master65.90ms - 73.20ms-unsure 🔍
-8% - +8%
-5.61ms - +5.65ms
preact-local65.24ms - 73.82msunsure 🔍
-8% - +8%
-5.65ms - +5.61ms
-

run-warmup-4

VersionAvg timevs preact-mastervs preact-local
preact-master91.15ms - 95.39ms-unsure 🔍
-4% - +3%
-4.19ms - +2.38ms
preact-local91.66ms - 96.69msunsure 🔍
-3% - +5%
-2.38ms - +4.19ms
-

run-final

VersionAvg timevs preact-mastervs preact-local
preact-master64.53ms - 67.45ms-unsure 🔍
-5% - +4%
-3.00ms - +2.43ms
preact-local63.98ms - 68.56msunsure 🔍
-4% - +5%
-2.43ms - +3.00ms
-
03_update10th1k_x16
  • Browser: chrome-headless 96.0.4664.110
  • Sample size: 130
  • Built by: Benchmarks #616
  • Commit: 7c9ca12

duration

VersionAvg timevs preact-mastervs preact-local
preact-master31.07ms - 32.94ms-unsure 🔍
-1% - +7%
-0.24ms - +2.16ms
preact-local30.30ms - 31.79msunsure 🔍
-7% - +1%
-2.16ms - +0.24ms
-

usedJSHeapSize

VersionAvg timevs preact-mastervs preact-local
preact-master3.40ms - 3.40ms-unsure 🔍
-0% - -0%
-0.01ms - -0.00ms
preact-local3.40ms - 3.41msunsure 🔍
+0% - +0%
+0.00ms - +0.01ms
-
07_create10k
  • Browser: chrome-headless 96.0.4664.110
  • Sample size: 50
  • Built by: Benchmarks #616
  • Commit: 7c9ca12

duration

VersionAvg timevs preact-mastervs preact-local
preact-master1723.32ms - 1747.74ms-unsure 🔍
-1% - +1%
-22.59ms - +12.36ms
preact-local1728.15ms - 1753.14msunsure 🔍
-1% - +1%
-12.36ms - +22.59ms
-

usedJSHeapSize

VersionAvg timevs preact-mastervs preact-local
preact-master25.32ms - 25.32ms-unsure 🔍
-0% - -0%
-0.01ms - -0.01ms
preact-local25.33ms - 25.33msunsure 🔍
+0% - +0%
+0.01ms - +0.01ms
-
filter_list
  • Browser: chrome-headless 96.0.4664.110
  • Sample size: 50
  • Built by: Benchmarks #616
  • Commit: 7c9ca12

duration

VersionAvg timevs preact-mastervs preact-local
preact-master22.84ms - 23.43ms-unsure 🔍
-1% - +2%
-0.23ms - +0.51ms
preact-local22.77ms - 23.22msunsure 🔍
-2% - +1%
-0.51ms - +0.23ms
-

usedJSHeapSize

VersionAvg timevs preact-mastervs preact-local
preact-master1.54ms - 1.54ms-unsure 🔍
-0% - -0%
-0.00ms - -0.00ms
preact-local1.55ms - 1.55msunsure 🔍
+0% - +0%
+0.00ms - +0.00ms
-
hydrate1k
  • Browser: chrome-headless 96.0.4664.110
  • Sample size: 80
  • Built by: Benchmarks #616
  • Commit: 7c9ca12

duration

VersionAvg timevs preact-mastervs preact-local
preact-master213.30ms - 226.89ms-slower ❌
2% - 14%
4.71ms - 27.48ms
preact-local194.86ms - 213.13msfaster ✔
2% - 12%
4.71ms - 27.48ms
-

usedJSHeapSize

VersionAvg timevs preact-mastervs preact-local
preact-master6.17ms - 6.23ms-unsure 🔍
-0% - +1%
-0.02ms - +0.06ms
preact-local6.15ms - 6.21msunsure 🔍
-1% - +0%
-0.06ms - +0.02ms
-
many_updates
  • Browser: chrome-headless 96.0.4664.110
  • Sample size: 80
  • Built by: Benchmarks #616
  • Commit: 7c9ca12

duration

VersionAvg timevs preact-mastervs preact-local
preact-master27.04ms - 28.25ms-faster ✔
0% - 7%
0.11ms - 1.91ms
preact-local27.99ms - 29.32msslower ❌
0% - 7%
0.11ms - 1.91ms
-

usedJSHeapSize

VersionAvg timevs preact-mastervs preact-local
preact-master4.61ms - 4.61ms-unsure 🔍
-0% - -0%
-0.00ms - -0.00ms
preact-local4.61ms - 4.62msunsure 🔍
+0% - +0%
+0.00ms - +0.00ms
-
text_update
  • Browser: chrome-headless 96.0.4664.110
  • Sample size: 60
  • Built by: Benchmarks #616
  • Commit: 7c9ca12

duration

VersionAvg timevs preact-mastervs preact-local
preact-master2.86ms - 2.91ms-unsure 🔍
-4% - +2%
-0.13ms - +0.06ms
preact-local2.83ms - 3.00msunsure 🔍
-2% - +4%
-0.06ms - +0.13ms
-

usedJSHeapSize

VersionAvg timevs preact-mastervs preact-local
preact-master0.78ms - 0.78ms-unsure 🔍
-0% - -0%
-0.00ms - -0.00ms
preact-local0.78ms - 0.78msunsure 🔍
+0% - +0%
+0.00ms - +0.00ms
-

tachometer-reporter-action v2 for Benchmarks

github-actions[bot] avatar Dec 27 '21 19:12 github-actions[bot]

@JoviDeCroock Thank you for reviewing the PR and your feedback.

Partial hydration is certainly an interesting solution but it does not address my problems, primarily because I know that there will always be some differences between SSR and CSR. I call render with a third argument as the first child node instead. It's similar to hydration but it can do some small actual rendering as well. Apart from that, I would have a tough time prioritizing exactly what to render in my case. I need a more general solution and I think many people do as well. Just the current route rendering takes 300ms in my case on mobile devices and it's not particularly big.

You mentioned concern about async rendering causing deadlock/infinite loop for enqueueing. I don't think that's going to be a problem because process routine in component.js actually awaits the rendering of each component. So, async rendering is not "concurrent rendering". I did see double rendering issues when I tried that.

I'm not sure what you mean with the arrow function toString() issue not reliable. I do the parsing based on non-arrow functions but it can easily be modified to work with arrow functions as well if needed.

I do agree that the bundle size is a problem but I'm hoping the build can be modified to generate a separate async build that is separate from the current build. Async build would be bigger in size of course and would require a browser supporting generators and async functions but that's a decision the end user can have. Such browsers are 95% at this time: https://caniuse.com/?search=await and https://caniuse.com/es6-generators - basically anything except IE, which is considered dead at this time even by Microsoft: https://www.pdq.com/blog/ie-11-end-of-life

I tried to refactor the code so that I could use async/generator functions that can be transpiled to ES5 but like you said, it's a huge undertaking. I ended up with bugs and much greater bundle size. Preact is so efficient in terms of size and code that it's very difficult to refactor it without blowing up the size or impacting the performance, especially if you introduce async/generator function transformations by babel.

I am good at this time with my fork of Preact but I just don't want to fall too much behind before this is implemented officially since it's going to be difficult for me to get the updates. How long do you think it would take for async rendering to be implemented in Preact 11? Will IE11 support by dropped at that time?

Thank you, Cagdas

cucar avatar Jan 02 '22 02:01 cucar

@JoviDeCroock I added another change that should fix the build errors. I disabled async/generator transforms. I'm using generator functions dynamically everywhere for microbundle/rollup to not get confused. Build is working but it's still quite big (5876 bytes). It's really weird that the difference is this much. I don't think the sum of the changes bring about 2000 characters but I can't think of anything else since I disabled the async/generator transforms. Let me know if you have any ideas about reducing the bundle size.

Thank you, Cagdas

cucar avatar Jan 02 '22 03:01 cucar

@JoviDeCroock I verified that the reason for the bundle size blow up is indeed the async function transformations. Even though I disabled them in babel options, microbundle/rollup is still transpiling them, causing big size change. When I removed the async functions, I get 4285 bytes for the bundle. It's still quite a bit increase but I believe it can be reduced by around 100 bytes by refactoring the code. The challenge is to generate the async functions bypassing rollup and not including them in the bundle. I'm going to create a separate bundle for async functions that can be imported optionally. I'm working on a solution for that. I think I can finish it tomorrow. I'll keep you posted.

cucar avatar Jan 02 '22 08:01 cucar

@JoviDeCroock Here's the new structure with async as a separate bundle. I added a couple of API integration points using option hooks. These are used to override the queue processing functions. I also exported a bunch of functions from the core export so that async bundle can use them. Async bundle exports 2 functions: renderAsync and hydrateAsync. Users can call these to do async render/hydrate. Internally they are generated dynamically from existing functions. The dependencies are fed from the exported functions, sometimes modified for async/generator versions. Unfortunately this means that the dependencies should not be renamed by microbundle. So, I added them as reserved in the mangle options. This is the main reason for the bundle size increase of around 300 bytes. Total size I see now is 4257 bytes, up from 3936. If I could use a nameCache option for the microbundle it would be much less but I can't get it to work. I think it's good enough but @developit may be able to reduce it by getting the nameCache to work. Let me know what you think.

Thank you, Cagdas

cucar avatar Jan 04 '22 02:01 cucar

Here's typical usage:

import { render, renderAsync, h } from 'preact/async'; const mainComponent = h(App, {}); const renderArgs = [ mainComponent, document.getElementById('root'), document.getElementById('root').firstElementChild ]; if (asyncRendering) await renderAsync(...renderArgs); else render(...renderArgs);

cucar avatar Jan 04 '22 20:01 cucar

@JoviDeCroock if you would like to be able to test it easier, I published the fork to npm so that I can use it in production. Here's sample usage:

Install preact-async and alias preact as 'preact-async'.

import { render, renderAsync, h } from 'preact/async';

// create main application component
const mainComponent = h(App, {});

// serial rendering
render(mainComponent, document.getElementById('root')); 
  
// async rendering - you can await it
renderAsync(mainComponent, document.getElementById('root-async')); 

Due to the async nature of the module, certain variables need to remain unchanged. This list is exported from this module and can be used for minification purposes. Below is example usage in webpack. If you minify the code without these reserved tokens you will get an error.

...
optimization: {
	...
	minimize: true,
	minimizer: [ 
		new TerserPlugin({ 
			terserOptions: { 
				mangle: { 
					reserved: require('preact-async/async/reserved').minify.mangle.reserved 
				} 
			} 
		}) 
	]
},

cucar avatar Jan 05 '22 02:01 cucar

@JoviDeCroock @developit here's an article I wrote about this: https://dev.to/cagdas_ucar/preact-async-rendering-51p2

cucar avatar Jan 05 '22 21:01 cucar

@cucar, this is epic, thanks a ton for the time and effort!

Came here through your article and, before that - lighthouse report complaining about TBT of ~400ms. Do you have any idea if yours or another solution for this problem will get merged into preact at some future?

Is it even being worked on or discussed by the core team, @JoviDeCroock? Thanks

amalitsky avatar Apr 13 '22 03:04 amalitsky

@amalitsky Thank you. preact async rendering is exactly what's supposed to help with that. I've been using it in production with great success. Blocking time is usually 0. Try https://www.npmjs.com/package/preact-async and please let me know if that works.

@developit @JoviDeCroock IE support was a major reason why this PR was not taken up. With IE falling out of support in June, can we please work on merging this?

Thank you, Cagdas

cucar avatar Apr 13 '22 13:04 cucar

Does this work for server side aync rendering? Would be bool to implement it in an html streaming

lordanubi avatar May 24 '22 23:05 lordanubi

Hey @cucar !

This looks interesting. We are running into the same issue as @amalitsky running into a Total Blocking Time of about ~600ms. Just tried to use preact-async. Unfortunately, it looks like it is running into issues with hooks:

import { renderAsync } from "preact/async";
import { useState } from "react";

function MyApp() {
    console.log("useState", useState);

    const [myState] = useState("test");

    console.log(myState);

    return <div>{myState}</div>;
}

renderAsync(<MyAppWorking />, document.body);

Configuration:

// webpack
config.resolve.alias = {
    ...(config.resolve.alias || {}),
    react: "preact/compat",
    "react-dom": "preact/compat",
    preact: "preact-async"
};

// Babel
[
    "@babel/preset-react",
    {
        runtime: "automatic",
        importSource: "preact"
    }
]

Unfortunately, I get the following error:

Uncaught TypeError: Cannot read properties of undefined (reading '__H')

There are similar issues in this repo, unfortunately, all the suggested solutions did not work. I also made sure that only one preact-async module is installed (no duplicate).

The strange thing: console.log(useState); outputs a valid function in the browser console. When I remove the useState() expression, the <div can be rendered.

matzeeable avatar Sep 15 '23 08:09 matzeeable

Hi @matzeeable

Thank you for trying this. I'm actually using preact-async in a production project with hooks. I use esbuild with a custom build script, so chances are that's where things are going wrong. I added my build script for reference below. Feel free to work on it but I actually would not recommend using preact-async at this point because the maintainers of Preact seem to have decided it's not worth it and I don't have the time to keep it up to date with Preact.

I'm building a visual development environment and soon it will be possible to build components with Preact APIs. Our own website uses preact-async. Feel free to check out the performance: https://webdigital.com

import { promises as fs } from 'fs';
import esbuild from 'esbuild';

/**
 * build function using esbuild
 */
const build = async (entry, folder, plugins, external = [], analyze, report) => {
	
	console.log(`prep ${folder} folder...`);
	await fs.rm(`./${folder}`, { recursive: true, force: true });
	await fs.cp('./public/index.html', `./${folder}/index.html`);
	
	console.log(`running esbuild for ${entry}...`);
	const result = await esbuild.build({
		entryPoints: [ entry ],
		bundle: true,
		splitting: true,
		metafile: true,
		minify: true,
		format: 'esm',
		target: 'es2020',
		inject: [ './preact-shim.js' ],
		jsxFactory: 'h',
		jsxFragment: 'Fragment',
		outdir: `./${folder}`,
		external: [ '*.woff', '*.woff2', ...external ],
		loader: { '.js': 'jsx' },
		define: { 'process.env.NODE_ENV': '"production"', global: '{}' },
		plugins
	});
	
	if (analyze) {
		if (report) console.log(await esbuild.analyzeMetafile(result.metafile));
		console.log('writing meta file...');
		await fs.writeFile(`./${folder}/meta.json`, JSON.stringify(result.metafile));
	}
};

/**
 * client side build
 */
const buildCSR = async () => {
	
	// build bundles from main entry - keep preact external because it gets broken by minification variable renames
	await build('./src/csr.js', 'dist', [
		{
			name: 'importMap',
			setup: build => build.onResolve({ filter: /.*?/ }, (args) => {
				if (args.path === 'react' || args.path === 'preact/compat') return { path: '/preact-compat.js', namespace: args.path, external: true };
				if (args.path === 'preact' || args.path === 'preact/async') return { path: '/preact-async.js', namespace: args.path, external: true };
				return {};
			})
		}
	], [ 'preact', 'preact-async', 'react', '/preact-compat.js', '/preact-async.js' ]);
	
	// copy the preact bundles - server will serve these like other bundles - replace preact and hooks import paths to something that browser can understand
	await copyPreactBundle('./node_modules/preact-async/async/dist/async.module.js', 'preact-async.js');
	await copyPreactBundle('./node_modules/preact-async/compat/dist/compat.module.js', 'preact-compat.js');
	await copyPreactBundle('./node_modules/preact-async/hooks/dist/hooks.module.js', 'preact-hooks.js');
};

/**
 * copies a preact bundle - updates import paths in them
 */
const copyPreactBundle = async (source, target) => {
	let contents = await fs.readFile(source, 'utf8');
	contents = contents.replaceAll('from"preact"', 'from"/preact-async.js"');
	contents = contents.replaceAll('from"preact/hooks"', 'from"/preact-hooks.js"');
	await fs.writeFile(`./dist/${target}`, contents);
	await fs.cp(`${source}.map`, `./dist/${source.split('/').pop()}.map`);
};

/**
 * server side build
 */
const buildSSR = async () => {
	await build('./src/ssr.js', 'ssr');
};

// run CSR and SSR build in parallel
await Promise.all([ buildCSR(), buildSSR() ]);

cucar avatar Sep 15 '23 18:09 cucar