From 7139f2229f2885cb4631041cf5ef21fca8d67068 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 15 Aug 2026 11:57:11 +0800 Subject: [PATCH 01/18] Document @taffyjs/yoga implementation plan --- .agents/docs/README.md | 5 + .agents/docs/architecture.md | 8 +- .agents/docs/intent.md | 4 +- .agents/docs/loop-goal.md | 32 ++ .agents/docs/loop-status.md | 29 ++ .agents/docs/taffyjs-yoga-decisions.md | 207 ++++++++ .../docs/taffyjs-yoga-implementation-plan.md | 212 ++++++++ .agents/docs/taffyjs-yoga-reference.md | 460 ++++++++++++++++++ 8 files changed, 952 insertions(+), 5 deletions(-) create mode 100644 .agents/docs/loop-goal.md create mode 100644 .agents/docs/loop-status.md create mode 100644 .agents/docs/taffyjs-yoga-decisions.md create mode 100644 .agents/docs/taffyjs-yoga-implementation-plan.md create mode 100644 .agents/docs/taffyjs-yoga-reference.md diff --git a/.agents/docs/README.md b/.agents/docs/README.md index a02cb84..19c8a69 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -13,4 +13,9 @@ - [API query code generation](api-codegen-query.md) — query-specific generation guidance for the agreed selective-query API shape; implementation and release remain performance-gated. - [@taffyjs/node decisions](taffyjs-node-decisions.md) — vouched choices for the Node binding's role, API priorities, module format, distribution, and testing strategy. - [@taffyjs/wasm package design](taffyjs-wasm-package.md) — public contract, artifact layout, initialization model, ownership boundary, and verification requirements for the explicit Wasm package. +- [@taffyjs/yoga decisions](taffyjs-yoga-decisions.md) — compatibility decisions for replacing yoga-layout through the TaffyJS Yoga layer. +- [@taffyjs/yoga implementation reference](taffyjs-yoga-reference.md) — unstamped research progress, the Yoga public API inventory, and the remaining reference work required before implementation. +- [@taffyjs/yoga implementation plan](taffyjs-yoga-implementation-plan.md) — the package architecture, repository changes, milestone gates, verification strategy, and draft-PR workflow for implementing the compatibility layer. +- [@taffyjs/yoga implementation loop goal](loop-goal.md) — the unattended implementation contract that must receive a whole-file human vouch before the run starts. +- [@taffyjs/yoga implementation loop status](loop-status.md) — the unattended run's overwrite-in-place progress and final handover record. - [Tooling decisions](tooling-decisions.md) — rulings for npm scope and test workspace package naming, the repository-wide TypeScript default, JavaScript package builds, repository command orchestration, tool ownership, and demand-driven API code generation. diff --git a/.agents/docs/architecture.md b/.agents/docs/architecture.md index 346b901..62fabfe 100644 --- a/.agents/docs/architecture.md +++ b/.agents/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -This repository is a small Rust and JavaScript monorepo with one shared napi-rs binding and two public packages. +This repository is a small Rust and JavaScript monorepo with one shared napi-rs binding and three public packages. ## Ownership @@ -8,12 +8,14 @@ This repository is a small Rust and JavaScript monorepo with one shared napi-rs - `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 and context; Taffy owns topology, Style, Layout, cache, and computation state. - `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 Node-only 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. - `tests/taffyjs-node` is a private consumer package that tests `@taffyjs/node` through its package boundary. - `tests/taffyjs-wasm` is a private consumer package that checks the Wasm package through Node and browser package resolution, reuses the existing public type tests, mechanically inspects the published file set, and installs the packed result in fresh npm and pnpm consumers. +- `tests/taffyjs-yoga` is the corresponding private consumer package for compatibility, differential, and end-to-end tests through the `@taffyjs/yoga` package boundary; it does not contain a redundant nested `tests/` directory. -The `@taffyjs/node` public entry bundles the private napi-rs root loader, which selects the matching optional platform package. Its generated loader and declaration remain repository build inputs rather than published package files. The `@taffyjs/wasm` default entry reaches a no-TLA ESM bridge that imports the shared payload and immediately invokes a synchronous CommonJS factory mechanically derived from napi-rs's eager Node loader; its browser entry reaches a TLA adapter and the unmodified deferred loader. Neither package exposes an intermediate binding package, JavaScript shadow tree, separate core crate, public binding, or public initialization API. +The `@taffyjs/node` public entry bundles the private napi-rs root loader, which selects the matching optional platform package. Its generated loader and declaration remain repository build inputs rather than published package files. The `@taffyjs/wasm` default entry reaches a no-TLA ESM bridge that imports the shared payload and immediately invokes a synchronous CommonJS factory mechanically derived from napi-rs's eager Node loader; its browser entry reaches a TLA adapter and the unmodified deferred loader. Neither direct runtime package exposes an intermediate binding package, JavaScript shadow tree, separate core crate, public binding, or public initialization API. -A future `@taffyjs/yoga` package should be a JavaScript or TypeScript compatibility layer over `@taffyjs/node` unless a concrete need requires a different native boundary. A future `@taffyjs/wasm` package should preserve the direct package's high-level JavaScript API while replacing the runtime binding layer; its concrete Rust and packaging boundaries remain open until implementation. +`@taffyjs/yoga` is a Node-only TypeScript compatibility layer over `@taffyjs/node`; browser and WASM backends never live in that package. It performs bounded input normalization and output processing, but it does not modify or fork Taffy core, add a Yoga-specific native algorithm, or reimplement Flex layout in JavaScript; behavior that cannot cross that boundary is documented as Different or Unsupported as recorded in [@taffyjs/yoga decisions](taffyjs-yoga-decisions.md). ## Testing diff --git a/.agents/docs/intent.md b/.agents/docs/intent.md index 71d77a7..ff902ea 100644 --- a/.agents/docs/intent.md +++ b/.agents/docs/intent.md @@ -6,7 +6,7 @@ TaffyJS is intended to bring Taffy, a high-performance layout engine written in - `@taffyjs/node` is the first entry point and provides native Node-API bindings built with napi-rs. - `@taffyjs/wasm` is the explicit WebAssembly entry point for Node.js and bundled browsers. It reuses the `@taffyjs/node` public API over a threadless napi-rs WASIP binding rather than acting as an automatic fallback; its vouched contract is in [@taffyjs/wasm package design](taffyjs-wasm-package.md). -- A future `@taffyjs/yoga` package may provide a Yoga-compatible JavaScript API built on top of `@taffyjs/node` rather than as a second native implementation. [VOUCHED @hyfdev 2026-08-14] +- `@taffyjs/yoga` is the Node-only Yoga 3.2.1 compatibility package built on top of `@taffyjs/node`; browser and WASM backends remain outside that package. - The package family should support real consumers such as terminal UI frameworks without forcing those consumers to own their own Taffy bindings. ## Bun and Deno runtime support @@ -32,4 +32,4 @@ The binding role and API priority are recorded in [@taffyjs/node decisions](taff ## Open questions - Runtime and platform support beyond the current native targets, the approved `@taffyjs/wasm` Node.js and bundled-browser scope, Bun 1.2+ within major 1, and Deno 2.2+ within major 2 requires a concrete consumer need. -- The coverage and release timing of `@taffyjs/yoga` remain open. Yoga compatibility is not part of the `@taffyjs/node` contract. +- The release timing of `@taffyjs/yoga` remains open. Yoga compatibility is not part of the `@taffyjs/node` contract. diff --git a/.agents/docs/loop-goal.md b/.agents/docs/loop-goal.md new file mode 100644 index 0000000..0adf983 --- /dev/null +++ b/.agents/docs/loop-goal.md @@ -0,0 +1,32 @@ +# @taffyjs/yoga Implementation Loop Goal + +## Goal + +Implement the initial Node-only, ESM-only `@taffyjs/yoga` package over `@taffyjs/node`, targeting exactly `yoga-layout@3.2.1`, and deliver the complete reviewed implementation through one observable draft pull request. + +## Authoritative direction + +The run must follow `.agents/docs/taffyjs-yoga-decisions.md`, `.agents/docs/taffyjs-yoga-reference.md`, and `.agents/docs/taffyjs-yoga-implementation-plan.md`. Vouched decisions outrank derived reference and planning text. The run may resolve implementation details with evidence but may not reinterpret or silently expand the vouched product boundary. + +## Boundaries + +- Implement only in TypeScript over the existing `@taffyjs/node` API. Do not change Taffy core, Rust binding behavior, or public or private `@taffyjs/node` APIs for Yoga. +- Do not implement Flex, Yoga cache behavior, sibling distribution, or parent-dependent layout correction in JavaScript. Use documented Different or Unsupported classifications when bounded TypeScript cannot align behavior credibly. +- Do not add browser or WASM support, a Yoga-specific generator, selective query, batch reads, compact transport, publication, or release automation. +- Keep tests under `tests/taffyjs-yoga/` without a redundant nested `tests/` or unexplained `e2e/` directory. Use official `yoga-layout@3.2.1` only as a separately aliased test oracle. +- Preserve unrelated work. Do not post GitHub comments on Yunfei's behalf, mark the PR ready, merge, publish, or make a new product decision without Yunfei. + +## Execution contract + +Complete Milestones 1 through 6 from `.agents/docs/taffyjs-yoga-implementation-plan.md` in order. Each milestone must pass its relevant checks and one fresh bounded adversarial review with no unresolved material finding, then land as one coherent commit pushed immediately to the same draft PR. When a material review finding changes the result, use the same reviewer for one targeted follow-up and then stop that review loop. + +Continue autonomously between milestones. Stop only for a genuine conflict with vouched direction, a product choice not settled by the records, a required expansion of authority, or an external blocker that focused evidence cannot resolve. Keep `.agents/docs/loop-status.md` current with completed work, evidence and review results, in-flight work, next work, blockers, and conflicts. Never edit this vouched file during the run. + +## Finish criteria + +- All six implementation milestones and their adversarial reviews are complete and pushed to the same draft PR. +- Both supported package entries and unchanged-import workspace alias work on the supported Node runtime, committed package output includes all entry files, declarations, and shared chunks, and freshness detects untracked new output as well as changed or deleted output. +- Every evaluated Yoga 3.2.1 capability is classified and represented in types, runtime, documentation, and durable evidence according to its Compatible, Different, or Unsupported status. +- No Yoga-specific core, Rust, `@taffyjs/node`, Flex, WASM, browser, generator, query, batch, compact-transport, publication, or release-automation scope was added. +- `vp run ready` and `vp run check:codegen` pass on the current supported host, and the final `loop-status.md` contains the human handover. +- The pull request remains draft and unmerged for Yunfei's decision. diff --git a/.agents/docs/loop-status.md b/.agents/docs/loop-status.md new file mode 100644 index 0000000..5b9bba0 --- /dev/null +++ b/.agents/docs/loop-status.md @@ -0,0 +1,29 @@ +# @taffyjs/yoga Implementation Loop Status + +Updated: 2026-08-15 + +## State + +Milestone 0 is on the draft implementation PR. The unattended implementation is waiting for Yunfei to vouch the exact `loop-goal.md` and has not started. + +## Done + +- Recorded the vouched Yoga product decisions. +- Completed the Yoga 3.2.1 API and behavior reference. +- Drafted the repository implementation plan, milestone gates, PR protocol, Goal prompt, and unattended-run contract. +- Ran formatting, local-link checks, and an independent adversarial review of the plan and prompt. +- Resolved the review findings about the unattended-run contract, multi-entry shared output, untracked-output freshness, and failure ownership; the targeted follow-up found no remaining issue outside the freshness command, which was then corrected and verified against the repository's existing intent-to-add pattern. +- Committed and pushed the reviewed Milestone 0 baseline to the draft implementation PR. + +## In flight + +- None. The implementation run is waiting at its required human-vouch gate. + +## Next + +- Obtain Yunfei's whole-file vouch on `loop-goal.md`. +- Begin Milestone 1 only after that vouch. + +## Blocked + +- The unattended implementation run cannot start until `loop-goal.md` is vouched. diff --git a/.agents/docs/taffyjs-yoga-decisions.md b/.agents/docs/taffyjs-yoga-decisions.md new file mode 100644 index 0000000..4bd3215 --- /dev/null +++ b/.agents/docs/taffyjs-yoga-decisions.md @@ -0,0 +1,207 @@ +# @taffyjs/yoga Decisions + +This ledger records only judgments that Yunfei explicitly expressed about `@taffyjs/yoga`. Implementation, passing tests, resemblance to Yoga, review, or silence do not constitute acceptance. + +## Decided + +### Public package name + +**Ruling:** The Yoga-compatible package must be named `@taffyjs/yoga`, not `@taffyjs/node-yoga`. + +**Limits:** The name does not by itself promise Node-only, browser, or WASM runtime support. The implementation boundary is governed separately by [JavaScript-only compatibility boundary](#javascript-only-compatibility-boundary). + +**Why:** Yunfei chose the shorter package name so that the public identity describes Yoga compatibility rather than encoding the current Node backend; no additional rationale was stated. + +**Source:** Yunfei (`@hyfdev`), 2026-08-14; explicitly renamed the package from `@taffyjs/node-yoga` to `@taffyjs/yoga`. + +### Drop-in source replacement + +[VOUCHED @hyfdev 2026-08-14] + +**Ruling:** After dependency substitution, a supported `yoga-layout` consumer must be able to use `@taffyjs/yoga` without changing its application source. + +**Limits:** Source replacement applies only to the supported subset. It does not promise support for every Yoga API or pixel-identical layout: a supported call may have a published behavioral difference, while an unsupported call fails through the public TypeScript and runtime boundaries. The baseline version and runtime entries are governed by their separate decisions; the exact package-manager substitution mechanism and release criteria remain open. Replacing the dependency itself is outside application source and is necessarily part of selecting the compatibility package. + +**Why:** Yunfei established unchanged consumer code as the basic design principle, then accepted explicit unsupported cases and documented layout differences instead of requiring universal Yoga parity. + +**Source:** Yunfei (`@hyfdev`), 2026-08-14; explicitly required dependency-only replacement for supported consumers and vouched the clarified scope after the compatibility research. + +### Initial Yoga compatibility baseline + +[VOUCHED @hyfdev 2026-08-15] + +**Ruling:** The initial `@taffyjs/yoga` compatibility target is the latest stable `yoga-layout` release selected for the implementation and release; the current pinned research baseline is `yoga-layout@3.2.1`, which was the latest stable release when inventoried. + +**Limits:** Compatibility claims, differential tests, and documentation must name the exact selected version. A later Yoga release is not covered automatically merely because it becomes latest; adopting it requires updating the inventory and rerunning the compatibility evidence. Supporting older Yoga versions or a multi-version range is not an initial requirement. + +**Why:** A single current baseline keeps the API and behavior oracle unambiguous without adding legacy-version compatibility work before there is demand. + +**Source:** Yunfei (`@hyfdev`), 2026-08-15; selected the latest Yoga release for the initial target and vouched the current `3.2.1` baseline. + +### Node runtime and package entries + +[VOUCHED @hyfdev 2026-08-15] + +**Ruling:** `@taffyjs/yoga` is permanently the Node.js compatibility package built over `@taffyjs/node`; it does not provide a browser or WASM backend. Its supported Node.js version follows `@taffyjs/node`, currently Node.js `>=22.18`. The package is ESM-only and reproduces both Yoga 3.2.1 public entry shapes at `@taffyjs/yoga` and `@taffyjs/yoga/load`. + +**Limits:** The root entry provides the default Yoga facade plus the supported named enums and types; the `/load` entry provides `loadYoga()` plus the corresponding supported named enums and types. Exact unsupported exports remain governed by the compatibility classification. A possible future `@taffyjs/yoga-wasm` would be a separate package and is not promised or designed by this decision. The package-manager alias or dependency-substitution instructions remain to be verified. + +**Why:** The implementation is intentionally a JavaScript compatibility layer over the native `@taffyjs/node` backend, while Yoga 3.2.1 itself is ESM and exposes both eager root and asynchronous `/load` entries. Keeping any future WASM backend under a distinct package avoids pretending that one package has interchangeable runtime implementations. + +**Source:** Yunfei (`@hyfdev`), 2026-08-15; vouched Node-only support, the inherited Node.js version, ESM-only publication, and both public entries, and explicitly ruled out ever adding browser or WASM support to `@taffyjs/yoga` itself. + +### Compatibility levels and behavior + +[VOUCHED @hyfdev 2026-08-15] + +**Ruling:** Every evaluated Yoga capability must be classified as compatible, different, or unsupported: bounded and maintainable JavaScript alignment is preferred, a stable Taffy result may remain as a documented difference, and a capability without credible semantics must be rejected. + +**Limits:** The classification is versioned and may improve as evidence supports maintainable JavaScript alignment. The initial concrete Different and Unsupported groups are governed by the entries below and detailed in the implementation reference. Exact measure-callback counts, order, and argument traces are not compatibility promises; the resulting layout follows the same three-way classification. A difference is not an unsupported capability and remains available in the public types. Compatibility targets the normal supported API and the core behavior consumers use, not total replication of wrapper identity, internal cache decisions, post-free access, double-free behavior, exact error text, internal recomputation, or other incidental implementation artifacts unless real consumer evidence makes one relevant. + +**Why:** Yunfei initially treated strange Yoga behavior as contractual, then explicitly accepted differences when they are clearly documented and directed the project to align what is practical without overcomplicating the implementation. + +**Source:** Yunfei (`@hyfdev`), 2026-08-14 and 2026-08-15; accepted documented Yoga/Taffy differences, retained the callback-trace exception, approved the three classifications and their initial examples, vouched the consolidated policy, and later clarified that 100% behavioral replication is meaningless compared with aligning core functionality and behavior. + +### Initial measurement and computed-output scope + +[VOUCHED @hyfdev 2026-08-15] + +**Ruling:** Yoga's Measure API and Yoga-shaped computed-output API must be supported as Compatible through bounded JavaScript mapping over `@taffyjs/node`; exact callback traces and MeasureMode-sensitive results remain governed by the Different classification below. + +**Limits:** The Measure bridge may map each Taffy request to the closest meaningful Yoga constraint, normalize callback results, and preserve thrown-value identity, but it does not reconstruct Yoga's measurement phases or promise callback count, order, or exact argument provenance. Output projection may reconstruct Yoga fields, computed edges, and per-Node point-grid rounding from Taffy's unrounded Layout plus retained declaration and Config state, but it never replaces Taffy's geometry. A known Different Taffy layout remains Different after projection. Batch reads, selective queries, and compact transport are later performance work rather than compatibility semantics. + +**Why:** The callback API and output shapes are important consumer capabilities and can be provided without modifying Taffy or implementing Flex in JavaScript. Yunfei accepted the bounded bridge and its explicit trace limitation after reviewing the feasibility evidence. + +**Source:** Yunfei (`@hyfdev`), 2026-08-15; explicitly asked for Measure and output feasibility research, reviewed the resulting boundaries, and instructed that the proposed conclusions be recorded. + +### Initial Different result groups + +[VOUCHED @hyfdev 2026-08-15] + +**Ruling:** The initial Yoga 3.2.1 Different results include calculated `aspectRatio`; `min/max` combined with flex growth or shrinkage; MeasureMode-sensitive or flex-basis-sensitive measurement; Yoga-specific cache artifacts; and the evidenced main-axis auto-margin, reversed-axis overflow, oversized WrapReverse, and zero-cross-size line-distribution cases. + +**Limits:** The same supported API calls remain available and return stable Taffy geometry under these documented triggers. The exact trigger and observed Yoga and Taffy results belong in [the implementation reference](taffyjs-yoga-reference.md#computed-layout-getters-and-cross-cutting-result-classes) and regression fixtures, not in this ledger. New evidence may add or narrow a Different trigger under the general classification policy; reclassifying a capability as Compatible or Unsupported requires corresponding documentation and verification. + +**Why:** Aligning these results would require parent-dependent Flex state, Yoga cache replication, or sibling distribution logic in JavaScript, crossing the accepted implementation boundary. Yunfei preferred explicit stable differences over such complexity. + +**Source:** Yunfei (`@hyfdev`), 2026-08-15; accepted the proposed Different groups and instructed that they be recorded after the independent adversarial review preserved the no-blocker conclusion and broadened the auto-margin trigger. + +### Initial Unsupported capability groups + +[VOUCHED @hyfdev 2026-08-15] + +**Ruling:** The initial Yoga 3.2.1 Unsupported capabilities are `Display.Contents`, `PositionType.Static`, non-`None` Errata values, `setIsReferenceBaseline(true)`, Align values used outside their valid property context, and undeclared broken generic-setter coercions or Value-like getter-to-setter inputs. + +**Limits:** Omit a wholly unsupported method, enum member, or legacy constant where TypeScript can express the restriction, and reject every dynamic unsupported use at runtime before state changes. Supported portions of a split API remain available: Relative and Absolute positioning, `Errata.None`, valid Align contexts, and reference-baseline false state are not removed. Exact error classes and wording remain implementation choices. + +**Why:** These capabilities cannot provide credible semantics within a bounded JavaScript layer over the current binding, while silently accepting them would make the compatibility claim misleading. Yunfei preferred type-level exclusion plus runtime rejection when support is not defensible. + +**Source:** Yunfei (`@hyfdev`), 2026-08-15; accepted the proposed initial Unsupported list and instructed that it be recorded. + +### Yoga declaration and runtime mismatch policy + +[VOUCHED @hyfdev 2026-08-15] + +**Ruling:** For supported APIs, preserve Yoga 3.2.1's published TypeScript declarations even where they disagree with its working runtime behavior, and reproduce the useful runtime behavior; deliberately correct the dirtied callback so `null` unsets it and a real callback receives the declared canonical Node argument. + +**Limits:** Unsupported values remain narrowed despite Yoga's broader declarations. This policy covers known mismatches such as `getGap`, the gap-setter return declarations, out-of-range `getChild`, and omitted `calculateLayout` arguments; it does not require accidental coercions or broken Value-like round trips. Exact callback counts and ordering remain excluded, and future Yoga baselines require the mismatch inventory to be rerun. + +**Why:** Correcting published declarations would break otherwise unchanged TypeScript consumers, while the actual runtime shapes are finite and cheap to reproduce. Yoga's zero-argument dirtied callback and delayed failure after installing `null` contradict its own declared API and are not useful compatibility behavior. + +**Source:** Yunfei (`@hyfdev`), 2026-08-15; accepted the proposed source-compatibility policy and deliberate dirtied-callback corrections and instructed that they be recorded. + +### JavaScript-only compatibility boundary + +[VOUCHED @hyfdev 2026-08-14] + +**Ruling:** `@taffyjs/yoga` must never modify, fork, or patch Taffy core for Yoga compatibility and must never reimplement the Flex algorithm in JavaScript; all compatibility logic must remain a bounded JavaScript or TypeScript layer over `@taffyjs/node`. + +**Limits:** Local input normalization, API and enum mapping, unit and edge conversion, numeric `flex` expansion, and independently implementable output processing such as rounding are allowed. If alignment requires Taffy-internal Flex state or reproducing the layout algorithm, the behavior must remain a documented difference when Taffy has credible semantics or become unsupported otherwise. This ruling does not choose unrelated `@taffyjs/node` or upstream Taffy evolution. + +**Why:** Yunfei explicitly rejected core changes as unnecessary maintenance and limited compatibility work to JavaScript logic, with a documented difference or unsupported result when that boundary cannot align Yoga. + +**Source:** Yunfei (`@hyfdev`), 2026-08-14; stated that core modification will never be used for this package, accepted only JavaScript-layer compatibility, and vouched the consolidated boundary. + +### Facade runtime and ownership + +[VOUCHED @hyfdev 2026-08-15] + +**Ruling:** Each loaded Yoga facade owns one hidden `TaffyTree`. Every live Node created by that facade has a stable NodeId in the tree; Taffy is the sole authority for topology and layout calculation. The JavaScript layer may own the bounded Yoga-facing state, Config data, callbacks, lifetime checks, and output conversion needed by the compatibility API, but it must not maintain a second authoritative node tree or layout implementation. + +**Limits:** This fixes the logical ownership boundary, not private class names, file layout, registry data structures, garbage-collection mechanics, exact error types, or optional non-authoritative caches. Normal lifecycle behavior should be safe and deterministic; Yoga's dangling pointers, proxy aliasing, and other incidental post-free artifacts are not compatibility targets. Exact operation-level lifecycle transitions remain subject to implementation fixtures. + +**Why:** One Yoga facade is one isolated object space that may contain many detached Nodes and unrelated layout roots. A `TaffyTree` already represents that whole forest, so this ownership follows the public object model directly while keeping topology and geometry in one native authority. + +**Source:** Yunfei (`@hyfdev`), 2026-08-15; accepted and vouched the recommended high-level ownership model, while treating alternative per-root or per-Config partitioning as unnecessary research detail rather than a meaningful product choice. + +### Yoga declaration state and derived native state + +[VOUCHED @hyfdev 2026-08-15] + +**Ruling:** Every Node retains the current normalized Yoga declaration state for every supported Style capability. A successful Yoga setter updates that state synchronously. The Taffy Style applied to the NodeId and the most recently calculated Layout are derived states that may represent an older declaration revision: declared-style getters immediately return the current Yoga declaration, computed-layout getters continue to return the last calculated output until `calculateLayout()` runs, and Yoga-visible dirty state reports that separation. Taffy remains the sole topology authority and layout calculator. + +**Limits:** Complete logical declaration state does not require an eagerly allocated full nested object. Shared defaults, sparse deviations, compact slots, concrete revision bookkeeping, and eager or lazy Taffy synchronization remain implementation and performance choices. Unsupported or invalid setters must fail without changing the declaration. Exact mutation failure atomicity and operation-level state transitions still require fixtures. The hidden TaffyTree is not exposed as a second public mutation path. + +**Why:** Yoga preserves declaration distinctions that a final Taffy Style cannot reconstruct, including edge precedence, unset versus explicit values, flex shorthand fallback, `Direction.Inherit`, and Config-sensitive defaults. Keeping the current Yoga declaration as one logical owner also makes getters, `copyStyle()`, `reset()`, and dirty comparison consistent, while allowing native Style and Layout to be treated as ordinary derived results. + +**Source:** Yunfei (`@hyfdev`), 2026-08-15; reviewed the declared-versus-computed getter behavior, accepted that dirty describes outdated derived state rather than an invalid declaration, and explicitly vouched the model. + +### Type and runtime rejection + +[VOUCHED @hyfdev 2026-08-14] + +**Ruling:** A statically known unsupported method or value must be rejected by the public TypeScript declarations where TypeScript can express the restriction, and every unsupported capability must also throw at runtime for JavaScript, `any`, and dynamic values. + +**Limits:** The types need not model combinations assembled across multiple mutable setter calls, such as `minWidth` followed by `flexGrow`; those supported-but-different combinations remain callable. Exact error classes, messages, and the complete initial unsupported list remain implementation choices. Unsupported input must not be silently ignored or converted into a result with semantics the package cannot defend. + +**Why:** Yunfei accepted explicit runtime failure for unsupported options and stated that catching an unsupported use during type checking is preferable whenever possible. + +**Source:** Yunfei (`@hyfdev`), 2026-08-14; explicitly selected type-level rejection plus runtime checking and vouched the consolidated policy. + +### PositionType public shape + +[VOUCHED @hyfdev 2026-08-15] + +**Ruling:** @taffyjs/yoga must preserve Yoga's numeric enum, default uppercase constants, and setPositionType/getPositionType method form for the supported Relative and Absolute values; it must omit PositionType.Static and POSITION_TYPE_STATIC and reject a dynamically supplied zero. + +**Limits:** This ruling covers PositionType only. Other enums require the API inventory and compatibility classification before unsupported members are removed, and the exact runtime error class and message remain open. + +**Why:** Yunfei required the compatibility API to use Yoga's own form, preferred unsupported capabilities not to be exposed, and accepted the narrowed enum after Yoga 3.2.1 was verified to use a TypeScript numeric enum rather than an as-const object. + +**Source:** Yunfei (`@hyfdev`), 2026-08-14; confirmed that the corrected Yoga-shaped API had no issue. + +### Published compatibility and verification + +[VOUCHED @hyfdev 2026-08-15] + +**Ruling:** Published compatibility documentation must identify each evaluated capability as compatible, different, or unsupported, state the affected Yoga version and trigger, and describe the observable Yoga and TaffyJS behaviors; known differences and unsupported cases must have regression coverage. + +**Limits:** Test counts must not be presented as a universal compatibility percentage. Verification should use real Yoga as an oracle. A specific unchanged external consumer may be added only after it is deliberately selected; none is part of the base reference. The exact fixture corpus, supported-version range, and release threshold remain open. A later JavaScript-only improvement may reclassify an entry if its documentation and tests change with it. + +**Why:** Yunfei made clear documentation the condition for accepting behavioral differences and approved differential verification rather than hiding or overclaiming compatibility. + +**Source:** Yunfei (`@hyfdev`), 2026-08-14; required differences to be documented, accepted explicit unsupported behavior, vouched the original consolidated verification rules, and later clarified that the base design must not preselect Ink or another external consumer. + +### Integration-test placement + +[VOUCHED @hyfdev 2026-08-15] + +**Ruling:** Public compatibility, differential, and end-to-end tests for `@taffyjs/yoga` belong under the repository-level `tests/taffyjs-yoga/` consumer package, following the same package-boundary testing policy as `@taffyjs/node`. + +**Limits:** Do not add a redundant `tests/taffyjs-yoga/tests/` directory. Exact fixture files and any purposeful behavior-based subdirectories remain an implementation-planning choice. Rare isolated package-internal unit tests remain governed by the repository-wide testing policy. + +**Why:** These tests prove the public package as a downstream consumer sees it; package-boundary integration and end-to-end coverage already belong under the repository-level `tests/` collection. + +**Source:** Yunfei (`@hyfdev`), 2026-08-15; explicitly required all `@taffyjs/yoga` compatibility tests to use the top-level `tests/taffyjs-yoga/` location and rejected a redundant nested `tests` directory. + +### Calculation target and observable layout state + +[VOUCHED @hyfdev 2026-08-14] + +**Ruling:** `calculateLayout()` remains supported on any live Yoga Node, including a node that is still attached to a parent; it must not be restricted to detached roots. Taffy's native dirty and calculation-cache state are internal implementation details, while the JavaScript compatibility layer owns the Yoga-visible `isDirty()` and `hasNewLayout()` state. The layer must not reproduce Yoga's complete internal cache algorithm; an observable result that differs only because Yoga retained a stale cached ancestor or subtree after a separate attached-subtree calculation is classified and documented as different. + +**Limits:** This ruling preserves the public calculation entry and state methods but does not promise identical internal recomputation, cache-hit behavior, performance, measure-callback traces, or every cache-derived stale geometry sequence. The exact JavaScript state transitions still require regression fixtures. Taffy remains the only layout calculator; JavaScript may retain finite flags and revisions but must not calculate geometry or duplicate the Flex algorithm. + +**Why:** Calculating an attached subtree can require updating its translated Taffy Style, which propagates native dirty state into ancestors even though Yoga may leave those ancestors observably clean. Yunfei judged the underlying dirty/cache propagation to be an internal behavior and accepted a documented difference rather than restricting `calculateLayout()` or reproducing Yoga's cache implementation. + +**Source:** Yunfei (`@hyfdev`), 2026-08-14; explicitly vouched the recommendation that native dirty/cache remain internal, Yoga-visible state be maintained in JavaScript, cache-derived differences be documented, and `calculateLayout()` remain available on arbitrary nodes. diff --git a/.agents/docs/taffyjs-yoga-implementation-plan.md b/.agents/docs/taffyjs-yoga-implementation-plan.md new file mode 100644 index 0000000..98d8a4c --- /dev/null +++ b/.agents/docs/taffyjs-yoga-implementation-plan.md @@ -0,0 +1,212 @@ +# @taffyjs/yoga Implementation Plan + +This is the operational plan for implementing the Yoga 3.2.1 compatibility package. Product commitments live in [the decision ledger](taffyjs-yoga-decisions.md), while the evidence and exact compatibility inventory live in [the implementation reference](taffyjs-yoga-reference.md). If this plan conflicts with a vouched decision, the decision wins. + +## Outcome + +Add an ESM-only `@taffyjs/yoga` package for Node.js `>=22.18` that is implemented entirely in TypeScript over `@taffyjs/node`, exposes the supported Yoga 3.2.1 root and `/load` entry shapes, and lets a supported consumer keep imports such as `import Yoga from "yoga-layout"` after replacing only the dependency. + +The implementation is successful when every evaluated Yoga 3.2.1 capability is represented in the public types and runtime as Compatible, Different, or Unsupported; the supported surface has package-boundary tests; every documented Difference and Unsupported case has a durable regression fixture; and the repository's normal checks pass on the supported native platforms. + +## Boundaries + +- Do not modify, fork, or patch Taffy core or add Yoga-specific Rust behavior. +- Do not add public or private `@taffyjs/node` APIs for this implementation. If the current binding cannot support credible Yoga semantics through bounded TypeScript, classify the behavior as Different or Unsupported. +- Do not implement Flex layout, sibling distribution, Yoga cache behavior, or parent-dependent layout correction in JavaScript. +- Do not add browser or WASM support. A possible future `@taffyjs/yoga-wasm` is a separate project. +- Do not promise callback count, callback order, exact internal recomputation, post-free artifacts, or pixel equality for cases already classified as Different. +- Do not make selective query, batch reads, or compact Layout transport a prerequisite. They are later transport optimizations that must not change Yoga semantics. +- Do not add a Yoga-specific code generator in the first implementation. The authored TypeScript source is the single source for runtime values and emitted declarations. Add generation only if one maintained fact later has to agree across independent outputs and ordinary TypeScript cannot keep it single-sourced. +- Do not publish packages or add release automation in this implementation PR. The repository currently has no publication workflow; registry publication and changing package privacy are separate release decisions. + +## Runtime design + +### Facade + +The root module creates one default Yoga facade. Every call to `loadYoga()` from `@taffyjs/yoga/load` asynchronously returns a new facade. Each facade owns one hidden `TaffyTree`, disables Taffy's tree-wide rounding, owns its default Config state, and rejects Nodes or Config handles created by another facade. + +The default facade is a module singleton. The asynchronous `/load` shape is preserved even though creating the native-backed facade does not itself require asynchronous work. + +### Node and Config state + +Each live Node has a stable NodeId and one normalized Yoga declaration record. The facade also retains the Node's Config state, callbacks, measure marker, Yoga-visible dirty and new-layout flags, last applied translation revision, and last committed Yoga-shaped output. Taffy remains the only owner of topology and geometry. + +A public Config handle may be freed while Nodes retain the underlying Config state they were created with. Freeing invalidates the public handle but does not leave dangling state. Node and Config handles use facade and lifetime brands so cross-facade, stale, and freed values fail before native mutation. + +The first implementation uses explicit Yoga lifetime operations rather than `FinalizationRegistry` behavior. A facade may retain Node records and their canonical wrappers until `free()`, `freeRecursive()`, or facade collection; this preserves stable identity and matches Yoga's explicit-lifetime model without making garbage-collection timing observable. + +### Declaration translation + +Yoga setters normalize their input into the Node's declaration record. Getters read that record or a Yoga-defined default, never reverse-engineer the current Taffy Style. The record preserves information that Taffy cannot: unset versus Auto, logical and shorthand edges, `Direction.Inherit`, numeric `flex`, independent flex longhands, and Config-sensitive defaults. + +Translation from the declaration record plus Config and resolved direction produces a complete Taffy Style. Translation is deterministic and side-effect free. It may be applied lazily before calculation so inherited direction and related context are correct. Invalid or unsupported mutations are rejected before any JavaScript or native state changes. + +### Topology and lifecycle + +All parent and child questions go through Taffy; there is no authoritative JavaScript child array. A small cache is permitted only when it can be rebuilt from or checked against Taffy and cannot affect correctness. + +Every single-step topology operation follows the same order: validate all handles, facade ownership, indices, cycle and measured-leaf constraints; perform the native mutation once; commit JavaScript state; then invoke user callbacks. Validation and expected native failures before commit leave declarations, topology, callbacks, public flags, and computed output unchanged. A user callback that throws does so after the mutation is committed and is not rollback-safe; internal state must already be consistent before the callback runs. + +Destructive multi-step operations such as `freeRecursive()` first snapshot and validate the complete target subtree, then remove it in postorder without invoking user callbacks between native steps. There is no native transaction with which to promise rollback after an unexpected mid-operation binding failure. If such an internal invariant failure occurs after a partial native commit, mark the facade unusable and reject later calls rather than exposing a facade whose JavaScript state may disagree with Taffy. This poison path is defensive handling for an unexpected internal failure, not a normal public error case. + +### Calculation and output + +`calculateLayout()` accepts any live Node, including an attached subtree. Before calculation, traverse only the selected subtree, resolve effective declarations, and synchronize stale Taffy Styles. Use ordinary `computeLayout` when the subtree has no measured Node and `computeLayoutWithMeasure` only when measurement is needed. + +A finite calculation width or height is an exact outer constraint on the selected Yoga calculation root. Apply the already researched temporary root-Style adjustment, calculate with Taffy, read all required unrounded Layout values, and restore the declaration-derived Style in a `finally` path. The temporary Style is calculation context and never becomes Yoga declaration state. + +After native calculation succeeds, project the selected subtree into Yoga-shaped outputs in a temporary map. Geometry always comes from Taffy. The projection reconstructs Yoga's right and bottom values, root positions, resolved computed edges, auto-margin reporting, and per-Node point-scale rounding. Commit every Node's output and public flags only after the entire projection succeeds; a measurement or projection failure preserves the previous public outputs and dirty state. + +If Yoga's pixel-grid formula is ported from Yoga source, retain the MIT permission notice and clear source attribution in a packaged third-party notice and beside the implementation. Do not copy the upstream wrapper wholesale. + +### Measurement + +One facade-level Taffy measure closure dispatches a request by NodeId to the Yoga callback stored for that Node. Known dimensions map to `MeasureMode.Exactly`, finite available space to `AtMost`, max-content to `Undefined` with NaN, and min-content to `AtMost` with zero. Callback results follow the normalization and thrown-value rules in the implementation reference. + +Measured Nodes must remain leaves. Same-tree native calls made reentrantly from a measure callback may use `@taffyjs/node`'s existing controlled tree-busy failure; exact reentrant behavior and callback traces are not compatibility promises. A callback failure must leave public output atomic and allow a later calculation to retry. + +## Repository changes + +The implementation adds these package boundaries: + +```text +packages/taffyjs-yoga/ + package.json + vite.config.ts + tsconfig.json + README.md + COMPATIBILITY.md + THIRD_PARTY_NOTICES.md + dist/ + index.js + index.d.ts + load.js + load.d.ts + + src/ + index.ts + load.ts + facade.ts + config.ts + node.ts + declarations.ts + enums.ts + values.ts + translate.ts + measurement.ts + output.ts + errors.ts + +tests/taffyjs-yoga/ + package.json + vite.config.ts + tsconfig.json + exports.test.mts + config.test.mts + style.test.mts + topology.test.mts + lifecycle.test.mts + state.test.mts + layout.test.mts + measurement.test.mts + differences.test.mts + unsupported.test.mts + types/ + tsconfig.json + public-api.test-d.ts +``` + +The source split is a starting ownership map, not a requirement to keep empty or one-function files. Merge files when that produces a clearer implementation. `tests/taffyjs-yoga/` is itself the downstream consumer package: do not add another `tests/` directory or an unexplained `e2e/` directory inside it. + +`packages/taffyjs-yoga` uses `vp pack` with `src/index.ts` and `src/load.ts` as ESM entries and a cleaned `dist/` output directory. The package exports point to `dist/index.js`, `dist/index.d.ts`, `dist/load.js`, and `dist/load.d.ts`; every shared JavaScript chunk emitted by the pinned packer is also included through the packaged `dist/` directory. Its runtime dependency is only `@taffyjs/node` through `workspace:*`; `yoga-layout` is never a runtime dependency of the published package. + +`tests/taffyjs-yoga` installs the implementation under Yoga's original name with the pnpm workspace alias `"yoga-layout": "workspace:@taffyjs/yoga@*"`, so test source imports the unchanged consumer specifiers `yoga-layout` and `yoga-layout/load`. It installs the official oracle separately as `"yoga-layout-oracle": "npm:yoga-layout@3.2.1"`. The package README gives published consumers the standard npm alias `"yoga-layout": "npm:@taffyjs/yoga@"`. The workspace form follows [pnpm's documented workspace aliases](https://pnpm.io/workspaces#referencing-workspace-packages-through-aliases), while the published form follows [npm package aliases](https://docs.npmjs.com/cli/using-npm/package-spec#aliases) and [pnpm aliases](https://pnpm.io/aliases). Because publication is out of scope and the repository deliberately has no tarball-install test path, CI proves the built workspace alias and all exported entry files; installing the registry alias remains a post-publication smoke check. + +The root Vite+ task graph gains a Yoga package build after `build:binding`, Yoga runtime integration tests, and Yoga declaration checks. The normal root `build`, `check:test`, `check`, and `ready` tasks include them. After building on Ubuntu, CI runs `git add --intent-to-add --all -- packages/taffyjs-yoga/dist` before diffing the complete committed output directory, so a new untracked shared chunk as well as changed or deleted entry output, chunks, and declarations fails freshness; Windows runs the same runtime and type tests through the normal task graph. `pnpm-lock.yaml`, workspace metadata, root documentation, and package metadata change in the same milestone that introduces the package. + +## Verification strategy + +The official `yoga-layout@3.2.1` package is the behavior oracle, but tests must remain small enough to explain and maintain. + +- Compatible fixtures run the same supported source operations against both implementations and compare the relevant public result. They cover representative equivalence classes rather than copying Yoga's complete upstream suite. +- Different fixtures pin both the Yoga result and the TaffyJS result for the exact documented trigger. They prove that the difference remains understood; they do not assert equality. +- Unsupported fixtures prove both boundaries where possible: statically known use fails the TaffyJS declaration check, and dynamic JavaScript or `any` input throws before any observable state changes. +- Package-entry fixtures inspect root and `/load` exports, enum reverse lookup, legacy constants, facade isolation, and the unchanged-import alias. Public surface comparison uses real Yoga reflection plus the explicit unsupported exclusions instead of a second duplicated 100-method list. +- State fixtures prove declaration getters versus last calculated output, dirty and new-layout transitions, callback argument correction, arbitrary attached-subtree calculation, retained Config state, canonical Node identity, pre-commit mutation failure atomicity, and post-commit callback-error behavior. +- Layout fixtures cover ordinary fixed and flex cases plus the explicit Difference classes. Computed-output fixtures separately cover root and trailing positions, logical edges under LTR and RTL, auto margins, detached output reset, and mixed point-scale factors. +- Measurement fixtures cover constraint-insensitive callbacks, mode mapping, normalization, thrown-value identity, retry, measured-leaf restrictions, ordinary-compute fast selection, and the documented MeasureMode-sensitive Difference. + +Tests run concurrently by default. Add a separate-process fixture only when process containment is itself necessary, such as proving that a native panic, abort, or deadlock cannot escape the test. Do not add runtime tests that read documentation or source files, generator tests, or duplicate fixtures that differ only in setup wording. + +Performance work in this PR is diagnostic rather than contractual. Keep a small local benchmark or profile for creation, style mutation, calculation, measured calculation, and computed-output reads, but do not add a timing threshold to CI or claim that the package is faster than Yoga. Optimize only after a representative workload identifies a material cost; batch/query/compact transport remains separate work. + +## Milestones + +### Milestone 0: reviewed implementation baseline and draft PR + +Deliver the decision ledger, implementation reference, this plan, the repository-level intent and architecture links, and the repository-required `loop-goal.md` plus initial `loop-status.md`. Resolve factual contradictions, run formatting and link checks, obtain an independent adversarial review of the plan and Goal contract, and open one draft implementation PR from the existing task branch. The unattended implementation run must not start until Yunfei has vouched the exact `loop-goal.md`; that file then remains immutable for the run. + +Exit gate: the review finds no unresolved material blocker; the documents distinguish vouched decisions from derived implementation guidance; the branch is pushed; the draft PR is visible; and Yunfei has explicitly vouched the exact `loop-goal.md` before unattended implementation starts. No implementation claim is made yet. + +### Milestone 1: package, entries, facade, and thin vertical slice + +Add the package and test-consumer scaffolding, both public entries, all supported enum objects and legacy constants, factory shapes, facade isolation, lifetime brands, a hidden rounded-disabled TaffyTree, and one minimal create-set-calculate-read path. Wire the package into the workspace, Vite+ build graph, type checking, runtime tests, CI output freshness, and lockfile. + +Exit gate: unchanged `yoga-layout` and `yoga-layout/load` imports resolve through the workspace alias; a clean build produces working exported entries and declarations plus every required shared chunk under `dist/`; the package metadata includes that complete directory, the freshness command detects a deliberately untracked probe output during implementation verification, and the built code contains no runtime `yoga-layout` dependency; two loaded facades reject each other's handles; the thin layout slice passes on the current host; relevant root checks pass; adversarial review findings are resolved; one milestone commit is pushed to the draft PR. + +### Milestone 2: Config, declaration model, and Style API + +Implement Config state and lifetime, complete normalized declarations, Yoga input normalization, Yoga defaults and `useWebDefaults`, all supported Style getters and setters, edge and logical precedence, gaps, direction, flex shorthand and longhands, deterministic translation, `copyStyle`, unsupported enum/value narrowing, and runtime validation with pre-commit failure atomicity. + +Exit gate: the public Style and Config surface matches the classified Yoga 3.2.1 contract; representative round trips and oracle cases pass; Unsupported inputs are absent or narrowed in declarations and throw dynamically without mutation; relevant and full repository checks pass; adversarial review findings are resolved; one milestone commit is pushed. + +### Milestone 3: topology, lifecycle, callbacks, and public state + +Implement Node creation and destruction, insert/remove/lookup, canonical identity, one-parent and acyclic constraints, `free`, `freeRecursive`, `reset`, retained Config state, declaration copying, measured-leaf topology checks, Yoga-visible dirty and new-layout transitions, and the deliberately corrected dirtied callback behavior. + +Exit gate: lifecycle, attached and detached topology, cross-facade rejection, state transitions, retained Config behavior, callback argument and null handling have durable fixtures; validation and expected pre-commit native failures preserve observable state; callback exceptions are pinned as post-commit behavior; multi-step destructive operations prevalidate their snapshot and have an explicit poisoned-facade path for an unexpected partial internal failure; no JavaScript child tree or layout algorithm exists; relevant and full checks pass; adversarial review findings are resolved; one milestone commit is pushed. + +### Milestone 4: complete calculation and computed output + +Implement arbitrary-node calculation, declaration synchronization, effective direction, temporary exact root constraints with guaranteed restoration, subtree output collection, Yoga right and bottom semantics, root positions, computed edges, auto-margin reporting, per-Node point-grid rounding, output commit atomicity, and all computed getters. + +Exit gate: the ordinary Compatible geometry corpus matches Yoga; every current non-measurement Difference has a fixture that pins both results and a matching `COMPATIBILITY.md` entry; failed calculation or projection retains prior public output; mixed Config rounding passes; relevant and full checks pass; adversarial review findings are resolved; one milestone commit is pushed. + +### Milestone 5: Measure API + +Implement callback registration and removal, measured-leaf enforcement, Taffy request to Yoga MeasureMode mapping, result normalization, thrown-value identity, retry, measured output rounding, ordinary-compute selection for subtrees without measured Nodes, and controlled same-tree callback reentrancy behavior. + +Exit gate: normal measurement behavior matches the accepted contract; callback-trace and mode-sensitive limits are documented and pinned as Differences; failure and retry are atomic; a profile confirms that unmeasured trees do not enter the measure bridge; relevant and full checks pass; adversarial review findings are resolved; one milestone commit is pushed. + +### Milestone 6: compatibility closure and package evidence + +Audit the exact Yoga 3.2.1 surface against the inventory, finish root and `/load` export parity, verify the workspace alias and clean `dist/` package contents, complete `README.md`, `COMPATIBILITY.md`, and third-party notices, remove obsolete research TODOs, and run the complete repository verification on the supported local host. + +Exit gate: no public capability remains unclassified; documentation names Yoga 3.2.1 and every known Difference and Unsupported trigger; package output is current; `vp run ready` and `vp run check:codegen` pass; the final implementation receives adversarial review and targeted follow-up if needed; the last milestone commit is pushed. The PR stays draft and unmerged for Yunfei's decision. + +## Review and PR protocol + +Use one long-lived draft PR for the complete implementation. Stacked milestone PRs would add branch and merge-order overhead without improving observability; milestone commits and checks already expose progress clearly. + +For every milestone: + +1. Implement only that milestone's scope and preserve unrelated user work. +2. Run the smallest relevant checks while iterating, then the milestone's full gate before review. +3. Give one fresh independent reviewer a self-contained, bounded packet containing the milestone objective, changed files, test evidence, governing decisions, and the highest-cost assumption to attack. The reviewer must not delegate and returns a pass or at most three material findings. +4. Verify each finding against source or a focused reproduction. Fix evidence-backed findings. If a finding materially changes the result, ask the same reviewer for one targeted follow-up covering that finding and regressions, then stop the review loop. +5. Commit the reviewed milestone as one coherent named commit and push it to the existing draft PR. Do not hide unfinished work in an unpushed local commit. +6. Update `loop-status.md` with completed work, evidence, next work, blockers, and any vouched-direction conflict, and include that state in the milestone push. +7. Continue to the next milestone autonomously unless a genuine product choice, authority boundary, or external blocker requires Yunfei. Do not merge, mark the PR ready, publish, or post GitHub comments on Yunfei's behalf. + +Milestone review is a completion gate, not a request to review every routine edit. A pushed milestone may contain several local work-in-progress commits only if they are squashed or otherwise made coherent before the milestone is declared complete. + +## Risks to watch during implementation + +- Temporary root-Style restoration and public output commit must preserve the previous public state when a measure callback throws; an unexpected failure to restore native Style poisons the facade because native rollback cannot be promised. +- Direction, logical edges, Config changes, and attached-subtree calculation can invalidate translation without a corresponding Yoga-visible dirty transition; revisions must keep those concepts separate. +- A strong canonical-wrapper registry is deliberately simple but requires explicit lifetime tests so `free`, `freeRecursive`, and facade collection do not retain native state accidentally. +- User callback errors are post-commit errors. Never invoke a user callback while JavaScript and native state disagree, and never pretend that an external side effect can be rolled back. +- Yoga declarations and useful runtime behavior sometimes disagree. Preserve the published declaration for supported APIs while pinning the selected runtime behavior; do not silently “correct” types during implementation. +- The official oracle is a test-only dependency under an alias. Any accidental runtime import from it would invalidate the package architecture. +- The compatibility matrix is version-specific. Do not update the Yoga baseline opportunistically during implementation. +- A local performance observation is not a release claim. Measure before adding caches, batch calls, queries, or compact transport. diff --git a/.agents/docs/taffyjs-yoga-reference.md b/.agents/docs/taffyjs-yoga-reference.md new file mode 100644 index 0000000..f7c30ea --- /dev/null +++ b/.agents/docs/taffyjs-yoga-reference.md @@ -0,0 +1,460 @@ +# @taffyjs/yoga Implementation Reference + +This is an unstamped working reference for research that must guide a future @taffyjs/yoga implementation. It records evidence, progress, unresolved questions, and derived implementation constraints; only judgments explicitly accepted by Yunfei belong in [the decision ledger](taffyjs-yoga-decisions.md). An inventory entry means that Yoga exposes a capability, not that TaffyJS has decided to support it. + +## Progress + +| Priority | Reference area | Status | Completion condition | +| -------- | -------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| P0 | Exact Yoga baseline and public API inventory | In progress | The initial latest-release policy and current yoga-layout 3.2.1 baseline are vouched, the raw inventory is complete, and every capability now has a compatible, different, or unsupported classification. Convert the bounded research probes for intentional wrapper behavior into durable implementation fixtures. | +| P0 | Node, Config, and TaffyTree ownership | In progress | The one-TaffyTree-per-facade ownership model is vouched. Add durable lifecycle, facade-isolation, stable-identity, and no-migration fixtures, and finalize the operation-level lifetime mapping. | +| P0 | Authoritative state and observable transitions | In progress | The Yoga declaration, derived native Style, topology, calculation, Config, and public-flag ownership boundaries are vouched, and bounded probes cover computed output plus measure-callback failure. Convert those probes into fixtures, finish pre-commit failure and post-commit callback-error semantics, and constrain any permitted non-authoritative caches. | +| P1 | Compatibility classification and translation rules | In progress | The grouped first-pass classifications and shared translation rules now cover values, defaults, edges, direction, flex, root constraints, rounding, measurement, layout getters, and errors. Review the material public boundaries and attach fixture identifiers during implementation planning. | +| P1 | Verification and release gate | Planned | The fixture classes, repository integration, milestone checks, and no-unclassified-capability gate are defined in [the implementation plan](taffyjs-yoga-implementation-plan.md). Implement them and attach durable fixture identifiers to the compatibility rows. | +| P2 | Performance evolution boundary | In progress | Semantics permit later cache, batch, selective-query, or compact-transport changes, and the mixed measured/unmeasured leaf callback overhead is identified. Define representative end-to-end workloads before selecting optimizations or thresholds. | + +The P0 ownership design must reject a normal create, insert, remove, detach, or free path that implicitly reconstructs or migrates an entire subtree or all unrelated roots. Operations whose meaning requires traversal, such as freeRecursive, are exempt. Style and topology changes must not implicitly compute layout; calculateLayout remains the explicit calculation boundary. + +Internal file splitting, private class names, code generation versus handwritten code, exact error wording, benchmark thresholds, a named external consumer, and multi-version Yoga support are deliberately deferred unless earlier research makes one of them necessary to settle a P0 boundary. Browser and WASM support are excluded: @taffyjs/yoga is permanently the Node package over @taffyjs/node; any future WASM implementation would use a separate package. + +## Inventory method + +The initial target is the latest stable yoga-layout release selected for implementation and release. The current exact research baseline is yoga-layout 3.2.1, which the npm latest tag resolved to on 2026-08-14. Compatibility evidence remains pinned to that exact version until a newer release is deliberately adopted and the inventory and differential evidence are updated. + +Primary sources: + +- [Published package metadata](https://registry.npmjs.org/yoga-layout/latest) +- [Tagged JavaScript package metadata](https://github.com/facebook/yoga/blob/v3.2.1/javascript/package.json) +- [Root entry](https://github.com/facebook/yoga/blob/v3.2.1/javascript/src/index.ts) +- [Async load entry](https://github.com/facebook/yoga/blob/v3.2.1/javascript/src/load.ts) +- [JavaScript wrapper and public structural types](https://github.com/facebook/yoga/blob/v3.2.1/javascript/src/wrapAssembly.ts) +- [Yoga JavaScript tree construction and release guidance](https://github.com/facebook/yoga/blob/v3.2.1/website/docs/getting-started/laying-out-a-tree.mdx) +- [Yoga Config ownership guidance](https://github.com/facebook/yoga/blob/v3.2.1/website/docs/getting-started/configuring-yoga.mdx) +- [Generated enums and legacy constants](https://github.com/facebook/yoga/blob/v3.2.1/javascript/src/generated/YGEnums.ts) +- [C++ Node wrapper](https://github.com/facebook/yoga/blob/v3.2.1/javascript/src/Node.cpp) +- [C++ Config wrapper](https://github.com/facebook/yoga/blob/v3.2.1/javascript/src/Config.cpp) +- [C++ Node and callback declarations](https://github.com/facebook/yoga/blob/v3.2.1/javascript/src/Node.h) +- [Embind registrations](https://github.com/facebook/yoga/blob/v3.2.1/javascript/src/embind.cpp) +- [Yoga node topology and state transitions](https://github.com/facebook/yoga/blob/v3.2.1/yoga/YGNode.cpp) +- [Yoga Config mutation and versioning](https://github.com/facebook/yoga/blob/v3.2.1/yoga/config/Config.cpp) +- [Yoga style defaults and edge resolution](https://github.com/facebook/yoga/blob/v3.2.1/yoga/style/Style.h) +- [Yoga computed edge getters](https://github.com/facebook/yoga/blob/v3.2.1/yoga/YGNodeLayout.cpp) +- [Yoga pixel-grid processing](https://github.com/facebook/yoga/blob/v3.2.1/yoga/algorithm/PixelGrid.cpp) +- [Yoga flex-basis experiment branch](https://github.com/facebook/yoga/blob/v3.2.1/yoga/algorithm/CalculateLayout.cpp) +- [Yoga WebFlexBasis relayout regression](https://github.com/facebook/yoga/blob/v3.2.1/tests/YGRelayoutTest.cpp) +- [Taffy 0.13 Style](https://github.com/DioxusLabs/taffy/blob/v0.13.0/src/style/mod.rs) +- [Taffy 0.13 tree and layout storage](https://github.com/DioxusLabs/taffy/blob/v0.13.0/src/tree/taffy_tree.rs) +- [Current @taffyjs/node tree wrapper](../../packages/taffyjs-node/src/tree.ts) + +The inventory includes package exports, intentional named runtime and type exports, members on the default Yoga object, declared factories and methods, and deliberate behavior added by the JavaScript wrapper. It excludes undocumented Emscripten or Embind implementation artifacts such as the runtime Z property found on factory objects. + +## Classification rules + +Every evaluated public capability uses Compatible, Different, or Unsupported. Required implementation fixtures and release evidence are tracked separately and do not weaken a classification back into an ambiguous status. + +- Compatible means the same supported Yoga source call and public value shape can provide the core function and normal observable behavior consumers rely on through bounded JavaScript or TypeScript mapping over the current @taffyjs/node API. It does not mean total replication of callback invocation traces, JavaScript proxy identity, internal cache or recomputation choices, exact error text, post-free access, double-free behavior, or other incidental implementation artifacts; such a detail becomes a compatibility target only when real consumer evidence makes it material. +- Different means the same source call remains available and produces credible, stable Taffy semantics, but a specific observable result differs from Yoga and must be stated with its trigger. +- Unsupported means no credible semantics can be provided inside the JavaScript-only boundary; a whole unsupported method or export is omitted, while a dynamically supplied unsupported value is rejected at runtime. + +API shape, accepted input, stored getter value, state transition, error behavior, and calculated layout are separate observable capabilities when their classifications differ. A general setter is not marked Different merely because some separate style combination exercises a known Yoga/Taffy algorithm difference; that combination receives its own row. + +## Package entries and exports + +The decided package is ESM-only, follows @taffyjs/node's supported Node.js version (currently Node.js >=22.18), and mirrors both Yoga entry shapes at @taffyjs/yoga and @taffyjs/yoga/load. It never provides browser or WASM support. + +Yoga 3.2.1 is ESM and publishes two package entries. + +| Entry | Runtime exports | Type-only exports | Loading behavior | +| ---------------- | --------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| yoga-layout | One default Yoga object and 18 named numeric enum objects | Config, DirtiedFunction, MeasureFunction, and Node | Eagerly loads the bundled WASM assembly with top-level await. | +| yoga-layout/load | loadYoga plus the same 18 named numeric enum objects | Config, DirtiedFunction, MeasureFunction, Node, and Yoga | loadYoga asynchronously creates and returns a Yoga object. There is no default export. | + +The default Yoga object contains Config and Node factory objects plus 72 uppercase numeric constants corresponding to every member of the 18 named enums. Config and Node are not named runtime exports from either entry; their names are exported only as types, while the runtime factories are Yoga.Config and Yoga.Node. + +The wrapper uses three structural result types that are public through method signatures but are not exported by name: Layout has left, right, top, bottom, width, and height numbers; Size has width and height numbers; Value has unit and value fields. + +A runtime reflection check against the published 3.2.1 package found the same eight Config and 100 Node prototype method names as the declarations, with no missing or extra prototype method; it also confirmed 18 named runtime enums and 72 legacy constants. Intentional differences in accepted arguments, defaults, or return values still require behavioral probes because method-name equality does not prove behavioral equality. + +### Dependency substitution + +The unchanged-import mechanism is a standard package-manager alias. A published consumer can retain `import Yoga from "yoga-layout"` and `import { loadYoga } from "yoga-layout/load"` while declaring `"yoga-layout": "npm:@taffyjs/yoga@"`. [npm package aliases](https://docs.npmjs.com/cli/using-npm/package-spec#aliases) install one registry package under another local dependency name, and [pnpm aliases](https://pnpm.io/aliases) document the same no-source-change replacement behavior. + +The repository consumer fixture can exercise the local package before publication with `"yoga-layout": "workspace:@taffyjs/yoga@*"`; [pnpm documents this exact workspace-alias form](https://pnpm.io/workspaces#referencing-workspace-packages-through-aliases) and rewrites it to a normal npm alias when a package is packed. The official Yoga oracle must therefore use a second test-only alias such as `"yoga-layout-oracle": "npm:yoga-layout@3.2.1"`. Implementation still has to prove both root and `/load` subpath resolution through the built workspace package and reserve an actual registry-alias install for post-publication smoke testing. The repository does not add a tarball-install test path for this package. + +## Factory and callback inventory + +| Owner | API | Declared shape | +| ----------- | ---------------- | ---------------------------------------------------------------------------------------- | +| Yoga.Config | create | create(): Config | +| Yoga.Config | destroy | destroy(config: Config): void | +| Yoga.Node | create | create(config?: Config): Node | +| Yoga.Node | createDefault | createDefault(): Node | +| Yoga.Node | createWithConfig | createWithConfig(config: Config): Node | +| Yoga.Node | destroy | destroy(node: Node): void | +| Type | DirtiedFunction | (node: Node) => void | +| Type | MeasureFunction | (width: number, widthMode: MeasureMode, height: number, heightMode: MeasureMode) => Size | + +## Config inventory + +The public Config type declares eight instance methods. + +| API | Declared shape | Classification | +| ----------------------------- | ----------------------------------------------------------------------------------- | -------------- | +| free | free(): void | Compatible | +| isExperimentalFeatureEnabled | isExperimentalFeatureEnabled(feature: ExperimentalFeature): boolean | Compatible | +| setExperimentalFeatureEnabled | setExperimentalFeatureEnabled(feature: ExperimentalFeature, enabled: boolean): void | Compatible | +| setPointScaleFactor | setPointScaleFactor(factor: number): void | Compatible | +| getErrata | getErrata(): Errata | Compatible | +| setErrata(Errata.None) | setErrata(errata: Errata): void | Compatible | +| setErrata(non-None Errata) | setErrata(errata: Errata): void | Unsupported | +| useWebDefaults | useWebDefaults(): boolean | Compatible | +| setUseWebDefaults | setUseWebDefaults(useWebDefaults: boolean): void | Compatible | + +## Enum inventory + +All named enums are runtime TypeScript numeric enums, including reverse numeric lookup. The default Yoga object separately exposes an uppercase constant for every member. + +| Enum | Members and numeric values | Member count | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | +| Align | Auto=0, FlexStart=1, Center=2, FlexEnd=3, Stretch=4, Baseline=5, SpaceBetween=6, SpaceAround=7, SpaceEvenly=8 | 9 | +| BoxSizing | BorderBox=0, ContentBox=1 | 2 | +| Dimension | Width=0, Height=1 | 2 | +| Direction | Inherit=0, LTR=1, RTL=2 | 3 | +| Display | Flex=0, None=1, Contents=2 | 3 | +| Edge | Left=0, Top=1, Right=2, Bottom=3, Start=4, End=5, Horizontal=6, Vertical=7, All=8 | 9 | +| Errata | None=0, StretchFlexBasis=1, AbsolutePositionWithoutInsetsExcludesPadding=2, AbsolutePercentAgainstInnerSize=4, All=2147483647, Classic=2147483646 | 6 | +| ExperimentalFeature | WebFlexBasis=0 | 1 | +| FlexDirection | Column=0, ColumnReverse=1, Row=2, RowReverse=3 | 4 | +| Gutter | Column=0, Row=1, All=2 | 3 | +| Justify | FlexStart=0, Center=1, FlexEnd=2, SpaceBetween=3, SpaceAround=4, SpaceEvenly=5 | 6 | +| LogLevel | Error=0, Warn=1, Info=2, Debug=3, Verbose=4, Fatal=5 | 6 | +| MeasureMode | Undefined=0, Exactly=1, AtMost=2 | 3 | +| NodeType | Default=0, Text=1 | 2 | +| Overflow | Visible=0, Hidden=1, Scroll=2 | 3 | +| PositionType | Static=0, Relative=1, Absolute=2 | 3 | +| Unit | Undefined=0, Point=1, Percent=2, Auto=3 | 4 | +| Wrap | NoWrap=0, Wrap=1, WrapReverse=2 | 3 | + +The uppercase constant name is the enum and member spelling converted to uppercase snake case, for example PositionType.Relative also appears as Yoga.POSITION_TYPE_RELATIVE. The initial @taffyjs/yoga PositionType direction is recorded separately in the decision ledger: preserve Yoga's enum and method form for Relative and Absolute while omitting the unsupported Static member and constant and rejecting a dynamically supplied zero. + +## Node inventory + +The public Node type declares exactly 100 instance methods. This raw inventory is grouped for navigation; classification may split one method into multiple capability rows when inputs or state produce different compatibility outcomes. + +### Lifecycle, topology, calculation, state, and callbacks + +There are 21 methods in this group: calculateLayout, copyStyle, free, freeRecursive, getChild, getChildCount, getParent, insertChild, removeChild, isDirty, markDirty, hasNewLayout, markLayoutSeen, reset, isReferenceBaseline, setIsReferenceBaseline, setAlwaysFormsContainingBlock, setMeasureFunc, unsetMeasureFunc, setDirtiedFunc, and unsetDirtiedFunc. + +| API | Declared shape | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| calculateLayout | calculateLayout(width: number or auto or undefined, height: number or auto or undefined, direction?: Direction): void | +| copyStyle | copyStyle(node: Node): void | +| free | free(): void | +| freeRecursive | freeRecursive(): void | +| getChild | getChild(index: number): Node | +| getChildCount | getChildCount(): number | +| getParent | getParent(): Node or null | +| insertChild | insertChild(child: Node, index: number): void | +| removeChild | removeChild(child: Node): void | +| isDirty | isDirty(): boolean | +| markDirty | markDirty(): void | +| hasNewLayout | hasNewLayout(): boolean | +| markLayoutSeen | markLayoutSeen(): void | +| reset | reset(): void | +| isReferenceBaseline | isReferenceBaseline(): boolean | +| setIsReferenceBaseline | setIsReferenceBaseline(isReferenceBaseline: boolean): void | +| setAlwaysFormsContainingBlock | setAlwaysFormsContainingBlock(alwaysFormsContainingBlock: boolean): void | +| setMeasureFunc | setMeasureFunc(measureFunc: MeasureFunction or null): void | +| unsetMeasureFunc | unsetMeasureFunc(): void | +| setDirtiedFunc | setDirtiedFunc(dirtiedFunc: DirtiedFunction or null): void | +| unsetDirtiedFunc | unsetDirtiedFunc(): void | + +### Computed layout getters + +There are 10 methods in this group: getComputedBorder, getComputedBottom, getComputedHeight, getComputedLayout, getComputedLeft, getComputedMargin, getComputedPadding, getComputedRight, getComputedTop, and getComputedWidth. + +| API | Declared result | +| ------------------------------ | --------------- | +| getComputedBorder(edge: Edge) | number | +| getComputedBottom() | number | +| getComputedHeight() | number | +| getComputedLayout() | Layout | +| getComputedLeft() | number | +| getComputedMargin(edge: Edge) | number | +| getComputedPadding(edge: Edge) | number | +| getComputedRight() | number | +| getComputedTop() | number | +| getComputedWidth() | number | + +### Style getters + +There are 26 methods in this group: getAlignContent, getAlignItems, getAlignSelf, getAspectRatio, getBorder, getDirection, getDisplay, getFlexBasis, getFlexDirection, getFlexGrow, getFlexShrink, getFlexWrap, getHeight, getJustifyContent, getGap, getMargin, getMaxHeight, getMaxWidth, getMinHeight, getMinWidth, getOverflow, getPadding, getPosition, getPositionType, getBoxSizing, and getWidth. + +| Result family | APIs | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Align | getAlignContent(), getAlignItems(), getAlignSelf() | +| number | getAspectRatio(), getBorder(edge: Edge), getFlexGrow(), getFlexShrink() | +| Direction | getDirection() | +| Display | getDisplay() | +| FlexDirection | getFlexDirection() | +| Wrap | getFlexWrap() | +| Justify | getJustifyContent() | +| Overflow | getOverflow() | +| PositionType | getPositionType() | +| BoxSizing | getBoxSizing() | +| Value | getFlexBasis(), getHeight(), getGap(gutter: Gutter), getMargin(edge: Edge), getMaxHeight(), getMaxWidth(), getMinHeight(), getMinWidth(), getPadding(edge: Edge), getPosition(edge: Edge), getWidth() | + +### Style setters + +There are 43 methods in this group: setAlignContent, setAlignItems, setAlignSelf, setAspectRatio, setBorder, setDirection, setDisplay, setFlex, setFlexBasis, setFlexBasisPercent, setFlexBasisAuto, setFlexDirection, setFlexGrow, setFlexShrink, setFlexWrap, setHeight, setHeightAuto, setHeightPercent, setJustifyContent, setGap, setGapPercent, setMargin, setMarginAuto, setMarginPercent, setMaxHeight, setMaxHeightPercent, setMaxWidth, setMaxWidthPercent, setMinHeight, setMinHeightPercent, setMinWidth, setMinWidthPercent, setOverflow, setPadding, setPaddingPercent, setPosition, setPositionPercent, setPositionType, setPositionAuto, setBoxSizing, setWidth, setWidthAuto, and setWidthPercent. + +| Input family | APIs and declared shapes | +| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Align | setAlignContent(value: Align), setAlignItems(value: Align), setAlignSelf(value: Align) | +| Optional number | setAspectRatio(value: number or undefined), setBorder(edge: Edge, value: number or undefined), setFlex(value: number or undefined), setFlexGrow(value: number or undefined), setFlexShrink(value: number or undefined) | +| Enum | setDirection(value: Direction), setDisplay(value: Display), setFlexDirection(value: FlexDirection), setFlexWrap(value: Wrap), setJustifyContent(value: Justify), setOverflow(value: Overflow), setPositionType(value: PositionType), setBoxSizing(value: BoxSizing) | +| Point, percent string, auto, or undefined | setFlexBasis(value), setHeight(value), setMargin(edge, value), setWidth(value) | +| Point, percent string, or undefined with a separate auto method | setPosition(edge, value); setPositionAuto(edge) supplies auto because the general setter does not declare it | +| Point, percent string, or undefined | setGap(gutter, value), setMaxHeight(value), setMaxWidth(value), setMinHeight(value), setMinWidth(value), setPadding(edge, value) | +| Explicit percent | setFlexBasisPercent(value), setHeightPercent(value), setGapPercent(gutter, value), setMarginPercent(edge, value), setMaxHeightPercent(value), setMaxWidthPercent(value), setMinHeightPercent(value), setMinWidthPercent(value), setPaddingPercent(edge, value), setPositionPercent(edge, value), setWidthPercent(value) | +| Explicit auto | setFlexBasisAuto(), setHeightAuto(), setMarginAuto(edge), setPositionAuto(edge), setWidthAuto() | + +Both setGap and setGapPercent are declared to return Value rather than void, but their C++ methods and the published runtime return void. This is tracked below as a declaration/runtime mismatch. + +## Capability matrix + +This matrix answers whether the public capability has a credible implementation over the current @taffyjs/node boundary. It does not claim that every Flex layout produced after a compatible setter is pixel-identical to Yoga. Setter storage and getter round trips, calculation mechanics, and the resulting layout are classified separately so that an algorithm difference does not incorrectly make a useful API unsupported. + +No first-release feasibility blocker remains in the inventoried Yoga 3.2.1 surface. The grouped result is: + +- Package entries, factories, ordinary Config state, tree ownership and mutation, declaration getters and setters, calculation on any live Node, logical dirty and new-layout flags, ordinary measure callbacks, Yoga-shaped computed output, and per-Node point-grid rounding are Compatible through bounded JavaScript or TypeScript mapping. +- Yoga cache artifacts, MeasureMode-sensitive or flex-basis-sensitive measurement results, aspect-ratio layout, min/max combined with flex growth or shrinkage, main-axis auto margins combined with justification offsets, and the documented reversed-axis, overflowing, or zero-size distribution cases are Different. The same API remains callable and returns Taffy's stable geometry under the documented trigger. +- Display.Contents, PositionType.Static, non-None Errata values, reference-baseline selection, property-invalid Align values, and undeclared broken setter coercions are Unsupported. Omit a wholly unsupported member where practical and always reject it at runtime. + +### Package entries, factories, and public value shapes + +| Capability | Classification | Basis and translation | +| ------------------------------------------------------------------------------------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Root entry default Yoga object, named enums, type-only exports, and legacy constants | Compatible | TypeScript and JavaScript can reproduce the Yoga 3.2.1 export shapes. Unsupported enum members are narrowed separately rather than changing the rest of an enum. The initial baseline is exactly 3.2.1; only the package-manager substitution instructions remain to verify. | +| @taffyjs/yoga/load and loadYoga(), mirroring yoga-layout/load | Compatible | Each call creates an isolated facade with its own hidden TaffyTree, default Config state, object brand, and Node registry. Yoga 3.2.1 rejects Node, Config, and factory-destroy arguments from another loaded assembly; the JavaScript layer rejects corresponding cross-facade operations before they reach @taffyjs/node. | +| Layout, Size, and Value structural objects | Compatible | They are finite ordinary JavaScript records. Value requires Yoga's Unit values and NaN payload behavior, not the @taffyjs/node tagged-value representation. | +| Yoga.Config.create() | Compatible | A JavaScript Config retains Yoga defaults and private revisions used when translating Config-sensitive style. Nodes hold the Config state strongly, and calculation-time traversal observes revisions without requiring a reverse registry of every Node using that Config. | +| Yoga.Node.create(), createDefault(), and createWithConfig() | Compatible | Create one stable native NodeId immediately in the facade tree. Arbitrary unattached nodes and multiple roots coexist without subtree migration, and nodes created with different same-facade Config values may attach to each other. The no-argument factories retain an immortal private default Config state. | +| Yoga.Node.destroy() and Node.free() | Compatible | Invalidate exactly one canonical Node wrapper and remove its NodeId. Detach it from its parent, detach but preserve its direct children and their subtrees, and make surviving topology dirty enough for a later calculation to be correct. Deterministic post-free errors intentionally replace Yoga's unsafe raw-pointer alias behavior. | +| Yoga.Config.destroy() and Config.free() | Compatible | Invalidate the public Config handle immediately, reject later methods and new Node creation through that handle, and retain the internal immutable-or-live-last-value Config state while existing Nodes still reference it. This supplies safe reference-counted lifetime instead of Yoga's dangling Config pointer when a Config is freed too early. | +| DirtiedFunction public declaration | Compatible | Preserve Yoga's declared one-Node callback type. The facade can pass the canonical Node wrapper, which makes the declaration truthful; Yoga 3.2.1's zero-argument runtime invocation is a documented callback-trace difference rather than a useful contract to reproduce. | +| MeasureFunction public declaration | Compatible | Preserve Yoga's four-argument callback type and Size return. Constraint-sensitive callback traces and resulting layouts are classified separately rather than changing the public declaration. | + +### Enum members + +| Enum or member group | Classification | Mapping | +| ------------------------------------------------------------------------------------------------------------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| BoxSizing, Dimension, FlexDirection, Gutter, Justify, LogLevel, MeasureMode, NodeType, Overflow, Unit, and Wrap | Compatible | The public numeric enum and reverse lookup can be reproduced. Every layout-affecting member has a direct Taffy value or a finite wrapper conversion; Dimension, LogLevel, and NodeType are export-only in this JavaScript API. | +| Direction.LTR and Direction.RTL | Compatible | Direct Taffy mapping. | +| Direction.Inherit | Compatible | Keep Inherit in Yoga declaration state, resolve it from the parent or calculateLayout owner direction before native calculation, and re-resolve logical edges when direction changes. | +| Edge.Left, Top, Right, Bottom, Start, End, Horizontal, Vertical, and All | Compatible | Keep all nine declarations in JavaScript and resolve each physical edge with Yoga's logical, physical, axis, then All precedence. Taffy receives only the resolved four-edge values. Style getters continue to expose the original edge declarations. | +| Align members in their valid property contexts | Compatible | Taffy directly represents FlexStart, Center, FlexEnd, Stretch, and Baseline for item/self alignment, Auto for self alignment, and the normal content-distribution values for content alignment. The shared Align enum remains exported because every member has at least one valid context. | +| Align members used outside their valid property context | Unsupported | Yoga's shared enum lets Auto or distribution values reach item alignment and lets Auto or Baseline reach content alignment, producing incidental branch fallthrough behavior that changes between single-line and multi-line layouts. Reproducing that is not a credible style capability. Each setter accepts the union of members valid for that property, so a general Align value must be narrowed by the caller, and the runtime always rejects an invalid combination. | +| Display.Flex and Display.None | Compatible | Direct Taffy mapping. | +| Display.Contents | Unsupported | Taffy 0.13 has no contents box. Preserving Yoga semantics would require a shadow or repeatedly rewritten computation topology, which conflicts with the bounded JavaScript layer and stable node ownership. Omit the member and legacy constant and reject a dynamic numeric value. | +| PositionType.Relative and PositionType.Absolute | Compatible | Direct Taffy mapping. | +| PositionType.Static | Unsupported | Omit the member and POSITION_TYPE_STATIC and reject a dynamic zero, as recorded in the decision ledger. | +| Errata.None | Compatible | It selects Yoga's modern default behavior and requires no additional Taffy mode. | +| Errata.StretchFlexBasis, AbsolutePositionWithoutInsetsExcludesPadding, AbsolutePercentAgainstInnerSize, All, and Classic | Unsupported | These values request Yoga-specific algorithm branches and combinations for which Taffy exposes no matching switch. Omit the members and legacy constants, narrow setErrata to None, and reject dynamically supplied nonzero values rather than retaining state that native layout ignores. | +| ExperimentalFeature.WebFlexBasis | Compatible | Preserve the enum and boolean Config state. The disabled Yoga cache behavior has a separate Different result row rather than making the enum or state API unavailable. | + +### Config behavior + +| Capability | Classification | Mapping and boundary | +| ------------------------------------------------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| useWebDefaults() and setUseWebDefaults() state | Compatible | A Config retains and immediately reports the flag. Node construction materializes Yoga's legacy Column/FlexStart defaults or web Row/Stretch defaults; changing the Config later does not rewrite those construction-time declarations. Unresolved flexShrink and numeric flex continue to read the live flag. The setter itself must not make isDirty true or invoke dirtied callbacks. | +| Layout after changing useWebDefaults on a live Config | Different | Yoga does not increment its Config version for this setter, so it can reuse an old result even though style getters immediately expose the new flexShrink default. TaffyJS retains the public Config state and JavaScript-owned Yoga dirty flags, but it does not reproduce Yoga's complete calculation cache; a calculation that reaches Taffy may therefore apply the current Config-sensitive flex values where Yoga would retain stale geometry. This includes attached-subtree then ancestor sequences and must be documented with fixed fixtures. | +| setPointScaleFactor() | Compatible | Disable Taffy's tree-wide rounding, retain unrounded layouts, and apply Yoga's pixel-grid formula while constructing Yoga outputs. The formula is local output processing, including each Node's own Config factor and measured-text rounding. Zero disables rounding; negative values and NaN are rejected before state changes, matching Yoga, while positive infinity is accepted and produces Yoga's NaN output. A Config revision forces new output projection on the next calculateLayout without making Yoga-visible isDirty true. | +| getErrata() and setErrata(Errata.None) | Compatible | Plain Config state plus the default native calculation. Non-None values use the Unsupported enum row above. | +| isExperimentalFeatureEnabled() and setExperimentalFeatureEnabled() | Compatible | Retain and report the boolean. Layout under the disabled value uses the separate Different result row below. | +| free() | Compatible | Invalidate the public Config handle but keep its last state alive through strong references from existing Nodes. Repeated free, use of the freed handle, and createWithConfig with that handle fail deterministically instead of exposing Yoga's dangling-pointer behavior. | + +The attached-subtree probe used an already calculated 200-point ancestor containing a 100-point row whose two children each declared width 80. After changing useWebDefaults from false to true, getFlexShrink changed from 0 to 1 while every node stayed clean and the children stayed 80/80. Calling calculateLayout on the still-attached row changed the children to 50/50 while the row and its ancestor both remained clean; recalculating the ancestor with its original inputs also left that state intact. This sequence is the minimum regression fixture for the documented cache-derived difference; reproducing Yoga's cache decision is not an implementation requirement. + +### Node lifecycle, topology, calculation, state, and callbacks + +| APIs or capability | Classification | Mapping and boundary | +| ----------------------------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| getChildCount(), getChild(), getParent(), insertChild(), and removeChild() | Compatible | The facade tree is authoritative for topology. Enforce same-facade, live-handle, one-parent, acyclic-tree, valid-index, and measured-node invariants before mutation; then call Taffy and update any JavaScript cache only after success. Return a canonical wrapper, return null for an out-of-range getChild despite Yoga's declaration, leave removeChild(non-child) as a no-op, and reset a successfully detached child's Yoga-shaped output to zero positions and NaN dimensions while preserving its Style and logical dirty flag. | +| copyStyle() | Compatible | Copy the source node's raw Yoga Style declarations, retranslate once under the destination Config, compare before mutation, and dirty only when the declarations differ. Config, topology, callbacks, measure state, reference-baseline and containing-block flags, and computed layout are not copied. | +| reset() | Compatible | Reject a node with a parent or children before mutation, retain its same NodeId and internal Config state, and restore construction defaults using that Config's current values. Clear Style declarations, callbacks, measurement state, reference and containing-block flags, computed output, and public state flags. Allow callbacks to be installed again after reset rather than reproducing Yoga 3.2.1's lost native context bug. | +| free(), Node factory destroy(), and post-free access | Compatible | free and factory destroy use one path. Detach and invalidate exactly that Node, preserve direct children as independent live roots with their subtrees and last public output, and dirty any surviving parent so its next calculation sees the removal. Every alias resolves to one canonical wrapper, and post-free or repeated-free calls fail deterministically; Yoga 3.2.1's stale Embind aliases and missing parent invalidation are not compatibility targets. | +| freeRecursive() | Compatible | Traverse and invalidate the selected node and every descendant in postorder. If the selected node was attached, detach it and dirty only the surviving outside parent chain. O(subtree) traversal is inherent in the method; ordinary free remains proportional to the node's direct relationships and never rebuilds another subtree. | +| calculateLayout() input shape and explicit calculation boundary | Compatible | Normalize omitted, undefined, or auto width and height to indefinite space, default owner direction to LTR, translate dirty Yoga declarations, invoke one Taffy calculation rooted at the selected live node even when it still has a parent, and then build Yoga-shaped outputs. Finite owner dimensions require the bounded root-constraint translation below rather than being passed only as Taffy available space. Native recomputation and cache-derived result differences are classified separately. | +| isDirty() | Compatible | JavaScript owns the Yoga-visible logical dirty flag. Ordinary setters preserve Yoga transitions: writing the identical raw declaration does nothing, while any raw declaration change dirties the node and ancestors even when a higher-precedence edge leaves the translated Taffy Style unchanged. Native setStyle or markDirty still ensures Taffy recomputes when required, but internally propagated native dirty state is allowed to be a superset and is not exposed directly. | +| markDirty() | Compatible | Call Taffy markDirty after enforcing Yoga's restriction that only a measured leaf may be manually dirtied. | +| hasNewLayout() and markLayoutSeen() | Compatible | Retain the flag in JavaScript. Initial true, mark-seen false, calculations mark the selected root as new even on a clean cache hit, dirty descendant calculations mark the recomputed subtree, and an attached-subtree calculation need not alter an outside ancestor's flag. Differential fixtures are an implementation requirement, not an unresolved capability classification. | +| setMeasureFunc(), unsetMeasureFunc(), and setMeasureFunc(null) | Compatible | Store a per-node callback and dispatch through Taffy's one calculation callback. Reject measured nodes with children and children added to measured nodes; null unsets the function. Yoga's four-argument callback shape, content-box constraints, invalid-result normalization, thrown-value identity, and retry after failure are feasible. Taffy's constraint provenance and measurement phases differ, so callback traces and MeasureMode-sensitive or flexBasis-dependent results use the separate Different row below. | +| setDirtiedFunc(function), unsetDirtiedFunc(), and clean-to-dirty notification | Compatible | JavaScript topology and pre-mutation dirty snapshots can invoke registered callbacks for clean-to-dirty transitions and pass the canonical Node declared by Yoga's public type. Exact callback count and order remain outside the promise. | +| setDirtiedFunc(null) and Yoga's zero-argument callback trace | Different | Treat null as unset and pass the declared Node to a real callback. Yoga 3.2.1 instead installs null and later throws a TypeError on a dirty transition, and invokes real callbacks with zero arguments. Those wrapper artifacts are not useful core behavior and are explicitly covered by the accepted callback-trace exception. | +| isReferenceBaseline() and setIsReferenceBaseline(false) | Compatible | Retain and report the only credible default state, false. | +| setIsReferenceBaseline(true) | Unsupported | The current binding cannot select Yoga's reference child during baseline calculation, and reproducing that algorithm in JavaScript is outside the boundary. Reject true instead of retaining a flag that native layout ignores. | +| setAlwaysFormsContainingBlock(false or true) | Compatible | Yoga consults this flag only when the node itself is PositionType.Static. Static is already unsupported, while Relative and Absolute nodes form containing blocks regardless of the flag. Retaining either boolean is therefore behaviorally inert but accurate for every supported node; a regression fixture must guard this dependency between the two classifications. | + +### Calculation context for the selected node + +Yoga's calculateLayout width and height are not merely maximum available space. For an axis with no definite declared size or declared maximum, a finite argument gives the selected Node an exact outer constraint; for example, an empty Yoga Flex root calculated with width 100 has computed width 100, while the same Taffy Flex root given AvailableSpace.Definite(100) has width zero. A measured Yoga root likewise receives Exactly(100) and finishes at width 100, whereas an unmodified Taffy measured root remains at its measured width. + +The bounded bridge is to derive a calculation-only Taffy Style for the selected Node. A Yoga exact root constraint becomes an effective exact Taffy size after resolving the selected Node's margins, padding, border, box sizing, min/max constraints, and percentage bases from the calculateLayout owner inputs. A Yoga declared maximum remains a fit-content bound rather than becoming an exact size. Root declarations remain unchanged and all descendants continue through the ordinary Taffy algorithm. The selected Node's ordinary derived Style is restored in a finally path after native calculation; restoring it may leave broader native dirty state, which is internal under the decided calculation-state model. This translation applies equally when the selected Node is still attached because Taffy can calculate any NodeId as the root of one invocation without moving it. + +This temporary Style is calculation context, not a second declaration or layout engine: Yoga getters continue to read the one declaration state, callback closures see that state, and Taffy alone computes geometry. A successful calculation projects and commits outputs for the selected subtree; a thrown measurement leaves the previous Yoga-shaped outputs and logical dirty state intact, while a first failed calculation retains the initial zero-position and NaN-size output. + +### Measure callback bridge + +One facade-level Taffy measure closure dispatches by NodeId to the callback stored on the Yoga Node. Nodes without a Yoga callback return the ordinary zero intrinsic size. The adapter converts each Taffy request independently: a known dimension becomes Yoga MeasureMode.Exactly, finite available space becomes MeasureMode.AtMost, MaxContent becomes MeasureMode.Undefined with NaN, and MinContent becomes MeasureMode.AtMost with zero. The calculation-only root constraint above turns a finite exact owner dimension into a known dimension for a selected measured root, so that callback receives Exactly rather than AtMost. Padding and border have already been removed from Taffy's available space and are added back by Taffy, matching Yoga's content-box callback contract. + +This conversion is intentionally not a reconstruction of Yoga's measurement phases. Taffy can make several intrinsic, sizing, and final-layout requests where Yoga makes one, and AvailableSpace.Definite does not record whether its source was an exact stretch or a fit-content bound. Consulting parent Flex state to synthesize Yoga's call sequence would duplicate algorithm logic and is outside the JavaScript-only boundary. Callback count, order, and exact argument trace therefore remain unspecified, and a callback that changes its result based on MeasureMode can produce a documented Different layout. + +The callback result is converted to numbers, missing dimensions become NaN, and each NaN or negative dimension is normalized to zero before returning to Taffy; positive infinity remains positive infinity, matching Yoga's normalizer. A thrown JavaScript value is rethrown with identity preserved. The current @taffyjs/node binding marks the calculation subtree dirty after callback failure, so a later calculation can retry. Measured Nodes must remain leaves, markDirty remains restricted to measured leaves, and native TaffyTree access attempted reentrantly during the callback can throw the existing tree-busy error; exact callback reentrancy is not part of the compatibility promise. + +There is one performance boundary to measure during implementation. Once a calculation uses computeLayoutWithMeasure, the current @taffyjs/node boundary invokes the JavaScript closure for every Taffy leaf request, including leaves without a Yoga callback; a bounded 100-leaf probe observed 200 calls for fixed-size leaves and 400 for intrinsically sized leaves. The facade should use ordinary computeLayout when the calculated subtree has no measured Node. The remaining mixed-tree overhead is not a correctness blocker and does not justify shadow topology or JavaScript Flex logic; it belongs in later end-to-end performance work. + +### Style declaration storage, getters, and setters + +The Yoga declarations must be authoritative JavaScript state. Reading back only Taffy's translated Style would lose undefined versus auto, shorthand edges, Start and End, Direction.Inherit, numeric flex, and Config-sensitive defaults. This is a bounded compatibility record, not a second layout tree: Taffy remains authoritative for topology, native calculation, and unrounded layout, while JavaScript owns the finite Yoga-visible dirty and new-layout flags. Taffy's internal invalidation may be broader than Yoga's observable invalidation and is not exposed directly. + +The TypeScript surface should preserve Yoga 3.2.1's published declarations for every supported capability, even where a return declaration is known to disagree with Yoga's runtime. Correcting those types would make previously compiling consumer source fail during dependency substitution, while reproducing the runtime value is cheap and unambiguous. Unsupported members and values remain the explicit exception: they are narrowed where practical and always rejected at runtime. Inputs outside Yoga's published declarations are not a compatibility surface merely because the generic wrapper happens to coerce them. + +| APIs or capability | Classification | Mapping and boundary | +| --------------------------------------------------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The 25 style getters other than getGap() | Compatible | Read the raw Yoga declaration or Yoga-resolved getter default from JavaScript state. Construct fresh Value records with Yoga Unit values and NaN payloads where the Yoga runtime does so. | +| getGap() TypeScript declaration and runtime result | Compatible | Preserve Yoga's declared Value return for source compatibility while returning the raw number or NaN at runtime exactly as Yoga 3.2.1 does. The declaration/runtime contradiction is pinned reference behavior, not a reason to invent a third shape. | +| setDirection(), setFlexDirection(), setFlexWrap(), setJustifyContent(), setOverflow(), and setBoxSizing() | Compatible | Direct enum translation, with Direction.Inherit resolved before calculation. | +| setDisplay(Flex or None) and setPositionType(Relative or Absolute) | Compatible | Direct translation. Display.Contents and PositionType.Static use their unsupported rows above. | +| Valid-context setAlignContent(), setAlignItems(), and setAlignSelf() values | Compatible | Direct mapping. Broad-enum values invalid for a particular property use the unsupported-input row in the enum matrix rather than being silently normalized. | +| setFlexGrow(), setFlexShrink(), setFlexBasis(), setFlexBasisPercent(), and setFlexBasisAuto() | Compatible | Preserve separate Yoga declarations and derive the effective Taffy fields with Yoga precedence and Config defaults. | +| setFlex() | Compatible | Retain numeric flex and derive grow, shrink, and basis exactly as Yoga does: positive values supply grow and a legacy zero basis or web-default auto basis; negative values supply legacy shrink; explicit longhands win. The declaration mapping is finite; cache-derived stale-layout behavior after a live useWebDefaults change follows the decided difference above. | +| setWidth/Height and Auto/Percent variants; setMin/Max Width/Height and Percent variants | Compatible | Normalize every declared point, percent, auto, undefined, and NaN form into Yoga declaration state, then convert to Taffy dimensions. Valid percent strings are parsed once; malformed strings and Value-like objects are outside the declared input contract and use the unsupported-input row below. | +| Margin, padding, border, position, and gap setter families | Compatible | Retain all shorthand and logical declarations, apply Yoga edge precedence and direction when producing Taffy's physical Rect and Size values, and preserve original declarations for getters. Auto margin affects layout but Yoga's computed-margin getter reports zero for an auto declaration, so computed output must mask Taffy's resolved auto-margin value. | +| setAspectRatio() storage and getAspectRatio() | Compatible | Optional numeric declaration storage and getter shape are finite. Calculated layout is classified separately. | +| setGap() and setGapPercent() TypeScript declaration and runtime result | Compatible | Preserve Yoga's declared Value return while returning undefined at runtime, matching Yoga 3.2.1. Normal consumers ignore these setter returns; exact source declarations avoid an unnecessary substitution-time type change. | +| Undeclared generic-setter coercions and Value-like getter-to-setter inputs | Unsupported | Reject malformed strings, arbitrary objects, and other values outside the published setter declarations. Yoga's wrapper attempts broad parseFloat and Value-like coercion, but its own getWidth-to-setWidth round trip resets the value rather than preserving it. Reproducing accidental and broken coercions would add maintenance without preserving a working consumer capability. | + +### Computed layout getters and cross-cutting result classes + +| APIs or result class | Classification | Mapping or observed boundary | +| --------------------------------------------------------------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| getComputedLeft/Top/Width/Height(), getComputedRight/Bottom(), and getComputedLayout() object shape | Compatible | Width, height, and normal left/top come from Taffy's unrounded layout followed by Yoga point-grid processing. Yoga right/bottom are stored trailing margin plus relative inset values rather than geometric remaining space, and can be reconstructed from finite declaration state. Root left/top also need Yoga's leading margin and relative inset because Taffy roots remain at zero. | +| getComputedMargin(), getComputedPadding(), and getComputedBorder() for Left/Top/Right/Bottom/Start/End | Compatible | Taffy's unrounded Layout contains the resolved physical records; Start and End map through the resolved layout direction, and auto declarations mask Taffy's distributed margin back to Yoga's reported zero. Bounded comparisons cover positive and negative point margins, percentage padding and margins, physical and logical edges under LTR and RTL, auto margins, borders, and overflow. | +| Computed margin, padding, or border queried with Horizontal, Vertical, or All | Compatible | Reject these multi-edge shorthands; Yoga aborts rather than returning an aggregate result. Exact error class and wording are deferred. | +| Point-scale rounding | Compatible | It is an output conversion over unrounded layouts, including Yoga's ceil-sensitive text-node sizes, and does not require a Taffy core change. | +| Ordinary non-degenerate fixed sizes, simple grow or shrink without min/max, percentages, and simple nested flex trees | Compatible | Bounded differential probes found matching unrounded geometry outside the explicit difference triggers below. A committed real-Yoga oracle suite remains a release gate and must not be presented as a compatibility percentage. | +| min/max combined with flex growth or shrinkage | Different | Yoga and Taffy can produce different final sizes; the API remains available with Taffy's stable result. | +| aspectRatio in calculated layout | Different | Yoga derives the cross size from the final flex main size, while Taffy follows its own CSS-oriented aspect-ratio and stretch rules. With rounding disabled, a 100x100 Row parent and width-40 ratio-2 child produce 40x20 in Yoga but 40x100 in Taffy; a Column child declaring width 40, height 30, and ratio 2 produces 60x30 in Yoga but 40x30 in Taffy. Min/max and flex growth diverge further. Aligning all cases would require parent- and algorithm-dependent size rewriting, which crosses the accepted JavaScript boundary. | +| MeasureMode-sensitive callbacks and measurement combined with flexBasis | Different | Taffy does not preserve whether a finite constraint originated as Yoga Exactly or AtMost and may request intrinsic measurements in different phases. The bridge therefore gives each Taffy request a meaningful Yoga-shaped mode but does not promise a Yoga callback trace. Fixed-size and constraint-insensitive callbacks remain useful; callbacks branching on MeasureMode and flex-basis interactions have stable result differences. | +| WebFlexBasis disabled across a changed containing size | Different | Yoga 3.2.1 can reuse a stale computed percentage flex basis when the experiment is false, while Taffy recomputes it. Emulating Yoga's stale cache would require Yoga-internal flex-basis state and is outside the boundary; the enabled Yoga result matched the tested Taffy relayout. | +| Attached-subtree calculation followed by an ancestor calculation that Yoga serves from cache | Different | Yoga can retain geometry written by the separate subtree calculation because the clean ancestor does not revisit it. Taffy may recompute that ancestor after internal Style translation propagated native dirty state and can therefore produce current ancestor-context geometry instead. The package keeps calculateLayout available on both nodes and does not reproduce Yoga's complete cache algorithm. | +| Oversized WrapReverse lines with distribution-style alignContent | Different | With point rounding disabled, a 100x30 Row/WrapReverse container holding three 60-point items on separate 10, 20, and 15-point lines produces Yoga top positions 20, 0, and -15 but Taffy positions 35, 15, and 0 under Stretch, SpaceBetween, SpaceAround, or SpaceEvenly. The content's 45-point cross size exceeds the 30-point container; FlexStart, FlexEnd, and Center are not covered by this difference. Reconstructing Yoga's negative-free-space line placement would duplicate Flex logic. | +| Oversized single line on a reversed main axis with distribution-style justifyContent | Different | When a RowReverse or ColumnReverse no-wrap line overflows and justifyContent is SpaceBetween, SpaceAround, or SpaceEvenly, Yoga and Taffy choose different negative-free-space fallback placement. In a 240-point RowReverse line with 140, 130, and 120-point children, Yoga left positions are 100, -30, and -150 while Taffy reports 250, 120, and 0. This affects sibling distribution and is not an output-only correction. | +| Zero-cross-size wrapped line under Stretch item alignment | Different | A wrapped line whose items all resolve to zero cross size stays on Yoga's cross-start or cross-end edge, while Taffy distributes the zero-size line under FlexEnd, Center, SpaceAround, or SpaceEvenly alignContent. For a 180-point cross axis, ordinary Wrap plus Center places Yoga at 0 and Taffy at 90; WrapReverse plus Center places Yoga at 180 and Taffy at 90. Reconstructing this degenerate line placement would duplicate Flex logic. | +| Main-axis auto margins combined with a nonzero justifyContent offset | Different | Yoga lets positive-free-space main-axis auto margins consume the remaining space and then suppresses justifyContent; Taffy distributes the auto margins and can still apply a justification offset. This is not limited to reversed axes: in a 240x180 Column with justifyContent Center, a 10x30 child with auto margins followed by a 20x20 child has Yoga tops 65 and 160 but Taffy tops 130 and 225. Normal FlexStart cases can match, while reversed axes expose additional combinations. Correcting sibling positions would duplicate Flex distribution, so Taffy geometry is retained and computed-margin getters still mask auto declarations to zero. | +| Relative insets, including reversed axes | Compatible | Relative positioning does not participate in normal-flow sizing. When Taffy and Yoga apply opposite signs on a reversed axis, the projection can remove Taffy's declaration-derived offset and apply Yoga's declaration-derived offset to the Node's projected position and descendant absolute accumulation without redistributing siblings or calculating Flex geometry. A combination that also triggers the main-axis auto-margin difference remains covered by the Different row above. | + +### Yoga-shaped output projection + +Taffy's unrounded Layout is the geometry source, but it is not returned directly. After a successful calculation the facade traverses the selected subtree once and stores Yoga-shaped output records. This projection is required even when the numeric geometry matches because Yoga exposes only left, right, top, bottom, width, and height, retains last-calculated results separately from declarations, and gives right and bottom semantics that are not simply the unused space after the Node. + +For each non-root Node, Taffy's physical location supplies the unrounded left and top. The parent flex and cross axes determine which physical edge Yoga treated as flex-start: when it is Left or Top, that edge receives the Taffy location; when it is Right or Bottom, the edge is derived from the parent size, child size, and physical location. The opposite edge retains Yoga's resolved margin plus relative-inset initialization. This reproduces Yoga's observable behavior such as a normal-flow right value remaining the trailing margin rather than parentWidth - left - width. The selected root has no parent-placement step, so all four positions are resolved from its own margins and relative insets under the calculation owner inputs. Static-position containing-block traversal does not need to be reproduced because PositionType.Static is unsupported. + +Pixel-grid processing then follows Yoga's algorithm over absolute unrounded left and top. Each Node uses its own Config pointScaleFactor; only left, top, width, and height are rounded; a Node with a measure function uses Yoga's ceil-sensitive text-size branch; right, bottom, margin, padding, and border remain unrounded. JavaScript must apply Yoga's 0.0001 comparison tolerance and Math.fround at the stored float boundaries. Mixed factors in one tree are therefore supported without using Taffy's tree-wide rounding. + +Computed physical margin, padding, and border values come from the unrounded Taffy Layout. Start and End select the physical side from the calculated direction, and an auto-margin declaration reports zero even when Taffy stores the distributed physical margin. Horizontal, Vertical, and All remain invalid computed-getter inputs. New and reset Nodes start with zero positions, NaN width and height, and zero computed edges; removing a child resets that child's output while preserving its declaration state. Output records are committed only after the native calculation and complete projection succeed. + +The first implementation may read topology and unrounded Layout one Node at a time. Later @taffyjs/node batch reads, selective queries, or compact complete-Layout transport can reduce boundary calls and object construction without changing any Yoga declaration, calculation, or output rule here. Those are transport optimizations, not part of Yoga compatibility semantics. + +### Decided ownership and lifecycle mapping + +A Yoga facade means the default Yoga object exported by one loaded module or the Yoga object returned by one loadYoga() call. It is an isolated object space, not one layout root: a facade can contain many detached nodes and many unrelated roots. Yoga 3.2.1 creates a separate WASM assembly and separate Node and Config classes for each loadYoga() call, rejects cross-assembly objects, and permits nodes with different same-facade Config values to appear in one tree. + +The decided model is one private facade runtime with one TaffyTree. Every live Yoga Node receives one stable NodeId in that tree at creation and keeps it through attachment, detachment, reset, and calculation. A TaffyTree already holds multiple disconnected roots, so it directly represents the facade's node forest without any extra partitioning or subtree migration. + +| Owner or handle | Authoritative responsibility | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Facade runtime | One TaffyTree, an immortal default Config state, the facade identity brand, and the registry from every live NodeId to its canonical Node wrapper. The runtime remains reachable through any live Node or Config handle, so dropping the Yoga object alone cannot invalidate them. | +| TaffyTree | Live topology, NodeId validity, translated effective Style, internal native dirty/cache state, and unrounded calculated Layout. It can hold multiple disconnected roots, and a calculation selects any live node without moving it. | +| JavaScript Node wrapper | Facade and lifetime brand, NodeId, normalized Yoga declaration state, Config-state reference, last applied translation revisions, callbacks, measured-node marker, Yoga-visible logical dirty and new-layout flags, and Yoga-shaped public output. It never computes Flex geometry or owns a second authoritative child list. | +| Public Config handle | A same-facade handle with an alive/freed state and access to one Config state. Its methods and use in new Node construction stop after free. | +| Internal Config state | Yoga options, current values, and translation/cache revisions. Nodes retain it strongly, so an early public Config.free cannot leave a dangling reference. The state becomes unreachable only after the public handle and every Node using it are gone. | +| Optional implementation-only cache | A parent pointer or last translation revision may avoid repeated native queries. Taffy topology remains authoritative, and such a cache is updated only after the corresponding native mutation succeeds; it cannot independently authorize topology. | + +The high-level ownership and operation mapping are fixed implementation references. Durable fixtures must still prove pre-commit failure atomicity, post-commit callback-error behavior, and the stated transitions: + +| Operation | Mapping | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Node creation | Allocate the canonical wrapper and native NodeId immediately. createDefault and create without a Config use the facade's immortal default Config state; createWithConfig retains the supplied same-facade state. | +| insertChild | Prevalidate live same-facade handles, index, one-parent ownership, measured-parent restriction, and cycles. Mutate Taffy once; only then update any parent cache and Yoga-visible dirty flags. Different Config states are allowed in one subtree. | +| removeChild | A non-child is a no-op. A real child is detached without being freed; its Style, Config, callbacks, identity, and logical dirty flag survive, while its Yoga-shaped computed output resets to zero positions and NaN dimensions. Internally dirty the detached native node if necessary so a later standalone calculation does not reuse parent-context cache. | +| Node.copyStyle | Copy the source's normalized Yoga Style declaration state while retaining the destination's Config, topology, callbacks, and identity. Mark the destination's derived Taffy Style pending and its visible state dirty only when the declaration state changes. | +| Node.free or Node.destroy | Invalidate and remove exactly that Node. If attached, detach it and dirty the surviving parent chain. Detach direct children as independent live roots while preserving each child's subtree, identity, Style, Config, callbacks, public state flags, and last public output. | +| Node.freeRecursive | Collect and remove the selected Node and all descendants in postorder. References to every removed wrapper become invalid. Only an outside parent, when present, survives and becomes dirty. | +| Node.reset | Require a live detached leaf, preserve its NodeId and internal Config state, then replace every other Node-owned input and callback with construction defaults based on the Config's current value. Set logical dirty and new-layout to true and reset public geometry to zero positions and NaN dimensions. | +| Config.free or Config.destroy | Invalidate the public handle and reject future methods or Node construction through it. Existing Nodes safely retain the last Config state until they are freed; this is ordinary retained ownership, not a second public Config object. | +| Any method through a freed handle | Throw a deterministic JavaScript error before native access. Exact Yoga use-after-free, double-free, and Embind pointer-alias behavior is outside the core compatibility target. | + +Several Yoga 3.2.1 implementation artifacts are intentionally excluded by the accepted core-compatibility principle. getChild and getParent create additional Embind proxy objects rather than preserving JavaScript identity; freeing through one proxy can leave another proxy addressing freed memory; freeing an attached Node removes it from the parent without marking that parent dirty; freeing Config before its Nodes leaves dangling native pointers; and reset loses the callback context needed to install a later measure function. The facade instead returns one canonical wrapper, invalidates it reliably, preserves safe retained Config state, dirties surviving topology, and permits callbacks after reset. + +This model does not duplicate layout. JavaScript retains only public compatibility inputs, handles, flags, callbacks, and projected outputs; Taffy remains the only topology authority and geometry calculator. Normal create, attach, detach, reset, and single-node free never reconstruct or migrate a subtree. The remaining P0 validation is pre-commit failure atomicity plus post-commit callback-error semantics, the poisoned-facade policy for unexpected partial multi-step native failure, exact computed-output transitions, facade/runtime garbage-collection policy, same-tree reentrancy during measure callbacks, and evidence that any optional parent cache cannot diverge from Taffy topology. + +### Decided Yoga declaration-state model and open storage strategy + +The Yoga-facing state is the normalized declaration produced by a Yoga setter, not the caller's original JavaScript token and not the final values used by layout. For example, setWidth("50%") and setWidthPercent(50) produce the same Yoga declaration, while an unset field, an explicit auto value, and a resolved numeric default may remain distinct even when they currently lay out the same way. + +Treating the stored Taffy Style as the only source for Yoga getters and later mutations is not lossless: + +| Yoga state area | Information lost by the Taffy Style representation | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| position, margin, padding, and border | Yoga stores nine independent declarations per family: Left, Top, Right, Bottom, Start, End, Horizontal, Vertical, and All. Taffy stores four resolved physical sides. The original precedence and fallback declarations cannot be reconstructed. | +| gap | Yoga stores Column, Row, and All independently; Taffy stores two resolved axes. | +| flex shorthand and longhands | Yoga stores flex, flexGrow, and flexShrink as independent optional numbers and flexBasis as a separate tagged length. The JavaScript API has setFlex but no getFlex, yet the stored flex value remains observable through later longhand resets, copyStyle, and layout. Taffy has no flex shorthand and stores only concrete grow, shrink, and basis values. Unset and explicit zero have different fallback behavior even though Yoga getters can currently return the same number. | +| dimensions and flex basis | Yoga distinguishes Unit.Undefined from Unit.Auto. Taffy's public Dimension has Auto but no Undefined, and Taffy materializes defaults for min and max sizes. | +| direction | Yoga stores Inherit and resolves it from the calculation direction and ancestor state. Taffy Style stores only Ltr or Rtl. Start and End edge resolution therefore also depends on declaration state outside the final four-sided Rect. | +| Config-sensitive defaults | useWebDefaults changes construction and reset defaults and the resolved flex-shrink default. copyStyle copies Style but retains the destination Config, so copying an unset flexShrink from a normal-default node to a web-default node changes the getter from 0 to 1. A resolved Taffy number cannot preserve that distinction. | + +The current @taffyjs/node setStyle operation replaces the complete native Style and applies Taffy defaults to omitted fields. A minimal overlay would therefore split one logical Yoga Style between JavaScript metadata and native Taffy fields: getters, copyStyle, reset, dirty comparison, and every complete native update would have to merge both sources. The lossy categories already cover most value-bearing Yoga declarations, so that split is more complex than retaining one complete logical Yoga declaration model. + +The decided model is that each Node owns one complete logical Yoga declaration state and its Config reference. A derived Taffy Style is produced from that state, the effective direction, and supported Config behavior, then synchronized to the NodeId before native calculation. Yoga setters and declared-style getters operate on the Yoga state; Taffy remains authoritative for topology and computed layout. This is an input model plus a derived native representation, not a second layout engine. + +The declaration state is mutable current state, not an immutable snapshot that becomes stale after a setter. A successful setter updates it synchronously and advances a conceptual style revision. Dirty means that the last applied Taffy Style or calculated Layout represents an older revision: declared-style getters immediately return the new declaration, while computed-layout getters continue to return the last calculated output until calculateLayout runs. Yoga 3.2.1 exhibits this directly: after a calculated width of 100, setWidth(200) makes getWidth return 200 and isDirty return true while getComputedWidth remains 100; the next calculation changes the computed width to 200 and clears dirty. + +Complete logical state does not require eagerly allocating a large nested JavaScript object for every Node. The physical representation may share defaults, store only deviations, use fixed compact slots, or use another bounded representation as long as it preserves every supported declaration and has one owner. The eager-versus-lazy native synchronization schedule and memory representation remain performance choices to validate with representative node counts; they are not part of the semantic model. + +## Intentional JavaScript wrapper behavior + +The Yoga JavaScript wrapper adds observable behavior beyond forwarding the declarations to WASM. + +- It patches setPosition, setMargin, setFlexBasis, setWidth, setHeight, setMinWidth, setMinHeight, setMaxWidth, setMaxHeight, setPadding, and setGap to inspect the last argument and route point, percent, and auto values to the unit-specific native method. An auto value is rejected when that method family has no Auto variant. +- For non-object values, the patched setters choose percent only when a string ends with a percent sign and otherwise choose point, then use parseFloat. This admits runtime inputs beyond the narrower declarations and makes malformed strings, NaN, and undefined behavior part of the later compatibility check. +- The patched setter code also attempts to accept an object by reading its unit and valueOf result, while the TypeScript declarations do not include Value objects in these setter inputs. The wrapper source comment claims getter-to-setter round trips, but the published 3.2.1 Value is a plain object and a getWidth-to-setWidth probe resets the declaration to Unit.Undefined; this accidental input is excluded. +- setMeasureFunc accepts null by routing it to unsetMeasureFunc, and the wrapper converts a missing measured width or height to NaN before returning to Yoga. +- setDirtiedFunc converts the JavaScript function to the WASM callback representation. +- Config.free and Node.free call the corresponding destroy factory. +- Node.create selects createWithConfig or createDefault from the optional argument. +- freeRecursive repeatedly frees the first child and then frees the current node. +- calculateLayout supplies NaN for omitted width and height and LTR for an omitted direction at runtime, although its TypeScript declaration makes the first two arguments required while allowing explicit undefined. + +These wrapper behaviors are part of the compatibility inventory. A generated declaration alone is therefore insufficient as the @taffyjs/yoga reference. + +## Upstream declaration and runtime mismatches + +For supported APIs, @taffyjs/yoga preserves Yoga 3.2.1's published declaration shape so existing TypeScript source still compiles, and separately implements the selected runtime behavior below. The only deliberate runtime corrections are the dirtied callback argument and null handling described in the capability matrix; unsupported inputs are rejected instead of entering Yoga's accidental coercion paths. + +- getGap is declared to return Value, but the C++ wrapper returns float. The published runtime returns a plain number for both point and percent gaps and does not preserve Unit in the result. +- setGap and setGapPercent are declared to return Value, but the C++ setters return void and the published runtime returns undefined. +- getChild is declared to return Node, but the C++ wrapper returns a null pointer for an out-of-range index and the published runtime exposes that as null. +- DirtiedFunction is declared as receiving the dirty Node, but the C++ callback invokes dirtied with no arguments and the published callback receives zero arguments. +- setDirtiedFunc is declared to accept null, but unlike setMeasureFunc the JavaScript wrapper does not route null to the unset method. Setting null succeeds immediately and a later dirty transition throws a TypeError while trying to call the null callback. +- calculateLayout requires width and height arguments in TypeScript while allowing each to be explicitly undefined; the runtime wrapper also permits the arguments to be omitted and supplies NaN, NaN, and LTR defaults. +- The generic setter wrapper attempts to accept Value-like objects and comments that a getter result can be passed back to a setter, but the published Value is a plain object without a numeric valueOf implementation. A published 3.2.1 getWidth-to-setWidth round trip resets the value to Unit.Undefined rather than preserving it. + +## Inventory checks still open + +- Keep the capability matrix exhaustive as Yoga evolves; no public release may contain an unclassified method, value, or result trigger. +- Add committed regression probes for the declaration/runtime mismatches so later Yoga or toolchain changes cannot silently alter the reference evidence. +- Check whether any intentional public behavior exists in the generated WASM binding beyond wrapAssembly and the declarations; ignore accidental Embind properties. +- Add workspace-alias fixtures proving unchanged root and `/load` imports and verify every built entry and shared chunk through the cleaned package output directory; the freshness command must make untracked new chunks visible before diffing. Reserve the actual registry-alias installation for post-publication smoke testing and do not add a tarball-install test path. +- Add durable lifecycle, facade-isolation, stable-identity, pre-commit failure, post-commit callback-error, and no-migration fixtures for the decided one-TaffyTree-per-facade ownership model, then finalize the operation-level lifetime mapping. From f12ac37eedd2e136d338e16da70051be05008fe8 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 15 Aug 2026 14:05:55 +0800 Subject: [PATCH 02/18] Vouch @taffyjs/yoga implementation goal --- .agents/docs/loop-goal.md | 2 ++ .agents/docs/loop-status.md | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.agents/docs/loop-goal.md b/.agents/docs/loop-goal.md index 0adf983..4988bab 100644 --- a/.agents/docs/loop-goal.md +++ b/.agents/docs/loop-goal.md @@ -1,5 +1,7 @@ # @taffyjs/yoga Implementation Loop Goal +[VOUCHED @hyfdev 2026-08-15] + ## Goal Implement the initial Node-only, ESM-only `@taffyjs/yoga` package over `@taffyjs/node`, targeting exactly `yoga-layout@3.2.1`, and deliver the complete reviewed implementation through one observable draft pull request. diff --git a/.agents/docs/loop-status.md b/.agents/docs/loop-status.md index 5b9bba0..8a55410 100644 --- a/.agents/docs/loop-status.md +++ b/.agents/docs/loop-status.md @@ -4,7 +4,7 @@ Updated: 2026-08-15 ## State -Milestone 0 is on the draft implementation PR. The unattended implementation is waiting for Yunfei to vouch the exact `loop-goal.md` and has not started. +Milestone 0 and Yunfei's whole-file vouch of `loop-goal.md` are on the draft implementation branch. Milestone 1 is in progress. ## Done @@ -14,16 +14,16 @@ Milestone 0 is on the draft implementation PR. The unattended implementation is - Ran formatting, local-link checks, and an independent adversarial review of the plan and prompt. - Resolved the review findings about the unattended-run contract, multi-entry shared output, untracked-output freshness, and failure ownership; the targeted follow-up found no remaining issue outside the freshness command, which was then corrected and verified against the repository's existing intent-to-add pattern. - Committed and pushed the reviewed Milestone 0 baseline to the draft implementation PR. +- Recorded Yunfei's explicit whole-file vouch of `loop-goal.md` on 2026-08-15; the file is immutable for the implementation run. ## In flight -- None. The implementation run is waiting at its required human-vouch gate. +- Milestone 1: package, entries, facade, repository wiring, and the thin create-set-calculate-read vertical slice. ## Next -- Obtain Yunfei's whole-file vouch on `loop-goal.md`. -- Begin Milestone 1 only after that vouch. +- Complete the Milestone 1 implementation and gate, dispatch its fresh bounded adversarial review, resolve material findings, and push one coherent milestone commit. ## Blocked -- The unattended implementation run cannot start until `loop-goal.md` is vouched. +- None. From 3845d8cf1e6e25dd49f702fdaeefad1bc1a49082 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 15 Aug 2026 14:33:59 +0800 Subject: [PATCH 03/18] Add @taffyjs/yoga package foundation --- .agents/docs/loop-status.md | 16 +- .agents/docs/technology-stack.md | 2 + .github/workflows/ci.yml | 4 +- packages/taffyjs-yoga/COMPATIBILITY.md | 5 + packages/taffyjs-yoga/README.md | 7 + packages/taffyjs-yoga/dist/facade-Baj8muUk.js | 363 ++++++++++++++++++ packages/taffyjs-yoga/dist/index.d.ts | 74 ++++ packages/taffyjs-yoga/dist/index.js | 5 + packages/taffyjs-yoga/dist/load.d.ts | 5 + packages/taffyjs-yoga/dist/load.js | 7 + .../taffyjs-yoga/dist/types-3Q6Btqzs.d.ts | 218 +++++++++++ packages/taffyjs-yoga/package.json | 47 +++ packages/taffyjs-yoga/src/enums.ts | 186 +++++++++ packages/taffyjs-yoga/src/facade.ts | 257 +++++++++++++ packages/taffyjs-yoga/src/index.ts | 27 ++ packages/taffyjs-yoga/src/load.ts | 28 ++ packages/taffyjs-yoga/src/types.ts | 69 ++++ packages/taffyjs-yoga/tsconfig.json | 19 + packages/taffyjs-yoga/vite.config.ts | 24 ++ pnpm-lock.yaml | 34 ++ tests/taffyjs-yoga/exports.test.mts | 153 ++++++++ tests/taffyjs-yoga/layout.test.mts | 25 ++ tests/taffyjs-yoga/package.json | 19 + tests/taffyjs-yoga/tsconfig.json | 19 + tests/taffyjs-yoga/types/public-api.test-d.ts | 50 +++ tests/taffyjs-yoga/types/tsconfig.json | 11 + tests/taffyjs-yoga/vite.config.ts | 16 + vite.config.ts | 16 +- 28 files changed, 1701 insertions(+), 5 deletions(-) create mode 100644 packages/taffyjs-yoga/COMPATIBILITY.md create mode 100644 packages/taffyjs-yoga/README.md create mode 100644 packages/taffyjs-yoga/dist/facade-Baj8muUk.js create mode 100644 packages/taffyjs-yoga/dist/index.d.ts create mode 100644 packages/taffyjs-yoga/dist/index.js create mode 100644 packages/taffyjs-yoga/dist/load.d.ts create mode 100644 packages/taffyjs-yoga/dist/load.js create mode 100644 packages/taffyjs-yoga/dist/types-3Q6Btqzs.d.ts create mode 100644 packages/taffyjs-yoga/package.json create mode 100644 packages/taffyjs-yoga/src/enums.ts create mode 100644 packages/taffyjs-yoga/src/facade.ts create mode 100644 packages/taffyjs-yoga/src/index.ts create mode 100644 packages/taffyjs-yoga/src/load.ts create mode 100644 packages/taffyjs-yoga/src/types.ts create mode 100644 packages/taffyjs-yoga/tsconfig.json create mode 100644 packages/taffyjs-yoga/vite.config.ts create mode 100644 tests/taffyjs-yoga/exports.test.mts create mode 100644 tests/taffyjs-yoga/layout.test.mts create mode 100644 tests/taffyjs-yoga/package.json create mode 100644 tests/taffyjs-yoga/tsconfig.json create mode 100644 tests/taffyjs-yoga/types/public-api.test-d.ts create mode 100644 tests/taffyjs-yoga/types/tsconfig.json create mode 100644 tests/taffyjs-yoga/vite.config.ts diff --git a/.agents/docs/loop-status.md b/.agents/docs/loop-status.md index 8a55410..b28762f 100644 --- a/.agents/docs/loop-status.md +++ b/.agents/docs/loop-status.md @@ -4,7 +4,7 @@ Updated: 2026-08-15 ## State -Milestone 0 and Yunfei's whole-file vouch of `loop-goal.md` are on the draft implementation branch. Milestone 1 is in progress. +Milestone 1 is complete, reviewed, and ready in its milestone commit. Milestone 2 is next. ## Done @@ -15,14 +15,24 @@ Milestone 0 and Yunfei's whole-file vouch of `loop-goal.md` are on the draft imp - Resolved the review findings about the unattended-run contract, multi-entry shared output, untracked-output freshness, and failure ownership; the targeted follow-up found no remaining issue outside the freshness command, which was then corrected and verified against the repository's existing intent-to-add pattern. - Committed and pushed the reviewed Milestone 0 baseline to the draft implementation PR. - Recorded Yunfei's explicit whole-file vouch of `loop-goal.md` on 2026-08-15; the file is immutable for the implementation run. +- Added the Node-only ESM `@taffyjs/yoga` package, root and `/load` entries, every supported Yoga 3.2.1 enum and legacy constant, facade factories, private per-facade runtime ownership, and the fixed-leaf create-set-calculate-read path. +- Added the `tests/taffyjs-yoga/` consumer package with unchanged `yoga-layout` imports, a separately aliased Yoga 3.2.1 oracle, runtime and declaration coverage, facade isolation, lifetime, and hidden-state fixtures. +- Wired the package build, test, type-check, lockfile, complete `dist/` freshness check, and format-before-build aggregate-check ordering into the repository. + +## Evidence and review + +- `vp run check` passes after the aggregate task waits for formatting before starting the build-backed tests: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 6 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. +- Direct Node imports of `yoga-layout` and `yoga-layout/load` resolve the built workspace package; `pnpm pack --dry-run --json` includes both JavaScript entries, both entry declarations, and both emitted shared chunks; built JavaScript has no `yoga-layout` runtime import. +- A temporary untracked file under `packages/taffyjs-yoga/dist` is detected by the exact intent-to-add freshness pattern with `git diff` exit 1. +- The fresh adversarial reviewer found one material issue: Node and Config instances exposed writable runtime, tree, NodeId, and Config fields. The implementation moved all handle state into module-private WeakMap records and added a runtime fixture proving fake same-named properties cannot affect native operations. The same reviewer returned PASS on the targeted follow-up. ## In flight -- Milestone 1: package, entries, facade, repository wiring, and the thin create-set-calculate-read vertical slice. +- None. ## Next -- Complete the Milestone 1 implementation and gate, dispatch its fresh bounded adversarial review, resolve material findings, and push one coherent milestone commit. +- Implement Milestone 2: Config state and lifetime, the complete normalized Yoga declaration model, input normalization, supported Style getters and setters, deterministic Taffy translation, `copyStyle`, and Unsupported rejection. ## Blocked diff --git a/.agents/docs/technology-stack.md b/.agents/docs/technology-stack.md index 459ec9a..e175a0c 100644 --- a/.agents/docs/technology-stack.md +++ b/.agents/docs/technology-stack.md @@ -44,6 +44,8 @@ The interactive Getting Started example imports the real browser entry from `@ta The root `vite.config.ts` defines build and verification dependencies. The native build is an explicit graph from napi-rs binding generation through Node builtin specifier normalization and generated-file formatting to the independent platform-artifact and Vite+ entry branches. The Wasm build is a three-stage chain—staged napi-rs binding, Vite+ public entries, then final inline runtime files. Native build completion precedes package-local and consumer tests. Rust tests are part of the full test task, while Rust formatting and Clippy remain separate checks. Node formatting and linting do not depend on a native build. Generated-source drift is checked separately in CI and is not part of the default local `check` or `ready` graph. +The aggregate `check` task completes its formatting scan before invoking the build-backed test graph. This prevents the formatter from observing napi-rs transaction directories or generated package files while a concurrent native build is replacing them; invoking `check:test` directly remains independent of formatting. + Task caching is disabled at the root. This keeps native artifacts and runtime tests from being skipped or restored from stale task outputs until the project makes a new explicit caching decision. `check:wasm` is a separate Linux-capable graph rather than a dependency of the cross-platform default `check:test`: it requires the `wasm32-wasip1` Rust target and a Playwright browser. Independent verification branches run in parallel after the shared Wasm build, while browser-bundle inspection waits only for its consumer build. Existing native jobs therefore keep their target assumptions while the dedicated WASIP job exercises the complete package boundary. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 971f111..7919cae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,9 @@ jobs: - name: Smoke test @taffyjs/node with Deno 2.2 run: pnpm dlx deno@2.2.0 run --node-modules-dir=manual --allow-env --allow-read --allow-ffi packages/taffyjs-node/tests/runtime-smoke.mjs - name: Verify committed package outputs are current - run: git diff --exit-code -- packages/taffyjs-node/index.js packages/taffyjs-node/index.d.ts packages/taffyjs-node/binding.js packages/taffyjs-node/binding.d.ts + run: | + git add --intent-to-add --all -- packages/taffyjs-yoga/dist + git diff --exit-code -- packages/taffyjs-node/index.js packages/taffyjs-node/index.d.ts packages/taffyjs-node/binding.js packages/taffyjs-node/binding.d.ts packages/taffyjs-yoga/dist test-windows-x64-msvc: name: Build and test Windows x64 diff --git a/packages/taffyjs-yoga/COMPATIBILITY.md b/packages/taffyjs-yoga/COMPATIBILITY.md new file mode 100644 index 0000000..ed65448 --- /dev/null +++ b/packages/taffyjs-yoga/COMPATIBILITY.md @@ -0,0 +1,5 @@ +# @taffyjs/yoga Compatibility + +The compatibility baseline is exactly `yoga-layout@3.2.1`. The implementation is being completed through reviewed milestones; the exhaustive Compatible, Different, and Unsupported matrix will be finalized before this package leaves draft status. + +The package is permanently Node-only and ESM-only. It does not provide a browser or WASM backend. diff --git a/packages/taffyjs-yoga/README.md b/packages/taffyjs-yoga/README.md new file mode 100644 index 0000000..fdaee79 --- /dev/null +++ b/packages/taffyjs-yoga/README.md @@ -0,0 +1,7 @@ +# @taffyjs/yoga + +`@taffyjs/yoga` is an ESM-only, Node.js `>=22.18` compatibility facade for `yoga-layout@3.2.1`, implemented in TypeScript over `@taffyjs/node`. + +The package is under active development and is not published. Its root and `/load` entries are exercised through the repository's package-boundary consumer tests. + +See [COMPATIBILITY.md](COMPATIBILITY.md) for the current compatibility boundary. diff --git a/packages/taffyjs-yoga/dist/facade-Baj8muUk.js b/packages/taffyjs-yoga/dist/facade-Baj8muUk.js new file mode 100644 index 0000000..1eb9103 --- /dev/null +++ b/packages/taffyjs-yoga/dist/facade-Baj8muUk.js @@ -0,0 +1,363 @@ +import { AlignItems, AvailableSpace, Direction, Display, FlexDirection, TaffyTree } from "@taffyjs/node"; +//#region src/enums.ts +let Align = /* @__PURE__ */ function(Align) { + Align[Align["Auto"] = 0] = "Auto"; + Align[Align["FlexStart"] = 1] = "FlexStart"; + Align[Align["Center"] = 2] = "Center"; + Align[Align["FlexEnd"] = 3] = "FlexEnd"; + Align[Align["Stretch"] = 4] = "Stretch"; + Align[Align["Baseline"] = 5] = "Baseline"; + Align[Align["SpaceBetween"] = 6] = "SpaceBetween"; + Align[Align["SpaceAround"] = 7] = "SpaceAround"; + Align[Align["SpaceEvenly"] = 8] = "SpaceEvenly"; + return Align; +}({}); +let BoxSizing = /* @__PURE__ */ function(BoxSizing) { + BoxSizing[BoxSizing["BorderBox"] = 0] = "BorderBox"; + BoxSizing[BoxSizing["ContentBox"] = 1] = "ContentBox"; + return BoxSizing; +}({}); +let Dimension = /* @__PURE__ */ function(Dimension) { + Dimension[Dimension["Width"] = 0] = "Width"; + Dimension[Dimension["Height"] = 1] = "Height"; + return Dimension; +}({}); +let Direction$1 = /* @__PURE__ */ function(Direction) { + Direction[Direction["Inherit"] = 0] = "Inherit"; + Direction[Direction["LTR"] = 1] = "LTR"; + Direction[Direction["RTL"] = 2] = "RTL"; + return Direction; +}({}); +let Display$1 = /* @__PURE__ */ function(Display) { + Display[Display["Flex"] = 0] = "Flex"; + Display[Display["None"] = 1] = "None"; + return Display; +}({}); +let Edge = /* @__PURE__ */ function(Edge) { + Edge[Edge["Left"] = 0] = "Left"; + Edge[Edge["Top"] = 1] = "Top"; + Edge[Edge["Right"] = 2] = "Right"; + Edge[Edge["Bottom"] = 3] = "Bottom"; + Edge[Edge["Start"] = 4] = "Start"; + Edge[Edge["End"] = 5] = "End"; + Edge[Edge["Horizontal"] = 6] = "Horizontal"; + Edge[Edge["Vertical"] = 7] = "Vertical"; + Edge[Edge["All"] = 8] = "All"; + return Edge; +}({}); +let Errata = /* @__PURE__ */ function(Errata) { + Errata[Errata["None"] = 0] = "None"; + return Errata; +}({}); +let ExperimentalFeature = /* @__PURE__ */ function(ExperimentalFeature) { + ExperimentalFeature[ExperimentalFeature["WebFlexBasis"] = 0] = "WebFlexBasis"; + return ExperimentalFeature; +}({}); +let FlexDirection$1 = /* @__PURE__ */ function(FlexDirection) { + FlexDirection[FlexDirection["Column"] = 0] = "Column"; + FlexDirection[FlexDirection["ColumnReverse"] = 1] = "ColumnReverse"; + FlexDirection[FlexDirection["Row"] = 2] = "Row"; + FlexDirection[FlexDirection["RowReverse"] = 3] = "RowReverse"; + return FlexDirection; +}({}); +let Gutter = /* @__PURE__ */ function(Gutter) { + Gutter[Gutter["Column"] = 0] = "Column"; + Gutter[Gutter["Row"] = 1] = "Row"; + Gutter[Gutter["All"] = 2] = "All"; + return Gutter; +}({}); +let Justify = /* @__PURE__ */ function(Justify) { + Justify[Justify["FlexStart"] = 0] = "FlexStart"; + Justify[Justify["Center"] = 1] = "Center"; + Justify[Justify["FlexEnd"] = 2] = "FlexEnd"; + Justify[Justify["SpaceBetween"] = 3] = "SpaceBetween"; + Justify[Justify["SpaceAround"] = 4] = "SpaceAround"; + Justify[Justify["SpaceEvenly"] = 5] = "SpaceEvenly"; + return Justify; +}({}); +let LogLevel = /* @__PURE__ */ function(LogLevel) { + LogLevel[LogLevel["Error"] = 0] = "Error"; + LogLevel[LogLevel["Warn"] = 1] = "Warn"; + LogLevel[LogLevel["Info"] = 2] = "Info"; + LogLevel[LogLevel["Debug"] = 3] = "Debug"; + LogLevel[LogLevel["Verbose"] = 4] = "Verbose"; + LogLevel[LogLevel["Fatal"] = 5] = "Fatal"; + return LogLevel; +}({}); +let MeasureMode = /* @__PURE__ */ function(MeasureMode) { + MeasureMode[MeasureMode["Undefined"] = 0] = "Undefined"; + MeasureMode[MeasureMode["Exactly"] = 1] = "Exactly"; + MeasureMode[MeasureMode["AtMost"] = 2] = "AtMost"; + return MeasureMode; +}({}); +let NodeType = /* @__PURE__ */ function(NodeType) { + NodeType[NodeType["Default"] = 0] = "Default"; + NodeType[NodeType["Text"] = 1] = "Text"; + return NodeType; +}({}); +let Overflow = /* @__PURE__ */ function(Overflow) { + Overflow[Overflow["Visible"] = 0] = "Visible"; + Overflow[Overflow["Hidden"] = 1] = "Hidden"; + Overflow[Overflow["Scroll"] = 2] = "Scroll"; + return Overflow; +}({}); +let PositionType = /* @__PURE__ */ function(PositionType) { + PositionType[PositionType["Relative"] = 1] = "Relative"; + PositionType[PositionType["Absolute"] = 2] = "Absolute"; + return PositionType; +}({}); +let Unit = /* @__PURE__ */ function(Unit) { + Unit[Unit["Undefined"] = 0] = "Undefined"; + Unit[Unit["Point"] = 1] = "Point"; + Unit[Unit["Percent"] = 2] = "Percent"; + Unit[Unit["Auto"] = 3] = "Auto"; + return Unit; +}({}); +let Wrap = /* @__PURE__ */ function(Wrap) { + Wrap[Wrap["NoWrap"] = 0] = "NoWrap"; + Wrap[Wrap["Wrap"] = 1] = "Wrap"; + Wrap[Wrap["WrapReverse"] = 2] = "WrapReverse"; + return Wrap; +}({}); +const legacyConstants = Object.freeze({ + ALIGN_AUTO: 0, + ALIGN_FLEX_START: 1, + ALIGN_CENTER: 2, + ALIGN_FLEX_END: 3, + ALIGN_STRETCH: 4, + ALIGN_BASELINE: 5, + ALIGN_SPACE_BETWEEN: 6, + ALIGN_SPACE_AROUND: 7, + ALIGN_SPACE_EVENLY: 8, + BOX_SIZING_BORDER_BOX: 0, + BOX_SIZING_CONTENT_BOX: 1, + DIMENSION_WIDTH: 0, + DIMENSION_HEIGHT: 1, + DIRECTION_INHERIT: 0, + DIRECTION_LTR: 1, + DIRECTION_RTL: 2, + DISPLAY_FLEX: 0, + DISPLAY_NONE: 1, + EDGE_LEFT: 0, + EDGE_TOP: 1, + EDGE_RIGHT: 2, + EDGE_BOTTOM: 3, + EDGE_START: 4, + EDGE_END: 5, + EDGE_HORIZONTAL: 6, + EDGE_VERTICAL: 7, + EDGE_ALL: 8, + ERRATA_NONE: 0, + EXPERIMENTAL_FEATURE_WEB_FLEX_BASIS: 0, + FLEX_DIRECTION_COLUMN: 0, + FLEX_DIRECTION_COLUMN_REVERSE: 1, + FLEX_DIRECTION_ROW: 2, + FLEX_DIRECTION_ROW_REVERSE: 3, + GUTTER_COLUMN: 0, + GUTTER_ROW: 1, + GUTTER_ALL: 2, + JUSTIFY_FLEX_START: 0, + JUSTIFY_CENTER: 1, + JUSTIFY_FLEX_END: 2, + JUSTIFY_SPACE_BETWEEN: 3, + JUSTIFY_SPACE_AROUND: 4, + JUSTIFY_SPACE_EVENLY: 5, + LOG_LEVEL_ERROR: 0, + LOG_LEVEL_WARN: 1, + LOG_LEVEL_INFO: 2, + LOG_LEVEL_DEBUG: 3, + LOG_LEVEL_VERBOSE: 4, + LOG_LEVEL_FATAL: 5, + MEASURE_MODE_UNDEFINED: 0, + MEASURE_MODE_EXACTLY: 1, + MEASURE_MODE_AT_MOST: 2, + NODE_TYPE_DEFAULT: 0, + NODE_TYPE_TEXT: 1, + OVERFLOW_VISIBLE: 0, + OVERFLOW_HIDDEN: 1, + OVERFLOW_SCROLL: 2, + POSITION_TYPE_RELATIVE: 1, + POSITION_TYPE_ABSOLUTE: 2, + UNIT_UNDEFINED: 0, + UNIT_POINT: 1, + UNIT_PERCENT: 2, + UNIT_AUTO: 3, + WRAP_NO_WRAP: 0, + WRAP_WRAP: 1, + WRAP_WRAP_REVERSE: 2 +}); +//#endregion +//#region src/facade.ts +const initialLayout = () => ({ + left: 0, + right: 0, + top: 0, + bottom: 0, + width: NaN, + height: NaN +}); +var FacadeRuntime = class { + tree = new TaffyTree(); + nodes = /* @__PURE__ */ new Map(); + defaultConfig = new ConfigState(); + constructor() { + this.tree.disableRounding(); + } +}; +var ConfigState = class {}; +const configRecords = /* @__PURE__ */ new WeakMap(); +var YogaConfig = class { + constructor(runtime, state) { + configRecords.set(this, { + runtime, + state, + alive: true + }); + } + free() { + freeConfig(requireConfig(void 0, this)); + } +}; +function requireConfig(runtime, value) { + if (!(value instanceof YogaConfig)) throw new TypeError("Expected a Yoga Config"); + const record = configRecords.get(value); + if (record === void 0) throw new Error("Yoga Config state is unavailable"); + if (runtime !== void 0 && record.runtime !== runtime) throw new TypeError("Config belongs to another Yoga facade"); + if (!record.alive) throw new Error("Config has been freed"); + return record; +} +function freeConfig(record) { + record.alive = false; +} +function requireDimension(value, name) { + if (value === void 0 || Number.isNaN(value)) return void 0; + if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be a finite number or undefined`); + return value; +} +function requireCalculationDimension(value, name) { + if (value === void 0 || value === "auto" || Number.isNaN(value)) return AvailableSpace.MaxContent; + if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be a finite number, auto, or undefined`); + return value; +} +const nodeRecords = /* @__PURE__ */ new WeakMap(); +function styleFor(width, height, direction = Direction.Ltr) { + return { + display: Display.Flex, + direction, + flexDirection: FlexDirection.Column, + alignItems: AlignItems.FlexStart, + size: { + width, + height + } + }; +} +var YogaNode = class { + constructor(runtime, config) { + const nodeId = runtime.tree.newLeaf(styleFor(void 0, void 0)); + nodeRecords.set(this, { + runtime, + config, + nodeId, + alive: true, + width: void 0, + height: void 0, + layout: initialLayout() + }); + runtime.nodes.set(nodeId, this); + } + free() { + freeNode(requireNode(void 0, this)); + } + setWidth(value) { + const record = requireNode(void 0, this); + const width = requireDimension(value, "width"); + record.runtime.tree.setStyle(record.nodeId, styleFor(width, record.height)); + record.width = width; + } + setHeight(value) { + const record = requireNode(void 0, this); + const height = requireDimension(value, "height"); + record.runtime.tree.setStyle(record.nodeId, styleFor(record.width, height)); + record.height = height; + } + calculateLayout(width, height, direction = 1) { + const record = requireNode(void 0, this); + if (direction !== 1 && direction !== 2) throw new TypeError("calculateLayout direction must be LTR or RTL"); + const resolvedDirection = direction === 2 ? Direction.Rtl : Direction.Ltr; + record.runtime.tree.setStyle(record.nodeId, styleFor(record.width, record.height, resolvedDirection)); + record.runtime.tree.computeLayout({ + root: record.nodeId, + availableSpace: { + width: requireCalculationDimension(width, "width"), + height: requireCalculationDimension(height, "height") + } + }); + const layout = record.runtime.tree.getUnroundedLayout(record.nodeId); + record.layout = { + left: layout.location.x, + right: 0, + top: layout.location.y, + bottom: 0, + width: layout.size.width, + height: layout.size.height + }; + } + getComputedLeft() { + return requireNode(void 0, this).layout.left; + } + getComputedRight() { + return requireNode(void 0, this).layout.right; + } + getComputedTop() { + return requireNode(void 0, this).layout.top; + } + getComputedBottom() { + return requireNode(void 0, this).layout.bottom; + } + getComputedWidth() { + return requireNode(void 0, this).layout.width; + } + getComputedHeight() { + return requireNode(void 0, this).layout.height; + } + getComputedLayout() { + return { ...requireNode(void 0, this).layout }; + } +}; +function requireNode(runtime, value) { + if (!(value instanceof YogaNode)) throw new TypeError("Expected a Yoga Node"); + const record = nodeRecords.get(value); + if (record === void 0) throw new Error("Yoga Node state is unavailable"); + if (runtime !== void 0 && record.runtime !== runtime) throw new TypeError("Node belongs to another Yoga facade"); + if (!record.alive) throw new Error("Node has been freed"); + return record; +} +function freeNode(record) { + record.runtime.tree.remove(record.nodeId); + record.runtime.nodes.delete(record.nodeId); + record.alive = false; +} +function createFactories(runtime) { + return { + Config: Object.freeze({ + create: () => new YogaConfig(runtime, new ConfigState()), + destroy: (config) => freeConfig(requireConfig(runtime, config)) + }), + Node: Object.freeze({ + create: (config) => new YogaNode(runtime, config === void 0 ? runtime.defaultConfig : requireConfig(runtime, config).state), + createDefault: () => new YogaNode(runtime, runtime.defaultConfig), + createWithConfig: (config) => new YogaNode(runtime, requireConfig(runtime, config).state), + destroy: (node) => freeNode(requireNode(runtime, node)) + }) + }; +} +function createYoga() { + const runtime = new FacadeRuntime(); + return Object.freeze({ + ...createFactories(runtime), + ...legacyConstants + }); +} +//#endregion +export { PositionType as _, Direction$1 as a, Errata as c, Gutter as d, Justify as f, Overflow as g, NodeType as h, Dimension as i, ExperimentalFeature as l, MeasureMode as m, Align as n, Display$1 as o, LogLevel as p, BoxSizing as r, Edge as s, createYoga as t, FlexDirection$1 as u, Unit as v, Wrap as y }; diff --git a/packages/taffyjs-yoga/dist/index.d.ts b/packages/taffyjs-yoga/dist/index.d.ts new file mode 100644 index 0000000..f331d0e --- /dev/null +++ b/packages/taffyjs-yoga/dist/index.d.ts @@ -0,0 +1,74 @@ +import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, n as ConfigFactory, o as NodeFactory, p as Edge, r as DirtiedFunction, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-3Q6Btqzs.js"; +//#region src/index.d.ts +declare const Yoga: Readonly<{ + Config: ConfigFactory; + Node: NodeFactory; +} & Readonly<{ + readonly ALIGN_AUTO: Align.Auto; + readonly ALIGN_FLEX_START: Align.FlexStart; + readonly ALIGN_CENTER: Align.Center; + readonly ALIGN_FLEX_END: Align.FlexEnd; + readonly ALIGN_STRETCH: Align.Stretch; + readonly ALIGN_BASELINE: Align.Baseline; + readonly ALIGN_SPACE_BETWEEN: Align.SpaceBetween; + readonly ALIGN_SPACE_AROUND: Align.SpaceAround; + readonly ALIGN_SPACE_EVENLY: Align.SpaceEvenly; + readonly BOX_SIZING_BORDER_BOX: BoxSizing.BorderBox; + readonly BOX_SIZING_CONTENT_BOX: BoxSizing.ContentBox; + readonly DIMENSION_WIDTH: Dimension.Width; + readonly DIMENSION_HEIGHT: Dimension.Height; + readonly DIRECTION_INHERIT: Direction.Inherit; + readonly DIRECTION_LTR: Direction.LTR; + readonly DIRECTION_RTL: Direction.RTL; + readonly DISPLAY_FLEX: Display.Flex; + readonly DISPLAY_NONE: Display.None; + readonly EDGE_LEFT: Edge.Left; + readonly EDGE_TOP: Edge.Top; + readonly EDGE_RIGHT: Edge.Right; + readonly EDGE_BOTTOM: Edge.Bottom; + readonly EDGE_START: Edge.Start; + readonly EDGE_END: Edge.End; + readonly EDGE_HORIZONTAL: Edge.Horizontal; + readonly EDGE_VERTICAL: Edge.Vertical; + readonly EDGE_ALL: Edge.All; + readonly ERRATA_NONE: Errata; + readonly EXPERIMENTAL_FEATURE_WEB_FLEX_BASIS: ExperimentalFeature; + readonly FLEX_DIRECTION_COLUMN: FlexDirection.Column; + readonly FLEX_DIRECTION_COLUMN_REVERSE: FlexDirection.ColumnReverse; + readonly FLEX_DIRECTION_ROW: FlexDirection.Row; + readonly FLEX_DIRECTION_ROW_REVERSE: FlexDirection.RowReverse; + readonly GUTTER_COLUMN: Gutter.Column; + readonly GUTTER_ROW: Gutter.Row; + readonly GUTTER_ALL: Gutter.All; + readonly JUSTIFY_FLEX_START: Justify.FlexStart; + readonly JUSTIFY_CENTER: Justify.Center; + readonly JUSTIFY_FLEX_END: Justify.FlexEnd; + readonly JUSTIFY_SPACE_BETWEEN: Justify.SpaceBetween; + readonly JUSTIFY_SPACE_AROUND: Justify.SpaceAround; + readonly JUSTIFY_SPACE_EVENLY: Justify.SpaceEvenly; + readonly LOG_LEVEL_ERROR: LogLevel.Error; + readonly LOG_LEVEL_WARN: LogLevel.Warn; + readonly LOG_LEVEL_INFO: LogLevel.Info; + readonly LOG_LEVEL_DEBUG: LogLevel.Debug; + readonly LOG_LEVEL_VERBOSE: LogLevel.Verbose; + readonly LOG_LEVEL_FATAL: LogLevel.Fatal; + readonly MEASURE_MODE_UNDEFINED: MeasureMode.Undefined; + readonly MEASURE_MODE_EXACTLY: MeasureMode.Exactly; + readonly MEASURE_MODE_AT_MOST: MeasureMode.AtMost; + readonly NODE_TYPE_DEFAULT: NodeType.Default; + readonly NODE_TYPE_TEXT: NodeType.Text; + readonly OVERFLOW_VISIBLE: Overflow.Visible; + readonly OVERFLOW_HIDDEN: Overflow.Hidden; + readonly OVERFLOW_SCROLL: Overflow.Scroll; + readonly POSITION_TYPE_RELATIVE: PositionType.Relative; + readonly POSITION_TYPE_ABSOLUTE: PositionType.Absolute; + readonly UNIT_UNDEFINED: Unit.Undefined; + readonly UNIT_POINT: Unit.Point; + readonly UNIT_PERCENT: Unit.Percent; + readonly UNIT_AUTO: Unit.Auto; + readonly WRAP_NO_WRAP: Wrap.NoWrap; + readonly WRAP_WRAP: Wrap.Wrap; + readonly WRAP_WRAP_REVERSE: Wrap.WrapReverse; +}>>; +//#endregion +export { Align, BoxSizing, type Config, Dimension, Direction, type DirtiedFunction, Display, Edge, Errata, ExperimentalFeature, FlexDirection, Gutter, Justify, LogLevel, type MeasureFunction, MeasureMode, type Node, NodeType, Overflow, PositionType, Unit, Wrap, Yoga as default }; \ No newline at end of file diff --git a/packages/taffyjs-yoga/dist/index.js b/packages/taffyjs-yoga/dist/index.js new file mode 100644 index 0000000..48b60f9 --- /dev/null +++ b/packages/taffyjs-yoga/dist/index.js @@ -0,0 +1,5 @@ +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-Baj8muUk.js"; +//#region src/index.ts +const Yoga = createYoga(); +//#endregion +export { Align, BoxSizing, Dimension, Direction, Display, Edge, Errata, ExperimentalFeature, FlexDirection, Gutter, Justify, LogLevel, MeasureMode, NodeType, Overflow, PositionType, Unit, Wrap, Yoga as default }; diff --git a/packages/taffyjs-yoga/dist/load.d.ts b/packages/taffyjs-yoga/dist/load.d.ts new file mode 100644 index 0000000..63d2351 --- /dev/null +++ b/packages/taffyjs-yoga/dist/load.d.ts @@ -0,0 +1,5 @@ +import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, p as Edge, r as DirtiedFunction, s as Yoga, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-3Q6Btqzs.js"; +//#region src/load.d.ts +declare function loadYoga(): Promise; +//#endregion +export { Align, BoxSizing, type Config, Dimension, Direction, type DirtiedFunction, Display, Edge, Errata, ExperimentalFeature, FlexDirection, Gutter, Justify, LogLevel, type MeasureFunction, MeasureMode, type Node, NodeType, Overflow, PositionType, Unit, Wrap, type Yoga, loadYoga }; \ No newline at end of file diff --git a/packages/taffyjs-yoga/dist/load.js b/packages/taffyjs-yoga/dist/load.js new file mode 100644 index 0000000..efe2169 --- /dev/null +++ b/packages/taffyjs-yoga/dist/load.js @@ -0,0 +1,7 @@ +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-Baj8muUk.js"; +//#region src/load.ts +async function loadYoga() { + return createYoga(); +} +//#endregion +export { Align, BoxSizing, Dimension, Direction, Display, Edge, Errata, ExperimentalFeature, FlexDirection, Gutter, Justify, LogLevel, MeasureMode, NodeType, Overflow, PositionType, Unit, Wrap, loadYoga }; diff --git a/packages/taffyjs-yoga/dist/types-3Q6Btqzs.d.ts b/packages/taffyjs-yoga/dist/types-3Q6Btqzs.d.ts new file mode 100644 index 0000000..362251f --- /dev/null +++ b/packages/taffyjs-yoga/dist/types-3Q6Btqzs.d.ts @@ -0,0 +1,218 @@ +//#region src/enums.d.ts +declare enum Align { + Auto = 0, + FlexStart = 1, + Center = 2, + FlexEnd = 3, + Stretch = 4, + Baseline = 5, + SpaceBetween = 6, + SpaceAround = 7, + SpaceEvenly = 8 +} +declare enum BoxSizing { + BorderBox = 0, + ContentBox = 1 +} +declare enum Dimension { + Width = 0, + Height = 1 +} +declare enum Direction { + Inherit = 0, + LTR = 1, + RTL = 2 +} +declare enum Display { + Flex = 0, + None = 1 +} +declare enum Edge { + Left = 0, + Top = 1, + Right = 2, + Bottom = 3, + Start = 4, + End = 5, + Horizontal = 6, + Vertical = 7, + All = 8 +} +declare enum Errata { + None = 0 +} +declare enum ExperimentalFeature { + WebFlexBasis = 0 +} +declare enum FlexDirection { + Column = 0, + ColumnReverse = 1, + Row = 2, + RowReverse = 3 +} +declare enum Gutter { + Column = 0, + Row = 1, + All = 2 +} +declare enum Justify { + FlexStart = 0, + Center = 1, + FlexEnd = 2, + SpaceBetween = 3, + SpaceAround = 4, + SpaceEvenly = 5 +} +declare enum LogLevel { + Error = 0, + Warn = 1, + Info = 2, + Debug = 3, + Verbose = 4, + Fatal = 5 +} +declare enum MeasureMode { + Undefined = 0, + Exactly = 1, + AtMost = 2 +} +declare enum NodeType { + Default = 0, + Text = 1 +} +declare enum Overflow { + Visible = 0, + Hidden = 1, + Scroll = 2 +} +declare enum PositionType { + Relative = 1, + Absolute = 2 +} +declare enum Unit { + Undefined = 0, + Point = 1, + Percent = 2, + Auto = 3 +} +declare enum Wrap { + NoWrap = 0, + Wrap = 1, + WrapReverse = 2 +} +declare const legacyConstants: Readonly<{ + readonly ALIGN_AUTO: Align.Auto; + readonly ALIGN_FLEX_START: Align.FlexStart; + readonly ALIGN_CENTER: Align.Center; + readonly ALIGN_FLEX_END: Align.FlexEnd; + readonly ALIGN_STRETCH: Align.Stretch; + readonly ALIGN_BASELINE: Align.Baseline; + readonly ALIGN_SPACE_BETWEEN: Align.SpaceBetween; + readonly ALIGN_SPACE_AROUND: Align.SpaceAround; + readonly ALIGN_SPACE_EVENLY: Align.SpaceEvenly; + readonly BOX_SIZING_BORDER_BOX: BoxSizing.BorderBox; + readonly BOX_SIZING_CONTENT_BOX: BoxSizing.ContentBox; + readonly DIMENSION_WIDTH: Dimension.Width; + readonly DIMENSION_HEIGHT: Dimension.Height; + readonly DIRECTION_INHERIT: Direction.Inherit; + readonly DIRECTION_LTR: Direction.LTR; + readonly DIRECTION_RTL: Direction.RTL; + readonly DISPLAY_FLEX: Display.Flex; + readonly DISPLAY_NONE: Display.None; + readonly EDGE_LEFT: Edge.Left; + readonly EDGE_TOP: Edge.Top; + readonly EDGE_RIGHT: Edge.Right; + readonly EDGE_BOTTOM: Edge.Bottom; + readonly EDGE_START: Edge.Start; + readonly EDGE_END: Edge.End; + readonly EDGE_HORIZONTAL: Edge.Horizontal; + readonly EDGE_VERTICAL: Edge.Vertical; + readonly EDGE_ALL: Edge.All; + readonly ERRATA_NONE: Errata; + readonly EXPERIMENTAL_FEATURE_WEB_FLEX_BASIS: ExperimentalFeature; + readonly FLEX_DIRECTION_COLUMN: FlexDirection.Column; + readonly FLEX_DIRECTION_COLUMN_REVERSE: FlexDirection.ColumnReverse; + readonly FLEX_DIRECTION_ROW: FlexDirection.Row; + readonly FLEX_DIRECTION_ROW_REVERSE: FlexDirection.RowReverse; + readonly GUTTER_COLUMN: Gutter.Column; + readonly GUTTER_ROW: Gutter.Row; + readonly GUTTER_ALL: Gutter.All; + readonly JUSTIFY_FLEX_START: Justify.FlexStart; + readonly JUSTIFY_CENTER: Justify.Center; + readonly JUSTIFY_FLEX_END: Justify.FlexEnd; + readonly JUSTIFY_SPACE_BETWEEN: Justify.SpaceBetween; + readonly JUSTIFY_SPACE_AROUND: Justify.SpaceAround; + readonly JUSTIFY_SPACE_EVENLY: Justify.SpaceEvenly; + readonly LOG_LEVEL_ERROR: LogLevel.Error; + readonly LOG_LEVEL_WARN: LogLevel.Warn; + readonly LOG_LEVEL_INFO: LogLevel.Info; + readonly LOG_LEVEL_DEBUG: LogLevel.Debug; + readonly LOG_LEVEL_VERBOSE: LogLevel.Verbose; + readonly LOG_LEVEL_FATAL: LogLevel.Fatal; + readonly MEASURE_MODE_UNDEFINED: MeasureMode.Undefined; + readonly MEASURE_MODE_EXACTLY: MeasureMode.Exactly; + readonly MEASURE_MODE_AT_MOST: MeasureMode.AtMost; + readonly NODE_TYPE_DEFAULT: NodeType.Default; + readonly NODE_TYPE_TEXT: NodeType.Text; + readonly OVERFLOW_VISIBLE: Overflow.Visible; + readonly OVERFLOW_HIDDEN: Overflow.Hidden; + readonly OVERFLOW_SCROLL: Overflow.Scroll; + readonly POSITION_TYPE_RELATIVE: PositionType.Relative; + readonly POSITION_TYPE_ABSOLUTE: PositionType.Absolute; + readonly UNIT_UNDEFINED: Unit.Undefined; + readonly UNIT_POINT: Unit.Point; + readonly UNIT_PERCENT: Unit.Percent; + readonly UNIT_AUTO: Unit.Auto; + readonly WRAP_NO_WRAP: Wrap.NoWrap; + readonly WRAP_WRAP: Wrap.Wrap; + readonly WRAP_WRAP_REVERSE: Wrap.WrapReverse; +}>; +//#endregion +//#region src/types.d.ts +interface Layout { + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; +} +interface Size { + width: number; + height: number; +} +type DirtiedFunction = (node: Node) => void; +type MeasureFunction = (width: number, widthMode: MeasureMode, height: number, heightMode: MeasureMode) => Size; +type LayoutDimension = number | "auto" | undefined; +interface Config { + free(): void; +} +interface Node { + free(): void; + setWidth(value: number | string | undefined): void; + setHeight(value: number | string | undefined): void; + calculateLayout(width: LayoutDimension, height: LayoutDimension, direction?: Direction): void; + getComputedLeft(): number; + getComputedRight(): number; + getComputedTop(): number; + getComputedBottom(): number; + getComputedWidth(): number; + getComputedHeight(): number; + getComputedLayout(): Layout; +} +interface ConfigFactory { + create(): Config; + destroy(config: Config): void; +} +interface NodeFactory { + create(config?: Config): Node; + createDefault(): Node; + createWithConfig(config: Config): Node; + destroy(node: Node): void; +} +type Yoga = Readonly<{ + Config: ConfigFactory; + Node: NodeFactory; +} & typeof legacyConstants>; +//#endregion +export { PositionType as C, Overflow as S, Wrap as T, Gutter as _, Node as a, MeasureMode as b, Align as c, Direction as d, Display as f, FlexDirection as g, ExperimentalFeature as h, MeasureFunction as i, BoxSizing as l, Errata as m, ConfigFactory as n, NodeFactory as o, Edge as p, DirtiedFunction as r, Yoga as s, Config as t, Dimension as u, Justify as v, Unit as w, NodeType as x, LogLevel as y }; \ No newline at end of file diff --git a/packages/taffyjs-yoga/package.json b/packages/taffyjs-yoga/package.json new file mode 100644 index 0000000..fa9ed65 --- /dev/null +++ b/packages/taffyjs-yoga/package.json @@ -0,0 +1,47 @@ +{ + "name": "@taffyjs/yoga", + "version": "0.0.0", + "private": true, + "description": "Yoga-compatible Node.js facade backed by TaffyJS.", + "license": "UNLICENSED", + "repository": { + "type": "git", + "url": "git+https://github.com/hyfdev/taffyjs.git", + "directory": "packages/taffyjs-yoga" + }, + "files": [ + "README.md", + "COMPATIBILITY.md", + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./load": { + "types": "./dist/load.d.ts", + "default": "./dist/load.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "vp pack", + "check": "vp check" + }, + "dependencies": { + "@taffyjs/node": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/packages/taffyjs-yoga/src/enums.ts b/packages/taffyjs-yoga/src/enums.ts new file mode 100644 index 0000000..c5bb580 --- /dev/null +++ b/packages/taffyjs-yoga/src/enums.ts @@ -0,0 +1,186 @@ +export enum Align { + Auto = 0, + FlexStart = 1, + Center = 2, + FlexEnd = 3, + Stretch = 4, + Baseline = 5, + SpaceBetween = 6, + SpaceAround = 7, + SpaceEvenly = 8, +} + +export enum BoxSizing { + BorderBox = 0, + ContentBox = 1, +} + +export enum Dimension { + Width = 0, + Height = 1, +} + +export enum Direction { + Inherit = 0, + LTR = 1, + RTL = 2, +} + +export enum Display { + Flex = 0, + None = 1, +} + +export enum Edge { + Left = 0, + Top = 1, + Right = 2, + Bottom = 3, + Start = 4, + End = 5, + Horizontal = 6, + Vertical = 7, + All = 8, +} + +export enum Errata { + None = 0, +} + +export enum ExperimentalFeature { + WebFlexBasis = 0, +} + +export enum FlexDirection { + Column = 0, + ColumnReverse = 1, + Row = 2, + RowReverse = 3, +} + +export enum Gutter { + Column = 0, + Row = 1, + All = 2, +} + +export enum Justify { + FlexStart = 0, + Center = 1, + FlexEnd = 2, + SpaceBetween = 3, + SpaceAround = 4, + SpaceEvenly = 5, +} + +export enum LogLevel { + Error = 0, + Warn = 1, + Info = 2, + Debug = 3, + Verbose = 4, + Fatal = 5, +} + +export enum MeasureMode { + Undefined = 0, + Exactly = 1, + AtMost = 2, +} + +export enum NodeType { + Default = 0, + Text = 1, +} + +export enum Overflow { + Visible = 0, + Hidden = 1, + Scroll = 2, +} + +export enum PositionType { + Relative = 1, + Absolute = 2, +} + +export enum Unit { + Undefined = 0, + Point = 1, + Percent = 2, + Auto = 3, +} + +export enum Wrap { + NoWrap = 0, + Wrap = 1, + WrapReverse = 2, +} + +export const legacyConstants = Object.freeze({ + ALIGN_AUTO: Align.Auto, + ALIGN_FLEX_START: Align.FlexStart, + ALIGN_CENTER: Align.Center, + ALIGN_FLEX_END: Align.FlexEnd, + ALIGN_STRETCH: Align.Stretch, + ALIGN_BASELINE: Align.Baseline, + ALIGN_SPACE_BETWEEN: Align.SpaceBetween, + ALIGN_SPACE_AROUND: Align.SpaceAround, + ALIGN_SPACE_EVENLY: Align.SpaceEvenly, + BOX_SIZING_BORDER_BOX: BoxSizing.BorderBox, + BOX_SIZING_CONTENT_BOX: BoxSizing.ContentBox, + DIMENSION_WIDTH: Dimension.Width, + DIMENSION_HEIGHT: Dimension.Height, + DIRECTION_INHERIT: Direction.Inherit, + DIRECTION_LTR: Direction.LTR, + DIRECTION_RTL: Direction.RTL, + DISPLAY_FLEX: Display.Flex, + DISPLAY_NONE: Display.None, + EDGE_LEFT: Edge.Left, + EDGE_TOP: Edge.Top, + EDGE_RIGHT: Edge.Right, + EDGE_BOTTOM: Edge.Bottom, + EDGE_START: Edge.Start, + EDGE_END: Edge.End, + EDGE_HORIZONTAL: Edge.Horizontal, + EDGE_VERTICAL: Edge.Vertical, + EDGE_ALL: Edge.All, + ERRATA_NONE: Errata.None, + EXPERIMENTAL_FEATURE_WEB_FLEX_BASIS: ExperimentalFeature.WebFlexBasis, + FLEX_DIRECTION_COLUMN: FlexDirection.Column, + FLEX_DIRECTION_COLUMN_REVERSE: FlexDirection.ColumnReverse, + FLEX_DIRECTION_ROW: FlexDirection.Row, + FLEX_DIRECTION_ROW_REVERSE: FlexDirection.RowReverse, + GUTTER_COLUMN: Gutter.Column, + GUTTER_ROW: Gutter.Row, + GUTTER_ALL: Gutter.All, + JUSTIFY_FLEX_START: Justify.FlexStart, + JUSTIFY_CENTER: Justify.Center, + JUSTIFY_FLEX_END: Justify.FlexEnd, + JUSTIFY_SPACE_BETWEEN: Justify.SpaceBetween, + JUSTIFY_SPACE_AROUND: Justify.SpaceAround, + JUSTIFY_SPACE_EVENLY: Justify.SpaceEvenly, + LOG_LEVEL_ERROR: LogLevel.Error, + LOG_LEVEL_WARN: LogLevel.Warn, + LOG_LEVEL_INFO: LogLevel.Info, + LOG_LEVEL_DEBUG: LogLevel.Debug, + LOG_LEVEL_VERBOSE: LogLevel.Verbose, + LOG_LEVEL_FATAL: LogLevel.Fatal, + MEASURE_MODE_UNDEFINED: MeasureMode.Undefined, + MEASURE_MODE_EXACTLY: MeasureMode.Exactly, + MEASURE_MODE_AT_MOST: MeasureMode.AtMost, + NODE_TYPE_DEFAULT: NodeType.Default, + NODE_TYPE_TEXT: NodeType.Text, + OVERFLOW_VISIBLE: Overflow.Visible, + OVERFLOW_HIDDEN: Overflow.Hidden, + OVERFLOW_SCROLL: Overflow.Scroll, + POSITION_TYPE_RELATIVE: PositionType.Relative, + POSITION_TYPE_ABSOLUTE: PositionType.Absolute, + UNIT_UNDEFINED: Unit.Undefined, + UNIT_POINT: Unit.Point, + UNIT_PERCENT: Unit.Percent, + UNIT_AUTO: Unit.Auto, + WRAP_NO_WRAP: Wrap.NoWrap, + WRAP_WRAP: Wrap.Wrap, + WRAP_WRAP_REVERSE: Wrap.WrapReverse, +} as const); diff --git a/packages/taffyjs-yoga/src/facade.ts b/packages/taffyjs-yoga/src/facade.ts new file mode 100644 index 0000000..36e8e25 --- /dev/null +++ b/packages/taffyjs-yoga/src/facade.ts @@ -0,0 +1,257 @@ +import { + AlignItems as TaffyAlignItems, + AvailableSpace, + Direction as TaffyDirection, + Display as TaffyDisplay, + FlexDirection as TaffyFlexDirection, + TaffyTree, + type Direction as TaffyDirectionValue, + type NodeId, + type StyleInput, +} from "@taffyjs/node"; +import { Direction, legacyConstants } from "./enums.js"; +import type { + Config, + ConfigFactory, + Layout, + LayoutDimension, + Node, + NodeFactory, + Yoga, +} from "./types.js"; + +const initialLayout = (): Layout => ({ + left: 0, + right: 0, + top: 0, + bottom: 0, + width: Number.NaN, + height: Number.NaN, +}); + +class FacadeRuntime { + readonly tree = new TaffyTree(); + readonly nodes = new Map(); + readonly defaultConfig = new ConfigState(); + + constructor() { + this.tree.disableRounding(); + } +} + +class ConfigState {} + +interface ConfigRecord { + readonly runtime: FacadeRuntime; + readonly state: ConfigState; + alive: boolean; +} + +const configRecords = new WeakMap(); + +class YogaConfig implements Config { + constructor(runtime: FacadeRuntime, state: ConfigState) { + configRecords.set(this, { runtime, state, alive: true }); + } + + free(): void { + freeConfig(requireConfig(undefined, this)); + } +} + +function requireConfig(runtime: FacadeRuntime | undefined, value: Config): ConfigRecord { + if (!(value instanceof YogaConfig)) throw new TypeError("Expected a Yoga Config"); + const record = configRecords.get(value); + if (record === undefined) throw new Error("Yoga Config state is unavailable"); + if (runtime !== undefined && record.runtime !== runtime) { + throw new TypeError("Config belongs to another Yoga facade"); + } + if (!record.alive) throw new Error("Config has been freed"); + return record; +} + +function freeConfig(record: ConfigRecord): void { + record.alive = false; +} + +function requireDimension(value: number | string | undefined, name: string): number | undefined { + if (value === undefined || Number.isNaN(value)) return undefined; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new TypeError(`${name} must be a finite number or undefined`); + } + return value; +} + +function requireCalculationDimension(value: LayoutDimension, name: string) { + if (value === undefined || value === "auto" || Number.isNaN(value)) { + return AvailableSpace.MaxContent; + } + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new TypeError(`${name} must be a finite number, auto, or undefined`); + } + return value; +} + +interface NodeRecord { + readonly runtime: FacadeRuntime; + readonly config: ConfigState; + readonly nodeId: NodeId; + alive: boolean; + width: number | undefined; + height: number | undefined; + layout: Layout; +} + +const nodeRecords = new WeakMap(); + +function styleFor( + width: number | undefined, + height: number | undefined, + direction: TaffyDirectionValue = TaffyDirection.Ltr, +): StyleInput { + return { + display: TaffyDisplay.Flex, + direction, + flexDirection: TaffyFlexDirection.Column, + alignItems: TaffyAlignItems.FlexStart, + size: { width, height }, + }; +} + +class YogaNode implements Node { + constructor(runtime: FacadeRuntime, config: ConfigState) { + const nodeId = runtime.tree.newLeaf(styleFor(undefined, undefined)); + nodeRecords.set(this, { + runtime, + config, + nodeId, + alive: true, + width: undefined, + height: undefined, + layout: initialLayout(), + }); + runtime.nodes.set(nodeId, this); + } + + free(): void { + freeNode(requireNode(undefined, this)); + } + + setWidth(value: number | string | undefined): void { + const record = requireNode(undefined, this); + const width = requireDimension(value, "width"); + record.runtime.tree.setStyle(record.nodeId, styleFor(width, record.height)); + record.width = width; + } + + setHeight(value: number | string | undefined): void { + const record = requireNode(undefined, this); + const height = requireDimension(value, "height"); + record.runtime.tree.setStyle(record.nodeId, styleFor(record.width, height)); + record.height = height; + } + + calculateLayout( + width?: LayoutDimension, + height?: LayoutDimension, + direction: Direction = Direction.LTR, + ): void { + const record = requireNode(undefined, this); + if (direction !== Direction.LTR && direction !== Direction.RTL) { + throw new TypeError("calculateLayout direction must be LTR or RTL"); + } + const resolvedDirection = direction === Direction.RTL ? TaffyDirection.Rtl : TaffyDirection.Ltr; + record.runtime.tree.setStyle( + record.nodeId, + styleFor(record.width, record.height, resolvedDirection), + ); + record.runtime.tree.computeLayout({ + root: record.nodeId, + availableSpace: { + width: requireCalculationDimension(width, "width"), + height: requireCalculationDimension(height, "height"), + }, + }); + const layout = record.runtime.tree.getUnroundedLayout(record.nodeId); + record.layout = { + left: layout.location.x, + right: 0, + top: layout.location.y, + bottom: 0, + width: layout.size.width, + height: layout.size.height, + }; + } + + getComputedLeft(): number { + return requireNode(undefined, this).layout.left; + } + + getComputedRight(): number { + return requireNode(undefined, this).layout.right; + } + + getComputedTop(): number { + return requireNode(undefined, this).layout.top; + } + + getComputedBottom(): number { + return requireNode(undefined, this).layout.bottom; + } + + getComputedWidth(): number { + return requireNode(undefined, this).layout.width; + } + + getComputedHeight(): number { + return requireNode(undefined, this).layout.height; + } + + getComputedLayout(): Layout { + return { ...requireNode(undefined, this).layout }; + } +} + +function requireNode(runtime: FacadeRuntime | undefined, value: Node): NodeRecord { + if (!(value instanceof YogaNode)) throw new TypeError("Expected a Yoga Node"); + const record = nodeRecords.get(value); + if (record === undefined) throw new Error("Yoga Node state is unavailable"); + if (runtime !== undefined && record.runtime !== runtime) { + throw new TypeError("Node belongs to another Yoga facade"); + } + if (!record.alive) throw new Error("Node has been freed"); + return record; +} + +function freeNode(record: NodeRecord): void { + record.runtime.tree.remove(record.nodeId); + record.runtime.nodes.delete(record.nodeId); + record.alive = false; +} + +function createFactories(runtime: FacadeRuntime): { + Config: ConfigFactory; + Node: NodeFactory; +} { + const Config: ConfigFactory = Object.freeze({ + create: () => new YogaConfig(runtime, new ConfigState()), + destroy: (config: Config) => freeConfig(requireConfig(runtime, config)), + }); + const Node: NodeFactory = Object.freeze({ + create: (config?: Config) => + new YogaNode( + runtime, + config === undefined ? runtime.defaultConfig : requireConfig(runtime, config).state, + ), + createDefault: () => new YogaNode(runtime, runtime.defaultConfig), + createWithConfig: (config: Config) => + new YogaNode(runtime, requireConfig(runtime, config).state), + destroy: (node: Node) => freeNode(requireNode(runtime, node)), + }); + return { Config, Node }; +} + +export function createYoga(): Yoga { + const runtime = new FacadeRuntime(); + return Object.freeze({ ...createFactories(runtime), ...legacyConstants }); +} diff --git a/packages/taffyjs-yoga/src/index.ts b/packages/taffyjs-yoga/src/index.ts new file mode 100644 index 0000000..06fad92 --- /dev/null +++ b/packages/taffyjs-yoga/src/index.ts @@ -0,0 +1,27 @@ +export { + Align, + BoxSizing, + Dimension, + Direction, + Display, + Edge, + Errata, + ExperimentalFeature, + FlexDirection, + Gutter, + Justify, + LogLevel, + MeasureMode, + NodeType, + Overflow, + PositionType, + Unit, + Wrap, +} from "./enums.js"; +export type { Config, DirtiedFunction, MeasureFunction, Node } from "./types.js"; + +import { createYoga } from "./facade.js"; + +const Yoga = createYoga(); + +export default Yoga; diff --git a/packages/taffyjs-yoga/src/load.ts b/packages/taffyjs-yoga/src/load.ts new file mode 100644 index 0000000..78ec63b --- /dev/null +++ b/packages/taffyjs-yoga/src/load.ts @@ -0,0 +1,28 @@ +export { + Align, + BoxSizing, + Dimension, + Direction, + Display, + Edge, + Errata, + ExperimentalFeature, + FlexDirection, + Gutter, + Justify, + LogLevel, + MeasureMode, + NodeType, + Overflow, + PositionType, + Unit, + Wrap, +} from "./enums.js"; +export type { Config, DirtiedFunction, MeasureFunction, Node, Yoga } from "./types.js"; + +import { createYoga } from "./facade.js"; +import type { Yoga } from "./types.js"; + +export async function loadYoga(): Promise { + return createYoga(); +} diff --git a/packages/taffyjs-yoga/src/types.ts b/packages/taffyjs-yoga/src/types.ts new file mode 100644 index 0000000..5aaadf2 --- /dev/null +++ b/packages/taffyjs-yoga/src/types.ts @@ -0,0 +1,69 @@ +import type { Direction, MeasureMode, Unit } from "./enums.js"; +import type { legacyConstants } from "./enums.js"; + +export interface Layout { + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; +} + +export interface Size { + width: number; + height: number; +} + +export interface Value { + unit: Unit; + value: number; +} + +export type DirtiedFunction = (node: Node) => void; + +export type MeasureFunction = ( + width: number, + widthMode: MeasureMode, + height: number, + heightMode: MeasureMode, +) => Size; + +export type LayoutDimension = number | "auto" | undefined; + +export interface Config { + free(): void; +} + +export interface Node { + free(): void; + setWidth(value: number | string | undefined): void; + setHeight(value: number | string | undefined): void; + calculateLayout(width: LayoutDimension, height: LayoutDimension, direction?: Direction): void; + getComputedLeft(): number; + getComputedRight(): number; + getComputedTop(): number; + getComputedBottom(): number; + getComputedWidth(): number; + getComputedHeight(): number; + getComputedLayout(): Layout; +} + +export interface ConfigFactory { + create(): Config; + destroy(config: Config): void; +} + +export interface NodeFactory { + create(config?: Config): Node; + createDefault(): Node; + createWithConfig(config: Config): Node; + destroy(node: Node): void; +} + +export type Yoga = Readonly< + { + Config: ConfigFactory; + Node: NodeFactory; + } & typeof legacyConstants +>; diff --git a/packages/taffyjs-yoga/tsconfig.json b/packages/taffyjs-yoga/tsconfig.json new file mode 100644 index 0000000..f9d167b --- /dev/null +++ b/packages/taffyjs-yoga/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["es2023"], + "moduleDetection": "force", + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"], + "strict": true, + "noUnusedLocals": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/packages/taffyjs-yoga/vite.config.ts b/packages/taffyjs-yoga/vite.config.ts new file mode 100644 index 0000000..b8275ed --- /dev/null +++ b/packages/taffyjs-yoga/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + pack: { + entry: ["src/index.ts", "src/load.ts"], + format: "esm", + outDir: "dist", + clean: true, + dts: true, + platform: "node", + target: "node22.18", + fixedExtension: false, + }, + fmt: { + ignorePatterns: ["dist"], + }, + lint: { + ignorePatterns: ["dist"], + options: { + typeAware: true, + typeCheck: true, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09814db..775d539 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -295,6 +295,13 @@ importers: '@napi-rs/cli': specifier: 'catalog:' version: 3.8.2(@emnapi/runtime@2.0.0-alpha.3)(@types/node@22.18.0) + + packages/taffyjs-yoga: + dependencies: + '@taffyjs/node': + specifier: workspace:* + version: link:../taffyjs-node + devDependencies: '@types/node': specifier: 'catalog:' version: 22.18.0 @@ -355,6 +362,28 @@ importers: specifier: 'catalog:' version: 0.2.8(@types/node@22.18.0)(@vitest/browser-playwright@4.1.10)(@voidzero-dev/vite-plus-core@0.2.8(@types/node@22.18.0)(typescript@7.0.2))(typescript@7.0.2) + tests/taffyjs-yoga: + dependencies: + yoga-layout: + specifier: workspace:@taffyjs/yoga@* + version: link:../../packages/taffyjs-yoga + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.18.0 + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: npm:@voidzero-dev/vite-plus-core@0.2.8 + version: '@voidzero-dev/vite-plus-core@0.2.8(@types/node@22.18.0)(typescript@7.0.2)' + vite-plus: + specifier: 'catalog:' + version: 0.2.8(@types/node@22.18.0)(@voidzero-dev/vite-plus-core@0.2.8(@types/node@22.18.0)(typescript@7.0.2))(typescript@7.0.2) + yoga-layout-oracle: + specifier: npm:yoga-layout@3.2.1 + version: yoga-layout@3.2.1 + packages: '@babel/code-frame@7.29.7': @@ -2581,6 +2610,9 @@ packages: utf-8-validate: optional: true + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + yuku-codegen@0.5.48: resolution: {integrity: sha512-p7HxD5Xl4jzDzqMrGePAOeSHmRY4g58h4HuGq15weQFPxuPWd/W6e7nqp/+Lea6JfpOdBwJOAyXFqIZ/J9Zfnw==} @@ -4553,6 +4585,8 @@ snapshots: ws@8.21.3: {} + yoga-layout@3.2.1: {} + yuku-codegen@0.5.48: dependencies: '@yuku-toolchain/types': 0.5.43 diff --git a/tests/taffyjs-yoga/exports.test.mts b/tests/taffyjs-yoga/exports.test.mts new file mode 100644 index 0000000..e589f2c --- /dev/null +++ b/tests/taffyjs-yoga/exports.test.mts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import Yoga, * as implementation from "yoga-layout"; +import * as implementationLoad from "yoga-layout/load"; +import OracleYoga, * as oracle from "yoga-layout-oracle"; +import * as oracleLoad from "yoga-layout-oracle/load"; +import { test } from "vite-plus/test"; + +const enumNames = [ + "Align", + "BoxSizing", + "Dimension", + "Direction", + "Display", + "Edge", + "Errata", + "ExperimentalFeature", + "FlexDirection", + "Gutter", + "Justify", + "LogLevel", + "MeasureMode", + "NodeType", + "Overflow", + "PositionType", + "Unit", + "Wrap", +] as const; + +const unsupportedMembers: Partial> = { + Display: ["Contents"], + Errata: [ + "StretchFlexBasis", + "AbsolutePositionWithoutInsetsExcludesPadding", + "AbsolutePercentAgainstInnerSize", + "All", + "Classic", + ], + PositionType: ["Static"], +}; + +const unsupportedConstants = new Set([ + "DISPLAY_CONTENTS", + "ERRATA_STRETCH_FLEX_BASIS", + "ERRATA_ABSOLUTE_POSITION_WITHOUT_INSETS_EXCLUDES_PADDING", + "ERRATA_ABSOLUTE_PERCENT_AGAINST_INNER_SIZE", + "ERRATA_ALL", + "ERRATA_CLASSIC", + "POSITION_TYPE_STATIC", +]); + +function supportedEnum(name: (typeof enumNames)[number], value: unknown): Record { + assert.equal(typeof value, "object"); + assert.notEqual(value, null); + const result = { ...(value as Record) }; + for (const member of unsupportedMembers[name] ?? []) { + const numericValue = result[member]; + delete result[member]; + if (typeof numericValue === "number") delete result[String(numericValue)]; + } + return result; +} + +test("root and load entries expose every supported Yoga 3.2.1 enum", () => { + assert.deepEqual(Object.keys(implementation).sort(), Object.keys(oracle).sort()); + assert.deepEqual(Object.keys(implementationLoad).sort(), Object.keys(oracleLoad).sort()); + + for (const name of enumNames) { + const expected = supportedEnum(name, oracle[name]); + assert.deepEqual(implementation[name], expected, `${name} root export`); + assert.deepEqual(implementationLoad[name], expected, `${name} load export`); + } +}); + +test("default facade exposes every supported legacy constant", () => { + const expectedKeys = Object.keys(OracleYoga).filter((key) => !unsupportedConstants.has(key)); + assert.deepEqual(Object.keys(Yoga).sort(), expectedKeys.sort()); + for (const key of expectedKeys) { + if (key === "Config" || key === "Node") continue; + assert.equal( + (Yoga as unknown as Record)[key], + (OracleYoga as unknown as Record)[key], + key, + ); + } +}); + +test("facade factories preserve Yoga's documented factory methods", () => { + assert.deepEqual(Object.keys(Yoga.Config).sort(), ["create", "destroy"]); + assert.deepEqual(Object.keys(Yoga.Node).sort(), [ + "create", + "createDefault", + "createWithConfig", + "destroy", + ]); +}); + +test("handles do not expose or replace facade-native state", async () => { + const facade = await implementationLoad.loadYoga(); + const config = facade.Config.create(); + const node = facade.Node.createWithConfig(config); + const nodeView = node as unknown as Record; + const configView = config as unknown as Record; + + assert.deepEqual(Reflect.ownKeys(node), []); + assert.deepEqual(Reflect.ownKeys(config), []); + assert.equal("runtime" in node, false); + assert.equal("nodeId" in node, false); + assert.equal("config" in node, false); + assert.equal("state" in config, false); + assert.equal("assertLive" in node, false); + assert.equal("assertLive" in config, false); + + let fakeTreeUsed = false; + nodeView.runtime = { + tree: { + enableRounding() { + fakeTreeUsed = true; + }, + }, + }; + nodeView.nodeId = 0n; + configView.state = { useWebDefaults: true }; + + node.setWidth(11); + node.setHeight(6); + node.calculateLayout(undefined, undefined, implementation.Direction.LTR); + assert.equal(node.getComputedWidth(), 11); + assert.equal(fakeTreeUsed, false); + + node.free(); + config.free(); +}); + +test("loadYoga creates isolated facades with branded handles", async () => { + const first = await implementationLoad.loadYoga(); + const second = await implementationLoad.loadYoga(); + const secondConfig = second.Config.create(); + const secondNode = second.Node.create(); + + assert.throws(() => first.Node.createWithConfig(secondConfig), /another Yoga facade/); + assert.throws(() => first.Config.destroy(secondConfig), /another Yoga facade/); + assert.throws(() => first.Node.destroy(secondNode), /another Yoga facade/); + + secondNode.setWidth(12); + secondNode.setHeight(7); + secondNode.calculateLayout(undefined, undefined, implementation.Direction.LTR); + assert.equal(secondNode.getComputedWidth(), 12); + + second.Node.destroy(secondNode); + second.Config.destroy(secondConfig); + assert.throws(() => secondNode.getComputedWidth(), /freed/); + assert.throws(() => second.Node.createWithConfig(secondConfig), /freed/); +}); diff --git a/tests/taffyjs-yoga/layout.test.mts b/tests/taffyjs-yoga/layout.test.mts new file mode 100644 index 0000000..5371d31 --- /dev/null +++ b/tests/taffyjs-yoga/layout.test.mts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import Yoga, { Direction } from "yoga-layout"; +import OracleYoga, { Direction as OracleDirection } from "yoga-layout-oracle"; +import { test } from "vite-plus/test"; + +test("create-set-calculate-read matches Yoga for a fixed leaf", () => { + const node = Yoga.Node.create(); + const oracleNode = OracleYoga.Node.create(); + try { + node.setWidth(100); + node.setHeight(40); + node.calculateLayout(undefined, undefined, Direction.LTR); + + oracleNode.setWidth(100); + oracleNode.setHeight(40); + oracleNode.calculateLayout(undefined, undefined, OracleDirection.LTR); + + assert.deepEqual(node.getComputedLayout(), oracleNode.getComputedLayout()); + assert.equal(node.getComputedWidth(), oracleNode.getComputedWidth()); + assert.equal(node.getComputedHeight(), oracleNode.getComputedHeight()); + } finally { + node.free(); + oracleNode.free(); + } +}); diff --git a/tests/taffyjs-yoga/package.json b/tests/taffyjs-yoga/package.json new file mode 100644 index 0000000..5914b31 --- /dev/null +++ b/tests/taffyjs-yoga/package.json @@ -0,0 +1,19 @@ +{ + "name": "@taffyjs/yoga-integration-tests", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "vp test" + }, + "dependencies": { + "yoga-layout": "workspace:@taffyjs/yoga@*" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "yoga-layout-oracle": "npm:yoga-layout@3.2.1" + } +} diff --git a/tests/taffyjs-yoga/tsconfig.json b/tests/taffyjs-yoga/tsconfig.json new file mode 100644 index 0000000..1536c15 --- /dev/null +++ b/tests/taffyjs-yoga/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["es2023"], + "moduleDetection": "force", + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"], + "strict": true, + "noUnusedLocals": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["*.test.mts", "vite.config.ts"] +} diff --git a/tests/taffyjs-yoga/types/public-api.test-d.ts b/tests/taffyjs-yoga/types/public-api.test-d.ts new file mode 100644 index 0000000..22d1cd5 --- /dev/null +++ b/tests/taffyjs-yoga/types/public-api.test-d.ts @@ -0,0 +1,50 @@ +import Yoga, { + Direction, + Display, + Errata, + PositionType, + type Config, + type DirtiedFunction, + type MeasureFunction, + type Node, +} from "yoga-layout"; +import { + loadYoga, + type Yoga as LoadedYoga, + type Config as LoadedConfig, + type Node as LoadedNode, +} from "yoga-layout/load"; + +const config: Config = Yoga.Config.create(); +const node: Node = Yoga.Node.create(config); +node.setWidth(100); +node.setHeight(40); +node.calculateLayout(undefined, undefined, Direction.LTR); +const width: number = node.getComputedWidth(); +const dirtied: DirtiedFunction = (dirtyNode) => dirtyNode.getComputedWidth(); +const measure: MeasureFunction = () => ({ width: 1, height: 2 }); +const loadedPromise: Promise = loadYoga(); +const loadedConfig: LoadedConfig = config; +const loadedNode: LoadedNode = node; + +// @ts-expect-error Display.Contents is unsupported and omitted. +void Display.Contents; +// @ts-expect-error PositionType.Static is unsupported and omitted. +void PositionType.Static; +// @ts-expect-error Non-None Errata values are unsupported and omitted. +void Errata.Classic; +// @ts-expect-error The eager root does not export Yoga as a named type. +type RootYoga = import("yoga-layout").Yoga; + +void [ + width, + dirtied, + measure, + loadedPromise, + loadedConfig, + loadedNode, + Display.Flex, + PositionType.Relative, + Errata.None, + 0 as unknown as RootYoga, +]; diff --git a/tests/taffyjs-yoga/types/tsconfig.json b/tests/taffyjs-yoga/types/tsconfig.json new file mode 100644 index 0000000..aefbd5c --- /dev/null +++ b/tests/taffyjs-yoga/types/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "exactOptionalPropertyTypes": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "target": "ES2022" + }, + "files": ["public-api.test-d.ts"] +} diff --git a/tests/taffyjs-yoga/vite.config.ts b/tests/taffyjs-yoga/vite.config.ts new file mode 100644 index 0000000..ffabb08 --- /dev/null +++ b/tests/taffyjs-yoga/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + fmt: {}, + lint: { + options: { + typeAware: true, + typeCheck: true, + }, + }, + test: { + include: ["*.test.mts"], + retry: 0, + sequence: { concurrent: true }, + }, +}); diff --git a/vite.config.ts b/vite.config.ts index a81b155..9fd5613 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,6 +17,14 @@ const testTasks = { command: "vp exec tsc --project tests/taffyjs-node/tests/types/tsconfig.json", dependsOn: ["build"], }, + "check:test:yoga": { + command: "vp run @taffyjs/yoga-integration-tests#test", + dependsOn: ["build"], + }, + "check:test:yoga:types": { + command: "vp exec tsc --project tests/taffyjs-yoga/types/tsconfig.json", + dependsOn: ["build"], + }, }; const wasmTasks = { @@ -59,6 +67,7 @@ export default defineConfig({ "packages/.taffyjs-*.napi-stage-*", "packages/**/.napi-rs-filesystem-transaction*", "tests/taffyjs-wasm/browser/dist", + "packages/taffyjs-yoga/dist", ], overrides: [ { @@ -77,6 +86,7 @@ export default defineConfig({ "packages/.taffyjs-*.napi-stage-*", "packages/**/.napi-rs-filesystem-transaction*", "tests/taffyjs-wasm/browser/dist", + "packages/taffyjs-yoga/dist", ], jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], rules: { @@ -135,9 +145,13 @@ export default defineConfig({ command: "vp run @taffyjs/website#dev", dependsOn: ["build:wasm"], }, + "build:yoga": { + command: "vp run @taffyjs/yoga#build", + dependsOn: ["build:node:entries"], + }, build: { command: "echo build ok", - dependsOn: ["build:node:platform-artifact", "build:node:entries"], + dependsOn: ["build:node:platform-artifact", "build:node:entries", "build:yoga"], }, "check:format": { command: "vp fmt --check", From 522f9e5115effac16ad7d6b91d48acaae52849f9 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 15 Aug 2026 15:08:16 +0800 Subject: [PATCH 04/18] Implement @taffyjs/yoga Config and Style APIs --- .agents/docs/loop-status.md | 11 +- packages/taffyjs-yoga/dist/facade-Baj8muUk.js | 363 ----- packages/taffyjs-yoga/dist/facade-CLOoIkHD.js | 1189 +++++++++++++++++ packages/taffyjs-yoga/dist/index.d.ts | 2 +- packages/taffyjs-yoga/dist/index.js | 2 +- packages/taffyjs-yoga/dist/load.d.ts | 2 +- packages/taffyjs-yoga/dist/load.js | 2 +- ...ypes-3Q6Btqzs.d.ts => types-Dm1ehQuc.d.ts} | 86 +- packages/taffyjs-yoga/src/declarations.ts | 200 +++ packages/taffyjs-yoga/src/facade.ts | 715 +++++++++- packages/taffyjs-yoga/src/translate.ts | 285 ++++ packages/taffyjs-yoga/src/types.ts | 115 +- packages/taffyjs-yoga/src/values.ts | 142 ++ tests/taffyjs-yoga/config.test.mts | 135 ++ tests/taffyjs-yoga/style.test.mts | 441 ++++++ tests/taffyjs-yoga/types/public-api.test-d.ts | 84 ++ 16 files changed, 3335 insertions(+), 439 deletions(-) delete mode 100644 packages/taffyjs-yoga/dist/facade-Baj8muUk.js create mode 100644 packages/taffyjs-yoga/dist/facade-CLOoIkHD.js rename packages/taffyjs-yoga/dist/{types-3Q6Btqzs.d.ts => types-Dm1ehQuc.d.ts} (60%) create mode 100644 packages/taffyjs-yoga/src/declarations.ts create mode 100644 packages/taffyjs-yoga/src/translate.ts create mode 100644 packages/taffyjs-yoga/src/values.ts create mode 100644 tests/taffyjs-yoga/config.test.mts create mode 100644 tests/taffyjs-yoga/style.test.mts diff --git a/.agents/docs/loop-status.md b/.agents/docs/loop-status.md index b28762f..824e66e 100644 --- a/.agents/docs/loop-status.md +++ b/.agents/docs/loop-status.md @@ -4,7 +4,7 @@ Updated: 2026-08-15 ## State -Milestone 1 is complete, reviewed, and ready in its milestone commit. Milestone 2 is next. +Milestone 2 is complete, reviewed, and ready in its milestone commit. Milestone 3 is next. ## Done @@ -18,6 +18,10 @@ Milestone 1 is complete, reviewed, and ready in its milestone commit. Milestone - Added the Node-only ESM `@taffyjs/yoga` package, root and `/load` entries, every supported Yoga 3.2.1 enum and legacy constant, facade factories, private per-facade runtime ownership, and the fixed-leaf create-set-calculate-read path. - Added the `tests/taffyjs-yoga/` consumer package with unchanged `yoga-layout` imports, a separately aliased Yoga 3.2.1 oracle, runtime and declaration coverage, facade isolation, lifetime, and hidden-state fixtures. - Wired the package build, test, type-check, lockfile, complete `dist/` freshness check, and format-before-build aggregate-check ordering into the repository. +- Added retained Config state and lifetime behavior, Yoga and web defaults, live Config-sensitive flex defaults, experimental-feature and point-scale state, and runtime rejection for malformed or unsupported Config inputs. +- Added the complete normalized Yoga Style declaration model, all 26 classified Style getters and 43 setters, strict declared-input normalization, fresh Yoga-shaped Value records, Yoga's deliberate gap declaration/runtime mismatch, `copyStyle`, and type-level narrowing for invalid Align contexts. +- Added deterministic full-Style translation for dimensions, min/max constraints, box sizing, display, overflow, aspect ratio, direction, logical and shorthand edges, position, margin, padding, border, gaps, alignment, flex direction and wrapping, and Config-sensitive flex shorthand and longhands. Setters validate and build a candidate first, apply native Style second, and only then commit the public declaration state. +- Added differential Config and Style fixtures, invalid-input atomicity checks, retained-Config and facade-isolation checks, fixed-leaf translation cases, and a table-driven 345-case numeric boundary corpus against the pinned Yoga 3.2.1 oracle. ## Evidence and review @@ -25,6 +29,9 @@ Milestone 1 is complete, reviewed, and ready in its milestone commit. Milestone - Direct Node imports of `yoga-layout` and `yoga-layout/load` resolve the built workspace package; `pnpm pack --dry-run --json` includes both JavaScript entries, both entry declarations, and both emitted shared chunks; built JavaScript has no `yoga-layout` runtime import. - A temporary untracked file under `packages/taffyjs-yoga/dist` is detected by the exact intent-to-add freshness pattern with `git diff` exit 1. - The fresh adversarial reviewer found one material issue: Node and Config instances exposed writable runtime, tree, NodeId, and Config fields. The implementation moved all handle state into module-private WeakMap records and added a runtime fixture proving fake same-named properties cannot affect native operations. The same reviewer returned PASS on the targeted follow-up. +- The Milestone 2 full `vp run check` passes: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 17 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. +- The Milestone 2 adversarial reviewer found one numeric boundary error: finite JavaScript numbers such as `1e39` can overflow only after f32 conversion, and length-like or aspect-ratio declarations must then clear to Undefined while flex numbers retain Infinity. The normalization was corrected after independent reproduction, `±1e39`, `1e-50`, and `±1e39%` were added to the oracle fixture, and the same reviewer returned PASS on the targeted follow-up. +- A bounded calculation probe found that Yoga and Taffy differ when `Display.None` is applied to the selected calculation root with declared dimensions. Style storage and direct display translation remain complete in Milestone 2; the calculated-output trigger is retained for Milestone 4 classification and regression coverage rather than being hidden in the declaration layer. ## In flight @@ -32,7 +39,7 @@ Milestone 1 is complete, reviewed, and ready in its milestone commit. Milestone ## Next -- Implement Milestone 2: Config state and lifetime, the complete normalized Yoga declaration model, input normalization, supported Style getters and setters, deterministic Taffy translation, `copyStyle`, and Unsupported rejection. +- Implement Milestone 3: topology and lifecycle operations, canonical identity, reset, callbacks, measurement markers, and Yoga-visible dirty and new-layout state. ## Blocked diff --git a/packages/taffyjs-yoga/dist/facade-Baj8muUk.js b/packages/taffyjs-yoga/dist/facade-Baj8muUk.js deleted file mode 100644 index 1eb9103..0000000 --- a/packages/taffyjs-yoga/dist/facade-Baj8muUk.js +++ /dev/null @@ -1,363 +0,0 @@ -import { AlignItems, AvailableSpace, Direction, Display, FlexDirection, TaffyTree } from "@taffyjs/node"; -//#region src/enums.ts -let Align = /* @__PURE__ */ function(Align) { - Align[Align["Auto"] = 0] = "Auto"; - Align[Align["FlexStart"] = 1] = "FlexStart"; - Align[Align["Center"] = 2] = "Center"; - Align[Align["FlexEnd"] = 3] = "FlexEnd"; - Align[Align["Stretch"] = 4] = "Stretch"; - Align[Align["Baseline"] = 5] = "Baseline"; - Align[Align["SpaceBetween"] = 6] = "SpaceBetween"; - Align[Align["SpaceAround"] = 7] = "SpaceAround"; - Align[Align["SpaceEvenly"] = 8] = "SpaceEvenly"; - return Align; -}({}); -let BoxSizing = /* @__PURE__ */ function(BoxSizing) { - BoxSizing[BoxSizing["BorderBox"] = 0] = "BorderBox"; - BoxSizing[BoxSizing["ContentBox"] = 1] = "ContentBox"; - return BoxSizing; -}({}); -let Dimension = /* @__PURE__ */ function(Dimension) { - Dimension[Dimension["Width"] = 0] = "Width"; - Dimension[Dimension["Height"] = 1] = "Height"; - return Dimension; -}({}); -let Direction$1 = /* @__PURE__ */ function(Direction) { - Direction[Direction["Inherit"] = 0] = "Inherit"; - Direction[Direction["LTR"] = 1] = "LTR"; - Direction[Direction["RTL"] = 2] = "RTL"; - return Direction; -}({}); -let Display$1 = /* @__PURE__ */ function(Display) { - Display[Display["Flex"] = 0] = "Flex"; - Display[Display["None"] = 1] = "None"; - return Display; -}({}); -let Edge = /* @__PURE__ */ function(Edge) { - Edge[Edge["Left"] = 0] = "Left"; - Edge[Edge["Top"] = 1] = "Top"; - Edge[Edge["Right"] = 2] = "Right"; - Edge[Edge["Bottom"] = 3] = "Bottom"; - Edge[Edge["Start"] = 4] = "Start"; - Edge[Edge["End"] = 5] = "End"; - Edge[Edge["Horizontal"] = 6] = "Horizontal"; - Edge[Edge["Vertical"] = 7] = "Vertical"; - Edge[Edge["All"] = 8] = "All"; - return Edge; -}({}); -let Errata = /* @__PURE__ */ function(Errata) { - Errata[Errata["None"] = 0] = "None"; - return Errata; -}({}); -let ExperimentalFeature = /* @__PURE__ */ function(ExperimentalFeature) { - ExperimentalFeature[ExperimentalFeature["WebFlexBasis"] = 0] = "WebFlexBasis"; - return ExperimentalFeature; -}({}); -let FlexDirection$1 = /* @__PURE__ */ function(FlexDirection) { - FlexDirection[FlexDirection["Column"] = 0] = "Column"; - FlexDirection[FlexDirection["ColumnReverse"] = 1] = "ColumnReverse"; - FlexDirection[FlexDirection["Row"] = 2] = "Row"; - FlexDirection[FlexDirection["RowReverse"] = 3] = "RowReverse"; - return FlexDirection; -}({}); -let Gutter = /* @__PURE__ */ function(Gutter) { - Gutter[Gutter["Column"] = 0] = "Column"; - Gutter[Gutter["Row"] = 1] = "Row"; - Gutter[Gutter["All"] = 2] = "All"; - return Gutter; -}({}); -let Justify = /* @__PURE__ */ function(Justify) { - Justify[Justify["FlexStart"] = 0] = "FlexStart"; - Justify[Justify["Center"] = 1] = "Center"; - Justify[Justify["FlexEnd"] = 2] = "FlexEnd"; - Justify[Justify["SpaceBetween"] = 3] = "SpaceBetween"; - Justify[Justify["SpaceAround"] = 4] = "SpaceAround"; - Justify[Justify["SpaceEvenly"] = 5] = "SpaceEvenly"; - return Justify; -}({}); -let LogLevel = /* @__PURE__ */ function(LogLevel) { - LogLevel[LogLevel["Error"] = 0] = "Error"; - LogLevel[LogLevel["Warn"] = 1] = "Warn"; - LogLevel[LogLevel["Info"] = 2] = "Info"; - LogLevel[LogLevel["Debug"] = 3] = "Debug"; - LogLevel[LogLevel["Verbose"] = 4] = "Verbose"; - LogLevel[LogLevel["Fatal"] = 5] = "Fatal"; - return LogLevel; -}({}); -let MeasureMode = /* @__PURE__ */ function(MeasureMode) { - MeasureMode[MeasureMode["Undefined"] = 0] = "Undefined"; - MeasureMode[MeasureMode["Exactly"] = 1] = "Exactly"; - MeasureMode[MeasureMode["AtMost"] = 2] = "AtMost"; - return MeasureMode; -}({}); -let NodeType = /* @__PURE__ */ function(NodeType) { - NodeType[NodeType["Default"] = 0] = "Default"; - NodeType[NodeType["Text"] = 1] = "Text"; - return NodeType; -}({}); -let Overflow = /* @__PURE__ */ function(Overflow) { - Overflow[Overflow["Visible"] = 0] = "Visible"; - Overflow[Overflow["Hidden"] = 1] = "Hidden"; - Overflow[Overflow["Scroll"] = 2] = "Scroll"; - return Overflow; -}({}); -let PositionType = /* @__PURE__ */ function(PositionType) { - PositionType[PositionType["Relative"] = 1] = "Relative"; - PositionType[PositionType["Absolute"] = 2] = "Absolute"; - return PositionType; -}({}); -let Unit = /* @__PURE__ */ function(Unit) { - Unit[Unit["Undefined"] = 0] = "Undefined"; - Unit[Unit["Point"] = 1] = "Point"; - Unit[Unit["Percent"] = 2] = "Percent"; - Unit[Unit["Auto"] = 3] = "Auto"; - return Unit; -}({}); -let Wrap = /* @__PURE__ */ function(Wrap) { - Wrap[Wrap["NoWrap"] = 0] = "NoWrap"; - Wrap[Wrap["Wrap"] = 1] = "Wrap"; - Wrap[Wrap["WrapReverse"] = 2] = "WrapReverse"; - return Wrap; -}({}); -const legacyConstants = Object.freeze({ - ALIGN_AUTO: 0, - ALIGN_FLEX_START: 1, - ALIGN_CENTER: 2, - ALIGN_FLEX_END: 3, - ALIGN_STRETCH: 4, - ALIGN_BASELINE: 5, - ALIGN_SPACE_BETWEEN: 6, - ALIGN_SPACE_AROUND: 7, - ALIGN_SPACE_EVENLY: 8, - BOX_SIZING_BORDER_BOX: 0, - BOX_SIZING_CONTENT_BOX: 1, - DIMENSION_WIDTH: 0, - DIMENSION_HEIGHT: 1, - DIRECTION_INHERIT: 0, - DIRECTION_LTR: 1, - DIRECTION_RTL: 2, - DISPLAY_FLEX: 0, - DISPLAY_NONE: 1, - EDGE_LEFT: 0, - EDGE_TOP: 1, - EDGE_RIGHT: 2, - EDGE_BOTTOM: 3, - EDGE_START: 4, - EDGE_END: 5, - EDGE_HORIZONTAL: 6, - EDGE_VERTICAL: 7, - EDGE_ALL: 8, - ERRATA_NONE: 0, - EXPERIMENTAL_FEATURE_WEB_FLEX_BASIS: 0, - FLEX_DIRECTION_COLUMN: 0, - FLEX_DIRECTION_COLUMN_REVERSE: 1, - FLEX_DIRECTION_ROW: 2, - FLEX_DIRECTION_ROW_REVERSE: 3, - GUTTER_COLUMN: 0, - GUTTER_ROW: 1, - GUTTER_ALL: 2, - JUSTIFY_FLEX_START: 0, - JUSTIFY_CENTER: 1, - JUSTIFY_FLEX_END: 2, - JUSTIFY_SPACE_BETWEEN: 3, - JUSTIFY_SPACE_AROUND: 4, - JUSTIFY_SPACE_EVENLY: 5, - LOG_LEVEL_ERROR: 0, - LOG_LEVEL_WARN: 1, - LOG_LEVEL_INFO: 2, - LOG_LEVEL_DEBUG: 3, - LOG_LEVEL_VERBOSE: 4, - LOG_LEVEL_FATAL: 5, - MEASURE_MODE_UNDEFINED: 0, - MEASURE_MODE_EXACTLY: 1, - MEASURE_MODE_AT_MOST: 2, - NODE_TYPE_DEFAULT: 0, - NODE_TYPE_TEXT: 1, - OVERFLOW_VISIBLE: 0, - OVERFLOW_HIDDEN: 1, - OVERFLOW_SCROLL: 2, - POSITION_TYPE_RELATIVE: 1, - POSITION_TYPE_ABSOLUTE: 2, - UNIT_UNDEFINED: 0, - UNIT_POINT: 1, - UNIT_PERCENT: 2, - UNIT_AUTO: 3, - WRAP_NO_WRAP: 0, - WRAP_WRAP: 1, - WRAP_WRAP_REVERSE: 2 -}); -//#endregion -//#region src/facade.ts -const initialLayout = () => ({ - left: 0, - right: 0, - top: 0, - bottom: 0, - width: NaN, - height: NaN -}); -var FacadeRuntime = class { - tree = new TaffyTree(); - nodes = /* @__PURE__ */ new Map(); - defaultConfig = new ConfigState(); - constructor() { - this.tree.disableRounding(); - } -}; -var ConfigState = class {}; -const configRecords = /* @__PURE__ */ new WeakMap(); -var YogaConfig = class { - constructor(runtime, state) { - configRecords.set(this, { - runtime, - state, - alive: true - }); - } - free() { - freeConfig(requireConfig(void 0, this)); - } -}; -function requireConfig(runtime, value) { - if (!(value instanceof YogaConfig)) throw new TypeError("Expected a Yoga Config"); - const record = configRecords.get(value); - if (record === void 0) throw new Error("Yoga Config state is unavailable"); - if (runtime !== void 0 && record.runtime !== runtime) throw new TypeError("Config belongs to another Yoga facade"); - if (!record.alive) throw new Error("Config has been freed"); - return record; -} -function freeConfig(record) { - record.alive = false; -} -function requireDimension(value, name) { - if (value === void 0 || Number.isNaN(value)) return void 0; - if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be a finite number or undefined`); - return value; -} -function requireCalculationDimension(value, name) { - if (value === void 0 || value === "auto" || Number.isNaN(value)) return AvailableSpace.MaxContent; - if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be a finite number, auto, or undefined`); - return value; -} -const nodeRecords = /* @__PURE__ */ new WeakMap(); -function styleFor(width, height, direction = Direction.Ltr) { - return { - display: Display.Flex, - direction, - flexDirection: FlexDirection.Column, - alignItems: AlignItems.FlexStart, - size: { - width, - height - } - }; -} -var YogaNode = class { - constructor(runtime, config) { - const nodeId = runtime.tree.newLeaf(styleFor(void 0, void 0)); - nodeRecords.set(this, { - runtime, - config, - nodeId, - alive: true, - width: void 0, - height: void 0, - layout: initialLayout() - }); - runtime.nodes.set(nodeId, this); - } - free() { - freeNode(requireNode(void 0, this)); - } - setWidth(value) { - const record = requireNode(void 0, this); - const width = requireDimension(value, "width"); - record.runtime.tree.setStyle(record.nodeId, styleFor(width, record.height)); - record.width = width; - } - setHeight(value) { - const record = requireNode(void 0, this); - const height = requireDimension(value, "height"); - record.runtime.tree.setStyle(record.nodeId, styleFor(record.width, height)); - record.height = height; - } - calculateLayout(width, height, direction = 1) { - const record = requireNode(void 0, this); - if (direction !== 1 && direction !== 2) throw new TypeError("calculateLayout direction must be LTR or RTL"); - const resolvedDirection = direction === 2 ? Direction.Rtl : Direction.Ltr; - record.runtime.tree.setStyle(record.nodeId, styleFor(record.width, record.height, resolvedDirection)); - record.runtime.tree.computeLayout({ - root: record.nodeId, - availableSpace: { - width: requireCalculationDimension(width, "width"), - height: requireCalculationDimension(height, "height") - } - }); - const layout = record.runtime.tree.getUnroundedLayout(record.nodeId); - record.layout = { - left: layout.location.x, - right: 0, - top: layout.location.y, - bottom: 0, - width: layout.size.width, - height: layout.size.height - }; - } - getComputedLeft() { - return requireNode(void 0, this).layout.left; - } - getComputedRight() { - return requireNode(void 0, this).layout.right; - } - getComputedTop() { - return requireNode(void 0, this).layout.top; - } - getComputedBottom() { - return requireNode(void 0, this).layout.bottom; - } - getComputedWidth() { - return requireNode(void 0, this).layout.width; - } - getComputedHeight() { - return requireNode(void 0, this).layout.height; - } - getComputedLayout() { - return { ...requireNode(void 0, this).layout }; - } -}; -function requireNode(runtime, value) { - if (!(value instanceof YogaNode)) throw new TypeError("Expected a Yoga Node"); - const record = nodeRecords.get(value); - if (record === void 0) throw new Error("Yoga Node state is unavailable"); - if (runtime !== void 0 && record.runtime !== runtime) throw new TypeError("Node belongs to another Yoga facade"); - if (!record.alive) throw new Error("Node has been freed"); - return record; -} -function freeNode(record) { - record.runtime.tree.remove(record.nodeId); - record.runtime.nodes.delete(record.nodeId); - record.alive = false; -} -function createFactories(runtime) { - return { - Config: Object.freeze({ - create: () => new YogaConfig(runtime, new ConfigState()), - destroy: (config) => freeConfig(requireConfig(runtime, config)) - }), - Node: Object.freeze({ - create: (config) => new YogaNode(runtime, config === void 0 ? runtime.defaultConfig : requireConfig(runtime, config).state), - createDefault: () => new YogaNode(runtime, runtime.defaultConfig), - createWithConfig: (config) => new YogaNode(runtime, requireConfig(runtime, config).state), - destroy: (node) => freeNode(requireNode(runtime, node)) - }) - }; -} -function createYoga() { - const runtime = new FacadeRuntime(); - return Object.freeze({ - ...createFactories(runtime), - ...legacyConstants - }); -} -//#endregion -export { PositionType as _, Direction$1 as a, Errata as c, Gutter as d, Justify as f, Overflow as g, NodeType as h, Dimension as i, ExperimentalFeature as l, MeasureMode as m, Align as n, Display$1 as o, LogLevel as p, BoxSizing as r, Edge as s, createYoga as t, FlexDirection$1 as u, Unit as v, Wrap as y }; diff --git a/packages/taffyjs-yoga/dist/facade-CLOoIkHD.js b/packages/taffyjs-yoga/dist/facade-CLOoIkHD.js new file mode 100644 index 0000000..a19e189 --- /dev/null +++ b/packages/taffyjs-yoga/dist/facade-CLOoIkHD.js @@ -0,0 +1,1189 @@ +import { AlignContent, AlignItems, AvailableSpace, BoxSizing, Dimension, Direction, Display, FlexDirection, FlexWrap, Overflow, Position, TaffyTree } from "@taffyjs/node"; +//#region src/enums.ts +let Align = /* @__PURE__ */ function(Align) { + Align[Align["Auto"] = 0] = "Auto"; + Align[Align["FlexStart"] = 1] = "FlexStart"; + Align[Align["Center"] = 2] = "Center"; + Align[Align["FlexEnd"] = 3] = "FlexEnd"; + Align[Align["Stretch"] = 4] = "Stretch"; + Align[Align["Baseline"] = 5] = "Baseline"; + Align[Align["SpaceBetween"] = 6] = "SpaceBetween"; + Align[Align["SpaceAround"] = 7] = "SpaceAround"; + Align[Align["SpaceEvenly"] = 8] = "SpaceEvenly"; + return Align; +}({}); +let BoxSizing$1 = /* @__PURE__ */ function(BoxSizing) { + BoxSizing[BoxSizing["BorderBox"] = 0] = "BorderBox"; + BoxSizing[BoxSizing["ContentBox"] = 1] = "ContentBox"; + return BoxSizing; +}({}); +let Dimension$1 = /* @__PURE__ */ function(Dimension) { + Dimension[Dimension["Width"] = 0] = "Width"; + Dimension[Dimension["Height"] = 1] = "Height"; + return Dimension; +}({}); +let Direction$1 = /* @__PURE__ */ function(Direction) { + Direction[Direction["Inherit"] = 0] = "Inherit"; + Direction[Direction["LTR"] = 1] = "LTR"; + Direction[Direction["RTL"] = 2] = "RTL"; + return Direction; +}({}); +let Display$1 = /* @__PURE__ */ function(Display) { + Display[Display["Flex"] = 0] = "Flex"; + Display[Display["None"] = 1] = "None"; + return Display; +}({}); +let Edge = /* @__PURE__ */ function(Edge) { + Edge[Edge["Left"] = 0] = "Left"; + Edge[Edge["Top"] = 1] = "Top"; + Edge[Edge["Right"] = 2] = "Right"; + Edge[Edge["Bottom"] = 3] = "Bottom"; + Edge[Edge["Start"] = 4] = "Start"; + Edge[Edge["End"] = 5] = "End"; + Edge[Edge["Horizontal"] = 6] = "Horizontal"; + Edge[Edge["Vertical"] = 7] = "Vertical"; + Edge[Edge["All"] = 8] = "All"; + return Edge; +}({}); +let Errata = /* @__PURE__ */ function(Errata) { + Errata[Errata["None"] = 0] = "None"; + return Errata; +}({}); +let ExperimentalFeature = /* @__PURE__ */ function(ExperimentalFeature) { + ExperimentalFeature[ExperimentalFeature["WebFlexBasis"] = 0] = "WebFlexBasis"; + return ExperimentalFeature; +}({}); +let FlexDirection$1 = /* @__PURE__ */ function(FlexDirection) { + FlexDirection[FlexDirection["Column"] = 0] = "Column"; + FlexDirection[FlexDirection["ColumnReverse"] = 1] = "ColumnReverse"; + FlexDirection[FlexDirection["Row"] = 2] = "Row"; + FlexDirection[FlexDirection["RowReverse"] = 3] = "RowReverse"; + return FlexDirection; +}({}); +let Gutter = /* @__PURE__ */ function(Gutter) { + Gutter[Gutter["Column"] = 0] = "Column"; + Gutter[Gutter["Row"] = 1] = "Row"; + Gutter[Gutter["All"] = 2] = "All"; + return Gutter; +}({}); +let Justify = /* @__PURE__ */ function(Justify) { + Justify[Justify["FlexStart"] = 0] = "FlexStart"; + Justify[Justify["Center"] = 1] = "Center"; + Justify[Justify["FlexEnd"] = 2] = "FlexEnd"; + Justify[Justify["SpaceBetween"] = 3] = "SpaceBetween"; + Justify[Justify["SpaceAround"] = 4] = "SpaceAround"; + Justify[Justify["SpaceEvenly"] = 5] = "SpaceEvenly"; + return Justify; +}({}); +let LogLevel = /* @__PURE__ */ function(LogLevel) { + LogLevel[LogLevel["Error"] = 0] = "Error"; + LogLevel[LogLevel["Warn"] = 1] = "Warn"; + LogLevel[LogLevel["Info"] = 2] = "Info"; + LogLevel[LogLevel["Debug"] = 3] = "Debug"; + LogLevel[LogLevel["Verbose"] = 4] = "Verbose"; + LogLevel[LogLevel["Fatal"] = 5] = "Fatal"; + return LogLevel; +}({}); +let MeasureMode = /* @__PURE__ */ function(MeasureMode) { + MeasureMode[MeasureMode["Undefined"] = 0] = "Undefined"; + MeasureMode[MeasureMode["Exactly"] = 1] = "Exactly"; + MeasureMode[MeasureMode["AtMost"] = 2] = "AtMost"; + return MeasureMode; +}({}); +let NodeType = /* @__PURE__ */ function(NodeType) { + NodeType[NodeType["Default"] = 0] = "Default"; + NodeType[NodeType["Text"] = 1] = "Text"; + return NodeType; +}({}); +let Overflow$1 = /* @__PURE__ */ function(Overflow) { + Overflow[Overflow["Visible"] = 0] = "Visible"; + Overflow[Overflow["Hidden"] = 1] = "Hidden"; + Overflow[Overflow["Scroll"] = 2] = "Scroll"; + return Overflow; +}({}); +let PositionType = /* @__PURE__ */ function(PositionType) { + PositionType[PositionType["Relative"] = 1] = "Relative"; + PositionType[PositionType["Absolute"] = 2] = "Absolute"; + return PositionType; +}({}); +let Unit = /* @__PURE__ */ function(Unit) { + Unit[Unit["Undefined"] = 0] = "Undefined"; + Unit[Unit["Point"] = 1] = "Point"; + Unit[Unit["Percent"] = 2] = "Percent"; + Unit[Unit["Auto"] = 3] = "Auto"; + return Unit; +}({}); +let Wrap = /* @__PURE__ */ function(Wrap) { + Wrap[Wrap["NoWrap"] = 0] = "NoWrap"; + Wrap[Wrap["Wrap"] = 1] = "Wrap"; + Wrap[Wrap["WrapReverse"] = 2] = "WrapReverse"; + return Wrap; +}({}); +const legacyConstants = Object.freeze({ + ALIGN_AUTO: 0, + ALIGN_FLEX_START: 1, + ALIGN_CENTER: 2, + ALIGN_FLEX_END: 3, + ALIGN_STRETCH: 4, + ALIGN_BASELINE: 5, + ALIGN_SPACE_BETWEEN: 6, + ALIGN_SPACE_AROUND: 7, + ALIGN_SPACE_EVENLY: 8, + BOX_SIZING_BORDER_BOX: 0, + BOX_SIZING_CONTENT_BOX: 1, + DIMENSION_WIDTH: 0, + DIMENSION_HEIGHT: 1, + DIRECTION_INHERIT: 0, + DIRECTION_LTR: 1, + DIRECTION_RTL: 2, + DISPLAY_FLEX: 0, + DISPLAY_NONE: 1, + EDGE_LEFT: 0, + EDGE_TOP: 1, + EDGE_RIGHT: 2, + EDGE_BOTTOM: 3, + EDGE_START: 4, + EDGE_END: 5, + EDGE_HORIZONTAL: 6, + EDGE_VERTICAL: 7, + EDGE_ALL: 8, + ERRATA_NONE: 0, + EXPERIMENTAL_FEATURE_WEB_FLEX_BASIS: 0, + FLEX_DIRECTION_COLUMN: 0, + FLEX_DIRECTION_COLUMN_REVERSE: 1, + FLEX_DIRECTION_ROW: 2, + FLEX_DIRECTION_ROW_REVERSE: 3, + GUTTER_COLUMN: 0, + GUTTER_ROW: 1, + GUTTER_ALL: 2, + JUSTIFY_FLEX_START: 0, + JUSTIFY_CENTER: 1, + JUSTIFY_FLEX_END: 2, + JUSTIFY_SPACE_BETWEEN: 3, + JUSTIFY_SPACE_AROUND: 4, + JUSTIFY_SPACE_EVENLY: 5, + LOG_LEVEL_ERROR: 0, + LOG_LEVEL_WARN: 1, + LOG_LEVEL_INFO: 2, + LOG_LEVEL_DEBUG: 3, + LOG_LEVEL_VERBOSE: 4, + LOG_LEVEL_FATAL: 5, + MEASURE_MODE_UNDEFINED: 0, + MEASURE_MODE_EXACTLY: 1, + MEASURE_MODE_AT_MOST: 2, + NODE_TYPE_DEFAULT: 0, + NODE_TYPE_TEXT: 1, + OVERFLOW_VISIBLE: 0, + OVERFLOW_HIDDEN: 1, + OVERFLOW_SCROLL: 2, + POSITION_TYPE_RELATIVE: 1, + POSITION_TYPE_ABSOLUTE: 2, + UNIT_UNDEFINED: 0, + UNIT_POINT: 1, + UNIT_PERCENT: 2, + UNIT_AUTO: 3, + WRAP_NO_WRAP: 0, + WRAP_WRAP: 1, + WRAP_WRAP_REVERSE: 2 +}); +//#endregion +//#region src/values.ts +const percentPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?%$/i; +function float32(value) { + if (!Number.isFinite(value)) return value; + if (value === 0) return 0; + return Math.fround(value); +} +function finiteFloat32(value) { + const normalized = float32(value); + return Number.isFinite(normalized) ? normalized : void 0; +} +function undefinedValue() { + return { + unit: 0, + value: NaN + }; +} +function autoValue() { + return { + unit: 3, + value: NaN + }; +} +function pointValue(value) { + return { + unit: 1, + value + }; +} +function percentValue(value) { + return { + unit: 2, + value + }; +} +function cloneValue(value) { + return { + unit: value.unit, + value: value.value + }; +} +function sameValue(left, right) { + return left.unit === right.unit && Object.is(left.value, right.value); +} +function publicValue(value) { + return { + value: value.value, + unit: value.unit + }; +} +function normalizeLength(value, name, allowAuto) { + if (value === void 0) return undefinedValue(); + if (typeof value === "number") { + if (!Number.isFinite(value)) return undefinedValue(); + const normalized = finiteFloat32(value); + return normalized === void 0 ? undefinedValue() : pointValue(normalized); + } + if (typeof value !== "string") throw new TypeError(`${name} must be a number, percentage,${allowAuto ? " auto," : ""} or undefined`); + if (allowAuto && value === "auto") return autoValue(); + if (!percentPattern.test(value)) throw new TypeError(`${name} must be a number, percentage,${allowAuto ? " auto," : ""} or undefined`); + const numeric = Number(value.slice(0, -1)); + if (!Number.isFinite(numeric)) throw new TypeError(`${name} percentage must be finite`); + const normalized = finiteFloat32(numeric); + return normalized === void 0 ? undefinedValue() : percentValue(normalized); +} +function normalizePercent(value, name) { + if (value === void 0) return undefinedValue(); + if (typeof value !== "number") throw new TypeError(`${name} must be a number or undefined`); + if (!Number.isFinite(value)) return undefinedValue(); + const normalized = finiteFloat32(value); + return normalized === void 0 ? undefinedValue() : percentValue(normalized); +} +function normalizePoint(value, name) { + if (value === void 0) return void 0; + if (typeof value !== "number") throw new TypeError(`${name} must be a number or undefined`); + if (!Number.isFinite(value)) return void 0; + return finiteFloat32(value); +} +function normalizeFlexNumber(value, name) { + if (value === void 0) return void 0; + if (typeof value !== "number") throw new TypeError(`${name} must be a number or undefined`); + if (Number.isNaN(value)) return void 0; + return float32(value); +} +function normalizeAspectRatio(value) { + if (value === void 0) return void 0; + if (typeof value !== "number") throw new TypeError("aspectRatio must be a number or undefined"); + if (!Number.isFinite(value) || value === 0) return void 0; + const normalized = finiteFloat32(value); + return normalized === void 0 || normalized === 0 ? void 0 : normalized; +} +function toDimension(value) { + switch (value.unit) { + case 1: return Dimension.Length(value.value); + case 2: return Dimension.Percent(value.value); + case 3: + case 0: return Dimension.Auto; + } +} +function toLengthPercentage(value) { + switch (value.unit) { + case 1: return Dimension.Length(value.value); + case 2: return Dimension.Percent(value.value); + case 3: + case 0: return 0; + } +} +function toLengthPercentageAuto(value) { + return value.unit === 0 ? 0 : toDimension(value); +} +//#endregion +//#region src/declarations.ts +function edgeValues(create) { + return [ + create(), + create(), + create(), + create(), + create(), + create(), + create(), + create(), + create() + ]; +} +function gutterValues(create) { + return [ + create(), + create(), + create() + ]; +} +function createDeclarations(useWebDefaults) { + return { + alignContent: useWebDefaults ? 4 : 1, + alignItems: 4, + alignSelf: 0, + aspectRatio: void 0, + border: edgeValues(() => void 0), + direction: 0, + display: 0, + flex: void 0, + flexBasis: autoValue(), + flexDirection: useWebDefaults ? 2 : 0, + flexGrow: void 0, + flexShrink: void 0, + flexWrap: 0, + height: autoValue(), + justifyContent: 0, + gap: gutterValues(undefinedValue), + margin: edgeValues(undefinedValue), + maxHeight: undefinedValue(), + maxWidth: undefinedValue(), + minHeight: undefinedValue(), + minWidth: undefinedValue(), + overflow: 0, + padding: edgeValues(undefinedValue), + position: edgeValues(undefinedValue), + positionType: 1, + boxSizing: 0, + width: autoValue() + }; +} +function cloneDeclarations(value) { + return { + ...value, + border: [...value.border], + flexBasis: cloneValue(value.flexBasis), + height: cloneValue(value.height), + gap: value.gap.map(cloneValue), + margin: value.margin.map(cloneValue), + maxHeight: cloneValue(value.maxHeight), + maxWidth: cloneValue(value.maxWidth), + minHeight: cloneValue(value.minHeight), + minWidth: cloneValue(value.minWidth), + padding: value.padding.map(cloneValue), + position: value.position.map(cloneValue), + width: cloneValue(value.width) + }; +} +function sameNumber(left, right) { + return Object.is(left, right); +} +function sameArray(left, right, same) { + return left.every((value, index) => same(value, right[index])); +} +function sameDeclarations(left, right) { + return left.alignContent === right.alignContent && left.alignItems === right.alignItems && left.alignSelf === right.alignSelf && sameNumber(left.aspectRatio, right.aspectRatio) && sameArray(left.border, right.border, sameNumber) && left.direction === right.direction && left.display === right.display && sameNumber(left.flex, right.flex) && sameValue(left.flexBasis, right.flexBasis) && left.flexDirection === right.flexDirection && sameNumber(left.flexGrow, right.flexGrow) && sameNumber(left.flexShrink, right.flexShrink) && left.flexWrap === right.flexWrap && sameValue(left.height, right.height) && left.justifyContent === right.justifyContent && sameArray(left.gap, right.gap, sameValue) && sameArray(left.margin, right.margin, sameValue) && sameValue(left.maxHeight, right.maxHeight) && sameValue(left.maxWidth, right.maxWidth) && sameValue(left.minHeight, right.minHeight) && sameValue(left.minWidth, right.minWidth) && left.overflow === right.overflow && sameArray(left.padding, right.padding, sameValue) && sameArray(left.position, right.position, sameValue) && left.positionType === right.positionType && left.boxSizing === right.boxSizing && sameValue(left.width, right.width); +} +function edgeCandidates(edge, direction) { + switch (edge) { + case 0: return [ + direction === 1 ? 4 : 5, + 0, + 6, + 8 + ]; + case 2: return [ + direction === 1 ? 5 : 4, + 2, + 6, + 8 + ]; + case 1: return [ + 1, + 7, + 8 + ]; + case 3: return [ + 3, + 7, + 8 + ]; + default: throw new TypeError("A physical edge is required"); + } +} +function resolveEdge(values, edge, direction, isDefined, fallback) { + for (const candidate of edgeCandidates(edge, direction)) { + const value = values[candidate]; + if (isDefined(value)) return value; + } + return fallback(); +} +function resolveGutter(values, gutter, isDefined, fallback) { + const direct = values[gutter]; + if (isDefined(direct)) return direct; + const all = values[2]; + return isDefined(all) ? all : fallback(); +} +//#endregion +//#region src/translate.ts +function alignItems(value) { + switch (value) { + case 1: return AlignItems.FlexStart; + case 2: return AlignItems.Center; + case 3: return AlignItems.FlexEnd; + case 4: return AlignItems.Stretch; + case 5: return AlignItems.Baseline; + default: throw new TypeError("Unsupported item alignment"); + } +} +function alignContent(value) { + switch (value) { + case 1: return AlignContent.FlexStart; + case 2: return AlignContent.Center; + case 3: return AlignContent.FlexEnd; + case 4: return AlignContent.Stretch; + case 6: return AlignContent.SpaceBetween; + case 7: return AlignContent.SpaceAround; + case 8: return AlignContent.SpaceEvenly; + default: throw new TypeError("Unsupported content alignment"); + } +} +function justifyContent(value) { + switch (value) { + case 0: return AlignContent.FlexStart; + case 1: return AlignContent.Center; + case 2: return AlignContent.FlexEnd; + case 3: return AlignContent.SpaceBetween; + case 4: return AlignContent.SpaceAround; + case 5: return AlignContent.SpaceEvenly; + } +} +function direction(value) { + return value === 2 ? Direction.Rtl : Direction.Ltr; +} +function flexDirection(value) { + switch (value) { + case 0: return FlexDirection.Column; + case 1: return FlexDirection.ColumnReverse; + case 2: return FlexDirection.Row; + case 3: return FlexDirection.RowReverse; + } +} +function flexWrap(value) { + switch (value) { + case 0: return FlexWrap.NoWrap; + case 1: return FlexWrap.Wrap; + case 2: return FlexWrap.WrapReverse; + } +} +function physicalValue(values, edge, resolvedDirection) { + return resolveEdge(values, edge, resolvedDirection, (value) => value.unit !== 0, undefinedValue); +} +function physicalNumber(values, edge, resolvedDirection) { + return resolveEdge(values, edge, resolvedDirection, (value) => value !== void 0, () => 0) ?? 0; +} +function rect(read) { + return { + left: read(0), + top: read(1), + right: read(2), + bottom: read(3) + }; +} +function effectiveFlexBasis(declarations, config) { + if (declarations.flexBasis.unit === 3 && declarations.flex !== void 0 && declarations.flex > 0 && !config.useWebDefaults) return Dimension.Length(0); + return toDimension(declarations.flexBasis); +} +function effectiveFlexGrow(declarations) { + if (declarations.flexGrow !== void 0) return declarations.flexGrow; + return declarations.flex !== void 0 && declarations.flex > 0 ? declarations.flex : 0; +} +function effectiveFlexShrink(declarations, config) { + if (declarations.flexShrink !== void 0) return declarations.flexShrink; + if (!config.useWebDefaults && declarations.flex !== void 0 && declarations.flex < 0) return -declarations.flex; + return config.useWebDefaults ? 1 : 0; +} +function translateStyle(declarations, config, resolvedDirection) { + const margin = rect((edge) => toLengthPercentageAuto(physicalValue(declarations.margin, edge, resolvedDirection))); + const padding = rect((edge) => toLengthPercentage(physicalValue(declarations.padding, edge, resolvedDirection))); + const position = rect((edge) => toDimension(physicalValue(declarations.position, edge, resolvedDirection))); + const border = rect((edge) => physicalNumber(declarations.border, edge, resolvedDirection)); + const columnGap = resolveGutter(declarations.gap, 0, (value) => value.unit !== 0, undefinedValue); + const rowGap = resolveGutter(declarations.gap, 1, (value) => value.unit !== 0, undefinedValue); + return { + display: declarations.display === 1 ? Display.None : Display.Flex, + boxSizing: declarations.boxSizing === 1 ? BoxSizing.ContentBox : BoxSizing.BorderBox, + direction: direction(resolvedDirection), + overflow: { + x: declarations.overflow === 1 ? Overflow.Hidden : declarations.overflow === 2 ? Overflow.Scroll : Overflow.Visible, + y: declarations.overflow === 1 ? Overflow.Hidden : declarations.overflow === 2 ? Overflow.Scroll : Overflow.Visible + }, + position: declarations.positionType === 2 ? Position.Absolute : Position.Relative, + inset: position, + size: { + width: toDimension(declarations.width), + height: toDimension(declarations.height) + }, + minSize: { + width: toDimension(declarations.minWidth), + height: toDimension(declarations.minHeight) + }, + maxSize: { + width: toDimension(declarations.maxWidth), + height: toDimension(declarations.maxHeight) + }, + aspectRatio: declarations.aspectRatio ?? null, + margin, + padding, + border, + alignItems: alignItems(declarations.alignItems), + alignSelf: declarations.alignSelf === 0 ? null : alignItems(declarations.alignSelf), + alignContent: alignContent(declarations.alignContent), + justifyContent: justifyContent(declarations.justifyContent), + gap: { + width: toLengthPercentage(columnGap), + height: toLengthPercentage(rowGap) + }, + flexDirection: flexDirection(declarations.flexDirection), + flexWrap: flexWrap(declarations.flexWrap), + flexBasis: effectiveFlexBasis(declarations, config), + flexGrow: effectiveFlexGrow(declarations), + flexShrink: effectiveFlexShrink(declarations, config) + }; +} +function declarationDirection(declarations, ownerDirection) { + return declarations.direction === 0 ? ownerDirection : declarations.direction; +} +//#endregion +//#region src/facade.ts +const initialLayout = () => ({ + left: 0, + right: 0, + top: 0, + bottom: 0, + width: NaN, + height: NaN +}); +var ConfigState = class { + useWebDefaults = false; + pointScaleFactor = 1; + errata = 0; + webFlexBasis = false; + revision = 0; +}; +var FacadeRuntime = class { + tree = new TaffyTree(); + nodes = /* @__PURE__ */ new Map(); + defaultConfig = new ConfigState(); + constructor() { + this.tree.disableRounding(); + } +}; +const configRecords = /* @__PURE__ */ new WeakMap(); +function requireBoolean(value, name) { + if (typeof value !== "boolean") throw new TypeError(`${name} must be a boolean`); + return value; +} +function requireEnum(value, name, allowed) { + if (typeof value !== "number" || !allowed.includes(value)) throw new TypeError(`${name} is unsupported`); + return value; +} +var YogaConfig = class { + constructor(runtime, state) { + configRecords.set(this, { + runtime, + state, + alive: true + }); + } + free() { + freeConfig(requireConfig(void 0, this)); + } + isExperimentalFeatureEnabled(feature) { + const record = requireConfig(void 0, this); + requireEnum(feature, "experimental feature", [0]); + return record.state.webFlexBasis; + } + setExperimentalFeatureEnabled(feature, enabled) { + const record = requireConfig(void 0, this); + requireEnum(feature, "experimental feature", [0]); + const next = requireBoolean(enabled, "enabled"); + if (record.state.webFlexBasis === next) return; + record.state.webFlexBasis = next; + record.state.revision += 1; + } + setPointScaleFactor(factor) { + const record = requireConfig(void 0, this); + if (typeof factor !== "number" || Number.isNaN(factor) || factor < 0) throw new TypeError("pointScaleFactor must be a non-negative number"); + const next = Number.isFinite(factor) ? Math.fround(factor) : factor; + if (Object.is(record.state.pointScaleFactor, next)) return; + record.state.pointScaleFactor = next; + record.state.revision += 1; + } + getErrata() { + return requireConfig(void 0, this).state.errata; + } + setErrata(errata) { + const record = requireConfig(void 0, this); + requireEnum(errata, "errata", [0]); + record.state.errata = 0; + } + useWebDefaults() { + return requireConfig(void 0, this).state.useWebDefaults; + } + setUseWebDefaults(useWebDefaults) { + const record = requireConfig(void 0, this); + const next = requireBoolean(useWebDefaults, "useWebDefaults"); + if (record.state.useWebDefaults === next) return; + record.state.useWebDefaults = next; + record.state.revision += 1; + } +}; +function requireConfig(runtime, value) { + if (!(value instanceof YogaConfig)) throw new TypeError("Expected a Yoga Config"); + const record = configRecords.get(value); + if (record === void 0) throw new Error("Yoga Config state is unavailable"); + if (runtime !== void 0 && record.runtime !== runtime) throw new TypeError("Config belongs to another Yoga facade"); + if (!record.alive) throw new Error("Config has been freed"); + return record; +} +function freeConfig(record) { + record.alive = false; +} +function requireCalculationDimension(value, name) { + if (value === void 0 || value === "auto" || Number.isNaN(value)) return AvailableSpace.MaxContent; + if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be a finite number, auto, or undefined`); + return value; +} +const nodeRecords = /* @__PURE__ */ new WeakMap(); +function requireNode(runtime, value) { + if (!(value instanceof YogaNode)) throw new TypeError("Expected a Yoga Node"); + const record = nodeRecords.get(value); + if (record === void 0) throw new Error("Yoga Node state is unavailable"); + if (runtime !== void 0 && record.runtime !== runtime) throw new TypeError("Node belongs to another Yoga facade"); + if (!record.alive) throw new Error("Node has been freed"); + return record; +} +function effectiveDirection(record, declarations = record.declarations) { + return declarationDirection(declarations, record.ownerDirection); +} +function applyDeclarations(record, declarations) { + if (sameDeclarations(record.declarations, declarations)) return; + const direction = effectiveDirection(record, declarations); + const style = translateStyle(declarations, record.config, direction); + record.runtime.tree.setStyle(record.nodeId, style); + record.declarations = declarations; + record.appliedDirection = direction; + record.appliedConfigRevision = record.config.revision; +} +function mutateDeclarations(node, mutate) { + const record = requireNode(void 0, node); + const next = cloneDeclarations(record.declarations); + mutate(next); + applyDeclarations(record, next); +} +function syncStyle(record) { + const direction = effectiveDirection(record); + if (record.appliedConfigRevision === record.config.revision && record.appliedDirection === direction) return; + record.runtime.tree.setStyle(record.nodeId, translateStyle(record.declarations, record.config, direction)); + record.appliedDirection = direction; + record.appliedConfigRevision = record.config.revision; +} +const physicalEdges = [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 +]; +function requireEdge(edge) { + return requireEnum(edge, "edge", physicalEdges); +} +function requireGutter(gutter) { + return requireEnum(gutter, "gutter", [ + 0, + 1, + 2 + ]); +} +var YogaNode = class { + constructor(runtime, config) { + const declarations = createDeclarations(config.useWebDefaults); + const ownerDirection = 1; + const appliedDirection = declarationDirection(declarations, ownerDirection); + const nodeId = runtime.tree.newLeaf(translateStyle(declarations, config, appliedDirection)); + nodeRecords.set(this, { + runtime, + config, + nodeId, + alive: true, + declarations, + ownerDirection, + appliedDirection, + appliedConfigRevision: config.revision, + layout: initialLayout() + }); + runtime.nodes.set(nodeId, this); + } + free() { + freeNode(requireNode(void 0, this)); + } + copyStyle(node) { + const record = requireNode(void 0, this); + applyDeclarations(record, cloneDeclarations(requireNode(record.runtime, node).declarations)); + } + getAlignContent() { + return requireNode(void 0, this).declarations.alignContent; + } + getAlignItems() { + return requireNode(void 0, this).declarations.alignItems; + } + getAlignSelf() { + return requireNode(void 0, this).declarations.alignSelf; + } + getAspectRatio() { + return requireNode(void 0, this).declarations.aspectRatio ?? NaN; + } + getBorder(edge) { + return requireNode(void 0, this).declarations.border[requireEdge(edge)] ?? NaN; + } + getDirection() { + return requireNode(void 0, this).declarations.direction; + } + getDisplay() { + return requireNode(void 0, this).declarations.display; + } + getFlexBasis() { + return publicValue(requireNode(void 0, this).declarations.flexBasis); + } + getFlexDirection() { + return requireNode(void 0, this).declarations.flexDirection; + } + getFlexGrow() { + return requireNode(void 0, this).declarations.flexGrow ?? 0; + } + getFlexShrink() { + const record = requireNode(void 0, this); + return record.declarations.flexShrink ?? (record.config.useWebDefaults ? 1 : 0); + } + getFlexWrap() { + return requireNode(void 0, this).declarations.flexWrap; + } + getHeight() { + return publicValue(requireNode(void 0, this).declarations.height); + } + getJustifyContent() { + return requireNode(void 0, this).declarations.justifyContent; + } + getGap(gutter) { + return requireNode(void 0, this).declarations.gap[requireGutter(gutter)].value; + } + getMargin(edge) { + return publicValue(requireNode(void 0, this).declarations.margin[requireEdge(edge)]); + } + getMaxHeight() { + return publicValue(requireNode(void 0, this).declarations.maxHeight); + } + getMaxWidth() { + return publicValue(requireNode(void 0, this).declarations.maxWidth); + } + getMinHeight() { + return publicValue(requireNode(void 0, this).declarations.minHeight); + } + getMinWidth() { + return publicValue(requireNode(void 0, this).declarations.minWidth); + } + getOverflow() { + return requireNode(void 0, this).declarations.overflow; + } + getPadding(edge) { + return publicValue(requireNode(void 0, this).declarations.padding[requireEdge(edge)]); + } + getPosition(edge) { + return publicValue(requireNode(void 0, this).declarations.position[requireEdge(edge)]); + } + getPositionType() { + return requireNode(void 0, this).declarations.positionType; + } + getBoxSizing() { + return requireNode(void 0, this).declarations.boxSizing; + } + getWidth() { + return publicValue(requireNode(void 0, this).declarations.width); + } + setAlignContent(alignContent) { + const value = requireEnum(alignContent, "alignContent", [ + 1, + 2, + 3, + 4, + 6, + 7, + 8 + ]); + mutateDeclarations(this, (declarations) => { + declarations.alignContent = value; + }); + } + setAlignItems(alignItems) { + const value = requireEnum(alignItems, "alignItems", [ + 1, + 2, + 3, + 4, + 5 + ]); + mutateDeclarations(this, (declarations) => { + declarations.alignItems = value; + }); + } + setAlignSelf(alignSelf) { + const value = requireEnum(alignSelf, "alignSelf", [ + 0, + 1, + 2, + 3, + 4, + 5 + ]); + mutateDeclarations(this, (declarations) => { + declarations.alignSelf = value; + }); + } + setAspectRatio(aspectRatio) { + const value = normalizeAspectRatio(aspectRatio); + mutateDeclarations(this, (declarations) => { + declarations.aspectRatio = value; + }); + } + setBorder(edge, borderWidth) { + const index = requireEdge(edge); + const value = normalizePoint(borderWidth, "borderWidth"); + mutateDeclarations(this, (declarations) => { + declarations.border[index] = value; + }); + } + setDirection(direction) { + const value = requireEnum(direction, "direction", [ + 0, + 1, + 2 + ]); + mutateDeclarations(this, (declarations) => { + declarations.direction = value; + }); + } + setDisplay(display) { + const value = requireEnum(display, "display", [0, 1]); + mutateDeclarations(this, (declarations) => { + declarations.display = value; + }); + } + setFlex(flex) { + const value = normalizeFlexNumber(flex, "flex"); + mutateDeclarations(this, (declarations) => { + declarations.flex = value; + }); + } + setFlexBasis(flexBasis) { + const value = normalizeLength(flexBasis, "flexBasis", true); + mutateDeclarations(this, (declarations) => { + declarations.flexBasis = value; + }); + } + setFlexBasisPercent(flexBasis) { + const value = normalizePercent(flexBasis, "flexBasis"); + mutateDeclarations(this, (declarations) => { + declarations.flexBasis = value; + }); + } + setFlexBasisAuto() { + mutateDeclarations(this, (declarations) => { + declarations.flexBasis = autoValue(); + }); + } + setFlexDirection(flexDirection) { + const value = requireEnum(flexDirection, "flexDirection", [ + 0, + 1, + 2, + 3 + ]); + mutateDeclarations(this, (declarations) => { + declarations.flexDirection = value; + }); + } + setFlexGrow(flexGrow) { + const value = normalizeFlexNumber(flexGrow, "flexGrow"); + mutateDeclarations(this, (declarations) => { + declarations.flexGrow = value; + }); + } + setFlexShrink(flexShrink) { + const value = normalizeFlexNumber(flexShrink, "flexShrink"); + mutateDeclarations(this, (declarations) => { + declarations.flexShrink = value; + }); + } + setFlexWrap(flexWrap) { + const value = requireEnum(flexWrap, "flexWrap", [ + 0, + 1, + 2 + ]); + mutateDeclarations(this, (declarations) => { + declarations.flexWrap = value; + }); + } + setHeight(height) { + const value = normalizeLength(height, "height", true); + mutateDeclarations(this, (declarations) => { + declarations.height = value; + }); + } + setHeightAuto() { + mutateDeclarations(this, (declarations) => { + declarations.height = autoValue(); + }); + } + setHeightPercent(height) { + const value = normalizePercent(height, "height"); + mutateDeclarations(this, (declarations) => { + declarations.height = value; + }); + } + setJustifyContent(justifyContent) { + const value = requireEnum(justifyContent, "justifyContent", [ + 0, + 1, + 2, + 3, + 4, + 5 + ]); + mutateDeclarations(this, (declarations) => { + declarations.justifyContent = value; + }); + } + setGap(gutter, gapLength) { + const index = requireGutter(gutter); + const value = normalizeLength(gapLength, "gapLength", false); + mutateDeclarations(this, (declarations) => { + declarations.gap[index] = value; + }); + } + setGapPercent(gutter, gapLength) { + const index = requireGutter(gutter); + const value = normalizePercent(gapLength, "gapLength"); + mutateDeclarations(this, (declarations) => { + declarations.gap[index] = value; + }); + } + setMargin(edge, margin) { + const index = requireEdge(edge); + const value = normalizeLength(margin, "margin", true); + mutateDeclarations(this, (declarations) => { + declarations.margin[index] = value; + }); + } + setMarginAuto(edge) { + const index = requireEdge(edge); + mutateDeclarations(this, (declarations) => { + declarations.margin[index] = autoValue(); + }); + } + setMarginPercent(edge, margin) { + const index = requireEdge(edge); + const value = normalizePercent(margin, "margin"); + mutateDeclarations(this, (declarations) => { + declarations.margin[index] = value; + }); + } + setMaxHeight(maxHeight) { + const value = normalizeLength(maxHeight, "maxHeight", false); + mutateDeclarations(this, (declarations) => { + declarations.maxHeight = value; + }); + } + setMaxHeightPercent(maxHeight) { + const value = normalizePercent(maxHeight, "maxHeight"); + mutateDeclarations(this, (declarations) => { + declarations.maxHeight = value; + }); + } + setMaxWidth(maxWidth) { + const value = normalizeLength(maxWidth, "maxWidth", false); + mutateDeclarations(this, (declarations) => { + declarations.maxWidth = value; + }); + } + setMaxWidthPercent(maxWidth) { + const value = normalizePercent(maxWidth, "maxWidth"); + mutateDeclarations(this, (declarations) => { + declarations.maxWidth = value; + }); + } + setMinHeight(minHeight) { + const value = normalizeLength(minHeight, "minHeight", false); + mutateDeclarations(this, (declarations) => { + declarations.minHeight = value; + }); + } + setMinHeightPercent(minHeight) { + const value = normalizePercent(minHeight, "minHeight"); + mutateDeclarations(this, (declarations) => { + declarations.minHeight = value; + }); + } + setMinWidth(minWidth) { + const value = normalizeLength(minWidth, "minWidth", false); + mutateDeclarations(this, (declarations) => { + declarations.minWidth = value; + }); + } + setMinWidthPercent(minWidth) { + const value = normalizePercent(minWidth, "minWidth"); + mutateDeclarations(this, (declarations) => { + declarations.minWidth = value; + }); + } + setOverflow(overflow) { + const value = requireEnum(overflow, "overflow", [ + 0, + 1, + 2 + ]); + mutateDeclarations(this, (declarations) => { + declarations.overflow = value; + }); + } + setPadding(edge, padding) { + const index = requireEdge(edge); + const value = normalizeLength(padding, "padding", false); + mutateDeclarations(this, (declarations) => { + declarations.padding[index] = value; + }); + } + setPaddingPercent(edge, padding) { + const index = requireEdge(edge); + const value = normalizePercent(padding, "padding"); + mutateDeclarations(this, (declarations) => { + declarations.padding[index] = value; + }); + } + setPosition(edge, position) { + const index = requireEdge(edge); + const value = normalizeLength(position, "position", false); + mutateDeclarations(this, (declarations) => { + declarations.position[index] = value; + }); + } + setPositionPercent(edge, position) { + const index = requireEdge(edge); + const value = normalizePercent(position, "position"); + mutateDeclarations(this, (declarations) => { + declarations.position[index] = value; + }); + } + setPositionType(positionType) { + const value = requireEnum(positionType, "positionType", [1, 2]); + mutateDeclarations(this, (declarations) => { + declarations.positionType = value; + }); + } + setPositionAuto(edge) { + const index = requireEdge(edge); + mutateDeclarations(this, (declarations) => { + declarations.position[index] = autoValue(); + }); + } + setBoxSizing(boxSizing) { + const value = requireEnum(boxSizing, "boxSizing", [0, 1]); + mutateDeclarations(this, (declarations) => { + declarations.boxSizing = value; + }); + } + setWidth(width) { + const value = normalizeLength(width, "width", true); + mutateDeclarations(this, (declarations) => { + declarations.width = value; + }); + } + setWidthAuto() { + mutateDeclarations(this, (declarations) => { + declarations.width = autoValue(); + }); + } + setWidthPercent(width) { + const value = normalizePercent(width, "width"); + mutateDeclarations(this, (declarations) => { + declarations.width = value; + }); + } + calculateLayout(width, height, direction = 1) { + const record = requireNode(void 0, this); + record.ownerDirection = requireEnum(direction, "calculateLayout direction", [1, 2]); + syncStyle(record); + record.runtime.tree.computeLayout({ + root: record.nodeId, + availableSpace: { + width: requireCalculationDimension(width, "width"), + height: requireCalculationDimension(height, "height") + } + }); + const layout = record.runtime.tree.getUnroundedLayout(record.nodeId); + record.layout = { + left: layout.location.x, + right: 0, + top: layout.location.y, + bottom: 0, + width: layout.size.width, + height: layout.size.height + }; + } + getComputedLeft() { + return requireNode(void 0, this).layout.left; + } + getComputedRight() { + return requireNode(void 0, this).layout.right; + } + getComputedTop() { + return requireNode(void 0, this).layout.top; + } + getComputedBottom() { + return requireNode(void 0, this).layout.bottom; + } + getComputedWidth() { + return requireNode(void 0, this).layout.width; + } + getComputedHeight() { + return requireNode(void 0, this).layout.height; + } + getComputedLayout() { + return { ...requireNode(void 0, this).layout }; + } +}; +function freeNode(record) { + record.runtime.tree.remove(record.nodeId); + record.runtime.nodes.delete(record.nodeId); + record.alive = false; +} +function createFactories(runtime) { + return { + Config: Object.freeze({ + create: () => new YogaConfig(runtime, new ConfigState()), + destroy: (config) => freeConfig(requireConfig(runtime, config)) + }), + Node: Object.freeze({ + create: (config) => new YogaNode(runtime, config === void 0 ? runtime.defaultConfig : requireConfig(runtime, config).state), + createDefault: () => new YogaNode(runtime, runtime.defaultConfig), + createWithConfig: (config) => new YogaNode(runtime, requireConfig(runtime, config).state), + destroy: (node) => freeNode(requireNode(runtime, node)) + }) + }; +} +function createYoga() { + const runtime = new FacadeRuntime(); + return Object.freeze({ + ...createFactories(runtime), + ...legacyConstants + }); +} +//#endregion +export { PositionType as _, Direction$1 as a, Errata as c, Gutter as d, Justify as f, Overflow$1 as g, NodeType as h, Dimension$1 as i, ExperimentalFeature as l, MeasureMode as m, Align as n, Display$1 as o, LogLevel as p, BoxSizing$1 as r, Edge as s, createYoga as t, FlexDirection$1 as u, Unit as v, Wrap as y }; diff --git a/packages/taffyjs-yoga/dist/index.d.ts b/packages/taffyjs-yoga/dist/index.d.ts index f331d0e..d2572c1 100644 --- a/packages/taffyjs-yoga/dist/index.d.ts +++ b/packages/taffyjs-yoga/dist/index.d.ts @@ -1,4 +1,4 @@ -import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, n as ConfigFactory, o as NodeFactory, p as Edge, r as DirtiedFunction, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-3Q6Btqzs.js"; +import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, n as ConfigFactory, o as NodeFactory, p as Edge, r as DirtiedFunction, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-Dm1ehQuc.js"; //#region src/index.d.ts declare const Yoga: Readonly<{ Config: ConfigFactory; diff --git a/packages/taffyjs-yoga/dist/index.js b/packages/taffyjs-yoga/dist/index.js index 48b60f9..1aef7cc 100644 --- a/packages/taffyjs-yoga/dist/index.js +++ b/packages/taffyjs-yoga/dist/index.js @@ -1,4 +1,4 @@ -import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-Baj8muUk.js"; +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-CLOoIkHD.js"; //#region src/index.ts const Yoga = createYoga(); //#endregion diff --git a/packages/taffyjs-yoga/dist/load.d.ts b/packages/taffyjs-yoga/dist/load.d.ts index 63d2351..edfb387 100644 --- a/packages/taffyjs-yoga/dist/load.d.ts +++ b/packages/taffyjs-yoga/dist/load.d.ts @@ -1,4 +1,4 @@ -import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, p as Edge, r as DirtiedFunction, s as Yoga, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-3Q6Btqzs.js"; +import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, p as Edge, r as DirtiedFunction, s as Yoga, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-Dm1ehQuc.js"; //#region src/load.d.ts declare function loadYoga(): Promise; //#endregion diff --git a/packages/taffyjs-yoga/dist/load.js b/packages/taffyjs-yoga/dist/load.js index efe2169..5ad4cc3 100644 --- a/packages/taffyjs-yoga/dist/load.js +++ b/packages/taffyjs-yoga/dist/load.js @@ -1,4 +1,4 @@ -import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-Baj8muUk.js"; +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-CLOoIkHD.js"; //#region src/load.ts async function loadYoga() { return createYoga(); diff --git a/packages/taffyjs-yoga/dist/types-3Q6Btqzs.d.ts b/packages/taffyjs-yoga/dist/types-Dm1ehQuc.d.ts similarity index 60% rename from packages/taffyjs-yoga/dist/types-3Q6Btqzs.d.ts rename to packages/taffyjs-yoga/dist/types-Dm1ehQuc.d.ts index 362251f..868d3bb 100644 --- a/packages/taffyjs-yoga/dist/types-3Q6Btqzs.d.ts +++ b/packages/taffyjs-yoga/dist/types-Dm1ehQuc.d.ts @@ -181,16 +181,98 @@ interface Size { width: number; height: number; } +interface Value { + unit: Unit; + value: number; +} type DirtiedFunction = (node: Node) => void; type MeasureFunction = (width: number, widthMode: MeasureMode, height: number, heightMode: MeasureMode) => Size; type LayoutDimension = number | "auto" | undefined; +type AlignContentValue = Align.FlexStart | Align.Center | Align.FlexEnd | Align.Stretch | Align.SpaceBetween | Align.SpaceAround | Align.SpaceEvenly; +type AlignItemsValue = Align.FlexStart | Align.Center | Align.FlexEnd | Align.Stretch | Align.Baseline; +type AlignSelfValue = Align.Auto | AlignItemsValue; interface Config { free(): void; + isExperimentalFeatureEnabled(feature: ExperimentalFeature): boolean; + setExperimentalFeatureEnabled(feature: ExperimentalFeature, enabled: boolean): void; + setPointScaleFactor(factor: number): void; + getErrata(): Errata; + setErrata(errata: Errata): void; + useWebDefaults(): boolean; + setUseWebDefaults(useWebDefaults: boolean): void; } interface Node { free(): void; - setWidth(value: number | string | undefined): void; - setHeight(value: number | string | undefined): void; + copyStyle(node: Node): void; + getAlignContent(): Align; + getAlignItems(): Align; + getAlignSelf(): Align; + getAspectRatio(): number; + getBorder(edge: Edge): number; + getDirection(): Direction; + getDisplay(): Display; + getFlexBasis(): Value; + getFlexDirection(): FlexDirection; + getFlexGrow(): number; + getFlexShrink(): number; + getFlexWrap(): Wrap; + getHeight(): Value; + getJustifyContent(): Justify; + getGap(gutter: Gutter): Value; + getMargin(edge: Edge): Value; + getMaxHeight(): Value; + getMaxWidth(): Value; + getMinHeight(): Value; + getMinWidth(): Value; + getOverflow(): Overflow; + getPadding(edge: Edge): Value; + getPosition(edge: Edge): Value; + getPositionType(): PositionType; + getBoxSizing(): BoxSizing; + getWidth(): Value; + setAlignContent(alignContent: AlignContentValue): void; + setAlignItems(alignItems: AlignItemsValue): void; + setAlignSelf(alignSelf: AlignSelfValue): void; + setAspectRatio(aspectRatio: number | undefined): void; + setBorder(edge: Edge, borderWidth: number | undefined): void; + setDirection(direction: Direction): void; + setDisplay(display: Display): void; + setFlex(flex: number | undefined): void; + setFlexBasis(flexBasis: number | "auto" | `${number}%` | undefined): void; + setFlexBasisPercent(flexBasis: number | undefined): void; + setFlexBasisAuto(): void; + setFlexDirection(flexDirection: FlexDirection): void; + setFlexGrow(flexGrow: number | undefined): void; + setFlexShrink(flexShrink: number | undefined): void; + setFlexWrap(flexWrap: Wrap): void; + setHeight(height: number | "auto" | `${number}%` | undefined): void; + setHeightAuto(): void; + setHeightPercent(height: number | undefined): void; + setJustifyContent(justifyContent: Justify): void; + setGap(gutter: Gutter, gapLength: number | `${number}%` | undefined): Value; + setGapPercent(gutter: Gutter, gapLength: number | undefined): Value; + setMargin(edge: Edge, margin: number | "auto" | `${number}%` | undefined): void; + setMarginAuto(edge: Edge): void; + setMarginPercent(edge: Edge, margin: number | undefined): void; + setMaxHeight(maxHeight: number | `${number}%` | undefined): void; + setMaxHeightPercent(maxHeight: number | undefined): void; + setMaxWidth(maxWidth: number | `${number}%` | undefined): void; + setMaxWidthPercent(maxWidth: number | undefined): void; + setMinHeight(minHeight: number | `${number}%` | undefined): void; + setMinHeightPercent(minHeight: number | undefined): void; + setMinWidth(minWidth: number | `${number}%` | undefined): void; + setMinWidthPercent(minWidth: number | undefined): void; + setOverflow(overflow: Overflow): void; + setPadding(edge: Edge, padding: number | `${number}%` | undefined): void; + setPaddingPercent(edge: Edge, padding: number | undefined): void; + setPosition(edge: Edge, position: number | `${number}%` | undefined): void; + setPositionPercent(edge: Edge, position: number | undefined): void; + setPositionType(positionType: PositionType): void; + setPositionAuto(edge: Edge): void; + setBoxSizing(boxSizing: BoxSizing): void; + setWidth(width: number | "auto" | `${number}%` | undefined): void; + setWidthAuto(): void; + setWidthPercent(width: number | undefined): void; calculateLayout(width: LayoutDimension, height: LayoutDimension, direction?: Direction): void; getComputedLeft(): number; getComputedRight(): number; diff --git a/packages/taffyjs-yoga/src/declarations.ts b/packages/taffyjs-yoga/src/declarations.ts new file mode 100644 index 0000000..903fb51 --- /dev/null +++ b/packages/taffyjs-yoga/src/declarations.ts @@ -0,0 +1,200 @@ +import { + Align, + BoxSizing, + Direction, + Display, + Edge, + FlexDirection, + Gutter, + Justify, + Overflow, + PositionType, + Wrap, +} from "./enums.js"; +import { autoValue, cloneValue, sameValue, undefinedValue, type YogaValue } from "./values.js"; + +export type EdgeValues = [T, T, T, T, T, T, T, T, T]; +export type GutterValues = [T, T, T]; + +export interface YogaDeclarations { + alignContent: Align; + alignItems: Align; + alignSelf: Align; + aspectRatio: number | undefined; + border: EdgeValues; + direction: Direction; + display: Display; + flex: number | undefined; + flexBasis: YogaValue; + flexDirection: FlexDirection; + flexGrow: number | undefined; + flexShrink: number | undefined; + flexWrap: Wrap; + height: YogaValue; + justifyContent: Justify; + gap: GutterValues; + margin: EdgeValues; + maxHeight: YogaValue; + maxWidth: YogaValue; + minHeight: YogaValue; + minWidth: YogaValue; + overflow: Overflow; + padding: EdgeValues; + position: EdgeValues; + positionType: PositionType; + boxSizing: BoxSizing; + width: YogaValue; +} + +function edgeValues(create: () => T): EdgeValues { + return [create(), create(), create(), create(), create(), create(), create(), create(), create()]; +} + +function gutterValues(create: () => T): GutterValues { + return [create(), create(), create()]; +} + +export function createDeclarations(useWebDefaults: boolean): YogaDeclarations { + return { + alignContent: useWebDefaults ? Align.Stretch : Align.FlexStart, + alignItems: Align.Stretch, + alignSelf: Align.Auto, + aspectRatio: undefined, + border: edgeValues(() => undefined), + direction: Direction.Inherit, + display: Display.Flex, + flex: undefined, + flexBasis: autoValue(), + flexDirection: useWebDefaults ? FlexDirection.Row : FlexDirection.Column, + flexGrow: undefined, + flexShrink: undefined, + flexWrap: Wrap.NoWrap, + height: autoValue(), + justifyContent: Justify.FlexStart, + gap: gutterValues(undefinedValue), + margin: edgeValues(undefinedValue), + maxHeight: undefinedValue(), + maxWidth: undefinedValue(), + minHeight: undefinedValue(), + minWidth: undefinedValue(), + overflow: Overflow.Visible, + padding: edgeValues(undefinedValue), + position: edgeValues(undefinedValue), + positionType: PositionType.Relative, + boxSizing: BoxSizing.BorderBox, + width: autoValue(), + }; +} + +export function cloneDeclarations(value: YogaDeclarations): YogaDeclarations { + return { + ...value, + border: [...value.border], + flexBasis: cloneValue(value.flexBasis), + height: cloneValue(value.height), + gap: value.gap.map(cloneValue) as GutterValues, + margin: value.margin.map(cloneValue) as EdgeValues, + maxHeight: cloneValue(value.maxHeight), + maxWidth: cloneValue(value.maxWidth), + minHeight: cloneValue(value.minHeight), + minWidth: cloneValue(value.minWidth), + padding: value.padding.map(cloneValue) as EdgeValues, + position: value.position.map(cloneValue) as EdgeValues, + width: cloneValue(value.width), + }; +} + +function sameNumber(left: number | undefined, right: number | undefined): boolean { + return Object.is(left, right); +} + +function sameArray( + left: readonly T[], + right: readonly T[], + same: (left: T, right: T) => boolean, +): boolean { + return left.every((value, index) => same(value, right[index])); +} + +export function sameDeclarations(left: YogaDeclarations, right: YogaDeclarations): boolean { + return ( + left.alignContent === right.alignContent && + left.alignItems === right.alignItems && + left.alignSelf === right.alignSelf && + sameNumber(left.aspectRatio, right.aspectRatio) && + sameArray(left.border, right.border, sameNumber) && + left.direction === right.direction && + left.display === right.display && + sameNumber(left.flex, right.flex) && + sameValue(left.flexBasis, right.flexBasis) && + left.flexDirection === right.flexDirection && + sameNumber(left.flexGrow, right.flexGrow) && + sameNumber(left.flexShrink, right.flexShrink) && + left.flexWrap === right.flexWrap && + sameValue(left.height, right.height) && + left.justifyContent === right.justifyContent && + sameArray(left.gap, right.gap, sameValue) && + sameArray(left.margin, right.margin, sameValue) && + sameValue(left.maxHeight, right.maxHeight) && + sameValue(left.maxWidth, right.maxWidth) && + sameValue(left.minHeight, right.minHeight) && + sameValue(left.minWidth, right.minWidth) && + left.overflow === right.overflow && + sameArray(left.padding, right.padding, sameValue) && + sameArray(left.position, right.position, sameValue) && + left.positionType === right.positionType && + left.boxSizing === right.boxSizing && + sameValue(left.width, right.width) + ); +} + +function edgeCandidates(edge: Edge, direction: Direction.LTR | Direction.RTL): readonly Edge[] { + switch (edge) { + case Edge.Left: + return [ + direction === Direction.LTR ? Edge.Start : Edge.End, + Edge.Left, + Edge.Horizontal, + Edge.All, + ]; + case Edge.Right: + return [ + direction === Direction.LTR ? Edge.End : Edge.Start, + Edge.Right, + Edge.Horizontal, + Edge.All, + ]; + case Edge.Top: + return [Edge.Top, Edge.Vertical, Edge.All]; + case Edge.Bottom: + return [Edge.Bottom, Edge.Vertical, Edge.All]; + default: + throw new TypeError("A physical edge is required"); + } +} + +export function resolveEdge( + values: EdgeValues, + edge: Edge.Left | Edge.Top | Edge.Right | Edge.Bottom, + direction: Direction.LTR | Direction.RTL, + isDefined: (value: T) => boolean, + fallback: () => T, +): T { + for (const candidate of edgeCandidates(edge, direction)) { + const value = values[candidate]; + if (isDefined(value)) return value; + } + return fallback(); +} + +export function resolveGutter( + values: GutterValues, + gutter: Gutter.Column | Gutter.Row, + isDefined: (value: T) => boolean, + fallback: () => T, +): T { + const direct = values[gutter]; + if (isDefined(direct)) return direct; + const all = values[Gutter.All]; + return isDefined(all) ? all : fallback(); +} diff --git a/packages/taffyjs-yoga/src/facade.ts b/packages/taffyjs-yoga/src/facade.ts index 36e8e25..ed56595 100644 --- a/packages/taffyjs-yoga/src/facade.ts +++ b/packages/taffyjs-yoga/src/facade.ts @@ -1,24 +1,49 @@ +import { AvailableSpace, TaffyTree, type NodeId } from "@taffyjs/node"; import { - AlignItems as TaffyAlignItems, - AvailableSpace, - Direction as TaffyDirection, - Display as TaffyDisplay, - FlexDirection as TaffyFlexDirection, - TaffyTree, - type Direction as TaffyDirectionValue, - type NodeId, - type StyleInput, -} from "@taffyjs/node"; -import { Direction, legacyConstants } from "./enums.js"; + cloneDeclarations, + createDeclarations, + sameDeclarations, + type YogaDeclarations, +} from "./declarations.js"; +import { + Align, + BoxSizing, + Direction, + Display, + Edge, + Errata, + ExperimentalFeature, + FlexDirection, + Gutter, + Justify, + Overflow, + PositionType, + Wrap, + legacyConstants, +} from "./enums.js"; +import { declarationDirection, translateStyle } from "./translate.js"; import type { + AlignContentValue, + AlignItemsValue, + AlignSelfValue, Config, ConfigFactory, Layout, LayoutDimension, Node, NodeFactory, + Value, Yoga, } from "./types.js"; +import { + autoValue, + normalizeAspectRatio, + normalizeFlexNumber, + normalizeLength, + normalizePercent, + normalizePoint, + publicValue, +} from "./values.js"; const initialLayout = (): Layout => ({ left: 0, @@ -29,6 +54,14 @@ const initialLayout = (): Layout => ({ height: Number.NaN, }); +class ConfigState { + useWebDefaults = false; + pointScaleFactor = 1; + errata = Errata.None; + webFlexBasis = false; + revision = 0; +} + class FacadeRuntime { readonly tree = new TaffyTree(); readonly nodes = new Map(); @@ -39,8 +72,6 @@ class FacadeRuntime { } } -class ConfigState {} - interface ConfigRecord { readonly runtime: FacadeRuntime; readonly state: ConfigState; @@ -49,6 +80,18 @@ interface ConfigRecord { const configRecords = new WeakMap(); +function requireBoolean(value: boolean, name: string): boolean { + if (typeof value !== "boolean") throw new TypeError(`${name} must be a boolean`); + return value; +} + +function requireEnum(value: T, name: string, allowed: readonly number[]): T { + if (typeof value !== "number" || !allowed.includes(value)) { + throw new TypeError(`${name} is unsupported`); + } + return value; +} + class YogaConfig implements Config { constructor(runtime: FacadeRuntime, state: ConfigState) { configRecords.set(this, { runtime, state, alive: true }); @@ -57,6 +100,54 @@ class YogaConfig implements Config { free(): void { freeConfig(requireConfig(undefined, this)); } + + isExperimentalFeatureEnabled(feature: ExperimentalFeature): boolean { + const record = requireConfig(undefined, this); + requireEnum(feature, "experimental feature", [ExperimentalFeature.WebFlexBasis]); + return record.state.webFlexBasis; + } + + setExperimentalFeatureEnabled(feature: ExperimentalFeature, enabled: boolean): void { + const record = requireConfig(undefined, this); + requireEnum(feature, "experimental feature", [ExperimentalFeature.WebFlexBasis]); + const next = requireBoolean(enabled, "enabled"); + if (record.state.webFlexBasis === next) return; + record.state.webFlexBasis = next; + record.state.revision += 1; + } + + setPointScaleFactor(factor: number): void { + const record = requireConfig(undefined, this); + if (typeof factor !== "number" || Number.isNaN(factor) || factor < 0) { + throw new TypeError("pointScaleFactor must be a non-negative number"); + } + const next = Number.isFinite(factor) ? Math.fround(factor) : factor; + if (Object.is(record.state.pointScaleFactor, next)) return; + record.state.pointScaleFactor = next; + record.state.revision += 1; + } + + getErrata(): Errata { + return requireConfig(undefined, this).state.errata; + } + + setErrata(errata: Errata): void { + const record = requireConfig(undefined, this); + requireEnum(errata, "errata", [Errata.None]); + record.state.errata = Errata.None; + } + + useWebDefaults(): boolean { + return requireConfig(undefined, this).state.useWebDefaults; + } + + setUseWebDefaults(useWebDefaults: boolean): void { + const record = requireConfig(undefined, this); + const next = requireBoolean(useWebDefaults, "useWebDefaults"); + if (record.state.useWebDefaults === next) return; + record.state.useWebDefaults = next; + record.state.revision += 1; + } } function requireConfig(runtime: FacadeRuntime | undefined, value: Config): ConfigRecord { @@ -74,14 +165,6 @@ function freeConfig(record: ConfigRecord): void { record.alive = false; } -function requireDimension(value: number | string | undefined, name: string): number | undefined { - if (value === undefined || Number.isNaN(value)) return undefined; - if (typeof value !== "number" || !Number.isFinite(value)) { - throw new TypeError(`${name} must be a finite number or undefined`); - } - return value; -} - function requireCalculationDimension(value: LayoutDimension, name: string) { if (value === undefined || value === "auto" || Number.isNaN(value)) { return AvailableSpace.MaxContent; @@ -97,37 +180,101 @@ interface NodeRecord { readonly config: ConfigState; readonly nodeId: NodeId; alive: boolean; - width: number | undefined; - height: number | undefined; + declarations: YogaDeclarations; + ownerDirection: Direction.LTR | Direction.RTL; + appliedDirection: Direction.LTR | Direction.RTL; + appliedConfigRevision: number; layout: Layout; } const nodeRecords = new WeakMap(); -function styleFor( - width: number | undefined, - height: number | undefined, - direction: TaffyDirectionValue = TaffyDirection.Ltr, -): StyleInput { - return { - display: TaffyDisplay.Flex, - direction, - flexDirection: TaffyFlexDirection.Column, - alignItems: TaffyAlignItems.FlexStart, - size: { width, height }, - }; +function requireNode(runtime: FacadeRuntime | undefined, value: Node): NodeRecord { + if (!(value instanceof YogaNode)) throw new TypeError("Expected a Yoga Node"); + const record = nodeRecords.get(value); + if (record === undefined) throw new Error("Yoga Node state is unavailable"); + if (runtime !== undefined && record.runtime !== runtime) { + throw new TypeError("Node belongs to another Yoga facade"); + } + if (!record.alive) throw new Error("Node has been freed"); + return record; +} + +function effectiveDirection(record: NodeRecord, declarations = record.declarations) { + return declarationDirection(declarations, record.ownerDirection); +} + +function applyDeclarations(record: NodeRecord, declarations: YogaDeclarations): void { + if (sameDeclarations(record.declarations, declarations)) return; + const direction = effectiveDirection(record, declarations); + const style = translateStyle(declarations, record.config, direction); + record.runtime.tree.setStyle(record.nodeId, style); + record.declarations = declarations; + record.appliedDirection = direction; + record.appliedConfigRevision = record.config.revision; +} + +function mutateDeclarations( + node: YogaNode, + mutate: (declarations: YogaDeclarations) => void, +): void { + const record = requireNode(undefined, node); + const next = cloneDeclarations(record.declarations); + mutate(next); + applyDeclarations(record, next); +} + +function syncStyle(record: NodeRecord): void { + const direction = effectiveDirection(record); + if ( + record.appliedConfigRevision === record.config.revision && + record.appliedDirection === direction + ) { + return; + } + record.runtime.tree.setStyle( + record.nodeId, + translateStyle(record.declarations, record.config, direction), + ); + record.appliedDirection = direction; + record.appliedConfigRevision = record.config.revision; +} + +const physicalEdges = [ + Edge.Left, + Edge.Top, + Edge.Right, + Edge.Bottom, + Edge.Start, + Edge.End, + Edge.Horizontal, + Edge.Vertical, + Edge.All, +] as const; + +function requireEdge(edge: Edge): Edge { + return requireEnum(edge, "edge", physicalEdges); +} + +function requireGutter(gutter: Gutter): Gutter { + return requireEnum(gutter, "gutter", [Gutter.Column, Gutter.Row, Gutter.All]); } class YogaNode implements Node { constructor(runtime: FacadeRuntime, config: ConfigState) { - const nodeId = runtime.tree.newLeaf(styleFor(undefined, undefined)); + const declarations = createDeclarations(config.useWebDefaults); + const ownerDirection = Direction.LTR; + const appliedDirection = declarationDirection(declarations, ownerDirection); + const nodeId = runtime.tree.newLeaf(translateStyle(declarations, config, appliedDirection)); nodeRecords.set(this, { runtime, config, nodeId, alive: true, - width: undefined, - height: undefined, + declarations, + ownerDirection, + appliedDirection, + appliedConfigRevision: config.revision, layout: initialLayout(), }); runtime.nodes.set(nodeId, this); @@ -137,18 +284,469 @@ class YogaNode implements Node { freeNode(requireNode(undefined, this)); } - setWidth(value: number | string | undefined): void { + copyStyle(node: Node): void { const record = requireNode(undefined, this); - const width = requireDimension(value, "width"); - record.runtime.tree.setStyle(record.nodeId, styleFor(width, record.height)); - record.width = width; + const source = requireNode(record.runtime, node); + applyDeclarations(record, cloneDeclarations(source.declarations)); } - setHeight(value: number | string | undefined): void { + getAlignContent(): Align { + return requireNode(undefined, this).declarations.alignContent; + } + + getAlignItems(): Align { + return requireNode(undefined, this).declarations.alignItems; + } + + getAlignSelf(): Align { + return requireNode(undefined, this).declarations.alignSelf; + } + + getAspectRatio(): number { + return requireNode(undefined, this).declarations.aspectRatio ?? Number.NaN; + } + + getBorder(edge: Edge): number { + return requireNode(undefined, this).declarations.border[requireEdge(edge)] ?? Number.NaN; + } + + getDirection(): Direction { + return requireNode(undefined, this).declarations.direction; + } + + getDisplay(): Display { + return requireNode(undefined, this).declarations.display; + } + + getFlexBasis(): Value { + return publicValue(requireNode(undefined, this).declarations.flexBasis); + } + + getFlexDirection(): FlexDirection { + return requireNode(undefined, this).declarations.flexDirection; + } + + getFlexGrow(): number { + return requireNode(undefined, this).declarations.flexGrow ?? 0; + } + + getFlexShrink(): number { const record = requireNode(undefined, this); - const height = requireDimension(value, "height"); - record.runtime.tree.setStyle(record.nodeId, styleFor(record.width, height)); - record.height = height; + return record.declarations.flexShrink ?? (record.config.useWebDefaults ? 1 : 0); + } + + getFlexWrap(): Wrap { + return requireNode(undefined, this).declarations.flexWrap; + } + + getHeight(): Value { + return publicValue(requireNode(undefined, this).declarations.height); + } + + getJustifyContent(): Justify { + return requireNode(undefined, this).declarations.justifyContent; + } + + getGap(gutter: Gutter): Value { + const value = requireNode(undefined, this).declarations.gap[requireGutter(gutter)]; + return value.value as unknown as Value; + } + + getMargin(edge: Edge): Value { + return publicValue(requireNode(undefined, this).declarations.margin[requireEdge(edge)]); + } + + getMaxHeight(): Value { + return publicValue(requireNode(undefined, this).declarations.maxHeight); + } + + getMaxWidth(): Value { + return publicValue(requireNode(undefined, this).declarations.maxWidth); + } + + getMinHeight(): Value { + return publicValue(requireNode(undefined, this).declarations.minHeight); + } + + getMinWidth(): Value { + return publicValue(requireNode(undefined, this).declarations.minWidth); + } + + getOverflow(): Overflow { + return requireNode(undefined, this).declarations.overflow; + } + + getPadding(edge: Edge): Value { + return publicValue(requireNode(undefined, this).declarations.padding[requireEdge(edge)]); + } + + getPosition(edge: Edge): Value { + return publicValue(requireNode(undefined, this).declarations.position[requireEdge(edge)]); + } + + getPositionType(): PositionType { + return requireNode(undefined, this).declarations.positionType; + } + + getBoxSizing(): BoxSizing { + return requireNode(undefined, this).declarations.boxSizing; + } + + getWidth(): Value { + return publicValue(requireNode(undefined, this).declarations.width); + } + + setAlignContent(alignContent: AlignContentValue): void { + const value = requireEnum(alignContent, "alignContent", [ + Align.FlexStart, + Align.Center, + Align.FlexEnd, + Align.Stretch, + Align.SpaceBetween, + Align.SpaceAround, + Align.SpaceEvenly, + ]); + mutateDeclarations(this, (declarations) => { + declarations.alignContent = value; + }); + } + + setAlignItems(alignItems: AlignItemsValue): void { + const value = requireEnum(alignItems, "alignItems", [ + Align.FlexStart, + Align.Center, + Align.FlexEnd, + Align.Stretch, + Align.Baseline, + ]); + mutateDeclarations(this, (declarations) => { + declarations.alignItems = value; + }); + } + + setAlignSelf(alignSelf: AlignSelfValue): void { + const value = requireEnum(alignSelf, "alignSelf", [ + Align.Auto, + Align.FlexStart, + Align.Center, + Align.FlexEnd, + Align.Stretch, + Align.Baseline, + ]); + mutateDeclarations(this, (declarations) => { + declarations.alignSelf = value; + }); + } + + setAspectRatio(aspectRatio: number | undefined): void { + const value = normalizeAspectRatio(aspectRatio); + mutateDeclarations(this, (declarations) => { + declarations.aspectRatio = value; + }); + } + + setBorder(edge: Edge, borderWidth: number | undefined): void { + const index = requireEdge(edge); + const value = normalizePoint(borderWidth, "borderWidth"); + mutateDeclarations(this, (declarations) => { + declarations.border[index] = value; + }); + } + + setDirection(direction: Direction): void { + const value = requireEnum(direction, "direction", [ + Direction.Inherit, + Direction.LTR, + Direction.RTL, + ]); + mutateDeclarations(this, (declarations) => { + declarations.direction = value; + }); + } + + setDisplay(display: Display): void { + const value = requireEnum(display, "display", [Display.Flex, Display.None]); + mutateDeclarations(this, (declarations) => { + declarations.display = value; + }); + } + + setFlex(flex: number | undefined): void { + const value = normalizeFlexNumber(flex, "flex"); + mutateDeclarations(this, (declarations) => { + declarations.flex = value; + }); + } + + setFlexBasis(flexBasis: number | "auto" | `${number}%` | undefined): void { + const value = normalizeLength(flexBasis, "flexBasis", true); + mutateDeclarations(this, (declarations) => { + declarations.flexBasis = value; + }); + } + + setFlexBasisPercent(flexBasis: number | undefined): void { + const value = normalizePercent(flexBasis, "flexBasis"); + mutateDeclarations(this, (declarations) => { + declarations.flexBasis = value; + }); + } + + setFlexBasisAuto(): void { + mutateDeclarations(this, (declarations) => { + declarations.flexBasis = autoValue(); + }); + } + + setFlexDirection(flexDirection: FlexDirection): void { + const value = requireEnum(flexDirection, "flexDirection", [ + FlexDirection.Column, + FlexDirection.ColumnReverse, + FlexDirection.Row, + FlexDirection.RowReverse, + ]); + mutateDeclarations(this, (declarations) => { + declarations.flexDirection = value; + }); + } + + setFlexGrow(flexGrow: number | undefined): void { + const value = normalizeFlexNumber(flexGrow, "flexGrow"); + mutateDeclarations(this, (declarations) => { + declarations.flexGrow = value; + }); + } + + setFlexShrink(flexShrink: number | undefined): void { + const value = normalizeFlexNumber(flexShrink, "flexShrink"); + mutateDeclarations(this, (declarations) => { + declarations.flexShrink = value; + }); + } + + setFlexWrap(flexWrap: Wrap): void { + const value = requireEnum(flexWrap, "flexWrap", [Wrap.NoWrap, Wrap.Wrap, Wrap.WrapReverse]); + mutateDeclarations(this, (declarations) => { + declarations.flexWrap = value; + }); + } + + setHeight(height: number | "auto" | `${number}%` | undefined): void { + const value = normalizeLength(height, "height", true); + mutateDeclarations(this, (declarations) => { + declarations.height = value; + }); + } + + setHeightAuto(): void { + mutateDeclarations(this, (declarations) => { + declarations.height = autoValue(); + }); + } + + setHeightPercent(height: number | undefined): void { + const value = normalizePercent(height, "height"); + mutateDeclarations(this, (declarations) => { + declarations.height = value; + }); + } + + setJustifyContent(justifyContent: Justify): void { + const value = requireEnum(justifyContent, "justifyContent", [ + Justify.FlexStart, + Justify.Center, + Justify.FlexEnd, + Justify.SpaceBetween, + Justify.SpaceAround, + Justify.SpaceEvenly, + ]); + mutateDeclarations(this, (declarations) => { + declarations.justifyContent = value; + }); + } + + setGap(gutter: Gutter, gapLength: number | `${number}%` | undefined): Value { + const index = requireGutter(gutter); + const value = normalizeLength(gapLength, "gapLength", false); + mutateDeclarations(this, (declarations) => { + declarations.gap[index] = value; + }); + return undefined as unknown as Value; + } + + setGapPercent(gutter: Gutter, gapLength: number | undefined): Value { + const index = requireGutter(gutter); + const value = normalizePercent(gapLength, "gapLength"); + mutateDeclarations(this, (declarations) => { + declarations.gap[index] = value; + }); + return undefined as unknown as Value; + } + + setMargin(edge: Edge, margin: number | "auto" | `${number}%` | undefined): void { + const index = requireEdge(edge); + const value = normalizeLength(margin, "margin", true); + mutateDeclarations(this, (declarations) => { + declarations.margin[index] = value; + }); + } + + setMarginAuto(edge: Edge): void { + const index = requireEdge(edge); + mutateDeclarations(this, (declarations) => { + declarations.margin[index] = autoValue(); + }); + } + + setMarginPercent(edge: Edge, margin: number | undefined): void { + const index = requireEdge(edge); + const value = normalizePercent(margin, "margin"); + mutateDeclarations(this, (declarations) => { + declarations.margin[index] = value; + }); + } + + setMaxHeight(maxHeight: number | `${number}%` | undefined): void { + const value = normalizeLength(maxHeight, "maxHeight", false); + mutateDeclarations(this, (declarations) => { + declarations.maxHeight = value; + }); + } + + setMaxHeightPercent(maxHeight: number | undefined): void { + const value = normalizePercent(maxHeight, "maxHeight"); + mutateDeclarations(this, (declarations) => { + declarations.maxHeight = value; + }); + } + + setMaxWidth(maxWidth: number | `${number}%` | undefined): void { + const value = normalizeLength(maxWidth, "maxWidth", false); + mutateDeclarations(this, (declarations) => { + declarations.maxWidth = value; + }); + } + + setMaxWidthPercent(maxWidth: number | undefined): void { + const value = normalizePercent(maxWidth, "maxWidth"); + mutateDeclarations(this, (declarations) => { + declarations.maxWidth = value; + }); + } + + setMinHeight(minHeight: number | `${number}%` | undefined): void { + const value = normalizeLength(minHeight, "minHeight", false); + mutateDeclarations(this, (declarations) => { + declarations.minHeight = value; + }); + } + + setMinHeightPercent(minHeight: number | undefined): void { + const value = normalizePercent(minHeight, "minHeight"); + mutateDeclarations(this, (declarations) => { + declarations.minHeight = value; + }); + } + + setMinWidth(minWidth: number | `${number}%` | undefined): void { + const value = normalizeLength(minWidth, "minWidth", false); + mutateDeclarations(this, (declarations) => { + declarations.minWidth = value; + }); + } + + setMinWidthPercent(minWidth: number | undefined): void { + const value = normalizePercent(minWidth, "minWidth"); + mutateDeclarations(this, (declarations) => { + declarations.minWidth = value; + }); + } + + setOverflow(overflow: Overflow): void { + const value = requireEnum(overflow, "overflow", [ + Overflow.Visible, + Overflow.Hidden, + Overflow.Scroll, + ]); + mutateDeclarations(this, (declarations) => { + declarations.overflow = value; + }); + } + + setPadding(edge: Edge, padding: number | `${number}%` | undefined): void { + const index = requireEdge(edge); + const value = normalizeLength(padding, "padding", false); + mutateDeclarations(this, (declarations) => { + declarations.padding[index] = value; + }); + } + + setPaddingPercent(edge: Edge, padding: number | undefined): void { + const index = requireEdge(edge); + const value = normalizePercent(padding, "padding"); + mutateDeclarations(this, (declarations) => { + declarations.padding[index] = value; + }); + } + + setPosition(edge: Edge, position: number | `${number}%` | undefined): void { + const index = requireEdge(edge); + const value = normalizeLength(position, "position", false); + mutateDeclarations(this, (declarations) => { + declarations.position[index] = value; + }); + } + + setPositionPercent(edge: Edge, position: number | undefined): void { + const index = requireEdge(edge); + const value = normalizePercent(position, "position"); + mutateDeclarations(this, (declarations) => { + declarations.position[index] = value; + }); + } + + setPositionType(positionType: PositionType): void { + const value = requireEnum(positionType, "positionType", [ + PositionType.Relative, + PositionType.Absolute, + ]); + mutateDeclarations(this, (declarations) => { + declarations.positionType = value; + }); + } + + setPositionAuto(edge: Edge): void { + const index = requireEdge(edge); + mutateDeclarations(this, (declarations) => { + declarations.position[index] = autoValue(); + }); + } + + setBoxSizing(boxSizing: BoxSizing): void { + const value = requireEnum(boxSizing, "boxSizing", [BoxSizing.BorderBox, BoxSizing.ContentBox]); + mutateDeclarations(this, (declarations) => { + declarations.boxSizing = value; + }); + } + + setWidth(width: number | "auto" | `${number}%` | undefined): void { + const value = normalizeLength(width, "width", true); + mutateDeclarations(this, (declarations) => { + declarations.width = value; + }); + } + + setWidthAuto(): void { + mutateDeclarations(this, (declarations) => { + declarations.width = autoValue(); + }); + } + + setWidthPercent(width: number | undefined): void { + const value = normalizePercent(width, "width"); + mutateDeclarations(this, (declarations) => { + declarations.width = value; + }); } calculateLayout( @@ -157,14 +755,12 @@ class YogaNode implements Node { direction: Direction = Direction.LTR, ): void { const record = requireNode(undefined, this); - if (direction !== Direction.LTR && direction !== Direction.RTL) { - throw new TypeError("calculateLayout direction must be LTR or RTL"); - } - const resolvedDirection = direction === Direction.RTL ? TaffyDirection.Rtl : TaffyDirection.Ltr; - record.runtime.tree.setStyle( - record.nodeId, - styleFor(record.width, record.height, resolvedDirection), - ); + const ownerDirection = requireEnum(direction, "calculateLayout direction", [ + Direction.LTR, + Direction.RTL, + ]); + record.ownerDirection = ownerDirection as Direction.LTR | Direction.RTL; + syncStyle(record); record.runtime.tree.computeLayout({ root: record.nodeId, availableSpace: { @@ -212,17 +808,6 @@ class YogaNode implements Node { } } -function requireNode(runtime: FacadeRuntime | undefined, value: Node): NodeRecord { - if (!(value instanceof YogaNode)) throw new TypeError("Expected a Yoga Node"); - const record = nodeRecords.get(value); - if (record === undefined) throw new Error("Yoga Node state is unavailable"); - if (runtime !== undefined && record.runtime !== runtime) { - throw new TypeError("Node belongs to another Yoga facade"); - } - if (!record.alive) throw new Error("Node has been freed"); - return record; -} - function freeNode(record: NodeRecord): void { record.runtime.tree.remove(record.nodeId); record.runtime.nodes.delete(record.nodeId); diff --git a/packages/taffyjs-yoga/src/translate.ts b/packages/taffyjs-yoga/src/translate.ts new file mode 100644 index 0000000..05e9563 --- /dev/null +++ b/packages/taffyjs-yoga/src/translate.ts @@ -0,0 +1,285 @@ +import { + AlignContent as TaffyAlignContent, + AlignItems as TaffyAlignItems, + BoxSizing as TaffyBoxSizing, + Dimension as TaffyDimension, + Direction as TaffyDirection, + Display as TaffyDisplay, + FlexDirection as TaffyFlexDirection, + FlexWrap as TaffyFlexWrap, + Overflow as TaffyOverflow, + Position as TaffyPosition, + type AlignContent as TaffyAlignContentValue, + type AlignItems as TaffyAlignItemsValue, + type DimensionInput, + type StyleInput, +} from "@taffyjs/node"; +import { resolveEdge, resolveGutter, type YogaDeclarations } from "./declarations.js"; +import { + Align, + BoxSizing, + Direction, + Display, + Edge, + FlexDirection, + Gutter, + Justify, + Overflow, + PositionType, + Unit, + Wrap, +} from "./enums.js"; +import { + toDimension, + toLengthPercentage, + toLengthPercentageAuto, + undefinedValue, + type YogaValue, +} from "./values.js"; + +export interface TranslationConfig { + readonly useWebDefaults: boolean; +} + +function alignItems(value: Align): TaffyAlignItemsValue { + switch (value) { + case Align.FlexStart: + return TaffyAlignItems.FlexStart; + case Align.Center: + return TaffyAlignItems.Center; + case Align.FlexEnd: + return TaffyAlignItems.FlexEnd; + case Align.Stretch: + return TaffyAlignItems.Stretch; + case Align.Baseline: + return TaffyAlignItems.Baseline; + default: + throw new TypeError("Unsupported item alignment"); + } +} + +function alignContent(value: Align): TaffyAlignContentValue { + switch (value) { + case Align.FlexStart: + return TaffyAlignContent.FlexStart; + case Align.Center: + return TaffyAlignContent.Center; + case Align.FlexEnd: + return TaffyAlignContent.FlexEnd; + case Align.Stretch: + return TaffyAlignContent.Stretch; + case Align.SpaceBetween: + return TaffyAlignContent.SpaceBetween; + case Align.SpaceAround: + return TaffyAlignContent.SpaceAround; + case Align.SpaceEvenly: + return TaffyAlignContent.SpaceEvenly; + default: + throw new TypeError("Unsupported content alignment"); + } +} + +function justifyContent(value: Justify): TaffyAlignContentValue { + switch (value) { + case Justify.FlexStart: + return TaffyAlignContent.FlexStart; + case Justify.Center: + return TaffyAlignContent.Center; + case Justify.FlexEnd: + return TaffyAlignContent.FlexEnd; + case Justify.SpaceBetween: + return TaffyAlignContent.SpaceBetween; + case Justify.SpaceAround: + return TaffyAlignContent.SpaceAround; + case Justify.SpaceEvenly: + return TaffyAlignContent.SpaceEvenly; + } +} + +function direction(value: Direction.LTR | Direction.RTL) { + return value === Direction.RTL ? TaffyDirection.Rtl : TaffyDirection.Ltr; +} + +function flexDirection(value: FlexDirection) { + switch (value) { + case FlexDirection.Column: + return TaffyFlexDirection.Column; + case FlexDirection.ColumnReverse: + return TaffyFlexDirection.ColumnReverse; + case FlexDirection.Row: + return TaffyFlexDirection.Row; + case FlexDirection.RowReverse: + return TaffyFlexDirection.RowReverse; + } +} + +function flexWrap(value: Wrap) { + switch (value) { + case Wrap.NoWrap: + return TaffyFlexWrap.NoWrap; + case Wrap.Wrap: + return TaffyFlexWrap.Wrap; + case Wrap.WrapReverse: + return TaffyFlexWrap.WrapReverse; + } +} + +function physicalValue( + values: YogaDeclarations["margin"], + edge: Edge.Left | Edge.Top | Edge.Right | Edge.Bottom, + resolvedDirection: Direction.LTR | Direction.RTL, +): YogaValue { + return resolveEdge( + values, + edge, + resolvedDirection, + (value) => value.unit !== Unit.Undefined, + undefinedValue, + ); +} + +function physicalNumber( + values: YogaDeclarations["border"], + edge: Edge.Left | Edge.Top | Edge.Right | Edge.Bottom, + resolvedDirection: Direction.LTR | Direction.RTL, +): number { + return ( + resolveEdge( + values, + edge, + resolvedDirection, + (value) => value !== undefined, + () => 0, + ) ?? 0 + ); +} + +function rect(read: (edge: Edge.Left | Edge.Top | Edge.Right | Edge.Bottom) => T) { + return { + left: read(Edge.Left), + top: read(Edge.Top), + right: read(Edge.Right), + bottom: read(Edge.Bottom), + }; +} + +function effectiveFlexBasis( + declarations: YogaDeclarations, + config: TranslationConfig, +): DimensionInput { + if ( + declarations.flexBasis.unit === Unit.Auto && + declarations.flex !== undefined && + declarations.flex > 0 && + !config.useWebDefaults + ) { + return TaffyDimension.Length(0); + } + return toDimension(declarations.flexBasis); +} + +function effectiveFlexGrow(declarations: YogaDeclarations): number { + if (declarations.flexGrow !== undefined) return declarations.flexGrow; + return declarations.flex !== undefined && declarations.flex > 0 ? declarations.flex : 0; +} + +function effectiveFlexShrink(declarations: YogaDeclarations, config: TranslationConfig): number { + if (declarations.flexShrink !== undefined) return declarations.flexShrink; + if (!config.useWebDefaults && declarations.flex !== undefined && declarations.flex < 0) { + return -declarations.flex; + } + return config.useWebDefaults ? 1 : 0; +} + +export function translateStyle( + declarations: YogaDeclarations, + config: TranslationConfig, + resolvedDirection: Direction.LTR | Direction.RTL, +): StyleInput { + const margin = rect((edge) => + toLengthPercentageAuto(physicalValue(declarations.margin, edge, resolvedDirection)), + ); + const padding = rect((edge) => + toLengthPercentage(physicalValue(declarations.padding, edge, resolvedDirection)), + ); + const position = rect((edge) => + toDimension(physicalValue(declarations.position, edge, resolvedDirection)), + ); + const border = rect((edge) => physicalNumber(declarations.border, edge, resolvedDirection)); + const columnGap = resolveGutter( + declarations.gap, + Gutter.Column, + (value) => value.unit !== Unit.Undefined, + undefinedValue, + ); + const rowGap = resolveGutter( + declarations.gap, + Gutter.Row, + (value) => value.unit !== Unit.Undefined, + undefinedValue, + ); + + return { + display: declarations.display === Display.None ? TaffyDisplay.None : TaffyDisplay.Flex, + boxSizing: + declarations.boxSizing === BoxSizing.ContentBox + ? TaffyBoxSizing.ContentBox + : TaffyBoxSizing.BorderBox, + direction: direction(resolvedDirection), + overflow: { + x: + declarations.overflow === Overflow.Hidden + ? TaffyOverflow.Hidden + : declarations.overflow === Overflow.Scroll + ? TaffyOverflow.Scroll + : TaffyOverflow.Visible, + y: + declarations.overflow === Overflow.Hidden + ? TaffyOverflow.Hidden + : declarations.overflow === Overflow.Scroll + ? TaffyOverflow.Scroll + : TaffyOverflow.Visible, + }, + position: + declarations.positionType === PositionType.Absolute + ? TaffyPosition.Absolute + : TaffyPosition.Relative, + inset: position, + size: { + width: toDimension(declarations.width), + height: toDimension(declarations.height), + }, + minSize: { + width: toDimension(declarations.minWidth), + height: toDimension(declarations.minHeight), + }, + maxSize: { + width: toDimension(declarations.maxWidth), + height: toDimension(declarations.maxHeight), + }, + aspectRatio: declarations.aspectRatio ?? null, + margin, + padding, + border, + alignItems: alignItems(declarations.alignItems), + alignSelf: declarations.alignSelf === Align.Auto ? null : alignItems(declarations.alignSelf), + alignContent: alignContent(declarations.alignContent), + justifyContent: justifyContent(declarations.justifyContent), + gap: { + width: toLengthPercentage(columnGap), + height: toLengthPercentage(rowGap), + }, + flexDirection: flexDirection(declarations.flexDirection), + flexWrap: flexWrap(declarations.flexWrap), + flexBasis: effectiveFlexBasis(declarations, config), + flexGrow: effectiveFlexGrow(declarations), + flexShrink: effectiveFlexShrink(declarations, config), + }; +} + +export function declarationDirection( + declarations: YogaDeclarations, + ownerDirection: Direction.LTR | Direction.RTL, +): Direction.LTR | Direction.RTL { + return declarations.direction === Direction.Inherit ? ownerDirection : declarations.direction; +} diff --git a/packages/taffyjs-yoga/src/types.ts b/packages/taffyjs-yoga/src/types.ts index 5aaadf2..c0e536a 100644 --- a/packages/taffyjs-yoga/src/types.ts +++ b/packages/taffyjs-yoga/src/types.ts @@ -1,4 +1,20 @@ -import type { Direction, MeasureMode, Unit } from "./enums.js"; +import type { + Align, + BoxSizing, + Direction, + Display, + Edge, + Errata, + ExperimentalFeature, + FlexDirection, + Gutter, + Justify, + MeasureMode, + Overflow, + PositionType, + Unit, + Wrap, +} from "./enums.js"; import type { legacyConstants } from "./enums.js"; export interface Layout { @@ -31,14 +47,107 @@ export type MeasureFunction = ( export type LayoutDimension = number | "auto" | undefined; +export type AlignContentValue = + | Align.FlexStart + | Align.Center + | Align.FlexEnd + | Align.Stretch + | Align.SpaceBetween + | Align.SpaceAround + | Align.SpaceEvenly; + +export type AlignItemsValue = + | Align.FlexStart + | Align.Center + | Align.FlexEnd + | Align.Stretch + | Align.Baseline; + +export type AlignSelfValue = Align.Auto | AlignItemsValue; + export interface Config { free(): void; + isExperimentalFeatureEnabled(feature: ExperimentalFeature): boolean; + setExperimentalFeatureEnabled(feature: ExperimentalFeature, enabled: boolean): void; + setPointScaleFactor(factor: number): void; + getErrata(): Errata; + setErrata(errata: Errata): void; + useWebDefaults(): boolean; + setUseWebDefaults(useWebDefaults: boolean): void; } export interface Node { free(): void; - setWidth(value: number | string | undefined): void; - setHeight(value: number | string | undefined): void; + copyStyle(node: Node): void; + getAlignContent(): Align; + getAlignItems(): Align; + getAlignSelf(): Align; + getAspectRatio(): number; + getBorder(edge: Edge): number; + getDirection(): Direction; + getDisplay(): Display; + getFlexBasis(): Value; + getFlexDirection(): FlexDirection; + getFlexGrow(): number; + getFlexShrink(): number; + getFlexWrap(): Wrap; + getHeight(): Value; + getJustifyContent(): Justify; + getGap(gutter: Gutter): Value; + getMargin(edge: Edge): Value; + getMaxHeight(): Value; + getMaxWidth(): Value; + getMinHeight(): Value; + getMinWidth(): Value; + getOverflow(): Overflow; + getPadding(edge: Edge): Value; + getPosition(edge: Edge): Value; + getPositionType(): PositionType; + getBoxSizing(): BoxSizing; + getWidth(): Value; + setAlignContent(alignContent: AlignContentValue): void; + setAlignItems(alignItems: AlignItemsValue): void; + setAlignSelf(alignSelf: AlignSelfValue): void; + setAspectRatio(aspectRatio: number | undefined): void; + setBorder(edge: Edge, borderWidth: number | undefined): void; + setDirection(direction: Direction): void; + setDisplay(display: Display): void; + setFlex(flex: number | undefined): void; + setFlexBasis(flexBasis: number | "auto" | `${number}%` | undefined): void; + setFlexBasisPercent(flexBasis: number | undefined): void; + setFlexBasisAuto(): void; + setFlexDirection(flexDirection: FlexDirection): void; + setFlexGrow(flexGrow: number | undefined): void; + setFlexShrink(flexShrink: number | undefined): void; + setFlexWrap(flexWrap: Wrap): void; + setHeight(height: number | "auto" | `${number}%` | undefined): void; + setHeightAuto(): void; + setHeightPercent(height: number | undefined): void; + setJustifyContent(justifyContent: Justify): void; + setGap(gutter: Gutter, gapLength: number | `${number}%` | undefined): Value; + setGapPercent(gutter: Gutter, gapLength: number | undefined): Value; + setMargin(edge: Edge, margin: number | "auto" | `${number}%` | undefined): void; + setMarginAuto(edge: Edge): void; + setMarginPercent(edge: Edge, margin: number | undefined): void; + setMaxHeight(maxHeight: number | `${number}%` | undefined): void; + setMaxHeightPercent(maxHeight: number | undefined): void; + setMaxWidth(maxWidth: number | `${number}%` | undefined): void; + setMaxWidthPercent(maxWidth: number | undefined): void; + setMinHeight(minHeight: number | `${number}%` | undefined): void; + setMinHeightPercent(minHeight: number | undefined): void; + setMinWidth(minWidth: number | `${number}%` | undefined): void; + setMinWidthPercent(minWidth: number | undefined): void; + setOverflow(overflow: Overflow): void; + setPadding(edge: Edge, padding: number | `${number}%` | undefined): void; + setPaddingPercent(edge: Edge, padding: number | undefined): void; + setPosition(edge: Edge, position: number | `${number}%` | undefined): void; + setPositionPercent(edge: Edge, position: number | undefined): void; + setPositionType(positionType: PositionType): void; + setPositionAuto(edge: Edge): void; + setBoxSizing(boxSizing: BoxSizing): void; + setWidth(width: number | "auto" | `${number}%` | undefined): void; + setWidthAuto(): void; + setWidthPercent(width: number | undefined): void; calculateLayout(width: LayoutDimension, height: LayoutDimension, direction?: Direction): void; getComputedLeft(): number; getComputedRight(): number; diff --git a/packages/taffyjs-yoga/src/values.ts b/packages/taffyjs-yoga/src/values.ts new file mode 100644 index 0000000..97e7cb9 --- /dev/null +++ b/packages/taffyjs-yoga/src/values.ts @@ -0,0 +1,142 @@ +import { + Dimension as TaffyDimension, + type DimensionInput, + type LengthPercentageAutoInput, + type LengthPercentageInput, +} from "@taffyjs/node"; +import { Unit } from "./enums.js"; +import type { Value } from "./types.js"; + +export interface YogaValue { + readonly unit: Unit; + readonly value: number; +} + +const percentPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?%$/i; + +function float32(value: number): number { + if (!Number.isFinite(value)) return value; + if (value === 0) return 0; + return Math.fround(value); +} + +function finiteFloat32(value: number): number | undefined { + const normalized = float32(value); + return Number.isFinite(normalized) ? normalized : undefined; +} + +export function undefinedValue(): YogaValue { + return { unit: Unit.Undefined, value: Number.NaN }; +} + +export function autoValue(): YogaValue { + return { unit: Unit.Auto, value: Number.NaN }; +} + +function pointValue(value: number): YogaValue { + return { unit: Unit.Point, value }; +} + +function percentValue(value: number): YogaValue { + return { unit: Unit.Percent, value }; +} + +export function cloneValue(value: YogaValue): YogaValue { + return { unit: value.unit, value: value.value }; +} + +export function sameValue(left: YogaValue, right: YogaValue): boolean { + return left.unit === right.unit && Object.is(left.value, right.value); +} + +export function publicValue(value: YogaValue): Value { + return { value: value.value, unit: value.unit }; +} + +export function normalizeLength( + value: number | string | undefined, + name: string, + allowAuto: boolean, +): YogaValue { + if (value === undefined) return undefinedValue(); + if (typeof value === "number") { + if (!Number.isFinite(value)) return undefinedValue(); + const normalized = finiteFloat32(value); + return normalized === undefined ? undefinedValue() : pointValue(normalized); + } + if (typeof value !== "string") { + throw new TypeError( + `${name} must be a number, percentage,${allowAuto ? " auto," : ""} or undefined`, + ); + } + if (allowAuto && value === "auto") return autoValue(); + if (!percentPattern.test(value)) { + throw new TypeError( + `${name} must be a number, percentage,${allowAuto ? " auto," : ""} or undefined`, + ); + } + const numeric = Number(value.slice(0, -1)); + if (!Number.isFinite(numeric)) { + throw new TypeError(`${name} percentage must be finite`); + } + const normalized = finiteFloat32(numeric); + return normalized === undefined ? undefinedValue() : percentValue(normalized); +} + +export function normalizePercent(value: number | undefined, name: string): YogaValue { + if (value === undefined) return undefinedValue(); + if (typeof value !== "number") throw new TypeError(`${name} must be a number or undefined`); + if (!Number.isFinite(value)) return undefinedValue(); + const normalized = finiteFloat32(value); + return normalized === undefined ? undefinedValue() : percentValue(normalized); +} + +export function normalizePoint(value: number | undefined, name: string): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number") throw new TypeError(`${name} must be a number or undefined`); + if (!Number.isFinite(value)) return undefined; + return finiteFloat32(value); +} + +export function normalizeFlexNumber(value: number | undefined, name: string): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number") throw new TypeError(`${name} must be a number or undefined`); + if (Number.isNaN(value)) return undefined; + return float32(value); +} + +export function normalizeAspectRatio(value: number | undefined): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number") throw new TypeError("aspectRatio must be a number or undefined"); + if (!Number.isFinite(value) || value === 0) return undefined; + const normalized = finiteFloat32(value); + return normalized === undefined || normalized === 0 ? undefined : normalized; +} + +export function toDimension(value: YogaValue): DimensionInput { + switch (value.unit) { + case Unit.Point: + return TaffyDimension.Length(value.value); + case Unit.Percent: + return TaffyDimension.Percent(value.value); + case Unit.Auto: + case Unit.Undefined: + return TaffyDimension.Auto; + } +} + +export function toLengthPercentage(value: YogaValue): LengthPercentageInput { + switch (value.unit) { + case Unit.Point: + return TaffyDimension.Length(value.value); + case Unit.Percent: + return TaffyDimension.Percent(value.value); + case Unit.Auto: + case Unit.Undefined: + return 0; + } +} + +export function toLengthPercentageAuto(value: YogaValue): LengthPercentageAutoInput { + return value.unit === Unit.Undefined ? 0 : toDimension(value); +} diff --git a/tests/taffyjs-yoga/config.test.mts b/tests/taffyjs-yoga/config.test.mts new file mode 100644 index 0000000..5be54b9 --- /dev/null +++ b/tests/taffyjs-yoga/config.test.mts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import Yoga, { Align, ExperimentalFeature, FlexDirection } from "yoga-layout"; +import OracleYoga, { + Align as OracleAlign, + ExperimentalFeature as OracleExperimentalFeature, + FlexDirection as OracleFlexDirection, +} from "yoga-layout-oracle"; +import { test } from "vite-plus/test"; + +test("Config exposes the classified Yoga 3.2.1 state API", () => { + const config = Yoga.Config.create(); + const oracle = OracleYoga.Config.create(); + try { + assert.deepEqual(Object.getOwnPropertyNames(Object.getPrototypeOf(config)).sort(), [ + "constructor", + "free", + "getErrata", + "isExperimentalFeatureEnabled", + "setErrata", + "setExperimentalFeatureEnabled", + "setPointScaleFactor", + "setUseWebDefaults", + "useWebDefaults", + ]); + + assert.equal(config.useWebDefaults(), oracle.useWebDefaults()); + assert.equal(config.getErrata(), oracle.getErrata()); + assert.equal( + config.isExperimentalFeatureEnabled(ExperimentalFeature.WebFlexBasis), + oracle.isExperimentalFeatureEnabled(OracleExperimentalFeature.WebFlexBasis), + ); + + config.setUseWebDefaults(true); + oracle.setUseWebDefaults(true); + config.setExperimentalFeatureEnabled(ExperimentalFeature.WebFlexBasis, true); + oracle.setExperimentalFeatureEnabled(OracleExperimentalFeature.WebFlexBasis, true); + config.setPointScaleFactor(0.5); + oracle.setPointScaleFactor(0.5); + + assert.equal(config.useWebDefaults(), oracle.useWebDefaults()); + assert.equal( + config.isExperimentalFeatureEnabled(ExperimentalFeature.WebFlexBasis), + oracle.isExperimentalFeatureEnabled(OracleExperimentalFeature.WebFlexBasis), + ); + } finally { + config.free(); + oracle.free(); + } +}); + +test("useWebDefaults materializes construction defaults and leaves live defaults live", () => { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + const legacyNode = Yoga.Node.createWithConfig(config); + const oracleLegacyNode = OracleYoga.Node.createWithConfig(oracleConfig); + try { + assert.equal(legacyNode.getAlignContent(), Align.FlexStart); + assert.equal(legacyNode.getFlexDirection(), FlexDirection.Column); + assert.equal(legacyNode.getFlexShrink(), 0); + + config.setUseWebDefaults(true); + oracleConfig.setUseWebDefaults(true); + assert.equal(legacyNode.getAlignContent(), oracleLegacyNode.getAlignContent()); + assert.equal(legacyNode.getFlexDirection(), oracleLegacyNode.getFlexDirection()); + assert.equal(legacyNode.getFlexShrink(), oracleLegacyNode.getFlexShrink()); + + const webNode = Yoga.Node.createWithConfig(config); + const oracleWebNode = OracleYoga.Node.createWithConfig(oracleConfig); + try { + assert.equal(webNode.getAlignContent(), Align.Stretch); + assert.equal(webNode.getAlignContent(), OracleAlign.Stretch); + assert.equal(webNode.getFlexDirection(), FlexDirection.Row); + assert.equal(webNode.getFlexDirection(), OracleFlexDirection.Row); + assert.equal(webNode.getFlexShrink(), 1); + assert.equal(webNode.getFlexShrink(), oracleWebNode.getFlexShrink()); + } finally { + webNode.free(); + oracleWebNode.free(); + } + } finally { + legacyNode.free(); + oracleLegacyNode.free(); + config.free(); + oracleConfig.free(); + } +}); + +test("Nodes retain Config state after the public Config handle is freed", () => { + const config = Yoga.Config.create(); + config.setUseWebDefaults(true); + const node = Yoga.Node.createWithConfig(config); + + config.free(); + assert.throws(() => config.useWebDefaults(), /freed/); + assert.throws(() => Yoga.Node.createWithConfig(config), /freed/); + + assert.equal(node.getFlexShrink(), 1); + node.setWidth(12); + node.setHeight(7); + node.calculateLayout(undefined, undefined); + assert.equal(node.getComputedWidth(), 12); + node.free(); +}); + +test("Config rejects unsupported or malformed dynamic values before mutation", () => { + const config = Yoga.Config.create(); + try { + const originalWebDefaults = config.useWebDefaults(); + const originalExperiment = config.isExperimentalFeatureEnabled( + ExperimentalFeature.WebFlexBasis, + ); + + assert.throws(() => config.setUseWebDefaults(1 as never), TypeError); + assert.equal(config.useWebDefaults(), originalWebDefaults); + + assert.throws( + () => config.setExperimentalFeatureEnabled(ExperimentalFeature.WebFlexBasis, 1 as never), + TypeError, + ); + assert.equal( + config.isExperimentalFeatureEnabled(ExperimentalFeature.WebFlexBasis), + originalExperiment, + ); + + assert.throws(() => config.setExperimentalFeatureEnabled(99 as never, true), TypeError); + assert.throws(() => config.isExperimentalFeatureEnabled(99 as never), TypeError); + assert.throws(() => config.setErrata(1 as never), TypeError); + assert.equal(config.getErrata(), Yoga.ERRATA_NONE); + assert.throws(() => config.setPointScaleFactor(-1), TypeError); + assert.throws(() => config.setPointScaleFactor(Number.NaN), TypeError); + config.setPointScaleFactor(Number.POSITIVE_INFINITY); + } finally { + config.free(); + } +}); diff --git a/tests/taffyjs-yoga/style.test.mts b/tests/taffyjs-yoga/style.test.mts new file mode 100644 index 0000000..4d8980a --- /dev/null +++ b/tests/taffyjs-yoga/style.test.mts @@ -0,0 +1,441 @@ +import assert from "node:assert/strict"; +import Yoga, { + Align, + BoxSizing, + Direction, + Display, + Edge, + FlexDirection, + Gutter, + Justify, + Overflow, + PositionType, + Unit, + Wrap, + type Node, +} from "yoga-layout"; +import OracleYoga, { + Align as OracleAlign, + BoxSizing as OracleBoxSizing, + Direction as OracleDirection, + Display as OracleDisplay, + Edge as OracleEdge, + FlexDirection as OracleFlexDirection, + Gutter as OracleGutter, + Justify as OracleJustify, + Overflow as OracleOverflow, + PositionType as OraclePositionType, + Wrap as OracleWrap, + type Node as OracleNode, +} from "yoga-layout-oracle"; +import { test } from "vite-plus/test"; + +const edges = [ + Edge.Left, + Edge.Top, + Edge.Right, + Edge.Bottom, + Edge.Start, + Edge.End, + Edge.Horizontal, + Edge.Vertical, + Edge.All, +] as const; + +const gutters = [Gutter.Column, Gutter.Row, Gutter.All] as const; + +function assertStyleEqual(actual: Node, expected: OracleNode): void { + assert.equal(actual.getAlignContent(), expected.getAlignContent()); + assert.equal(actual.getAlignItems(), expected.getAlignItems()); + assert.equal(actual.getAlignSelf(), expected.getAlignSelf()); + assert.equal(actual.getAspectRatio(), expected.getAspectRatio()); + assert.equal(actual.getDirection(), expected.getDirection()); + assert.equal(actual.getDisplay(), expected.getDisplay()); + assert.deepEqual(actual.getFlexBasis(), expected.getFlexBasis()); + assert.equal(actual.getFlexDirection(), expected.getFlexDirection()); + assert.equal(actual.getFlexGrow(), expected.getFlexGrow()); + assert.equal(actual.getFlexShrink(), expected.getFlexShrink()); + assert.equal(actual.getFlexWrap(), expected.getFlexWrap()); + assert.deepEqual(actual.getHeight(), expected.getHeight()); + assert.equal(actual.getJustifyContent(), expected.getJustifyContent()); + assert.deepEqual(actual.getMaxHeight(), expected.getMaxHeight()); + assert.deepEqual(actual.getMaxWidth(), expected.getMaxWidth()); + assert.deepEqual(actual.getMinHeight(), expected.getMinHeight()); + assert.deepEqual(actual.getMinWidth(), expected.getMinWidth()); + assert.equal(actual.getOverflow(), expected.getOverflow()); + assert.equal(actual.getPositionType(), expected.getPositionType()); + assert.equal(actual.getBoxSizing(), expected.getBoxSizing()); + assert.deepEqual(actual.getWidth(), expected.getWidth()); + + for (const edge of edges) { + assert.equal(actual.getBorder(edge), expected.getBorder(edge as OracleEdge)); + assert.deepEqual(actual.getMargin(edge), expected.getMargin(edge as OracleEdge)); + assert.deepEqual(actual.getPadding(edge), expected.getPadding(edge as OracleEdge)); + assert.deepEqual(actual.getPosition(edge), expected.getPosition(edge as OracleEdge)); + } + for (const gutter of gutters) { + assert.equal( + actual.getGap(gutter) as unknown, + expected.getGap(gutter as OracleGutter) as unknown, + ); + } +} + +test("normal and web-default declaration getters match Yoga", () => { + for (const webDefaults of [false, true]) { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setUseWebDefaults(webDefaults); + oracleConfig.setUseWebDefaults(webDefaults); + const node = Yoga.Node.createWithConfig(config); + const oracleNode = OracleYoga.Node.createWithConfig(oracleConfig); + try { + assertStyleEqual(node, oracleNode); + } finally { + node.free(); + oracleNode.free(); + config.free(); + oracleConfig.free(); + } + } +}); + +test("every supported Style setter family round-trips like Yoga", () => { + const node = Yoga.Node.create(); + const oracle = OracleYoga.Node.create(); + try { + node.setAlignContent(Align.SpaceEvenly); + oracle.setAlignContent(OracleAlign.SpaceEvenly); + node.setAlignItems(Align.Baseline); + oracle.setAlignItems(OracleAlign.Baseline); + node.setAlignSelf(Align.Center); + oracle.setAlignSelf(OracleAlign.Center); + node.setAspectRatio(1.1); + oracle.setAspectRatio(1.1); + node.setBorder(Edge.Bottom, 2.3); + oracle.setBorder(OracleEdge.Bottom, 2.3); + node.setDirection(Direction.RTL); + oracle.setDirection(OracleDirection.RTL); + node.setDisplay(Display.None); + oracle.setDisplay(OracleDisplay.None); + node.setFlex(2.4); + oracle.setFlex(2.4); + node.setFlexBasis("12.5%"); + oracle.setFlexBasis("12.5%"); + node.setFlexBasisPercent(13.5); + oracle.setFlexBasisPercent(13.5); + node.setFlexBasisAuto(); + oracle.setFlexBasisAuto(); + node.setFlexDirection(FlexDirection.RowReverse); + oracle.setFlexDirection(OracleFlexDirection.RowReverse); + node.setFlexGrow(3.4); + oracle.setFlexGrow(3.4); + node.setFlexShrink(4.5); + oracle.setFlexShrink(4.5); + node.setFlexWrap(Wrap.WrapReverse); + oracle.setFlexWrap(OracleWrap.WrapReverse); + node.setHeight("42.5%"); + oracle.setHeight("42.5%"); + node.setHeightAuto(); + oracle.setHeightAuto(); + node.setHeightPercent(43.5); + oracle.setHeightPercent(43.5); + node.setJustifyContent(Justify.SpaceAround); + oracle.setJustifyContent(OracleJustify.SpaceAround); + assert.equal(node.setGap(Gutter.All, "7.5%") as unknown, undefined); + assert.equal(oracle.setGap(OracleGutter.All, "7.5%") as unknown, undefined); + assert.equal(node.setGapPercent(Gutter.Column, 8.5) as unknown, undefined); + assert.equal(oracle.setGapPercent(OracleGutter.Column, 8.5) as unknown, undefined); + node.setMargin(Edge.All, 9.5); + oracle.setMargin(OracleEdge.All, 9.5); + node.setMarginAuto(Edge.Left); + oracle.setMarginAuto(OracleEdge.Left); + node.setMarginPercent(Edge.Start, 10.5); + oracle.setMarginPercent(OracleEdge.Start, 10.5); + node.setMaxHeight("51.5%"); + oracle.setMaxHeight("51.5%"); + node.setMaxHeightPercent(52.5); + oracle.setMaxHeightPercent(52.5); + node.setMaxWidth("53.5%"); + oracle.setMaxWidth("53.5%"); + node.setMaxWidthPercent(54.5); + oracle.setMaxWidthPercent(54.5); + node.setMinHeight("31.5%"); + oracle.setMinHeight("31.5%"); + node.setMinHeightPercent(32.5); + oracle.setMinHeightPercent(32.5); + node.setMinWidth("33.5%"); + oracle.setMinWidth("33.5%"); + node.setMinWidthPercent(34.5); + oracle.setMinWidthPercent(34.5); + node.setOverflow(Overflow.Scroll); + oracle.setOverflow(OracleOverflow.Scroll); + node.setPadding(Edge.Horizontal, "11.5%"); + oracle.setPadding(OracleEdge.Horizontal, "11.5%"); + node.setPaddingPercent(Edge.End, 12.5); + oracle.setPaddingPercent(OracleEdge.End, 12.5); + node.setPosition(Edge.Right, "13.5%"); + oracle.setPosition(OracleEdge.Right, "13.5%"); + node.setPositionPercent(Edge.Top, 14.5); + oracle.setPositionPercent(OracleEdge.Top, 14.5); + node.setPositionAuto(Edge.Bottom); + oracle.setPositionAuto(OracleEdge.Bottom); + node.setPositionType(PositionType.Absolute); + oracle.setPositionType(OraclePositionType.Absolute); + node.setBoxSizing(BoxSizing.ContentBox); + oracle.setBoxSizing(OracleBoxSizing.ContentBox); + node.setWidth(61.5); + oracle.setWidth(61.5); + node.setWidthAuto(); + oracle.setWidthAuto(); + node.setWidthPercent(62.5); + oracle.setWidthPercent(62.5); + + assertStyleEqual(node, oracle); + } finally { + node.free(); + oracle.free(); + } +}); + +test("declared numeric boundary inputs normalize like Yoga", () => { + interface NumericCase { + setter: string; + getter: string; + arguments: readonly unknown[]; + getterArguments?: readonly unknown[]; + } + + const cases: NumericCase[] = []; + const numbers = [ + undefined, + Number.NaN, + Infinity, + -Infinity, + -0, + 0, + 0.1, + -3, + 1e39, + -1e39, + 1e-50, + ] as const; + const lengths = [...numbers, "25%", "-0%", "1e2%", "1e39%", "-1e39%"] as const; + const autoLengths = [...lengths, "auto"] as const; + + for (const [setter, getter, values] of [ + ["setWidth", "getWidth", autoLengths], + ["setHeight", "getHeight", autoLengths], + ["setFlexBasis", "getFlexBasis", autoLengths], + ["setMinWidth", "getMinWidth", lengths], + ["setMinHeight", "getMinHeight", lengths], + ["setMaxWidth", "getMaxWidth", lengths], + ["setMaxHeight", "getMaxHeight", lengths], + ] as const) { + for (const value of values) cases.push({ setter, getter, arguments: [value] }); + } + + for (const [setter, getter] of [ + ["setWidthPercent", "getWidth"], + ["setHeightPercent", "getHeight"], + ["setFlexBasisPercent", "getFlexBasis"], + ["setMinWidthPercent", "getMinWidth"], + ["setMinHeightPercent", "getMinHeight"], + ["setMaxWidthPercent", "getMaxWidth"], + ["setMaxHeightPercent", "getMaxHeight"], + ["setFlexGrow", "getFlexGrow"], + ["setFlexShrink", "getFlexShrink"], + ["setAspectRatio", "getAspectRatio"], + ] as const) { + for (const value of numbers) cases.push({ setter, getter, arguments: [value] }); + } + + for (const [setter, getter, edge, values] of [ + ["setMargin", "getMargin", Edge.Start, autoLengths], + ["setPadding", "getPadding", Edge.Horizontal, lengths], + ["setPosition", "getPosition", Edge.End, lengths], + ] as const) { + for (const value of values) { + cases.push({ setter, getter, arguments: [edge, value], getterArguments: [edge] }); + } + } + + for (const [setter, getter, edge] of [ + ["setMarginPercent", "getMargin", Edge.Start], + ["setPaddingPercent", "getPadding", Edge.Horizontal], + ["setPositionPercent", "getPosition", Edge.End], + ["setBorder", "getBorder", Edge.All], + ] as const) { + for (const value of numbers) { + cases.push({ setter, getter, arguments: [edge, value], getterArguments: [edge] }); + } + } + + for (const value of lengths) { + cases.push({ + setter: "setGap", + getter: "getGap", + arguments: [Gutter.Row, value], + getterArguments: [Gutter.Row], + }); + } + for (const value of numbers) { + cases.push({ + setter: "setGapPercent", + getter: "getGap", + arguments: [Gutter.Column, value], + getterArguments: [Gutter.Column], + }); + } + + for (const entry of cases) { + const node = Yoga.Node.create(); + const oracle = OracleYoga.Node.create(); + try { + const implementationView = node as unknown as Record< + string, + (...arguments_: readonly unknown[]) => unknown + >; + const oracleView = oracle as unknown as Record< + string, + (...arguments_: readonly unknown[]) => unknown + >; + implementationView[entry.setter](...entry.arguments); + oracleView[entry.setter](...entry.arguments); + assert.deepEqual( + implementationView[entry.getter](...(entry.getterArguments ?? [])), + oracleView[entry.getter](...(entry.getterArguments ?? [])), + `${entry.setter}(${entry.arguments.map(String).join(", ")})`, + ); + } finally { + node.free(); + oracle.free(); + } + } +}); + +test("Value getters return fresh Yoga-shaped records while gap keeps Yoga's runtime mismatch", () => { + const node = Yoga.Node.create(); + const oracle = OracleYoga.Node.create(); + try { + node.setWidth(0.1); + oracle.setWidth(0.1); + const width = node.getWidth(); + assert.deepEqual(width, oracle.getWidth()); + assert.deepEqual(Object.keys(width), Object.keys(oracle.getWidth())); + width.value = 99; + width.unit = Unit.Auto; + assert.deepEqual(node.getWidth(), oracle.getWidth()); + + node.setGap(Gutter.Row, "25%"); + oracle.setGap(OracleGutter.Row, "25%"); + assert.equal(typeof (node.getGap(Gutter.Row) as unknown), "number"); + assert.equal(node.getGap(Gutter.Row) as unknown, oracle.getGap(OracleGutter.Row) as unknown); + } finally { + node.free(); + oracle.free(); + } +}); + +test("copyStyle copies declarations but retains the destination Config", () => { + const webConfig = Yoga.Config.create(); + const oracleWebConfig = OracleYoga.Config.create(); + webConfig.setUseWebDefaults(true); + oracleWebConfig.setUseWebDefaults(true); + const source = Yoga.Node.create(); + const oracleSource = OracleYoga.Node.create(); + const destination = Yoga.Node.createWithConfig(webConfig); + const oracleDestination = OracleYoga.Node.createWithConfig(oracleWebConfig); + try { + source.setWidth("45%"); + oracleSource.setWidth("45%"); + source.setMargin(Edge.Start, 3); + oracleSource.setMargin(OracleEdge.Start, 3); + source.setFlex(2); + oracleSource.setFlex(2); + + destination.copyStyle(source); + oracleDestination.copyStyle(oracleSource); + assertStyleEqual(destination, oracleDestination); + assert.equal(destination.getFlexShrink(), 1); + + source.setWidth(90); + oracleSource.setWidth(90); + assert.notDeepEqual(destination.getWidth(), source.getWidth()); + assert.deepEqual(destination.getWidth(), oracleDestination.getWidth()); + } finally { + source.free(); + oracleSource.free(); + destination.free(); + oracleDestination.free(); + webConfig.free(); + oracleWebConfig.free(); + } +}); + +test("invalid and unsupported dynamic Style inputs fail without changing declarations", async () => { + const node = Yoga.Node.create(); + node.setWidth(25); + node.setMargin(Edge.Left, 4); + const width = node.getWidth(); + const margin = node.getMargin(Edge.Left); + try { + assert.throws(() => node.setWidth("12px" as never), TypeError); + assert.throws(() => node.setWidth({ unit: Unit.Point, value: 12 } as never), TypeError); + assert.throws(() => node.setAlignContent(Align.Auto as never), TypeError); + assert.throws(() => node.setAlignContent(Align.Baseline as never), TypeError); + assert.throws(() => node.setAlignItems(Align.SpaceBetween as never), TypeError); + assert.throws(() => node.setAlignSelf(Align.SpaceEvenly as never), TypeError); + assert.throws(() => node.setDisplay(2 as never), TypeError); + assert.throws(() => node.setPositionType(0 as never), TypeError); + assert.throws(() => node.setMargin(99 as never, 10), TypeError); + assert.throws(() => node.setGap(99 as never, 10), TypeError); + assert.deepEqual(node.getWidth(), width); + assert.deepEqual(node.getMargin(Edge.Left), margin); + + const { loadYoga } = await import("yoga-layout/load"); + const otherFacade = await loadYoga(); + const other = otherFacade.Node.create(); + try { + assert.throws(() => node.copyStyle(other), /another Yoga facade/); + assert.deepEqual(node.getWidth(), width); + } finally { + other.free(); + } + } finally { + node.free(); + } +}); + +test("translated fixed-leaf Style cases match Yoga geometry", () => { + const cases: Array<(node: Node | OracleNode) => void> = [ + (node) => { + node.setWidth(100); + node.setHeight(40); + node.setMinWidth(120); + }, + (node) => { + node.setWidth(100); + node.setHeight(40); + node.setPadding(Edge.All, 5); + node.setBorder(Edge.All, 2); + node.setBoxSizing(BoxSizing.ContentBox); + }, + ]; + + for (const configure of cases) { + const node = Yoga.Node.create(); + const oracle = OracleYoga.Node.create(); + try { + configure(node); + configure(oracle); + node.calculateLayout(undefined, undefined, Direction.LTR); + oracle.calculateLayout(undefined, undefined, OracleDirection.LTR); + assert.equal(node.getComputedWidth(), oracle.getComputedWidth()); + assert.equal(node.getComputedHeight(), oracle.getComputedHeight()); + } finally { + node.free(); + oracle.free(); + } + } +}); diff --git a/tests/taffyjs-yoga/types/public-api.test-d.ts b/tests/taffyjs-yoga/types/public-api.test-d.ts index 22d1cd5..89660b4 100644 --- a/tests/taffyjs-yoga/types/public-api.test-d.ts +++ b/tests/taffyjs-yoga/types/public-api.test-d.ts @@ -1,8 +1,18 @@ import Yoga, { + Align, + BoxSizing, Direction, Display, + Edge, Errata, + ExperimentalFeature, + FlexDirection, + Gutter, + Justify, + Overflow, PositionType, + Unit, + Wrap, type Config, type DirtiedFunction, type MeasureFunction, @@ -17,8 +27,64 @@ import { const config: Config = Yoga.Config.create(); const node: Node = Yoga.Node.create(config); +config.setUseWebDefaults(true); +const webDefaults: boolean = config.useWebDefaults(); +config.setExperimentalFeatureEnabled(ExperimentalFeature.WebFlexBasis, true); +const webFlexBasis: boolean = config.isExperimentalFeatureEnabled(ExperimentalFeature.WebFlexBasis); +config.setPointScaleFactor(2); +config.setErrata(Errata.None); +const errata: Errata = config.getErrata(); + +node.setAlignContent(Align.SpaceEvenly); +node.setAlignItems(Align.Baseline); +node.setAlignSelf(Align.Auto); +node.setAspectRatio(2); +node.setBorder(Edge.All, 1); +node.setDirection(Direction.Inherit); +node.setDisplay(Display.Flex); +node.setFlex(1); +node.setFlexBasis("25%"); +node.setFlexBasisPercent(25); +node.setFlexBasisAuto(); +node.setFlexDirection(FlexDirection.Row); +node.setFlexGrow(1); +node.setFlexShrink(1); +node.setFlexWrap(Wrap.Wrap); node.setWidth(100); +node.setWidthPercent(50); +node.setWidthAuto(); node.setHeight(40); +node.setHeightPercent(50); +node.setHeightAuto(); +node.setJustifyContent(Justify.SpaceBetween); +const declaredGapResult: { unit: Unit; value: number } = node.setGap(Gutter.Row, 4); +const declaredGapPercentResult: { unit: Unit; value: number } = node.setGapPercent( + Gutter.Column, + 5, +); +node.setMargin(Edge.Start, "auto"); +node.setMarginPercent(Edge.End, 5); +node.setMarginAuto(Edge.All); +node.setMinWidth("25%"); +node.setMinWidthPercent(25); +node.setMinHeight("25%"); +node.setMinHeightPercent(25); +node.setMaxWidth("75%"); +node.setMaxWidthPercent(75); +node.setMaxHeight("75%"); +node.setMaxHeightPercent(75); +node.setOverflow(Overflow.Hidden); +node.setPadding(Edge.Horizontal, "5%"); +node.setPaddingPercent(Edge.Vertical, 5); +node.setPosition(Edge.Left, "5%"); +node.setPositionPercent(Edge.Right, 5); +node.setPositionAuto(Edge.Top); +node.setPositionType(PositionType.Relative); +node.setBoxSizing(BoxSizing.BorderBox); +const styleWidth: { unit: Unit; value: number } = node.getWidth(); +const gap: { unit: Unit; value: number } = node.getGap(Gutter.Row); +const margin: { unit: Unit; value: number } = node.getMargin(Edge.Start); +node.copyStyle(node); node.calculateLayout(undefined, undefined, Direction.LTR); const width: number = node.getComputedWidth(); const dirtied: DirtiedFunction = (dirtyNode) => dirtyNode.getComputedWidth(); @@ -33,11 +99,29 @@ void Display.Contents; void PositionType.Static; // @ts-expect-error Non-None Errata values are unsupported and omitted. void Errata.Classic; +// @ts-expect-error Auto is not a valid align-content value. +node.setAlignContent(Align.Auto); +// @ts-expect-error Distribution values are not valid align-items values. +node.setAlignItems(Align.SpaceBetween); +// @ts-expect-error Distribution values are not valid align-self values. +node.setAlignSelf(Align.SpaceEvenly); +// @ts-expect-error Static is omitted and its numeric value is rejected. +node.setPositionType(0); +// @ts-expect-error Value-like objects are not accepted by generic setters. +node.setWidth(node.getWidth()); // @ts-expect-error The eager root does not export Yoga as a named type. type RootYoga = import("yoga-layout").Yoga; void [ width, + webDefaults, + webFlexBasis, + errata, + declaredGapResult, + declaredGapPercentResult, + styleWidth, + gap, + margin, dirtied, measure, loadedPromise, From 3a643f0669dfb412b8424514184dd7d834cc58c9 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 15 Aug 2026 15:44:18 +0800 Subject: [PATCH 05/18] Implement @taffyjs/yoga topology and state --- .agents/docs/loop-status.md | 11 +- ...{facade-CLOoIkHD.js => facade-vnf8jWiE.js} | 298 +++++++++++- packages/taffyjs-yoga/dist/index.d.ts | 2 +- packages/taffyjs-yoga/dist/index.js | 2 +- packages/taffyjs-yoga/dist/load.d.ts | 2 +- packages/taffyjs-yoga/dist/load.js | 2 +- ...ypes-Dm1ehQuc.d.ts => types-B1oAtvXl.d.ts} | 18 + packages/taffyjs-yoga/src/facade.ts | 432 +++++++++++++++++- packages/taffyjs-yoga/src/types.ts | 18 + pnpm-lock.yaml | 3 + tests/taffyjs-yoga/lifecycle.test.mts | 128 ++++++ tests/taffyjs-yoga/package.json | 1 + tests/taffyjs-yoga/state.test.mts | 249 ++++++++++ tests/taffyjs-yoga/topology.test.mts | 206 +++++++++ tests/taffyjs-yoga/types/public-api.test-d.ts | 29 ++ 15 files changed, 1366 insertions(+), 35 deletions(-) rename packages/taffyjs-yoga/dist/{facade-CLOoIkHD.js => facade-vnf8jWiE.js} (75%) rename packages/taffyjs-yoga/dist/{types-Dm1ehQuc.d.ts => types-B1oAtvXl.d.ts} (93%) create mode 100644 tests/taffyjs-yoga/lifecycle.test.mts create mode 100644 tests/taffyjs-yoga/state.test.mts create mode 100644 tests/taffyjs-yoga/topology.test.mts diff --git a/.agents/docs/loop-status.md b/.agents/docs/loop-status.md index 824e66e..20117af 100644 --- a/.agents/docs/loop-status.md +++ b/.agents/docs/loop-status.md @@ -4,7 +4,7 @@ Updated: 2026-08-15 ## State -Milestone 2 is complete, reviewed, and ready in its milestone commit. Milestone 3 is next. +Milestone 3 is complete, reviewed, and ready in its milestone commit. Milestone 4 is next. ## Done @@ -22,6 +22,11 @@ Milestone 2 is complete, reviewed, and ready in its milestone commit. Milestone - Added the complete normalized Yoga Style declaration model, all 26 classified Style getters and 43 setters, strict declared-input normalization, fresh Yoga-shaped Value records, Yoga's deliberate gap declaration/runtime mismatch, `copyStyle`, and type-level narrowing for invalid Align contexts. - Added deterministic full-Style translation for dimensions, min/max constraints, box sizing, display, overflow, aspect ratio, direction, logical and shorthand edges, position, margin, padding, border, gaps, alignment, flex direction and wrapping, and Config-sensitive flex shorthand and longhands. Setters validate and build a candidate first, apply native Style second, and only then commit the public declaration state. - Added differential Config and Style fixtures, invalid-input atomicity checks, retained-Config and facade-isolation checks, fixed-leaf translation cases, and a table-driven 345-case numeric boundary corpus against the pinned Yoga 3.2.1 oracle. +- Added canonical Node identity backed by the facade's single native `TaffyTree`, native-authoritative child and parent reads, strict insertion and removal validation, arbitrary selected-subtree calculation, cross-facade rejection, one-parent and acyclic constraints, and measured-leaf topology restrictions without a JavaScript child tree. +- Added deterministic `free`, iterative `freeRecursive`, factory destruction, detached-leaf `reset`, retained Config ownership, single-node free with preserved detached child subtrees, and facade poisoning after an unexpected partial multi-step native failure. +- Added Yoga-visible dirty and new-layout state, layout-seen transitions, canonical dirtied-callback arguments, post-commit callback errors, measurement markers, manual dirty restrictions, supported containing-block state, and type/runtime rejection for unsupported reference-baseline selection. +- Added one pre-calculation subtree pass that resolves inherited direction and each Node's Config revision, prepares all translated Styles before native mutation, synchronizes every stale Style, and reuses the same selected-subtree snapshot for Yoga-visible state commits. +- Added durable topology, lifecycle, state, callback, fault-injection, independent-Config, and public-type fixtures while keeping Taffy as the sole topology and layout authority. ## Evidence and review @@ -32,6 +37,8 @@ Milestone 2 is complete, reviewed, and ready in its milestone commit. Milestone - The Milestone 2 full `vp run check` passes: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 17 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. - The Milestone 2 adversarial reviewer found one numeric boundary error: finite JavaScript numbers such as `1e39` can overflow only after f32 conversion, and length-like or aspect-ratio declarations must then clear to Undefined while flex numbers retain Infinity. The normalization was corrected after independent reproduction, `±1e39`, `1e-50`, and `±1e39%` were added to the oracle fixture, and the same reviewer returned PASS on the targeted follow-up. - A bounded calculation probe found that Yoga and Taffy differ when `Display.None` is applied to the selected calculation root with declared dimensions. Style storage and direct display translation remain complete in Milestone 2; the calculated-output trigger is retained for Milestone 4 classification and regression coverage rather than being hidden in the declaration layer. +- The Milestone 3 full `vp run check` passes: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 36 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. +- The Milestone 3 adversarial reviewer found two native-cache/state gaps. Single-node and recursive release marked only Yoga-visible surviving ancestors, so Taffy could reuse an obsolete parent layout; Config freshness was checked only on the selected Node, so an independently configured descendant could miss both Style synchronization and `hasNewLayout`. Release now invalidates the surviving native parent and direct detached child roots with post-commit poisoning on failure, while calculation uses one full selected-subtree Style plan. The exact 30-by-10 release and independent child-Config reproductions now pass, and the same reviewer returned PASS on the targeted follow-up. ## In flight @@ -39,7 +46,7 @@ Milestone 2 is complete, reviewed, and ready in its milestone commit. Milestone ## Next -- Implement Milestone 3: topology and lifecycle operations, canonical identity, reset, callbacks, measurement markers, and Yoga-visible dirty and new-layout state. +- Implement Milestone 4: calculated-output synchronization, Yoga-shaped geometry, direction and edge behavior, rounding, and the classified Yoga/Taffy output differences. ## Blocked diff --git a/packages/taffyjs-yoga/dist/facade-CLOoIkHD.js b/packages/taffyjs-yoga/dist/facade-vnf8jWiE.js similarity index 75% rename from packages/taffyjs-yoga/dist/facade-CLOoIkHD.js rename to packages/taffyjs-yoga/dist/facade-vnf8jWiE.js index a19e189..248259b 100644 --- a/packages/taffyjs-yoga/dist/facade-CLOoIkHD.js +++ b/packages/taffyjs-yoga/dist/facade-vnf8jWiE.js @@ -567,10 +567,19 @@ var FacadeRuntime = class { tree = new TaffyTree(); nodes = /* @__PURE__ */ new Map(); defaultConfig = new ConfigState(); + poisoned = null; constructor() { this.tree.disableRounding(); } }; +function assertRuntimeUsable(runtime) { + if (runtime.poisoned !== null) throw runtime.poisoned; +} +function poisonRuntime(runtime, cause) { + const error = new Error("Yoga facade is unusable after an unexpected partial native failure", { cause }); + runtime.poisoned = error; + return error; +} const configRecords = /* @__PURE__ */ new WeakMap(); function requireBoolean(value, name) { if (typeof value !== "boolean") throw new TypeError(`${name} must be a boolean`); @@ -582,6 +591,7 @@ function requireEnum(value, name, allowed) { } var YogaConfig = class { constructor(runtime, state) { + assertRuntimeUsable(runtime); configRecords.set(this, { runtime, state, @@ -636,6 +646,7 @@ function requireConfig(runtime, value) { const record = configRecords.get(value); if (record === void 0) throw new Error("Yoga Config state is unavailable"); if (runtime !== void 0 && record.runtime !== runtime) throw new TypeError("Config belongs to another Yoga facade"); + assertRuntimeUsable(record.runtime); if (!record.alive) throw new Error("Config has been freed"); return record; } @@ -653,20 +664,66 @@ function requireNode(runtime, value) { const record = nodeRecords.get(value); if (record === void 0) throw new Error("Yoga Node state is unavailable"); if (runtime !== void 0 && record.runtime !== runtime) throw new TypeError("Node belongs to another Yoga facade"); + assertRuntimeUsable(record.runtime); if (!record.alive) throw new Error("Node has been freed"); return record; } +function nodeForRecord(record) { + const node = record.runtime.nodes.get(record.nodeId); + if (node === void 0) throw poisonRuntime(record.runtime, /* @__PURE__ */ new Error("Node registry mismatch")); + return node; +} +function recordForId(runtime, nodeId) { + const node = runtime.nodes.get(nodeId); + if (node === void 0) throw poisonRuntime(runtime, /* @__PURE__ */ new Error("Node registry mismatch")); + const record = nodeRecords.get(node); + if (record === void 0 || !record.alive) throw poisonRuntime(runtime, /* @__PURE__ */ new Error("Node record mismatch")); + return record; +} +function collectAncestors(record, includeSelf) { + const records = []; + let nodeId = includeSelf ? record.nodeId : record.runtime.tree.getParent(record.nodeId); + while (nodeId !== null) { + const current = recordForId(record.runtime, nodeId); + records.push(current); + nodeId = record.runtime.tree.getParent(nodeId); + } + return records; +} +function collectSubtree(record) { + const records = []; + const pending = [record.nodeId]; + while (pending.length > 0) { + const nodeId = pending.pop(); + if (nodeId === void 0) break; + records.push(recordForId(record.runtime, nodeId)); + const children = record.runtime.tree.getChildren(nodeId); + for (let index = children.length - 1; index >= 0; index -= 1) pending.push(children[index]); + } + return records; +} +function markDirtyRecords(records) { + const notifications = []; + for (const record of records) { + if (record.dirty) continue; + record.dirty = true; + if (record.dirtiedFunction !== null) notifications.push([record.dirtiedFunction, nodeForRecord(record)]); + } + for (const [callback, node] of notifications) callback(node); +} function effectiveDirection(record, declarations = record.declarations) { return declarationDirection(declarations, record.ownerDirection); } function applyDeclarations(record, declarations) { if (sameDeclarations(record.declarations, declarations)) return; + const dirtyRecords = collectAncestors(record, true); const direction = effectiveDirection(record, declarations); const style = translateStyle(declarations, record.config, direction); record.runtime.tree.setStyle(record.nodeId, style); record.declarations = declarations; record.appliedDirection = direction; record.appliedConfigRevision = record.config.revision; + markDirtyRecords(dirtyRecords); } function mutateDeclarations(node, mutate) { const record = requireNode(void 0, node); @@ -674,12 +731,45 @@ function mutateDeclarations(node, mutate) { mutate(next); applyDeclarations(record, next); } -function syncStyle(record) { - const direction = effectiveDirection(record); - if (record.appliedConfigRevision === record.config.revision && record.appliedDirection === direction) return; - record.runtime.tree.setStyle(record.nodeId, translateStyle(record.declarations, record.config, direction)); - record.appliedDirection = direction; - record.appliedConfigRevision = record.config.revision; +function syncSubtreeStyles(root, rootOwnerDirection, subtree) { + const directions = /* @__PURE__ */ new Map(); + const plan = []; + for (const record of subtree) { + let ownerDirection; + if (record === root) ownerDirection = rootOwnerDirection; + else { + const parentId = root.runtime.tree.getParent(record.nodeId); + const parentDirection = parentId === null ? void 0 : directions.get(parentId); + if (parentDirection === void 0) throw poisonRuntime(root.runtime, /* @__PURE__ */ new Error("Yoga subtree traversal order mismatch")); + ownerDirection = parentDirection; + } + const appliedDirection = declarationDirection(record.declarations, ownerDirection); + const stale = record.appliedConfigRevision !== record.config.revision || record.appliedDirection !== appliedDirection; + plan.push({ + record, + ownerDirection, + appliedDirection, + style: stale ? translateStyle(record.declarations, record.config, appliedDirection) : void 0 + }); + directions.set(record.nodeId, appliedDirection); + } + let nativeWrites = 0; + for (const entry of plan) { + if (entry.style === void 0) continue; + try { + root.runtime.tree.setStyle(entry.record.nodeId, entry.style); + } catch (error) { + if (nativeWrites > 0) throw poisonRuntime(root.runtime, error); + throw error; + } + nativeWrites += 1; + } + for (const entry of plan) { + entry.record.ownerDirection = entry.ownerDirection; + entry.record.appliedDirection = entry.appliedDirection; + entry.record.appliedConfigRevision = entry.record.config.revision; + } + return nativeWrites > 0; } const physicalEdges = [ 0, @@ -702,8 +792,27 @@ function requireGutter(gutter) { 2 ]); } +function requireInsertIndex(index, childCount) { + if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index > childCount) throw new RangeError("Child index is out of range"); + return index; +} +function lookupChildIndex(index) { + if (typeof index !== "number") throw new TypeError("Child index must be a number"); + return Number.isInteger(index) && index >= 0 ? index : null; +} +function assertAcyclicInsertion(parent, child) { + let current = parent.nodeId; + while (current !== null) { + if (current === child.nodeId) throw new Error("Cannot create a cycle in a Yoga tree"); + current = parent.runtime.tree.getParent(current); + } +} +function sameCalculation(left, right) { + return left !== void 0 && Object.is(left.width, right.width) && Object.is(left.height, right.height) && left.direction === right.direction; +} var YogaNode = class { constructor(runtime, config) { + assertRuntimeUsable(runtime); const declarations = createDeclarations(config.useWebDefaults); const ownerDirection = 1; const appliedDirection = declarationDirection(declarations, ownerDirection); @@ -717,13 +826,131 @@ var YogaNode = class { ownerDirection, appliedDirection, appliedConfigRevision: config.revision, - layout: initialLayout() + layout: initialLayout(), + dirty: true, + hasNewLayout: true, + dirtiedFunction: null, + measureFunction: null, + referenceBaseline: false, + alwaysFormsContainingBlock: false, + lastCalculation: void 0 }); runtime.nodes.set(nodeId, this); } free() { freeNode(requireNode(void 0, this)); } + freeRecursive() { + freeRecursiveNode(requireNode(void 0, this)); + } + getChild(index) { + const record = requireNode(void 0, this); + const resolvedIndex = lookupChildIndex(index); + if (resolvedIndex === null || resolvedIndex >= record.runtime.tree.getChildCount(record.nodeId)) return null; + const childId = record.runtime.tree.getChildAtIndex(record.nodeId, resolvedIndex); + return nodeForRecord(recordForId(record.runtime, childId)); + } + getChildCount() { + const record = requireNode(void 0, this); + return record.runtime.tree.getChildCount(record.nodeId); + } + getParent() { + const record = requireNode(void 0, this); + const parentId = record.runtime.tree.getParent(record.nodeId); + return parentId === null ? null : nodeForRecord(recordForId(record.runtime, parentId)); + } + insertChild(child, index) { + const parentRecord = requireNode(void 0, this); + const childRecord = requireNode(parentRecord.runtime, child); + const resolvedIndex = requireInsertIndex(index, parentRecord.runtime.tree.getChildCount(parentRecord.nodeId)); + if (parentRecord.measureFunction !== null) throw new Error("Measured Yoga nodes cannot have children"); + if (parentRecord.runtime.tree.getParent(childRecord.nodeId) !== null) throw new Error("Yoga child already has a parent"); + assertAcyclicInsertion(parentRecord, childRecord); + const dirtyRecords = collectAncestors(parentRecord, true); + parentRecord.runtime.tree.insertChildAtIndex(parentRecord.nodeId, resolvedIndex, childRecord.nodeId); + childRecord.lastCalculation = void 0; + markDirtyRecords(dirtyRecords); + } + removeChild(child) { + const parentRecord = requireNode(void 0, this); + const childRecord = requireNode(parentRecord.runtime, child); + if (parentRecord.runtime.tree.getParent(childRecord.nodeId) !== parentRecord.nodeId) return; + const dirtyRecords = collectAncestors(parentRecord, true); + parentRecord.runtime.tree.removeChild(parentRecord.nodeId, childRecord.nodeId); + try { + parentRecord.runtime.tree.markDirty(childRecord.nodeId); + } catch (error) { + throw poisonRuntime(parentRecord.runtime, error); + } + childRecord.layout = initialLayout(); + childRecord.lastCalculation = void 0; + markDirtyRecords(dirtyRecords); + } + isDirty() { + return requireNode(void 0, this).dirty; + } + markDirty() { + const record = requireNode(void 0, this); + if (record.measureFunction === null || record.runtime.tree.getChildCount(record.nodeId) !== 0) throw new Error("Only measured Yoga leaves can be manually marked dirty"); + const dirtyRecords = collectAncestors(record, true); + record.runtime.tree.markDirty(record.nodeId); + markDirtyRecords(dirtyRecords); + } + hasNewLayout() { + return requireNode(void 0, this).hasNewLayout; + } + markLayoutSeen() { + requireNode(void 0, this).hasNewLayout = false; + } + reset() { + const record = requireNode(void 0, this); + if (record.runtime.tree.getParent(record.nodeId) !== null || record.runtime.tree.getChildCount(record.nodeId) !== 0) throw new Error("Only a detached Yoga leaf can be reset"); + const declarations = createDeclarations(record.config.useWebDefaults); + const ownerDirection = 1; + const appliedDirection = declarationDirection(declarations, ownerDirection); + record.runtime.tree.setStyle(record.nodeId, translateStyle(declarations, record.config, appliedDirection)); + record.declarations = declarations; + record.ownerDirection = ownerDirection; + record.appliedDirection = appliedDirection; + record.appliedConfigRevision = record.config.revision; + record.layout = initialLayout(); + record.dirty = true; + record.hasNewLayout = true; + record.dirtiedFunction = null; + record.measureFunction = null; + record.referenceBaseline = false; + record.alwaysFormsContainingBlock = false; + record.lastCalculation = void 0; + } + isReferenceBaseline() { + return requireNode(void 0, this).referenceBaseline; + } + setIsReferenceBaseline(isReferenceBaseline) { + const record = requireNode(void 0, this); + if (requireBoolean(isReferenceBaseline, "isReferenceBaseline")) throw new TypeError("Reference baselines are unsupported"); + record.referenceBaseline = false; + } + setAlwaysFormsContainingBlock(alwaysFormsContainingBlock) { + const record = requireNode(void 0, this); + record.alwaysFormsContainingBlock = requireBoolean(alwaysFormsContainingBlock, "alwaysFormsContainingBlock"); + } + setMeasureFunc(measureFunc) { + const record = requireNode(void 0, this); + if (measureFunc !== null && typeof measureFunc !== "function") throw new TypeError("measureFunc must be a function or null"); + if (measureFunc !== null && record.runtime.tree.getChildCount(record.nodeId) !== 0) throw new Error("Measured Yoga nodes cannot have children"); + record.measureFunction = measureFunc; + } + unsetMeasureFunc() { + requireNode(void 0, this).measureFunction = null; + } + setDirtiedFunc(dirtiedFunc) { + const record = requireNode(void 0, this); + if (dirtiedFunc !== null && typeof dirtiedFunc !== "function") throw new TypeError("dirtiedFunc must be a function or null"); + record.dirtiedFunction = dirtiedFunc; + } + unsetDirtiedFunc() { + requireNode(void 0, this).dirtiedFunction = null; + } copyStyle(node) { const record = requireNode(void 0, this); applyDeclarations(record, cloneDeclarations(requireNode(record.runtime, node).declarations)); @@ -1118,13 +1345,22 @@ var YogaNode = class { } calculateLayout(width, height, direction = 1) { const record = requireNode(void 0, this); - record.ownerDirection = requireEnum(direction, "calculateLayout direction", [1, 2]); - syncStyle(record); + const ownerDirection = requireEnum(direction, "calculateLayout direction", [1, 2]); + const availableWidth = requireCalculationDimension(width, "width"); + const availableHeight = requireCalculationDimension(height, "height"); + const signature = { + width: typeof availableWidth === "number" ? Math.fround(availableWidth) : void 0, + height: typeof availableHeight === "number" ? Math.fround(availableHeight) : void 0, + direction: ownerDirection + }; + const subtree = collectSubtree(record); + const translationIsStale = syncSubtreeStyles(record, ownerDirection, subtree); + const recomputeSubtree = record.dirty || translationIsStale || !sameCalculation(record.lastCalculation, signature); record.runtime.tree.computeLayout({ root: record.nodeId, availableSpace: { - width: requireCalculationDimension(width, "width"), - height: requireCalculationDimension(height, "height") + width: availableWidth, + height: availableHeight } }); const layout = record.runtime.tree.getUnroundedLayout(record.nodeId); @@ -1136,6 +1372,12 @@ var YogaNode = class { width: layout.size.width, height: layout.size.height }; + for (const current of subtree) { + current.dirty = false; + if (recomputeSubtree) current.hasNewLayout = true; + } + record.hasNewLayout = true; + record.lastCalculation = signature; } getComputedLeft() { return requireNode(void 0, this).layout.left; @@ -1160,9 +1402,43 @@ var YogaNode = class { } }; function freeNode(record) { + const survivingAncestors = collectAncestors(record, false); + const directChildren = record.runtime.tree.getChildren(record.nodeId).map((nodeId) => recordForId(record.runtime, nodeId)); record.runtime.tree.remove(record.nodeId); record.runtime.nodes.delete(record.nodeId); record.alive = false; + for (const child of directChildren) child.lastCalculation = void 0; + try { + const survivingParent = survivingAncestors[0]; + if (survivingParent !== void 0) record.runtime.tree.markDirty(survivingParent.nodeId); + for (const child of directChildren) record.runtime.tree.markDirty(child.nodeId); + } catch (error) { + throw poisonRuntime(record.runtime, error); + } + markDirtyRecords(survivingAncestors); +} +function freeRecursiveNode(record) { + const survivingAncestors = collectAncestors(record, false); + const postorder = collectSubtree(record).reverse(); + let removed = 0; + for (const current of postorder) { + try { + record.runtime.tree.remove(current.nodeId); + } catch (error) { + if (removed > 0) throw poisonRuntime(record.runtime, error); + throw error; + } + record.runtime.nodes.delete(current.nodeId); + current.alive = false; + removed += 1; + } + const survivingParent = survivingAncestors[0]; + if (survivingParent !== void 0) try { + record.runtime.tree.markDirty(survivingParent.nodeId); + } catch (error) { + throw poisonRuntime(record.runtime, error); + } + markDirtyRecords(survivingAncestors); } function createFactories(runtime) { return { diff --git a/packages/taffyjs-yoga/dist/index.d.ts b/packages/taffyjs-yoga/dist/index.d.ts index d2572c1..3520b31 100644 --- a/packages/taffyjs-yoga/dist/index.d.ts +++ b/packages/taffyjs-yoga/dist/index.d.ts @@ -1,4 +1,4 @@ -import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, n as ConfigFactory, o as NodeFactory, p as Edge, r as DirtiedFunction, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-Dm1ehQuc.js"; +import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, n as ConfigFactory, o as NodeFactory, p as Edge, r as DirtiedFunction, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-B1oAtvXl.js"; //#region src/index.d.ts declare const Yoga: Readonly<{ Config: ConfigFactory; diff --git a/packages/taffyjs-yoga/dist/index.js b/packages/taffyjs-yoga/dist/index.js index 1aef7cc..1a50e17 100644 --- a/packages/taffyjs-yoga/dist/index.js +++ b/packages/taffyjs-yoga/dist/index.js @@ -1,4 +1,4 @@ -import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-CLOoIkHD.js"; +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-vnf8jWiE.js"; //#region src/index.ts const Yoga = createYoga(); //#endregion diff --git a/packages/taffyjs-yoga/dist/load.d.ts b/packages/taffyjs-yoga/dist/load.d.ts index edfb387..d4dfae5 100644 --- a/packages/taffyjs-yoga/dist/load.d.ts +++ b/packages/taffyjs-yoga/dist/load.d.ts @@ -1,4 +1,4 @@ -import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, p as Edge, r as DirtiedFunction, s as Yoga, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-Dm1ehQuc.js"; +import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, p as Edge, r as DirtiedFunction, s as Yoga, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-B1oAtvXl.js"; //#region src/load.d.ts declare function loadYoga(): Promise; //#endregion diff --git a/packages/taffyjs-yoga/dist/load.js b/packages/taffyjs-yoga/dist/load.js index 5ad4cc3..3bcc662 100644 --- a/packages/taffyjs-yoga/dist/load.js +++ b/packages/taffyjs-yoga/dist/load.js @@ -1,4 +1,4 @@ -import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-CLOoIkHD.js"; +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-vnf8jWiE.js"; //#region src/load.ts async function loadYoga() { return createYoga(); diff --git a/packages/taffyjs-yoga/dist/types-Dm1ehQuc.d.ts b/packages/taffyjs-yoga/dist/types-B1oAtvXl.d.ts similarity index 93% rename from packages/taffyjs-yoga/dist/types-Dm1ehQuc.d.ts rename to packages/taffyjs-yoga/dist/types-B1oAtvXl.d.ts index 868d3bb..8e73e05 100644 --- a/packages/taffyjs-yoga/dist/types-Dm1ehQuc.d.ts +++ b/packages/taffyjs-yoga/dist/types-B1oAtvXl.d.ts @@ -203,7 +203,25 @@ interface Config { } interface Node { free(): void; + freeRecursive(): void; copyStyle(node: Node): void; + getChild(index: number): Node; + getChildCount(): number; + getParent(): Node | null; + insertChild(child: Node, index: number): void; + removeChild(child: Node): void; + isDirty(): boolean; + markDirty(): void; + hasNewLayout(): boolean; + markLayoutSeen(): void; + reset(): void; + isReferenceBaseline(): boolean; + setIsReferenceBaseline(isReferenceBaseline: false): void; + setAlwaysFormsContainingBlock(alwaysFormsContainingBlock: boolean): void; + setMeasureFunc(measureFunc: MeasureFunction | null): void; + unsetMeasureFunc(): void; + setDirtiedFunc(dirtiedFunc: DirtiedFunction | null): void; + unsetDirtiedFunc(): void; getAlignContent(): Align; getAlignItems(): Align; getAlignSelf(): Align; diff --git a/packages/taffyjs-yoga/src/facade.ts b/packages/taffyjs-yoga/src/facade.ts index ed56595..52573d3 100644 --- a/packages/taffyjs-yoga/src/facade.ts +++ b/packages/taffyjs-yoga/src/facade.ts @@ -28,8 +28,10 @@ import type { AlignSelfValue, Config, ConfigFactory, + DirtiedFunction, Layout, LayoutDimension, + MeasureFunction, Node, NodeFactory, Value, @@ -66,12 +68,25 @@ class FacadeRuntime { readonly tree = new TaffyTree(); readonly nodes = new Map(); readonly defaultConfig = new ConfigState(); + poisoned: Error | null = null; constructor() { this.tree.disableRounding(); } } +function assertRuntimeUsable(runtime: FacadeRuntime): void { + if (runtime.poisoned !== null) throw runtime.poisoned; +} + +function poisonRuntime(runtime: FacadeRuntime, cause: unknown): Error { + const error = new Error("Yoga facade is unusable after an unexpected partial native failure", { + cause, + }); + runtime.poisoned = error; + return error; +} + interface ConfigRecord { readonly runtime: FacadeRuntime; readonly state: ConfigState; @@ -94,6 +109,7 @@ function requireEnum(value: T, name: string, allowed: readonly class YogaConfig implements Config { constructor(runtime: FacadeRuntime, state: ConfigState) { + assertRuntimeUsable(runtime); configRecords.set(this, { runtime, state, alive: true }); } @@ -157,6 +173,7 @@ function requireConfig(runtime: FacadeRuntime | undefined, value: Config): Confi if (runtime !== undefined && record.runtime !== runtime) { throw new TypeError("Config belongs to another Yoga facade"); } + assertRuntimeUsable(record.runtime); if (!record.alive) throw new Error("Config has been freed"); return record; } @@ -185,6 +202,19 @@ interface NodeRecord { appliedDirection: Direction.LTR | Direction.RTL; appliedConfigRevision: number; layout: Layout; + dirty: boolean; + hasNewLayout: boolean; + dirtiedFunction: DirtiedFunction | null; + measureFunction: MeasureFunction | null; + referenceBaseline: boolean; + alwaysFormsContainingBlock: boolean; + lastCalculation: CalculationSignature | undefined; +} + +interface CalculationSignature { + readonly width: number | undefined; + readonly height: number | undefined; + readonly direction: Direction.LTR | Direction.RTL; } const nodeRecords = new WeakMap(); @@ -196,22 +226,81 @@ function requireNode(runtime: FacadeRuntime | undefined, value: Node): NodeRecor if (runtime !== undefined && record.runtime !== runtime) { throw new TypeError("Node belongs to another Yoga facade"); } + assertRuntimeUsable(record.runtime); if (!record.alive) throw new Error("Node has been freed"); return record; } +function nodeForRecord(record: NodeRecord): YogaNode { + const node = record.runtime.nodes.get(record.nodeId); + if (node === undefined) throw poisonRuntime(record.runtime, new Error("Node registry mismatch")); + return node; +} + +function recordForId(runtime: FacadeRuntime, nodeId: NodeId): NodeRecord { + const node = runtime.nodes.get(nodeId); + if (node === undefined) throw poisonRuntime(runtime, new Error("Node registry mismatch")); + const record = nodeRecords.get(node); + if (record === undefined || !record.alive) { + throw poisonRuntime(runtime, new Error("Node record mismatch")); + } + return record; +} + +function collectAncestors(record: NodeRecord, includeSelf: boolean): NodeRecord[] { + const records: NodeRecord[] = []; + let nodeId: NodeId | null = includeSelf + ? record.nodeId + : record.runtime.tree.getParent(record.nodeId); + while (nodeId !== null) { + const current = recordForId(record.runtime, nodeId); + records.push(current); + nodeId = record.runtime.tree.getParent(nodeId); + } + return records; +} + +function collectSubtree(record: NodeRecord): NodeRecord[] { + const records: NodeRecord[] = []; + const pending = [record.nodeId]; + while (pending.length > 0) { + const nodeId = pending.pop(); + if (nodeId === undefined) break; + records.push(recordForId(record.runtime, nodeId)); + const children = record.runtime.tree.getChildren(nodeId); + for (let index = children.length - 1; index >= 0; index -= 1) { + pending.push(children[index]); + } + } + return records; +} + +function markDirtyRecords(records: readonly NodeRecord[]): void { + const notifications: Array = []; + for (const record of records) { + if (record.dirty) continue; + record.dirty = true; + if (record.dirtiedFunction !== null) { + notifications.push([record.dirtiedFunction, nodeForRecord(record)]); + } + } + for (const [callback, node] of notifications) callback(node); +} + function effectiveDirection(record: NodeRecord, declarations = record.declarations) { return declarationDirection(declarations, record.ownerDirection); } function applyDeclarations(record: NodeRecord, declarations: YogaDeclarations): void { if (sameDeclarations(record.declarations, declarations)) return; + const dirtyRecords = collectAncestors(record, true); const direction = effectiveDirection(record, declarations); const style = translateStyle(declarations, record.config, direction); record.runtime.tree.setStyle(record.nodeId, style); record.declarations = declarations; record.appliedDirection = direction; record.appliedConfigRevision = record.config.revision; + markDirtyRecords(dirtyRecords); } function mutateDeclarations( @@ -224,20 +313,67 @@ function mutateDeclarations( applyDeclarations(record, next); } -function syncStyle(record: NodeRecord): void { - const direction = effectiveDirection(record); - if ( - record.appliedConfigRevision === record.config.revision && - record.appliedDirection === direction - ) { - return; +interface StyleSyncPlanEntry { + readonly record: NodeRecord; + readonly ownerDirection: Direction.LTR | Direction.RTL; + readonly appliedDirection: Direction.LTR | Direction.RTL; + readonly style: ReturnType | undefined; +} + +function syncSubtreeStyles( + root: NodeRecord, + rootOwnerDirection: Direction.LTR | Direction.RTL, + subtree: readonly NodeRecord[], +): boolean { + const directions = new Map(); + const plan: StyleSyncPlanEntry[] = []; + + for (const record of subtree) { + let ownerDirection: Direction.LTR | Direction.RTL; + if (record === root) { + ownerDirection = rootOwnerDirection; + } else { + const parentId = root.runtime.tree.getParent(record.nodeId); + const parentDirection = parentId === null ? undefined : directions.get(parentId); + if (parentDirection === undefined) { + throw poisonRuntime(root.runtime, new Error("Yoga subtree traversal order mismatch")); + } + ownerDirection = parentDirection; + } + + const appliedDirection = declarationDirection(record.declarations, ownerDirection); + const stale = + record.appliedConfigRevision !== record.config.revision || + record.appliedDirection !== appliedDirection; + plan.push({ + record, + ownerDirection, + appliedDirection, + style: stale + ? translateStyle(record.declarations, record.config, appliedDirection) + : undefined, + }); + directions.set(record.nodeId, appliedDirection); } - record.runtime.tree.setStyle( - record.nodeId, - translateStyle(record.declarations, record.config, direction), - ); - record.appliedDirection = direction; - record.appliedConfigRevision = record.config.revision; + + let nativeWrites = 0; + for (const entry of plan) { + if (entry.style === undefined) continue; + try { + root.runtime.tree.setStyle(entry.record.nodeId, entry.style); + } catch (error) { + if (nativeWrites > 0) throw poisonRuntime(root.runtime, error); + throw error; + } + nativeWrites += 1; + } + + for (const entry of plan) { + entry.record.ownerDirection = entry.ownerDirection; + entry.record.appliedDirection = entry.appliedDirection; + entry.record.appliedConfigRevision = entry.record.config.revision; + } + return nativeWrites > 0; } const physicalEdges = [ @@ -260,8 +396,41 @@ function requireGutter(gutter: Gutter): Gutter { return requireEnum(gutter, "gutter", [Gutter.Column, Gutter.Row, Gutter.All]); } +function requireInsertIndex(index: number, childCount: number): number { + if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index > childCount) { + throw new RangeError("Child index is out of range"); + } + return index; +} + +function lookupChildIndex(index: number): number | null { + if (typeof index !== "number") throw new TypeError("Child index must be a number"); + return Number.isInteger(index) && index >= 0 ? index : null; +} + +function assertAcyclicInsertion(parent: NodeRecord, child: NodeRecord): void { + let current: NodeId | null = parent.nodeId; + while (current !== null) { + if (current === child.nodeId) throw new Error("Cannot create a cycle in a Yoga tree"); + current = parent.runtime.tree.getParent(current); + } +} + +function sameCalculation( + left: CalculationSignature | undefined, + right: CalculationSignature, +): boolean { + return ( + left !== undefined && + Object.is(left.width, right.width) && + Object.is(left.height, right.height) && + left.direction === right.direction + ); +} + class YogaNode implements Node { constructor(runtime: FacadeRuntime, config: ConfigState) { + assertRuntimeUsable(runtime); const declarations = createDeclarations(config.useWebDefaults); const ownerDirection = Direction.LTR; const appliedDirection = declarationDirection(declarations, ownerDirection); @@ -276,6 +445,13 @@ class YogaNode implements Node { appliedDirection, appliedConfigRevision: config.revision, layout: initialLayout(), + dirty: true, + hasNewLayout: true, + dirtiedFunction: null, + measureFunction: null, + referenceBaseline: false, + alwaysFormsContainingBlock: false, + lastCalculation: undefined, }); runtime.nodes.set(nodeId, this); } @@ -284,6 +460,172 @@ class YogaNode implements Node { freeNode(requireNode(undefined, this)); } + freeRecursive(): void { + freeRecursiveNode(requireNode(undefined, this)); + } + + getChild(index: number): Node { + const record = requireNode(undefined, this); + const resolvedIndex = lookupChildIndex(index); + if ( + resolvedIndex === null || + resolvedIndex >= record.runtime.tree.getChildCount(record.nodeId) + ) { + return null as unknown as Node; + } + const childId = record.runtime.tree.getChildAtIndex(record.nodeId, resolvedIndex); + return nodeForRecord(recordForId(record.runtime, childId)); + } + + getChildCount(): number { + const record = requireNode(undefined, this); + return record.runtime.tree.getChildCount(record.nodeId); + } + + getParent(): Node | null { + const record = requireNode(undefined, this); + const parentId = record.runtime.tree.getParent(record.nodeId); + return parentId === null ? null : nodeForRecord(recordForId(record.runtime, parentId)); + } + + insertChild(child: Node, index: number): void { + const parentRecord = requireNode(undefined, this); + const childRecord = requireNode(parentRecord.runtime, child); + const childCount = parentRecord.runtime.tree.getChildCount(parentRecord.nodeId); + const resolvedIndex = requireInsertIndex(index, childCount); + if (parentRecord.measureFunction !== null) { + throw new Error("Measured Yoga nodes cannot have children"); + } + if (parentRecord.runtime.tree.getParent(childRecord.nodeId) !== null) { + throw new Error("Yoga child already has a parent"); + } + assertAcyclicInsertion(parentRecord, childRecord); + const dirtyRecords = collectAncestors(parentRecord, true); + + parentRecord.runtime.tree.insertChildAtIndex( + parentRecord.nodeId, + resolvedIndex, + childRecord.nodeId, + ); + childRecord.lastCalculation = undefined; + markDirtyRecords(dirtyRecords); + } + + removeChild(child: Node): void { + const parentRecord = requireNode(undefined, this); + const childRecord = requireNode(parentRecord.runtime, child); + if (parentRecord.runtime.tree.getParent(childRecord.nodeId) !== parentRecord.nodeId) return; + const dirtyRecords = collectAncestors(parentRecord, true); + + parentRecord.runtime.tree.removeChild(parentRecord.nodeId, childRecord.nodeId); + try { + parentRecord.runtime.tree.markDirty(childRecord.nodeId); + } catch (error) { + throw poisonRuntime(parentRecord.runtime, error); + } + childRecord.layout = initialLayout(); + childRecord.lastCalculation = undefined; + markDirtyRecords(dirtyRecords); + } + + isDirty(): boolean { + return requireNode(undefined, this).dirty; + } + + markDirty(): void { + const record = requireNode(undefined, this); + if (record.measureFunction === null || record.runtime.tree.getChildCount(record.nodeId) !== 0) { + throw new Error("Only measured Yoga leaves can be manually marked dirty"); + } + const dirtyRecords = collectAncestors(record, true); + record.runtime.tree.markDirty(record.nodeId); + markDirtyRecords(dirtyRecords); + } + + hasNewLayout(): boolean { + return requireNode(undefined, this).hasNewLayout; + } + + markLayoutSeen(): void { + requireNode(undefined, this).hasNewLayout = false; + } + + reset(): void { + const record = requireNode(undefined, this); + if ( + record.runtime.tree.getParent(record.nodeId) !== null || + record.runtime.tree.getChildCount(record.nodeId) !== 0 + ) { + throw new Error("Only a detached Yoga leaf can be reset"); + } + const declarations = createDeclarations(record.config.useWebDefaults); + const ownerDirection = Direction.LTR; + const appliedDirection = declarationDirection(declarations, ownerDirection); + record.runtime.tree.setStyle( + record.nodeId, + translateStyle(declarations, record.config, appliedDirection), + ); + + record.declarations = declarations; + record.ownerDirection = ownerDirection; + record.appliedDirection = appliedDirection; + record.appliedConfigRevision = record.config.revision; + record.layout = initialLayout(); + record.dirty = true; + record.hasNewLayout = true; + record.dirtiedFunction = null; + record.measureFunction = null; + record.referenceBaseline = false; + record.alwaysFormsContainingBlock = false; + record.lastCalculation = undefined; + } + + isReferenceBaseline(): boolean { + return requireNode(undefined, this).referenceBaseline; + } + + setIsReferenceBaseline(isReferenceBaseline: boolean): void { + const record = requireNode(undefined, this); + const value = requireBoolean(isReferenceBaseline, "isReferenceBaseline"); + if (value) throw new TypeError("Reference baselines are unsupported"); + record.referenceBaseline = false; + } + + setAlwaysFormsContainingBlock(alwaysFormsContainingBlock: boolean): void { + const record = requireNode(undefined, this); + record.alwaysFormsContainingBlock = requireBoolean( + alwaysFormsContainingBlock, + "alwaysFormsContainingBlock", + ); + } + + setMeasureFunc(measureFunc: MeasureFunction | null): void { + const record = requireNode(undefined, this); + if (measureFunc !== null && typeof measureFunc !== "function") { + throw new TypeError("measureFunc must be a function or null"); + } + if (measureFunc !== null && record.runtime.tree.getChildCount(record.nodeId) !== 0) { + throw new Error("Measured Yoga nodes cannot have children"); + } + record.measureFunction = measureFunc; + } + + unsetMeasureFunc(): void { + requireNode(undefined, this).measureFunction = null; + } + + setDirtiedFunc(dirtiedFunc: DirtiedFunction | null): void { + const record = requireNode(undefined, this); + if (dirtiedFunc !== null && typeof dirtiedFunc !== "function") { + throw new TypeError("dirtiedFunc must be a function or null"); + } + record.dirtiedFunction = dirtiedFunc; + } + + unsetDirtiedFunc(): void { + requireNode(undefined, this).dirtiedFunction = null; + } + copyStyle(node: Node): void { const record = requireNode(undefined, this); const source = requireNode(record.runtime, node); @@ -758,14 +1100,23 @@ class YogaNode implements Node { const ownerDirection = requireEnum(direction, "calculateLayout direction", [ Direction.LTR, Direction.RTL, - ]); - record.ownerDirection = ownerDirection as Direction.LTR | Direction.RTL; - syncStyle(record); + ]) as Direction.LTR | Direction.RTL; + const availableWidth = requireCalculationDimension(width, "width"); + const availableHeight = requireCalculationDimension(height, "height"); + const signature: CalculationSignature = { + width: typeof availableWidth === "number" ? Math.fround(availableWidth) : undefined, + height: typeof availableHeight === "number" ? Math.fround(availableHeight) : undefined, + direction: ownerDirection, + }; + const subtree = collectSubtree(record); + const translationIsStale = syncSubtreeStyles(record, ownerDirection, subtree); + const recomputeSubtree = + record.dirty || translationIsStale || !sameCalculation(record.lastCalculation, signature); record.runtime.tree.computeLayout({ root: record.nodeId, availableSpace: { - width: requireCalculationDimension(width, "width"), - height: requireCalculationDimension(height, "height"), + width: availableWidth, + height: availableHeight, }, }); const layout = record.runtime.tree.getUnroundedLayout(record.nodeId); @@ -777,6 +1128,12 @@ class YogaNode implements Node { width: layout.size.width, height: layout.size.height, }; + for (const current of subtree) { + current.dirty = false; + if (recomputeSubtree) current.hasNewLayout = true; + } + record.hasNewLayout = true; + record.lastCalculation = signature; } getComputedLeft(): number { @@ -809,9 +1166,48 @@ class YogaNode implements Node { } function freeNode(record: NodeRecord): void { + const survivingAncestors = collectAncestors(record, false); + const directChildren = record.runtime.tree + .getChildren(record.nodeId) + .map((nodeId) => recordForId(record.runtime, nodeId)); record.runtime.tree.remove(record.nodeId); record.runtime.nodes.delete(record.nodeId); record.alive = false; + for (const child of directChildren) child.lastCalculation = undefined; + try { + const survivingParent = survivingAncestors[0]; + if (survivingParent !== undefined) record.runtime.tree.markDirty(survivingParent.nodeId); + for (const child of directChildren) record.runtime.tree.markDirty(child.nodeId); + } catch (error) { + throw poisonRuntime(record.runtime, error); + } + markDirtyRecords(survivingAncestors); +} + +function freeRecursiveNode(record: NodeRecord): void { + const survivingAncestors = collectAncestors(record, false); + const postorder = collectSubtree(record).reverse(); + let removed = 0; + for (const current of postorder) { + try { + record.runtime.tree.remove(current.nodeId); + } catch (error) { + if (removed > 0) throw poisonRuntime(record.runtime, error); + throw error; + } + record.runtime.nodes.delete(current.nodeId); + current.alive = false; + removed += 1; + } + const survivingParent = survivingAncestors[0]; + if (survivingParent !== undefined) { + try { + record.runtime.tree.markDirty(survivingParent.nodeId); + } catch (error) { + throw poisonRuntime(record.runtime, error); + } + } + markDirtyRecords(survivingAncestors); } function createFactories(runtime: FacadeRuntime): { diff --git a/packages/taffyjs-yoga/src/types.ts b/packages/taffyjs-yoga/src/types.ts index c0e536a..d6dc028 100644 --- a/packages/taffyjs-yoga/src/types.ts +++ b/packages/taffyjs-yoga/src/types.ts @@ -78,7 +78,25 @@ export interface Config { export interface Node { free(): void; + freeRecursive(): void; copyStyle(node: Node): void; + getChild(index: number): Node; + getChildCount(): number; + getParent(): Node | null; + insertChild(child: Node, index: number): void; + removeChild(child: Node): void; + isDirty(): boolean; + markDirty(): void; + hasNewLayout(): boolean; + markLayoutSeen(): void; + reset(): void; + isReferenceBaseline(): boolean; + setIsReferenceBaseline(isReferenceBaseline: false): void; + setAlwaysFormsContainingBlock(alwaysFormsContainingBlock: boolean): void; + setMeasureFunc(measureFunc: MeasureFunction | null): void; + unsetMeasureFunc(): void; + setDirtiedFunc(dirtiedFunc: DirtiedFunction | null): void; + unsetDirtiedFunc(): void; getAlignContent(): Align; getAlignItems(): Align; getAlignSelf(): Align; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 775d539..516b719 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -368,6 +368,9 @@ importers: specifier: workspace:@taffyjs/yoga@* version: link:../../packages/taffyjs-yoga devDependencies: + '@taffyjs/node': + specifier: workspace:* + version: link:../../packages/taffyjs-node '@types/node': specifier: 'catalog:' version: 22.18.0 diff --git a/tests/taffyjs-yoga/lifecycle.test.mts b/tests/taffyjs-yoga/lifecycle.test.mts new file mode 100644 index 0000000..0758f80 --- /dev/null +++ b/tests/taffyjs-yoga/lifecycle.test.mts @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import Yoga, { Align, FlexDirection, Unit } from "yoga-layout"; +import { test } from "vite-plus/test"; + +test("free removes exactly one attached node and preserves its child subtrees", () => { + const root = Yoga.Node.create(); + const middle = Yoga.Node.create(); + const leaf = Yoga.Node.create(); + middle.setWidth(30); + middle.setHeight(10); + root.insertChild(middle, 0); + middle.insertChild(leaf, 0); + root.calculateLayout(undefined, undefined); + root.markLayoutSeen(); + middle.markLayoutSeen(); + leaf.markLayoutSeen(); + assert.equal(root.getComputedWidth(), 30); + assert.equal(root.getComputedHeight(), 10); + + middle.free(); + assert.equal(root.getChildCount(), 0); + assert.equal(root.isDirty(), true); + assert.equal(leaf.getParent(), null); + assert.equal(leaf.isDirty(), false); + assert.equal(leaf.hasNewLayout(), false); + assert.throws(() => middle.getParent(), /freed/); + + root.calculateLayout(undefined, undefined); + assert.equal(root.getComputedWidth(), 0); + assert.equal(root.getComputedHeight(), 0); + + leaf.setWidth(7); + leaf.calculateLayout(undefined, undefined); + assert.equal(leaf.getComputedWidth(), 7); + root.free(); + leaf.free(); +}); + +test("freeRecursive invalidates only the selected subtree", () => { + const root = Yoga.Node.create(); + const selected = Yoga.Node.create(); + const descendant = Yoga.Node.create(); + const sibling = Yoga.Node.create(); + selected.setWidth(30); + selected.setHeight(10); + root.insertChild(selected, 0); + root.insertChild(sibling, 1); + selected.insertChild(descendant, 0); + root.calculateLayout(undefined, undefined); + root.markLayoutSeen(); + sibling.markLayoutSeen(); + assert.equal(root.getComputedWidth(), 30); + assert.equal(root.getComputedHeight(), 10); + + selected.freeRecursive(); + assert.equal(root.getChildCount(), 1); + assert.equal(root.getChild(0), sibling); + assert.equal(sibling.getParent(), root); + assert.equal(root.isDirty(), true); + assert.equal(sibling.isDirty(), false); + assert.throws(() => selected.getChildCount(), /freed/); + assert.throws(() => descendant.getParent(), /freed/); + + root.calculateLayout(undefined, undefined); + assert.equal(root.getComputedWidth(), 0); + assert.equal(root.getComputedHeight(), 0); + + root.freeRecursive(); +}); + +test("factory destroy uses the same deterministic lifetime path", () => { + const node = Yoga.Node.create(); + Yoga.Node.destroy(node); + assert.throws(() => node.getWidth(), /freed/); + assert.throws(() => Yoga.Node.destroy(node), /freed/); +}); + +test("reset preserves identity and Config while restoring every Node-owned input", () => { + const config = Yoga.Config.create(); + const node = Yoga.Node.createWithConfig(config); + let dirtiedCalls = 0; + node.setWidth(30); + node.setFlexGrow(2); + node.setMeasureFunc(() => ({ width: 3, height: 4 })); + node.setDirtiedFunc(() => { + dirtiedCalls += 1; + }); + node.setAlwaysFormsContainingBlock(true); + node.calculateLayout(undefined, undefined); + node.markLayoutSeen(); + + config.setUseWebDefaults(true); + node.reset(); + assert.deepEqual(node.getWidth(), { value: Number.NaN, unit: Unit.Auto }); + assert.equal(node.getFlexGrow(), 0); + assert.equal(node.getFlexShrink(), 1); + assert.equal(node.getFlexDirection(), FlexDirection.Row); + assert.equal(node.getAlignContent(), Align.Stretch); + assert.equal(node.isReferenceBaseline(), false); + assert.equal(node.isDirty(), true); + assert.equal(node.hasNewLayout(), true); + assert.deepEqual(node.getComputedLayout(), { + left: 0, + right: 0, + top: 0, + bottom: 0, + width: Number.NaN, + height: Number.NaN, + }); + + node.calculateLayout(undefined, undefined); + node.setWidth(9); + assert.equal(dirtiedCalls, 0); + + const parent = Yoga.Node.create(); + const child = Yoga.Node.create(); + parent.insertChild(node, 0); + assert.equal(parent.getChild(0), node); + parent.removeChild(node); + parent.insertChild(child, 0); + assert.throws(() => parent.reset(), /detached Yoga leaf/); + assert.throws(() => child.reset(), /detached Yoga leaf/); + assert.equal(parent.getChild(0), child); + + parent.freeRecursive(); + node.free(); + config.free(); +}); diff --git a/tests/taffyjs-yoga/package.json b/tests/taffyjs-yoga/package.json index 5914b31..b82fc64 100644 --- a/tests/taffyjs-yoga/package.json +++ b/tests/taffyjs-yoga/package.json @@ -10,6 +10,7 @@ "yoga-layout": "workspace:@taffyjs/yoga@*" }, "devDependencies": { + "@taffyjs/node": "workspace:*", "@types/node": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/tests/taffyjs-yoga/state.test.mts b/tests/taffyjs-yoga/state.test.mts new file mode 100644 index 0000000..62b9e63 --- /dev/null +++ b/tests/taffyjs-yoga/state.test.mts @@ -0,0 +1,249 @@ +import assert from "node:assert/strict"; +import Yoga, { Edge } from "yoga-layout"; +import OracleYoga from "yoga-layout-oracle"; +import { test } from "vite-plus/test"; + +function flags(node: { isDirty(): boolean; hasNewLayout(): boolean }): readonly [boolean, boolean] { + return [node.isDirty(), node.hasNewLayout()]; +} + +test("declaration changes preserve Yoga dirty and new-layout transitions", () => { + const node = Yoga.Node.create(); + const oracle = OracleYoga.Node.create(); + try { + assert.deepEqual(flags(node), flags(oracle)); + node.markLayoutSeen(); + oracle.markLayoutSeen(); + assert.deepEqual(flags(node), flags(oracle)); + + node.setWidth(10); + oracle.setWidth(10); + assert.deepEqual(flags(node), flags(oracle)); + node.calculateLayout(undefined, undefined); + oracle.calculateLayout(undefined, undefined); + assert.deepEqual(flags(node), flags(oracle)); + node.markLayoutSeen(); + oracle.markLayoutSeen(); + + node.setWidth(10); + oracle.setWidth(10); + assert.deepEqual(flags(node), flags(oracle)); + node.setMargin(Edge.All, 2); + oracle.setMargin(OracleYoga.EDGE_ALL, 2); + assert.deepEqual(flags(node), flags(oracle)); + assert.equal(node.getComputedWidth(), 10); + node.setHeight(4); + oracle.setHeight(4); + assert.deepEqual(flags(node), flags(oracle)); + + node.calculateLayout(undefined, undefined); + oracle.calculateLayout(undefined, undefined); + assert.deepEqual(flags(node), flags(oracle)); + } finally { + node.free(); + oracle.free(); + } +}); + +test("copyStyle dirties only when raw declarations differ", () => { + const source = Yoga.Node.create(); + const destination = Yoga.Node.create(); + source.setWidth(20); + destination.calculateLayout(undefined, undefined); + destination.markLayoutSeen(); + + destination.copyStyle(source); + assert.deepEqual(flags(destination), [true, false]); + assert.deepEqual(destination.getWidth(), source.getWidth()); + destination.calculateLayout(undefined, undefined); + destination.markLayoutSeen(); + destination.copyStyle(source); + assert.deepEqual(flags(destination), [false, false]); + + source.free(); + destination.free(); +}); + +test("calculateLayout commits public state only for the selected subtree", () => { + const root = Yoga.Node.create(); + const child = Yoga.Node.create(); + const grandchild = Yoga.Node.create(); + root.insertChild(child, 0); + child.insertChild(grandchild, 0); + + root.calculateLayout(undefined, undefined); + for (const node of [root, child, grandchild]) node.markLayoutSeen(); + + root.calculateLayout(undefined, undefined); + assert.deepEqual(flags(root), [false, true]); + assert.deepEqual(flags(child), [false, false]); + assert.deepEqual(flags(grandchild), [false, false]); + + for (const node of [root, child, grandchild]) node.markLayoutSeen(); + root.calculateLayout(100, 100); + assert.deepEqual(flags(root), [false, true]); + assert.deepEqual(flags(child), [false, true]); + assert.deepEqual(flags(grandchild), [false, true]); + + for (const node of [root, child, grandchild]) node.markLayoutSeen(); + child.calculateLayout(undefined, undefined); + assert.deepEqual(flags(root), [false, false]); + assert.deepEqual(flags(child), [false, true]); + assert.deepEqual(flags(grandchild), [false, true]); + + for (const node of [root, child, grandchild]) node.markLayoutSeen(); + child.calculateLayout(undefined, undefined); + assert.deepEqual(flags(root), [false, false]); + assert.deepEqual(flags(child), [false, true]); + assert.deepEqual(flags(grandchild), [false, false]); + + for (const node of [root, child, grandchild]) node.markLayoutSeen(); + grandchild.setWidth(3); + assert.deepEqual(flags(root), [true, false]); + assert.deepEqual(flags(child), [true, false]); + assert.deepEqual(flags(grandchild), [true, false]); + child.calculateLayout(undefined, undefined); + assert.deepEqual(flags(root), [true, false]); + assert.deepEqual(flags(child), [false, true]); + assert.deepEqual(flags(grandchild), [false, true]); + + root.freeRecursive(); +}); + +test("Config translation revisions do not dirty Nodes but refresh the next selected subtree", () => { + const config = Yoga.Config.create(); + const root = Yoga.Node.createWithConfig(config); + const child = Yoga.Node.createWithConfig(config); + root.insertChild(child, 0); + root.calculateLayout(undefined, undefined); + root.markLayoutSeen(); + child.markLayoutSeen(); + + config.setUseWebDefaults(true); + assert.deepEqual(flags(root), [false, false]); + assert.deepEqual(flags(child), [false, false]); + root.calculateLayout(undefined, undefined); + assert.deepEqual(flags(root), [false, true]); + assert.deepEqual(flags(child), [false, true]); + + root.freeRecursive(); + config.free(); +}); + +test("a descendant's independent Config refreshes the whole selected subtree", () => { + const childConfig = Yoga.Config.create(); + const root = Yoga.Node.create(); + const child = Yoga.Node.createWithConfig(childConfig); + root.insertChild(child, 0); + root.calculateLayout(undefined, undefined); + root.markLayoutSeen(); + child.markLayoutSeen(); + + childConfig.setUseWebDefaults(true); + assert.deepEqual(flags(root), [false, false]); + assert.deepEqual(flags(child), [false, false]); + root.calculateLayout(undefined, undefined); + assert.deepEqual(flags(root), [false, true]); + assert.deepEqual(flags(child), [false, true]); + assert.equal(child.getFlexShrink(), 1); + + root.freeRecursive(); + childConfig.free(); +}); + +test("dirtied callbacks receive canonical Nodes after state is committed", () => { + const parent = Yoga.Node.create(); + const child = Yoga.Node.create(); + parent.insertChild(child, 0); + parent.calculateLayout(undefined, undefined); + parent.markLayoutSeen(); + child.markLayoutSeen(); + + const calls = new Set(); + parent.setDirtiedFunc((node) => { + assert.equal(node.isDirty(), true); + calls.add(node); + }); + child.setDirtiedFunc((node) => { + assert.equal(parent.isDirty(), true); + assert.equal(node.isDirty(), true); + calls.add(node); + }); + child.setWidth(8); + assert.deepEqual(calls, new Set([parent, child])); + + parent.calculateLayout(undefined, undefined); + parent.setDirtiedFunc(null); + child.unsetDirtiedFunc(); + calls.clear(); + child.setWidth(9); + assert.equal(calls.size, 0); + + parent.freeRecursive(); +}); + +test("a dirtied callback error is post-commit and preserves thrown identity", () => { + const parent = Yoga.Node.create(); + const child = Yoga.Node.create(); + parent.insertChild(child, 0); + parent.calculateLayout(undefined, undefined); + const sentinel = { reason: "expected" }; + child.setDirtiedFunc(() => { + throw sentinel; + }); + + assert.throws( + () => child.setWidth(14), + (error) => error === sentinel, + ); + assert.deepEqual(child.getWidth(), { value: 14, unit: Yoga.UNIT_POINT }); + assert.equal(child.isDirty(), true); + assert.equal(parent.isDirty(), true); + + child.unsetDirtiedFunc(); + parent.freeRecursive(); +}); + +test("markDirty is restricted to measured leaves and propagates public dirty state", () => { + const parent = Yoga.Node.create(); + const child = Yoga.Node.create(); + parent.insertChild(child, 0); + parent.calculateLayout(undefined, undefined); + parent.markLayoutSeen(); + child.markLayoutSeen(); + + assert.throws(() => child.markDirty(), /measured Yoga leaves/); + child.setMeasureFunc(() => ({ width: 1, height: 2 })); + assert.deepEqual(flags(child), [false, false]); + child.markDirty(); + assert.deepEqual(flags(child), [true, false]); + assert.deepEqual(flags(parent), [true, false]); + + parent.calculateLayout(undefined, undefined); + parent.markLayoutSeen(); + child.markLayoutSeen(); + child.setMeasureFunc(() => ({ width: 3, height: 4 })); + assert.deepEqual(flags(child), [false, false]); + child.setMeasureFunc(null); + assert.deepEqual(flags(child), [false, false]); + assert.throws(() => child.markDirty(), /measured Yoga leaves/); + + parent.freeRecursive(); +}); + +test("supported inert flags stay clean and unsupported baseline selection is atomic", () => { + const node = Yoga.Node.create(); + node.calculateLayout(undefined, undefined); + node.markLayoutSeen(); + node.setAlwaysFormsContainingBlock(true); + assert.deepEqual(flags(node), [false, false]); + node.setAlwaysFormsContainingBlock(false); + assert.deepEqual(flags(node), [false, false]); + node.setIsReferenceBaseline(false); + assert.equal(node.isReferenceBaseline(), false); + assert.deepEqual(flags(node), [false, false]); + assert.throws(() => node.setIsReferenceBaseline(true as never), /unsupported/); + assert.equal(node.isReferenceBaseline(), false); + assert.deepEqual(flags(node), [false, false]); + node.free(); +}); diff --git a/tests/taffyjs-yoga/topology.test.mts b/tests/taffyjs-yoga/topology.test.mts new file mode 100644 index 0000000..6c96ded --- /dev/null +++ b/tests/taffyjs-yoga/topology.test.mts @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import { TaffyTree, type NodeId } from "@taffyjs/node"; +import Yoga, { FlexDirection } from "yoga-layout"; +import { loadYoga } from "yoga-layout/load"; +import { test } from "vite-plus/test"; + +test("topology reads return canonical wrappers and preserve native order", () => { + const parent = Yoga.Node.create(); + const first = Yoga.Node.create(); + const second = Yoga.Node.create(); + const grandchild = Yoga.Node.create(); + try { + parent.insertChild(second, 0); + parent.insertChild(first, 0); + second.insertChild(grandchild, 0); + + assert.equal(parent.getChildCount(), 2); + assert.equal(parent.getChild(0), first); + assert.equal(parent.getChild(1), second); + assert.equal(parent.getChild(2) as unknown, null); + assert.equal(parent.getChild(-1) as unknown, null); + assert.equal(parent.getChild(0.5) as unknown, null); + assert.equal(first.getParent(), parent); + assert.equal(grandchild.getParent(), second); + + assert.throws(() => parent.getChild("0" as never), TypeError); + } finally { + parent.freeRecursive(); + } +}); + +test("insertChild validates ownership, cycles, indices, and measured parents before mutation", async () => { + const parent = Yoga.Node.create(); + const child = Yoga.Node.create(); + const otherParent = Yoga.Node.create(); + const measured = Yoga.Node.create(); + const measuredChild = Yoga.Node.create(); + const facade = await loadYoga(); + const foreign = facade.Node.create(); + try { + assert.throws(() => parent.insertChild(child, -1), RangeError); + assert.throws(() => parent.insertChild(child, 0.5), RangeError); + assert.throws(() => parent.insertChild(child, 1), RangeError); + assert.equal(parent.getChildCount(), 0); + assert.equal(child.getParent(), null); + + parent.insertChild(child, 0); + assert.throws(() => otherParent.insertChild(child, 0), /already has a parent/); + assert.throws(() => child.insertChild(parent, 0), /cycle/); + assert.throws(() => parent.insertChild(parent, 1), /cycle/); + assert.throws(() => parent.insertChild(foreign, 1), /another Yoga facade/); + assert.equal(parent.getChildCount(), 1); + assert.equal(child.getParent(), parent); + + measured.setMeasureFunc(() => ({ width: 1, height: 1 })); + assert.throws(() => measured.insertChild(measuredChild, 0), /Measured/); + assert.equal(measured.getChildCount(), 0); + assert.equal(measuredChild.getParent(), null); + + otherParent.insertChild(measuredChild, 0); + assert.throws(() => otherParent.setMeasureFunc(() => ({ width: 1, height: 1 })), /Measured/); + assert.equal(otherParent.getChild(0), measuredChild); + } finally { + parent.freeRecursive(); + otherParent.freeRecursive(); + measured.free(); + foreign.free(); + } +}); + +test("removeChild is a no-op for non-children and resets a detached child's output", () => { + const parent = Yoga.Node.create(); + const child = Yoga.Node.create(); + const unrelated = Yoga.Node.create(); + let dirtiedCalls = 0; + try { + child.setWidth(10); + child.setHeight(4); + child.calculateLayout(undefined, undefined); + child.markLayoutSeen(); + assert.equal(child.isDirty(), false); + assert.equal(child.getComputedWidth(), 10); + + parent.calculateLayout(undefined, undefined); + parent.markLayoutSeen(); + parent.setDirtiedFunc((node) => { + assert.equal(node, parent); + dirtiedCalls += 1; + }); + parent.removeChild(unrelated); + assert.equal(parent.isDirty(), false); + assert.equal(dirtiedCalls, 0); + assert.equal(unrelated.getParent(), null); + + parent.insertChild(child, 0); + assert.equal(parent.isDirty(), true); + assert.equal(child.isDirty(), false); + assert.equal(dirtiedCalls, 1); + parent.calculateLayout(undefined, undefined); + parent.markLayoutSeen(); + child.markLayoutSeen(); + + parent.removeChild(child); + assert.equal(parent.isDirty(), true); + assert.equal(dirtiedCalls, 2); + assert.equal(child.isDirty(), false); + assert.equal(child.hasNewLayout(), false); + assert.equal(child.getParent(), null); + assert.deepEqual(child.getComputedLayout(), { + left: 0, + right: 0, + top: 0, + bottom: 0, + width: Number.NaN, + height: Number.NaN, + }); + assert.deepEqual(child.getWidth(), { value: 10, unit: Yoga.UNIT_POINT }); + } finally { + parent.free(); + child.free(); + unrelated.free(); + } +}); + +test("different Config states may coexist in one native topology", () => { + const config = Yoga.Config.create(); + config.setUseWebDefaults(true); + const parent = Yoga.Node.create(); + const child = Yoga.Node.createWithConfig(config); + try { + parent.insertChild(child, 0); + assert.equal(parent.getFlexDirection(), FlexDirection.Column); + assert.equal(child.getFlexDirection(), FlexDirection.Row); + assert.equal(child.getParent(), parent); + } finally { + parent.freeRecursive(); + config.free(); + } +}); + +test("a pre-commit native topology failure preserves public state", async () => { + const facade = await loadYoga(); + const parent = facade.Node.create(); + const child = facade.Node.create(); + parent.calculateLayout(undefined, undefined); + parent.markLayoutSeen(); + + const prototype = TaffyTree.prototype; + const originalInsert = Object.getOwnPropertyDescriptor(prototype, "insertChildAtIndex") + ?.value as TaffyTree["insertChildAtIndex"]; + const sentinel = new Error("expected insert failure"); + prototype.insertChildAtIndex = function (): never { + throw sentinel; + }; + try { + assert.throws( + () => parent.insertChild(child, 0), + (error) => error === sentinel, + ); + } finally { + prototype.insertChildAtIndex = originalInsert; + } + + assert.equal(parent.getChildCount(), 0); + assert.equal(parent.isDirty(), false); + assert.equal(parent.hasNewLayout(), false); + assert.equal(child.getParent(), null); + + parent.insertChild(child, 0); + assert.equal(parent.getChild(0), child); + parent.freeRecursive(); +}); + +test("an unexpected partial recursive free poisons only its isolated facade", async () => { + const facade = await loadYoga(); + const root = facade.Node.create(); + const child = facade.Node.create(); + root.insertChild(child, 0); + + const prototype = TaffyTree.prototype; + const originalRemove = Object.getOwnPropertyDescriptor(prototype, "remove") + ?.value as TaffyTree["remove"]; + const sentinel = new Error("expected second remove failure"); + let removeCalls = 0; + prototype.remove = function (this: TaffyTree, node: NodeId): void { + removeCalls += 1; + if (removeCalls === 2) throw sentinel; + originalRemove.call(this, node); + }; + try { + assert.throws(() => root.freeRecursive(), /facade is unusable/); + } finally { + prototype.remove = originalRemove; + } + + assert.equal(removeCalls, 2); + assert.throws(() => root.getChildCount(), /facade is unusable/); + assert.throws(() => child.getParent(), /facade is unusable/); + + const healthy = await loadYoga(); + const healthyNode = healthy.Node.create(); + healthyNode.setWidth(3); + healthyNode.calculateLayout(undefined, undefined); + assert.equal(healthyNode.getComputedWidth(), 3); + healthyNode.free(); +}); diff --git a/tests/taffyjs-yoga/types/public-api.test-d.ts b/tests/taffyjs-yoga/types/public-api.test-d.ts index 89660b4..2b1d4f2 100644 --- a/tests/taffyjs-yoga/types/public-api.test-d.ts +++ b/tests/taffyjs-yoga/types/public-api.test-d.ts @@ -89,6 +89,27 @@ node.calculateLayout(undefined, undefined, Direction.LTR); const width: number = node.getComputedWidth(); const dirtied: DirtiedFunction = (dirtyNode) => dirtyNode.getComputedWidth(); const measure: MeasureFunction = () => ({ width: 1, height: 2 }); +const child: Node = Yoga.Node.create(); +node.insertChild(child, 0); +const childCount: number = node.getChildCount(); +const typedChild: Node = node.getChild(0); +const typedParent: Node | null = child.getParent(); +node.removeChild(child); +const dirty: boolean = node.isDirty(); +const newLayout: boolean = node.hasNewLayout(); +node.markLayoutSeen(); +node.setMeasureFunc(measure); +node.markDirty(); +node.unsetMeasureFunc(); +node.setMeasureFunc(null); +node.setDirtiedFunc(dirtied); +node.unsetDirtiedFunc(); +node.setDirtiedFunc(null); +node.setIsReferenceBaseline(false); +const referenceBaseline: boolean = node.isReferenceBaseline(); +node.setAlwaysFormsContainingBlock(true); +node.reset(); +child.freeRecursive(); const loadedPromise: Promise = loadYoga(); const loadedConfig: LoadedConfig = config; const loadedNode: LoadedNode = node; @@ -109,6 +130,8 @@ node.setAlignSelf(Align.SpaceEvenly); node.setPositionType(0); // @ts-expect-error Value-like objects are not accepted by generic setters. node.setWidth(node.getWidth()); +// @ts-expect-error Reference-baseline selection is unsupported. +node.setIsReferenceBaseline(true); // @ts-expect-error The eager root does not export Yoga as a named type. type RootYoga = import("yoga-layout").Yoga; @@ -122,6 +145,12 @@ void [ styleWidth, gap, margin, + childCount, + typedChild, + typedParent, + dirty, + newLayout, + referenceBaseline, dirtied, measure, loadedPromise, From 6af3ed27b1b19dc3550b8c93c717cab0596a8043 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 15 Aug 2026 17:07:21 +0800 Subject: [PATCH 06/18] Implement @taffyjs/yoga computed output --- .agents/docs/loop-status.md | 13 +- .agents/docs/taffyjs-yoga-reference.md | 38 +- packages/taffyjs-yoga/COMPATIBILITY.md | 39 +- packages/taffyjs-yoga/THIRD_PARTY_NOTICES.md | 15 + ...{facade-vnf8jWiE.js => facade-94Ssdbk_.js} | 427 ++++++++- packages/taffyjs-yoga/dist/index.d.ts | 2 +- packages/taffyjs-yoga/dist/index.js | 2 +- packages/taffyjs-yoga/dist/load.d.ts | 2 +- packages/taffyjs-yoga/dist/load.js | 2 +- ...ypes-B1oAtvXl.d.ts => types-BNBR-NNr.d.ts} | 3 + packages/taffyjs-yoga/package.json | 1 + packages/taffyjs-yoga/src/facade.ts | 166 +++- packages/taffyjs-yoga/src/projection.ts | 557 ++++++++++++ packages/taffyjs-yoga/src/translate.ts | 113 ++- packages/taffyjs-yoga/src/types.ts | 3 + packages/taffyjs-yoga/src/values.ts | 4 + tests/taffyjs-yoga/computed-output.test.mts | 812 ++++++++++++++++++ tests/taffyjs-yoga/differences.test.mts | 601 +++++++++++++ tests/taffyjs-yoga/types/public-api.test-d.ts | 6 + 19 files changed, 2736 insertions(+), 70 deletions(-) create mode 100644 packages/taffyjs-yoga/THIRD_PARTY_NOTICES.md rename packages/taffyjs-yoga/dist/{facade-vnf8jWiE.js => facade-94Ssdbk_.js} (70%) rename packages/taffyjs-yoga/dist/{types-B1oAtvXl.d.ts => types-BNBR-NNr.d.ts} (98%) create mode 100644 packages/taffyjs-yoga/src/projection.ts create mode 100644 tests/taffyjs-yoga/computed-output.test.mts create mode 100644 tests/taffyjs-yoga/differences.test.mts diff --git a/.agents/docs/loop-status.md b/.agents/docs/loop-status.md index 20117af..b1d1720 100644 --- a/.agents/docs/loop-status.md +++ b/.agents/docs/loop-status.md @@ -4,7 +4,7 @@ Updated: 2026-08-15 ## State -Milestone 3 is complete, reviewed, and ready in its milestone commit. Milestone 4 is next. +Milestone 4 is complete, reviewed, and ready in its milestone commit. Milestone 5 is next. ## Done @@ -27,6 +27,11 @@ Milestone 3 is complete, reviewed, and ready in its milestone commit. Milestone - Added Yoga-visible dirty and new-layout state, layout-seen transitions, canonical dirtied-callback arguments, post-commit callback errors, measurement markers, manual dirty restrictions, supported containing-block state, and type/runtime rejection for unsupported reference-baseline selection. - Added one pre-calculation subtree pass that resolves inherited direction and each Node's Config revision, prepares all translated Styles before native mutation, synchronizes every stale Style, and reuses the same selected-subtree snapshot for Yoga-visible state commits. - Added durable topology, lifecycle, state, callback, fault-injection, independent-Config, and public-type fixtures while keeping Taffy as the sole topology and layout authority. +- Added arbitrary-node calculation with exact finite root constraints, selected-root Display.None handling, guaranteed ordinary-Style restoration, facade poisoning on an unexpected restoration failure, and atomic selected-subtree output commits. +- Added Yoga-shaped output projection for root and descendant positions, right and bottom semantics, effective direction, relative and absolute insets, absolute containing-direction translation, display-none suppression, computed physical and logical edges, auto-margin masking, and per-Node point-grid rounding across mixed Configs. +- Added bounded Yoga-compatible float32 percentage resolution, explicit Taffy-offset removal for relative projection, the documented 0.0001 compatibility boundary for equivalent unrounded percentage arithmetic, and durable exact and tolerance-bound oracle fixtures. +- Added differential fixtures and public compatibility entries for every researched non-measurement Difference, including live Config cache behavior, callback artifacts, max/shrink, aspect ratio, WebFlexBasis cache behavior, attached-subtree cache behavior, overlapping physical and logical margins, oversized and ordinary WrapReverse placement, reversed overflow distribution, zero cross-size lines, and auto margins under justification or reversed axes. +- Added Yoga 3.2.1 PixelGrid source attribution, a complete packaged MIT third-party notice, and package-file coverage for the notice. ## Evidence and review @@ -39,6 +44,10 @@ Milestone 3 is complete, reviewed, and ready in its milestone commit. Milestone - A bounded calculation probe found that Yoga and Taffy differ when `Display.None` is applied to the selected calculation root with declared dimensions. Style storage and direct display translation remain complete in Milestone 2; the calculated-output trigger is retained for Milestone 4 classification and regression coverage rather than being hidden in the declaration layer. - The Milestone 3 full `vp run check` passes: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 36 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. - The Milestone 3 adversarial reviewer found two native-cache/state gaps. Single-node and recursive release marked only Yoga-visible surviving ancestors, so Taffy could reuse an obsolete parent layout; Config freshness was checked only on the selected Node, so an independently configured descendant could miss both Style synchronization and `hasNewLayout`. Release now invalidates the surviving native parent and direct detached child roots with post-commit poisoning on failure, while calculation uses one full selected-subtree Style plan. The exact 30-by-10 release and independent child-Config reproductions now pass, and the same reviewer returned PASS on the targeted follow-up. +- The Milestone 4 full `vp run check` passes after the final projection fixes: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 64 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. +- The fresh Milestone 4 adversarial reviewer found three material issues: absolute mixed-direction logical insets were translated too late to recover auto-sized width, two known Taffy/Yoga Difference triggers were documented too narrowly, and the ported PixelGrid code lacked its required MIT attribution. Native Style translation now tracks containing direction before calculation, both Difference classes have exact dual-oracle fixtures and expanded documentation, and the source plus packaged notice carry the attribution. +- The same reviewer’s single targeted follow-up confirmed those three repairs and found two residuals: Taffy's percentage conversion needed to divide before its float32 cast, and the authoritative implementation reference had not been synchronized with the expanded Difference evidence. The exact width-3/left-0.3% reproduction now matches Yoga bit-for-bit, the remaining equivalent absolute-margin arithmetic is pinned under Yoga's 0.0001 float tolerance, and the reference now contains all three added Difference triggers. Per the review protocol, the review loop stopped after that one follow-up and the residual fixes were verified locally and by the complete gate. +- A `pnpm pack --dry-run --json` preview includes both entries, both entry declarations, both shared chunks, `COMPATIBILITY.md`, and `THIRD_PARTY_NOTICES.md`; a 64-case percentage direction, axis, and position matrix has zero exact mismatches for the tested inset corpus. ## In flight @@ -46,7 +55,7 @@ Milestone 3 is complete, reviewed, and ready in its milestone commit. Milestone ## Next -- Implement Milestone 4: calculated-output synchronization, Yoga-shaped geometry, direction and edge behavior, rounding, and the classified Yoga/Taffy output differences. +- Implement Milestone 5: Measure callback registration, native bridge, mode mapping, failure and retry behavior, measured rounding, reentrancy, and documented measurement Differences. ## Blocked diff --git a/.agents/docs/taffyjs-yoga-reference.md b/.agents/docs/taffyjs-yoga-reference.md index f7c30ea..d689ef0 100644 --- a/.agents/docs/taffyjs-yoga-reference.md +++ b/.agents/docs/taffyjs-yoga-reference.md @@ -337,23 +337,27 @@ The TypeScript surface should preserve Yoga 3.2.1's published declarations for e ### Computed layout getters and cross-cutting result classes -| APIs or result class | Classification | Mapping or observed boundary | -| --------------------------------------------------------------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| getComputedLeft/Top/Width/Height(), getComputedRight/Bottom(), and getComputedLayout() object shape | Compatible | Width, height, and normal left/top come from Taffy's unrounded layout followed by Yoga point-grid processing. Yoga right/bottom are stored trailing margin plus relative inset values rather than geometric remaining space, and can be reconstructed from finite declaration state. Root left/top also need Yoga's leading margin and relative inset because Taffy roots remain at zero. | -| getComputedMargin(), getComputedPadding(), and getComputedBorder() for Left/Top/Right/Bottom/Start/End | Compatible | Taffy's unrounded Layout contains the resolved physical records; Start and End map through the resolved layout direction, and auto declarations mask Taffy's distributed margin back to Yoga's reported zero. Bounded comparisons cover positive and negative point margins, percentage padding and margins, physical and logical edges under LTR and RTL, auto margins, borders, and overflow. | -| Computed margin, padding, or border queried with Horizontal, Vertical, or All | Compatible | Reject these multi-edge shorthands; Yoga aborts rather than returning an aggregate result. Exact error class and wording are deferred. | -| Point-scale rounding | Compatible | It is an output conversion over unrounded layouts, including Yoga's ceil-sensitive text-node sizes, and does not require a Taffy core change. | -| Ordinary non-degenerate fixed sizes, simple grow or shrink without min/max, percentages, and simple nested flex trees | Compatible | Bounded differential probes found matching unrounded geometry outside the explicit difference triggers below. A committed real-Yoga oracle suite remains a release gate and must not be presented as a compatibility percentage. | -| min/max combined with flex growth or shrinkage | Different | Yoga and Taffy can produce different final sizes; the API remains available with Taffy's stable result. | -| aspectRatio in calculated layout | Different | Yoga derives the cross size from the final flex main size, while Taffy follows its own CSS-oriented aspect-ratio and stretch rules. With rounding disabled, a 100x100 Row parent and width-40 ratio-2 child produce 40x20 in Yoga but 40x100 in Taffy; a Column child declaring width 40, height 30, and ratio 2 produces 60x30 in Yoga but 40x30 in Taffy. Min/max and flex growth diverge further. Aligning all cases would require parent- and algorithm-dependent size rewriting, which crosses the accepted JavaScript boundary. | -| MeasureMode-sensitive callbacks and measurement combined with flexBasis | Different | Taffy does not preserve whether a finite constraint originated as Yoga Exactly or AtMost and may request intrinsic measurements in different phases. The bridge therefore gives each Taffy request a meaningful Yoga-shaped mode but does not promise a Yoga callback trace. Fixed-size and constraint-insensitive callbacks remain useful; callbacks branching on MeasureMode and flex-basis interactions have stable result differences. | -| WebFlexBasis disabled across a changed containing size | Different | Yoga 3.2.1 can reuse a stale computed percentage flex basis when the experiment is false, while Taffy recomputes it. Emulating Yoga's stale cache would require Yoga-internal flex-basis state and is outside the boundary; the enabled Yoga result matched the tested Taffy relayout. | -| Attached-subtree calculation followed by an ancestor calculation that Yoga serves from cache | Different | Yoga can retain geometry written by the separate subtree calculation because the clean ancestor does not revisit it. Taffy may recompute that ancestor after internal Style translation propagated native dirty state and can therefore produce current ancestor-context geometry instead. The package keeps calculateLayout available on both nodes and does not reproduce Yoga's complete cache algorithm. | -| Oversized WrapReverse lines with distribution-style alignContent | Different | With point rounding disabled, a 100x30 Row/WrapReverse container holding three 60-point items on separate 10, 20, and 15-point lines produces Yoga top positions 20, 0, and -15 but Taffy positions 35, 15, and 0 under Stretch, SpaceBetween, SpaceAround, or SpaceEvenly. The content's 45-point cross size exceeds the 30-point container; FlexStart, FlexEnd, and Center are not covered by this difference. Reconstructing Yoga's negative-free-space line placement would duplicate Flex logic. | -| Oversized single line on a reversed main axis with distribution-style justifyContent | Different | When a RowReverse or ColumnReverse no-wrap line overflows and justifyContent is SpaceBetween, SpaceAround, or SpaceEvenly, Yoga and Taffy choose different negative-free-space fallback placement. In a 240-point RowReverse line with 140, 130, and 120-point children, Yoga left positions are 100, -30, and -150 while Taffy reports 250, 120, and 0. This affects sibling distribution and is not an output-only correction. | -| Zero-cross-size wrapped line under Stretch item alignment | Different | A wrapped line whose items all resolve to zero cross size stays on Yoga's cross-start or cross-end edge, while Taffy distributes the zero-size line under FlexEnd, Center, SpaceAround, or SpaceEvenly alignContent. For a 180-point cross axis, ordinary Wrap plus Center places Yoga at 0 and Taffy at 90; WrapReverse plus Center places Yoga at 180 and Taffy at 90. Reconstructing this degenerate line placement would duplicate Flex logic. | -| Main-axis auto margins combined with a nonzero justifyContent offset | Different | Yoga lets positive-free-space main-axis auto margins consume the remaining space and then suppresses justifyContent; Taffy distributes the auto margins and can still apply a justification offset. This is not limited to reversed axes: in a 240x180 Column with justifyContent Center, a 10x30 child with auto margins followed by a 20x20 child has Yoga tops 65 and 160 but Taffy tops 130 and 225. Normal FlexStart cases can match, while reversed axes expose additional combinations. Correcting sibling positions would duplicate Flex distribution, so Taffy geometry is retained and computed-margin getters still mask auto declarations to zero. | -| Relative insets, including reversed axes | Compatible | Relative positioning does not participate in normal-flow sizing. When Taffy and Yoga apply opposite signs on a reversed axis, the projection can remove Taffy's declaration-derived offset and apply Yoga's declaration-derived offset to the Node's projected position and descendant absolute accumulation without redistributing siblings or calculating Flex geometry. A combination that also triggers the main-axis auto-margin difference remains covered by the Different row above. | +Compatible numeric comparisons allow an absolute error below `0.0001` for finite unrounded float fields, matching Yoga's own internal float-comparison tolerance. Rounded point-grid output and fields covered by strict fixtures continue to compare exactly; the tolerance covers only last-bit differences where Yoga and Taffy evaluate equivalent percentage arithmetic in different float32 steps. + +| APIs or result class | Classification | Mapping or observed boundary | +| --------------------------------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| getComputedLeft/Top/Width/Height(), getComputedRight/Bottom(), and getComputedLayout() object shape | Compatible | Width, height, and normal left/top come from Taffy's unrounded layout followed by Yoga point-grid processing. Yoga right/bottom are stored trailing margin plus relative inset values rather than geometric remaining space, and can be reconstructed from finite declaration state. Root left/top also need Yoga's leading margin and relative inset because Taffy roots remain at zero. | +| getComputedMargin(), getComputedPadding(), and getComputedBorder() for Left/Top/Right/Bottom/Start/End | Compatible | Taffy's unrounded Layout contains the resolved physical records; Start and End map through the resolved layout direction, and auto declarations mask Taffy's distributed margin back to Yoga's reported zero. Bounded comparisons cover positive and negative point margins, percentage padding and margins, physical and logical edges under LTR and RTL, auto margins, borders, and overflow. | +| Computed margin, padding, or border queried with Horizontal, Vertical, or All | Compatible | Reject these multi-edge shorthands; Yoga aborts rather than returning an aggregate result. Exact error class and wording are deferred. | +| Point-scale rounding | Compatible | It is an output conversion over unrounded layouts, including Yoga's ceil-sensitive text-node sizes, and does not require a Taffy core change. | +| Ordinary non-degenerate fixed sizes, simple grow or shrink without min/max, percentages, and simple nested flex trees | Compatible | Bounded differential probes found matching unrounded geometry outside the explicit difference triggers below. A committed real-Yoga oracle suite remains a release gate and must not be presented as a compatibility percentage. | +| min/max combined with flex growth or shrinkage | Different | Yoga and Taffy can produce different final sizes; the API remains available with Taffy's stable result. | +| Overlapping physical and logical main-axis margins under a child direction that differs from its parent | Different | Yoga can use one direction while totaling margins for Flex sizing and the child's resolved direction when it stores computed physical margins. In a width-100 LTR Row, a width-20 RTL child with margin-left 4 and margin-start 10% reports computed margins left 4 and right 10 in both engines, but Yoga resolves width 16 while Taffy remains width 20. Reproducing the hidden intermediate margin total would require Flex-specific size correction, so Taffy's stable result is retained. | +| aspectRatio in calculated layout | Different | Yoga derives the cross size from the final flex main size, while Taffy follows its own CSS-oriented aspect-ratio and stretch rules. With rounding disabled, a 100x100 Row parent and width-40 ratio-2 child produce 40x20 in Yoga but 40x100 in Taffy; a Column child declaring width 40, height 30, and ratio 2 produces 60x30 in Yoga but 40x30 in Taffy. Min/max and flex growth diverge further. Aligning all cases would require parent- and algorithm-dependent size rewriting, which crosses the accepted JavaScript boundary. | +| MeasureMode-sensitive callbacks and measurement combined with flexBasis | Different | Taffy does not preserve whether a finite constraint originated as Yoga Exactly or AtMost and may request intrinsic measurements in different phases. The bridge therefore gives each Taffy request a meaningful Yoga-shaped mode but does not promise a Yoga callback trace. Fixed-size and constraint-insensitive callbacks remain useful; callbacks branching on MeasureMode and flex-basis interactions have stable result differences. | +| WebFlexBasis disabled across a changed containing size | Different | Yoga 3.2.1 can reuse a stale computed percentage flex basis when the experiment is false, while Taffy recomputes it. Emulating Yoga's stale cache would require Yoga-internal flex-basis state and is outside the boundary; the enabled Yoga result matched the tested Taffy relayout. | +| Attached-subtree calculation followed by an ancestor calculation that Yoga serves from cache | Different | Yoga can retain geometry written by the separate subtree calculation because the clean ancestor does not revisit it. Taffy may recompute that ancestor after internal Style translation propagated native dirty state and can therefore produce current ancestor-context geometry instead. The package keeps calculateLayout available on both nodes and does not reproduce Yoga's complete cache algorithm. | +| Oversized WrapReverse lines with distribution-style alignContent | Different | With point rounding disabled, a 100x30 Row/WrapReverse container holding three 60-point items on separate 10, 20, and 15-point lines produces Yoga top positions 20, 0, and -15 but Taffy positions 35, 15, and 0 under Stretch, SpaceBetween, SpaceAround, or SpaceEvenly. The content's 45-point cross size exceeds the 30-point container; FlexStart, FlexEnd, and Center are not covered by this difference. Reconstructing Yoga's negative-free-space line placement would duplicate Flex logic. | +| A non-overflowing WrapReverse line with a cross-axis margin | Different | Cross-axis margin placement can also differ without overflow or distribution-style alignContent. In a 100x100 Row/WrapReverse container with alignItems FlexStart, a 40x20 child with an 11-point bottom margin has top 80 in Yoga and top 69 in Taffy. Correcting this requires reproducing WrapReverse line and item alignment, so Taffy's placement is retained. | +| Oversized single line on a reversed main axis with distribution-style justifyContent | Different | When a RowReverse or ColumnReverse no-wrap line overflows and justifyContent is SpaceBetween, SpaceAround, or SpaceEvenly, Yoga and Taffy choose different negative-free-space fallback placement. In a 240-point RowReverse line with 140, 130, and 120-point children, Yoga left positions are 100, -30, and -150 while Taffy reports 250, 120, and 0. This affects sibling distribution and is not an output-only correction. | +| Zero-cross-size wrapped line under Stretch item alignment | Different | A wrapped line whose items all resolve to zero cross size stays on Yoga's cross-start or cross-end edge, while Taffy distributes the zero-size line under FlexEnd, Center, SpaceAround, or SpaceEvenly alignContent. For a 180-point cross axis, ordinary Wrap plus Center places Yoga at 0 and Taffy at 90; WrapReverse plus Center places Yoga at 180 and Taffy at 90. Reconstructing this degenerate line placement would duplicate Flex logic. | +| Main-axis auto margins on a reversed axis or combined with a nonzero justifyContent offset | Different | Yoga lets positive-free-space main-axis auto margins consume the remaining space and can suppress justifyContent, while Taffy can distribute the margin and retain an additional offset. In a width-240 RowReverse, a width-60 child with margin-left auto has left 180 in Yoga and 360 in Taffy even under FlexStart. In a 240x180 Column with justifyContent Center, Yoga child tops are 65 and 160 while Taffy reports 130 and 225. Correcting either sibling distribution would duplicate Flex logic, so Taffy's geometry is retained and computed auto margins remain masked to zero. | +| Relative insets, including reversed axes | Compatible | Relative positioning does not participate in normal-flow sizing. When Taffy and Yoga apply opposite signs on a reversed axis, the projection can remove Taffy's declaration-derived offset and apply Yoga's declaration-derived offset to the Node's projected position and descendant absolute accumulation without redistributing siblings or calculating Flex geometry. A combination that also triggers the main-axis auto-margin difference remains covered by the Different row above. | ### Yoga-shaped output projection diff --git a/packages/taffyjs-yoga/COMPATIBILITY.md b/packages/taffyjs-yoga/COMPATIBILITY.md index ed65448..026b62f 100644 --- a/packages/taffyjs-yoga/COMPATIBILITY.md +++ b/packages/taffyjs-yoga/COMPATIBILITY.md @@ -1,5 +1,40 @@ # @taffyjs/yoga Compatibility -The compatibility baseline is exactly `yoga-layout@3.2.1`. The implementation is being completed through reviewed milestones; the exhaustive Compatible, Different, and Unsupported matrix will be finalized before this package leaves draft status. +The compatibility baseline is exactly `yoga-layout@3.2.1`. The package is permanently Node-only and ESM-only; it does not provide a browser or WASM backend. -The package is permanently Node-only and ESM-only. It does not provide a browser or WASM backend. +Compatible means the supported API and its useful result match Yoga within the documented input boundary. Different means the API remains available but a named trigger returns stable Taffy geometry or deliberately corrected callback behavior. Unsupported means the package rejects a capability because it cannot provide credible semantics within the JavaScript-only compatibility layer. + +## Calculation and computed output + +The following calculation and output behavior is Compatible: + +Compatible numeric comparisons allow an absolute error below `0.0001` for finite unrounded float fields, matching Yoga's own internal float-comparison tolerance. Rounded point-grid output and the exact fields covered by strict fixtures still compare exactly; this tolerance only covers last-bit differences when Yoga and Taffy evaluate equivalent percentage arithmetic in different float32 steps. + +- `calculateLayout()` may select any live Node, including one still attached to a parent. Finite owner dimensions act as Yoga exact outer constraints when the selected Node has no definite declaration or maximum on that axis, without changing the declared Style. +- A successful calculation commits Yoga-shaped output for the complete selected subtree. `getComputedLayout()` and the six scalar layout getters preserve Yoga's left, right, top, bottom, width, and height semantics across LTR, RTL, reversed axes, relative positioning, and absolute positioning. +- `getComputedMargin()`, `getComputedPadding()`, and `getComputedBorder()` support Left, Top, Right, Bottom, Start, and End. Auto margins report zero, and Horizontal, Vertical, and All are rejected. +- Point-grid conversion follows each Node's own Config, including factor zero, mixed factors in one tree, and positive infinity. Computed edges other than left and top remain unrounded, as in Yoga. +- Ordinary fixed and percentage sizes, simple grow and shrink without min/max, and simple nested Flex trees match the pinned Yoga oracle. This is a bounded compatibility corpus, not a universal compatibility percentage. + +## Known Differences + +These rows are deliberate, version-specific differences with regression fixtures: + +| Capability and trigger | `@taffyjs/yoga` behavior | Yoga 3.2.1 behavior | +| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| Change `useWebDefaults` on a live Config, then calculate a tree whose unresolved shrink values are already cached | Applies the current Config-sensitive Style when the selected calculation reaches Taffy. In the fixture, two 80-point children in a 100-point row become 50/50. | The Config setter does not advance Yoga's cache version, so the same calculation retains 80/80 even though `getFlexShrink()` reports 1. | +| `setDirtiedFunc()` callback argument and `setDirtiedFunc(null)` | Calls a real callback with its canonical Node after state commit; null unsets the callback. | Calls a real callback with zero arguments; null is retained and a later clean-to-dirty transition throws a wrapper TypeError. | +| A max constraint combined with Flex shrink | Keeps Taffy's Flex result. A width-120, max-width-90, shrink-1 child in a width-60 Row resolves to 60. | The same child resolves to 67.5. | +| Overlapping physical and logical main-axis margins under a child direction that differs from its parent | Resolves the declarations to one physical Taffy margin rect. A width-20 RTL child with `margin-left: 4` and `margin-start: 10%` in a width-100 LTR Row remains width 20. | Computed margins are also left 4 and right 10, but Yoga's separate Flex margin resolution reduces the child width to 16. | +| Calculated `aspectRatio` | Keeps Taffy's CSS-oriented aspect-ratio and stretch result. A width-40, ratio-2 child stretched by a 100-by-100 Row is 40-by-100. | The same child is 40-by-20. | +| `WebFlexBasis` disabled while a percentage basis is recalculated under a changed containing size | Recomputes the percentage basis. A 50% basis changes from 50 to 100 when its Row changes from width 100 to 200. | Reuses the stale 50-point basis while the experiment is disabled; enabling the experiment produces 100 in both implementations. | +| Calculate an attached subtree with different owner dimensions, then calculate a clean ancestor | Restoring the selected Node's ordinary native Style invalidates Taffy's ancestor cache. The fixture returns the attached Row from width 100 to 200 and its children from 50 to 80. | The clean ancestor retains the attached-subtree result: Row width 100 and child width 50. | +| Oversized `WrapReverse` lines with distribution-style `alignContent` | Keeps Taffy's line placement. Three 60-point lines with heights 10, 20, and 15 in a 100-by-30 Row have tops 35, 15, and 0 under Stretch. | The same tops are 20, 0, and -15. | +| A non-overflowing `WrapReverse` line with a cross-axis margin | Keeps Taffy's cross-axis margin placement. A 40-by-20 child with an 11-point bottom margin in a 100-by-100 Row has top 69 under `alignItems: FlexStart`. | The same child has top 80. | +| An oversized no-wrap reversed main axis with `SpaceBetween`, `SpaceAround`, or `SpaceEvenly` | Keeps Taffy's negative-free-space fallback. Widths 140, 130, and 120 in a width-240 `RowReverse` with `SpaceBetween` have left positions 250, 120, and 0. | The same left positions are 100, -30, and -150. | +| A wrapped line whose stretched items resolve to zero cross size under distribution-style `alignContent` | Keeps Taffy's line distribution. Two 60-point, flex-basis-zero children in a 100-by-180 Row with `alignContent: Center` both have top 90 for Wrap and WrapReverse. | Both children have top 0 for Wrap and 180 for WrapReverse. | +| Main-axis auto margins on a reversed axis or combined with a nonzero `justifyContent` offset | Keeps Taffy's distribution and masks computed auto margins to zero. A width-60 child with `margin-left: auto` in a width-240 `RowReverse` has left 360; the Center fixture has child tops 130 and 225. | Yoga places the reversed child at left 180; auto margins suppress the Center offset and produce tops 65 and 160. | + +Measure callback modes, traces, and measurement-dependent results are completed and documented with the Measure milestone rather than being inferred from the non-measurement rows above. + +The exhaustive Compatible, Different, and Unsupported API matrix will be finalized before this draft package is release-ready. diff --git a/packages/taffyjs-yoga/THIRD_PARTY_NOTICES.md b/packages/taffyjs-yoga/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..3ed6e14 --- /dev/null +++ b/packages/taffyjs-yoga/THIRD_PARTY_NOTICES.md @@ -0,0 +1,15 @@ +# Third-Party Notices + +## Yoga 3.2.1 + +The point-grid rounding implementation in `src/projection.ts` is derived from Yoga 3.2.1's `yoga/algorithm/PixelGrid.cpp`. + +MIT License + +Copyright (c) Facebook, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/taffyjs-yoga/dist/facade-vnf8jWiE.js b/packages/taffyjs-yoga/dist/facade-94Ssdbk_.js similarity index 70% rename from packages/taffyjs-yoga/dist/facade-vnf8jWiE.js rename to packages/taffyjs-yoga/dist/facade-94Ssdbk_.js index 248259b..230ee20 100644 --- a/packages/taffyjs-yoga/dist/facade-vnf8jWiE.js +++ b/packages/taffyjs-yoga/dist/facade-94Ssdbk_.js @@ -237,6 +237,9 @@ function publicValue(value) { unit: value.unit }; } +function resolvePercentage(basis, percentage) { + return Math.fround(Math.fround(basis * percentage) * Math.fround(.01)); +} function normalizeLength(value, name, allowAuto) { if (value === void 0) return undefinedValue(); if (typeof value === "number") { @@ -417,6 +420,261 @@ function resolveGutter(values, gutter, isDefined, fallback) { return isDefined(all) ? all : fallback(); } //#endregion +//#region src/projection.ts +function initialComputedEdges() { + return { + left: 0, + top: 0, + right: 0, + bottom: 0 + }; +} +function floatAdd(left, right) { + return Math.fround(left + right); +} +function floatSubtract(left, right) { + return Math.fround(left - right); +} +function inexactEquals(left, right) { + if (!Number.isNaN(left) && !Number.isNaN(right)) return Math.abs(left - right) < 1e-4; + return Number.isNaN(left) && Number.isNaN(right); +} +function roundValueToPixelGrid(value, pointScaleFactor, forceCeil, forceFloor) { + let scaledValue = value * pointScaleFactor; + let fractional = scaledValue % 1; + if (fractional < 0) fractional += 1; + if (inexactEquals(fractional, 0)) scaledValue -= fractional; + else if (inexactEquals(fractional, 1)) scaledValue = scaledValue - fractional + 1; + else if (forceCeil) scaledValue = scaledValue - fractional + 1; + else if (forceFloor) scaledValue -= fractional; + else scaledValue = scaledValue - fractional + (!Number.isNaN(fractional) && (fractional > .5 || inexactEquals(fractional, .5)) ? 1 : 0); + return Math.fround(Number.isNaN(scaledValue) || Number.isNaN(pointScaleFactor) ? NaN : scaledValue / pointScaleFactor); +} +function roundLayout(layout, absoluteLeft, absoluteTop, pointScaleFactor, textRounding) { + if (pointScaleFactor === 0) return layout; + const absoluteRight = absoluteLeft + layout.width; + const absoluteBottom = absoluteTop + layout.height; + const fractionalWidth = layout.width * pointScaleFactor % 1; + const fractionalHeight = layout.height * pointScaleFactor % 1; + const hasFractionalWidth = !inexactEquals(fractionalWidth, 0) && !inexactEquals(fractionalWidth, 1); + const hasFractionalHeight = !inexactEquals(fractionalHeight, 0) && !inexactEquals(fractionalHeight, 1); + return { + left: roundValueToPixelGrid(layout.left, pointScaleFactor, false, textRounding), + right: layout.right, + top: roundValueToPixelGrid(layout.top, pointScaleFactor, false, textRounding), + bottom: layout.bottom, + width: Math.fround(roundValueToPixelGrid(absoluteRight, pointScaleFactor, textRounding && hasFractionalWidth, textRounding && !hasFractionalWidth) - roundValueToPixelGrid(absoluteLeft, pointScaleFactor, false, textRounding)), + height: Math.fround(roundValueToPixelGrid(absoluteBottom, pointScaleFactor, textRounding && hasFractionalHeight, textRounding && !hasFractionalHeight) - roundValueToPixelGrid(absoluteTop, pointScaleFactor, false, textRounding)) + }; +} +function physicalValue$1(values, edge, direction) { + return resolveEdge(values, edge, direction, (value) => value.unit !== 0, () => ({ + unit: 0, + value: NaN + })); +} +function resolvePosition(value, basis) { + switch (value.unit) { + case 1: return value.value; + case 2: return basis === void 0 ? void 0 : resolvePercentage(basis, value.value); + case 3: + case 0: return; + } +} +function resolveTaffyPosition(value, basis) { + return value.unit === 2 && basis !== void 0 ? Math.fround(basis * Math.fround(value.value / 100)) : resolvePosition(value, basis); +} +function axisRelativePosition(declarations, start, end, direction, basis, resolve = resolvePosition) { + const resolvedStart = resolve(physicalValue$1(declarations.position, start, direction), basis); + if (resolvedStart !== void 0) return resolvedStart; + const resolvedEnd = resolve(physicalValue$1(declarations.position, end, direction), basis); + return resolvedEnd === void 0 ? 0 : Math.fround(-resolvedEnd); +} +function nativeMarginEdges(entry, suppressed) { + if (suppressed) return initialComputedEdges(); + const { declarations, direction, nativeLayout } = entry; + const read = (edge) => physicalValue$1(declarations.margin, edge, direction).unit === 3 ? 0 : nativeLayout.margin[edge === 0 ? "left" : edge === 1 ? "top" : edge === 2 ? "right" : "bottom"]; + return { + left: read(0), + top: read(1), + right: read(2), + bottom: read(3) + }; +} +function declaredMarginEdges(entry, basis, suppressed) { + if (suppressed) return initialComputedEdges(); + const read = (edge) => { + const value = physicalValue$1(entry.declarations.margin, edge, entry.direction); + return value.unit === 3 ? 0 : resolvePosition(value, basis) ?? 0; + }; + return { + left: read(0), + top: read(1), + right: read(2), + bottom: read(3) + }; +} +function nativeEdges(edges, suppressed) { + return suppressed ? initialComputedEdges() : { + left: edges.left, + top: edges.top, + right: edges.right, + bottom: edges.bottom + }; +} +function resolveMainAxis(flexDirection, direction) { + if (direction === 2) { + if (flexDirection === 2) return 3; + if (flexDirection === 3) return 2; + } + return flexDirection; +} +function resolveCrossAxis(mainAxis, direction) { + return mainAxis === 0 || mainAxis === 1 ? resolveMainAxis(2, direction) : 0; +} +function isHorizontal(axis) { + return axis === 2 || axis === 3; +} +function startsAtEnd(axis) { + return axis === 3 || axis === 1; +} +function innerWidth(layout) { + return Math.max(0, Math.fround(layout.size.width - layout.padding.left - layout.padding.right - layout.border.left - layout.border.right)); +} +function innerHeight(layout) { + return Math.max(0, Math.fround(layout.size.height - layout.padding.top - layout.padding.bottom - layout.border.top - layout.border.bottom)); +} +function absoluteAxisLocation(entry, parentEntry, horizontal) { + const parentLayout = parentEntry.nativeLayout; + const containingWidth = Math.max(0, Math.fround(parentLayout.size.width - parentLayout.border.left - parentLayout.border.right)); + const containingHeight = Math.max(0, Math.fround(parentLayout.size.height - parentLayout.border.top - parentLayout.border.bottom)); + const start = horizontal ? parentEntry.direction === 2 ? 2 : 0 : 1; + const end = horizontal ? parentEntry.direction === 2 ? 0 : 2 : 3; + const positionBasis = horizontal ? containingWidth : containingHeight; + const startPosition = resolvePosition(physicalValue$1(entry.declarations.position, start, parentEntry.direction), positionBasis); + const endPosition = resolvePosition(physicalValue$1(entry.declarations.position, end, parentEntry.direction), positionBasis); + const selectedEdge = startPosition === void 0 ? end : start; + const selectedPosition = startPosition ?? endPosition; + if (selectedPosition === void 0) return void 0; + const selectedMargin = resolvePosition(physicalValue$1(entry.declarations.margin, selectedEdge, parentEntry.direction), containingWidth) ?? 0; + const selectedBorder = selectedEdge === 0 ? parentLayout.border.left : selectedEdge === 1 ? parentLayout.border.top : selectedEdge === 2 ? parentLayout.border.right : parentLayout.border.bottom; + const offset = floatAdd(floatAdd(selectedPosition, selectedMargin), selectedBorder); + if (!(selectedEdge === 2 || selectedEdge === 3)) return { + location: offset, + trailing: void 0 + }; + return { + location: floatSubtract(floatSubtract(horizontal ? parentLayout.size.width : parentLayout.size.height, horizontal ? entry.nativeLayout.size.width : entry.nativeLayout.size.height), offset), + trailing: startPosition === void 0 ? void 0 : offset + }; +} +function projectOutputs(entries, options) { + const states = []; + for (const [index, entry] of entries.entries()) { + const parent = entry.parentIndex === null ? void 0 : states[entry.parentIndex]; + if (entry.parentIndex !== null && parent === void 0) throw new Error("Yoga projection requires parents before children"); + const suppressed = parent?.suppressed === true || index !== 0 && entry.declarations.display === 1; + const parentEntry = entry.parentIndex === null ? void 0 : entries[entry.parentIndex]; + const containingWidth = parentEntry === void 0 ? options.ownerWidth : Math.max(0, Math.fround(parentEntry.nativeLayout.size.width - parentEntry.nativeLayout.border.left - parentEntry.nativeLayout.border.right)); + const margin = entry.declarations.positionType === 2 ? declaredMarginEdges(entry, containingWidth, suppressed) : nativeMarginEdges(entry, suppressed); + const positionMargin = entry.declarations.positionType === 2 && parentEntry !== void 0 ? declaredMarginEdges(entry, innerWidth(parentEntry.nativeLayout), suppressed) : margin; + const padding = nativeEdges(entry.nativeLayout.padding, suppressed); + const border = nativeEdges(entry.nativeLayout.border, suppressed); + let rawLayout; + let absoluteLeft; + let absoluteTop; + if (suppressed) { + rawLayout = { + left: 0, + right: 0, + top: 0, + bottom: 0, + width: 0, + height: 0 + }; + absoluteLeft = parent?.absoluteLeft ?? 0; + absoluteTop = parent?.absoluteTop ?? 0; + } else if (parent === void 0) { + const positionDirection = options.rootHasOwner ? entry.direction : 1; + const relativeX = axisRelativePosition(entry.declarations, positionDirection === 2 ? 2 : 0, positionDirection === 2 ? 0 : 2, positionDirection, options.ownerWidth); + const relativeY = axisRelativePosition(entry.declarations, 1, 3, positionDirection, options.ownerHeight); + rawLayout = { + left: floatAdd(margin.left, relativeX), + right: floatAdd(margin.right, relativeX), + top: floatAdd(margin.top, relativeY), + bottom: floatAdd(margin.bottom, relativeY), + width: entry.nativeLayout.size.width, + height: entry.nativeLayout.size.height + }; + absoluteLeft = rawLayout.left; + absoluteTop = rawLayout.top; + } else { + const parentEntry = entries[entry.parentIndex]; + const parentLayout = parentEntry.nativeLayout; + const ownerWidth = innerWidth(parentLayout); + const ownerHeight = innerHeight(parentLayout); + const relativeX = axisRelativePosition(entry.declarations, entry.direction === 2 ? 2 : 0, entry.direction === 2 ? 0 : 2, entry.direction, ownerWidth); + const relativeY = axisRelativePosition(entry.declarations, 1, 3, entry.direction, ownerHeight); + const mainAxis = resolveMainAxis(parentEntry.declarations.flexDirection, parentEntry.direction); + const crossAxis = resolveCrossAxis(mainAxis, parentEntry.direction); + const horizontalAxis = isHorizontal(mainAxis) ? mainAxis : crossAxis; + const verticalAxis = isHorizontal(mainAxis) ? crossAxis : mainAxis; + const horizontalStartsAtEnd = startsAtEnd(horizontalAxis); + const verticalStartsAtEnd = startsAtEnd(verticalAxis); + let left = entry.nativeLayout.location.x; + let top = entry.nativeLayout.location.y; + let reverseRight; + let reverseBottom; + if (entry.declarations.positionType === 1) { + const physicalLeftFirst = axisRelativePosition(entry.declarations, 0, 2, entry.direction, ownerWidth, resolveTaffyPosition); + const physicalRightFirst = axisRelativePosition(entry.declarations, 2, 0, entry.direction, ownerWidth, resolveTaffyPosition); + const taffyX = parentEntry.direction === 2 ? Math.fround(-physicalRightFirst) : physicalLeftFirst; + const taffyY = axisRelativePosition(entry.declarations, 1, 3, entry.direction, ownerHeight, resolveTaffyPosition); + const desiredX = horizontalStartsAtEnd ? Math.fround(-relativeX) : relativeX; + const desiredY = verticalStartsAtEnd ? Math.fround(-relativeY) : relativeY; + const flowLeft = floatSubtract(left, taffyX); + const flowTop = floatSubtract(top, taffyY); + left = floatAdd(flowLeft, desiredX); + top = floatAdd(flowTop, desiredY); + if (horizontalStartsAtEnd) reverseRight = floatAdd(floatSubtract(floatSubtract(parentLayout.size.width, entry.nativeLayout.size.width), flowLeft), relativeX); + if (verticalStartsAtEnd) reverseBottom = floatAdd(floatSubtract(floatSubtract(parentLayout.size.height, entry.nativeLayout.size.height), flowTop), relativeY); + } else { + const horizontalLocation = absoluteAxisLocation(entry, parentEntry, true); + const verticalLocation = absoluteAxisLocation(entry, parentEntry, false); + if (horizontalLocation !== void 0) { + left = horizontalLocation.location; + if (horizontalStartsAtEnd) reverseRight = horizontalLocation.trailing; + } + if (verticalLocation !== void 0) { + top = verticalLocation.location; + if (verticalStartsAtEnd) reverseBottom = verticalLocation.trailing; + } + } + rawLayout = { + left, + right: horizontalStartsAtEnd ? reverseRight ?? floatSubtract(floatSubtract(parentLayout.size.width, entry.nativeLayout.size.width), left) : floatAdd(positionMargin.right, relativeX), + top, + bottom: verticalStartsAtEnd ? reverseBottom ?? floatSubtract(floatSubtract(parentLayout.size.height, entry.nativeLayout.size.height), top) : floatAdd(positionMargin.bottom, relativeY), + width: entry.nativeLayout.size.width, + height: entry.nativeLayout.size.height + }; + absoluteLeft = parent.absoluteLeft + left; + absoluteTop = parent.absoluteTop + top; + } + states.push({ + layout: roundLayout(rawLayout, absoluteLeft, absoluteTop, entry.pointScaleFactor, entry.measured), + margin, + padding, + border, + direction: entry.direction, + absoluteLeft, + absoluteTop, + suppressed + }); + } + return states; +} +//#endregion //#region src/translate.ts function alignItems(value) { switch (value) { @@ -495,10 +753,11 @@ function effectiveFlexShrink(declarations, config) { if (!config.useWebDefaults && declarations.flex !== void 0 && declarations.flex < 0) return -declarations.flex; return config.useWebDefaults ? 1 : 0; } -function translateStyle(declarations, config, resolvedDirection) { - const margin = rect((edge) => toLengthPercentageAuto(physicalValue(declarations.margin, edge, resolvedDirection))); +function translateStyle(declarations, config, resolvedDirection, ownerDirection) { + const absoluteDirection = declarations.positionType === 2 ? ownerDirection : resolvedDirection; + const margin = rect((edge) => toLengthPercentageAuto(physicalValue(declarations.margin, edge, absoluteDirection))); const padding = rect((edge) => toLengthPercentage(physicalValue(declarations.padding, edge, resolvedDirection))); - const position = rect((edge) => toDimension(physicalValue(declarations.position, edge, resolvedDirection))); + const position = rect((edge) => toDimension(physicalValue(declarations.position, edge, absoluteDirection))); const border = rect((edge) => physicalNumber(declarations.border, edge, resolvedDirection)); const columnGap = resolveGutter(declarations.gap, 0, (value) => value.unit !== 0, undefinedValue); const rowGap = resolveGutter(declarations.gap, 1, (value) => value.unit !== 0, undefinedValue); @@ -543,6 +802,50 @@ function translateStyle(declarations, config, resolvedDirection) { flexShrink: effectiveFlexShrink(declarations, config) }; } +function resolveLength(value, basis) { + switch (value.unit) { + case 1: return value.value; + case 2: return basis === void 0 ? void 0 : resolvePercentage(basis, value.value); + case 3: + case 0: return; + } +} +function exactRootDimension(declarations, axis, ownerSize, ownerWidth, resolvedDirection) { + if (ownerSize === void 0 || Number.isNaN(ownerSize) || ownerSize === Number.NEGATIVE_INFINITY) return; + const declared = axis === "width" ? declarations.width : declarations.height; + const maximum = axis === "width" ? declarations.maxWidth : declarations.maxHeight; + const declaredLength = resolveLength(declared, ownerSize); + if (declaredLength !== void 0 && declaredLength >= 0) return void 0; + if (resolveLength(maximum, ownerSize) !== void 0) return void 0; + const startEdge = axis === "width" ? 0 : 1; + const endEdge = axis === "width" ? 2 : 3; + const marginStart = resolveLength(physicalValue(declarations.margin, startEdge, resolvedDirection), ownerWidth) ?? 0; + const marginEnd = resolveLength(physicalValue(declarations.margin, endEdge, resolvedDirection), ownerWidth) ?? 0; + let outerSize = Math.fround(Math.fround(ownerSize - marginStart) - marginEnd); + if (declarations.boxSizing === 1) { + const paddingStart = Math.max(0, resolveLength(physicalValue(declarations.padding, startEdge, resolvedDirection), ownerWidth) ?? 0); + const paddingEnd = Math.max(0, resolveLength(physicalValue(declarations.padding, endEdge, resolvedDirection), ownerWidth) ?? 0); + const borderStart = Math.max(0, physicalNumber(declarations.border, startEdge, resolvedDirection)); + const borderEnd = Math.max(0, physicalNumber(declarations.border, endEdge, resolvedDirection)); + outerSize = Math.fround(Math.fround(Math.fround(Math.fround(outerSize - paddingStart) - paddingEnd) - borderStart) - borderEnd); + } + return Math.max(0, outerSize); +} +function translateCalculationStyle(declarations, config, resolvedDirection, ownerDirection, ownerWidth, ownerHeight) { + const width = exactRootDimension(declarations, "width", ownerWidth, ownerWidth, resolvedDirection); + const height = exactRootDimension(declarations, "height", ownerHeight, ownerWidth, resolvedDirection); + const forceFlexDisplay = declarations.display === 1; + if (width === void 0 && height === void 0 && !forceFlexDisplay) return null; + const ordinary = translateStyle(declarations, config, resolvedDirection, ownerDirection); + return { + ...ordinary, + display: forceFlexDisplay ? Display.Flex : ordinary.display, + size: { + width: width === void 0 ? toDimension(declarations.width) : Dimension.Length(width), + height: height === void 0 ? toDimension(declarations.height) : Dimension.Length(height) + } + }; +} function declarationDirection(declarations, ownerDirection) { return declarations.direction === 0 ? ownerDirection : declarations.direction; } @@ -718,7 +1021,7 @@ function applyDeclarations(record, declarations) { if (sameDeclarations(record.declarations, declarations)) return; const dirtyRecords = collectAncestors(record, true); const direction = effectiveDirection(record, declarations); - const style = translateStyle(declarations, record.config, direction); + const style = translateStyle(declarations, record.config, direction, record.ownerDirection); record.runtime.tree.setStyle(record.nodeId, style); record.declarations = declarations; record.appliedDirection = direction; @@ -744,12 +1047,12 @@ function syncSubtreeStyles(root, rootOwnerDirection, subtree) { ownerDirection = parentDirection; } const appliedDirection = declarationDirection(record.declarations, ownerDirection); - const stale = record.appliedConfigRevision !== record.config.revision || record.appliedDirection !== appliedDirection; + const stale = record.appliedConfigRevision !== record.config.revision || record.appliedDirection !== appliedDirection || record.ownerDirection !== ownerDirection; plan.push({ record, ownerDirection, appliedDirection, - style: stale ? translateStyle(record.declarations, record.config, appliedDirection) : void 0 + style: stale ? translateStyle(record.declarations, record.config, appliedDirection, ownerDirection) : void 0 }); directions.set(record.nodeId, appliedDirection); } @@ -816,7 +1119,7 @@ var YogaNode = class { const declarations = createDeclarations(config.useWebDefaults); const ownerDirection = 1; const appliedDirection = declarationDirection(declarations, ownerDirection); - const nodeId = runtime.tree.newLeaf(translateStyle(declarations, config, appliedDirection)); + const nodeId = runtime.tree.newLeaf(translateStyle(declarations, config, appliedDirection, ownerDirection)); nodeRecords.set(this, { runtime, config, @@ -827,6 +1130,10 @@ var YogaNode = class { appliedDirection, appliedConfigRevision: config.revision, layout: initialLayout(), + computedMargin: initialComputedEdges(), + computedPadding: initialComputedEdges(), + computedBorder: initialComputedEdges(), + layoutDirection: appliedDirection, dirty: true, hasNewLayout: true, dirtiedFunction: null, @@ -883,6 +1190,10 @@ var YogaNode = class { throw poisonRuntime(parentRecord.runtime, error); } childRecord.layout = initialLayout(); + childRecord.computedMargin = initialComputedEdges(); + childRecord.computedPadding = initialComputedEdges(); + childRecord.computedBorder = initialComputedEdges(); + childRecord.layoutDirection = childRecord.appliedDirection; childRecord.lastCalculation = void 0; markDirtyRecords(dirtyRecords); } @@ -908,12 +1219,16 @@ var YogaNode = class { const declarations = createDeclarations(record.config.useWebDefaults); const ownerDirection = 1; const appliedDirection = declarationDirection(declarations, ownerDirection); - record.runtime.tree.setStyle(record.nodeId, translateStyle(declarations, record.config, appliedDirection)); + record.runtime.tree.setStyle(record.nodeId, translateStyle(declarations, record.config, appliedDirection, ownerDirection)); record.declarations = declarations; record.ownerDirection = ownerDirection; record.appliedDirection = appliedDirection; record.appliedConfigRevision = record.config.revision; record.layout = initialLayout(); + record.computedMargin = initialComputedEdges(); + record.computedPadding = initialComputedEdges(); + record.computedBorder = initialComputedEdges(); + record.layoutDirection = appliedDirection; record.dirty = true; record.hasNewLayout = true; record.dirtiedFunction = null; @@ -1356,22 +1671,62 @@ var YogaNode = class { const subtree = collectSubtree(record); const translationIsStale = syncSubtreeStyles(record, ownerDirection, subtree); const recomputeSubtree = record.dirty || translationIsStale || !sameCalculation(record.lastCalculation, signature); - record.runtime.tree.computeLayout({ - root: record.nodeId, - availableSpace: { - width: availableWidth, - height: availableHeight + const rootHasOwner = record.runtime.tree.getParent(record.nodeId) !== null; + const temporaryStyle = translateCalculationStyle(record.declarations, record.config, record.appliedDirection, record.ownerDirection, signature.width, signature.height); + const ordinaryStyle = temporaryStyle === null ? null : translateStyle(record.declarations, record.config, record.appliedDirection, record.ownerDirection); + if (temporaryStyle !== null) record.runtime.tree.setStyle(record.nodeId, temporaryStyle); + record.lastCalculation = void 0; + let calculationFailed = false; + let calculationError; + try { + record.runtime.tree.computeLayout({ + root: record.nodeId, + availableSpace: { + width: availableWidth, + height: availableHeight + } + }); + } catch (error) { + calculationFailed = true; + calculationError = error; + } + if (ordinaryStyle !== null) try { + record.runtime.tree.setStyle(record.nodeId, ordinaryStyle); + } catch (error) { + throw poisonRuntime(record.runtime, error); + } + if (calculationFailed) throw calculationError; + const subtreeIndices = /* @__PURE__ */ new Map(); + for (const [index, current] of subtree.entries()) subtreeIndices.set(current.nodeId, index); + const outputs = projectOutputs(subtree.map((current, index) => { + let parentIndex = null; + if (index !== 0) { + const parentId = record.runtime.tree.getParent(current.nodeId); + const resolvedParentIndex = parentId === null ? void 0 : subtreeIndices.get(parentId); + if (resolvedParentIndex === void 0) throw poisonRuntime(record.runtime, /* @__PURE__ */ new Error("Yoga subtree projection mismatch")); + parentIndex = resolvedParentIndex; } + return { + declarations: current.declarations, + direction: current.appliedDirection, + pointScaleFactor: current.config.pointScaleFactor, + measured: current.measureFunction !== null, + nativeLayout: record.runtime.tree.getUnroundedLayout(current.nodeId), + parentIndex + }; + }), { + ownerWidth: signature.width, + ownerHeight: signature.height, + rootHasOwner }); - const layout = record.runtime.tree.getUnroundedLayout(record.nodeId); - record.layout = { - left: layout.location.x, - right: 0, - top: layout.location.y, - bottom: 0, - width: layout.size.width, - height: layout.size.height - }; + for (const [index, current] of subtree.entries()) { + const output = outputs[index]; + current.layout = output.layout; + current.computedMargin = output.margin; + current.computedPadding = output.padding; + current.computedBorder = output.border; + current.layoutDirection = output.direction; + } for (const current of subtree) { current.dirty = false; if (recomputeSubtree) current.hasNewLayout = true; @@ -1397,10 +1752,38 @@ var YogaNode = class { getComputedHeight() { return requireNode(void 0, this).layout.height; } + getComputedMargin(edge) { + const record = requireNode(void 0, this); + return computedEdge(record, record.computedMargin, edge); + } + getComputedPadding(edge) { + const record = requireNode(void 0, this); + return computedEdge(record, record.computedPadding, edge); + } + getComputedBorder(edge) { + const record = requireNode(void 0, this); + return computedEdge(record, record.computedBorder, edge); + } getComputedLayout() { return { ...requireNode(void 0, this).layout }; } }; +function computedEdge(record, edges, edge) { + const resolved = requireEnum(edge, "computed edge", [ + 0, + 1, + 2, + 3, + 4, + 5 + ]); + if (resolved === 4) return record.layoutDirection === 2 ? edges.right : edges.left; + if (resolved === 5) return record.layoutDirection === 2 ? edges.left : edges.right; + if (resolved === 0) return edges.left; + if (resolved === 1) return edges.top; + if (resolved === 2) return edges.right; + return edges.bottom; +} function freeNode(record) { const survivingAncestors = collectAncestors(record, false); const directChildren = record.runtime.tree.getChildren(record.nodeId).map((nodeId) => recordForId(record.runtime, nodeId)); diff --git a/packages/taffyjs-yoga/dist/index.d.ts b/packages/taffyjs-yoga/dist/index.d.ts index 3520b31..ac4871d 100644 --- a/packages/taffyjs-yoga/dist/index.d.ts +++ b/packages/taffyjs-yoga/dist/index.d.ts @@ -1,4 +1,4 @@ -import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, n as ConfigFactory, o as NodeFactory, p as Edge, r as DirtiedFunction, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-B1oAtvXl.js"; +import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, n as ConfigFactory, o as NodeFactory, p as Edge, r as DirtiedFunction, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-BNBR-NNr.js"; //#region src/index.d.ts declare const Yoga: Readonly<{ Config: ConfigFactory; diff --git a/packages/taffyjs-yoga/dist/index.js b/packages/taffyjs-yoga/dist/index.js index 1a50e17..3b775c3 100644 --- a/packages/taffyjs-yoga/dist/index.js +++ b/packages/taffyjs-yoga/dist/index.js @@ -1,4 +1,4 @@ -import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-vnf8jWiE.js"; +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-94Ssdbk_.js"; //#region src/index.ts const Yoga = createYoga(); //#endregion diff --git a/packages/taffyjs-yoga/dist/load.d.ts b/packages/taffyjs-yoga/dist/load.d.ts index d4dfae5..2b6fe00 100644 --- a/packages/taffyjs-yoga/dist/load.d.ts +++ b/packages/taffyjs-yoga/dist/load.d.ts @@ -1,4 +1,4 @@ -import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, p as Edge, r as DirtiedFunction, s as Yoga, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-B1oAtvXl.js"; +import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, p as Edge, r as DirtiedFunction, s as Yoga, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-BNBR-NNr.js"; //#region src/load.d.ts declare function loadYoga(): Promise; //#endregion diff --git a/packages/taffyjs-yoga/dist/load.js b/packages/taffyjs-yoga/dist/load.js index 3bcc662..94acbfa 100644 --- a/packages/taffyjs-yoga/dist/load.js +++ b/packages/taffyjs-yoga/dist/load.js @@ -1,4 +1,4 @@ -import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-vnf8jWiE.js"; +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-94Ssdbk_.js"; //#region src/load.ts async function loadYoga() { return createYoga(); diff --git a/packages/taffyjs-yoga/dist/types-B1oAtvXl.d.ts b/packages/taffyjs-yoga/dist/types-BNBR-NNr.d.ts similarity index 98% rename from packages/taffyjs-yoga/dist/types-B1oAtvXl.d.ts rename to packages/taffyjs-yoga/dist/types-BNBR-NNr.d.ts index 8e73e05..f5e1394 100644 --- a/packages/taffyjs-yoga/dist/types-B1oAtvXl.d.ts +++ b/packages/taffyjs-yoga/dist/types-BNBR-NNr.d.ts @@ -298,6 +298,9 @@ interface Node { getComputedBottom(): number; getComputedWidth(): number; getComputedHeight(): number; + getComputedMargin(edge: Edge): number; + getComputedPadding(edge: Edge): number; + getComputedBorder(edge: Edge): number; getComputedLayout(): Layout; } interface ConfigFactory { diff --git a/packages/taffyjs-yoga/package.json b/packages/taffyjs-yoga/package.json index fa9ed65..f6c03dc 100644 --- a/packages/taffyjs-yoga/package.json +++ b/packages/taffyjs-yoga/package.json @@ -12,6 +12,7 @@ "files": [ "README.md", "COMPATIBILITY.md", + "THIRD_PARTY_NOTICES.md", "dist" ], "type": "module", diff --git a/packages/taffyjs-yoga/src/facade.ts b/packages/taffyjs-yoga/src/facade.ts index 52573d3..fad1dbd 100644 --- a/packages/taffyjs-yoga/src/facade.ts +++ b/packages/taffyjs-yoga/src/facade.ts @@ -21,7 +21,13 @@ import { Wrap, legacyConstants, } from "./enums.js"; -import { declarationDirection, translateStyle } from "./translate.js"; +import { + initialComputedEdges, + projectOutputs, + type ComputedEdges, + type ProjectionEntry, +} from "./projection.js"; +import { declarationDirection, translateCalculationStyle, translateStyle } from "./translate.js"; import type { AlignContentValue, AlignItemsValue, @@ -202,6 +208,10 @@ interface NodeRecord { appliedDirection: Direction.LTR | Direction.RTL; appliedConfigRevision: number; layout: Layout; + computedMargin: ComputedEdges; + computedPadding: ComputedEdges; + computedBorder: ComputedEdges; + layoutDirection: Direction.LTR | Direction.RTL; dirty: boolean; hasNewLayout: boolean; dirtiedFunction: DirtiedFunction | null; @@ -295,7 +305,7 @@ function applyDeclarations(record: NodeRecord, declarations: YogaDeclarations): if (sameDeclarations(record.declarations, declarations)) return; const dirtyRecords = collectAncestors(record, true); const direction = effectiveDirection(record, declarations); - const style = translateStyle(declarations, record.config, direction); + const style = translateStyle(declarations, record.config, direction, record.ownerDirection); record.runtime.tree.setStyle(record.nodeId, style); record.declarations = declarations; record.appliedDirection = direction; @@ -344,13 +354,14 @@ function syncSubtreeStyles( const appliedDirection = declarationDirection(record.declarations, ownerDirection); const stale = record.appliedConfigRevision !== record.config.revision || - record.appliedDirection !== appliedDirection; + record.appliedDirection !== appliedDirection || + record.ownerDirection !== ownerDirection; plan.push({ record, ownerDirection, appliedDirection, style: stale - ? translateStyle(record.declarations, record.config, appliedDirection) + ? translateStyle(record.declarations, record.config, appliedDirection, ownerDirection) : undefined, }); directions.set(record.nodeId, appliedDirection); @@ -434,7 +445,9 @@ class YogaNode implements Node { const declarations = createDeclarations(config.useWebDefaults); const ownerDirection = Direction.LTR; const appliedDirection = declarationDirection(declarations, ownerDirection); - const nodeId = runtime.tree.newLeaf(translateStyle(declarations, config, appliedDirection)); + const nodeId = runtime.tree.newLeaf( + translateStyle(declarations, config, appliedDirection, ownerDirection), + ); nodeRecords.set(this, { runtime, config, @@ -445,6 +458,10 @@ class YogaNode implements Node { appliedDirection, appliedConfigRevision: config.revision, layout: initialLayout(), + computedMargin: initialComputedEdges(), + computedPadding: initialComputedEdges(), + computedBorder: initialComputedEdges(), + layoutDirection: appliedDirection, dirty: true, hasNewLayout: true, dirtiedFunction: null, @@ -524,6 +541,10 @@ class YogaNode implements Node { throw poisonRuntime(parentRecord.runtime, error); } childRecord.layout = initialLayout(); + childRecord.computedMargin = initialComputedEdges(); + childRecord.computedPadding = initialComputedEdges(); + childRecord.computedBorder = initialComputedEdges(); + childRecord.layoutDirection = childRecord.appliedDirection; childRecord.lastCalculation = undefined; markDirtyRecords(dirtyRecords); } @@ -563,7 +584,7 @@ class YogaNode implements Node { const appliedDirection = declarationDirection(declarations, ownerDirection); record.runtime.tree.setStyle( record.nodeId, - translateStyle(declarations, record.config, appliedDirection), + translateStyle(declarations, record.config, appliedDirection, ownerDirection), ); record.declarations = declarations; @@ -571,6 +592,10 @@ class YogaNode implements Node { record.appliedDirection = appliedDirection; record.appliedConfigRevision = record.config.revision; record.layout = initialLayout(); + record.computedMargin = initialComputedEdges(); + record.computedPadding = initialComputedEdges(); + record.computedBorder = initialComputedEdges(); + record.layoutDirection = appliedDirection; record.dirty = true; record.hasNewLayout = true; record.dirtiedFunction = null; @@ -1112,22 +1137,85 @@ class YogaNode implements Node { const translationIsStale = syncSubtreeStyles(record, ownerDirection, subtree); const recomputeSubtree = record.dirty || translationIsStale || !sameCalculation(record.lastCalculation, signature); - record.runtime.tree.computeLayout({ - root: record.nodeId, - availableSpace: { - width: availableWidth, - height: availableHeight, - }, + const rootHasOwner = record.runtime.tree.getParent(record.nodeId) !== null; + const temporaryStyle = translateCalculationStyle( + record.declarations, + record.config, + record.appliedDirection, + record.ownerDirection, + signature.width, + signature.height, + ); + const ordinaryStyle = + temporaryStyle === null + ? null + : translateStyle( + record.declarations, + record.config, + record.appliedDirection, + record.ownerDirection, + ); + if (temporaryStyle !== null) record.runtime.tree.setStyle(record.nodeId, temporaryStyle); + record.lastCalculation = undefined; + let calculationFailed = false; + let calculationError: unknown; + try { + record.runtime.tree.computeLayout({ + root: record.nodeId, + availableSpace: { + width: availableWidth, + height: availableHeight, + }, + }); + } catch (error) { + calculationFailed = true; + calculationError = error; + } + if (ordinaryStyle !== null) { + try { + record.runtime.tree.setStyle(record.nodeId, ordinaryStyle); + } catch (error) { + throw poisonRuntime(record.runtime, error); + } + } + if (calculationFailed) throw calculationError; + + const subtreeIndices = new Map(); + for (const [index, current] of subtree.entries()) { + subtreeIndices.set(current.nodeId, index); + } + const projectionEntries: ProjectionEntry[] = subtree.map((current, index) => { + let parentIndex: number | null = null; + if (index !== 0) { + const parentId = record.runtime.tree.getParent(current.nodeId); + const resolvedParentIndex = parentId === null ? undefined : subtreeIndices.get(parentId); + if (resolvedParentIndex === undefined) { + throw poisonRuntime(record.runtime, new Error("Yoga subtree projection mismatch")); + } + parentIndex = resolvedParentIndex; + } + return { + declarations: current.declarations, + direction: current.appliedDirection, + pointScaleFactor: current.config.pointScaleFactor, + measured: current.measureFunction !== null, + nativeLayout: record.runtime.tree.getUnroundedLayout(current.nodeId), + parentIndex, + }; }); - const layout = record.runtime.tree.getUnroundedLayout(record.nodeId); - record.layout = { - left: layout.location.x, - right: 0, - top: layout.location.y, - bottom: 0, - width: layout.size.width, - height: layout.size.height, - }; + const outputs = projectOutputs(projectionEntries, { + ownerWidth: signature.width, + ownerHeight: signature.height, + rootHasOwner, + }); + for (const [index, current] of subtree.entries()) { + const output = outputs[index]; + current.layout = output.layout; + current.computedMargin = output.margin; + current.computedPadding = output.padding; + current.computedBorder = output.border; + current.layoutDirection = output.direction; + } for (const current of subtree) { current.dirty = false; if (recomputeSubtree) current.hasNewLayout = true; @@ -1160,11 +1248,47 @@ class YogaNode implements Node { return requireNode(undefined, this).layout.height; } + getComputedMargin(edge: Edge): number { + const record = requireNode(undefined, this); + return computedEdge(record, record.computedMargin, edge); + } + + getComputedPadding(edge: Edge): number { + const record = requireNode(undefined, this); + return computedEdge(record, record.computedPadding, edge); + } + + getComputedBorder(edge: Edge): number { + const record = requireNode(undefined, this); + return computedEdge(record, record.computedBorder, edge); + } + getComputedLayout(): Layout { return { ...requireNode(undefined, this).layout }; } } +function computedEdge(record: NodeRecord, edges: ComputedEdges, edge: Edge): number { + const resolved = requireEnum(edge, "computed edge", [ + Edge.Left, + Edge.Top, + Edge.Right, + Edge.Bottom, + Edge.Start, + Edge.End, + ]); + if (resolved === Edge.Start) { + return record.layoutDirection === Direction.RTL ? edges.right : edges.left; + } + if (resolved === Edge.End) { + return record.layoutDirection === Direction.RTL ? edges.left : edges.right; + } + if (resolved === Edge.Left) return edges.left; + if (resolved === Edge.Top) return edges.top; + if (resolved === Edge.Right) return edges.right; + return edges.bottom; +} + function freeNode(record: NodeRecord): void { const survivingAncestors = collectAncestors(record, false); const directChildren = record.runtime.tree diff --git a/packages/taffyjs-yoga/src/projection.ts b/packages/taffyjs-yoga/src/projection.ts new file mode 100644 index 0000000..ac4df3e --- /dev/null +++ b/packages/taffyjs-yoga/src/projection.ts @@ -0,0 +1,557 @@ +import type { Layout as NativeLayout } from "@taffyjs/node"; +import { resolveEdge, type YogaDeclarations } from "./declarations.js"; +import { Direction, Display, Edge, FlexDirection, PositionType, Unit } from "./enums.js"; +import type { Layout } from "./types.js"; +import { resolvePercentage, type YogaValue } from "./values.js"; + +export interface ComputedEdges { + readonly left: number; + readonly top: number; + readonly right: number; + readonly bottom: number; +} + +export interface ProjectionEntry { + readonly declarations: YogaDeclarations; + readonly direction: Direction.LTR | Direction.RTL; + readonly pointScaleFactor: number; + readonly measured: boolean; + readonly nativeLayout: NativeLayout; + readonly parentIndex: number | null; +} + +export interface ProjectionOptions { + readonly ownerWidth: number | undefined; + readonly ownerHeight: number | undefined; + readonly rootHasOwner: boolean; +} + +export interface ProjectedOutput { + readonly layout: Layout; + readonly margin: ComputedEdges; + readonly padding: ComputedEdges; + readonly border: ComputedEdges; + readonly direction: Direction.LTR | Direction.RTL; +} + +interface ProjectionState extends ProjectedOutput { + readonly absoluteLeft: number; + readonly absoluteTop: number; + readonly suppressed: boolean; +} + +export function initialComputedEdges(): ComputedEdges { + return { left: 0, top: 0, right: 0, bottom: 0 }; +} + +function floatAdd(left: number, right: number): number { + return Math.fround(left + right); +} + +function floatSubtract(left: number, right: number): number { + return Math.fround(left - right); +} + +function inexactEquals(left: number, right: number): boolean { + if (!Number.isNaN(left) && !Number.isNaN(right)) return Math.abs(left - right) < 0.0001; + return Number.isNaN(left) && Number.isNaN(right); +} + +// Ported from Yoga 3.2.1's `yoga/algorithm/PixelGrid.cpp`, copyright Facebook, Inc. and its affiliates, under the MIT license. See `../THIRD_PARTY_NOTICES.md`. +function roundValueToPixelGrid( + value: number, + pointScaleFactor: number, + forceCeil: boolean, + forceFloor: boolean, +): number { + let scaledValue = value * pointScaleFactor; + let fractional = scaledValue % 1; + if (fractional < 0) fractional += 1; + + if (inexactEquals(fractional, 0)) { + scaledValue -= fractional; + } else if (inexactEquals(fractional, 1)) { + scaledValue = scaledValue - fractional + 1; + } else if (forceCeil) { + scaledValue = scaledValue - fractional + 1; + } else if (forceFloor) { + scaledValue -= fractional; + } else { + scaledValue = + scaledValue - + fractional + + (!Number.isNaN(fractional) && (fractional > 0.5 || inexactEquals(fractional, 0.5)) ? 1 : 0); + } + + return Math.fround( + Number.isNaN(scaledValue) || Number.isNaN(pointScaleFactor) + ? Number.NaN + : scaledValue / pointScaleFactor, + ); +} + +function roundLayout( + layout: Layout, + absoluteLeft: number, + absoluteTop: number, + pointScaleFactor: number, + textRounding: boolean, +): Layout { + if (pointScaleFactor === 0) return layout; + + const absoluteRight = absoluteLeft + layout.width; + const absoluteBottom = absoluteTop + layout.height; + const fractionalWidth = (layout.width * pointScaleFactor) % 1; + const fractionalHeight = (layout.height * pointScaleFactor) % 1; + const hasFractionalWidth = + !inexactEquals(fractionalWidth, 0) && !inexactEquals(fractionalWidth, 1); + const hasFractionalHeight = + !inexactEquals(fractionalHeight, 0) && !inexactEquals(fractionalHeight, 1); + + return { + left: roundValueToPixelGrid(layout.left, pointScaleFactor, false, textRounding), + right: layout.right, + top: roundValueToPixelGrid(layout.top, pointScaleFactor, false, textRounding), + bottom: layout.bottom, + width: Math.fround( + roundValueToPixelGrid( + absoluteRight, + pointScaleFactor, + textRounding && hasFractionalWidth, + textRounding && !hasFractionalWidth, + ) - roundValueToPixelGrid(absoluteLeft, pointScaleFactor, false, textRounding), + ), + height: Math.fround( + roundValueToPixelGrid( + absoluteBottom, + pointScaleFactor, + textRounding && hasFractionalHeight, + textRounding && !hasFractionalHeight, + ) - roundValueToPixelGrid(absoluteTop, pointScaleFactor, false, textRounding), + ), + }; +} + +function physicalValue( + values: YogaDeclarations["position"], + edge: Edge.Left | Edge.Top | Edge.Right | Edge.Bottom, + direction: Direction.LTR | Direction.RTL, +): YogaValue { + return resolveEdge( + values, + edge, + direction, + (value) => value.unit !== Unit.Undefined, + () => ({ unit: Unit.Undefined, value: Number.NaN }), + ); +} + +function resolvePosition(value: YogaValue, basis: number | undefined): number | undefined { + switch (value.unit) { + case Unit.Point: + return value.value; + case Unit.Percent: + return basis === undefined ? undefined : resolvePercentage(basis, value.value); + case Unit.Auto: + case Unit.Undefined: + return undefined; + } +} + +function resolveTaffyPosition(value: YogaValue, basis: number | undefined): number | undefined { + return value.unit === Unit.Percent && basis !== undefined + ? Math.fround(basis * Math.fround(value.value / 100)) + : resolvePosition(value, basis); +} + +function axisRelativePosition( + declarations: YogaDeclarations, + start: Edge.Left | Edge.Top | Edge.Right | Edge.Bottom, + end: Edge.Left | Edge.Top | Edge.Right | Edge.Bottom, + direction: Direction.LTR | Direction.RTL, + basis: number | undefined, + resolve: (value: YogaValue, basis: number | undefined) => number | undefined = resolvePosition, +): number { + const startValue = physicalValue(declarations.position, start, direction); + const resolvedStart = resolve(startValue, basis); + if (resolvedStart !== undefined) return resolvedStart; + const endValue = physicalValue(declarations.position, end, direction); + const resolvedEnd = resolve(endValue, basis); + return resolvedEnd === undefined ? 0 : Math.fround(-resolvedEnd); +} + +function nativeMarginEdges(entry: ProjectionEntry, suppressed: boolean): ComputedEdges { + if (suppressed) return initialComputedEdges(); + const { declarations, direction, nativeLayout } = entry; + const read = (edge: Edge.Left | Edge.Top | Edge.Right | Edge.Bottom): number => + physicalValue(declarations.margin, edge, direction).unit === Unit.Auto + ? 0 + : nativeLayout.margin[ + edge === Edge.Left + ? "left" + : edge === Edge.Top + ? "top" + : edge === Edge.Right + ? "right" + : "bottom" + ]; + return { + left: read(Edge.Left), + top: read(Edge.Top), + right: read(Edge.Right), + bottom: read(Edge.Bottom), + }; +} + +function declaredMarginEdges( + entry: ProjectionEntry, + basis: number | undefined, + suppressed: boolean, +): ComputedEdges { + if (suppressed) return initialComputedEdges(); + const read = (edge: Edge.Left | Edge.Top | Edge.Right | Edge.Bottom): number => { + const value = physicalValue(entry.declarations.margin, edge, entry.direction); + return value.unit === Unit.Auto ? 0 : (resolvePosition(value, basis) ?? 0); + }; + return { + left: read(Edge.Left), + top: read(Edge.Top), + right: read(Edge.Right), + bottom: read(Edge.Bottom), + }; +} + +function nativeEdges(edges: NativeLayout["padding"], suppressed: boolean): ComputedEdges { + return suppressed + ? initialComputedEdges() + : { left: edges.left, top: edges.top, right: edges.right, bottom: edges.bottom }; +} + +function resolveMainAxis( + flexDirection: FlexDirection, + direction: Direction.LTR | Direction.RTL, +): FlexDirection { + if (direction === Direction.RTL) { + if (flexDirection === FlexDirection.Row) return FlexDirection.RowReverse; + if (flexDirection === FlexDirection.RowReverse) return FlexDirection.Row; + } + return flexDirection; +} + +function resolveCrossAxis( + mainAxis: FlexDirection, + direction: Direction.LTR | Direction.RTL, +): FlexDirection { + return mainAxis === FlexDirection.Column || mainAxis === FlexDirection.ColumnReverse + ? resolveMainAxis(FlexDirection.Row, direction) + : FlexDirection.Column; +} + +function isHorizontal(axis: FlexDirection): boolean { + return axis === FlexDirection.Row || axis === FlexDirection.RowReverse; +} + +function startsAtEnd(axis: FlexDirection): boolean { + return axis === FlexDirection.RowReverse || axis === FlexDirection.ColumnReverse; +} + +function innerWidth(layout: NativeLayout): number { + return Math.max( + 0, + Math.fround( + layout.size.width - + layout.padding.left - + layout.padding.right - + layout.border.left - + layout.border.right, + ), + ); +} + +function innerHeight(layout: NativeLayout): number { + return Math.max( + 0, + Math.fround( + layout.size.height - + layout.padding.top - + layout.padding.bottom - + layout.border.top - + layout.border.bottom, + ), + ); +} + +function absoluteAxisLocation( + entry: ProjectionEntry, + parentEntry: ProjectionEntry, + horizontal: boolean, +): { readonly location: number; readonly trailing: number | undefined } | undefined { + const parentLayout = parentEntry.nativeLayout; + const containingWidth = Math.max( + 0, + Math.fround(parentLayout.size.width - parentLayout.border.left - parentLayout.border.right), + ); + const containingHeight = Math.max( + 0, + Math.fround(parentLayout.size.height - parentLayout.border.top - parentLayout.border.bottom), + ); + const start = horizontal + ? parentEntry.direction === Direction.RTL + ? Edge.Right + : Edge.Left + : Edge.Top; + const end = horizontal + ? parentEntry.direction === Direction.RTL + ? Edge.Left + : Edge.Right + : Edge.Bottom; + const positionBasis = horizontal ? containingWidth : containingHeight; + const startPosition = resolvePosition( + physicalValue(entry.declarations.position, start, parentEntry.direction), + positionBasis, + ); + const endPosition = resolvePosition( + physicalValue(entry.declarations.position, end, parentEntry.direction), + positionBasis, + ); + const selectedEdge = startPosition === undefined ? end : start; + const selectedPosition = startPosition ?? endPosition; + if (selectedPosition === undefined) return undefined; + + const selectedMargin = + resolvePosition( + physicalValue(entry.declarations.margin, selectedEdge, parentEntry.direction), + containingWidth, + ) ?? 0; + const selectedBorder = + selectedEdge === Edge.Left + ? parentLayout.border.left + : selectedEdge === Edge.Top + ? parentLayout.border.top + : selectedEdge === Edge.Right + ? parentLayout.border.right + : parentLayout.border.bottom; + const offset = floatAdd(floatAdd(selectedPosition, selectedMargin), selectedBorder); + const startsAtPhysicalEnd = selectedEdge === Edge.Right || selectedEdge === Edge.Bottom; + if (!startsAtPhysicalEnd) return { location: offset, trailing: undefined }; + const parentSize = horizontal ? parentLayout.size.width : parentLayout.size.height; + const childSize = horizontal ? entry.nativeLayout.size.width : entry.nativeLayout.size.height; + return { + location: floatSubtract(floatSubtract(parentSize, childSize), offset), + trailing: startPosition === undefined ? undefined : offset, + }; +} + +export function projectOutputs( + entries: readonly ProjectionEntry[], + options: ProjectionOptions, +): readonly ProjectedOutput[] { + const states: ProjectionState[] = []; + + for (const [index, entry] of entries.entries()) { + const parent = entry.parentIndex === null ? undefined : states[entry.parentIndex]; + if (entry.parentIndex !== null && parent === undefined) { + throw new Error("Yoga projection requires parents before children"); + } + const suppressed = + parent?.suppressed === true || (index !== 0 && entry.declarations.display === Display.None); + const parentEntry = + entry.parentIndex === null ? undefined : entries[entry.parentIndex as number]; + const containingWidth = + parentEntry === undefined + ? options.ownerWidth + : Math.max( + 0, + Math.fround( + parentEntry.nativeLayout.size.width - + parentEntry.nativeLayout.border.left - + parentEntry.nativeLayout.border.right, + ), + ); + const margin = + entry.declarations.positionType === PositionType.Absolute + ? declaredMarginEdges(entry, containingWidth, suppressed) + : nativeMarginEdges(entry, suppressed); + const positionMargin = + entry.declarations.positionType === PositionType.Absolute && parentEntry !== undefined + ? declaredMarginEdges(entry, innerWidth(parentEntry.nativeLayout), suppressed) + : margin; + const padding = nativeEdges(entry.nativeLayout.padding, suppressed); + const border = nativeEdges(entry.nativeLayout.border, suppressed); + + let rawLayout: Layout; + let absoluteLeft: number; + let absoluteTop: number; + if (suppressed) { + rawLayout = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 }; + absoluteLeft = parent?.absoluteLeft ?? 0; + absoluteTop = parent?.absoluteTop ?? 0; + } else if (parent === undefined) { + const positionDirection = options.rootHasOwner ? entry.direction : Direction.LTR; + const relativeX = axisRelativePosition( + entry.declarations, + positionDirection === Direction.RTL ? Edge.Right : Edge.Left, + positionDirection === Direction.RTL ? Edge.Left : Edge.Right, + positionDirection, + options.ownerWidth, + ); + const relativeY = axisRelativePosition( + entry.declarations, + Edge.Top, + Edge.Bottom, + positionDirection, + options.ownerHeight, + ); + rawLayout = { + left: floatAdd(margin.left, relativeX), + right: floatAdd(margin.right, relativeX), + top: floatAdd(margin.top, relativeY), + bottom: floatAdd(margin.bottom, relativeY), + width: entry.nativeLayout.size.width, + height: entry.nativeLayout.size.height, + }; + absoluteLeft = rawLayout.left; + absoluteTop = rawLayout.top; + } else { + const parentEntry = entries[entry.parentIndex as number]; + const parentLayout = parentEntry.nativeLayout; + const ownerWidth = innerWidth(parentLayout); + const ownerHeight = innerHeight(parentLayout); + const relativeX = axisRelativePosition( + entry.declarations, + entry.direction === Direction.RTL ? Edge.Right : Edge.Left, + entry.direction === Direction.RTL ? Edge.Left : Edge.Right, + entry.direction, + ownerWidth, + ); + const relativeY = axisRelativePosition( + entry.declarations, + Edge.Top, + Edge.Bottom, + entry.direction, + ownerHeight, + ); + const mainAxis = resolveMainAxis( + parentEntry.declarations.flexDirection, + parentEntry.direction, + ); + const crossAxis = resolveCrossAxis(mainAxis, parentEntry.direction); + const horizontalAxis = isHorizontal(mainAxis) ? mainAxis : crossAxis; + const verticalAxis = isHorizontal(mainAxis) ? crossAxis : mainAxis; + const horizontalStartsAtEnd = startsAtEnd(horizontalAxis); + const verticalStartsAtEnd = startsAtEnd(verticalAxis); + + let left = entry.nativeLayout.location.x; + let top = entry.nativeLayout.location.y; + let reverseRight: number | undefined; + let reverseBottom: number | undefined; + if (entry.declarations.positionType === PositionType.Relative) { + const physicalLeftFirst = axisRelativePosition( + entry.declarations, + Edge.Left, + Edge.Right, + entry.direction, + ownerWidth, + resolveTaffyPosition, + ); + const physicalRightFirst = axisRelativePosition( + entry.declarations, + Edge.Right, + Edge.Left, + entry.direction, + ownerWidth, + resolveTaffyPosition, + ); + const taffyX = + parentEntry.direction === Direction.RTL + ? Math.fround(-physicalRightFirst) + : physicalLeftFirst; + const taffyY = axisRelativePosition( + entry.declarations, + Edge.Top, + Edge.Bottom, + entry.direction, + ownerHeight, + resolveTaffyPosition, + ); + const desiredX = horizontalStartsAtEnd ? Math.fround(-relativeX) : relativeX; + const desiredY = verticalStartsAtEnd ? Math.fround(-relativeY) : relativeY; + const flowLeft = floatSubtract(left, taffyX); + const flowTop = floatSubtract(top, taffyY); + left = floatAdd(flowLeft, desiredX); + top = floatAdd(flowTop, desiredY); + if (horizontalStartsAtEnd) { + reverseRight = floatAdd( + floatSubtract( + floatSubtract(parentLayout.size.width, entry.nativeLayout.size.width), + flowLeft, + ), + relativeX, + ); + } + if (verticalStartsAtEnd) { + reverseBottom = floatAdd( + floatSubtract( + floatSubtract(parentLayout.size.height, entry.nativeLayout.size.height), + flowTop, + ), + relativeY, + ); + } + } else { + const horizontalLocation = absoluteAxisLocation(entry, parentEntry, true); + const verticalLocation = absoluteAxisLocation(entry, parentEntry, false); + if (horizontalLocation !== undefined) { + left = horizontalLocation.location; + if (horizontalStartsAtEnd) reverseRight = horizontalLocation.trailing; + } + if (verticalLocation !== undefined) { + top = verticalLocation.location; + if (verticalStartsAtEnd) reverseBottom = verticalLocation.trailing; + } + } + + rawLayout = { + left, + right: horizontalStartsAtEnd + ? (reverseRight ?? + floatSubtract( + floatSubtract(parentLayout.size.width, entry.nativeLayout.size.width), + left, + )) + : floatAdd(positionMargin.right, relativeX), + top, + bottom: verticalStartsAtEnd + ? (reverseBottom ?? + floatSubtract( + floatSubtract(parentLayout.size.height, entry.nativeLayout.size.height), + top, + )) + : floatAdd(positionMargin.bottom, relativeY), + width: entry.nativeLayout.size.width, + height: entry.nativeLayout.size.height, + }; + absoluteLeft = parent.absoluteLeft + left; + absoluteTop = parent.absoluteTop + top; + } + + states.push({ + layout: roundLayout( + rawLayout, + absoluteLeft, + absoluteTop, + entry.pointScaleFactor, + entry.measured, + ), + margin, + padding, + border, + direction: entry.direction, + absoluteLeft, + absoluteTop, + suppressed, + }); + } + + return states; +} diff --git a/packages/taffyjs-yoga/src/translate.ts b/packages/taffyjs-yoga/src/translate.ts index 05e9563..42b5ad5 100644 --- a/packages/taffyjs-yoga/src/translate.ts +++ b/packages/taffyjs-yoga/src/translate.ts @@ -34,6 +34,7 @@ import { toLengthPercentage, toLengthPercentageAuto, undefinedValue, + resolvePercentage, type YogaValue, } from "./values.js"; @@ -195,15 +196,18 @@ export function translateStyle( declarations: YogaDeclarations, config: TranslationConfig, resolvedDirection: Direction.LTR | Direction.RTL, + ownerDirection: Direction.LTR | Direction.RTL, ): StyleInput { + const absoluteDirection = + declarations.positionType === PositionType.Absolute ? ownerDirection : resolvedDirection; const margin = rect((edge) => - toLengthPercentageAuto(physicalValue(declarations.margin, edge, resolvedDirection)), + toLengthPercentageAuto(physicalValue(declarations.margin, edge, absoluteDirection)), ); const padding = rect((edge) => toLengthPercentage(physicalValue(declarations.padding, edge, resolvedDirection)), ); const position = rect((edge) => - toDimension(physicalValue(declarations.position, edge, resolvedDirection)), + toDimension(physicalValue(declarations.position, edge, absoluteDirection)), ); const border = rect((edge) => physicalNumber(declarations.border, edge, resolvedDirection)); const columnGap = resolveGutter( @@ -277,6 +281,111 @@ export function translateStyle( }; } +function resolveLength(value: YogaValue, basis: number | undefined): number | undefined { + switch (value.unit) { + case Unit.Point: + return value.value; + case Unit.Percent: + return basis === undefined ? undefined : resolvePercentage(basis, value.value); + case Unit.Auto: + case Unit.Undefined: + return undefined; + } +} + +function exactRootDimension( + declarations: YogaDeclarations, + axis: "width" | "height", + ownerSize: number | undefined, + ownerWidth: number | undefined, + resolvedDirection: Direction.LTR | Direction.RTL, +): number | undefined { + if ( + ownerSize === undefined || + Number.isNaN(ownerSize) || + ownerSize === Number.NEGATIVE_INFINITY + ) { + return undefined; + } + const declared = axis === "width" ? declarations.width : declarations.height; + const maximum = axis === "width" ? declarations.maxWidth : declarations.maxHeight; + const declaredLength = resolveLength(declared, ownerSize); + if (declaredLength !== undefined && declaredLength >= 0) return undefined; + if (resolveLength(maximum, ownerSize) !== undefined) return undefined; + + const startEdge = axis === "width" ? Edge.Left : Edge.Top; + const endEdge = axis === "width" ? Edge.Right : Edge.Bottom; + const marginStart = + resolveLength(physicalValue(declarations.margin, startEdge, resolvedDirection), ownerWidth) ?? + 0; + const marginEnd = + resolveLength(physicalValue(declarations.margin, endEdge, resolvedDirection), ownerWidth) ?? 0; + let outerSize = Math.fround(Math.fround(ownerSize - marginStart) - marginEnd); + + if (declarations.boxSizing === BoxSizing.ContentBox) { + const paddingStart = Math.max( + 0, + resolveLength( + physicalValue(declarations.padding, startEdge, resolvedDirection), + ownerWidth, + ) ?? 0, + ); + const paddingEnd = Math.max( + 0, + resolveLength(physicalValue(declarations.padding, endEdge, resolvedDirection), ownerWidth) ?? + 0, + ); + const borderStart = Math.max( + 0, + physicalNumber(declarations.border, startEdge, resolvedDirection), + ); + const borderEnd = Math.max(0, physicalNumber(declarations.border, endEdge, resolvedDirection)); + outerSize = Math.fround( + Math.fround(Math.fround(Math.fround(outerSize - paddingStart) - paddingEnd) - borderStart) - + borderEnd, + ); + } + + return Math.max(0, outerSize); +} + +export function translateCalculationStyle( + declarations: YogaDeclarations, + config: TranslationConfig, + resolvedDirection: Direction.LTR | Direction.RTL, + ownerDirection: Direction.LTR | Direction.RTL, + ownerWidth: number | undefined, + ownerHeight: number | undefined, +): StyleInput | null { + const width = exactRootDimension( + declarations, + "width", + ownerWidth, + ownerWidth, + resolvedDirection, + ); + const height = exactRootDimension( + declarations, + "height", + ownerHeight, + ownerWidth, + resolvedDirection, + ); + const forceFlexDisplay = declarations.display === Display.None; + if (width === undefined && height === undefined && !forceFlexDisplay) return null; + + const ordinary = translateStyle(declarations, config, resolvedDirection, ownerDirection); + return { + ...ordinary, + display: forceFlexDisplay ? TaffyDisplay.Flex : ordinary.display, + size: { + width: width === undefined ? toDimension(declarations.width) : TaffyDimension.Length(width), + height: + height === undefined ? toDimension(declarations.height) : TaffyDimension.Length(height), + }, + }; +} + export function declarationDirection( declarations: YogaDeclarations, ownerDirection: Direction.LTR | Direction.RTL, diff --git a/packages/taffyjs-yoga/src/types.ts b/packages/taffyjs-yoga/src/types.ts index d6dc028..1ee831a 100644 --- a/packages/taffyjs-yoga/src/types.ts +++ b/packages/taffyjs-yoga/src/types.ts @@ -173,6 +173,9 @@ export interface Node { getComputedBottom(): number; getComputedWidth(): number; getComputedHeight(): number; + getComputedMargin(edge: Edge): number; + getComputedPadding(edge: Edge): number; + getComputedBorder(edge: Edge): number; getComputedLayout(): Layout; } diff --git a/packages/taffyjs-yoga/src/values.ts b/packages/taffyjs-yoga/src/values.ts index 97e7cb9..d287c92 100644 --- a/packages/taffyjs-yoga/src/values.ts +++ b/packages/taffyjs-yoga/src/values.ts @@ -53,6 +53,10 @@ export function publicValue(value: YogaValue): Value { return { value: value.value, unit: value.unit }; } +export function resolvePercentage(basis: number, percentage: number): number { + return Math.fround(Math.fround(basis * percentage) * Math.fround(0.01)); +} + export function normalizeLength( value: number | string | undefined, name: string, diff --git a/tests/taffyjs-yoga/computed-output.test.mts b/tests/taffyjs-yoga/computed-output.test.mts new file mode 100644 index 0000000..b5d5cd1 --- /dev/null +++ b/tests/taffyjs-yoga/computed-output.test.mts @@ -0,0 +1,812 @@ +import assert from "node:assert/strict"; +import { TaffyTree, type NodeId } from "@taffyjs/node"; +import Yoga, { + BoxSizing, + Direction, + Display, + Edge, + FlexDirection, + PositionType, + type Node, +} from "yoga-layout"; +import { loadYoga } from "yoga-layout/load"; +import OracleYoga, { + BoxSizing as OracleBoxSizing, + Direction as OracleDirection, + Display as OracleDisplay, + Edge as OracleEdge, + FlexDirection as OracleFlexDirection, + PositionType as OraclePositionType, + type Node as OracleNode, +} from "yoga-layout-oracle"; +import { test } from "vite-plus/test"; + +function assertLayoutEqual(actual: Node, expected: OracleNode, message?: string): void { + assert.deepEqual(actual.getComputedLayout(), expected.getComputedLayout(), message); + assert.equal(actual.getComputedLeft(), expected.getComputedLeft(), message); + assert.equal(actual.getComputedRight(), expected.getComputedRight(), message); + assert.equal(actual.getComputedTop(), expected.getComputedTop(), message); + assert.equal(actual.getComputedBottom(), expected.getComputedBottom(), message); + assert.equal(actual.getComputedWidth(), expected.getComputedWidth(), message); + assert.equal(actual.getComputedHeight(), expected.getComputedHeight(), message); +} + +function assertFloatCompatible(actual: number, expected: number, message: string): void { + if (Number.isNaN(actual) && Number.isNaN(expected)) return; + assert.ok( + Math.abs(actual - expected) < 0.0001, + `${message}: expected ${expected}, received ${actual}`, + ); +} + +test("finite owner dimensions become exact selected-root constraints", () => { + const scenarios: Array<{ + readonly name: string; + readonly configure: (actual: Node, expected: OracleNode) => void; + }> = [ + { name: "empty", configure: () => {} }, + { + name: "margins", + configure: (actual, expected) => { + actual.setMargin(Edge.Left, 10); + actual.setMargin(Edge.Right, 20); + expected.setMargin(OracleEdge.Left, 10); + expected.setMargin(OracleEdge.Right, 20); + }, + }, + { + name: "padding and border", + configure: (actual, expected) => { + actual.setPadding(Edge.Horizontal, 10); + actual.setBorder(Edge.Right, 4); + expected.setPadding(OracleEdge.Horizontal, 10); + expected.setBorder(OracleEdge.Right, 4); + }, + }, + { + name: "content box", + configure: (actual, expected) => { + actual.setBoxSizing(BoxSizing.ContentBox); + actual.setPadding(Edge.Horizontal, 10); + expected.setBoxSizing(OracleBoxSizing.ContentBox); + expected.setPadding(OracleEdge.Horizontal, 10); + }, + }, + { + name: "minimum", + configure: (actual, expected) => { + actual.setMinWidth(120); + expected.setMinWidth(120); + }, + }, + { + name: "maximum", + configure: (actual, expected) => { + actual.setMaxWidth(70); + expected.setMaxWidth(70); + }, + }, + { + name: "percentage declaration", + configure: (actual, expected) => { + actual.setWidthPercent(50); + expected.setWidthPercent(50); + }, + }, + { + name: "selected display-none root", + configure: (actual, expected) => { + actual.setDisplay(Display.None); + actual.setWidth(40); + actual.setHeight(20); + expected.setDisplay(OracleDisplay.None); + expected.setWidth(40); + expected.setHeight(20); + }, + }, + ]; + + for (const scenario of scenarios) { + const actual = Yoga.Node.create(); + const expected = OracleYoga.Node.create(); + try { + scenario.configure(actual, expected); + const declaration = actual.getWidth(); + actual.calculateLayout(100, 80, Direction.LTR); + expected.calculateLayout(100, 80, OracleDirection.LTR); + assertLayoutEqual(actual, expected, scenario.name); + assert.deepEqual(actual.getWidth(), declaration, `${scenario.name} declaration`); + } finally { + actual.free(); + expected.free(); + } + } + + for (const width of [1e39, -1e39]) { + const overflowed = Yoga.Node.create(); + const oracleOverflowed = OracleYoga.Node.create(); + try { + overflowed.calculateLayout(width, 10); + oracleOverflowed.calculateLayout(width, 10); + assertLayoutEqual(overflowed, oracleOverflowed, `finite ${width} that overflows f32`); + } finally { + overflowed.free(); + oracleOverflowed.free(); + } + } +}); + +test("subtree projection matches Yoga positions across directions and reversed axes", () => { + const positionCases = ["left", "right", "both"] as const; + for (const direction of [Direction.LTR, Direction.RTL] as const) { + for (const flexDirection of [ + FlexDirection.Row, + FlexDirection.RowReverse, + FlexDirection.Column, + FlexDirection.ColumnReverse, + ] as const) { + for (const positionType of [PositionType.Relative, PositionType.Absolute] as const) { + for (const positionCase of positionCases) { + const actualParent = Yoga.Node.create(); + const actualChild = Yoga.Node.create(); + const expectedParent = OracleYoga.Node.create(); + const expectedChild = OracleYoga.Node.create(); + try { + actualParent.setDirection(direction); + actualParent.setFlexDirection(flexDirection); + actualParent.setWidth(100); + actualParent.setHeight(80); + expectedParent.setDirection(direction as unknown as OracleDirection); + expectedParent.setFlexDirection(flexDirection as unknown as OracleFlexDirection); + expectedParent.setWidth(100); + expectedParent.setHeight(80); + + actualChild.setPositionType(positionType); + actualChild.setWidth(40); + actualChild.setHeight(20); + actualChild.setMargin(Edge.Left, 1); + actualChild.setMargin(Edge.Right, 2); + actualChild.setMargin(Edge.Top, 3); + actualChild.setMargin(Edge.Bottom, 4); + expectedChild.setPositionType(positionType as unknown as OraclePositionType); + expectedChild.setWidth(40); + expectedChild.setHeight(20); + expectedChild.setMargin(OracleEdge.Left, 1); + expectedChild.setMargin(OracleEdge.Right, 2); + expectedChild.setMargin(OracleEdge.Top, 3); + expectedChild.setMargin(OracleEdge.Bottom, 4); + if (positionCase !== "right") { + actualChild.setPosition(Edge.Left, 5); + expectedChild.setPosition(OracleEdge.Left, 5); + } + if (positionCase !== "left") { + actualChild.setPosition(Edge.Right, 6); + expectedChild.setPosition(OracleEdge.Right, 6); + } + actualChild.setPosition(Edge.Top, 7); + actualChild.setPosition(Edge.Bottom, 8); + expectedChild.setPosition(OracleEdge.Top, 7); + expectedChild.setPosition(OracleEdge.Bottom, 8); + + actualParent.insertChild(actualChild, 0); + expectedParent.insertChild(expectedChild, 0); + actualParent.calculateLayout(undefined, undefined, direction); + expectedParent.calculateLayout( + undefined, + undefined, + direction as unknown as OracleDirection, + ); + assertLayoutEqual( + actualChild, + expectedChild, + `${Direction[direction]} ${FlexDirection[flexDirection]} ${PositionType[positionType]} ${positionCase}`, + ); + } finally { + actualParent.freeRecursive(); + expectedParent.freeRecursive(); + } + } + } + } + } +}); + +test("display-none output follows the node selected for calculation", () => { + const parent = Yoga.Node.create(); + const child = Yoga.Node.create(); + const oracleParent = OracleYoga.Node.create(); + const oracleChild = OracleYoga.Node.create(); + try { + child.setDisplay(Display.None); + child.setWidth(40); + child.setHeight(20); + child.setMargin(Edge.Left, 3); + oracleChild.setDisplay(OracleDisplay.None); + oracleChild.setWidth(40); + oracleChild.setHeight(20); + oracleChild.setMargin(OracleEdge.Left, 3); + parent.insertChild(child, 0); + oracleParent.insertChild(oracleChild, 0); + parent.calculateLayout(100, 80); + oracleParent.calculateLayout(100, 80); + assertLayoutEqual(child, oracleChild, "suppressed child"); + assert.equal(child.getComputedMargin(Edge.Left), 0); + + child.calculateLayout(100, 80); + oracleChild.calculateLayout(100, 80); + assertLayoutEqual(child, oracleChild, "selected display-none root"); + assert.equal(child.getComputedMargin(Edge.Left), 3); + } finally { + parent.freeRecursive(); + oracleParent.freeRecursive(); + } +}); + +test("absolute logical insets use the containing Node's direction", () => { + const parent = Yoga.Node.create(); + const child = Yoga.Node.create(); + const oracleParent = OracleYoga.Node.create(); + const oracleChild = OracleYoga.Node.create(); + try { + parent.setDirection(Direction.LTR); + parent.setFlexDirection(FlexDirection.RowReverse); + parent.setWidth(100); + parent.setHeight(80); + parent.setPadding(Edge.All, 3); + parent.setBorder(Edge.All, 2); + child.setDirection(Direction.RTL); + child.setPositionType(PositionType.Absolute); + child.setWidth(40); + child.setHeight(20); + child.setPosition(Edge.Start, 5); + child.setPosition(Edge.End, 6); + child.setPosition(Edge.Top, 7); + child.setPosition(Edge.Bottom, 8); + parent.insertChild(child, 0); + oracleParent.setDirection(OracleDirection.LTR); + oracleParent.setFlexDirection(OracleFlexDirection.RowReverse); + oracleParent.setWidth(100); + oracleParent.setHeight(80); + oracleParent.setPadding(OracleEdge.All, 3); + oracleParent.setBorder(OracleEdge.All, 2); + oracleChild.setDirection(OracleDirection.RTL); + oracleChild.setPositionType(OraclePositionType.Absolute); + oracleChild.setWidth(40); + oracleChild.setHeight(20); + oracleChild.setPosition(OracleEdge.Start, 5); + oracleChild.setPosition(OracleEdge.End, 6); + oracleChild.setPosition(OracleEdge.Top, 7); + oracleChild.setPosition(OracleEdge.Bottom, 8); + oracleParent.insertChild(oracleChild, 0); + parent.calculateLayout(undefined, undefined, Direction.LTR); + oracleParent.calculateLayout(undefined, undefined, OracleDirection.LTR); + assertLayoutEqual(child, oracleChild); + } finally { + parent.freeRecursive(); + oracleParent.freeRecursive(); + } +}); + +test("absolute logical inset sizing tracks the containing Node's direction", () => { + const parent = Yoga.Node.create(); + const child = Yoga.Node.create(); + const oracleParent = OracleYoga.Node.create(); + const oracleChild = OracleYoga.Node.create(); + try { + parent.setWidth(100); + parent.setHeight(40); + child.setDirection(Direction.RTL); + child.setPositionType(PositionType.Absolute); + child.setHeight(10); + child.setPosition(Edge.Start, 5); + child.setPosition(Edge.Right, 7); + parent.insertChild(child, 0); + oracleParent.setWidth(100); + oracleParent.setHeight(40); + oracleChild.setDirection(OracleDirection.RTL); + oracleChild.setPositionType(OraclePositionType.Absolute); + oracleChild.setHeight(10); + oracleChild.setPosition(OracleEdge.Start, 5); + oracleChild.setPosition(OracleEdge.Right, 7); + oracleParent.insertChild(oracleChild, 0); + + for (const direction of [Direction.LTR, Direction.RTL] as const) { + parent.setDirection(direction); + oracleParent.setDirection(direction as unknown as OracleDirection); + parent.calculateLayout(undefined, undefined); + oracleParent.calculateLayout(undefined, undefined); + assertLayoutEqual(child, oracleChild, Direction[direction]); + } + } finally { + parent.freeRecursive(); + oracleParent.freeRecursive(); + } +}); + +test("absolute logical margins use containing direction for placement", () => { + const parent = Yoga.Node.create(); + const child = Yoga.Node.create(); + const oracleParent = OracleYoga.Node.create(); + const oracleChild = OracleYoga.Node.create(); + try { + parent.setFlexDirection(FlexDirection.Row); + parent.setWidth(103); + parent.setHeight(40); + parent.setPadding(Edge.All, 3); + parent.setBorder(Edge.All, 2); + child.setDirection(Direction.RTL); + child.setPositionType(PositionType.Absolute); + child.setWidth(20); + child.setHeight(10); + child.setMarginPercent(Edge.Start, 9); + parent.insertChild(child, 0); + oracleParent.setFlexDirection(OracleFlexDirection.Row); + oracleParent.setWidth(103); + oracleParent.setHeight(40); + oracleParent.setPadding(OracleEdge.All, 3); + oracleParent.setBorder(OracleEdge.All, 2); + oracleChild.setDirection(OracleDirection.RTL); + oracleChild.setPositionType(OraclePositionType.Absolute); + oracleChild.setWidth(20); + oracleChild.setHeight(10); + oracleChild.setMarginPercent(OracleEdge.Start, 9); + oracleParent.insertChild(oracleChild, 0); + parent.calculateLayout(undefined, undefined); + oracleParent.calculateLayout(undefined, undefined); + assertLayoutEqual(child, oracleChild); + assert.equal( + child.getComputedMargin(Edge.Right), + oracleChild.getComputedMargin(OracleEdge.Right), + ); + } finally { + parent.freeRecursive(); + oracleParent.freeRecursive(); + } +}); + +test("percentage projected positions preserve Yoga float32 operation order", () => { + const parent = Yoga.Node.create(); + const child = Yoga.Node.create(); + const oracleParent = OracleYoga.Node.create(); + const oracleChild = OracleYoga.Node.create(); + try { + parent.setWidth(93); + parent.setHeight(40); + child.setWidth(21); + child.setHeight(10); + child.setPositionPercent(Edge.Left, 7); + parent.insertChild(child, 0); + oracleParent.setWidth(93); + oracleParent.setHeight(40); + oracleChild.setWidth(21); + oracleChild.setHeight(10); + oracleChild.setPositionPercent(OracleEdge.Left, 7); + oracleParent.insertChild(oracleChild, 0); + parent.calculateLayout(undefined, undefined); + oracleParent.calculateLayout(undefined, undefined); + assertLayoutEqual(child, oracleChild); + + parent.setFlexDirection(FlexDirection.RowReverse); + oracleParent.setFlexDirection(OracleFlexDirection.RowReverse); + parent.calculateLayout(undefined, undefined); + oracleParent.calculateLayout(undefined, undefined); + assertLayoutEqual(child, oracleChild, "reversed axis"); + } finally { + parent.freeRecursive(); + oracleParent.freeRecursive(); + } +}); + +test("unrounded percentage projection respects the documented float boundary", () => { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + + const relativeParent = Yoga.Node.createWithConfig(config); + const relativeChild = Yoga.Node.createWithConfig(config); + const oracleRelativeParent = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleRelativeChild = OracleYoga.Node.createWithConfig(oracleConfig); + try { + relativeParent.setWidth(3); + relativeChild.setWidth(1); + relativeChild.setHeight(1); + relativeChild.setPositionPercent(Edge.Left, 0.3); + relativeParent.insertChild(relativeChild, 0); + oracleRelativeParent.setWidth(3); + oracleRelativeChild.setWidth(1); + oracleRelativeChild.setHeight(1); + oracleRelativeChild.setPositionPercent(OracleEdge.Left, 0.3); + oracleRelativeParent.insertChild(oracleRelativeChild, 0); + relativeParent.calculateLayout(undefined, undefined); + oracleRelativeParent.calculateLayout(undefined, undefined); + assert.equal(relativeChild.getComputedLeft(), oracleRelativeChild.getComputedLeft()); + } finally { + relativeParent.freeRecursive(); + oracleRelativeParent.freeRecursive(); + } + + const absoluteParent = Yoga.Node.createWithConfig(config); + const absoluteChild = Yoga.Node.createWithConfig(config); + const oracleAbsoluteParent = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleAbsoluteChild = OracleYoga.Node.createWithConfig(oracleConfig); + try { + absoluteParent.setDirection(Direction.RTL); + absoluteParent.setWidth(103); + absoluteParent.setHeight(40); + absoluteChild.setDirection(Direction.LTR); + absoluteChild.setPositionType(PositionType.Absolute); + absoluteChild.setWidth(20); + absoluteChild.setHeight(10); + absoluteChild.setMarginPercent(Edge.Start, 9); + absoluteParent.insertChild(absoluteChild, 0); + oracleAbsoluteParent.setDirection(OracleDirection.RTL); + oracleAbsoluteParent.setWidth(103); + oracleAbsoluteParent.setHeight(40); + oracleAbsoluteChild.setDirection(OracleDirection.LTR); + oracleAbsoluteChild.setPositionType(OraclePositionType.Absolute); + oracleAbsoluteChild.setWidth(20); + oracleAbsoluteChild.setHeight(10); + oracleAbsoluteChild.setMarginPercent(OracleEdge.Start, 9); + oracleAbsoluteParent.insertChild(oracleAbsoluteChild, 0); + absoluteParent.calculateLayout(undefined, undefined); + oracleAbsoluteParent.calculateLayout(undefined, undefined); + const actual = absoluteChild.getComputedLayout(); + const expected = oracleAbsoluteChild.getComputedLayout(); + for (const key of ["left", "right", "top", "bottom", "width", "height"] as const) { + assertFloatCompatible(actual[key], expected[key], `absolute ${key}`); + } + assert.equal( + absoluteChild.getComputedMargin(Edge.Left), + oracleAbsoluteChild.getComputedMargin(OracleEdge.Left), + ); + } finally { + absoluteParent.freeRecursive(); + oracleAbsoluteParent.freeRecursive(); + config.free(); + oracleConfig.free(); + } +}); + +test("computed physical and logical edges match Yoga and reject shorthands", () => { + const edges = [Edge.Left, Edge.Top, Edge.Right, Edge.Bottom, Edge.Start, Edge.End] as const; + for (const direction of [Direction.LTR, Direction.RTL] as const) { + const actualParent = Yoga.Node.create(); + const actualChild = Yoga.Node.create(); + const expectedParent = OracleYoga.Node.create(); + const expectedChild = OracleYoga.Node.create(); + try { + actualParent.setDirection(direction); + actualParent.setWidth(200); + actualParent.setHeight(100); + expectedParent.setDirection(direction as unknown as OracleDirection); + expectedParent.setWidth(200); + expectedParent.setHeight(100); + actualChild.setWidth(40); + actualChild.setHeight(20); + actualChild.setMargin(Edge.Left, 3); + actualChild.setMargin(Edge.Start, 7); + actualChild.setMarginAuto(Edge.End); + actualChild.setPaddingPercent(Edge.Horizontal, 5); + actualChild.setPadding(Edge.Start, 13); + actualChild.setBorder(Edge.All, 2); + actualChild.setBorder(Edge.End, 4); + expectedChild.setWidth(40); + expectedChild.setHeight(20); + expectedChild.setMargin(OracleEdge.Left, 3); + expectedChild.setMargin(OracleEdge.Start, 7); + expectedChild.setMarginAuto(OracleEdge.End); + expectedChild.setPaddingPercent(OracleEdge.Horizontal, 5); + expectedChild.setPadding(OracleEdge.Start, 13); + expectedChild.setBorder(OracleEdge.All, 2); + expectedChild.setBorder(OracleEdge.End, 4); + actualParent.insertChild(actualChild, 0); + expectedParent.insertChild(expectedChild, 0); + actualParent.calculateLayout(undefined, undefined, direction); + expectedParent.calculateLayout(undefined, undefined, direction as unknown as OracleDirection); + + for (const edge of edges) { + assert.equal( + actualChild.getComputedMargin(edge), + expectedChild.getComputedMargin(edge as unknown as OracleEdge), + ); + assert.equal( + actualChild.getComputedPadding(edge), + expectedChild.getComputedPadding(edge as unknown as OracleEdge), + ); + assert.equal( + actualChild.getComputedBorder(edge), + expectedChild.getComputedBorder(edge as unknown as OracleEdge), + ); + } + assert.equal(actualChild.getComputedMargin(Edge.End), 0, "auto margin is masked"); + } finally { + actualParent.freeRecursive(); + expectedParent.freeRecursive(); + } + } + + const node = Yoga.Node.create(); + try { + for (const edge of edges) { + assert.equal(node.getComputedMargin(edge), 0); + assert.equal(node.getComputedPadding(edge), 0); + assert.equal(node.getComputedBorder(edge), 0); + } + for (const edge of [Edge.Horizontal, Edge.Vertical, Edge.All]) { + assert.throws(() => node.getComputedMargin(edge), TypeError); + assert.throws(() => node.getComputedPadding(edge), TypeError); + assert.throws(() => node.getComputedBorder(edge), TypeError); + } + } finally { + node.free(); + } +}); + +test("point-grid output rounding matches Yoga per Config", () => { + for (const factor of [0, 1, 2, 3, Infinity]) { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(factor); + oracleConfig.setPointScaleFactor(factor); + const actualParent = Yoga.Node.createWithConfig(config); + const actualChild = Yoga.Node.createWithConfig(config); + const expectedParent = OracleYoga.Node.createWithConfig(oracleConfig); + const expectedChild = OracleYoga.Node.createWithConfig(oracleConfig); + try { + actualParent.setFlexDirection(FlexDirection.Row); + actualParent.setWidth(10.25); + actualParent.setHeight(6.25); + actualParent.setPadding(Edge.Left, 0.2); + actualChild.setWidth(3.3); + actualChild.setHeight(2.2); + actualChild.setMargin(Edge.Left, 0.15); + expectedParent.setFlexDirection(OracleFlexDirection.Row); + expectedParent.setWidth(10.25); + expectedParent.setHeight(6.25); + expectedParent.setPadding(OracleEdge.Left, 0.2); + expectedChild.setWidth(3.3); + expectedChild.setHeight(2.2); + expectedChild.setMargin(OracleEdge.Left, 0.15); + actualParent.insertChild(actualChild, 0); + expectedParent.insertChild(expectedChild, 0); + actualParent.calculateLayout(undefined, undefined); + expectedParent.calculateLayout(undefined, undefined); + assertLayoutEqual(actualParent, expectedParent, `factor ${factor} parent`); + assertLayoutEqual(actualChild, expectedChild, `factor ${factor} child`); + } finally { + actualParent.freeRecursive(); + expectedParent.freeRecursive(); + config.free(); + oracleConfig.free(); + } + } + + const parentConfig = Yoga.Config.create(); + const childConfig = Yoga.Config.create(); + const oracleParentConfig = OracleYoga.Config.create(); + const oracleChildConfig = OracleYoga.Config.create(); + parentConfig.setPointScaleFactor(1); + childConfig.setPointScaleFactor(3); + oracleParentConfig.setPointScaleFactor(1); + oracleChildConfig.setPointScaleFactor(3); + const parent = Yoga.Node.createWithConfig(parentConfig); + const child = Yoga.Node.createWithConfig(childConfig); + const oracleParent = OracleYoga.Node.createWithConfig(oracleParentConfig); + const oracleChild = OracleYoga.Node.createWithConfig(oracleChildConfig); + try { + parent.setFlexDirection(FlexDirection.Row); + parent.setWidth(10.3); + parent.setHeight(6.2); + child.setWidth(3.3); + child.setHeight(2.2); + oracleParent.setFlexDirection(OracleFlexDirection.Row); + oracleParent.setWidth(10.3); + oracleParent.setHeight(6.2); + oracleChild.setWidth(3.3); + oracleChild.setHeight(2.2); + parent.insertChild(child, 0); + oracleParent.insertChild(oracleChild, 0); + parent.calculateLayout(undefined, undefined); + oracleParent.calculateLayout(undefined, undefined); + assertLayoutEqual(parent, oracleParent, "mixed Config parent"); + assertLayoutEqual(child, oracleChild, "mixed Config child"); + } finally { + parent.freeRecursive(); + oracleParent.freeRecursive(); + parentConfig.free(); + childConfig.free(); + oracleParentConfig.free(); + oracleChildConfig.free(); + } +}); + +test("detaching a child resets its complete computed output", () => { + const parent = Yoga.Node.create(); + const child = Yoga.Node.create(); + child.setWidth(10); + child.setHeight(4); + child.setMargin(Edge.Left, 2); + child.setPadding(Edge.Left, 3); + child.setBorder(Edge.Left, 1); + parent.insertChild(child, 0); + parent.calculateLayout(undefined, undefined); + assert.equal(child.getComputedMargin(Edge.Left), 2); + assert.equal(child.getComputedPadding(Edge.Left), 3); + assert.equal(child.getComputedBorder(Edge.Left), 1); + + parent.removeChild(child); + assert.deepEqual(child.getComputedLayout(), { + left: 0, + right: 0, + top: 0, + bottom: 0, + width: Number.NaN, + height: Number.NaN, + }); + assert.equal(child.getComputedMargin(Edge.Left), 0); + assert.equal(child.getComputedPadding(Edge.Left), 0); + assert.equal(child.getComputedBorder(Edge.Left), 0); + parent.free(); + child.free(); +}); + +test("ordinary nested Flex, percentage, grow, and shrink geometry matches Yoga", () => { + const actual = Array.from({ length: 4 }, () => Yoga.Node.create()); + const expected = Array.from({ length: 4 }, () => OracleYoga.Node.create()); + const [root, first, second, grandchild] = actual; + const [oracleRoot, oracleFirst, oracleSecond, oracleGrandchild] = expected; + try { + root.setFlexDirection(FlexDirection.Row); + root.setWidth(200); + root.setHeight(100); + root.setPadding(Edge.All, 10); + first.setWidthPercent(25); + first.setHeightPercent(50); + first.setMargin(Edge.Right, 5); + second.setFlexGrow(1); + second.setFlexBasis(20); + second.setHeight(60); + second.setPadding(Edge.Left, 3); + grandchild.setWidthPercent(50); + grandchild.setHeight(20); + second.insertChild(grandchild, 0); + root.insertChild(first, 0); + root.insertChild(second, 1); + + oracleRoot.setFlexDirection(OracleFlexDirection.Row); + oracleRoot.setWidth(200); + oracleRoot.setHeight(100); + oracleRoot.setPadding(OracleEdge.All, 10); + oracleFirst.setWidthPercent(25); + oracleFirst.setHeightPercent(50); + oracleFirst.setMargin(OracleEdge.Right, 5); + oracleSecond.setFlexGrow(1); + oracleSecond.setFlexBasis(20); + oracleSecond.setHeight(60); + oracleSecond.setPadding(OracleEdge.Left, 3); + oracleGrandchild.setWidthPercent(50); + oracleGrandchild.setHeight(20); + oracleSecond.insertChild(oracleGrandchild, 0); + oracleRoot.insertChild(oracleFirst, 0); + oracleRoot.insertChild(oracleSecond, 1); + + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + for (const [index, node] of actual.entries()) { + assertLayoutEqual(node, expected[index], `node ${index}`); + } + } finally { + root.freeRecursive(); + oracleRoot.freeRecursive(); + } +}); + +test("one calculation commits the complete selected subtree atomically", async () => { + const facade = await loadYoga(); + const root = facade.Node.create(); + const child = facade.Node.create(); + child.setWidth(10); + child.setHeight(4); + root.insertChild(child, 0); + root.calculateLayout(undefined, undefined); + const previousRoot = root.getComputedLayout(); + const previousChild = child.getComputedLayout(); + child.setWidth(20); + + const prototype = TaffyTree.prototype; + const originalGet = Object.getOwnPropertyDescriptor(prototype, "getUnroundedLayout") + ?.value as TaffyTree["getUnroundedLayout"]; + const sentinel = new Error("expected projection failure"); + let reads = 0; + prototype.getUnroundedLayout = function (this: TaffyTree, node: NodeId) { + reads += 1; + if (reads === 2) throw sentinel; + return originalGet.call(this, node); + }; + try { + assert.throws( + () => root.calculateLayout(undefined, undefined), + (error) => error === sentinel, + ); + } finally { + prototype.getUnroundedLayout = originalGet; + } + + assert.deepEqual(root.getComputedLayout(), previousRoot); + assert.deepEqual(child.getComputedLayout(), previousChild); + assert.equal(root.isDirty(), true); + assert.equal(child.isDirty(), true); + root.calculateLayout(undefined, undefined); + assert.equal(root.getComputedWidth(), 20); + assert.equal(child.getComputedWidth(), 20); + root.freeRecursive(); +}); + +test("failed native calculation restores the temporary root Style and old output", async () => { + const facade = await loadYoga(); + const node = facade.Node.create(); + node.calculateLayout(100, 40); + const previous = node.getComputedLayout(); + + const prototype = TaffyTree.prototype; + const originalCompute = Object.getOwnPropertyDescriptor(prototype, "computeLayout") + ?.value as TaffyTree["computeLayout"]; + const sentinel = { reason: "expected calculation failure" }; + prototype.computeLayout = function (): never { + throw sentinel; + }; + try { + assert.throws( + () => node.calculateLayout(200, 80), + (error) => error === sentinel, + ); + } finally { + prototype.computeLayout = originalCompute; + } + + assert.deepEqual(node.getComputedLayout(), previous); + assert.equal(node.isDirty(), false); + node.calculateLayout(200, 80); + assert.deepEqual(node.getComputedLayout(), { + left: 0, + right: 0, + top: 0, + bottom: 0, + width: 200, + height: 80, + }); + node.free(); +}); + +test("an unexpected temporary-Style restoration failure poisons only its facade", async () => { + const facade = await loadYoga(); + const node = facade.Node.create(); + const prototype = TaffyTree.prototype; + const originalSetStyle = Object.getOwnPropertyDescriptor(prototype, "setStyle") + ?.value as TaffyTree["setStyle"]; + const sentinel = new Error("expected restoration failure"); + let writes = 0; + prototype.setStyle = function ( + this: TaffyTree, + nodeId: NodeId, + style: Parameters[1], + ): void { + writes += 1; + if (writes === 2) throw sentinel; + originalSetStyle.call(this, nodeId, style); + }; + try { + assert.throws(() => node.calculateLayout(100, 40), /facade is unusable/); + } finally { + prototype.setStyle = originalSetStyle; + } + assert.equal(writes, 2); + assert.throws(() => node.getComputedWidth(), /facade is unusable/); + + const healthyFacade = await loadYoga(); + const healthy = healthyFacade.Node.create(); + healthy.calculateLayout(12, 7); + assert.equal(healthy.getComputedWidth(), 12); + healthy.free(); +}); diff --git a/tests/taffyjs-yoga/differences.test.mts b/tests/taffyjs-yoga/differences.test.mts new file mode 100644 index 0000000..f0e375e --- /dev/null +++ b/tests/taffyjs-yoga/differences.test.mts @@ -0,0 +1,601 @@ +import assert from "node:assert/strict"; +import Yoga, { + Align, + Direction, + Edge, + ExperimentalFeature, + FlexDirection, + Justify, + Wrap, +} from "yoga-layout"; +import OracleYoga, { + Align as OracleAlign, + Direction as OracleDirection, + Edge as OracleEdge, + ExperimentalFeature as OracleExperimentalFeature, + FlexDirection as OracleFlexDirection, + Justify as OracleJustify, + Wrap as OracleWrap, +} from "yoga-layout-oracle"; +import { test } from "vite-plus/test"; + +test("Different: a live useWebDefaults change does not reproduce Yoga's stale cache", () => { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + const root = Yoga.Node.createWithConfig(config); + const row = Yoga.Node.createWithConfig(config); + const first = Yoga.Node.createWithConfig(config); + const second = Yoga.Node.createWithConfig(config); + const oracleRoot = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleRow = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleFirst = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleSecond = OracleYoga.Node.createWithConfig(oracleConfig); + try { + root.setFlexDirection(FlexDirection.Row); + root.setWidth(200); + root.setHeight(20); + row.setFlexDirection(FlexDirection.Row); + row.setWidth(100); + row.setHeight(20); + first.setWidth(80); + first.setHeight(20); + second.setWidth(80); + second.setHeight(20); + row.insertChild(first, 0); + row.insertChild(second, 1); + root.insertChild(row, 0); + oracleRoot.setFlexDirection(OracleFlexDirection.Row); + oracleRoot.setWidth(200); + oracleRoot.setHeight(20); + oracleRow.setFlexDirection(OracleFlexDirection.Row); + oracleRow.setWidth(100); + oracleRow.setHeight(20); + oracleFirst.setWidth(80); + oracleFirst.setHeight(20); + oracleSecond.setWidth(80); + oracleSecond.setHeight(20); + oracleRow.insertChild(oracleFirst, 0); + oracleRow.insertChild(oracleSecond, 1); + oracleRoot.insertChild(oracleRow, 0); + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + assert.deepEqual([first.getComputedWidth(), second.getComputedWidth()], [80, 80]); + assert.deepEqual([oracleFirst.getComputedWidth(), oracleSecond.getComputedWidth()], [80, 80]); + + config.setUseWebDefaults(true); + oracleConfig.setUseWebDefaults(true); + row.calculateLayout(undefined, undefined); + oracleRow.calculateLayout(undefined, undefined); + assert.deepEqual([first.getComputedWidth(), second.getComputedWidth()], [50, 50]); + assert.deepEqual([oracleFirst.getComputedWidth(), oracleSecond.getComputedWidth()], [80, 80]); + assert.equal(root.isDirty(), false); + assert.equal(row.isDirty(), false); + } finally { + root.freeRecursive(); + oracleRoot.freeRecursive(); + config.free(); + oracleConfig.free(); + } +}); + +test("Different: dirtied callbacks use the declared argument and null unsets", () => { + const node = Yoga.Node.create(); + const oracle = OracleYoga.Node.create(); + try { + node.calculateLayout(undefined, undefined); + oracle.calculateLayout(undefined, undefined); + let actualArguments = -1; + let oracleArguments = -1; + node.setDirtiedFunc((...arguments_) => { + actualArguments = arguments_.length; + assert.equal(arguments_[0], node); + }); + oracle.setDirtiedFunc((...arguments_) => { + oracleArguments = arguments_.length; + }); + node.setWidth(1); + oracle.setWidth(1); + assert.equal(actualArguments, 1); + assert.equal(oracleArguments, 0); + + node.calculateLayout(undefined, undefined); + oracle.calculateLayout(undefined, undefined); + node.setDirtiedFunc(null); + oracle.setDirtiedFunc(null); + assert.doesNotThrow(() => node.setWidth(2)); + assert.throws(() => oracle.setWidth(2), TypeError); + } finally { + node.free(); + oracle.free(); + } +}); + +test("Different: max size combined with flex shrink keeps Taffy geometry", () => { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + const root = Yoga.Node.createWithConfig(config); + const child = Yoga.Node.createWithConfig(config); + const oracleRoot = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleChild = OracleYoga.Node.createWithConfig(oracleConfig); + try { + root.setFlexDirection(FlexDirection.Row); + root.setWidth(60); + child.setWidth(120); + child.setMaxWidth(90); + child.setFlexShrink(1); + root.insertChild(child, 0); + oracleRoot.setFlexDirection(OracleFlexDirection.Row); + oracleRoot.setWidth(60); + oracleChild.setWidth(120); + oracleChild.setMaxWidth(90); + oracleChild.setFlexShrink(1); + oracleRoot.insertChild(oracleChild, 0); + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + assert.equal(child.getComputedWidth(), 60); + assert.equal(oracleChild.getComputedWidth(), 67.5); + } finally { + root.freeRecursive(); + oracleRoot.freeRecursive(); + config.free(); + oracleConfig.free(); + } +}); + +test("Different: overlapping physical and logical margins keep Taffy sizing", () => { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + const root = Yoga.Node.createWithConfig(config); + const child = Yoga.Node.createWithConfig(config); + const oracleRoot = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleChild = OracleYoga.Node.createWithConfig(oracleConfig); + try { + root.setFlexDirection(FlexDirection.Row); + root.setWidth(100); + child.setDirection(Direction.RTL); + child.setWidth(20); + child.setHeight(10); + child.setMargin(Edge.Left, 4); + child.setMarginPercent(Edge.Start, 10); + root.insertChild(child, 0); + oracleRoot.setFlexDirection(OracleFlexDirection.Row); + oracleRoot.setWidth(100); + oracleChild.setDirection(OracleDirection.RTL); + oracleChild.setWidth(20); + oracleChild.setHeight(10); + oracleChild.setMargin(OracleEdge.Left, 4); + oracleChild.setMarginPercent(OracleEdge.Start, 10); + oracleRoot.insertChild(oracleChild, 0); + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + assert.equal(child.getComputedWidth(), 20); + assert.equal(oracleChild.getComputedWidth(), 16); + assert.deepEqual( + [child.getComputedMargin(Edge.Left), child.getComputedMargin(Edge.Right)], + [4, 10], + ); + assert.deepEqual( + [ + oracleChild.getComputedMargin(OracleEdge.Left), + oracleChild.getComputedMargin(OracleEdge.Right), + ], + [4, 10], + ); + } finally { + root.freeRecursive(); + oracleRoot.freeRecursive(); + config.free(); + oracleConfig.free(); + } +}); + +test("Different: calculated aspect ratio keeps Taffy's CSS-oriented result", () => { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + const row = Yoga.Node.createWithConfig(config); + const child = Yoga.Node.createWithConfig(config); + const oracleRow = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleChild = OracleYoga.Node.createWithConfig(oracleConfig); + try { + row.setFlexDirection(FlexDirection.Row); + row.setWidth(100); + row.setHeight(100); + child.setWidth(40); + child.setAspectRatio(2); + row.insertChild(child, 0); + oracleRow.setFlexDirection(OracleFlexDirection.Row); + oracleRow.setWidth(100); + oracleRow.setHeight(100); + oracleChild.setWidth(40); + oracleChild.setAspectRatio(2); + oracleRow.insertChild(oracleChild, 0); + row.calculateLayout(undefined, undefined); + oracleRow.calculateLayout(undefined, undefined); + assert.deepEqual([child.getComputedWidth(), child.getComputedHeight()], [40, 100]); + assert.deepEqual([oracleChild.getComputedWidth(), oracleChild.getComputedHeight()], [40, 20]); + } finally { + row.freeRecursive(); + oracleRow.freeRecursive(); + config.free(); + oracleConfig.free(); + } +}); + +test("Different: disabled WebFlexBasis does not retain Yoga's stale percentage basis", () => { + for (const enabled of [false, true]) { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + config.setExperimentalFeatureEnabled(ExperimentalFeature.WebFlexBasis, enabled); + oracleConfig.setExperimentalFeatureEnabled(OracleExperimentalFeature.WebFlexBasis, enabled); + const root = Yoga.Node.createWithConfig(config); + const child = Yoga.Node.createWithConfig(config); + const oracleRoot = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleChild = OracleYoga.Node.createWithConfig(oracleConfig); + try { + root.setFlexDirection(FlexDirection.Row); + root.setWidth(100); + root.setHeight(20); + child.setFlexBasisPercent(50); + child.setFlexShrink(0); + root.insertChild(child, 0); + oracleRoot.setFlexDirection(OracleFlexDirection.Row); + oracleRoot.setWidth(100); + oracleRoot.setHeight(20); + oracleChild.setFlexBasisPercent(50); + oracleChild.setFlexShrink(0); + oracleRoot.insertChild(oracleChild, 0); + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + root.setWidth(200); + oracleRoot.setWidth(200); + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + assert.equal(child.getComputedWidth(), 100); + assert.equal(oracleChild.getComputedWidth(), enabled ? 100 : 50); + } finally { + root.freeRecursive(); + oracleRoot.freeRecursive(); + config.free(); + oracleConfig.free(); + } + } +}); + +test("Different: an ancestor recomputes after a separate attached-subtree calculation", () => { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + const nodes = Array.from({ length: 4 }, () => Yoga.Node.createWithConfig(config)); + const oracleNodes = Array.from({ length: 4 }, () => + OracleYoga.Node.createWithConfig(oracleConfig), + ); + const [root, row, first, second] = nodes; + const [oracleRoot, oracleRow, oracleFirst, oracleSecond] = oracleNodes; + try { + root.setFlexDirection(FlexDirection.Row); + root.setWidth(200); + root.setHeight(20); + row.setFlexDirection(FlexDirection.Row); + row.setFlexGrow(1); + row.setHeight(20); + for (const child of [first, second]) { + child.setWidth(80); + child.setHeight(20); + child.setFlexShrink(1); + } + row.insertChild(first, 0); + row.insertChild(second, 1); + root.insertChild(row, 0); + oracleRoot.setFlexDirection(OracleFlexDirection.Row); + oracleRoot.setWidth(200); + oracleRoot.setHeight(20); + oracleRow.setFlexDirection(OracleFlexDirection.Row); + oracleRow.setFlexGrow(1); + oracleRow.setHeight(20); + for (const child of [oracleFirst, oracleSecond]) { + child.setWidth(80); + child.setHeight(20); + child.setFlexShrink(1); + } + oracleRow.insertChild(oracleFirst, 0); + oracleRow.insertChild(oracleSecond, 1); + oracleRoot.insertChild(oracleRow, 0); + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + row.calculateLayout(100, 20); + oracleRow.calculateLayout(100, 20); + assert.deepEqual([row.getComputedWidth(), first.getComputedWidth()], [100, 50]); + assert.deepEqual([oracleRow.getComputedWidth(), oracleFirst.getComputedWidth()], [100, 50]); + + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + assert.deepEqual([row.getComputedWidth(), first.getComputedWidth()], [200, 80]); + assert.deepEqual([oracleRow.getComputedWidth(), oracleFirst.getComputedWidth()], [100, 50]); + } finally { + root.freeRecursive(); + oracleRoot.freeRecursive(); + config.free(); + oracleConfig.free(); + } +}); + +test("Different: oversized WrapReverse distribution keeps Taffy line positions", () => { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + const root = Yoga.Node.createWithConfig(config); + const oracleRoot = OracleYoga.Node.createWithConfig(oracleConfig); + const children = [10, 20, 15].map(() => Yoga.Node.createWithConfig(config)); + const oracleChildren = [10, 20, 15].map(() => OracleYoga.Node.createWithConfig(oracleConfig)); + try { + root.setWidth(100); + root.setHeight(30); + root.setFlexDirection(FlexDirection.Row); + root.setFlexWrap(Wrap.WrapReverse); + root.setAlignContent(Align.Stretch); + oracleRoot.setWidth(100); + oracleRoot.setHeight(30); + oracleRoot.setFlexDirection(OracleFlexDirection.Row); + oracleRoot.setFlexWrap(OracleWrap.WrapReverse); + oracleRoot.setAlignContent(OracleAlign.Stretch); + for (const [index, height] of [10, 20, 15].entries()) { + children[index].setWidth(60); + children[index].setHeight(height); + root.insertChild(children[index], index); + oracleChildren[index].setWidth(60); + oracleChildren[index].setHeight(height); + oracleRoot.insertChild(oracleChildren[index], index); + } + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + assert.deepEqual( + children.map((child) => child.getComputedTop()), + [35, 15, 0], + ); + assert.deepEqual( + oracleChildren.map((child) => child.getComputedTop()), + [20, 0, -15], + ); + } finally { + root.freeRecursive(); + oracleRoot.freeRecursive(); + config.free(); + oracleConfig.free(); + } +}); + +test("Different: ordinary WrapReverse cross margins keep Taffy placement", () => { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + const root = Yoga.Node.createWithConfig(config); + const child = Yoga.Node.createWithConfig(config); + const oracleRoot = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleChild = OracleYoga.Node.createWithConfig(oracleConfig); + try { + root.setWidth(100); + root.setHeight(100); + root.setFlexDirection(FlexDirection.Row); + root.setFlexWrap(Wrap.WrapReverse); + root.setAlignItems(Align.FlexStart); + child.setWidth(40); + child.setHeight(20); + child.setMargin(Edge.Bottom, 11); + root.insertChild(child, 0); + oracleRoot.setWidth(100); + oracleRoot.setHeight(100); + oracleRoot.setFlexDirection(OracleFlexDirection.Row); + oracleRoot.setFlexWrap(OracleWrap.WrapReverse); + oracleRoot.setAlignItems(OracleAlign.FlexStart); + oracleChild.setWidth(40); + oracleChild.setHeight(20); + oracleChild.setMargin(OracleEdge.Bottom, 11); + oracleRoot.insertChild(oracleChild, 0); + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + assert.equal(child.getComputedTop(), 69); + assert.equal(oracleChild.getComputedTop(), 80); + } finally { + root.freeRecursive(); + oracleRoot.freeRecursive(); + config.free(); + oracleConfig.free(); + } +}); + +test("Different: overflowing reversed justify distribution keeps Taffy positions", () => { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + const root = Yoga.Node.createWithConfig(config); + const oracleRoot = OracleYoga.Node.createWithConfig(oracleConfig); + const children = [140, 130, 120].map(() => Yoga.Node.createWithConfig(config)); + const oracleChildren = [140, 130, 120].map(() => OracleYoga.Node.createWithConfig(oracleConfig)); + try { + root.setWidth(240); + root.setHeight(20); + root.setFlexDirection(FlexDirection.RowReverse); + root.setJustifyContent(Justify.SpaceBetween); + oracleRoot.setWidth(240); + oracleRoot.setHeight(20); + oracleRoot.setFlexDirection(OracleFlexDirection.RowReverse); + oracleRoot.setJustifyContent(OracleJustify.SpaceBetween); + for (const [index, width] of [140, 130, 120].entries()) { + children[index].setWidth(width); + children[index].setHeight(10); + children[index].setFlexShrink(0); + root.insertChild(children[index], index); + oracleChildren[index].setWidth(width); + oracleChildren[index].setHeight(10); + oracleChildren[index].setFlexShrink(0); + oracleRoot.insertChild(oracleChildren[index], index); + } + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + assert.deepEqual( + children.map((child) => child.getComputedLeft()), + [250, 120, 0], + ); + assert.deepEqual( + oracleChildren.map((child) => child.getComputedLeft()), + [100, -30, -150], + ); + } finally { + root.freeRecursive(); + oracleRoot.freeRecursive(); + config.free(); + oracleConfig.free(); + } +}); + +test("Different: zero-cross-size stretched lines keep Taffy distribution", () => { + for (const wrap of [Wrap.Wrap, Wrap.WrapReverse] as const) { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + const root = Yoga.Node.createWithConfig(config); + const oracleRoot = OracleYoga.Node.createWithConfig(oracleConfig); + const children = [0, 1].map(() => Yoga.Node.createWithConfig(config)); + const oracleChildren = [0, 1].map(() => OracleYoga.Node.createWithConfig(oracleConfig)); + try { + root.setWidth(100); + root.setHeight(180); + root.setFlexDirection(FlexDirection.Row); + root.setFlexWrap(wrap); + root.setAlignContent(Align.Center); + root.setAlignItems(Align.Stretch); + oracleRoot.setWidth(100); + oracleRoot.setHeight(180); + oracleRoot.setFlexDirection(OracleFlexDirection.Row); + oracleRoot.setFlexWrap(wrap as unknown as OracleWrap); + oracleRoot.setAlignContent(OracleAlign.Center); + oracleRoot.setAlignItems(OracleAlign.Stretch); + for (const [index, child] of children.entries()) { + child.setWidth(60); + child.setFlexBasis(0); + root.insertChild(child, index); + oracleChildren[index].setWidth(60); + oracleChildren[index].setFlexBasis(0); + oracleRoot.insertChild(oracleChildren[index], index); + } + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + assert.deepEqual( + children.map((child) => child.getComputedTop()), + [90, 90], + ); + assert.deepEqual( + oracleChildren.map((child) => child.getComputedTop()), + wrap === Wrap.Wrap ? [0, 0] : [180, 180], + ); + } finally { + root.freeRecursive(); + oracleRoot.freeRecursive(); + config.free(); + oracleConfig.free(); + } + } +}); + +test("Different: main-axis auto margins retain Taffy's justify offset", () => { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + const root = Yoga.Node.createWithConfig(config); + const first = Yoga.Node.createWithConfig(config); + const second = Yoga.Node.createWithConfig(config); + const oracleRoot = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleFirst = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleSecond = OracleYoga.Node.createWithConfig(oracleConfig); + try { + root.setWidth(240); + root.setHeight(180); + root.setFlexDirection(FlexDirection.Column); + root.setJustifyContent(Justify.Center); + first.setWidth(10); + first.setHeight(30); + first.setMarginAuto(Edge.Top); + first.setMarginAuto(Edge.Bottom); + second.setWidth(20); + second.setHeight(20); + root.insertChild(first, 0); + root.insertChild(second, 1); + oracleRoot.setWidth(240); + oracleRoot.setHeight(180); + oracleRoot.setFlexDirection(OracleFlexDirection.Column); + oracleRoot.setJustifyContent(OracleJustify.Center); + oracleFirst.setWidth(10); + oracleFirst.setHeight(30); + oracleFirst.setMarginAuto(OracleEdge.Top); + oracleFirst.setMarginAuto(OracleEdge.Bottom); + oracleSecond.setWidth(20); + oracleSecond.setHeight(20); + oracleRoot.insertChild(oracleFirst, 0); + oracleRoot.insertChild(oracleSecond, 1); + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + assert.deepEqual([first.getComputedTop(), second.getComputedTop()], [130, 225]); + assert.deepEqual([oracleFirst.getComputedTop(), oracleSecond.getComputedTop()], [65, 160]); + assert.equal(first.getComputedMargin(Edge.Top), 0); + assert.equal(first.getComputedMargin(Edge.Bottom), 0); + } finally { + root.freeRecursive(); + oracleRoot.freeRecursive(); + config.free(); + oracleConfig.free(); + } +}); + +test("Different: reversed-axis auto margins keep Taffy's distribution", () => { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + const root = Yoga.Node.createWithConfig(config); + const child = Yoga.Node.createWithConfig(config); + const oracleRoot = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleChild = OracleYoga.Node.createWithConfig(oracleConfig); + try { + root.setWidth(240); + root.setHeight(100); + root.setFlexDirection(FlexDirection.RowReverse); + child.setWidth(60); + child.setHeight(20); + child.setMarginAuto(Edge.Left); + root.insertChild(child, 0); + oracleRoot.setWidth(240); + oracleRoot.setHeight(100); + oracleRoot.setFlexDirection(OracleFlexDirection.RowReverse); + oracleChild.setWidth(60); + oracleChild.setHeight(20); + oracleChild.setMarginAuto(OracleEdge.Left); + oracleRoot.insertChild(oracleChild, 0); + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + assert.equal(child.getComputedLeft(), 360); + assert.equal(oracleChild.getComputedLeft(), 180); + assert.equal(child.getComputedMargin(Edge.Left), 0); + assert.equal(oracleChild.getComputedMargin(OracleEdge.Left), 0); + } finally { + root.freeRecursive(); + oracleRoot.freeRecursive(); + config.free(); + oracleConfig.free(); + } +}); diff --git a/tests/taffyjs-yoga/types/public-api.test-d.ts b/tests/taffyjs-yoga/types/public-api.test-d.ts index 2b1d4f2..158f187 100644 --- a/tests/taffyjs-yoga/types/public-api.test-d.ts +++ b/tests/taffyjs-yoga/types/public-api.test-d.ts @@ -87,6 +87,9 @@ const margin: { unit: Unit; value: number } = node.getMargin(Edge.Start); node.copyStyle(node); node.calculateLayout(undefined, undefined, Direction.LTR); const width: number = node.getComputedWidth(); +const computedMargin: number = node.getComputedMargin(Edge.Start); +const computedPadding: number = node.getComputedPadding(Edge.End); +const computedBorder: number = node.getComputedBorder(Edge.Left); const dirtied: DirtiedFunction = (dirtyNode) => dirtyNode.getComputedWidth(); const measure: MeasureFunction = () => ({ width: 1, height: 2 }); const child: Node = Yoga.Node.create(); @@ -137,6 +140,9 @@ type RootYoga = import("yoga-layout").Yoga; void [ width, + computedMargin, + computedPadding, + computedBorder, webDefaults, webFlexBasis, errata, From 00f7ccabba7ead20d2f771c98731eaf1d6edbb12 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 15 Aug 2026 17:43:09 +0800 Subject: [PATCH 07/18] Implement @taffyjs/yoga Measure API --- .agents/docs/loop-status.md | 11 +- .agents/docs/taffyjs-yoga-reference.md | 6 +- packages/taffyjs-yoga/COMPATIBILITY.md | 16 +- packages/taffyjs-yoga/README.md | 2 + ...{facade-94Ssdbk_.js => facade-nUK7MuU2.js} | 121 ++++- packages/taffyjs-yoga/dist/index.js | 2 +- packages/taffyjs-yoga/dist/load.js | 2 +- packages/taffyjs-yoga/src/facade.ts | 83 +++- packages/taffyjs-yoga/src/measurement.ts | 69 +++ packages/taffyjs-yoga/src/translate.ts | 28 +- .../fixtures/measure-path-profile.mjs | 36 ++ tests/taffyjs-yoga/measurement.test.mts | 434 ++++++++++++++++++ 12 files changed, 767 insertions(+), 43 deletions(-) rename packages/taffyjs-yoga/dist/{facade-94Ssdbk_.js => facade-nUK7MuU2.js} (94%) create mode 100644 packages/taffyjs-yoga/src/measurement.ts create mode 100644 tests/taffyjs-yoga/fixtures/measure-path-profile.mjs create mode 100644 tests/taffyjs-yoga/measurement.test.mts diff --git a/.agents/docs/loop-status.md b/.agents/docs/loop-status.md index b1d1720..b0ea547 100644 --- a/.agents/docs/loop-status.md +++ b/.agents/docs/loop-status.md @@ -4,7 +4,7 @@ Updated: 2026-08-15 ## State -Milestone 4 is complete, reviewed, and ready in its milestone commit. Milestone 5 is next. +Milestone 5 is complete, reviewed, and ready in its milestone commit. Milestone 6 is next. ## Done @@ -32,6 +32,10 @@ Milestone 4 is complete, reviewed, and ready in its milestone commit. Milestone - Added bounded Yoga-compatible float32 percentage resolution, explicit Taffy-offset removal for relative projection, the documented 0.0001 compatibility boundary for equivalent unrounded percentage arithmetic, and durable exact and tolerance-bound oracle fixtures. - Added differential fixtures and public compatibility entries for every researched non-measurement Difference, including live Config cache behavior, callback artifacts, max/shrink, aspect ratio, WebFlexBasis cache behavior, attached-subtree cache behavior, overlapping physical and logical margins, oversized and ordinary WrapReverse placement, reversed overflow distribution, zero cross-size lines, and auto margins under justification or reversed axes. - Added Yoga 3.2.1 PixelGrid source attribution, a complete packaged MIT third-party notice, and package-file coverage for the notice. +- Added one facade-level synchronous Measure bridge that dispatches retained callbacks by NodeId, keeps measured Nodes as leaves, maps known, finite, min-content, and max-content constraints to Yoga modes, preserves content-box values, normalizes callback results, and rethrows callback failures with identity preserved. +- Added selected-root exact-axis context for measured calculations, zero clamping for exhausted content-box constraints, Yoga measured-text point-grid rounding, native-only cache invalidation when callbacks are replaced or removed, and hidden measurement/output revisions that preserve public dirty state while making failure retry and arbitrary-subtree output commits correct. +- Added the ordinary-compute fast path for selected subtrees without measured Nodes, controlled same-facade native tree-busy behavior during callbacks, and durable callback registration, external-data dirtying, result coercion, failure atomicity, temporary root-Style restoration, retry, Config-refresh retry, and path-selection fixtures. +- Added exact dual-oracle Difference fixtures and public documentation for callback count/order/mode traces, MeasureMode-sensitive results, measured flex-basis interaction, and Yoga's stale cache after callback replacement or removal. ## Evidence and review @@ -48,6 +52,9 @@ Milestone 4 is complete, reviewed, and ready in its milestone commit. Milestone - The fresh Milestone 4 adversarial reviewer found three material issues: absolute mixed-direction logical insets were translated too late to recover auto-sized width, two known Taffy/Yoga Difference triggers were documented too narrowly, and the ported PixelGrid code lacked its required MIT attribution. Native Style translation now tracks containing direction before calculation, both Difference classes have exact dual-oracle fixtures and expanded documentation, and the source plus packaged notice carry the attribution. - The same reviewer’s single targeted follow-up confirmed those three repairs and found two residuals: Taffy's percentage conversion needed to divide before its float32 cast, and the authoritative implementation reference had not been synchronized with the expanded Difference evidence. The exact width-3/left-0.3% reproduction now matches Yoga bit-for-bit, the remaining equivalent absolute-margin arithmetic is pinned under Yoga's 0.0001 float tolerance, and the reference now contains all three added Difference triggers. Per the review protocol, the review loop stopped after that one follow-up and the residual fixes were verified locally and by the complete gate. - A `pnpm pack --dry-run --json` preview includes both entries, both entry declarations, both shared chunks, `COMPATIBILITY.md`, and `THIRD_PARTY_NOTICES.md`; a 64-case percentage direction, axis, and position matrix has zero exact mismatches for the tested inset corpus. +- The Milestone 5 full `vp run check` passes after the final Measure fixes: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 74 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. The committed path-selection profile records one ordinary call and zero measured calls for an unmeasured calculation, then one measured call only after a callback is present. +- The fresh Milestone 5 adversarial reviewer found two material issues: a negative Taffy finite content-box remainder was exposed as `AtMost(-5)` instead of Yoga's `AtMost(0)`, and a failed Config refresh consumed native Style staleness before retry, allowing changed descendant geometry to remain `hasNewLayout() === false`. Finite content constraints now clamp to zero, and each selected subtree retains a private output-stale marker until a complete successful projection and state commit. +- The same reviewer's single targeted follow-up returned PASS after verifying both axes, positive and exact constraints, the original Config/point-scale retry, partial child-then-ancestor recovery, and clean cache-hit flags. The focused Measure suite passes 10 tests, including the exact 20-by-30 negative-space and 10.5-by-5.5 retry reproductions. ## In flight @@ -55,7 +62,7 @@ Milestone 4 is complete, reviewed, and ready in its milestone commit. Milestone ## Next -- Implement Milestone 5: Measure callback registration, native bridge, mode mapping, failure and retry behavior, measured rounding, reentrancy, and documented measurement Differences. +- Complete Milestone 6: audit the exact Yoga 3.2.1 public surface and classifications, finish package and alias evidence, remove obsolete research TODOs, complete public documentation, and run the release-readiness gates while keeping the PR draft and unmerged. ## Blocked diff --git a/.agents/docs/taffyjs-yoga-reference.md b/.agents/docs/taffyjs-yoga-reference.md index d689ef0..99d3587 100644 --- a/.agents/docs/taffyjs-yoga-reference.md +++ b/.agents/docs/taffyjs-yoga-reference.md @@ -306,13 +306,13 @@ This temporary Style is calculation context, not a second declaration or layout ### Measure callback bridge -One facade-level Taffy measure closure dispatches by NodeId to the callback stored on the Yoga Node. Nodes without a Yoga callback return the ordinary zero intrinsic size. The adapter converts each Taffy request independently: a known dimension becomes Yoga MeasureMode.Exactly, finite available space becomes MeasureMode.AtMost, MaxContent becomes MeasureMode.Undefined with NaN, and MinContent becomes MeasureMode.AtMost with zero. The calculation-only root constraint above turns a finite exact owner dimension into a known dimension for a selected measured root, so that callback receives Exactly rather than AtMost. Padding and border have already been removed from Taffy's available space and are added back by Taffy, matching Yoga's content-box callback contract. +One facade-level Taffy measure closure dispatches by NodeId to the callback stored on the Yoga Node. Nodes without a Yoga callback return the ordinary zero intrinsic size. The adapter converts each Taffy request independently: a known dimension becomes Yoga MeasureMode.Exactly, finite available space becomes MeasureMode.AtMost, MaxContent becomes MeasureMode.Undefined with NaN, and MinContent becomes MeasureMode.AtMost with zero. Taffy's known dimensions can contain an outer border-box size, while the matching finite available-space value has padding and border removed, so the adapter uses that content-box value for Exactly. Every finite content-box value is clamped to zero rather than exposing a negative constraint. The calculation context also marks each finite exact axis on the selected measured root, because Taffy's final leaf request can otherwise retain only a finite available-space value. Taffy adds padding and border back to the measured result. This conversion is intentionally not a reconstruction of Yoga's measurement phases. Taffy can make several intrinsic, sizing, and final-layout requests where Yoga makes one, and AvailableSpace.Definite does not record whether its source was an exact stretch or a fit-content bound. Consulting parent Flex state to synthesize Yoga's call sequence would duplicate algorithm logic and is outside the JavaScript-only boundary. Callback count, order, and exact argument trace therefore remain unspecified, and a callback that changes its result based on MeasureMode can produce a documented Different layout. -The callback result is converted to numbers, missing dimensions become NaN, and each NaN or negative dimension is normalized to zero before returning to Taffy; positive infinity remains positive infinity, matching Yoga's normalizer. A thrown JavaScript value is rethrown with identity preserved. The current @taffyjs/node binding marks the calculation subtree dirty after callback failure, so a later calculation can retry. Measured Nodes must remain leaves, markDirty remains restricted to measured leaves, and native TaffyTree access attempted reentrantly during the callback can throw the existing tree-busy error; exact callback reentrancy is not part of the compatibility promise. +The callback result is converted to numbers, missing dimensions become NaN, and each NaN or negative dimension is normalized to zero before returning to Taffy; positive infinity remains positive infinity, matching Yoga's normalizer. A thrown JavaScript value is rethrown with identity preserved. The current @taffyjs/node binding marks the calculation subtree dirty after callback failure, so a later calculation can retry. Replacing or removing the retained callback also marks only the private native node dirty, leaving Yoga-visible dirty state unchanged while ensuring that the next calculation uses the current registration; Yoga 3.2.1 instead reuses its stale cached measurement in the pinned fixture, which is a documented cache-artifact Difference. Measured Nodes must remain leaves, markDirty remains restricted to measured leaves, and native TaffyTree access attempted reentrantly during the callback can throw the existing tree-busy error; exact callback reentrancy is not part of the compatibility promise. -There is one performance boundary to measure during implementation. Once a calculation uses computeLayoutWithMeasure, the current @taffyjs/node boundary invokes the JavaScript closure for every Taffy leaf request, including leaves without a Yoga callback; a bounded 100-leaf probe observed 200 calls for fixed-size leaves and 400 for intrinsically sized leaves. The facade should use ordinary computeLayout when the calculated subtree has no measured Node. The remaining mixed-tree overhead is not a correctness blocker and does not justify shadow topology or JavaScript Flex logic; it belongs in later end-to-end performance work. +There is one performance boundary to preserve. Once a calculation uses computeLayoutWithMeasure, the current @taffyjs/node boundary invokes the JavaScript closure for every Taffy leaf request, including leaves without a Yoga callback; a bounded 100-leaf probe observed 200 calls for fixed-size leaves and 400 for intrinsically sized leaves. A committed path-selection profile confirms that a selected subtree without a measured Node invokes ordinary computeLayout once and never enters computeLayoutWithMeasure, while adding a measured Node selects the measured entry. The remaining mixed-tree overhead is not a correctness blocker and does not justify shadow topology or JavaScript Flex logic; it belongs in later end-to-end performance work. ### Style declaration storage, getters, and setters diff --git a/packages/taffyjs-yoga/COMPATIBILITY.md b/packages/taffyjs-yoga/COMPATIBILITY.md index 026b62f..dc7355d 100644 --- a/packages/taffyjs-yoga/COMPATIBILITY.md +++ b/packages/taffyjs-yoga/COMPATIBILITY.md @@ -16,6 +16,16 @@ Compatible numeric comparisons allow an absolute error below `0.0001` for finite - Point-grid conversion follows each Node's own Config, including factor zero, mixed factors in one tree, and positive infinity. Computed edges other than left and top remain unrounded, as in Yoga. - Ordinary fixed and percentage sizes, simple grow and shrink without min/max, and simple nested Flex trees match the pinned Yoga oracle. This is a bounded compatibility corpus, not a universal compatibility percentage. +## Measurement + +The normal Measure API is Compatible. `setMeasureFunc()`, `setMeasureFunc(null)`, and `unsetMeasureFunc()` register or remove one synchronous callback on a leaf Node. A measured Node cannot have children, and `markDirty()` remains restricted to measured leaves. + +The callback receives Yoga's four arguments and content-box constraints. A known dimension is `Exactly`; a finite soft constraint is `AtMost`; max-content is `Undefined` with NaN; and min-content is `AtMost` with zero. Content-box constraints are never negative. A finite `calculateLayout()` constraint on the selected measured root is treated as `Exactly`, including when padding and border consume all available space. Missing callback result dimensions, NaN, and negative values normalize to zero; values are converted with JavaScript `Number()`, and positive infinity is retained. A thrown value is rethrown without changing its identity. + +Measurement failure does not commit partial Yoga-shaped output or public dirty/new-layout flags, and the same calculation can be retried. Measured output uses Yoga's text rounding. Replacing or removing a callback invalidates the private native measurement cache without changing Yoga-visible `isDirty()`, so the next calculation uses the current registration rather than an obsolete result. + +An unmeasured selected subtree uses the ordinary native calculation entry and does not enter the JavaScript measurement bridge. Once a selected subtree contains a measured Node, Taffy controls callback count and order and may also request intrinsic sizes for unmeasured leaves. JavaScript-only declaration and computed-output getters remain usable during a callback; a same-facade method that needs native tree access can throw `ERR_TAFFY_TREE_BUSY`. Exact reentrant behavior is not a compatibility promise. + ## Known Differences These rows are deliberate, version-specific differences with regression fixtures: @@ -34,7 +44,9 @@ These rows are deliberate, version-specific differences with regression fixtures | An oversized no-wrap reversed main axis with `SpaceBetween`, `SpaceAround`, or `SpaceEvenly` | Keeps Taffy's negative-free-space fallback. Widths 140, 130, and 120 in a width-240 `RowReverse` with `SpaceBetween` have left positions 250, 120, and 0. | The same left positions are 100, -30, and -150. | | A wrapped line whose stretched items resolve to zero cross size under distribution-style `alignContent` | Keeps Taffy's line distribution. Two 60-point, flex-basis-zero children in a 100-by-180 Row with `alignContent: Center` both have top 90 for Wrap and WrapReverse. | Both children have top 0 for Wrap and 180 for WrapReverse. | | Main-axis auto margins on a reversed axis or combined with a nonzero `justifyContent` offset | Keeps Taffy's distribution and masks computed auto margins to zero. A width-60 child with `margin-left: auto` in a width-240 `RowReverse` has left 360; the Center fixture has child tops 130 and 225. | Yoga places the reversed child at left 180; auto margins suppress the Center offset and produce tops 65 and 160. | - -Measure callback modes, traces, and measurement-dependent results are completed and documented with the Measure milestone rather than being inferred from the non-measurement rows above. +| Measure callback count, order, and exact mode trace | Maps every Taffy request independently. A constant 30-by-10 callback for a child in a 100-by-40 Row runs four times in the pinned fixture and still produces the same 30-by-40 layout. | Runs once with width `AtMost(100)` and height `Exactly(40)` in the same fixture. | +| A Measure callback whose result changes with `MeasureMode` | Keeps Taffy's multi-stage result. In the pinned 100-by-40 Row, a callback returning width 80 for `Undefined` and 20 for `AtMost` produces width 80. | Calls the callback with `AtMost(100)` and produces width 20. | +| A measured child with an explicit 20-point `flexBasis` | Keeps Taffy's measured size. A constant 30-by-10 callback produces a 30-by-10 child in a width-100 Row. | Uses the explicit basis for the main size and produces a 20-by-10 child. | +| Replace or remove a Measure callback on an otherwise clean Node | Privately invalidates Taffy's measurement cache. Replacing a cached width-10 callback with width 20 produces 20 on the next calculation; removing it then produces zero. | Leaves the Node publicly clean and reuses the cached width 10 after both operations. | The exhaustive Compatible, Different, and Unsupported API matrix will be finalized before this draft package is release-ready. diff --git a/packages/taffyjs-yoga/README.md b/packages/taffyjs-yoga/README.md index fdaee79..81cdb69 100644 --- a/packages/taffyjs-yoga/README.md +++ b/packages/taffyjs-yoga/README.md @@ -4,4 +4,6 @@ The package is under active development and is not published. Its root and `/load` entries are exercised through the repository's package-boundary consumer tests. +The current implementation includes Yoga-shaped Config, Style, topology, calculation, computed-output, and synchronous Measure APIs. Taffy remains the only layout engine; the compatibility layer maps Yoga declarations and callback constraints and documents the cases where Taffy's result intentionally differs. + See [COMPATIBILITY.md](COMPATIBILITY.md) for the current compatibility boundary. diff --git a/packages/taffyjs-yoga/dist/facade-94Ssdbk_.js b/packages/taffyjs-yoga/dist/facade-nUK7MuU2.js similarity index 94% rename from packages/taffyjs-yoga/dist/facade-94Ssdbk_.js rename to packages/taffyjs-yoga/dist/facade-nUK7MuU2.js index 230ee20..269dfcc 100644 --- a/packages/taffyjs-yoga/dist/facade-94Ssdbk_.js +++ b/packages/taffyjs-yoga/dist/facade-nUK7MuU2.js @@ -1,4 +1,4 @@ -import { AlignContent, AlignItems, AvailableSpace, BoxSizing, Dimension, Direction, Display, FlexDirection, FlexWrap, Overflow, Position, TaffyTree } from "@taffyjs/node"; +import { AlignContent, AlignItems, AvailableSpace, AvailableSpaceKind, BoxSizing, Dimension, Direction, Display, FlexDirection, FlexWrap, Overflow, Position, TaffyTree } from "@taffyjs/node"; //#region src/enums.ts let Align = /* @__PURE__ */ function(Align) { Align[Align["Auto"] = 0] = "Auto"; @@ -675,6 +675,41 @@ function projectOutputs(entries, options) { return states; } //#endregion +//#region src/measurement.ts +function mapConstraint(knownDimension, availableSpace, forceExactly) { + if (knownDimension !== void 0 || forceExactly) return { + mode: 1, + value: availableSpace.kind === AvailableSpaceKind.Definite ? Math.max(0, availableSpace.value) : Math.max(0, knownDimension ?? 0) + }; + switch (availableSpace.kind) { + case AvailableSpaceKind.Definite: return { + mode: 2, + value: Math.max(0, availableSpace.value) + }; + case AvailableSpaceKind.MinContent: return { + mode: 2, + value: 0 + }; + case AvailableSpaceKind.MaxContent: return { + mode: 0, + value: NaN + }; + } +} +function normalizeDimension(value) { + const number = Number(value); + return Number.isNaN(number) || number < 0 ? 0 : number; +} +function invokeYogaMeasure(args, measure, forceExactly) { + const width = mapConstraint(args.knownDimensions.width, args.availableSpace.width, forceExactly.width); + const height = mapConstraint(args.knownDimensions.height, args.availableSpace.height, forceExactly.height); + const { width: measuredWidth = NaN, height: measuredHeight = NaN } = measure(width.value, width.mode, height.value, height.mode); + return { + width: normalizeDimension(measuredWidth), + height: normalizeDimension(measuredHeight) + }; +} +//#endregion //#region src/translate.ts function alignItems(value) { switch (value) { @@ -835,15 +870,23 @@ function translateCalculationStyle(declarations, config, resolvedDirection, owne const width = exactRootDimension(declarations, "width", ownerWidth, ownerWidth, resolvedDirection); const height = exactRootDimension(declarations, "height", ownerHeight, ownerWidth, resolvedDirection); const forceFlexDisplay = declarations.display === 1; - if (width === void 0 && height === void 0 && !forceFlexDisplay) return null; + if (width === void 0 && height === void 0 && !forceFlexDisplay) return { + style: null, + exactWidth: false, + exactHeight: false + }; const ordinary = translateStyle(declarations, config, resolvedDirection, ownerDirection); return { - ...ordinary, - display: forceFlexDisplay ? Display.Flex : ordinary.display, - size: { - width: width === void 0 ? toDimension(declarations.width) : Dimension.Length(width), - height: height === void 0 ? toDimension(declarations.height) : Dimension.Length(height) - } + style: { + ...ordinary, + display: forceFlexDisplay ? Display.Flex : ordinary.display, + size: { + width: width === void 0 ? toDimension(declarations.width) : Dimension.Length(width), + height: height === void 0 ? toDimension(declarations.height) : Dimension.Length(height) + } + }, + exactWidth: width !== void 0, + exactHeight: height !== void 0 }; } function declarationDirection(declarations, ownerDirection) { @@ -870,6 +913,21 @@ var FacadeRuntime = class { tree = new TaffyTree(); nodes = /* @__PURE__ */ new Map(); defaultConfig = new ConfigState(); + measure = (args) => { + const callback = recordForId(this, args.node).measureFunction; + if (callback === null) return { + width: 0, + height: 0 + }; + const context = this.activeMeasureContext; + const isSelectedRoot = context?.root === args.node; + return invokeYogaMeasure(args, callback, { + width: isSelectedRoot === true && context.exactWidth, + height: isSelectedRoot === true && context.exactHeight + }); + }; + activeMeasureContext = null; + measurementRevision = 0; poisoned = null; constructor() { this.tree.disableRounding(); @@ -1111,7 +1169,7 @@ function assertAcyclicInsertion(parent, child) { } } function sameCalculation(left, right) { - return left !== void 0 && Object.is(left.width, right.width) && Object.is(left.height, right.height) && left.direction === right.direction; + return left !== void 0 && Object.is(left.width, right.width) && Object.is(left.height, right.height) && left.direction === right.direction && left.measurementRevision === right.measurementRevision; } var YogaNode = class { constructor(runtime, config) { @@ -1138,6 +1196,8 @@ var YogaNode = class { hasNewLayout: true, dirtiedFunction: null, measureFunction: null, + measurementRevision: 0, + outputStale: false, referenceBaseline: false, alwaysFormsContainingBlock: false, lastCalculation: void 0 @@ -1232,7 +1292,12 @@ var YogaNode = class { record.dirty = true; record.hasNewLayout = true; record.dirtiedFunction = null; + if (record.measureFunction !== null) { + record.runtime.measurementRevision += 1; + record.measurementRevision = record.runtime.measurementRevision; + } record.measureFunction = null; + record.outputStale = false; record.referenceBaseline = false; record.alwaysFormsContainingBlock = false; record.lastCalculation = void 0; @@ -1253,10 +1318,14 @@ var YogaNode = class { const record = requireNode(void 0, this); if (measureFunc !== null && typeof measureFunc !== "function") throw new TypeError("measureFunc must be a function or null"); if (measureFunc !== null && record.runtime.tree.getChildCount(record.nodeId) !== 0) throw new Error("Measured Yoga nodes cannot have children"); + if (record.measureFunction === measureFunc) return; + record.runtime.tree.markDirty(record.nodeId); record.measureFunction = measureFunc; + record.runtime.measurementRevision += 1; + record.measurementRevision = record.runtime.measurementRevision; } unsetMeasureFunc() { - requireNode(void 0, this).measureFunction = null; + this.setMeasureFunc(null); } setDirtiedFunc(dirtiedFunc) { const record = requireNode(void 0, this); @@ -1663,29 +1732,46 @@ var YogaNode = class { const ownerDirection = requireEnum(direction, "calculateLayout direction", [1, 2]); const availableWidth = requireCalculationDimension(width, "width"); const availableHeight = requireCalculationDimension(height, "height"); + const subtree = collectSubtree(record); const signature = { width: typeof availableWidth === "number" ? Math.fround(availableWidth) : void 0, height: typeof availableHeight === "number" ? Math.fround(availableHeight) : void 0, - direction: ownerDirection + direction: ownerDirection, + measurementRevision: subtree.reduce((revision, current) => Math.max(revision, current.measurementRevision), 0) }; - const subtree = collectSubtree(record); const translationIsStale = syncSubtreeStyles(record, ownerDirection, subtree); - const recomputeSubtree = record.dirty || translationIsStale || !sameCalculation(record.lastCalculation, signature); + const recomputeSubtree = record.dirty || translationIsStale || subtree.some((current) => current.outputStale) || !sameCalculation(record.lastCalculation, signature); const rootHasOwner = record.runtime.tree.getParent(record.nodeId) !== null; - const temporaryStyle = translateCalculationStyle(record.declarations, record.config, record.appliedDirection, record.ownerDirection, signature.width, signature.height); + const calculationStyle = translateCalculationStyle(record.declarations, record.config, record.appliedDirection, record.ownerDirection, signature.width, signature.height); + const temporaryStyle = calculationStyle.style; const ordinaryStyle = temporaryStyle === null ? null : translateStyle(record.declarations, record.config, record.appliedDirection, record.ownerDirection); + for (const current of subtree) current.outputStale = true; if (temporaryStyle !== null) record.runtime.tree.setStyle(record.nodeId, temporaryStyle); - record.lastCalculation = void 0; let calculationFailed = false; let calculationError; try { - record.runtime.tree.computeLayout({ + const options = { root: record.nodeId, availableSpace: { width: availableWidth, height: availableHeight } - }); + }; + if (subtree.some((current) => current.measureFunction !== null)) { + record.runtime.activeMeasureContext = { + root: record.nodeId, + exactWidth: calculationStyle.exactWidth, + exactHeight: calculationStyle.exactHeight + }; + try { + record.runtime.tree.computeLayoutWithMeasure({ + ...options, + measure: record.runtime.measure + }); + } finally { + record.runtime.activeMeasureContext = null; + } + } else record.runtime.tree.computeLayout(options); } catch (error) { calculationFailed = true; calculationError = error; @@ -1730,6 +1816,7 @@ var YogaNode = class { for (const current of subtree) { current.dirty = false; if (recomputeSubtree) current.hasNewLayout = true; + current.outputStale = false; } record.hasNewLayout = true; record.lastCalculation = signature; diff --git a/packages/taffyjs-yoga/dist/index.js b/packages/taffyjs-yoga/dist/index.js index 3b775c3..0f176a5 100644 --- a/packages/taffyjs-yoga/dist/index.js +++ b/packages/taffyjs-yoga/dist/index.js @@ -1,4 +1,4 @@ -import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-94Ssdbk_.js"; +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-nUK7MuU2.js"; //#region src/index.ts const Yoga = createYoga(); //#endregion diff --git a/packages/taffyjs-yoga/dist/load.js b/packages/taffyjs-yoga/dist/load.js index 94acbfa..1d7ac55 100644 --- a/packages/taffyjs-yoga/dist/load.js +++ b/packages/taffyjs-yoga/dist/load.js @@ -1,4 +1,4 @@ -import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-94Ssdbk_.js"; +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-nUK7MuU2.js"; //#region src/load.ts async function loadYoga() { return createYoga(); diff --git a/packages/taffyjs-yoga/src/facade.ts b/packages/taffyjs-yoga/src/facade.ts index fad1dbd..2f21c3e 100644 --- a/packages/taffyjs-yoga/src/facade.ts +++ b/packages/taffyjs-yoga/src/facade.ts @@ -1,4 +1,9 @@ -import { AvailableSpace, TaffyTree, type NodeId } from "@taffyjs/node"; +import { + AvailableSpace, + TaffyTree, + type MeasureArgs as NativeMeasureArgs, + type NodeId, +} from "@taffyjs/node"; import { cloneDeclarations, createDeclarations, @@ -27,6 +32,7 @@ import { type ComputedEdges, type ProjectionEntry, } from "./projection.js"; +import { invokeYogaMeasure } from "./measurement.js"; import { declarationDirection, translateCalculationStyle, translateStyle } from "./translate.js"; import type { AlignContentValue, @@ -74,6 +80,18 @@ class FacadeRuntime { readonly tree = new TaffyTree(); readonly nodes = new Map(); readonly defaultConfig = new ConfigState(); + readonly measure = (args: NativeMeasureArgs) => { + const callback = recordForId(this, args.node).measureFunction; + if (callback === null) return { width: 0, height: 0 }; + const context = this.activeMeasureContext; + const isSelectedRoot = context?.root === args.node; + return invokeYogaMeasure(args, callback, { + width: isSelectedRoot === true && context.exactWidth, + height: isSelectedRoot === true && context.exactHeight, + }); + }; + activeMeasureContext: ActiveMeasureContext | null = null; + measurementRevision = 0; poisoned: Error | null = null; constructor() { @@ -81,6 +99,12 @@ class FacadeRuntime { } } +interface ActiveMeasureContext { + readonly root: NodeId; + readonly exactWidth: boolean; + readonly exactHeight: boolean; +} + function assertRuntimeUsable(runtime: FacadeRuntime): void { if (runtime.poisoned !== null) throw runtime.poisoned; } @@ -216,6 +240,8 @@ interface NodeRecord { hasNewLayout: boolean; dirtiedFunction: DirtiedFunction | null; measureFunction: MeasureFunction | null; + measurementRevision: number; + outputStale: boolean; referenceBaseline: boolean; alwaysFormsContainingBlock: boolean; lastCalculation: CalculationSignature | undefined; @@ -225,6 +251,7 @@ interface CalculationSignature { readonly width: number | undefined; readonly height: number | undefined; readonly direction: Direction.LTR | Direction.RTL; + readonly measurementRevision: number; } const nodeRecords = new WeakMap(); @@ -435,7 +462,8 @@ function sameCalculation( left !== undefined && Object.is(left.width, right.width) && Object.is(left.height, right.height) && - left.direction === right.direction + left.direction === right.direction && + left.measurementRevision === right.measurementRevision ); } @@ -466,6 +494,8 @@ class YogaNode implements Node { hasNewLayout: true, dirtiedFunction: null, measureFunction: null, + measurementRevision: 0, + outputStale: false, referenceBaseline: false, alwaysFormsContainingBlock: false, lastCalculation: undefined, @@ -599,7 +629,12 @@ class YogaNode implements Node { record.dirty = true; record.hasNewLayout = true; record.dirtiedFunction = null; + if (record.measureFunction !== null) { + record.runtime.measurementRevision += 1; + record.measurementRevision = record.runtime.measurementRevision; + } record.measureFunction = null; + record.outputStale = false; record.referenceBaseline = false; record.alwaysFormsContainingBlock = false; record.lastCalculation = undefined; @@ -632,11 +667,15 @@ class YogaNode implements Node { if (measureFunc !== null && record.runtime.tree.getChildCount(record.nodeId) !== 0) { throw new Error("Measured Yoga nodes cannot have children"); } + if (record.measureFunction === measureFunc) return; + record.runtime.tree.markDirty(record.nodeId); record.measureFunction = measureFunc; + record.runtime.measurementRevision += 1; + record.measurementRevision = record.runtime.measurementRevision; } unsetMeasureFunc(): void { - requireNode(undefined, this).measureFunction = null; + this.setMeasureFunc(null); } setDirtiedFunc(dirtiedFunc: DirtiedFunction | null): void { @@ -1128,17 +1167,24 @@ class YogaNode implements Node { ]) as Direction.LTR | Direction.RTL; const availableWidth = requireCalculationDimension(width, "width"); const availableHeight = requireCalculationDimension(height, "height"); + const subtree = collectSubtree(record); const signature: CalculationSignature = { width: typeof availableWidth === "number" ? Math.fround(availableWidth) : undefined, height: typeof availableHeight === "number" ? Math.fround(availableHeight) : undefined, direction: ownerDirection, + measurementRevision: subtree.reduce( + (revision, current) => Math.max(revision, current.measurementRevision), + 0, + ), }; - const subtree = collectSubtree(record); const translationIsStale = syncSubtreeStyles(record, ownerDirection, subtree); const recomputeSubtree = - record.dirty || translationIsStale || !sameCalculation(record.lastCalculation, signature); + record.dirty || + translationIsStale || + subtree.some((current) => current.outputStale) || + !sameCalculation(record.lastCalculation, signature); const rootHasOwner = record.runtime.tree.getParent(record.nodeId) !== null; - const temporaryStyle = translateCalculationStyle( + const calculationStyle = translateCalculationStyle( record.declarations, record.config, record.appliedDirection, @@ -1146,6 +1192,7 @@ class YogaNode implements Node { signature.width, signature.height, ); + const temporaryStyle = calculationStyle.style; const ordinaryStyle = temporaryStyle === null ? null @@ -1155,18 +1202,35 @@ class YogaNode implements Node { record.appliedDirection, record.ownerDirection, ); + for (const current of subtree) current.outputStale = true; if (temporaryStyle !== null) record.runtime.tree.setStyle(record.nodeId, temporaryStyle); - record.lastCalculation = undefined; let calculationFailed = false; let calculationError: unknown; try { - record.runtime.tree.computeLayout({ + const options = { root: record.nodeId, availableSpace: { width: availableWidth, height: availableHeight, }, - }); + }; + if (subtree.some((current) => current.measureFunction !== null)) { + record.runtime.activeMeasureContext = { + root: record.nodeId, + exactWidth: calculationStyle.exactWidth, + exactHeight: calculationStyle.exactHeight, + }; + try { + record.runtime.tree.computeLayoutWithMeasure({ + ...options, + measure: record.runtime.measure, + }); + } finally { + record.runtime.activeMeasureContext = null; + } + } else { + record.runtime.tree.computeLayout(options); + } } catch (error) { calculationFailed = true; calculationError = error; @@ -1219,6 +1283,7 @@ class YogaNode implements Node { for (const current of subtree) { current.dirty = false; if (recomputeSubtree) current.hasNewLayout = true; + current.outputStale = false; } record.hasNewLayout = true; record.lastCalculation = signature; diff --git a/packages/taffyjs-yoga/src/measurement.ts b/packages/taffyjs-yoga/src/measurement.ts new file mode 100644 index 0000000..a07b9cf --- /dev/null +++ b/packages/taffyjs-yoga/src/measurement.ts @@ -0,0 +1,69 @@ +import { + AvailableSpaceKind, + type AvailableSpace, + type MeasureArgs as NativeMeasureArgs, +} from "@taffyjs/node"; +import { MeasureMode } from "./enums.js"; +import type { MeasureFunction, Size } from "./types.js"; + +interface YogaMeasureConstraint { + readonly mode: MeasureMode; + readonly value: number; +} + +function mapConstraint( + knownDimension: number | undefined, + availableSpace: AvailableSpace, + forceExactly: boolean, +): YogaMeasureConstraint { + if (knownDimension !== undefined || forceExactly) { + return { + mode: MeasureMode.Exactly, + value: + availableSpace.kind === AvailableSpaceKind.Definite + ? Math.max(0, availableSpace.value) + : Math.max(0, knownDimension ?? 0), + }; + } + + switch (availableSpace.kind) { + case AvailableSpaceKind.Definite: + return { mode: MeasureMode.AtMost, value: Math.max(0, availableSpace.value) }; + case AvailableSpaceKind.MinContent: + return { mode: MeasureMode.AtMost, value: 0 }; + case AvailableSpaceKind.MaxContent: + return { mode: MeasureMode.Undefined, value: Number.NaN }; + } +} + +function normalizeDimension(value: unknown): number { + const number = Number(value); + return Number.isNaN(number) || number < 0 ? 0 : number; +} + +export function invokeYogaMeasure( + args: NativeMeasureArgs, + measure: MeasureFunction, + forceExactly: Readonly<{ width: boolean; height: boolean }>, +): Size { + const width = mapConstraint( + args.knownDimensions.width, + args.availableSpace.width, + forceExactly.width, + ); + const height = mapConstraint( + args.knownDimensions.height, + args.availableSpace.height, + forceExactly.height, + ); + const result = measure(width.value, width.mode, height.value, height.mode); + const { width: measuredWidth = Number.NaN, height: measuredHeight = Number.NaN } = + result as unknown as { + readonly width?: unknown; + readonly height?: unknown; + }; + return { + width: normalizeDimension(measuredWidth), + height: normalizeDimension(measuredHeight), + }; +} diff --git a/packages/taffyjs-yoga/src/translate.ts b/packages/taffyjs-yoga/src/translate.ts index 42b5ad5..e3c6852 100644 --- a/packages/taffyjs-yoga/src/translate.ts +++ b/packages/taffyjs-yoga/src/translate.ts @@ -349,6 +349,12 @@ function exactRootDimension( return Math.max(0, outerSize); } +export interface CalculationStylePlan { + readonly style: StyleInput | null; + readonly exactWidth: boolean; + readonly exactHeight: boolean; +} + export function translateCalculationStyle( declarations: YogaDeclarations, config: TranslationConfig, @@ -356,7 +362,7 @@ export function translateCalculationStyle( ownerDirection: Direction.LTR | Direction.RTL, ownerWidth: number | undefined, ownerHeight: number | undefined, -): StyleInput | null { +): CalculationStylePlan { const width = exactRootDimension( declarations, "width", @@ -372,17 +378,23 @@ export function translateCalculationStyle( resolvedDirection, ); const forceFlexDisplay = declarations.display === Display.None; - if (width === undefined && height === undefined && !forceFlexDisplay) return null; + if (width === undefined && height === undefined && !forceFlexDisplay) { + return { style: null, exactWidth: false, exactHeight: false }; + } const ordinary = translateStyle(declarations, config, resolvedDirection, ownerDirection); return { - ...ordinary, - display: forceFlexDisplay ? TaffyDisplay.Flex : ordinary.display, - size: { - width: width === undefined ? toDimension(declarations.width) : TaffyDimension.Length(width), - height: - height === undefined ? toDimension(declarations.height) : TaffyDimension.Length(height), + style: { + ...ordinary, + display: forceFlexDisplay ? TaffyDisplay.Flex : ordinary.display, + size: { + width: width === undefined ? toDimension(declarations.width) : TaffyDimension.Length(width), + height: + height === undefined ? toDimension(declarations.height) : TaffyDimension.Length(height), + }, }, + exactWidth: width !== undefined, + exactHeight: height !== undefined, }; } diff --git a/tests/taffyjs-yoga/fixtures/measure-path-profile.mjs b/tests/taffyjs-yoga/fixtures/measure-path-profile.mjs new file mode 100644 index 0000000..2780b52 --- /dev/null +++ b/tests/taffyjs-yoga/fixtures/measure-path-profile.mjs @@ -0,0 +1,36 @@ +import { TaffyTree } from "@taffyjs/node"; +import Yoga from "yoga-layout"; + +const ordinaryCompute = Object.getOwnPropertyDescriptor(TaffyTree.prototype, "computeLayout").value; +const measuredCompute = Object.getOwnPropertyDescriptor( + TaffyTree.prototype, + "computeLayoutWithMeasure", +).value; +const calls = { ordinary: 0, measured: 0 }; + +TaffyTree.prototype.computeLayout = function (...args) { + calls.ordinary += 1; + return ordinaryCompute.apply(this, args); +}; +TaffyTree.prototype.computeLayoutWithMeasure = function (...args) { + calls.measured += 1; + return measuredCompute.apply(this, args); +}; + +try { + const plain = Yoga.Node.create(); + plain.calculateLayout(undefined, undefined); + const afterPlain = { ...calls }; + plain.free(); + + const measured = Yoga.Node.create(); + measured.setMeasureFunc(() => ({ width: 8, height: 4 })); + measured.calculateLayout(undefined, undefined); + const afterMeasured = { ...calls }; + measured.free(); + + process.stdout.write(JSON.stringify({ afterPlain, afterMeasured })); +} finally { + TaffyTree.prototype.computeLayout = ordinaryCompute; + TaffyTree.prototype.computeLayoutWithMeasure = measuredCompute; +} diff --git a/tests/taffyjs-yoga/measurement.test.mts b/tests/taffyjs-yoga/measurement.test.mts new file mode 100644 index 0000000..3eb04ec --- /dev/null +++ b/tests/taffyjs-yoga/measurement.test.mts @@ -0,0 +1,434 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import Yoga, { FlexDirection, MeasureMode, type MeasureFunction } from "yoga-layout"; +import OracleYoga, { + FlexDirection as OracleFlexDirection, + MeasureMode as OracleMeasureMode, +} from "yoga-layout-oracle"; +import { test } from "vite-plus/test"; + +type CallbackCall = readonly [number, number, number, number]; + +function layoutSize(node: { + getComputedWidth(): number; + getComputedHeight(): number; +}): readonly [number, number] { + return [node.getComputedWidth(), node.getComputedHeight()]; +} + +function captureThrown(body: () => unknown): unknown { + try { + body(); + } catch (error) { + return error; + } + assert.fail("Expected operation to throw"); +} + +test("ordinary measured roots preserve Yoga constraints, normalization, and text rounding", () => { + for (const Implementation of [Yoga, OracleYoga]) { + const node = Implementation.Node.create(); + const calls: CallbackCall[] = []; + node.setMeasureFunc((width, widthMode, height, heightMode) => { + calls.push([width, widthMode, height, heightMode]); + return { width: 23.25, height: 11.5 }; + }); + node.calculateLayout(undefined, undefined); + assert.deepEqual(calls, [ + [ + Number.NaN, + Implementation.MEASURE_MODE_UNDEFINED, + Number.NaN, + Implementation.MEASURE_MODE_UNDEFINED, + ], + ]); + assert.deepEqual(layoutSize(node), [24, 12]); + node.free(); + } + + for (const Implementation of [Yoga, OracleYoga]) { + const node = Implementation.Node.create(); + const calls: CallbackCall[] = []; + node.setMaxWidth(15); + node.setPadding(Implementation.EDGE_ALL, 10); + node.setMeasureFunc((width, widthMode, height, heightMode) => { + calls.push([width, widthMode, height, heightMode]); + return { width: 30, height: width < 0 ? 50 : 10 }; + }); + node.calculateLayout(100, undefined); + assert.deepEqual(calls, [ + [0, Implementation.MEASURE_MODE_AT_MOST, Number.NaN, Implementation.MEASURE_MODE_UNDEFINED], + ]); + assert.deepEqual(layoutSize(node), [20, 30]); + node.free(); + } + + const config = Yoga.Config.create(); + const measuredText = Yoga.Node.createWithConfig(config); + const oracleConfig = OracleYoga.Config.create(); + const oracleMeasuredText = OracleYoga.Node.createWithConfig(oracleConfig); + config.setPointScaleFactor(2); + oracleConfig.setPointScaleFactor(2); + measuredText.setMeasureFunc(() => ({ width: 10.1, height: 5.1 })); + oracleMeasuredText.setMeasureFunc(() => ({ width: 10.1, height: 5.1 })); + measuredText.calculateLayout(undefined, undefined); + oracleMeasuredText.calculateLayout(undefined, undefined); + assert.deepEqual(layoutSize(measuredText), [10.5, 5.5]); + assert.deepEqual(layoutSize(measuredText), layoutSize(oracleMeasuredText)); + measuredText.free(); + oracleMeasuredText.free(); + config.free(); + oracleConfig.free(); + + for (const Implementation of [Yoga, OracleYoga]) { + const node = Implementation.Node.create(); + const calls: CallbackCall[] = []; + node.setPadding(Implementation.EDGE_ALL, 10); + node.setMeasureFunc((width, widthMode, height, heightMode) => { + calls.push([width, widthMode, height, heightMode]); + return { width: 30, height: 10 }; + }); + node.calculateLayout(5, undefined); + assert.deepEqual(calls, [ + [0, Implementation.MEASURE_MODE_EXACTLY, Number.NaN, Implementation.MEASURE_MODE_UNDEFINED], + ]); + assert.deepEqual(layoutSize(node), [20, 30]); + node.free(); + } + + const normalizedResults: readonly unknown[] = [ + { width: "7.5", height: undefined }, + { width: Number.NaN, height: -2 }, + { width: Number.POSITIVE_INFINITY, height: Number.POSITIVE_INFINITY }, + ]; + for (const result of normalizedResults) { + const node = Yoga.Node.create(); + const oracle = OracleYoga.Node.create(); + node.setMeasureFunc(() => result as never); + oracle.setMeasureFunc(() => result as never); + node.calculateLayout(undefined, undefined); + oracle.calculateLayout(undefined, undefined); + assert.deepEqual(layoutSize(node), layoutSize(oracle)); + node.free(); + oracle.free(); + } +}); + +test("markDirty refreshes captured measurement data", () => { + for (const Implementation of [Yoga, OracleYoga]) { + const node = Implementation.Node.create(); + let width = 10; + let calls = 0; + node.setMeasureFunc(() => { + calls += 1; + return { width, height: 3 }; + }); + node.calculateLayout(undefined, undefined); + const firstCalls = calls; + width = 20; + node.calculateLayout(undefined, undefined); + assert.deepEqual(layoutSize(node), [10, 3]); + assert.equal(calls, firstCalls); + node.markDirty(); + node.calculateLayout(undefined, undefined); + assert.deepEqual(layoutSize(node), [20, 3]); + assert.ok(calls > firstCalls); + node.free(); + } +}); + +test("constant measurement matches Yoga while callback traces remain deliberately different", () => { + const root = Yoga.Node.create(); + const child = Yoga.Node.create(); + const oracleRoot = OracleYoga.Node.create(); + const oracleChild = OracleYoga.Node.create(); + const calls: CallbackCall[] = []; + const oracleCalls: CallbackCall[] = []; + + root.setWidth(100); + root.setHeight(40); + root.setFlexDirection(FlexDirection.Row); + oracleRoot.setWidth(100); + oracleRoot.setHeight(40); + oracleRoot.setFlexDirection(OracleFlexDirection.Row); + child.setMeasureFunc((...args) => { + calls.push(args); + return { width: 30, height: 10 }; + }); + oracleChild.setMeasureFunc((...args) => { + oracleCalls.push(args); + return { width: 30, height: 10 }; + }); + root.insertChild(child, 0); + oracleRoot.insertChild(oracleChild, 0); + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + + assert.deepEqual(layoutSize(child), [30, 40]); + assert.deepEqual(layoutSize(child), layoutSize(oracleChild)); + assert.deepEqual(calls, [ + [Number.NaN, MeasureMode.Undefined, 40, MeasureMode.Exactly], + [0, MeasureMode.AtMost, 40, MeasureMode.Exactly], + [30, MeasureMode.Exactly, 40, MeasureMode.AtMost], + [30, MeasureMode.AtMost, 40, MeasureMode.AtMost], + ]); + assert.deepEqual(oracleCalls, [[100, OracleMeasureMode.AtMost, 40, OracleMeasureMode.Exactly]]); + + root.freeRecursive(); + oracleRoot.freeRecursive(); +}); + +test("MeasureMode-sensitive and flex-basis-sensitive layouts pin the documented Differences", () => { + const modeSensitive: MeasureFunction = (width, widthMode) => ({ + width: widthMode === MeasureMode.Undefined ? 80 : widthMode === MeasureMode.AtMost ? 20 : width, + height: 10, + }); + const oracleModeSensitive = (width: number, widthMode: OracleMeasureMode) => ({ + width: + widthMode === OracleMeasureMode.Undefined + ? 80 + : widthMode === OracleMeasureMode.AtMost + ? 20 + : width, + height: 10, + }); + + const root = Yoga.Node.create(); + const child = Yoga.Node.create(); + const oracleRoot = OracleYoga.Node.create(); + const oracleChild = OracleYoga.Node.create(); + root.setWidth(100); + root.setHeight(40); + root.setFlexDirection(FlexDirection.Row); + oracleRoot.setWidth(100); + oracleRoot.setHeight(40); + oracleRoot.setFlexDirection(OracleFlexDirection.Row); + child.setMeasureFunc(modeSensitive); + oracleChild.setMeasureFunc(oracleModeSensitive); + root.insertChild(child, 0); + oracleRoot.insertChild(oracleChild, 0); + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + assert.deepEqual(layoutSize(child), [80, 40]); + assert.deepEqual(layoutSize(oracleChild), [20, 40]); + root.freeRecursive(); + oracleRoot.freeRecursive(); + + const basisRoot = Yoga.Node.create(); + const basisChild = Yoga.Node.create(); + const oracleBasisRoot = OracleYoga.Node.create(); + const oracleBasisChild = OracleYoga.Node.create(); + basisRoot.setWidth(100); + basisRoot.setFlexDirection(FlexDirection.Row); + oracleBasisRoot.setWidth(100); + oracleBasisRoot.setFlexDirection(OracleFlexDirection.Row); + basisChild.setFlexBasis(20); + oracleBasisChild.setFlexBasis(20); + basisChild.setMeasureFunc(() => ({ width: 30, height: 10 })); + oracleBasisChild.setMeasureFunc(() => ({ width: 30, height: 10 })); + basisRoot.insertChild(basisChild, 0); + oracleBasisRoot.insertChild(oracleBasisChild, 0); + basisRoot.calculateLayout(undefined, undefined); + oracleBasisRoot.calculateLayout(undefined, undefined); + assert.deepEqual(layoutSize(basisChild), [30, 10]); + assert.deepEqual(layoutSize(oracleBasisChild), [20, 10]); + basisRoot.freeRecursive(); + oracleBasisRoot.freeRecursive(); +}); + +test("measurement failure preserves public output and thrown identity, then retries atomically", () => { + const node = Yoga.Node.create(); + node.setMeasureFunc(() => ({ width: 10, height: 5 })); + node.calculateLayout(undefined, undefined); + node.markLayoutSeen(); + + const sentinel = { reason: "retry" }; + let shouldThrow = true; + node.setMeasureFunc(() => { + if (shouldThrow) throw sentinel; + return { width: 20, height: 8 }; + }); + assert.deepEqual( + [node.isDirty(), node.hasNewLayout(), ...layoutSize(node)], + [false, false, 10, 5], + ); + assert.equal( + captureThrown(() => node.calculateLayout(undefined, undefined)), + sentinel, + ); + assert.deepEqual( + [node.isDirty(), node.hasNewLayout(), ...layoutSize(node)], + [false, false, 10, 5], + ); + + shouldThrow = false; + node.calculateLayout(undefined, undefined); + assert.deepEqual( + [node.isDirty(), node.hasNewLayout(), ...layoutSize(node)], + [false, true, 20, 8], + ); + + node.setMeasureFunc(() => null as never); + const invalid = captureThrown(() => node.calculateLayout(undefined, undefined)); + assert.equal((invalid as Error).constructor, TypeError); + assert.deepEqual(layoutSize(node), [20, 8]); + node.setMeasureFunc(() => ({ width: 6, height: 4 })); + node.calculateLayout(undefined, undefined); + assert.deepEqual(layoutSize(node), [6, 4]); + node.free(); +}); + +test("measurement failure restores a temporary exact root Style before retry", () => { + const node = Yoga.Node.create(); + node.setMeasureFunc(() => ({ width: 10, height: 5 })); + node.calculateLayout(undefined, undefined); + node.markLayoutSeen(); + + const sentinel = { reason: "exact-root" }; + let shouldThrow = true; + node.setMeasureFunc(() => { + if (shouldThrow) throw sentinel; + return { width: 20, height: 8 }; + }); + assert.equal( + captureThrown(() => node.calculateLayout(40, undefined)), + sentinel, + ); + assert.deepEqual(node.getWidth(), { value: Number.NaN, unit: Yoga.UNIT_AUTO }); + assert.deepEqual( + [node.isDirty(), node.hasNewLayout(), ...layoutSize(node)], + [false, false, 10, 5], + ); + + shouldThrow = false; + node.calculateLayout(40, undefined); + assert.deepEqual(node.getWidth(), { value: Number.NaN, unit: Yoga.UNIT_AUTO }); + assert.deepEqual( + [node.isDirty(), node.hasNewLayout(), ...layoutSize(node)], + [false, true, 40, 8], + ); + node.free(); +}); + +test("a failed Config refresh remains visible to descendant new-layout state on retry", () => { + const config = Yoga.Config.create(); + const root = Yoga.Node.createWithConfig(config); + const child = Yoga.Node.createWithConfig(config); + const sentinel = { source: "candidate" }; + let shouldThrow = false; + child.setMeasureFunc(() => { + if (shouldThrow) throw sentinel; + return { width: 10.1, height: 5.1 }; + }); + root.insertChild(child, 0); + root.calculateLayout(undefined, undefined); + root.markLayoutSeen(); + child.markLayoutSeen(); + config.setPointScaleFactor(2); + shouldThrow = true; + assert.equal( + captureThrown(() => root.calculateLayout(undefined, undefined)), + sentinel, + ); + assert.deepEqual( + [root.hasNewLayout(), child.hasNewLayout(), ...layoutSize(child)], + [false, false, 11, 6], + ); + shouldThrow = false; + root.calculateLayout(undefined, undefined); + assert.deepEqual( + [root.hasNewLayout(), child.hasNewLayout(), ...layoutSize(child)], + [true, true, 10.5, 5.5], + ); + + const oracleConfig = OracleYoga.Config.create(); + const oracleRoot = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleChild = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleSentinel = { source: "oracle" }; + let oracleShouldThrow = false; + oracleChild.setMeasureFunc(() => { + if (oracleShouldThrow) throw oracleSentinel; + return { width: 10.1, height: 5.1 }; + }); + oracleRoot.insertChild(oracleChild, 0); + oracleRoot.calculateLayout(undefined, undefined); + oracleRoot.markLayoutSeen(); + oracleChild.markLayoutSeen(); + oracleConfig.setPointScaleFactor(2); + oracleShouldThrow = true; + assert.equal( + captureThrown(() => oracleRoot.calculateLayout(undefined, undefined)), + oracleSentinel, + ); + assert.deepEqual( + [oracleRoot.hasNewLayout(), oracleChild.hasNewLayout(), ...layoutSize(oracleChild)], + [false, false, 11, 6], + ); + oracleShouldThrow = false; + oracleRoot.calculateLayout(undefined, undefined); + assert.deepEqual( + [oracleRoot.hasNewLayout(), oracleChild.hasNewLayout(), ...layoutSize(oracleChild)], + [true, true, 10.5, 5.5], + ); + + root.freeRecursive(); + oracleRoot.freeRecursive(); + config.free(); + oracleConfig.free(); +}); + +test("callback replacement and removal deliberately invalidate native measurement caches", () => { + const node = Yoga.Node.create(); + const oracle = OracleYoga.Node.create(); + node.setMeasureFunc(() => ({ width: 10, height: 2 })); + oracle.setMeasureFunc(() => ({ width: 10, height: 2 })); + node.calculateLayout(undefined, undefined); + oracle.calculateLayout(undefined, undefined); + node.markLayoutSeen(); + oracle.markLayoutSeen(); + + node.setMeasureFunc(() => ({ width: 20, height: 2 })); + oracle.setMeasureFunc(() => ({ width: 20, height: 2 })); + assert.deepEqual([node.isDirty(), node.hasNewLayout()], [false, false]); + assert.deepEqual([oracle.isDirty(), oracle.hasNewLayout()], [false, false]); + node.calculateLayout(undefined, undefined); + oracle.calculateLayout(undefined, undefined); + assert.deepEqual(layoutSize(node), [20, 2]); + assert.deepEqual(layoutSize(oracle), [10, 2]); + + node.unsetMeasureFunc(); + oracle.unsetMeasureFunc(); + node.calculateLayout(undefined, undefined); + oracle.calculateLayout(undefined, undefined); + assert.deepEqual(layoutSize(node), [0, 0]); + assert.deepEqual(layoutSize(oracle), [10, 2]); + node.free(); + oracle.free(); +}); + +test("same-facade native reentrancy fails before mutation while JavaScript getters remain usable", () => { + const node = Yoga.Node.create(); + let busyError: unknown; + node.setMeasureFunc(() => { + assert.deepEqual(node.getWidth(), { value: Number.NaN, unit: Yoga.UNIT_AUTO }); + busyError = captureThrown(() => node.setWidth(12)); + return { width: 7, height: 3 }; + }); + node.calculateLayout(undefined, undefined); + assert.equal((busyError as { code?: string }).code, "ERR_TAFFY_TREE_BUSY"); + assert.deepEqual(node.getWidth(), { value: Number.NaN, unit: Yoga.UNIT_AUTO }); + assert.deepEqual(layoutSize(node), [7, 3]); + node.free(); +}); + +test("unmeasured calculations stay on the ordinary native path", () => { + const fixture = fileURLToPath(new URL("./fixtures/measure-path-profile.mjs", import.meta.url)); + const result = spawnSync(process.execPath, [fixture], { encoding: "utf8", timeout: 20_000 }); + assert.equal(result.signal, null); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { + afterPlain: { ordinary: 1, measured: 0 }, + afterMeasured: { ordinary: 1, measured: 1 }, + }); +}); From c22645fed258af757fce6155eb832953ab06d2dd Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 15 Aug 2026 18:11:57 +0800 Subject: [PATCH 08/18] Close @taffyjs/yoga compatibility surface --- .agents/docs/README.md | 2 +- .agents/docs/loop-status.md | 23 ++- .agents/docs/taffyjs-yoga-reference.md | 75 +++++----- .agents/docs/technology-stack.md | 2 + packages/taffyjs-yoga/COMPATIBILITY.md | 35 ++++- packages/taffyjs-yoga/README.md | 54 ++++++- ...{facade-nUK7MuU2.js => facade-o8y1e7bl.js} | 26 ++-- packages/taffyjs-yoga/dist/index.d.ts | 73 +-------- packages/taffyjs-yoga/dist/index.js | 2 +- packages/taffyjs-yoga/dist/load.d.ts | 2 +- packages/taffyjs-yoga/dist/load.js | 2 +- ...ypes-BNBR-NNr.d.ts => types-qTisbHPP.d.ts} | 140 +++++++++--------- packages/taffyjs-yoga/src/enums.ts | 4 +- packages/taffyjs-yoga/src/facade.ts | 17 ++- packages/taffyjs-yoga/src/types.ts | 10 +- tests/taffyjs-yoga/computed-output.test.mts | 45 ++++++ tests/taffyjs-yoga/exports.test.mts | 64 ++++++++ tests/taffyjs-yoga/types/public-api.test-d.ts | 24 +++ 18 files changed, 382 insertions(+), 218 deletions(-) rename packages/taffyjs-yoga/dist/{facade-nUK7MuU2.js => facade-o8y1e7bl.js} (99%) rename packages/taffyjs-yoga/dist/{types-BNBR-NNr.d.ts => types-qTisbHPP.d.ts} (67%) diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 19c8a69..82f1de8 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -14,7 +14,7 @@ - [@taffyjs/node decisions](taffyjs-node-decisions.md) — vouched choices for the Node binding's role, API priorities, module format, distribution, and testing strategy. - [@taffyjs/wasm package design](taffyjs-wasm-package.md) — public contract, artifact layout, initialization model, ownership boundary, and verification requirements for the explicit Wasm package. - [@taffyjs/yoga decisions](taffyjs-yoga-decisions.md) — compatibility decisions for replacing yoga-layout through the TaffyJS Yoga layer. -- [@taffyjs/yoga implementation reference](taffyjs-yoga-reference.md) — unstamped research progress, the Yoga public API inventory, and the remaining reference work required before implementation. +- [@taffyjs/yoga implementation reference](taffyjs-yoga-reference.md) — the audited Yoga 3.2.1 public API inventory, implemented compatibility boundary, evidence, and ongoing maintenance constraints. - [@taffyjs/yoga implementation plan](taffyjs-yoga-implementation-plan.md) — the package architecture, repository changes, milestone gates, verification strategy, and draft-PR workflow for implementing the compatibility layer. - [@taffyjs/yoga implementation loop goal](loop-goal.md) — the unattended implementation contract that must receive a whole-file human vouch before the run starts. - [@taffyjs/yoga implementation loop status](loop-status.md) — the unattended run's overwrite-in-place progress and final handover record. diff --git a/.agents/docs/loop-status.md b/.agents/docs/loop-status.md index b0ea547..5bea159 100644 --- a/.agents/docs/loop-status.md +++ b/.agents/docs/loop-status.md @@ -4,7 +4,7 @@ Updated: 2026-08-15 ## State -Milestone 5 is complete, reviewed, and ready in its milestone commit. Milestone 6 is next. +Milestones 1 through 6 are complete and reviewed. The implementation is ready for Yunfei's decision in the existing draft, unmerged pull request. ## Done @@ -36,6 +36,11 @@ Milestone 5 is complete, reviewed, and ready in its milestone commit. Milestone - Added selected-root exact-axis context for measured calculations, zero clamping for exhausted content-box constraints, Yoga measured-text point-grid rounding, native-only cache invalidation when callbacks are replaced or removed, and hidden measurement/output revisions that preserve public dirty state while making failure retry and arbitrary-subtree output commits correct. - Added the ordinary-compute fast path for selected subtrees without measured Nodes, controlled same-facade native tree-busy behavior during callbacks, and durable callback registration, external-data dirtying, result coercion, failure atomicity, temporary root-Style restoration, retry, Config-refresh retry, and path-selection fixtures. - Added exact dual-oracle Difference fixtures and public documentation for callback count/order/mode traces, MeasureMode-sensitive results, measured flex-basis interaction, and Yoga's stale cache after callback replacement or removal. +- Completed the exact Yoga 3.2.1 closure audit: both package entries and all supported exports match the oracle; Config and Node retain the exact eight and 100 method-name sets at runtime and in TypeScript; supported legacy constants retain Yoga's broad writable enum types; and the facade plus both factory objects retain Yoga's writable runtime property descriptors. +- Added Yoga-compatible `Direction.Inherit` owner-direction fallback for `calculateLayout`, explicitly classified positive and negative infinite owner dimensions as Unsupported with atomic rejection, and added direct oracle and failure-state fixtures. +- Completed the public package guide and exhaustive compatibility boundary, including every known Different group and every Unsupported trigger, the Node-only ESM and Yoga 3.2.1 baseline, unchanged-import registry-alias form, both entry examples, and third-party notice linkage. +- Replaced obsolete pre-implementation research status, ownership, package-build, and repository-index language with the implemented architecture and ongoing maintenance boundary. +- Verified the cleaned six-file `dist/`, unchanged root and `/load` workspace-alias imports, exact package preview, declaration entry placement, absence of a runtime Yoga dependency, and the current generated output. ## Evidence and review @@ -55,6 +60,10 @@ Milestone 5 is complete, reviewed, and ready in its milestone commit. Milestone - The Milestone 5 full `vp run check` passes after the final Measure fixes: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 74 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. The committed path-selection profile records one ordinary call and zero measured calls for an unmeasured calculation, then one measured call only after a callback is present. - The fresh Milestone 5 adversarial reviewer found two material issues: a negative Taffy finite content-box remainder was exposed as `AtMost(-5)` instead of Yoga's `AtMost(0)`, and a failed Config refresh consumed native Style staleness before retry, allowing changed descendant geometry to remain `hasNewLayout() === false`. Finite content constraints now clamp to zero, and each selected subtree retains a private output-stale marker until a complete successful projection and state commit. - The same reviewer's single targeted follow-up returned PASS after verifying both axes, positive and exact constraints, the original Config/point-scale retry, partial child-then-ancestor recovery, and clean cache-hit flags. The focused Measure suite passes 10 tests, including the exact 20-by-30 negative-space and 10.5-by-5.5 retry reproductions. +- The Milestone 6 full `vp run ready` passes on Node.js 24.19.0, Linux x64 GNU with glibc 2.41: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 77 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. Node.js 24 satisfies the package's Node.js >=22.18 runtime boundary; CI remains pinned to the minimum Node.js 22.18.0 baseline. +- A final `pnpm pack --dry-run --json` preview contains exactly `package.json`, the three package documents, root and `/load` JavaScript, root and `/load` declarations, one shared JavaScript chunk, and one shared declaration chunk. The cleaned output contains no `yoga-layout` or `yoga-layout-oracle` runtime import. +- The fresh Milestone 6 adversarial reviewer found two material public-surface issues: the facade and factories were frozen and their declarations narrowed writable enum constants into readonly literals, and the public min/max Flex Difference row was narrower than the vouched result group. Runtime and declaration mutability now match the oracle with direct tests, and the public row now covers min or max constraints combined with Flex growth or shrinkage while retaining the exact max-plus-shrink fixture. +- The same reviewer's single targeted follow-up returned PASS for both repairs and confirmed the neighboring `Direction.Inherit` and infinite-owner-dimension behavior. The bounded follow-up passed 23 focused tests and the Yoga declaration check. ## In flight @@ -62,8 +71,18 @@ Milestone 5 is complete, reviewed, and ready in its milestone commit. Milestone ## Next -- Complete Milestone 6: audit the exact Yoga 3.2.1 public surface and classifications, finish package and alias evidence, remove obsolete research TODOs, complete public documentation, and run the release-readiness gates while keeping the PR draft and unmerged. +- Yunfei reviews draft PR [#9](https://github.com/hyfdev/taffyjs/pull/9) and decides whether or when to merge. This run does not mark it ready, merge it, publish packages, or add release automation. +- After a future @taffyjs/yoga publication, run one real registry installation using the `yoga-layout` npm alias. That smoke test cannot be completed against the current private, unpublished package and is outside this implementation run. ## Blocked - None. + +## Human handover + +- Pull request: [hyfdev/taffyjs#9](https://github.com/hyfdev/taffyjs/pull/9), still draft and unmerged. +- Baseline and scope: exactly `yoga-layout@3.2.1`; ESM-only; Node.js >=22.18; TypeScript over the existing public `@taffyjs/node` API; no Rust, Taffy core, `@taffyjs/node` API, Flex, browser, WASM, generator, query, batch, compact-transport, publication, or release-automation expansion. +- Milestone commits: `2c04ce7` package foundation; `fab9a8b` Config and Style; `49d7a87` topology and state; `8e21f38` computed output; `3a8d860` Measure API; the PR-head `Close @taffyjs/yoga compatibility surface` commit contains Milestone 6 and this handover. The vouched run baseline is `3313f0b`. +- Remaining Different groups: named Yoga cache artifacts; corrected dirtied-callback argument and null behavior; min or max constraints combined with Flex growth or shrinkage; overlapping physical/logical main-axis margins; calculated aspect ratio; oversized or ordinary WrapReverse placement; overflowing reversed-axis distribution; zero-cross-size wrapped-line distribution; main-axis auto margins with reversal or justification; Measure callback trace, mode-sensitive result, measured flex-basis, and callback replacement/removal cache behavior. Exact triggers and pinned results are in the package compatibility guide. +- Remaining Unsupported triggers: Display.Contents; PositionType.Static; every non-None Errata; property-invalid Align values; `setIsReferenceBaseline(true)`; positive or negative infinite calculation owner dimensions; and undeclared malformed or Value-like generic-setter coercions. Types omit or narrow these where possible, and dynamic inputs throw before mutation. +- Only post-publication evidence remains: install the published package under the `yoga-layout` registry alias and smoke-test both entries. It does not block this private draft implementation. diff --git a/.agents/docs/taffyjs-yoga-reference.md b/.agents/docs/taffyjs-yoga-reference.md index 99d3587..c2dd42d 100644 --- a/.agents/docs/taffyjs-yoga-reference.md +++ b/.agents/docs/taffyjs-yoga-reference.md @@ -1,17 +1,17 @@ # @taffyjs/yoga Implementation Reference -This is an unstamped working reference for research that must guide a future @taffyjs/yoga implementation. It records evidence, progress, unresolved questions, and derived implementation constraints; only judgments explicitly accepted by Yunfei belong in [the decision ledger](taffyjs-yoga-decisions.md). An inventory entry means that Yoga exposes a capability, not that TaffyJS has decided to support it. +This is the implementation reference for the current draft @taffyjs/yoga package. It records the audited Yoga 3.2.1 surface, evidence, derived constraints, and the implemented compatibility boundary; only judgments explicitly accepted by Yunfei belong in [the decision ledger](taffyjs-yoga-decisions.md). The public summary lives in the package's [compatibility guide](../../packages/taffyjs-yoga/COMPATIBILITY.md). ## Progress -| Priority | Reference area | Status | Completion condition | -| -------- | -------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| P0 | Exact Yoga baseline and public API inventory | In progress | The initial latest-release policy and current yoga-layout 3.2.1 baseline are vouched, the raw inventory is complete, and every capability now has a compatible, different, or unsupported classification. Convert the bounded research probes for intentional wrapper behavior into durable implementation fixtures. | -| P0 | Node, Config, and TaffyTree ownership | In progress | The one-TaffyTree-per-facade ownership model is vouched. Add durable lifecycle, facade-isolation, stable-identity, and no-migration fixtures, and finalize the operation-level lifetime mapping. | -| P0 | Authoritative state and observable transitions | In progress | The Yoga declaration, derived native Style, topology, calculation, Config, and public-flag ownership boundaries are vouched, and bounded probes cover computed output plus measure-callback failure. Convert those probes into fixtures, finish pre-commit failure and post-commit callback-error semantics, and constrain any permitted non-authoritative caches. | -| P1 | Compatibility classification and translation rules | In progress | The grouped first-pass classifications and shared translation rules now cover values, defaults, edges, direction, flex, root constraints, rounding, measurement, layout getters, and errors. Review the material public boundaries and attach fixture identifiers during implementation planning. | -| P1 | Verification and release gate | Planned | The fixture classes, repository integration, milestone checks, and no-unclassified-capability gate are defined in [the implementation plan](taffyjs-yoga-implementation-plan.md). Implement them and attach durable fixture identifiers to the compatibility rows. | -| P2 | Performance evolution boundary | In progress | Semantics permit later cache, batch, selective-query, or compact-transport changes, and the mixed measured/unmeasured leaf callback overhead is identified. Define representative end-to-end workloads before selecting optimizations or thresholds. | +| Priority | Reference area | Status | Result | +| -------- | -------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P0 | Exact Yoga baseline and public API inventory | Complete | The exact 3.2.1 entries, exports, factories, eight Config methods, 100 Node methods, enum members, constants, public value shapes, and wrapper behaviors are inventoried and classified as Compatible, Different, or Unsupported. | +| P0 | Node, Config, and TaffyTree ownership | Complete | One hidden TaffyTree owns topology per facade; retained Yoga declarations and Config state, canonical Node identity, lifecycle, facade isolation, and no-migration behavior have durable fixtures. | +| P0 | Authoritative state and observable transitions | Complete | Taffy is the only topology and geometry authority; declaration, dirty, new-layout, calculation, output projection, callback failure, partial native failure, and retry boundaries have durable fixtures. | +| P1 | Compatibility classification and translation rules | Complete | Values, defaults, edges, direction, flex, selected-root constraints, rounding, measurement, computed output, and input rejection are implemented and the material Differences and Unsupported triggers are public. | +| P1 | Verification and release gate | Complete | Repository consumer, declaration, differential-oracle, failure-injection, package-boundary, cleaned-output, and full repository gates cover the implementation plan. | +| P2 | Performance evolution boundary | Deferred | Later cache, batch, selective-query, or compact-transport work remains semantically possible. Representative end-to-end workloads and retained measurements are required before choosing an optimization; this is not part of Yoga compatibility closure. | The P0 ownership design must reject a normal create, insert, remove, detach, or free path that implicitly reconstructs or migrates an entire subtree or all unrelated roots. Operations whose meaning requires traversal, such as freeRecursive, are exempt. Style and topology changes must not implicitly compute layout; calculateLayout remains the explicit calculation boundary. @@ -79,7 +79,7 @@ A runtime reflection check against the published 3.2.1 package found the same ei The unchanged-import mechanism is a standard package-manager alias. A published consumer can retain `import Yoga from "yoga-layout"` and `import { loadYoga } from "yoga-layout/load"` while declaring `"yoga-layout": "npm:@taffyjs/yoga@"`. [npm package aliases](https://docs.npmjs.com/cli/using-npm/package-spec#aliases) install one registry package under another local dependency name, and [pnpm aliases](https://pnpm.io/aliases) document the same no-source-change replacement behavior. -The repository consumer fixture can exercise the local package before publication with `"yoga-layout": "workspace:@taffyjs/yoga@*"`; [pnpm documents this exact workspace-alias form](https://pnpm.io/workspaces#referencing-workspace-packages-through-aliases) and rewrites it to a normal npm alias when a package is packed. The official Yoga oracle must therefore use a second test-only alias such as `"yoga-layout-oracle": "npm:yoga-layout@3.2.1"`. Implementation still has to prove both root and `/load` subpath resolution through the built workspace package and reserve an actual registry-alias install for post-publication smoke testing. The repository does not add a tarball-install test path for this package. +The repository consumer fixture exercises the local package before publication with `"yoga-layout": "workspace:@taffyjs/yoga@*"`; [pnpm documents this exact workspace-alias form](https://pnpm.io/workspaces#referencing-workspace-packages-through-aliases) and rewrites it to a normal npm alias when a package is packed. The official Yoga oracle therefore uses the separate test-only alias `"yoga-layout-oracle": "npm:yoga-layout@3.2.1"`. Committed fixtures prove unchanged root and `/load` imports through the built workspace package. An actual registry-alias installation remains a post-publication smoke test; the repository does not add a tarball-install test path for this package. ## Factory and callback inventory @@ -229,13 +229,13 @@ No first-release feasibility blocker remains in the inventoried Yoga 3.2.1 surfa - Package entries, factories, ordinary Config state, tree ownership and mutation, declaration getters and setters, calculation on any live Node, logical dirty and new-layout flags, ordinary measure callbacks, Yoga-shaped computed output, and per-Node point-grid rounding are Compatible through bounded JavaScript or TypeScript mapping. - Yoga cache artifacts, MeasureMode-sensitive or flex-basis-sensitive measurement results, aspect-ratio layout, min/max combined with flex growth or shrinkage, main-axis auto margins combined with justification offsets, and the documented reversed-axis, overflowing, or zero-size distribution cases are Different. The same API remains callable and returns Taffy's stable geometry under the documented trigger. -- Display.Contents, PositionType.Static, non-None Errata values, reference-baseline selection, property-invalid Align values, and undeclared broken setter coercions are Unsupported. Omit a wholly unsupported member where practical and always reject it at runtime. +- Display.Contents, PositionType.Static, non-None Errata values, reference-baseline selection, property-invalid Align values, positive or negative infinite calculation owner dimensions, and undeclared broken setter coercions are Unsupported. Omit a wholly unsupported member where practical and always reject it at runtime. ### Package entries, factories, and public value shapes | Capability | Classification | Basis and translation | | ------------------------------------------------------------------------------------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Root entry default Yoga object, named enums, type-only exports, and legacy constants | Compatible | TypeScript and JavaScript can reproduce the Yoga 3.2.1 export shapes. Unsupported enum members are narrowed separately rather than changing the rest of an enum. The initial baseline is exactly 3.2.1; only the package-manager substitution instructions remain to verify. | +| Root entry default Yoga object, named enums, type-only exports, and legacy constants | Compatible | TypeScript and JavaScript reproduce the Yoga 3.2.1 export categories and every supported value. Unsupported enum members are narrowed separately rather than changing the rest of an enum. Workspace-alias fixtures cover unchanged local substitution; an actual registry alias remains possible only after publication. | | @taffyjs/yoga/load and loadYoga(), mirroring yoga-layout/load | Compatible | Each call creates an isolated facade with its own hidden TaffyTree, default Config state, object brand, and Node registry. Yoga 3.2.1 rejects Node, Config, and factory-destroy arguments from another loaded assembly; the JavaScript layer rejects corresponding cross-facade operations before they reach @taffyjs/node. | | Layout, Size, and Value structural objects | Compatible | They are finite ordinary JavaScript records. Value requires Yoga's Unit values and NaN payload behavior, not the @taffyjs/node tagged-value representation. | | Yoga.Config.create() | Compatible | A JavaScript Config retains Yoga defaults and private revisions used when translating Config-sensitive style. Nodes hold the Config state strongly, and calculation-time traversal observes revisions without requiring a reverse registry of every Node using that Config. | @@ -278,23 +278,24 @@ The attached-subtree probe used an already calculated 200-point ancestor contain ### Node lifecycle, topology, calculation, state, and callbacks -| APIs or capability | Classification | Mapping and boundary | -| ----------------------------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| getChildCount(), getChild(), getParent(), insertChild(), and removeChild() | Compatible | The facade tree is authoritative for topology. Enforce same-facade, live-handle, one-parent, acyclic-tree, valid-index, and measured-node invariants before mutation; then call Taffy and update any JavaScript cache only after success. Return a canonical wrapper, return null for an out-of-range getChild despite Yoga's declaration, leave removeChild(non-child) as a no-op, and reset a successfully detached child's Yoga-shaped output to zero positions and NaN dimensions while preserving its Style and logical dirty flag. | -| copyStyle() | Compatible | Copy the source node's raw Yoga Style declarations, retranslate once under the destination Config, compare before mutation, and dirty only when the declarations differ. Config, topology, callbacks, measure state, reference-baseline and containing-block flags, and computed layout are not copied. | -| reset() | Compatible | Reject a node with a parent or children before mutation, retain its same NodeId and internal Config state, and restore construction defaults using that Config's current values. Clear Style declarations, callbacks, measurement state, reference and containing-block flags, computed output, and public state flags. Allow callbacks to be installed again after reset rather than reproducing Yoga 3.2.1's lost native context bug. | -| free(), Node factory destroy(), and post-free access | Compatible | free and factory destroy use one path. Detach and invalidate exactly that Node, preserve direct children as independent live roots with their subtrees and last public output, and dirty any surviving parent so its next calculation sees the removal. Every alias resolves to one canonical wrapper, and post-free or repeated-free calls fail deterministically; Yoga 3.2.1's stale Embind aliases and missing parent invalidation are not compatibility targets. | -| freeRecursive() | Compatible | Traverse and invalidate the selected node and every descendant in postorder. If the selected node was attached, detach it and dirty only the surviving outside parent chain. O(subtree) traversal is inherent in the method; ordinary free remains proportional to the node's direct relationships and never rebuilds another subtree. | -| calculateLayout() input shape and explicit calculation boundary | Compatible | Normalize omitted, undefined, or auto width and height to indefinite space, default owner direction to LTR, translate dirty Yoga declarations, invoke one Taffy calculation rooted at the selected live node even when it still has a parent, and then build Yoga-shaped outputs. Finite owner dimensions require the bounded root-constraint translation below rather than being passed only as Taffy available space. Native recomputation and cache-derived result differences are classified separately. | -| isDirty() | Compatible | JavaScript owns the Yoga-visible logical dirty flag. Ordinary setters preserve Yoga transitions: writing the identical raw declaration does nothing, while any raw declaration change dirties the node and ancestors even when a higher-precedence edge leaves the translated Taffy Style unchanged. Native setStyle or markDirty still ensures Taffy recomputes when required, but internally propagated native dirty state is allowed to be a superset and is not exposed directly. | -| markDirty() | Compatible | Call Taffy markDirty after enforcing Yoga's restriction that only a measured leaf may be manually dirtied. | -| hasNewLayout() and markLayoutSeen() | Compatible | Retain the flag in JavaScript. Initial true, mark-seen false, calculations mark the selected root as new even on a clean cache hit, dirty descendant calculations mark the recomputed subtree, and an attached-subtree calculation need not alter an outside ancestor's flag. Differential fixtures are an implementation requirement, not an unresolved capability classification. | -| setMeasureFunc(), unsetMeasureFunc(), and setMeasureFunc(null) | Compatible | Store a per-node callback and dispatch through Taffy's one calculation callback. Reject measured nodes with children and children added to measured nodes; null unsets the function. Yoga's four-argument callback shape, content-box constraints, invalid-result normalization, thrown-value identity, and retry after failure are feasible. Taffy's constraint provenance and measurement phases differ, so callback traces and MeasureMode-sensitive or flexBasis-dependent results use the separate Different row below. | -| setDirtiedFunc(function), unsetDirtiedFunc(), and clean-to-dirty notification | Compatible | JavaScript topology and pre-mutation dirty snapshots can invoke registered callbacks for clean-to-dirty transitions and pass the canonical Node declared by Yoga's public type. Exact callback count and order remain outside the promise. | -| setDirtiedFunc(null) and Yoga's zero-argument callback trace | Different | Treat null as unset and pass the declared Node to a real callback. Yoga 3.2.1 instead installs null and later throws a TypeError on a dirty transition, and invokes real callbacks with zero arguments. Those wrapper artifacts are not useful core behavior and are explicitly covered by the accepted callback-trace exception. | -| isReferenceBaseline() and setIsReferenceBaseline(false) | Compatible | Retain and report the only credible default state, false. | -| setIsReferenceBaseline(true) | Unsupported | The current binding cannot select Yoga's reference child during baseline calculation, and reproducing that algorithm in JavaScript is outside the boundary. Reject true instead of retaining a flag that native layout ignores. | -| setAlwaysFormsContainingBlock(false or true) | Compatible | Yoga consults this flag only when the node itself is PositionType.Static. Static is already unsupported, while Relative and Absolute nodes form containing blocks regardless of the flag. Retaining either boolean is therefore behaviorally inert but accurate for every supported node; a regression fixture must guard this dependency between the two classifications. | +| APIs or capability | Classification | Mapping and boundary | +| ----------------------------------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| getChildCount(), getChild(), getParent(), insertChild(), and removeChild() | Compatible | The facade tree is authoritative for topology. Enforce same-facade, live-handle, one-parent, acyclic-tree, valid-index, and measured-node invariants before mutation; then call Taffy and update any JavaScript cache only after success. Return a canonical wrapper, return null for an out-of-range getChild despite Yoga's declaration, leave removeChild(non-child) as a no-op, and reset a successfully detached child's Yoga-shaped output to zero positions and NaN dimensions while preserving its Style and logical dirty flag. | +| copyStyle() | Compatible | Copy the source node's raw Yoga Style declarations, retranslate once under the destination Config, compare before mutation, and dirty only when the declarations differ. Config, topology, callbacks, measure state, reference-baseline and containing-block flags, and computed layout are not copied. | +| reset() | Compatible | Reject a node with a parent or children before mutation, retain its same NodeId and internal Config state, and restore construction defaults using that Config's current values. Clear Style declarations, callbacks, measurement state, reference and containing-block flags, computed output, and public state flags. Allow callbacks to be installed again after reset rather than reproducing Yoga 3.2.1's lost native context bug. | +| free(), Node factory destroy(), and post-free access | Compatible | free and factory destroy use one path. Detach and invalidate exactly that Node, preserve direct children as independent live roots with their subtrees and last public output, and dirty any surviving parent so its next calculation sees the removal. Every alias resolves to one canonical wrapper, and post-free or repeated-free calls fail deterministically; Yoga 3.2.1's stale Embind aliases and missing parent invalidation are not compatibility targets. | +| freeRecursive() | Compatible | Traverse and invalidate the selected node and every descendant in postorder. If the selected node was attached, detach it and dirty only the surviving outside parent chain. O(subtree) traversal is inherent in the method; ordinary free remains proportional to the node's direct relationships and never rebuilds another subtree. | +| calculateLayout() input shape and explicit calculation boundary | Compatible | Normalize omitted, undefined, auto, or NaN width and height to indefinite space; normalize an Inherit or omitted owner direction to LTR; translate dirty Yoga declarations; invoke one Taffy calculation rooted at the selected live node even when it still has a parent; and then build Yoga-shaped outputs. Finite owner dimensions require the bounded root-constraint translation below rather than being passed only as Taffy available space. Native recomputation and cache-derived result differences are classified separately. | +| Positive or negative infinite calculateLayout owner dimensions | Unsupported | Reject before mutation. Yoga 3.2.1 accepts these declared-number values but produces NaN width for positive infinity and zero width for negative infinity on an empty root; these are not credible available-space semantics to preserve. | +| isDirty() | Compatible | JavaScript owns the Yoga-visible logical dirty flag. Ordinary setters preserve Yoga transitions: writing the identical raw declaration does nothing, while any raw declaration change dirties the node and ancestors even when a higher-precedence edge leaves the translated Taffy Style unchanged. Native setStyle or markDirty still ensures Taffy recomputes when required, but internally propagated native dirty state is allowed to be a superset and is not exposed directly. | +| markDirty() | Compatible | Call Taffy markDirty after enforcing Yoga's restriction that only a measured leaf may be manually dirtied. | +| hasNewLayout() and markLayoutSeen() | Compatible | Retain the flag in JavaScript. Initial true, mark-seen false, calculations mark the selected root as new even on a clean cache hit, dirty descendant calculations mark the recomputed subtree, and an attached-subtree calculation need not alter an outside ancestor's flag. Differential fixtures cover these transitions. | +| setMeasureFunc(), unsetMeasureFunc(), and setMeasureFunc(null) | Compatible | Store a per-node callback and dispatch through Taffy's one calculation callback. Reject measured nodes with children and children added to measured nodes; null unsets the function. Yoga's four-argument callback shape, content-box constraints, invalid-result normalization, thrown-value identity, and retry after failure are feasible. Taffy's constraint provenance and measurement phases differ, so callback traces and MeasureMode-sensitive or flexBasis-dependent results use the separate Different row below. | +| setDirtiedFunc(function), unsetDirtiedFunc(), and clean-to-dirty notification | Compatible | JavaScript topology and pre-mutation dirty snapshots can invoke registered callbacks for clean-to-dirty transitions and pass the canonical Node declared by Yoga's public type. Exact callback count and order remain outside the promise. | +| setDirtiedFunc(null) and Yoga's zero-argument callback trace | Different | Treat null as unset and pass the declared Node to a real callback. Yoga 3.2.1 instead installs null and later throws a TypeError on a dirty transition, and invokes real callbacks with zero arguments. Those wrapper artifacts are not useful core behavior and are explicitly covered by the accepted callback-trace exception. | +| isReferenceBaseline() and setIsReferenceBaseline(false) | Compatible | Retain and report the only credible default state, false. | +| setIsReferenceBaseline(true) | Unsupported | The current binding cannot select Yoga's reference child during baseline calculation, and reproducing that algorithm in JavaScript is outside the boundary. Reject true instead of retaining a flag that native layout ignores. | +| setAlwaysFormsContainingBlock(false or true) | Compatible | Yoga consults this flag only when the node itself is PositionType.Static. Static is already unsupported, while Relative and Absolute nodes form containing blocks regardless of the flag. Retaining either boolean is therefore behaviorally inert but accurate for every supported node; a regression fixture must guard this dependency between the two classifications. | ### Calculation context for the selected node @@ -386,7 +387,7 @@ The decided model is one private facade runtime with one TaffyTree. Every live Y | Internal Config state | Yoga options, current values, and translation/cache revisions. Nodes retain it strongly, so an early public Config.free cannot leave a dangling reference. The state becomes unreachable only after the public handle and every Node using it are gone. | | Optional implementation-only cache | A parent pointer or last translation revision may avoid repeated native queries. Taffy topology remains authoritative, and such a cache is updated only after the corresponding native mutation succeeds; it cannot independently authorize topology. | -The high-level ownership and operation mapping are fixed implementation references. Durable fixtures must still prove pre-commit failure atomicity, post-commit callback-error behavior, and the stated transitions: +The high-level ownership and operation mapping are fixed implementation references. Durable fixtures cover pre-commit failure atomicity, post-commit callback-error behavior, and the stated transitions: | Operation | Mapping | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -402,9 +403,9 @@ The high-level ownership and operation mapping are fixed implementation referenc Several Yoga 3.2.1 implementation artifacts are intentionally excluded by the accepted core-compatibility principle. getChild and getParent create additional Embind proxy objects rather than preserving JavaScript identity; freeing through one proxy can leave another proxy addressing freed memory; freeing an attached Node removes it from the parent without marking that parent dirty; freeing Config before its Nodes leaves dangling native pointers; and reset loses the callback context needed to install a later measure function. The facade instead returns one canonical wrapper, invalidates it reliably, preserves safe retained Config state, dirties surviving topology, and permits callbacks after reset. -This model does not duplicate layout. JavaScript retains only public compatibility inputs, handles, flags, callbacks, and projected outputs; Taffy remains the only topology authority and geometry calculator. Normal create, attach, detach, reset, and single-node free never reconstruct or migrate a subtree. The remaining P0 validation is pre-commit failure atomicity plus post-commit callback-error semantics, the poisoned-facade policy for unexpected partial multi-step native failure, exact computed-output transitions, facade/runtime garbage-collection policy, same-tree reentrancy during measure callbacks, and evidence that any optional parent cache cannot diverge from Taffy topology. +This model does not duplicate layout. JavaScript retains only public compatibility inputs, handles, flags, callbacks, and projected outputs; Taffy remains the only topology authority and geometry calculator. Normal create, attach, detach, reset, and single-node free never reconstruct or migrate a subtree. Fixtures cover pre-commit failure atomicity, post-commit callback errors, poisoning after an unexpected partial multi-step native failure, exact computed-output transitions, facade isolation, same-tree reentrancy during measure callbacks, and native-authoritative topology. -### Decided Yoga declaration-state model and open storage strategy +### Decided Yoga declaration-state model The Yoga-facing state is the normalized declaration produced by a Yoga setter, not the caller's original JavaScript token and not the final values used by layout. For example, setWidth("50%") and setWidthPercent(50) produce the same Yoga declaration, while an unset field, an explicit auto value, and a resolved numeric default may remain distinct even when they currently lay out the same way. @@ -455,10 +456,8 @@ For supported APIs, @taffyjs/yoga preserves Yoga 3.2.1's published declaration s - calculateLayout requires width and height arguments in TypeScript while allowing each to be explicitly undefined; the runtime wrapper also permits the arguments to be omitted and supplies NaN, NaN, and LTR defaults. - The generic setter wrapper attempts to accept Value-like objects and comments that a getter result can be passed back to a setter, but the published Value is a plain object without a numeric valueOf implementation. A published 3.2.1 getWidth-to-setWidth round trip resets the value to Unit.Undefined rather than preserving it. -## Inventory checks still open +## Ongoing maintenance -- Keep the capability matrix exhaustive as Yoga evolves; no public release may contain an unclassified method, value, or result trigger. -- Add committed regression probes for the declaration/runtime mismatches so later Yoga or toolchain changes cannot silently alter the reference evidence. -- Check whether any intentional public behavior exists in the generated WASM binding beyond wrapAssembly and the declarations; ignore accidental Embind properties. -- Add workspace-alias fixtures proving unchanged root and `/load` imports and verify every built entry and shared chunk through the cleaned package output directory; the freshness command must make untracked new chunks visible before diffing. Reserve the actual registry-alias installation for post-publication smoke testing and do not add a tarball-install test path. -- Add durable lifecycle, facade-isolation, stable-identity, pre-commit failure, post-commit callback-error, and no-migration fixtures for the decided one-TaffyTree-per-facade ownership model, then finalize the operation-level lifetime mapping. +- Re-audit the complete capability matrix before deliberately adopting a Yoga version newer than 3.2.1; no supported release may contain an unclassified method, value, or result trigger. +- Run one actual `yoga-layout` registry-alias installation smoke test after @taffyjs/yoga is published. This cannot be completed against the current private, unpublished package and does not justify a separate tarball-install harness. +- Treat cache, batch, selective-query, compact-transport, and mixed measured-tree overhead as later performance work that requires a representative consumer workload and retained end-to-end measurements. diff --git a/.agents/docs/technology-stack.md b/.agents/docs/technology-stack.md index e175a0c..04edab3 100644 --- a/.agents/docs/technology-stack.md +++ b/.agents/docs/technology-stack.md @@ -24,6 +24,8 @@ The `@taffyjs/wasm` Vite+ pack configuration compiles that same entry twice. A f The root Vite+ task graph builds the napi-rs artifacts into `packages/taffyjs-wasm/.napi-build`, runs `vp pack` with `dist` cleanup enabled for the two public entries, then runs `tools/taffy-wasm/generate-inline-wasm-runtime-files.ts`. That typed, package-specific generator validates and base64-encodes the staged release binary, copies the deferred loader unchanged, generates the small host adapters, and transforms the freshly generated eager Node loader only at asserted input and WASI-construction sites. Because only final runtime files are written after `vp pack` cleans `dist`, the build has no separate package-finalization or deletion phase. `dist` is ignored build output: CI, package inspection, and packed-consumer tests build it before use rather than committing compiler output to Git. +For `packages/taffyjs-yoga`, `vp pack` builds the root and `/load` entries plus shared chunks and declarations over the public `@taffyjs/node` dependency. Its build cleans the complete output directory first, and CI freshness checks include untracked chunks before comparing the committed artifacts. + `tools/api-codegen` owns source generation that must keep Rust and TypeScript API facts aligned. Its first maintained input is `api/numeric-families.json`; `vp run codegen` updates both language outputs. CI runs `vp run check:codegen`, which regenerates and rejects any resulting Git diff. CI has five jobs. Ubuntu x64 and Windows x64 each build the native addon and run all Rust, JavaScript, and type tests with Node.js 22.18.0, then smoke-test the public `@taffyjs/node` entry with Bun 1.2.0 and Deno 2.2.0; Ubuntu also rejects stale committed package JavaScript and declarations after the build. A separate Ubuntu WASIP job installs the Rust target and Playwright Chromium, builds `@taffyjs/wasm`, reruns the complete Node public API suite against it, runs type, package-content, packed-consumer, bundled-consumer, and browser-runtime checks against the generated package, and applies the same Bun 1.2.0 and Deno 2.2.0 smoke checks to the public entry. Alternate-runtime smoke checks perform one fixed layout rather than copying the complete behavior suite. A Node-only job checks formatting, JavaScript and repository TypeScript including maintained tools through Vite+'s type-aware lint path, and generated-source drift. A Rust-only job checks formatting and Clippy. macOS and publication workflows are not configured. diff --git a/packages/taffyjs-yoga/COMPATIBILITY.md b/packages/taffyjs-yoga/COMPATIBILITY.md index dc7355d..4355c95 100644 --- a/packages/taffyjs-yoga/COMPATIBILITY.md +++ b/packages/taffyjs-yoga/COMPATIBILITY.md @@ -26,6 +26,37 @@ Measurement failure does not commit partial Yoga-shaped output or public dirty/n An unmeasured selected subtree uses the ordinary native calculation entry and does not enter the JavaScript measurement bridge. Once a selected subtree contains a measured Node, Taffy controls callback count and order and may also request intrinsic sizes for unmeasured leaves. JavaScript-only declaration and computed-output getters remain usable during a callback; a same-facade method that needs native tree access can throw `ERR_TAFFY_TREE_BUSY`. Exact reentrant behavior is not a compatibility promise. +## API classification + +The Yoga 3.2.1 runtime and declarations expose eight Config methods and 100 Node methods. `@taffyjs/yoga` implements every method name. Unless a trigger appears in the Unsupported table or the Known Differences table below, the capability is classified as Compatible. + +| Public area | Compatible surface | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Package entries and exports | The default Yoga facade at the root entry; `loadYoga()` at `/load`; all 18 named enum objects; the supported legacy constants; and Yoga's Config, DirtiedFunction, MeasureFunction, Node, and Yoga type-export placement. Unsupported enum members and their legacy constants are deliberately omitted. | +| Factories | `Yoga.Config.create/destroy` and `Yoga.Node.create/createDefault/createWithConfig/destroy`, including retained Config state and deterministic same-facade lifetime validation. | +| Config | All eight method names: `free`, `isExperimentalFeatureEnabled`, `setExperimentalFeatureEnabled`, `setPointScaleFactor`, `getErrata`, `setErrata`, `useWebDefaults`, and `setUseWebDefaults`. Only `Errata.None` is accepted by `setErrata`. | +| Node lifecycle and topology | `free`, `freeRecursive`, `copyStyle`, `getChild`, `getChildCount`, `getParent`, `insertChild`, `removeChild`, and `reset`. Canonical Node identity, one-parent and acyclic topology, detached subtrees, arbitrary roots, and cross-facade rejection are enforced. | +| Node state and callbacks | `isDirty`, `markDirty`, `hasNewLayout`, `markLayoutSeen`, `isReferenceBaseline`, `setIsReferenceBaseline`, `setAlwaysFormsContainingBlock`, `setMeasureFunc`, `unsetMeasureFunc`, `setDirtiedFunc`, and `unsetDirtiedFunc`. Measured Nodes remain leaves, and `markDirty` is restricted to measured leaves as in Yoga. The explicitly corrected dirtied-callback behavior and measurement trace differences are listed below. | +| Style declarations | All 26 Style getters and all 43 Style setters. Getters preserve Yoga declarations, defaults, fresh Value records, units, NaN payloads, and the Yoga 3.2.1 `getGap()` declaration/runtime mismatch. Setters support the declared numeric, percent, auto, edge, gutter, direction, display, overflow, box-sizing, alignment, flex, size, position, margin, padding, border, gap, and aspect-ratio inputs except the explicit Unsupported values below. `setGap` and `setGapPercent` retain Yoga's declared Value return type while returning `undefined` at runtime. | +| Calculation and output | `calculateLayout` plus all ten computed-output getters. Omitted runtime dimensions, arbitrary selected Nodes, an Inherit owner-direction fallback to LTR, direction inheritance, exact owner constraints, display-none output, logical edges, relative and absolute positions, and per-Node point-scale rounding are supported within the numeric boundary above. | +| Public values | Yoga-shaped Layout, Size, and Value records, supported numeric enum values and reverse lookups, and the default facade's corresponding uppercase constants. | + +API compatibility targets the useful public contract. It deliberately does not reproduce Embind proxy identity, unsafe post-free aliases, dangling Config pointers, exact error text, internal cache/recomputation choices, or callback count and order. These are not separate unclassified capabilities; a material observable exception is listed explicitly below. + +## Unsupported + +Unsupported enum members and constants are absent from the TypeScript and runtime surface. If JavaScript supplies their raw numeric values dynamically, the receiving method throws before changing Node or Config state. The same pre-mutation rule applies to the other rejected inputs in this table. + +| Capability or input | Public behavior | Reason | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `Display.Contents` and `DISPLAY_CONTENTS` | Omitted; `setDisplay(2)` throws. | Taffy 0.13 has no contents box. Credible support would require a second or repeatedly rewritten topology. | +| `PositionType.Static` and `POSITION_TYPE_STATIC` | Omitted; `setPositionType(0)` throws. | Static containing-block behavior has no direct Taffy representation and is not emulated in JavaScript. | +| `Errata.StretchFlexBasis`, `AbsolutePositionWithoutInsetsExcludesPadding`, `AbsolutePercentAgainstInnerSize`, `All`, and `Classic`, plus their legacy constants | Omitted; `setErrata()` accepts only `Errata.None` and throws for any dynamic nonzero value. | These values select Yoga-specific algorithm branches for which Taffy has no matching switch. | +| `Align` outside the property where it is meaningful | TypeScript narrows each setter and runtime validation throws. `setAlignContent` rejects Auto and Baseline; `setAlignItems` rejects Auto and the three distribution values; `setAlignSelf` rejects the three distribution values. | Yoga's incidental fallthrough for invalid shared-enum values is layout-dependent and is not a credible style contract. | +| `setIsReferenceBaseline(true)` | The declaration accepts only `false`; dynamic `true` throws. | The current native boundary cannot select Yoga's reference child during baseline calculation. | +| Positive or negative infinity as a `calculateLayout` owner dimension | Throws before calculation or public-state mutation. Finite numbers, `"auto"`, `undefined`, omitted values, and NaN remain supported. | Yoga's resulting NaN or zero geometry is not a credible available-space contract. | +| Undeclared generic-setter coercions | Malformed strings, arbitrary objects, and Value-like getter results supplied to setters throw atomically. | Yoga's broad wrapper coercion is outside its published types and its own getter-to-setter path is broken in 3.2.1. | + ## Known Differences These rows are deliberate, version-specific differences with regression fixtures: @@ -34,7 +65,7 @@ These rows are deliberate, version-specific differences with regression fixtures | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | Change `useWebDefaults` on a live Config, then calculate a tree whose unresolved shrink values are already cached | Applies the current Config-sensitive Style when the selected calculation reaches Taffy. In the fixture, two 80-point children in a 100-point row become 50/50. | The Config setter does not advance Yoga's cache version, so the same calculation retains 80/80 even though `getFlexShrink()` reports 1. | | `setDirtiedFunc()` callback argument and `setDirtiedFunc(null)` | Calls a real callback with its canonical Node after state commit; null unsets the callback. | Calls a real callback with zero arguments; null is retained and a later clean-to-dirty transition throws a wrapper TypeError. | -| A max constraint combined with Flex shrink | Keeps Taffy's Flex result. A width-120, max-width-90, shrink-1 child in a width-60 Row resolves to 60. | The same child resolves to 67.5. | +| A min or max constraint combined with Flex growth or shrinkage | Keeps Taffy's Flex result. The pinned width-120, max-width-90, shrink-1 child in a width-60 Row resolves to 60. | The pinned child resolves to 67.5; other combinations in this result group can likewise follow Yoga's different min/max ordering. | | Overlapping physical and logical main-axis margins under a child direction that differs from its parent | Resolves the declarations to one physical Taffy margin rect. A width-20 RTL child with `margin-left: 4` and `margin-start: 10%` in a width-100 LTR Row remains width 20. | Computed margins are also left 4 and right 10, but Yoga's separate Flex margin resolution reduces the child width to 16. | | Calculated `aspectRatio` | Keeps Taffy's CSS-oriented aspect-ratio and stretch result. A width-40, ratio-2 child stretched by a 100-by-100 Row is 40-by-100. | The same child is 40-by-20. | | `WebFlexBasis` disabled while a percentage basis is recalculated under a changed containing size | Recomputes the percentage basis. A 50% basis changes from 50 to 100 when its Row changes from width 100 to 200. | Reuses the stale 50-point basis while the experiment is disabled; enabling the experiment produces 100 in both implementations. | @@ -48,5 +79,3 @@ These rows are deliberate, version-specific differences with regression fixtures | A Measure callback whose result changes with `MeasureMode` | Keeps Taffy's multi-stage result. In the pinned 100-by-40 Row, a callback returning width 80 for `Undefined` and 20 for `AtMost` produces width 80. | Calls the callback with `AtMost(100)` and produces width 20. | | A measured child with an explicit 20-point `flexBasis` | Keeps Taffy's measured size. A constant 30-by-10 callback produces a 30-by-10 child in a width-100 Row. | Uses the explicit basis for the main size and produces a 20-by-10 child. | | Replace or remove a Measure callback on an otherwise clean Node | Privately invalidates Taffy's measurement cache. Replacing a cached width-10 callback with width 20 produces 20 on the next calculation; removing it then produces zero. | Leaves the Node publicly clean and reuses the cached width 10 after both operations. | - -The exhaustive Compatible, Different, and Unsupported API matrix will be finalized before this draft package is release-ready. diff --git a/packages/taffyjs-yoga/README.md b/packages/taffyjs-yoga/README.md index 81cdb69..3b86724 100644 --- a/packages/taffyjs-yoga/README.md +++ b/packages/taffyjs-yoga/README.md @@ -1,9 +1,55 @@ # @taffyjs/yoga -`@taffyjs/yoga` is an ESM-only, Node.js `>=22.18` compatibility facade for `yoga-layout@3.2.1`, implemented in TypeScript over `@taffyjs/node`. +`@taffyjs/yoga` is a Node.js compatibility facade for `yoga-layout@3.2.1`, implemented in TypeScript over `@taffyjs/node`. It keeps Yoga's package entries and public API shape while Taffy remains the only layout engine. -The package is under active development and is not published. Its root and `/load` entries are exercised through the repository's package-boundary consumer tests. +The package is ESM-only, requires Node.js `>=22.18`, and does not provide a browser or WASM backend. Native platform availability follows `@taffyjs/node`; this repository currently builds Linux x64 GNU and Windows x64 MSVC packages. -The current implementation includes Yoga-shaped Config, Style, topology, calculation, computed-output, and synchronous Measure APIs. Taffy remains the only layout engine; the compatibility layer maps Yoga declarations and callback constraints and documents the cases where Taffy's result intentionally differs. +This package is still private and unpublished. The installation form below describes the intended registry substitution after publication. -See [COMPATIBILITY.md](COMPATIBILITY.md) for the current compatibility boundary. +## Substitute it for yoga-layout + +Use a package-manager alias so application source can keep importing `yoga-layout` unchanged: + +```json +{ + "dependencies": { + "yoga-layout": "npm:@taffyjs/yoga@" + } +} +``` + +The normal Yoga entry then keeps the same import and factory shape: + +```ts +import Yoga from "yoga-layout"; + +const root = Yoga.Node.create(); +const child = Yoga.Node.create(); + +root.setWidth(100); +root.setHeight(100); +root.insertChild(child, 0); +root.calculateLayout(100, 100, Yoga.DIRECTION_LTR); + +console.log(child.getComputedLayout()); +root.freeRecursive(); +``` + +The asynchronous entry is also preserved: + +```ts +import { loadYoga } from "yoga-layout/load"; + +const Yoga = await loadYoga(); +const node = Yoga.Node.create(); +node.calculateLayout(undefined, undefined); +node.free(); +``` + +Each `loadYoga()` call creates an isolated Yoga facade with its own hidden Taffy tree. The root default export is one eagerly created facade. + +## Compatibility boundary + +The compatibility baseline is exactly Yoga 3.2.1. Config, Style, tree ownership and mutation, calculation on any live Node, computed output, dirty and new-layout state, and synchronous Measure callbacks are implemented. Taffy owns topology and Flex calculation; the facade retains only the Yoga declarations and public state needed to translate inputs and project Yoga-shaped outputs. + +This is core behavioral compatibility, not a claim that Taffy and Yoga produce identical geometry for every degenerate Flex combination or that their internal cache and callback traces are identical. [COMPATIBILITY.md](COMPATIBILITY.md) classifies the complete public surface and lists every known Difference and Unsupported trigger. [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) contains the required third-party notice for the Yoga PixelGrid logic used by the facade. diff --git a/packages/taffyjs-yoga/dist/facade-nUK7MuU2.js b/packages/taffyjs-yoga/dist/facade-o8y1e7bl.js similarity index 99% rename from packages/taffyjs-yoga/dist/facade-nUK7MuU2.js rename to packages/taffyjs-yoga/dist/facade-o8y1e7bl.js index 269dfcc..3343b10 100644 --- a/packages/taffyjs-yoga/dist/facade-nUK7MuU2.js +++ b/packages/taffyjs-yoga/dist/facade-o8y1e7bl.js @@ -119,7 +119,7 @@ let Wrap = /* @__PURE__ */ function(Wrap) { Wrap[Wrap["WrapReverse"] = 2] = "WrapReverse"; return Wrap; }({}); -const legacyConstants = Object.freeze({ +const legacyConstants = { ALIGN_AUTO: 0, ALIGN_FLEX_START: 1, ALIGN_CENTER: 2, @@ -185,7 +185,7 @@ const legacyConstants = Object.freeze({ WRAP_NO_WRAP: 0, WRAP_WRAP: 1, WRAP_WRAP_REVERSE: 2 -}); +}; //#endregion //#region src/values.ts const percentPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?%$/i; @@ -1729,7 +1729,12 @@ var YogaNode = class { } calculateLayout(width, height, direction = 1) { const record = requireNode(void 0, this); - const ownerDirection = requireEnum(direction, "calculateLayout direction", [1, 2]); + const requestedDirection = requireEnum(direction, "calculateLayout direction", [ + 0, + 1, + 2 + ]); + const ownerDirection = requestedDirection === 0 ? 1 : requestedDirection; const availableWidth = requireCalculationDimension(width, "width"); const availableHeight = requireCalculationDimension(height, "height"); const subtree = collectSubtree(record); @@ -1912,24 +1917,23 @@ function freeRecursiveNode(record) { } function createFactories(runtime) { return { - Config: Object.freeze({ + Config: { create: () => new YogaConfig(runtime, new ConfigState()), destroy: (config) => freeConfig(requireConfig(runtime, config)) - }), - Node: Object.freeze({ + }, + Node: { create: (config) => new YogaNode(runtime, config === void 0 ? runtime.defaultConfig : requireConfig(runtime, config).state), createDefault: () => new YogaNode(runtime, runtime.defaultConfig), createWithConfig: (config) => new YogaNode(runtime, requireConfig(runtime, config).state), destroy: (node) => freeNode(requireNode(runtime, node)) - }) + } }; } function createYoga() { - const runtime = new FacadeRuntime(); - return Object.freeze({ - ...createFactories(runtime), + return { + ...createFactories(new FacadeRuntime()), ...legacyConstants - }); + }; } //#endregion export { PositionType as _, Direction$1 as a, Errata as c, Gutter as d, Justify as f, Overflow$1 as g, NodeType as h, Dimension$1 as i, ExperimentalFeature as l, MeasureMode as m, Align as n, Display$1 as o, LogLevel as p, BoxSizing$1 as r, Edge as s, createYoga as t, FlexDirection$1 as u, Unit as v, Wrap as y }; diff --git a/packages/taffyjs-yoga/dist/index.d.ts b/packages/taffyjs-yoga/dist/index.d.ts index ac4871d..aabf516 100644 --- a/packages/taffyjs-yoga/dist/index.d.ts +++ b/packages/taffyjs-yoga/dist/index.d.ts @@ -1,74 +1,5 @@ -import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, n as ConfigFactory, o as NodeFactory, p as Edge, r as DirtiedFunction, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-BNBR-NNr.js"; +import { C as Wrap, S as Unit, _ as LogLevel, a as Yoga$1, b as Overflow, c as Dimension, d as Edge, f as Errata, g as Justify, h as Gutter, i as Node, l as Direction, m as FlexDirection, n as DirtiedFunction, o as Align, p as ExperimentalFeature, r as MeasureFunction, s as BoxSizing, t as Config, u as Display, v as MeasureMode, x as PositionType, y as NodeType } from "./types-qTisbHPP.js"; //#region src/index.d.ts -declare const Yoga: Readonly<{ - Config: ConfigFactory; - Node: NodeFactory; -} & Readonly<{ - readonly ALIGN_AUTO: Align.Auto; - readonly ALIGN_FLEX_START: Align.FlexStart; - readonly ALIGN_CENTER: Align.Center; - readonly ALIGN_FLEX_END: Align.FlexEnd; - readonly ALIGN_STRETCH: Align.Stretch; - readonly ALIGN_BASELINE: Align.Baseline; - readonly ALIGN_SPACE_BETWEEN: Align.SpaceBetween; - readonly ALIGN_SPACE_AROUND: Align.SpaceAround; - readonly ALIGN_SPACE_EVENLY: Align.SpaceEvenly; - readonly BOX_SIZING_BORDER_BOX: BoxSizing.BorderBox; - readonly BOX_SIZING_CONTENT_BOX: BoxSizing.ContentBox; - readonly DIMENSION_WIDTH: Dimension.Width; - readonly DIMENSION_HEIGHT: Dimension.Height; - readonly DIRECTION_INHERIT: Direction.Inherit; - readonly DIRECTION_LTR: Direction.LTR; - readonly DIRECTION_RTL: Direction.RTL; - readonly DISPLAY_FLEX: Display.Flex; - readonly DISPLAY_NONE: Display.None; - readonly EDGE_LEFT: Edge.Left; - readonly EDGE_TOP: Edge.Top; - readonly EDGE_RIGHT: Edge.Right; - readonly EDGE_BOTTOM: Edge.Bottom; - readonly EDGE_START: Edge.Start; - readonly EDGE_END: Edge.End; - readonly EDGE_HORIZONTAL: Edge.Horizontal; - readonly EDGE_VERTICAL: Edge.Vertical; - readonly EDGE_ALL: Edge.All; - readonly ERRATA_NONE: Errata; - readonly EXPERIMENTAL_FEATURE_WEB_FLEX_BASIS: ExperimentalFeature; - readonly FLEX_DIRECTION_COLUMN: FlexDirection.Column; - readonly FLEX_DIRECTION_COLUMN_REVERSE: FlexDirection.ColumnReverse; - readonly FLEX_DIRECTION_ROW: FlexDirection.Row; - readonly FLEX_DIRECTION_ROW_REVERSE: FlexDirection.RowReverse; - readonly GUTTER_COLUMN: Gutter.Column; - readonly GUTTER_ROW: Gutter.Row; - readonly GUTTER_ALL: Gutter.All; - readonly JUSTIFY_FLEX_START: Justify.FlexStart; - readonly JUSTIFY_CENTER: Justify.Center; - readonly JUSTIFY_FLEX_END: Justify.FlexEnd; - readonly JUSTIFY_SPACE_BETWEEN: Justify.SpaceBetween; - readonly JUSTIFY_SPACE_AROUND: Justify.SpaceAround; - readonly JUSTIFY_SPACE_EVENLY: Justify.SpaceEvenly; - readonly LOG_LEVEL_ERROR: LogLevel.Error; - readonly LOG_LEVEL_WARN: LogLevel.Warn; - readonly LOG_LEVEL_INFO: LogLevel.Info; - readonly LOG_LEVEL_DEBUG: LogLevel.Debug; - readonly LOG_LEVEL_VERBOSE: LogLevel.Verbose; - readonly LOG_LEVEL_FATAL: LogLevel.Fatal; - readonly MEASURE_MODE_UNDEFINED: MeasureMode.Undefined; - readonly MEASURE_MODE_EXACTLY: MeasureMode.Exactly; - readonly MEASURE_MODE_AT_MOST: MeasureMode.AtMost; - readonly NODE_TYPE_DEFAULT: NodeType.Default; - readonly NODE_TYPE_TEXT: NodeType.Text; - readonly OVERFLOW_VISIBLE: Overflow.Visible; - readonly OVERFLOW_HIDDEN: Overflow.Hidden; - readonly OVERFLOW_SCROLL: Overflow.Scroll; - readonly POSITION_TYPE_RELATIVE: PositionType.Relative; - readonly POSITION_TYPE_ABSOLUTE: PositionType.Absolute; - readonly UNIT_UNDEFINED: Unit.Undefined; - readonly UNIT_POINT: Unit.Point; - readonly UNIT_PERCENT: Unit.Percent; - readonly UNIT_AUTO: Unit.Auto; - readonly WRAP_NO_WRAP: Wrap.NoWrap; - readonly WRAP_WRAP: Wrap.Wrap; - readonly WRAP_WRAP_REVERSE: Wrap.WrapReverse; -}>>; +declare const Yoga: Yoga$1; //#endregion export { Align, BoxSizing, type Config, Dimension, Direction, type DirtiedFunction, Display, Edge, Errata, ExperimentalFeature, FlexDirection, Gutter, Justify, LogLevel, type MeasureFunction, MeasureMode, type Node, NodeType, Overflow, PositionType, Unit, Wrap, Yoga as default }; \ No newline at end of file diff --git a/packages/taffyjs-yoga/dist/index.js b/packages/taffyjs-yoga/dist/index.js index 0f176a5..4bc8186 100644 --- a/packages/taffyjs-yoga/dist/index.js +++ b/packages/taffyjs-yoga/dist/index.js @@ -1,4 +1,4 @@ -import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-nUK7MuU2.js"; +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-o8y1e7bl.js"; //#region src/index.ts const Yoga = createYoga(); //#endregion diff --git a/packages/taffyjs-yoga/dist/load.d.ts b/packages/taffyjs-yoga/dist/load.d.ts index 2b6fe00..3e6f6c8 100644 --- a/packages/taffyjs-yoga/dist/load.d.ts +++ b/packages/taffyjs-yoga/dist/load.d.ts @@ -1,4 +1,4 @@ -import { C as PositionType, S as Overflow, T as Wrap, _ as Gutter, a as Node, b as MeasureMode, c as Align, d as Direction, f as Display, g as FlexDirection, h as ExperimentalFeature, i as MeasureFunction, l as BoxSizing, m as Errata, p as Edge, r as DirtiedFunction, s as Yoga, t as Config, u as Dimension, v as Justify, w as Unit, x as NodeType, y as LogLevel } from "./types-BNBR-NNr.js"; +import { C as Wrap, S as Unit, _ as LogLevel, a as Yoga, b as Overflow, c as Dimension, d as Edge, f as Errata, g as Justify, h as Gutter, i as Node, l as Direction, m as FlexDirection, n as DirtiedFunction, o as Align, p as ExperimentalFeature, r as MeasureFunction, s as BoxSizing, t as Config, u as Display, v as MeasureMode, x as PositionType, y as NodeType } from "./types-qTisbHPP.js"; //#region src/load.d.ts declare function loadYoga(): Promise; //#endregion diff --git a/packages/taffyjs-yoga/dist/load.js b/packages/taffyjs-yoga/dist/load.js index 1d7ac55..6b2209a 100644 --- a/packages/taffyjs-yoga/dist/load.js +++ b/packages/taffyjs-yoga/dist/load.js @@ -1,4 +1,4 @@ -import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-nUK7MuU2.js"; +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-o8y1e7bl.js"; //#region src/load.ts async function loadYoga() { return createYoga(); diff --git a/packages/taffyjs-yoga/dist/types-BNBR-NNr.d.ts b/packages/taffyjs-yoga/dist/types-qTisbHPP.d.ts similarity index 67% rename from packages/taffyjs-yoga/dist/types-BNBR-NNr.d.ts rename to packages/taffyjs-yoga/dist/types-qTisbHPP.d.ts index f5e1394..9cd0002 100644 --- a/packages/taffyjs-yoga/dist/types-BNBR-NNr.d.ts +++ b/packages/taffyjs-yoga/dist/types-qTisbHPP.d.ts @@ -100,73 +100,73 @@ declare enum Wrap { Wrap = 1, WrapReverse = 2 } -declare const legacyConstants: Readonly<{ - readonly ALIGN_AUTO: Align.Auto; - readonly ALIGN_FLEX_START: Align.FlexStart; - readonly ALIGN_CENTER: Align.Center; - readonly ALIGN_FLEX_END: Align.FlexEnd; - readonly ALIGN_STRETCH: Align.Stretch; - readonly ALIGN_BASELINE: Align.Baseline; - readonly ALIGN_SPACE_BETWEEN: Align.SpaceBetween; - readonly ALIGN_SPACE_AROUND: Align.SpaceAround; - readonly ALIGN_SPACE_EVENLY: Align.SpaceEvenly; - readonly BOX_SIZING_BORDER_BOX: BoxSizing.BorderBox; - readonly BOX_SIZING_CONTENT_BOX: BoxSizing.ContentBox; - readonly DIMENSION_WIDTH: Dimension.Width; - readonly DIMENSION_HEIGHT: Dimension.Height; - readonly DIRECTION_INHERIT: Direction.Inherit; - readonly DIRECTION_LTR: Direction.LTR; - readonly DIRECTION_RTL: Direction.RTL; - readonly DISPLAY_FLEX: Display.Flex; - readonly DISPLAY_NONE: Display.None; - readonly EDGE_LEFT: Edge.Left; - readonly EDGE_TOP: Edge.Top; - readonly EDGE_RIGHT: Edge.Right; - readonly EDGE_BOTTOM: Edge.Bottom; - readonly EDGE_START: Edge.Start; - readonly EDGE_END: Edge.End; - readonly EDGE_HORIZONTAL: Edge.Horizontal; - readonly EDGE_VERTICAL: Edge.Vertical; - readonly EDGE_ALL: Edge.All; - readonly ERRATA_NONE: Errata; - readonly EXPERIMENTAL_FEATURE_WEB_FLEX_BASIS: ExperimentalFeature; - readonly FLEX_DIRECTION_COLUMN: FlexDirection.Column; - readonly FLEX_DIRECTION_COLUMN_REVERSE: FlexDirection.ColumnReverse; - readonly FLEX_DIRECTION_ROW: FlexDirection.Row; - readonly FLEX_DIRECTION_ROW_REVERSE: FlexDirection.RowReverse; - readonly GUTTER_COLUMN: Gutter.Column; - readonly GUTTER_ROW: Gutter.Row; - readonly GUTTER_ALL: Gutter.All; - readonly JUSTIFY_FLEX_START: Justify.FlexStart; - readonly JUSTIFY_CENTER: Justify.Center; - readonly JUSTIFY_FLEX_END: Justify.FlexEnd; - readonly JUSTIFY_SPACE_BETWEEN: Justify.SpaceBetween; - readonly JUSTIFY_SPACE_AROUND: Justify.SpaceAround; - readonly JUSTIFY_SPACE_EVENLY: Justify.SpaceEvenly; - readonly LOG_LEVEL_ERROR: LogLevel.Error; - readonly LOG_LEVEL_WARN: LogLevel.Warn; - readonly LOG_LEVEL_INFO: LogLevel.Info; - readonly LOG_LEVEL_DEBUG: LogLevel.Debug; - readonly LOG_LEVEL_VERBOSE: LogLevel.Verbose; - readonly LOG_LEVEL_FATAL: LogLevel.Fatal; - readonly MEASURE_MODE_UNDEFINED: MeasureMode.Undefined; - readonly MEASURE_MODE_EXACTLY: MeasureMode.Exactly; - readonly MEASURE_MODE_AT_MOST: MeasureMode.AtMost; - readonly NODE_TYPE_DEFAULT: NodeType.Default; - readonly NODE_TYPE_TEXT: NodeType.Text; - readonly OVERFLOW_VISIBLE: Overflow.Visible; - readonly OVERFLOW_HIDDEN: Overflow.Hidden; - readonly OVERFLOW_SCROLL: Overflow.Scroll; - readonly POSITION_TYPE_RELATIVE: PositionType.Relative; - readonly POSITION_TYPE_ABSOLUTE: PositionType.Absolute; - readonly UNIT_UNDEFINED: Unit.Undefined; - readonly UNIT_POINT: Unit.Point; - readonly UNIT_PERCENT: Unit.Percent; - readonly UNIT_AUTO: Unit.Auto; - readonly WRAP_NO_WRAP: Wrap.NoWrap; - readonly WRAP_WRAP: Wrap.Wrap; - readonly WRAP_WRAP_REVERSE: Wrap.WrapReverse; -}>; +declare const legacyConstants: { + ALIGN_AUTO: Align; + ALIGN_FLEX_START: Align; + ALIGN_CENTER: Align; + ALIGN_FLEX_END: Align; + ALIGN_STRETCH: Align; + ALIGN_BASELINE: Align; + ALIGN_SPACE_BETWEEN: Align; + ALIGN_SPACE_AROUND: Align; + ALIGN_SPACE_EVENLY: Align; + BOX_SIZING_BORDER_BOX: BoxSizing; + BOX_SIZING_CONTENT_BOX: BoxSizing; + DIMENSION_WIDTH: Dimension; + DIMENSION_HEIGHT: Dimension; + DIRECTION_INHERIT: Direction; + DIRECTION_LTR: Direction; + DIRECTION_RTL: Direction; + DISPLAY_FLEX: Display; + DISPLAY_NONE: Display; + EDGE_LEFT: Edge; + EDGE_TOP: Edge; + EDGE_RIGHT: Edge; + EDGE_BOTTOM: Edge; + EDGE_START: Edge; + EDGE_END: Edge; + EDGE_HORIZONTAL: Edge; + EDGE_VERTICAL: Edge; + EDGE_ALL: Edge; + ERRATA_NONE: Errata; + EXPERIMENTAL_FEATURE_WEB_FLEX_BASIS: ExperimentalFeature; + FLEX_DIRECTION_COLUMN: FlexDirection; + FLEX_DIRECTION_COLUMN_REVERSE: FlexDirection; + FLEX_DIRECTION_ROW: FlexDirection; + FLEX_DIRECTION_ROW_REVERSE: FlexDirection; + GUTTER_COLUMN: Gutter; + GUTTER_ROW: Gutter; + GUTTER_ALL: Gutter; + JUSTIFY_FLEX_START: Justify; + JUSTIFY_CENTER: Justify; + JUSTIFY_FLEX_END: Justify; + JUSTIFY_SPACE_BETWEEN: Justify; + JUSTIFY_SPACE_AROUND: Justify; + JUSTIFY_SPACE_EVENLY: Justify; + LOG_LEVEL_ERROR: LogLevel; + LOG_LEVEL_WARN: LogLevel; + LOG_LEVEL_INFO: LogLevel; + LOG_LEVEL_DEBUG: LogLevel; + LOG_LEVEL_VERBOSE: LogLevel; + LOG_LEVEL_FATAL: LogLevel; + MEASURE_MODE_UNDEFINED: MeasureMode; + MEASURE_MODE_EXACTLY: MeasureMode; + MEASURE_MODE_AT_MOST: MeasureMode; + NODE_TYPE_DEFAULT: NodeType; + NODE_TYPE_TEXT: NodeType; + OVERFLOW_VISIBLE: Overflow; + OVERFLOW_HIDDEN: Overflow; + OVERFLOW_SCROLL: Overflow; + POSITION_TYPE_RELATIVE: PositionType; + POSITION_TYPE_ABSOLUTE: PositionType; + UNIT_UNDEFINED: Unit; + UNIT_POINT: Unit; + UNIT_PERCENT: Unit; + UNIT_AUTO: Unit; + WRAP_NO_WRAP: Wrap; + WRAP_WRAP: Wrap; + WRAP_WRAP_REVERSE: Wrap; +}; //#endregion //#region src/types.d.ts interface Layout { @@ -313,9 +313,9 @@ interface NodeFactory { createWithConfig(config: Config): Node; destroy(node: Node): void; } -type Yoga = Readonly<{ +type Yoga = { Config: ConfigFactory; Node: NodeFactory; -} & typeof legacyConstants>; +} & typeof legacyConstants; //#endregion -export { PositionType as C, Overflow as S, Wrap as T, Gutter as _, Node as a, MeasureMode as b, Align as c, Direction as d, Display as f, FlexDirection as g, ExperimentalFeature as h, MeasureFunction as i, BoxSizing as l, Errata as m, ConfigFactory as n, NodeFactory as o, Edge as p, DirtiedFunction as r, Yoga as s, Config as t, Dimension as u, Justify as v, Unit as w, NodeType as x, LogLevel as y }; \ No newline at end of file +export { Wrap as C, Unit as S, LogLevel as _, Yoga as a, Overflow as b, Dimension as c, Edge as d, Errata as f, Justify as g, Gutter as h, Node as i, Direction as l, FlexDirection as m, DirtiedFunction as n, Align as o, ExperimentalFeature as p, MeasureFunction as r, BoxSizing as s, Config as t, Display as u, MeasureMode as v, PositionType as x, NodeType as y }; \ No newline at end of file diff --git a/packages/taffyjs-yoga/src/enums.ts b/packages/taffyjs-yoga/src/enums.ts index c5bb580..d3836eb 100644 --- a/packages/taffyjs-yoga/src/enums.ts +++ b/packages/taffyjs-yoga/src/enums.ts @@ -117,7 +117,7 @@ export enum Wrap { WrapReverse = 2, } -export const legacyConstants = Object.freeze({ +export const legacyConstants = { ALIGN_AUTO: Align.Auto, ALIGN_FLEX_START: Align.FlexStart, ALIGN_CENTER: Align.Center, @@ -183,4 +183,4 @@ export const legacyConstants = Object.freeze({ WRAP_NO_WRAP: Wrap.NoWrap, WRAP_WRAP: Wrap.Wrap, WRAP_WRAP_REVERSE: Wrap.WrapReverse, -} as const); +}; diff --git a/packages/taffyjs-yoga/src/facade.ts b/packages/taffyjs-yoga/src/facade.ts index 2f21c3e..712d74b 100644 --- a/packages/taffyjs-yoga/src/facade.ts +++ b/packages/taffyjs-yoga/src/facade.ts @@ -1161,10 +1161,13 @@ class YogaNode implements Node { direction: Direction = Direction.LTR, ): void { const record = requireNode(undefined, this); - const ownerDirection = requireEnum(direction, "calculateLayout direction", [ + const requestedDirection = requireEnum(direction, "calculateLayout direction", [ + Direction.Inherit, Direction.LTR, Direction.RTL, - ]) as Direction.LTR | Direction.RTL; + ]); + const ownerDirection = + requestedDirection === Direction.Inherit ? Direction.LTR : requestedDirection; const availableWidth = requireCalculationDimension(width, "width"); const availableHeight = requireCalculationDimension(height, "height"); const subtree = collectSubtree(record); @@ -1403,11 +1406,11 @@ function createFactories(runtime: FacadeRuntime): { Config: ConfigFactory; Node: NodeFactory; } { - const Config: ConfigFactory = Object.freeze({ + const Config: ConfigFactory = { create: () => new YogaConfig(runtime, new ConfigState()), destroy: (config: Config) => freeConfig(requireConfig(runtime, config)), - }); - const Node: NodeFactory = Object.freeze({ + }; + const Node: NodeFactory = { create: (config?: Config) => new YogaNode( runtime, @@ -1417,11 +1420,11 @@ function createFactories(runtime: FacadeRuntime): { createWithConfig: (config: Config) => new YogaNode(runtime, requireConfig(runtime, config).state), destroy: (node: Node) => freeNode(requireNode(runtime, node)), - }); + }; return { Config, Node }; } export function createYoga(): Yoga { const runtime = new FacadeRuntime(); - return Object.freeze({ ...createFactories(runtime), ...legacyConstants }); + return { ...createFactories(runtime), ...legacyConstants }; } diff --git a/packages/taffyjs-yoga/src/types.ts b/packages/taffyjs-yoga/src/types.ts index 1ee831a..66fa488 100644 --- a/packages/taffyjs-yoga/src/types.ts +++ b/packages/taffyjs-yoga/src/types.ts @@ -191,9 +191,7 @@ export interface NodeFactory { destroy(node: Node): void; } -export type Yoga = Readonly< - { - Config: ConfigFactory; - Node: NodeFactory; - } & typeof legacyConstants ->; +export type Yoga = { + Config: ConfigFactory; + Node: NodeFactory; +} & typeof legacyConstants; diff --git a/tests/taffyjs-yoga/computed-output.test.mts b/tests/taffyjs-yoga/computed-output.test.mts index b5d5cd1..30488c4 100644 --- a/tests/taffyjs-yoga/computed-output.test.mts +++ b/tests/taffyjs-yoga/computed-output.test.mts @@ -136,6 +136,51 @@ test("finite owner dimensions become exact selected-root constraints", () => { } }); +test("calculation input preserves Yoga's owner-direction fallback and finite boundary", () => { + for (const direction of [Direction.Inherit, Direction.LTR, Direction.RTL] as const) { + const actualRoot = Yoga.Node.create(); + const actualChild = Yoga.Node.create(); + const expectedRoot = OracleYoga.Node.create(); + const expectedChild = OracleYoga.Node.create(); + try { + actualRoot.setWidth(100); + actualRoot.setHeight(20); + actualRoot.setFlexDirection(FlexDirection.Row); + actualChild.setWidth(10); + actualChild.setMargin(Edge.Start, 5); + actualRoot.insertChild(actualChild, 0); + + expectedRoot.setWidth(100); + expectedRoot.setHeight(20); + expectedRoot.setFlexDirection(OracleFlexDirection.Row); + expectedChild.setWidth(10); + expectedChild.setMargin(OracleEdge.Start, 5); + expectedRoot.insertChild(expectedChild, 0); + + actualRoot.calculateLayout(undefined, undefined, direction); + expectedRoot.calculateLayout(undefined, undefined, direction as unknown as OracleDirection); + assertLayoutEqual(actualRoot, expectedRoot, Direction[direction]); + assertLayoutEqual(actualChild, expectedChild, Direction[direction]); + } finally { + actualRoot.freeRecursive(); + expectedRoot.freeRecursive(); + } + } + + const node = Yoga.Node.create(); + try { + node.setWidth(12); + node.setHeight(7); + node.calculateLayout(undefined, undefined); + const previous = node.getComputedLayout(); + assert.throws(() => node.calculateLayout(Infinity, undefined), /finite number/); + assert.throws(() => node.calculateLayout(undefined, -Infinity), /finite number/); + assert.deepEqual(node.getComputedLayout(), previous); + } finally { + node.free(); + } +}); + test("subtree projection matches Yoga positions across directions and reversed axes", () => { const positionCases = ["left", "right", "both"] as const; for (const direction of [Direction.LTR, Direction.RTL] as const) { diff --git a/tests/taffyjs-yoga/exports.test.mts b/tests/taffyjs-yoga/exports.test.mts index e589f2c..58a1cb6 100644 --- a/tests/taffyjs-yoga/exports.test.mts +++ b/tests/taffyjs-yoga/exports.test.mts @@ -60,6 +60,12 @@ function supportedEnum(name: (typeof enumNames)[number], value: unknown): Record return result; } +function prototypeMethods(value: object): string[] { + return Object.getOwnPropertyNames(Object.getPrototypeOf(value)) + .filter((name) => name !== "constructor") + .sort(); +} + test("root and load entries expose every supported Yoga 3.2.1 enum", () => { assert.deepEqual(Object.keys(implementation).sort(), Object.keys(oracle).sort()); assert.deepEqual(Object.keys(implementationLoad).sort(), Object.keys(oracleLoad).sort()); @@ -94,6 +100,64 @@ test("facade factories preserve Yoga's documented factory methods", () => { ]); }); +test("facade and factory properties retain Yoga's runtime mutability", async () => { + const facade = await implementationLoad.loadYoga(); + const pairs: Array, Record]> = [ + [ + facade as unknown as Record, + OracleYoga as unknown as Record, + ], + [ + facade.Config as unknown as Record, + OracleYoga.Config as unknown as Record, + ], + [ + facade.Node as unknown as Record, + OracleYoga.Node as unknown as Record, + ], + ]; + + for (const [actual, expected] of pairs) { + assert.equal(Object.isFrozen(actual), false); + for (const key of Object.keys(actual)) { + const actualDescriptor = Object.getOwnPropertyDescriptor(actual, key); + const expectedDescriptor = Object.getOwnPropertyDescriptor(expected, key); + assert.notEqual(actualDescriptor, undefined, key); + assert.notEqual(expectedDescriptor, undefined, key); + assert.equal(actualDescriptor?.writable, expectedDescriptor?.writable, `${key} writable`); + assert.equal( + actualDescriptor?.configurable, + expectedDescriptor?.configurable, + `${key} configurable`, + ); + assert.equal( + actualDescriptor?.enumerable, + expectedDescriptor?.enumerable, + `${key} enumerable`, + ); + } + } +}); + +test("Node and Config expose every Yoga 3.2.1 method name", () => { + const config = Yoga.Config.create(); + const node = Yoga.Node.createWithConfig(config); + const oracleConfig = OracleYoga.Config.create(); + const oracleNode = OracleYoga.Node.createWithConfig(oracleConfig); + + try { + assert.deepEqual(prototypeMethods(config), prototypeMethods(oracleConfig)); + assert.deepEqual(prototypeMethods(node), prototypeMethods(oracleNode)); + assert.equal(prototypeMethods(config).length, 8); + assert.equal(prototypeMethods(node).length, 100); + } finally { + node.free(); + config.free(); + oracleNode.free(); + oracleConfig.free(); + } +}); + test("handles do not expose or replace facade-native state", async () => { const facade = await implementationLoad.loadYoga(); const config = facade.Config.create(); diff --git a/tests/taffyjs-yoga/types/public-api.test-d.ts b/tests/taffyjs-yoga/types/public-api.test-d.ts index 158f187..f9b1461 100644 --- a/tests/taffyjs-yoga/types/public-api.test-d.ts +++ b/tests/taffyjs-yoga/types/public-api.test-d.ts @@ -24,6 +24,27 @@ import { type Config as LoadedConfig, type Node as LoadedNode, } from "yoga-layout/load"; +import type { Config as OracleConfig, Node as OracleNode } from "yoga-layout-oracle"; + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false; +type Assert = Value; +type ConfigMethodNamesMatch = Assert>; +type NodeMethodNamesMatch = Assert>; +type LegacyAlignConstantIsBroad = Assert>; + +declare const replacementConfigFactory: typeof Yoga.Config; +declare const replacementConfigCreate: () => Config; +declare const replacementNodeFactory: typeof Yoga.Node; +declare const replacementNodeCreate: (config?: Config) => Node; + +Yoga.ALIGN_AUTO = Align.Center; +Yoga.Config = replacementConfigFactory; +Yoga.Config.create = replacementConfigCreate; +Yoga.Node = replacementNodeFactory; +Yoga.Node.create = replacementNodeCreate; const config: Config = Yoga.Config.create(); const node: Node = Yoga.Node.create(config); @@ -166,4 +187,7 @@ void [ PositionType.Relative, Errata.None, 0 as unknown as RootYoga, + 0 as unknown as ConfigMethodNamesMatch, + 0 as unknown as NodeMethodNamesMatch, + 0 as unknown as LegacyAlignConstantIsBroad, ]; From e10ff75827b930da1e74a0a3c39bd29ec838e7f3 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 15 Aug 2026 23:41:30 +0800 Subject: [PATCH 09/18] Record @taffyjs/yoga rebase --- .agents/docs/loop-status.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.agents/docs/loop-status.md b/.agents/docs/loop-status.md index 5bea159..48f2b55 100644 --- a/.agents/docs/loop-status.md +++ b/.agents/docs/loop-status.md @@ -64,6 +64,7 @@ Milestones 1 through 6 are complete and reviewed. The implementation is ready fo - A final `pnpm pack --dry-run --json` preview contains exactly `package.json`, the three package documents, root and `/load` JavaScript, root and `/load` declarations, one shared JavaScript chunk, and one shared declaration chunk. The cleaned output contains no `yoga-layout` or `yoga-layout-oracle` runtime import. - The fresh Milestone 6 adversarial reviewer found two material public-surface issues: the facade and factories were frozen and their declarations narrowed writable enum constants into readonly literals, and the public min/max Flex Difference row was narrower than the vouched result group. Runtime and declaration mutability now match the oracle with direct tests, and the public row now covers min or max constraints combined with Flex growth or shrinkage while retaining the exact max-plus-shrink fixture. - The same reviewer's single targeted follow-up returned PASS for both repairs and confirmed the neighboring `Direction.Inherit` and infinite-owner-dimension behavior. The bounded follow-up passed 23 focused tests and the Yoga declaration check. +- After the reviewed Milestone 6 commit, the branch was linearly rebased onto `origin/main` at `17a567d`. The only conflict was the project-document index, where the completed Yoga entries and main's expanded codegen description were both retained. On the rebased tree, the complete `vp run ready` gate passes with the same test counts. ## In flight @@ -82,7 +83,7 @@ Milestones 1 through 6 are complete and reviewed. The implementation is ready fo - Pull request: [hyfdev/taffyjs#9](https://github.com/hyfdev/taffyjs/pull/9), still draft and unmerged. - Baseline and scope: exactly `yoga-layout@3.2.1`; ESM-only; Node.js >=22.18; TypeScript over the existing public `@taffyjs/node` API; no Rust, Taffy core, `@taffyjs/node` API, Flex, browser, WASM, generator, query, batch, compact-transport, publication, or release-automation expansion. -- Milestone commits: `2c04ce7` package foundation; `fab9a8b` Config and Style; `49d7a87` topology and state; `8e21f38` computed output; `3a8d860` Measure API; the PR-head `Close @taffyjs/yoga compatibility surface` commit contains Milestone 6 and this handover. The vouched run baseline is `3313f0b`. +- Milestone commits after the rebase: `f646c29` package foundation; `e1861a1` Config and Style; `3f1f557` topology and state; `a65114a` computed output; `7bb18c7` Measure API; `ff6f8a9` compatibility closure and handover. The vouched run baseline is `bd40988`; the branch is based on `origin/main` at `17a567d`. - Remaining Different groups: named Yoga cache artifacts; corrected dirtied-callback argument and null behavior; min or max constraints combined with Flex growth or shrinkage; overlapping physical/logical main-axis margins; calculated aspect ratio; oversized or ordinary WrapReverse placement; overflowing reversed-axis distribution; zero-cross-size wrapped-line distribution; main-axis auto margins with reversal or justification; Measure callback trace, mode-sensitive result, measured flex-basis, and callback replacement/removal cache behavior. Exact triggers and pinned results are in the package compatibility guide. - Remaining Unsupported triggers: Display.Contents; PositionType.Static; every non-None Errata; property-invalid Align values; `setIsReferenceBaseline(true)`; positive or negative infinite calculation owner dimensions; and undeclared malformed or Value-like generic-setter coercions. Types omit or narrow these where possible, and dynamic inputs throw before mutation. - Only post-publication evidence remains: install the published package under the `yoga-layout` registry alias and smoke-test both entries. It does not block this private draft implementation. From 7c19ad797db98bc724a6e02436f9aa3e0d419e26 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 16 Aug 2026 18:18:08 +0800 Subject: [PATCH 10/18] Fix Yoga undefined minimum sizes --- .agents/docs/loop-status.md | 4 +- .agents/docs/taffyjs-yoga-reference.md | 36 ++++++++-------- packages/taffyjs-yoga/COMPATIBILITY.md | 1 - ...{facade-o8y1e7bl.js => facade-acowLNHQ.js} | 7 ++- packages/taffyjs-yoga/dist/index.js | 2 +- packages/taffyjs-yoga/dist/load.js | 2 +- packages/taffyjs-yoga/src/translate.ts | 5 ++- packages/taffyjs-yoga/src/values.ts | 4 ++ tests/taffyjs-yoga/computed-output.test.mts | 43 +++++++++++++++++++ tests/taffyjs-yoga/measurement.test.mts | 6 +-- 10 files changed, 80 insertions(+), 30 deletions(-) rename packages/taffyjs-yoga/dist/{facade-o8y1e7bl.js => facade-acowLNHQ.js} (99%) diff --git a/.agents/docs/loop-status.md b/.agents/docs/loop-status.md index 48f2b55..9b4ad9e 100644 --- a/.agents/docs/loop-status.md +++ b/.agents/docs/loop-status.md @@ -35,7 +35,7 @@ Milestones 1 through 6 are complete and reviewed. The implementation is ready fo - Added one facade-level synchronous Measure bridge that dispatches retained callbacks by NodeId, keeps measured Nodes as leaves, maps known, finite, min-content, and max-content constraints to Yoga modes, preserves content-box values, normalizes callback results, and rethrows callback failures with identity preserved. - Added selected-root exact-axis context for measured calculations, zero clamping for exhausted content-box constraints, Yoga measured-text point-grid rounding, native-only cache invalidation when callbacks are replaced or removed, and hidden measurement/output revisions that preserve public dirty state while making failure retry and arbitrary-subtree output commits correct. - Added the ordinary-compute fast path for selected subtrees without measured Nodes, controlled same-facade native tree-busy behavior during callbacks, and durable callback registration, external-data dirtying, result coercion, failure atomicity, temporary root-Style restoration, retry, Config-refresh retry, and path-selection fixtures. -- Added exact dual-oracle Difference fixtures and public documentation for callback count/order/mode traces, MeasureMode-sensitive results, measured flex-basis interaction, and Yoga's stale cache after callback replacement or removal. +- Added exact dual-oracle Difference fixtures and public documentation for callback count/order/mode traces, MeasureMode-sensitive results, and Yoga's stale cache after callback replacement or removal. - Completed the exact Yoga 3.2.1 closure audit: both package entries and all supported exports match the oracle; Config and Node retain the exact eight and 100 method-name sets at runtime and in TypeScript; supported legacy constants retain Yoga's broad writable enum types; and the facade plus both factory objects retain Yoga's writable runtime property descriptors. - Added Yoga-compatible `Direction.Inherit` owner-direction fallback for `calculateLayout`, explicitly classified positive and negative infinite owner dimensions as Unsupported with atomic rejection, and added direct oracle and failure-state fixtures. - Completed the public package guide and exhaustive compatibility boundary, including every known Different group and every Unsupported trigger, the Node-only ESM and Yoga 3.2.1 baseline, unchanged-import registry-alias form, both entry examples, and third-party notice linkage. @@ -84,6 +84,6 @@ Milestones 1 through 6 are complete and reviewed. The implementation is ready fo - Pull request: [hyfdev/taffyjs#9](https://github.com/hyfdev/taffyjs/pull/9), still draft and unmerged. - Baseline and scope: exactly `yoga-layout@3.2.1`; ESM-only; Node.js >=22.18; TypeScript over the existing public `@taffyjs/node` API; no Rust, Taffy core, `@taffyjs/node` API, Flex, browser, WASM, generator, query, batch, compact-transport, publication, or release-automation expansion. - Milestone commits after the rebase: `f646c29` package foundation; `e1861a1` Config and Style; `3f1f557` topology and state; `a65114a` computed output; `7bb18c7` Measure API; `ff6f8a9` compatibility closure and handover. The vouched run baseline is `bd40988`; the branch is based on `origin/main` at `17a567d`. -- Remaining Different groups: named Yoga cache artifacts; corrected dirtied-callback argument and null behavior; min or max constraints combined with Flex growth or shrinkage; overlapping physical/logical main-axis margins; calculated aspect ratio; oversized or ordinary WrapReverse placement; overflowing reversed-axis distribution; zero-cross-size wrapped-line distribution; main-axis auto margins with reversal or justification; Measure callback trace, mode-sensitive result, measured flex-basis, and callback replacement/removal cache behavior. Exact triggers and pinned results are in the package compatibility guide. +- Remaining Different groups: named Yoga cache artifacts; corrected dirtied-callback argument and null behavior; min or max constraints combined with Flex growth or shrinkage; overlapping physical/logical main-axis margins; calculated aspect ratio; oversized or ordinary WrapReverse placement; overflowing reversed-axis distribution; zero-cross-size wrapped-line distribution; main-axis auto margins with reversal or justification; Measure callback trace, mode-sensitive result, and callback replacement/removal cache behavior. Exact triggers and pinned results are in the package compatibility guide. - Remaining Unsupported triggers: Display.Contents; PositionType.Static; every non-None Errata; property-invalid Align values; `setIsReferenceBaseline(true)`; positive or negative infinite calculation owner dimensions; and undeclared malformed or Value-like generic-setter coercions. Types omit or narrow these where possible, and dynamic inputs throw before mutation. - Only post-publication evidence remains: install the published package under the `yoga-layout` registry alias and smoke-test both entries. It does not block this private draft implementation. diff --git a/.agents/docs/taffyjs-yoga-reference.md b/.agents/docs/taffyjs-yoga-reference.md index c2dd42d..5bfa415 100644 --- a/.agents/docs/taffyjs-yoga-reference.md +++ b/.agents/docs/taffyjs-yoga-reference.md @@ -228,7 +228,7 @@ This matrix answers whether the public capability has a credible implementation No first-release feasibility blocker remains in the inventoried Yoga 3.2.1 surface. The grouped result is: - Package entries, factories, ordinary Config state, tree ownership and mutation, declaration getters and setters, calculation on any live Node, logical dirty and new-layout flags, ordinary measure callbacks, Yoga-shaped computed output, and per-Node point-grid rounding are Compatible through bounded JavaScript or TypeScript mapping. -- Yoga cache artifacts, MeasureMode-sensitive or flex-basis-sensitive measurement results, aspect-ratio layout, min/max combined with flex growth or shrinkage, main-axis auto margins combined with justification offsets, and the documented reversed-axis, overflowing, or zero-size distribution cases are Different. The same API remains callable and returns Taffy's stable geometry under the documented trigger. +- Yoga cache artifacts, MeasureMode-sensitive measurement results, aspect-ratio layout, min/max combined with flex growth or shrinkage, main-axis auto margins combined with justification offsets, and the documented reversed-axis, overflowing, or zero-size distribution cases are Different. The same API remains callable and returns Taffy's stable geometry under the documented trigger. - Display.Contents, PositionType.Static, non-None Errata values, reference-baseline selection, property-invalid Align values, positive or negative infinite calculation owner dimensions, and undeclared broken setter coercions are Unsupported. Omit a wholly unsupported member where practical and always reject it at runtime. ### Package entries, factories, and public value shapes @@ -290,7 +290,7 @@ The attached-subtree probe used an already calculated 200-point ancestor contain | isDirty() | Compatible | JavaScript owns the Yoga-visible logical dirty flag. Ordinary setters preserve Yoga transitions: writing the identical raw declaration does nothing, while any raw declaration change dirties the node and ancestors even when a higher-precedence edge leaves the translated Taffy Style unchanged. Native setStyle or markDirty still ensures Taffy recomputes when required, but internally propagated native dirty state is allowed to be a superset and is not exposed directly. | | markDirty() | Compatible | Call Taffy markDirty after enforcing Yoga's restriction that only a measured leaf may be manually dirtied. | | hasNewLayout() and markLayoutSeen() | Compatible | Retain the flag in JavaScript. Initial true, mark-seen false, calculations mark the selected root as new even on a clean cache hit, dirty descendant calculations mark the recomputed subtree, and an attached-subtree calculation need not alter an outside ancestor's flag. Differential fixtures cover these transitions. | -| setMeasureFunc(), unsetMeasureFunc(), and setMeasureFunc(null) | Compatible | Store a per-node callback and dispatch through Taffy's one calculation callback. Reject measured nodes with children and children added to measured nodes; null unsets the function. Yoga's four-argument callback shape, content-box constraints, invalid-result normalization, thrown-value identity, and retry after failure are feasible. Taffy's constraint provenance and measurement phases differ, so callback traces and MeasureMode-sensitive or flexBasis-dependent results use the separate Different row below. | +| setMeasureFunc(), unsetMeasureFunc(), and setMeasureFunc(null) | Compatible | Store a per-node callback and dispatch through Taffy's one calculation callback. Reject measured nodes with children and children added to measured nodes; null unsets the function. Yoga's four-argument callback shape, content-box constraints, invalid-result normalization, thrown-value identity, and retry after failure are feasible. Taffy's constraint provenance and measurement phases differ, so callback traces and MeasureMode-sensitive results use the separate Different row below. | | setDirtiedFunc(function), unsetDirtiedFunc(), and clean-to-dirty notification | Compatible | JavaScript topology and pre-mutation dirty snapshots can invoke registered callbacks for clean-to-dirty transitions and pass the canonical Node declared by Yoga's public type. Exact callback count and order remain outside the promise. | | setDirtiedFunc(null) and Yoga's zero-argument callback trace | Different | Treat null as unset and pass the declared Node to a real callback. Yoga 3.2.1 instead installs null and later throws a TypeError on a dirty transition, and invokes real callbacks with zero arguments. Those wrapper artifacts are not useful core behavior and are explicitly covered by the accepted callback-trace exception. | | isReferenceBaseline() and setIsReferenceBaseline(false) | Compatible | Retain and report the only credible default state, false. | @@ -321,20 +321,20 @@ The Yoga declarations must be authoritative JavaScript state. Reading back only The TypeScript surface should preserve Yoga 3.2.1's published declarations for every supported capability, even where a return declaration is known to disagree with Yoga's runtime. Correcting those types would make previously compiling consumer source fail during dependency substitution, while reproducing the runtime value is cheap and unambiguous. Unsupported members and values remain the explicit exception: they are narrowed where practical and always rejected at runtime. Inputs outside Yoga's published declarations are not a compatibility surface merely because the generic wrapper happens to coerce them. -| APIs or capability | Classification | Mapping and boundary | -| --------------------------------------------------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| The 25 style getters other than getGap() | Compatible | Read the raw Yoga declaration or Yoga-resolved getter default from JavaScript state. Construct fresh Value records with Yoga Unit values and NaN payloads where the Yoga runtime does so. | -| getGap() TypeScript declaration and runtime result | Compatible | Preserve Yoga's declared Value return for source compatibility while returning the raw number or NaN at runtime exactly as Yoga 3.2.1 does. The declaration/runtime contradiction is pinned reference behavior, not a reason to invent a third shape. | -| setDirection(), setFlexDirection(), setFlexWrap(), setJustifyContent(), setOverflow(), and setBoxSizing() | Compatible | Direct enum translation, with Direction.Inherit resolved before calculation. | -| setDisplay(Flex or None) and setPositionType(Relative or Absolute) | Compatible | Direct translation. Display.Contents and PositionType.Static use their unsupported rows above. | -| Valid-context setAlignContent(), setAlignItems(), and setAlignSelf() values | Compatible | Direct mapping. Broad-enum values invalid for a particular property use the unsupported-input row in the enum matrix rather than being silently normalized. | -| setFlexGrow(), setFlexShrink(), setFlexBasis(), setFlexBasisPercent(), and setFlexBasisAuto() | Compatible | Preserve separate Yoga declarations and derive the effective Taffy fields with Yoga precedence and Config defaults. | -| setFlex() | Compatible | Retain numeric flex and derive grow, shrink, and basis exactly as Yoga does: positive values supply grow and a legacy zero basis or web-default auto basis; negative values supply legacy shrink; explicit longhands win. The declaration mapping is finite; cache-derived stale-layout behavior after a live useWebDefaults change follows the decided difference above. | -| setWidth/Height and Auto/Percent variants; setMin/Max Width/Height and Percent variants | Compatible | Normalize every declared point, percent, auto, undefined, and NaN form into Yoga declaration state, then convert to Taffy dimensions. Valid percent strings are parsed once; malformed strings and Value-like objects are outside the declared input contract and use the unsupported-input row below. | -| Margin, padding, border, position, and gap setter families | Compatible | Retain all shorthand and logical declarations, apply Yoga edge precedence and direction when producing Taffy's physical Rect and Size values, and preserve original declarations for getters. Auto margin affects layout but Yoga's computed-margin getter reports zero for an auto declaration, so computed output must mask Taffy's resolved auto-margin value. | -| setAspectRatio() storage and getAspectRatio() | Compatible | Optional numeric declaration storage and getter shape are finite. Calculated layout is classified separately. | -| setGap() and setGapPercent() TypeScript declaration and runtime result | Compatible | Preserve Yoga's declared Value return while returning undefined at runtime, matching Yoga 3.2.1. Normal consumers ignore these setter returns; exact source declarations avoid an unnecessary substitution-time type change. | -| Undeclared generic-setter coercions and Value-like getter-to-setter inputs | Unsupported | Reject malformed strings, arbitrary objects, and other values outside the published setter declarations. Yoga's wrapper attempts broad parseFloat and Value-like coercion, but its own getWidth-to-setWidth round trip resets the value rather than preserving it. Reproducing accidental and broken coercions would add maintenance without preserving a working consumer capability. | +| APIs or capability | Classification | Mapping and boundary | +| --------------------------------------------------------------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The 25 style getters other than getGap() | Compatible | Read the raw Yoga declaration or Yoga-resolved getter default from JavaScript state. Construct fresh Value records with Yoga Unit values and NaN payloads where the Yoga runtime does so. | +| getGap() TypeScript declaration and runtime result | Compatible | Preserve Yoga's declared Value return for source compatibility while returning the raw number or NaN at runtime exactly as Yoga 3.2.1 does. The declaration/runtime contradiction is pinned reference behavior, not a reason to invent a third shape. | +| setDirection(), setFlexDirection(), setFlexWrap(), setJustifyContent(), setOverflow(), and setBoxSizing() | Compatible | Direct enum translation, with Direction.Inherit resolved before calculation. | +| setDisplay(Flex or None) and setPositionType(Relative or Absolute) | Compatible | Direct translation. Display.Contents and PositionType.Static use their unsupported rows above. | +| Valid-context setAlignContent(), setAlignItems(), and setAlignSelf() values | Compatible | Direct mapping. Broad-enum values invalid for a particular property use the unsupported-input row in the enum matrix rather than being silently normalized. | +| setFlexGrow(), setFlexShrink(), setFlexBasis(), setFlexBasisPercent(), and setFlexBasisAuto() | Compatible | Preserve separate Yoga declarations and derive the effective Taffy fields with Yoga precedence and Config defaults. | +| setFlex() | Compatible | Retain numeric flex and derive grow, shrink, and basis exactly as Yoga does: positive values supply grow and a legacy zero basis or web-default auto basis; negative values supply legacy shrink; explicit longhands win. The declaration mapping is finite; cache-derived stale-layout behavior after a live useWebDefaults change follows the decided difference above. | +| setWidth/Height and Auto/Percent variants; setMin/Max Width/Height and Percent variants | Compatible | Normalize every declared point, percent, auto, undefined, and NaN form into Yoga declaration state, then convert to Taffy dimensions. An undefined Yoga minimum becomes an explicit zero-length Taffy minimum rather than Taffy's CSS automatic minimum, while the Yoga getter retains Unit.Undefined. Valid percent strings are parsed once; malformed strings and Value-like objects are outside the declared input contract and use the unsupported-input row below. | +| Margin, padding, border, position, and gap setter families | Compatible | Retain all shorthand and logical declarations, apply Yoga edge precedence and direction when producing Taffy's physical Rect and Size values, and preserve original declarations for getters. Auto margin affects layout but Yoga's computed-margin getter reports zero for an auto declaration, so computed output must mask Taffy's resolved auto-margin value. | +| setAspectRatio() storage and getAspectRatio() | Compatible | Optional numeric declaration storage and getter shape are finite. Calculated layout is classified separately. | +| setGap() and setGapPercent() TypeScript declaration and runtime result | Compatible | Preserve Yoga's declared Value return while returning undefined at runtime, matching Yoga 3.2.1. Normal consumers ignore these setter returns; exact source declarations avoid an unnecessary substitution-time type change. | +| Undeclared generic-setter coercions and Value-like getter-to-setter inputs | Unsupported | Reject malformed strings, arbitrary objects, and other values outside the published setter declarations. Yoga's wrapper attempts broad parseFloat and Value-like coercion, but its own getWidth-to-setWidth round trip resets the value rather than preserving it. Reproducing accidental and broken coercions would add maintenance without preserving a working consumer capability. | ### Computed layout getters and cross-cutting result classes @@ -350,7 +350,7 @@ Compatible numeric comparisons allow an absolute error below `0.0001` for finite | min/max combined with flex growth or shrinkage | Different | Yoga and Taffy can produce different final sizes; the API remains available with Taffy's stable result. | | Overlapping physical and logical main-axis margins under a child direction that differs from its parent | Different | Yoga can use one direction while totaling margins for Flex sizing and the child's resolved direction when it stores computed physical margins. In a width-100 LTR Row, a width-20 RTL child with margin-left 4 and margin-start 10% reports computed margins left 4 and right 10 in both engines, but Yoga resolves width 16 while Taffy remains width 20. Reproducing the hidden intermediate margin total would require Flex-specific size correction, so Taffy's stable result is retained. | | aspectRatio in calculated layout | Different | Yoga derives the cross size from the final flex main size, while Taffy follows its own CSS-oriented aspect-ratio and stretch rules. With rounding disabled, a 100x100 Row parent and width-40 ratio-2 child produce 40x20 in Yoga but 40x100 in Taffy; a Column child declaring width 40, height 30, and ratio 2 produces 60x30 in Yoga but 40x30 in Taffy. Min/max and flex growth diverge further. Aligning all cases would require parent- and algorithm-dependent size rewriting, which crosses the accepted JavaScript boundary. | -| MeasureMode-sensitive callbacks and measurement combined with flexBasis | Different | Taffy does not preserve whether a finite constraint originated as Yoga Exactly or AtMost and may request intrinsic measurements in different phases. The bridge therefore gives each Taffy request a meaningful Yoga-shaped mode but does not promise a Yoga callback trace. Fixed-size and constraint-insensitive callbacks remain useful; callbacks branching on MeasureMode and flex-basis interactions have stable result differences. | +| MeasureMode-sensitive callbacks | Different | Taffy does not preserve whether a finite constraint originated as Yoga Exactly or AtMost and may request intrinsic measurements in different phases. The bridge therefore gives each Taffy request a meaningful Yoga-shaped mode but does not promise a Yoga callback trace. Fixed-size and constraint-insensitive callbacks remain useful; callbacks branching on MeasureMode can have stable result differences. | | WebFlexBasis disabled across a changed containing size | Different | Yoga 3.2.1 can reuse a stale computed percentage flex basis when the experiment is false, while Taffy recomputes it. Emulating Yoga's stale cache would require Yoga-internal flex-basis state and is outside the boundary; the enabled Yoga result matched the tested Taffy relayout. | | Attached-subtree calculation followed by an ancestor calculation that Yoga serves from cache | Different | Yoga can retain geometry written by the separate subtree calculation because the clean ancestor does not revisit it. Taffy may recompute that ancestor after internal Style translation propagated native dirty state and can therefore produce current ancestor-context geometry instead. The package keeps calculateLayout available on both nodes and does not reproduce Yoga's complete cache algorithm. | | Oversized WrapReverse lines with distribution-style alignContent | Different | With point rounding disabled, a 100x30 Row/WrapReverse container holding three 60-point items on separate 10, 20, and 15-point lines produces Yoga top positions 20, 0, and -15 but Taffy positions 35, 15, and 0 under Stretch, SpaceBetween, SpaceAround, or SpaceEvenly. The content's 45-point cross size exceeds the 30-point container; FlexStart, FlexEnd, and Center are not covered by this difference. Reconstructing Yoga's negative-free-space line placement would duplicate Flex logic. | @@ -416,7 +416,7 @@ Treating the stored Taffy Style as the only source for Yoga getters and later mu | position, margin, padding, and border | Yoga stores nine independent declarations per family: Left, Top, Right, Bottom, Start, End, Horizontal, Vertical, and All. Taffy stores four resolved physical sides. The original precedence and fallback declarations cannot be reconstructed. | | gap | Yoga stores Column, Row, and All independently; Taffy stores two resolved axes. | | flex shorthand and longhands | Yoga stores flex, flexGrow, and flexShrink as independent optional numbers and flexBasis as a separate tagged length. The JavaScript API has setFlex but no getFlex, yet the stored flex value remains observable through later longhand resets, copyStyle, and layout. Taffy has no flex shorthand and stores only concrete grow, shrink, and basis values. Unset and explicit zero have different fallback behavior even though Yoga getters can currently return the same number. | -| dimensions and flex basis | Yoga distinguishes Unit.Undefined from Unit.Auto. Taffy's public Dimension has Auto but no Undefined, and Taffy materializes defaults for min and max sizes. | +| dimensions and flex basis | Yoga distinguishes Unit.Undefined from Unit.Auto. Taffy's public Dimension has Auto but no Undefined, and its automatic minimum can retain min-content size; the translation therefore keeps the Yoga declaration separately and maps an undefined minimum to an explicit zero-length Taffy minimum. | | direction | Yoga stores Inherit and resolves it from the calculation direction and ancestor state. Taffy Style stores only Ltr or Rtl. Start and End edge resolution therefore also depends on declaration state outside the final four-sided Rect. | | Config-sensitive defaults | useWebDefaults changes construction and reset defaults and the resolved flex-shrink default. copyStyle copies Style but retains the destination Config, so copying an unset flexShrink from a normal-default node to a web-default node changes the getter from 0 to 1. A resolved Taffy number cannot preserve that distinction. | diff --git a/packages/taffyjs-yoga/COMPATIBILITY.md b/packages/taffyjs-yoga/COMPATIBILITY.md index 4355c95..e4d5be8 100644 --- a/packages/taffyjs-yoga/COMPATIBILITY.md +++ b/packages/taffyjs-yoga/COMPATIBILITY.md @@ -77,5 +77,4 @@ These rows are deliberate, version-specific differences with regression fixtures | Main-axis auto margins on a reversed axis or combined with a nonzero `justifyContent` offset | Keeps Taffy's distribution and masks computed auto margins to zero. A width-60 child with `margin-left: auto` in a width-240 `RowReverse` has left 360; the Center fixture has child tops 130 and 225. | Yoga places the reversed child at left 180; auto margins suppress the Center offset and produce tops 65 and 160. | | Measure callback count, order, and exact mode trace | Maps every Taffy request independently. A constant 30-by-10 callback for a child in a 100-by-40 Row runs four times in the pinned fixture and still produces the same 30-by-40 layout. | Runs once with width `AtMost(100)` and height `Exactly(40)` in the same fixture. | | A Measure callback whose result changes with `MeasureMode` | Keeps Taffy's multi-stage result. In the pinned 100-by-40 Row, a callback returning width 80 for `Undefined` and 20 for `AtMost` produces width 80. | Calls the callback with `AtMost(100)` and produces width 20. | -| A measured child with an explicit 20-point `flexBasis` | Keeps Taffy's measured size. A constant 30-by-10 callback produces a 30-by-10 child in a width-100 Row. | Uses the explicit basis for the main size and produces a 20-by-10 child. | | Replace or remove a Measure callback on an otherwise clean Node | Privately invalidates Taffy's measurement cache. Replacing a cached width-10 callback with width 20 produces 20 on the next calculation; removing it then produces zero. | Leaves the Node publicly clean and reuses the cached width 10 after both operations. | diff --git a/packages/taffyjs-yoga/dist/facade-o8y1e7bl.js b/packages/taffyjs-yoga/dist/facade-acowLNHQ.js similarity index 99% rename from packages/taffyjs-yoga/dist/facade-o8y1e7bl.js rename to packages/taffyjs-yoga/dist/facade-acowLNHQ.js index 3343b10..2f81e63 100644 --- a/packages/taffyjs-yoga/dist/facade-o8y1e7bl.js +++ b/packages/taffyjs-yoga/dist/facade-acowLNHQ.js @@ -289,6 +289,9 @@ function toDimension(value) { case 0: return Dimension.Auto; } } +function toMinDimension(value) { + return value.unit === 0 ? Dimension.Length(0) : toDimension(value); +} function toLengthPercentage(value) { switch (value.unit) { case 1: return Dimension.Length(value.value); @@ -811,8 +814,8 @@ function translateStyle(declarations, config, resolvedDirection, ownerDirection) height: toDimension(declarations.height) }, minSize: { - width: toDimension(declarations.minWidth), - height: toDimension(declarations.minHeight) + width: toMinDimension(declarations.minWidth), + height: toMinDimension(declarations.minHeight) }, maxSize: { width: toDimension(declarations.maxWidth), diff --git a/packages/taffyjs-yoga/dist/index.js b/packages/taffyjs-yoga/dist/index.js index 4bc8186..ddd7572 100644 --- a/packages/taffyjs-yoga/dist/index.js +++ b/packages/taffyjs-yoga/dist/index.js @@ -1,4 +1,4 @@ -import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-o8y1e7bl.js"; +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-acowLNHQ.js"; //#region src/index.ts const Yoga = createYoga(); //#endregion diff --git a/packages/taffyjs-yoga/dist/load.js b/packages/taffyjs-yoga/dist/load.js index 6b2209a..af5627c 100644 --- a/packages/taffyjs-yoga/dist/load.js +++ b/packages/taffyjs-yoga/dist/load.js @@ -1,4 +1,4 @@ -import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-o8y1e7bl.js"; +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-acowLNHQ.js"; //#region src/load.ts async function loadYoga() { return createYoga(); diff --git a/packages/taffyjs-yoga/src/translate.ts b/packages/taffyjs-yoga/src/translate.ts index e3c6852..a8b390f 100644 --- a/packages/taffyjs-yoga/src/translate.ts +++ b/packages/taffyjs-yoga/src/translate.ts @@ -33,6 +33,7 @@ import { toDimension, toLengthPercentage, toLengthPercentageAuto, + toMinDimension, undefinedValue, resolvePercentage, type YogaValue, @@ -254,8 +255,8 @@ export function translateStyle( height: toDimension(declarations.height), }, minSize: { - width: toDimension(declarations.minWidth), - height: toDimension(declarations.minHeight), + width: toMinDimension(declarations.minWidth), + height: toMinDimension(declarations.minHeight), }, maxSize: { width: toDimension(declarations.maxWidth), diff --git a/packages/taffyjs-yoga/src/values.ts b/packages/taffyjs-yoga/src/values.ts index d287c92..5d8ae78 100644 --- a/packages/taffyjs-yoga/src/values.ts +++ b/packages/taffyjs-yoga/src/values.ts @@ -129,6 +129,10 @@ export function toDimension(value: YogaValue): DimensionInput { } } +export function toMinDimension(value: YogaValue): DimensionInput { + return value.unit === Unit.Undefined ? TaffyDimension.Length(0) : toDimension(value); +} + export function toLengthPercentage(value: YogaValue): LengthPercentageInput { switch (value.unit) { case Unit.Point: diff --git a/tests/taffyjs-yoga/computed-output.test.mts b/tests/taffyjs-yoga/computed-output.test.mts index 30488c4..c46ba58 100644 --- a/tests/taffyjs-yoga/computed-output.test.mts +++ b/tests/taffyjs-yoga/computed-output.test.mts @@ -747,6 +747,49 @@ test("ordinary nested Flex, percentage, grow, and shrink geometry matches Yoga", } }); +test("undefined minimums do not impose Taffy's automatic content floor", () => { + for (const axis of ["row", "column"] as const) { + const actual = Array.from({ length: 4 }, () => Yoga.Node.create()); + const expected = Array.from({ length: 4 }, () => OracleYoga.Node.create()); + const [root, zeroBasis, child, sibling] = actual; + const [oracleRoot, oracleZeroBasis, oracleChild, oracleSibling] = expected; + try { + root.setFlexDirection(axis === "row" ? FlexDirection.Row : FlexDirection.Column); + oracleRoot.setFlexDirection( + axis === "row" ? OracleFlexDirection.Row : OracleFlexDirection.Column, + ); + if (axis === "row") { + root.setWidth(6); + oracleRoot.setWidth(6); + } else { + root.setHeight(6); + oracleRoot.setHeight(6); + } + zeroBasis.setFlexBasis(0); + oracleZeroBasis.setFlexBasis(0); + child.setMeasureFunc(() => ({ width: 1, height: 1 })); + oracleChild.setMeasureFunc(() => ({ width: 1, height: 1 })); + sibling.setMeasureFunc(() => ({ width: 1, height: 1 })); + oracleSibling.setMeasureFunc(() => ({ width: 1, height: 1 })); + zeroBasis.insertChild(child, 0); + oracleZeroBasis.insertChild(oracleChild, 0); + root.insertChild(zeroBasis, 0); + oracleRoot.insertChild(oracleZeroBasis, 0); + root.insertChild(sibling, 1); + oracleRoot.insertChild(oracleSibling, 1); + + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + for (const [index, node] of actual.entries()) { + assertLayoutEqual(node, expected[index], `${axis} node ${index}`); + } + } finally { + root.freeRecursive(); + oracleRoot.freeRecursive(); + } + } +}); + test("one calculation commits the complete selected subtree atomically", async () => { const facade = await loadYoga(); const root = facade.Node.create(); diff --git a/tests/taffyjs-yoga/measurement.test.mts b/tests/taffyjs-yoga/measurement.test.mts index 3eb04ec..2aab379 100644 --- a/tests/taffyjs-yoga/measurement.test.mts +++ b/tests/taffyjs-yoga/measurement.test.mts @@ -179,7 +179,7 @@ test("constant measurement matches Yoga while callback traces remain deliberatel oracleRoot.freeRecursive(); }); -test("MeasureMode-sensitive and flex-basis-sensitive layouts pin the documented Differences", () => { +test("MeasureMode-sensitive layout differs while constant measured flex basis matches Yoga", () => { const modeSensitive: MeasureFunction = (width, widthMode) => ({ width: widthMode === MeasureMode.Undefined ? 80 : widthMode === MeasureMode.AtMost ? 20 : width, height: 10, @@ -231,8 +231,8 @@ test("MeasureMode-sensitive and flex-basis-sensitive layouts pin the documented oracleBasisRoot.insertChild(oracleBasisChild, 0); basisRoot.calculateLayout(undefined, undefined); oracleBasisRoot.calculateLayout(undefined, undefined); - assert.deepEqual(layoutSize(basisChild), [30, 10]); - assert.deepEqual(layoutSize(oracleBasisChild), [20, 10]); + assert.deepEqual(layoutSize(basisChild), [20, 10]); + assert.deepEqual(layoutSize(basisChild), layoutSize(oracleBasisChild)); basisRoot.freeRecursive(); oracleBasisRoot.freeRecursive(); }); From aed21c4c56261ad3caa7310aef5404d36bf40a35 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 16 Aug 2026 18:36:41 +0800 Subject: [PATCH 11/18] Refresh Yoga merge readiness --- .agents/docs/loop-status.md | 13 +++++++------ pnpm-lock.yaml | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.agents/docs/loop-status.md b/.agents/docs/loop-status.md index 9b4ad9e..accf21c 100644 --- a/.agents/docs/loop-status.md +++ b/.agents/docs/loop-status.md @@ -1,10 +1,10 @@ # @taffyjs/yoga Implementation Loop Status -Updated: 2026-08-15 +Updated: 2026-08-16 ## State -Milestones 1 through 6 are complete and reviewed. The implementation is ready for Yunfei's decision in the existing draft, unmerged pull request. +Milestones 1 through 6 are complete and reviewed. The implementation is undergoing its final stacked-PR review before merge. ## Done @@ -64,15 +64,16 @@ Milestones 1 through 6 are complete and reviewed. The implementation is ready fo - A final `pnpm pack --dry-run --json` preview contains exactly `package.json`, the three package documents, root and `/load` JavaScript, root and `/load` declarations, one shared JavaScript chunk, and one shared declaration chunk. The cleaned output contains no `yoga-layout` or `yoga-layout-oracle` runtime import. - The fresh Milestone 6 adversarial reviewer found two material public-surface issues: the facade and factories were frozen and their declarations narrowed writable enum constants into readonly literals, and the public min/max Flex Difference row was narrower than the vouched result group. Runtime and declaration mutability now match the oracle with direct tests, and the public row now covers min or max constraints combined with Flex growth or shrinkage while retaining the exact max-plus-shrink fixture. - The same reviewer's single targeted follow-up returned PASS for both repairs and confirmed the neighboring `Direction.Inherit` and infinite-owner-dimension behavior. The bounded follow-up passed 23 focused tests and the Yoga declaration check. -- After the reviewed Milestone 6 commit, the branch was linearly rebased onto `origin/main` at `17a567d`. The only conflict was the project-document index, where the completed Yoga entries and main's expanded codegen description were both retained. On the rebased tree, the complete `vp run ready` gate passes with the same test counts. +- The VueTUI consumer run exposed one real translation defect: an undefined Yoga minimum was translated to Taffy's automatic minimum instead of zero. The fix now translates only undefined minimum dimensions to zero, retains ordinary undefined dimensions as auto, and adds row and column differential fixtures. It also removed the previously reported measured-flex-basis Difference, which the same defect had caused. A bounded 270-case flex-basis probe found no remaining mismatch in that tested constant-measure corpus. +- The complete stack was linearly rebased onto `origin/main` at `dbdad1e`, retaining the newer Node build, Wasm package, website, and Yoga integration. On the rebased tree, `vp run ready` passes: 9 Rust tests, 45 native tests, 195 `@taffyjs/node` integration tests, 78 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. ## In flight -- None. +- Final combined-diff correctness and simplicity reviews, stacked branch synchronization, and merge. ## Next -- Yunfei reviews draft PR [#9](https://github.com/hyfdev/taffyjs/pull/9) and decides whether or when to merge. This run does not mark it ready, merge it, publish packages, or add release automation. +- Merge the reviewed stacked PRs when their final combined-diff review and CI gates pass. Publication and release automation remain outside this implementation run. - After a future @taffyjs/yoga publication, run one real registry installation using the `yoga-layout` npm alias. That smoke test cannot be completed against the current private, unpublished package and is outside this implementation run. ## Blocked @@ -83,7 +84,7 @@ Milestones 1 through 6 are complete and reviewed. The implementation is ready fo - Pull request: [hyfdev/taffyjs#9](https://github.com/hyfdev/taffyjs/pull/9), still draft and unmerged. - Baseline and scope: exactly `yoga-layout@3.2.1`; ESM-only; Node.js >=22.18; TypeScript over the existing public `@taffyjs/node` API; no Rust, Taffy core, `@taffyjs/node` API, Flex, browser, WASM, generator, query, batch, compact-transport, publication, or release-automation expansion. -- Milestone commits after the rebase: `f646c29` package foundation; `e1861a1` Config and Style; `3f1f557` topology and state; `a65114a` computed output; `7bb18c7` Measure API; `ff6f8a9` compatibility closure and handover. The vouched run baseline is `bd40988`; the branch is based on `origin/main` at `17a567d`. +- Milestone commits after the latest rebase: `91be271` package foundation; `0a58671` Config and Style; `d10d156` topology and state; `c354269` computed output; `336772f` Measure API; `577cbca` compatibility closure and handover. The vouched run baseline is `806c862`; the branch is based on `origin/main` at `dbdad1e`. - Remaining Different groups: named Yoga cache artifacts; corrected dirtied-callback argument and null behavior; min or max constraints combined with Flex growth or shrinkage; overlapping physical/logical main-axis margins; calculated aspect ratio; oversized or ordinary WrapReverse placement; overflowing reversed-axis distribution; zero-cross-size wrapped-line distribution; main-axis auto margins with reversal or justification; Measure callback trace, mode-sensitive result, and callback replacement/removal cache behavior. Exact triggers and pinned results are in the package compatibility guide. - Remaining Unsupported triggers: Display.Contents; PositionType.Static; every non-None Errata; property-invalid Align values; `setIsReferenceBaseline(true)`; positive or negative infinite calculation owner dimensions; and undeclared malformed or Value-like generic-setter coercions. Types omit or narrow these where possible, and dynamic inputs throw before mutation. - Only post-publication evidence remains: install the published package under the `yoga-layout` registry alias and smoke-test both entries. It does not block this private draft implementation. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 516b719..b3bfe9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -295,6 +295,18 @@ importers: '@napi-rs/cli': specifier: 'catalog:' version: 3.8.2(@emnapi/runtime@2.0.0-alpha.3)(@types/node@22.18.0) + '@types/node': + specifier: 'catalog:' + version: 22.18.0 + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@0.2.8(@types/node@22.18.0)(typescript@7.0.2)' + vite-plus: + specifier: 'catalog:' + version: 0.2.8(@types/node@22.18.0)(@vitest/browser-playwright@4.1.10)(@voidzero-dev/vite-plus-core@0.2.8(@types/node@22.18.0)(typescript@7.0.2))(typescript@7.0.2) packages/taffyjs-yoga: dependencies: @@ -378,11 +390,11 @@ importers: specifier: 'catalog:' version: 7.0.2 vite: - specifier: npm:@voidzero-dev/vite-plus-core@0.2.8 + specifier: 'catalog:' version: '@voidzero-dev/vite-plus-core@0.2.8(@types/node@22.18.0)(typescript@7.0.2)' vite-plus: specifier: 'catalog:' - version: 0.2.8(@types/node@22.18.0)(@voidzero-dev/vite-plus-core@0.2.8(@types/node@22.18.0)(typescript@7.0.2))(typescript@7.0.2) + version: 0.2.8(@types/node@22.18.0)(@vitest/browser-playwright@4.1.10)(@voidzero-dev/vite-plus-core@0.2.8(@types/node@22.18.0)(typescript@7.0.2))(typescript@7.0.2) yoga-layout-oracle: specifier: npm:yoga-layout@3.2.1 version: yoga-layout@3.2.1 From 0258e1733c79a31153b59d3895b448176913ee91 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 15 Aug 2026 23:39:44 +0800 Subject: [PATCH 12/18] Clarify Yoga Align migration boundary --- .agents/docs/taffyjs-yoga-decisions.md | 10 +-- .agents/docs/taffyjs-yoga-reference.md | 58 +++++++------ packages/taffyjs-yoga/COMPATIBILITY.md | 22 ++--- packages/taffyjs-yoga/README.md | 24 ++++++ packages/taffyjs-yoga/dist/index.d.ts | 4 +- packages/taffyjs-yoga/dist/load.d.ts | 4 +- ...ypes-qTisbHPP.d.ts => types-CQnSOAjF.d.ts} | 5 +- packages/taffyjs-yoga/src/index.ts | 10 ++- packages/taffyjs-yoga/src/load.ts | 11 ++- packages/taffyjs-yoga/src/types.ts | 3 + tests/taffyjs-yoga/style.test.mts | 9 ++ tests/taffyjs-yoga/types/public-api.test-d.ts | 85 +++++++++++++++++++ 12 files changed, 196 insertions(+), 49 deletions(-) rename packages/taffyjs-yoga/dist/{types-qTisbHPP.d.ts => types-CQnSOAjF.d.ts} (93%) diff --git a/.agents/docs/taffyjs-yoga-decisions.md b/.agents/docs/taffyjs-yoga-decisions.md index 4bd3215..ee89780 100644 --- a/.agents/docs/taffyjs-yoga-decisions.md +++ b/.agents/docs/taffyjs-yoga-decisions.md @@ -148,15 +148,15 @@ This ledger records only judgments that Yunfei explicitly expressed about `@taff ### Type and runtime rejection -[VOUCHED @hyfdev 2026-08-14] +[VOUCHED @hyfdev 2026-08-16] -**Ruling:** A statically known unsupported method or value must be rejected by the public TypeScript declarations where TypeScript can express the restriction, and every unsupported capability must also throw at runtime for JavaScript, `any`, and dynamic values. +**Ruling:** An unsupported method or value that TypeScript can identify from one call must be rejected by the public declarations even when Yoga's published parameter type is broader, and every unsupported capability must also throw at runtime for JavaScript, `any`, and dynamic values. -**Limits:** The types need not model combinations assembled across multiple mutable setter calls, such as `minWidth` followed by `flexGrow`; those supported-but-different combinations remain callable. Exact error classes, messages, and the complete initial unsupported list remain implementation choices. Unsupported input must not be silently ignored or converted into a result with semantics the package cannot defend. +**Limits:** A restriction is statically expressible when unsupported status depends only on that call's argument and the supported values form a finite union. A variable or generic whose type still permits any unsupported member must be narrowed before the call; this compile error is intentional migration feedback, not a defect in drop-in replacement. Use simple named unions rather than conditional types designed to special-case the exact broad upstream enum; named type-only aliases may expose those unions as migration aids without changing the runtime surface. Do not narrow an API merely because its result is Different, and do not try to encode combinations across multiple setters, tree state, or layout output. Exact error classes, messages, and the complete initial unsupported list remain implementation choices. Unsupported input must not be silently ignored or converted into a result with semantics the package cannot defend. -**Why:** Yunfei accepted explicit runtime failure for unsupported options and stated that catching an unsupported use during type checking is preferable whenever possible. +**Why:** Compile-time rejection helps migration find a potentially unsupported path before that path happens to execute. Preserving a broad upstream declaration would instead allow unsupported members to compile and leave discovery to runtime coverage. Simple finite unions keep the restriction predictable and maintainable, while runtime validation still protects untyped and dynamic calls. -**Source:** Yunfei (`@hyfdev`), 2026-08-14; explicitly selected type-level rejection plus runtime checking and vouched the consolidated policy. +**Source:** Yunfei (`@hyfdev`), 2026-08-14, 2026-08-15, and 2026-08-16; selected type-level rejection plus runtime checking, clarified that a broad Align variable or generic must be treated as a migration issue when it may carry an unsupported property value, accepted named type-only aliases as the friendlier migration path, and explicitly vouched this distilled rule. ### PositionType public shape diff --git a/.agents/docs/taffyjs-yoga-reference.md b/.agents/docs/taffyjs-yoga-reference.md index 5bfa415..6ee1892 100644 --- a/.agents/docs/taffyjs-yoga-reference.md +++ b/.agents/docs/taffyjs-yoga-reference.md @@ -233,35 +233,35 @@ No first-release feasibility blocker remains in the inventoried Yoga 3.2.1 surfa ### Package entries, factories, and public value shapes -| Capability | Classification | Basis and translation | -| ------------------------------------------------------------------------------------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Root entry default Yoga object, named enums, type-only exports, and legacy constants | Compatible | TypeScript and JavaScript reproduce the Yoga 3.2.1 export categories and every supported value. Unsupported enum members are narrowed separately rather than changing the rest of an enum. Workspace-alias fixtures cover unchanged local substitution; an actual registry alias remains possible only after publication. | -| @taffyjs/yoga/load and loadYoga(), mirroring yoga-layout/load | Compatible | Each call creates an isolated facade with its own hidden TaffyTree, default Config state, object brand, and Node registry. Yoga 3.2.1 rejects Node, Config, and factory-destroy arguments from another loaded assembly; the JavaScript layer rejects corresponding cross-facade operations before they reach @taffyjs/node. | -| Layout, Size, and Value structural objects | Compatible | They are finite ordinary JavaScript records. Value requires Yoga's Unit values and NaN payload behavior, not the @taffyjs/node tagged-value representation. | -| Yoga.Config.create() | Compatible | A JavaScript Config retains Yoga defaults and private revisions used when translating Config-sensitive style. Nodes hold the Config state strongly, and calculation-time traversal observes revisions without requiring a reverse registry of every Node using that Config. | -| Yoga.Node.create(), createDefault(), and createWithConfig() | Compatible | Create one stable native NodeId immediately in the facade tree. Arbitrary unattached nodes and multiple roots coexist without subtree migration, and nodes created with different same-facade Config values may attach to each other. The no-argument factories retain an immortal private default Config state. | -| Yoga.Node.destroy() and Node.free() | Compatible | Invalidate exactly one canonical Node wrapper and remove its NodeId. Detach it from its parent, detach but preserve its direct children and their subtrees, and make surviving topology dirty enough for a later calculation to be correct. Deterministic post-free errors intentionally replace Yoga's unsafe raw-pointer alias behavior. | -| Yoga.Config.destroy() and Config.free() | Compatible | Invalidate the public Config handle immediately, reject later methods and new Node creation through that handle, and retain the internal immutable-or-live-last-value Config state while existing Nodes still reference it. This supplies safe reference-counted lifetime instead of Yoga's dangling Config pointer when a Config is freed too early. | -| DirtiedFunction public declaration | Compatible | Preserve Yoga's declared one-Node callback type. The facade can pass the canonical Node wrapper, which makes the declaration truthful; Yoga 3.2.1's zero-argument runtime invocation is a documented callback-trace difference rather than a useful contract to reproduce. | -| MeasureFunction public declaration | Compatible | Preserve Yoga's four-argument callback type and Size return. Constraint-sensitive callback traces and resulting layouts are classified separately rather than changing the public declaration. | +| Capability | Classification | Basis and translation | +| ------------------------------------------------------------------------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Root entry default Yoga object, named enums, type-only exports, and legacy constants | Compatible | TypeScript and JavaScript reproduce the Yoga 3.2.1 export categories and every supported value. Unsupported enum members are narrowed separately rather than changing the rest of an enum; the three additional type-only Align subset aliases make those restrictions reusable without changing the runtime surface. Workspace-alias fixtures cover unchanged local substitution; an actual registry alias remains possible only after publication. | +| @taffyjs/yoga/load and loadYoga(), mirroring yoga-layout/load | Compatible | Each call creates an isolated facade with its own hidden TaffyTree, default Config state, object brand, and Node registry. Yoga 3.2.1 rejects Node, Config, and factory-destroy arguments from another loaded assembly; the JavaScript layer rejects corresponding cross-facade operations before they reach @taffyjs/node. | +| Layout, Size, and Value structural objects | Compatible | They are finite ordinary JavaScript records. Value requires Yoga's Unit values and NaN payload behavior, not the @taffyjs/node tagged-value representation. | +| Yoga.Config.create() | Compatible | A JavaScript Config retains Yoga defaults and private revisions used when translating Config-sensitive style. Nodes hold the Config state strongly, and calculation-time traversal observes revisions without requiring a reverse registry of every Node using that Config. | +| Yoga.Node.create(), createDefault(), and createWithConfig() | Compatible | Create one stable native NodeId immediately in the facade tree. Arbitrary unattached nodes and multiple roots coexist without subtree migration, and nodes created with different same-facade Config values may attach to each other. The no-argument factories retain an immortal private default Config state. | +| Yoga.Node.destroy() and Node.free() | Compatible | Invalidate exactly one canonical Node wrapper and remove its NodeId. Detach it from its parent, detach but preserve its direct children and their subtrees, and make surviving topology dirty enough for a later calculation to be correct. Deterministic post-free errors intentionally replace Yoga's unsafe raw-pointer alias behavior. | +| Yoga.Config.destroy() and Config.free() | Compatible | Invalidate the public Config handle immediately, reject later methods and new Node creation through that handle, and retain the internal immutable-or-live-last-value Config state while existing Nodes still reference it. This supplies safe reference-counted lifetime instead of Yoga's dangling Config pointer when a Config is freed too early. | +| DirtiedFunction public declaration | Compatible | Preserve Yoga's declared one-Node callback type. The facade can pass the canonical Node wrapper, which makes the declaration truthful; Yoga 3.2.1's zero-argument runtime invocation is a documented callback-trace difference rather than a useful contract to reproduce. | +| MeasureFunction public declaration | Compatible | Preserve Yoga's four-argument callback type and Size return. Constraint-sensitive callback traces and resulting layouts are classified separately rather than changing the public declaration. | ### Enum members -| Enum or member group | Classification | Mapping | -| ------------------------------------------------------------------------------------------------------------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| BoxSizing, Dimension, FlexDirection, Gutter, Justify, LogLevel, MeasureMode, NodeType, Overflow, Unit, and Wrap | Compatible | The public numeric enum and reverse lookup can be reproduced. Every layout-affecting member has a direct Taffy value or a finite wrapper conversion; Dimension, LogLevel, and NodeType are export-only in this JavaScript API. | -| Direction.LTR and Direction.RTL | Compatible | Direct Taffy mapping. | -| Direction.Inherit | Compatible | Keep Inherit in Yoga declaration state, resolve it from the parent or calculateLayout owner direction before native calculation, and re-resolve logical edges when direction changes. | -| Edge.Left, Top, Right, Bottom, Start, End, Horizontal, Vertical, and All | Compatible | Keep all nine declarations in JavaScript and resolve each physical edge with Yoga's logical, physical, axis, then All precedence. Taffy receives only the resolved four-edge values. Style getters continue to expose the original edge declarations. | -| Align members in their valid property contexts | Compatible | Taffy directly represents FlexStart, Center, FlexEnd, Stretch, and Baseline for item/self alignment, Auto for self alignment, and the normal content-distribution values for content alignment. The shared Align enum remains exported because every member has at least one valid context. | -| Align members used outside their valid property context | Unsupported | Yoga's shared enum lets Auto or distribution values reach item alignment and lets Auto or Baseline reach content alignment, producing incidental branch fallthrough behavior that changes between single-line and multi-line layouts. Reproducing that is not a credible style capability. Each setter accepts the union of members valid for that property, so a general Align value must be narrowed by the caller, and the runtime always rejects an invalid combination. | -| Display.Flex and Display.None | Compatible | Direct Taffy mapping. | -| Display.Contents | Unsupported | Taffy 0.13 has no contents box. Preserving Yoga semantics would require a shadow or repeatedly rewritten computation topology, which conflicts with the bounded JavaScript layer and stable node ownership. Omit the member and legacy constant and reject a dynamic numeric value. | -| PositionType.Relative and PositionType.Absolute | Compatible | Direct Taffy mapping. | -| PositionType.Static | Unsupported | Omit the member and POSITION_TYPE_STATIC and reject a dynamic zero, as recorded in the decision ledger. | -| Errata.None | Compatible | It selects Yoga's modern default behavior and requires no additional Taffy mode. | -| Errata.StretchFlexBasis, AbsolutePositionWithoutInsetsExcludesPadding, AbsolutePercentAgainstInnerSize, All, and Classic | Unsupported | These values request Yoga-specific algorithm branches and combinations for which Taffy exposes no matching switch. Omit the members and legacy constants, narrow setErrata to None, and reject dynamically supplied nonzero values rather than retaining state that native layout ignores. | -| ExperimentalFeature.WebFlexBasis | Compatible | Preserve the enum and boolean Config state. The disabled Yoga cache behavior has a separate Different result row rather than making the enum or state API unavailable. | +| Enum or member group | Classification | Mapping | +| ------------------------------------------------------------------------------------------------------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| BoxSizing, Dimension, FlexDirection, Gutter, Justify, LogLevel, MeasureMode, NodeType, Overflow, Unit, and Wrap | Compatible | The public numeric enum and reverse lookup can be reproduced. Every layout-affecting member has a direct Taffy value or a finite wrapper conversion; Dimension, LogLevel, and NodeType are export-only in this JavaScript API. | +| Direction.LTR and Direction.RTL | Compatible | Direct Taffy mapping. | +| Direction.Inherit | Compatible | Keep Inherit in Yoga declaration state, resolve it from the parent or calculateLayout owner direction before native calculation, and re-resolve logical edges when direction changes. | +| Edge.Left, Top, Right, Bottom, Start, End, Horizontal, Vertical, and All | Compatible | Keep all nine declarations in JavaScript and resolve each physical edge with Yoga's logical, physical, axis, then All precedence. Taffy receives only the resolved four-edge values. Style getters continue to expose the original edge declarations. | +| Align members in their valid property contexts | Compatible | Taffy directly represents FlexStart, Center, FlexEnd, Stretch, and Baseline for item/self alignment, Auto for self alignment, and the normal content-distribution values for content alignment. The shared Align enum remains exported because every member has at least one valid context. | +| Align members used outside their valid property context | Unsupported | Yoga's shared enum lets Auto or distribution values reach item alignment and lets Auto or Baseline reach content alignment, producing incidental branch fallthrough behavior that changes between single-line and multi-line layouts. Reproducing that is not a credible style capability. Each setter accepts a named union of the members valid for that property, so a general Align variable or generic must be narrowed by the caller; runtime validation always rejects an invalid combination before mutation. | +| Display.Flex and Display.None | Compatible | Direct Taffy mapping. | +| Display.Contents | Unsupported | Taffy 0.13 has no contents box. Preserving Yoga semantics would require a shadow or repeatedly rewritten computation topology, which conflicts with the bounded JavaScript layer and stable node ownership. Omit the member and legacy constant and reject a dynamic numeric value. | +| PositionType.Relative and PositionType.Absolute | Compatible | Direct Taffy mapping. | +| PositionType.Static | Unsupported | Omit the member and POSITION_TYPE_STATIC and reject a dynamic zero, as recorded in the decision ledger. | +| Errata.None | Compatible | It selects Yoga's modern default behavior and requires no additional Taffy mode. | +| Errata.StretchFlexBasis, AbsolutePositionWithoutInsetsExcludesPadding, AbsolutePercentAgainstInnerSize, All, and Classic | Unsupported | These values request Yoga-specific algorithm branches and combinations for which Taffy exposes no matching switch. Omit the members and legacy constants, narrow setErrata to None, and reject dynamically supplied nonzero values rather than retaining state that native layout ignores. | +| ExperimentalFeature.WebFlexBasis | Compatible | Preserve the enum and boolean Config state. The disabled Yoga cache behavior has a separate Different result row rather than making the enum or state API unavailable. | ### Config behavior @@ -319,7 +319,11 @@ There is one performance boundary to preserve. Once a calculation uses computeLa The Yoga declarations must be authoritative JavaScript state. Reading back only Taffy's translated Style would lose undefined versus auto, shorthand edges, Start and End, Direction.Inherit, numeric flex, and Config-sensitive defaults. This is a bounded compatibility record, not a second layout tree: Taffy remains authoritative for topology, native calculation, and unrounded layout, while JavaScript owns the finite Yoga-visible dirty and new-layout flags. Taffy's internal invalidation may be broader than Yoga's observable invalidation and is not exposed directly. -The TypeScript surface should preserve Yoga 3.2.1's published declarations for every supported capability, even where a return declaration is known to disagree with Yoga's runtime. Correcting those types would make previously compiling consumer source fail during dependency substitution, while reproducing the runtime value is cheap and unambiguous. Unsupported members and values remain the explicit exception: they are narrowed where practical and always rejected at runtime. Inputs outside Yoga's published declarations are not a compatibility surface merely because the generic wrapper happens to coerce them. +#### TypeScript migration boundary + +The TypeScript surface should preserve Yoga 3.2.1's published declarations for every fully supported capability, even where a return declaration is known to disagree with Yoga's runtime. Unsupported members and values are the explicit exception: when unsupported status depends only on one argument and the supported values form a finite union, the public parameter type must expose that union even if Yoga's declaration is broader. A variable or generic whose static type still includes an unsupported member must be narrowed before the call; the resulting compile error is intended migration feedback. Different results remain callable, and types must not attempt to encode combinations across mutable setters, tree state, or layout output. + +Keep these restrictions as simple named unions. Do not add a conditional type that admits the exact broad upstream enum while trying to reject particular enum literals: such a signature either leaves unsupported paths statically callable or makes generic forwarding unpredictable. `AlignContentValue`, `AlignItemsValue`, and `AlignSelfValue` are exported from both package entries as the direct migration types and are the single source for the corresponding setter declarations. Code that must compile against either Yoga provider can instead derive the active provider's parameter with `Parameters[0]`. Runtime validation remains mandatory for JavaScript, `any`, assertions, and dynamic data, and every rejected call must fail before declaration state changes. Inputs outside Yoga's published declarations are not a compatibility surface merely because the generic wrapper happens to coerce them. | APIs or capability | Classification | Mapping and boundary | | --------------------------------------------------------------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/packages/taffyjs-yoga/COMPATIBILITY.md b/packages/taffyjs-yoga/COMPATIBILITY.md index e4d5be8..88440fd 100644 --- a/packages/taffyjs-yoga/COMPATIBILITY.md +++ b/packages/taffyjs-yoga/COMPATIBILITY.md @@ -32,7 +32,7 @@ The Yoga 3.2.1 runtime and declarations expose eight Config methods and 100 Node | Public area | Compatible surface | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Package entries and exports | The default Yoga facade at the root entry; `loadYoga()` at `/load`; all 18 named enum objects; the supported legacy constants; and Yoga's Config, DirtiedFunction, MeasureFunction, Node, and Yoga type-export placement. Unsupported enum members and their legacy constants are deliberately omitted. | +| Package entries and exports | The default Yoga facade at the root entry; `loadYoga()` at `/load`; all 18 named enum objects; the supported legacy constants; Yoga's Config, DirtiedFunction, MeasureFunction, Node, and Yoga type-export placement; and the additional type-only `AlignContentValue`, `AlignItemsValue`, and `AlignSelfValue` migration helpers. Unsupported enum members and their legacy constants are deliberately omitted. | | Factories | `Yoga.Config.create/destroy` and `Yoga.Node.create/createDefault/createWithConfig/destroy`, including retained Config state and deterministic same-facade lifetime validation. | | Config | All eight method names: `free`, `isExperimentalFeatureEnabled`, `setExperimentalFeatureEnabled`, `setPointScaleFactor`, `getErrata`, `setErrata`, `useWebDefaults`, and `setUseWebDefaults`. Only `Errata.None` is accepted by `setErrata`. | | Node lifecycle and topology | `free`, `freeRecursive`, `copyStyle`, `getChild`, `getChildCount`, `getParent`, `insertChild`, `removeChild`, and `reset`. Canonical Node identity, one-parent and acyclic topology, detached subtrees, arbitrary roots, and cross-facade rejection are enforced. | @@ -47,15 +47,17 @@ API compatibility targets the useful public contract. It deliberately does not r Unsupported enum members and constants are absent from the TypeScript and runtime surface. If JavaScript supplies their raw numeric values dynamically, the receiving method throws before changing Node or Config state. The same pre-mutation rule applies to the other rejected inputs in this table. -| Capability or input | Public behavior | Reason | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| `Display.Contents` and `DISPLAY_CONTENTS` | Omitted; `setDisplay(2)` throws. | Taffy 0.13 has no contents box. Credible support would require a second or repeatedly rewritten topology. | -| `PositionType.Static` and `POSITION_TYPE_STATIC` | Omitted; `setPositionType(0)` throws. | Static containing-block behavior has no direct Taffy representation and is not emulated in JavaScript. | -| `Errata.StretchFlexBasis`, `AbsolutePositionWithoutInsetsExcludesPadding`, `AbsolutePercentAgainstInnerSize`, `All`, and `Classic`, plus their legacy constants | Omitted; `setErrata()` accepts only `Errata.None` and throws for any dynamic nonzero value. | These values select Yoga-specific algorithm branches for which Taffy has no matching switch. | -| `Align` outside the property where it is meaningful | TypeScript narrows each setter and runtime validation throws. `setAlignContent` rejects Auto and Baseline; `setAlignItems` rejects Auto and the three distribution values; `setAlignSelf` rejects the three distribution values. | Yoga's incidental fallthrough for invalid shared-enum values is layout-dependent and is not a credible style contract. | -| `setIsReferenceBaseline(true)` | The declaration accepts only `false`; dynamic `true` throws. | The current native boundary cannot select Yoga's reference child during baseline calculation. | -| Positive or negative infinity as a `calculateLayout` owner dimension | Throws before calculation or public-state mutation. Finite numbers, `"auto"`, `undefined`, omitted values, and NaN remain supported. | Yoga's resulting NaN or zero geometry is not a credible available-space contract. | -| Undeclared generic-setter coercions | Malformed strings, arbitrary objects, and Value-like getter results supplied to setters throw atomically. | Yoga's broad wrapper coercion is outside its published types and its own getter-to-setter path is broken in 3.2.1. | +When an upstream enum type also contains values that are Unsupported for one property, the replacement narrows that setter to the supported members. A TypeScript variable or generic that still permits an unsupported member must be narrowed before the call; this is intentional migration feedback. The package exports `AlignContentValue`, `AlignItemsValue`, and `AlignSelfValue` for that purpose. Code that must compile against both Yoga providers can derive the selected provider's type with `Parameters[0]`. JavaScript and dynamic inputs receive the same check at runtime. + +| Capability or input | Public behavior | Reason | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| `Display.Contents` and `DISPLAY_CONTENTS` | Omitted; `setDisplay(2)` throws. | Taffy 0.13 has no contents box. Credible support would require a second or repeatedly rewritten topology. | +| `PositionType.Static` and `POSITION_TYPE_STATIC` | Omitted; `setPositionType(0)` throws. | Static containing-block behavior has no direct Taffy representation and is not emulated in JavaScript. | +| `Errata.StretchFlexBasis`, `AbsolutePositionWithoutInsetsExcludesPadding`, `AbsolutePercentAgainstInnerSize`, `All`, and `Classic`, plus their legacy constants | Omitted; `setErrata()` accepts only `Errata.None` and throws for any dynamic nonzero value. | These values select Yoga-specific algorithm branches for which Taffy has no matching switch. | +| `Align` outside the property where it is meaningful | TypeScript narrows each setter and runtime validation throws before mutation. `setAlignContent` rejects Auto and Baseline; `setAlignItems` rejects Auto and the three distribution values; `setAlignSelf` rejects the three distribution values. | Yoga's incidental fallthrough for property-invalid shared-enum values is layout-dependent and is not a credible style contract. | +| `setIsReferenceBaseline(true)` | The declaration accepts only `false`; dynamic `true` throws. | The current native boundary cannot select Yoga's reference child during baseline calculation. | +| Positive or negative infinity as a `calculateLayout` owner dimension | Throws before calculation or public-state mutation. Finite numbers, `"auto"`, `undefined`, omitted values, and NaN remain supported. | Yoga's resulting NaN or zero geometry is not a credible available-space contract. | +| Undeclared generic-setter coercions | Malformed strings, arbitrary objects, and Value-like getter results supplied to setters throw atomically. | Yoga's broad wrapper coercion is outside its published types and its own getter-to-setter path is broken in 3.2.1. | ## Known Differences diff --git a/packages/taffyjs-yoga/README.md b/packages/taffyjs-yoga/README.md index 3b86724..67616d7 100644 --- a/packages/taffyjs-yoga/README.md +++ b/packages/taffyjs-yoga/README.md @@ -48,6 +48,30 @@ node.free(); Each `loadYoga()` call creates an isolated Yoga facade with its own hidden Taffy tree. The root default export is one eagerly created facade. +## TypeScript migration for Align values + +Yoga publishes one broad `Align` enum for three setters even though each property supports a different subset. `@taffyjs/yoga` reports unsupported combinations during type checking and exports the accepted sets as `AlignContentValue`, `AlignItemsValue`, and `AlignSelfValue`: + +```ts +import Yoga, { Align, type AlignItemsValue, type Node } from "yoga-layout"; + +function setItemAlignment(node: Node, value: AlignItemsValue) { + node.setAlignItems(value); +} + +const node = Yoga.Node.create(); +setItemAlignment(node, Align.Center); +node.free(); +``` + +Code that must compile against either the official package or the replacement can derive the active provider's parameter type instead: + +```ts +type AlignItemsInput = Parameters[0]; +``` + +A broad `Align` variable must be narrowed before being passed to one of these setters. JavaScript and other dynamic calls receive the same validation at runtime before Node state changes. + ## Compatibility boundary The compatibility baseline is exactly Yoga 3.2.1. Config, Style, tree ownership and mutation, calculation on any live Node, computed output, dirty and new-layout state, and synchronous Measure callbacks are implemented. Taffy owns topology and Flex calculation; the facade retains only the Yoga declarations and public state needed to translate inputs and project Yoga-shaped outputs. diff --git a/packages/taffyjs-yoga/dist/index.d.ts b/packages/taffyjs-yoga/dist/index.d.ts index aabf516..943ab83 100644 --- a/packages/taffyjs-yoga/dist/index.d.ts +++ b/packages/taffyjs-yoga/dist/index.d.ts @@ -1,5 +1,5 @@ -import { C as Wrap, S as Unit, _ as LogLevel, a as Yoga$1, b as Overflow, c as Dimension, d as Edge, f as Errata, g as Justify, h as Gutter, i as Node, l as Direction, m as FlexDirection, n as DirtiedFunction, o as Align, p as ExperimentalFeature, r as MeasureFunction, s as BoxSizing, t as Config, u as Display, v as MeasureMode, x as PositionType, y as NodeType } from "./types-qTisbHPP.js"; +import { C as Overflow, E as Wrap, S as NodeType, T as Unit, _ as FlexDirection, a as DirtiedFunction, b as LogLevel, c as Yoga$1, d as Dimension, f as Direction, g as ExperimentalFeature, h as Errata, i as Config, l as Align, m as Edge, n as AlignItemsValue, o as MeasureFunction, p as Display, r as AlignSelfValue, s as Node, t as AlignContentValue, u as BoxSizing, v as Gutter, w as PositionType, x as MeasureMode, y as Justify } from "./types-CQnSOAjF.js"; //#region src/index.d.ts declare const Yoga: Yoga$1; //#endregion -export { Align, BoxSizing, type Config, Dimension, Direction, type DirtiedFunction, Display, Edge, Errata, ExperimentalFeature, FlexDirection, Gutter, Justify, LogLevel, type MeasureFunction, MeasureMode, type Node, NodeType, Overflow, PositionType, Unit, Wrap, Yoga as default }; \ No newline at end of file +export { Align, type AlignContentValue, type AlignItemsValue, type AlignSelfValue, BoxSizing, type Config, Dimension, Direction, type DirtiedFunction, Display, Edge, Errata, ExperimentalFeature, FlexDirection, Gutter, Justify, LogLevel, type MeasureFunction, MeasureMode, type Node, NodeType, Overflow, PositionType, Unit, Wrap, Yoga as default }; \ No newline at end of file diff --git a/packages/taffyjs-yoga/dist/load.d.ts b/packages/taffyjs-yoga/dist/load.d.ts index 3e6f6c8..01f5654 100644 --- a/packages/taffyjs-yoga/dist/load.d.ts +++ b/packages/taffyjs-yoga/dist/load.d.ts @@ -1,5 +1,5 @@ -import { C as Wrap, S as Unit, _ as LogLevel, a as Yoga, b as Overflow, c as Dimension, d as Edge, f as Errata, g as Justify, h as Gutter, i as Node, l as Direction, m as FlexDirection, n as DirtiedFunction, o as Align, p as ExperimentalFeature, r as MeasureFunction, s as BoxSizing, t as Config, u as Display, v as MeasureMode, x as PositionType, y as NodeType } from "./types-qTisbHPP.js"; +import { C as Overflow, E as Wrap, S as NodeType, T as Unit, _ as FlexDirection, a as DirtiedFunction, b as LogLevel, c as Yoga, d as Dimension, f as Direction, g as ExperimentalFeature, h as Errata, i as Config, l as Align, m as Edge, n as AlignItemsValue, o as MeasureFunction, p as Display, r as AlignSelfValue, s as Node, t as AlignContentValue, u as BoxSizing, v as Gutter, w as PositionType, x as MeasureMode, y as Justify } from "./types-CQnSOAjF.js"; //#region src/load.d.ts declare function loadYoga(): Promise; //#endregion -export { Align, BoxSizing, type Config, Dimension, Direction, type DirtiedFunction, Display, Edge, Errata, ExperimentalFeature, FlexDirection, Gutter, Justify, LogLevel, type MeasureFunction, MeasureMode, type Node, NodeType, Overflow, PositionType, Unit, Wrap, type Yoga, loadYoga }; \ No newline at end of file +export { Align, type AlignContentValue, type AlignItemsValue, type AlignSelfValue, BoxSizing, type Config, Dimension, Direction, type DirtiedFunction, Display, Edge, Errata, ExperimentalFeature, FlexDirection, Gutter, Justify, LogLevel, type MeasureFunction, MeasureMode, type Node, NodeType, Overflow, PositionType, Unit, Wrap, type Yoga, loadYoga }; \ No newline at end of file diff --git a/packages/taffyjs-yoga/dist/types-qTisbHPP.d.ts b/packages/taffyjs-yoga/dist/types-CQnSOAjF.d.ts similarity index 93% rename from packages/taffyjs-yoga/dist/types-qTisbHPP.d.ts rename to packages/taffyjs-yoga/dist/types-CQnSOAjF.d.ts index 9cd0002..1c368f7 100644 --- a/packages/taffyjs-yoga/dist/types-qTisbHPP.d.ts +++ b/packages/taffyjs-yoga/dist/types-CQnSOAjF.d.ts @@ -188,8 +188,11 @@ interface Value { type DirtiedFunction = (node: Node) => void; type MeasureFunction = (width: number, widthMode: MeasureMode, height: number, heightMode: MeasureMode) => Size; type LayoutDimension = number | "auto" | undefined; +/** Values supported by {@link Node.setAlignContent}. */ type AlignContentValue = Align.FlexStart | Align.Center | Align.FlexEnd | Align.Stretch | Align.SpaceBetween | Align.SpaceAround | Align.SpaceEvenly; +/** Values supported by {@link Node.setAlignItems}. */ type AlignItemsValue = Align.FlexStart | Align.Center | Align.FlexEnd | Align.Stretch | Align.Baseline; +/** Values supported by {@link Node.setAlignSelf}. */ type AlignSelfValue = Align.Auto | AlignItemsValue; interface Config { free(): void; @@ -318,4 +321,4 @@ type Yoga = { Node: NodeFactory; } & typeof legacyConstants; //#endregion -export { Wrap as C, Unit as S, LogLevel as _, Yoga as a, Overflow as b, Dimension as c, Edge as d, Errata as f, Justify as g, Gutter as h, Node as i, Direction as l, FlexDirection as m, DirtiedFunction as n, Align as o, ExperimentalFeature as p, MeasureFunction as r, BoxSizing as s, Config as t, Display as u, MeasureMode as v, PositionType as x, NodeType as y }; \ No newline at end of file +export { Overflow as C, Wrap as E, NodeType as S, Unit as T, FlexDirection as _, DirtiedFunction as a, LogLevel as b, Yoga as c, Dimension as d, Direction as f, ExperimentalFeature as g, Errata as h, Config as i, Align as l, Edge as m, AlignItemsValue as n, MeasureFunction as o, Display as p, AlignSelfValue as r, Node as s, AlignContentValue as t, BoxSizing as u, Gutter as v, PositionType as w, MeasureMode as x, Justify as y }; \ No newline at end of file diff --git a/packages/taffyjs-yoga/src/index.ts b/packages/taffyjs-yoga/src/index.ts index 06fad92..c7c61e8 100644 --- a/packages/taffyjs-yoga/src/index.ts +++ b/packages/taffyjs-yoga/src/index.ts @@ -18,7 +18,15 @@ export { Unit, Wrap, } from "./enums.js"; -export type { Config, DirtiedFunction, MeasureFunction, Node } from "./types.js"; +export type { + AlignContentValue, + AlignItemsValue, + AlignSelfValue, + Config, + DirtiedFunction, + MeasureFunction, + Node, +} from "./types.js"; import { createYoga } from "./facade.js"; diff --git a/packages/taffyjs-yoga/src/load.ts b/packages/taffyjs-yoga/src/load.ts index 78ec63b..8554748 100644 --- a/packages/taffyjs-yoga/src/load.ts +++ b/packages/taffyjs-yoga/src/load.ts @@ -18,7 +18,16 @@ export { Unit, Wrap, } from "./enums.js"; -export type { Config, DirtiedFunction, MeasureFunction, Node, Yoga } from "./types.js"; +export type { + AlignContentValue, + AlignItemsValue, + AlignSelfValue, + Config, + DirtiedFunction, + MeasureFunction, + Node, + Yoga, +} from "./types.js"; import { createYoga } from "./facade.js"; import type { Yoga } from "./types.js"; diff --git a/packages/taffyjs-yoga/src/types.ts b/packages/taffyjs-yoga/src/types.ts index 66fa488..6a8981a 100644 --- a/packages/taffyjs-yoga/src/types.ts +++ b/packages/taffyjs-yoga/src/types.ts @@ -47,6 +47,7 @@ export type MeasureFunction = ( export type LayoutDimension = number | "auto" | undefined; +/** Values supported by {@link Node.setAlignContent}. */ export type AlignContentValue = | Align.FlexStart | Align.Center @@ -56,6 +57,7 @@ export type AlignContentValue = | Align.SpaceAround | Align.SpaceEvenly; +/** Values supported by {@link Node.setAlignItems}. */ export type AlignItemsValue = | Align.FlexStart | Align.Center @@ -63,6 +65,7 @@ export type AlignItemsValue = | Align.Stretch | Align.Baseline; +/** Values supported by {@link Node.setAlignSelf}. */ export type AlignSelfValue = Align.Auto | AlignItemsValue; export interface Config { diff --git a/tests/taffyjs-yoga/style.test.mts b/tests/taffyjs-yoga/style.test.mts index 4d8980a..debf622 100644 --- a/tests/taffyjs-yoga/style.test.mts +++ b/tests/taffyjs-yoga/style.test.mts @@ -377,8 +377,14 @@ test("invalid and unsupported dynamic Style inputs fail without changing declara const node = Yoga.Node.create(); node.setWidth(25); node.setMargin(Edge.Left, 4); + node.setAlignContent(Align.Center); + node.setAlignItems(Align.Center); + node.setAlignSelf(Align.Center); const width = node.getWidth(); const margin = node.getMargin(Edge.Left); + const alignContent = node.getAlignContent(); + const alignItems = node.getAlignItems(); + const alignSelf = node.getAlignSelf(); try { assert.throws(() => node.setWidth("12px" as never), TypeError); assert.throws(() => node.setWidth({ unit: Unit.Point, value: 12 } as never), TypeError); @@ -392,6 +398,9 @@ test("invalid and unsupported dynamic Style inputs fail without changing declara assert.throws(() => node.setGap(99 as never, 10), TypeError); assert.deepEqual(node.getWidth(), width); assert.deepEqual(node.getMargin(Edge.Left), margin); + assert.equal(node.getAlignContent(), alignContent); + assert.equal(node.getAlignItems(), alignItems); + assert.equal(node.getAlignSelf(), alignSelf); const { loadYoga } = await import("yoga-layout/load"); const otherFacade = await loadYoga(); diff --git a/tests/taffyjs-yoga/types/public-api.test-d.ts b/tests/taffyjs-yoga/types/public-api.test-d.ts index f9b1461..7cdd747 100644 --- a/tests/taffyjs-yoga/types/public-api.test-d.ts +++ b/tests/taffyjs-yoga/types/public-api.test-d.ts @@ -13,6 +13,9 @@ import Yoga, { PositionType, Unit, Wrap, + type AlignContentValue, + type AlignItemsValue, + type AlignSelfValue, type Config, type DirtiedFunction, type MeasureFunction, @@ -20,6 +23,9 @@ import Yoga, { } from "yoga-layout"; import { loadYoga, + type AlignContentValue as LoadedAlignContentValue, + type AlignItemsValue as LoadedAlignItemsValue, + type AlignSelfValue as LoadedAlignSelfValue, type Yoga as LoadedYoga, type Config as LoadedConfig, type Node as LoadedNode, @@ -33,6 +39,40 @@ type Equal = type Assert = Value; type ConfigMethodNamesMatch = Assert>; type NodeMethodNamesMatch = Assert>; +type AlignValueSetsMatch = Assert< + Equal< + [AlignContentValue, AlignItemsValue, AlignSelfValue], + [ + ( + | Align.FlexStart + | Align.Center + | Align.FlexEnd + | Align.Stretch + | Align.SpaceBetween + | Align.SpaceAround + | Align.SpaceEvenly + ), + Align.FlexStart | Align.Center | Align.FlexEnd | Align.Stretch | Align.Baseline, + Align.Auto | Align.FlexStart | Align.Center | Align.FlexEnd | Align.Stretch | Align.Baseline, + ] + > +>; +type AlignSetterParametersMatch = Assert< + Equal< + [ + Parameters[0], + Parameters[0], + Parameters[0], + ], + [AlignContentValue, AlignItemsValue, AlignSelfValue] + > +>; +type LoadedAlignValuesMatch = Assert< + Equal< + [LoadedAlignContentValue, LoadedAlignItemsValue, LoadedAlignSelfValue], + [AlignContentValue, AlignItemsValue, AlignSelfValue] + > +>; type LegacyAlignConstantIsBroad = Assert>; declare const replacementConfigFactory: typeof Yoga.Config; @@ -59,6 +99,21 @@ const errata: Errata = config.getErrata(); node.setAlignContent(Align.SpaceEvenly); node.setAlignItems(Align.Baseline); node.setAlignSelf(Align.Auto); +declare const alignContentValue: AlignContentValue; +declare const alignItemsValue: AlignItemsValue; +declare const alignSelfValue: AlignSelfValue; +node.setAlignContent(alignContentValue); +node.setAlignItems(alignItemsValue); +node.setAlignSelf(alignSelfValue); +const forwardAlignContent = (value: Value): void => { + node.setAlignContent(value); +}; +const forwardAlignItems = (value: Value): void => { + node.setAlignItems(value); +}; +const forwardAlignSelf = (value: Value): void => { + node.setAlignSelf(value); +}; node.setAspectRatio(2); node.setBorder(Edge.All, 1); node.setDirection(Direction.Inherit); @@ -150,6 +205,24 @@ node.setAlignContent(Align.Auto); node.setAlignItems(Align.SpaceBetween); // @ts-expect-error Distribution values are not valid align-self values. node.setAlignSelf(Align.SpaceEvenly); +declare const broadAlign: Align; +// @ts-expect-error A broad Align may contain a value unsupported by align-content. +node.setAlignContent(broadAlign); +// @ts-expect-error A broad Align may contain a value unsupported by align-items. +node.setAlignItems(broadAlign); +// @ts-expect-error A broad Align may contain a value unsupported by align-self. +node.setAlignSelf(broadAlign); +declare const mixedItemsAlign: Align.Center | Align.SpaceBetween; +// @ts-expect-error The union still contains an align-items value that is unsupported. +node.setAlignItems(mixedItemsAlign); +const forwardBroadAlign = (value: Value): void => { + // @ts-expect-error A broad generic may contain a value unsupported by align-content. + node.setAlignContent(value); + // @ts-expect-error A broad generic may contain a value unsupported by align-items. + node.setAlignItems(value); + // @ts-expect-error A broad generic may contain a value unsupported by align-self. + node.setAlignSelf(value); +}; // @ts-expect-error Static is omitted and its numeric value is rejected. node.setPositionType(0); // @ts-expect-error Value-like objects are not accepted by generic setters. @@ -189,5 +262,17 @@ void [ 0 as unknown as RootYoga, 0 as unknown as ConfigMethodNamesMatch, 0 as unknown as NodeMethodNamesMatch, + 0 as unknown as AlignValueSetsMatch, + 0 as unknown as AlignSetterParametersMatch, + 0 as unknown as LoadedAlignValuesMatch, 0 as unknown as LegacyAlignConstantIsBroad, + alignContentValue, + alignItemsValue, + alignSelfValue, + broadAlign, + mixedItemsAlign, + forwardAlignContent, + forwardAlignItems, + forwardAlignSelf, + forwardBroadAlign, ]; From d6813695f435e991d42a6b1deca9c022dabad76e Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 16 Aug 2026 03:16:15 +0800 Subject: [PATCH 13/18] Document Yoga evolution policy --- .agents/docs/taffyjs-yoga-decisions.md | 24 ++++++++++++++++++++++++ .agents/docs/taffyjs-yoga-reference.md | 17 ++++++++++++++++- packages/taffyjs-yoga/COMPATIBILITY.md | 10 ++++++++++ packages/taffyjs-yoga/README.md | 4 ++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/.agents/docs/taffyjs-yoga-decisions.md b/.agents/docs/taffyjs-yoga-decisions.md index ee89780..72dc88f 100644 --- a/.agents/docs/taffyjs-yoga-decisions.md +++ b/.agents/docs/taffyjs-yoga-decisions.md @@ -182,6 +182,30 @@ This ledger records only judgments that Yunfei explicitly expressed about `@taff **Source:** Yunfei (`@hyfdev`), 2026-08-14; required differences to be documented, accepted explicit unsupported behavior, vouched the original consolidated verification rules, and later clarified that the base design must not preselect Ink or another external consumer. +### Upstream work and current compatibility + +[VOUCHED @hyfdev 2026-08-16] + +**Ruling:** Published compatibility classifications must describe only the behavior available through the Taffy version actually pinned by `@taffyjs/yoga`; relevant upstream implementation work must be shown separately as work in progress without changing the current Compatible, Different, or Unsupported classification. + +**Limits:** An upstream issue or pull request is evidence of active work, not shipped support, a delivery promise, or an ETA. Reclassification requires the upstream capability to land in a released Taffy version, the required native API to be adopted and exposed by `@taffyjs/node`, and the affected Yoga behavior to pass differential verification with updated documentation and regression fixtures. Tracked upstream work must be updated when it merges, closes, or is replaced. Under the current Taffy 0.13 pin, `PositionType.Static` remains Unsupported while [DioxusLabs/taffy#1009](https://github.com/DioxusLabs/taffy/pull/1009) is open upstream. + +**Why:** Users should be able to distinguish a capability with a credible upstream implementation path from one with no known path, without mistaking unshipped work for something they can use today. + +**Source:** Yunfei (`@hyfdev`), 2026-08-16; explicitly required active upstream support work such as DioxusLabs/taffy#1009 to appear in the compatibility documentation and vouched this separation from current compatibility status. + +### Pre-1.0 versioning + +[VOUCHED @hyfdev 2026-08-16] + +**Ruling:** `@taffyjs/yoga` will remain on the `0.x` version line for the foreseeable future; before 1.0, every public breaking change, including a Yoga-alignment fix that changes supported observable behavior, must bump the minor version, while a fix that preserves the public behavior contract bumps the patch version. + +**Limits:** This policy governs only pre-1.0 releases and does not promise that 1.0 will retain the same versioning convention or set a timetable for 1.0. The required bump follows the observable effect of `@taffyjs/yoga`, not whether Taffy or Yoga labels an upstream change as a fix. Internal implementation, performance, cache, recomputation, exact error wording, and callback-trace changes may remain patch-level when they stay outside the compatibility contract and preserve supported results. The versioning of additive features is not decided by this ruling. A release containing both patch-level fixes and any breaking behavior change requires a minor bump. + +**Why:** Correcting a Yoga mismatch is necessary, but consumers may depend on the package's previous layout or API behavior even when that behavior was a bug; a minor release makes that change explicit while keeping contract-preserving fixes available through patch upgrades. + +**Source:** Yunfei (`@hyfdev`), 2026-08-16; explicitly chose a long-lived `0.x` line, minor bumps for behavior-changing compatibility fixes and other breaking changes, patch bumps for other fixes, and deferred any guarantee about post-1.0 policy. + ### Integration-test placement [VOUCHED @hyfdev 2026-08-15] diff --git a/.agents/docs/taffyjs-yoga-reference.md b/.agents/docs/taffyjs-yoga-reference.md index 6ee1892..9bf263a 100644 --- a/.agents/docs/taffyjs-yoga-reference.md +++ b/.agents/docs/taffyjs-yoga-reference.md @@ -258,7 +258,7 @@ No first-release feasibility blocker remains in the inventoried Yoga 3.2.1 surfa | Display.Flex and Display.None | Compatible | Direct Taffy mapping. | | Display.Contents | Unsupported | Taffy 0.13 has no contents box. Preserving Yoga semantics would require a shadow or repeatedly rewritten computation topology, which conflicts with the bounded JavaScript layer and stable node ownership. Omit the member and legacy constant and reject a dynamic numeric value. | | PositionType.Relative and PositionType.Absolute | Compatible | Direct Taffy mapping. | -| PositionType.Static | Unsupported | Omit the member and POSITION_TYPE_STATIC and reject a dynamic zero, as recorded in the decision ledger. | +| PositionType.Static | Unsupported | Omit the member and POSITION_TYPE_STATIC and reject a dynamic zero, as recorded in the decision ledger. Taffy has active upstream implementation work, tracked separately below; it is not available through the current 0.13 pin. | | Errata.None | Compatible | It selects Yoga's modern default behavior and requires no additional Taffy mode. | | Errata.StretchFlexBasis, AbsolutePositionWithoutInsetsExcludesPadding, AbsolutePercentAgainstInnerSize, All, and Classic | Unsupported | These values request Yoga-specific algorithm branches and combinations for which Taffy exposes no matching switch. Omit the members and legacy constants, narrow setErrata to None, and reject dynamically supplied nonzero values rather than retaining state that native layout ignores. | | ExperimentalFeature.WebFlexBasis | Compatible | Preserve the enum and boolean Config state. The disabled Yoga cache behavior has a separate Different result row rather than making the enum or state API unavailable. | @@ -460,8 +460,23 @@ For supported APIs, @taffyjs/yoga preserves Yoga 3.2.1's published declaration s - calculateLayout requires width and height arguments in TypeScript while allowing each to be explicitly undefined; the runtime wrapper also permits the arguments to be omitted and supplies NaN, NaN, and LTR defaults. - The generic setter wrapper attempts to accept Value-like objects and comments that a getter result can be passed back to a setter, but the published Value is a plain object without a numeric valueOf implementation. A published 3.2.1 getWidth-to-setWidth round trip resets the value to Unit.Undefined rather than preserving it. +## Upstream evolution + +The capability matrix describes the dependency version that `@taffyjs/yoga` actually pins. Upstream work in progress is an independent maintenance signal, not a fourth compatibility classification and not a promise that the package will adopt the result. + +| Yoga capability | Current status under Taffy 0.13 | Upstream work | Adoption gate | +| --------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PositionType.Static` | Unsupported | [DioxusLabs/taffy#1009](https://github.com/DioxusLabs/taffy/pull/1009) is open as of 2026-08-16 and proposes `Position::Static`, deferred containing-block layout, and related absolute-positioning infrastructure. | The capability must ship in a released Taffy version, `@taffyjs/node` must adopt and expose the required native surface without Yoga-specific core changes, and Yoga 3.2.1 differential fixtures must verify the full Static behavior before reclassification. | + +Merging an upstream pull request is therefore necessary but not sufficient. Its status must be refreshed before release work or a Taffy dependency update, and the current compatibility row remains authoritative until all adoption gates pass. + +## Pre-1.0 release policy + +`@taffyjs/yoga` intentionally remains on the `0.x` version line for the foreseeable future. Before 1.0, any public breaking change requires a minor bump, including a compatibility fix that changes a supported layout result or other observable behavior. A fix may use a patch bump only when it preserves the public behavior contract. Classify the package's effect rather than copying the upstream change label; [the decision ledger](taffyjs-yoga-decisions.md#pre-10-versioning) defines the boundary and leaves post-1.0 policy open. + ## Ongoing maintenance - Re-audit the complete capability matrix before deliberately adopting a Yoga version newer than 3.2.1; no supported release may contain an unclassified method, value, or result trigger. +- Refresh every tracked upstream-work marker before a release or Taffy dependency update. A merged upstream change remains unavailable until the native boundary, behavior evidence, documentation, and regression fixtures are updated together. - Run one actual `yoga-layout` registry-alias installation smoke test after @taffyjs/yoga is published. This cannot be completed against the current private, unpublished package and does not justify a separate tarball-install harness. - Treat cache, batch, selective-query, compact-transport, and mixed measured-tree overhead as later performance work that requires a representative consumer workload and retained end-to-end measurements. diff --git a/packages/taffyjs-yoga/COMPATIBILITY.md b/packages/taffyjs-yoga/COMPATIBILITY.md index 88440fd..409b238 100644 --- a/packages/taffyjs-yoga/COMPATIBILITY.md +++ b/packages/taffyjs-yoga/COMPATIBILITY.md @@ -59,6 +59,16 @@ When an upstream enum type also contains values that are Unsupported for one pro | Positive or negative infinity as a `calculateLayout` owner dimension | Throws before calculation or public-state mutation. Finite numbers, `"auto"`, `undefined`, omitted values, and NaN remain supported. | Yoga's resulting NaN or zero geometry is not a credible available-space contract. | | Undeclared generic-setter coercions | Malformed strings, arbitrary objects, and Value-like getter results supplied to setters throw atomically. | Yoga's broad wrapper coercion is outside its published types and its own getter-to-setter path is broken in 3.2.1. | +## Upstream work in progress + +The compatibility tables describe what the current package can do with its pinned Taffy 0.13 dependency. Upstream work is tracked separately so that a possible path to support is visible without presenting unshipped code as available behavior. + +| Capability | Current classification | Upstream status | What must happen before support | +| --------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PositionType.Static` | Unsupported | [DioxusLabs/taffy#1009](https://github.com/DioxusLabs/taffy/pull/1009) is open as of 2026-08-16. It proposes native `Position::Static` support and the containing-block machinery needed by absolute descendants of static nodes. | The change must ship in a released Taffy version, `@taffyjs/node` must update and expose the needed native capability, and Yoga 3.2.1 differential tests must verify the complete observable behavior. Until then, the Unsupported row above is authoritative. | + +An upstream pull request is not a support promise or an ETA. Merging it does not reclassify the capability automatically; adoption and compatibility verification are separate work. + ## Known Differences These rows are deliberate, version-specific differences with regression fixtures: diff --git a/packages/taffyjs-yoga/README.md b/packages/taffyjs-yoga/README.md index 67616d7..b795e5d 100644 --- a/packages/taffyjs-yoga/README.md +++ b/packages/taffyjs-yoga/README.md @@ -77,3 +77,7 @@ A broad `Align` variable must be narrowed before being passed to one of these se The compatibility baseline is exactly Yoga 3.2.1. Config, Style, tree ownership and mutation, calculation on any live Node, computed output, dirty and new-layout state, and synchronous Measure callbacks are implemented. Taffy owns topology and Flex calculation; the facade retains only the Yoga declarations and public state needed to translate inputs and project Yoga-shaped outputs. This is core behavioral compatibility, not a claim that Taffy and Yoga produce identical geometry for every degenerate Flex combination or that their internal cache and callback traces are identical. [COMPATIBILITY.md](COMPATIBILITY.md) classifies the complete public surface and lists every known Difference and Unsupported trigger. [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) contains the required third-party notice for the Yoga PixelGrid logic used by the facade. + +## Versioning before 1.0 + +`@taffyjs/yoga` will remain on the `0.x` version line for the foreseeable future. During that period, any public breaking change uses a minor release, including a Yoga-alignment fix that changes a supported layout result or other observable behavior. Fixes that preserve the public behavior contract use patch releases. This is the pre-1.0 contract only; the policy will be reconsidered before a 1.0 release. From c00286afa34082765b7d786d8bdd3b34c73adabc Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 16 Aug 2026 11:38:53 +0800 Subject: [PATCH 14/18] Deduplicate Yoga evolution reference --- .agents/docs/taffyjs-yoga-reference.md | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/.agents/docs/taffyjs-yoga-reference.md b/.agents/docs/taffyjs-yoga-reference.md index 9bf263a..6000b48 100644 --- a/.agents/docs/taffyjs-yoga-reference.md +++ b/.agents/docs/taffyjs-yoga-reference.md @@ -258,7 +258,7 @@ No first-release feasibility blocker remains in the inventoried Yoga 3.2.1 surfa | Display.Flex and Display.None | Compatible | Direct Taffy mapping. | | Display.Contents | Unsupported | Taffy 0.13 has no contents box. Preserving Yoga semantics would require a shadow or repeatedly rewritten computation topology, which conflicts with the bounded JavaScript layer and stable node ownership. Omit the member and legacy constant and reject a dynamic numeric value. | | PositionType.Relative and PositionType.Absolute | Compatible | Direct Taffy mapping. | -| PositionType.Static | Unsupported | Omit the member and POSITION_TYPE_STATIC and reject a dynamic zero, as recorded in the decision ledger. Taffy has active upstream implementation work, tracked separately below; it is not available through the current 0.13 pin. | +| PositionType.Static | Unsupported | Omit the member and POSITION_TYPE_STATIC and reject a dynamic zero, as recorded in the decision ledger. Current upstream implementation work is tracked in the [public compatibility document](../../packages/taffyjs-yoga/COMPATIBILITY.md#upstream-work-in-progress); it is not available through the current 0.13 pin. | | Errata.None | Compatible | It selects Yoga's modern default behavior and requires no additional Taffy mode. | | Errata.StretchFlexBasis, AbsolutePositionWithoutInsetsExcludesPadding, AbsolutePercentAgainstInnerSize, All, and Classic | Unsupported | These values request Yoga-specific algorithm branches and combinations for which Taffy exposes no matching switch. Omit the members and legacy constants, narrow setErrata to None, and reject dynamically supplied nonzero values rather than retaining state that native layout ignores. | | ExperimentalFeature.WebFlexBasis | Compatible | Preserve the enum and boolean Config state. The disabled Yoga cache behavior has a separate Different result row rather than making the enum or state API unavailable. | @@ -460,23 +460,9 @@ For supported APIs, @taffyjs/yoga preserves Yoga 3.2.1's published declaration s - calculateLayout requires width and height arguments in TypeScript while allowing each to be explicitly undefined; the runtime wrapper also permits the arguments to be omitted and supplies NaN, NaN, and LTR defaults. - The generic setter wrapper attempts to accept Value-like objects and comments that a getter result can be passed back to a setter, but the published Value is a plain object without a numeric valueOf implementation. A published 3.2.1 getWidth-to-setWidth round trip resets the value to Unit.Undefined rather than preserving it. -## Upstream evolution - -The capability matrix describes the dependency version that `@taffyjs/yoga` actually pins. Upstream work in progress is an independent maintenance signal, not a fourth compatibility classification and not a promise that the package will adopt the result. - -| Yoga capability | Current status under Taffy 0.13 | Upstream work | Adoption gate | -| --------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PositionType.Static` | Unsupported | [DioxusLabs/taffy#1009](https://github.com/DioxusLabs/taffy/pull/1009) is open as of 2026-08-16 and proposes `Position::Static`, deferred containing-block layout, and related absolute-positioning infrastructure. | The capability must ship in a released Taffy version, `@taffyjs/node` must adopt and expose the required native surface without Yoga-specific core changes, and Yoga 3.2.1 differential fixtures must verify the full Static behavior before reclassification. | - -Merging an upstream pull request is therefore necessary but not sufficient. Its status must be refreshed before release work or a Taffy dependency update, and the current compatibility row remains authoritative until all adoption gates pass. - -## Pre-1.0 release policy - -`@taffyjs/yoga` intentionally remains on the `0.x` version line for the foreseeable future. Before 1.0, any public breaking change requires a minor bump, including a compatibility fix that changes a supported layout result or other observable behavior. A fix may use a patch bump only when it preserves the public behavior contract. Classify the package's effect rather than copying the upstream change label; [the decision ledger](taffyjs-yoga-decisions.md#pre-10-versioning) defines the boundary and leaves post-1.0 policy open. - ## Ongoing maintenance - Re-audit the complete capability matrix before deliberately adopting a Yoga version newer than 3.2.1; no supported release may contain an unclassified method, value, or result trigger. -- Refresh every tracked upstream-work marker before a release or Taffy dependency update. A merged upstream change remains unavailable until the native boundary, behavior evidence, documentation, and regression fixtures are updated together. +- Treat the [public compatibility document](../../packages/taffyjs-yoga/COMPATIBILITY.md#upstream-work-in-progress) as the only current list of tracked upstream work. Refresh it before a release or Taffy dependency update; a merged upstream change remains unavailable until the native boundary, behavior evidence, documentation, and regression fixtures are updated together. - Run one actual `yoga-layout` registry-alias installation smoke test after @taffyjs/yoga is published. This cannot be completed against the current private, unpublished package and does not justify a separate tarball-install harness. - Treat cache, batch, selective-query, compact-transport, and mixed measured-tree overhead as later performance work that requires a representative consumer workload and retained end-to-end measurements. From b08f3421208488b355c119e7b437b46211410fa9 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 16 Aug 2026 12:16:12 +0800 Subject: [PATCH 15/18] Refine Yoga evolution policy --- .agents/docs/taffyjs-yoga-decisions.md | 8 +++----- packages/taffyjs-yoga/COMPATIBILITY.md | 11 ++++++----- packages/taffyjs-yoga/README.md | 2 +- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.agents/docs/taffyjs-yoga-decisions.md b/.agents/docs/taffyjs-yoga-decisions.md index 72dc88f..9f837cb 100644 --- a/.agents/docs/taffyjs-yoga-decisions.md +++ b/.agents/docs/taffyjs-yoga-decisions.md @@ -184,15 +184,13 @@ This ledger records only judgments that Yunfei explicitly expressed about `@taff ### Upstream work and current compatibility -[VOUCHED @hyfdev 2026-08-16] - **Ruling:** Published compatibility classifications must describe only the behavior available through the Taffy version actually pinned by `@taffyjs/yoga`; relevant upstream implementation work must be shown separately as work in progress without changing the current Compatible, Different, or Unsupported classification. -**Limits:** An upstream issue or pull request is evidence of active work, not shipped support, a delivery promise, or an ETA. Reclassification requires the upstream capability to land in a released Taffy version, the required native API to be adopted and exposed by `@taffyjs/node`, and the affected Yoga behavior to pass differential verification with updated documentation and regression fixtures. Tracked upstream work must be updated when it merges, closes, or is replaced. Under the current Taffy 0.13 pin, `PositionType.Static` remains Unsupported while [DioxusLabs/taffy#1009](https://github.com/DioxusLabs/taffy/pull/1009) is open upstream. +**Limits:** An upstream issue or pull request is evidence of active work, not shipped support, a delivery promise, or an ETA. Reclassification requires the upstream capability to land in a released Taffy version, the required native API to be adopted and exposed by `@taffyjs/node`, and the affected Yoga behavior to pass differential verification with updated documentation and regression fixtures. Current upstream-work entries and statuses live only in the [public compatibility document](../../packages/taffyjs-yoga/COMPATIBILITY.md#upstream-work-in-progress) and must be refreshed when work merges, closes, or is replaced. -**Why:** Users should be able to distinguish a capability with a credible upstream implementation path from one with no known path, without mistaking unshipped work for something they can use today. +**Why:** Yunfei required active support work to appear in the compatibility documentation; no additional rationale was stated. -**Source:** Yunfei (`@hyfdev`), 2026-08-16; explicitly required active upstream support work such as DioxusLabs/taffy#1009 to appear in the compatibility documentation and vouched this separation from current compatibility status. +**Source:** Yunfei (`@hyfdev`), 2026-08-16; explicitly required relevant work in progress to appear in the compatibility documentation, used [DioxusLabs/taffy#1009](https://github.com/DioxusLabs/taffy/pull/1009) as the concrete example, and instructed that direction to be recorded as vouched. ### Pre-1.0 versioning diff --git a/packages/taffyjs-yoga/COMPATIBILITY.md b/packages/taffyjs-yoga/COMPATIBILITY.md index 409b238..7eff7a0 100644 --- a/packages/taffyjs-yoga/COMPATIBILITY.md +++ b/packages/taffyjs-yoga/COMPATIBILITY.md @@ -61,13 +61,14 @@ When an upstream enum type also contains values that are Unsupported for one pro ## Upstream work in progress -The compatibility tables describe what the current package can do with its pinned Taffy 0.13 dependency. Upstream work is tracked separately so that a possible path to support is visible without presenting unshipped code as available behavior. +The compatibility tables describe what the current package can do with its pinned Taffy 0.13 dependency. Upstream work is tracked separately so that a possible compatibility improvement is visible without presenting unshipped code as available behavior. -| Capability | Current classification | Upstream status | What must happen before support | -| --------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PositionType.Static` | Unsupported | [DioxusLabs/taffy#1009](https://github.com/DioxusLabs/taffy/pull/1009) is open as of 2026-08-16. It proposes native `Position::Static` support and the containing-block machinery needed by absolute descendants of static nodes. | The change must ship in a released Taffy version, `@taffyjs/node` must update and expose the needed native capability, and Yoga 3.2.1 differential tests must verify the complete observable behavior. Until then, the Unsupported row above is authoritative. | +| Capability | Current classification | Upstream status | +| --------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Display.Contents` | Unsupported | [DioxusLabs/taffy#944](https://github.com/DioxusLabs/taffy/pull/944) is open as of 2026-08-16. It proposes native `Display::Contents` support and recursive child flattening in `TaffyTree`. | +| `PositionType.Static` | Unsupported | [DioxusLabs/taffy#1009](https://github.com/DioxusLabs/taffy/pull/1009) is open as of 2026-08-16. It proposes native `Position::Static` support and the containing-block machinery needed by absolute descendants of static nodes. | -An upstream pull request is not a support promise or an ETA. Merging it does not reclassify the capability automatically; adoption and compatibility verification are separate work. +An upstream pull request is not a support promise or an ETA. Before reclassification, the capability must ship in a released Taffy version, `@taffyjs/node` must update and expose the needed native surface, and Yoga 3.2.1 differential tests must verify the complete observable behavior. Until then, each current classification above remains authoritative. ## Known Differences diff --git a/packages/taffyjs-yoga/README.md b/packages/taffyjs-yoga/README.md index b795e5d..c533f1d 100644 --- a/packages/taffyjs-yoga/README.md +++ b/packages/taffyjs-yoga/README.md @@ -80,4 +80,4 @@ This is core behavioral compatibility, not a claim that Taffy and Yoga produce i ## Versioning before 1.0 -`@taffyjs/yoga` will remain on the `0.x` version line for the foreseeable future. During that period, any public breaking change uses a minor release, including a Yoga-alignment fix that changes a supported layout result or other observable behavior. Fixes that preserve the public behavior contract use patch releases. This is the pre-1.0 contract only; the policy will be reconsidered before a 1.0 release. +`@taffyjs/yoga` will remain on the `0.x` version line for the foreseeable future. During that period, any public breaking change uses a minor release, including a Yoga-alignment fix that changes a supported layout result or other observable behavior. Fixes that preserve the public behavior contract use patch releases. This contract applies only to `0.x` releases and makes no promise about 1.0. From 47e05f6d57de3eb2734eb7692873278ed2148b68 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 16 Aug 2026 12:27:56 +0800 Subject: [PATCH 16/18] Vouch Yoga upstream policy --- .agents/docs/taffyjs-yoga-decisions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.agents/docs/taffyjs-yoga-decisions.md b/.agents/docs/taffyjs-yoga-decisions.md index 9f837cb..d630f2e 100644 --- a/.agents/docs/taffyjs-yoga-decisions.md +++ b/.agents/docs/taffyjs-yoga-decisions.md @@ -184,6 +184,8 @@ This ledger records only judgments that Yunfei explicitly expressed about `@taff ### Upstream work and current compatibility +[VOUCHED @hyfdev 2026-08-16] + **Ruling:** Published compatibility classifications must describe only the behavior available through the Taffy version actually pinned by `@taffyjs/yoga`; relevant upstream implementation work must be shown separately as work in progress without changing the current Compatible, Different, or Unsupported classification. **Limits:** An upstream issue or pull request is evidence of active work, not shipped support, a delivery promise, or an ETA. Reclassification requires the upstream capability to land in a released Taffy version, the required native API to be adopted and exposed by `@taffyjs/node`, and the affected Yoga behavior to pass differential verification with updated documentation and regression fixtures. Current upstream-work entries and statuses live only in the [public compatibility document](../../packages/taffyjs-yoga/COMPATIBILITY.md#upstream-work-in-progress) and must be refreshed when work merges, closes, or is replaced. From 839420012958c04b44fac6ea0c5674418ecb1ddb Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 16 Aug 2026 15:48:04 +0800 Subject: [PATCH 17/18] Deduplicate Yoga upstream tracking --- .agents/docs/taffyjs-yoga-reference.md | 4 ++-- packages/taffyjs-yoga/COMPATIBILITY.md | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/docs/taffyjs-yoga-reference.md b/.agents/docs/taffyjs-yoga-reference.md index 6000b48..dd10bc9 100644 --- a/.agents/docs/taffyjs-yoga-reference.md +++ b/.agents/docs/taffyjs-yoga-reference.md @@ -258,7 +258,7 @@ No first-release feasibility blocker remains in the inventoried Yoga 3.2.1 surfa | Display.Flex and Display.None | Compatible | Direct Taffy mapping. | | Display.Contents | Unsupported | Taffy 0.13 has no contents box. Preserving Yoga semantics would require a shadow or repeatedly rewritten computation topology, which conflicts with the bounded JavaScript layer and stable node ownership. Omit the member and legacy constant and reject a dynamic numeric value. | | PositionType.Relative and PositionType.Absolute | Compatible | Direct Taffy mapping. | -| PositionType.Static | Unsupported | Omit the member and POSITION_TYPE_STATIC and reject a dynamic zero, as recorded in the decision ledger. Current upstream implementation work is tracked in the [public compatibility document](../../packages/taffyjs-yoga/COMPATIBILITY.md#upstream-work-in-progress); it is not available through the current 0.13 pin. | +| PositionType.Static | Unsupported | Omit the member and POSITION_TYPE_STATIC and reject a dynamic zero, as recorded in the decision ledger. | | Errata.None | Compatible | It selects Yoga's modern default behavior and requires no additional Taffy mode. | | Errata.StretchFlexBasis, AbsolutePositionWithoutInsetsExcludesPadding, AbsolutePercentAgainstInnerSize, All, and Classic | Unsupported | These values request Yoga-specific algorithm branches and combinations for which Taffy exposes no matching switch. Omit the members and legacy constants, narrow setErrata to None, and reject dynamically supplied nonzero values rather than retaining state that native layout ignores. | | ExperimentalFeature.WebFlexBasis | Compatible | Preserve the enum and boolean Config state. The disabled Yoga cache behavior has a separate Different result row rather than making the enum or state API unavailable. | @@ -463,6 +463,6 @@ For supported APIs, @taffyjs/yoga preserves Yoga 3.2.1's published declaration s ## Ongoing maintenance - Re-audit the complete capability matrix before deliberately adopting a Yoga version newer than 3.2.1; no supported release may contain an unclassified method, value, or result trigger. -- Treat the [public compatibility document](../../packages/taffyjs-yoga/COMPATIBILITY.md#upstream-work-in-progress) as the only current list of tracked upstream work. Refresh it before a release or Taffy dependency update; a merged upstream change remains unavailable until the native boundary, behavior evidence, documentation, and regression fixtures are updated together. +- Follow the [public upstream-work tracker](../../packages/taffyjs-yoga/COMPATIBILITY.md#upstream-work-in-progress) and its [adoption decision](taffyjs-yoga-decisions.md#upstream-work-and-current-compatibility). - Run one actual `yoga-layout` registry-alias installation smoke test after @taffyjs/yoga is published. This cannot be completed against the current private, unpublished package and does not justify a separate tarball-install harness. - Treat cache, batch, selective-query, compact-transport, and mixed measured-tree overhead as later performance work that requires a representative consumer workload and retained end-to-end measurements. diff --git a/packages/taffyjs-yoga/COMPATIBILITY.md b/packages/taffyjs-yoga/COMPATIBILITY.md index 7eff7a0..43e787b 100644 --- a/packages/taffyjs-yoga/COMPATIBILITY.md +++ b/packages/taffyjs-yoga/COMPATIBILITY.md @@ -63,10 +63,10 @@ When an upstream enum type also contains values that are Unsupported for one pro The compatibility tables describe what the current package can do with its pinned Taffy 0.13 dependency. Upstream work is tracked separately so that a possible compatibility improvement is visible without presenting unshipped code as available behavior. -| Capability | Current classification | Upstream status | -| --------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Display.Contents` | Unsupported | [DioxusLabs/taffy#944](https://github.com/DioxusLabs/taffy/pull/944) is open as of 2026-08-16. It proposes native `Display::Contents` support and recursive child flattening in `TaffyTree`. | -| `PositionType.Static` | Unsupported | [DioxusLabs/taffy#1009](https://github.com/DioxusLabs/taffy/pull/1009) is open as of 2026-08-16. It proposes native `Position::Static` support and the containing-block machinery needed by absolute descendants of static nodes. | +| Capability | Upstream status | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Display.Contents` | [DioxusLabs/taffy#944](https://github.com/DioxusLabs/taffy/pull/944) is open as of 2026-08-16. It proposes native `Display::Contents` support and recursive child flattening in `TaffyTree`. | +| `PositionType.Static` | [DioxusLabs/taffy#1009](https://github.com/DioxusLabs/taffy/pull/1009) is open as of 2026-08-16. It proposes native `Position::Static` support and the containing-block machinery needed by absolute descendants of static nodes. | An upstream pull request is not a support promise or an ETA. Before reclassification, the capability must ship in a released Taffy version, `@taffyjs/node` must update and expose the needed native surface, and Yoga 3.2.1 differential tests must verify the complete observable behavior. Until then, each current classification above remains authoritative. From 403898737889b620b97de5f53d73f107a409d337 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 16 Aug 2026 19:50:27 +0800 Subject: [PATCH 18/18] Address final Yoga review findings --- .agents/docs/README.md | 3 - .agents/docs/loop-goal.md | 34 --- .agents/docs/loop-status.md | 90 -------- .../docs/taffyjs-yoga-implementation-plan.md | 212 ------------------ .agents/docs/taffyjs-yoga-reference.md | 88 ++++---- .agents/docs/technology-stack.md | 2 - packages/taffyjs-yoga/COMPATIBILITY.md | 35 +-- ...{facade-acowLNHQ.js => facade-CjSanW_Y.js} | 28 ++- packages/taffyjs-yoga/dist/index.js | 2 +- packages/taffyjs-yoga/dist/load.js | 2 +- packages/taffyjs-yoga/src/projection.ts | 2 +- packages/taffyjs-yoga/src/translate.ts | 57 ++++- packages/taffyjs-yoga/src/values.ts | 7 + tests/taffyjs-yoga/computed-output.test.mts | 208 +++++++++++++++++ tests/taffyjs-yoga/differences.test.mts | 42 ++++ 15 files changed, 392 insertions(+), 420 deletions(-) delete mode 100644 .agents/docs/loop-goal.md delete mode 100644 .agents/docs/loop-status.md delete mode 100644 .agents/docs/taffyjs-yoga-implementation-plan.md rename packages/taffyjs-yoga/dist/{facade-acowLNHQ.js => facade-CjSanW_Y.js} (97%) diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 82f1de8..a26aa4e 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -15,7 +15,4 @@ - [@taffyjs/wasm package design](taffyjs-wasm-package.md) — public contract, artifact layout, initialization model, ownership boundary, and verification requirements for the explicit Wasm package. - [@taffyjs/yoga decisions](taffyjs-yoga-decisions.md) — compatibility decisions for replacing yoga-layout through the TaffyJS Yoga layer. - [@taffyjs/yoga implementation reference](taffyjs-yoga-reference.md) — the audited Yoga 3.2.1 public API inventory, implemented compatibility boundary, evidence, and ongoing maintenance constraints. -- [@taffyjs/yoga implementation plan](taffyjs-yoga-implementation-plan.md) — the package architecture, repository changes, milestone gates, verification strategy, and draft-PR workflow for implementing the compatibility layer. -- [@taffyjs/yoga implementation loop goal](loop-goal.md) — the unattended implementation contract that must receive a whole-file human vouch before the run starts. -- [@taffyjs/yoga implementation loop status](loop-status.md) — the unattended run's overwrite-in-place progress and final handover record. - [Tooling decisions](tooling-decisions.md) — rulings for npm scope and test workspace package naming, the repository-wide TypeScript default, JavaScript package builds, repository command orchestration, tool ownership, and demand-driven API code generation. diff --git a/.agents/docs/loop-goal.md b/.agents/docs/loop-goal.md deleted file mode 100644 index 4988bab..0000000 --- a/.agents/docs/loop-goal.md +++ /dev/null @@ -1,34 +0,0 @@ -# @taffyjs/yoga Implementation Loop Goal - -[VOUCHED @hyfdev 2026-08-15] - -## Goal - -Implement the initial Node-only, ESM-only `@taffyjs/yoga` package over `@taffyjs/node`, targeting exactly `yoga-layout@3.2.1`, and deliver the complete reviewed implementation through one observable draft pull request. - -## Authoritative direction - -The run must follow `.agents/docs/taffyjs-yoga-decisions.md`, `.agents/docs/taffyjs-yoga-reference.md`, and `.agents/docs/taffyjs-yoga-implementation-plan.md`. Vouched decisions outrank derived reference and planning text. The run may resolve implementation details with evidence but may not reinterpret or silently expand the vouched product boundary. - -## Boundaries - -- Implement only in TypeScript over the existing `@taffyjs/node` API. Do not change Taffy core, Rust binding behavior, or public or private `@taffyjs/node` APIs for Yoga. -- Do not implement Flex, Yoga cache behavior, sibling distribution, or parent-dependent layout correction in JavaScript. Use documented Different or Unsupported classifications when bounded TypeScript cannot align behavior credibly. -- Do not add browser or WASM support, a Yoga-specific generator, selective query, batch reads, compact transport, publication, or release automation. -- Keep tests under `tests/taffyjs-yoga/` without a redundant nested `tests/` or unexplained `e2e/` directory. Use official `yoga-layout@3.2.1` only as a separately aliased test oracle. -- Preserve unrelated work. Do not post GitHub comments on Yunfei's behalf, mark the PR ready, merge, publish, or make a new product decision without Yunfei. - -## Execution contract - -Complete Milestones 1 through 6 from `.agents/docs/taffyjs-yoga-implementation-plan.md` in order. Each milestone must pass its relevant checks and one fresh bounded adversarial review with no unresolved material finding, then land as one coherent commit pushed immediately to the same draft PR. When a material review finding changes the result, use the same reviewer for one targeted follow-up and then stop that review loop. - -Continue autonomously between milestones. Stop only for a genuine conflict with vouched direction, a product choice not settled by the records, a required expansion of authority, or an external blocker that focused evidence cannot resolve. Keep `.agents/docs/loop-status.md` current with completed work, evidence and review results, in-flight work, next work, blockers, and conflicts. Never edit this vouched file during the run. - -## Finish criteria - -- All six implementation milestones and their adversarial reviews are complete and pushed to the same draft PR. -- Both supported package entries and unchanged-import workspace alias work on the supported Node runtime, committed package output includes all entry files, declarations, and shared chunks, and freshness detects untracked new output as well as changed or deleted output. -- Every evaluated Yoga 3.2.1 capability is classified and represented in types, runtime, documentation, and durable evidence according to its Compatible, Different, or Unsupported status. -- No Yoga-specific core, Rust, `@taffyjs/node`, Flex, WASM, browser, generator, query, batch, compact-transport, publication, or release-automation scope was added. -- `vp run ready` and `vp run check:codegen` pass on the current supported host, and the final `loop-status.md` contains the human handover. -- The pull request remains draft and unmerged for Yunfei's decision. diff --git a/.agents/docs/loop-status.md b/.agents/docs/loop-status.md deleted file mode 100644 index accf21c..0000000 --- a/.agents/docs/loop-status.md +++ /dev/null @@ -1,90 +0,0 @@ -# @taffyjs/yoga Implementation Loop Status - -Updated: 2026-08-16 - -## State - -Milestones 1 through 6 are complete and reviewed. The implementation is undergoing its final stacked-PR review before merge. - -## Done - -- Recorded the vouched Yoga product decisions. -- Completed the Yoga 3.2.1 API and behavior reference. -- Drafted the repository implementation plan, milestone gates, PR protocol, Goal prompt, and unattended-run contract. -- Ran formatting, local-link checks, and an independent adversarial review of the plan and prompt. -- Resolved the review findings about the unattended-run contract, multi-entry shared output, untracked-output freshness, and failure ownership; the targeted follow-up found no remaining issue outside the freshness command, which was then corrected and verified against the repository's existing intent-to-add pattern. -- Committed and pushed the reviewed Milestone 0 baseline to the draft implementation PR. -- Recorded Yunfei's explicit whole-file vouch of `loop-goal.md` on 2026-08-15; the file is immutable for the implementation run. -- Added the Node-only ESM `@taffyjs/yoga` package, root and `/load` entries, every supported Yoga 3.2.1 enum and legacy constant, facade factories, private per-facade runtime ownership, and the fixed-leaf create-set-calculate-read path. -- Added the `tests/taffyjs-yoga/` consumer package with unchanged `yoga-layout` imports, a separately aliased Yoga 3.2.1 oracle, runtime and declaration coverage, facade isolation, lifetime, and hidden-state fixtures. -- Wired the package build, test, type-check, lockfile, complete `dist/` freshness check, and format-before-build aggregate-check ordering into the repository. -- Added retained Config state and lifetime behavior, Yoga and web defaults, live Config-sensitive flex defaults, experimental-feature and point-scale state, and runtime rejection for malformed or unsupported Config inputs. -- Added the complete normalized Yoga Style declaration model, all 26 classified Style getters and 43 setters, strict declared-input normalization, fresh Yoga-shaped Value records, Yoga's deliberate gap declaration/runtime mismatch, `copyStyle`, and type-level narrowing for invalid Align contexts. -- Added deterministic full-Style translation for dimensions, min/max constraints, box sizing, display, overflow, aspect ratio, direction, logical and shorthand edges, position, margin, padding, border, gaps, alignment, flex direction and wrapping, and Config-sensitive flex shorthand and longhands. Setters validate and build a candidate first, apply native Style second, and only then commit the public declaration state. -- Added differential Config and Style fixtures, invalid-input atomicity checks, retained-Config and facade-isolation checks, fixed-leaf translation cases, and a table-driven 345-case numeric boundary corpus against the pinned Yoga 3.2.1 oracle. -- Added canonical Node identity backed by the facade's single native `TaffyTree`, native-authoritative child and parent reads, strict insertion and removal validation, arbitrary selected-subtree calculation, cross-facade rejection, one-parent and acyclic constraints, and measured-leaf topology restrictions without a JavaScript child tree. -- Added deterministic `free`, iterative `freeRecursive`, factory destruction, detached-leaf `reset`, retained Config ownership, single-node free with preserved detached child subtrees, and facade poisoning after an unexpected partial multi-step native failure. -- Added Yoga-visible dirty and new-layout state, layout-seen transitions, canonical dirtied-callback arguments, post-commit callback errors, measurement markers, manual dirty restrictions, supported containing-block state, and type/runtime rejection for unsupported reference-baseline selection. -- Added one pre-calculation subtree pass that resolves inherited direction and each Node's Config revision, prepares all translated Styles before native mutation, synchronizes every stale Style, and reuses the same selected-subtree snapshot for Yoga-visible state commits. -- Added durable topology, lifecycle, state, callback, fault-injection, independent-Config, and public-type fixtures while keeping Taffy as the sole topology and layout authority. -- Added arbitrary-node calculation with exact finite root constraints, selected-root Display.None handling, guaranteed ordinary-Style restoration, facade poisoning on an unexpected restoration failure, and atomic selected-subtree output commits. -- Added Yoga-shaped output projection for root and descendant positions, right and bottom semantics, effective direction, relative and absolute insets, absolute containing-direction translation, display-none suppression, computed physical and logical edges, auto-margin masking, and per-Node point-grid rounding across mixed Configs. -- Added bounded Yoga-compatible float32 percentage resolution, explicit Taffy-offset removal for relative projection, the documented 0.0001 compatibility boundary for equivalent unrounded percentage arithmetic, and durable exact and tolerance-bound oracle fixtures. -- Added differential fixtures and public compatibility entries for every researched non-measurement Difference, including live Config cache behavior, callback artifacts, max/shrink, aspect ratio, WebFlexBasis cache behavior, attached-subtree cache behavior, overlapping physical and logical margins, oversized and ordinary WrapReverse placement, reversed overflow distribution, zero cross-size lines, and auto margins under justification or reversed axes. -- Added Yoga 3.2.1 PixelGrid source attribution, a complete packaged MIT third-party notice, and package-file coverage for the notice. -- Added one facade-level synchronous Measure bridge that dispatches retained callbacks by NodeId, keeps measured Nodes as leaves, maps known, finite, min-content, and max-content constraints to Yoga modes, preserves content-box values, normalizes callback results, and rethrows callback failures with identity preserved. -- Added selected-root exact-axis context for measured calculations, zero clamping for exhausted content-box constraints, Yoga measured-text point-grid rounding, native-only cache invalidation when callbacks are replaced or removed, and hidden measurement/output revisions that preserve public dirty state while making failure retry and arbitrary-subtree output commits correct. -- Added the ordinary-compute fast path for selected subtrees without measured Nodes, controlled same-facade native tree-busy behavior during callbacks, and durable callback registration, external-data dirtying, result coercion, failure atomicity, temporary root-Style restoration, retry, Config-refresh retry, and path-selection fixtures. -- Added exact dual-oracle Difference fixtures and public documentation for callback count/order/mode traces, MeasureMode-sensitive results, and Yoga's stale cache after callback replacement or removal. -- Completed the exact Yoga 3.2.1 closure audit: both package entries and all supported exports match the oracle; Config and Node retain the exact eight and 100 method-name sets at runtime and in TypeScript; supported legacy constants retain Yoga's broad writable enum types; and the facade plus both factory objects retain Yoga's writable runtime property descriptors. -- Added Yoga-compatible `Direction.Inherit` owner-direction fallback for `calculateLayout`, explicitly classified positive and negative infinite owner dimensions as Unsupported with atomic rejection, and added direct oracle and failure-state fixtures. -- Completed the public package guide and exhaustive compatibility boundary, including every known Different group and every Unsupported trigger, the Node-only ESM and Yoga 3.2.1 baseline, unchanged-import registry-alias form, both entry examples, and third-party notice linkage. -- Replaced obsolete pre-implementation research status, ownership, package-build, and repository-index language with the implemented architecture and ongoing maintenance boundary. -- Verified the cleaned six-file `dist/`, unchanged root and `/load` workspace-alias imports, exact package preview, declaration entry placement, absence of a runtime Yoga dependency, and the current generated output. - -## Evidence and review - -- `vp run check` passes after the aggregate task waits for formatting before starting the build-backed tests: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 6 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. -- Direct Node imports of `yoga-layout` and `yoga-layout/load` resolve the built workspace package; `pnpm pack --dry-run --json` includes both JavaScript entries, both entry declarations, and both emitted shared chunks; built JavaScript has no `yoga-layout` runtime import. -- A temporary untracked file under `packages/taffyjs-yoga/dist` is detected by the exact intent-to-add freshness pattern with `git diff` exit 1. -- The fresh adversarial reviewer found one material issue: Node and Config instances exposed writable runtime, tree, NodeId, and Config fields. The implementation moved all handle state into module-private WeakMap records and added a runtime fixture proving fake same-named properties cannot affect native operations. The same reviewer returned PASS on the targeted follow-up. -- The Milestone 2 full `vp run check` passes: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 17 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. -- The Milestone 2 adversarial reviewer found one numeric boundary error: finite JavaScript numbers such as `1e39` can overflow only after f32 conversion, and length-like or aspect-ratio declarations must then clear to Undefined while flex numbers retain Infinity. The normalization was corrected after independent reproduction, `±1e39`, `1e-50`, and `±1e39%` were added to the oracle fixture, and the same reviewer returned PASS on the targeted follow-up. -- A bounded calculation probe found that Yoga and Taffy differ when `Display.None` is applied to the selected calculation root with declared dimensions. Style storage and direct display translation remain complete in Milestone 2; the calculated-output trigger is retained for Milestone 4 classification and regression coverage rather than being hidden in the declaration layer. -- The Milestone 3 full `vp run check` passes: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 36 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. -- The Milestone 3 adversarial reviewer found two native-cache/state gaps. Single-node and recursive release marked only Yoga-visible surviving ancestors, so Taffy could reuse an obsolete parent layout; Config freshness was checked only on the selected Node, so an independently configured descendant could miss both Style synchronization and `hasNewLayout`. Release now invalidates the surviving native parent and direct detached child roots with post-commit poisoning on failure, while calculation uses one full selected-subtree Style plan. The exact 30-by-10 release and independent child-Config reproductions now pass, and the same reviewer returned PASS on the targeted follow-up. -- The Milestone 4 full `vp run check` passes after the final projection fixes: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 64 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. -- The fresh Milestone 4 adversarial reviewer found three material issues: absolute mixed-direction logical insets were translated too late to recover auto-sized width, two known Taffy/Yoga Difference triggers were documented too narrowly, and the ported PixelGrid code lacked its required MIT attribution. Native Style translation now tracks containing direction before calculation, both Difference classes have exact dual-oracle fixtures and expanded documentation, and the source plus packaged notice carry the attribution. -- The same reviewer’s single targeted follow-up confirmed those three repairs and found two residuals: Taffy's percentage conversion needed to divide before its float32 cast, and the authoritative implementation reference had not been synchronized with the expanded Difference evidence. The exact width-3/left-0.3% reproduction now matches Yoga bit-for-bit, the remaining equivalent absolute-margin arithmetic is pinned under Yoga's 0.0001 float tolerance, and the reference now contains all three added Difference triggers. Per the review protocol, the review loop stopped after that one follow-up and the residual fixes were verified locally and by the complete gate. -- A `pnpm pack --dry-run --json` preview includes both entries, both entry declarations, both shared chunks, `COMPATIBILITY.md`, and `THIRD_PARTY_NOTICES.md`; a 64-case percentage direction, axis, and position matrix has zero exact mismatches for the tested inset corpus. -- The Milestone 5 full `vp run check` passes after the final Measure fixes: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 74 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. The committed path-selection profile records one ordinary call and zero measured calls for an unmeasured calculation, then one measured call only after a callback is present. -- The fresh Milestone 5 adversarial reviewer found two material issues: a negative Taffy finite content-box remainder was exposed as `AtMost(-5)` instead of Yoga's `AtMost(0)`, and a failed Config refresh consumed native Style staleness before retry, allowing changed descendant geometry to remain `hasNewLayout() === false`. Finite content constraints now clamp to zero, and each selected subtree retains a private output-stale marker until a complete successful projection and state commit. -- The same reviewer's single targeted follow-up returned PASS after verifying both axes, positive and exact constraints, the original Config/point-scale retry, partial child-then-ancestor recovery, and clean cache-hit flags. The focused Measure suite passes 10 tests, including the exact 20-by-30 negative-space and 10.5-by-5.5 retry reproductions. -- The Milestone 6 full `vp run ready` passes on Node.js 24.19.0, Linux x64 GNU with glibc 2.41: 9 Rust tests, 45 native tests, 194 `@taffyjs/node` integration tests, 77 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. Node.js 24 satisfies the package's Node.js >=22.18 runtime boundary; CI remains pinned to the minimum Node.js 22.18.0 baseline. -- A final `pnpm pack --dry-run --json` preview contains exactly `package.json`, the three package documents, root and `/load` JavaScript, root and `/load` declarations, one shared JavaScript chunk, and one shared declaration chunk. The cleaned output contains no `yoga-layout` or `yoga-layout-oracle` runtime import. -- The fresh Milestone 6 adversarial reviewer found two material public-surface issues: the facade and factories were frozen and their declarations narrowed writable enum constants into readonly literals, and the public min/max Flex Difference row was narrower than the vouched result group. Runtime and declaration mutability now match the oracle with direct tests, and the public row now covers min or max constraints combined with Flex growth or shrinkage while retaining the exact max-plus-shrink fixture. -- The same reviewer's single targeted follow-up returned PASS for both repairs and confirmed the neighboring `Direction.Inherit` and infinite-owner-dimension behavior. The bounded follow-up passed 23 focused tests and the Yoga declaration check. -- The VueTUI consumer run exposed one real translation defect: an undefined Yoga minimum was translated to Taffy's automatic minimum instead of zero. The fix now translates only undefined minimum dimensions to zero, retains ordinary undefined dimensions as auto, and adds row and column differential fixtures. It also removed the previously reported measured-flex-basis Difference, which the same defect had caused. A bounded 270-case flex-basis probe found no remaining mismatch in that tested constant-measure corpus. -- The complete stack was linearly rebased onto `origin/main` at `dbdad1e`, retaining the newer Node build, Wasm package, website, and Yoga integration. On the rebased tree, `vp run ready` passes: 9 Rust tests, 45 native tests, 195 `@taffyjs/node` integration tests, 78 Yoga tests, both declaration checks, formatting, linting, and Clippy pass. - -## In flight - -- Final combined-diff correctness and simplicity reviews, stacked branch synchronization, and merge. - -## Next - -- Merge the reviewed stacked PRs when their final combined-diff review and CI gates pass. Publication and release automation remain outside this implementation run. -- After a future @taffyjs/yoga publication, run one real registry installation using the `yoga-layout` npm alias. That smoke test cannot be completed against the current private, unpublished package and is outside this implementation run. - -## Blocked - -- None. - -## Human handover - -- Pull request: [hyfdev/taffyjs#9](https://github.com/hyfdev/taffyjs/pull/9), still draft and unmerged. -- Baseline and scope: exactly `yoga-layout@3.2.1`; ESM-only; Node.js >=22.18; TypeScript over the existing public `@taffyjs/node` API; no Rust, Taffy core, `@taffyjs/node` API, Flex, browser, WASM, generator, query, batch, compact-transport, publication, or release-automation expansion. -- Milestone commits after the latest rebase: `91be271` package foundation; `0a58671` Config and Style; `d10d156` topology and state; `c354269` computed output; `336772f` Measure API; `577cbca` compatibility closure and handover. The vouched run baseline is `806c862`; the branch is based on `origin/main` at `dbdad1e`. -- Remaining Different groups: named Yoga cache artifacts; corrected dirtied-callback argument and null behavior; min or max constraints combined with Flex growth or shrinkage; overlapping physical/logical main-axis margins; calculated aspect ratio; oversized or ordinary WrapReverse placement; overflowing reversed-axis distribution; zero-cross-size wrapped-line distribution; main-axis auto margins with reversal or justification; Measure callback trace, mode-sensitive result, and callback replacement/removal cache behavior. Exact triggers and pinned results are in the package compatibility guide. -- Remaining Unsupported triggers: Display.Contents; PositionType.Static; every non-None Errata; property-invalid Align values; `setIsReferenceBaseline(true)`; positive or negative infinite calculation owner dimensions; and undeclared malformed or Value-like generic-setter coercions. Types omit or narrow these where possible, and dynamic inputs throw before mutation. -- Only post-publication evidence remains: install the published package under the `yoga-layout` registry alias and smoke-test both entries. It does not block this private draft implementation. diff --git a/.agents/docs/taffyjs-yoga-implementation-plan.md b/.agents/docs/taffyjs-yoga-implementation-plan.md deleted file mode 100644 index 98d8a4c..0000000 --- a/.agents/docs/taffyjs-yoga-implementation-plan.md +++ /dev/null @@ -1,212 +0,0 @@ -# @taffyjs/yoga Implementation Plan - -This is the operational plan for implementing the Yoga 3.2.1 compatibility package. Product commitments live in [the decision ledger](taffyjs-yoga-decisions.md), while the evidence and exact compatibility inventory live in [the implementation reference](taffyjs-yoga-reference.md). If this plan conflicts with a vouched decision, the decision wins. - -## Outcome - -Add an ESM-only `@taffyjs/yoga` package for Node.js `>=22.18` that is implemented entirely in TypeScript over `@taffyjs/node`, exposes the supported Yoga 3.2.1 root and `/load` entry shapes, and lets a supported consumer keep imports such as `import Yoga from "yoga-layout"` after replacing only the dependency. - -The implementation is successful when every evaluated Yoga 3.2.1 capability is represented in the public types and runtime as Compatible, Different, or Unsupported; the supported surface has package-boundary tests; every documented Difference and Unsupported case has a durable regression fixture; and the repository's normal checks pass on the supported native platforms. - -## Boundaries - -- Do not modify, fork, or patch Taffy core or add Yoga-specific Rust behavior. -- Do not add public or private `@taffyjs/node` APIs for this implementation. If the current binding cannot support credible Yoga semantics through bounded TypeScript, classify the behavior as Different or Unsupported. -- Do not implement Flex layout, sibling distribution, Yoga cache behavior, or parent-dependent layout correction in JavaScript. -- Do not add browser or WASM support. A possible future `@taffyjs/yoga-wasm` is a separate project. -- Do not promise callback count, callback order, exact internal recomputation, post-free artifacts, or pixel equality for cases already classified as Different. -- Do not make selective query, batch reads, or compact Layout transport a prerequisite. They are later transport optimizations that must not change Yoga semantics. -- Do not add a Yoga-specific code generator in the first implementation. The authored TypeScript source is the single source for runtime values and emitted declarations. Add generation only if one maintained fact later has to agree across independent outputs and ordinary TypeScript cannot keep it single-sourced. -- Do not publish packages or add release automation in this implementation PR. The repository currently has no publication workflow; registry publication and changing package privacy are separate release decisions. - -## Runtime design - -### Facade - -The root module creates one default Yoga facade. Every call to `loadYoga()` from `@taffyjs/yoga/load` asynchronously returns a new facade. Each facade owns one hidden `TaffyTree`, disables Taffy's tree-wide rounding, owns its default Config state, and rejects Nodes or Config handles created by another facade. - -The default facade is a module singleton. The asynchronous `/load` shape is preserved even though creating the native-backed facade does not itself require asynchronous work. - -### Node and Config state - -Each live Node has a stable NodeId and one normalized Yoga declaration record. The facade also retains the Node's Config state, callbacks, measure marker, Yoga-visible dirty and new-layout flags, last applied translation revision, and last committed Yoga-shaped output. Taffy remains the only owner of topology and geometry. - -A public Config handle may be freed while Nodes retain the underlying Config state they were created with. Freeing invalidates the public handle but does not leave dangling state. Node and Config handles use facade and lifetime brands so cross-facade, stale, and freed values fail before native mutation. - -The first implementation uses explicit Yoga lifetime operations rather than `FinalizationRegistry` behavior. A facade may retain Node records and their canonical wrappers until `free()`, `freeRecursive()`, or facade collection; this preserves stable identity and matches Yoga's explicit-lifetime model without making garbage-collection timing observable. - -### Declaration translation - -Yoga setters normalize their input into the Node's declaration record. Getters read that record or a Yoga-defined default, never reverse-engineer the current Taffy Style. The record preserves information that Taffy cannot: unset versus Auto, logical and shorthand edges, `Direction.Inherit`, numeric `flex`, independent flex longhands, and Config-sensitive defaults. - -Translation from the declaration record plus Config and resolved direction produces a complete Taffy Style. Translation is deterministic and side-effect free. It may be applied lazily before calculation so inherited direction and related context are correct. Invalid or unsupported mutations are rejected before any JavaScript or native state changes. - -### Topology and lifecycle - -All parent and child questions go through Taffy; there is no authoritative JavaScript child array. A small cache is permitted only when it can be rebuilt from or checked against Taffy and cannot affect correctness. - -Every single-step topology operation follows the same order: validate all handles, facade ownership, indices, cycle and measured-leaf constraints; perform the native mutation once; commit JavaScript state; then invoke user callbacks. Validation and expected native failures before commit leave declarations, topology, callbacks, public flags, and computed output unchanged. A user callback that throws does so after the mutation is committed and is not rollback-safe; internal state must already be consistent before the callback runs. - -Destructive multi-step operations such as `freeRecursive()` first snapshot and validate the complete target subtree, then remove it in postorder without invoking user callbacks between native steps. There is no native transaction with which to promise rollback after an unexpected mid-operation binding failure. If such an internal invariant failure occurs after a partial native commit, mark the facade unusable and reject later calls rather than exposing a facade whose JavaScript state may disagree with Taffy. This poison path is defensive handling for an unexpected internal failure, not a normal public error case. - -### Calculation and output - -`calculateLayout()` accepts any live Node, including an attached subtree. Before calculation, traverse only the selected subtree, resolve effective declarations, and synchronize stale Taffy Styles. Use ordinary `computeLayout` when the subtree has no measured Node and `computeLayoutWithMeasure` only when measurement is needed. - -A finite calculation width or height is an exact outer constraint on the selected Yoga calculation root. Apply the already researched temporary root-Style adjustment, calculate with Taffy, read all required unrounded Layout values, and restore the declaration-derived Style in a `finally` path. The temporary Style is calculation context and never becomes Yoga declaration state. - -After native calculation succeeds, project the selected subtree into Yoga-shaped outputs in a temporary map. Geometry always comes from Taffy. The projection reconstructs Yoga's right and bottom values, root positions, resolved computed edges, auto-margin reporting, and per-Node point-scale rounding. Commit every Node's output and public flags only after the entire projection succeeds; a measurement or projection failure preserves the previous public outputs and dirty state. - -If Yoga's pixel-grid formula is ported from Yoga source, retain the MIT permission notice and clear source attribution in a packaged third-party notice and beside the implementation. Do not copy the upstream wrapper wholesale. - -### Measurement - -One facade-level Taffy measure closure dispatches a request by NodeId to the Yoga callback stored for that Node. Known dimensions map to `MeasureMode.Exactly`, finite available space to `AtMost`, max-content to `Undefined` with NaN, and min-content to `AtMost` with zero. Callback results follow the normalization and thrown-value rules in the implementation reference. - -Measured Nodes must remain leaves. Same-tree native calls made reentrantly from a measure callback may use `@taffyjs/node`'s existing controlled tree-busy failure; exact reentrant behavior and callback traces are not compatibility promises. A callback failure must leave public output atomic and allow a later calculation to retry. - -## Repository changes - -The implementation adds these package boundaries: - -```text -packages/taffyjs-yoga/ - package.json - vite.config.ts - tsconfig.json - README.md - COMPATIBILITY.md - THIRD_PARTY_NOTICES.md - dist/ - index.js - index.d.ts - load.js - load.d.ts - - src/ - index.ts - load.ts - facade.ts - config.ts - node.ts - declarations.ts - enums.ts - values.ts - translate.ts - measurement.ts - output.ts - errors.ts - -tests/taffyjs-yoga/ - package.json - vite.config.ts - tsconfig.json - exports.test.mts - config.test.mts - style.test.mts - topology.test.mts - lifecycle.test.mts - state.test.mts - layout.test.mts - measurement.test.mts - differences.test.mts - unsupported.test.mts - types/ - tsconfig.json - public-api.test-d.ts -``` - -The source split is a starting ownership map, not a requirement to keep empty or one-function files. Merge files when that produces a clearer implementation. `tests/taffyjs-yoga/` is itself the downstream consumer package: do not add another `tests/` directory or an unexplained `e2e/` directory inside it. - -`packages/taffyjs-yoga` uses `vp pack` with `src/index.ts` and `src/load.ts` as ESM entries and a cleaned `dist/` output directory. The package exports point to `dist/index.js`, `dist/index.d.ts`, `dist/load.js`, and `dist/load.d.ts`; every shared JavaScript chunk emitted by the pinned packer is also included through the packaged `dist/` directory. Its runtime dependency is only `@taffyjs/node` through `workspace:*`; `yoga-layout` is never a runtime dependency of the published package. - -`tests/taffyjs-yoga` installs the implementation under Yoga's original name with the pnpm workspace alias `"yoga-layout": "workspace:@taffyjs/yoga@*"`, so test source imports the unchanged consumer specifiers `yoga-layout` and `yoga-layout/load`. It installs the official oracle separately as `"yoga-layout-oracle": "npm:yoga-layout@3.2.1"`. The package README gives published consumers the standard npm alias `"yoga-layout": "npm:@taffyjs/yoga@"`. The workspace form follows [pnpm's documented workspace aliases](https://pnpm.io/workspaces#referencing-workspace-packages-through-aliases), while the published form follows [npm package aliases](https://docs.npmjs.com/cli/using-npm/package-spec#aliases) and [pnpm aliases](https://pnpm.io/aliases). Because publication is out of scope and the repository deliberately has no tarball-install test path, CI proves the built workspace alias and all exported entry files; installing the registry alias remains a post-publication smoke check. - -The root Vite+ task graph gains a Yoga package build after `build:binding`, Yoga runtime integration tests, and Yoga declaration checks. The normal root `build`, `check:test`, `check`, and `ready` tasks include them. After building on Ubuntu, CI runs `git add --intent-to-add --all -- packages/taffyjs-yoga/dist` before diffing the complete committed output directory, so a new untracked shared chunk as well as changed or deleted entry output, chunks, and declarations fails freshness; Windows runs the same runtime and type tests through the normal task graph. `pnpm-lock.yaml`, workspace metadata, root documentation, and package metadata change in the same milestone that introduces the package. - -## Verification strategy - -The official `yoga-layout@3.2.1` package is the behavior oracle, but tests must remain small enough to explain and maintain. - -- Compatible fixtures run the same supported source operations against both implementations and compare the relevant public result. They cover representative equivalence classes rather than copying Yoga's complete upstream suite. -- Different fixtures pin both the Yoga result and the TaffyJS result for the exact documented trigger. They prove that the difference remains understood; they do not assert equality. -- Unsupported fixtures prove both boundaries where possible: statically known use fails the TaffyJS declaration check, and dynamic JavaScript or `any` input throws before any observable state changes. -- Package-entry fixtures inspect root and `/load` exports, enum reverse lookup, legacy constants, facade isolation, and the unchanged-import alias. Public surface comparison uses real Yoga reflection plus the explicit unsupported exclusions instead of a second duplicated 100-method list. -- State fixtures prove declaration getters versus last calculated output, dirty and new-layout transitions, callback argument correction, arbitrary attached-subtree calculation, retained Config state, canonical Node identity, pre-commit mutation failure atomicity, and post-commit callback-error behavior. -- Layout fixtures cover ordinary fixed and flex cases plus the explicit Difference classes. Computed-output fixtures separately cover root and trailing positions, logical edges under LTR and RTL, auto margins, detached output reset, and mixed point-scale factors. -- Measurement fixtures cover constraint-insensitive callbacks, mode mapping, normalization, thrown-value identity, retry, measured-leaf restrictions, ordinary-compute fast selection, and the documented MeasureMode-sensitive Difference. - -Tests run concurrently by default. Add a separate-process fixture only when process containment is itself necessary, such as proving that a native panic, abort, or deadlock cannot escape the test. Do not add runtime tests that read documentation or source files, generator tests, or duplicate fixtures that differ only in setup wording. - -Performance work in this PR is diagnostic rather than contractual. Keep a small local benchmark or profile for creation, style mutation, calculation, measured calculation, and computed-output reads, but do not add a timing threshold to CI or claim that the package is faster than Yoga. Optimize only after a representative workload identifies a material cost; batch/query/compact transport remains separate work. - -## Milestones - -### Milestone 0: reviewed implementation baseline and draft PR - -Deliver the decision ledger, implementation reference, this plan, the repository-level intent and architecture links, and the repository-required `loop-goal.md` plus initial `loop-status.md`. Resolve factual contradictions, run formatting and link checks, obtain an independent adversarial review of the plan and Goal contract, and open one draft implementation PR from the existing task branch. The unattended implementation run must not start until Yunfei has vouched the exact `loop-goal.md`; that file then remains immutable for the run. - -Exit gate: the review finds no unresolved material blocker; the documents distinguish vouched decisions from derived implementation guidance; the branch is pushed; the draft PR is visible; and Yunfei has explicitly vouched the exact `loop-goal.md` before unattended implementation starts. No implementation claim is made yet. - -### Milestone 1: package, entries, facade, and thin vertical slice - -Add the package and test-consumer scaffolding, both public entries, all supported enum objects and legacy constants, factory shapes, facade isolation, lifetime brands, a hidden rounded-disabled TaffyTree, and one minimal create-set-calculate-read path. Wire the package into the workspace, Vite+ build graph, type checking, runtime tests, CI output freshness, and lockfile. - -Exit gate: unchanged `yoga-layout` and `yoga-layout/load` imports resolve through the workspace alias; a clean build produces working exported entries and declarations plus every required shared chunk under `dist/`; the package metadata includes that complete directory, the freshness command detects a deliberately untracked probe output during implementation verification, and the built code contains no runtime `yoga-layout` dependency; two loaded facades reject each other's handles; the thin layout slice passes on the current host; relevant root checks pass; adversarial review findings are resolved; one milestone commit is pushed to the draft PR. - -### Milestone 2: Config, declaration model, and Style API - -Implement Config state and lifetime, complete normalized declarations, Yoga input normalization, Yoga defaults and `useWebDefaults`, all supported Style getters and setters, edge and logical precedence, gaps, direction, flex shorthand and longhands, deterministic translation, `copyStyle`, unsupported enum/value narrowing, and runtime validation with pre-commit failure atomicity. - -Exit gate: the public Style and Config surface matches the classified Yoga 3.2.1 contract; representative round trips and oracle cases pass; Unsupported inputs are absent or narrowed in declarations and throw dynamically without mutation; relevant and full repository checks pass; adversarial review findings are resolved; one milestone commit is pushed. - -### Milestone 3: topology, lifecycle, callbacks, and public state - -Implement Node creation and destruction, insert/remove/lookup, canonical identity, one-parent and acyclic constraints, `free`, `freeRecursive`, `reset`, retained Config state, declaration copying, measured-leaf topology checks, Yoga-visible dirty and new-layout transitions, and the deliberately corrected dirtied callback behavior. - -Exit gate: lifecycle, attached and detached topology, cross-facade rejection, state transitions, retained Config behavior, callback argument and null handling have durable fixtures; validation and expected pre-commit native failures preserve observable state; callback exceptions are pinned as post-commit behavior; multi-step destructive operations prevalidate their snapshot and have an explicit poisoned-facade path for an unexpected partial internal failure; no JavaScript child tree or layout algorithm exists; relevant and full checks pass; adversarial review findings are resolved; one milestone commit is pushed. - -### Milestone 4: complete calculation and computed output - -Implement arbitrary-node calculation, declaration synchronization, effective direction, temporary exact root constraints with guaranteed restoration, subtree output collection, Yoga right and bottom semantics, root positions, computed edges, auto-margin reporting, per-Node point-grid rounding, output commit atomicity, and all computed getters. - -Exit gate: the ordinary Compatible geometry corpus matches Yoga; every current non-measurement Difference has a fixture that pins both results and a matching `COMPATIBILITY.md` entry; failed calculation or projection retains prior public output; mixed Config rounding passes; relevant and full checks pass; adversarial review findings are resolved; one milestone commit is pushed. - -### Milestone 5: Measure API - -Implement callback registration and removal, measured-leaf enforcement, Taffy request to Yoga MeasureMode mapping, result normalization, thrown-value identity, retry, measured output rounding, ordinary-compute selection for subtrees without measured Nodes, and controlled same-tree callback reentrancy behavior. - -Exit gate: normal measurement behavior matches the accepted contract; callback-trace and mode-sensitive limits are documented and pinned as Differences; failure and retry are atomic; a profile confirms that unmeasured trees do not enter the measure bridge; relevant and full checks pass; adversarial review findings are resolved; one milestone commit is pushed. - -### Milestone 6: compatibility closure and package evidence - -Audit the exact Yoga 3.2.1 surface against the inventory, finish root and `/load` export parity, verify the workspace alias and clean `dist/` package contents, complete `README.md`, `COMPATIBILITY.md`, and third-party notices, remove obsolete research TODOs, and run the complete repository verification on the supported local host. - -Exit gate: no public capability remains unclassified; documentation names Yoga 3.2.1 and every known Difference and Unsupported trigger; package output is current; `vp run ready` and `vp run check:codegen` pass; the final implementation receives adversarial review and targeted follow-up if needed; the last milestone commit is pushed. The PR stays draft and unmerged for Yunfei's decision. - -## Review and PR protocol - -Use one long-lived draft PR for the complete implementation. Stacked milestone PRs would add branch and merge-order overhead without improving observability; milestone commits and checks already expose progress clearly. - -For every milestone: - -1. Implement only that milestone's scope and preserve unrelated user work. -2. Run the smallest relevant checks while iterating, then the milestone's full gate before review. -3. Give one fresh independent reviewer a self-contained, bounded packet containing the milestone objective, changed files, test evidence, governing decisions, and the highest-cost assumption to attack. The reviewer must not delegate and returns a pass or at most three material findings. -4. Verify each finding against source or a focused reproduction. Fix evidence-backed findings. If a finding materially changes the result, ask the same reviewer for one targeted follow-up covering that finding and regressions, then stop the review loop. -5. Commit the reviewed milestone as one coherent named commit and push it to the existing draft PR. Do not hide unfinished work in an unpushed local commit. -6. Update `loop-status.md` with completed work, evidence, next work, blockers, and any vouched-direction conflict, and include that state in the milestone push. -7. Continue to the next milestone autonomously unless a genuine product choice, authority boundary, or external blocker requires Yunfei. Do not merge, mark the PR ready, publish, or post GitHub comments on Yunfei's behalf. - -Milestone review is a completion gate, not a request to review every routine edit. A pushed milestone may contain several local work-in-progress commits only if they are squashed or otherwise made coherent before the milestone is declared complete. - -## Risks to watch during implementation - -- Temporary root-Style restoration and public output commit must preserve the previous public state when a measure callback throws; an unexpected failure to restore native Style poisons the facade because native rollback cannot be promised. -- Direction, logical edges, Config changes, and attached-subtree calculation can invalidate translation without a corresponding Yoga-visible dirty transition; revisions must keep those concepts separate. -- A strong canonical-wrapper registry is deliberately simple but requires explicit lifetime tests so `free`, `freeRecursive`, and facade collection do not retain native state accidentally. -- User callback errors are post-commit errors. Never invoke a user callback while JavaScript and native state disagree, and never pretend that an external side effect can be rolled back. -- Yoga declarations and useful runtime behavior sometimes disagree. Preserve the published declaration for supported APIs while pinning the selected runtime behavior; do not silently “correct” types during implementation. -- The official oracle is a test-only dependency under an alias. Any accidental runtime import from it would invalidate the package architecture. -- The compatibility matrix is version-specific. Do not update the Yoga baseline opportunistically during implementation. -- A local performance observation is not a release claim. Measure before adding caches, batch calls, queries, or compact transport. diff --git a/.agents/docs/taffyjs-yoga-reference.md b/.agents/docs/taffyjs-yoga-reference.md index dd10bc9..49ecc63 100644 --- a/.agents/docs/taffyjs-yoga-reference.md +++ b/.agents/docs/taffyjs-yoga-reference.md @@ -1,21 +1,12 @@ # @taffyjs/yoga Implementation Reference -This is the implementation reference for the current draft @taffyjs/yoga package. It records the audited Yoga 3.2.1 surface, evidence, derived constraints, and the implemented compatibility boundary; only judgments explicitly accepted by Yunfei belong in [the decision ledger](taffyjs-yoga-decisions.md). The public summary lives in the package's [compatibility guide](../../packages/taffyjs-yoga/COMPATIBILITY.md). +This is the implementation reference for @taffyjs/yoga. It records the audited Yoga 3.2.1 surface, evidence, derived constraints, and the implemented compatibility boundary; only judgments explicitly accepted by Yunfei belong in [the decision ledger](taffyjs-yoga-decisions.md). The public summary lives in the package's [compatibility guide](../../packages/taffyjs-yoga/COMPATIBILITY.md). -## Progress +## Scope and maintenance boundary -| Priority | Reference area | Status | Result | -| -------- | -------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| P0 | Exact Yoga baseline and public API inventory | Complete | The exact 3.2.1 entries, exports, factories, eight Config methods, 100 Node methods, enum members, constants, public value shapes, and wrapper behaviors are inventoried and classified as Compatible, Different, or Unsupported. | -| P0 | Node, Config, and TaffyTree ownership | Complete | One hidden TaffyTree owns topology per facade; retained Yoga declarations and Config state, canonical Node identity, lifecycle, facade isolation, and no-migration behavior have durable fixtures. | -| P0 | Authoritative state and observable transitions | Complete | Taffy is the only topology and geometry authority; declaration, dirty, new-layout, calculation, output projection, callback failure, partial native failure, and retry boundaries have durable fixtures. | -| P1 | Compatibility classification and translation rules | Complete | Values, defaults, edges, direction, flex, selected-root constraints, rounding, measurement, computed output, and input rejection are implemented and the material Differences and Unsupported triggers are public. | -| P1 | Verification and release gate | Complete | Repository consumer, declaration, differential-oracle, failure-injection, package-boundary, cleaned-output, and full repository gates cover the implementation plan. | -| P2 | Performance evolution boundary | Deferred | Later cache, batch, selective-query, or compact-transport work remains semantically possible. Representative end-to-end workloads and retained measurements are required before choosing an optimization; this is not part of Yoga compatibility closure. | +The ownership design rejects a normal create, insert, remove, detach, or free path that implicitly reconstructs or migrates an entire subtree or all unrelated roots. Operations whose meaning requires traversal, such as freeRecursive, are exempt. Style and topology changes must not implicitly compute layout; calculateLayout remains the explicit calculation boundary. -The P0 ownership design must reject a normal create, insert, remove, detach, or free path that implicitly reconstructs or migrates an entire subtree or all unrelated roots. Operations whose meaning requires traversal, such as freeRecursive, are exempt. Style and topology changes must not implicitly compute layout; calculateLayout remains the explicit calculation boundary. - -Internal file splitting, private class names, code generation versus handwritten code, exact error wording, benchmark thresholds, a named external consumer, and multi-version Yoga support are deliberately deferred unless earlier research makes one of them necessary to settle a P0 boundary. Browser and WASM support are excluded: @taffyjs/yoga is permanently the Node package over @taffyjs/node; any future WASM implementation would use a separate package. +Internal file splitting, private class names, code generation versus handwritten code, exact error wording, benchmark thresholds, a named external consumer, and multi-version Yoga support are deliberately deferred unless evidence makes one of them necessary to preserve the compatibility boundary. Browser and WASM support are excluded: @taffyjs/yoga is permanently the Node package over @taffyjs/node; any future WASM implementation would use a separate package. ## Inventory method @@ -301,7 +292,7 @@ The attached-subtree probe used an already calculated 200-point ancestor contain Yoga's calculateLayout width and height are not merely maximum available space. For an axis with no definite declared size or declared maximum, a finite argument gives the selected Node an exact outer constraint; for example, an empty Yoga Flex root calculated with width 100 has computed width 100, while the same Taffy Flex root given AvailableSpace.Definite(100) has width zero. A measured Yoga root likewise receives Exactly(100) and finishes at width 100, whereas an unmodified Taffy measured root remains at its measured width. -The bounded bridge is to derive a calculation-only Taffy Style for the selected Node. A Yoga exact root constraint becomes an effective exact Taffy size after resolving the selected Node's margins, padding, border, box sizing, min/max constraints, and percentage bases from the calculateLayout owner inputs. A Yoga declared maximum remains a fit-content bound rather than becoming an exact size. Root declarations remain unchanged and all descendants continue through the ordinary Taffy algorithm. The selected Node's ordinary derived Style is restored in a finally path after native calculation; restoring it may leave broader native dirty state, which is internal under the decided calculation-state model. This translation applies equally when the selected Node is still attached because Taffy can calculate any NodeId as the root of one invocation without moving it. +The bounded bridge is to derive a calculation-only Taffy Style for the selected Node. A Yoga exact root constraint becomes an effective exact Taffy size after resolving the selected Node's margins, padding, border, box sizing, min/max constraints, and percentage bases from the calculateLayout owner inputs. A Yoga declared maximum remains a fit-content bound rather than becoming an exact size. For a selected root with a definite size and conflicting resolved minimum and maximum, Yoga compares that size with the maximum first; the calculation-only Style lowers the effective minimum to the maximum only when the definite size is strictly larger. Without a definite size, Yoga's root sizing mode and whether size comes from ordinary children or measurement can change the order; reproducing those layout-dispatch branches in JavaScript would cross the compatibility boundary, so the package retains Taffy's minimum-first result under the documented intrinsic-root Difference. Root declarations remain unchanged and all descendants continue through the ordinary Taffy algorithm. The selected Node's ordinary derived Style is restored in a finally path after native calculation; restoring it may leave broader native dirty state, which is internal under the decided calculation-state model. This translation applies equally when the selected Node is still attached because Taffy can calculate any NodeId as the root of one invocation without moving it. This temporary Style is calculation context, not a second declaration or layout engine: Yoga getters continue to read the one declaration state, callback closures see that state, and Taffy alone computes geometry. A successful calculation projects and commits outputs for the selected subtree; a thrown measurement leaves the previous Yoga-shaped outputs and logical dirty state intact, while a first failed calculation retains the initial zero-position and NaN-size output. @@ -325,44 +316,45 @@ The TypeScript surface should preserve Yoga 3.2.1's published declarations for e Keep these restrictions as simple named unions. Do not add a conditional type that admits the exact broad upstream enum while trying to reject particular enum literals: such a signature either leaves unsupported paths statically callable or makes generic forwarding unpredictable. `AlignContentValue`, `AlignItemsValue`, and `AlignSelfValue` are exported from both package entries as the direct migration types and are the single source for the corresponding setter declarations. Code that must compile against either Yoga provider can instead derive the active provider's parameter with `Parameters[0]`. Runtime validation remains mandatory for JavaScript, `any`, assertions, and dynamic data, and every rejected call must fail before declaration state changes. Inputs outside Yoga's published declarations are not a compatibility surface merely because the generic wrapper happens to coerce them. -| APIs or capability | Classification | Mapping and boundary | -| --------------------------------------------------------------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| The 25 style getters other than getGap() | Compatible | Read the raw Yoga declaration or Yoga-resolved getter default from JavaScript state. Construct fresh Value records with Yoga Unit values and NaN payloads where the Yoga runtime does so. | -| getGap() TypeScript declaration and runtime result | Compatible | Preserve Yoga's declared Value return for source compatibility while returning the raw number or NaN at runtime exactly as Yoga 3.2.1 does. The declaration/runtime contradiction is pinned reference behavior, not a reason to invent a third shape. | -| setDirection(), setFlexDirection(), setFlexWrap(), setJustifyContent(), setOverflow(), and setBoxSizing() | Compatible | Direct enum translation, with Direction.Inherit resolved before calculation. | -| setDisplay(Flex or None) and setPositionType(Relative or Absolute) | Compatible | Direct translation. Display.Contents and PositionType.Static use their unsupported rows above. | -| Valid-context setAlignContent(), setAlignItems(), and setAlignSelf() values | Compatible | Direct mapping. Broad-enum values invalid for a particular property use the unsupported-input row in the enum matrix rather than being silently normalized. | -| setFlexGrow(), setFlexShrink(), setFlexBasis(), setFlexBasisPercent(), and setFlexBasisAuto() | Compatible | Preserve separate Yoga declarations and derive the effective Taffy fields with Yoga precedence and Config defaults. | -| setFlex() | Compatible | Retain numeric flex and derive grow, shrink, and basis exactly as Yoga does: positive values supply grow and a legacy zero basis or web-default auto basis; negative values supply legacy shrink; explicit longhands win. The declaration mapping is finite; cache-derived stale-layout behavior after a live useWebDefaults change follows the decided difference above. | -| setWidth/Height and Auto/Percent variants; setMin/Max Width/Height and Percent variants | Compatible | Normalize every declared point, percent, auto, undefined, and NaN form into Yoga declaration state, then convert to Taffy dimensions. An undefined Yoga minimum becomes an explicit zero-length Taffy minimum rather than Taffy's CSS automatic minimum, while the Yoga getter retains Unit.Undefined. Valid percent strings are parsed once; malformed strings and Value-like objects are outside the declared input contract and use the unsupported-input row below. | -| Margin, padding, border, position, and gap setter families | Compatible | Retain all shorthand and logical declarations, apply Yoga edge precedence and direction when producing Taffy's physical Rect and Size values, and preserve original declarations for getters. Auto margin affects layout but Yoga's computed-margin getter reports zero for an auto declaration, so computed output must mask Taffy's resolved auto-margin value. | -| setAspectRatio() storage and getAspectRatio() | Compatible | Optional numeric declaration storage and getter shape are finite. Calculated layout is classified separately. | -| setGap() and setGapPercent() TypeScript declaration and runtime result | Compatible | Preserve Yoga's declared Value return while returning undefined at runtime, matching Yoga 3.2.1. Normal consumers ignore these setter returns; exact source declarations avoid an unnecessary substitution-time type change. | -| Undeclared generic-setter coercions and Value-like getter-to-setter inputs | Unsupported | Reject malformed strings, arbitrary objects, and other values outside the published setter declarations. Yoga's wrapper attempts broad parseFloat and Value-like coercion, but its own getWidth-to-setWidth round trip resets the value rather than preserving it. Reproducing accidental and broken coercions would add maintenance without preserving a working consumer capability. | +| APIs or capability | Classification | Mapping and boundary | +| --------------------------------------------------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The 25 style getters other than getGap() | Compatible | Read the raw Yoga declaration or Yoga-resolved getter default from JavaScript state. Construct fresh Value records with Yoga Unit values and NaN payloads where the Yoga runtime does so. | +| getGap() TypeScript declaration and runtime result | Compatible | Preserve Yoga's declared Value return for source compatibility while returning the raw number or NaN at runtime exactly as Yoga 3.2.1 does. The declaration/runtime contradiction is pinned reference behavior, not a reason to invent a third shape. | +| setDirection(), setFlexDirection(), setFlexWrap(), setJustifyContent(), setOverflow(), and setBoxSizing() | Compatible | Direct enum translation, with Direction.Inherit resolved before calculation. | +| setDisplay(Flex or None) and setPositionType(Relative or Absolute) | Compatible | Direct translation. Display.Contents and PositionType.Static use their unsupported rows above. | +| Valid-context setAlignContent(), setAlignItems(), and setAlignSelf() values | Compatible | Direct mapping. Broad-enum values invalid for a particular property use the unsupported-input row in the enum matrix rather than being silently normalized. | +| setFlexGrow(), setFlexShrink(), setFlexBasis(), setFlexBasisPercent(), and setFlexBasisAuto() | Compatible | Preserve separate Yoga declarations and derive the effective Taffy fields with Yoga precedence and Config defaults. | +| setFlex() | Compatible | Retain numeric flex and derive grow, shrink, and basis exactly as Yoga does: positive values supply grow and a legacy zero basis or web-default auto basis; negative values supply legacy shrink; explicit longhands win. The declaration mapping is finite; cache-derived stale-layout behavior after a live useWebDefaults change follows the decided difference above. | +| setWidth/Height and Auto/Percent variants; setMin/Max Width/Height and Percent variants | Compatible | Normalize every declared point, percent, auto, undefined, and NaN form into Yoga declaration state, then convert to Taffy dimensions. A negative point or percent width or height remains visible through its Yoga getter but becomes Auto only in the Taffy Style used for calculation. An undefined Yoga minimum becomes an explicit zero-length Taffy minimum rather than Taffy's CSS automatic minimum, while the Yoga getter retains Unit.Undefined. Valid percent strings are parsed once; malformed strings and Value-like objects are outside the declared input contract and use the unsupported-input row below. | +| Margin, padding, border, position, and gap setter families | Compatible | Retain all shorthand and logical declarations, apply Yoga edge precedence and direction when producing Taffy's physical Rect and Size values, and preserve original declarations for getters. Auto margin affects layout but Yoga's computed-margin getter reports zero for an auto declaration, so computed output must mask Taffy's resolved auto-margin value. | +| setAspectRatio() storage and getAspectRatio() | Compatible | Optional numeric declaration storage and getter shape are finite. Calculated layout is classified separately. | +| setGap() and setGapPercent() TypeScript declaration and runtime result | Compatible | Preserve Yoga's declared Value return while returning undefined at runtime, matching Yoga 3.2.1. Normal consumers ignore these setter returns; exact source declarations avoid an unnecessary substitution-time type change. | +| Undeclared generic-setter coercions and Value-like getter-to-setter inputs | Unsupported | Reject malformed strings, arbitrary objects, and other values outside the published setter declarations. Yoga's wrapper attempts broad parseFloat and Value-like coercion, but its own getWidth-to-setWidth round trip resets the value rather than preserving it. Reproducing accidental and broken coercions would add maintenance without preserving a working consumer capability. | ### Computed layout getters and cross-cutting result classes Compatible numeric comparisons allow an absolute error below `0.0001` for finite unrounded float fields, matching Yoga's own internal float-comparison tolerance. Rounded point-grid output and fields covered by strict fixtures continue to compare exactly; the tolerance covers only last-bit differences where Yoga and Taffy evaluate equivalent percentage arithmetic in different float32 steps. -| APIs or result class | Classification | Mapping or observed boundary | -| --------------------------------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| getComputedLeft/Top/Width/Height(), getComputedRight/Bottom(), and getComputedLayout() object shape | Compatible | Width, height, and normal left/top come from Taffy's unrounded layout followed by Yoga point-grid processing. Yoga right/bottom are stored trailing margin plus relative inset values rather than geometric remaining space, and can be reconstructed from finite declaration state. Root left/top also need Yoga's leading margin and relative inset because Taffy roots remain at zero. | -| getComputedMargin(), getComputedPadding(), and getComputedBorder() for Left/Top/Right/Bottom/Start/End | Compatible | Taffy's unrounded Layout contains the resolved physical records; Start and End map through the resolved layout direction, and auto declarations mask Taffy's distributed margin back to Yoga's reported zero. Bounded comparisons cover positive and negative point margins, percentage padding and margins, physical and logical edges under LTR and RTL, auto margins, borders, and overflow. | -| Computed margin, padding, or border queried with Horizontal, Vertical, or All | Compatible | Reject these multi-edge shorthands; Yoga aborts rather than returning an aggregate result. Exact error class and wording are deferred. | -| Point-scale rounding | Compatible | It is an output conversion over unrounded layouts, including Yoga's ceil-sensitive text-node sizes, and does not require a Taffy core change. | -| Ordinary non-degenerate fixed sizes, simple grow or shrink without min/max, percentages, and simple nested flex trees | Compatible | Bounded differential probes found matching unrounded geometry outside the explicit difference triggers below. A committed real-Yoga oracle suite remains a release gate and must not be presented as a compatibility percentage. | -| min/max combined with flex growth or shrinkage | Different | Yoga and Taffy can produce different final sizes; the API remains available with Taffy's stable result. | -| Overlapping physical and logical main-axis margins under a child direction that differs from its parent | Different | Yoga can use one direction while totaling margins for Flex sizing and the child's resolved direction when it stores computed physical margins. In a width-100 LTR Row, a width-20 RTL child with margin-left 4 and margin-start 10% reports computed margins left 4 and right 10 in both engines, but Yoga resolves width 16 while Taffy remains width 20. Reproducing the hidden intermediate margin total would require Flex-specific size correction, so Taffy's stable result is retained. | -| aspectRatio in calculated layout | Different | Yoga derives the cross size from the final flex main size, while Taffy follows its own CSS-oriented aspect-ratio and stretch rules. With rounding disabled, a 100x100 Row parent and width-40 ratio-2 child produce 40x20 in Yoga but 40x100 in Taffy; a Column child declaring width 40, height 30, and ratio 2 produces 60x30 in Yoga but 40x30 in Taffy. Min/max and flex growth diverge further. Aligning all cases would require parent- and algorithm-dependent size rewriting, which crosses the accepted JavaScript boundary. | -| MeasureMode-sensitive callbacks | Different | Taffy does not preserve whether a finite constraint originated as Yoga Exactly or AtMost and may request intrinsic measurements in different phases. The bridge therefore gives each Taffy request a meaningful Yoga-shaped mode but does not promise a Yoga callback trace. Fixed-size and constraint-insensitive callbacks remain useful; callbacks branching on MeasureMode can have stable result differences. | -| WebFlexBasis disabled across a changed containing size | Different | Yoga 3.2.1 can reuse a stale computed percentage flex basis when the experiment is false, while Taffy recomputes it. Emulating Yoga's stale cache would require Yoga-internal flex-basis state and is outside the boundary; the enabled Yoga result matched the tested Taffy relayout. | -| Attached-subtree calculation followed by an ancestor calculation that Yoga serves from cache | Different | Yoga can retain geometry written by the separate subtree calculation because the clean ancestor does not revisit it. Taffy may recompute that ancestor after internal Style translation propagated native dirty state and can therefore produce current ancestor-context geometry instead. The package keeps calculateLayout available on both nodes and does not reproduce Yoga's complete cache algorithm. | -| Oversized WrapReverse lines with distribution-style alignContent | Different | With point rounding disabled, a 100x30 Row/WrapReverse container holding three 60-point items on separate 10, 20, and 15-point lines produces Yoga top positions 20, 0, and -15 but Taffy positions 35, 15, and 0 under Stretch, SpaceBetween, SpaceAround, or SpaceEvenly. The content's 45-point cross size exceeds the 30-point container; FlexStart, FlexEnd, and Center are not covered by this difference. Reconstructing Yoga's negative-free-space line placement would duplicate Flex logic. | -| A non-overflowing WrapReverse line with a cross-axis margin | Different | Cross-axis margin placement can also differ without overflow or distribution-style alignContent. In a 100x100 Row/WrapReverse container with alignItems FlexStart, a 40x20 child with an 11-point bottom margin has top 80 in Yoga and top 69 in Taffy. Correcting this requires reproducing WrapReverse line and item alignment, so Taffy's placement is retained. | -| Oversized single line on a reversed main axis with distribution-style justifyContent | Different | When a RowReverse or ColumnReverse no-wrap line overflows and justifyContent is SpaceBetween, SpaceAround, or SpaceEvenly, Yoga and Taffy choose different negative-free-space fallback placement. In a 240-point RowReverse line with 140, 130, and 120-point children, Yoga left positions are 100, -30, and -150 while Taffy reports 250, 120, and 0. This affects sibling distribution and is not an output-only correction. | -| Zero-cross-size wrapped line under Stretch item alignment | Different | A wrapped line whose items all resolve to zero cross size stays on Yoga's cross-start or cross-end edge, while Taffy distributes the zero-size line under FlexEnd, Center, SpaceAround, or SpaceEvenly alignContent. For a 180-point cross axis, ordinary Wrap plus Center places Yoga at 0 and Taffy at 90; WrapReverse plus Center places Yoga at 180 and Taffy at 90. Reconstructing this degenerate line placement would duplicate Flex logic. | -| Main-axis auto margins on a reversed axis or combined with a nonzero justifyContent offset | Different | Yoga lets positive-free-space main-axis auto margins consume the remaining space and can suppress justifyContent, while Taffy can distribute the margin and retain an additional offset. In a width-240 RowReverse, a width-60 child with margin-left auto has left 180 in Yoga and 360 in Taffy even under FlexStart. In a 240x180 Column with justifyContent Center, Yoga child tops are 65 and 160 while Taffy reports 130 and 225. Correcting either sibling distribution would duplicate Flex logic, so Taffy's geometry is retained and computed auto margins remain masked to zero. | -| Relative insets, including reversed axes | Compatible | Relative positioning does not participate in normal-flow sizing. When Taffy and Yoga apply opposite signs on a reversed axis, the projection can remove Taffy's declaration-derived offset and apply Yoga's declaration-derived offset to the Node's projected position and descendant absolute accumulation without redistributing siblings or calculating Flex geometry. A combination that also triggers the main-axis auto-margin difference remains covered by the Different row above. | +| APIs or result class | Classification | Mapping or observed boundary | +| --------------------------------------------------------------------------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| getComputedLeft/Top/Width/Height(), getComputedRight/Bottom(), and getComputedLayout() object shape | Compatible | Width, height, and normal left/top come from Taffy's unrounded layout followed by Yoga point-grid processing. Yoga right/bottom are stored trailing margin plus relative inset values rather than geometric remaining space, and can be reconstructed from finite declaration state. Root left/top also need Yoga's leading margin and relative inset because Taffy roots remain at zero. | +| getComputedMargin(), getComputedPadding(), and getComputedBorder() for Left/Top/Right/Bottom/Start/End | Compatible | Taffy's unrounded Layout contains the resolved physical records; Start and End map through the resolved layout direction, and auto declarations mask Taffy's distributed margin back to Yoga's reported zero. Yoga resolves a vertical percentage margin against containing width for the computed-margin value but against containing height when that same margin participates in absolute Top or Bottom placement, so projection keeps those two bases separate. Bounded comparisons cover positive and negative point margins, percentage padding and margins, physical and logical edges under LTR and RTL, auto margins, borders, and overflow. | +| Computed margin, padding, or border queried with Horizontal, Vertical, or All | Compatible | Reject these multi-edge shorthands; Yoga aborts rather than returning an aggregate result. Exact error class and wording are deferred. | +| Point-scale rounding | Compatible | It is an output conversion over unrounded layouts, including Yoga's ceil-sensitive text-node sizes, and does not require a Taffy core change. | +| Ordinary non-degenerate fixed sizes, simple grow or shrink without min/max, percentages, and simple nested flex trees | Compatible | Bounded differential probes found matching unrounded geometry outside the explicit difference triggers below. A committed real-Yoga oracle suite remains a release gate and must not be presented as a compatibility percentage. | +| min/max combined with flex growth or shrinkage | Different | Yoga and Taffy can produce different final sizes; the API remains available with Taffy's stable result. | +| Conflicting min/max on an intrinsically sized selected root | Different | Yoga routes roots with no definite size through root-kind-specific sizing modes before applying conflicting constraints, while Taffy retains its ordinary minimum-first result. With point rounding disabled, both a root containing a width-25 child and a measured root returning width 75 resolve to width 50 in Yoga but width 100 in Taffy when min-width is 100 and max-width is 50. Definite root sizes remain Compatible through bounded calculation-only translation. Reproducing Yoga's empty, Flex-content, and Measure sizing dispatch would duplicate layout-algorithm control flow. | +| Overlapping physical and logical main-axis margins under a child direction that differs from its parent | Different | Yoga can use one direction while totaling margins for Flex sizing and the child's resolved direction when it stores computed physical margins. In a width-100 LTR Row, a width-20 RTL child with margin-left 4 and margin-start 10% reports computed margins left 4 and right 10 in both engines, but Yoga resolves width 16 while Taffy remains width 20. Reproducing the hidden intermediate margin total would require Flex-specific size correction, so Taffy's stable result is retained. | +| aspectRatio in calculated layout | Different | Yoga derives the cross size from the final flex main size, while Taffy follows its own CSS-oriented aspect-ratio and stretch rules. With rounding disabled, a 100x100 Row parent and width-40 ratio-2 child produce 40x20 in Yoga but 40x100 in Taffy; a Column child declaring width 40, height 30, and ratio 2 produces 60x30 in Yoga but 40x30 in Taffy. Min/max and flex growth diverge further. Aligning all cases would require parent- and algorithm-dependent size rewriting, which crosses the accepted JavaScript boundary. | +| MeasureMode-sensitive callbacks | Different | Taffy does not preserve whether a finite constraint originated as Yoga Exactly or AtMost and may request intrinsic measurements in different phases. The bridge therefore gives each Taffy request a meaningful Yoga-shaped mode but does not promise a Yoga callback trace. Fixed-size and constraint-insensitive callbacks remain useful; callbacks branching on MeasureMode can have stable result differences. | +| WebFlexBasis disabled across a changed containing size | Different | Yoga 3.2.1 can reuse a stale computed percentage flex basis when the experiment is false, while Taffy recomputes it. Emulating Yoga's stale cache would require Yoga-internal flex-basis state and is outside the boundary; the enabled Yoga result matched the tested Taffy relayout. | +| Attached-subtree calculation followed by an ancestor calculation that Yoga serves from cache | Different | Yoga can retain geometry written by the separate subtree calculation because the clean ancestor does not revisit it. Taffy may recompute that ancestor after internal Style translation propagated native dirty state and can therefore produce current ancestor-context geometry instead. The package keeps calculateLayout available on both nodes and does not reproduce Yoga's complete cache algorithm. | +| Oversized WrapReverse lines with distribution-style alignContent | Different | With point rounding disabled, a 100x30 Row/WrapReverse container holding three 60-point items on separate 10, 20, and 15-point lines produces Yoga top positions 20, 0, and -15 but Taffy positions 35, 15, and 0 under Stretch, SpaceBetween, SpaceAround, or SpaceEvenly. The content's 45-point cross size exceeds the 30-point container; FlexStart, FlexEnd, and Center are not covered by this difference. Reconstructing Yoga's negative-free-space line placement would duplicate Flex logic. | +| A non-overflowing WrapReverse line with a cross-axis margin | Different | Cross-axis margin placement can also differ without overflow or distribution-style alignContent. In a 100x100 Row/WrapReverse container with alignItems FlexStart, a 40x20 child with an 11-point bottom margin has top 80 in Yoga and top 69 in Taffy. Correcting this requires reproducing WrapReverse line and item alignment, so Taffy's placement is retained. | +| Oversized single line on a reversed main axis with distribution-style justifyContent | Different | When a RowReverse or ColumnReverse no-wrap line overflows and justifyContent is SpaceBetween, SpaceAround, or SpaceEvenly, Yoga and Taffy choose different negative-free-space fallback placement. In a 240-point RowReverse line with 140, 130, and 120-point children, Yoga left positions are 100, -30, and -150 while Taffy reports 250, 120, and 0. This affects sibling distribution and is not an output-only correction. | +| Zero-cross-size wrapped line under Stretch item alignment | Different | A wrapped line whose items all resolve to zero cross size stays on Yoga's cross-start or cross-end edge, while Taffy distributes the zero-size line under FlexEnd, Center, SpaceAround, or SpaceEvenly alignContent. For a 180-point cross axis, ordinary Wrap plus Center places Yoga at 0 and Taffy at 90; WrapReverse plus Center places Yoga at 180 and Taffy at 90. Reconstructing this degenerate line placement would duplicate Flex logic. | +| Main-axis auto margins on a reversed axis or combined with a nonzero justifyContent offset | Different | Yoga lets positive-free-space main-axis auto margins consume the remaining space and can suppress justifyContent, while Taffy can distribute the margin and retain an additional offset. In a width-240 RowReverse, a width-60 child with margin-left auto has left 180 in Yoga and 360 in Taffy even under FlexStart. In a 240x180 Column with justifyContent Center, Yoga child tops are 65 and 160 while Taffy reports 130 and 225. Correcting either sibling distribution would duplicate Flex logic, so Taffy's geometry is retained and computed auto margins remain masked to zero. | +| Relative insets, including reversed axes | Compatible | Relative positioning does not participate in normal-flow sizing. When Taffy and Yoga apply opposite signs on a reversed axis, the projection can remove Taffy's declaration-derived offset and apply Yoga's declaration-derived offset to the Node's projected position and descendant absolute accumulation without redistributing siblings or calculating Flex geometry. A combination that also triggers the main-axis auto-margin difference remains covered by the Different row above. | ### Yoga-shaped output projection @@ -420,7 +412,7 @@ Treating the stored Taffy Style as the only source for Yoga getters and later mu | position, margin, padding, and border | Yoga stores nine independent declarations per family: Left, Top, Right, Bottom, Start, End, Horizontal, Vertical, and All. Taffy stores four resolved physical sides. The original precedence and fallback declarations cannot be reconstructed. | | gap | Yoga stores Column, Row, and All independently; Taffy stores two resolved axes. | | flex shorthand and longhands | Yoga stores flex, flexGrow, and flexShrink as independent optional numbers and flexBasis as a separate tagged length. The JavaScript API has setFlex but no getFlex, yet the stored flex value remains observable through later longhand resets, copyStyle, and layout. Taffy has no flex shorthand and stores only concrete grow, shrink, and basis values. Unset and explicit zero have different fallback behavior even though Yoga getters can currently return the same number. | -| dimensions and flex basis | Yoga distinguishes Unit.Undefined from Unit.Auto. Taffy's public Dimension has Auto but no Undefined, and its automatic minimum can retain min-content size; the translation therefore keeps the Yoga declaration separately and maps an undefined minimum to an explicit zero-length Taffy minimum. | +| dimensions and flex basis | Yoga distinguishes Unit.Undefined from Unit.Auto. Taffy's public Dimension has Auto but no Undefined, and its automatic minimum can retain min-content size; the translation therefore keeps the Yoga declaration separately, treats negative ordinary widths and heights as Auto only during calculation, and maps an undefined minimum to an explicit zero-length Taffy minimum. | | direction | Yoga stores Inherit and resolves it from the calculation direction and ancestor state. Taffy Style stores only Ltr or Rtl. Start and End edge resolution therefore also depends on declaration state outside the final four-sided Rect. | | Config-sensitive defaults | useWebDefaults changes construction and reset defaults and the resolved flex-shrink default. copyStyle copies Style but retains the destination Config, so copying an unset flexShrink from a normal-default node to a web-default node changes the getter from 0 to 1. A resolved Taffy number cannot preserve that distinction. | diff --git a/.agents/docs/technology-stack.md b/.agents/docs/technology-stack.md index 04edab3..44c2044 100644 --- a/.agents/docs/technology-stack.md +++ b/.agents/docs/technology-stack.md @@ -46,8 +46,6 @@ The interactive Getting Started example imports the real browser entry from `@ta The root `vite.config.ts` defines build and verification dependencies. The native build is an explicit graph from napi-rs binding generation through Node builtin specifier normalization and generated-file formatting to the independent platform-artifact and Vite+ entry branches. The Wasm build is a three-stage chain—staged napi-rs binding, Vite+ public entries, then final inline runtime files. Native build completion precedes package-local and consumer tests. Rust tests are part of the full test task, while Rust formatting and Clippy remain separate checks. Node formatting and linting do not depend on a native build. Generated-source drift is checked separately in CI and is not part of the default local `check` or `ready` graph. -The aggregate `check` task completes its formatting scan before invoking the build-backed test graph. This prevents the formatter from observing napi-rs transaction directories or generated package files while a concurrent native build is replacing them; invoking `check:test` directly remains independent of formatting. - Task caching is disabled at the root. This keeps native artifacts and runtime tests from being skipped or restored from stale task outputs until the project makes a new explicit caching decision. `check:wasm` is a separate Linux-capable graph rather than a dependency of the cross-platform default `check:test`: it requires the `wasm32-wasip1` Rust target and a Playwright browser. Independent verification branches run in parallel after the shared Wasm build, while browser-bundle inspection waits only for its consumer build. Existing native jobs therefore keep their target assumptions while the dedicated WASIP job exercises the complete package boundary. diff --git a/packages/taffyjs-yoga/COMPATIBILITY.md b/packages/taffyjs-yoga/COMPATIBILITY.md index 43e787b..dd424cb 100644 --- a/packages/taffyjs-yoga/COMPATIBILITY.md +++ b/packages/taffyjs-yoga/COMPATIBILITY.md @@ -74,20 +74,21 @@ An upstream pull request is not a support promise or an ETA. Before reclassifica These rows are deliberate, version-specific differences with regression fixtures: -| Capability and trigger | `@taffyjs/yoga` behavior | Yoga 3.2.1 behavior | -| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | -| Change `useWebDefaults` on a live Config, then calculate a tree whose unresolved shrink values are already cached | Applies the current Config-sensitive Style when the selected calculation reaches Taffy. In the fixture, two 80-point children in a 100-point row become 50/50. | The Config setter does not advance Yoga's cache version, so the same calculation retains 80/80 even though `getFlexShrink()` reports 1. | -| `setDirtiedFunc()` callback argument and `setDirtiedFunc(null)` | Calls a real callback with its canonical Node after state commit; null unsets the callback. | Calls a real callback with zero arguments; null is retained and a later clean-to-dirty transition throws a wrapper TypeError. | -| A min or max constraint combined with Flex growth or shrinkage | Keeps Taffy's Flex result. The pinned width-120, max-width-90, shrink-1 child in a width-60 Row resolves to 60. | The pinned child resolves to 67.5; other combinations in this result group can likewise follow Yoga's different min/max ordering. | -| Overlapping physical and logical main-axis margins under a child direction that differs from its parent | Resolves the declarations to one physical Taffy margin rect. A width-20 RTL child with `margin-left: 4` and `margin-start: 10%` in a width-100 LTR Row remains width 20. | Computed margins are also left 4 and right 10, but Yoga's separate Flex margin resolution reduces the child width to 16. | -| Calculated `aspectRatio` | Keeps Taffy's CSS-oriented aspect-ratio and stretch result. A width-40, ratio-2 child stretched by a 100-by-100 Row is 40-by-100. | The same child is 40-by-20. | -| `WebFlexBasis` disabled while a percentage basis is recalculated under a changed containing size | Recomputes the percentage basis. A 50% basis changes from 50 to 100 when its Row changes from width 100 to 200. | Reuses the stale 50-point basis while the experiment is disabled; enabling the experiment produces 100 in both implementations. | -| Calculate an attached subtree with different owner dimensions, then calculate a clean ancestor | Restoring the selected Node's ordinary native Style invalidates Taffy's ancestor cache. The fixture returns the attached Row from width 100 to 200 and its children from 50 to 80. | The clean ancestor retains the attached-subtree result: Row width 100 and child width 50. | -| Oversized `WrapReverse` lines with distribution-style `alignContent` | Keeps Taffy's line placement. Three 60-point lines with heights 10, 20, and 15 in a 100-by-30 Row have tops 35, 15, and 0 under Stretch. | The same tops are 20, 0, and -15. | -| A non-overflowing `WrapReverse` line with a cross-axis margin | Keeps Taffy's cross-axis margin placement. A 40-by-20 child with an 11-point bottom margin in a 100-by-100 Row has top 69 under `alignItems: FlexStart`. | The same child has top 80. | -| An oversized no-wrap reversed main axis with `SpaceBetween`, `SpaceAround`, or `SpaceEvenly` | Keeps Taffy's negative-free-space fallback. Widths 140, 130, and 120 in a width-240 `RowReverse` with `SpaceBetween` have left positions 250, 120, and 0. | The same left positions are 100, -30, and -150. | -| A wrapped line whose stretched items resolve to zero cross size under distribution-style `alignContent` | Keeps Taffy's line distribution. Two 60-point, flex-basis-zero children in a 100-by-180 Row with `alignContent: Center` both have top 90 for Wrap and WrapReverse. | Both children have top 0 for Wrap and 180 for WrapReverse. | -| Main-axis auto margins on a reversed axis or combined with a nonzero `justifyContent` offset | Keeps Taffy's distribution and masks computed auto margins to zero. A width-60 child with `margin-left: auto` in a width-240 `RowReverse` has left 360; the Center fixture has child tops 130 and 225. | Yoga places the reversed child at left 180; auto margins suppress the Center offset and produce tops 65 and 160. | -| Measure callback count, order, and exact mode trace | Maps every Taffy request independently. A constant 30-by-10 callback for a child in a 100-by-40 Row runs four times in the pinned fixture and still produces the same 30-by-40 layout. | Runs once with width `AtMost(100)` and height `Exactly(40)` in the same fixture. | -| A Measure callback whose result changes with `MeasureMode` | Keeps Taffy's multi-stage result. In the pinned 100-by-40 Row, a callback returning width 80 for `Undefined` and 20 for `AtMost` produces width 80. | Calls the callback with `AtMost(100)` and produces width 20. | -| Replace or remove a Measure callback on an otherwise clean Node | Privately invalidates Taffy's measurement cache. Replacing a cached width-10 callback with width 20 produces 20 on the next calculation; removing it then produces zero. | Leaves the Node publicly clean and reuses the cached width 10 after both operations. | +| Capability and trigger | `@taffyjs/yoga` behavior | Yoga 3.2.1 behavior | +| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Change `useWebDefaults` on a live Config, then calculate a tree whose unresolved shrink values are already cached | Applies the current Config-sensitive Style when the selected calculation reaches Taffy. In the fixture, two 80-point children in a 100-point row become 50/50. | The Config setter does not advance Yoga's cache version, so the same calculation retains 80/80 even though `getFlexShrink()` reports 1. | +| `setDirtiedFunc()` callback argument and `setDirtiedFunc(null)` | Calls a real callback with its canonical Node after state commit; null unsets the callback. | Calls a real callback with zero arguments; null is retained and a later clean-to-dirty transition throws a wrapper TypeError. | +| A min or max constraint combined with Flex growth or shrinkage | Keeps Taffy's Flex result. The pinned width-120, max-width-90, shrink-1 child in a width-60 Row resolves to 60. | The pinned child resolves to 67.5; other combinations in this result group can likewise follow Yoga's different min/max ordering. | +| A selected root without a definite size has a conflicting minimum and maximum on that axis | Keeps Taffy's minimum-first constraint result. The child-content and measured fixtures both resolve a min-width-100/max-width-50 root to 100. | Root sizing mode affects Yoga's constraint order. The pinned 25-point child-content and measured-width-75 fixtures both resolve the root to 50. | +| Overlapping physical and logical main-axis margins under a child direction that differs from its parent | Resolves the declarations to one physical Taffy margin rect. A width-20 RTL child with `margin-left: 4` and `margin-start: 10%` in a width-100 LTR Row remains width 20. | Computed margins are also left 4 and right 10, but Yoga's separate Flex margin resolution reduces the child width to 16. | +| Calculated `aspectRatio` | Keeps Taffy's CSS-oriented aspect-ratio and stretch result. A width-40, ratio-2 child stretched by a 100-by-100 Row is 40-by-100. | The same child is 40-by-20. | +| `WebFlexBasis` disabled while a percentage basis is recalculated under a changed containing size | Recomputes the percentage basis. A 50% basis changes from 50 to 100 when its Row changes from width 100 to 200. | Reuses the stale 50-point basis while the experiment is disabled; enabling the experiment produces 100 in both implementations. | +| Calculate an attached subtree with different owner dimensions, then calculate a clean ancestor | Restoring the selected Node's ordinary native Style invalidates Taffy's ancestor cache. The fixture returns the attached Row from width 100 to 200 and its children from 50 to 80. | The clean ancestor retains the attached-subtree result: Row width 100 and child width 50. | +| Oversized `WrapReverse` lines with distribution-style `alignContent` | Keeps Taffy's line placement. Three 60-point lines with heights 10, 20, and 15 in a 100-by-30 Row have tops 35, 15, and 0 under Stretch. | The same tops are 20, 0, and -15. | +| A non-overflowing `WrapReverse` line with a cross-axis margin | Keeps Taffy's cross-axis margin placement. A 40-by-20 child with an 11-point bottom margin in a 100-by-100 Row has top 69 under `alignItems: FlexStart`. | The same child has top 80. | +| An oversized no-wrap reversed main axis with `SpaceBetween`, `SpaceAround`, or `SpaceEvenly` | Keeps Taffy's negative-free-space fallback. Widths 140, 130, and 120 in a width-240 `RowReverse` with `SpaceBetween` have left positions 250, 120, and 0. | The same left positions are 100, -30, and -150. | +| A wrapped line whose stretched items resolve to zero cross size under distribution-style `alignContent` | Keeps Taffy's line distribution. Two 60-point, flex-basis-zero children in a 100-by-180 Row with `alignContent: Center` both have top 90 for Wrap and WrapReverse. | Both children have top 0 for Wrap and 180 for WrapReverse. | +| Main-axis auto margins on a reversed axis or combined with a nonzero `justifyContent` offset | Keeps Taffy's distribution and masks computed auto margins to zero. A width-60 child with `margin-left: auto` in a width-240 `RowReverse` has left 360; the Center fixture has child tops 130 and 225. | Yoga places the reversed child at left 180; auto margins suppress the Center offset and produce tops 65 and 160. | +| Measure callback count, order, and exact mode trace | Maps every Taffy request independently. A constant 30-by-10 callback for a child in a 100-by-40 Row runs four times in the pinned fixture and still produces the same 30-by-40 layout. | Runs once with width `AtMost(100)` and height `Exactly(40)` in the same fixture. | +| A Measure callback whose result changes with `MeasureMode` | Keeps Taffy's multi-stage result. In the pinned 100-by-40 Row, a callback returning width 80 for `Undefined` and 20 for `AtMost` produces width 80. | Calls the callback with `AtMost(100)` and produces width 20. | +| Replace or remove a Measure callback on an otherwise clean Node | Privately invalidates Taffy's measurement cache. Replacing a cached width-10 callback with width 20 produces 20 on the next calculation; removing it then produces zero. | Leaves the Node publicly clean and reuses the cached width 10 after both operations. | diff --git a/packages/taffyjs-yoga/dist/facade-acowLNHQ.js b/packages/taffyjs-yoga/dist/facade-CjSanW_Y.js similarity index 97% rename from packages/taffyjs-yoga/dist/facade-acowLNHQ.js rename to packages/taffyjs-yoga/dist/facade-CjSanW_Y.js index 2f81e63..2a2130b 100644 --- a/packages/taffyjs-yoga/dist/facade-acowLNHQ.js +++ b/packages/taffyjs-yoga/dist/facade-CjSanW_Y.js @@ -289,6 +289,10 @@ function toDimension(value) { case 0: return Dimension.Auto; } } +function toSizeDimension(value) { + if ((value.unit === 1 || value.unit === 2) && value.value < 0) return Dimension.Auto; + return toDimension(value); +} function toMinDimension(value) { return value.unit === 0 ? Dimension.Length(0) : toDimension(value); } @@ -559,7 +563,7 @@ function absoluteAxisLocation(entry, parentEntry, horizontal) { const selectedEdge = startPosition === void 0 ? end : start; const selectedPosition = startPosition ?? endPosition; if (selectedPosition === void 0) return void 0; - const selectedMargin = resolvePosition(physicalValue$1(entry.declarations.margin, selectedEdge, parentEntry.direction), containingWidth) ?? 0; + const selectedMargin = resolvePosition(physicalValue$1(entry.declarations.margin, selectedEdge, parentEntry.direction), positionBasis) ?? 0; const selectedBorder = selectedEdge === 0 ? parentLayout.border.left : selectedEdge === 1 ? parentLayout.border.top : selectedEdge === 2 ? parentLayout.border.right : parentLayout.border.bottom; const offset = floatAdd(floatAdd(selectedPosition, selectedMargin), selectedBorder); if (!(selectedEdge === 2 || selectedEdge === 3)) return { @@ -810,8 +814,8 @@ function translateStyle(declarations, config, resolvedDirection, ownerDirection) position: declarations.positionType === 2 ? Position.Absolute : Position.Relative, inset: position, size: { - width: toDimension(declarations.width), - height: toDimension(declarations.height) + width: toSizeDimension(declarations.width), + height: toSizeDimension(declarations.height) }, minSize: { width: toMinDimension(declarations.minWidth), @@ -869,11 +873,19 @@ function exactRootDimension(declarations, axis, ownerSize, ownerWidth, resolvedD } return Math.max(0, outerSize); } +function rootMinimumOverride(declared, minimum, maximum, ownerSize) { + const declaredLength = resolveLength(declared, ownerSize); + const minimumLength = resolveLength(minimum, ownerSize); + const maximumLength = resolveLength(maximum, ownerSize); + return declaredLength !== void 0 && declaredLength >= 0 && minimumLength !== void 0 && minimumLength >= 0 && maximumLength !== void 0 && maximumLength >= 0 && minimumLength > maximumLength && declaredLength > maximumLength ? toDimension(maximum) : void 0; +} function translateCalculationStyle(declarations, config, resolvedDirection, ownerDirection, ownerWidth, ownerHeight) { const width = exactRootDimension(declarations, "width", ownerWidth, ownerWidth, resolvedDirection); const height = exactRootDimension(declarations, "height", ownerHeight, ownerWidth, resolvedDirection); const forceFlexDisplay = declarations.display === 1; - if (width === void 0 && height === void 0 && !forceFlexDisplay) return { + const minimumWidth = rootMinimumOverride(declarations.width, declarations.minWidth, declarations.maxWidth, ownerWidth); + const minimumHeight = rootMinimumOverride(declarations.height, declarations.minHeight, declarations.maxHeight, ownerHeight); + if (width === void 0 && height === void 0 && minimumWidth === void 0 && minimumHeight === void 0 && !forceFlexDisplay) return { style: null, exactWidth: false, exactHeight: false @@ -884,8 +896,12 @@ function translateCalculationStyle(declarations, config, resolvedDirection, owne ...ordinary, display: forceFlexDisplay ? Display.Flex : ordinary.display, size: { - width: width === void 0 ? toDimension(declarations.width) : Dimension.Length(width), - height: height === void 0 ? toDimension(declarations.height) : Dimension.Length(height) + width: width === void 0 ? toSizeDimension(declarations.width) : Dimension.Length(width), + height: height === void 0 ? toSizeDimension(declarations.height) : Dimension.Length(height) + }, + minSize: { + width: minimumWidth ?? toMinDimension(declarations.minWidth), + height: minimumHeight ?? toMinDimension(declarations.minHeight) } }, exactWidth: width !== void 0, diff --git a/packages/taffyjs-yoga/dist/index.js b/packages/taffyjs-yoga/dist/index.js index ddd7572..003e434 100644 --- a/packages/taffyjs-yoga/dist/index.js +++ b/packages/taffyjs-yoga/dist/index.js @@ -1,4 +1,4 @@ -import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-acowLNHQ.js"; +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-CjSanW_Y.js"; //#region src/index.ts const Yoga = createYoga(); //#endregion diff --git a/packages/taffyjs-yoga/dist/load.js b/packages/taffyjs-yoga/dist/load.js index af5627c..92c9aaa 100644 --- a/packages/taffyjs-yoga/dist/load.js +++ b/packages/taffyjs-yoga/dist/load.js @@ -1,4 +1,4 @@ -import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-acowLNHQ.js"; +import { _ as PositionType, a as Direction, c as Errata, d as Gutter, f as Justify, g as Overflow, h as NodeType, i as Dimension, l as ExperimentalFeature, m as MeasureMode, n as Align, o as Display, p as LogLevel, r as BoxSizing, s as Edge, t as createYoga, u as FlexDirection, v as Unit, y as Wrap } from "./facade-CjSanW_Y.js"; //#region src/load.ts async function loadYoga() { return createYoga(); diff --git a/packages/taffyjs-yoga/src/projection.ts b/packages/taffyjs-yoga/src/projection.ts index ac4df3e..32f780e 100644 --- a/packages/taffyjs-yoga/src/projection.ts +++ b/packages/taffyjs-yoga/src/projection.ts @@ -321,7 +321,7 @@ function absoluteAxisLocation( const selectedMargin = resolvePosition( physicalValue(entry.declarations.margin, selectedEdge, parentEntry.direction), - containingWidth, + positionBasis, ) ?? 0; const selectedBorder = selectedEdge === Edge.Left diff --git a/packages/taffyjs-yoga/src/translate.ts b/packages/taffyjs-yoga/src/translate.ts index a8b390f..2c2d9e5 100644 --- a/packages/taffyjs-yoga/src/translate.ts +++ b/packages/taffyjs-yoga/src/translate.ts @@ -34,6 +34,7 @@ import { toLengthPercentage, toLengthPercentageAuto, toMinDimension, + toSizeDimension, undefinedValue, resolvePercentage, type YogaValue, @@ -251,8 +252,8 @@ export function translateStyle( : TaffyPosition.Relative, inset: position, size: { - width: toDimension(declarations.width), - height: toDimension(declarations.height), + width: toSizeDimension(declarations.width), + height: toSizeDimension(declarations.height), }, minSize: { width: toMinDimension(declarations.minWidth), @@ -350,6 +351,27 @@ function exactRootDimension( return Math.max(0, outerSize); } +function rootMinimumOverride( + declared: YogaValue, + minimum: YogaValue, + maximum: YogaValue, + ownerSize: number | undefined, +): DimensionInput | undefined { + const declaredLength = resolveLength(declared, ownerSize); + const minimumLength = resolveLength(minimum, ownerSize); + const maximumLength = resolveLength(maximum, ownerSize); + return declaredLength !== undefined && + declaredLength >= 0 && + minimumLength !== undefined && + minimumLength >= 0 && + maximumLength !== undefined && + maximumLength >= 0 && + minimumLength > maximumLength && + declaredLength > maximumLength + ? toDimension(maximum) + : undefined; +} + export interface CalculationStylePlan { readonly style: StyleInput | null; readonly exactWidth: boolean; @@ -379,7 +401,25 @@ export function translateCalculationStyle( resolvedDirection, ); const forceFlexDisplay = declarations.display === Display.None; - if (width === undefined && height === undefined && !forceFlexDisplay) { + const minimumWidth = rootMinimumOverride( + declarations.width, + declarations.minWidth, + declarations.maxWidth, + ownerWidth, + ); + const minimumHeight = rootMinimumOverride( + declarations.height, + declarations.minHeight, + declarations.maxHeight, + ownerHeight, + ); + if ( + width === undefined && + height === undefined && + minimumWidth === undefined && + minimumHeight === undefined && + !forceFlexDisplay + ) { return { style: null, exactWidth: false, exactHeight: false }; } @@ -389,9 +429,16 @@ export function translateCalculationStyle( ...ordinary, display: forceFlexDisplay ? TaffyDisplay.Flex : ordinary.display, size: { - width: width === undefined ? toDimension(declarations.width) : TaffyDimension.Length(width), + width: + width === undefined ? toSizeDimension(declarations.width) : TaffyDimension.Length(width), height: - height === undefined ? toDimension(declarations.height) : TaffyDimension.Length(height), + height === undefined + ? toSizeDimension(declarations.height) + : TaffyDimension.Length(height), + }, + minSize: { + width: minimumWidth ?? toMinDimension(declarations.minWidth), + height: minimumHeight ?? toMinDimension(declarations.minHeight), }, }, exactWidth: width !== undefined, diff --git a/packages/taffyjs-yoga/src/values.ts b/packages/taffyjs-yoga/src/values.ts index 5d8ae78..74f7884 100644 --- a/packages/taffyjs-yoga/src/values.ts +++ b/packages/taffyjs-yoga/src/values.ts @@ -129,6 +129,13 @@ export function toDimension(value: YogaValue): DimensionInput { } } +export function toSizeDimension(value: YogaValue): DimensionInput { + if ((value.unit === Unit.Point || value.unit === Unit.Percent) && value.value < 0) { + return TaffyDimension.Auto; + } + return toDimension(value); +} + export function toMinDimension(value: YogaValue): DimensionInput { return value.unit === Unit.Undefined ? TaffyDimension.Length(0) : toDimension(value); } diff --git a/tests/taffyjs-yoga/computed-output.test.mts b/tests/taffyjs-yoga/computed-output.test.mts index c46ba58..2d14106 100644 --- a/tests/taffyjs-yoga/computed-output.test.mts +++ b/tests/taffyjs-yoga/computed-output.test.mts @@ -136,6 +136,169 @@ test("finite owner dimensions become exact selected-root constraints", () => { } }); +test("negative width and height declarations compute as auto without changing getters", () => { + const scenarios: Array<{ + readonly name: string; + readonly axis: "width" | "height"; + readonly configure: (actual: Node, expected: OracleNode) => void; + }> = [ + { + name: "negative point width", + axis: "width", + configure: (actual, expected) => { + actual.setWidth(-1); + expected.setWidth(-1); + }, + }, + { + name: "negative percent width", + axis: "width", + configure: (actual, expected) => { + actual.setWidthPercent(-10); + expected.setWidthPercent(-10); + }, + }, + { + name: "negative point height", + axis: "height", + configure: (actual, expected) => { + actual.setHeight(-1); + expected.setHeight(-1); + }, + }, + { + name: "negative percent height", + axis: "height", + configure: (actual, expected) => { + actual.setHeightPercent(-10); + expected.setHeightPercent(-10); + }, + }, + ]; + + for (const scenario of scenarios) { + const actualParent = Yoga.Node.create(); + const actualChild = Yoga.Node.create(); + const expectedParent = OracleYoga.Node.create(); + const expectedChild = OracleYoga.Node.create(); + try { + actualParent.setWidth(100); + actualParent.setHeight(100); + expectedParent.setWidth(100); + expectedParent.setHeight(100); + if (scenario.axis === "width") { + actualParent.setFlexDirection(FlexDirection.Column); + expectedParent.setFlexDirection(OracleFlexDirection.Column); + actualChild.setHeight(20); + expectedChild.setHeight(20); + } else { + actualParent.setFlexDirection(FlexDirection.Row); + expectedParent.setFlexDirection(OracleFlexDirection.Row); + actualChild.setWidth(20); + expectedChild.setWidth(20); + } + scenario.configure(actualChild, expectedChild); + actualParent.insertChild(actualChild, 0); + expectedParent.insertChild(expectedChild, 0); + + const actualDeclaration = + scenario.axis === "width" ? actualChild.getWidth() : actualChild.getHeight(); + const expectedDeclaration = + scenario.axis === "width" ? expectedChild.getWidth() : expectedChild.getHeight(); + assert.deepEqual(actualDeclaration, expectedDeclaration, `${scenario.name} declaration`); + actualParent.calculateLayout(undefined, undefined); + expectedParent.calculateLayout(undefined, undefined); + assertLayoutEqual(actualChild, expectedChild, scenario.name); + assert.deepEqual( + scenario.axis === "width" ? actualChild.getWidth() : actualChild.getHeight(), + actualDeclaration, + `${scenario.name} retained declaration`, + ); + } finally { + actualParent.freeRecursive(); + expectedParent.freeRecursive(); + } + } +}); + +test("selected-root definite sizes follow Yoga's conflicting-constraint order", () => { + for (const axis of ["width", "height"] as const) { + for (const unit of ["point", "percent"] as const) { + for (const base of [25, 50, 75]) { + const actual = Yoga.Node.create(); + const expected = OracleYoga.Node.create(); + const name = `${axis} ${unit} base ${base}`; + try { + if (axis === "width") { + actual.setWidth(base); + actual.setHeight(10); + expected.setWidth(base); + expected.setHeight(10); + if (unit === "point") { + actual.setMinWidth(100); + actual.setMaxWidth(50); + expected.setMinWidth(100); + expected.setMaxWidth(50); + } else { + actual.setMinWidthPercent(60); + actual.setMaxWidthPercent(25); + expected.setMinWidthPercent(60); + expected.setMaxWidthPercent(25); + } + } else { + actual.setWidth(10); + actual.setHeight(base); + expected.setWidth(10); + expected.setHeight(base); + if (unit === "point") { + actual.setMinHeight(100); + actual.setMaxHeight(50); + expected.setMinHeight(100); + expected.setMaxHeight(50); + } else { + actual.setMinHeightPercent(60); + actual.setMaxHeightPercent(25); + expected.setMinHeightPercent(60); + expected.setMaxHeightPercent(25); + } + } + + const ownerWidth = unit === "percent" && axis === "width" ? 200 : undefined; + const ownerHeight = unit === "percent" && axis === "height" ? 200 : undefined; + const minimum = axis === "width" ? actual.getMinWidth() : actual.getMinHeight(); + const maximum = axis === "width" ? actual.getMaxWidth() : actual.getMaxHeight(); + assert.deepEqual( + minimum, + axis === "width" ? expected.getMinWidth() : expected.getMinHeight(), + `${name} minimum`, + ); + assert.deepEqual( + maximum, + axis === "width" ? expected.getMaxWidth() : expected.getMaxHeight(), + `${name} maximum`, + ); + actual.calculateLayout(ownerWidth, ownerHeight); + expected.calculateLayout(ownerWidth, ownerHeight); + assertLayoutEqual(actual, expected, name); + assert.deepEqual( + axis === "width" ? actual.getMinWidth() : actual.getMinHeight(), + minimum, + `${name} retained minimum`, + ); + assert.deepEqual( + axis === "width" ? actual.getMaxWidth() : actual.getMaxHeight(), + maximum, + `${name} retained maximum`, + ); + } finally { + actual.free(); + expected.free(); + } + } + } + } +}); + test("calculation input preserves Yoga's owner-direction fallback and finite boundary", () => { for (const direction of [Direction.Inherit, Direction.LTR, Direction.RTL] as const) { const actualRoot = Yoga.Node.create(); @@ -409,6 +572,51 @@ test("absolute logical margins use containing direction for placement", () => { } }); +test("absolute vertical percentage margins separate placement and computed-margin bases", () => { + for (const [edge, oracleEdge] of [ + [Edge.Top, OracleEdge.Top], + [Edge.Bottom, OracleEdge.Bottom], + ] as const) { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + const parent = Yoga.Node.createWithConfig(config); + const child = Yoga.Node.createWithConfig(config); + const oracleParent = OracleYoga.Node.createWithConfig(oracleConfig); + const oracleChild = OracleYoga.Node.createWithConfig(oracleConfig); + try { + parent.setWidth(120); + parent.setHeight(90); + child.setPositionType(PositionType.Absolute); + child.setWidth(10); + child.setHeight(10); + child.setPosition(edge, 0); + child.setMarginPercent(edge, 5); + parent.insertChild(child, 0); + oracleParent.setWidth(120); + oracleParent.setHeight(90); + oracleChild.setPositionType(OraclePositionType.Absolute); + oracleChild.setWidth(10); + oracleChild.setHeight(10); + oracleChild.setPosition(oracleEdge, 0); + oracleChild.setMarginPercent(oracleEdge, 5); + oracleParent.insertChild(oracleChild, 0); + + parent.calculateLayout(undefined, undefined); + oracleParent.calculateLayout(undefined, undefined); + assertLayoutEqual(child, oracleChild, Edge[edge]); + assert.equal(child.getComputedMargin(edge), oracleChild.getComputedMargin(oracleEdge)); + assert.equal(child.getComputedMargin(edge), 6, `${Edge[edge]} computed margin uses width`); + } finally { + parent.freeRecursive(); + oracleParent.freeRecursive(); + config.free(); + oracleConfig.free(); + } + } +}); + test("percentage projected positions preserve Yoga float32 operation order", () => { const parent = Yoga.Node.create(); const child = Yoga.Node.create(); diff --git a/tests/taffyjs-yoga/differences.test.mts b/tests/taffyjs-yoga/differences.test.mts index f0e375e..b265b97 100644 --- a/tests/taffyjs-yoga/differences.test.mts +++ b/tests/taffyjs-yoga/differences.test.mts @@ -146,6 +146,48 @@ test("Different: max size combined with flex shrink keeps Taffy geometry", () => } }); +test("Different: intrinsic selected roots keep Taffy's conflicting constraint order", () => { + for (const kind of ["child content", "measurement"] as const) { + const config = Yoga.Config.create(); + const oracleConfig = OracleYoga.Config.create(); + config.setPointScaleFactor(0); + oracleConfig.setPointScaleFactor(0); + const root = Yoga.Node.createWithConfig(config); + const oracleRoot = OracleYoga.Node.createWithConfig(oracleConfig); + try { + root.setHeight(10); + root.setMinWidth(100); + root.setMaxWidth(50); + oracleRoot.setHeight(10); + oracleRoot.setMinWidth(100); + oracleRoot.setMaxWidth(50); + if (kind === "child content") { + const child = Yoga.Node.createWithConfig(config); + const oracleChild = OracleYoga.Node.createWithConfig(oracleConfig); + child.setWidth(25); + child.setHeight(1); + oracleChild.setWidth(25); + oracleChild.setHeight(1); + root.insertChild(child, 0); + oracleRoot.insertChild(oracleChild, 0); + } else { + root.setMeasureFunc(() => ({ width: 75, height: 10 })); + oracleRoot.setMeasureFunc(() => ({ width: 75, height: 10 })); + } + + root.calculateLayout(undefined, undefined); + oracleRoot.calculateLayout(undefined, undefined); + assert.equal(root.getComputedWidth(), 100, `${kind} Taffy`); + assert.equal(oracleRoot.getComputedWidth(), 50, `${kind} Yoga`); + } finally { + root.freeRecursive(); + oracleRoot.freeRecursive(); + config.free(); + oracleConfig.free(); + } + } +}); + test("Different: overlapping physical and logical margins keep Taffy sizing", () => { const config = Yoga.Config.create(); const oracleConfig = OracleYoga.Config.create();