diff --git a/.agents/docs/architecture.md b/.agents/docs/architecture.md index 1b4cf6f..d004614 100644 --- a/.agents/docs/architecture.md +++ b/.agents/docs/architecture.md @@ -40,6 +40,8 @@ Separate-process fixtures are justified only when the process mode or containmen ## Read boundary -Style, Layout, detailed Grid data, child arrays, and measure arguments cross the boundary as complete detached values. Binding-produced records are recursively readonly in TypeScript but remain ordinary mutable objects at runtime. No live Rust borrow, native-backed view, cache, lazy property, selector, prepared query, or batch snapshot is part of the current API. +Direct reads of Style, Layout, detailed Grid data, and child arrays cross the boundary as complete detached values. Binding-produced records are recursively readonly in TypeScript but remain ordinary mutable objects at runtime. No live Rust borrow, native-backed view, JavaScript data cache, lazy property, selector, prepared query, or batch snapshot is part of the current direct-read API. -Only a real consumer workload and complete measurements can justify another read path; that work is tracked in [API alignment TODOs](api-alignment-todos.md). +Measure arguments eagerly carry the small, commonly used constraints, node identity, and context. The complete Style capability remains available through `getStyle()`, but its large JavaScript value is created only when the callback calls that function. Each compute owns one Rust Style snapshot and one native provider for every node that reaches the callback, reuses that provider across the node's requests, and returns a fresh detached complete Style object on every call. A retained provider remains safe after the callback and compute return because it owns the Rust snapshot rather than borrowing or re-entering the busy tree. + +Large, rarely used callback data should be generated on demand when measurements show that eager delivery dominates the boundary. Preserve the public semantic capability and ordinary detached output without mechanically copying Rust's eager callback argument conversion. Any additional read path still requires a real consumer workload and complete measurements as tracked in [API alignment TODOs](api-alignment-todos.md). diff --git a/.agents/docs/binding-cases.md b/.agents/docs/binding-cases.md index 40dd420..5b06881 100644 --- a/.agents/docs/binding-cases.md +++ b/.agents/docs/binding-cases.md @@ -6,7 +6,7 @@ Their purpose is to test and improve the design method, then provide worked reas The feedback loop is: apply the current rules to concrete upstream behavior; distinguish mechanical derivation from a genuine product choice; ask for human judgment only when evidence and existing rules cannot determine the public contract; incorporate the correction into both the case and the shared mapping reference; then stop when further detail would only repeat an established rule. Completing a case means that its practical test has produced all of its useful API-design reference, not that the project has moved to another delivery phase. -Snapshot materialization is deliberately outside this case series. The [read boundary](architecture.md#read-boundary) retains complete eager snapshots, while the [performance TODO](api-alignment-todos.md#performance) preserves measured lazy and selective possibilities without turning them into an initial API or another alignment case. +Direct reads still materialize complete snapshots. The measured callback path now preserves the same complete Style capability through on-demand `getStyle()` delivery as recorded in the [read boundary](architecture.md#read-boundary); selective reads remain separate work under the [performance TODO](api-alignment-todos.md#performance). The evidence baseline for this case is Taffy 0.13.0, napi 3.12.0, napi-derive 3.6.2, and @napi-rs/cli 3.8.2. @@ -98,7 +98,7 @@ This case is closed as an API mapping exercise. It fixes the outer state owner, ## Case 2: Style values and conversion boundaries -This case maps the complete Style value that JavaScript supplies to node creation and replacement and the owned readonly Style value that a measure callback receives. It is intended to establish reusable value-mapping rules, not merely settle the spelling of one Style field. +This case maps the complete Style value that JavaScript supplies to node creation and replacement and the owned readonly Style value returned by direct reads or a measure callback's `getStyle()`. It is intended to establish reusable value-mapping rules, not merely settle the spelling of one Style field. This case is complete as an API-alignment example. Its reference value is that the selected container and value-family rules are sufficient to classify every currently known Style field without reviewing all 41 fields individually. The exhaustive inventory is intentionally outside the example because repeating already covered categories would add no new alignment reasoning. @@ -157,7 +157,7 @@ The reusable geometry declarations referenced here are selected below. The explicit `| undefined` members keep the declarations truthful for consumers that enable TypeScript's `exactOptionalPropertyTypes`: the runtime accepts both a missing property and an explicitly undefined property. Those forms use the field's default for construction and replacement and preserve the field for update, while `null` explicitly requests `None` for a publicly nullable field. Conversion stores only the resulting Rust value: when a default is `Some(value)`, omission or `undefined` and `null` remain observably different during replacement; when the default is already `None`, they converge. Every property where `null` and `undefined` differ must explain the operation-specific omission and explicit-null meanings in property-level JSDoc rather than relying only on an interface-level note. A complete `Style` keeps every field present and readonly, emits the concrete value for `Some(value)`, emits `null` for `None`, and never uses a missing or `undefined` field for nullable output. -`StyleInput` properties remain mutable in TypeScript. Conversion uses ordinary property access and does not inspect, reject, copy, freeze, or repeat validation solely because an input may contain accessors or be a Proxy; the caller owns those behaviors and side effects. Every binding-produced `Style` record uses readonly TypeScript properties because it is a detached snapshot whose mutation cannot change native state. The direct runtime representation is a complete eagerly materialized ordinary plain object without runtime freezing, sealing, or a Proxy. No lazy or selective output facility belongs to the initial API. +`StyleInput` properties remain mutable in TypeScript. Conversion uses ordinary property access and does not inspect, reject, copy, freeze, or repeat validation solely because an input may contain accessors or be a Proxy; the caller owns those behaviors and side effects. Every binding-produced `Style` record uses readonly TypeScript properties because it is a detached snapshot whose mutation cannot change native state. Direct `getStyle(node)` reads and each callback `getStyle()` call return a complete materialized ordinary plain object without runtime freezing, sealing, or a Proxy. The callback does not create that object unless the function is called; this changes delivery cost without changing the Style value mapping. The first checkpoint is demonstrated by ordinary integration cases: `{}` and explicit `undefined` produce Taffy defaults; `setStyle(node, {})` resets a previously nondefault Style instead of merging; a partial fixed-shape nested record fills each omitted component from the corresponding enclosing Style default rather than stored state; an unknown top-level or partial-record component and `null` for a required field throw without changing the stored Style; `null` succeeds only for a publicly nullable field; a complete output uses `null` rather than an omitted or undefined field for Rust `None`; and declarations accept explicit `undefined` under `exactOptionalPropertyTypes` and retain the required property-level JSDoc. Getter- or Proxy-driven tree mutation is deliberately not a baseline fixture. @@ -533,7 +533,7 @@ The case reached closure through four layers, each reusing the previous rules: 1. Fix the outer container, default, absence, unknown-field, replacement, and mutation-boundary semantics above. 2. Map scalar, closed-enum, alignment, geometry, semantic-length, and grid families, including canonical input and output forms and numeric conversion. 3. Confirm that every currently known Style type belongs to one of those families. The complete inventory belongs in maintained source and conversion code; behavior tests cover representative categories instead of copying the inventory. Only a value that does not fit an established family would add a new alignment question. -4. Select the complete eager plain-object `Style` snapshot as the direct baseline. Later optimization research does not reopen this value-mapping case or add an initial implementation requirement. +4. Select the complete plain-object `Style` snapshot as the value contract. Direct reads materialize it immediately; measured callback evidence may change when conversion happens without reopening its field mapping or detached-value semantics. ### Selected mapping categories @@ -550,9 +550,9 @@ Representations follow semantic role rather than copy Rust declaration kinds mec ### Input and output are separate decisions -`StyleInput` is a mutable input convenience around a complete Rust value and may omit defaulted fields. The baseline `getStyle(node)` method reads Taffy's complete stored Style and returns an owned readonly `Style` snapshot, and the measure callback receives the same complete readonly value semantics. The initial runtime representation for both outputs is a complete eagerly materialized ordinary plain object. It is not frozen, sealed, proxied, cached, or a mutable view of tree state. Input and output still have different declaration shapes and defaulting semantics even though both initially use ordinary objects. +`StyleInput` is a mutable input convenience around a complete Rust value and may omit defaulted fields. The `getStyle(node)` method reads Taffy's complete stored Style and returns an owned readonly `Style` snapshot, and a measure callback's `getStyle()` function returns the same complete readonly value semantics. Each result is a newly materialized ordinary plain object. It is not frozen, sealed, proxied, cached, or a mutable view of tree state. Input and output still have different declaration shapes and defaulting semantics even though both use ordinary objects. -Output cost must be evaluated in the concrete path that pays it. `getStyle(node)` and the existing `computeLayoutWithMeasure` callback keep complete eager Style snapshots. Changing callback delivery, selecting fields from direct reads, batching nodes, or introducing another representation would be separate optimizations that require a measured workload and a new decision. Runtime sealing and freezing are not part of the complete baseline, and the initial no-JavaScript-data-cache decision still applies. +Output cost must be evaluated in the concrete path that pays it. Direct `getStyle(node)` remains eager, while `computeLayoutWithMeasure` creates the JavaScript Style only when the callback calls `getStyle()`. The provider owns a Rust Style snapshot so it can be retained safely, and every call returns a new detached result. Selecting fields from direct reads, batching nodes, or introducing another representation remain separate optimizations that require their own evidence. Runtime sealing, freezing, and a JavaScript Style cache are not part of this boundary. ### Closure and escalation rule @@ -577,7 +577,7 @@ This case paid for several corrections that should prevent later mapping work fr ### Questions outside this example -The shared definition and stable codes that this case originally left open are now owned by [API code generation](api-codegen.md). One question may still matter elsewhere in the API, but it does not keep this example open: whether another callback contract should request less data. That output optimization is deferred and does not change the complete direct measure callback. +The shared definition and stable codes that this case originally left open are now owned by [API code generation](api-codegen.md). Whether another callback should expose a selective Style contract remains separate; the direct measure callback keeps complete Style capability through `getStyle()` without eagerly converting it. ### Evidence @@ -654,7 +654,7 @@ Case 3 establishes that a callback boundary must be designed as a complete obser - How a successful return is converted and how a JavaScript failure crosses the upstream callback's actual return type. - Which JavaScript, binding-owned, cached, and upstream states remain observable or require invalidation after failure. -For this direct synchronous Taffy path, those answers are: Taffy controls measurement scheduling; JavaScript owns the exact context while borrowed Rust inputs become owned values; same-tree native access is busy while independent JavaScript values and other trees remain usable; the callback returns one complete `SizeInput`; and the first callback failure is preserved while Taffy's infallible stack drains, the attempted subtree's caches are invalidated, and arbitrary JavaScript side effects and stored Layout are not rolled back. Once these sequence boundaries are fixed, the record, scalar, enum, readonly-output, and ordinary-object rules from Case 2 apply mechanically to the callback payloads. +For this direct synchronous Taffy path, those answers are: Taffy controls measurement scheduling; JavaScript owns the exact context while borrowed Rust inputs become owned values or, for Style, one provider-owned snapshot per callback-reached node and compute; same-tree native access is busy while independent JavaScript values, retained Style providers, and other trees remain usable; the callback returns one complete `SizeInput`; and the first callback failure is preserved while Taffy's infallible stack drains, the attempted subtree's caches are invalidated, and arbitrary JavaScript side effects and stored Layout are not rolled back. Once these sequence boundaries are fixed, the record, scalar, enum, readonly-output, and ordinary-object rules from Case 2 apply mechanically to materialized callback payloads. ### Closure and escalation rule @@ -679,7 +679,7 @@ This case produced several corrections that should guide later callback alignmen ### Outside this case -Retained per-node callbacks, automatically observed or proxy-backed context conveniences, asynchronous or off-thread layout, cancellation, batching, and a callback-specific selective-data contract are separate contracts. Deferred snapshot and callback-delivery experiments remain in the [performance TODO](api-alignment-todos.md#performance) and do not change this case's callback sequence. +Retained per-node measurement callbacks, automatically observed or proxy-backed context conveniences, asynchronous or off-thread layout, cancellation, batching, and a callback-specific selective-data contract are separate contracts. Retaining the current callback's owned `getStyle` provider is supported and does not retain the tree or a Rust borrow; it is part of the current snapshot-delivery lifetime rather than a retained measurement callback. ### Evidence diff --git a/.agents/docs/binding-mapping.md b/.agents/docs/binding-mapping.md index a528497..98adede 100644 --- a/.agents/docs/binding-mapping.md +++ b/.agents/docs/binding-mapping.md @@ -80,9 +80,9 @@ Simple scalar and fixed-object fields use concrete napi-rs types. Local `#[napi( ## Output conversion -Borrowed Rust values never escape. Style, Layout, child arrays, detailed Grid data, available space, and nested records are copied into complete detached JavaScript values. +Borrowed Rust values never escape. Direct Style reads, Layout, child arrays, detailed Grid data, available space, and nested records are copied into complete detached JavaScript values. A measure callback's Style is first cloned into an owned Rust snapshot and is converted into a complete detached JavaScript value only when its `getStyle()` function is called. -Binding-produced records and arrays are recursively readonly in TypeScript because mutation cannot update Taffy. Runtime objects remain ordinary mutable, unfrozen objects, and each read returns an independent snapshot. There are no live native views, output caches, lazy properties, selectors, prepared queries, or batch snapshots. +Binding-produced records and arrays are recursively readonly in TypeScript because mutation cannot update Taffy. Runtime objects remain ordinary mutable, unfrozen objects, and each read or callback `getStyle()` call returns an independent snapshot. There are no live native views, output caches, lazy properties, selectors, prepared queries, or batch snapshots; the callback function is an explicit on-demand operation rather than a property that hides an already materialized object. Public TypeScript declarations and JSDoc live in `packages/taffyjs-node/src` and are emitted by `vp pack` into `index.d.ts`. The private native declarations remain napi-rs-generated. @@ -92,13 +92,15 @@ 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 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 measure callback runs synchronously and receives owned `knownDimensions`, `availableSpace`, public NodeId, the original JavaScript context, and `getStyle()`. Calling `getStyle()` returns a fresh complete normalized detached Style snapshot; not calling it performs no `style::output` conversion and creates no JavaScript Style object. 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 tree is busy while the callback runs, so `getStyle()` does not re-enter `TaffyTree::style`. On the first callback request for a node in one compute, `MeasureSession` clones the borrowed Rust Style into an owned snapshot and creates one native function that captures it. Later requests for that node reuse the same function. The native reference is released when the compute returns, while JavaScript retention keeps the function and its captured snapshot alive until ordinary garbage collection. Every call converts the owned snapshot again, so mutating one returned object cannot change the tree or a later result. A new compute creates a new provider from the Style visible at that compute, including completed prior updates. This safe lifetime costs one Rust Style clone and one provider per callback-reached node per compute; it deliberately avoids callback-scoped pointers or borrows. 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. +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 creating a Style provider, and callback throws or binding conversion failures are never stored. The session, exact-result cache, and native provider references 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 diff --git a/.agents/docs/taffyjs-node-decisions.md b/.agents/docs/taffyjs-node-decisions.md index 639bf2a..41b3fa2 100644 --- a/.agents/docs/taffyjs-node-decisions.md +++ b/.agents/docs/taffyjs-node-decisions.md @@ -20,15 +20,25 @@ After the binding has produced a complete representable Rust value and satisfied ## Public data model -[VOUCHED @hyfdev 2026-08-15] - Readable ordinary JavaScript values are the public contract. Inputs are designed to be natural to write, while outputs are designed to make their complete meaning visible. Input and output do not need to use the same runtime representation. Input records remain mutable. Collection-valued inputs accept readonly arrays because the binding only reads them; ordinary mutable arrays remain valid inputs. Binding-produced snapshots are detached and recursively readonly in TypeScript, but runtime objects are not frozen, sealed, proxied, cached, or backed by a live Rust borrow. Closed choices without associated data use singular PascalCase frozen objects with stable numeric literal members, such as `Display.Flex`. When a numeric input has one clear common meaning, callers may use a number as an additive shorthand: a length number means an absolute length, and an available-space number means `Definite`. The complete forms remain supported, including `Dimension.Length(20)` and `AvailableSpace.Definite(640)`; the shorthand does not replace them. Other meanings remain explicit through values such as `Dimension.Percent(50)`, `Dimension.Auto`, `AvailableSpace.MinContent`, and `AvailableSpace.MaxContent`. Values returned by the binding keep complete numeric-tagged records, and those returned values remain valid as later inputs. Other values that carry data, including Grid values, continue to use ordinary tagged records. Public values do not use CSS strings, packed numbers, buffers, or native owner objects. The private representation passed from JavaScript to Rust is a separate implementation choice. Small fixed values may use primitive parameters, and larger values may use a compact buffer when measurements show that it is beneficial. Changing this private representation must not change the public input or output API. -`StyleInput` uses defaults for missing or `undefined` fields, explicit `null` only for publicly nullable fields, strict top-level and partial-geometry field names, and complete replacement in `setStyle`. `getStyle` and measure callbacks receive complete eager snapshots. A measured future optimization may be additive; no selector, query, lazy object, or output cache belongs to the baseline. +`StyleInput` uses defaults for missing or `undefined` fields, explicit `null` only for publicly nullable fields, strict top-level and partial-geometry field names, and complete replacement in `setStyle`. Direct `getStyle(node)` reads return complete eager snapshots. Measure callbacks preserve that complete snapshot capability through an on-demand `getStyle()` function rather than receiving an eager `style` field. No selector, query, lazy proxy, or output cache belongs to the baseline. + +## On-demand Style in measure callbacks + +[VOUCHED @hyfdev 2026-08-17] + +**Ruling:** `MeasureArgs` must expose `getStyle(): Style` instead of an eager `style` field, and no complete JavaScript Style object may be created unless the callback calls that function. + +**Limits:** `knownDimensions`, `availableSpace`, `node`, and `context` remain eager callback arguments. The function returns a fresh complete normalized detached snapshot on every call, may safely escape the callback, observes the Style at the start of its compute, and must not re-enter the busy tree. The safe implementation may clone one Rust Style and create one provider per callback-reached node and compute; this ruling does not approve numeric slots, a compact constraints ABI, per-node measure callbacks, an upstream algorithm change, a JavaScript Style mirror, or unsafe callback-scope handles. + +**Why:** Complete Style conversion is large and uncommon measurement data, and the direct eager Rust-to-JavaScript mapping paid that boundary cost even when callbacks only needed constraints or context. Yunfei required the public semantic capability to remain while materialization moves behind an explicit call, with separate measurements for the unused and used paths so the performance cause remains attributable. + +**Source:** Yunfei (`@hyfdev`), 2026-08-17; explicitly replaced issue #39's earlier eager-Style direction with on-demand `getStyle()`, fixed the public signature and lifetime and isolation requirements, and constrained the accepted implementation and performance experiments in the implementation request for issue #39. ## Value-based NodeId diff --git a/apps/website/guide/measuring-content.md b/apps/website/guide/measuring-content.md index c51101f..156016c 100644 --- a/apps/website/guide/measuring-content.md +++ b/apps/website/guide/measuring-content.md @@ -72,10 +72,12 @@ The callback receives five values: - `availableSpace` contains `AvailableSpace.Definite(value)`, `AvailableSpace.MinContent`, or `AvailableSpace.MaxContent` for each axis. - `node` identifies the leaf being measured. - `context` is the exact JavaScript value stored for that node, or `undefined` when absent. -- `style` is a detached, complete style snapshot. +- `getStyle()` creates and returns a fresh, complete, normalized, detached style snapshot when style affects the measurement. Leave it uncalled when the content measurement does not need style. Return a complete `{ width, height }` record. The callback is synchronous; returning a Promise or an incomplete record throws `TypeError`. +`getStyle()` replaces the earlier eager `style` callback field. The change is breaking, but avoids constructing a large JavaScript `Style` object for callbacks that only need constraints or context. + ## Taffy may ask more than once Taffy decides whether measurement is needed, how many times it is needed, and in which order nodes are visited. It may ask again with different constraints, or reuse a cached result without calling the callback at all. Do not depend on one call per node or a particular traversal order. diff --git a/apps/website/node/computing-layout.md b/apps/website/node/computing-layout.md index 3f1f938..61cfe12 100644 --- a/apps/website/node/computing-layout.md +++ b/apps/website/node/computing-layout.md @@ -46,7 +46,8 @@ Use `enableRounding()` or `disableRounding()` to change the mode. Compute again tree.computeLayoutWithMeasure({ root, availableSpace, - measure({ knownDimensions, availableSpace, node, context, style }) { + measure({ knownDimensions, availableSpace, node, context, getStyle }) { + const style = needsStyle(context) ? getStyle() : undefined; return measureContent({ knownDimensions, availableSpace, node, context, style }); }, }); @@ -56,8 +57,10 @@ Taffy may ask the callback to measure any leaf that needs an intrinsic size. Con The callback is validated as a function before native computation, even when Taffy may satisfy the request from cache. Taffy controls whether it runs, how often it runs, and the order of calls. -`knownDimensions` contains a number for an axis already fixed by layout and `undefined` otherwise. Callback `availableSpace` uses the same three tagged forms described above. `node` is the public ID, `context` is the exact JavaScript value stored for it, and `style` is a detached complete snapshot. The callback must return a complete `{ width, height }` record synchronously. +`knownDimensions` contains a number for an axis already fixed by layout and `undefined` otherwise. Callback `availableSpace` uses the same three tagged forms described above. `node` is the public ID, and `context` is the exact JavaScript value stored for it. Call `getStyle()` only when measurement needs the node's style; each call returns a fresh, complete, normalized, detached `Style` snapshot. If the callback never calls it, taffyjs does not create a JavaScript `Style` object. The callback must return a complete `{ width, height }` record synchronously. -During the callback, native-backed methods on the same tree fail with `ERR_TAFFY_TREE_BUSY`. `getNodeContext`, public value helpers, callback arguments, and operations on another tree remain usable. +This is a breaking change from the earlier callback shape: destructure `getStyle` and call it where needed instead of destructuring an eager `style` value. + +During the callback, native-backed methods on the same tree fail with `ERR_TAFFY_TREE_BUSY`. `getStyle()` is the callback-safe way to read the measured node's style; retained `getStyle` functions also remain safe to call after the callback returns. `getNodeContext`, public value helpers, callback arguments, and operations on another tree remain usable. A thrown callback value or invalid result stops the computation. See [Errors](./errors.md#measurement-failures) for the exact rethrow behavior and the state that remains afterward. diff --git a/apps/website/node/style.md b/apps/website/node/style.md index 6af0a7f..9c4d85e 100644 --- a/apps/website/node/style.md +++ b/apps/website/node/style.md @@ -1,6 +1,6 @@ # Style -`StyleInput` is the partial object accepted when a node is created or passed to `setStyle`; omitted fields and explicit `undefined` use Taffy's defaults. `StyleUpdate` has the same accepted field shapes for `updateStyle`, but omitted fields and explicit `undefined` preserve stored values. `Style` is the complete detached object returned by `getStyle` and supplied to measure callbacks. +`StyleInput` is the partial object accepted when a node is created or passed to `setStyle`; omitted fields and explicit `undefined` use Taffy's defaults. `StyleUpdate` has the same accepted field shapes for `updateStyle`, but omitted fields and explicit `undefined` preserve stored values. `Style` is the complete detached object returned by `TaffyTree.getStyle(node)` or by a measure callback's on-demand `getStyle()` function. Concrete lengths can usually be written as numbers. For example, `size: { width: 200 }` is the concise form of `size: { width: Dimension.Length(200) }`. Percentage, automatic, intrinsic, and Grid-specific values remain explicit. [Styles and Values](../guide/styles-and-values.md) explains these input forms in context. diff --git a/crates/taffyjs_binding/src/lib.rs b/crates/taffyjs_binding/src/lib.rs index 0552cba..b2cbdaa 100644 --- a/crates/taffyjs_binding/src/lib.rs +++ b/crates/taffyjs_binding/src/lib.rs @@ -577,14 +577,14 @@ impl BindingTaffyTree { env: Env, node: BigInt, available_space: Unknown<'env>, - measure: Function<'env, measure::MeasureArguments, Unknown<'env>>, + measure: Function<'env, measure::MeasureArguments<'env>, Unknown<'env>>, ) -> napi::Result<()> { let node = into_napi(env, raw_node_id(&node))?; let available_space = into_napi( env, geometry::size(available_space, available_space::available_space), )?; - let mut session = measure::MeasureSession::new(measure); + let mut session = measure::MeasureSession::new(env, measure); let result = self.owner.access("computeLayoutWithMeasure", |tree| { tree.compute_layout_with_measure( node, diff --git a/crates/taffyjs_binding/src/measure.rs b/crates/taffyjs_binding/src/measure.rs index 303809a..43cd1f0 100644 --- a/crates/taffyjs_binding/src/measure.rs +++ b/crates/taffyjs_binding/src/measure.rs @@ -1,10 +1,12 @@ -use std::collections::HashMap; +use std::collections::{HashMap, hash_map::Entry}; use std::marker::PhantomData; use std::ptr; use std::rc::Rc; -use napi::bindgen_prelude::{BigInt, Either, Function, ToNapiValue, Undefined, Unknown}; -use napi::{JsValue, sys}; +use napi::bindgen_prelude::{ + BigInt, Either, Function, FunctionRef, ToNapiValue, Undefined, Unknown, +}; +use napi::{Env, JsValue, sys}; use napi_derive::napi; use taffy::geometry::Size; use taffy::style::{AvailableSpace, Style}; @@ -26,11 +28,11 @@ pub struct AvailableSpaceSizeOutput { } #[napi(object, object_from_js = false)] -pub struct MeasureArguments { +pub struct MeasureArguments<'env> { pub known_dimensions: KnownDimensionsOutput, pub available_space: AvailableSpaceSizeOutput, pub node: BigInt, - pub style: style::StyleOutput, + pub get_style: Function<'env, (), style::StyleOutput>, } #[napi(object, object_to_js = false)] @@ -87,17 +89,24 @@ pub(crate) enum MeasureFailure<'env> { } pub(crate) struct MeasureSession<'env> { - callback: Function<'env, MeasureArguments, Unknown<'env>>, + env: Env, + callback: Function<'env, MeasureArguments<'env>, Unknown<'env>>, cache: HashMap>, + style_providers: HashMap>, failure: Option>, not_send: PhantomData>, } impl<'env> MeasureSession<'env> { - pub(crate) fn new(callback: Function<'env, MeasureArguments, Unknown<'env>>) -> Self { + pub(crate) fn new( + env: Env, + callback: Function<'env, MeasureArguments<'env>, Unknown<'env>>, + ) -> Self { Self { + env, callback, cache: HashMap::new(), + style_providers: HashMap::new(), failure: None, not_send: PhantomData, } @@ -119,10 +128,16 @@ impl<'env> MeasureSession<'env> { return *size; } - let result = call( - &self.callback, - self.arguments(known_dimensions, available_space, node, style), + let result = arguments( + &self.env, + &mut self.style_providers, + known_dimensions, + available_space, + node, + style, ) + .map_err(MeasureFailure::Binding) + .and_then(|arguments| call(&self.callback, arguments)) .and_then(|value| result_size(value).map_err(MeasureFailure::Binding)); match result { Ok(size) => { @@ -143,26 +158,51 @@ impl<'env> MeasureSession<'env> { pub(crate) fn has_failed(&self) -> bool { self.failure.is_some() } +} - fn arguments( - &self, - known_dimensions: Size>, - available_space: Size, - node: NodeId, - style: &Style, - ) -> MeasureArguments { - MeasureArguments { - known_dimensions: known_dimensions_output(known_dimensions), - available_space: available_space_size_output(available_space), - node: BigInt::from(u64::from(node)), - style: style::output(style), - } +fn arguments<'env>( + env: &'env Env, + style_providers: &mut HashMap>, + known_dimensions: Size>, + available_space: Size, + node: NodeId, + style: &Style, +) -> BindingResult> { + Ok(MeasureArguments { + known_dimensions: known_dimensions_output(known_dimensions), + available_space: available_space_size_output(available_space), + node: BigInt::from(u64::from(node)), + get_style: style_provider(env, style_providers, node, style)?, + }) +} + +fn style_provider<'env>( + env: &'env Env, + style_providers: &mut HashMap>, + node: NodeId, + style: &Style, +) -> BindingResult> { + let node = u64::from(node); + if let Entry::Vacant(entry) = style_providers.entry(node) { + // Taffy only lends Style for this measure call, but JavaScript may retain getStyle. + // Give one provider per node and compute an owned snapshot with an ordinary JS lifetime. + let snapshot = style.clone(); + let provider = env + .create_function_from_closure("getStyle", move |_| Ok(style::output(&snapshot))) + .and_then(|function| function.create_ref()) + .map_err(|_| internal_error())?; + entry.insert(provider); } + style_providers + .get(&node) + .ok_or_else(internal_error)? + .borrow_back(env) + .map_err(|_| internal_error()) } fn call<'env>( - callback: &Function<'env, MeasureArguments, Unknown<'env>>, - arguments: MeasureArguments, + callback: &Function<'env, MeasureArguments<'env>, Unknown<'env>>, + arguments: MeasureArguments<'_>, ) -> Result, MeasureFailure<'env>> { let env = callback.value().env; let mut receiver = ptr::null_mut(); diff --git a/packages/taffyjs-node/binding.d.ts b/packages/taffyjs-node/binding.d.ts index fb4309e..e20343e 100644 --- a/packages/taffyjs-node/binding.d.ts +++ b/packages/taffyjs-node/binding.d.ts @@ -199,7 +199,7 @@ export interface MeasureArguments { knownDimensions: KnownDimensionsOutput; availableSpace: AvailableSpaceSizeOutput; node: bigint; - style: StyleOutput; + getStyle: () => StyleOutput; } export interface MeasureResultInput { diff --git a/packages/taffyjs-node/index.d.ts b/packages/taffyjs-node/index.d.ts index f14fe9c..f3bddfa 100644 --- a/packages/taffyjs-node/index.d.ts +++ b/packages/taffyjs-node/index.d.ts @@ -696,13 +696,14 @@ interface DetailedGridItemInfo { /** Reports the column start value stored in DetailedGridItemInfo. */ readonly columnStart: number; /** Reports the column end value stored in DetailedGridItemInfo. */ readonly columnEnd: number; } -/** Supplies dimensions, available space, identity, context, and style to measurement. */ +/** Supplies dimensions, available space, identity, context, and on-demand style access to measurement. */ type MeasureArgs = Readonly<{ /** Supplies the known dimensions value used by MeasureArgs. */ knownDimensions: Size; /** Supplies the available space value used by MeasureArgs. */ availableSpace: Size; /** Supplies the node value used by MeasureArgs. */ node: NodeId; /** Supplies the context value used by MeasureArgs. */ context: TContext | undefined; - /** Supplies the style value used by MeasureArgs. */ style: Style; + /** Returns a new detached snapshot of the measured node's complete normalized Style. */ + getStyle(): Style; }>; /** Measures synchronously when Taffy requests it; invocation count and order are unspecified, and changed external data requires explicit dirtying. */ type MeasureFunction = (args: MeasureArgs) => SizeInput; diff --git a/packages/taffyjs-node/index.js b/packages/taffyjs-node/index.js index 7523132..ab5d29e 100644 --- a/packages/taffyjs-node/index.js +++ b/packages/taffyjs-node/index.js @@ -1188,7 +1188,7 @@ var TaffyTree = class { availableSpace: args.availableSpace, node, context: this.#contexts.get(node), - style: args.style + getStyle: args.getStyle }); }); } diff --git a/packages/taffyjs-node/src/public-types.ts b/packages/taffyjs-node/src/public-types.ts index 11ced09..0d3affd 100644 --- a/packages/taffyjs-node/src/public-types.ts +++ b/packages/taffyjs-node/src/public-types.ts @@ -539,7 +539,7 @@ export interface DetailedGridItemInfo { /** Reports the column end value stored in DetailedGridItemInfo. */ readonly columnEnd: number; } -/** Supplies dimensions, available space, identity, context, and style to measurement. */ +/** Supplies dimensions, available space, identity, context, and on-demand style access to measurement. */ export type MeasureArgs = Readonly<{ /** Supplies the known dimensions value used by MeasureArgs. */ knownDimensions: Size< number | undefined @@ -547,7 +547,8 @@ export type MeasureArgs = Readonly<{ /** Supplies the available space value used by MeasureArgs. */ availableSpace: Size; /** Supplies the node value used by MeasureArgs. */ node: NodeId; /** Supplies the context value used by MeasureArgs. */ context: TContext | undefined; - /** Supplies the style value used by MeasureArgs. */ style: Style; + /** Returns a new detached snapshot of the measured node's complete normalized Style. */ + getStyle(): Style; }>; /** Measures synchronously when Taffy requests it; invocation count and order are unspecified, and changed external data requires explicit dirtying. */ diff --git a/packages/taffyjs-node/src/tree.ts b/packages/taffyjs-node/src/tree.ts index 6db1004..5202f95 100644 --- a/packages/taffyjs-node/src/tree.ts +++ b/packages/taffyjs-node/src/tree.ts @@ -17,7 +17,7 @@ type RawMeasureArgs = { knownDimensions: Size; availableSpace: Size; node: bigint; - style: Style; + getStyle: () => Style; }; function checkedChildIndex(index: number): number { @@ -243,7 +243,7 @@ export class TaffyTree { availableSpace: args.availableSpace, node, context: this.#contexts.get(node), - style: args.style, + getStyle: args.getStyle, }); }); } diff --git a/tests/taffyjs-node/tests/tree/compute-layout-with-measure.test.mts b/tests/taffyjs-node/tests/tree/compute-layout-with-measure.test.mts index 37f7201..078c19d 100644 --- a/tests/taffyjs-node/tests/tree/compute-layout-with-measure.test.mts +++ b/tests/taffyjs-node/tests/tree/compute-layout-with-measure.test.mts @@ -183,7 +183,7 @@ test("callback-args", () => { "availableSpace", "node", "context", - "style", + "getStyle", ]); assert.deepEqual(saved.knownDimensions, { width: undefined, height: undefined }); assert.deepEqual(saved.availableSpace, { @@ -192,10 +192,57 @@ test("callback-args", () => { }); assert.equal(saved.node, node); assert.equal(saved.context, context); - assert.deepEqual(saved.style, tree.getStyle(node)); + assert.equal(typeof saved.getStyle, "function"); assert.equal(Object.isFrozen(saved), false); - (saved.style as { flexGrow: number }).flexGrow = 99; + + const firstStyle = saved.getStyle(); + assert.deepEqual(firstStyle, tree.getStyle(node)); + (firstStyle as { flexGrow: number }).flexGrow = 99; assert.equal(tree.getStyle(node).flexGrow, Math.fround(1.25)); + const secondStyle = saved.getStyle(); + assert.notEqual(secondStyle, firstStyle); + assert.equal(secondStyle.flexGrow, Math.fround(1.25)); +}); + +test("getStyle provider is reused per node and refreshed for the next compute", () => { + const fixture = createNestedMeasureFixture(FlexDirection.Row); + const providers = new Map ReturnType["getStyle"]>>(); + let repeatedProvider = false; + + fixture.tree.computeLayoutWithMeasure({ + root: fixture.root, + availableSpace: { width: 1280, height: 800 }, + measure(args) { + const previous = providers.get(args.node); + if (previous === undefined) providers.set(args.node, args.getStyle); + else { + repeatedProvider = true; + assert.equal(args.getStyle, previous); + } + return { width: 73, height: 19 }; + }, + }); + + assert.equal(repeatedProvider, true); + const previousProvider = providers.get(fixture.measured); + assert.ok(previousProvider); + assert.equal(previousProvider().flexGrow, 0); + + fixture.tree.setStyle(fixture.measured, { flexGrow: 2 }); + let nextProvider: MeasureArgs["getStyle"] | undefined; + fixture.tree.computeLayoutWithMeasure({ + root: fixture.root, + availableSpace: { width: 1280, height: 800 }, + measure(args) { + if (args.node === fixture.measured) nextProvider ??= args.getStyle; + return { width: 73, height: 19 }; + }, + }); + + assert.ok(nextProvider); + assert.notEqual(nextProvider, previousProvider); + assert.equal(nextProvider().flexGrow, 2); + assert.equal(previousProvider().flexGrow, 0); }); test("result-f32", () => { @@ -439,8 +486,9 @@ test("throw-identity", () => { const node = tree.newLeafWithContext({}, true); let failedCalls = 0; const received = captureError(() => - compute(tree, node, () => { + compute(tree, node, ({ getStyle }) => { failedCalls += 1; + assert.equal(getStyle().flexGrow, 0); throw thrown; }), ); diff --git a/tests/taffyjs-node/tests/types/public-api.test-d.ts b/tests/taffyjs-node/tests/types/public-api.test-d.ts index d8fdd9b..2540de0 100644 --- a/tests/taffyjs-node/tests/types/public-api.test-d.ts +++ b/tests/taffyjs-node/tests/types/public-api.test-d.ts @@ -93,8 +93,11 @@ tree.computeLayoutWithMeasure({ measure(args) { const callbackContext: Context | undefined = args.context; const callbackNode: NodeId = args.node; - const callbackStyle: Style = args.style; - void [callbackContext, callbackNode, callbackStyle]; + const callbackStyle: Style = args.getStyle(); + const getStyle: () => Style = args.getStyle; + // @ts-expect-error MeasureArgs no longer eagerly contains Style. + void args.style; + void [callbackContext, callbackNode, callbackStyle, getStyle]; return { width: 1, height: 2 }; }, });