Main-thread scripts

A main-thread script restyles an element on every touch or scroll event without running Angular's change detection. Use one for drags, scroll-linked effects and other work that must keep up with a finger. It's a function that runs on the main thread with a direct handle to the native element (see Glossary).

What they add

Your template event handlers, such as (bindtap), already run on the main thread, inside the native event (see Threads). So a main-thread script doesn't save a trip between threads. It gives you four other things:

  • No change detection per event. A (bindtouchmove) handler marks its component for checking, so Angular runs change detection after every move. A main-thread script changes the element itself, and Angular does no work.
  • An element handle. In a main-thread script, event.currentTarget and event.target are MainThread.Element objects with setStyleProperty(), setAttribute(), querySelector(), animate() and invoke(). In a template handler, $event.currentTarget is plain data.
  • Results from element methods. invoke() calls a UI method and returns a Promise of its result, such as the element's size and position.
  • A way to the background thread. runOnBackground() calls a function on the background thread, which has APIs the main thread lacks.

Bind a script to an event

src/app/app.ts
import { Component } from '@angular/core';
import {
  LYNX_ELEMENTS,
  LynxMainThreadEvent,
  mainThreadFn,
  type MainThread,
} from '@blotch/angular-lynx';

// Fades the card as the finger moves right, on every touchmove.
const fade = mainThreadFn((event: MainThread.TouchEvent) => {
  const touch = event.touches[0];
  if (!touch) return;
  const opacity = Math.max(0.3, Math.min(1, touch.clientX / 300));
  event.currentTarget.setStyleProperty('opacity', String(opacity));
});

const reset = mainThreadFn((event: MainThread.TouchEvent) => {
  event.currentTarget.setStyleProperty('opacity', '1');
});

@Component({
  selector: 'app-root',
  imports: [LYNX_ELEMENTS, LynxMainThreadEvent],
  template: `
    <view
      [mainThreadBindtouchmove]="fade"
      [mainThreadBindtouchend]="reset"
      style="height: 120px; background-color: #3b82f6;"
    >
      <text style="color: white;">Drag across me</text>
    </view>
  `,
})
export class App {
  readonly fade = fade;
  readonly reset = reset;
}
  • mainThreadFn() registers the function on the main thread and returns a handle.
  • LynxMainThreadEvent provides the mainThreadBind* and mainThreadCatch* inputs. Bind them with square brackets: you pass the handle in, you don't listen with ( ).
  • catch instead of bind stops the event bubbling, as with template events (see Handling taps and events).
  • Style changes are batched. AngularLynx draws them once, after your function returns. Property names are kebab-case and values are strings.

The inputs cover tap, touchstart, touchmove, touchend, mousedown, mousemove, mouseup, longpress and scroll. animationstart, animationend, transitionend and layoutchange have a bind form only. The LynxMainThreadEvent reference lists them all.

A desktop browser fires mouse events, not touch events. To drag in the web preview, bind mainThreadBindmousemove as well. Phones never fire it, so binding both is safe.

Use the 'main thread' directive

Instead of calling mainThreadFn(), make 'main thread' the first statement of the function:

import type { MainThread } from '@blotch/angular-lynx';

const reset = (event: MainThread.TouchEvent) => {
  'main thread';
  event.currentTarget.setStyleProperty('opacity', '1');
};

The build wraps the function in mainThreadFn() and adds the import. Both forms behave the same.

  • Arrow functions and function expressions with a block body are wrapped. 'main-thread' works too.
  • Function declarations and class methods aren't. Write const name = () => { 'main thread'; … } instead.

Define scripts at module level

Put main-thread scripts at the top level of a file, as above. A script is registered once, when its file loads. Both copies of your app load files in the same order, so the script gets the same ID on both threads. A call from the background thread needs that ID.

A class property, such as readonly fade = (event) => { 'main thread'; … }, works for a [mainThreadBind*] input and can read this. Avoid it anyway: every component instance registers a new function that's never released, so a component you create many times stays in memory. Its ID can also differ between the threads.

A script runs in the main-thread copy of your file. It reads that copy's module-level variables. Data from the background thread reaches it only as copied JSON arguments.

Keep state between events

A MainThreadRef holds a value that your scripts keep between events, such as where a drag started:

import {
  createMainThreadRef,
  mainThreadFn,
  type MainThread,
} from '@blotch/angular-lynx';

const startX = createMainThreadRef(0);

const dragStart = mainThreadFn((event: MainThread.TouchEvent) => {
  startX.current = event.touches[0]?.clientX ?? 0;
});

const dragMove = mainThreadFn((event: MainThread.TouchEvent) => {
  const dx = (event.touches[0]?.clientX ?? 0) - startX.current;
  event.currentTarget.setStyleProperty('transform', `translateX(${dx}px)`);
});

Each thread has its own copy of a ref, and the copies aren't synced. On the main thread, a ref works like a module-level variable that's labeled as script state.

Call the background thread

runOnBackground() runs a backgroundFn() on the background thread and returns a Promise of its result:

import {
  backgroundFn,
  mainThreadFn,
  type MainThread,
} from '@blotch/angular-lynx';

// Runs on the background thread, which has APIs such as
// lynx.createSelectorQuery() that the main thread doesn't.
const describe = backgroundFn((label: string) => `${label} (background)`);

const onTap = mainThreadFn((event: MainThread.TouchEvent) => {
  runOnBackground(describe, 'tapped').then((text) => console.log(text));
});
  • runOnBackground() is a global. Call it from main-thread code: a main-thread script, or a template handler. It doesn't exist on the background thread, so don't call it from a constructor or ngOnInit, which run on both.
  • Define backgroundFn() at module level, for the same ID reason as main-thread scripts.
  • Arguments and the return value are copied as JSON.
  • Return a value, not a Promise. A returned Promise arrives as {}. So you can't fetch data this way yet (see Network requests).

Call element methods uses this to reach lynx.createSelectorQuery().

Call a script from your code

LynxMainThread.runOnMainThread(handle, ...args) calls a main-thread script and returns a Promise of its result:

import { Component, inject } from '@angular/core';
import {
  LYNX_ELEMENTS,
  LynxMainThread,
  mainThreadFn,
} from '@blotch/angular-lynx';

const double = mainThreadFn((n: number) => n * 2);

@Component({
  selector: 'app-double',
  imports: [LYNX_ELEMENTS],
  template: `<view (bindtap)="run()"><text>Double 21</text></view>`,
})
export class Double {
  readonly #mainThread = inject(LynxMainThread);

  run(): void {
    this.#mainThread.runOnMainThread(double, 21).then((n) => console.log(n));
  }
}
  • From a template handler, which is already on the main thread, the script runs right away.
  • From the background thread, the call crosses threads and the arguments are copied as JSON.
  • Not from code that runs on both threads. A call in a constructor, ngOnInit or effect() runs the script twice: once from each copy of your app.

To get from the main thread to the background thread, use runOnBackground(), not runOnMainThread().

Angular doesn't see these changes

A main-thread script changes native elements behind Angular's back.

  • A later binding update to the same property, such as [style.opacity], overwrites your change.
  • When an element is removed and shown again, by an @if or by leaving and returning to a route, AngularLynx rebuilds it from Angular's state. Your changes are gone.

Use scripts for visual effects that follow a gesture. Keep anything that must last in signals.

Limitations

  • No dependency injection. A module-level script has no injection context, so inject() doesn't work in it.
  • A MainThreadRef doesn't cross threads. Passed to runOnMainThread() from the background thread, it arrives as a plain { _wvid } object, not as the ref.
  • Measure with invoke('boundingClientRect'). Elements also have an untyped getBoundingClientRect(), but its left and top aren't the element's position.