Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/mini-lynx-production-readiness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
'@amritk/mini-lynx': minor
---

Close the gaps between "the tests pass" and "an app can ship this": errors after
construction, the one unverified engine assumption, and the platform values an
app cannot reach.

The three share a shape, and it is the shape of everything left on the list β€”
none of them is a missing feature. Each is a place where **this runtime is the
outermost JavaScript frame**, with native code or the job queue above it, so
there is no caller to hand a problem to and nothing above to notice one.

- **`setErrorHandler`.** `ErrorBoundary` covers construction, which is all a
component can throw during; it runs once and is finished. Everything after
that ran unguarded into a frame with no contract β€” a handler throwing inside
the engine's own dispatch is undefined behaviour that ranges from the rest of
the frame's listeners being skipped to the app going down, and a commit
throwing on the promise job queue is an unhandled rejection Lynx's main-thread
context has nobody listening for. Both are now caught and reported, with the
source (`'render' | 'event' | 'gesture' | 'commit' | 'lifecycle'`) attached,
because "something threw" and "the commit threw" lead to different
investigations. Handlers on one `(type, name)` are isolated from each other,
since a component and a `ref` did not choose to share a dispatcher; a failed
commit does not wedge the scheduler, so the next mutation recovers the screen;
and `/recycle`'s `componentAtIndex` answers the engine's own `-1` rather than
unwinding into list layout mid-scroll. With no handler installed it warns, so
a mistake is visible in development with no setup. It does not rethrow, which
is the deliberate part: there is nowhere useful to throw *to*.
- **`setEventTransport`, and `/bridge`.** Event delivery is the one assumption
in this package a device could still disprove β€” the engine hands back a token
this framework made, and that it does so untouched is read from the engine's
source rather than confirmed on hardware. The failure would be total and
silent: the tree renders, nothing responds to touch. So the listener form is
now a seam with the worklet transport as its default, and `@amritk/mini-lynx/bridge`
ships the fallback the design note has always named β€” string handler names,
with `dispatchNamedEvent` for the app's forwarder to call once the event has
crossed back from the background thread. A device disagreeing is a startup
line rather than a fork. The cost is stated where it is chosen: a thread hop,
and with it the property that a handler runs in the gesture's own frame.
- **`globalProps()`.** Colour scheme, locale, flags and the reduced-motion
preference arrive from native twice β€” as `lynx.__globalProps` at startup, and
thereafter through the engine calling a global `updateGlobalProps`. An app can
read the first on its own but cannot usefully own the second: the engine calls
exactly one function, so a component reacting to a theme change would be
hoping it was the one that got there. `renderPage` claims the slot and turns
it into a signal, which also makes the reduced-motion line a one-off rather
than a subscription the app maintains.

`renderPage` also emits `firstScreen` even when the root component throws. That
event is the platform's cue to stop waiting rather than a report of success, and
a blank screen with a crash report is strictly better than a splash screen that
never dismisses and says nothing.

The core's gzipped budget moves 5064 β†’ 5414 to cover all of it. None of it is
addable from outside the package, and none of it runs on a day when nothing goes
wrong.
23 changes: 19 additions & 4 deletions docs/mini-lynx-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,25 @@ the main thread. That is the single most load-bearing inference in this change,
and it is worth flagging its status: the engine-side mechanism is read from the
engine's source, but a framework-defined token has not been round-tripped on a
physical device by this package. **Prototype it on a device before shipping
anything that depends on it.** If it fails, the fallback is contained and
already understood β€” register string handlers and own the receiving end by
assigning `lynxCoreInject.tt.publishEvent`, exactly as ReactLynx does, at the
cost of a thread hop.
anything that depends on it.**

The fallback is no longer only understood β€” it is shipped. `events/transport.ts`
makes the listener form a seam, `/bridge` implements the string-handler
alternative, and the two together turn "this package is wrong about the engine"
from a fork into a startup line:

```ts
setEventTransport(namedHandlerTransport)
```

What the runtime cannot supply is the wire back. A string handler is routed to
the **background** thread, and this runtime is on the main one, so the event
arrives in the other context and the app forwards it β€” through
`lynxCoreInject.tt.publishEvent` and a context message, exactly as ReactLynx
does, at the cost of a thread hop. That half depends on the engine version and
on how a bundle is split, which is precisely why it is the app's and not a guess
made here. The property lost with it is the one Β§4 calls the gift: a handler no
longer runs in the same frame as the gesture.

The fake engine **throws** on a function listener rather than accepting one,
mirroring `@lynx-js/testing-environment`. A test should fail where a device
Expand Down
55 changes: 48 additions & 7 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,45 @@ Two things follow that an app has to know:
> engine-side mechanism is read from the engine's source, but a
> *framework-defined* token has not been round-tripped on a physical device by
> this package. **Prototype events on a device before shipping anything that
> depends on them.** If it fails the fallback is contained β€” string handlers
> plus `lynxCoreInject.tt.publishEvent`, exactly as ReactLynx does, at the cost
> of a thread hop.
> depends on them.** If it fails, the recovery is a config line rather than a
> fork: `setEventTransport(namedHandlerTransport)` from
> `@amritk/mini-lynx/bridge` binds string handlers instead, with the app
> forwarding them back through `dispatchNamedEvent` β€” exactly the
> `lynxCoreInject.tt.publishEvent` arrangement ReactLynx uses, at the cost of a
> thread hop.

## Errors after the build go to `setErrorHandler`

`ErrorBoundary` catches construction, which is all a component can throw
during β€” it runs once. After that the runtime is the outermost JavaScript frame
and native code is above it, so a throw in a handler or in the scheduled commit
is caught, reported, and not propagated:

```ts
setErrorHandler((error, source) => report(error, source))
// source: 'render' | 'event' | 'gesture' | 'commit' | 'lifecycle'
```

With no handler installed it warns. Do not wrap handler bodies in `try`/`catch`
to keep the app alive β€” that is already the behaviour; wrap them only when the
failure means something specific to that screen.

## Platform values are `globalProps()`

Colour scheme, locale, flags and the reduced-motion preference come from native,
and `renderPage` claims the `updateGlobalProps` slot the engine pushes them
through, so they are a signal:

```tsx
const Screen = () => <view class={() => `screen ${globalProps<{ theme: string }>().theme}`} />

// Reduced motion is the one the runtime consults itself, via RouteStack:
effect(() => setReducedMotion(globalProps<{ reduceMotion?: boolean }>().reduceMotion === true))
```

Do not define `updateGlobalProps` yourself β€” the engine calls exactly one
function for it, and defining a second one silently wins or silently loses
depending on load order.

## Element props

Expand Down Expand Up @@ -339,12 +375,17 @@ Mutations are committed once per tick: a hundred signal writes cost one
| `@amritk/mini-lynx/router` | `createRouter`, `createMemoryHistory`, `RouteView`, `RouteLink`, `matchRoute`, `parseQuery`. Matching is pure arithmetic; history is a seam, and memory is the default because a stack of screens the app holds is genuinely what navigation is here |
| `@amritk/mini-lynx/forms` | `createForm` / `Field` / `bindField` / `schemaToValidator`. Which binding a field gets is decided by the type of its **initial value**, not by the element |
| `@amritk/mini-lynx/query` | `createQuery` over `@tanstack/query-core` β€” the same API as `@amritk/mini`'s. Mind the main-thread note above |
| `@amritk/mini-lynx/elements` | `querySelector`, `querySelectorAll`, `invoke` β€” the engine's UI methods, which are actions rather than attributes |
| `@amritk/mini-lynx/gestures` | `setGestureDetector`, `GestureType` β€” recogniser *arbitration*, the part ordinary events cannot express |
| `@amritk/mini-lynx/recycle` | `recycle` β€” `<list>`'s cell recycler. A cell receives **getters**, because it outlives the row it was built for |
| `@amritk/mini-lynx/bridge` | `namedHandlerTransport`, `dispatchNamedEvent` β€” the fallback event transport. Only if the worklet round trip fails on a device |
| `@amritk/mini-lynx/testing` | `createFakeEngine`, `serializeTree` β€” a complete in-memory PAPI |

There is no `/ui`, no `/platform`, no `/gestures`, no `/animate` and no host
subpath. Components are Lynx elements and CSS; environment questions go to
`SystemInfo` and `globalProps`; gestures and animation belong to the engine
(`@keyframes`, transitions, `element.animate()`).
There is no `/ui`, no `/platform`, no `/animate` and no host subpath. Components
are Lynx elements and CSS; environment questions go to `SystemInfo` and
`globalProps()`; animation belongs to the engine (`@keyframes`, transitions,
`element.animate()`). `/gestures` is not a gesture *recogniser* library β€” the
engine recognises, and that subpath only composes recognisers.

## Testing with `@amritk/mini-lynx/testing`

Expand Down
43 changes: 43 additions & 0 deletions packages/mini-lynx/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,15 @@ src/
resolve-class.ts A ClassValue β†’ the single space-joined string __SetClasses wants
on-cleanup.ts Teardown registered against the enclosing scope
warn.ts Recoverable-mistake reporting, without assuming a console
report-error.ts setErrorHandler + guard β€” what the engine-facing boundaries catch into
global-props.ts The platform's pushed values, as one signal
engine/
element-api.ts LynxElementApi β€” the whole platform boundary, as a type
current-engine.ts setEngine / requireEngine / scheduleFlush / globalEngine
index.ts The `/engine` subpath: the boundary on its own
events/
worklet-registry.ts Handle→closure table + the `runWorklet` global the engine calls
transport.ts Which listener form `__AddEvent` is given β€” the one replaceable engine bet
style/
apply-style.ts The style channel, and the visibility that shares it
to-css-name.ts A style key β†’ its CSS spelling
Expand All @@ -77,6 +80,7 @@ src/
elements/ querySelector/querySelectorAll and invoke β€” the engine's UI methods
gestures/ setGestureDetector β€” recogniser composition, the part events cannot express
recycle/ recycle β€” <list>'s cell recycler, the one inverted-ownership path here
bridge/ The fallback event transport: string handlers, with the app owning the wire
examples/
js-framework-benchmark/ The keyed benchmark; `bun run bench:reconciler` times it
```
Expand Down Expand Up @@ -222,6 +226,38 @@ either.
for you.
- **No raw-markup sink, ever.** There is no `innerHTML` equivalent anywhere on
this boundary, so bound data cannot inject elements.
- **Nothing the engine calls may throw back into it.** Four places in this
package are entered from native or from the job queue rather than from an app's
own call stack: the `runWorklet` dispatch behind every event and gesture,
`/recycle`'s `componentAtIndex` and `enqueueComponent`, the scheduled commit,
and the lifecycle slots in `entry.ts`. An exception leaving any of them unwinds
somewhere with no defined behaviour β€” on a device that ranges from the rest of
a frame's listeners being skipped to the app going down, and on the job queue
it is an unhandled rejection nothing is listening for. Each one wraps its body
in `guard(...)` from `report-error.ts`, which reports and carries on;
`componentAtIndex` additionally answers `-1`, the engine's own "nothing here".
**Any new engine-called callback owes the same wrapper.** Reporting rather than
rethrowing is the deliberate part: there is nowhere useful to throw *to*, and
a swallow would be the silent failure this package works hardest to avoid.
`ErrorBoundary` is unaffected β€” it still owns construction, which is the only
thing that has a build to unwind.
- **The listener form is a seam, not a constant.** `add-event.ts` asks
`events/transport.ts` what to hand `__AddEvent` rather than deciding.
That exists because the worklet round-trip is the one engine assumption here
that hardware could still disprove, and the failure is total and silent β€” so
the recovery has to be a startup line rather than a fork. Do not inline the
worklet handle back into `add-event.ts`, and do not add a second call site for
`registerWorklet` outside the default transport and `/gestures` (whose
callbacks the engine will only take as worklets, so they have no fallback).
- **A global the engine calls by name has exactly one owner.** `runWorklet`,
`renderPage`, `removeComponents` and `updateGlobalProps` are all "the engine
calls this function", so whoever assigns last wins and everyone else is
silently gone. This package claims them in one place each β€” the registry for
the first, `entry.ts` for the rest β€” and `updateGlobalProps` is why
`global-props.ts` exists at all: an app cannot own that slot AND let a
component react to a change. `runWorklet` is the one that chains to whatever
was there before, because a page mid-migration may legitimately run two
frameworks.
- **Anything that builds a subtree LATER must restore the context frame.**
`renderChild`, `list` and `ErrorBoundary`'s retry capture `currentFrame()` when
they are called β€” during the component body, inside whatever provider wraps it
Expand Down Expand Up @@ -311,6 +347,13 @@ That independence has a cost: **a defect found in one is usually latent in the
other.** Both gotchas above were found here and then fixed there. When you fix a
bug in this package, go read `../mini/AGENTS.md` and look for the same shape.

The error-reporting seam is the one place that shape does NOT apply, and it is
worth knowing why before porting it across: on the web a throw in a handler
reaches `window.onerror` and a rejected commit reaches `unhandledrejection`, both
of which are defined behaviour with somewhere to report to. Here the frame above
is native and there is no such contract, which is why the guards exist in this
package and not in `mini`.

One thing is deliberately borrowed rather than duplicated: the called-signal
scanner. `@amritk/mini`'s is purely syntactic and does not know which runtime
the JSX belongs to, so it already catches the identical mistake here β€” which is
Expand Down
55 changes: 48 additions & 7 deletions packages/mini-lynx/AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,45 @@ Two things follow that an app has to know:
> engine-side mechanism is read from the engine's source, but a
> *framework-defined* token has not been round-tripped on a physical device by
> this package. **Prototype events on a device before shipping anything that
> depends on them.** If it fails the fallback is contained β€” string handlers
> plus `lynxCoreInject.tt.publishEvent`, exactly as ReactLynx does, at the cost
> of a thread hop.
> depends on them.** If it fails, the recovery is a config line rather than a
> fork: `setEventTransport(namedHandlerTransport)` from
> `@amritk/mini-lynx/bridge` binds string handlers instead, with the app
> forwarding them back through `dispatchNamedEvent` β€” exactly the
> `lynxCoreInject.tt.publishEvent` arrangement ReactLynx uses, at the cost of a
> thread hop.

## Errors after the build go to `setErrorHandler`

`ErrorBoundary` catches construction, which is all a component can throw
during β€” it runs once. After that the runtime is the outermost JavaScript frame
and native code is above it, so a throw in a handler or in the scheduled commit
is caught, reported, and not propagated:

```ts
setErrorHandler((error, source) => report(error, source))
// source: 'render' | 'event' | 'gesture' | 'commit' | 'lifecycle'
```

With no handler installed it warns. Do not wrap handler bodies in `try`/`catch`
to keep the app alive β€” that is already the behaviour; wrap them only when the
failure means something specific to that screen.

## Platform values are `globalProps()`

Colour scheme, locale, flags and the reduced-motion preference come from native,
and `renderPage` claims the `updateGlobalProps` slot the engine pushes them
through, so they are a signal:

```tsx
const Screen = () => <view class={() => `screen ${globalProps<{ theme: string }>().theme}`} />

// Reduced motion is the one the runtime consults itself, via RouteStack:
effect(() => setReducedMotion(globalProps<{ reduceMotion?: boolean }>().reduceMotion === true))
```

Do not define `updateGlobalProps` yourself β€” the engine calls exactly one
function for it, and defining a second one silently wins or silently loses
depending on load order.

## Element props

Expand Down Expand Up @@ -189,12 +225,17 @@ Mutations are committed once per tick: a hundred signal writes cost one
| `@amritk/mini-lynx/router` | `createRouter`, `createMemoryHistory`, `RouteView`, `RouteLink`, `matchRoute`, `parseQuery`. Matching is pure arithmetic; history is a seam, and memory is the default because a stack of screens the app holds is genuinely what navigation is here |
| `@amritk/mini-lynx/forms` | `createForm` / `Field` / `bindField` / `schemaToValidator`. Which binding a field gets is decided by the type of its **initial value**, not by the element |
| `@amritk/mini-lynx/query` | `createQuery` over `@tanstack/query-core` β€” the same API as `@amritk/mini`'s. Mind the main-thread note above |
| `@amritk/mini-lynx/elements` | `querySelector`, `querySelectorAll`, `invoke` β€” the engine's UI methods, which are actions rather than attributes |
| `@amritk/mini-lynx/gestures` | `setGestureDetector`, `GestureType` β€” recogniser *arbitration*, the part ordinary events cannot express |
| `@amritk/mini-lynx/recycle` | `recycle` β€” `<list>`'s cell recycler. A cell receives **getters**, because it outlives the row it was built for |
| `@amritk/mini-lynx/bridge` | `namedHandlerTransport`, `dispatchNamedEvent` β€” the fallback event transport. Only if the worklet round trip fails on a device |
| `@amritk/mini-lynx/testing` | `createFakeEngine`, `serializeTree` β€” a complete in-memory PAPI |

There is no `/ui`, no `/platform`, no `/gestures`, no `/animate` and no host
subpath. Components are Lynx elements and CSS; environment questions go to
`SystemInfo` and `globalProps`; gestures and animation belong to the engine
(`@keyframes`, transitions, `element.animate()`).
There is no `/ui`, no `/platform`, no `/animate` and no host subpath. Components
are Lynx elements and CSS; environment questions go to `SystemInfo` and
`globalProps()`; animation belongs to the engine (`@keyframes`, transitions,
`element.animate()`). `/gestures` is not a gesture *recogniser* library β€” the
engine recognises, and that subpath only composes recognisers.

## Testing with `@amritk/mini-lynx/testing`

Expand Down
Loading
Loading