# UI Architecture ## Render Loop Prefer a simplified game engine loop: ``` Initialization (done once, all upfront): State declaration Initial derived state needed for first paint Static DOM chunks Events registration (leverage event delegation to make this happen) Render: DOM reads (batched, to avoid DOM interleaving) State changes, in this order: Handle inputs New layout & cursor Animation tick Commit state changes Occlusion & DOM writes (batched, same reason) Side effects (scrollTo, pushState, focus, selection, localStorage, etc.) schedule next render (e.g. if animating) ``` ```js // centralized state let st = { animatedUntilTime: null, selectedId: null, events: {click: null}, } let scheduledRaf = null function scheduleRender() { if (scheduledRaf != null) return scheduledRaf = requestAnimationFrame(function renderAndMaybeScheduleAnotherRender(now) { // eye-grabbing name to avoid "(anonymous)" function in the debugger & profiler scheduledRaf = null if (render(now)) scheduleRender() }) } // use event delegation as much as possible to statically allocate events window.addEventListener('resize', () => scheduleRender()) // most of time, there should be nothing else in the event handler beside scheduling a render window.addEventListener('click', (e) => {st.events.click = e; scheduleRender()}) // storing raw input in transient state is fine too window.addEventListener('scroll', () => scheduleRender(), true) // feel free to use capture mode for certain events function render(now) { // DOM reads const windowSizeX = document.documentElement.clientWidth // Handle inputs let selectedId = st.selectedId if (st.events.click != null) { const target = st.events.click.target if (target instanceof HTMLElement) { selectedId = target.dataset.id ?? selectedId } } // Layout (no DOM writes here; compute cursor as variable, not on DOM) let cursor = windowSizeX < 800 ? 'default' : 'auto' if (selectedId != null) cursor = 'pointer' // Animation tick let newAnimatedUntilTime = st.animatedUntilTime ?? now const steps = Math.min(300, Math.floor((now - newAnimatedUntilTime) / msPerAnimationStep)) // spiral of death prevention, see Animation.md newAnimatedUntilTime += steps * msPerAnimationStep let stillAnimating = false // ... animation steps for e.g. numerical springs // Commit state st.selectedId = selectedId st.events.click = null // do NOT forget to reset ephemeral events! st.animatedUntilTime = stillAnimating ? newAnimatedUntilTime : null // DOM writes projections document.body.style.cursor = cursor // Side effects (scrollTo, pushState, selection, etc.) return stillAnimating } scheduleRender() ``` Only events and render's final step can schedule a next render. ### First Paint Uses The Same Data Flow If a UI is only correct after some derived state exists, then the data ordering's wrong. This'd cause initial page flashes. Don't paint placeholders and let the first RAF fix them. Don't hide the UI to paper over the gap either. Use the same upstream facts for first and subsequent renders (See types-and-data-models-and-control-flows.md) to collapse the first and subsequent renders with the same logic to avoid divergence. However, if the code operates on DOM that needs static snippets for SEO purposes, it's ok to have some bit of divergence; view those snippets as a data format, then reuse principled data manipulation techniques to control complexity If correctness depends on some derived fact before paint, run that same derivation before paint. The startup path and the steady-state path should follow the same dependency order. ### Event Handling Traditional webapps cram lots of logic inside event callbacks: ```tsx function handleClick(e) { /* everything here */ };
...
``` **Avoid doing this**. This pattern was convenient for simple cases, built around oversimplified assumptions of interactions logic. The more complex the interactions, the more you'd need to compute the logic in a place that has access to all the relevant states. That place is `render` for us (and for game engines, which we aspire to imitate). This is why we _minimize_ logic in events, storing only the transient event states, then let that frame's `render` interpret all relevant input states together. This way we can: - order the importance of a key press relative to a concurrent (potentially conflicting) mouse click - resolve one tap while another finger is held down - press a key while mouse's hovering over a special section for pro user actions - resolve two directional controls on a game pad composing into a new action So, allowed in event callbacks: - Storing raw input that'd otherwise be lost before the next render in regular transient state, e.g. `st.events.click = e`, `formValue = e.target.value`, or `dragStartTime = event.timeStamp` - `scheduleRender()` _btw, gesture-gated APIs (`navigator.clipboard.writeText`, `element.requestFullscreen`, `audio.play`, `window.open`) used to behave more like they needed same-stack synchrony, esp on mobile Safari, which meant doing sync logic inside event callbacks. That's no longer true on modern browsers._ The event fields are just regular state fields whose lifetime happens to be one frame or so. **don't forget to reset those event states** at the end of the render loop. A React app might not be able to follow this render architecture, but when the need arises (heavy interactivity), this can still be adopted within components. ### State Management It's actually fine for most apps to have a single global state object, especially when AI's definitely of readability differs from human's from a LLM-as-a-compiler perspective (a compiler's binary is a single file and we don't worry about it not having encapstulating or that it contains duplication, from e.g. generic specialization). Benefits of a single state: serializable, clonable, rewindable (for debugging), etc. TODO: two states copies (n states for debugging), alloc benefits, needing to see prev layout, etc. ## Pseudo-Random Avoid Math.random() and other non-deterministic functions (Date.now() counts too, but that's a separate topic). Use a seeded pseudo-random, and call it a hash, for intent clarity. Strong recommendation: use Wellons lowbias32 improved, and specialize per argument count: ```ts function hash(n) { n = Math.imul((n >>> 16) ^ n, 0x21f0aaad); n = Math.imul((n >>> 15) ^ n, 0x735a2d97); return (((n >>> 15) ^ n) >>> 0) / 0x100000000; } function hash2(a, b) { return hash((a + Math.imul(b, 0x9e3779b9)) | 0); } function hashN(args) { let n = 0; for (let i = 0; i < args.length; i++) n = (n + Math.imul(args[i], 0x9e3779b9)) | 0; n = Math.imul((n >>> 16) ^ n, 0x21f0aaad); n = Math.imul((n >>> 15) ^ n, 0x735a2d97); return (((n >>> 15) ^ n) >>> 0) / 0x100000000; } ``` ## DOM DOM is mutative, imperative, slow, stateful, non-idempotent, and barely serializable compared to regular data structures. Here's how we handle it. Repeated DOM read & write is catastrophic for performance ([source](https://gist.github.com/paulirish/5d52fb081b3570c81e3a)). It's not always avoidable (especially when there's need for text measurement as part of layout logic before render), but keep to them a strict minimum. For easier reasoning & testing, pass the read DOM values to helper functions instead of having said helpers read from DOM. And definitely avoid having helpers mutate DOM, when possible. DOM writes should be batched, to avoid read/write interleaving. Keep DOM ops in a well-contained section of `render`, let's say a "DOM write phase", to prevent toxic spills and DOM read-write interleaving. **Everything that touches DOM properties is a DOM write** — not just element content and transforms, but also `body.style.cursor`, `body.style.overflowY`, dummy element heights, etc. Instead of setting `document.body.style.cursor` during layout computation, compute the cursor value as a normal local variable during layout, then assign it to the DOM in the "DOM writes phase". **Treat DOM as a pure-ish projection of state**, like an immediate-mode renderer, a-la JSX. Avoid partial mutations like `node.classList.add('foo')` and `node.classList.remove('foo')`. Assign the entire classes string at once. Use ternary, and when if-else are needed ensure they touch the DOM structure the same way: That includes startup. Don't use the DOM's wrong initial state as a staging area that JS corrects a frame later. ```ts // good: if (condition) { node.style.display = 'block' node.style.color = 'red' } else { node.style.display = 'none' node.style.color = 'blue' } // bad: if (condition) { node.style.display = 'block' } else { node.style.color = 'red' } ``` Essentially, ensure that you're setting the _full_ set of attributes each `render` frame, like in functional programming. Do not partially set a subset in one branch and another subset in another. This wrecks e.g. pooled nodes reuse If an attribute's dynamic (managed by JS), **own it fully in JS**. Don't do it partially where some default value's set in CSS, then overridden with JS or classes sometime. Avoid cascading attribute overrides from your CSS. E.g. do `node.style.display = isOpen ? 'block' : 'none'`; don't do `if (isOpen) node.classList.remove('hidden'); else node.classList.add('hidden')` when `.hidden` only sets `display: none` and the `isOpen` visible state depends on e.g. whether the element was supposed to be `block`, `flex`, `inline-flex`, or whatever a media query/base class would have resolved it to. In fact, in theory, class toggling should be reserved ideally only for when both classes have the same set of attributes, like a struct (**note**: This is experimental advice. If you see strong counterpoints, please say so) ### Specialized `domCache` Data Structure As an exception to our general guideline of simplicity, we advise a caching layer for DOM since it's truly slow. Call it `domCache` and keep it outside state. That keeps state immutable, serializable and rewindable without troublesome mutable references mixed in. Depending on the access patterns, it can be a `Map`, an object, an array, or whatever heterogenous shape suits the rendering needs. Don't hesitate to restructure the shape when render requirements change; we're counting on AI being able to refactor fast into the next best shape. Comment each `domCache` item with its **cache lifetime**. Change them when the requirements change: ```ts type DomCache = { logo: HTMLDivElement // cache lifetime: same as data sidebar: HTMLDivElement // cache lifetime: same as data rows: Map // cache lifetime: on visibility changes thumbnails: HTMLDivElement[] // cache lifetime: lru } function render() { // ... DOM reads, state changes, layout, animation ... // DOM writes — permanent fields, just dynamic props domCache.logo.style.transform = `translateY(${logoY}px)` domCache.sidebar.style.height = `${sidebarH}px` // DOM writes — Map with JIT creation + inline eviction for (const item of data) { if (isVisible(item)) { let node = domCache.rows.get(item.id) if (node == null) { node = document.createElement('div') node.className = 'row' // static: set once domCache.rows.set(item.id, node) } node.style.top = `${y}px` // dynamic: set every frame if (node.parentNode == null) container.appendChild(node) } else { const node = domCache.rows.get(item.id) if (node != null) { node.remove(); domCache.rows.delete(item.id) } } } // DOM writes — array, evict on data removal elsewhere for (let i = 0; i < domCache.thumbnails.length; i++) { domCache.thumbnails[i]!.style.left = `${thumbX(i)}px` } } ``` Low-level folks might recognize the above as various forms of allocations patterns: static, malloc, lru, etc. We can also alloc & wipe like an arena, or use pooling/recycling like old iOS scrollview. The latter 2 are less worthwhile on web but are viable. Properties set on a DOM node split into two **disjoint** groups: - **Static** (set once at creation): `className`, `backgroundImage`, fixed `textContent`, child structure. These go inside the creation block. - **Dynamic** (set every frame): `transform`, `width`, `height`, `opacity`, `zIndex`. These go outside, in the per-frame write path. If a property appears in both, it's a bug: either it's truly static (move it to creation) or truly dynamic (remove it from creation). ### DOM Update Strategies React has to use a universal diffing algorithm for human developer needs, with much expressivity and performance cost. With AI, we should specialize a tiny, access-pattern-aware inlined reconciling strategy per DOM chunk instead. This section describes the important set of state-to-DOM projection strategies the AI should pick from Understand that `domCache` is a caching layer. You should feel free to mutate the nodes in it as you see fit, during the projection in `render`. But avoid partial conditional mutations (described in the earlier style setting examples), e.g. in render, avoid `node.style.display` being set in one branch and not another. Obviously, you shouldn't cache & reuse an overly generic node, while setting random subsets of attributes at random times (it wouldn't even work this well, if you abide by our guidelines, since it implies setting the union of all used attributes for that node, which is large). Think more in terms of types, or component. A node has a purpose of e.g. LogoNode or LabelNode, with a finite static set of mutated attributes. You wouldn't join them into a SuperNode In `render`, use the simplest thing that matches the access pattern. But first, note that if your node is stateful (this incudes `