Skip to content

fix: do not round non-zero values down to zero - #264

Merged
ludofischer merged 2 commits into
postcss:masterfrom
dkryaklin:fix/round-non-zero-to-zero
Jul 29, 2026
Merged

fix: do not round non-zero values down to zero#264
ludofischer merged 2 commits into
postcss:masterfrom
dkryaklin:fix/round-non-zero-to-zero

Conversation

@dkryaklin

Copy link
Copy Markdown
Contributor

No description provided.

@ludofischer ludofischer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could you explain the goal of this change? Right now I don't understand what's going on.

Comment thread test/index.js Outdated

test(
'should not round a non-zero value down to zero',
testValue('calc(1/1000000)', '0.00001')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why does dividing 1 by one million give 1e-06 instead of 1e-05? I guess it's related to the precision being 5 decimal digits, but I would appreciate some explanation or a link to the spec.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right that 0.00001 (1e-5) was wrong: the true value is 0.000001 (1e-6), and the old code was clamping to "smallest value representable at 5 decimal places", which is a made-up magnitude (10× too large here, 30× for 1/3000000).

The precision isn't a spec rule. It's our own precision option (default 5), just for minified output. The spec (serialize a calculation tree) doesn't round at all. So rather than invent a number, when a value is too small for precision decimal places I now fall back to counting significant digits: 1/1000000 → 0.000001, 1/3000000 → 3.3333e-7. precision still bounds the detail, but the value stays true. The test now asserts 0.000001.

Comment thread src/lib/serialize.js
}
const m = Math.pow(10, prec);
return Math.round(v * m) / m;
const rounded = Math.round(v * m) / m;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should be some comment about explaining why the code is doing this or nobody will understand it in the future.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. Added a comment on the noise floor and on round() itself

@ludofischer

Copy link
Copy Markdown
Collaborator

After some research, it looks like rounding a small value to non-zero is similar to the algorithm for line-width (https://drafts.csswg.org/css-values-4/#snap-as-a-line-width) but is it correct for other properties?

@dkryaklin

Copy link
Copy Markdown
Contributor Author

Could you explain the goal of this change? Right now I don't understand what's going on.

The goal: when a calc() result is smaller than the serializer's precision can represent, we were rounding it to 0 and in CSS 0 frequently isn't "a small number", it's a mode switch. flex-grow: 0 never grows, 0s never animates, 0fr claims no space. So flex-grow: calc(1/1000000) silently became "don't grow at all", and the ratio between sibling values (1/1000000 vs 2/1000000) collapsed too.

The fix has two halves:

  1. Preserve genuinely small values instead of flattening them to 0 (serialize.js).
  2. Still fold float noise to 0, so calc(0.1px + 0.2px - 0.3px) doesn't turn into a phantom 5.5e-17 (serialize.js noise floor + a relative snap in sum.js for large-operand cancellation like 0.07 * 1e7 - 700000).

@dkryaklin

Copy link
Copy Markdown
Contributor Author

After some research, it looks like rounding a small value to non-zero is similar to the algorithm for line-width (https://drafts.csswg.org/css-values-4/#snap-as-a-line-width) but is it correct for other properties?

Short answer: no, generalizing line-width snapping would be incorrect which is why the current version doesn't do it.

Line-width snapping is a property-specific rendering rule: it's scoped to line widths (border-width, outline-width, column-rule-width), it's applied by the UA at the actual device resolution, and it floors a positive sub-pixel value to 1 device pixel so hairlines stay visible. Three reasons it doesn't generalize:

  • It's the browser's job, and it already does it. If we keep the true 0.000001px, border-width still gets snapped up to a visible line at paint time. At a DPR we can't know at build time. Doing it ourselves is guessing the browser's decision.
  • Regular lengths aren't snapped up. width: 0.0001px, margin, top render as ~0; browsers don't floor them. Applying line-width logic there would invent size that the platform doesn't give.
  • Non-lengths aren't lengths at all. flex-grow, fr snapping doesn't apply, and a fixed floor destroys the ratio between siblings.

The current version instead preserves the true magnitude and lets each property's own rules (the browser's line-width snap included) apply downstream. And to avoid leaking a phantom for expressions that are mathematically exactly zero (calc(0.07px * 1e7 - 700000px)), those now fold to 0 via a relative noise snap in sum.js.

@ludofischer
ludofischer merged commit 0fab36a into postcss:master Jul 29, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants