Skip to content

perf(binding)!: materialize measured styles on demand - #46

Merged
hyfdev merged 2 commits into
mainfrom
agent/lazy-measure-get-style
Aug 17, 2026
Merged

hyfdev merged 2 commits into
mainfrom
agent/lazy-measure-get-style

Conversation

@hyfdev

@hyfdev hyfdev commented Aug 17, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • replace the eager MeasureArgs.style field with getStyle(): Style
  • materialize a complete JavaScript Style only when the callback calls getStyle()
  • keep one owned Rust Style snapshot and one native provider per callback-reached node and compute
  • preserve retained-provider safety, detached return values, callback failure identity, retry, busy-tree, and dirty behavior in Native and WASI

This is a breaking API change:

// Before
measure({ style }) {
  return measureText();
}

// After
measure({ getStyle }) {
  const style = needsStyle ? getStyle() : undefined;
  return measureText();
}

Why

Taffy supplies &Style to every Rust measure call, but mechanically converting that large value at the JavaScript boundary made every callback pay for a complete Style object even when measurement only used constraints or context. The public capability remains complete and normalized, while conversion is now explicit and demand-driven.

Implementation and lifetime

The exact-request cache from #45 is checked first. On the first uncached callback request for a node, the measure session clones the borrowed Rust Style into an owned snapshot and creates a native getStyle function that captures it. Later requests for that node in the same compute reuse the same function.

Calling the function runs style::output() and returns a fresh detached JavaScript object. Not calling it never runs style::output() and never creates a JavaScript Style object. A retained function remains safe after the callback or compute returns because the JavaScript function finalizer owns the Rust snapshot; it does not retain a tree borrow, a callback-scope pointer, or re-enter the busy tree.

The fixed safe-lifetime cost is one Rust Style clone and one native provider for each callback-reached node per compute, whether or not the function is called. No JavaScript Style mirror, numeric slot, compact constraints ABI, per-node measure callback, or upstream algorithm change is included.

Performance

Base: d73e3351d8ce9571faa65b10a855f428f73e896a, which includes #38 through merged PR #45. Linux x64, Node 24.19.0, Intel i5-13500H. Each cell is the median of three independent local runs of the unchanged benchmark scenario. The omit and always-call variants were temporary diagnostics and were fully reverted; benchmarks/results/published.json was not changed.

Scenario Runtime eager baseline omit Style upper bound lazy, unused lazy, every callback calls unused vs eager gap to omit
initial layout Native 18.7039 ms 6.3879 ms 6.5603 ms 18.6966 ms 64.9% faster +0.1724 ms (2.7%)
initial layout WASI 150.0402 ms 38.9065 ms 40.8747 ms 137.7661 ms 72.8% faster +1.9682 ms (5.1%)
viewport resize Native 7.7655 ms 2.3475 ms 2.5974 ms 8.2470 ms 66.6% faster +0.2499 ms (10.6%)
viewport resize WASI 59.5517 ms 14.7847 ms 18.6995 ms 64.6087 ms 68.6% faster +3.9148 ms (26.5%)

The unused lazy path recovers 98.6% / 98.2% of the removable initial-layout cost in Native / WASI, and 95.4% / 91.3% for resize. When Style is requested every time, initial Native is effectively the eager cost; the resize probe is 6.2% slower in Native and 8.5% slower in WASI, reflecting provider and clone overhead on top of full conversion.

The bounded review changed the internal lifetime witness without changing the ownership model. The final commit was then rerun three times with the ordinary unused callback and still showed the same direction: 7.3256 / 50.1378 ms for initial Native / WASI and 2.9261 / 20.9033 ms for resize Native / WASI in a noisier follow-up round.

Allocation counts

A temporary probe compared provider identity by node and called getStyle() once per JavaScript callback. Native and WASI produced the same counts:

Compute JavaScript callback calls Rust snapshots / native providers JS Style objects when unused JS Style objects when always called
initial layout 1,262 166 0 1,262
resize setup 1,262 166 0 1,262
timed resize 570 130 0 570

Verification

  • vp run check
  • vp run check:wasm
  • Native and WASI both run the same 205-test @taffyjs/node behavior suite
  • @taffyjs/yoga, Yoga Wasm, browser bundles, packed consumers, type tests, Rust tests/clippy, formatting, and website build pass
  • bounded adversarial review covered true laziness, N-API lifetime safety, retained providers, isolation, exceptions, and benchmark attribution; its lifetime finding was fixed and the targeted follow-up passed

The public API and Guide/reference documentation now describe the breaking migration. Because this explicitly supersedes the earlier eager callback direction, the conflicting PCR vouch was removed; Yunfei has vouched the new on-demand decision entry.

Fixes #39

@hyfdev
hyfdev marked this pull request as ready for review August 17, 2026 15:10
Copilot AI lite review requested due to automatic review settings August 17, 2026 15:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Avoid rebuilding the full Style for every measure callback

2 participants