From 9eb7660478991f91e11b19c0f7f5ff9aa78778b7 Mon Sep 17 00:00:00 2001 From: Dmitry Kryaklin Date: Mon, 20 Jul 2026 18:41:36 +0300 Subject: [PATCH 1/2] fix: do not round non-zero values down to zero --- src/lib/serialize.js | 8 +++++++- test/index.js | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/lib/serialize.js b/src/lib/serialize.js index 8e4844f..15f65ae 100644 --- a/src/lib/serialize.js +++ b/src/lib/serialize.js @@ -15,6 +15,8 @@ * @property {string} [calcName] Wrapper name to use when `calc()` is needed. Default `'calc'`. */ +const NOISE_FLOOR = 1e-12; + /** * @param {number} v * @param {number | false} prec @@ -25,7 +27,11 @@ function round(v, prec) { return v; } const m = Math.pow(10, prec); - return Math.round(v * m) / m; + const rounded = Math.round(v * m) / m; + if (rounded === 0 && Math.abs(v) > NOISE_FLOOR) { + return v < 0 ? -1 / m : 1 / m; + } + return rounded; } // §10.13 / §10.7.2: Infinity/NaN serialize as canonical keywords. diff --git a/test/index.js b/test/index.js index c165ae2..87a6f8b 100644 --- a/test/index.js +++ b/test/index.js @@ -382,6 +382,26 @@ test( testValue('calc(5/1000000)', '0.000005', { precision: 6 }) ); +test( + 'should not round a non-zero value down to zero', + testValue('calc(1/1000000)', '0.00001') +); + +test( + 'should not round a non-zero dimension down to zero', + testValue('calc(1px/1000000)', '0.00001px') +); + +test( + 'should not round a non-zero negative value down to zero', + testValue('calc(-1/1000000)', '-0.00001') +); + +test( + 'should keep rounding float noise to zero', + testValue('calc(0.1px + 0.2px - 0.3px)', '0px') +); + test( 'should reduce browser-prefixed calc (1)', testValue('-webkit-calc(1px + 1px)', '2px') From 85a07347f700e9c199c54597c1de2a4c302e4612 Mon Sep 17 00:00:00 2001 From: Dmitry Kryaklin Date: Tue, 28 Jul 2026 12:13:28 +0300 Subject: [PATCH 2/2] fix: preserve sub-precision values and snap cancellation noise to zero --- src/lib/serialize.js | 9 ++++++++- src/lib/simplify/bucket.js | 1 + src/lib/simplify/sum.js | 21 +++++++++++++++++++-- test/index.js | 29 ++++++++++++++++++++++------- types/lib/simplify/bucket.d.ts | 5 +++++ types/lib/simplify/sum.d.ts | 7 ------- 6 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src/lib/serialize.js b/src/lib/serialize.js index 15f65ae..9b45fc2 100644 --- a/src/lib/serialize.js +++ b/src/lib/serialize.js @@ -15,9 +15,15 @@ * @property {string} [calcName] Wrapper name to use when `calc()` is needed. Default `'calc'`. */ +// Below this is float noise, not a value: `0.1 + 0.2 - 0.3` is 5.5e-17. const NOISE_FLOOR = 1e-12; /** + * Rounding to `prec` decimal places turns `calc(1/1000000)` into `0`, and a + * `0` in CSS is often a switch, not a small number (`flex-grow: 0` never + * grows). So when a value is too small for `prec`, keep its significant digits + * instead: `1/1000000` -> `0.000001`, `1/3000000` -> `3.3333e-7`. + * * @param {number} v * @param {number | false} prec * @return {number} @@ -29,7 +35,8 @@ function round(v, prec) { const m = Math.pow(10, prec); const rounded = Math.round(v * m) / m; if (rounded === 0 && Math.abs(v) > NOISE_FLOOR) { - return v < 0 ? -1 / m : 1 / m; + // toPrecision needs at least one significant digit; `prec` may be 0. + return Number(v.toPrecision(Math.max(prec, 1))); } return rounded; } diff --git a/src/lib/simplify/bucket.js b/src/lib/simplify/bucket.js index 3e66c99..a70b493 100644 --- a/src/lib/simplify/bucket.js +++ b/src/lib/simplify/bucket.js @@ -11,6 +11,7 @@ const { convert } = require('../convertUnits.js'); * @typedef {object} UnitBucket * @property {string} unit * @property {number} total + * @property {number} scale largest |term| accumulated into `total`, for noise detection * @property {import('../convertUnits.js').BaseType | null} base * @property {number} order */ diff --git a/src/lib/simplify/sum.js b/src/lib/simplify/sum.js index d188d05..6c491c0 100644 --- a/src/lib/simplify/sum.js +++ b/src/lib/simplify/sum.js @@ -12,6 +12,19 @@ const { mergeConvertibleBuckets } = require('./bucket.js'); * @typedef {import('./bucket.js').UnitBucket} UnitBucket */ +// Subtracting near-equal terms leaves float dust: `0.07 * 1e7 - 700000` is +// 1.16e-10, not 0. Snap a total that's tiny next to its terms back to 0. +const NOISE_REL = Number.EPSILON * 8; + +/** + * @param {number} total + * @param {number} scale largest |term| accumulated into `total` + * @return {number} + */ +function denoise(total, scale) { + return Math.abs(total) < scale * NOISE_REL ? 0 : total; +} + /** * @param {Sum} sum * @param {SimplifyFn} simplify @@ -23,6 +36,7 @@ function simplifySum(sum, simplify) { // encountered unit. `100vh - 5rem - 10rem - 100px` → `-15rem` in phase 1, // then vh/rem/px stay separate in phase 2 (none convert to each other). let numTotal = 0; + let numScale = 0; /** @type {Map} */ const byUnit = new Map(); /** @type {SumTerm[]} */ @@ -43,6 +57,7 @@ function simplifySum(sum, simplify) { } if (n.type === 'Num') { numTotal += sign * n.value; + numScale = Math.max(numScale, Math.abs(n.value)); return; } if (n.type === 'Dim') { @@ -50,10 +65,12 @@ function simplifySum(sum, simplify) { const existing = byUnit.get(key); if (existing) { existing.total += sign * n.value; + existing.scale = Math.max(existing.scale, Math.abs(n.value)); } else { byUnit.set(key, { unit: n.unit, total: sign * n.value, + scale: Math.abs(n.value), base: baseOf(n.unit), order: bucketOrder++, }); @@ -71,9 +88,9 @@ function simplifySum(sum, simplify) { // unconditionally is harmless. Zero-valued unit buckets are kept for // type info (WPT calc-serialization-002). /** @type {SumTerm[]} */ - const terms = [{ sign: 1, node: num(numTotal) }]; + const terms = [{ sign: 1, node: num(denoise(numTotal, numScale)) }]; for (const bucket of mergeConvertibleBuckets([...byUnit.values()])) { - terms.push({ sign: 1, node: dim(bucket.total, bucket.unit) }); + terms.push({ sign: 1, node: dim(denoise(bucket.total, bucket.scale), bucket.unit) }); } terms.push(...opaque); diff --git a/test/index.js b/test/index.js index 87a6f8b..2d3957e 100644 --- a/test/index.js +++ b/test/index.js @@ -383,25 +383,40 @@ test( ); test( - 'should not round a non-zero value down to zero', - testValue('calc(1/1000000)', '0.00001') + 'should keep a value smaller than the precision instead of rounding it to zero', + testValue('calc(1/1000000)', '0.000001') ); test( - 'should not round a non-zero dimension down to zero', - testValue('calc(1px/1000000)', '0.00001px') + 'should keep a dimension smaller than the precision', + testValue('calc(1px/1000000)', '0.000001px') ); test( - 'should not round a non-zero negative value down to zero', - testValue('calc(-1/1000000)', '-0.00001') + 'should keep a negative value smaller than the precision', + testValue('calc(-1/1000000)', '-0.000001') ); test( - 'should keep rounding float noise to zero', + 'should keep the ratio between two values smaller than the precision', + testValue('calc(2/1000000)', '0.000002') +); + +test( + 'should limit a value smaller than the precision to that many significant digits', + testValue('calc(1/3000000)', '3.3333e-7') +); + +test( + 'should still round float noise down to zero', testValue('calc(0.1px + 0.2px - 0.3px)', '0px') ); +test( + 'should fold exact cancellation with large operands to zero, not a phantom', + testValue('calc(0.07px * 1e7 - 700000px)', '0px') +); + test( 'should reduce browser-prefixed calc (1)', testValue('-webkit-calc(1px + 1px)', '2px') diff --git a/types/lib/simplify/bucket.d.ts b/types/lib/simplify/bucket.d.ts index 34bdd51..44f404b 100644 --- a/types/lib/simplify/bucket.d.ts +++ b/types/lib/simplify/bucket.d.ts @@ -1,6 +1,10 @@ export type UnitBucket = { unit: string; total: number; + /** + * largest |term| accumulated into `total`, for noise detection + */ + scale: number; base: import("../convertUnits.js").BaseType | null; order: number; }; @@ -8,6 +12,7 @@ export type UnitBucket = { * @typedef {object} UnitBucket * @property {string} unit * @property {number} total + * @property {number} scale largest |term| accumulated into `total`, for noise detection * @property {import('../convertUnits.js').BaseType | null} base * @property {number} order */ diff --git a/types/lib/simplify/sum.d.ts b/types/lib/simplify/sum.d.ts index a74574f..4250d9c 100644 --- a/types/lib/simplify/sum.d.ts +++ b/types/lib/simplify/sum.d.ts @@ -3,13 +3,6 @@ export type Sum = import("../node.js").Sum; export type SumTerm = import("../node.js").SumTerm; export type SimplifyFn = import("../simplify.js").SimplifyFn; export type UnitBucket = import("./bucket.js").UnitBucket; -/** - * @typedef {import('../node.js').Node} Node - * @typedef {import('../node.js').Sum} Sum - * @typedef {import('../node.js').SumTerm} SumTerm - * @typedef {import('../simplify.js').SimplifyFn} SimplifyFn - * @typedef {import('./bucket.js').UnitBucket} UnitBucket - */ /** * @param {Sum} sum * @param {SimplifyFn} simplify