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

Add when directive #439

Merged
merged 6 commits into from
Aug 22, 2018
Merged

Add when directive #439

merged 6 commits into from
Aug 22, 2018

Conversation

LarsDenBakker
Copy link
Contributor

@LarsDenBakker LarsDenBakker commented Aug 19, 2018

This PR adds a when directive, which can be used to efficiently switch between templates.

Based on the condition, either the 'if' or the 'else' value is rendered. When switching between conditions, the previously rendered DOM is cached and re-used instead of recreated.

Caching is done by reparenting the the rendered content into a detached dom node when the condition switches. I didn't change any code in the actual library itself, but I think might be useful to create some extra helper functions for moving around part contents. Repeat could also make use of this.

I think this concept could be extended further by creating a 'switch' directive which switches between dom content and caches nodes.

@ruphin
Copy link
Contributor

ruphin commented Aug 19, 2018

This implementation evaluates both possible outcomes on each resolution. Have you considered a lazy-evaluated implementation? It makes for a less clean API since you'll have to wrap each outcome in an anonymous function, but I think the behaviour is more "safe" and in line with expectation.

I think this implementation is a footgun, and it will invariably lead to people accidentally evaluating code they don't intend to.

@LarsDenBakker
Copy link
Contributor Author

Yeah, I agree. Lazy evaluation is something we will need in other places, it'd be nice to handle this in the core somehow. Currently functions are rendered as a string, maybe lit could just unpack the function when passed as a value. Would that introduce much overhead for regular rendering?

@ruphin
Copy link
Contributor

ruphin commented Aug 19, 2018

I think lazy evaluation is only necessary in a context where multiple results are possible.

If lit is to assume that all functions return results, should it always just call the function and render the results instead? What if the function returns another function? There's a lot of corner cases to handle here, and I don't think it should be the responsibility of core to do this.

The issue can easily be solved in the relevant directives, in this case:

if (condition) {
  value is ifValue();
} else {
  parts.reverse();
  value = elseValue();
}

Which changes the API to:

html`${when(condition, () => html`<div></div>`, () => html`<span></span>`)}`

It's just a question of what API you want the directive to have. As I mentioned above, I think any directive with multiple options should always be lazy-evaluated by default, because it is easy for developers to make mistakes otherwise.

src/lib/when.ts Outdated
const parts: PartCache = [
new NodePart(parentPart.templateFactory),
new NodePart(parentPart.templateFactory),
document.createElement('div'),
Copy link
Contributor

Choose a reason for hiding this comment

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

Have you considered using a DocumentFragment instead of a div? It allows for easier relocating of nodes, and also makes more sense semantically.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've never worked with DocumentFragment before, I will look into that.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm pretty sure you are familiar with it :)
For example, HTMLTemplateElement uses DocumentFragment to store the template contents:

https://developer.mozilla.org/en-US/docs/Web/API/HTMLTemplateElement/content

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I've seen it before but just never used myself or looked much into it. Definitely is the better option here.

src/lib/when.ts Outdated

import {directive, reparentNodes, DirectiveFn, NodePart} from '../core.js';

type PartCache = [NodePart, NodePart, DocumentFragment];
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason to have this an Array instead of an Object with named keys?

{
  ifPart: NodePart,
  elsePart: NodePart,
  container: DocumentFragment
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

Agreed, but a small naming nit: since this isn't exactly an if statement, I'd name the properties semantically as truePart and falsePart

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I see what you mean. I started out with just two parts, so I made it a tuple to keep it simple and because I did the reverse() later on. Later on I added the container element.

src/lib/when.ts Outdated
export const when = (condition: boolean, ifValue: () => any, elseValue: () => any): DirectiveFn<NodePart> =>
directive((part: NodePart) => {
// Create cache if this was the first render
if (!partCaches.has(part)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Using Map#has() causes there to be two map lookups in the hit case and three in the miss case. Use Map#get() instead and check for undefined:

let partCache = partCaches.get(part);
if (partCache === undefined) {
  partCache = {
    truePart: new NodePart(parentPart.templateFactory),
    falsePart: new NodePart(parentPart.templateFactory),
    container: document.createDocumentFragment(),
  };
  partCaches.set(part, partCache);
}

This is one lookup for a hit, two for a miss.

src/lib/when.ts Outdated

import {directive, reparentNodes, DirectiveFn, NodePart} from '../core.js';

type PartCache = [NodePart, NodePart, DocumentFragment];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Agreed, but a small naming nit: since this isn't exactly an if statement, I'd name the properties semantically as truePart and falsePart

src/lib/when.ts Outdated
if (condition) {
value = ifValue();
} else {
parts.reverse();
Copy link
Collaborator

Choose a reason for hiding this comment

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

this reverse() is a bit clever and hard to follow, IMO. Rather than a parts array, can you create two named variables?

src/lib/when.ts Outdated
const parts = [partCache[0], partCache[1]];
let value;

if (condition) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Before even checking the condition, I think we want to check that it's different from last time. If it's the same we can just propagate the current value.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The value to set depends on the condition, even if it didn't change. So we still need to do some work. You're right though currently we do a lot of dom manipulation when not necessary.

I've separated the work for moving dom nodes when switching conditions.

src/lib/when.ts Outdated

// Set the new part's value
newPart.setValue(value);
newPart.commit();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Shouldn't need a commit here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The commit is on the 'child part' of true or false condition, not on the 'parent part' of the directive. Without a commit, the value doesn't update.

});

test('caches if/then templates', () => {
let ifEl;
Copy link
Collaborator

Choose a reason for hiding this comment

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

these can be const

src/lib/when.ts Outdated
const nextValue = condition ? trueValue() : falseValue();

// Swap nodes if condition changed from previous
if (condition !== prevCondition) {
Copy link
Contributor

Choose a reason for hiding this comment

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

You are checking for equality against the prevCondition, but what you really want is to check if they are both truthy or both falsy. Does TypeScript automatically add a boolean conversion to condition because it is typed as a boolean? If not, it might be worth to manually cast condition to a boolean value with condition = !!condition to make sure this check returns what you expect.

Copy link
Contributor Author

@LarsDenBakker LarsDenBakker Aug 20, 2018

Choose a reason for hiding this comment

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

Typescript forces consumers to use a boolean value, which I like personally. But for JS I guess the expectation that it checks truthiness instead.

If we want to support that, then it needs some extra checks as now I depend on boolean equality so that the initial value of undefined for the cached condition does not cause it to match the false condition.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Don't assume that consumer will be using TypeScript. You can rely on the type as much as you want to assert that the docs have to be followed exactly. In this case, JS users are so used to "truthiness" that we should support it.

condition = !!condition is a fine way to do this, and you could even type condition as unknown. Make sure you test initial render in both the true and false cases.

src/lib/when.ts Outdated
const nextValue = condition ? trueValue() : falseValue();

// Swap nodes if condition changed from previous
if (condition !== prevCondition) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Don't assume that consumer will be using TypeScript. You can rely on the type as much as you want to assert that the docs have to be followed exactly. In this case, JS users are so used to "truthiness" that we should support it.

condition = !!condition is a fine way to do this, and you could even type condition as unknown. Make sure you test initial render in both the true and false cases.

src/lib/when.ts Outdated
let cache = partCaches.get(parentPart);

// Create a new cache if this is the first render
if (!cache) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use an explicit check instead of relying on boolean conversion: if (cache === undefined)

src/lib/when.ts Outdated
* @param parentPart the parent part
* @returns the cache for the given parent. creates a new cache if none exists
*/
function getPartCache(parentPart: NodePart) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Since this function is only called once I'd just linine it. Especially because it has side-effects on parentPart

src/lib/when.ts Outdated
const partCache = getPartCache(part);
const { truePart, falsePart, cacheContainer, prevCondition } = partCache;

const nextPart = condition ? truePart : falsePart;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Add some comments on what next and previous mean here.

@aadamsx
Copy link

aadamsx commented Aug 20, 2018

Don't assume that consumer will be using TypeScript. You can rely on the type as much as you want to assert that the docs have to be followed exactly. In this case, JS users are so used to "truthiness" that we should support it.

I for one will be using this lib when it his 1.0 and WILL NOT be using Typescript to save my life. :)

@justinfagnani
Copy link
Collaborator

@LarsDenBakker #436 is submitted, so please rebase to master now.

@LarsDenBakker
Copy link
Contributor Author

I rebased onto master

@justinfagnani justinfagnani merged commit 47ae12e into lit:master Aug 22, 2018
neuronetio pushed a commit to neuronetio/lit-html that referenced this pull request Dec 2, 2019
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