Skip to content
SiteEmail

Gameface’s layout system runs on a separate thread from JavaScript. A dual-tree architecture synchronizes the DOM and layout trees once per frame, which means computed geometry is always one frame behind your JavaScript reads. This article explains that synchronization model, shows how the three layout phases map to the Performance tab markers you will see while profiling, covers the getComputedStyle and getBoundingClientRect timing trap and its fix, and documents engine.enableImmediateLayout as a last-resort escape hatch.

Understanding the timing quirks in this article requires a basic mental model of how layout fits into Gameface’s rendering pipeline.

Gameface maintains two parallel trees: a DOM tree on the main thread (updated by JavaScript and CSS) and a layout tree on the layout thread (used by the layout solver). Each frame, the two trees synchronize twice: once from the main thread to the layout thread (pushing DOM changes into the solver), and once from the layout thread back to the main thread (publishing the resolved positions and sizes).

The critical implication: data calculated during the layout phase is only available to JavaScript in the next frame. This is the root cause of the getComputedStyle empty-string problem described below, and it is by design. Running layout synchronously on every read would block the main thread constantly.

When layout does run, it executes in up to three ordered phases. Recognizing these in the Performance tab is covered in the Performance & Memory Profiling article, but knowing their names and triggers is useful here.

PhasePerformance Tab MarkerTriggered When
Full layout solveSolve Flex LayoutWidth, height, margin, padding, or other box-model properties change
Transform updateUpdate Node TransformsOnly transform properties change; no box-model properties affected
Visual style recalcRecalcVisualStyleAlways runs; resolves %-based values using the newly computed sizes

The transform update is significantly cheaper than a full layout solve because transform does not change a node’s contribution to the flex layout of its siblings. When only transform changes, the engine skips Yoga entirely and only recalculates each node’s axis-aligned bounding box. The full solve always includes a transform update as its second step.


Calling window.getComputedStyle(element) immediately after page load returns empty strings for any property that depends on layout. The cause is the dual-tree sync: on initial load, the engine runs JS first, then syncs the trees, then runs the layout solver. The layout results are published back to the main thread only after all of this completes, which is after the current frame ends.

Concretely, computed styles become available:

  • After the third frame when read inside the window.onload event.
  • After the second frame following any subsequent page layout.

A naive read like the one below produces an empty string, even on an element that visually has a width:

window.addEventListener('load', () => {
const el = document.querySelector('.health-bar');
console.log(getComputedStyle(el).width); // "" - layout not yet published to main thread
});

The reliable pattern is to defer the getComputedStyle call behind requestAnimationFrame callbacks, letting the dual-tree sync cycle complete before reading. The raw nested version:

requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const el = document.getElementById('health-bar');
const styles = getComputedStyle(el);
console.log(styles.width); // "320px" - layout results are now published
});
});
});

This works, but nesting three callbacks for every geometry read is noisy. The following utility wraps the pattern and handles both the initial-load case (3 frames) and the post-layout case (2 frames) automatically:

utils/styles.js
let isLoading = true;
// After the first animation frame fires, the page has completed its first render pass.
window.addEventListener('load', () => {
requestAnimationFrame(() => {
isLoading = false;
});
}, { capture: true });
/**
* Defers a callback until computed styles are safe to read.
*
* @param {Function} callback - the function to run once styles are available
* @param {number} [count] - frame count override; defaults to 3 on load, 2 otherwise
* @param {...*} callbackArguments - forwarded to the callback
*/
function getStyles(callback = () => {}, count, ...callbackArguments) {
if (count === undefined) count = isLoading ? 3 : 2;
if (count === 0) {
return callback(...callbackArguments);
}
count--;
requestAnimationFrame(() => {
getStyles(callback, count, ...callbackArguments);
});
}

With the utility in place, reading styles becomes a single non-nested call:

hud.js
// After a layout change - waits 2 frames for the dual-tree sync to complete
getStyles(() => {
const barWidth = getComputedStyle(document.querySelector('.health-bar')).width;
console.log(barWidth); // "320px"
});
// Inside window.onload - waits 3 frames
window.addEventListener('load', () => {
getStyles(() => {
const panelHeight = getComputedStyle(document.querySelector('.skill-panel')).height;
positionTooltipRelativeTo(panelHeight);
});
});

Sometimes you do not control when geometry is read. A third-party animation library, a UI framework, or an accessibility helper may call offsetWidth or getBoundingClientRect synchronously and silently break because Gameface’s deferred model returns zeroes.

engine.enableImmediateLayout is the escape hatch for this situation. When enabled, any CSS geometry getter triggers a synchronous full layout pass before returning its value. The affected getters are the same layout-triggering properties as in standard browsers: offsetWidth, offsetHeight, getBoundingClientRect, scrollTop, and others.

The setting applies at the view level, affecting all documents loaded in that view.

// Force synchronous layout on every geometry getter call
engine.enableImmediateLayout(true);
// Check whether it is currently active
const isEnabled = engine.isImmediateLayoutEnabled();
// Disable when done diagnosing
engine.enableImmediateLayout(false);

Enabling Immediate Layout globally is the bluntest approach available. A more targeted option is engine.executeImmediateLayoutSync, which triggers a single synchronous layout pass on demand without changing the view-level setting.

The optimal pattern when you need accurate geometry after a batch of mutations is to batch all mutations first, run one sync pass, and then read all geometry values:

// 1. Apply all mutations upfront - accumulate all changes before any layout runs
updateInventoryGrid();
repositionHUDElements();
applyNewItemCounts();
// 2. One layout pass to flush all mutations at once - one Solve Flex Layout, not many
engine.executeImmediateLayoutSync();
// 3. Read geometry freely - the dual-tree sync has been forced, results are current
const gridBounds = document.querySelector('.inventory-grid').getBoundingClientRect();
const panelWidth = document.querySelector('.stats-panel').offsetWidth;
const tooltipHeight = document.querySelector('.tooltip').offsetHeight;

This runs one full layout solve instead of one per getter call, which is dramatically cheaper than having enableImmediateLayout(true) active across the same reads.

Four constraints apply whenever an immediate layout runs:

  • Animations are not ticked. The engine does not advance CSS or JS animations during the layout pass.
  • JavaScript is suspended. No other JavaScript executes while the layout is running.
  • Resource loads are not awaited. The layout runs on the current DOM state and does not wait for pending fonts, images, or other resources.
  • Runs on the JavaScript thread. The layout executes synchronously on the same thread that called it, not on the layout thread.