Lynx/Modules/Build Plugin/Usage
@sigx/lynx-plugin · Stable

Using Build Plugin#

Register one plugin and your Rspeedy build produces the dual-thread bundles Lynx needs — background JS, main-thread Lepus, and the worklet transform that stitches them together.

Basic usage#

@sigx/lynx-plugin is a build-time plugin for Rspack / Rspeedy. Install it as a dev dependency and register pluginSigxLynx() in your Lynx config. That single call wires SignalX into Lynx's dual-thread (Background + Main Thread) build pipeline.

Terminal
pnpm add -D @sigx/lynx-plugin
TypeScript
// lynx.config.ts (also works as rspeedy.config.ts / rspack.config.ts)
import { defineConfig } from '@lynx-js/rspeedy';
import { pluginSigxLynx } from '@sigx/lynx-plugin';

export default defineConfig({
  plugins: [pluginSigxLynx()],
});

That's the whole setup for most apps. The plugin splits each entry into two bundles, transpiles to the ES2019 target the Lynx engine expects, configures the SignalX JSX runtime, and runs the main-thread worklet transform.

Note: this is a build tool, not a native module — there are no iOS or Android sources to link. The device-side runtime behavior is provided by sibling @sigx/lynx-* packages. You only need to register the plugin here.

Configuring CSS and debug output#

pluginSigxLynx() accepts an options object that maps to the Lynx template's CSS behavior and debug-info placement. All fields are optional and have sensible defaults.

TypeScript
// lynx.config.ts
import { defineConfig } from '@lynx-js/rspeedy';
import { pluginSigxLynx } from '@sigx/lynx-plugin';

export default defineConfig({
  plugins: [
    pluginSigxLynx({
      // CSS selectors in the Lynx template (default: true)
      enableCSSSelector: true,
      // CSS inheritance in the Lynx engine (default: false)
      enableCSSInheritance: true,
      // extra properties to inherit — only used when enableCSSInheritance is true
      customCSSInheritanceList: ['font-size', 'color'],
      // keep debug info outside the template bundle (default: true)
      debugInfoOutside: true,
    }),
  ],
});

customCSSInheritanceList only takes effect when enableCSSInheritance is true; otherwise it is ignored. See the API reference for the full option table.

Authoring main-thread worklets#

The plugin moves marked event handlers onto the main thread so native touch events dispatch without a round trip to the background bundle. To opt a handler in, make 'main thread' the first statement of the function body. There is no auto-detection from JSX — the directive is required.

TSX
<view
  main-thread-bindtap={(e) => {
    'main thread';
    elRef.current?.setStyleProperties({ opacity: '0.5' });
  }}
/>

At build time the plugin runs that body through the main-thread transform: the background bundle keeps a compact placeholder, the main-thread bundle gets the real handler, and Lynx native dispatches the touch event straight to the main thread.

Worklet gotchas to keep in mind:

  • The 'main thread' directive must be the first statement in the function — JSX alone does not mark a worklet.
  • Variables declared inside a worklet body are main-thread locals; they cannot cross to the background thread through runOnBackground closure capture. Pass them as arguments instead.
  • useAnimatedStyle custom mappers ship as main-thread code and must be registered on the main thread via registerMapper(name, fn) — the background bundle only carries the name.

Cross-package worklets are picked up automatically: the transform runs over all JS/TS in the background and main-thread layers, including node_modules and prebuilt dist, so any package that ships 'main thread' directives (for example @sigx/lynx-motion, @sigx/lynx-navigation, @sigx/lynx-gestures) works with no allowlist.

How dynamic import() chunks load#

Code splitting on Lynx works differently from the web and from upstream Lynx, and getting the model wrong produces a blank app rather than an error, so it is worth knowing how the pieces fit:

  • A dynamic import() emits a plain JS chunk to dist/static/js/async/. At runtime it is loaded through lynx.requireModuleAsync.
  • sigx dev serves those chunks from the dev server. The @sigx/lynx-cli release flows — sigx run:* --release and sigx prebuild --embed-bundle — embed them into the app, and the generated native resource fetchers serve them locally, so they resolve offline.
  • OTA updates don't carry async chunks. sigx updates:publish refuses to publish while dist/static/js/async/ is non-empty, unless you pass --allow-async-chunks for a setup that hosts the chunks remotely. See Code splitting.
  • Setting your own output.assetPrefix hosts the chunks remotely instead. The generated app shells fall back to http(s) for chunk URLs that aren't local.
  • sigx does not use Lynx lazy bundles (lynx.loadLazyBundle). @lynx-js/template-webpack-plugin still writes an encoded template per dynamic import under dist/lazy-bundle/; those files are inert, are not embedded, and can be ignored.

Reading the app env in lynx.config.ts#

The typed env from signalx.config.ts (see App environment) is baked into the bundle by this plugin. To use the same values in the rspeedy config, such as a dev-server proxy target, call appEnv():

TypeScript
// lynx.config.ts
import { defineConfig } from '@lynx-js/rspeedy';
import { appEnv, pluginSigxLynx } from '@sigx/lynx-plugin';

const { apiBaseUrl } = appEnv<{ apiBaseUrl: string }>();

export default defineConfig({
  plugins: [pluginSigxLynx()],
  server: { proxy: { '/api': apiBaseUrl } },
});

@sigx/lynx-cli passes the resolved env to the build, so run it through sigx build / sigx dev. Under bare rspeedy, appEnv() and the app's env are both {}.

Keep the @lynx-js/* build plugins in step#

@lynx-js/template-webpack-plugin (the encoder) and @lynx-js/css-extract-webpack-plugin release in lockstep and must be upgraded together. The encoder's beforeEncode / beforeEmit hook payloads replaced entryNames with chunkGroups at 0.14, so a css-extract too old for the encoder in use fails with args.entryNames is not iterable. It fails only in sigx dev — the offending hook sits behind an HMR check, so release builds stay green and hide the mismatch.

@sigx/lynx-plugin peers the pair it is tested with: the encoder at exactly 0.15.0 and css-extract at ^0.10.1. If you pin these yourself in an app, move them together and keep the same shape — an open >= floor lets an install resolve a combination that was never tested.

Troubleshooting#

  • TypeError: m.loadLazyBundle is not a function at bundle load, and the app stays blank. A dynamic import was routed onto the Lynx lazy-bundle path, which a sigx bundle doesn't implement. @sigx/lynx-plugin keeps async chunks on the requireModuleAsync path; this means your build isn't using a @sigx/lynx-plugin that does — update it together with the rest of the @sigx/lynx* family.
  • args.entryNames is not iterable when sigx dev starts. Your @lynx-js/css-extract-webpack-plugin is too old for the installed @lynx-js/template-webpack-plugin. Align the two to the versions @sigx/lynx-plugin peers (see above) and reinstall.

Dev server, ports, and devices#

In development (NODE_ENV !== 'production') the plugin adds device-friendly behavior automatically — you do not need to call any extra API:

  • It boots a small WebSocket server on devServerPort + 1 to receive batched device console logs and print them to your terminal. On Android the Lynx background runtime has no fetch/XHR, so WebSocket is the only log transport.
  • It rewrites wildcard hosts (0.0.0.0, ::) in the dev server URLs to your real LAN IPv4 so phones and emulators can reach the dev server. Virtual adapters (Hyper-V, WSL, Docker, VPN) are skipped during detection; it falls back to the first external IPv4, then 127.0.0.1.
  • It narrows the file watcher's ignore list and supports forced polling, a workaround for Rspack 1.7.x file-watch noise.

Environment variables the plugin honors:

Terminal
# Override the dev server port (set automatically by @sigx/lynx-cli)
SIGX_LYNX_DEV_PORT=3000

# Force file-watch polling (useful on network drives / some VMs)
SIGX_LYNX_WATCH_POLL=1

SIGX_LYNX_DEV_PORT exists because the Rspeedy CLI has no --port flag; @sigx/lynx-cli sets it for you. You normally won't set these by hand.

Notes#

  • pluginSigxLynx() is the only API most projects need. LAYERS and applyEntry are low-level escapes for custom build setups — see the API reference.
  • The returned plugin is named lynx:sigx and declares pre: ['lynx:rsbuild:plugin-api', 'lynx:config', 'lynx:rsbuild:dev'], so it runs after Rspeedy's own config and dev plugins. That ordering is required for the dev-server URL fixes to apply.
  • The peer Lynx plugins (@lynx-js/* template / css-extract / runtime-wrapper / dev-transport) are declared as optional peers. Install the ones your build needs — and keep the encoder and css-extract in step.

See also#