UI Thread
Change native views from worklets without flicker.
Change Discord's native views in the same frame Discord draws them, using worklets. Only for native view changes, such as rounding avatars, that flicker when done from JavaScript.
Why It Flickers
A hook registered from JavaScript runs after the native call returned, a frame or more late. The user sees Discord's view first, then yours. A hook installed from a worklet runs inline on the main thread, inside Reanimated's UI runtime, in the same layout pass.
- Discord lays out the view
- The frame is drawnDiscord's version
- Your hook runsOn the JavaScript thread
- Next frameYour version
- Discord lays out the view
- Your hook runsInline, on the main thread
- The frame is drawnYour version
Quick Start
Write the worklet
A worklet's body starts with
'worklet'.ubd buildcompiles every worklet, including imported ones. Every function it calls must be a worklet too.src/avatars/ui-avatars.ts import type { NativeObjCBridge, NativeObjectHandle, NativeUIScope } from '@unbound-app/api/native'; function round(objc: NativeObjCBridge, avatar: NativeObjectHandle, radius: number) { 'worklet'; // `invoke` returns a plain value, a handle or nothing. `layer` is a handle. const layer = objc.invoke(avatar, 'layer', []) as NativeObjectHandle | undefined; if (layer) objc.invoke(layer, 'setCornerRadius:', [radius]); } export function installAvatarHooks(scope: NativeUIScope, radius: number) { 'worklet'; const { objc } = scope; if (!objc) return; objc.hook('DCDAvatarView', 'layoutSubviews', { after: ({ self }) => { try { round(objc, self, radius); } catch (error) { console.error(`[Avatar Radius] ${String(error)}`); } }, }); }Run it from start
contextis the argument ofstart.src/avatars/index.ts import type { PluginContext } from '@unbound-app/api/native'; import { installAvatarHooks } from '~/avatars/ui-avatars'; export function startAvatars(context: PluginContext, radius: number) { const { ui } = context.native; const scope = ui.createScope(); ui.run(installAvatarHooks, scope, radius); }
A worklet runs in its own runtime and can't see your plugin's variables, stores or settings. Values arrive as arguments. Constants and worklets it uses are copied in at build time, so later changes are never seen.
Reference
Call on context.native.ui | Returns | Does |
|---|---|---|
createScope() | NativeUIScope | Opens a scope with every synchronous native method your Capabilities allow: objc on iOS, java and hooks on Android. Pass it to a worklet as an argument |
run(worklet, ...args) | void | Queues the worklet. Use it to install hooks, push data and restore views |
call(worklet, ...args) | Promise | Runs the worklet and resolves with its serializable result. Use it for self-tests and counts |
- Arguments must be serializable, or a scope.
- Scopes leave out waiting methods (
invokeAsync,batch,snapshot,findViews); a worklet must never wait on its own thread. - Unbound disposes every scope when the plugin stops, removing its hooks.
- Every worklet runs behind a guard that logs errors instead of crashing. Hook callbacks still need their own
try/catch.
Full signatures: native in the SDK reference.
Platform Hooks
| Platform | Behavior |
|---|---|
| iOS | before, after and replace callbacks run inline on the main thread. A replace on a measuring method such as sizeThatFits: answers without waiting on JavaScript |
| Android | A hooks.hook listener runs inline when the method runs on the main thread. { notify: 'after' } fires after the original, so a bind hook sees the row already bound to its new message |
Data in the UI Runtime
Compute data such as role colors on the JavaScript thread, then push a plain snapshot with ui.run. Store it on globalThis under a key unique to your plugin.
const RADIUS_KEY = '__avatarRadius';
export function setAvatarRadius(radius: number) {
'worklet';
Reflect.set(globalThis, RADIUS_KEY, radius);
}- Push only on change. Compare with the last push and debounce bursts. Pushing every render keeps JavaScript busy while scrolling.
- Cache slow measurements per message, such as a bubble's frame, so the next layout draws at once.
- Clean up on reuse in the same pass:
prepareForReuseon iOS, an after-bind hook on Android. Tag your views so the hook finds them without searching.
Cost
Each native call from a worklet costs about 15 to 35 µs on iOS and 30 to 50 µs on Android. A scroll-time hook affords a handful of calls. Keep these on the JavaScript thread:
- View-tree snapshots, searches, and measuring many views.
- Mounting React surfaces with
fabric. - Flux stores, settings, network requests and React patches.
Rules
- Start every worklet, and every function it calls, with
'worklet'. - Wrap hook callbacks in
try/catch. On Android, an error inside a native callback is fatal and the guard can't catch it. - Look up Objective-C handles and Java references inside the worklet. Android association keys can be passed as arguments.
- Check a Java boolean with
javaFlag. A boxed{ type: 'boolean', value: false }is truthy. - Don't declare Capabilities for worklets.
ubd buildfinds their native calls.