Correctness, packaging and tooling pass; remove Solid adapter (breaking — 2.0.0) - #7
Merged
Merged
Conversation
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.
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>
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.
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:
made an entire adapter non-functional.
publintand@arethetypeswrong/clicleanly —they previously failed 8 of 12 resolution rows between them.
⚠ Breaking changes
@tiny-intl/solid-jsremoved1.2.0remain installable but unmaintained.main/requirenow point at./lib/index.cjs<script>. Not a plausible delivery mode for hook-based adapters, and there is no evidence of such use.exportsmaps restructured (nestedimport/requireconditions with splittypes)tc(key, 0)now uses azeroentry when the dictionary defines onezerokey. They previously got theotherstring; they now get what they wrote.change(locale, staticDict)now appliesstaticDictfor the current localechange()no longer notifies subscribersBugs fixed
@tiny-intl/solid-jsuseIntl()returned[undefined]from every primitive.memoizeCallbackcalledfn.call(args), which passes the argument array asthisand forwards no arguments. Every documented primitive —t,tc,n,dt,rt,sort,collator,list— was broken for the package's entirepublished life. Resolved by removing the adapter.
<Translate count={0}>rendered the singular;<Translate number={0}>rendered nothing. Truthiness guards (
if (count),if (number)) treat0asabsent, so empty-state counts silently fell through to the wrong branch — in a
library whose headline feature is
Intl.PluralRules.All five
Intlformatter caches were dead. Each wrote under anoptions-derived key and read with
locale, so every lookup missed and a freshIntl.*Formatwas constructed on everyn/dt/rt/list/collatorcall.@tiny-intl/preactshipped a type entry point re-exporting a file it neveremitted.
vite-plugin-dtsprinted sixTS2742diagnostics, declined to emituseIntl.d.ts, and exited 0. Every TypeScript consumer of the package got notypes for its primary API.
The documented
zeroplural key was unreachable in English and German.CLDR gives neither locale a
zerocategory, andtc()mapped straight throughpluralRules.select(count). A user writing{ zero: 'No documents', … }andcalling
tc('document', 0)got"0 documents", never"No documents".change()lost updates under concurrent calls, andmount()was notre-entrant. Two overlapping locale switches could leave
localeset to onelanguage with the other's dictionary installed;
loadDictwas also called withthe mutable
localerather than the requested one.mount()set its flagbefore 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/coredeclared anexports.typespath pointing at a directory thathas never existed. Both adapters served ESM declarations to
requireconsumerswhose implementation is CJS, and re-exported extensionless relative paths that
are invalid under node16 ESM resolution.
attw --pack, before → after:¹ core waives one rule: node10 cannot resolve the
./utilssubpath because itpredates
exports. Pre-existing, and node10 is long EOL.publintwent from 3 errors to 0. Also:sideEffects: falseon all packages,./package.jsonexported, dead build artifacts no longer published, and core'sunused vite library build removed.
Bundle size
Measured with esbuild (
bundle + minify + esm + treeshake, frameworkexternalised) then gzip -9 — a recipe that reproduces the published bundlejs
badge numbers exactly.
@tiny-intl/core@tiny-intl/react@tiny-intl/preactThe adapter deltas are the honest price of the
count={0}fix:count != nullis shorter than
typeof count === 'number'but still longer than the original —buggy — bare
if (count). Core's +43 B pays for thezeroplural key (+16 B), thechange()generationguard and re-entrant
mount()(+23 B), and the dead-cache fix (+4 B). Eachincrease was landed as a deliberate, reviewable bump to
scripts/size-budget.json— the budget guard rejected the commit until thenumber was raised on purpose.
Install size also dropped, from removing the UMD build and an
index.cjsthatnothing referenced: react 19.0 → 14.2 kB, preact 13.5 → 12.3 kB.
Testing & CI
reverting the source and re-running, not by assertion.
action versions, and builds — which it never did, which is how a
declaration file went missing while the build exited 0.
publint,attw, and a per-package bundle-sizebudget. The budget guard is proven to fire: bloating a package turns it red
and exits non-zero.
Not in this PR
@tiny-intl/solid-js. Removing the directory doesnothing to the 15 published versions. Needs publish rights:
lerna versionat release time.dalias, which the source says will go "in stablerelease" — a release decision, and removing it frees bytes in all packages.
published
src/). Dropping both takes it to roughly 8 kB at the cost ofconsumer source-level debugging. Install size only — it does not affect the
badges.
plans/README.md, each with evidence and effort.