Skip to content
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

feat: precompile JSX to string transform #162

Merged
merged 64 commits into from
Oct 24, 2023

Conversation

bartlomieju
Copy link
Member

@bartlomieju bartlomieju commented Oct 19, 2023

This PR introduces a new JSX transform that is optimized for server-side rendering. It works by serializing the static parts of a JSX template into static string arrays at compile time. Common JSX templates render around 200-500 nodes in total and the transform in this PR changes the problem from an object allocation to string concatenation one. The latter is around 7-20x faster depending on the amount of static bits inside a template. For the dynamic parts, this transform falls back to the automatic runtime one internally.

Quick JSX history

Traditionally, JSX leaned quite heavily on the Garbage Collector because of its heritage as a templating engine for in-browser rendering.

// input
const a = <div className="foo" tabIndex={-1}>hello<span /></div>

// classic Transform
const a = React.createElement(
  "div",
  { className: "foo", tabIndex: -1 },
  "hello",
  React.createElement("span", null)
);

Every element expands to two object allocations, a number which grows very quickly the more elements you have.

const a = {
  type: "div",
  props: {
    className: "foo",
    tabIndex: -1,
    children: [
      "hello",
      {
        type: "span",
        props: null
      }
    ]
  }
}

The later introduced automatic runtime transform, didn't change that fact and merely brought auto importing the JSX factory function, some special handling of the key prop to the table and putting children directly into the props object. The main goal of that transform was to improve the developer experience and avoid an internal deopt that most frameworks ran into by having to copy the children argument around.

// input
const a = <div className="foo" tabIndex={-1}>hello<span /></div>

// automatic Transform
import { jsx } from "react/jsx-runtime";
const a = jsx(
  "div",
  {
    className: "foo",
    tabIndex: -1,
    children: [
      "hello",
      jsx("span", null)
    ]
  }
})

...still expands to many objects:

const a = {
  type: "div",
  props: {
    className: "foo",
    tabIndex: -1,
    children: [
      "hello",
      {
        type: "span",
        props: null
      }
    ]
  }
}

Both transforms are quite allocation heavy and cause a lot of GC pressure. JSX element is at minimum converted into two object allocations and for many frameworks even a third one if they are backed by fiber nodes. This easily leads to +3000 allocations per request.

Precompiling JSX at transpile time

The proposed transform in this PR moves all that work to transpilation time and pre-serializes all the static bits of the JSX template.

// input
const a = <div className="foo" tabIndex={-1}>hello<span /></div>

// this PR
import { jsxssr, jsxattr } from "react/jsx-runtime";

const tpl = ['<div class="foo" ', '>hello<span></span></div>']
const a = jsxssr(tpl, jsxattr('tabindex', -1));

The jsxssr function can be thought of as a tagged template literal. It has very similar semantics around concatenating an array of static strings with dynamic content. To note here is that the className has been automatically transformed to class, same for tabIndex -> tabindex. Instead of creating thousands of short lived objects, the jsxssr function essentially just calls Array.join() conceptually. Only the output string is allocation, which makes this very fast.

Benchmarks

I've put this transform through the tests in a benchmark. The first is the automatic transform with Preact, second is the transform from this PR rendered by Preact and third is a custom HTML transform that skips component instances entirely. The latter is obviously the fastest, as component instances are quite heavy too, and currently those need to be instantiated for backwards compatibility reasons in Preact.

  • precompiled Preact: ~7.5x faster
  • fully precompiled HTML: ~20x faster
Screenshot 2023-10-20 at 20 26 34

Usefulness for the ecosystem

The transform in this PR was intentionally designed in a way that makes it independent of any framework. It's not tied to Preact or Fresh at all. Rather, by setting a custom jsxImportSource config in deno.json you can point it to your own factory functions. Each factory function needs to implement the following functions:

  • jsx(type: Function, props: Record<string, unknown>, key?: unknown) the automatic runtime jsx factory for dynamic elements or components.
  • jsxssr(tpl: string[], ...dynamicParts: unknown[]) the main templating function which merges the static bits and with the dynamic bits
  • jsxattr(name: string, value: unknown) used to serialize dynamic attributes when an element can be serialized. By using a function call frameworks can also return an empty string for event listeners for example and avoid them being serialized

Note: That custom implementations don't have to adhere to specific return values. The transform only expects a certain signature, so if you can also use it for frameworks which don't need to materialize component instances.

If you're interested in more of the transpilation output, checkout the tests.

@CLAassistant
Copy link

CLAassistant commented Oct 20, 2023

CLA assistant check
All committers have signed the CLA.

src/transpiling/jsx_string.rs Outdated Show resolved Hide resolved
src/transpiling/jsx_string.rs Outdated Show resolved Hide resolved
src/transpiling/jsx_string.rs Outdated Show resolved Hide resolved
src/transpiling/jsx_string.rs Outdated Show resolved Hide resolved
src/transpiling/jsx_string.rs Outdated Show resolved Hide resolved
src/transpiling/mod.rs Outdated Show resolved Hide resolved
src/transpiling/jsx_string.rs Outdated Show resolved Hide resolved
@bartlomieju bartlomieju changed the title [WIP] feat: JSX string transform feat: JSX string transform Oct 23, 2023
@bartlomieju bartlomieju changed the title feat: JSX string transform feat: precompile JSX to string transform Oct 23, 2023
src/transpiling/mod.rs Outdated Show resolved Hide resolved
Copy link
Member

@dsherret dsherret left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Just going to block this for a minutes because there's an swc change I want to land and patch before merging this one.

src/transpiling/jsx_precompile.rs Outdated Show resolved Hide resolved
src/transpiling/jsx_precompile.rs Outdated Show resolved Hide resolved
Copy link
Member

@dsherret dsherret left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer blocked to merge.

Copy link
Contributor

@marvinhagemeister marvinhagemeister left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's a go!

@marvinhagemeister marvinhagemeister merged commit 648f35d into denoland:main Oct 24, 2023
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants