Skip to content

Commit 913c27e

Browse files
authored
fix(fast-element1): fix design token updates when attaching/detaching nodes and deleting values (#6960)
# Pull Request ## 📖 Description This PR addresses a couple design-token-related bugs: 1. Removing/adding a child element does not update the CSS token reflection of that child. E.g. - Starting with: ```html <my-parent> <my-child></my-child> </my-parent> <my-other-parent></my-other-parent> ``` - Define a token `T` (with CSS variable `--T`) - Explicitly set `T` to "10" for both `<my-child>` and `<my-parent>` - because `<my-child>`'s value is the same as the inherited value, the value is not reflected to CSS on `<my-child>` - Explicitly set `T` to "20" for `<my-other-parent>` - Reparent `<my-child>` under `<my-other-parent>` (detach, then append) - Because the value "10" is explicitly set on `<my-child>`, the value of the CSS variable `--T` should be "10" for it, but it is not. It is "20", which is incorrect. - since the value for `<my-child>` differs from `<my-other-parent>`, it should reflect the value to CSS. It doesn't. ❌ 2. Deleting a token value from an element should notify affected elements (and update CSS reflection). E.g. - Starting with: `<my-element></my-element>` - Define a token `T` (with CSS variable `--T`) - Explicitly set `T` to "10" for `<my-element>` - Delete `T` from `<my-element>` - `<my-element>` should not have any value for `--T`, but it is still defined as "10". Also: - Removed `.only` from a combobox test that was apparently submitted on accident - Now catching errors thrown from derived token evaluation (and logging via `console.error`). Now that we are properly triggering token re-evaluations when detaching elements from the DOM, a derived token may throw an error during evaluation, because it depended on an inherited token value. It seems better to print an error than to let this derail execution. ### 🎫 Issues N/A ## 👩‍💻 Reviewer Notes [Stackblitz demo](https://stackblitz.com/edit/typescript-kd4ers?file=index.ts) of bug numbered 1 in description. [Stackblitz demo](https://stackblitz.com/edit/typescript-hd4ghv?file=index.ts) of bug numbered 2 in description. ## 📑 Test Plan Added additional test cases. ## ✅ Checklist ### General <!--- Review the list and put an x in the boxes that apply. --> - [x] I have included a change request file using `$ yarn change` - [x] I have added tests for my changes. - [x] I have tested my changes. - [ ] I have updated the project documentation to reflect my changes. - [x] I have read the [CONTRIBUTING](https://github.com/microsoft/fast/blob/master/CONTRIBUTING.md) documentation and followed the [standards](/docs/community/code-of-conduct/#our-standards) for this project.
1 parent 1f9c97d commit 913c27e

File tree

3 files changed

+187
-11
lines changed

3 files changed

+187
-11
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "Fix design token issues involving deleting values and CSS reflection",
4+
"packageName": "@microsoft/fast-foundation",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

packages/web-components/fast-foundation/src/design-token/design-token.spec.ts

+153-2
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,32 @@ describe("A DesignToken", () => {
194194
removeElement(ancestor);
195195
});
196196

197-
it("should set CSS custom property for element if value stops matching inherited value", async () => {
197+
it("should override inherited CSS custom property from ancestor", async () => {
198+
const ancestor = addElement();
199+
const target = addElement(ancestor);
200+
const token = DesignToken.create<number>("test");
201+
token.setValueFor(ancestor, 12);
202+
token.setValueFor(target, 14);
203+
await DOM.nextUpdate();
204+
expect(window.getComputedStyle(target).getPropertyValue(token.cssCustomProperty)).to.equal('14');
205+
removeElement(ancestor);
206+
});
207+
208+
it("should undefine CSS custom property for element if value deleted", async () => {
209+
const ancestor = addElement();
210+
const target = addElement(ancestor);
211+
const token = DesignToken.create<number>("test");
212+
token.setValueFor(ancestor, 12);
213+
token.setValueFor(target, 14);
214+
await DOM.nextUpdate();
215+
expect(window.getComputedStyle(target).getPropertyValue(token.cssCustomProperty)).to.equal('14');
216+
token.deleteValueFor(target);
217+
await DOM.nextUpdate();
218+
expect(window.getComputedStyle(target).getPropertyValue(token.cssCustomProperty)).to.equal('12');
219+
removeElement(ancestor);
220+
});
221+
222+
it("should set CSS custom property for element if value stops matching inherited value because value changed on ancestor", async () => {
198223
const ancestor = addElement();
199224
const target = addElement(ancestor);
200225
const token = DesignToken.create<number>("test");
@@ -207,6 +232,56 @@ describe("A DesignToken", () => {
207232
expect(window.getComputedStyle(target).getPropertyValue(token.cssCustomProperty)).to.equal('12');
208233
removeElement(ancestor);
209234
});
235+
236+
it("should set CSS custom property for element if value stops matching inherited value because of reparenting", async () => {
237+
const ancestorA = addElement();
238+
const ancestorB = addElement();
239+
const target = addElement(ancestorA);
240+
const token = DesignToken.create<number>("test");
241+
token.setValueFor(ancestorA, 12);
242+
token.setValueFor(ancestorB, 14);
243+
token.setValueFor(target, 12);
244+
await DOM.nextUpdate();
245+
expect(window.getComputedStyle(target).getPropertyValue(token.cssCustomProperty)).to.equal('12');
246+
ancestorA.removeChild(target);
247+
ancestorB.appendChild(target);
248+
await DOM.nextUpdate();
249+
expect(window.getComputedStyle(target).getPropertyValue(token.cssCustomProperty)).to.equal('12');
250+
removeElement(ancestorA, ancestorB);
251+
});
252+
253+
it("should set CSS custom property for element if matching value is deleted from ancestor", async () => {
254+
const ancestor = addElement();
255+
const target = addElement(ancestor);
256+
const token = DesignToken.create<number>("test");
257+
token.setValueFor(ancestor, 12);
258+
token.setValueFor(target, 12);
259+
await DOM.nextUpdate();
260+
// This is inherited from ancestor's CSS
261+
expect(window.getComputedStyle(target).getPropertyValue(token.cssCustomProperty)).to.equal('12');
262+
token.deleteValueFor(ancestor);
263+
await DOM.nextUpdate();
264+
// This should now come from CSS set directly on target
265+
expect(window.getComputedStyle(target).getPropertyValue(token.cssCustomProperty)).to.equal('12');
266+
removeElement(ancestor);
267+
});
268+
269+
it("should set CSS custom property for element if removed from parent with matching value", async () => {
270+
const ancestor = addElement();
271+
const target = addElement(ancestor);
272+
const token = DesignToken.create<number>("test");
273+
token.setValueFor(ancestor, 12);
274+
token.setValueFor(target, 12);
275+
await DOM.nextUpdate();
276+
// This is inherited from ancestor's CSS
277+
expect(window.getComputedStyle(target).getPropertyValue(token.cssCustomProperty)).to.equal('12');
278+
ancestor.removeChild(target);
279+
document.body.append(target);
280+
await DOM.nextUpdate();
281+
// This should now come from CSS set directly on target
282+
expect(window.getComputedStyle(target).getPropertyValue(token.cssCustomProperty)).to.equal('12');
283+
removeElement(ancestor);
284+
});
210285
});
211286
describe("that is not a CSSDesignToken", () => {
212287
it("should not set a CSS custom property for the element", () => {
@@ -640,7 +715,7 @@ describe("A DesignToken", () => {
640715
await DOM.nextUpdate();
641716
expect(handleChange).to.have.been.called.once;
642717
});
643-
it("should notify a subscriber for a token after being appended to a parent with a different token value than the previous context", async () => {
718+
it("should notify a subscriber for a derived token after being appended to a parent with a different token value than the previous context", async () => {
644719
const tokenA = DesignToken.create<number>("token-a");
645720
const tokenB = DesignToken.create<number>("token-b");
646721

@@ -678,6 +753,7 @@ describe("A DesignToken", () => {
678753

679754
expect(tokenA.getValueFor(child)).to.equal(6);
680755
tokenA.subscribe(subscriber, child);
756+
681757
expect(handleChange).not.to.have.been.called()
682758
parent.appendChild(child);
683759

@@ -760,6 +836,11 @@ describe("A DesignToken", () => {
760836
tokenA.deleteValueFor(target);
761837

762838
expect(tokenB.getValueFor(target)).to.equal(14);
839+
840+
// When disconnecting target, it will no longer inherit a value for tokenA,
841+
// and updating the value for tokenB will throw an error. So remove the
842+
// value for tokenB first to avoid the console error.
843+
tokenB.deleteValueFor(target);
763844
removeElement(parent)
764845
})
765846
});
@@ -859,6 +940,11 @@ describe("A DesignToken", () => {
859940
expect(spy.get(parent)).to.be.false;
860941
expect(spy.get(target)).to.be.false;
861942

943+
// Note that elements do not get subscribed for changes in their parent's values
944+
// until a token value is gotten for/set on that (child) element.
945+
// That is why setting values on ancestors do not trigger value change callbacks
946+
// for the descendants in this test.
947+
862948
token.setValueFor(ancestor, 12);
863949
expect(spy.get(ancestor)).to.be.true;
864950
expect(spy.get(parent)).to.be.false;
@@ -877,6 +963,37 @@ describe("A DesignToken", () => {
877963
removeElement(ancestor);
878964
});
879965

966+
it("should notify an un-targeted subscriber for each element whose value changes", () => {
967+
const ancestor = addElement();
968+
const parent = addElement(ancestor);
969+
const target = addElement(parent);
970+
const token = DesignToken.create<number>("test");
971+
const spy = new Map<HTMLElement, boolean>([[ancestor, false], [parent, false], [ target, false ]]);
972+
973+
// Set/get the values to initialize change notifications for these elements
974+
token.setValueFor(ancestor, 10);
975+
token.getValueFor(parent);
976+
token.getValueFor(target);
977+
978+
const handleChange = chia.spy((record: DesignTokenChangeRecord<typeof token>) => {
979+
spy.set(record.target, true)
980+
});
981+
const subscriber: DesignTokenSubscriber<typeof token> = {
982+
handleChange
983+
}
984+
985+
token.subscribe(subscriber);
986+
expect(handleChange).to.not.have.been.called;
987+
988+
token.setValueFor(parent, 14);
989+
expect(spy.get(ancestor)).to.be.false;
990+
expect(spy.get(parent)).to.be.true;
991+
expect(spy.get(target)).to.be.true;
992+
expect(handleChange).to.have.been.called.twice;
993+
994+
removeElement(ancestor);
995+
});
996+
880997
it("should notify a target-subscriber if the value is changed for the provided target", () => {
881998
const parent = addElement();
882999
const target = addElement(parent);
@@ -887,6 +1004,8 @@ describe("A DesignToken", () => {
8871004
handleChange
8881005
}
8891006

1007+
// A side effect of subscribing for a specific target is that the target gets initialized
1008+
// for change notifications, so when we set a value on the parent, it will notify for the target.
8901009
token.subscribe(subscriber, target);
8911010

8921011
token.setValueFor(parent, 12);
@@ -918,6 +1037,38 @@ describe("A DesignToken", () => {
9181037
removeElement(target);
9191038
});
9201039

1040+
it("should notify a subscriber when the value is deleted for an element", () => {
1041+
const ancestor = addElement();
1042+
const parent = addElement(ancestor);
1043+
const target = addElement(parent);
1044+
const token = DesignToken.create<number>("test");
1045+
const spy = new Map<HTMLElement, boolean>([[ancestor, false], [parent, false], [ target, false ]]);
1046+
1047+
const handleChange = chia.spy((record: DesignTokenChangeRecord<typeof token>) => {
1048+
spy.set(record.target, true)
1049+
});
1050+
const subscriber: DesignTokenSubscriber<typeof token> = {
1051+
handleChange
1052+
}
1053+
1054+
// Set/get the values to initialize change notifications for these elements
1055+
token.setValueFor(ancestor, 10);
1056+
token.setValueFor(parent, 12);
1057+
token.getValueFor(target);
1058+
1059+
token.subscribe(subscriber);
1060+
1061+
expect(handleChange).to.not.have.been.called;
1062+
1063+
token.deleteValueFor(parent);
1064+
expect(spy.get(ancestor)).to.be.false;
1065+
expect(spy.get(parent)).to.be.true;
1066+
expect(spy.get(target)).to.be.true;
1067+
expect(handleChange).to.have.been.called.twice;
1068+
1069+
removeElement(ancestor);
1070+
});
1071+
9211072
it("should infer DesignToken and CSSDesignToken token types on subscription record", () => {
9221073
type AssertCSSDesignToken<T> = T extends CSSDesignToken<any> ? T : never;
9231074
DesignToken.create<number>("css").subscribe({handleChange(record) {

packages/web-components/fast-foundation/src/design-token/design-token.ts

+27-9
Original file line numberDiff line numberDiff line change
@@ -386,14 +386,18 @@ class DesignTokenBindingObserver<T extends { createCSS?(): string }> {
386386
* @internal
387387
*/
388388
public handleChange() {
389-
this.node.store.set(
390-
this.token,
391-
392-
(this.observer.observe(
393-
this.node.target,
394-
defaultExecutionContext
395-
) as unknown) as StaticDesignTokenValue<T>
396-
);
389+
try {
390+
this.node.store.set(
391+
this.token,
392+
393+
(this.observer.observe(
394+
this.node.target,
395+
defaultExecutionContext
396+
) as unknown) as StaticDesignTokenValue<T>
397+
);
398+
} catch (e) {
399+
console.error(e);
400+
}
397401
}
398402
}
399403

@@ -421,6 +425,7 @@ class Store {
421425

422426
delete<T extends { createCSS?(): string }>(token: DesignTokenImpl<T>) {
423427
this.values.delete(token);
428+
Observable.getNotifier(this).notify(token.id);
424429
}
425430

426431
all() {
@@ -724,6 +729,8 @@ class DesignTokenNode implements Behavior, Subscriber {
724729
token,
725730
this.bindingObservers.has(token) ? this.getRaw(token) : value
726731
);
732+
// Need to stop reflecting any tokens that can now be inherited
733+
child.updateCSSTokenReflection(child.store, token);
727734
}
728735
}
729736

@@ -739,7 +746,18 @@ class DesignTokenNode implements Behavior, Subscriber {
739746
}
740747

741748
Observable.getNotifier(this.store).unsubscribe(child);
742-
return child.parent === this ? childToParent.delete(child) : false;
749+
750+
if (child.parent !== this) {
751+
return false;
752+
}
753+
const deleted = childToParent.delete(child);
754+
755+
for (const [token] of this.store.all()) {
756+
child.hydrate(token, child.getRaw(token));
757+
// Need to start reflecting any assigned values that were previously inherited
758+
child.updateCSSTokenReflection(child.store, token);
759+
}
760+
return deleted;
743761
}
744762

745763
/**

0 commit comments

Comments
 (0)