Skip to content

Conversation

@jviide
Copy link
Collaborator

@jviide jviide commented Oct 18, 2019

This pull request modifies htm's caching strategy in three major ways:

  • Use only Map for caching, getting rid of the previous fallback to string-keyed caching. This saves about 50 bytes in the brotli-compressed bundle.

  • Create a separate cache for each htm.bind(h) call, which allows us to...

  • Add static subtree caching. In short this means that HTM tracks the staticness of subtrees it evaluates, and caches h(...) call results for static subtrees. In short it's kind of like a on-the-fly @babel/plugin-transform-react-constant-elements (though less clever as even immutable dynamic properties/tags/chidren will prevent caching).

Static subtree caching

In this context by static we mean a subtree where the root element and none of its descendants depend on dynamic values (an empty set of descendants is considered static). In the following example only the span element is static. This is because the h1 element depends on a dynamic child value, div depends on a dynamic property value. main, while not directly dependent on a dynamic value, has non-static descendants (h1 and div).

html`
  <main>
    <h1>${"not static"}</h1>
    <div class=${foo}></div>
    <span>totally-static</span>
  </main>
`

The implementation takes advantage of the fact that build(...) uses the first index of the opcode lists it created for bookkeeping. Previously evaluate(...) just ignored the first element, but now evaluate repurposes the first element to store staticness info about the subtree that the opcode list represents.

The caching itself is done by rewriting the opcode list on-the-fly. Each new child that gets evaluated with h is represented in the opcode list as a slice like CHILD_RECURSE, 0, [...omitted...]. If the evaluated child x = h(...) is determined to be static then the slice gets rewritten in-place to CHILD_APPEND, 0, x. If the opcode list is re-evaluated at some later time then the x value is just reused, short-circuiting the evaluation process.

There are new tests to verify that subtree caching works.

Breaking changes

Due to subtree caching h is now required to be a pure function, or at least pure enough that it's OK to skip re-evaluating static subtrees. This new requirement for h could be considered an API change.

The fact that there is no a fallback for environments where Map is not defined is a breaking change. However even IE 11 supports Maps, and in older environments it can be polyfilled.

The main interface for binding h functions with htm.bind(h) remains unchanges. This is however a trick - htm.bind is redefined by us to allow creating a new cache per htm.bind call. This is technically a breaking change, as htm.call(h, ...) doesn't work like previously. As a counterpoint, the usage of htm.bind(h) was the documented way to use the library, while htm.call(h, ...) and others were not.

Performance & size

The Brotli-compressed size of the library decreases by 25 bytes compared to the current master (596 B -> 571 B). The size of htm/mini remains unchanged though.

The performance downsides and benefits need more benchmarking, but here are some preliminary notes:

  • In the test:perf benchmark the worst-case scenario where every element is dynamic is about 4% slower compared to the current master.

  • Switching to a more realistic h implementation (React.createElement) the difference disappears.

  • Keeping the React.createElement as the h and modifying the benchmark to the following:

    html`
      <div>
        <span>${1}</span>
        <span>totally-static</span>
      </div>
    `

    This PR's version is now about 42% faster than the current master.

  • These benchmarks do not test diffing etc. at all, only VDOM node creation.

@jviide jviide mentioned this pull request Oct 18, 2019
@jviide jviide requested a review from developit October 18, 2019 21:31
@developit
Copy link
Owner

I'm a little bit worried still about dropping support for htm.call() and htm.bind(h, strings). I wonder how brutal it would be for filesize to fall back to a global cache Map in the case where htm is not bound? Thinking of ways to make that work...

@jviide
Copy link
Collaborator Author

jviide commented Dec 24, 2019

Pushed a new commit with an alternative approach. Now htm.bind(h) and others work as in master, but a separate cache is created for each separate this inside the htm call.

htm.module.js.br size actually goes further down by 1 byte (to 570 B). Despite the double-Map.get per call performance numbers seem unchanged (on my machine).

@developit
Copy link
Owner

developit commented Dec 25, 2019

That's awesome! I think the double map get would only really come into play for folks who are using htm.call(x), which is totally a reasonable tradeoff. I really like this.

So the only backwards-incompatible change aside from the intentional reuse of subtrees would just be that HTM no longer goes out of its way to patch the Tagged Template strings bugs in Safari 10/11. IMO that's fine, there are better solutions to that issue folks should be using.

So... methinks we should merge this!

@jviide
Copy link
Collaborator Author

jviide commented Dec 26, 2019

I snuck in one more test, checking that different h functions indeed get different caches.

@jviide jviide merged commit 75fbb2e into developit:master Dec 26, 2019
@jviide jviide deleted the next-level-shenanigans branch December 26, 2019 21:14
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.

2 participants