fix(runtime): correct misleading measureElement() timing guidance (#139)
A bare `measureElement()` called inside `watchPostEffect` reads layout BEFORE
the commit scheduler's post-flush `calculateLayout` runs, so it returns an
uncomputed value (NaN for computed dimensions), not the current size. The JSDoc
previously recommended that exact broken call site.
Align the guidance to vue-tui's real post-flush timing: defer the read with
`nextTick(() => measureElement(ref.value))` — the pattern `useBoxMetrics` itself
uses — or read from an input/timer callback that fires after a flush; prefer
`useBoxMetrics` for reactive metrics. Also correct the stale claim that a
pre-layout read returns `{0,0}` (it returns NaN for an attached-but-uncomputed
node; `{0,0}` is only the detached case).
Adds a characterization test pinning bare-watchPostEffect = NaN vs
nextTick = real width (80), guarding against regressing to the old advice.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -313,6 +313,45 @@ describe("measureElement", () => {
|
|||||||
expect(measuredHeight.value).toBe(3);
|
expect(measuredHeight.value).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Characterization test locking the JSDoc guidance: a BARE measureElement()
|
||||||
|
// call inside watchPostEffect reads layout BEFORE the commit scheduler's
|
||||||
|
// queuePostFlushCb has run calculateLayout, so it does NOT return the real
|
||||||
|
// width (yoga reports an uncomputed value). The same read deferred with
|
||||||
|
// nextTick — the pattern useBoxMetrics itself uses — returns the real width.
|
||||||
|
// This guards the corrected guidance against a regression to the old
|
||||||
|
// "call it directly from watchPostEffect" advice.
|
||||||
|
test("bare watchPostEffect reads stale layout; nextTick reads the real width", async () => {
|
||||||
|
const bareWidth = shallowRef<number>(-1);
|
||||||
|
const deferredWidth = shallowRef(0);
|
||||||
|
const App = defineComponent(() => {
|
||||||
|
const boxRef = ref(null);
|
||||||
|
watchPostEffect(() => {
|
||||||
|
// Bare read: runs before calculateLayout in the same flush → unusable.
|
||||||
|
bareWidth.value = measureElement(boxRef.value).width;
|
||||||
|
// Deferred read: runs after the commit's layout pass → correct.
|
||||||
|
void nextTick(() => {
|
||||||
|
deferredWidth.value = measureElement(boxRef.value).width;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Box with no explicit width fills the 80-column terminal.
|
||||||
|
return () => (
|
||||||
|
<Box ref={boxRef}>
|
||||||
|
<Text>fill</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await render(App, { columns: 80 });
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
// The deferred (documented-correct) read returns the real terminal width.
|
||||||
|
expect(deferredWidth.value).toBe(80);
|
||||||
|
// The bare read is the uncomputed pre-layout value. On this first-render path
|
||||||
|
// yoga's getComputedWidth() is NaN (and measureElement's `?? 0` does not
|
||||||
|
// coalesce NaN), so pin exactly that — a plain `!== 80` would also pass if the
|
||||||
|
// watcher never ran or returned 0, which would not prove the bare path is stale.
|
||||||
|
expect(Number.isNaN(bareWidth.value)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("measureElement works when render is throttled", async () => {
|
test("measureElement works when render is throttled", async () => {
|
||||||
const measuredWidth = shallowRef(0);
|
const measuredWidth = shallowRef(0);
|
||||||
const App = defineComponent(() => {
|
const App = defineComponent(() => {
|
||||||
|
|||||||
@@ -71,12 +71,30 @@ function findRootNode(node: TuiNode | null): TuiRoot | null {
|
|||||||
/**
|
/**
|
||||||
* Imperative function that reads yoga computed dimensions from a TUI node.
|
* Imperative function that reads yoga computed dimensions from a TUI node.
|
||||||
*
|
*
|
||||||
* Returns `{ width: 0, height: 0 }` before layout (when yoga node doesn't
|
* Returns `{ width: 0, height: 0 }` when the ref is not attached to an element.
|
||||||
* exist or hasn't been calculated).
|
|
||||||
*
|
*
|
||||||
* Note: `measureElement()` returns `{width: 0, height: 0}` when called during
|
* Timing matters: layout is computed inside the commit scheduler's post-flush
|
||||||
* render (before layout is calculated). Call it from post-render code, such as
|
* callback, which can run *after* your own `watchPostEffect`/render-time code in
|
||||||
* `watchPostEffect`, `onMounted`, input handlers, or timer callbacks.
|
* the same flush. A bare `measureElement()` called there reads layout that has
|
||||||
|
* not been recalculated yet, so it does not return the current size. Read it
|
||||||
|
* only *after* the layout commit:
|
||||||
|
* - wrap the read in `nextTick(() => measureElement(ref.value))` (the pattern
|
||||||
|
* {@link useBoxMetrics} itself uses), or
|
||||||
|
* - call it from an input handler or timer callback that fires after a flush.
|
||||||
|
*
|
||||||
|
* For reactive metrics that stay in sync across renders and resizes, prefer
|
||||||
|
* {@link useBoxMetrics}, which handles this timing for you.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const boxRef = ref(null);
|
||||||
|
* watchPostEffect(() => {
|
||||||
|
* // Defer the read so it runs after layout is committed.
|
||||||
|
* void nextTick(() => {
|
||||||
|
* const { width } = measureElement(boxRef.value);
|
||||||
|
* });
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function measureElement(node: unknown): { width: number; height: number } {
|
export function measureElement(node: unknown): { width: number; height: number } {
|
||||||
const tuiNode = resolveYogaNode(node);
|
const tuiNode = resolveYogaNode(node);
|
||||||
|
|||||||
Reference in New Issue
Block a user