Skip to content

Commit ad97e08

Browse files
authored
Support ?? in observable bindings (#33)
# Pull Request ## Motivation We are enabling a new eslint rule (`@typescript-eslint/strict-boolean-expressions`) in Nimble that disallows using non-booleans in boolean expressions. We have some template bindings like `x.foo || 'default_val'` which are flagged by this rule and would ideally be rewritten as `x.foo ?? 'default_val'`. However, without proper support for `??` in binding expressions, we would have to rewrite them as `x.foo !== undefined ? x.foo : 'default_value'`, which is excessively verbose. ## 📖 Description See ni/nimble#2127 The observable binding logic uses a regex pattern to determine which kinds of expressions in a binding should cause the binding to be marked as volatile. The regex did not include `??`, presumably because the FAST libraries targeted `es2015`, in which a binding like `(x) => x.trigger ?? x.value` would be transpiled to something like `(x) => { var _a; return (_a = x.trigger) !== null && _a !== void 0 ? _a : x.value; }`. However, a client library like Nimble, which targets a later version of the language that directly supports `??` requires FAST to include `??` in its regex. As part of this change, I've: - updated the regex to include `??` - changed the target of the FAST libraries to `es2022` (for consistency with our client libraries) - fixed an unrelated issue with aggregate bindings never being marked as volatile - added test cases
1 parent 2b008f4 commit ad97e08

File tree

12 files changed

+169
-23
lines changed

12 files changed

+169
-23
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "Add support for ?? in observable bindings",
4+
"packageName": "@ni/fast-element",
5+
"email": "7282195+m-akinc@users.noreply.github.com",
6+
"dependentChangeType": "patch"
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "Target es2022 rather than es2015",
4+
"packageName": "@ni/fast-foundation",
5+
"email": "7282195+m-akinc@users.noreply.github.com",
6+
"dependentChangeType": "patch"
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "Target es2022 rather than es2015",
4+
"packageName": "@ni/fast-react-wrapper",
5+
"email": "7282195+m-akinc@users.noreply.github.com",
6+
"dependentChangeType": "patch"
7+
}

packages/utilities/fast-react-wrapper/tsconfig.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
"declarationDir": "dist/dts",
55
"outDir": "dist/esm",
66
"experimentalDecorators": true,
7-
"target": "es2015",
8-
"module": "ESNext",
7+
"useDefineForClassFields": false,
8+
"target": "es2022",
9+
"module": "es2020",
910
"importHelpers": true,
1011
"jsx": "react",
1112
"types": [
@@ -14,7 +15,7 @@
1415
],
1516
"lib": [
1617
"DOM",
17-
"ES2015"
18+
"ES2022"
1819
]
1920
},
2021
"include": ["src"]

packages/web-components/fast-element/src/observation/observable.spec.ts

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ describe("The Observable", () => {
99
@observable child = new ChildModel();
1010
@observable child2 = new ChildModel();
1111
@observable trigger = 0;
12+
@observable nullableTrigger: number | null = 0;
1213
@observable value = 10;
1314

1415
childChangedCalled = false;
@@ -41,9 +42,14 @@ describe("The Observable", () => {
4142
}
4243

4344
@volatile
44-
get andCondition() {
45+
get andConditional() {
4546
return this.trigger && this.value;
4647
}
48+
49+
@volatile
50+
get nullishCoalescingConditional() {
51+
return this.nullableTrigger ?? this.value;
52+
}
4753
}
4854

4955
class ChildModel {
@@ -121,18 +127,18 @@ describe("The Observable", () => {
121127
it("can list all accessors for an object", () => {
122128
const accessors = Observable.getAccessors(new Model());
123129

124-
expect(accessors.length).to.equal(4);
130+
expect(accessors.length).to.equal(5);
125131
expect(accessors[0].name).to.equal("child");
126132
expect(accessors[1].name).to.equal("child2");
127133
});
128134

129135
it("can list accessors for an object, including the prototype chain", () => {
130136
const accessors = Observable.getAccessors(new DerivedModel());
131137

132-
expect(accessors.length).to.equal(5);
138+
expect(accessors.length).to.equal(6);
133139
expect(accessors[0].name).to.equal("child");
134140
expect(accessors[1].name).to.equal("child2");
135-
expect(accessors[4].name).to.equal("derivedChild");
141+
expect(accessors[5].name).to.equal("derivedChild");
136142
});
137143

138144
it("can create a binding observer", () => {
@@ -463,7 +469,7 @@ describe("The Observable", () => {
463469
});
464470

465471
it("notifies on changes in a computed && expression", async () => {
466-
const binding = (x: Model) => x.trigger && x.value;
472+
const binding = (x: Model) => x.andConditional;
467473

468474
let wasNotified = false;
469475
const observer = Observable.binding(binding, {
@@ -497,6 +503,80 @@ describe("The Observable", () => {
497503
expect(value).to.equal(binding(model));
498504
});
499505

506+
it("notifies on changes in a ?? expression", async () => {
507+
const binding = (x: Model) => x.nullableTrigger ?? x.value;
508+
509+
let wasNotified = false;
510+
const observer = Observable.binding(binding, {
511+
handleChange() {
512+
wasNotified = true;
513+
},
514+
});
515+
516+
const model = new Model();
517+
model.nullableTrigger = 0;
518+
519+
let value = observer.observe(model, defaultExecutionContext);
520+
expect(value).to.equal(binding(model));
521+
522+
expect(wasNotified).to.be.false;
523+
model.nullableTrigger = null;
524+
525+
await DOM.nextUpdate();
526+
527+
expect(wasNotified).to.be.true;
528+
529+
value = observer.observe(model, defaultExecutionContext);
530+
expect(value).to.equal(binding(model));
531+
532+
wasNotified = false;
533+
model.value = 20; // nullableTrigger is null, so value change should notify
534+
535+
await DOM.nextUpdate();
536+
537+
expect(wasNotified).to.be.true;
538+
539+
value = observer.observe(model, defaultExecutionContext);
540+
expect(value).to.equal(binding(model));
541+
});
542+
543+
it("notifies on changes in a computed ?? expression", async () => {
544+
const binding = (x: Model) => x.nullishCoalescingConditional;
545+
546+
let wasNotified = false;
547+
const observer = Observable.binding(binding, {
548+
handleChange() {
549+
wasNotified = true;
550+
},
551+
});
552+
553+
const model = new Model();
554+
model.nullableTrigger = 0;
555+
556+
let value = observer.observe(model, defaultExecutionContext);
557+
expect(value).to.equal(binding(model));
558+
559+
expect(wasNotified).to.be.false;
560+
model.nullableTrigger = null;
561+
562+
await DOM.nextUpdate();
563+
564+
expect(wasNotified).to.be.true;
565+
566+
value = observer.observe(model, defaultExecutionContext);
567+
expect(value).to.equal(binding(model));
568+
569+
wasNotified = false;
570+
model.value = 20; // nullableTrigger is null, so value change should notify
571+
572+
await DOM.nextUpdate();
573+
574+
expect(wasNotified).to.be.true;
575+
576+
value = observer.observe(model, defaultExecutionContext);
577+
expect(value).to.equal(binding(model));
578+
});
579+
500580
it("notifies on changes in an || expression", async () => {
501581
const binding = (x: Model) => x.trigger || x.value;
502582

packages/web-components/fast-element/src/observation/observable.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export interface BindingObserver<TSource = any, TReturn = any, TParent = any>
8989
* @public
9090
*/
9191
export const Observable = FAST.getById(KernelServiceId.observable, () => {
92-
const volatileRegex = /(:|&&|\|\||if)/;
92+
const volatileRegex = /(\?\?|:|&&|\|\||if)/;
9393
const notifierLookup = new WeakMap<any, Notifier>();
9494
const queueUpdate = DOM.queueUpdate;
9595
let watcher: BindingObserverImplementation | undefined = void 0;

packages/web-components/fast-element/src/templating/binding.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,11 @@ export class HTMLBindingDirective extends TargetedHTMLDirective {
197197
/**
198198
* Creates an instance of BindingDirective.
199199
* @param binding - A binding that returns the data used to update the DOM.
200+
* @param isVolatile - Optional parameter indicating whether the binding is volatile. If not provided, the volatility of the binding is determined by Observable.isVolatileBinding().
200201
*/
201-
public constructor(public binding: Binding) {
202+
public constructor(public binding: Binding, isVolatile?: boolean) {
202203
super();
203-
this.isBindingVolatile = Observable.isVolatileBinding(this.binding);
204+
this.isBindingVolatile = isVolatile ?? Observable.isVolatileBinding(this.binding);
204205
}
205206

206207
/**

packages/web-components/fast-element/src/templating/compiler.spec.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { defaultExecutionContext } from "../observation/observable";
55
import { css } from "../styles/css";
66
import type { StyleTarget } from "../styles/element-styles";
77
import { toHTML, uniqueElementName } from "../__test__/helpers";
8-
import { HTMLBindingDirective } from "./binding";
8+
import { BindingBehavior, HTMLBindingDirective } from "./binding";
99
import { compileTemplate } from "./compiler";
1010
import type { HTMLDirective } from "./html-directive";
1111
import { html } from "./template";
@@ -25,6 +25,10 @@ describe("The template compiler", () => {
2525
return new HTMLBindingDirective(() => result);
2626
}
2727

28+
function volatileBinding() {
29+
return new HTMLBindingDirective(() => Math.random() > 0.5 ? "greater" : "less");
30+
}
31+
2832
const scope = {};
2933

3034
context("when compiling content", () => {
@@ -343,6 +347,42 @@ describe("The template compiler", () => {
343347
}
344348
});
345349
});
350+
351+
it(`marks aggregate binding volatile when first binding is volatile and second is not`, () => {
352+
const html = `<a href="beginning ${inline(0)} ${inline(1)}">Link</a>`;
353+
const directives = [volatileBinding(), binding()];
354+
const { fragment, viewBehaviorFactories } = compile(html, directives);
355+
356+
const behavior = viewBehaviorFactories[0].createBehavior(fragment) as BindingBehavior;
357+
expect(behavior.isBindingVolatile).to.be.true;
358+
});
359+
360+
it(`marks aggregate binding volatile when second binding is volatile and first is not`, () => {
361+
const html = `<a href="beginning ${inline(0)} ${inline(1)}">Link</a>`;
362+
const directives = [binding(), volatileBinding()];
363+
const { fragment, viewBehaviorFactories } = compile(html, directives);
364+
365+
const behavior = viewBehaviorFactories[0].createBehavior(fragment) as BindingBehavior;
366+
expect(behavior.isBindingVolatile).to.be.true;
367+
});
368+
369+
it(`marks aggregate binding non-volatile when neither of two bindings are volatile`, () => {
370+
const html = `<a href="beginning ${inline(0)} ${inline(1)}">Link</a>`;
371+
const directives = [binding(), binding()];
372+
const { fragment, viewBehaviorFactories } = compile(html, directives);
373+
374+
const behavior = viewBehaviorFactories[0].createBehavior(fragment) as BindingBehavior;
375+
expect(behavior.isBindingVolatile).to.be.false;
376+
});
377+
378+
it(`marks aggregate binding volatile when both of two bindings are volatile`, () => {
379+
const html = `<a href="beginning ${inline(0)} ${inline(1)}">Link</a>`;
380+
const directives = [volatileBinding(), volatileBinding()];
381+
const { fragment, viewBehaviorFactories } = compile(html, directives);
382+
383+
const behavior = viewBehaviorFactories[0].createBehavior(fragment) as BindingBehavior;
384+
expect(behavior.isBindingVolatile).to.be.true;
385+
});
346386
});
347387

348388
context("when compiling comments", () => {

packages/web-components/fast-element/src/templating/compiler.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { _interpolationEnd, _interpolationStart, DOM } from "../dom.js";
2-
import type { Binding, ExecutionContext } from "../observation/observable.js";
2+
import { type Binding, type ExecutionContext, Observable } from "../observation/observable.js";
33
import { HTMLBindingDirective } from "./binding.js";
44
import type { HTMLDirective, NodeBehaviorFactory } from "./html-directive.js";
55

@@ -53,13 +53,15 @@ function createAggregateBinding(
5353
}
5454

5555
let targetName: string | undefined;
56+
let isVolatile = false;
5657
const partCount = parts.length;
5758
const finalParts = parts.map((x: string | InlineDirective) => {
5859
if (typeof x === "string") {
5960
return (): string => x;
6061
}
6162

6263
targetName = x.targetName || targetName;
64+
isVolatile = isVolatile || Observable.isVolatileBinding(x.binding);
6365
return x.binding;
6466
});
6567

@@ -73,7 +75,7 @@ function createAggregateBinding(
7375
return output;
7476
};
7577

76-
const directive = new HTMLBindingDirective(binding);
78+
const directive = new HTMLBindingDirective(binding, isVolatile);
7779
directive.targetName = targetName;
7880
return directive;
7981
}

packages/web-components/fast-element/tsconfig.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@
88
"experimentalDecorators": true,
99
"noImplicitAny": false,
1010
"strictPropertyInitialization": false,
11-
"target": "es2015",
12-
"module": "ESNext",
11+
"useDefineForClassFields": false,
12+
"target": "es2022",
13+
"module": "es2020",
1314
"types": [
1415
"mocha",
1516
"webpack-env",
1617
"web-ie11"
1718
],
1819
"lib": [
1920
"DOM",
20-
"ES2015",
21-
"ES2016.Array.Include"
21+
"ES2022"
2222
]
2323
},
2424
"include": ["src"]

0 commit comments

Comments
 (0)