Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .agents/docs/binding-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,14 @@ Arbitrary context stays in a JavaScript map keyed by current public NodeId. Nati

`setNodeContext` updates native presence and the JavaScript map and marks the node dirty. In-place context changes and callback-captured data cannot be observed automatically; callers use `markDirty` when those changes affect later measurement. Supplying a different callback does not invalidate Taffy's cache by itself.

The measure callback runs synchronously and receives owned `knownDimensions`, `availableSpace`, public NodeId, the original JavaScript context, and a detached Style snapshot. Taffy controls whether, when, and how often it runs. It must return a complete `{ width, height }` number record; Promises, missing axes, and invalid values throw `TypeError`.
The measure callback runs synchronously and receives owned `knownDimensions`, `availableSpace`, public NodeId, the original JavaScript context, and a detached Style snapshot. Taffy controls the requested nodes, constraints, and ordering, subject to the exact-repeat reuse below. The callback must return a complete `{ width, height }` number record; Promises, missing axes, and invalid values throw `TypeError`.

The native owner uses checked `RefCell` access. A native-backed call on the same tree during measurement throws `ERR_TAFFY_TREE_BUSY`; JavaScript-only value operations and another tree remain usable.

On the first callback throw or invalid result, the bridge retains that failure, stops further JavaScript callbacks, lets Taffy's infallible stack finish with internal zero sizes, invalidates the requested subtree, and throws synchronously. A thrown JavaScript value keeps its identity. The tree remains usable, but already completed JavaScript side effects and stored Layout work are not rolled back.

Each `computeLayoutWithMeasure` creates one Rust `MeasureSession` that reuses a successful result when Taffy repeats the exact same request during that compute. The key contains the raw NodeId, both optional known dimensions, and both available-space variants and definite values; every `f32` uses its exact bit representation. Cache lookup happens before creating callback arguments or converting Style, and callback throws or binding conversion failures are never stored. The session and its cache are dropped when the compute returns, so caller-managed dirtying and Taffy's persistent cache semantics remain unchanged across computes.

## Mutation, errors, and panic containment

Validate complete input, every involved NodeId, topology, index, and range before the first ordinary mutation. Failed single-value and collection mutations must not leave partial wrapper or native state. Measured computation is the documented exception because callback failure happens after computation has started.
Expand Down
10 changes: 10 additions & 0 deletions .agents/docs/taffyjs-node-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ The callback returns a complete numeric size. The first thrown value or invalid

Retained, asynchronous, off-thread, cancellable, or transactionally rolled-back measurement would be a different API with its own ownership and failure contract.

## Exact measure request reuse within one compute

**Ruling:** During one `computeLayoutWithMeasure` call, the Rust binding must invoke the public JavaScript measure callback at most once for an exact combination of raw NodeId, both optional known dimensions, and both available-space variants and definite values. Floating-point equality must use the exact `f32` bit representation. Only a callback result that successfully converts to `Size<f32>` may be reused.

**Limits:** Reuse ends with the current compute and adds no persistent tree, Style, context, or JavaScript cache. Style and context are not key inputs because the public tree API cannot mutate either for the same node while its measure callback is running. This does not add a per-node measure API, reuse Style snapshots, or change Taffy's layout or measurement phases. The existing first-failure behavior, subtree invalidation, thrown-value identity, and later retry behavior remain unchanged.

**Why:** The coding-agent chat initial-layout workload showed that Taffy issued 3,420 measure requests but only 1,262 exact argument combinations. Reusing those successful results before Style conversion and the Node-API or WASI boundary removes repeated boundary work without changing a caller-visible input, broadening cache invalidation, or retaining results after the synchronous operation.

**Source:** Yunfei (`@hyfdev`), 2026-08-17; specified the exact key, lifetime, success-only insertion, failure semantics, non-goals, Native/WASI coverage, and benchmark acceptance criteria in [issue #38](https://github.com/hyfdev/taffyjs/issues/38).

## Selective query

### Complete bounded per-node selective reads
Expand Down
120 changes: 116 additions & 4 deletions crates/taffyjs_binding/src/measure.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::marker::PhantomData;
use std::ptr;
use std::rc::Rc;
Expand Down Expand Up @@ -38,13 +39,56 @@ pub struct MeasureResultInput {
pub height: f64,
}

#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
enum AvailableSpaceCacheKey {
Definite(u32),
MinContent,
MaxContent,
}

impl From<AvailableSpace> for AvailableSpaceCacheKey {
fn from(value: AvailableSpace) -> Self {
match value {
AvailableSpace::Definite(value) => Self::Definite(value.to_bits()),
AvailableSpace::MinContent => Self::MinContent,
AvailableSpace::MaxContent => Self::MaxContent,
}
}
}

#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
struct MeasureCacheKey {
node: u64,
known_width: Option<u32>,
known_height: Option<u32>,
available_width: AvailableSpaceCacheKey,
available_height: AvailableSpaceCacheKey,
}

impl MeasureCacheKey {
fn new(
node: NodeId,
known_dimensions: Size<Option<f32>>,
available_space: Size<AvailableSpace>,
) -> Self {
Self {
node: u64::from(node),
known_width: known_dimensions.width.map(f32::to_bits),
known_height: known_dimensions.height.map(f32::to_bits),
available_width: available_space.width.into(),
available_height: available_space.height.into(),
}
}
}

pub(crate) enum MeasureFailure<'env> {
Callback(Unknown<'env>),
Binding(BindingError),
}

pub(crate) struct MeasureSession<'env> {
callback: Function<'env, MeasureArguments, Unknown<'env>>,
cache: HashMap<MeasureCacheKey, Size<f32>>,
failure: Option<MeasureFailure<'env>>,
not_send: PhantomData<Rc<()>>,
}
Expand All @@ -53,6 +97,7 @@ impl<'env> MeasureSession<'env> {
pub(crate) fn new(callback: Function<'env, MeasureArguments, Unknown<'env>>) -> Self {
Self {
callback,
cache: HashMap::new(),
failure: None,
not_send: PhantomData,
}
Expand All @@ -69,13 +114,21 @@ impl<'env> MeasureSession<'env> {
return Size::ZERO;
}

let cache_key = MeasureCacheKey::new(node, known_dimensions, available_space);
if let Some(size) = self.cache.get(&cache_key) {
return *size;
}

let result = call(
&self.callback,
self.arguments(known_dimensions, available_space, node, style),
)
.and_then(|value| result_size(value).map_err(MeasureFailure::Binding));
match result {
Ok(size) => size,
Ok(size) => {
self.cache.insert(cache_key, size);
size
}
Err(failure) => {
self.failure = Some(failure);
Size::ZERO
Expand Down Expand Up @@ -190,10 +243,69 @@ pub(crate) fn invalidate_subtree(tree: &mut TaffyTree<()>, root: NodeId) -> Bind
mod tests {
use std::marker::PhantomData;

use taffy::TaffyTree;
use taffy::style::Style;
use taffy::geometry::Size;
use taffy::style::{AvailableSpace, Style};
use taffy::{NodeId, TaffyTree};

use super::{AvailableSpaceCacheKey, MeasureCacheKey, MeasureSession, invalidate_subtree};

use super::{MeasureSession, invalidate_subtree};
fn cache_key(
node: u64,
known_width: Option<f32>,
known_height: Option<f32>,
available_width: AvailableSpace,
available_height: AvailableSpace,
) -> MeasureCacheKey {
MeasureCacheKey::new(
NodeId::from(node),
Size {
width: known_width,
height: known_height,
},
Size {
width: available_width,
height: available_height,
},
)
}

#[test]
fn measure_cache_key_preserves_every_exact_input() {
let first_nan = f32::from_bits(0x7fc0_0000);
let second_nan = f32::from_bits(0x7fc0_0001);
assert_eq!(
cache_key(
7,
Some(-0.0),
Some(first_nan),
AvailableSpace::Definite(-0.0),
AvailableSpace::Definite(second_nan),
),
MeasureCacheKey {
node: 7,
known_width: Some((-0.0f32).to_bits()),
known_height: Some(first_nan.to_bits()),
available_width: AvailableSpaceCacheKey::Definite((-0.0f32).to_bits()),
available_height: AvailableSpaceCacheKey::Definite(second_nan.to_bits()),
}
);
assert_eq!(
cache_key(
8,
None,
None,
AvailableSpace::MinContent,
AvailableSpace::MaxContent,
),
MeasureCacheKey {
node: 8,
known_width: None,
known_height: None,
available_width: AvailableSpaceCacheKey::MinContent,
available_height: AvailableSpaceCacheKey::MaxContent,
}
);
}

#[test]
fn measure_session_stays_on_the_javascript_thread() {
Expand Down
Loading
Loading