Repository navigation
fix: make auto-forward timing identical on every JavaScript host - #377
Merged
Merged
Conversation
Reading units came from the host Intl.Segmenter whenever one existed, so the auto-forward delay of the same line depended on the browser's or runtime's ICU data. Node 22.12 (ICU 76.1, Unicode 16.0) segments 10 of the 766 official Unicode 17.0 GraphemeBreakTest cases and 2,976 of 15,568,896 code-point probes differently from Node 24.21 (ICU 78.3, Unicode 17.0). Khmer, Myanmar, Gujarati and Balinese conjuncts and U+2701 U+200D U+2701 change their unit counts; "ភាសាខ្មែរ" counts 5 units there and 4 on Unicode 17.0 hosts. Always use the bundled unicode-segmenter (Unicode 17.0), which already served hosts without Intl.Segmenter. On Unicode 17.0 hosts (Node 24.21, Node 22.22 with ICU 78.2, Bun 1.4.2) it matches Intl.Segmenter on all 766 official cases, a 44-text multilingual corpus and every probe, so results on those hosts do not change.
The multiplier was `2 ** ((speed - 50) / 50)`. ECMA-262 leaves `**` and Math.pow implementation-approximated, and hosts disagree in the last bit: V8 13.6 calls the C library's pow (glibc 2.43 on Linux), its own fdlibm port (--no-use-std-math-pow) differs at 9 of the 101 integer speeds, JavaScriptCore returns 0.7071067811865475 instead of 0.7071067811865476 at speed 25, and glibc itself misses the nearest double for about 0.08% of fractional speeds (5.39, 44.62, 55.39, 61.09 and 76.42 among the hundredths). The playback scheduler compares delays as exact quanta, so a one-ulp difference can move the tick on which auto-forward fires. Return the correctly rounded value instead. The clamp, subtraction and division stay IEEE-754 basic operations, and 2 ** x is evaluated with exact BigInt fixed-point arithmetic under a proven error bound, doubling the precision whenever the bracket straddles a rounding boundary. The result is unique, so any correctly rounded implementation, including non-JavaScript ports, reproduces it bit for bit. All 101 integer speeds keep the values V8 returns on Linux today; 832 of 1,021,864 distinct measured speeds move by one unit in the last place to the nearest double. A call takes about 7 microseconds.
Graphemes already came from the bundled Unicode 17.0 segmenter, but their
weights still used the host: \p{Script=Han|Hiragana|Katakana|Hangul} and
\s follow the engine's regex Unicode tables, and normalize("NFC") the
host's normalization data. CJK Extension J (U+323B0..U+33479, new in
17.0) weighed 3 per character on Node 24 but 1 in Chromium 143, whose
regex data is older.
Weigh graphemes with bundled tables instead: the Han, Hiragana, Katakana
and Hangul ranges from Unicode 17.0 Scripts.txt (115,792 code points, an
exact match), and the ECMAScript WhiteSpace and LineTerminator code
points. Drop the NFC step: the bundled segmenter keeps bases and marks
together, and over every decomposable code point in conjunct, Hangul and
emoji contexts NFC only changed counts for U+2194 U+0338 next to a ZWJ
emoji sequence. Pin unicode-segmenter to 0.17.0 so the documented
Unicode version cannot move with a patch release.
Also cache the last speed multiplier, since timers re-read it on every
reconcile; correct the comment on V8's pow; spell out in the docs that
(speed - 50) / 50 is ordinary double arithmetic; restore Intl.Segmenter
correctly in tests on hosts without it; drop the test made redundant by
the host-segmenter stub; and escape invisible code points in the corpus.
This was referenced Oct 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The auto-forward delay is
base + readingUnits * 60 / multiplier. Two inputs of it come from the host, so the same line, text and settings get a different delay on different browsers, runtimes and runtime versions:getAutoForwardSpeedMultiplierreturned2 ** ((speed - 50) / 50). ECMA-262 leaves**andMath.powimplementation-approximated, and engines, engine versions and C libraries disagree in the last bit. Only speeds 0, 50 and 100 are exact everywhere.getAutoForwardReadingUnitsused the hostIntl.Segmenterwhenever one existed; the bundledunicode-segmenterwas only a fallback. Grapheme boundaries therefore follow the host's ICU / Unicode version.The difference is observable: the playback scheduler converts
delayMsto exact quanta (toExactDurationQuanta) and fires whenremainingQuanta <= deltaQuanta, so a one-ulp different delay can fire on a different tick, and a different unit count changes the delay by 60 ms or more. It also means a non-JavaScript implementation of the engine cannot match "the" JavaScript result, because there is not one.Evidence
Multiplier, compared with the correctly rounded value (the double nearest to 2^x), over all 101 integer speeds and 1,021,864 distinct fractional speeds (every hundredth, slider-style
k * stepvalues for 10 step sizes, a 10,000-case LCG set and 1,000,000 uniform random speeds). The correctly rounded reference was checked against an independent Pythondecimalcomputation at 80 digits on all 2,034,582 measured inputs, with 0 disagreements.2 ** xcomes fromstd::pow, glibc 2.43--no-use-std-math-powstd::powoption)x = -0.5Examples: speed 25 is
0.7071067811865476on Node 24 and0.7071067811865475on Node 22 and JavaScriptCore; speed 55.39 is1.0775837498610183on Node 24 where the nearest double is1.0775837498610186. Every disagreement is exactly one ulp. Windows and macOS C libraries were not measured.Reading units, host
Intl.Segmenteragainst the bundled segmenter (unicode-segmenter0.17.0, Unicode 17.0), on the 766 official Unicode 17.0GraphemeBreakTestcases, a 44-text multilingual corpus (each raw and NFC) and 15,568,896 probes (every Unicode scalar value in 14 two- and three-character contexts covering combining marks, ZWJ, Indic virama, Hangul jamo, regional indicators, emoji modifiers, CR/LF and Thai SARA AM):On the Unicode 16.0 host, "ភាសាខ្មែរ" (Khmer) counts 5 units instead of 4, a Myanmar kinzi sequence 3 instead of 1, Gujarati and Balinese conjuncts one more, and U+2701 U+200D U+2701 one fewer (Unicode 17.0 changed GB9c conjunct data and Extended_Pictographic).
Fix
splitGraphemes(already a dependency and already the fallback). NFC normalization and the unit weights are unchanged.(speed - 50) / 50(IEEE-754 basic operations, identical everywhere) and return the correctly rounded 2^x. It is computed with exact BigInt fixed-point arithmetic: ln 2 from its series, e^(x ln 2) from the Taylor series with a proven error bound, and a retry at doubled precision whenever the error bracket straddles a rounding boundary. 2^x is irrational for every non-integer rational x, so it is never exactly halfway and the loop terminates; the first 128-bit pass decided all 2,034,546 measured inputs. About 90 lines, no new dependency, roughly 7 µs per call (called once per auto-forward timer).A lookup table was considered and rejected:
autoForwardSpeedaccepts any number from 0 to 100 (schematype: number, sliders with astepother than 1,${variables.*}bindings), so a table would need a fallback for fractional speeds. Because the correctly rounded value is unique, any correct implementation in another language reproduces these results bit for bit.Compatibility
playbackScheduler.js.unicode-segmenterto a newer Unicode version becomes an explicit behaviour change that the corpus test flags.\p{Script=Han}and friends),\sandnormalize("NFC")still use the engine's built-in Unicode tables. Between the Unicode 16.0 and 17.0 hosts above,\sand single-code-point NFC were identical, but 4,321 code points newly assigned in Unicode 17.0 (U+16FF2..U+33479, mostly CJK Extension J) weigh 1 instead of 3 on the older host. A follow-up could bundle a Script range table.Tests
spec/autoForwardTiming.test.jsgains 40 tests:Intl.Segmenterfor a stub with outdated data (one code point per grapheme), re-imports the module and checks the stub is never constructed and the corpus counts do not change.The old implementation fails the new tests where it depends on the host: 20 of 52 on Node 24.21, 17 on Node 22.12 (including the integer table and three Unicode 17 corpus rows) and 21 on Bun 1.4.2 (including speed 25). The new implementation passes 52 / 52 on all three. Full suite on Node 24.21: 2,366 passed before, 2,406 after (2,385 after the first commit alone).
bun run lint,check:test-markersandformat:changedpass.A JSON vector set generated from this branch (25,538 multiplier rows, 754 delay rows, 25 reading-unit rows with grapheme boundaries) is attached for other implementations. It was cross-checked against the Python
decimalreference and a literal Rust port of the same algorithm, with 0 mismatches.