GitHub - gesposito/momentum: Date libraries' APIs on Temporal, and a way off them.

GitHub

7 min read Original article ↗

Date libraries' APIs on Temporal, and a way off them.

Temporal is in Node.js 26, Bun and recent browsers, but the code that handles dates in most projects still speaks Luxon, Day.js or date-fns. momentum is a bridge: @momentum-js/luxon keeps Luxon code working with Temporal doing the work underneath, and @momentum-js/migrate tells a project how far it is from plain Temporal and rewrites what already has an exact native replacement. The bridge is meant to be temporary, and its tooling is built to take it down.

Package What it is
@momentum-js/luxon Luxon 3.7's DateTime, Duration, Interval, Info, Settings and zones, with the same methods, options, parsing and formatting, on native Temporal or on temporal-polyfill; verified by Luxon's own test suite
@momentum-js/migrate npx @momentum-js/migrate check <project> finds every Luxon, momentum, Day.js or date-fns use with TypeScript's type checker and reports whether the exact rewrites complete the switch to native Temporal; apply writes them
Day.js Day.js 1.11.23's code and test suite, vendored with Day.js's history: the reference the migration tool's Day.js rules are checked against, and a benchmark of Day.js against the Temporal that replaces it
date-fns date-fns 4.4.0 with @date-fns/tz and @date-fns/utc, vendored with its history: the reference the migration tool's date-fns rules are checked against
bun add @momentum-js/luxon            # Luxon's API on Temporal
npx @momentum-js/migrate check .      # how far this project is from plain Temporal
import { DateTime } from "@momentum-js/luxon";

DateTime.now().setZone("America/New_York").minus({ weeks: 1 }).endOf("day").toISO();

How it is built

Vendored and verified upstream. The library started as a verbatim copy of Luxon 3.7.2, with Luxon's own history (736 commits) merged in underneath, so git blame reaches the original authors and git log --first-parent lists the migration steps, each a small refactor verified by Luxon's suite, which fills packages/luxon/test/, is re-vendored by a script and is never edited by hand. Day.js's code and suite came in the same way (983 commits, tag dayjs/4549b8d0), and so did date-fns's (1,678 commits, tag date-fns/cd53d253), for the migration tool, which is checked by running each library's suite before and after its rewrites.

Temporal underneath. A DateTime is an instant in a Temporal time zone. Its calendar fields are read from a Temporal.ZonedDateTime when first needed, wall-clock times are resolved by Temporal's disambiguation, calendar arithmetic and week numbers come from Temporal.PlainDate, and offsets are cached per transition window. Reading fields through Temporal objects is still slow in today's engines, so Settings.temporalNative = false switches to Luxon's own algorithms fed with the same Temporal offsets; both cores pass the whole suite, and property tests check that they agree. The library's README has the details, the known differences and the measurements.

Exact or explicit. The migration tool applies a rewrite by default only when property tests prove it exact against the library, and Day.js's and date-fns's own suites confirm theirs; rewrites that differ in documented corner cases wait for --accept, and uses with no native replacement are reported as what keeps a library in the project. docs/migrate.md describes the catalog and the differences.

Upstream first. Every workaround for an engine, polyfill or Bun bug links its issue in the code, and docs/upstream.md lists what each one costs and what to delete once the fix ships. The slow native field getters are reported to V8 and JavaScriptCore. The goal is for all of this, and eventually momentum itself, to go away.

Development

The repository is a Bun workspace: the library in packages/luxon, the migration tool in packages/migrate, Day.js's code and test suite in packages/dayjs, date-fns's in packages/date-fns. The root holds the shared tooling, and everything runs through Bun from there:

bun install
bun run test            # Luxon's suite on Jest; Node's native Temporal, or temporal-polyfill on older Node
bun run test:polyfill   # the same suite with the polyfill forced
bun run test:opt-out    # the same suite with Settings.temporalNative = false
bun run test:bun        # the same suite on bun test, on Bun's native Temporal
bun run typecheck       # type check of both packages
bun fmt                 # format the code (bun fmt --check: fail if a file needs formatting)
bun run build           # bundles + declarations
bun run benchmark       # Luxon's benchmark cases, ported to mitata (Bun)
bun run benchmark:compare   # side by side with luxon (Bun)
bun run benchmark:node      # side by side with luxon, on Node
bun run benchmark:memory    # retained memory and bundle size, side by side with luxon (Bun)
bun run benchmark:memory:node
bun run sync-tests      # re-vendor the tests from a luxon checkout (default ../luxon)
bun run test:dayjs-suite    # Day.js's own suite, before and after the migration tool's rewrites
bun run sync-dayjs      # re-vendor Day.js's code and tests from a checkout (default ../dayjs)
bun run test:date-fns-suite # date-fns's own suite, before and after the rewrites
bun run sync-date-fns   # re-vendor date-fns's code and tests from a checkout (default ../date-fns)
bun run migrate check <project>   # how far a project is from native Temporal (docs/migrate.md)

The tests come in three groups, plus Day.js's and date-fns's. Luxon's suite fills packages/luxon/test/; bun run sync-tests vendors it from a Luxon checkout and it is never edited by hand. packages/luxon/test/parity/ checks Luxon behaviour that its suite does not cover, with every expectation taken from Luxon 3.7.2. packages/luxon/test/momentum/ covers what the library adds: the Temporal interop, the polyfill entry point, the offset cache, the two cores and the API-surface snapshot. The migration tool's tests live in packages/migrate/test/: property tests of every exact rewrite against the library, Day.js and date-fns, a reproduction of each documented difference, and fixture projects run through the CLI. Day.js's own suite sits in packages/dayjs/test, with Day.js's source, for bun run test:dayjs-suite, and date-fns's in packages/date-fns/core/src, with its source, for bun run test:date-fns-suite. MOMENTUM_TEMPORAL_NATIVE=false runs any of the test and benchmark scripts with the opt-out; tests written for one core set it themselves.

Randomized checks are fast-check properties, so a failure shrinks to a minimal counterexample and prints the seed and path that replay it. Each property runs 100 times by default; FC_NUM_RUNS raises that for a longer fuzzing session, and FC_SEED with FC_PATH replays a reported failure:

FC_NUM_RUNS=20000 bun run jest packages/luxon/test/momentum
FC_SEED=-1819918769 FC_PATH="12:3" bun run jest -t "name of the test"

mise.toml pins Node 26 and Bun 1.4.4, so with mise the tests run on Node's native Temporal. bun run test is the reference run. bun run test:bun compares its failures with packages/luxon/test/bun-known-failures.txt, the tests that fail only because of Bun's engine: three on every platform (JavaScriptCore puts plain spaces in date ranges, and Bun's snapshots do not call toJSON()) and twenty-one more on macOS, where Bun uses Apple's ICU (Latin digits for bn, " at " between date and time, among others) and, since Bun 1.4.4, rejects the POSIX-style US zone names such as PST8PDT; see docs/bun-compatibility.md.

momentum works around a few upstream bugs in Temporal polyfills, engines and Bun. docs/upstream.md lists each one with its issue and the code its fix lets us delete.

packages/luxon/scripts/sync-tests.ts copies Luxon's test/ directory into packages/luxon/test/, rewrites the import paths, turns the CommonJS helper module into an ES module, and compares strings containing U+202F with Luxon's own whitespace-tolerant matcher, because Node 26 and Bun replace the U+202F that CLDR puts between a time and its AM/PM marker with a regular space. packages/dayjs/scripts/sync.ts does the same for Day.js, pointing its tests' imports at the dayjs package's entry points, and packages/date-fns/scripts/sync.ts for date-fns.

The code is formatted with bun fmt, Bun's Prettier-compatible formatter, with the options in the [fmt] section of bunfig.toml: lines of 100 columns and trailing commas where ES5 allows them, the style Luxon's code came with. It leaves out the vendored code, which stays as its authors wrote it, the migration tool's fixture projects, which its tests compare byte for byte, and Markdown, which is wrapped by hand. bun fmt --check fails when a file needs formatting, and bun fmt --staged formats just the files about to be committed. .git-blame-ignore-revs lists the commit that reformatted the code, which GitHub's blame skips, and so does a clone after git config blame.ignoreRevsFile .git-blame-ignore-revs. The root tsconfig.json only references the two packages, so bun check at the root type-checks both, like tsc -b.

License

MIT, see LICENSE.md.

  • @momentum-js/luxon: the API, documentation comments, parsers, formatters and tests derive from Luxon, MIT, JS Foundation and other contributors.
  • packages/dayjs: Day.js's code and tests under Day.js's own MIT license, packages/dayjs/LICENSE.
  • packages/date-fns: date-fns's code and tests under date-fns's own MIT licenses, packages/date-fns/core/LICENSE.md, tz/LICENSE.md and utc/LICENSE.md.