Early access
Native apps with Lynx
Build with familiar Octane components, then render them with native views on iOS and Android. The examples below use that same code and run here in the browser with Lynx for Web.
How Lynx and Octane work together
Lynx is an open-source UI renderer from ByteDance and is used in TikTok. It runs application code across multiple threads and renders with native views on iOS, Android and HarmonyOS. Lynx for Web can run the same bundle in a browser.
Octane provides the programming model. @octanejs/lynx connects Octane to Lynx, so you
can keep using components, hooks, context, Suspense and transitions. The compiler
targets Lynx’s element API instead of the DOM, with no virtual DOM in between.
Image gallery with a main-thread scrollbar
This example brings Lynx’s Product Gallery tutorial to Octane. It combines a two-column image gallery with a like button on every card and a custom scrollbar. Each like button keeps its own state, and Lynx reuses off-screen list items instead of rendering every card at once.
The gallery scrolls automatically and reverses direction when it reaches the top or bottom. The custom scrollbar follows that movement on the main thread, keeping it in sync frame by frame. Open the Device tab to run the same example on a phone.
The component looks much like an Octane component for the web. @for renders the cards,
@if changes the heart icon when a card is liked, and a regular function keeps the
scrollbar moving on the main thread:
export function Gallery({ pictureData }: { pictureData: Picture[] }) @{
const scrollbarMTSRef = useMainThreadRef<object>();
const galleryMTSRef = useMainThreadRef<object>();
const galleryRef = useRef<LynxPublicHandle | null>(null);
const onScrollMTS = (event: { detail: GalleryScrollDetail }) => {
'main thread';
const scrollbar = scrollbarMTSRef.current;
const gallery = galleryMTSRef.current;
if (!scrollbar || !gallery) return;
const listHeight = SystemInfo.pixelHeight / SystemInfo.pixelRatio - 48;
const scrollbarTop = listHeight * (event.detail.scrollTop / event.detail.scrollHeight);
__AddInlineStyle(scrollbar, 'top', scrollbarTop + 'px');
__FlushElementTree();
const atBottom = event.detail.scrollTop + listHeight >= event.detail.scrollHeight;
if (event.detail.deltaY > 0 && atBottom) {
__InvokeUIMethod(
gallery,
'autoScroll',
{ rate: '-60', start: true, autoStop: true },
() => {},
);
} else if (event.detail.deltaY < 0 && event.detail.scrollTop <= 0) {
__InvokeUIMethod(
gallery,
'autoScroll',
{ rate: '60', start: true, autoStop: true },
() => {},
);
}
};
useEffect(() => {
void galleryRef.current?.invoke('autoScroll', {
rate: '60',
start: true,
autoStop: true,
});
}, []);
<list
ref={galleryRef}
main-thread:ref={galleryMTSRef}
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>
}Product carousel animated on the main thread
This example brings Lynx’s Product Detail tutorial to Octane. Drag tracking and the release and snap animations run on the main thread, so they do not have to wait for the background thread. The background thread keeps track of the current image and updates the indicator below the carousel.
The browser preview moves through all eight images automatically, then changes direction
at each end. The panel opens preview.json, where that sequence is defined.
Two threads, one component tree
Lynx uses two JavaScript environments. The main thread controls the native element tree and the work that must happen within a frame. The background thread handles state, effects, refs and most application logic. Octane compiles one component for both environments; you mark only the parts that need to run on the main thread:
const onTouchMove = (event: MainThread.TouchEvent) => {
'main thread';
// Runs on the frame that produced the touch.
};The rest of the component can use useState, useEffect, context, Suspense and
<Activity> just as it does on the web. These APIs run on the background thread without
changing how you write the component. Octane’s usual
differences from React still apply; Lynx does not add
another component syntax.
Run it yourself
@octanejs/lynx and @octanejs/rspeedy-plugin are not published yet. For now, run the
examples from a clone of the repository:
git clone https://github.com/octanejs/octane.git
cd octane && pnpm install
pnpm --filter @octanejs/rspeedy-plugin exec rspeedy dev --root examples/gallery --environment lynxInstall Lynx Explorer on a phone or simulator, then scan the QR code printed by the development server. Lynx Explorer loads the bundle and runs it with the native engine, so you can test the example on a device.
An Octane application for Lynx uses a standard Rspeedy configuration with the Octane plugin:
// 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 creates the native bundle for Lynx Explorer. environments.web
creates the Lynx-for-Web bundle used by the previews on this page. Keeping both lets you
test the same screen on a device and share it in a browser.
Current status
For current support details and known gaps, see
packages/lynx/status.json.
Each example README also records what has been tested on a device. If you would like to
help, start with the open pull requests.