Main Thread Script

The Main Thread Script is a JS script that can be executed on the main thread. The most common use cases are smooth animations and gesture handling. It addresses the response delay inherent in Lynx's multi-threaded architecture, aiming to achieve a near-native interactive experience.

Event Response Delay in Lynx

Here is a simple animation: a small square that moves in sync with a scroll-view. In the component, we listen to the scroll event, retrieve the current scroll position from the event parameters, and update the square's position immediately:

You can try scrolling the scroll-view on the left side of the example. The blue square on the right will follow the scroll-view's movement. However, you might notice that its movement has an unpredictable delay, especially on devices with lower performance. This delay also increases as the complexity of the page increases.

This is because in Lynx's architecture, events are triggered on the main thread, while regular JS event handlers (Vue's @scroll, etc.) can only be executed on background threads. Therefore, using regular events to trigger animations introduces multiple thread crossings, resulting in untimely responses and animations lagging behind gestures.

  Without Main Thread Script:

  ┌────────────────┐      ┌────────────────────┐      ┌────────────────┐
  │  Main Thread   │      │ Background Thread   │      │  Main Thread   │
  │  (event fires) │ ──▶  │ (Vue handler runs)  │ ──▶  │  (render)      │
  └────────────────┘      └────────────────────┘      └────────────────┘
          ▲                                                    │
          └─────────────── 2 thread crossings ─────────────────┘

The main thread script provides the capability to handle events synchronously on the main thread, ensuring synchronous event responses.

  With Main Thread Script:

  ┌────────────────────────────────────────────────────────────┐
  │                       Main Thread                          │
  │   event fires  ──▶  handler runs  ──▶  render              │
  └────────────────────────────────────────────────────────────┘
                      0 thread crossings

Use Main Thread Functions to Eliminate Event Response Delay

Implementing Animations with Main Thread Script

Synchronizing events using main thread script is very simple. Here we try to modify the previous example.

First, we inform the framework that we want to handle this event on the main thread by using the main-thread-bindscroll attribute instead of @scroll:

<scroll-view :main-thread-bindscroll="onScroll" />

Since onScroll is now a main thread event handler, we also need to declare it as a main thread function. This is done by adding a 'main thread' directive as the first line inside the function body:

const onScroll = (event) => {
  'main thread'
  // ...
}

After declaring it as a main thread function, we can no longer call it from the background thread.

Finally, we can now directly manipulate the element's properties on the main thread, so there's no need to use a reactive ref to change the position. When using a main thread function as an event handler, we can obtain a reference to the target element using useMainThreadRef() and access it via .current inside the main thread function. This object allows you to synchronously get and set node properties, such as using setStyleProperty() in the example:

<script setup lang="ts">
import { useMainThreadRef } from 'vue-lynx'

const mtDraggableRef = useMainThreadRef(null)

const onScroll = (event: { detail?: { scrollTop?: number } }) => {
  'main thread'
  const scrollTop = event.detail?.scrollTop ?? 0
  const el = (mtDraggableRef as unknown as {
    current?: { setStyleProperty?(k: string, v: string): void }
  }).current
  if (el?.setStyleProperty) {
    el.setStyleProperty('transform', `translate(0px, ${500 - scrollTop}px)`)
  }
}
</script>

<template>
  <scroll-view :main-thread-bindscroll="onScroll">
    <!-- ... -->
  </scroll-view>
  <view :main-thread-ref="mtDraggableRef">
    <!-- ... -->
  </view>
</template>

That's all the changes needed. The example below places the components before and after the modification side by side for comparison. You may notice that the animation delay has disappeared!

Retrieving Data from the Background Thread

You may have noticed that designating a function as a main thread function isolates it from its surrounding context, making it feel like an "island." Its runtime environment is different from other functions, meaning it cannot freely communicate with the background thread.

However, obtaining data from the background thread inside a main thread function is straightforward: just use it directly, as if it were a normal function.

<script setup lang="ts">
const red = 'red'

const addBackgroundColor = (event: MainThread.ITouchEvent) => {
  'main thread'
  event.currentTarget.setStyleProperty('background-color', red)
}
</script>

<template>
  <view :main-thread-bindtap="addBackgroundColor">
    <text>Hello World!</text>
  </view>
</template>

When the main thread function is defined, it automatically captures external variables from the background thread, such as the red variable in the example above. However, you cannot directly modify the values in the background thread.

The values captured by the main thread function are not updated in real time. Instead, they are synchronized from the background thread to the main thread only after the component containing the main thread function re-renders. Additionally, the synchronization requires that the captured values be serializable using JSON.stringify().

To summarize the precautions:

  • Main thread functions can and must only run on the main thread. Main thread functions can call each other.
  • Captured variables need to be passed between threads using JSON.stringify(), so they must be serializable to JSON.
  • Main thread functions do not support nested definitions.
  • You cannot modify variables captured from the external scope within a main thread function.

Using main-thread-ref to Obtain Node Objects

In the example above, clicking on the view would change its background color. If we want to change the color of only a specific child element, it is not easy to achieve with just event.target and event.currentTarget. In this case, you can use main-thread-ref to obtain a node object usable on the main thread.

Create a MainThreadRef using the useMainThreadRef() composable, and then assign it to the target node's main-thread-ref attribute:

<script setup lang="ts">
import { useMainThreadRef } from 'vue-lynx'

const red = 'red'
const textRef = useMainThreadRef(null)

const addBackgroundColor = (event: MainThread.ITouchEvent) => {
  'main thread'
  const el = (textRef as unknown as {
    current?: { setStyleProperty?(k: string, v: string): void }
  }).current
  el?.setStyleProperty?.('background-color', red)
}
</script>

<template>
  <view :main-thread-bindtap="addBackgroundColor">
    <text :main-thread-ref="textRef">Hello World!</text>
    <text>Hello World!</text>
  </view>
</template>

Note that the current property of MainThreadRef can only be accessed within a main thread function.

Maintaining State in Main Thread Functions

Main thread functions cannot modify captured variables. Therefore, if you need to maintain state between main thread functions, you should use MainThreadRef:

<script setup lang="ts">
import { useMainThreadRef } from 'vue-lynx'

const countRef = useMainThreadRef(0)

const handleTap = (event: MainThread.ITouchEvent) => {
  'main thread'
  event.currentTarget.setStyleProperty(
    'background-color',
    ++countRef.current % 2 ? 'blue' : 'green'
  )
}
</script>

<template>
  <view :main-thread-bindtap="handleTap">
    <text>Tap me!</text>
  </view>
</template>

Cross-Thread Function Calls

The examples so far use 'main thread' functions as event handlers. But what if you need to call a main thread function from the background thread, or vice versa? Vue Lynx provides runOnMainThread() and runOnBackground() for bidirectional async communication.

In this example, tapping the box triggers a round trip:

  1. Main Thread onTap fires, calls runOnBackground(incrementCount)() to update reactive state
  2. Background Thread watch(count) fires, calls runOnMainThread(applyColor)(nextColor) to change the box color

Asynchronously Invoking Main Thread Functions from the Background Thread

Use runOnMainThread() in the background thread to asynchronously execute a main thread function on the main thread:

<script setup lang="ts">
import { useMainThreadRef, runOnMainThread } from 'vue-lynx'

const countRef = useMainThreadRef(0)

const addCount = (value: number) => {
  'main thread'
  countRef.current += value
  return countRef.current
}

async function increaseMainThreadCount() {
  const result = await runOnMainThread(addCount)(1)
  console.log(result)
}
</script>

Asynchronously Invoking Non-Main Thread Functions from the Main Thread

Use runOnBackground() on the main thread to asynchronously execute a regular function on the background thread:

<script setup lang="ts">
import { ref } from 'vue'
import { runOnBackground } from 'vue-lynx'

const current = ref(0)

const syncIndexToBackground = runOnBackground((nextIndex: number) => {
  current.value = nextIndex
  return `Current index updated to ${nextIndex}`
})

const onScrollMainThread = async (event: { detail: { scrollTop: number } }) => {
  'main thread'
  const index = Math.round(event.detail.scrollTop / 300)
  const result = await syncIndexToBackground(index)
  console.log(result)
}
</script>

<template>
  <view>
    <scroll-view :main-thread-bindscroll="onScrollMainThread" />
    <text>Current index: {{ current }}</text>
  </view>
</template>

Cross-Thread Shared Modules

By default, main thread functions cannot directly call plain functions that do not have the 'main thread' directive. This makes code reuse difficult. To address this limitation, Vue Lynx supports the shared-module mechanism, allowing you to explicitly declare certain modules as shareable between the main thread and the background thread.

Add with { runtime: 'shared' } after the import statement, and the exported variables (including functions, classes, objects, etc.) in that module can be directly called in main thread functions:

import { func } from './utils' with { runtime: 'shared' }

const onTap = () => {
  'main thread'
  func() // ✅ Allows calling plain functions in a main thread function
}
NOTE

Functions in modules imported with with { runtime: 'shared' } can be called by main thread functions, but they are not automatically treated as main thread functions. If they use main-thread-specific capabilities like MainThreadRef, they must still be manually marked with 'main thread'.

The shared module is a regular TypeScript/JavaScript file — it does not need a 'main thread' directive. It must contain only plain functions and constants (no Vue reactivity, no DOM access, no side effects that depend on a specific thread).

// color-utils.ts — shared between both threads
const COLORS = ['#4FC3F7', '#81C784', '#FFB74D', '#E57373', '#BA68C8']

export function getNextColor(index: number): string {
  return COLORS[index % COLORS.length]!
}

Once imported with { runtime: 'shared' }, the exported functions can be called both inside 'main thread' functions and in regular background thread code:

<script setup lang="ts">
import { ref, computed } from 'vue'
import { useMainThreadRef } from 'vue-lynx'
import { getNextColor } from './color-utils' with { runtime: 'shared' }

// Background Thread: use shared function in computed/watch
const count = ref(0)
const currentColor = computed(() => getNextColor(count.value))

// Main Thread: use the same shared function in a tap handler
const boxRef = useMainThreadRef(null)
const onTap = () => {
  'main thread'
  const color = getNextColor(1)
  const el = (boxRef as unknown as {
    current?: { setStyleProperty?(k: string, v: string): void }
  }).current
  el?.setStyleProperty?.('background-color', color)
}
</script>

Referencing Third-Party Libraries

Usually, third-party libraries (e.g., motion-dom) contain plain JavaScript functions. If called directly in a main thread function, they will report an error due to the missing 'main thread' directive.

We can use with { runtime: 'shared' } to import them as shared modules:

// Import and use directly
import { animate } from 'motion-dom' with { runtime: 'shared' }

const onScroll = () => {
  'main thread'
  // Can be called directly in a main thread function
  animate(element, { opacity: 0 })
}

To facilitate reuse or avoid the limitations below (e.g., losing the shared characteristic after assignment), we recommend wrapping the import in a main thread function:

// src/utils/motion.ts
// 1. Import the original function as shared
import { animate as _animate } from 'motion-dom' with { runtime: 'shared' }

// 2. Export a new main thread function for encapsulation
export function animate(...args) {
  'main thread'
  return _animate(...args)
}

In this way, animate becomes a standard main thread function that can be freely used in any main thread function, no longer restricted by static analysis:

<script setup lang="ts">
import { animate } from './utils/motion'

const onTap = () => {
  'main thread'
  // Standard main thread function — can be assigned or passed freely
  const myAnimate = animate
  myAnimate(element, { x: 100 })
}
</script>

Limitations

Only identifiers directly imported via import are recognized as "shared". Assigning to a new variable will cause it to lose the shared characteristic:

import { func } from './utils' with { runtime: 'shared' }
const anotherFunc = func // [!code error]

const onTap = () => {
  'main thread'
  anotherFunc() // ❌ Cannot be analyzed at compile time, call fails
}

State Isolation

Variables and state in a shared module are completely isolated between the two threads, each possessing independent instances.

This means that if you modify a variable in a shared module on the main thread, the background thread cannot perceive it, and vice versa. Shared modules solve the "code sharing" problem, not the "state sharing" problem.