Early access

Native apps with Lynx

Write the same components you write for the web, and render them as real native UI on iOS and Android. The two examples below are running right here, in your browser, from the same source.

What Lynx is

Lynx is an open-source rendering engine from ByteDance, used in production in TikTok. It runs your UI code on a multithreaded engine and draws it with platform-native views on iOS, Android and HarmonyOS. The same bundle also runs on the web, through Lynx for Web.

Lynx is the renderer, not the programming model. @octanejs/lynx is Octane’s renderer for it: the components, hooks, context, Suspense and transitions you already know, compiled ahead of time and committed to Lynx’s element API instead of the DOM. There is no VDOM on either side of the boundary.

This is Lynx’s Product Gallery tutorial, ported to Octane. A two-column waterfall <list> recycles its cells, each card owns useState for its like button, and the scrollbar on the right is repositioned by a 'main thread' handler that never asks the background thread for anything.

It scrolls on its own because the example asks it to: ref.current.invoke('autoScroll', …) drives the native list, and the scrollbar you see tracking it is the main-thread handler doing its work every frame. Open the Device tab to run the same example on a phone.

Loading the example…

The template reads the way it would on the web. @for keys the cells, @if swaps the heart, and the scroll handler is an ordinary function marked to run on the other thread:

tsrx
export function Gallery({ pictureData }: { pictureData: Picture[] }) @{
	const scrollbarMTSRef = useMainThreadRef<object>();

	const onScrollMTS = (event: { detail: GalleryScrollDetail }) => {
		'main thread';
		const scrollbar = scrollbarMTSRef.current;
		if (scrollbar === null || scrollbar === undefined) return;
		__AddInlineStyle(scrollbar, 'top', scrollbarTop + 'px');
		__FlushElementTree();
	};

	<list list-type="waterfall" span-count={2} main-thread:bindscroll={onScrollMTS}>
		@for (const picture of pictureData; key picture.id) {
			<list-item item-key={picture.id}>
				<LikeImageCard picture={picture} />
			</list-item>
		}
	</list>
}

A carousel that never wakes the background thread

Lynx’s Product Detail tutorial, also ported to Octane. Touch tracking, the release animation and its easing curve all run on the main thread, so a frame never waits on a round trip. The indicator underneath is background state, updated across the thread boundary.

Loading the example…

Two threads, one component tree

Lynx runs two JavaScript environments. The main thread owns the element tree and the frames the user sees; the background thread owns state, effects, refs and everything that can afford a round trip. Octane compiles one authored entry into both graphs, so you write one component and mark the parts that must stay on the main thread:

tsrx
const onTouchMove = (event: MainThread.TouchEvent) => {
	'main thread';
	// Runs on the frame that produced the touch.
};

Everything else — useState, useEffect, context, Suspense, <Activity> — behaves the way it does in the browser, on the background thread. The differences from React that apply on the web apply here too; Lynx adds no second dialect.

Run it yourself

@octanejs/lynx and @octanejs/rspeedy-plugin are not published yet. Until they are, run the examples from a clone of the repository:

bash
git clone https://github.com/octanejs/octane.git
cd octane && pnpm install
pnpm --filter @octanejs/rspeedy-plugin exec rspeedy dev --root examples/gallery --environment lynx

Install Lynx Explorer on a phone or a simulator, then scan the QR code the dev server prints. Explorer is Lynx’s sandbox app: it loads a bundle over the network and runs it against the real native engine, so you see the same rendering a shipped app would.

An Octane Lynx application is an ordinary Rspeedy project with one plugin:

js
// lynx.config.mjs
import { defineConfig } from '@lynx-js/rspeedy';
import { pluginOctane } from '@octanejs/rspeedy-plugin';

export default defineConfig({
	environments: { lynx: {}, web: {} },
	source: { entry: { main: './src/index.ts' } },
	plugins: [pluginOctane()],
});

environments.lynx produces the native bundle Explorer loads. environments.web produces the Lynx-for-Web bundle the previews on this page mount, which is also how you put a Lynx screen in front of someone who has no app installed.

Where this stands

The renderer’s exact claims, divergences and open gates are tracked in packages/lynx/status.json, and both examples document what was checked on a device in their own READMEs. If you want to help close a gate, the open pull requests are the place to start.