Skip to content

Fractional Text measurement is coupled to Yoga callback order #283

Closed

Description

@hyfdev

Summary

Fractional Text measurement currently depends on a Yoga-specific sequence of measure callback invocations.

The Runtime currently:

  1. measures Text using a provisional whole-cell width;
  2. runs calculateLayout();
  3. compares the measured width with the final pixel-grid width;
  4. stores an override keyed by (availableWidth, widthMode);
  5. marks the Text node dirty and runs layout again when the line count changes.

This works with the current yoga-layout@3.2.1 callback trace, but the result can change when a compatible backend requests the same width using a different measure mode in the following pass.

The measurement result may legitimately differ between Exactly and AtMost. The problem is not that these modes should be treated as equivalent; it is that VueTUI's correction is stateful and only applies when a later callback happens to repeat the expected mode.

Reproduction

I tested VueTUI at commit 5ddb623 with yoga-layout locally replaced by the research implementation from @taffyjs/yoga PR #9 at commit 007bc33.

This is a backend-substitution experiment, not a currently supported VueTUI configuration.

cd tests/runtime
vp test run integration/layout/text-measure-parity.test.tsx

Two of eight tests fail:

expected: "buil\nd\nafter"
received: "buil\nafter"

expected: "xbuil\n d\nafter"
received: "xbuil\nafter"

In the first case, the relevant layout passes both provide availableWidth = 4.25, but use Exactly and AtMost at different points. The override recorded for one request is therefore not consumed by the other, and the final d is clipped.

The official yoga-layout@3.2.1 backend passes these tests because its callback trace happens to match the current reconciliation state.

Why this matters

A measure callback may be invoked multiple times internally during one layout calculation; that is normal.

The fragile part is that the Runtime completes a layout, stores information derived from that particular callback trace, marks the node dirty, and runs layout again expecting a compatible follow-up trace.

This creates hidden coupling to one layout engine's callback count, order, and modes.

Ink previously removed a similar renderer-owned layout/wrap/layout cycle in PR #276. However, copying Ink's current single-pass behavior directly is not sufficient: its conservative wrapping can leave a stale blank row for this fractional-width fixture. VueTUI therefore needs to establish its own terminal-cell contract rather than assuming floor() or ceil() is always correct.

Desired outcome

Investigate and prototype a Text measurement design with these properties:

  • The same complete measurement input—text revision, wrapping mode, available width, and measure mode—returns the same result regardless of previous or future callback calls.
  • Correctness does not depend on a particular Yoga callback count, order, or mode transition.
  • Measurement and painting agree on the number of wrapped lines.
  • Existing fractional-origin, fractional-offset, overflow-hidden, sub-cell, and dynamic-content cases remain covered.
  • Runtime-owned calculateLayout() pass count is observable in the regression tests.

Prefer a correct single-layout strategy. If the existing post-layout feedback pass is genuinely necessary because the final terminal-cell width cannot be known during measurement, document that constraint and replace the callback-trace-dependent override with an explicitly bounded, backend-independent mechanism.

Scope

This issue concerns only post-layout Text-width reconciliation.

The zero-content guard currently shares the same layout loop but solves a separate problem and should be evaluated independently.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions