Skip to content

Commit

Permalink
fix(renderer): prevent infinite loops for NaN (#3254)
Browse files Browse the repository at this point in the history
this commit is intended to prevent infinite loops when rendering the
following specific scenario:
- the prop is of type "number"
- the prop is reflected (`@Prop({reflect: true})`
- the value received by the prop is NaN

in this commit, we add an additional safeguard to `set-value` to
validate that both the previous and new values on the prop are not NaN.
this check is required as equality checks in javascript where NaN is on
both the left and right hand side of the operator will always return
false.

this change is intentionally placed as this layer of abstraction in
order to handle cases where a value may be set on a prop from different
places in the code base. for instance, we must consider cases where:
- a reflected camelCase prop 'myProp' is reflected as 'my-prop'
- a reflected single word prop 'val' is reflected as 'val' (no
  transformation)
  • Loading branch information
rwaskiewicz authored Feb 28, 2022
1 parent a04aa22 commit e2d4e16
Show file tree
Hide file tree
Showing 12 changed files with 258 additions and 1 deletion.
5 changes: 4 additions & 1 deletion src/runtime/set-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ export const setValue = (ref: d.RuntimeRef, propName: string, newVal: any, cmpMe
const instance = BUILD.lazyLoad ? hostRef.$lazyInstance$ : (elm as any);
newVal = parsePropertyValue(newVal, cmpMeta.$members$[propName][0]);

if ((!BUILD.lazyLoad || !(flags & HOST_FLAGS.isConstructingInstance) || oldVal === undefined) && newVal !== oldVal) {
// explicitly check for NaN on both sides, as `NaN === NaN` is always false
const areBothNaN = Number.isNaN(oldVal) && Number.isNaN(newVal);
const didValueChange = newVal !== oldVal && !areBothNaN;
if ((!BUILD.lazyLoad || !(flags & HOST_FLAGS.isConstructingInstance) || oldVal === undefined) && didValueChange) {
// gadzooks! the property's value has changed!!
// set our new value!
hostRef.$instanceValues$.set(propName, newVal);
Expand Down
58 changes: 58 additions & 0 deletions test/karma/test-app/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export namespace Components {
}
interface BuildData {
}
interface ChildReflectNanAttribute {
"val": number;
}
interface ChildWithReflection {
"val": number | any;
}
Expand Down Expand Up @@ -180,8 +183,16 @@ export namespace Components {
}
interface NodeResolution {
}
interface ParentReflectNanAttribute {
}
interface ParentWithReflectChild {
}
interface ReflectNanAttribute {
"val": number;
}
interface ReflectNanAttributeHyphen {
"valNum": number;
}
interface ReflectToAttr {
"bool": boolean;
"disabled": boolean;
Expand Down Expand Up @@ -378,6 +389,12 @@ declare global {
prototype: HTMLBuildDataElement;
new (): HTMLBuildDataElement;
};
interface HTMLChildReflectNanAttributeElement extends Components.ChildReflectNanAttribute, HTMLStencilElement {
}
var HTMLChildReflectNanAttributeElement: {
prototype: HTMLChildReflectNanAttributeElement;
new (): HTMLChildReflectNanAttributeElement;
};
interface HTMLChildWithReflectionElement extends Components.ChildWithReflection, HTMLStencilElement {
}
var HTMLChildWithReflectionElement: {
Expand Down Expand Up @@ -732,12 +749,30 @@ declare global {
prototype: HTMLNodeResolutionElement;
new (): HTMLNodeResolutionElement;
};
interface HTMLParentReflectNanAttributeElement extends Components.ParentReflectNanAttribute, HTMLStencilElement {
}
var HTMLParentReflectNanAttributeElement: {
prototype: HTMLParentReflectNanAttributeElement;
new (): HTMLParentReflectNanAttributeElement;
};
interface HTMLParentWithReflectChildElement extends Components.ParentWithReflectChild, HTMLStencilElement {
}
var HTMLParentWithReflectChildElement: {
prototype: HTMLParentWithReflectChildElement;
new (): HTMLParentWithReflectChildElement;
};
interface HTMLReflectNanAttributeElement extends Components.ReflectNanAttribute, HTMLStencilElement {
}
var HTMLReflectNanAttributeElement: {
prototype: HTMLReflectNanAttributeElement;
new (): HTMLReflectNanAttributeElement;
};
interface HTMLReflectNanAttributeHyphenElement extends Components.ReflectNanAttributeHyphen, HTMLStencilElement {
}
var HTMLReflectNanAttributeHyphenElement: {
prototype: HTMLReflectNanAttributeHyphenElement;
new (): HTMLReflectNanAttributeHyphenElement;
};
interface HTMLReflectToAttrElement extends Components.ReflectToAttr, HTMLStencilElement {
}
var HTMLReflectToAttrElement: {
Expand Down Expand Up @@ -1079,6 +1114,7 @@ declare global {
"attribute-html-root": HTMLAttributeHtmlRootElement;
"bad-shared-jsx": HTMLBadSharedJsxElement;
"build-data": HTMLBuildDataElement;
"child-reflect-nan-attribute": HTMLChildReflectNanAttributeElement;
"child-with-reflection": HTMLChildWithReflectionElement;
"cmp-label": HTMLCmpLabelElement;
"cmp-label-with-slot-sibling": HTMLCmpLabelWithSlotSiblingElement;
Expand Down Expand Up @@ -1138,7 +1174,10 @@ declare global {
"no-delegates-focus": HTMLNoDelegatesFocusElement;
"node-globals": HTMLNodeGlobalsElement;
"node-resolution": HTMLNodeResolutionElement;
"parent-reflect-nan-attribute": HTMLParentReflectNanAttributeElement;
"parent-with-reflect-child": HTMLParentWithReflectChildElement;
"reflect-nan-attribute": HTMLReflectNanAttributeElement;
"reflect-nan-attribute-hyphen": HTMLReflectNanAttributeHyphenElement;
"reflect-to-attr": HTMLReflectToAttrElement;
"reparent-style-no-vars": HTMLReparentStyleNoVarsElement;
"reparent-style-with-vars": HTMLReparentStyleWithVarsElement;
Expand Down Expand Up @@ -1235,6 +1274,9 @@ declare namespace LocalJSX {
}
interface BuildData {
}
interface ChildReflectNanAttribute {
"val"?: number;
}
interface ChildWithReflection {
"val"?: number | any;
}
Expand Down Expand Up @@ -1375,8 +1417,16 @@ declare namespace LocalJSX {
}
interface NodeResolution {
}
interface ParentReflectNanAttribute {
}
interface ParentWithReflectChild {
}
interface ReflectNanAttribute {
"val"?: number;
}
interface ReflectNanAttributeHyphen {
"valNum"?: number;
}
interface ReflectToAttr {
"bool"?: boolean;
"disabled"?: boolean;
Expand Down Expand Up @@ -1522,6 +1572,7 @@ declare namespace LocalJSX {
"attribute-html-root": AttributeHtmlRoot;
"bad-shared-jsx": BadSharedJsx;
"build-data": BuildData;
"child-reflect-nan-attribute": ChildReflectNanAttribute;
"child-with-reflection": ChildWithReflection;
"cmp-label": CmpLabel;
"cmp-label-with-slot-sibling": CmpLabelWithSlotSibling;
Expand Down Expand Up @@ -1581,7 +1632,10 @@ declare namespace LocalJSX {
"no-delegates-focus": NoDelegatesFocus;
"node-globals": NodeGlobals;
"node-resolution": NodeResolution;
"parent-reflect-nan-attribute": ParentReflectNanAttribute;
"parent-with-reflect-child": ParentWithReflectChild;
"reflect-nan-attribute": ReflectNanAttribute;
"reflect-nan-attribute-hyphen": ReflectNanAttributeHyphen;
"reflect-to-attr": ReflectToAttr;
"reparent-style-no-vars": ReparentStyleNoVars;
"reparent-style-with-vars": ReparentStyleWithVars;
Expand Down Expand Up @@ -1653,6 +1707,7 @@ declare module "@stencil/core" {
"attribute-html-root": LocalJSX.AttributeHtmlRoot & JSXBase.HTMLAttributes<HTMLAttributeHtmlRootElement>;
"bad-shared-jsx": LocalJSX.BadSharedJsx & JSXBase.HTMLAttributes<HTMLBadSharedJsxElement>;
"build-data": LocalJSX.BuildData & JSXBase.HTMLAttributes<HTMLBuildDataElement>;
"child-reflect-nan-attribute": LocalJSX.ChildReflectNanAttribute & JSXBase.HTMLAttributes<HTMLChildReflectNanAttributeElement>;
"child-with-reflection": LocalJSX.ChildWithReflection & JSXBase.HTMLAttributes<HTMLChildWithReflectionElement>;
"cmp-label": LocalJSX.CmpLabel & JSXBase.HTMLAttributes<HTMLCmpLabelElement>;
"cmp-label-with-slot-sibling": LocalJSX.CmpLabelWithSlotSibling & JSXBase.HTMLAttributes<HTMLCmpLabelWithSlotSiblingElement>;
Expand Down Expand Up @@ -1712,7 +1767,10 @@ declare module "@stencil/core" {
"no-delegates-focus": LocalJSX.NoDelegatesFocus & JSXBase.HTMLAttributes<HTMLNoDelegatesFocusElement>;
"node-globals": LocalJSX.NodeGlobals & JSXBase.HTMLAttributes<HTMLNodeGlobalsElement>;
"node-resolution": LocalJSX.NodeResolution & JSXBase.HTMLAttributes<HTMLNodeResolutionElement>;
"parent-reflect-nan-attribute": LocalJSX.ParentReflectNanAttribute & JSXBase.HTMLAttributes<HTMLParentReflectNanAttributeElement>;
"parent-with-reflect-child": LocalJSX.ParentWithReflectChild & JSXBase.HTMLAttributes<HTMLParentWithReflectChildElement>;
"reflect-nan-attribute": LocalJSX.ReflectNanAttribute & JSXBase.HTMLAttributes<HTMLReflectNanAttributeElement>;
"reflect-nan-attribute-hyphen": LocalJSX.ReflectNanAttributeHyphen & JSXBase.HTMLAttributes<HTMLReflectNanAttributeHyphenElement>;
"reflect-to-attr": LocalJSX.ReflectToAttr & JSXBase.HTMLAttributes<HTMLReflectToAttrElement>;
"reparent-style-no-vars": LocalJSX.ReparentStyleNoVars & JSXBase.HTMLAttributes<HTMLReparentStyleNoVarsElement>;
"reparent-style-with-vars": LocalJSX.ReparentStyleWithVars & JSXBase.HTMLAttributes<HTMLReparentStyleWithVarsElement>;
Expand Down
8 changes: 8 additions & 0 deletions test/karma/test-app/reflect-nan-attribute-hyphen/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<meta charset="utf8">
<script src="./build/testapp.esm.js" type="module"></script>
<script src="./build/testapp.js" nomodule></script>

<!-- The string 'NaN' will be interpreted as a number by Stencil, based on the type declaration on the prop tied to -->
<!-- the 'val-num' attribute -->
<reflect-nan-attribute-hyphen val-num='NaN'></reflect-nan-attribute-hyphen>
20 changes: 20 additions & 0 deletions test/karma/test-app/reflect-nan-attribute-hyphen/karma.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { setupDomTests } from '../util';

describe('reflect-nan-attribute-hyphen', () => {
const { setupDom, tearDownDom } = setupDomTests(document);
let app: HTMLElement;

beforeEach(async () => {
app = await setupDom('/reflect-nan-attribute-hyphen/index.html');
});
afterEach(tearDownDom);

it('renders the component the correct number of times', async () => {
const cmpShadowRoot = app.querySelector('reflect-nan-attribute-hyphen')?.shadowRoot;
if (!cmpShadowRoot) {
fail(`unable to find shadow root on component 'reflect-nan-attribute-hyphen'`);
}

expect(cmpShadowRoot.textContent).toEqual('reflect-nan-attribute-hyphen Render Count: 1');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Component, Prop, h } from '@stencil/core';

@Component({
tag: 'reflect-nan-attribute-hyphen',
// 'shadow' is not needed here, but does make testing easier by using the shadow root to help encapsulate textContent
shadow: true,
})
export class ReflectNanAttributeHyphen {
// for this test, it's necessary that 'reflect' is true, the class member is camel-cased, and is of type 'number'
@Prop({ reflect: true }) valNum: number;

// counter to proxy the number of times a render has occurred, since we don't have access to those dev tools during
// karma tests
renderCount = 0;

render() {
this.renderCount += 1;
return <div>reflect-nan-attribute-hyphen Render Count: {this.renderCount}</div>;
}

componentDidUpdate() {
// we don't expect the component to update, this will increment the render count in case it does (and should fail
// the test)
this.renderCount += 1;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Component, h, Prop } from '@stencil/core';

@Component({
tag: 'child-reflect-nan-attribute',
// 'shadow' is not needed here, but does make testing easier by using the shadow root to help encapsulate textContent
shadow: true,
})
export class ChildReflectNanAttribute {
// for this test, it's necessary that 'reflect' is true, the class member is not camel-cased, and is of type 'number'
@Prop({ reflect: true }) val: number;

// counter to proxy the number of times a render has occurred, since we don't have access to those dev tools during
// karma tests
renderCount = 0;

render() {
this.renderCount += 1;
return <div>child-reflect-nan-attribute Render Count: {this.renderCount}</div>;
}

componentDidUpdate() {
// we don't expect the component to update, this will increment the render count in case it does (and should fail
// the test)
this.renderCount += 1;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!DOCTYPE html>
<meta charset="utf8">
<script src="./build/testapp.esm.js" type="module"></script>
<script src="./build/testapp.js" nomodule></script>

<parent-reflect-nan-attribute></parent-reflect-nan-attribute>
26 changes: 26 additions & 0 deletions test/karma/test-app/reflect-nan-attribute-with-child/karma.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { setupDomTests } from '../util';

describe('reflect-nan-attribute-with-child', () => {
const { setupDom, tearDownDom } = setupDomTests(document);
let app: HTMLElement;

beforeEach(async () => {
app = await setupDom('/reflect-nan-attribute-with-child/index.html');
});
afterEach(tearDownDom);

it('renders the parent and child the correct number of times', async () => {
const parentShadowRoot = app.querySelector('parent-reflect-nan-attribute')?.shadowRoot;
if (!parentShadowRoot) {
fail(`unable to find shadow root on component 'parent-reflect-nan-attribute'`);
}

const childShadowRoot = parentShadowRoot.querySelector('child-reflect-nan-attribute')?.shadowRoot;
if (!childShadowRoot) {
fail(`unable to find shadow root on component 'child-with-reflection'`);
}

expect(parentShadowRoot.textContent).toEqual('parent-reflect-nan-attribute Render Count: 1');
expect(childShadowRoot.textContent).toEqual('child-reflect-nan-attribute Render Count: 1');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Component, h } from '@stencil/core';

@Component({
tag: 'parent-reflect-nan-attribute',
// 'shadow' is not needed here, but does make testing easier by using the shadow root to help encapsulate textContent
shadow: true,
})
export class ParentReflectNanAttribute {
// counter to proxy the number of times a render has occurred, since we don't have access to those dev tools during
// karma tests
renderCount = 0;

render() {
this.renderCount += 1;
return (
<div>
<div>parent-reflect-nan-attribute Render Count: {this.renderCount}</div>
{/*
// @ts-ignore */}
<child-reflect-nan-attribute val={'I am not a number!!'}></child-reflect-nan-attribute>
</div>
);
}

componentDidUpdate() {
// we don't expect the component to update, this will increment the render count in case it does (and should fail
// the test)
this.renderCount += 1;
}
}
8 changes: 8 additions & 0 deletions test/karma/test-app/reflect-nan-attribute/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<meta charset="utf8">
<script src="./build/testapp.esm.js" type="module"></script>
<script src="./build/testapp.js" nomodule></script>

<!-- The string 'NaN' will be interpreted as a number by Stencil, based on the type declaration on the prop tied to -->
<!-- the 'val' attribute -->
<reflect-nan-attribute val='NaN'></reflect-nan-attribute>
20 changes: 20 additions & 0 deletions test/karma/test-app/reflect-nan-attribute/karma.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { setupDomTests } from '../util';

describe('reflect-nan-attribute', () => {
const { setupDom, tearDownDom } = setupDomTests(document);
let app: HTMLElement;

beforeEach(async () => {
app = await setupDom('/reflect-nan-attribute/index.html');
});
afterEach(tearDownDom);

it('renders the component the correct number of times', async () => {
const cmpShadowRoot = app.querySelector('reflect-nan-attribute')?.shadowRoot;
if (!cmpShadowRoot) {
fail(`unable to find shadow root on component 'reflect-nan-attribute'`);
}

expect(cmpShadowRoot.textContent).toEqual('reflect-nan-attribute Render Count: 1');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Component, Prop, h } from '@stencil/core';

@Component({
tag: 'reflect-nan-attribute',
// 'shadow' is not needed here, but does make testing easier by using the shadow root to help encapsulate textContent
shadow: true,
})
export class ReflectNanAttribute {
// for this test, it's necessary that 'reflect' is true, the class member is not camel-cased, and is of type 'number'
@Prop({ reflect: true }) val: number;

// counter to proxy the number of times a render has occurred, since we don't have access to those dev tools during
// karma tests
renderCount = 0;

render() {
this.renderCount += 1;
return <div>reflect-nan-attribute Render Count: {this.renderCount}</div>;
}

componentDidUpdate() {
// we don't expect the component to update, this will increment the render count in case it does (and should fail
// the test)
this.renderCount += 1;
}
}

0 comments on commit e2d4e16

Please sign in to comment.