-
Notifications
You must be signed in to change notification settings - Fork 87
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
[WNMGDS-3009][WNMGDS-3010] Add Angular example project and fix Angular web component content issue #3275
Conversation
These components show the current issue, which is that our custom elements don't have any initial `innerHTML`, which causes various symptoms. For the month picker, we don't get the default checked state or the disabled months. For the alert content, we don't get any of the alert content, but we get the header because it was defined with an attribute.
150d8ea
to
368bda6
Compare
When Angular renders the web component at first, it doesn't have any innerHTML, but then it inserts the content later and comes in under the watchful eye of our MutationObserver. This might be a bad assumption, but the code I've added just now assumes the same kind of thing that we assumed before that if we're getting mutations to the root content, it means that we want to replace the previous content and re-render everything. Instead of the mutation kicking off the `renderPreactComponent` function and trying to read the `innerHTML` that isn't really settled with the new content, it will take the new content from the mutations and use that instead as the new content to render. For this to work, we're not taking into account previously defined template elements. I have to think about this a bit more, but I think after our changes to make the web components more stateful, we don't actually need to keep the old template around...that could be wrong. I need to think about it more.
It was the `correctly caches children when moved in the DOM` test, and it caught that when a component was moved in the DOM it ended up with nested templates. The fix for that is to bring back the old logic of only creating a template if one doesn't already exist WHEN we're not dealing with inner content mutations.
This reverts commit 32237c1. Apparently we can't both make this unit test happy and have working Angular examples. I'm going to have to think about this scenario some more. If I bring back the code that will look for previous templates when `addedNodes` doesn't exist, nested web components don't work right in the Angular examples. Try checking out 32237c1 and running the `examples/angular` project.
Regarding c578c7c, the only place I see a problem without that "fix" is with the Astro example. The nested button inside the alert gets essentially a double-render that puts a button inside the button. This is a similar symptom to what we had before we implemented the template memory thing (using a template to store the original inner content and falling back to that when we need to "re-hydrate" the web component). The problem doesn't show up in any of the other nested components like the accordions or the choice checked children, and it doesn't seem to be a problem in any of the other example projects or inside Storybook. Perhaps Astro is doing something special to the DOM? |
I think next I'll try actually removing the template if it is empty so even the empty template doesn't get duplicated inside the new template
where we not only create a new template if the previous one was empty, but we remove the empty template before creating the new one so that it doesn't make its way into the content of the new one. Before this fix, the Angular-rendered alert example with a nested button would end up looking like this in the DOM: ```html <ds-button> <button type="button" class="ds-c-button ds-c-button--alternate ds-c-button--solid" > <template><span></span></template> Break things </button> <template> <span> <template><span></span></template> Break things </span> </template> </ds-button> ``` The above HTML has an empty content inside the actual button content, and the top-level template for the `ds-button` element has a another template inside of it! After this fix, it looks as we expect it: ```html <ds-button> <button type="button" class="ds-c-button ds-c-button--alternate ds-c-button--solid" > Break things </button> <template><span>Break things</span></template> </ds-button> ```
In our last pairing session, we started putting dynamic content into the <ds-alert heading="Alert with dynamic content">
<p>Count: {{count}}</p>
<ds-button>Increment</ds-button>
</ds-alert> but we found that the web component content did not update on its own when the Angular variable Another problem we noticed that we didn't come up with any solution for was that nested components can't have Angular bindings. If a web component has various kinds of bindings defined through the Angular template, but that web component is inside another web component, the rendering of the top-level web component clobbers all of that. When one of our web components renders, it reads in its inner HTML content through a |
I just wanted to get that code in version control, but I don't think this is the long-term solution
Ideally if we have to have an Angular wrapper, we could detect changes made by Angular and trigger a re-render, but nothing has seemed to work. This is the only thing so far that has worked.
fda72bf
to
f415f9a
Compare
A note on event binding: The only place it doesn't seem to work is in nested web components—because the original element that had the binding gets clobbered by the parent web component rendering. I don't see a way around that in Angular, and it could be problematic for container-like components like the dialog, drawers, and accordions. |
Hey @pwolfert, thanks for this detailed explanation! Just a heads up, I’m not totally confident I understand all the context here. |
@tamara-corbalt, being able to detect changes would be vastly superior, but that is the very thing I can't seem to do. Take a look at the comments for this commit. I've tried many different things but haven't been able to solve that problem yet. I dislike the polling solution too, but I haven't been able to come up with anything else that works. Everyone should feel free to experiment with this and try to detect when Angular makes changes! |
I want to put it down in writing here because this is a pretty good record of these problems we're working through: I've said recently that we likely wouldn't have some of the same problems if we were using the Shadow DOM in our components, but I also couldn't articulate on the spot the accessibility reasons we chose to not use the Shadow DOM in the first place. This article is likely one of the ones we referenced when coming to this decision, and I think it does a great job explaining the problem and the work that has been done to try to find solutions. We also didn't make the decision in a vacuum; accessibility specialists were in agreement with us that introducing a Shadow DOM would create accessibility problems. If the team were to want to move towards using Shadow DOMs, there'd need to be a pretty extensive research phase that includes working with product teams to find out how they use ARIA within their applications beyond what we've mentioned in our own guidance in order to make sure the design system solutions can support all accessibility needs. One of the problems we're facing right now with Angular is that the web component render process loses event bindings on nested web components. I don't think that would actually be fixed by switching to the Shadow DOM, because the events that Angular binds are on the input elements and not the output elements. The elements provided to web components as content are not the same exact elements that are rendered in the final product. I'd be curious to see some research into whether other web component frameworks do support that scenario. Does Lit allow Angular to bind a click event listener to an interactive custom element inside a container-like custom element and not lose it? |
Update: Angular bindings do work with Lit because the "input" elements using slots are preserved in the Shadow DOM "output" (which is rendered in a |
Hi @pwolfert, I’m just trying to briefly synthesize this for myself and check my understanding. Basically we're saying that using the shadow DOM would improve change detection by creating a clear boundary within which we could reliably observe changes, eliminating the need to poll or scan the entire DOM over and over. However, this doesn't solve the problem of angular’s event bindings on nested elements because angular's bindings are applied to the original input elements in the DOM. When these elements are handed over to Preact’s runtime—whether they’re in the shadow DOM or part of From your last comment, we might be able to preserve angular bindings on nested elements with Lit, specifically through its use of slots within the shadow DOM. If we were to explore this option, it sounds like we would first need to conduct more research on two key areas: the ARIA concerns related to using the shadow DOM and the potential challenges with managing global styles. |
@tamara-corbalt, that's exactly it! Well said. |
@tamara-corbalt, @jack-ryan-nava-pbc, and @kim-cmsds, As per the discussion we had in the planning meeting, I've broken off the more experimental solutions into what will be a new pull request for a follow-up ticket. I've cleaned up this current PR to be 1) the creation of the new Angular example (WNMGDS-3009) and 2) the simpler fix for static content only (WNMGDS-3010). This is now ready for review. Thanks! |
packages/design-system/src/components/web-components/preactement/define.ts
Outdated
Show resolved
Hide resolved
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.
Just noting for myself that this file will need to be manually updated when we call apply-example-templates
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.
Minor comments and questions. Looks good! Changes to define don't seem to impact existing web components, which is great. Nice work!!!
|
||
<ds-alert | ||
heading="{{alertHeading}}" | ||
[attr.variation]="alertVariation ?? null" |
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.
Nit: does it make sense to coalesce a null value to null?
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's the only way I can keep Angular from passing an empty string to that prop. That actually highlights an issue that I didn't want to try to fix in this PR but that should probably be discussed: It's actually not very easy for a downstream app to have the variation as a variable and pass it on, because the only way someone can make an info alert is if this is undefined. Our web component wrapper doesn't treat an empty string as undefined, either, which means the only way to pass on the lack of a variation is to not set the attribute in the first place. Two possible fixes:
- Just change the
ds-alert
wrapper to interpret empty or nullish values asundefined
when passing it on to theAlert
React component or - Change the varations that the
Alert
React component accepts to include something like"info"
as one of the options.
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.
Ah ok. Is a possible issue with option 2 that it would become a breaking change? In cases where users have no variation expressed they would have to supply an option?
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.
Interestingly there's an open PR (#3301) that is asking to expose 'informational' (or the Alert's default type)
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 was thinking about that. It's not a bad idea. Maybe info
is a good abbreviation for it, and we might need to take a look at all the places where it's used and change some actual code and not just the types in order to merge 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.
@jack-ryan-nava-pbc, regarding it being a breaking change, I actually think we could make it a completely backwards-compatible change. Leaving it off would still default to info
like it always has.
...s/design-system/src/components/web-components/preactement/__snapshots__/define.test.tsx.snap
Show resolved
Hide resolved
packages/design-system/src/components/web-components/preactement/define.ts
Show resolved
Hide resolved
…r web component content issue (#3275) * Add a basic Angular example that imports the design system styles * Configure it to allow web components and add a couple simple ones * Copy and paste the other wc examples without the scripts * Update version after patch * Add some debug logging * Strip the example down to just a few components These components show the current issue, which is that our custom elements don't have any initial `innerHTML`, which causes various symptoms. For the month picker, we don't get the default checked state or the disabled months. For the alert content, we don't get any of the alert content, but we get the header because it was defined with an attribute. * I think this fixes it! When Angular renders the web component at first, it doesn't have any innerHTML, but then it inserts the content later and comes in under the watchful eye of our MutationObserver. This might be a bad assumption, but the code I've added just now assumes the same kind of thing that we assumed before that if we're getting mutations to the root content, it means that we want to replace the previous content and re-render everything. Instead of the mutation kicking off the `renderPreactComponent` function and trying to read the `innerHTML` that isn't really settled with the new content, it will take the new content from the mutations and use that instead as the new content to render. For this to work, we're not taking into account previously defined template elements. I have to think about this a bit more, but I think after our changes to make the web components more stateful, we don't actually need to keep the old template around...that could be wrong. I need to think about it more. * Get rid of debug logging * A unit test in `define.test.tsx` caught an issue It was the `correctly caches children when moved in the DOM` test, and it caught that when a component was moved in the DOM it ended up with nested templates. The fix for that is to bring back the old logic of only creating a template if one doesn't already exist WHEN we're not dealing with inner content mutations. * Add some web component examples back in * Revert "A unit test in `define.test.tsx` caught an issue" This reverts commit 32237c1. Apparently we can't both make this unit test happy and have working Angular examples. I'm going to have to think about this scenario some more. If I bring back the code that will look for previous templates when `addedNodes` doesn't exist, nested web components don't work right in the Angular examples. Try checking out 32237c1 and running the `examples/angular` project. * Fix c578c7c (see note) I think next I'll try actually removing the template if it is empty so even the empty template doesn't get duplicated inside the new template * This is a better version of the previous fix where we not only create a new template if the previous one was empty, but we remove the empty template before creating the new one so that it doesn't make its way into the content of the new one. Before this fix, the Angular-rendered alert example with a nested button would end up looking like this in the DOM: ```html <ds-button> <button type="button" class="ds-c-button ds-c-button--alternate ds-c-button--solid" > <template><span></span></template> Break things </button> <template> <span> <template><span></span></template> Break things </span> </template> </ds-button> ``` The above HTML has an empty content inside the actual button content, and the top-level template for the `ds-button` element has a another template inside of it! After this fix, it looks as we expect it: ```html <ds-button> <button type="button" class="ds-c-button ds-c-button--alternate ds-c-button--solid" > Break things </button> <template><span>Break things</span></template> </ds-button> ``` * Edited the comment to try to make it clearer * Update our nvm node version for testing * Fix bad ds version after bump * Move the template content into a separate HTML file—thanks, Michael! * Update lockfile (automatically removing bad entries) * An ugly solution: polling for changes to the template The way I got to this solution is that I observed that Angular was actually making changes to the web component's `template` element contents whenever Angular was re-rendering (probably because of [this line](https://github.com/CMSgov/design-system/blob/pwolfert/angular-example/packages/design-system/src/components/web-components/preactement/define.ts#L393)). The problem is that I can't use a MutationObserver on template content because it's only a document fragment and not part of the DOM. Polling for changes works, but I don't think this is a production-level solution. * Remove the polling part of my last commit I just wanted to get that code in version control, but I don't think this is the long-term solution * Polling now working in the Angular wrapper instead of the design system Ideally if we have to have an Angular wrapper, we could detect changes made by Angular and trigger a re-render, but nothing has seemed to work. This is the only thing so far that has worked. * Looks like custom event binding works even without the wrapper * Remove experimental solutions for ticket 3041 * Add new comment and remove old one * Add a README for the project * Add some more unit tests around this functionality * Using `flatMap` is more succinct and readible here * Get rid of debug logs
Summary
WNMGDS-3009 and WNMGDS-3010
Angular example project
I took the basic Angular project off of stackblitz.com and added some our web components. Because I lack modern Angular experience, I'm looking for feedback and help to...
Fix for Angular providing content to web components
In this Angular project, we're trying to provide inner HTML content to certain web components like the
ds-alert
andds-button
by Angular's standard methods like putting the content in a template like this:However, what we found is that when the
CustomElement
instances (web components) tried to read theirthis.innerHTML
to get that content, it was blank—just an empty string. That's the bug. That's what our Angular product teams were experiencing. They had a fix, which was to supply the content through aninnerHTML
attribute (a special Angular binding), but this method is cumbersome and not ideal.It turns out that Angular wasn't failing to supply the content to the
CustomElement
; it was just supplying it late. We already had aMutationObserver
that was looking out for changes to the inner content, and it was indeed finding those late arrivals. However, ourrenderPreactComponent
wasn't set up to respond well to this "it's blank—psych!—no it's not" behavior.How to test
yarn install
examples/angular
directory, runyarn start
Checklist
[WNMGDS-####] Title
or [NO-TICKET] if this is unticketed work.Type
(only one) label for this PR, if it is a breaking change, label should only beType: Breaking
Impacts
, multiple can be selected.