-
Notifications
You must be signed in to change notification settings - Fork 918
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
Add when directive #439
Conversation
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. |
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? |
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'), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
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
}
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
src/test/lib/when_test.ts
Outdated
}); | ||
|
||
test('caches if/then templates', () => { | ||
let ifEl; |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
I for one will be using this lib when it his 1.0 and WILL NOT be using Typescript to save my life. :) |
@LarsDenBakker #436 is submitted, so please rebase to master now. |
I rebased onto master |
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.