Conversation
660c0d8 to
59223b9
Compare
Builds ready [59223b9]
Page Load Metrics (863 ± 46 ms)
|
rekmarks
left a comment
There was a problem hiding this comment.
I won't finish reviewing this until later, but I have to say, metamask-template-renderer.js is a thing of beauty.
rekmarks
left a comment
There was a problem hiding this comment.
This PR owns. One nit and one question/comment. No blockers.
| } | ||
| return ( | ||
| <> | ||
| {sections.reduce((acc, child, index) => { |
There was a problem hiding this comment.
nit: We should rename acc to allChildren (or something better) for great specificity while avoiding a collision with the children constant assigned on L21.
| sections: ValidChildren, | ||
| } | ||
|
|
||
| export default memo(MetaMaskTemplateRenderer) |
There was a problem hiding this comment.
By my reading of the docs for React.memo, I suspect we'll need to pass a custom comparison function as the second parameter to memo to get a significant performance boost:
By default it will only shallowly compare complex objects in the props object.
If I'm right, I don't want to hold up this PR on that point, and would rather see the call to memo removed than cause delays. We can always optimize in a follow-up.
There was a problem hiding this comment.
Question: The recursive calls are not memoized the way this is written, right?
There was a problem hiding this comment.
I think this could be an easy win.
More background
The current implementations of MetaMaskTemplateRenderer handle memoization themselves, only passing referentially equal arrays to the sections prop.
For example, the new confirmation UI page has to generate template values based on the current confirmation. Because calling the template generator function is expensive, it is memoized and therefore the value will be referentially equal as long as the confirmation doesn't change.
Another scenario we might see is where the template is just a const and doesn't need to change. In which case, we don't need the isEqual check on the memo call.
Another case is we might be receiving a template payload from another provider, be it our own background or a third party in the future. In those cases, we'll be deserializing the template in some form or fashion and will want to handle memory allocation specifically.
lodash.isEqual
The very first thing this function does is check referential equality, and then it ejects early if they are equal. So even though the usages of the component might be passing referentially equal objects, having isEqual as the comparator function will give easy optimization for future usages that don't care so much about the values they are passing in. It also would mean that if the memoized values are updated but the resulting object is the same, this would also prevent a re-render.
Recursive calls
They should bypass the memo method as written, which was done purposefully to allow the root node of the tree to be the sole manager of re-rendering.
Builds ready [368c57e]
Page Load Metrics (650 ± 120 ms)
|
Could you clarify what the goals of this list are? In what respect are these components "safe" (e.g. is it just to prevent injecting scripts)?
It sounds like an error would be better here 🤔 This might lead to components being accidentally omitted. It sounds like a developer mistake, which generally we should throw for. |
The e.g, an The main thing it is used for right now is just to map element names to Components. This, I believe, is sufficient for us while testing this out and getting a feel for it. If ever this thing evolves to be used in snaps it'll need much more rigorous controls for validation and safety checks.
I'm ambivalent about this, so I'll yield to your suggestion. |
|
I support failing hard and fast by throwing re: the renderer being passed disallowed components. |
ui/app/components/app/metamask-template-renderer/metamask-template-renderer.js
Outdated
Show resolved
Hide resolved
| ) : null | ||
| if (Element) { | ||
| allChildren.push( | ||
| <Element key={`${element}${index + 0}`} {...props}> |
There was a problem hiding this comment.
Should we have a default key? 🤔 This should probably be provided to the template renderer, to ensure that distinct elements are treated as distinct by React. To do otherwise risks breaking in situations where similar components get moved around, and that moving has a side-effect (e.g. CSS transitions). Effectively we're silencing the React warning here.
There was a problem hiding this comment.
I get why you did it though. Requiring a key for every entry is demanding.
We could eliminate the key requirement in the single-element case by returning it directly, rather than in an array. It'd require another condition in the renderer for cases where sections is a single element or an array with one entry.
There was a problem hiding this comment.
I added object-hash, which I was planning on using as part of the transaction -> activity refactor and grouping by intent. This allows me to generate a key based on the shape of sections, the child we are rendering, and the current index. I strongly prefer to not make key something that is required in the object entries if we can avoid it.
There was a problem hiding this comment.
This again serves only to silence the warning, and defeats the purpose of the key: to distinguish the identity of components as props change. This is functionally identical to what this was before, isn't it?
There was a problem hiding this comment.
Actually, I'd stake some eth on this being worse than just using the element name and the index. What I did will actually change the key anytime props change anywhere in the tree because the props were included in the hash. I'm not sure what I was thinking, keys are meant to provide a stable identifier for the element. It should be the same even when props change.
I still really feel like adding a key to every entry to be tedious. however,
- the list and items are static–they are not computed and do not change;
- the items in the list have no ids;
- the list is never reordered or filtered.
When all of them are met, you may safely use the index as a key.
Source: index-as-akey-is-an-anti-pattern
the list and items are static-they are not computed and do not change
with the current implementation, the sections object only changes when the confirmation itself changes. In these cases, we're dealing with, potentially, an entirely different tree of components. They are computed, so it doesn't meet the full requirement, but we aren't dealing with a list of items in a <li>.
the items in the list have no ids
I don't see a way of generating a stable id for these objects, but we could ask the dev to id the section manually.
the list is never reordered or filtered
We can't make any strong assurances on this. In the future, we may want to render a sortable list component with the template language, which would place us in violation of this rule as well.
I'm going to work on implementing your suggestion, @Gudahtt
There was a problem hiding this comment.
Maybe we could add an optional prop to the renderer that declares something as static, meeting all three of those requirements? Then we could auto-generate keys in the manner you did previously.
As long as we don't auto-generate a key in the default case, we should be OK.
There was a problem hiding this comment.
I updated this to allow an object as valid children, indicating a single node.
To recap possible values for sections and sections.children are now:
- a string
- a object with the shape of
sections - an array of objects with the shape of
sections
when dealing with an object or string, we don't require key. When dealing with an array we do.
check the latest commit to see how the story/code changed. I think this is the safest approach. Optimizations around key generation can come later if it becomes burdensome.
There was a problem hiding this comment.
@Gudahtt i just rewrote that commit a bit, its now much simpler
Builds ready [561db1b]
Page Load Metrics (589 ± 28 ms)
|
561db1b to
e168b68
Compare
Builds ready [c432540]
Page Load Metrics (585 ± 33 ms)
|
c432540 to
c85a06b
Compare
c85a06b to
cd94e27
Compare
ui/app/components/app/metamask-template-renderer/metamask-template-renderer.js
Show resolved
Hide resolved
Builds ready [cd94e27]
Page Load Metrics (605 ± 41 ms)
|

Rationale
I have spoken before of a templating system that simply maps to React children/props. I hope that some version of this can be used by Snaps to customize the UI of MetaMask. I don't expect that the API proposed here is ready for that endeavor, however, this gives us a real-world tool to start playing around with what would be acceptable as an API for templating. It will also be insanely useful for quickly scaffolding UI.
API Design
Everything centers around
Sections. EachSectionhas this shape:The MetaMaskTemplateRenderer accepts a
sectionsprop that is either an array ofSections, an array ofstrings, an array of mixedstringsandSections, or astring. The renderer will first check ifsectionsis astringand simply return it. Otherwise, it reduces over the array ofSections. In the event of astring, thestringis pushed into the accumulator. In the event of aSection, theelementis looked up from thesafe-component-listand becomes theElementso it can be rendered as a react component. The props provided in theSectionare spread into the component. If theSectionhaschildren, we again invokeMetaMaskTemplateRendererpassingchildrenassections. In this way, the renderer recursively traverses the object and renders all elements.I think that this API will evolve to a point where our design-system "Molecules" (that is, fully designed sections with clear UX) are the only "safe" components. So instead of rendering a Box, with two Typography, etc, we render fully formed UI pieces. A good example of what this might look like is the
TruncatedDefinitionListwhich just pieces together other UI components and adds functionality.Safe guards
safe-component-listacts as an attenuation, preventing all possible HTML tags from being rendered, and allowing us to explicitly hook up our custom components.elementdoesn't exist in thesafe-component-listit is skipped and itschildrenare not rendered. A warning is issued in the console. This could be upgraded to throw an Error instead.Examples
Quick scaffolding of UI
[ { "element": "Box", "props": { "margin": 4, "padding": 8, "borderColor": "primary-1", "borderWidth": 2 }, "children": [ { "element": "Typography", "children": "A Test String", "props": { "color": "ui-3", "variant": "h2" } }, { "element": "Typography", "children": "Some more text as a paragraph", "props": { "color": "ui-4", "variant": "paragraph" } }, { "element": "TruncatedDefinitionList", "props": { "dictionary": { "term": "a word or phrase used to describe a thing or to express a concept, especially in a particular kind of language or branch of study.", "definition": "a statement of the exact meaning of a word, especially in a dictionary.", "dl": "HTML tag denoting a definition list", "dt": "HTML tag denoting a definition list term", "dd": "HTML tag denoting a definition list definition" }, "prefaceKeys": [ "term", "definition" ] } }, { "element": "Box", "children": [ { "element": "Button", "children": "Cancel", "props": { "rounded": true, "type": "outlined", "style": { "width": "45%" } } }, { "element": "Button", "children": "OK", "props": { "rounded": true, "type": "primary", "style": { "width": "45%" } } } ], "props": { "justifyContent": "space-between", "padding": [ 0, 4 ] } } ] } ]With an unsafe component
[ { "element": "Box", "props": { "margin": 4, "padding": 8, "borderColor": "primary-1", "borderWidth": 2 }, "children": [ { "element": "Typography", "children": "A Test String", "props": { "color": "ui-3", "variant": "h2" } }, { "element": "Typography", "children": "Some more text as a paragraph", "props": { "color": "ui-4", "variant": "paragraph" } }, { "element": "TruncatedDefinitionList", "props": { "dictionary": { "term": "a word or phrase used to describe a thing or to express a concept, especially in a particular kind of language or branch of study.", "definition": "a statement of the exact meaning of a word, especially in a dictionary.", "dl": "HTML tag denoting a definition list", "dt": "HTML tag denoting a definition list term", "dd": "HTML tag denoting a definition list definition" }, "prefaceKeys": [ "term", "definition" ] } }, { "element": "Box", "children": [ { "element": "Button", "children": "Cancel", "props": { "rounded": true, "type": "outlined", "style": { "width": "45%" } } }, { "element": "Button", "children": "OK", "props": { "rounded": true, "type": "primary", "style": { "width": "45%" } } } ], "props": { "justifyContent": "space-between", "padding": [ 0, 4 ] } } ] }, { element: 'Unsafe', children: 'I should be displayed, but I wont be due to unsafe component', }, ]