-
Notifications
You must be signed in to change notification settings - Fork 396
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
refactor(engine-core): encapsulate renderer as an object and allow it to be injectable in vnodes #2763
Conversation
@@ -50,7 +51,12 @@ function addVNodeToChildLWC(vnode: VCustomElement) { | |||
} | |||
|
|||
// [h]tml node | |||
function h(sel: string, data: VElementData, children: VNodes = EmptyArray): VElement { | |||
function h( | |||
renderer: RendererAPI, |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
@@ -24,29 +24,6 @@ import { | |||
KEY__SYNTHETIC_MODE, | |||
setPrototypeOf, | |||
} from '@lwc/shared'; | |||
import { |
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.
there are now extracted from the renderer associated to the vm
for a lightning element.
@@ -490,20 +511,20 @@ const childGetters = [ | |||
'lastElementChild', | |||
] as const; | |||
|
|||
function getChildGetter(methodName: typeof childGetters[number]) { | |||
function getChildGetter(methodName: typeof childGetters[number], renderer: RendererAPI) { |
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.
@nolanlawson I believe this abstraction can be removed completely now... seems unnecessary since no more optimization is possible for those import statements.
@@ -531,16 +551,16 @@ const queryMethods = [ | |||
'querySelector', | |||
'querySelectorAll', | |||
] as const; | |||
function getQueryMethod(methodName: typeof queryMethods[number]) { | |||
function getQueryMethod(methodName: typeof queryMethods[number], renderer: RendererAPI) { |
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 is the counter part of the previous note @nolanlawson, also can be removed.
@@ -51,47 +51,3 @@ export type { | |||
WireAdapterConstructor, | |||
WireAdapterSchemaValue, | |||
} from './wiring'; | |||
|
|||
// Initialization APIs for the renderer, to be used by engine implementations ---------------------- |
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.
removing all these public apis... with this change, core package is useless by itself because it doesn't export the renderer methods needed by a compiled template, basically the compiled code must import or be linked to a production of a package that does have an export called renderer
.
@@ -5,50 +5,7 @@ | |||
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT |
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 file is to be renamed to renderer.ts
@@ -8,7 +8,8 @@ | |||
import './polyfills'; | |||
|
|||
// Renderer initialization ------------------------------------------------------------------------- | |||
import './initializeRenderer'; | |||
import * as renderer from './initializeRenderer'; |
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.
not sure if we want to just export an object, or to deal with exotic namespace objects via * as
syntax.
import { isString, isFunction, isObject, isNull } from '@lwc/shared'; | ||
|
||
import * as renderer from '../renderer'; |
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.
import * is not good a determining types, probably another point to make the renderer an actual export of an object called renderer.
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.
Let's make sure to address this before merging.
export function setGetCustomElement(getCustomElementImpl: getCustomElementFunc) { | ||
getCustomElement = getCustomElementImpl; | ||
|
||
export interface RendererAPI { |
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 is just the API that was previously defined in renderer.ts shape, but since it needs to be passed around, I Don't know if there is a way to define a type based on the shape of a module that can be imported. In any case, I did it manually.
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 mentioned this during Dev Sync, but I would prefer for us to take a slightly different approach. In particular, I would prefer not to have a renderer
object that we call at runtime (renderer.getAttribute()
, renderer.setCSSStyleProperty()
, etc.) and pass around on the VM
. Instead, I would prefer to have top-level functions imported from a module (import { getAttribute, setCSSStyleProperty } from 'somewhere'
).
In a previous PR, I demonstrated runtime performance gains by using top-level functions (#2598). We also had an unintentional runtime regression that would have been avoided if we had been using top-level functions at the time (#2562). I wasn't able to run the perf tests on this PR (there are functional errors), but I would expect to see a slight perf degradation from passing around the renderer
object and calling methods on it.
Also, this PR increases the size of engine-dom.min.js
from 49.5kB to 52.1kB (2.6kB increase). So there is a bundle size regression as well.
It seems that the goal is to be able to add hooks into the renderer so that Locker can run custom logic. But even in that case, there is only one renderer – it is a singleton. So I would prefer to make this hook a compile-time concern rather than a runtime concern.
One way to do this is the technique I originally used in #2598, which is to have a separate @lwc/renderer-abstract
package that is imported by @lwc/engine-core
. The expectation is that whoever is building their own version of LWC (e.g. @lwc/engine-dom
, @lwc/engine-server
, or @lwc/engine-dom-with-locker-hooks
) would be responsible for supplying the implementation of this package and including it in the Rollup config.
Another solution is something like #2758 (comment), which is to hoist all the renderer
functions into the lwc
package. Then you would do e.g. import { getAttribute } from 'lwc'
.
Both of these approaches add code complexity, but the benefit is that end-users do not pay the perf tax for extra runtime code to expose hooks (or to pass the renderer
object around). I think this is especially important for use cases where consumers want the smallest possible bundle size and do not need Locker (e.g. off-core B2C), so we should be sensitive to the perf concerns of those use cases.
The problem is that the whole point of this PR is to create an indirection between the renderer, and the consumers of the renderer via the compiled template. Meaning, the template file itself doesn't know what methods of the renderer will be used by core, it only knows those that are used by template optimizations directly. In terms of hooks, the idea here is that even core is subject to the transformation of the imported, in this case, the importer is the compiled template, and that's where locker can intervene. The two solutions that you mentioned can't really allow such thing. |
@caridy I don't follow you. In the model I propose, anyone who is building their own version of LWC (e.g. In this commit (2d548bd), I had a PoC of how this would work. The compiled template would do: import { getAttribute } from '@lwc/renderer-abstract' And then we'd use a simple Rollup plugin to substitute at build time: plugins: [
{
resolveId(importee) {
if (importee === '@lwc/renderer-abstract') {
return 'my-custom-renderer-implementation';
}
},
}, Is this not compatible with your proposal? |
No, because the compiled code has no idea what function to import, it can only import everything, and passing it around for core to do its thing. |
After talking with Caridy, I get it now. Each template needs its own sandbox. There's not just one sandbox for the whole engine JS file. So the template can't just import the renderer APIs from some shared module. I could still imagine some clever way to compile away the boilerplate for the off-core usecase (e.g. maybe some reference to the sandbox could be passed in as the first argument to every renderer API), but I dunno if it's worth it for the 1-4% performance gain. Worth noodling on though. |
@caridy After chatting with @pmdartus, we have a couple counter-proposals:
|
@nolanlawson neither of those will work with synthetic shadow or light-dom where each vnode might be associated to a different template at the time of the reification of the vnode into a dom node. What this means is that I might "render" a node into the DOM that was produced by a template two levels up, which might be running in a different sandbox, and it is subject to a different set of distortions/capabilities. |
Correct. Today, VNodes are passed from the parent to the child components in the Light DOM and synthetic shadow DOM. Currently, each VNode has to have enough information on itself to be rendered by the child component. It includes the That said, I don't think it should be the case. All the VNodes produced by a given template share the same Granted it will make the engine code a little more complex but I see the following advantages in contextual information from the VNode:
|
@pmdartus I agreed with you! I have changed this PR to reflects that, now the |
@ravijayaramappa it turns out that this PR is very far from what we discussed today. It is about one renderer API per template (or the vm that holds the template to be more precise), but what we want is a renderer API associated to a vnode (and consequently to the reifying version of that which is the node). |
a952c43
to
00e9140
Compare
export let getCustomElement: getCustomElementFunc; | ||
export function setGetCustomElement(getCustomElementImpl: getCustomElementFunc) { | ||
getCustomElement = getCustomElementImpl; | ||
let defaultRenderer: RendererAPI; |
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.
What is the value of keeping around defaultRender
? Since all the VM have an associated renderer, the renderer can be passed as an argument to the patching method: patchChildren
and hydrateChildren
.
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 will like to explore that. You still need to have access to the defaultRenderer though
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.
done something similar, instead of default renderer, I'm passing the proper renderer, whether it is default or not is relevant... take another look.
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.
Nice.
Now speaking about the getRendererFromVNode
. If we put aside all the usage of modules/*.ts
, this function is only used in patch
and mount
. In both places, we have access to the owner's renderer. We could update the getRendererFromVNode
renderer to completely get rid of this concept of default renderer:
export function getRendererFromVNode(vnode: VBaseElement, fallbackRenderer: RendererAPI): RendererAPI {
return vnode.data.renderer ?? fallbackRenderer;
}
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.
that will be awesome, the whole setDefault* injection mechanism, we know how that goes... so I believe you're absolutely right, because a vnode can't be created without creating a vm first. I will work on this today.
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.
it is gone!
@pmdartus the reasoning for going with two abstract methods for collecting the renderer was to avoid mistakes. since the renderer is available off of the vm, and vm is accessible everywhere (including vnode.owner), to avoid mistakes (mostly destructing) we came up with the idea of using the methods instead. We can remove that abstraction for sure, that's what we had before. |
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.
Still reviewing.
'lastElementChild', | ||
] as const; | ||
|
||
function getChildGetter(methodName: typeof childGetters[number]) { |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
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 is great!
@@ -341,7 +345,9 @@ export function createVM<HostNode, HostElement>( | |||
return vm; | |||
} | |||
|
|||
function computeShadowMode(def: ComponentDef, owner: VM | null) { | |||
function computeShadowMode(def: ComponentDef, renderer: RendererAPI, owner: VM | null) { | |||
const { isSyntheticShadowDefined, isNativeShadowDefined } = renderer; |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
const token = cmpTemplate?.stylesheetToken; | ||
if (!isUndefined(token) && context.hasScopedStyles) { | ||
// TODO [#2762]: this dot notation with add is probably problematic |
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 right, the token comes from user land. The user can tinker with the compiled template and change the token. So it must be run through the hooks.
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 we merge this, I'd like to have a conversation about whether the current renderer
is the right level of abstraction to expose to Locker. It's the right level of abstraction for splitting up @lwc/engine-dom
from @lwc/engine-server
, but that doesn't necessarily mean it's the right level of abstraction for Locker.
Also I'd like us to run the perf tests just to get an idea of the impact of this PR. (yarn build:performance && yarn test:performance
)
packages/@lwc/engine-core/src/framework/base-lightning-element.ts
Outdated
Show resolved
Hide resolved
} | ||
return renderer.getLastElementChild(vm.elm); | ||
}, | ||
|
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 duplication is a bit unfortunate. Before #2598 we had a way to consolidate some of this logic:
lwc/packages/@lwc/engine-core/src/framework/base-lightning-element.ts
Lines 498 to 518 in caa3c72
const childGetters: Array< | |
[ | |
keyof HTMLElement, | |
keyof Pick< | |
Renderer, | |
| 'getChildren' | |
| 'getChildNodes' | |
| 'getFirstChild' | |
| 'getFirstElementChild' | |
| 'getLastChild' | |
| 'getLastElementChild' | |
> | |
] | |
> = [ | |
['children', 'getChildren'], | |
['childNodes', 'getChildNodes'], | |
['firstChild', 'getFirstChild'], | |
['firstElementChild', 'getFirstElementChild'], | |
['lastChild', 'getLastChild'], | |
['lastElementChild', 'getLastElementChild'], | |
]; |
That said, I don't have a strong opinion, because gzip will remove a lot of the duplication, and maybe it's not worth all the TypeScript gymnastics.
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.
There is also similar duplication below for querySelector
et al.
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, the main issue is the renaming of the methods... if we rename the methods in renderer.* then this becomes an easy step here, but that's not the case right now.
@nolanlawson these are the results... I'm not sure how to represet this from the CLI, but I can tell that most were faster for this PR, few were unsure, and none were slower.
|
@caridy Yeah the markdown table is not super easy to read; I have a Google Sheet I use to format the results more nicely. Here's what your results look like: So it's a regression, but in the 1-5% range. This is about what I expected. Thanks! |
Chatted with @caridy about the "right level of abstraction" issue. I think there are tradeoffs here (not having a tailor-made API surface for LWS vs potentially having tight coupling of the existing renderer API surface with downstream consumers like LWS), but given that we might have downstream consumers other than LWS, I'm fine with just exposing the whole renderer API surface. Just wanted to think a bit about the tradeoffs. |
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.
Removing my block on the PR
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.
Approving to remove my block, I'd like to see Ravi approve too before merging ideally
…in compiled templates
…in compiled templates
…ad of using the vnode
Co-authored-by: Pierre-Marie Dartus <p.dartus@salesforce.com>
Co-authored-by: Ravi Jayaramappa <ravi.jayaramappa@salesforce.com>
Co-authored-by: Nolan Lawson <nolan@nolanlawson.com>
9b72866
to
f35826f
Compare
* fix: only remove slot children in synthetic shadow (#2843) * fix: only remove slot children in synthetic shadow * fix: use case block * fix: only add version mismatch test code in karma (#2852) * test(integration-karma): ensure constructable stylesheets are re-used (#2844) * test(integration-karma): ensure constructable stylesheets are re-used * test: add test for shared style * chore(nucleus): remove more downstreams (#2855) * chore(nucleus): remove another downstream (#2857) * docs: fix typo in template compiler readme (#2848) * docs: fix typo in template compiler readme * docs: rewording usage of lwc dynamic directive Co-authored-by: Eugene Kashida <ekashida@gmail.com> Co-authored-by: Eugene Kashida <ekashida@gmail.com> * test(integration-karma): update ACT components (#2862) * fix(engine-core): revert "optimize computation of transitive shadow mode" (#2859) * fix(engine-core): correctly compute shadowMode * fix: fix comment * fix: update packages/integration-karma/test/mixed-shadow-mode/dual-component/index.spec.js Co-authored-by: Eugene Kashida <ekashida@gmail.com> * fix: fix another one * fix: improve tests * fix: improve tests * fix: improve test * fix: revert * Revert "refactor(engine): optimize computation of transitive shadow mode (#2803)" This reverts commit 95ce4c3. Co-authored-by: Eugene Kashida <ekashida@gmail.com> * chore: release v2.14.1 (#2866) * refactor(engine-core): encapsulate renderer as an object and allow it to be injectable in vnodes (#2763) * refactor(engine-core): passing the renderer from an import statement in compiled templates Co-authored-by: Ravi Jayaramappa <ravi.jayaramappa@salesforce.com> Co-authored-by: Pierre-Marie Dartus <p.dartus@salesforce.com> Co-authored-by: Nolan Lawson <nolan@nolanlawson.com> * test(integration-karma): more shadow transitivity tests (#2864) * chore: release v.2.14.2 @W-7258582 (#2871) * Revert "chore: release v2.14.1 (#2866) @W-7258582 (#2867)" This reverts commit 518ab2e. Co-authored-by: Nolan Lawson <nolan@nolanlawson.com> Co-authored-by: Mohan Raj Rajamanickam <1509984+mohanraj-r@users.noreply.github.com> Co-authored-by: Eugene Kashida <ekashida@gmail.com> Co-authored-by: Ravi Jayaramappa <ravi.jayaramappa@salesforce.com> Co-authored-by: Caridy Patiño <caridy@gmail.com> Co-authored-by: Pierre-Marie Dartus <p.dartus@salesforce.com>
Details
Does this pull request introduce a breaking change?
Does this pull request introduce an observable change?
It introduces a new API, the
renderer
object is now exported, even though is an exotic namespace object, it contains a lot of functions that are now public somehow, but kinda private in the sense that no one is supposed to use this directly, but only the compiled code.renderer
from lwc.TODO
import { renderer } from 'lwc';
in the output modele, and passingrenderer
to some of the functions exported fromapi
as well.Potential Issues
The big problem with this approach is "performance", specifically, if LWS needs to tap into these functions, it will slow down everyone when in reality LWS is only interested on the 0.01% of the DOM APIs used by LWC templates. This poses a real problem.