Skip to content

Correctness, packaging and tooling pass; remove Solid adapter (breaking — 2.0.0) - #7

Merged
ps73 merged 49 commits into
mainfrom
release/v2.0.0
Aug 15, 2026
Merged

ps73 merged 49 commits into
mainfrom
release/v2.0.0

Conversation

@ps73

@ps73 ps73 commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Summary

A correctness, packaging and tooling pass across the monorepo, plus the removal
of the Solid adapter. This is a breaking release — it should ship as 2.0.0.

Every change was executed against a written plan and reviewed against machine-checkable
criteria before landing; the plans and the full review record are committed under
plans/.

Highlights:

  • Four user-visible bugs fixed, three of which shipped to npm and one of which
    made an entire adapter non-functional.
  • All three packages now pass publint and @arethetypeswrong/cli cleanly —
    they previously failed 8 of 12 resolution rows between them.
  • Test count went from 18 (core only) to 58 across all packages.
  • CI now builds, lints, tests, and enforces packaging + a per-package byte budget.

⚠ Breaking changes

Change Who it affects
@tiny-intl/solid-js removed Solid users. Published versions ≤ 1.2.0 remain installable but unmaintained.
UMD builds dropped; main/require now point at ./lib/index.cjs Anyone loading the adapters from a CDN via <script>. Not a plausible delivery mode for hook-based adapters, and there is no evidence of such use.
exports maps restructured (nested import/require conditions with split types) Resolution changes for TypeScript consumers — strictly for the better, but it is a resolution change.
tc(key, 0) now uses a zero entry when the dictionary defines one Only dictionaries that explicitly wrote a zero key. They previously got the other string; they now get what they wrote.
change(locale, staticDict) now applies staticDict for the current locale Anyone relying on the previous no-op.
A superseded change() no longer notifies subscribers Consumers counting notifications will see fewer — it previously notified with a locale whose dictionary it had failed to install.

Bugs fixed

@tiny-intl/solid-js useIntl() returned [undefined] from every primitive.
memoizeCallback called fn.call(args), which passes the argument array as
this and forwards no arguments. Every documented primitive — t, tc, n,
dt, rt, sort, collator, list — was broken for the package's entire
published life. Resolved by removing the adapter.

<Translate count={0}> rendered the singular; <Translate number={0}>
rendered nothing.
Truthiness guards (if (count), if (number)) treat 0 as
absent, so empty-state counts silently fell through to the wrong branch — in a
library whose headline feature is Intl.PluralRules.

All five Intl formatter caches were dead. Each wrote under an
options-derived key and read with locale, so every lookup missed and a fresh
Intl.*Format was constructed on every n/dt/rt/list/collator call.

@tiny-intl/preact shipped a type entry point re-exporting a file it never
emitted.
vite-plugin-dts printed six TS2742 diagnostics, declined to emit
useIntl.d.ts, and exited 0. Every TypeScript consumer of the package got no
types for its primary API.

The documented zero plural key was unreachable in English and German.
CLDR gives neither locale a zero category, and tc() mapped straight through
pluralRules.select(count). A user writing { zero: 'No documents', … } and
calling tc('document', 0) got "0 documents", never "No documents".

change() lost updates under concurrent calls, and mount() was not
re-entrant.
Two overlapping locale switches could leave locale set to one
language with the other's dictionary installed; loadDict was also called with
the mutable locale rather than the requested one. mount() set its flag
before awaiting, so a second concurrent call — exactly what React 18 StrictMode
produces from the documented effect pattern — resolved early against an empty
dictionary.


Packaging & types

@tiny-intl/core declared an exports.types path pointing at a directory that
has never existed. Both adapters served ESM declarations to require consumers
whose implementation is CJS, and re-exported extensionless relative paths that
are invalid under node16 ESM resolution.

attw --pack, before → after:

core react preact
before ❌ No types (node16-CJS) 👺 + 🥴 on both node16 rows 👺 + 🥴 on both node16 rows
after 🟢 all rows¹ No problems found 🌟 No problems found 🌟

¹ core waives one rule: node10 cannot resolve the ./utils subpath because it
predates exports. Pre-existing, and node10 is long EOL.

publint went from 3 errors to 0. Also: sideEffects: false on all packages,
./package.json exported, dead build artifacts no longer published, and core's
unused vite library build removed.


Bundle size

Measured with esbuild (bundle + minify + esm + treeshake, framework
externalised) then gzip -9 — a recipe that reproduces the published bundlejs
badge numbers exactly.

package before after delta
@tiny-intl/core 1418 B 1461 B +43 B, for three correctness fixes
@tiny-intl/react 541 B 548 B +7 B
@tiny-intl/preact 727 B 735 B +8 B

The adapter deltas are the honest price of the count={0} fix: count != null
is shorter than typeof count === 'number' but still longer than the original —
buggy — bare if (count). Core's +43 B pays for the zero plural key (+16 B), the change() generation
guard and re-entrant mount() (+23 B), and the dead-cache fix (+4 B). Each
increase was landed as a deliberate, reviewable bump to
scripts/size-budget.json — the budget guard rejected the commit until the
number was raised on purpose.

Install size also dropped, from removing the UMD build and an index.cjs that
nothing referenced: react 19.0 → 14.2 kB, preact 13.5 → 12.3 kB.


Testing & CI

  • 58 tests (core 30, react 14, preact 14), up from 18. react and preact had none at all.
  • Every bug fix landed with a test proven to fail without it — verified by
    reverting the source and re-running, not by assertion.
  • CI now runs on Node 20/22/24 (was 16/18/20/21, two of them EOL) with supported
    action versions, and builds — which it never did, which is how a
    declaration file went missing while the build exited 0.
  • A second CI job enforces publint, attw, and a per-package bundle-size
    budget. The budget guard is proven to fire: bloating a package turns it red
    and exits non-zero.

Not in this PR

  • The npm deprecation for @tiny-intl/solid-js. Removing the directory does
    nothing to the 15 published versions. Needs publish rights:
    npm deprecate @tiny-intl/solid-js "No longer maintained. Solid support was removed from tiny-intl; use @tiny-intl/core directly."
    
  • Version bumps. Left for lerna version at release time.
  • The deprecated d alias, which the source says will go "in stable
    release" — a release decision, and removing it frees bytes in all packages.
  • Core's install size (~82 kB, of which ~35 kB sourcemaps and ~40 kB
    published src/). Dropping both takes it to roughly 8 kB at the cost of
    consumer source-level debugging. Install size only — it does not affect the
    badges.
  • Remaining deferred findings are listed in
    plans/README.md, each with evidence and effort.

ps73 and others added 30 commits August 12, 2026 11:41
Drop @tiny-intl/solid-js from the monorepo. Its primary API never worked:
memoizeCallback in useIntl.tsx called fn.call(args), passing the argument
array as `this` and forwarding no arguments, so every primitive returned
by useIntl() (t, tc, n, dt, rt, sort, collator, list) returned '[undefined]'
or its numeric equivalent instead of a translation. That has been the
published behaviour since the adapter shipped.

BREAKING CHANGE: @tiny-intl/solid-js is removed from this repository and
will no longer be published. Consumers of @tiny-intl/solid-js should stop
depending on it; published versions up to 1.2.0 remain installable from
npm but are no longer maintained. No other package in this repo depended
on it.
Remove the ESLint override that targeted packages/solid-js and the two
devDependencies that existed only to lint it:
@gridventures/eslint-config-solid-js and eslint-plugin-solid (pulled in
transitively). Follows the removal of the solid-js package itself.
Drop the @tiny-intl/solid README entry (its link target,
./packages/solid, was already wrong -- the directory was
packages/solid-js) and add a note that @tiny-intl/solid-js was removed,
with published versions up to 1.2.0 remaining installable but no longer
maintained.
The solid-js scope in the commitlint scope-enum no longer has a package
behind it now that packages/solid-js is gone. Committed last so that no
earlier commit in this series needed the solid-js scope while it was
still a valid enum value.
Each of n(), dt(), rt(), collator() and list() wrote its formatter into
the cache under cacheKey but read it back with cache.get(locale), so
every lookup missed and a new Intl.* formatter was constructed on every
call. Read with cacheKey instead, matching the write. Safe because
change() clears all five caches on every locale change. Also drops the
duplicate numberFormatCache.clear() in change() and normalises
collator's newCacheKey(options || {}) to newCacheKey(options) so a
no-options call keys as '_' like the other four.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tiny-intl/react had zero automated tests despite being published to npm.
Add a vitest + jsdom + @testing-library/react setup (versions pinned to
stay compatible with the repo's vitest 0.34 / vite 4) and cover the
provider guard, translation, locale-change re-rendering, and number/date
formatting for useIntl() and Translate.

Also relax no-shadow for packages/*/tests/**, since Testing Library's
`screen` export legitimately shadows the browser global of the same name.
Same gap as @tiny-intl/react: @tiny-intl/preact had zero automated
tests. Its useIntl/Translate implementation is a byte-for-byte copy of
the react version (aside from importing from preact/compat), so mirror
the react test suite with @testing-library/preact instead.

package-lock.json already picked up these devDependencies in the
preceding react commit, since both installs ran before that commit.
npm run lint had no target and did nothing outside of lint-staged.
Add lint:all (lints the whole repo) and test (runs each workspace's
vitest suite via lerna) so there is a single command to verify the
whole monorepo, matching what CI will call next.
The old workflow only ran packages/core's coverage script, so react and
preact were never linted, typechecked, built, or tested in CI. It also
ran on actions/cache@v2 and actions/setup-node@v2 (retired Node 16
runtime), ordered the cache restore before the Node install it depended
on, used npm install instead of npm ci despite a committed lockfile, and
tested on Node 16/21, both past end-of-life.

Replace it with setup-node@v4's built-in npm cache, npm ci, a Node
20/22/24 matrix, and lint:all + test steps that cover core, react and
preact.
count={0} fell through to the string-translation branch (Intl's `.one`
fallback), and number={0}/date={0} rendered nothing, because the branch
guards used truthiness checks. Switch to explicit presence checks
(typeof === 'number', !== undefined) so zero values take their correct
branch, and add `relative` to the missing useCallback dependency array.
count={0} fell through to the string-translation branch (Intl's `.one`
fallback), and number={0}/date={0} rendered nothing, because the branch
guards used truthiness checks. Switch to explicit presence checks
(typeof === 'number', !== undefined) so zero values take their correct
branch, and add `relative` to the missing useCallback dependency array.
The "." and "./utils" exports conditions declared a types path
(lib/esm/types/index.d.ts) that the build never produced, and listed
"types" after "import"/"require" where resolvers never reach it since
they take the first matching condition. Nest types first under each
of import/require, pointing ESM at the emitted .d.ts and CJS at the
emitted .d.cts, add an explicit ./package.json export, and mark the
package side-effect free.
entry: ['src'] swept up src/vite-env.d.ts as a build entry, publishing
junk lib/esm/vite-env.d.js and lib/cjs/vite-env.d.d.cts files to npm.
Narrow the glob to *.ts files excluding *.d.ts so only real source
modules are built.
npm run build ran vite build (emitting to dist/) before tsup (emitting
to lib/), but files only ships lib and src, so everything vite
produced was thrown away on every build. Collapse the build script to
just the two tsup passes, trim vite.config.ts down to the vitest
config it also carries, and drop the two devDependencies
(rollup-plugin-visualizer, vite-plugin-dts) that existed solely to
feed the dead build. vite itself stays, since vitest depends on it and
dev/preview still use it.
"types" was listed last in the "." export condition, where resolvers
that take the first matching condition would never reach it. Move it
first, add an explicit ./package.json export, and mark the package
side-effect free.
Same fix as the react adapter: "types" was listed last in the "."
export condition, where resolvers that take the first matching
condition would never reach it. Move it first, add an explicit
./package.json export, and mark the package side-effect free.
Annotate useIntl's return type explicitly as TinyIntl<string>. The
inferred return type triggered TS2742 (portability diagnostic) because
it referenced TinyIntl through a deep node_modules path, causing
vite-plugin-dts to skip emitting useIntl.d.ts entirely. The package's
type entry point (index.d.ts) re-exported that missing module, leaving
every TypeScript consumer of @tiny-intl/preact without types for
useIntl, the package's primary API.
typeof count === 'number' and its siblings cost more bytes than the
equivalent != null check after minification, while still treating 0
as a value (the fix from the earlier count={0}/number={0} bug).
Same guard shrink as the react adapter: typeof count === 'number'
and its siblings become != null, which minifies smaller while still
treating 0 as a value.
Drop the unused UMD format: package.json referenced only ./lib/index.js
(import) and ./lib/index.umd.cjs (require), so the plain CJS output was
built but never shipped to consumers, and require pointed at the larger
of the two CommonJS-capable bundles. Build only es/cjs and point main
and the require export condition at lib/index.cjs.
Same fix as the react adapter: drop the unused UMD format and point
main and the require export condition at lib/index.cjs instead of the
larger lib/index.umd.cjs.
Adds plans/ — the audit findings, seven implementation plans, and the
review record for each. All seven plans are executed and merged as of
98ffdf0; the deferred findings and open questions in plans/README.md are
the remaining backlog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Explicitly typing useIntl() as TinyIntl<string> (matching preact) stops
tsc from inferring the return type and naming @tiny-intl/core's internal
createTinyIntl module in the emitted declaration. That deep import is not
an exported subpath, so it could not resolve for consumers under attw's
node16 resolution mode.
Relative specifiers in an ESM declaration file must carry an explicit
extension under node16 module resolution. index.ts now re-exports
'./Translate.js' and './useIntl.js', matching the convention
@tiny-intl/core's src/index.ts already follows, so vite-plugin-dts emits
a resolvable index.d.ts.
Same fix as the react adapter: relative specifiers in an ESM declaration
file must carry an explicit extension under node16 module resolution.
index.ts now re-exports './Translate.js' and './useIntl.js', matching
@tiny-intl/core's convention, so vite-plugin-dts emits a resolvable
index.d.ts.
The exports map previously served the same index.d.ts to both the
import and require conditions. Since the package declares
"type": "module", that file is interpreted as ESM even when it
describes the CJS build served to require consumers, which attw flags
as FalseESM. The build script now copies index.d.ts to index.d.cts
(interpreted as CJS regardless of "type"), and exports splits into
nested import/require conditions, each with its own types entry,
matching the shape @tiny-intl/core already uses.

Also aligns vite-plugin-dts to ^3.6.4 (previously pinned to 3.6.0),
matching preact's declaration, so both adapters generate declarations
with the same emitter.
Same fix as the react adapter: the exports map previously served the
same index.d.ts to both the import and require conditions, which attw
flags as FalseESM since the package declares "type": "module" while
require consumers get the CJS build. The build script now copies
index.d.ts to index.d.cts, and exports splits into nested
import/require conditions, each with its own types entry, matching
@tiny-intl/core.
Adds plans/008-fix-adapter-types-conditions.md and updates the index:
attw now reports "No problems found" for all three packages. Closes the
deferred findings for the adapter types conditions, the react/preact
useIntl type asymmetry, and the vite-plugin-dts version skew.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also adds a root build script (explicit npm-workspaces chain, core
first) so packaging checks in CI have build output to inspect.
ps73 and others added 19 commits August 13, 2026 09:13
Measures each package's published ESM entry the way bundlejs does
(esbuild bundle+minify+treeshake, framework externalised, gzip -9)
and fails if it exceeds the committed budget in
scripts/size-budget.json. Budgets are the current measured sizes
rounded up to the next 10 bytes, so any real growth trips the check.
publint lints package manifests for the three publishable packages.
attw checks that their type declarations actually resolve for
consumers; core waives the pre-existing node10 no-resolution failure
on its ./utils subpath (node10 predates package.json "exports").
Adds a second CI job that builds all three packages and runs
publint, attw, and the bundle-size budget check against the build
output. None of these are observable from the unit test suite, and
each one caught a real defect that reached npm before this plan:
a broken exports.types path, adapter type declarations that don't
resolve, and silent bundle-size growth. The existing test job and
its matrix are unchanged.
ESLint 8's directory traversal only picks up .js plus whatever
overrides.files patterns exist; nothing matched .mjs, so
scripts/bundle-size.mjs was invisible to `npm run lint:all` despite
being in tsconfig.eslint.json's include. Add a files: ['scripts/**/*.mjs']
override, modelled on the existing '**/*.config.*' one, that both
brings the file into traversal and turns off the three rules that
are wrong for a standalone build script (no-console,
import/no-extraneous-dependencies, no-await-in-loop — the awaits are
intentionally sequential so esbuild's output for the three packages
doesn't interleave).

Also fixes the three genuine style nits in the file itself: a
missing blank line between import groups, a `status` local shadowing
a global, and a `continue` restructured into an if/else. Behaviour is
unchanged: `npm run size` still reports the same three sizes, and the
Step 3 bloat-then-restore guard proof still fails/passes as before.
CI now builds all packages and enforces publint, attw and a per-package
bundle-size budget. Records the three-attempt history, including the
lerna/nx worktree escape that made attempt 2 stop, and the revision round
that closed a lint criterion which had been passing vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every README advertises the `zero` plural key, and `TinyIntlPluralDefinition`
types it, but it was unreachable in English and German — the two locales the
project's own examples use — because neither locale's CLDR plural rules have
a `zero` category, and `tc()` mapped straight through
`pluralRules.select(count)` with no special case for 0. A user who wrote a
`zero` entry per the docs and called `tc('key', 0)` silently got the `other`
form instead.

`tc()` now uses the dictionary's `zero` entry when `count === 0` and that key
defines one, falling back to the CLDR category otherwise (unaffected for
locales that already have a genuine `zero` category, since `select(0)`
already returns `'zero'` there).

Raises the `@tiny-intl/core` bundle-size budget from 1430 B to 1440 B (measured
1438 B) to account for the added branch in `tc()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tc() now honours an explicit zero entry at count 0, so the key every
README advertises is finally reachable in locales without a CLDR zero
category. Cost core +16 B, paid via a deliberate size-budget bump — the
guard added in plan 009 firing on the very next change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds .claude/ to .gitignore so agent worktrees and session state can
never be committed by accident, and records the plan for the change()
race / mount() re-entrancy fix, which is still in flight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…trant

change() previously assigned `locale` and applied a resolved dictionary
without any guard against overlapping calls, so two concurrent change()
calls could interleave and leave `dict` set to whichever loadDict()
resolved last, even if it was not the most recent request. It also read
the mutable `locale` instead of its own `nextLocale` parameter when
calling loadDict(), and silently dropped a passed staticDict whenever the
target locale matched the current one.

Fix change() with a monotonic generation counter captured before the
await: a call whose generation has been superseded by a newer call
discards its result and returns without notifying subscribers, and the
staticDict early-return bypass is removed so a same-locale staticDict
swap is applied. loadDict() is now called with the nextLocale parameter,
not the mutable locale variable.

mount() used a synchronous `mounted` flag set before its await, so a
second concurrent mount() (e.g. React 18 StrictMode's double effect
invocation) would resolve immediately while the first mount was still
loading, letting callers render against an empty dictionary. mount() now
caches and returns the same in-flight promise so every caller awaits the
same load.

The public `change` type only declared two parameters, making the
runtime's `forceLoad` third parameter unreachable for TypeScript
consumers; it is now part of the type (type-only, zero runtime bytes).

Raises the @tiny-intl/core bundle-size budget from 1440 B to 1470 B
(measured 1461 B) to cover the generation-guard and mountPromise logic.
react and preact budgets are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change() no longer loses updates when locale switches overlap, honours a
staticDict for the current locale, and calls loadDict with the requested
locale rather than the mutable one. mount() is re-entrant. Core grew
+23 B, paid via a deliberate size-budget bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test job's Lint and Test steps both resolve @tiny-intl/core, whose
package.json points into lib/ (build output). Without a build step, a
clean checkout has nothing there to resolve, so the adapters' tests
fail with "Failed to resolve entry for package @tiny-intl/core" on
every clean CI runner. Add a Build step between Install and Lint,
mirroring what the package job already does correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI's test job never built, so the adapter tests could not resolve
@tiny-intl/core. Hidden locally because lib/ is gitignored but
persistent; a clean runner was the first environment to run them
without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
t(), template(), and tc() used truthiness (||) to decide whether a
dictionary value or template param was present, so an empty string
translation rendered as the "[key]" missing-key marker instead of "".
Switch to nullish coalescing (??) for t()/template(), which is
byte-identical to || after minification and only changes behavior for
''. tc()'s zero-entry check switches from truthiness to `in`, a true
presence check, since a defined `zero: ''` entry must still be used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mount() cached its promise unconditionally, so a rejected loadDict()
(e.g. a network blip fetching a dictionary chunk) left mountPromise
holding a permanently rejected promise. Every later mount() call
returned that same rejection with no way to recover short of building
a new instance. Clear mountPromise in the rejection handler before
rethrowing, so the caller still observes the error but a subsequent
mount() starts a fresh load.

Raises the @tiny-intl/core bundle-size budget from 1470 B to 1480 B:
the rejection handler measures 1478 B (was 1461 B), so the previous
budget no longer had headroom for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solid-js support has already shipped as removed, not pending removal
in some future release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings this audit missed: empty-string dictionary values were
treated as missing (contradicting a note plan 010 itself added), and a
rejected mount() could never be retried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The push trigger never matched slash-named branches, so CI had only run
via pull_request. Repository URL now points at the canonical repo in the
full git-URL form, clearing publint's suggestion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ps73
ps73 merged commit 4a388e8 into main Aug 15, 2026
9 checks passed
@ps73
ps73 deleted the release/v2.0.0 branch August 15, 2026 15:52
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