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

[WIP] Hydration #649

Merged
merged 13 commits into from
Jun 23, 2017
Merged

[WIP] Hydration #649

merged 13 commits into from
Jun 23, 2017

Conversation

Rich-Harris
Copy link
Member

@Rich-Harris Rich-Harris commented Jun 17, 2017

This is far, far, far from done, but I thought I may as well put it up here and try to articulate the approach I'm taking.

Essentially, each block gets two three new methods — create, claim and hydrate. create contains most of the stuff that previously went at the top of the block, while claim contains basically the same code but 'claiming' chunks of existing DOM rather than creating new stuff:

function create_main_fragment ( state, component ) {
  var h1, text;

  return {
    create: function () {
      h1 = createElement( 'h1' );
      text = createText( "Hello world!" );
    },

    claim: function ( nodes ) {
      h1 = claimElement( nodes, 'H1' );
      var h1_nodes = children( h1 )

      text = claimText( h1_nodes, "Hello world!" );
    },

    mount: function ( target, anchor ) {
      insertNode( h1, target, anchor );
      appendNode( text, h1 );
    },

    unmount: function () {
      detachNode( h1 );
      detachNode( h1 );
    },

    destroy: noop
  };
}

The hydrate method (if necessary) adds attributes, event listeners and so on.

There are three new helper functions referenced here — children, claimElement and claimText:

export function children ( element ) {
  return Array.from(element.childNodes);
}

export function claimElement ( nodes, name ) {
  for (var i = 0; i < nodes.length; i += 1) {
    var node = nodes[i];
    if (node.nodeName === name) {
      return nodes.splice(i, 1)[0]; // TODO strip unwanted attributes
    }
  }

  return createElement(name);
}

export function claimText ( nodes, data ) {
  for (var i = 0; i < nodes.length; i += 1) {
    var node = nodes[i];
    if (node.nodeType === 3) {
      node.data = data;
      return nodes.splice(i, 1)[0];
    }
  }

  return createText(data);
}

So we begin as before, calling create_main_fragment, but then we hydrate it with the array of nodes that comprise the children of options.target:

this._fragment = create_main_fragment( this._state, this );
this._fragment.hydrate( children( options.target ) );
this._fragment.mount( options.target, null );

Notice that we're not diffing in the conventional sense — we're simply grabbing the first node that seems like it might be the right one. This ought to handle most cases (DOM is empty, DOM is already in exactly the right state, DOM needs minor repairs, whether that means adding or removing nodes) pretty well, and only have negative performance implications in pathological situations that are easily avoided.

TODO:

  • Removing excess DOM at the end of the process
  • Removing unwanted attributes from elements
  • each blocks (keyed and unkeyed)
  • raw tags
  • components
  • making this opt-in (i.e. skipping hydrate methods and associated helpers unless, say, the component is compiled with options.hydratable === true)
  • things I haven't thought of yet

@PaulBGD
Copy link
Member

PaulBGD commented Jun 17, 2017

I'm very interested in this feature, anything I can help with?

@Rich-Harris
Copy link
Member Author

@PaulBGD testing 😀 Everything is quite dynamic at the moment so we'll probably just trip over each other if we're both working on the implementation in its current form, but we should have a reasonable first draft soon, at which point trying it out on existing apps will be very helpful

@Rich-Harris
Copy link
Member Author

Alright, I think this is basically ready. It's quite a big PR, and the code generation code is getting increasingly unkempt, but if anyone fancies giving it a once over I'd be grateful.

I have hydration working on my local build of https://svelte.technology — it seems to render the guide a decent amount quicker (~55ms down from ~80ms, though these are very rough averages as there's a bit of variance and I couldn't be bothered to take notes and get the calculator out), but actually renders the REPL slightly slower (~12ms up from ~10ms). On balance I think this is probably a good change — am sure we can identify and remove that minor slowdown.

A couple of caveats:

  • It doesn't yet hydrate {{{raw}}} nodes — these are blown away as before. Maybe something to fix later.
  • mount is still called, even if hydration is used — this ensures that everything is in the correct order (since claim is not order-sensitive), and some other stuff happens there, but it might be redundant in cases where the original DOM was already correct. Again, something to think about later

To use hydration, components must be compiled with hydratable: true, and instantiated with hydrate: true. These are open to bikeshedding!

As an aside, I'm more anxious than ever to come up with a neater approach to code generation, as it really is getting quite tricky to understand what's going on.

All thoughts welcome as ever.

@DylanPiercey
Copy link

DylanPiercey commented Jun 18, 2017

@Rich-Harris extremely excited for this feature, thanks for all your work on it.

I had a couple things I was thinking.

  1. Wouldn't the claim function fail if there were two h1 out of order (probably not a big deal)?
  2. What are your thoughts on compiling directly to accessing the node or creating it inside claim like so?
    claim: function ( nodes ) {
      h1 = nodes[0] || createElement( 'H1' );
      var h1_nodes = children( h1 )

      text = h1_nodes[0] || createText( "Hello world!" );
    },

Would be a bit faster and perhaps more compact? Just a thought.
Honestly though perf barely matters here - the big thing is not throwing away stateful DOM like inputs within forms or videos and such.

@Rich-Harris
Copy link
Member Author

The answers to 1 & 2 are linked — basically, we need to be able to handle cases where the DOM isn't in the state we expect. If we do this...

h1 = nodes[0] || createElement( 'H1' );

...and the HTML looks like this...

<!-- here comes the h1! -->
<h1>Hello world!</h1>

...then h1 will be the comment, not the <h1> element. One approach would be to verify that the DOM is in the correct state — Vue does this, and React does something similar with a checksum (though they're moving away from that I think) — but that has some fairly major limitations. Firstly, you have to use SSR, you can't just have an index.html with some skeleton markup. Secondly, you can't do this sort of thing:

{{#if loading}}
  <p>loading, please wait</p>
{{else}}
  <p>loaded, woo!</p>
{{/if}}
// server
res.write(app.render({ loading: true }));

// client
new App({
  target: document.body,
  hydrate: true,
  data: { loading: false }
});

So, to the first question —

Wouldn't the claim function fail if there were two h1 out of order

— it depends what you mean by fail. If you had something like this...

<h1>this is the wrong element</h1>
<h1>this is the right element</h1>

...then yes, it would claim the wrong element. But then it would repair the text node inside it and discard the second <h1>, so the DOM would end up in the right state. So it kind of failed, insofar as it selected the wrong node (because it's not diffing the entire tree, it's just scanning the array of current child nodes), but it also didn't fail because the DOM ended up correct. It's arguably suboptimal (because it had to repair the text node), but I think correct-but-suboptimal is okay in a situation like that — this approach optimises for correct and nearly-correct starting DOM, guaranteeing the correct result while avoiding full-tree diffing.

@DylanPiercey
Copy link

Ah yes, makes sense to me. Thanks for clearing both of those questions up.

@lukeed
Copy link
Member

lukeed commented Jun 19, 2017

You're a wizard, Harris ✨

@Rich-Harris Rich-Harris merged commit 1ce6cfa into master Jun 23, 2017
@Rich-Harris Rich-Harris deleted the hydration branch June 23, 2017 12:28
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