@sigx/lynx-plugin
v0.28.0
Published
Rspack/Rspeedy plugin for SignalX Lynx dual-thread rendering
Readme
@sigx/lynx-plugin
Rspack/Rspeedy plugin for SignalX on Lynx. Splits a single user app into the two bundles Lynx requires (background JS + main-thread Lepus) and runs the SWC worklet transform that powers 'main thread'-marked event handlers.
📚 Documentation
Full guides, API reference and live examples → https://sigx.dev/lynx/modules/plugin/overview/
Installation
npm install -D @sigx/lynx-plugin// rspeedy.config.ts (or rspack.config.ts)
import { defineConfig } from '@lynx-js/rspeedy';
import { pluginSigxLynx } from '@sigx/lynx-plugin';
export default defineConfig({
plugins: [pluginSigxLynx()],
});What it does
Two-bundle split. Lynx ships JS to two contexts on the device:
- The background bundle — your sigx components, signals, effects, fetch logic.
- The main-thread bundle — only the worklet handlers extracted from your source, plus the runtime bootstrap.
The plugin sets up a separate webpack rule so user source files are processed twice: once with the BG-target transform, once with the LEPUS-target transform.
Worklet transform. Files containing the string
'main thread'are run through@lynx-js/react/transform. The transform:- Replaces a worklet expression in the BG bundle with a
{_wkltId, _c}placeholder so the BG renderer can reference it without shipping the function body. - Emits
registerWorkletInternal("main-thread", "<id>", function(...) { ... })calls into the MT bundle so Lynx native can invoke the worklet body when it dispatches a touch event. - Folds the thread defines
__MAIN_THREAD__/__BACKGROUND__to literals per layer (BG:false/true, MT: the inverse) with dead-branch elimination, soif (__MAIN_THREAD__) { … }inside a worklet body ships only in the registered MT form andif (__BACKGROUND__) { … }only in the BG bundle. Files containing either token are transformed even without a worklet directive. App/workspace-src only — published dists pass through the MT layer verbatim (cross-layer module identity), so packages must use a runtime check instead. Types come from@sigx/lynx/client.
- Replaces a worklet expression in the BG bundle with a
MT-bundle bootstrap. Every file in the MT bundle gets three side-effect imports prepended:
@sigx/lynx-runtime-main/entry-main— installs theprocessData/renderPage/sigxPatchUpdateglobals Lynx expects.@lynx-js/react/runtime/worklet-runtime/main.js— populateslynxWorkletImpl,registerWorkletInternal,runWorklet.@sigx/lynx-runtime-main/install-hybrid-worklet— registers the hybrid dispatcher used by thebindtap+main-thread-bindtapslot machine.
Listing them as separate entries in webpack isn't sufficient because the chunk graph can evaluate user code before the bootstrap chain. Prepending side-effect imports per-file forces the dep-graph order.
Async-chunk plumbing (#599). Dynamic
import()emits async chunks (dist/static/js/async/<hash>.js). The plugin pins the productionoutput.assetPrefixto/(only when you haven't set one) so chunk request URLs are root-relative and map 1:1 onto the assets@sigx/lynx-cli's release flows embed into the native app, and it logs every emitted async chunk after a production build. Set your ownoutput.assetPrefixto host chunks remotely instead — the generated app shells fall back to http(s) for non-local chunk URLs.Zero-config web environment (#699). When a web build is requested (
sigx run:websetsSIGX_WEB_ENV=1in the rspeedy child env), the plugin adds any missinglynx/webkeys toenvironments(creating the block when yourlynx.config.tsdeclares none) — present keys and every other user-declared environment are untouched, and plainsigx dev/sigx build(no env var) are unaffected. On the web environment it also injects__WEB__/__NATIVE__defines,.web.tsx-style file resolution, and a.web.jsextensionAliasso per-package web shims apply through published dists (#697). Opt out withpluginSigxLynx({ web: false }).Inline CSS variables (#116). The plugin encodes
enableCSSInlineVariables: trueinto the template's page config, so the native engine registers CSS custom properties declared in inlinestyle(style={{ '--x': '…' }}) and descendants resolvevar(--x)from the very first paint; value changes re-resolve descendants too. Requires a native host on Lynx ≥ 3.9 (the CLI's templates pin 4.0.1; registration without change-propagation shipped in 3.6).@lynx-js/web-corehonors inline custom properties unconditionally. Kill switch:pluginSigxLynx({ enableCSSInlineVariables: false }).Page-config flags.
LynxTemplatePluginemits a fixed key set, but the native engine decodes more than that, soSigxPageConfigPluginmerges the rest in. Every flag here follows one rule: encode the resolved boolean, never omit the key. An absent key means "whatever the engine or host decides today", which is how a behaviour change arrives without anyone choosing it. A host older than the engine version listed below simply ignores the key, so encoding one is always safe.| Flag | Needs | Default | Opt in with | |---|---|---|---| |
enableCSSInlineVariables(#116) | Lynx ≥ 3.6, and ≥ 3.9 to re-resolve on change |true| on by default;falseis the kill switch | |enableNewSticky(#950) | Lynx ≥ 4.0 |false|pluginSigxLynx({ enableNewSticky: true })| |enableElementApiNewRegistration(#957) | Lynx ≥ 4.0 |false|pluginSigxLynx({ enableElementApiNewRegistration: true })| |enableCSSRule(#951) | tasm ≥ 0.0.41 to encode; Lynx ≥ 4.0 to evaluate |true| on by default;falseis the kill switch |enableNewStickyopts into Lynx 4.0's new sticky layout for<list>headers; it changes where and when a header pins, so re-check anything doing pixel math against sticky positions.enableElementApiNewRegistrationselects Lynx 4.0's new raw binding path for the Fiber Element API. It is pinned off because every<sigx-*>custom element is created through the generic__CreateElementand so moves as one surface — and because upstream marks this flagreadSettings: true, meaning a host setting can otherwise flip it under a bundle that never asked. The page config takes precedence, so encoding it keeps the choice with the app.enableCSSRuleroutes stylesheet encoding through the tasmCSSRuleParser— the only encoder path that carries@media,@supports(ConditionRule) and@layerrules into the.lynxbinary; the legacy token path silently drops them. This flag is read at encode time (unlike the others, which only the runtime decodes), so it needs@lynx-js/template-webpack-plugin≥ 0.14 (@lynx-js/tasm≥ 0.0.41) to have any effect; when enabled the encoder also forcesenableCSSSelectorandenableCSSInvalidationon. On device,prefers-color-schemeresolution additionally needs the host to drive the engine's color scheme —@sigx/lynx-appearance's publisher does (#951). The web target does not carry media/supports rules regardless (upstreamWebEncodePlugindrops them).Because that last sentence has two ways of coming true — the kill switch and the web target — the resolved answer is also folded into the bundle as the
__SIGX_CSS_RULE__define, alongside__WEB__/__NATIVE__. Library and app code branches on it when a missing at-rule would leave something unstyled rather than merely unrefined:@sigx/lynx-zero-legacy's<ThemeProvider>reads it to choose between letting the CSS engine resolve a built-in theme's palette and declaring that palette inline (#985). The define reflects the encoder only — it can't know the host's engine version.Evaluated and deliberately not plumbed:
enableNativeInteraction(changes platform-view interaction defaults — belongs with the gesture work, not here),syncXElementRegistry(no evidence it helps; the default is what our custom elements are verified against),enableNativeList(device-tested in #950 and it does not do what it looks like it does —custom-list-namestill selects the platform node), andenableSingleSession(a devtool setting, not a page config —@sigx/lynx-dev-client's concern).Cross-package worklet pickup. The worklet rules run on every JS/TS file in the BG / MT layers, including
node_modulesand pre-builtdist/. Any package shipping'main thread'directives in its dist (@sigx/lynx-motion,@sigx/lynx-navigation,@sigx/lynx-gestures, future additions) is picked up automatically — no allowlist or opt-in flag. See CONTRIBUTING.md for the loader-branching details.
Worklet author quick reference
Mark an event handler as MT-thread by adding the directive as the first statement:
<view
main-thread-bindtap={(e) => {
'main thread';
elRef.current?.setStyleProperties({ opacity: '0.5' });
}}
/>The plugin handles the rest — the handler body lives in the MT bundle, the BG bundle keeps a {_wkltId, _c} placeholder, and Lynx native dispatches the touch event directly to the MT thread.
For higher-level abstractions (drag, tap, swipe, animations), see @sigx/lynx-gestures.
Snapshot templates (default ON)
pluginSigxLynx({ snapshots: false }) // kill switch — keeps the per-element pathCompiles static JSX subtrees to main-thread snapshot templates (#620): the main thread constructs each compiled subtree itself from one snapshot op (instead of ~10 per-element ops + a thread hop), then receives hole-granular patches. Measured ~25–30x cheaper cell construction on release builds.
- Default ON (since #642;
snapshots: falseremains as a kill switch for one release). Works in dev and production: undersigx dev, template registrations ride the MT hot-update bridge, an edit's stale templates are purged per file (the id's filename-hash prefix is edit-stable), and op batches that outrun a registration park and replay after the next update. - JSX must be statically analyzable. Dynamic parts (attribute expressions,
children) become numbered holes; the subtree shape is fixed at compile
time. Non-static subtrees keep today's per-element path automatically.
Whole files using
use:*directive attributes are pre-filtered to the per-element path silently (they panic the upstream WASM pass); only an unexpected transform failure emits a build warning naming the file that fell back. Raw<list>JSX compiles: cells are staged instance records thatcomponentAtIndexmaterializes synchronously on first pull, and offscreen cells recycle through template-keyed pools (enqueueComponentre-patches a pooled tree instead of constructing). - App/workspace-src only. Published dists ship pre-lowered
_jsx()calls and keep the per-element path.
Limitations
Custom worklet bodies require
'main thread'directives. Worklets aren't auto-detected from JSX shape; the directive is the marker.Variables declared inside a worklet body are MT-locals. They can't cross the bridge via
runOnBackgroundclosure capture — pass them as arguments instead. See@sigx/lynx-gesturesREADME, "Performance notes."Mappers for
useAnimatedStyleship as MT-side code. Custom mappers must be registered from a MT-side module viaregisterMapper(name, fn)— BG-sideuseAnimatedStyleonly carries the name across the build pipeline.Worklets must survive tree-shaking on the main-thread layer. A worklet compiles into two halves: a
{_wkltId}placeholder in the background bundle and aregisterWorkletInternal("main-thread", "<id>", …)in the main-thread bundle, whichrunWorkletlooks up at dispatch. On the MT layer that registration is the module's only reason to exist, so a package markedsideEffects: falsegets its worklet modules dropped and every worklet in it silently never runs — no gesture response, no animation, no error (#1021). The plugin marks all MT-layer modules side-effectful to prevent this, and fails the build if any background worklet id has no matching MT registration. If you hit that error, look for asideEffectsdeclaration excluding worklet modules in one of your dependencies.Dynamic
import()produces plain JS chunks, not Lynx lazy bundles. Each async chunk is emitted todist/static/js/async/and loaded at runtime throughlynx.requireModuleAsync, which@sigx/lynx-cliembeds into the app and serves from the native resource fetchers (#599/#612). The plugin therefore empties__webpack_require__.lynx_aci, the map that would otherwise route chunks throughlynx.loadLazyBundle— a method@lynx-js/reactinstalls as a module side effect, which is undefined in a sigx bundle because sigx substitutes its own runtime (#1015).@lynx-js/template-webpack-pluginstill emits an encoded template per dynamic import underdist/lazy-bundle/; those are inert, are not embedded, and can be ignored.Peer versions matter here.
@lynx-js/template-webpack-pluginand@lynx-js/css-extract-webpack-pluginrelease in lockstep and must be upgraded together — the encoder'sbeforeEncode/beforeEmitpayloads changed shape at 0.14 (entryNames→chunkGroups), and a css-extract too old for the encoder in use breakssigx devwithargs.entryNames is not iterablewhile leaving release builds green. css-extract ≥ 0.10.1 pairs with encoder 0.15.x.
License
MIT
