Skip to content

feat!: require a user-provided Temporal (remove bundled shim) and fix non-Gregorian locale values - #15

Merged
dogmar merged 6 commits into
mainfrom
fix/locale-calendar
Jun 14, 2026
Merged

feat!: require a user-provided Temporal (remove bundled shim) and fix non-Gregorian locale values#15
dogmar merged 6 commits into
mainfrom
fix/locale-calendar

Conversation

@dogmar

@dogmar dogmar commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

Audit fix C, which grew into a Temporal-provisioning change. Breaking.

Summary

  1. Fix non-Gregorian locale values (the original audit finding). onMonthChange, rootState.viewing, and NavButtonState.target were built with PlainYearMonth.from({ year, month, calendar: localeCalendar }) — feeding ISO year/month numbers in as locale-calendar fields. Under a real Temporal implementation this reinterprets them (e.g. th-TH Buddhist: ISO June 2026 → 1483-06-10[u-ca=buddhist], ~543 years off). These values are now built in ISO; the locale only affects display.

  2. Remove the bundled Temporal shim; require a user-provided Temporal. The library no longer ships a Temporal implementation.

Breaking change

The Temporal value export is removed from the package barrel and from every @klinking/colander/* format subpath. Consumers must now provide a Temporal implementation via the temporal prop (or run where native Temporal exists). resolveTemporal is provided → native globalThis.Temporal → throw; the error and the temporal prop TSDoc point to two recommended polyfills:

import { Temporal } from "temporal-polyfill";
<MonthView temporal={Temporal}  />

This deletes the temporal-polyfill module (~960 LOC incl. test) and drops the ~148KB Gregorian engine the audit flagged from the bundle.

Display formatting

MonthYearString now formats the month label through the provided Temporal — T.PlainDate.from({ year, month, day: 1 }).toLocaleString(locale, opts) — instead of new Date(...). (Not PlainYearMonth.toLocaleString, which throws on any iso8601-vs-locale calendar mismatch — even en-US; an iso8601 PlainDate is the documented Intl exception and adopts the locale's calendar, so th-TH still renders the Buddhist era 2569.) Both recommended polyfills implement this.

Notes / decisions

  • The temporal prop stays optional in the type — native Temporal is still valid; the requirement is enforced at runtime and documented. No hard peerDependency added (two valid packages + native; documented instead).
  • calendarForLocale (which only existed to feed the buggy calendar injection) was removed.
  • Tests run against @js-temporal/polyfill; the "mini polyfill" matrix entry and shim-specific tests are gone. Follow-up Run the full test suite against both temporal-polyfill and @js-temporal/polyfill #16 tracks matrixing the whole suite over both polyfills.
  • Still deferred (separate, larger surface — selected ZonedDateTime + timezone, and a locale-prop-driven component): DateString/TimeString and MonthSeparator still format via native Date.

Tests

  • th-TH coverage: onMonthChange and rootState.viewing emit ISO (RED before the fix); NavButtonState.target is ISO; the label still localizes to the Buddhist era; a controlled round-trip doesn't jump centuries.
  • vp run ready: lint+typecheck 0/0, all suites green.

🤖 Generated with Claude Code

dogmar and others added 5 commits June 14, 2026 11:54
onMonthChange, rootState.viewing, and NavButtonState.target were built
with `PlainYearMonth.from({ year, month, calendar: localeCalendar })`,
feeding ISO year/month numbers in as locale-calendar fields. With the
full @js-temporal/polyfill (and native Temporal) this reinterpreted the
numbers: for locale="th-TH" (Buddhist), viewing ISO June 2026 produced
1483-06-10[u-ca=buddhist] — off by ~543 years. (The mini shim is
ISO-only and silently ignored the field, so the bug only surfaced with a
real calendar implementation.)

These values are now built in ISO. The locale calendar only ever
affected display, which goes through `new Date(...).toLocaleDateString`
independently (MonthYearString) and is unchanged — Thai still renders
"มิถุนายน 2569". Making the exposed values ISO also lets the controlled
`month`/onMonthChange pair round-trip under the identity function.

`calendarForLocale` existed only to feed this buggy pattern and is now
removed (not a public export). month/defaultMonth/onMonthChange docs now
state the ISO contract.

Adds th-TH coverage: onMonthChange and rootState.viewing emit ISO,
NavButtonState.target is ISO, the label still localizes to the Buddhist
era, and a controlled round-trip does not jump centuries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MonthYearString built `new Date(year, month-1, 1).toLocaleDateString(...)`
in component code. Format through the injected Temporal instead, so the
only native `Date` on this display path lives inside the polyfill — the
bundled shim's `PlainDate.toLocaleString` (Gregorian-only) or the full
polyfill.

Uses `PlainDate.toLocaleString`, NOT `PlainYearMonth.toLocaleString`:
verified the latter throws on any iso8601-vs-locale calendar mismatch
("cannot format PlainYearMonth with calendar iso8601 in locale with
calendar gregory") — even for en-US. An iso8601 `PlainDate` is the
documented compatibility exception and adopts the locale's calendar, so
th-TH still renders the Buddhist era (2569). The mini shim's existing
`MiniPlainDate.toLocaleString` already localizes through Intl; no new
shim method was added (a non-throwing `MiniPlainYearMonth.toLocaleString`
would diverge from real Temporal, which throws).

Documents the shim as ISO/Gregorian-only (module doc + the public
`Temporal` export): display localizes via Intl, but calendar math is
Gregorian; use @js-temporal/polyfill or native Temporal for other
calendars.

Tests: MiniPlainDate.toLocaleString (en-US, th-TH Buddhist, no day leak,
custom options) and a MonthYearString th-TH label rendered with the
bundled shim.

Note: DateString/TimeString (selected ZonedDateTime + timezone) and the
locale-prop-driven MonthSeparator still format via native Date; those are
a separate, larger surface left for a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Frame it as the internal Gregorian-only date engine for callers who use
non-Temporal value formats (Date/object/string) on hosts without native
Temporal and don't want the @js-temporal/polyfill dependency — not as a
"Gregorian Temporal" picked for calendar reasons. It is never
auto-selected; resolveTemporal uses what you pass, else native Temporal,
else throws.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resolveTemporal now falls back to the bundled Gregorian shim when no
Temporal is provided and none is native — but only for non-Temporal value
formats (`object`, `Date`), whose values never expose Temporal objects to
the caller. So a `format="Date"` consumer on a host without native
Temporal works with no `temporal` prop and no `@js-temporal/polyfill`.

Temporal value formats (PlainDate, PlainYearMonth, …) still throw when no
real Temporal is available: the caller wants real Temporal objects and
the shim is not one, so silently substituting it would be wrong. The
calendar passes the resolved `format` into resolveTemporal for this
decision.

Updates the shim docs (module + public export) to describe the
auto-selection. resolveTemporal is not a public export, so the new
optional `format` param is non-breaking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oral

BREAKING CHANGE: the library no longer bundles a `Temporal` implementation.
Provide one via the `temporal` prop (or run where native `Temporal`
exists). Two recommended polyfills:
  - temporal-polyfill        https://github.com/fullcalendar/temporal-polyfill
  - @js-temporal/polyfill    https://github.com/js-temporal/temporal-polyfill

The `Temporal` value export is removed from the package barrel and from
every `@klinking/colander/*` format subpath. resolveTemporal reverts to
provided -> native -> throw (no shim fallback); the error message points
to the two polyfills. The `temporal` prop docs document them.

Drops the temporal-polyfill module + test, the "mini polyfill" entry in
the test matrix, and the shim-specific tests. The month label still
formats through the provided Temporal (PlainDate.toLocaleString), which
both recommended polyfills implement.

Removing the shim also drops the ~148KB Gregorian engine from the bundle.

Follow-up tracked in #16: run the whole suite against both polyfills.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dogmar dogmar changed the title fix: expose ISO PlainYearMonth values for non-Gregorian locales feat!: require a user-provided Temporal (remove bundled shim) and fix non-Gregorian locale values Jun 14, 2026
Comment thread package/src/navigation.tsx Outdated
@dogmar
dogmar merged commit 3613690 into main Jun 14, 2026
6 checks passed
@dogmar
dogmar deleted the fix/locale-calendar branch June 14, 2026 19:49
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.

1 participant