Skip to content

fix(changesets): plan mini-lynx's first release as 0.1.0, not 1.0.0 - #16

Merged
amritk merged 9 commits into
mainfrom
claude/design-system-readiness-vgbljn
Jul 28, 2026
Merged

fix(changesets): plan mini-lynx's first release as 0.1.0, not 1.0.0#16
amritk merged 9 commits into
mainfrom
claude/design-system-readiness-vgbljn

Conversation

@amritk

@amritk amritk commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Two problems, both of which would have shipped on the next release.

The version was wrong. Two major changesets sat on a package at
0.0.0, and semver's major from 0.x is 1.0.0 — so a package whose README
calls itself pre-alpha, and which asks you to prototype events on a
device before committing to it, would have landed on npm as a stable
1.0.0 on its first ever publish. The repo's stated policy is that
breaking changes ride a minor bump while it is 0.x; this makes the
release plan agree with it.

The changelog described a package that does not exist. Of the 31
mini-lynx changesets, roughly a third announced the layers the Lynx
rewrite deleted — /ui, /platform, /gestures, /animate,
VirtualFor, the token layer, the semantics layer, the three-host
model, the five-tag vocabulary. Changesets flattens all of them into one
entry per version, so the package's debut changelog would have
advertised subpaths a consumer cannot import. The reasoning in them is
worth keeping and already is — it lives in docs/ and in git — but a
changelog is for changes to a published package, and none of this was
ever published.

They are replaced by a single entry describing 0.1.0 as it actually
ships. The @amritk/mini and @amritk/mini-helpers changesets are
untouched: those describe real changes to a package that has consumers.

📦 Bundle-size delta vs main (f54a9de0503002)

Package Entry main PR Δ
mini core (.) 3,156 B 3,156 B +0 B (+0.0%) ⚪
mini flow 4,083 B 4,083 B +0 B (+0.0%) ⚪
mini router 4,158 B 4,158 B +0 B (+0.0%) ⚪
mini forms 4,259 B 4,259 B +0 B (+0.0%) ⚪
mini query 1,988 B 1,988 B +0 B (+0.0%) ⚪
mini-lynx core (.) 5,064 B 5,102 B +38 B (+0.8%) 🔴
mini-lynx flow 4,641 B 4,641 B +0 B (+0.0%) ⚪
mini-lynx router 5,580 B 5,597 B +17 B (+0.3%) ⚪
mini-lynx forms 5,013 B 5,013 B +0 B (+0.0%) ⚪
mini-lynx query 1,990 B 1,990 B +0 B (+0.0%) ⚪
mini-lynx testing 1,610 B 2,451 B +841 B (+52.2%) 🔴

Gzipped bytes of each bundled entry (esbuild, minified, browser/es2022, optional peer deps external). Bundling is deterministic, so unlike a timed benchmark these numbers carry no noise and every Δ is exact. ⚪ within ±0.5% · 🟢 smaller · 🔴 larger. Each package's core (.) must stay flat as subpath features land — that is the whole charter, and src/core-size-budget.test.ts holds the absolute ceiling.

claude added 9 commits July 28, 2026 07:53
Two problems, both of which would have shipped on the next release.

**The version was wrong.** Two `major` changesets sat on a package at
0.0.0, and semver's major from 0.x is 1.0.0 — so a package whose README
calls itself pre-alpha, and which asks you to prototype events on a
device before committing to it, would have landed on npm as a stable
1.0.0 on its first ever publish. The repo's stated policy is that
breaking changes ride a minor bump while it is 0.x; this makes the
release plan agree with it.

**The changelog described a package that does not exist.** Of the 31
mini-lynx changesets, roughly a third announced the layers the Lynx
rewrite deleted — `/ui`, `/platform`, `/gestures`, `/animate`,
`VirtualFor`, the token layer, the semantics layer, the three-host
model, the five-tag vocabulary. Changesets flattens all of them into one
entry per version, so the package's debut changelog would have
advertised subpaths a consumer cannot import. The reasoning in them is
worth keeping and already is — it lives in `docs/` and in git — but a
changelog is for changes to a published package, and none of this was
ever published.

They are replaced by a single entry describing 0.1.0 as it actually
ships. The `@amritk/mini` and `@amritk/mini-helpers` changesets are
untouched: those describe real changes to a package that has consumers.
Two of the README's *Known gaps*, both of which were "the engine can do
this and the runtime gives you no way to ask".

**`/elements`** — `querySelector`, `querySelectorAll` and `invoke`. A
handful of the engine's capabilities are actions rather than state and so
cannot be attributes a signal binds to: `scrollTo` on a scroller,
`boundingClientRect`, `scrollIntoView`, `setFocus`. They are reached by
calling a UI method on an element, and nothing here wrapped that. The
whole of `invoke` is turning one engine convention into a JavaScript
one — `__InvokeUIMethod` reports through a callback whose numeric `code`
is zero on success — so that every call site is not rewriting that check
and getting the polarity backwards.

**`/gestures`** — `setGestureDetector`, and the three relations that are
the point of it. Recognising a gesture already worked: `bindtap`,
`catchtap`, the capture phase and the touch stream cover it, by hand if
need be. What could not be expressed was arbitration — a tap that must
lose to a pan already under way, a pinch and a rotation sharing two
fingers, a pan handing off to a fling — and that is what the engine's
gesture system is actually for. Callback names are passed straight
through rather than enumerated here, on the same reasoning as attributes
and events: which callbacks a recogniser reads belongs to the engine, so
its documentation stays this runtime's documentation.

Both are subpaths, because most apps need neither and the `.` entry is
byte-budgeted. A gesture callback goes through the worklet registry, not
`__AddEvent`'s trap in a second costume: a raw closure would be accepted
and then silently never invoked, on a device only.

The fake engine grows with them, since it is the reference implementation
the suite runs against — `__QuerySelector`, `__QuerySelectorAll`,
`__InvokeUIMethod`, `__GetTag`, `__GetAttributeByName`, and the two
gesture calls. Its selector matcher implements tag, id, class, compounds
and the descendant combinator, and **throws** on anything else rather
than reporting no match: the engine supports more than the fake does, and
"nothing matched" is indistinguishable from "not implemented" in exactly
the way that lets a test assert the opposite of the device. An
un-stubbed UI method fails for the same reason — the fake lays nothing
out, so it cannot honestly answer `boundingClientRect`.
The README called this "a regression, and the one worth fixing", and it
was right. The deleted `/animate` layer read the preference off its host
and skipped a non-essential timeline on its own, so honouring it cost an
app nothing. When that layer went so did the reading, and the
replacement — "pass no `transition` while the preference is set" — is
worse ergonomics for an accessibility feature, which is exactly the kind
of thing that quietly stops being done.

`reducedMotion()` is one signal with one home, and `RouteStack` consults
it above the transition seam. That placement is the part that matters: a
transition never has to check, including one this package never saw, and
an app gets the behaviour by stating the preference once rather than by
threading a prop through every animated component.

The app still supplies the value, because the engine offers nothing to
read. There is no reduced-motion field on `SystemInfo` in
`@lynx-js/types`, and Lynx has no media queries at all, so
`prefers-reduced-motion` does not exist either — the preference reaches
the host app natively and is passed in the way colour scheme is. The
runtime does not pretend otherwise; what it provides is the single place
to put it. The docs on the transition seam said the preference lives on
`SystemInfo`, which is not true of the types this package is built
against, and now say what is.

It defaults to false so an app that never calls `setReducedMotion`
behaves as it did before this existed, and it is read untracked at the
navigation, so a stack does not take a dependency on a signal that has
nothing to do with which screen is on top.
The README called this "the largest single piece of work outstanding",
and the reason it stayed outstanding is in its own description: a
recycler's "the engine owns the cell, you fill it" is a different
contract from the rest of this runtime. Every other creator takes an id;
`__CreateList` takes the callbacks the framework must implement, and the
engine drives reuse by calling back into them.

`recycle()` implements that side. The engine asks `componentAtIndex` for
the cell at a data index and gets back an element id; it hands one back
through `enqueueComponent` when it scrolls out. The framework keeps a
pool of the dozen or so elements the viewport needs and answers from it.
Ten thousand rows realise three elements, which is the assertion the
whole feature exists for.

The part worth writing down is how well this suits a signals runtime.
Every other framework has to REBUILD a recycled cell — ReactLynx
hydrates the pooled element tree against the new row's virtual output,
a diff per reuse per scroll frame. There is nothing to diff here. A cell
owns an `item` signal, and the bindings it built when it was created are
still attached to its elements, so reusing a cell for row 5,000 is one
signal write: the bindings that depend on it mutate exactly the
attributes that changed and nothing else is visited. "Created once,
mutated forever" turns out to be what a recycler wanted all along — the
pool is simply the set of elements that were created once. It is also
why `cell` receives getters rather than values, since a closure over the
row a cell happened to be built with would show row 0 forever.

`recycle` creates the list element rather than taking one, because a
recycling list must come from `__CreateList` and by the time a `ref`
could hand an element over it has already been created as something
else. Everything else about the list is spelled the engine's way and
applied through the ordinary prop path. A plain `<list>` is untouched.

The fake engine grows `__CreateList`, `__UpdateListCallbacks` and the
two driver methods — deliberately named `enterListItemAtIndex` and
`leaveListItem` after the pair in Lynx's own testing-library, so a test
here drives the recycler through the same sequence a device does. Its
`__FlushElementTree` now logs its arguments too, because a cell is
committed with `{ triggerLayout, operationID, elementID, listID }` and
the engine correlates on them: a commit missing them lays the cell out
at zero height on a device while looking perfectly correct in any tree
assertion. That is now asserted rather than assumed.
Four of the README's *Known gaps* are closed, so they stop being listed
as gaps and start being documented as features. What replaces them are
the limits the new code actually has, which are narrower and different
in kind: a recycled cell pool is per-shape rather than per-row, and a
move inside a recycling list is a remove plus an insert — correct, but
not minimal.

`docs/mini-lynx-style.md` gets the superseded banner it should have had
when the layer it documents was deleted. It describes the reset, the
token layer, the theme signal and the type scale in the present tense,
nothing links to it, and the only changeset that mentioned it described
work that no longer exists — so the one reader it was reaching was
someone taking it for documentation of the shipped package. It is kept
for the same reason the cross-platform note is (§3.5, on why the CSS
system colours were a web-only trick, is worth not re-deriving), and
`mini-lynx-runtime.md` now names both as the notes it replaces.

Also corrected: the README claimed `<list>` recycling was among the
things that "work without a release here". It was not — it is one of
exactly three capabilities where the engine calls the framework rather
than the other way round, and all three needed code. *Before you ship*
now lists all three together, since they share a failure shape: the tree
renders, the tests pass, and the device does nothing. The device
prototype that was already owed for events settles the gesture callbacks
too, since they go through the same worklet path.

AGENTS.md gains the invariant that matters most for anyone editing
`recycle/`: it is not a second reconciler and must not become one. It
owns no order and compares no trees. The moment it starts diffing
element trees to reuse a cell it has reimplemented hydration, and the
reason this package is small is gone.
…shim

A recycling list is the one place the engine calls the FRAMEWORK, so
nothing appears until something invokes `componentAtIndex`. The DOM
shim's `__CreateList` ignored its callbacks and just made an element,
which would have rendered an empty box and read as a bug in `/recycle`
rather than a gap in the preview.

It now drives the real protocol — `componentAtIndex`,
`enqueueComponent`, and the `operationID` the engine correlates on — off
a scroll listener and measured row offsets. The windowing maths is the
shim's own and says nothing about how Lynx virtualises; what it does
prove is the protocol, and the element count, which is the claim worth
demonstrating: ten thousand rows, a dozen elements.

Two real defects came out of wiring it up, both found because the
playground's screens test renders every screen and then tears the engine
down:

- **The shim ignored `__UpdateListCallbacks`.** That call exists exactly
  for teardown — the framework installs an inert pair when its list goes
  away — so a shim without it went on calling the original closures from
  the next scroll frame, building cells against a disposed pool and
  against whatever engine happened to be installed by then.
- **`recycle` trusted the engine to stop calling it.** Installing inert
  callbacks is necessary and not sufficient: the engine may be mid-scroll
  and is not obliged to honour the swap promptly. It now guards its own
  callbacks with a torn-down flag, so a late request answers -1 instead
  of building. A framework should not depend on the engine's timing to
  avoid using freed state.

`__SetAttribute` also serialises an object value as JSON rather than
letting `String(value)` flatten it to `[object Object]`, and the
inventory is handed to the recycler as the object the framework wrote —
on a device the engine receives the real thing, so the preview should
too.

The list screen's honesty panel said recycling was absent. That was true
of every panel and is now true of all but the last one, so it says which.
Main released while this branch was open: mini-lynx is published at
0.1.0, and PR #14 independently fixed the 1.0.0 version hazard this
branch opened by fixing — by the same route, replacing the stale
development changesets with one written for the release. That work is
now redundant here and the merge takes main's version of it.

What is left is this branch's actual contribution, so the changeset says
that instead: the four *Known gaps* closed on top of 0.1.0, riding a
minor to 0.2.0. Keeping the old "initial release" changeset would have
bumped 0.1.0 to 0.2.0 while restating a changelog entry that already
shipped.
@amritk
amritk merged commit 47c6bf9 into main Jul 28, 2026
2 checks passed
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.

2 participants