Skip to content

Commit 087aae5

Browse files
Fix attribute order sensitivity for range input (#33831)
1 parent 0e1a309 commit 087aae5

File tree

6 files changed

+70
-30
lines changed

6 files changed

+70
-30
lines changed

src/Components/Web.JS/dist/Release/blazor.server.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/dist/Release/blazor.webview.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/src/Rendering/BrowserRenderer.ts

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { EventDelegator } from './Events/EventDelegator';
33
import { LogicalElement, PermutationListEntry, toLogicalElement, insertLogicalChild, removeLogicalChild, getLogicalParent, getLogicalChild, createAndInsertLogicalContainer, isSvgElement, getLogicalChildrenArray, getLogicalSiblingEnd, permuteLogicalChildren, getClosestDomElement } from './LogicalElements';
44
import { applyCaptureIdToElement } from './ElementReferenceCapture';
55
import { attachToEventDelegator as attachNavigationManagerToEventDelegator } from '../Services/NavigationManager';
6-
const selectValuePropname = '_blazorSelectValue';
6+
const deferredValuePropname = '_blazorDeferredValue';
77
const sharedTemplateElemForParsing = document.createElement('template');
88
const sharedSvgElemForParsing = document.createElementNS('http://www.w3.org/2000/svg', 'g');
99
const rootComponentsPendingFirstRender: { [componentId: number]: LogicalElement } = {};
@@ -257,21 +257,26 @@ export class BrowserRenderer {
257257
// [3] In case the the value of the select and the option value is changed in the same batch.
258258
// We just receive an attribute frame and have to set the select value afterwards.
259259

260+
// We also defer setting the 'value' property for <input> because certain types of inputs have
261+
// default attribute values that may incorrectly constain the specified 'value'.
262+
// For example, range inputs have default 'min' and 'max' attributes that may incorrectly
263+
// clamp the 'value' property if it is applied before custom 'min' and 'max' attributes.
264+
260265
if (newDomElementRaw instanceof HTMLOptionElement) {
261266
// Situation 1
262267
this.trySetSelectValueFromOptionElement(newDomElementRaw);
263-
} else if (newDomElementRaw instanceof HTMLSelectElement && selectValuePropname in newDomElementRaw) {
268+
} else if (deferredValuePropname in newDomElementRaw) {
264269
// Situation 2
265-
const selectValue: string | null = newDomElementRaw[selectValuePropname];
266-
setSelectElementValue(newDomElementRaw, selectValue);
270+
const deferredValue: string | null = newDomElementRaw[deferredValuePropname];
271+
setDeferredElementValue(newDomElementRaw, deferredValue);
267272
}
268273
}
269274

270275
private trySetSelectValueFromOptionElement(optionElement: HTMLOptionElement) {
271276
const selectElem = this.findClosestAncestorSelectElement(optionElement);
272-
if (selectElem && (selectValuePropname in selectElem) && selectElem[selectValuePropname] === optionElement.value) {
273-
setSelectElementValue(selectElem, optionElement.value);
274-
delete selectElem[selectValuePropname];
277+
if (selectElem && (deferredValuePropname in selectElem) && selectElem[deferredValuePropname] === optionElement.value) {
278+
setDeferredElementValue(selectElem, optionElement.value);
279+
delete selectElem[deferredValuePropname];
275280
return true;
276281
}
277282
return false;
@@ -375,18 +380,18 @@ export class BrowserRenderer {
375380
case 'TEXTAREA': {
376381
const value = attributeFrame ? frameReader.attributeValue(attributeFrame) : null;
377382

378-
if (element instanceof HTMLSelectElement) {
379-
setSelectElementValue(element, value);
383+
// <select> is special, in that anything we write to .value will be lost if there
384+
// isn't yet a matching <option>. To maintain the expected behavior no matter the
385+
// element insertion/update order, preserve the desired value separately so
386+
// we can recover it when inserting any matching <option> or after inserting an
387+
// entire markup block of descendants.
380388

381-
// <select> is special, in that anything we write to .value will be lost if there
382-
// isn't yet a matching <option>. To maintain the expected behavior no matter the
383-
// element insertion/update order, preserve the desired value separately so
384-
// we can recover it when inserting any matching <option> or after inserting an
385-
// entire markup block of descendants.
386-
element[selectValuePropname] = value;
387-
} else {
388-
(element as any).value = value;
389-
}
389+
// We also defer setting the 'value' property for <input> because certain types of inputs have
390+
// default attribute values that may incorrectly constain the specified 'value'.
391+
// For example, range inputs have default 'min' and 'max' attributes that may incorrectly
392+
// clamp the 'value' property if it is applied before custom 'min' and 'max' attributes.
393+
element[deferredValuePropname] = value;
394+
setDeferredElementValue(element, value);
390395

391396
return true;
392397
}
@@ -510,14 +515,18 @@ function stripOnPrefix(attributeName: string) {
510515
throw new Error(`Attribute should be an event name, but doesn't start with 'on'. Value: '${attributeName}'`);
511516
}
512517

513-
function setSelectElementValue(element: HTMLSelectElement, value: string | null) {
514-
// There's no sensible way to represent a select option with value 'null', because
515-
// (1) HTML attributes can't have null values - the closest equivalent is absence of the attribute
516-
// (2) When picking an <option> with no 'value' attribute, the browser treats the value as being the
517-
// *text content* on that <option> element. Trying to suppress that default behavior would involve
518-
// a long chain of special-case hacks, as well as being breaking vs 3.x.
519-
// So, the most plausible 'null' equivalent is an empty string. It's unfortunate that people can't
520-
// write <option value=@someNullVariable>, and that we can never distinguish between null and empty
521-
// string in a bound <select>, but that's a limit in the representational power of HTML.
522-
element.value = value || '';
518+
function setDeferredElementValue(element: Element, value: string | null) {
519+
if (element instanceof HTMLSelectElement) {
520+
// There's no sensible way to represent a select option with value 'null', because
521+
// (1) HTML attributes can't have null values - the closest equivalent is absence of the attribute
522+
// (2) When picking an <option> with no 'value' attribute, the browser treats the value as being the
523+
// *text content* on that <option> element. Trying to suppress that default behavior would involve
524+
// a long chain of special-case hacks, as well as being breaking vs 3.x.
525+
// So, the most plausible 'null' equivalent is an empty string. It's unfortunate that people can't
526+
// write <option value=@someNullVariable>, and that we can never distinguish between null and empty
527+
// string in a bound <select>, but that's a limit in the representational power of HTML.
528+
element.value = value || '';
529+
} else {
530+
(element as any).value = value;
531+
}
523532
}

src/Components/test/E2ETest/Tests/FormsTest.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,20 @@ public void CanRemoveAndReAddDataAnnotationsSupport()
598598
Browser.Equal(new[] { "That name is too long" }, messagesAccessor);
599599
}
600600

601+
[Fact]
602+
public void InputRangeAttributeOrderDoesNotAffectValue()
603+
{
604+
// Regression test for https://github.com/dotnet/aspnetcore/issues/33499
605+
606+
var appElement = Browser.MountTestComponent<InputRangeComponent>();
607+
var rangeWithValueFirst = appElement.FindElement(By.Id("range-value-first"));
608+
var rangeWithValueLast = appElement.FindElement(By.Id("range-value-last"));
609+
610+
// Value never gets incorrectly clamped.
611+
Browser.Equal("210", () => rangeWithValueFirst.GetProperty("value"));
612+
Browser.Equal("210", () => rangeWithValueLast.GetProperty("value"));
613+
}
614+
601615
private Func<string[]> CreateValidationMessagesAccessor(IWebElement appElement)
602616
{
603617
return () => appElement.FindElements(By.ClassName("validation-message"))
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<p>
2+
This component renders two range inputs:
3+
One with the 'value' attribute specified before the 'min' and 'max' attributees, and one specified after.
4+
</p>
5+
6+
<p>
7+
This verifies that the value is applied after all other attributes so it doesn't get incorrectly clamped
8+
by the default 'min' and 'max' attribute values.
9+
</p>
10+
11+
<input id="range-value-first" value="@RangeValue" type="range" min="20" max="220" />
12+
<input id="range-value-last" type="range" min="20" max="220" value="@RangeValue" />
13+
14+
@code {
15+
private const int RangeValue = 210;
16+
}

src/Components/test/testassets/BasicTestApp/Index.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
<option value="BasicTestApp.FormsTest.TypicalValidationComponent">Typical validation</option>
4242
<option value="BasicTestApp.FormsTest.TypicalValidationComponentUsingExperimentalValidator">Typical validation using experimental validator</option>
4343
<option value="BasicTestApp.FormsTest.InputFileComponent">Input file</option>
44+
<option value="BasicTestApp.FormsTest.InputRangeComponent">Input range</option>
4445
<option value="BasicTestApp.NavigateOnSubmit">Navigate to submit</option>
4546
<option value="BasicTestApp.GlobalizationBindCases">Globalization Bind Cases</option>
4647
<option value="BasicTestApp.GracefulTermination">Graceful Termination</option>

0 commit comments

Comments
 (0)