From bdcf33498f37286ef404d0005b1a3ff0720ab101 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 23 Aug 2026 17:34:58 +0800 Subject: [PATCH 1/5] refactor(node): use raw Taffy node IDs --- .agents/docs/architecture.md | 4 +- .agents/docs/binding-cases.md | 31 ++-- .agents/docs/binding-mapping.md | 12 +- .agents/docs/query-api-design.md | 2 +- .agents/docs/style-codec.md | 4 +- .agents/docs/taffyjs-node-decisions.md | 36 +++-- .agents/docs/technology-stack.md | 2 +- apps/website/node/design.md | 2 +- apps/website/node/errors.md | 19 ++- apps/website/node/index.md | 2 +- apps/website/node/nodes-and-topology.md | 17 +- crates/taffyjs_binding/src/error.rs | 15 -- crates/taffyjs_binding/src/owner.rs | 17 ++ packages/taffyjs-node/index.d.ts | 6 +- packages/taffyjs-node/index.js | 152 +++++------------- packages/taffyjs-node/src/node-id.ts | 89 +--------- packages/taffyjs-node/src/tree.ts | 101 +++++------- .../tests/native/node-id.test.mts | 15 ++ .../tests/tree/add-child.test.mts | 32 ---- tests/taffyjs-node/tests/tree/clear.test.mts | 36 ----- .../tests/tree/compute-layout.test.mts | 24 --- .../tests/tree/constructor.test.mts | 2 +- .../tests/tree/get-child-at-index.test.mts | 19 --- .../tests/tree/get-child-count.test.mts | 28 ---- .../tests/tree/get-children.test.mts | 25 --- .../tree/get-detailed-layout-info.test.mts | 33 ---- .../tests/tree/get-layout.test.mts | 29 ---- .../tests/tree/get-node-context.test.mts | 28 ---- .../tests/tree/get-parent.test.mts | 26 --- .../tests/tree/get-style.test.mts | 25 --- .../tests/tree/get-unrounded-layout.test.mts | 38 ----- .../tests/tree/insert-child-at-index.test.mts | 38 ----- .../taffyjs-node/tests/tree/is-dirty.test.mts | 25 --- .../tests/tree/mark-dirty.test.mts | 25 --- .../tests/tree/new-leaf-with-context.test.mts | 7 - .../taffyjs-node/tests/tree/new-leaf.test.mts | 11 +- .../tests/tree/new-with-children.test.mts | 19 --- .../tests/tree/remove-child-at-index.test.mts | 3 - .../tests/tree/remove-child.test.mts | 35 ---- tests/taffyjs-node/tests/tree/remove.test.mts | 55 +------ .../tree/replace-child-at-index.test.mts | 39 ----- .../tests/tree/set-children.test.mts | 15 -- .../tests/tree/set-node-context.test.mts | 38 ----- .../tests/tree/set-style.test.mts | 34 ---- .../tests/tree/update-style.test.mts | 34 ---- .../tests/types/public-api.test-d.ts | 2 +- .../tests/values/node-id.test.mts | 46 ++---- .../tests/values/style-codec.test.mts | 26 +-- 48 files changed, 218 insertions(+), 1105 deletions(-) create mode 100644 packages/taffyjs-node/tests/native/node-id.test.mts diff --git a/.agents/docs/architecture.md b/.agents/docs/architecture.md index d717f50..6c72c96 100644 --- a/.agents/docs/architecture.md +++ b/.agents/docs/architecture.md @@ -6,7 +6,7 @@ This repository is a small Rust and JavaScript monorepo with one shared napi-rs - `.agents/` holds durable project intent and decisions that cannot be expressed in code or configuration. - `crates/taffyjs_binding` owns the napi-rs adapter and depends directly on Taffy. A shared Rust crate is justified only when a second Rust consumer needs it. -- `packages/taffyjs-node` owns the public ESM wrapper and declarations, the private napi-rs loader and declarations, and npm metadata. The wrapper owns JavaScript-only NodeId validity data, context, and per-node measure functions; Taffy owns topology, Style, Layout, cache, computation state, and the native context/measure-presence metadata used to decide whether a leaf may cross the JavaScript boundary. +- `packages/taffyjs-node` owns the public ESM wrapper and declarations, the private napi-rs loader and declarations, and npm metadata. The wrapper owns JavaScript context and per-node measure functions keyed by raw NodeId values, but no NodeId validity data; Taffy owns NodeId allocation and lifetime, topology, Style, Layout, cache, computation state, and the native context/measure-presence metadata used to decide whether a leaf may cross the JavaScript boundary. - `packages/taffyjs-wasm` compiles the same authored source from `packages/taffyjs-node/src` against generated private Node and browser adapters that share one inline payload. It owns package metadata, generated artifacts, and build-time binding redirection; its package-specific typed generator lives under `tools/taffy-wasm`. It has no authored public wrapper source or separate initialization API. - `packages/taffyjs-yoga` owns the native ESM Yoga 3.2.1 compatibility facade, Yoga declarations and visible state, input translation, and Yoga-shaped output projection. It depends only on the public `@taffyjs/node` package boundary and does not modify Taffy or the native binding. - `packages/taffyjs-yoga-wasm` builds the exact facade source owned by `packages/taffyjs-yoga/src` against the public `@taffyjs/wasm` backend. It owns only its package metadata and build-time backend redirection; it has no copied facade source or second compatibility policy. @@ -46,6 +46,6 @@ Separate-process fixtures are justified only when the process mode or containmen 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. -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. The private constraints cross in a caller-owned reusable `Float64Array` record rather than in the callback argument, which carries only that `getStyle` handle; presence and variant tags occupy their own slot, the node id occupies two, and the wrapper reconstructs the public `knownDimensions`, `availableSpace`, and `NodeId` before invoking the user function. Extra properties on the returned size are ignored; missing or invalid `width` and `height` fail. +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. The private constraints cross in a caller-owned reusable `Float64Array` record rather than in the callback argument, which carries only that `getStyle` handle; presence and variant tags occupy their own slot, the raw `u64` node id occupies two, and the wrapper reconstructs the public `knownDimensions`, `availableSpace`, and `NodeId` before invoking the user function. Extra properties on the returned size are ignored; missing or invalid `width` and `height` fail. 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 02f816e..9288d98 100644 --- a/.agents/docs/binding-cases.md +++ b/.agents/docs/binding-cases.md @@ -34,7 +34,9 @@ Readonly TypeScript fields describe the snapshot boundary to TypeScript consumer `NodeId` derives equality from its single stored `u64`. Two separately created TaffyTree instances can issue equal NodeId values. Passing one tree's NodeId to another tree can therefore access the other tree's node when the numeric keys match instead of producing an error. Using a NodeId after removal can index missing storage and panic. -The JavaScript binding must do more than Rust's `NodeId == NodeId`: it must know which tree issued a public NodeId and whether the node still exists before calling Taffy. +The raw value is a SlotMap key containing a slot and generation. Ordinary removal and reuse changes that generation, so an old key normally does not name the replacement. This prevents accidental aliasing inside the SlotMap but does not attach a tree owner to the key, guarantee against eventual generation wrap, or make every stale access return `Result`: several pinned TaffyTree methods directly index their SlotMaps. + +The binding deliberately inherits this identity model. A live NodeId from the receiving tree is a caller precondition, not a property authenticated by the JavaScript wrapper. ### JavaScript boundary @@ -51,7 +53,7 @@ const layout = tree.getLayout(child); The public `TaffyTree` wrapper stores one private native tree in `#inner`, which remains the sole layout-state owner. `child` and `root` are bigint values that JavaScript can retain and pass back to tree methods; they do not own a second Style, Layout, or child list. -The public type is a bigint with a private phantom type marker and a private numeric encoding: +The public type is a bigint with a private phantom type marker: ```ts declare const phantomMarker: unique symbol; @@ -61,32 +63,31 @@ export type NodeId = bigint & { }; ``` -The private `unique symbol` is a phantom marker: it keeps an ordinary bigint from satisfying `NodeId` accidentally during type checking but has no runtime representation. It does not stop JavaScript from constructing or modifying a bigint, so runtime checks remain mandatory. +The private `unique symbol` keeps an ordinary bigint from satisfying `NodeId` accidentally during type checking but has no runtime representation. At runtime the value is exactly Taffy's raw `u64` NodeId. The public wrapper checks that an input is a bigint in the `u64` range, and the native boundary repeats that lossless conversion check; neither check proves issuance, ownership, or liveness. -The bigint logically contains a tree identity, a binding-issued serial for that node creation, and the raw Taffy NodeId. The exact field widths and encoding are not public. Each public tree wrapper keeps a private registry such as: +There is no public-to-raw translation or node registry: ```ts #inner: BindingTaffyTree; -#nodes: PrivateNodeRegistry; +#contexts: Map; +#measures: Map>; ``` -This registry contains one entry for each node currently stored in the TaffyTree. It is binding identity metadata, not a copy of Style, Layout, parent, children, or cache state. - -Before native access, the wrapper confirms that each NodeId is a bigint with the complete private format, was issued by the target tree, and still matches the current registry entry for its raw Taffy NodeId. An ordinary object, a malformed bigint, a NodeId from another tree, and a NodeId for a removed node produce controlled JavaScript errors through @taffyjs/node. +The latter two maps retain JavaScript-owned values only for nodes that use them; they are not a live-node index. Creation returns the native bigint unchanged, native node queries return the same values unchanged, and consuming methods pass the checked `u64` bigint directly back to native. -The JavaScript registry is the sole NodeId-validity registry for the supported API, so its single current-node lookup happens immediately before the synchronous native call in the ordinary value-object path. The baseline does not add a second lookup or a dedicated defensive copy solely for a getter or Proxy trap that mutates the same tree during argument conversion; that re-entrant value-object behavior is outside the initial guarantee. This is an internal implementation constraint rather than additional public NodeId behavior. Direct calls to the separate private platform packages bypass the wrapper and are deliberately outside the public API and its safety guarantee. +Independent trees commonly issue equal values when they perform the same allocation sequence. If a foreign value numerically matches a live key in the receiving tree, the operation addresses that receiving-tree node. If a forged, foreign, or stale in-range key does not match a live node, behavior follows the pinned Taffy operation and is unsupported rather than receiving an `invalid`, `foreign`, or `stale` classification. -A successful creation adds the corresponding registry entry, a successful removal deletes it, and clearing the tree clears the registry. A supported operation must not report success while exposing disagreement between the native tree and that registry. If a later node receives the same raw Taffy NodeId, it receives a new binding-issued serial and therefore a different public NodeId. The old bigint remains an ordinary JavaScript value but no longer passes the registry check. +This Taffy revision can panic on a missing SlotMap key because several high-level methods index directly even when their signatures return `TaffyResult`. Every native tree operation already runs inside `TreeOwner::access`; its `catch_unwind` boundary turns such an unexpected panic into an internal failure and poisons the tree before further native use. That is process-safety containment, not supported invalid-key behavior, and it does not require a duplicate live-node table. -The registry does not need weak keys or automatic cleanup when application code drops a NodeId. TaffyTree itself continues to own that node until explicit removal, clearing, or collection of the whole tree. The registry therefore grows with the nodes still stored in the tree, not with the number of nodes ever created. A bigint NodeId does not retain a reference to its tree, so collecting the tree also collects its registry. +Style encoding still checks that consumed NodeIds are representable bigints before reading caller-controlled Style properties. It does not check owner or liveness first. A getter or Proxy that removes a node during conversion and later causes native invalid-key behavior is caller-controlled re-entry outside the supported ordinary-value path, consistent with the earlier correction in this case. ### Equality in JavaScript -Every query that returns the same current node recreates the same bigint value. JavaScript can therefore use `===`, `Map`, `Set`, and `includes` directly. Equality states that two values name the same binding-issued node identity; it does not prove that the node is still present. Liveness is checked when a tree operation consumes the NodeId, so no separate `isSameNode` API is needed for ordinary identity comparisons. +Every query that returns the same current node returns the same bigint value. JavaScript can therefore use `===`, `Map`, `Set`, and `includes` directly within one tree. Equality across different trees has no node-identity meaning, and equality never proves that a node is still present. No separate `isSameNode` API is needed because supported identity comparisons already have the receiving tree as their scope. ### Conclusion -This case is closed as an API mapping exercise. It fixes the outer state owner, stored-layout behavior, owned snapshot boundary, public NodeId value model, JavaScript equality behavior, cross-tree and stale-node rejection, registry lifetime, internal-ID reuse behavior, and the no-data-cache boundary. Those conclusions are implemented; the private NodeId bit layout remains an internal detail. The current vouched product wording is kept in [@taffyjs/node decisions](taffyjs-node-decisions.md), and the reusable implementation rules are in [Taffy-to-Node binding mapping](binding-mapping.md#nodeid). +This case is closed as an API mapping exercise. It fixes the outer state owner, stored-layout behavior, owned snapshot boundary, raw public NodeId value model, same-tree JavaScript equality behavior, caller-owned tree and lifetime preconditions, ordinary SlotMap reuse behavior, panic containment, and the no-data-cache boundary. Those conclusions are implemented; callers still treat the raw bigint as opaque and nonpersistent. The current vouched product wording is kept in [@taffyjs/node decisions](taffyjs-node-decisions.md), and the reusable implementation rules are in [Taffy-to-Node binding mapping](binding-mapping.md#nodeid). ### Evidence @@ -116,7 +117,7 @@ The direct analogue of default-based Rust Style construction is a plain `StyleIn Fixed-shape geometry records used directly as Style fields are partial on input for the same default-based construction. Each missing or explicit-`undefined` `Point`, `Size`, `Rect`, or `Line` component comes from that component of the enclosing field's `Style::DEFAULT` value. Thus `size: { width: value }` gets the default `auto` height, `padding: { left: value }` gets zero for the other three sides, and `inset: { left: value }` gets `auto` for the other three sides. This never reads the stored Style. Unknown enumerable string components are rejected because every component is optional and a misspelling would otherwise silently select the default. This rule does not make tagged semantic values, grid payloads, or arbitrary nested records recursively partial. -The public boundary treats Style as an ordinary data object. The generated encoder reads every known property once, ignores unknown top-level properties without enumeration, and keeps its byte storage owned through the synchronous native call. Methods consuming NodeIds resolve them before encoding so getter or Proxy re-entry cannot change stale or foreign ID precedence; nested Style operations receive separate temporary storage. +The public boundary treats Style as an ordinary data object. The generated encoder reads every known property once, ignores unknown top-level properties without enumeration, and keeps its byte storage owned through the synchronous native call. Methods consuming NodeIds check their bigint and `u64` representation before encoding; owner and liveness remain caller preconditions, so getter or Proxy re-entry receives no stale or foreign error precedence. Nested Style operations receive separate temporary storage. The first checkpoint selects the following container rules: @@ -127,7 +128,7 @@ The first checkpoint selects the following container rules: | `null` | Reject `null` when a field's public value cannot be empty. Accept it for a publicly nullable field, currently backed by Taffy `Option`, where it explicitly means `None`; omission and `undefined` still mean “use this Taffy version's default.” | This preserves explicit absence for `aspectRatio`, alignment fields, and `gridTemplateAreas`. If a nullable field defaults to `Some(value)`, omission and `undefined` select that value while `null` still selects `None`. | | Unknown fields | Ignore unknown top-level Style properties without enumerating them; continue to reject unknown components of partial geometry records. | The binding reads only the fields it needs, and supplying correct field names is the caller's responsibility, so a misspelled top-level field selects that field's default. Partial geometry records stay strict because a misspelled component would instead default or preserve one part of a field the caller did supply. | | `setStyle` | Replace the complete stored Style with the default-plus-supplied value. | This preserves `TaffyTree::set_style`; merging with the previous Style would require a native read and define a different operation. | -| Conversion and mutation | Resolve consumed NodeIds, encode known Style properties once, then call native synchronously; compact decoding and complete candidate validation finish before replacing tree state. | The operation preserves public error precedence and reaches mutation only with a complete Rust value, without a second property walk or rich-object conversion. | +| Conversion and mutation | Check consumed NodeIds are representable `u64` bigints, encode known Style properties once, then call native synchronously; compact decoding and complete candidate validation finish before replacing tree state. | Representation errors retain deterministic precedence and mutation receives a complete Rust value without adding owner/liveness lookup, a second property walk, or rich-object conversion. | Under these rules, input and complete output have deliberately different outer shapes: diff --git a/.agents/docs/binding-mapping.md b/.agents/docs/binding-mapping.md index e952d24..e84884c 100644 --- a/.agents/docs/binding-mapping.md +++ b/.agents/docs/binding-mapping.md @@ -8,25 +8,25 @@ Recheck version-sensitive behavior whenever Taffy, napi-rs, Node.js, or TypeScri `@taffyjs/node` exposes Taffy's high-level `TaffyTree` workflow: create nodes, mutate topology and style, attach JavaScript context, compute layout, and read stored results. Low-level custom-tree traits, cache internals, CSS parsing, Yoga compatibility, and Rust implementation helpers are outside this package. -Taffy remains the only owner of topology, Style, Layout, cache, and computation state. The authored JavaScript wrapper owns only data that Taffy cannot represent safely for JavaScript: public NodeId validity metadata, arbitrary JavaScript context values, and per-node JavaScript measure functions. There is no JavaScript shadow tree or cache of Taffy-owned data. +Taffy remains the only owner of topology, Style, Layout, cache, and computation state. The authored JavaScript wrapper owns only data that Taffy cannot retain safely for JavaScript: arbitrary JavaScript context values and per-node JavaScript measure functions. There is no JavaScript shadow tree, NodeId registry, or cache of Taffy-owned data. The napi-rs class and generated loader are private implementation modules. `packages/taffyjs-node/src/binding.ts` is the only maintained TypeScript file that imports the generated loader; the wrapper and private native tests use that entry. Public consumers import only `@taffyjs/node`; direct platform-package access bypasses the wrapper's guarantees. ## Public and native calls -The public `TaffyTree` is implemented in TypeScript and owns one private native tree. Public methods validate wrapper-owned state, then synchronously call a small raw native operation. Public method names are not sent through Node-API; the native busy diagnostic is selected inside each Rust method. +The public `TaffyTree` is implemented in TypeScript and owns one private native tree. Public methods validate representability and wrapper-owned callback or context state, then synchronously call a small raw native operation. Public method names are not sent through Node-API; the native busy diagnostic is selected inside each Rust method. Layout remains explicit. `getLayout` and `getUnroundedLayout` return what Taffy currently stores, including zero before the first compute and an older value after a mutation. Only `computeLayout` performs layout work; its options may include a call-scoped global measurement fallback. ## NodeId -`NodeId` is an opaque TypeScript-branded `bigint`. Its private encoding contains a per-tree random token, a binding-issued creation serial, and Taffy's raw NodeId. The bit layout is not a persistence or arithmetic format. +`NodeId` is an opaque TypeScript-branded `bigint` whose runtime value is exactly Taffy's raw `u64` NodeId. The TypeScript marker prevents accidental mixing with general bigints in checked code but has no runtime representation. The wrapper checks only that a consumed value is a bigint within the `u64` range; the native boundary repeats the lossless conversion check. -Each public tree keeps one `raw NodeId -> current serial` map. Every consumed ID must be a well-formed bigint, belong to that tree, and match the current serial. Removal deletes its entry; clear deletes all entries; reuse of a native slot receives a new serial. Repeated reads of the same live node recreate the same bigint, so `===`, `Map`, `Set`, and `includes` work normally. +Taffy's raw NodeId is the `u64` representation of its SlotMap key. SlotMap normally changes the key generation when a removed slot is reused, so a replacement receives a different raw value without wrapper bookkeeping. Repeated reads of the same live node return the same bigint, so `===`, `Map`, `Set`, and `includes` work normally within one tree. -NodeId arrays are fully checked before native mutation. The native layer trusts the supported wrapper path and does not maintain a second owner or serial table. +The wrapper and native layer maintain no owner, serial, or liveness table. A live NodeId from the receiving tree is a caller precondition. Independent trees can issue equal values; using one tree's value with another can operate on a numerically matching target-tree node. Forged, foreign, and stale in-range keys are unsupported and receive no stable classification. -Malformed, foreign, and stale IDs use `ERR_TAFFY_INVALID_NODE_ID`, `ERR_TAFFY_FOREIGN_NODE_ID`, and `ERR_TAFFY_STALE_NODE_ID`. A copied bigint is not transferable with its tree across workers or separate package instances. +The pinned Taffy revision directly indexes its SlotMaps in several high-level methods, so a non-live in-range key can panic instead of returning `TaffyError`. Every native tree operation runs inside `TreeOwner::access`, whose unwind boundary converts an unexpected panic to an internal failure and poisons that tree before it can be used again. This preserves native process safety without recreating a node registry; it does not make the invalid call supported. A copied bigint is not a transferable native tree handle across workers or separate package instances. ## Input conversion diff --git a/.agents/docs/query-api-design.md b/.agents/docs/query-api-design.md index dbc38b6..18dc468 100644 --- a/.agents/docs/query-api-design.md +++ b/.agents/docs/query-api-design.md @@ -55,7 +55,7 @@ For an inline batch such as this one, TypeScript must infer a positionally corre If the caller first stores that literal in an ordinarily inferred variable, TypeScript may widen its selectors and array positions before `queryStyle` sees it. The call cannot recover information that was already widened, so preserving a known positional tuple may legitimately require `as const` or an explicit positional tuple annotation that retains each selector literal. A collection assembled dynamically has no statically fixed positions and therefore returns an array whose element type covers the possible request results rather than a positional tuple. These are normal TypeScript inference boundaries, not reasons to add a builder. The concrete generated declaration structure remains an implementation choice as long as it provides this observable behavior. -A malformed request makes the complete batch throw without returning partial results. Malformed requests include unknown selectors, the wrong number of indices, invalid indices, and invalid, stale, or foreign NodeIds. Valid queries whose current values are absent still produce `undefined` only at their corresponding result positions. Before validation, the wrapper must copy every caller-controlled batch entry and request argument into an ordinary internal snapshot; it then validates the complete snapshot and immediately enters native code, so an accessor or Proxy cannot invalidate an earlier NodeId between validation and native use. +A malformed request makes the complete batch throw without returning partial results. Malformed requests include unknown selectors, the wrong number of indices, invalid indices, and NodeIds that are not representable as bigint `u64` values. Valid queries whose current values are absent still produce `undefined` only at their corresponding result positions. A live NodeId from the receiving tree is a caller precondition rather than a batch validation step; forged, foreign, and stale in-range keys follow the direct raw NodeId contract. Before validation, the wrapper must copy every caller-controlled batch entry and request argument into an ordinary internal snapshot, then validate that snapshot and enter native code without consulting the original values again. Every selected object or array is an ordinary detached owned snapshot, recursively readonly in TypeScript, with no live view or native borrow. Separate requests do not promise shared JavaScript object identity, including duplicate requests or requests for overlapping parent and child values. diff --git a/.agents/docs/style-codec.md b/.agents/docs/style-codec.md index 679479b..0a9579b 100644 --- a/.agents/docs/style-codec.md +++ b/.agents/docs/style-codec.md @@ -6,7 +6,7 @@ The five public Style-taking operations accept ordinary JavaScript values: `newL Creation and `setStyle` decode into `Style::default()`. `updateStyle` clones the current Style, applies only present fields and nested components, and calls Taffy's setter only when the decoded candidate differs. Missing and explicit-`undefined` fields are absent, accepted `null` values carry an explicit marker, and present arrays carry a count even when empty. Complete arrays, strings, tagged values, and Grid records replace their corresponding value. Unknown top-level Style properties are ignored, so a misspelled field name selects that field's default. Invalid values of known properties and unknown components of the public partial geometry records are errors. -The generated encoder reads each known top-level property once through ordinary property access and never enumerates the outer Style object. The wrapper holds the encoded storage through the one synchronous private call, so getter, Proxy, and recursive Style operations cannot change bytes already handed to Rust. Operations that consume NodeIds resolve them before encoding, preserving stale and foreign ID error precedence. Encoding and Rust decoding finish before tree mutation; a failure drops the local candidate and leaves the tree unchanged. +The generated encoder reads each known top-level property once through ordinary property access and never enumerates the outer Style object. The wrapper holds the encoded storage through the one synchronous private call, so getter, Proxy, and recursive Style operations cannot change bytes already handed to Rust. Operations that consume NodeIds check their bigint and `u64` representation before encoding; owner and liveness are caller preconditions and receive no stale or foreign error precedence. Encoding and Rust decoding finish before tree mutation; a failure drops the local candidate and leaves the tree unchanged. ## Generated ownership @@ -38,4 +38,4 @@ Acceptance performance is measured through the complete public API rather than b ## Verification boundary -Shared public behavior tests run against both `@taffyjs/node` and `@taffyjs/wasm` and cover all five operations, defaults, replacement, partial update, absence, null, empty collections, every Grid/string family, unknown and invalid fields, atomic failure, unchanged updates, floating-point edge cases, NodeId precedence, getters, Proxies, reentrancy, children, and context. Rust safety tests exercise malformed headers, versions, bitmaps, lengths, counts, UTF-8, truncation, and trailing bytes without freezing a particular byte snapshot. Two guards run over the complete public field list rather than over a sample: every field accepts explicit `undefined` as absence, and exactly the eight publicly nullable fields accept `null` while the other thirty-three reject it. Generated freshness is enforced by `vp run check:codegen` in a clean checkout. +Shared public behavior tests run against both `@taffyjs/node` and `@taffyjs/wasm` and cover all five operations, defaults, replacement, partial update, absence, null, empty collections, every Grid/string family, unknown and invalid fields, atomic failure, unchanged updates, floating-point edge cases, NodeId representation precedence, getters, Proxies, reentrancy, children, and context. Rust safety tests exercise malformed headers, versions, bitmaps, lengths, counts, UTF-8, truncation, and trailing bytes without freezing a particular byte snapshot. Two guards run over the complete public field list rather than over a sample: every field accepts explicit `undefined` as absence, and exactly the eight publicly nullable fields accept `null` while the other thirty-three reject it. Generated freshness is enforced by `vp run check:codegen` in a clean checkout. diff --git a/.agents/docs/taffyjs-node-decisions.md b/.agents/docs/taffyjs-node-decisions.md index 0bb75a5..b3da63a 100644 --- a/.agents/docs/taffyjs-node-decisions.md +++ b/.agents/docs/taffyjs-node-decisions.md @@ -24,11 +24,15 @@ Additive convenience or measured performance APIs may coexist with the direct me ## Safe boundary with Taffy-owned semantics -[VOUCHED @hyfdev 2026-08-14] +[VOUCHED @hyfdev 2026-08-23] + +**Ruling:** Supported `@taffyjs/node` calls with representable values and live NodeIds from the receiving tree must prevent JavaScript-controlled values, callback re-entry, and known Taffy panic paths from violating Rust or native process safety. A NodeId's origin and liveness are caller preconditions rather than facts the wrapper authenticates: foreign, forged, and stale in-range `u64` values do not receive controlled ownership or lifetime errors. Direct platform-package use remains an unsupported implementation path. -Supported `@taffyjs/node` calls must prevent JavaScript-controlled values, ownership mistakes, callback re-entry, and known Taffy panic paths from violating Rust or native state. Expected misuse produces controlled JavaScript errors. Direct platform-package use is an unsupported implementation path and does not receive this guarantee. +**Limits:** The public boundary still rejects values that cannot be represented as the required Rust type, including a non-bigint or out-of-range NodeId, and the native owner still contains an unexpected Taffy panic so it cannot unwind through Node-API; a contained panic may poison that tree. Once the binding has produced complete representable Rust values and satisfied its explicit absence, lifetime, and safety rules, Taffy owns the semantic result. The direct binding does not add JavaScript-side clamping, CSS policy, Yoga normalization, defensive defaults, an owner registry, or a liveness registry merely because a safely representable value is unusual. -After the binding has produced a complete representable Rust value and satisfied its explicit identity, absence, ownership, lifetime, and safety rules, Taffy owns the semantic result. The direct binding does not add JavaScript-side clamping, CSS policy, Yoga normalization, or defensive defaults merely because a safely representable value is unusual. +**Why:** Yunfei distinguished native process safety from defending against callers that use a node key outside its documented tree and lifetime. Protecting the latter duplicated Taffy's identity machinery and added state and checks to every ordinary node operation; the direct binding should instead reuse Taffy's raw NodeId contract. + +**Source:** Yunfei (`@hyfdev`), 2026-08-23; explicitly replaced the earlier ownership-mistake guarantee while approving the raw NodeId design and requested this revised direction be re-vouched. ## Public data model @@ -42,15 +46,15 @@ The private representation passed from JavaScript to Rust is a separate implemen ## Generated compact Style codec -[VOUCHED @hyfdev 2026-08-22] +[VOUCHED @hyfdev 2026-08-23] **Ruling:** The creation methods use `newLeaf(style?)`, `newLeafWithContext(context, style?)`, and `newWithChildren(children, style?)`: required context or children precede the optional Style input, omission and explicit `undefined` select defaults, and the old argument order is not retained as an overload. Every Style-taking public method keeps its ordinary object input while the shared Node/Wasm wrapper encodes that input once into one versioned compact private value and performs one binding call. One maintained model under `api/` owns the Style field inventory, order, encoding categories, and generated `StyleInput` and `StyleUpdate` declarations, TypeScript encoder, and Rust `decode_into` application. Creation and `setStyle` decode into defaults; update decodes into a clone and writes only when changed. Unknown top-level Style fields are ignored, known invalid values are errors, and all decoding and validation complete before mutation. -**Limits:** `setStyle(node, style)` and `updateStyle(node, update)` retain their signatures, and this change does not alter the private binding methods or compact wire format. The implementation remains specific to Style. It does not add a public buffer API, fallback parser, `StylePatch`, general serializer, binding language, runtime schema interpreter, or buffer pool. A fixed common buffer may be reused only while one synchronous call owns it; recursive calls and oversized inputs require temporary storage that cannot grow global retained capacity. Native should borrow the typed-array view and WASI should receive only the compact used bytes. Getter, Proxy, reentrancy, NodeId precedence, partial-update, null, empty-collection, and floating-point change semantics remain public behavior. +**Limits:** `setStyle(node, style)` and `updateStyle(node, update)` retain their signatures, and this change does not alter the private binding methods or compact wire format. The implementation remains specific to Style. It does not add a public buffer API, fallback parser, `StylePatch`, general serializer, binding language, runtime schema interpreter, or buffer pool. A fixed common buffer may be reused only while one synchronous call owns it; recursive calls and oversized inputs require temporary storage that cannot grow global retained capacity. Native should borrow the typed-array view and WASI should receive only the compact used bytes. Getter, Proxy, reentrancy, partial-update, null, empty-collection, and floating-point change semantics remain public behavior. A NodeId's bigint and `u64` representation is checked before Style encoding, but owner and liveness are caller preconditions and have no error-precedence guarantee. **Why:** Yunfei selected required context or children first and optional Style last so callers can omit default styling without placeholder objects, and chose a one-time argument-order change while the published version is `0.0.1`. The codec design is based on same-machine evidence that rich JavaScript-object conversion dominated Style-heavy public transactions, especially across Emnapi/WASI. A prototype found the fixed presence bitmap and a field-tag stream effectively equal for nonempty values, while JSON lost `NaN`, negative zero, and absence semantics. The fixed bitmap matches generated straight-line code with fewer decoding states. Complete end-to-end Native and WASI benchmarks are the acceptance evidence rather than decoder-only timing. Yunfei chose ignored unknown top-level fields on the ground that the binding should read only the fields it needs and that supplying correct field names is the caller's responsibility. -**Source:** Yunfei (`@hyfdev`), 2026-08-21 and 2026-08-22; explicitly required the compact Style codec end to end, selected the reordered optional-Style creation signatures for the `0.0.1` API, and chose ignored unknown top-level Style fields over a field-name check. Implementation and measured format details are in [Compact Style codec](style-codec.md). +**Source:** Yunfei (`@hyfdev`), 2026-08-21 through 2026-08-23; explicitly required the compact Style codec end to end, selected the reordered optional-Style creation signatures for the `0.0.1` API, chose ignored unknown top-level Style fields over a field-name check, and then replaced owner/liveness error precedence with the raw NodeId precondition. Implementation and measured format details are in [Compact Style codec](style-codec.md). ## On-demand Style in measure callbacks @@ -66,11 +70,15 @@ The private representation passed from JavaScript to Rust is a separate implemen ## Value-based NodeId -[VOUCHED @hyfdev 2026-08-14] +[VOUCHED @hyfdev 2026-08-23] -Public `NodeId` is an opaque bigint with a TypeScript marker and a private per-tree encoding, not Taffy's raw `u64` and not a JavaScript node object. Stable bigint equality must work in ordinary JavaScript collections without a native call. +**Ruling:** Public `NodeId` must be an opaque TypeScript-branded bigint whose runtime value is exactly Taffy's raw `u64` NodeId, not a JavaScript node object or a binding-defined composite identity. The wrapper must not add a tree token, creation serial, owner registry, or liveness registry. Stable bigint equality and ordinary JavaScript `Map` and `Set` use work without a native call within one tree. -The public wrapper keeps one current-node registry and validates owner, creation serial, and raw ID before native access. Native code does not duplicate that registry. IDs are not persistent or transferable across trees, workers, module evaluations, or separately installed package copies. +**Limits:** A NodeId must be used only while its node is live and only with the `TaffyTree` that returned it. Equality has meaning only within that tree: independent trees may issue equal values, and a foreign value that numerically matches a live target key may operate on that target node. Forged, foreign, and stale in-range values are unsupported; the binding guarantees only bigint-to-`u64` representation checks and native panic containment, not authentication or stable errors for those values. SlotMap's generation normally changes the raw value when a removed slot is reused, but the package adds no stronger anti-revival guarantee. NodeIds are not persistent handles or transferable together with native tree ownership across workers, module evaluations, or package copies. + +**Why:** Yunfei judged cross-tree detection, anti-forgery, duplicate lifetime tracking, and protection beyond SlotMap's generation behavior to be unnecessary complexity for this direct binding. Reusing the native value removes per-tree randomness, per-node registry memory, serial bookkeeping, and wrapper lookups while preserving the identity behavior needed by supported same-tree calls. + +**Source:** Yunfei (`@hyfdev`), 2026-08-23; after a full NodeId review, explicitly chose the raw Taffy NodeId value, accepted tree-and-lifetime scope as a caller precondition, and requested implementation and a new vouch. ## Explicit layout and JavaScript context @@ -178,15 +186,15 @@ Asynchronous, off-thread, cancellable, or transactionally rolled-back measuremen ### All-or-nothing selective batch errors -[VOUCHED @hyfdev 2026-08-13] +[VOUCHED @hyfdev 2026-08-23] -**Ruling:** A selective-query batch must return positionally corresponding values only when every request is well formed and every NodeId is valid. Any unknown selector, wrong index count, invalid index, or invalid, stale, or foreign NodeId makes the complete batch throw a controlled JavaScript error without returning partial results. An out-of-bounds index, inactive tagged variant, or null parent remains a valid absent value and produces `undefined` only at that request's result position. The wrapper must first copy all caller-controlled batch entries and request arguments into an ordinary internal snapshot, then validate the entire snapshot, and immediately enter one native operation without consulting the caller's original values again. +**Ruling:** A selective-query batch must return positionally corresponding values only when every request is well formed. Any unknown selector, wrong index count, invalid index, or NodeId that is not representable as a bigint `u64` makes the complete batch throw a controlled JavaScript error without returning partial results. An out-of-bounds index, inactive tagged variant, or null parent remains a valid absent value and produces `undefined` only at that request's result position. Every NodeId is required by precondition to be live and from the receiving tree; the batch does not authenticate owner or liveness. The wrapper must copy all caller-controlled batch entries and request arguments into an ordinary internal snapshot before validation and must not consult the original values again after entering native code. -**Limits:** This decides whole-batch failure versus per-request error results and the required snapshot-before-validation safety order. It does not fix exact error classes, batch size limits, the private native input representation, or which malformed request is reported when several are present. The batch is read-only, so this ruling does not define rollback or partial mutation behavior. +**Limits:** This decides whole-batch failure versus per-request error results and the required snapshot-before-validation order for supported inputs. It does not fix exact error classes, batch size limits, the private native input representation, or which malformed request is reported when several are present. A forged, foreign, or stale in-range key is outside the supported batch contract and may alias a target-tree node or encounter Taffy's invalid-key behavior. The batch is read-only, so this ruling does not define rollback or partial mutation behavior. -**Why:** Per-request error values would complicate every batch result type and let callers accidentally consume partial output. Snapshotting before validation prevents a later array accessor or Proxy trap from mutating the tree and making a NodeId that was validated earlier stale before native code uses it. +**Why:** Per-request error values would complicate every batch result type and let callers accidentally consume partial output. Snapshotting keeps ordinary request conversion deterministic without creating a second defensive identity model. Yunfei later removed owner and liveness validation from the NodeId contract, so batching must not reintroduce it. -**Source:** Yunfei (`@hyfdev`), 2026-08-13; accepted whole-batch failure for malformed requests while retaining per-position `undefined` for valid absent values and asked that the decision be recorded. +**Source:** Yunfei (`@hyfdev`), 2026-08-13 and 2026-08-23; accepted whole-batch failure for malformed requests and per-position `undefined` for valid absent values, then explicitly replaced owner and liveness validation with the raw NodeId precondition and requested the affected direction be re-vouched. ### Detached selective-query results diff --git a/.agents/docs/technology-stack.md b/.agents/docs/technology-stack.md index c336c36..ee8da99 100644 --- a/.agents/docs/technology-stack.md +++ b/.agents/docs/technology-stack.md @@ -12,7 +12,7 @@ The generated ESM loader is a private build input. The authored wrapper imports The root package has one exact-version optional package for each of the 13 native targets in the maintained napi-rs pnpm template: macOS x64 and ARM64; Windows x86, x64, and ARM64 MSVC; Linux x64 and ARM64 with GNU or musl plus ARMv7 GNU; Android ARMv7 and ARM64; and FreeBSD x64. The upstream baseline was verified against [`napi-rs/package-template-pnpm` commit `8b984603f168ed8cff57fc4428d0813221e2e694`](https://github.com/napi-rs/package-template-pnpm/blob/8b984603f168ed8cff57fc4428d0813221e2e694/package.json). Targets only understood by the CLI but absent from the maintained template are not claimed. The template's threaded WASI target is represented instead by TaffyJS's explicit threadless `@taffyjs/wasm` package, preserving the package boundary described below. Committed manifests remain private `0.0.0` development templates; the Core publication workflow assigns one release version to the Node, Wasm, and 13 platform tarballs and publishes the platform packages as implementation-only optional dependencies. -The bigint NodeId marker and its JavaScript validity registry require the authored wrapper, but not a custom loader or another package. +The bigint NodeId marker and lightweight `u64` representation check require the authored wrapper, but not a custom loader, node registry, or another package. `@taffyjs/wasm` uses the same Rust crate through napi-rs's threadless `wasm32-wasip1` target. The pinned CLI generates eager Node and deferred ESM loaders plus the release Wasm binary in a package-local staging directory. The package build encodes that binary into one generated ESM payload, publishes the deferred loader unchanged, and mechanically derives a synchronous CommonJS factory from the eager loader with exact-match drift assertions. The no-TLA Node ESM bridge imports the payload and immediately calls that factory; the browser adapter imports the same payload directly. Raw binaries and unadapted eager loaders never enter the final output directory, and the generated target package is not published. napi-rs 3.8.2 still requires the staged declaration output passed to `--dts` to use a `.d.cts` filename. diff --git a/apps/website/node/design.md b/apps/website/node/design.md index 861661b..416420f 100644 --- a/apps/website/node/design.md +++ b/apps/website/node/design.md @@ -14,7 +14,7 @@ The benefit is that a Taffy concept has one meaning. Reading Taffy's documentati ## Keep one owner for layout state -Taffy owns the tree topology, styles, layout cache, computation state, and results. JavaScript receives readable inputs, safe node IDs, and detached result objects, but it does not maintain a second copy of the native tree. +Taffy owns the tree topology, styles, layout cache, computation state, results, and raw node-key lifetime. JavaScript receives readable inputs, opaque bigint NodeIds, and detached result objects, but it does not maintain a second copy of the native tree or a NodeId registry. That boundary matters when an application grows. A JavaScript shadow tree would need to stay synchronized after every mutation and failure. It could also disagree with the state that Taffy actually used. With one owner, a layout result always comes from the native tree that performed the calculation. diff --git a/apps/website/node/errors.md b/apps/website/node/errors.md index b3eb87d..90fc5d2 100644 --- a/apps/website/node/errors.md +++ b/apps/website/node/errors.md @@ -6,9 +6,6 @@ The binding uses ordinary JavaScript error classes and adds stable `code` values | Code | Error class | Meaning | | ------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------- | -| `ERR_TAFFY_INVALID_NODE_ID` | `Error` | A bigint is not a public `NodeId` issued by the binding. | -| `ERR_TAFFY_FOREIGN_NODE_ID` | `Error` | The ID belongs to another `TaffyTree`. | -| `ERR_TAFFY_STALE_NODE_ID` | `Error` | The node was removed or its tree was cleared. | | `ERR_TAFFY_TREE_BUSY` | `Error` | A native-backed method was called on the same tree from its measure callback. | | `ERR_TAFFY_INVALID_TOPOLOGY` | `Error` | The requested relationship would duplicate, reattach, self-parent, or cycle nodes, or remove a non-child. | | `ERR_TAFFY_CHILD_INDEX_OUT_OF_BOUNDS` | `RangeError` | A valid integer index is outside the current child list. | @@ -17,10 +14,10 @@ The code is available as an own property on the thrown error: ```ts try { - tree.getStyle(node); + tree.addChild(parent, child); } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ERR_TAFFY_STALE_NODE_ID") { - // Stop using this NodeId. + if (error instanceof Error && "code" in error && error.code === "ERR_TAFFY_INVALID_TOPOLOGY") { + // The child is already attached, would introduce a cycle, or otherwise violates topology. } else { throw error; } @@ -29,7 +26,7 @@ try { ## JavaScript error classes -`TypeError` reports the wrong JavaScript type or object shape, such as a non-bigint node ID, an incomplete available-space record, an unknown component in a partial geometry record, or a non-function measure callback. +`TypeError` reports the wrong JavaScript type or object shape, such as a NodeId that is not a bigint in the `u64` range, an incomplete available-space record, an unknown component in a partial geometry record, or a non-function measure callback. `RangeError` reports a numeric value that cannot represent the requested public value, such as an unknown numeric-family code, a fractional child index, an out-of-range Grid line, or an invalid child range. The out-of-bounds child-index case also carries the code shown above. @@ -39,7 +36,13 @@ Other Taffy operation failures use `Error`. Check a documented code when the pro Inputs are converted and validated before a mutation reaches the tree. A rejected node creation does not consume a public node ID or add a node. A rejected style replacement or partial update leaves the old style and dirty state unchanged. A rejected topology operation leaves the previous parents, child order, contexts, and node count unchanged. -Invalid node IDs also fail before the requested operation changes either the JavaScript wrapper or native tree. +NodeId representation errors fail before the requested operation changes either the JavaScript wrapper or native tree. + +## NodeId precondition + +A NodeId must still be live and must be used only with the `TaffyTree` that returned it. The binding deliberately does not authenticate issuance, ownership, or liveness. Independent trees may issue equal raw keys, so a foreign key can address a numerically matching node in the receiving tree. A forged, foreign, or stale in-range key that does not match can encounter Taffy's invalid-key behavior; no stable error class or code is promised for this unsupported call. + +Native tree operations contain unexpected Rust panics so they cannot unwind through Node-API. Containment may make the affected tree unusable. It is a final process-safety boundary, not a NodeId validation API. ## Measurement failures diff --git a/apps/website/node/index.md b/apps/website/node/index.md index e643978..f81480b 100644 --- a/apps/website/node/index.md +++ b/apps/website/node/index.md @@ -40,7 +40,7 @@ Do not import `native.js`, a `.node` file, or one of the platform packages direc ## What the package provides -Each `TaffyTree` owns its native tree, public node IDs, JavaScript context values and per-node measure functions, styles, cached computation state, and stored layout results. Work is synchronous and explicit: create or update nodes, call a compute method, and then read detached snapshots. +Each `TaffyTree` owns its native nodes, styles, cached computation state, stored layout results, JavaScript context values, and per-node measure functions. Public NodeIds are opaque bigint views of Taffy's raw keys. Work is synchronous and explicit: create or update nodes, call a compute method, and then read detached snapshots. The package does not parse CSS, create DOM elements, or render output. It also does not provide Yoga compatibility or asynchronous computation. Higher layers can build those behaviors on the layout tree without changing the direct API. diff --git a/apps/website/node/nodes-and-topology.md b/apps/website/node/nodes-and-topology.md index 672fa6c..d2967c0 100644 --- a/apps/website/node/nodes-and-topology.md +++ b/apps/website/node/nodes-and-topology.md @@ -1,6 +1,6 @@ # Nodes and Topology -A `TaffyTree` is an independent owner. Its `NodeId` values are opaque bigints that only name live nodes in that same tree. +A `TaffyTree` is an independent owner. Its `NodeId` values are Taffy's raw `u64` keys represented as TypeScript-branded bigints and must only be used while live with the tree that returned them. ## Create nodes @@ -59,16 +59,17 @@ Indices and range endpoints must be non-negative integers within the relevant bo Removing the node marks its former parent and ancestors dirty, like the child-detachment methods in the previous section, so the next compute accounts for the removal. -`clear()` removes every node, context value, and per-node measure function. All IDs previously created by the tree become stale. The tree itself remains reusable, and its rounding mode is retained. +`clear()` removes every node, context value, and per-node measure function. All IDs previously created by the tree become stale and must not be used again. The tree itself remains reusable, and its rounding mode is retained. ## `NodeId` lifetime -`NodeId` is a TypeScript-branded `bigint`. It works as a stable JavaScript identity while the node remains live, including as a `Map` or `Set` key. Its bit layout is not a public format, and arithmetic produces a plain bigint rather than another valid `NodeId`. +`NodeId` is a TypeScript-branded `bigint` whose runtime value is exactly Taffy's raw NodeId. It works as a stable JavaScript identity while the node remains live, including as a `Map` or `Set` key. Treat the value as opaque and nonpersistent: arithmetic produces a plain bigint rather than a supported `NodeId`. -The binding distinguishes three mistakes: +Identity is scoped to one tree: -- A bigint that was not issued as a node ID fails with `ERR_TAFFY_INVALID_NODE_ID`. -- An ID issued by another tree fails with `ERR_TAFFY_FOREIGN_NODE_ID`. -- An ID left behind by `remove` or `clear` fails with `ERR_TAFFY_STALE_NODE_ID`. +- Independent trees may issue equal NodeId values, so equality across trees has no node-identity meaning. +- Passing another tree's value can operate on a numerically matching node in the receiving tree. +- Passing a forged or stale in-range value is unsupported and has no stable error classification. +- A value that is not a bigint in the `u64` range is rejected with `TypeError`. -Native storage slots may be reused, but an old public ID never starts naming the replacement node. See [Errors](./errors.md) for the error classes and failure guarantees. +SlotMap normally changes a key's generation when a removed storage slot is reused, so ordinary reuse produces a different NodeId without wrapper bookkeeping. TaffyJS adds no owner token, creation serial, or live-node registry and makes no stronger anti-revival guarantee. See [Errors](./errors.md) for the supported error boundary. diff --git a/crates/taffyjs_binding/src/error.rs b/crates/taffyjs_binding/src/error.rs index 576be0b..066043d 100644 --- a/crates/taffyjs_binding/src/error.rs +++ b/crates/taffyjs_binding/src/error.rs @@ -55,21 +55,6 @@ pub(crate) fn child_index_out_of_bounds_error(message: impl Into) -> Bin coded_range_error("ERR_TAFFY_CHILD_INDEX_OUT_OF_BOUNDS", message) } -#[allow(dead_code)] -pub(crate) fn invalid_node_id_error(message: impl Into) -> BindingError { - coded_error("ERR_TAFFY_INVALID_NODE_ID", message) -} - -#[allow(dead_code)] -pub(crate) fn foreign_node_id_error(message: impl Into) -> BindingError { - coded_error("ERR_TAFFY_FOREIGN_NODE_ID", message) -} - -#[allow(dead_code)] -pub(crate) fn stale_node_id_error(message: impl Into) -> BindingError { - coded_error("ERR_TAFFY_STALE_NODE_ID", message) -} - #[allow(dead_code)] pub(crate) fn invalid_topology_error(message: impl Into) -> BindingError { coded_error("ERR_TAFFY_INVALID_TOPOLOGY", message) diff --git a/crates/taffyjs_binding/src/owner.rs b/crates/taffyjs_binding/src/owner.rs index 5be9be8..f2f6e1d 100644 --- a/crates/taffyjs_binding/src/owner.rs +++ b/crates/taffyjs_binding/src/owner.rs @@ -32,6 +32,8 @@ impl TreeOwner { .tree .try_borrow_mut() .map_err(|_| busy_error(public_method))?; + // Taffy's high-level methods directly index SlotMaps in several paths. This boundary also + // contains their panic when an unsupported forged, foreign, or stale raw NodeId misses. match catch_unwind(AssertUnwindSafe(|| operation(&mut tree))) { Ok(result) => result, Err(_) => { @@ -73,4 +75,19 @@ mod tests { let second = owner.access("getNodeCount", |tree| Ok(tree.total_node_count())); assert_eq!(second.unwrap_err().code, Some("ERR_TAFFY_TREE_POISONED")); } + + #[test] + fn invalid_taffy_node_panic_is_contained() { + let owner = TreeOwner::new(); + let invalid = taffy::NodeId::from(0u64); + let first = owner.access("getStyle", |tree| { + tree.style(invalid) + .map(|_| ()) + .map_err(|_| internal_error()) + }); + assert_eq!(first.unwrap_err().code, Some("ERR_TAFFY_INTERNAL")); + + let second = owner.access("getNodeCount", |tree| Ok(tree.total_node_count())); + assert_eq!(second.unwrap_err().code, Some("ERR_TAFFY_TREE_POISONED")); + } } diff --git a/packages/taffyjs-node/index.d.ts b/packages/taffyjs-node/index.d.ts index 7a19e73..e40fcd3 100644 --- a/packages/taffyjs-node/index.d.ts +++ b/packages/taffyjs-node/index.d.ts @@ -268,7 +268,7 @@ type DetailedLayoutInfoKind = EnumValue; //#endregion //#region src/node-id.d.ts declare const phantomMarker: unique symbol; -/** Identifies a node in one TaffyTree without exposing its native identity. */ +/** Identifies a live node within the TaffyTree that returned it. */ type NodeId = bigint & { readonly [phantomMarker]: never; }; @@ -804,7 +804,7 @@ type StyleUpdate = StyleInput; /** Owns one independent node tree, its contexts, styles, and stored layouts. */ declare class TaffyTree { #private; - /** Creates an independent Taffy tree with its own NodeId namespace. */ + /** Creates an independent Taffy tree. */ constructor(); /** Enables pixel rounding for subsequently computed public layouts. */ enableRounding(): void; @@ -840,7 +840,7 @@ declare class TaffyTree { newLeafWithContext(context: TContext | undefined, style?: StyleInput): NodeId; /** Creates a parent from ordered children and an optional style. */ newWithChildren(children: readonly NodeId[], style?: StyleInput): NodeId; - /** Removes one node, its context and measure function, and invalidates its public NodeId. */ + /** Removes one node and releases its context and measure function. The NodeId must not be used again. */ remove(node: NodeId): void; /** Returns the JavaScript context currently associated with one node. */ getNodeContext(node: NodeId): TContext | undefined; diff --git a/packages/taffyjs-node/index.js b/packages/taffyjs-node/index.js index 9b45416..59b660a 100644 --- a/packages/taffyjs-node/index.js +++ b/packages/taffyjs-node/index.js @@ -1010,66 +1010,11 @@ function decodeLayout(output) { } //#endregion //#region src/node-id.ts -const U64_BITS = 64n; -const TOKEN_SHIFT = 128n; -const U64_MAX = (1n << U64_BITS) - 1n; -const NODE_ID_LIMIT = 1n << 256n; -function codedError(code, message) { - return Object.assign(new Error(message), { code }); -} -function randomToken() { - const bytes = /* @__PURE__ */ new Uint8Array(16); - globalThis.crypto.getRandomValues(bytes); - let token = 0n; - for (const byte of bytes) token = token << 8n | BigInt(byte); - return token; -} -function isEncodedNodeId(value) { - if (value < 0n || value >= NODE_ID_LIMIT) return false; - return (value >> U64_BITS & U64_MAX) !== 0n; +const U64_MAX = (1n << 64n) - 1n; +function toRawNodeId(value) { + if (typeof value !== "bigint" || value < 0n || value > U64_MAX) throw new TypeError("NodeId must be a non-negative u64 bigint"); + return value; } -var NodeIdRegistry = class { - #token; - #nextSerial = 1n; - #serialByRaw = /* @__PURE__ */ new Map(); - constructor() { - this.#token = randomToken(); - } - reserveSerial() { - const serial = this.#nextSerial; - if (serial < 1n || serial > U64_MAX) throw new RangeError("The per-tree NodeId creation serial is exhausted"); - return serial; - } - register(raw, serial) { - if (serial !== this.#nextSerial || raw < 0n || raw > U64_MAX || this.#serialByRaw.has(raw)) throw codedError("ERR_TAFFY_INTERNAL", "The native and public node registries diverged"); - const node = this.#token << TOKEN_SHIFT | serial << U64_BITS | raw; - this.#serialByRaw.set(raw, serial); - this.#nextSerial = serial + 1n; - return node; - } - resolve(value) { - if (typeof value !== "bigint") throw new TypeError("NodeId must be a bigint"); - if (!isEncodedNodeId(value)) throw codedError("ERR_TAFFY_INVALID_NODE_ID", "The bigint is not a valid NodeId"); - if (value >> TOKEN_SHIFT !== this.#token) throw codedError("ERR_TAFFY_FOREIGN_NODE_ID", "The NodeId belongs to another TaffyTree"); - const serial = value >> U64_BITS & U64_MAX; - const raw = value & U64_MAX; - if (this.#serialByRaw.get(raw) !== serial) throw codedError("ERR_TAFFY_STALE_NODE_ID", "The NodeId no longer names a current node"); - return raw; - } - fromRaw(raw) { - const serial = this.#serialByRaw.get(raw); - if (serial === void 0) throw codedError("ERR_TAFFY_INTERNAL", "The native and public node registries diverged"); - return this.#token << TOKEN_SHIFT | serial << U64_BITS | raw; - } - unregister(node, raw) { - const serial = node >> U64_BITS & U64_MAX; - if ((node & U64_MAX) !== raw || this.#serialByRaw.get(raw) !== serial) throw codedError("ERR_TAFFY_INTERNAL", "The native and public node registries diverged"); - this.#serialByRaw.delete(raw); - } - clear() { - this.#serialByRaw.clear(); - } -}; //#endregion //#region src/style-codec.ts const COMMON_STYLE_BUFFER_SIZE = 1024; @@ -1775,10 +1720,9 @@ function checkedChildIndex(index) { /** Owns one independent node tree, its contexts, styles, and stored layouts. */ var TaffyTree = class { #inner; - #nodes = new NodeIdRegistry(); #contexts = /* @__PURE__ */ new Map(); #measures = /* @__PURE__ */ new Map(); - /** Creates an independent Taffy tree with its own NodeId namespace. */ + /** Creates an independent Taffy tree. */ constructor() { this.#inner = new BindingTaffyTree(); } @@ -1796,76 +1740,67 @@ var TaffyTree = class { } /** Returns the current number of children for one parent. */ getChildCount(parent) { - return this.#inner.rawGetChildCount(this.#nodes.resolve(parent)); + return this.#inner.rawGetChildCount(toRawNodeId(parent)); } /** Returns the current parent or null for a root node. */ getParent(node) { - const rawParent = this.#inner.rawGetParent(this.#nodes.resolve(node)); - return rawParent === null ? null : this.#nodes.fromRaw(rawParent); + return this.#inner.rawGetParent(toRawNodeId(node)); } /** Returns a detached readonly snapshot of the ordered children. */ getChildren(parent) { - return this.#inner.rawGetChildren(this.#nodes.resolve(parent)).map((child) => this.#nodes.fromRaw(child)); + return this.#inner.rawGetChildren(toRawNodeId(parent)); } /** Returns the child at the requested parent index. */ getChildAtIndex(parent, index) { - const rawChild = this.#inner.rawGetChildAtIndex(this.#nodes.resolve(parent), checkedChildIndex(index)); - return this.#nodes.fromRaw(rawChild); + return this.#inner.rawGetChildAtIndex(toRawNodeId(parent), checkedChildIndex(index)); } /** Appends an existing node to the parent child list. */ addChild(parent, child) { - const rawParent = this.#nodes.resolve(parent); - const rawChild = this.#nodes.resolve(child); + const rawParent = toRawNodeId(parent); + const rawChild = toRawNodeId(child); this.#inner.rawAddChild(rawParent, rawChild); } /** Inserts an existing child at the requested parent index. */ insertChildAtIndex(parent, index, child) { - const rawParent = this.#nodes.resolve(parent); - const rawChild = this.#nodes.resolve(child); + const rawParent = toRawNodeId(parent); + const rawChild = toRawNodeId(child); this.#inner.rawInsertChildAtIndex(rawParent, checkedChildIndex(index), rawChild); } /** Replaces the complete ordered child list for one parent. */ setChildren(parent, children) { - const rawParent = this.#nodes.resolve(parent); + const rawParent = toRawNodeId(parent); if (!Array.isArray(children)) throw new TypeError("children must be an array"); - const rawChildren = Array.from(children, (child) => this.#nodes.resolve(child)); + const rawChildren = Array.from(children, (child) => toRawNodeId(child)); this.#inner.rawSetChildren(rawParent, rawChildren); } /** Detaches the selected child from its current parent. */ removeChild(parent, child) { - const rawParent = this.#nodes.resolve(parent); - const rawChild = this.#nodes.resolve(child); + const rawParent = toRawNodeId(parent); + const rawChild = toRawNodeId(child); this.#inner.rawRemoveChild(rawParent, rawChild); } /** Detaches and returns the child at the requested index. */ removeChildAtIndex(parent, index) { - const rawChild = this.#inner.rawRemoveChildAtIndex(this.#nodes.resolve(parent), checkedChildIndex(index)); - return this.#nodes.fromRaw(rawChild); + return this.#inner.rawRemoveChildAtIndex(toRawNodeId(parent), checkedChildIndex(index)); } /** Detaches children in the supplied half-open index range. */ removeChildrenRange(parent, range) { - this.#inner.rawRemoveChildrenRange(this.#nodes.resolve(parent), range); + this.#inner.rawRemoveChildrenRange(toRawNodeId(parent), range); } /** Replaces and returns the child at the requested index. */ replaceChildAtIndex(parent, index, newChild) { - const rawParent = this.#nodes.resolve(parent); - const rawNewChild = this.#nodes.resolve(newChild); - const rawOldChild = this.#inner.rawReplaceChildAtIndex(rawParent, checkedChildIndex(index), rawNewChild); - return this.#nodes.fromRaw(rawOldChild); + const rawParent = toRawNodeId(parent); + const rawNewChild = toRawNodeId(newChild); + return this.#inner.rawReplaceChildAtIndex(rawParent, checkedChildIndex(index), rawNewChild); } /** Creates a leaf node, using Taffy's defaults when style is omitted. */ newLeaf(style = DEFAULT_STYLE_INPUT) { - return withEncodedStyle(style, (encoded) => { - const serial = this.#nodes.reserveSerial(); - return this.#nodes.register(this.#inner.rawNewLeaf(encoded), serial); - }); + return withEncodedStyle(style, (encoded) => this.#inner.rawNewLeaf(encoded)); } /** Creates a leaf node with JavaScript context and an optional style. */ newLeafWithContext(context, style = DEFAULT_STYLE_INPUT) { return withEncodedStyle(style, (encoded) => { - const serial = this.#nodes.reserveSerial(); - const raw = this.#inner.rawNewLeafWithContext(encoded, context !== void 0); - const node = this.#nodes.register(raw, serial); + const node = this.#inner.rawNewLeafWithContext(encoded, context !== void 0); if (context !== void 0) this.#contexts.set(node, context); return node; }); @@ -1873,35 +1808,31 @@ var TaffyTree = class { /** Creates a parent from ordered children and an optional style. */ newWithChildren(children, style = DEFAULT_STYLE_INPUT) { if (!Array.isArray(children)) throw new TypeError("children must be an array"); - const rawChildren = Array.from(children, (child) => this.#nodes.resolve(child)); - return withEncodedStyle(style, (encoded) => { - const serial = this.#nodes.reserveSerial(); - return this.#nodes.register(this.#inner.rawNewWithChildren(encoded, rawChildren), serial); - }); + const rawChildren = Array.from(children, (child) => toRawNodeId(child)); + return withEncodedStyle(style, (encoded) => this.#inner.rawNewWithChildren(encoded, rawChildren)); } - /** Removes one node, its context and measure function, and invalidates its public NodeId. */ + /** Removes one node and releases its context and measure function. The NodeId must not be used again. */ remove(node) { - const raw = this.#nodes.resolve(node); + const raw = toRawNodeId(node); this.#inner.rawRemove(raw); - this.#nodes.unregister(node, raw); this.#contexts.delete(node); this.#measures.delete(node); } /** Returns the JavaScript context currently associated with one node. */ getNodeContext(node) { - this.#nodes.resolve(node); + toRawNodeId(node); return this.#contexts.get(node); } /** Replaces or clears the JavaScript context for one node. */ setNodeContext(node, context) { - const raw = this.#nodes.resolve(node); + const raw = toRawNodeId(node); this.#inner.rawSetNodeContext(raw, context !== void 0); if (context === void 0) this.#contexts.delete(node); else this.#contexts.set(node, context); } /** Sets or clears this node's synchronous measure function; every call marks it dirty, including when the function identity is unchanged. */ setMeasure(node, measure) { - const raw = this.#nodes.resolve(node); + const raw = toRawNodeId(node); if (measure !== void 0 && typeof measure !== "function") throw new TypeError("measure must be a function or undefined"); this.#inner.rawSetMeasure(raw, measure !== void 0); if (measure === void 0) this.#measures.delete(node); @@ -1909,50 +1840,49 @@ var TaffyTree = class { } /** Replaces a node style and marks affected layout state dirty. */ setStyle(node, style) { - const raw = this.#nodes.resolve(node); + const raw = toRawNodeId(node); withEncodedStyle(style, (encoded) => this.#inner.rawSetStyle(raw, encoded)); } /** Updates supplied style fields and geometry components, preserving omitted values. */ updateStyle(node, update) { - const raw = this.#nodes.resolve(node); + const raw = toRawNodeId(node); withEncodedStyle(update, (encoded) => this.#inner.rawUpdateStyle(raw, encoded)); } /** Returns a detached readable snapshot of the node style. */ getStyle(node) { - return this.#inner.rawGetStyle(this.#nodes.resolve(node)); + return this.#inner.rawGetStyle(toRawNodeId(node)); } /** Returns the most recently stored layout selected by the tree's current rounding mode. */ getLayout(node) { - this.#inner.rawWriteLayout(this.#nodes.resolve(node), layoutCodecBuffer); + this.#inner.rawWriteLayout(toRawNodeId(node), layoutCodecBuffer); return decodeLayout(layoutCodecBuffer); } /** Returns the most recently stored unrounded layout snapshot. */ getUnroundedLayout(node) { - this.#inner.rawWriteUnroundedLayout(this.#nodes.resolve(node), layoutCodecBuffer); + this.#inner.rawWriteUnroundedLayout(toRawNodeId(node), layoutCodecBuffer); return decodeLayout(layoutCodecBuffer); } /** Returns detailed Grid tracks and item placement when available. */ getDetailedLayoutInfo(node) { - return this.#inner.rawGetDetailedLayoutInfo(this.#nodes.resolve(node)); + return this.#inner.rawGetDetailedLayoutInfo(toRawNodeId(node)); } /** Explicitly marks a node for layout recomputation. */ markDirty(node) { - this.#inner.rawMarkDirty(this.#nodes.resolve(node)); + this.#inner.rawMarkDirty(toRawNodeId(node)); } /** Reports whether a node currently needs layout recomputation. */ isDirty(node) { - return this.#inner.rawIsDirty(this.#nodes.resolve(node)); + return this.#inner.rawIsDirty(toRawNodeId(node)); } /** Removes every node, context value, and per-node measure function from this tree. */ clear() { this.#inner.rawClear(); - this.#nodes.clear(); this.#contexts.clear(); this.#measures.clear(); } /** Computes and stores layout synchronously with configured per-node measures and an optional global fallback. */ computeLayout(options) { - const root = this.#nodes.resolve(options.root); + const root = toRawNodeId(options.root); const measure = options.measure; if (measure !== void 0 && typeof measure !== "function") throw new TypeError("measure must be a function or undefined"); if (this.#measures.size === 0 && measure === void 0) { @@ -1964,7 +1894,7 @@ var TaffyTree = class { #computeMeasuredLayout(root, availableSpace, fallback) { this.#inner.rawComputeLayoutWithMeasure(root, availableSpace, (getStyle) => { const constraints = decodeConstraints(constraintRecord); - const node = this.#nodes.fromRaw(constraints.node); + const node = constraints.node; const measure = this.#measures.get(node) ?? fallback; if (measure === void 0) throw new Error("Native measure marker has no JavaScript measure function"); return measure({ diff --git a/packages/taffyjs-node/src/node-id.ts b/packages/taffyjs-node/src/node-id.ts index 0b0b9fd..b08a625 100644 --- a/packages/taffyjs-node/src/node-id.ts +++ b/packages/taffyjs-node/src/node-id.ts @@ -1,90 +1,13 @@ declare const phantomMarker: unique symbol; -/** Identifies a node in one TaffyTree without exposing its native identity. */ +/** Identifies a live node within the TaffyTree that returned it. */ export type NodeId = bigint & { readonly [phantomMarker]: never }; -const U64_BITS = 64n; -const TOKEN_SHIFT = 128n; -const U64_MAX = (1n << U64_BITS) - 1n; -const NODE_ID_LIMIT = 1n << 256n; +const U64_MAX = (1n << 64n) - 1n; -function codedError(code: string, message: string): Error & { code: string } { - return Object.assign(new Error(message), { code }); -} - -function randomToken(): bigint { - const bytes = new Uint8Array(16); - globalThis.crypto.getRandomValues(bytes); - let token = 0n; - for (const byte of bytes) token = (token << 8n) | BigInt(byte); - return token; -} - -function isEncodedNodeId(value: bigint): boolean { - if (value < 0n || value >= NODE_ID_LIMIT) return false; - return ((value >> U64_BITS) & U64_MAX) !== 0n; -} - -export class NodeIdRegistry { - readonly #token: bigint; - #nextSerial = 1n; - readonly #serialByRaw = new Map(); - - constructor() { - this.#token = randomToken(); - } - - reserveSerial(): bigint { - const serial = this.#nextSerial; - if (serial < 1n || serial > U64_MAX) { - throw new RangeError("The per-tree NodeId creation serial is exhausted"); - } - return serial; - } - - register(raw: bigint, serial: bigint): NodeId { - if (serial !== this.#nextSerial || raw < 0n || raw > U64_MAX || this.#serialByRaw.has(raw)) { - throw codedError("ERR_TAFFY_INTERNAL", "The native and public node registries diverged"); - } - const node = ((this.#token << TOKEN_SHIFT) | (serial << U64_BITS) | raw) as NodeId; - this.#serialByRaw.set(raw, serial); - this.#nextSerial = serial + 1n; - return node; - } - - resolve(value: unknown): bigint { - if (typeof value !== "bigint") throw new TypeError("NodeId must be a bigint"); - if (!isEncodedNodeId(value)) { - throw codedError("ERR_TAFFY_INVALID_NODE_ID", "The bigint is not a valid NodeId"); - } - if (value >> TOKEN_SHIFT !== this.#token) { - throw codedError("ERR_TAFFY_FOREIGN_NODE_ID", "The NodeId belongs to another TaffyTree"); - } - const serial = (value >> U64_BITS) & U64_MAX; - const raw = value & U64_MAX; - if (this.#serialByRaw.get(raw) !== serial) { - throw codedError("ERR_TAFFY_STALE_NODE_ID", "The NodeId no longer names a current node"); - } - return raw; - } - - fromRaw(raw: bigint): NodeId { - const serial = this.#serialByRaw.get(raw); - if (serial === undefined) { - throw codedError("ERR_TAFFY_INTERNAL", "The native and public node registries diverged"); - } - return ((this.#token << TOKEN_SHIFT) | (serial << U64_BITS) | raw) as NodeId; - } - - unregister(node: NodeId, raw: bigint): void { - const serial = (node >> U64_BITS) & U64_MAX; - if ((node & U64_MAX) !== raw || this.#serialByRaw.get(raw) !== serial) { - throw codedError("ERR_TAFFY_INTERNAL", "The native and public node registries diverged"); - } - this.#serialByRaw.delete(raw); - } - - clear(): void { - this.#serialByRaw.clear(); +export function toRawNodeId(value: unknown): bigint { + if (typeof value !== "bigint" || value < 0n || value > U64_MAX) { + throw new TypeError("NodeId must be a non-negative u64 bigint"); } + return value; } diff --git a/packages/taffyjs-node/src/tree.ts b/packages/taffyjs-node/src/tree.ts index 3541e16..7e6f4c4 100644 --- a/packages/taffyjs-node/src/tree.ts +++ b/packages/taffyjs-node/src/tree.ts @@ -1,6 +1,6 @@ import { BindingTaffyTree } from "./binding.js"; import { layoutCodecByteLength, decodeLayout, type Layout } from "./layout-codec.js"; -import { NodeIdRegistry, type NodeId } from "./node-id.js"; +import { toRawNodeId, type NodeId } from "./node-id.js"; import { type StyleInput, type StyleUpdate, withEncodedStyle } from "./style-input.js"; import type { ChildRangeInput, @@ -83,11 +83,10 @@ function checkedChildIndex(index: number): number { /** Owns one independent node tree, its contexts, styles, and stored layouts. */ export class TaffyTree { readonly #inner: BindingTaffyTree; - readonly #nodes = new NodeIdRegistry(); readonly #contexts = new Map(); readonly #measures = new Map>(); - /** Creates an independent Taffy tree with its own NodeId namespace. */ + /** Creates an independent Taffy tree. */ constructor() { this.#inner = new BindingTaffyTree(); } @@ -109,92 +108,84 @@ export class TaffyTree { /** Returns the current number of children for one parent. */ getChildCount(parent: NodeId): number { - return this.#inner.rawGetChildCount(this.#nodes.resolve(parent)); + return this.#inner.rawGetChildCount(toRawNodeId(parent)); } /** Returns the current parent or null for a root node. */ getParent(node: NodeId): NodeId | null { - const rawParent = this.#inner.rawGetParent(this.#nodes.resolve(node)); - return rawParent === null ? null : this.#nodes.fromRaw(rawParent); + const rawParent = this.#inner.rawGetParent(toRawNodeId(node)); + return rawParent as NodeId | null; } /** Returns a detached readonly snapshot of the ordered children. */ getChildren(parent: NodeId): readonly NodeId[] { - return this.#inner - .rawGetChildren(this.#nodes.resolve(parent)) - .map((child) => this.#nodes.fromRaw(child)); + return this.#inner.rawGetChildren(toRawNodeId(parent)) as NodeId[]; } /** Returns the child at the requested parent index. */ getChildAtIndex(parent: NodeId, index: number): NodeId { - const rawChild = this.#inner.rawGetChildAtIndex( - this.#nodes.resolve(parent), - checkedChildIndex(index), - ); - return this.#nodes.fromRaw(rawChild); + const rawChild = this.#inner.rawGetChildAtIndex(toRawNodeId(parent), checkedChildIndex(index)); + return rawChild as NodeId; } /** Appends an existing node to the parent child list. */ addChild(parent: NodeId, child: NodeId): void { - const rawParent = this.#nodes.resolve(parent); - const rawChild = this.#nodes.resolve(child); + const rawParent = toRawNodeId(parent); + const rawChild = toRawNodeId(child); this.#inner.rawAddChild(rawParent, rawChild); } /** Inserts an existing child at the requested parent index. */ insertChildAtIndex(parent: NodeId, index: number, child: NodeId): void { - const rawParent = this.#nodes.resolve(parent); - const rawChild = this.#nodes.resolve(child); + const rawParent = toRawNodeId(parent); + const rawChild = toRawNodeId(child); this.#inner.rawInsertChildAtIndex(rawParent, checkedChildIndex(index), rawChild); } /** Replaces the complete ordered child list for one parent. */ setChildren(parent: NodeId, children: readonly NodeId[]): void { - const rawParent = this.#nodes.resolve(parent); + const rawParent = toRawNodeId(parent); if (!Array.isArray(children)) throw new TypeError("children must be an array"); - const rawChildren = Array.from(children, (child) => this.#nodes.resolve(child)); + const rawChildren = Array.from(children, (child) => toRawNodeId(child)); this.#inner.rawSetChildren(rawParent, rawChildren); } /** Detaches the selected child from its current parent. */ removeChild(parent: NodeId, child: NodeId): void { - const rawParent = this.#nodes.resolve(parent); - const rawChild = this.#nodes.resolve(child); + const rawParent = toRawNodeId(parent); + const rawChild = toRawNodeId(child); this.#inner.rawRemoveChild(rawParent, rawChild); } /** Detaches and returns the child at the requested index. */ removeChildAtIndex(parent: NodeId, index: number): NodeId { const rawChild = this.#inner.rawRemoveChildAtIndex( - this.#nodes.resolve(parent), + toRawNodeId(parent), checkedChildIndex(index), ); - return this.#nodes.fromRaw(rawChild); + return rawChild as NodeId; } /** Detaches children in the supplied half-open index range. */ removeChildrenRange(parent: NodeId, range: ChildRangeInput): void { - this.#inner.rawRemoveChildrenRange(this.#nodes.resolve(parent), range); + this.#inner.rawRemoveChildrenRange(toRawNodeId(parent), range); } /** Replaces and returns the child at the requested index. */ replaceChildAtIndex(parent: NodeId, index: number, newChild: NodeId): NodeId { - const rawParent = this.#nodes.resolve(parent); - const rawNewChild = this.#nodes.resolve(newChild); + const rawParent = toRawNodeId(parent); + const rawNewChild = toRawNodeId(newChild); const rawOldChild = this.#inner.rawReplaceChildAtIndex( rawParent, checkedChildIndex(index), rawNewChild, ); - return this.#nodes.fromRaw(rawOldChild); + return rawOldChild as NodeId; } /** Creates a leaf node, using Taffy's defaults when style is omitted. */ newLeaf(style: StyleInput = DEFAULT_STYLE_INPUT): NodeId { - return withEncodedStyle(style, (encoded) => { - const serial = this.#nodes.reserveSerial(); - return this.#nodes.register(this.#inner.rawNewLeaf(encoded), serial); - }); + return withEncodedStyle(style, (encoded) => this.#inner.rawNewLeaf(encoded) as NodeId); } /** Creates a leaf node with JavaScript context and an optional style. */ @@ -203,9 +194,7 @@ export class TaffyTree { style: StyleInput = DEFAULT_STYLE_INPUT, ): NodeId { return withEncodedStyle(style, (encoded) => { - const serial = this.#nodes.reserveSerial(); - const raw = this.#inner.rawNewLeafWithContext(encoded, context !== undefined); - const node = this.#nodes.register(raw, serial); + const node = this.#inner.rawNewLeafWithContext(encoded, context !== undefined) as NodeId; if (context !== undefined) this.#contexts.set(node, context); return node; }); @@ -214,31 +203,30 @@ export class TaffyTree { /** Creates a parent from ordered children and an optional style. */ newWithChildren(children: readonly NodeId[], style: StyleInput = DEFAULT_STYLE_INPUT): NodeId { if (!Array.isArray(children)) throw new TypeError("children must be an array"); - const rawChildren = Array.from(children, (child) => this.#nodes.resolve(child)); - return withEncodedStyle(style, (encoded) => { - const serial = this.#nodes.reserveSerial(); - return this.#nodes.register(this.#inner.rawNewWithChildren(encoded, rawChildren), serial); - }); + const rawChildren = Array.from(children, (child) => toRawNodeId(child)); + return withEncodedStyle( + style, + (encoded) => this.#inner.rawNewWithChildren(encoded, rawChildren) as NodeId, + ); } - /** Removes one node, its context and measure function, and invalidates its public NodeId. */ + /** Removes one node and releases its context and measure function. The NodeId must not be used again. */ remove(node: NodeId): void { - const raw = this.#nodes.resolve(node); + const raw = toRawNodeId(node); this.#inner.rawRemove(raw); - this.#nodes.unregister(node, raw); this.#contexts.delete(node); this.#measures.delete(node); } /** Returns the JavaScript context currently associated with one node. */ getNodeContext(node: NodeId): TContext | undefined { - this.#nodes.resolve(node); + toRawNodeId(node); return this.#contexts.get(node); } /** Replaces or clears the JavaScript context for one node. */ setNodeContext(node: NodeId, context: TContext | undefined): void { - const raw = this.#nodes.resolve(node); + const raw = toRawNodeId(node); this.#inner.rawSetNodeContext(raw, context !== undefined); if (context === undefined) this.#contexts.delete(node); else this.#contexts.set(node, context); @@ -246,7 +234,7 @@ export class TaffyTree { /** Sets or clears this node's synchronous measure function; every call marks it dirty, including when the function identity is unchanged. */ setMeasure(node: NodeId, measure: MeasureFunction | undefined): void { - const raw = this.#nodes.resolve(node); + const raw = toRawNodeId(node); if (measure !== undefined && typeof measure !== "function") { throw new TypeError("measure must be a function or undefined"); } @@ -257,59 +245,58 @@ export class TaffyTree { /** Replaces a node style and marks affected layout state dirty. */ setStyle(node: NodeId, style: StyleInput): void { - const raw = this.#nodes.resolve(node); + const raw = toRawNodeId(node); withEncodedStyle(style, (encoded) => this.#inner.rawSetStyle(raw, encoded)); } /** Updates supplied style fields and geometry components, preserving omitted values. */ updateStyle(node: NodeId, update: StyleUpdate): void { - const raw = this.#nodes.resolve(node); + const raw = toRawNodeId(node); withEncodedStyle(update, (encoded) => this.#inner.rawUpdateStyle(raw, encoded)); } /** Returns a detached readable snapshot of the node style. */ getStyle(node: NodeId): Style { - return this.#inner.rawGetStyle(this.#nodes.resolve(node)) as Style; + return this.#inner.rawGetStyle(toRawNodeId(node)) as Style; } /** Returns the most recently stored layout selected by the tree's current rounding mode. */ getLayout(node: NodeId): Layout { - this.#inner.rawWriteLayout(this.#nodes.resolve(node), layoutCodecBuffer); + this.#inner.rawWriteLayout(toRawNodeId(node), layoutCodecBuffer); return decodeLayout(layoutCodecBuffer); } /** Returns the most recently stored unrounded layout snapshot. */ getUnroundedLayout(node: NodeId): Layout { - this.#inner.rawWriteUnroundedLayout(this.#nodes.resolve(node), layoutCodecBuffer); + this.#inner.rawWriteUnroundedLayout(toRawNodeId(node), layoutCodecBuffer); return decodeLayout(layoutCodecBuffer); } /** Returns detailed Grid tracks and item placement when available. */ getDetailedLayoutInfo(node: NodeId): DetailedLayoutInfo { - return this.#inner.rawGetDetailedLayoutInfo(this.#nodes.resolve(node)) as DetailedLayoutInfo; + return this.#inner.rawGetDetailedLayoutInfo(toRawNodeId(node)) as DetailedLayoutInfo; } /** Explicitly marks a node for layout recomputation. */ markDirty(node: NodeId): void { - this.#inner.rawMarkDirty(this.#nodes.resolve(node)); + this.#inner.rawMarkDirty(toRawNodeId(node)); } /** Reports whether a node currently needs layout recomputation. */ isDirty(node: NodeId): boolean { - return this.#inner.rawIsDirty(this.#nodes.resolve(node)); + return this.#inner.rawIsDirty(toRawNodeId(node)); } /** Removes every node, context value, and per-node measure function from this tree. */ clear(): void { this.#inner.rawClear(); - this.#nodes.clear(); this.#contexts.clear(); this.#measures.clear(); } /** Computes and stores layout synchronously with configured per-node measures and an optional global fallback. */ computeLayout(options: ComputeLayoutOptions): void { - const root = this.#nodes.resolve(options.root); + const root = toRawNodeId(options.root); const measure = options.measure; if (measure !== undefined && typeof measure !== "function") { throw new TypeError("measure must be a function or undefined"); @@ -331,7 +318,7 @@ export class TaffyTree { availableSpace, (getStyle) => { const constraints = decodeConstraints(constraintRecord); - const node = this.#nodes.fromRaw(constraints.node); + const node = constraints.node as NodeId; const measure = this.#measures.get(node) ?? fallback; if (measure === undefined) { throw new Error("Native measure marker has no JavaScript measure function"); diff --git a/packages/taffyjs-node/tests/native/node-id.test.mts b/packages/taffyjs-node/tests/native/node-id.test.mts new file mode 100644 index 0000000..edd3899 --- /dev/null +++ b/packages/taffyjs-node/tests/native/node-id.test.mts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { BindingTaffyTree } from "../../src/binding.ts"; +import { withEncodedStyle } from "../../src/style-input.ts"; +import { test } from "vite-plus/test"; + +test("native NodeId inputs require a lossless u64 bigint", () => { + const owner = new BindingTaffyTree(); + const node = withEncodedStyle({}, (encoded) => owner.rawNewLeaf(encoded)); + + assert.throws(() => owner.rawGetStyle(1 as never), { code: "BigintExpected" }); + for (const value of [-1n, 1n << 64n]) { + assert.throws(() => owner.rawGetStyle(value as never), TypeError); + assert.deepEqual(owner.rawGetStyle(node), owner.rawGetStyle(node)); + } +}); diff --git a/tests/taffyjs-node/tests/tree/add-child.test.mts b/tests/taffyjs-node/tests/tree/add-child.test.mts index 6316b99..f82c70e 100644 --- a/tests/taffyjs-node/tests/tree/add-child.test.mts +++ b/tests/taffyjs-node/tests/tree/add-child.test.mts @@ -82,38 +82,6 @@ test("topology-reject", () => { ); }); -test("id-roles", () => { - const tree = new TaffyTree(); - const parent = tree.newLeaf(); - const child = tree.newLeaf(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.addChild(1 as never, child)).constructor, TypeError); - assert.equal( - captureError(() => tree.addChild(parent, 0n as never)).code, - "ERR_TAFFY_INVALID_NODE_ID", - ); - assert.equal(captureError(() => tree.addChild(foreign, child)).code, "ERR_TAFFY_FOREIGN_NODE_ID"); - assert.equal( - captureError(() => tree.addChild(parent, foreign)).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - - const staleParent = tree.newLeaf(); - const staleChild = tree.newLeaf(); - tree.clear(); - const currentParent = tree.newLeaf(); - const currentChild = tree.newLeaf(); - assert.equal( - captureError(() => tree.addChild(staleParent, currentChild)).code, - "ERR_TAFFY_STALE_NODE_ID", - ); - assert.equal( - captureError(() => tree.addChild(currentParent, staleChild)).code, - "ERR_TAFFY_STALE_NODE_ID", - ); -}); - test("failure-atomic", () => { const tree = new TaffyTree(); const child = tree.newLeaf({ flexGrow: 1 }); diff --git a/tests/taffyjs-node/tests/tree/clear.test.mts b/tests/taffyjs-node/tests/tree/clear.test.mts index a116ada..75a498e 100644 --- a/tests/taffyjs-node/tests/tree/clear.test.mts +++ b/tests/taffyjs-node/tests/tree/clear.test.mts @@ -2,21 +2,6 @@ import assert from "node:assert/strict"; import { TaffyTree } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; - -const U32_MASK = (1n << 32n) - 1n; -const U64_MASK = (1n << 64n) - 1n; - -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - test("empty-tree", () => { const tree = new TaffyTree(); tree.clear(); @@ -32,24 +17,3 @@ test("leaf-tree", () => { tree.clear(); assert.equal(tree.getNodeCount(), 0); }); - -test("ids-stale", () => { - const tree = new TaffyTree(); - const nodes = [tree.newLeaf(), tree.newLeaf(), tree.newLeaf()]; - tree.clear(); - - for (const node of nodes) { - assert.equal(captureError(() => tree.getStyle(node)).code, "ERR_TAFFY_STALE_NODE_ID"); - } -}); - -test("serial-monotonic", () => { - const tree = new TaffyTree(); - const first = tree.newLeaf(); - tree.clear(); - const second = tree.newLeaf(); - - assert.equal(first & U32_MASK, second & U32_MASK, "native slot is reused by the fixture"); - assert.equal((second >> 64n) & U64_MASK, ((first >> 64n) & U64_MASK) + 1n); - assert.notEqual(first, second); -}); diff --git a/tests/taffyjs-node/tests/tree/compute-layout.test.mts b/tests/taffyjs-node/tests/tree/compute-layout.test.mts index 81ab94f..a1b2094 100644 --- a/tests/taffyjs-node/tests/tree/compute-layout.test.mts +++ b/tests/taffyjs-node/tests/tree/compute-layout.test.mts @@ -188,30 +188,6 @@ test("rounding", () => { assert.deepEqual(tree.getUnroundedLayout(node).size, { width: 10.5, height: 6.25 }); }); -test("invalid-root", () => { - const tree = new TaffyTree(); - const node = tree.newLeaf(); - const foreign = new TaffyTree().newLeaf(); - const options = (root: NodeId) => ({ root, availableSpace: maxContentSpace() }); - - assert.equal(captureError(() => tree.computeLayout(options(1 as never))).constructor, TypeError); - assert.equal( - captureError(() => tree.computeLayout(options(0n as never))).code, - "ERR_TAFFY_INVALID_NODE_ID", - ); - assert.equal( - captureError(() => tree.computeLayout(options(foreign))).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - assert.deepEqual(tree.getLayout(node).size, { width: 0, height: 0 }); - - tree.clear(); - assert.equal( - captureError(() => tree.computeLayout(options(node))).code, - "ERR_TAFFY_STALE_NODE_ID", - ); -}); - test("invalid-space", () => { const tree = new TaffyTree(); const node = tree.newLeaf({ diff --git a/tests/taffyjs-node/tests/tree/constructor.test.mts b/tests/taffyjs-node/tests/tree/constructor.test.mts index 72b1b33..ed12d67 100644 --- a/tests/taffyjs-node/tests/tree/constructor.test.mts +++ b/tests/taffyjs-node/tests/tree/constructor.test.mts @@ -9,7 +9,7 @@ test("construct", () => { assert.equal(first.getNodeCount(), 0); assert.equal(second.getNodeCount(), 0); - assert.notEqual(first.newLeaf(), second.newLeaf(), "each tree has an independent token"); + assert.equal(first.newLeaf(), second.newLeaf(), "independent trees reuse Taffy's raw key values"); }); test("export-boundary", () => { diff --git a/tests/taffyjs-node/tests/tree/get-child-at-index.test.mts b/tests/taffyjs-node/tests/tree/get-child-at-index.test.mts index 4549ee8..a02e41e 100644 --- a/tests/taffyjs-node/tests/tree/get-child-at-index.test.mts +++ b/tests/taffyjs-node/tests/tree/get-child-at-index.test.mts @@ -55,22 +55,3 @@ test("integer", () => { TypeError, ); }); - -test("invalid-parent", () => { - const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.getChildAtIndex(1 as never, 0)).constructor, TypeError); - assert.equal( - captureError(() => tree.getChildAtIndex(0n as never, 0)).code, - "ERR_TAFFY_INVALID_NODE_ID", - ); - assert.equal( - captureError(() => tree.getChildAtIndex(foreign, 0)).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - - const stale = tree.newLeaf(); - tree.clear(); - assert.equal(captureError(() => tree.getChildAtIndex(stale, 0)).code, "ERR_TAFFY_STALE_NODE_ID"); -}); diff --git a/tests/taffyjs-node/tests/tree/get-child-count.test.mts b/tests/taffyjs-node/tests/tree/get-child-count.test.mts index 8a72ad3..6f370d3 100644 --- a/tests/taffyjs-node/tests/tree/get-child-count.test.mts +++ b/tests/taffyjs-node/tests/tree/get-child-count.test.mts @@ -2,18 +2,6 @@ import assert from "node:assert/strict"; import { TaffyTree } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; - -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - test("empty", () => { const tree = new TaffyTree(); const leaf = tree.newLeaf(); @@ -66,19 +54,3 @@ test("number-result", () => { assert.equal(Number.isSafeInteger(count), true); assert.equal(count >= 0, true); }); - -test("invalid-parent", () => { - const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.getChildCount(1 as never)).constructor, TypeError); - assert.equal( - captureError(() => tree.getChildCount(0n as never)).code, - "ERR_TAFFY_INVALID_NODE_ID", - ); - assert.equal(captureError(() => tree.getChildCount(foreign)).code, "ERR_TAFFY_FOREIGN_NODE_ID"); - - const stale = tree.newLeaf(); - tree.clear(); - assert.equal(captureError(() => tree.getChildCount(stale)).code, "ERR_TAFFY_STALE_NODE_ID"); -}); diff --git a/tests/taffyjs-node/tests/tree/get-children.test.mts b/tests/taffyjs-node/tests/tree/get-children.test.mts index 2a965fe..0fce6dd 100644 --- a/tests/taffyjs-node/tests/tree/get-children.test.mts +++ b/tests/taffyjs-node/tests/tree/get-children.test.mts @@ -2,18 +2,6 @@ import assert from "node:assert/strict"; import { type NodeId, TaffyTree } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; - -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - test("empty", () => { const tree = new TaffyTree(); const leaf = tree.newLeaf(); @@ -61,16 +49,3 @@ test("detached-array", () => { assert.deepEqual(tree.getChildren(parent), children); assert.notEqual(tree.getChildren(parent), tree.getChildren(parent)); }); - -test("invalid-parent", () => { - const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.getChildren(1 as never)).constructor, TypeError); - assert.equal(captureError(() => tree.getChildren(0n as never)).code, "ERR_TAFFY_INVALID_NODE_ID"); - assert.equal(captureError(() => tree.getChildren(foreign)).code, "ERR_TAFFY_FOREIGN_NODE_ID"); - - const stale = tree.newLeaf(); - tree.clear(); - assert.equal(captureError(() => tree.getChildren(stale)).code, "ERR_TAFFY_STALE_NODE_ID"); -}); diff --git a/tests/taffyjs-node/tests/tree/get-detailed-layout-info.test.mts b/tests/taffyjs-node/tests/tree/get-detailed-layout-info.test.mts index 517f0b2..aaf5b8c 100644 --- a/tests/taffyjs-node/tests/tree/get-detailed-layout-info.test.mts +++ b/tests/taffyjs-node/tests/tree/get-detailed-layout-info.test.mts @@ -11,7 +11,6 @@ import { } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; type Tracks = { negativeImplicitTracks: number; explicitTracks: number; @@ -66,16 +65,6 @@ function gridValue(value: Detail): GridInfo { return (value as { kind: number; value: GridInfo }).value; } -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - test("new-none", () => { const tree = new TaffyTree(); const node = tree.newLeaf(); @@ -147,28 +136,6 @@ test("deep-detached", () => { }); }); -test("invalid-id", () => { - const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.getDetailedLayoutInfo(1 as never)).constructor, TypeError); - assert.equal( - captureError(() => tree.getDetailedLayoutInfo(0n as never)).code, - "ERR_TAFFY_INVALID_NODE_ID", - ); - assert.equal( - captureError(() => tree.getDetailedLayoutInfo(foreign)).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - - const stale = tree.newLeaf(); - tree.clear(); - assert.equal( - captureError(() => tree.getDetailedLayoutInfo(stale)).code, - "ERR_TAFFY_STALE_NODE_ID", - ); -}); - test("stale-upstream", () => { const tree = new TaffyTree(); const { root } = explicitGrid(tree); diff --git a/tests/taffyjs-node/tests/tree/get-layout.test.mts b/tests/taffyjs-node/tests/tree/get-layout.test.mts index fa4b683..ca063be 100644 --- a/tests/taffyjs-node/tests/tree/get-layout.test.mts +++ b/tests/taffyjs-node/tests/tree/get-layout.test.mts @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import { AvailableSpace, TaffyTree } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; type Layout = { order: number; location: { x: number; y: number }; @@ -29,16 +28,6 @@ function maxContentSpace() { return { width: AvailableSpace.MaxContent, height: AvailableSpace.MaxContent }; } -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - test("exact-zero", () => { const tree = new TaffyTree(); const node = tree.newLeaf(); @@ -129,21 +118,3 @@ test("numeric-widening", () => { assert.equal(typeof layout.order, "number"); assert.equal(Number.isSafeInteger(layout.order), true); }); - -test("invalid-id", () => { - const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.getLayout(1 as never)).constructor, TypeError); - assert.equal(captureError(() => tree.getLayout(0n as never)).code, "ERR_TAFFY_INVALID_NODE_ID"); - assert.equal(captureError(() => tree.getLayout(foreign)).code, "ERR_TAFFY_FOREIGN_NODE_ID"); - - const stale = tree.newLeaf(); - tree.clear(); - assert.equal(captureError(() => tree.getLayout(stale)).code, "ERR_TAFFY_STALE_NODE_ID"); - - const removedTree = new TaffyTree(); - const removed = removedTree.newLeaf({}); - removedTree.remove(removed); - assert.equal(captureError(() => removedTree.getLayout(removed)).code, "ERR_TAFFY_STALE_NODE_ID"); -}); diff --git a/tests/taffyjs-node/tests/tree/get-node-context.test.mts b/tests/taffyjs-node/tests/tree/get-node-context.test.mts index 4779fff..5b78336 100644 --- a/tests/taffyjs-node/tests/tree/get-node-context.test.mts +++ b/tests/taffyjs-node/tests/tree/get-node-context.test.mts @@ -2,22 +2,10 @@ import assert from "node:assert/strict"; import { AvailableSpace, TaffyTree } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; - function availableSpace() { return { width: AvailableSpace.MinContent, height: AvailableSpace.MinContent }; } -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - test("absence", () => { const tree = new TaffyTree(); const leaf = tree.newLeaf(); @@ -65,19 +53,3 @@ test("manual-dirty", () => { tree.markDirty(node); assert.equal(tree.isDirty(node), true); }); - -test("invalid-id", () => { - const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.getNodeContext(1 as never)).constructor, TypeError); - assert.equal( - captureError(() => tree.getNodeContext(0n as never)).code, - "ERR_TAFFY_INVALID_NODE_ID", - ); - assert.equal(captureError(() => tree.getNodeContext(foreign)).code, "ERR_TAFFY_FOREIGN_NODE_ID"); - - const stale = tree.newLeaf(); - tree.clear(); - assert.equal(captureError(() => tree.getNodeContext(stale)).code, "ERR_TAFFY_STALE_NODE_ID"); -}); diff --git a/tests/taffyjs-node/tests/tree/get-parent.test.mts b/tests/taffyjs-node/tests/tree/get-parent.test.mts index a37025a..db1e132 100644 --- a/tests/taffyjs-node/tests/tree/get-parent.test.mts +++ b/tests/taffyjs-node/tests/tree/get-parent.test.mts @@ -2,20 +2,8 @@ import assert from "node:assert/strict"; import { TaffyTree } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; - const SLOT_MASK = (1n << 32n) - 1n; -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - test("root-null", () => { const tree = new TaffyTree(); const leaf = tree.newLeaf(); @@ -67,18 +55,4 @@ test("slot-reuse", () => { assert.notEqual(secondParent, firstChild); assert.notEqual(secondParent, firstParent); assert.notEqual(tree.getParent(secondChild), firstParent); - assert.equal(captureError(() => tree.getParent(firstChild)).code, "ERR_TAFFY_STALE_NODE_ID"); -}); - -test("invalid-id", () => { - const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.getParent(1 as never)).constructor, TypeError); - assert.equal(captureError(() => tree.getParent(0n as never)).code, "ERR_TAFFY_INVALID_NODE_ID"); - assert.equal(captureError(() => tree.getParent(foreign)).code, "ERR_TAFFY_FOREIGN_NODE_ID"); - - const stale = tree.newLeaf(); - tree.clear(); - assert.equal(captureError(() => tree.getParent(stale)).code, "ERR_TAFFY_STALE_NODE_ID"); }); diff --git a/tests/taffyjs-node/tests/tree/get-style.test.mts b/tests/taffyjs-node/tests/tree/get-style.test.mts index 898602a..f2101b5 100644 --- a/tests/taffyjs-node/tests/tree/get-style.test.mts +++ b/tests/taffyjs-node/tests/tree/get-style.test.mts @@ -2,8 +2,6 @@ import assert from "node:assert/strict"; import { Dimension, Display, TaffyTree, TrackSizingFunction } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; - const STYLE_FIELDS = [ "display", "itemIsTable", @@ -48,16 +46,6 @@ const STYLE_FIELDS = [ "gridColumn", ]; -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - test("exact-keys", () => { const tree = new TaffyTree(); const defaults = tree.getStyle(tree.newLeaf()); @@ -149,16 +137,3 @@ test("independent-snapshots", () => { (second.gridTemplateRowNames as unknown[])[0], ); }); - -test("invalid-id", () => { - const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.getStyle(1 as never)).constructor, TypeError); - assert.equal(captureError(() => tree.getStyle(0n as never)).code, "ERR_TAFFY_INVALID_NODE_ID"); - assert.equal(captureError(() => tree.getStyle(foreign)).code, "ERR_TAFFY_FOREIGN_NODE_ID"); - - const stale = tree.newLeaf(); - tree.clear(); - assert.equal(captureError(() => tree.getStyle(stale)).code, "ERR_TAFFY_STALE_NODE_ID"); -}); diff --git a/tests/taffyjs-node/tests/tree/get-unrounded-layout.test.mts b/tests/taffyjs-node/tests/tree/get-unrounded-layout.test.mts index 4f2777c..c9299d9 100644 --- a/tests/taffyjs-node/tests/tree/get-unrounded-layout.test.mts +++ b/tests/taffyjs-node/tests/tree/get-unrounded-layout.test.mts @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import { AvailableSpace, TaffyTree } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; type Layout = { order: number; location: { x: number; y: number }; @@ -29,16 +28,6 @@ function maxContentSpace() { return { width: AvailableSpace.MaxContent, height: AvailableSpace.MaxContent }; } -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - test("exact-zero", () => { const tree = new TaffyTree(); const node = tree.newLeaf(); @@ -91,30 +80,3 @@ test("detached", () => { mutableFirst.padding.left = 88; assert.deepEqual(tree.getUnroundedLayout(node), second); }); - -test("invalid-id", () => { - const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.getUnroundedLayout(1 as never)).constructor, TypeError); - assert.equal( - captureError(() => tree.getUnroundedLayout(0n as never)).code, - "ERR_TAFFY_INVALID_NODE_ID", - ); - assert.equal( - captureError(() => tree.getUnroundedLayout(foreign)).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - - const stale = tree.newLeaf(); - tree.clear(); - assert.equal(captureError(() => tree.getUnroundedLayout(stale)).code, "ERR_TAFFY_STALE_NODE_ID"); - - const removedTree = new TaffyTree(); - const removed = removedTree.newLeaf({}); - removedTree.remove(removed); - assert.equal( - captureError(() => removedTree.getUnroundedLayout(removed)).code, - "ERR_TAFFY_STALE_NODE_ID", - ); -}); diff --git a/tests/taffyjs-node/tests/tree/insert-child-at-index.test.mts b/tests/taffyjs-node/tests/tree/insert-child-at-index.test.mts index 066e488..d223803 100644 --- a/tests/taffyjs-node/tests/tree/insert-child-at-index.test.mts +++ b/tests/taffyjs-node/tests/tree/insert-child-at-index.test.mts @@ -78,44 +78,6 @@ test("index-errors", () => { assert.equal(tree.getParent(child), null); }); -test("id-roles", () => { - const tree = new TaffyTree(); - const parent = tree.newLeaf(); - const child = tree.newLeaf(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal( - captureError(() => tree.insertChildAtIndex(1 as never, 0, child)).constructor, - TypeError, - ); - assert.equal( - captureError(() => tree.insertChildAtIndex(parent, 0, 0n as never)).code, - "ERR_TAFFY_INVALID_NODE_ID", - ); - assert.equal( - captureError(() => tree.insertChildAtIndex(foreign, 0, child)).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - assert.equal( - captureError(() => tree.insertChildAtIndex(parent, 0, foreign)).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - - const staleParent = tree.newLeaf(); - const staleChild = tree.newLeaf(); - tree.clear(); - const currentParent = tree.newLeaf(); - const currentChild = tree.newLeaf(); - assert.equal( - captureError(() => tree.insertChildAtIndex(staleParent, 0, currentChild)).code, - "ERR_TAFFY_STALE_NODE_ID", - ); - assert.equal( - captureError(() => tree.insertChildAtIndex(currentParent, 0, staleChild)).code, - "ERR_TAFFY_STALE_NODE_ID", - ); -}); - test("failure-atomic", () => { const tree = new TaffyTree(); const child = tree.newLeaf(); diff --git a/tests/taffyjs-node/tests/tree/is-dirty.test.mts b/tests/taffyjs-node/tests/tree/is-dirty.test.mts index 30ed443..d68a9f1 100644 --- a/tests/taffyjs-node/tests/tree/is-dirty.test.mts +++ b/tests/taffyjs-node/tests/tree/is-dirty.test.mts @@ -2,8 +2,6 @@ import assert from "node:assert/strict"; import { AlignContent, AvailableSpace, Display, type NodeId, TaffyTree } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; - function availableSpace() { return { width: AvailableSpace.MaxContent, height: AvailableSpace.MaxContent }; } @@ -28,16 +26,6 @@ function assertParentDirty(tree: TaffyTree, child: NodeId, parent: NodeId, root: assert.equal(tree.isDirty(root), true, "ancestor is dirty"); } -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - test("lifecycle", () => { const tree = new TaffyTree(); const child = tree.newLeaf(); @@ -148,16 +136,3 @@ test("child-nuance", () => { assert.notEqual(tree.getUnroundedLayout(child).location.x, before); assert.equal(tree.isDirty(child), false); }); - -test("invalid-id", () => { - const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.isDirty(1 as never)).constructor, TypeError); - assert.equal(captureError(() => tree.isDirty(0n as never)).code, "ERR_TAFFY_INVALID_NODE_ID"); - assert.equal(captureError(() => tree.isDirty(foreign)).code, "ERR_TAFFY_FOREIGN_NODE_ID"); - - const stale = tree.newLeaf(); - tree.clear(); - assert.equal(captureError(() => tree.isDirty(stale)).code, "ERR_TAFFY_STALE_NODE_ID"); -}); diff --git a/tests/taffyjs-node/tests/tree/mark-dirty.test.mts b/tests/taffyjs-node/tests/tree/mark-dirty.test.mts index 05736c5..fe6cbbe 100644 --- a/tests/taffyjs-node/tests/tree/mark-dirty.test.mts +++ b/tests/taffyjs-node/tests/tree/mark-dirty.test.mts @@ -2,8 +2,6 @@ import assert from "node:assert/strict"; import { AvailableSpace, Display, type NodeId, TaffyTree } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; - function availableSpace() { return { width: AvailableSpace.MaxContent, height: AvailableSpace.MaxContent }; } @@ -19,16 +17,6 @@ function computeMeasured(tree: TaffyTree, root: NodeId, calls: Map unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - test("propagation", () => { const tree = new TaffyTree(); const leaf = tree.newLeaf(); @@ -105,16 +93,3 @@ test("any-node", () => { assert.doesNotThrow(() => tree.markDirty(contextual)); assert.doesNotThrow(() => tree.markDirty(root)); }); - -test("invalid-id", () => { - const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.markDirty(1 as never)).constructor, TypeError); - assert.equal(captureError(() => tree.markDirty(0n as never)).code, "ERR_TAFFY_INVALID_NODE_ID"); - assert.equal(captureError(() => tree.markDirty(foreign)).code, "ERR_TAFFY_FOREIGN_NODE_ID"); - - const stale = tree.newLeaf(); - tree.clear(); - assert.equal(captureError(() => tree.markDirty(stale)).code, "ERR_TAFFY_STALE_NODE_ID"); -}); diff --git a/tests/taffyjs-node/tests/tree/new-leaf-with-context.test.mts b/tests/taffyjs-node/tests/tree/new-leaf-with-context.test.mts index 14c024b..0c2d939 100644 --- a/tests/taffyjs-node/tests/tree/new-leaf-with-context.test.mts +++ b/tests/taffyjs-node/tests/tree/new-leaf-with-context.test.mts @@ -6,16 +6,10 @@ import { test } from "vite-plus/test"; type MeasureArgs = { node: NodeId; context: unknown }; -const U64_MASK = (1n << 64n) - 1n; - function availableSpace() { return { width: AvailableSpace.MinContent, height: AvailableSpace.MinContent }; } -function creationSerial(node: NodeId) { - return (node >> 64n) & U64_MASK; -} - function measure(tree: TaffyTree, root: NodeId, callback: (args: MeasureArgs) => void) { tree.computeLayout({ root, @@ -116,7 +110,6 @@ test("conversion-atomic", () => { assert.equal(tree.getNodeCount(), 1); const first = tree.newLeafWithContext(context); - assert.equal(creationSerial(first), 2n, "failed conversion does not consume a serial"); assert.equal(tree.getNodeCount(), 2); assert.equal(tree.getNodeContext(first), context); }); diff --git a/tests/taffyjs-node/tests/tree/new-leaf.test.mts b/tests/taffyjs-node/tests/tree/new-leaf.test.mts index c072387..92a2ad6 100644 --- a/tests/taffyjs-node/tests/tree/new-leaf.test.mts +++ b/tests/taffyjs-node/tests/tree/new-leaf.test.mts @@ -1,13 +1,7 @@ import assert from "node:assert/strict"; -import { BoxSizing, Dimension, Display, type NodeId, TaffyTree } from "@taffyjs/node"; +import { BoxSizing, Dimension, Display, TaffyTree } from "@taffyjs/node"; import { test } from "vite-plus/test"; -const U64_MASK = (1n << 64n) - 1n; - -function creationSerial(node: NodeId) { - return (node >> 64n) & U64_MASK; -} - test("default-style", () => { const tree = new TaffyTree(); const omitted = tree.newLeaf(); @@ -56,7 +50,6 @@ test("conversion-atomic", () => { assert.throws(() => tree.newLeaf({ display: 999 } as never), RangeError); assert.equal(tree.getNodeCount(), 1); - const first = tree.newLeaf(); - assert.equal(creationSerial(first), 2n, "failed conversion does not consume a serial"); + tree.newLeaf(); assert.equal(tree.getNodeCount(), 2); }); diff --git a/tests/taffyjs-node/tests/tree/new-with-children.test.mts b/tests/taffyjs-node/tests/tree/new-with-children.test.mts index e16b6ff..e5e2e11 100644 --- a/tests/taffyjs-node/tests/tree/new-with-children.test.mts +++ b/tests/taffyjs-node/tests/tree/new-with-children.test.mts @@ -81,25 +81,6 @@ test("attached", () => { assert.deepEqual(tree.getChildren(firstParent), [child]); }); -test("invalid-id", () => { - const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal( - captureError(() => tree.newWithChildren([foreign], { display: 999 } as never)).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - assert.equal(tree.getNodeCount(), 0); - - const stale = tree.newLeaf(); - tree.clear(); - assert.equal( - captureError(() => tree.newWithChildren([stale], { display: 999 } as never)).code, - "ERR_TAFFY_STALE_NODE_ID", - ); - assert.equal(tree.getNodeCount(), 0); -}); - test("failure-atomic", () => { const tree = new TaffyTree(); const first = tree.newLeaf(); diff --git a/tests/taffyjs-node/tests/tree/remove-child-at-index.test.mts b/tests/taffyjs-node/tests/tree/remove-child-at-index.test.mts index cd78f03..27653ec 100644 --- a/tests/taffyjs-node/tests/tree/remove-child-at-index.test.mts +++ b/tests/taffyjs-node/tests/tree/remove-child-at-index.test.mts @@ -90,7 +90,6 @@ test("failure-atomic", () => { const tree = new TaffyTree(); const [first, second] = [tree.newLeaf(), tree.newLeaf()]; const parent = tree.newWithChildren([first, second]); - const foreign = new TaffyTree().newLeaf(); const nodes = [first, second, parent]; const before = topology(tree, nodes); @@ -99,8 +98,6 @@ test("failure-atomic", () => { () => tree.removeChildAtIndex(parent, -1), () => tree.removeChildAtIndex(parent, 0.5), () => tree.removeChildAtIndex(parent, "0" as unknown as number), - () => tree.removeChildAtIndex(0n as never, 0), - () => tree.removeChildAtIndex(foreign, 0), ]; for (const rejectedCall of rejectedCalls) { diff --git a/tests/taffyjs-node/tests/tree/remove-child.test.mts b/tests/taffyjs-node/tests/tree/remove-child.test.mts index 2ec1719..e732026 100644 --- a/tests/taffyjs-node/tests/tree/remove-child.test.mts +++ b/tests/taffyjs-node/tests/tree/remove-child.test.mts @@ -73,41 +73,6 @@ test("dirty", () => { assert.equal(tree.isDirty(root), true); }); -test("id-roles", () => { - const tree = new TaffyTree(); - const child = tree.newLeaf(); - const parent = tree.newWithChildren([child]); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.removeChild(1 as never, child)).constructor, TypeError); - assert.equal( - captureError(() => tree.removeChild(parent, 0n as never)).code, - "ERR_TAFFY_INVALID_NODE_ID", - ); - assert.equal( - captureError(() => tree.removeChild(foreign, child)).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - assert.equal( - captureError(() => tree.removeChild(parent, foreign)).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - - const staleParent = tree.newLeaf(); - const staleChild = tree.newLeaf(); - tree.clear(); - const currentParent = tree.newLeaf(); - const currentChild = tree.newLeaf(); - assert.equal( - captureError(() => tree.removeChild(staleParent, currentChild)).code, - "ERR_TAFFY_STALE_NODE_ID", - ); - assert.equal( - captureError(() => tree.removeChild(currentParent, staleChild)).code, - "ERR_TAFFY_STALE_NODE_ID", - ); -}); - test("failure-atomic", () => { const tree = new TaffyTree(); const child = tree.newLeaf(); diff --git a/tests/taffyjs-node/tests/tree/remove.test.mts b/tests/taffyjs-node/tests/tree/remove.test.mts index 8a07dc9..c77fcfe 100644 --- a/tests/taffyjs-node/tests/tree/remove.test.mts +++ b/tests/taffyjs-node/tests/tree/remove.test.mts @@ -2,8 +2,6 @@ import assert from "node:assert/strict"; import { AvailableSpace, type NodeId, TaffyTree } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; - const SLOT_MASK = (1n << 32n) - 1n; function availableSpace() { @@ -14,29 +12,6 @@ function compute(tree: TaffyTree, root: NodeId) { tree.computeLayout({ root, availableSpace: availableSpace() }); } -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - -function snapshot(tree: TaffyTree, child: NodeId, parent: NodeId, root: NodeId) { - return { - count: tree.getNodeCount(), - childParent: tree.getParent(child), - parentChildren: [...tree.getChildren(parent)], - parentParent: tree.getParent(parent), - rootChildren: [...tree.getChildren(root)], - context: tree.getNodeContext(child), - dirty: [tree.isDirty(child), tree.isDirty(parent), tree.isDirty(root)], - layouts: [tree.getLayout(child), tree.getLayout(parent), tree.getLayout(root)], - }; -} - test("remove-root", () => { const tree = new TaffyTree(); const root = tree.newLeaf(); @@ -57,20 +32,17 @@ test("remove-child", () => { assert.equal(tree.getNodeCount(), 2); assert.deepEqual(tree.getChildren(parent), []); assert.equal(tree.getParent(grandchild), null); - assert.equal(captureError(() => tree.getParent(child)).code, "ERR_TAFFY_STALE_NODE_ID"); }); -test("id-stale", () => { +test("slot-reuse", () => { const tree = new TaffyTree(); const removed = tree.newLeaf(); tree.remove(removed); - assert.equal(captureError(() => tree.remove(removed)).code, "ERR_TAFFY_STALE_NODE_ID"); const replacement = tree.newLeaf(); assert.equal(replacement & SLOT_MASK, removed & SLOT_MASK, "the native slot is reused"); assert.notEqual(replacement, removed); assert.equal(tree.getParent(replacement), null); - assert.equal(captureError(() => tree.getParent(removed)).code, "ERR_TAFFY_STALE_NODE_ID"); }); test("parent-dirtied", () => { @@ -92,28 +64,3 @@ test("parent-dirtied", () => { assert.deepEqual(tree.getLayout(parent), parentLayout); assert.deepEqual(tree.getLayout(root), rootLayout); }); - -test("invalid-atomic", () => { - const tree = new TaffyTree(); - const context = { retained: true }; - const child = tree.newLeafWithContext(context); - const parent = tree.newWithChildren([child]); - const root = tree.newWithChildren([parent]); - const foreign = new TaffyTree().newLeaf(); - compute(tree, root); - const before = snapshot(tree, child, parent, root); - - for (const invalid of [1 as never, 0n as never, foreign]) { - captureError(() => tree.remove(invalid)); - assert.deepEqual(snapshot(tree, child, parent, root), before); - } - - tree.clear(); - const currentChild = tree.newLeafWithContext(context); - const currentParent = tree.newWithChildren([currentChild]); - const currentRoot = tree.newWithChildren([currentParent]); - compute(tree, currentRoot); - const afterClear = snapshot(tree, currentChild, currentParent, currentRoot); - assert.equal(captureError(() => tree.remove(child)).code, "ERR_TAFFY_STALE_NODE_ID"); - assert.deepEqual(snapshot(tree, currentChild, currentParent, currentRoot), afterClear); -}); diff --git a/tests/taffyjs-node/tests/tree/replace-child-at-index.test.mts b/tests/taffyjs-node/tests/tree/replace-child-at-index.test.mts index 6a0964e..b66a3ca 100644 --- a/tests/taffyjs-node/tests/tree/replace-child-at-index.test.mts +++ b/tests/taffyjs-node/tests/tree/replace-child-at-index.test.mts @@ -123,45 +123,6 @@ test("reject", () => { ); }); -test("id-roles", () => { - const tree = new TaffyTree(); - const child = tree.newLeaf(); - const replacement = tree.newLeaf(); - const parent = tree.newWithChildren([child]); - const foreign = new TaffyTree().newLeaf(); - - assert.equal( - captureError(() => tree.replaceChildAtIndex(1 as never, 0, replacement)).constructor, - TypeError, - ); - assert.equal( - captureError(() => tree.replaceChildAtIndex(parent, 0, 0n as never)).code, - "ERR_TAFFY_INVALID_NODE_ID", - ); - assert.equal( - captureError(() => tree.replaceChildAtIndex(foreign, 0, replacement)).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - assert.equal( - captureError(() => tree.replaceChildAtIndex(parent, 0, foreign)).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - - const staleParent = tree.newLeaf(); - const staleChild = tree.newLeaf(); - tree.clear(); - const currentParent = tree.newLeaf(); - const currentChild = tree.newLeaf(); - assert.equal( - captureError(() => tree.replaceChildAtIndex(staleParent, 0, currentChild)).code, - "ERR_TAFFY_STALE_NODE_ID", - ); - assert.equal( - captureError(() => tree.replaceChildAtIndex(currentParent, 0, staleChild)).code, - "ERR_TAFFY_STALE_NODE_ID", - ); -}); - test("failure-atomic", () => { const tree = new TaffyTree(); const [first, second, attached, free] = [ diff --git a/tests/taffyjs-node/tests/tree/set-children.test.mts b/tests/taffyjs-node/tests/tree/set-children.test.mts index b23badc..22b4807 100644 --- a/tests/taffyjs-node/tests/tree/set-children.test.mts +++ b/tests/taffyjs-node/tests/tree/set-children.test.mts @@ -112,21 +112,6 @@ test("topology-reject", () => { ); }); -test("invalid-middle", () => { - const tree = new TaffyTree(); - const first = tree.newLeaf(); - const last = tree.newLeaf(); - const parent = tree.newWithChildren([first]); - const foreign = new TaffyTree().newLeaf(); - const before = topology(tree, [first, last, parent]); - - assert.equal( - captureError(() => tree.setChildren(parent, [last, foreign, first])).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - assert.deepEqual(topology(tree, [first, last, parent]), before); -}); - test("failure-atomic", () => { const tree = new TaffyTree(); const [first, second, third] = [tree.newLeaf(), tree.newLeaf(), tree.newLeaf()]; diff --git a/tests/taffyjs-node/tests/tree/set-node-context.test.mts b/tests/taffyjs-node/tests/tree/set-node-context.test.mts index ab79e26..a2a77f0 100644 --- a/tests/taffyjs-node/tests/tree/set-node-context.test.mts +++ b/tests/taffyjs-node/tests/tree/set-node-context.test.mts @@ -2,23 +2,12 @@ import assert from "node:assert/strict"; import { AvailableSpace, type NodeId, TaffyTree } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; type MeasureArgs = { node: NodeId; context: unknown }; function availableSpace() { return { width: AvailableSpace.MinContent, height: AvailableSpace.MinContent }; } -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - function computeWithMeasure(tree: TaffyTree, root: NodeId, measure: (args: MeasureArgs) => void) { tree.computeLayout({ root, @@ -98,30 +87,3 @@ test("measure-delivery", () => { }); assert.equal(calls > 0, true); }); - -test("invalid-atomic", () => { - const tree = new TaffyTree(); - const original = { unchanged: true }; - const replacement = { unchanged: false }; - const node = tree.newLeafWithContext(original); - const foreign = new TaffyTree().newLeaf(); - tree.computeLayout({ root: node, availableSpace: availableSpace() }); - assert.equal(tree.isDirty(node), false); - - for (const invalid of [1 as never, 0n as never, foreign]) { - captureError(() => tree.setNodeContext(invalid, replacement)); - assert.equal(tree.getNodeContext(node), original); - assert.equal(tree.isDirty(node), false); - } - - const stale = tree.newLeaf(); - tree.clear(); - const current = tree.newLeafWithContext(original); - tree.computeLayout({ root: current, availableSpace: availableSpace() }); - assert.equal( - captureError(() => tree.setNodeContext(stale, replacement)).code, - "ERR_TAFFY_STALE_NODE_ID", - ); - assert.equal(tree.getNodeContext(current), original); - assert.equal(tree.isDirty(current), false); -}); diff --git a/tests/taffyjs-node/tests/tree/set-style.test.mts b/tests/taffyjs-node/tests/tree/set-style.test.mts index b638781..d38e065 100644 --- a/tests/taffyjs-node/tests/tree/set-style.test.mts +++ b/tests/taffyjs-node/tests/tree/set-style.test.mts @@ -11,22 +11,10 @@ import { } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; - function maxContentSpace() { return { width: AvailableSpace.MaxContent, height: AvailableSpace.MaxContent }; } -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - test("complete-replace", () => { const tree = new TaffyTree(); const node = tree.newLeaf({ display: Display.None, flexGrow: 3 }); @@ -109,25 +97,3 @@ test("failure-atomic", () => { assert.deepEqual(tree.getStyle(node), beforeStyle); assert.equal(tree.isDirty(node), beforeDirty); }); - -test("invalid-id", () => { - const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.setStyle(1 as never, {})).constructor, TypeError); - assert.equal( - captureError(() => tree.setStyle(0n as never, {})).code, - "ERR_TAFFY_INVALID_NODE_ID", - ); - assert.equal( - captureError(() => tree.setStyle(foreign, { display: 999 } as never)).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - - const stale = tree.newLeaf(); - tree.clear(); - assert.equal( - captureError(() => tree.setStyle(stale, { display: 999 } as never)).code, - "ERR_TAFFY_STALE_NODE_ID", - ); -}); diff --git a/tests/taffyjs-node/tests/tree/update-style.test.mts b/tests/taffyjs-node/tests/tree/update-style.test.mts index e5967d4..752d372 100644 --- a/tests/taffyjs-node/tests/tree/update-style.test.mts +++ b/tests/taffyjs-node/tests/tree/update-style.test.mts @@ -24,22 +24,10 @@ import { } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; - function maxContentSpace() { return { width: AvailableSpace.MaxContent, height: AvailableSpace.MaxContent }; } -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - function oneFieldUpdate(field: keyof StyleUpdate, value: unknown): StyleUpdate { return { [field]: value } as StyleUpdate; } @@ -271,25 +259,3 @@ test("validates the merged style before one atomic write", () => { assert.deepEqual(tree.getStyle(node), beforeStyle); assert.equal(tree.isDirty(node), beforeDirty); }); - -test("validates node IDs before updating", () => { - const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - - assert.equal(captureError(() => tree.updateStyle(1 as never, {})).constructor, TypeError); - assert.equal( - captureError(() => tree.updateStyle(0n as never, {})).code, - "ERR_TAFFY_INVALID_NODE_ID", - ); - assert.equal( - captureError(() => tree.updateStyle(foreign, { display: 999 } as never)).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); - - const stale = tree.newLeaf(); - tree.clear(); - assert.equal( - captureError(() => tree.updateStyle(stale, { display: 999 } as never)).code, - "ERR_TAFFY_STALE_NODE_ID", - ); -}); 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 b3b4928..ec42ed0 100644 --- a/tests/taffyjs-node/tests/types/public-api.test-d.ts +++ b/tests/taffyjs-node/tests/types/public-api.test-d.ts @@ -125,7 +125,7 @@ void tree.computeLayoutWithMeasure; const primitive: bigint = node; const nodeMap = new Map([[node, "node"]]); -// @ts-expect-error Plain bigint values cannot be forged into NodeId. +// @ts-expect-error Plain bigint values do not satisfy the branded NodeId type. const forged: NodeId = 1n; // @ts-expect-error bigint arithmetic removes the NodeId marker. const changed: NodeId = node + 1n; diff --git a/tests/taffyjs-node/tests/values/node-id.test.mts b/tests/taffyjs-node/tests/values/node-id.test.mts index c3fc8ab..350a644 100644 --- a/tests/taffyjs-node/tests/values/node-id.test.mts +++ b/tests/taffyjs-node/tests/values/node-id.test.mts @@ -2,19 +2,8 @@ import assert from "node:assert/strict"; import { TaffyTree } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; - const SLOT_MASK = (1n << 32n) - 1n; - -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} +const U64_LIMIT = 1n << 64n; test("js-identity", () => { const tree = new TaffyTree(); @@ -30,36 +19,24 @@ test("js-identity", () => { assert.equal([child].includes(tree.getChildAtIndex(parent, 0)), true); }); -test("malformed", () => { +test("u64-representation", () => { const tree = new TaffyTree(); - assert.equal(captureError(() => tree.getStyle(1 as never)).constructor, TypeError); - for (const value of [-1n, 0n, 1n, 1n << 256n]) { - const error = captureError(() => tree.getStyle(value as never)); - assert.equal(error.constructor, Error); - assert.equal(error.code, "ERR_TAFFY_INVALID_NODE_ID"); + for (const value of [1, -1n, U64_LIMIT]) { + assert.throws(() => tree.getStyle(value as never), TypeError); + assert.throws(() => tree.getNodeContext(value as never), TypeError); } }); -test("foreign", () => { +test("tree-local-values", () => { const first = new TaffyTree(); const second = new TaffyTree(); - const node = first.newLeaf(); - - const error = captureError(() => second.getStyle(node)); - assert.equal(error.constructor, Error); - assert.equal(error.code, "ERR_TAFFY_FOREIGN_NODE_ID"); - assert.deepEqual(first.getStyle(node), first.getStyle(node)); -}); - -test("stale-clear", () => { - const tree = new TaffyTree(); - const node = tree.newLeaf(); - tree.clear(); + const firstNode = first.newLeaf({ flexGrow: 1 }); + const secondNode = second.newLeaf({ flexGrow: 2 }); - const error = captureError(() => tree.getStyle(node)); - assert.equal(error.constructor, Error); - assert.equal(error.code, "ERR_TAFFY_STALE_NODE_ID"); + assert.equal(firstNode, secondNode, "independent trees can issue the same raw key"); + assert.equal(first.getStyle(firstNode).flexGrow, 1); + assert.equal(second.getStyle(secondNode).flexGrow, 2); }); test("slot-reuse", () => { @@ -70,6 +47,5 @@ test("slot-reuse", () => { assert.equal(first & SLOT_MASK, second & SLOT_MASK, "fixture reuses the native slot"); assert.notEqual(first, second); - assert.throws(() => tree.getStyle(first), { code: "ERR_TAFFY_STALE_NODE_ID" }); assert.deepEqual(tree.getStyle(second), tree.getStyle(second)); }); diff --git a/tests/taffyjs-node/tests/values/style-codec.test.mts b/tests/taffyjs-node/tests/values/style-codec.test.mts index 5ed8464..ec0e6fe 100644 --- a/tests/taffyjs-node/tests/values/style-codec.test.mts +++ b/tests/taffyjs-node/tests/values/style-codec.test.mts @@ -13,22 +13,10 @@ import { } from "@taffyjs/node"; import { test } from "vite-plus/test"; -type CodedError = Error & { code?: string }; - function maxContentSpace() { return { width: AvailableSpace.MaxContent, height: AvailableSpace.MaxContent }; } -function captureError(body: () => unknown): CodedError { - try { - body(); - } catch (error) { - assert.ok(error instanceof Error); - return error; - } - assert.fail("Expected operation to throw"); -} - function reentrantStyle(run: () => void, flexGrow: number): StyleInput { let entered = false; return { @@ -167,11 +155,8 @@ test("all five Style operations isolate recursive calls and oversized storage", } }); -test("stale and foreign IDs win before Style getters", () => { +test("NodeId representation is checked before Style getters", () => { const tree = new TaffyTree(); - const foreign = new TaffyTree().newLeaf(); - const stale = tree.newLeaf(); - tree.remove(stale); let getterCalls = 0; const style = { get display(): never { @@ -180,12 +165,9 @@ test("stale and foreign IDs win before Style getters", () => { }, }; - assert.equal(captureError(() => tree.setStyle(foreign, style)).code, "ERR_TAFFY_FOREIGN_NODE_ID"); - assert.equal(captureError(() => tree.updateStyle(stale, style)).code, "ERR_TAFFY_STALE_NODE_ID"); - assert.equal( - captureError(() => tree.newWithChildren([foreign], style)).code, - "ERR_TAFFY_FOREIGN_NODE_ID", - ); + assert.throws(() => tree.setStyle(-1n as never, style), TypeError); + assert.throws(() => tree.updateStyle((1n << 64n) as never, style), TypeError); + assert.throws(() => tree.newWithChildren([1 as never], style), TypeError); assert.equal(getterCalls, 0); }); From 13238648d1a5c697bf7e004901f914ae628d21d5 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 23 Aug 2026 17:41:37 +0800 Subject: [PATCH 2/5] test(node): cover the raw NodeId contract --- packages/taffyjs-node/tests/native/node-id.test.mts | 9 +++++++++ tests/taffyjs-node/tests/tree/set-children.test.mts | 3 +++ tests/taffyjs-node/tests/values/node-id.test.mts | 4 ++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/taffyjs-node/tests/native/node-id.test.mts b/packages/taffyjs-node/tests/native/node-id.test.mts index edd3899..3882a9e 100644 --- a/packages/taffyjs-node/tests/native/node-id.test.mts +++ b/packages/taffyjs-node/tests/native/node-id.test.mts @@ -1,8 +1,17 @@ import assert from "node:assert/strict"; import { BindingTaffyTree } from "../../src/binding.ts"; +import { TaffyTree } from "../../src/index.ts"; import { withEncodedStyle } from "../../src/style-input.ts"; import { test } from "vite-plus/test"; +test("public NodeId is the exact native raw value", () => { + const owner = new BindingTaffyTree(); + const raw = withEncodedStyle({}, (encoded) => owner.rawNewLeaf(encoded)); + const node = new TaffyTree().newLeaf(); + + assert.equal(node, raw); +}); + test("native NodeId inputs require a lossless u64 bigint", () => { const owner = new BindingTaffyTree(); const node = withEncodedStyle({}, (encoded) => owner.rawNewLeaf(encoded)); diff --git a/tests/taffyjs-node/tests/tree/set-children.test.mts b/tests/taffyjs-node/tests/tree/set-children.test.mts index 22b4807..ba8a4fd 100644 --- a/tests/taffyjs-node/tests/tree/set-children.test.mts +++ b/tests/taffyjs-node/tests/tree/set-children.test.mts @@ -125,6 +125,9 @@ test("failure-atomic", () => { ); assert.deepEqual(topology(tree, nodes), before); + assert.throws(() => tree.setChildren(parent, [third, -1n as never, first]), TypeError); + assert.deepEqual(topology(tree, nodes), before); + assert.throws(() => tree.setChildren(parent, {} as unknown as readonly NodeId[]), TypeError); assert.deepEqual(topology(tree, nodes), before); }); diff --git a/tests/taffyjs-node/tests/values/node-id.test.mts b/tests/taffyjs-node/tests/values/node-id.test.mts index 350a644..f5ea76a 100644 --- a/tests/taffyjs-node/tests/values/node-id.test.mts +++ b/tests/taffyjs-node/tests/values/node-id.test.mts @@ -35,8 +35,8 @@ test("tree-local-values", () => { const secondNode = second.newLeaf({ flexGrow: 2 }); assert.equal(firstNode, secondNode, "independent trees can issue the same raw key"); - assert.equal(first.getStyle(firstNode).flexGrow, 1); - assert.equal(second.getStyle(secondNode).flexGrow, 2); + assert.equal(first.getStyle(secondNode).flexGrow, 1); + assert.equal(second.getStyle(firstNode).flexGrow, 2); }); test("slot-reuse", () => { From d77b83a7f59c9b18356ee4e06ff7b947c2c60b08 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 23 Aug 2026 17:44:26 +0800 Subject: [PATCH 3/5] docs: align remaining NodeId boundary wording --- .agents/docs/binding-mapping.md | 4 ++-- .agents/docs/taffyjs-wasm-package.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/docs/binding-mapping.md b/.agents/docs/binding-mapping.md index e84884c..7bb72b0 100644 --- a/.agents/docs/binding-mapping.md +++ b/.agents/docs/binding-mapping.md @@ -108,9 +108,9 @@ Each native measured `computeLayout` creates one Rust `MeasureSession` that reus ## 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. +Validate complete input, every involved NodeId's bigint/`u64` representation, 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. -Shape failures use `TypeError`, numeric range failures use `RangeError`, and ordinary Taffy operation failures use `Error`. Stable NodeId and busy codes are part of the public contract; exact prose is not. +Shape failures use `TypeError`, numeric range failures use `RangeError`, and ordinary Taffy operation failures use `Error`. The documented topology, child-index, and busy codes are part of the public contract; exact prose is not. Known JavaScript-controlled panic paths are prevented before Taffy. On native targets, the Rust owner catches unexpected panics as a final boundary and prevents later access to a possibly inconsistent tree; panic handling is not normal error control flow. `wasm32-wasip1` is compiled with aborting panics, so `@taffyjs/wasm` does not promise that this final unexpected-panic containment is recoverable. Expected validation and callback errors are still controlled before any abort path and are tested to leave the tree reusable. diff --git a/.agents/docs/taffyjs-wasm-package.md b/.agents/docs/taffyjs-wasm-package.md index 310ba65..2e0e82f 100644 --- a/.agents/docs/taffyjs-wasm-package.md +++ b/.agents/docs/taffyjs-wasm-package.md @@ -54,7 +54,7 @@ Static and dynamic imports use the host's ordinary module cache. The synchronous ## Source and maintenance ownership -The existing authored source under `packages/taffyjs-node/src` remains the single source for `TaffyTree`, NodeId validation, JavaScript-owned context, public helpers, types, and declarations. The Wasm package build compiles that source twice and redirects only its private raw-binding import to the matching generated Node or browser adapter. This is build wiring, not a new public runtime wrapper or a second implementation of Taffy behavior. +The existing authored source under `packages/taffyjs-node/src` remains the single source for `TaffyTree`, NodeId bigint/`u64` representation checks, JavaScript-owned context, public helpers, types, and declarations. The Wasm package build compiles that source twice and redirects only its private raw-binding import to the matching generated Node or browser adapter. This is build wiring, not a new public runtime wrapper or a second implementation of Taffy behavior. napi-rs owns the source behavior of the eager Node and deferred browser loaders: WASI and emnapi setup, WebAssembly instantiation, memory plumbing, cleanup behavior, and raw binding exports. TaffyJS publishes the deferred loader unchanged. For Node, TaffyJS owns a strict build-time transformation of the freshly generated eager loader: exact-match assertions wrap its eager body as a synchronous factory, replace only its Node filesystem input, native `node:wasi` construction, file-path rollback key, and final CommonJS export with an input parameter, the JavaScript WASI runtime, and a returned binding. Any upstream template drift makes the build fail instead of silently maintaining a fork. TaffyJS does not hand-maintain or reimplement the remaining lifecycle code. From b5a207b57d09e6f39b9429f90419f98c1bbbdbd3 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 23 Aug 2026 17:47:02 +0800 Subject: [PATCH 4/5] docs: clarify NodeId representation errors --- .agents/docs/binding-mapping.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/docs/binding-mapping.md b/.agents/docs/binding-mapping.md index 7bb72b0..a5a868b 100644 --- a/.agents/docs/binding-mapping.md +++ b/.agents/docs/binding-mapping.md @@ -110,7 +110,7 @@ Each native measured `computeLayout` creates one Rust `MeasureSession` that reus Validate complete input, every involved NodeId's bigint/`u64` representation, 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. -Shape failures use `TypeError`, numeric range failures use `RangeError`, and ordinary Taffy operation failures use `Error`. The documented topology, child-index, and busy codes are part of the public contract; exact prose is not. +Shape failures and NodeId bigint/`u64` representation failures use `TypeError`, other numeric range failures use `RangeError`, and ordinary Taffy operation failures use `Error`. The documented topology, child-index, and busy codes are part of the public contract; exact prose is not. Known JavaScript-controlled panic paths are prevented before Taffy. On native targets, the Rust owner catches unexpected panics as a final boundary and prevents later access to a possibly inconsistent tree; panic handling is not normal error control flow. `wasm32-wasip1` is compiled with aborting panics, so `@taffyjs/wasm` does not promise that this final unexpected-panic containment is recoverable. Expected validation and callback errors are still controlled before any abort path and are tested to leave the tree reusable. From e5e08267f32e6779c592bab1a619e18fd2229d7a Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 23 Aug 2026 17:49:13 +0800 Subject: [PATCH 5/5] test(node): avoid pinning unsupported foreign IDs --- tests/taffyjs-node/tests/values/node-id.test.mts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/taffyjs-node/tests/values/node-id.test.mts b/tests/taffyjs-node/tests/values/node-id.test.mts index f5ea76a..350a644 100644 --- a/tests/taffyjs-node/tests/values/node-id.test.mts +++ b/tests/taffyjs-node/tests/values/node-id.test.mts @@ -35,8 +35,8 @@ test("tree-local-values", () => { const secondNode = second.newLeaf({ flexGrow: 2 }); assert.equal(firstNode, secondNode, "independent trees can issue the same raw key"); - assert.equal(first.getStyle(secondNode).flexGrow, 1); - assert.equal(second.getStyle(firstNode).flexGrow, 2); + assert.equal(first.getStyle(firstNode).flexGrow, 1); + assert.equal(second.getStyle(secondNode).flexGrow, 2); }); test("slot-reuse", () => {