Renderer internals
This page explains how AngularLynx turns Angular's rendering calls into native Lynx elements. It's for contributors and the curious: you don't need it to build an app. The source is in packages/runtime/src/lib, mainly renderer/, lynx-document/, lynx-element/, router/ and runtime.ts.
Two bundles from one source
Apps install @blotch/angular-lynx-build. Its builders add the internal build plugin, @blotch/rsbuild-plugin-angular-lynx, to RSpeedy, Lynx's build tool. The plugin compiles each entry twice:
Both are packed into one bundle, main.lynx.bundle. __MAIN_THREAD__ is a compile-time constant, so each half drops the other thread's branches.
- The main thread runs PrimJS, a QuickJS-based engine, and its code ships as bytecode. Older Lynx code calls this runtime Lepus.
- The background thread runs PrimJS on Android and JavaScriptCore on iOS.
- On the web,
@lynx-js/web-coreruns the main thread in an iframe and the background thread in a Web Worker.
Both threads boot Angular
bootstrapApplication() from @blotch/angular-lynx runs in both halves of the bundle (see Threads for what this means for app code):
- The background copy bootstraps as soon as it loads.
provideRenderer()gives it aLynxBackgroundDocument. - The main copy waits for the engine to call the global
renderPage(), then bootstraps with aLynxDocument. - Before either, the runtime installs its globals:
onerrorhandlers that forward to_ReportError, polyfills (timers fromlynx.setTimeout,URL,queueMicrotask), and the registry for main-thread scripts.
LynxDocument creates real native elements through Lynx's element functions (__CreateView, __SetAttribute, …). Its elements are the only ones you see, and the only ones that receive events.
LynxBackgroundDocument builds a tree of LynxBackgroundElement objects in memory. Nothing reads it for layout, and nothing sends it to the main thread. It exists so the same Angular code runs on a thread with no element functions. Event listeners on it are stored but never registered with the engine, so background handlers never fire.
The background copy is useful because the background thread has APIs the main thread lacks, such as fetch, native modules and lynx.createSelectorQuery(). LynxLogger, backgroundFn() and runOnBackground() reach them across threads with Lynx's event channels (getJSContext() / getCoreContext()).
How Renderer2 maps onto Lynx
LynxRenderer implements Angular's Renderer2. It delegates to LynxDocument and to LynxElement, a wrapper around a native element reference:
A few choices behind the table:
- Comment anchors are hidden views. Angular inserts
@if,@forandcreateComponent()content next to comment nodes. Lynx's non-element node crashes the tree functions Angular calls on anchors, so adisplay: noneview stands in. - Unknown tags become views. Component host elements such as
<app-card>go throughcreateElement(), and they need to be real layout boxes. <list>is special. It returns aLynxListElement, whose children the engine requests through acomponentAtIndexcallback instead of__AppendElement.- Native references can't be hashed. Using one as a
Map,SetorWeakSetkey crashes the main-thread engine. Wrappers are keyed by__GetElementUniqueID()instead. listen()adds no-opstopPropagation()andpreventDefault()to event objects that lack them, so Angular's own listener wrapper doesn't throw.
Emulated and None renderers
LynxRendererFactory2.createRenderer() picks a renderer per component type:
ViewEncapsulation.None, or no type: one sharedLynxRenderer.ViewEncapsulation.Emulated, the default: oneEmulatedLynxRendererper component, keyed by itsɵcmp.id. It adds a_nghost-<id>class to the element fromselectRootElement(), and nothing to child elements.ViewEncapsulation.ShadowDom: logs a warning and falls back toLynxRenderer.
Unlike the browser's emulated renderer, nothing adds _ngcontent-* attributes to child elements. Instead, the build plugin sets each component's ɵcmp.id to a scope ID, made from its class name and file path. The component's compiled CSS file is named after the same ID. The Lynx template loads that CSS, and the native CSS engine matches its class selectors.
The flush cycle
Element functions change the engine's element tree right away, but nothing is laid out or drawn until __FlushElementTree() runs. Angular calls begin() and end() on the renderer factory around every change-detection cycle. On the main thread, end() runs, in this order:
processPendingRemovals()commits queued removals with__RemoveElement.processPendingTextNormalization()trims edge spaces from text runs that changed.__FlushElementTree()commits everything to the screen.processPendingListUpdates()tells each<list>that gained or lost children, then flushes that list.
Reversing steps 3 and 4 leaves list items empty, or re-enters the flush and crashes. On the background thread, end() does nothing.
The first render isn't flushed by end(). Bootstrap's change detection still runs inside the engine's renderPage() call. A promise continuation doesn't escape it, because the engine drains microtasks before the call returns. So end() skips step 3 until markFirstRenderComplete(), and the engine flushes once renderPage() returns. Work that must not run inside that call, such as a list's first layout, waits for a setTimeout. @lynx-js/web-core has no flush after renderPage(), so on the web the runtime schedules one.
Changes outside a cycle get a settle flush. scheduleSettleFlush() queues one microtask that commits removals, text and a flush. It runs when:
- an element is created outside a cycle, such as a lazy route's component that
RouterOutletbuilds during navigation - an event handler returns, in case it changed elements directly without changing a signal
- a removed element is rebuilt
It's skipped inside a cycle, because end() will flush, and during the first render. Main-thread scripts batch their own changes into one flush on a microtask.
How a tap reaches the screen
Change detection is zoneless (see Signals and change detection). A tap runs like this:
- The engine calls the global
runWorklet()on the main thread with the listener thatlisten()registered. - The listener runs your handler synchronously, inside the native event.
- Your handler sets a signal. Angular marks the view dirty, and its zoneless scheduler queues a tick, racing
setTimeoutagainstrequestAnimationFrame. Lynx puts its timers onlynx, so the runtime copies them ontoglobalThisat startup. - When the handler returns,
scheduleSettleFlush()queues a microtask for any direct element changes. - The tick runs change detection. The renderer calls element functions, and
end()flushes.
Changing the tree in step 2 is what the setTimeout rule forbids: the engine is still delivering the event.
Removal, moves and rebuilding
Angular moves a view by removing it and inserting it again in the same tick. remove() only queues the element, so an insert in the same tick cancels the removal. The engine sees a move, and the native element keeps its subtree.
A removal that isn't undone before the flush destroys the element's native view, and Lynx can't bring it back. So every LynxElement caches what was set on it: attributes, id, dataset, classes, styles, listeners, text and children. Inserting a destroyed element rebuilds it, and its subtree, from that cache. This is how content shown again by an @if, or a route you return to, still renders.
The element pool
An on-device crash investigation found that Lynx keeps a fixed pool of native element slots, about 256 (see Glossary). __RemoveElement doesn't free a slot, and Lynx has no public function that does. Angular's default router destroys a route's components on every navigation and creates new elements on return. The app crashed on about the ninth navigation.
LynxRouteReuseStrategy is the fix, and provideRouter() installs it:
shouldDetach()always returnstrue, so leaving a route detaches its view instead of destroying it.store()andretrieve()keep detached views, keyed by the route's full path.shouldReuseRoute()reuses a route when its config is the same.
So component instances survive navigation, and ngOnDestroy() doesn't run when you leave a route.
The strategy predates rebuilding on remount. A re-attached route now rebuilds its native elements from cache, so a revisit creates new elements. Whether the strategy still caps pool use hasn't been measured on a device since.
Routing without a browser
provideRouter() from @blotch/angular-lynx wraps Angular's and swaps three providers:
LynxPlatformLocationreplacesBrowserPlatformLocation, which readswindow.locationand crashes on Lynx. It keeps location state in memory, parses URL strings by hand, and reportshrefaslynx://app/…. It needs its own@Injectable(): without one, Angular inheritsPlatformLocation's factory and builds the browser class.LynxLocationStrategyreplacesPathLocationStrategy. It keeps a history stack and an index in memory.back()andforward()move the index and notifypopstatelisteners.LynxRouteReuseStrategy, described above.
It also sets resolveNavigationPromiseOnError: true, so a failed navigation resolves to false instead of rejecting. Unhandled rejections there used to leave a blank screen. Listen for NavigationError to see failures.
The main thread has no URL class, and since Angular 22.2 the router's URL serializer calls new URL(). Without it, every URL serialized to / and no navigation happened. The runtime installs a URL and URLSearchParams polyfill that follows the web's URL Standard.
