Skip to content
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

fix(range): round value to same number of decimal places as props to avoid floating point rounding errors #27375

Merged
merged 9 commits into from
May 8, 2023

Conversation

averyjohnston
Copy link
Contributor

@averyjohnston averyjohnston commented May 3, 2023

Issue number: Resolves #21968


What is the current behavior?

When using fractional values for min, max, or step, it is possible for floating point rounding errors to cause unexpected values to be emitted. For example, step="0.05" min="0.1" max="1" emits a value of 0.150000000004 after moving one step.

What is the new behavior?

Values are rounded to the max number of decimal places between the three props. Note that it isn't mathematically possible to arrive at a value with more decimal places than the props*, since addition (i.e. starting at min and adding multiples of step) can't increase the precision of a number.

* Unless the value is set manually, but in that case, ion-range currently snaps to a multiple of step as soon as the slider is moved, resuming normal behavior.

Does this introduce a breaking change?

  • Yes
  • No

Other information

@stackblitz
Copy link

stackblitz bot commented May 3, 2023

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@github-actions github-actions bot added the package: core @ionic/core package label May 3, 2023
@averyjohnston averyjohnston marked this pull request as ready for review May 3, 2023 22:25
@averyjohnston averyjohnston requested a review from a team as a code owner May 3, 2023 22:25
Copy link
Contributor

@liamdebeasi liamdebeasi May 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think there's value in extracting getDecimalPlaces to a util function and writing spec tests that test that as opposed to doing an e2e test? My understanding of this bug is it's less about the interaction and more about the underlying logic that caused this bug.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pulled the functionality out in 3537810. Let me know if you think this is too much abstraction; I couldn't think of another use case for this util off the top of my head, so I made the function pretty specific, but I could also break it up into smaller functions and move more of the logic (probably the Math.max piece) back into range.tsx.

Copy link
Contributor

@sean-perkins sean-perkins left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only one small suggestion around the function naming, everything else looks good to me.

core/src/utils/floating-point/index.ts Outdated Show resolved Hide resolved
@averyjohnston averyjohnston requested a review from liamdebeasi May 4, 2023 20:13
Comment on lines +911 to +913
const clampedValue = clamp(min, value, max);

return roundToMaxDecimalPlaces(clampedValue, min, max, step);
Copy link
Contributor

@liamdebeasi liamdebeasi May 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, why did you opt for this approach instead of multiplying by a scaled step (and then dividing by the scale amount)?

For example:

let value = (max - min) * ratio;

const SCALE = 100;
const SCALED_STEP = step * SCALE;

if (step > 0) {
  // round to nearest multiple of step, then add min
  value = Math.round(value / step) * SCALED_STEP + min;
}

return clamp(min, value / SCALE, max);

Scaling the value ensures that you deal with integers to avoid JavaScript's floating point precision funkiness. As long as you scale down by the same amount you scaled up, you should get a nice neat decimal as a multiple of step.

The benefit of the above approach is that you can round and then clamp, whereas the proposed changes would do round, clamp, and then round again.

edit: Though I suppose if your step was 0.001 you'd be back to a decimal, but you could increase SCALE to a larger number like 10000.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it wouldn't work for all numbers. I'd rather not set an arbitrary limit on how high or low the props can be, nor the precision they can have. For example, a SCALE of 10000 sets the bounds within 0.0001 < n < (MAX_INT / 10000), and you can't have more than 4 decimal places -- 5.12345 * 10000 = 51234.5.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah good point. Though I think the current approach may have some accuracy issues too since toFixed can sometimes yield funky results.

<ion-range
  min="0.1"
  max="0.54321876549876532"
  value="0.2"
  step="0.001"
>
  <span slot="label">Label</span>
</ion-range>

Video sample:

floating-point.mov

Is there a defined level of precision we support with ion-range?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's happening there is you've got a max with the same number of decimal places as the result with the rounding errors, and toFixed is rounding to as many as the max, so the error isn't trimmed. I would say that's okay, since we wouldn't be able to tell the difference between a rounding error result and something legitimate in a case like that. (While in your case, the only legitimate value with that precision is the actual max, if the step were 0.00000000000000001 instead, the two would definitely be indistinguishable.)

I think it's okay that this means we implicitly only support up to a certain level of precision, because the problem is with floating point math itself, not Ionic. If people are using precision levels that high, they're going to run into rounding error issues like this all over the place. 🤭 (Or in other words, JS itself only supports up to a certain level of precision anyway, unless you start using third party data types meant for this sort of thing. And I'd say supporting those is out of scope for this fix 😆)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me! Could we add a little bit of explanation about these limitations in the code so other devs are aware?

Copy link
Contributor

@liamdebeasi liamdebeasi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a request for some more code comments, but otherwise this looks great. Nice work!

@averyjohnston averyjohnston enabled auto-merge May 8, 2023 17:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
package: core @ionic/core package
Projects
None yet
Development

Successfully merging this pull request may close these issues.

ion-range's ionChange event emitter emits fractional numbers unexpectedly
3 participants