From 96765f30bccf8593ea02dcd43daae727525ac7c2 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Fri, 19 Jun 2026 17:32:26 +0200 Subject: [PATCH 01/66] fix(vendor): support FormattedMessage --- .changeset/two-geese-stand.md | 5 ++ .../src/element/LightningTextElement.ts | 53 +++++++++++++++++++ .../src/render/createHostConfig.ts | 23 +++++++- .../src/render/isValidTextChild.test.ts | 44 +++++++++++++++ .../src/render/isValidTextChild.ts | 17 ++++++ .../render/mapReactPropsToLightning.test.ts | 34 ++++++++++++ .../src/render/mapReactPropsToLightning.ts | 29 +++------- 7 files changed, 180 insertions(+), 25 deletions(-) create mode 100644 .changeset/two-geese-stand.md create mode 100644 packages/react-lightning/src/render/isValidTextChild.test.ts create mode 100644 packages/react-lightning/src/render/mapReactPropsToLightning.test.ts diff --git a/.changeset/two-geese-stand.md b/.changeset/two-geese-stand.md new file mode 100644 index 00000000..7de6b19c --- /dev/null +++ b/.changeset/two-geese-stand.md @@ -0,0 +1,5 @@ +--- +"@plextv/react-lightning": patch +--- + +fix(text): support FormattedMessage diff --git a/packages/react-lightning/src/element/LightningTextElement.ts b/packages/react-lightning/src/element/LightningTextElement.ts index cd70289e..dfc70b30 100644 --- a/packages/react-lightning/src/element/LightningTextElement.ts +++ b/packages/react-lightning/src/element/LightningTextElement.ts @@ -1,6 +1,7 @@ import type { INodeProps } from '@lightningjs/renderer'; import { + type LightningElement, LightningElementType, type LightningTextElementProps, type LightningTextElementStyle, @@ -23,6 +24,11 @@ export class LightningTextElement extends LightningViewElement< return true; } + // Set once this element renders its text from child fragments rather than + // from its own `text` prop (see `shouldSetTextContent`). Once true we always + // derive `node.text` from the children so removing them clears it. + private _aggregatesChildText = false; + public get text(): string { return this.node.text; } @@ -31,6 +37,53 @@ export class LightningTextElement extends LightningViewElement< this.node.text = v; } + /** + * Children that resolve to plain text (e.g. the string a `` + * rendered to) are appended as their own text instances rather than handed to + * us as a `text` prop. We keep them in the reconciler's child list for + * ordering/cleanup but fold their text into this node and detach them from + * the render tree so only this element draws. + */ + public override insertChild( + child: LightningElement, + beforeChild?: LightningElement | null, + ): void { + super.insertChild(child, beforeChild); + + if (child.isTextElement) { + // Keep it out of the visual tree; its text lives in our node instead. + child.node.parent = null; + this._aggregatesChildText = true; + this.recomputeChildText(); + } + } + + public override removeChild(child: LightningElement): void { + super.removeChild(child); + + if (this._aggregatesChildText) { + this.recomputeChildText(); + } + } + + /** + * Recompute `node.text` from the ordered text of child text fragments. + * Called when fragments are added/removed and when one's text updates. + */ + public recomputeChildText(): void { + let text = ''; + + for (let i = 0; i < this.children.length; i++) { + const child = this.children[i]; + + if (child?.isTextElement) { + text += (child as LightningTextElement).text; + } + } + + this.text = text; + } + public override _toLightningNodeProps( props: LightningViewElementProps & { text?: string; diff --git a/packages/react-lightning/src/render/createHostConfig.ts b/packages/react-lightning/src/render/createHostConfig.ts index 584dc47f..253bc478 100644 --- a/packages/react-lightning/src/render/createHostConfig.ts +++ b/packages/react-lightning/src/render/createHostConfig.ts @@ -12,6 +12,7 @@ import { type RendererNode, } from '../types'; import { simpleDiff } from '../utils/simpleDiff'; +import { isPrimitiveTextContent } from './isValidTextChild'; import { mapReactPropsToLightning } from './mapReactPropsToLightning'; import type { Plugin } from './Plugin'; @@ -164,8 +165,18 @@ export function createHostConfig(options?: LightningHostConfigOptions): Lightnin return instance as LightningElement; }, - shouldSetTextContent(type) { - return type === LightningElementType.Text; + shouldSetTextContent(type, props) { + // For text elements we normally take over their children as raw text + // content (the fast path — no child reconciliation). But that swallows + // children React still needs to render: a `` only + // becomes a translated, interpolated string once React renders it. So + // when the children aren't already a flat string, return false and let + // the reconciler render them; their text is folded back into the node by + // `LightningTextElement` as the string children are appended. + return ( + type === LightningElementType.Text && + isPrimitiveTextContent((props as LightningElementProps)?.children) + ); }, setCurrentUpdatePriority(newPriority: EventPriority): void { @@ -227,6 +238,14 @@ export function createHostConfig(options?: LightningHostConfigOptions): Lightnin commitTextUpdate(instance, oldText, newText) { if (instance.isTextElement && oldText !== newText) { instance.text = newText; + + // When this text instance is a child fragment of a parent text element + // (e.g. the string a `` resolved to), the parent owns + // the rendered text and must re-fold its children after the update. + const parent = instance.parent; + if (parent?.isTextElement) { + (parent as LightningTextElement).recomputeChildText(); + } } }, diff --git a/packages/react-lightning/src/render/isValidTextChild.test.ts b/packages/react-lightning/src/render/isValidTextChild.test.ts new file mode 100644 index 00000000..efbb7082 --- /dev/null +++ b/packages/react-lightning/src/render/isValidTextChild.test.ts @@ -0,0 +1,44 @@ +import { createElement } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { isPrimitiveTextContent, isValidTextChild } from './isValidTextChild'; + +describe('isValidTextChild', () => { + it('accepts strings, numbers and booleans', () => { + expect(isValidTextChild('hello')).toBe(true); + expect(isValidTextChild(42)).toBe(true); + expect(isValidTextChild(true)).toBe(true); + }); + + it('rejects objects, arrays and elements', () => { + expect(isValidTextChild({})).toBe(false); + expect(isValidTextChild(['a'])).toBe(false); + expect(isValidTextChild(createElement('span'))).toBe(false); + }); +}); + +describe('isPrimitiveTextContent', () => { + it('treats empty/primitive children as flattenable here', () => { + expect(isPrimitiveTextContent(undefined)).toBe(true); + expect(isPrimitiveTextContent(null)).toBe(true); + expect(isPrimitiveTextContent('hello')).toBe(true); + expect(isPrimitiveTextContent(7)).toBe(true); + }); + + it('treats arrays of primitives (e.g. "Count: {n}") as flattenable', () => { + expect(isPrimitiveTextContent(['Count: ', 3])).toBe(true); + expect(isPrimitiveTextContent(['a', null, 'b'])).toBe(true); + }); + + it('defers element children to the reconciler', () => { + // A only becomes a translated, interpolated string once + // React renders it — the renderer must not try to flatten it itself. + const formattedMessage = createElement('FormattedMessage', { + defaultMessage: 'Hello {name}', + values: { name: 'world' }, + }); + + expect(isPrimitiveTextContent(formattedMessage)).toBe(false); + expect(isPrimitiveTextContent(['Hello ', formattedMessage])).toBe(false); + }); +}); diff --git a/packages/react-lightning/src/render/isValidTextChild.ts b/packages/react-lightning/src/render/isValidTextChild.ts index c2ccbcef..8a5e55e1 100644 --- a/packages/react-lightning/src/render/isValidTextChild.ts +++ b/packages/react-lightning/src/render/isValidTextChild.ts @@ -1,3 +1,20 @@ export function isValidTextChild(text: unknown): text is boolean | number | string { return typeof text === 'string' || typeof text === 'number' || typeof text === 'boolean'; } + +/** + * True when `children` can be flattened to a string here in the renderer + * (a primitive, an empty value, or an array of those). When this is false the + * children include something only React can resolve — a ``, + * a ternary returning an element, a fragment — so we must let the reconciler + * render them rather than guess at their text. See `shouldSetTextContent`. + */ +export function isPrimitiveTextContent(children: unknown): boolean { + if (children == null || isValidTextChild(children)) { + return true; + } + + return ( + Array.isArray(children) && children.every((child) => child == null || isValidTextChild(child)) + ); +} diff --git a/packages/react-lightning/src/render/mapReactPropsToLightning.test.ts b/packages/react-lightning/src/render/mapReactPropsToLightning.test.ts new file mode 100644 index 00000000..5b89139c --- /dev/null +++ b/packages/react-lightning/src/render/mapReactPropsToLightning.test.ts @@ -0,0 +1,34 @@ +import { createElement } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { LightningElementType, type LightningTextElementProps } from '../types'; +import { mapReactPropsToLightning } from './mapReactPropsToLightning'; + +describe('mapReactPropsToLightning — text children', () => { + const mapText = (children: unknown) => + mapReactPropsToLightning(LightningElementType.Text, { + children, + } as LightningTextElementProps) as LightningTextElementProps; + + it('uses a primitive child as the text content', () => { + expect(mapText('hello').text).toBe('hello'); + expect(mapText(42).text).toBe('42'); + }); + + it('concatenates an array of primitive children', () => { + expect(mapText(['Count: ', 3]).text).toBe('Count: 3'); + }); + + it('does not derive text from element children', () => { + // These reach the renderer only when React could not resolve them to a + // string. Folding them in here is what produced untranslated / + // non-interpolated output before — the reconciler renders them instead and + // LightningTextElement folds the result back in. + const formattedMessage = createElement('FormattedMessage', { + defaultMessage: 'Hello {name}', + values: { name: 'world' }, + }); + + expect(mapText(formattedMessage).text).toBeUndefined(); + }); +}); diff --git a/packages/react-lightning/src/render/mapReactPropsToLightning.ts b/packages/react-lightning/src/render/mapReactPropsToLightning.ts index 37d7ff37..4aeec87e 100644 --- a/packages/react-lightning/src/render/mapReactPropsToLightning.ts +++ b/packages/react-lightning/src/render/mapReactPropsToLightning.ts @@ -5,16 +5,6 @@ import { } from '../types'; import { isValidTextChild } from './isValidTextChild'; -function isIntlObject(obj: unknown): obj is { props: { defaultMessage?: string } } { - return ( - typeof obj === 'object' && - obj !== null && - 'props' in obj && - !!obj.props && - 'defaultMessage' in (obj.props as { defaultMessage?: string }) - ); -} - /** * Converts React props to work with LightningElements */ @@ -33,7 +23,11 @@ export function mapReactPropsToLightning( for (prop in props) { switch (prop) { case 'children': - // If it's text, we don't actually use children as text + // Text takes its children as raw text content rather than as rendered + // child nodes — but only the primitive cases reach us here. Anything + // React must render (a ``, a ternary, a fragment) is + // routed through the reconciler by `shouldSetTextContent` and folded + // back in by `LightningTextElement`, so we never see it as a prop. if (type === LightningElementType.Text) { const textProps = mappedProps as LightningTextElementProps; const children = props[prop]; @@ -41,26 +35,15 @@ export function mapReactPropsToLightning( if (isValidTextChild(children)) { textProps.text = String(children); } else if (Array.isArray(children)) { - // Single-pass: validate and concatenate simultaneously let text = ''; - let allValid = true; for (let i = 0; i < children.length; i++) { if (isValidTextChild(children[i])) { text += String(children[i]); - } else { - allValid = false; - break; } } - if (allValid) { - textProps.text = text; - } - } else if (isIntlObject(children)) { - textProps.text = children.props.defaultMessage; - } else if (children) { - console.error('Unsupported child type found for text element'); + textProps.text = text; } } From 5502e4b5cabf9d5095c5de4050e660b8f5cb1afb Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Sun, 21 Jun 2026 17:22:22 +0200 Subject: [PATCH 02/66] feat(vendor): synchronous Yoga text measurement for wrapping and intrinsic sizing --- .changeset/lightning-text-intrinsic-sizing.md | 8 + .../plugin-flexbox/src/LightningManager.ts | 172 ++++++- .../plugin-flexbox/src/YogaManager.spec.ts | 1 + packages/plugin-flexbox/src/YogaManager.ts | 142 +++++- .../plugin-flexbox/src/YogaManagerWorker.ts | 14 + .../src/measureText.integration.test.ts | 136 ++++++ .../src/text/FontMetricsStore.ts | 179 ++++++++ .../src/text/layoutText.test.ts | 89 ++++ .../plugin-flexbox/src/text/layoutText.ts | 424 ++++++++++++++++++ .../plugin-flexbox/src/types/ManagerNode.ts | 7 + .../plugin-flexbox/src/types/YogaOptions.ts | 14 + .../src/element/LightningTextElement.ts | 14 + 12 files changed, 1194 insertions(+), 6 deletions(-) create mode 100644 .changeset/lightning-text-intrinsic-sizing.md create mode 100644 packages/plugin-flexbox/src/measureText.integration.test.ts create mode 100644 packages/plugin-flexbox/src/text/FontMetricsStore.ts create mode 100644 packages/plugin-flexbox/src/text/layoutText.test.ts create mode 100644 packages/plugin-flexbox/src/text/layoutText.ts diff --git a/.changeset/lightning-text-intrinsic-sizing.md b/.changeset/lightning-text-intrinsic-sizing.md new file mode 100644 index 00000000..20337784 --- /dev/null +++ b/.changeset/lightning-text-intrinsic-sizing.md @@ -0,0 +1,8 @@ +--- +"@plextv/react-lightning-plugin-flexbox": minor +"@plextv/react-lightning": patch +--- + +feat(flexbox): measure text synchronously in Yoga for wrapping and intrinsic sizing + +Text leaves are now measured during Yoga layout (via msdf font metrics passed through the new `fonts` option) instead of relying solely on the renderer's async texture measurement, so text wraps and sizes correctly within flex layouts. The text node's explicit width/height is cleared when it becomes a measured leaf so the measure function — not a stale renderer-set width — drives its size, and `react-lightning` emits a `textChanged` signal so recycled/updated text re-measures. diff --git a/packages/plugin-flexbox/src/LightningManager.ts b/packages/plugin-flexbox/src/LightningManager.ts index 2b0d2995..bf55b9f3 100644 --- a/packages/plugin-flexbox/src/LightningManager.ts +++ b/packages/plugin-flexbox/src/LightningManager.ts @@ -2,15 +2,22 @@ import type { LightningElement, LightningElementStyle, LightningTextElement, + LightningTextElementStyle, RendererNode, TextRendererNode, } from '@plextv/react-lightning'; +import type { TextMeasureProps } from './text/layoutText'; import type { YogaOptions } from './types/YogaOptions'; import loadYoga from './yoga'; import type { YogaManager } from './YogaManager'; import type { Workerized } from './YogaManagerWorker'; +// Sub-glyph slack added to a measured text node's rendered contain width so the +// renderer doesn't clip the final glyph(s) when our measurement lands a hair +// under its own. Far below one glyph, so it never affects wrapping. +const TEXT_CONTAIN_EPSILON = 2; + /** Lifecycle of Yoga nodes for Lightning elements. Main-thread only. */ export class LightningManager { private _elements = new Map(); @@ -20,9 +27,27 @@ export class LightningManager { private _yogaParents = new Map(); /** Per-parent attached-children count. Lets `_yogaIndexFor` skip the O(n) sibling walk on append-at-end. */ private _yogaChildCounts = new Map(); + /** Text elements measured by Yoga — their node w/h/contain come from layout, not the async texture. */ + private _measuredText = new Set(); + /** + * Per measured-text element, the widest parent node width it has been measured + * against. Yoga caches measure results and won't re-call the measure func when + * a text node's container resolves to a wider width after an early narrow + * measure. When the container grows past this we re-dirty the text so it + * re-measures at the real width (see the grow-only re-dirty in `_applyUpdates`). + */ + private _textContextWidth = new Map(); private _yogaManager: YogaManager | Workerized | undefined; + /** + * Font families we have metrics for and can measure. Only text in one of + * these is measured by Yoga; everything else (e.g. the canvas `plex-icons` + * glyph font) falls back to the renderer's own sizing. + */ + private _measurableFonts = new Set(); + public async init(yogaOptions?: YogaOptions): Promise { + this._measurableFonts = new Set((yogaOptions?.fonts ?? []).map((font) => font.fontFamily)); this._yogaManager = await loadYoga(yogaOptions); this._yogaManager.on('render', this._applyUpdates); } @@ -200,6 +225,43 @@ export class LightningManager { } } + /** + * Push a text element's content + font props to Yoga so it can measure the + * text during layout. Only text in a font we have metrics for is measured; + * anything else (no family, or a non-measurable font like the canvas + * `plex-icons` glyph font) is left to the renderer's own sizing. + */ + private _syncTextMeasure(element: LightningElement): void { + if (!this._yogaManager) { + return; + } + + const style = (element.style ?? {}) as Partial; + const fontFamily = style.fontFamily; + + if (!fontFamily || !this._measurableFonts.has(fontFamily)) { + if (this._measuredText.delete(element.id)) { + this._textContextWidth.delete(element.id); + this._yogaManager.clearTextMeasure(element.id); + } + + return; + } + + this._measuredText.add(element.id); + this._yogaManager.setTextMeasure(element.id, fontFamily, { + text: (element as LightningTextElement).text ?? '', + fontSize: style.fontSize || 16, + letterSpacing: style.letterSpacing || 0, + // 0 / undefined lineHeight → natural (1× metrics); a value > 3 is px. + lineHeight: style.lineHeight || 1, + maxLines: style.maxLines || 0, + maxHeight: style.maxHeight || 0, + wordBreak: (style.wordBreak as TextMeasureProps['wordBreak']) || 'break-word', + overflowSuffix: style.overflowSuffix ?? '...', + }); + } + public trackElement(element: LightningElement): void { if (this._elements.has(element.id)) { console.warn(`Yoga node is already attached to element #${element.id}.`); @@ -214,6 +276,12 @@ export class LightningManager { this._elements.set(element.id, element); this._yogaManager.addNode(element.id); + // Set text measurement before any children mount, so the Yoga node is a + // leaf when its measure func is installed (Yoga requires that). + if (element.isTextElement) { + this._syncTextMeasure(element); + } + const disposers = [ element.on('destroy', () => { for (const dispose of disposers) { @@ -230,6 +298,8 @@ export class LightningManager { this._boundaries.delete(element.id); this._flexRoots.delete(element.id); this._yogaChildCounts.delete(element.id); + this._measuredText.delete(element.id); + this._textContextWidth.delete(element.id); // oxlint-disable-next-line typescript/no-non-null-assertion -- Guaranteed to exist. But avoiding the nullish operator for perf reasons this._yogaManager!.applyStyle(element.id, null, true); // oxlint-disable-next-line typescript/no-non-null-assertion -- Guaranteed to exist. See above @@ -287,6 +357,11 @@ export class LightningManager { element.on('stylesChanged', () => { this.applyStyle(element.id, element.props.style); + + // Font/size/maxLines changes affect measurement. + if (element.isTextElement) { + this._syncTextMeasure(element); + } }), element.on( @@ -296,6 +371,22 @@ export class LightningManager { event: { type: string; dimensions: { w: number; h: number } }, ) => { if (element.isTextElement) { + // Static text (set once at mount via the initial-props path, not the + // `text` setter) never fires `textChanged`, so `_syncTextMeasure` + // only ran at trackElement before the content existed and the node + // was never measured. The renderer's texture-loaded event is the + // first point where `node.text` is reliably populated — sync now so + // such text gets a measure func and wraps like dynamic text does. + if (!this._measuredText.has(element.id)) { + this._syncTextMeasure(element); + } + + // When Yoga measures the text itself, it owns the node's size — + // pushing the async texture size back would fight the measure func. + if (this._measuredText.has(element.id)) { + return; + } + this.applyStyle(element.id, { w: event.dimensions.w, h: event.dimensions.h, @@ -309,6 +400,21 @@ export class LightningManager { }, ), ]; + + // Re-measure when text content changes. `propsChanged` covers the setProps + // path; `textChanged` covers value changes that bypass it — recycled nodes + // (commitTextUpdate) and folded fragment text (recomputeChildText) — which + // is the common case for reused preview/hero nodes. + if (element.isTextElement) { + disposers.push( + element.on('propsChanged', () => { + this._syncTextMeasure(element); + }), + element.on('textChanged', () => { + this._syncTextMeasure(element); + }), + ); + } } public applyStyle( @@ -375,13 +481,37 @@ export class LightningManager { dirty = el.setNodeProp('y', y) || dirty; } - // Skip zero (causes layout issues) and text elements (Lightning sizes them). - if (width !== 0 && !isText) { - dirty = el.setNodeProp('w', width) || dirty; + // Normally text elements are sized by Lightning (async texture measure), + // so we skip them here. But when Yoga measures the text itself, its + // computed size IS the text box: apply it and pin contain:'width' so the + // renderer wraps to the same width and textAlign has a box to align in. + const isMeasuredText = isText && this._measuredText.has(elementId); + + if (width !== 0 && (!isText || isMeasuredText)) { + if (isMeasuredText) { + // `contain` lives on the text node, not the base node type — set it + // directly. Wrapping/textAlign only take effect with a contained width. + const textNode = el.node as TextRendererNode; + + if (textNode.contain !== 'width') { + textNode.contain = 'width'; + } + + // Pin the renderer's text box a hair wider than the Yoga-measured + // width. Our msdf measurement can land a sub-pixel under the + // renderer's own glyph layout; containing to the exact width would + // clip the final glyphs (e.g. "Sign Up" → "Sign…"). The epsilon is + // far below a glyph, so it never changes wrapping, and Yoga still + // positions siblings from its own (un-padded) computed width. + dirty = el.setNodeProp('w', width + TEXT_CONTAIN_EPSILON) || dirty; + } else { + dirty = el.setNodeProp('w', width) || dirty; + } + resize = true; } - if (height !== 0 && !isText) { + if (height !== 0 && (!isText || isMeasuredText)) { dirty = el.setNodeProp('h', height) || dirty; resize = true; } @@ -394,5 +524,39 @@ export class LightningManager { el.emitLayoutEvent(); } } + + // Yoga caches text measurements and won't re-run the measure func when a + // container resolves to its real (wider) width after an early too-narrow + // measure — leaving text stuck narrow (the classic collapsed-then-expanded + // hero title). After each layout, if a measured text node's container has + // GROWN past the width it was last measured against, re-dirty it so Yoga + // re-measures at the now-available width. + // + // Grow-only is deliberate. The container width is often *derived* from the + // text itself (a shrink-to-content wrapper) or from a sibling whose size + // toggles (e.g. ClearLogo's logo image vs. its text fallback). Re-measuring + // on every change — including shrink — feeds that derived width back into + // the measure and oscillates (the same title flip-flopping between e.g. 686 + // and 462). Reacting only to growth converges (the recorded width climbs + // monotonically until it matches the settled container) and biases toward + // the widest the container ever offered, which never clips. New text on a + // recycled node is handled separately by the `textChanged` → setTextMeasure + // measure-func reinstall, so a narrower reuse still re-measures correctly. + if (this._measuredText.size > 0) { + for (const textId of this._measuredText) { + const textEl = this._elements.get(textId); + + if (!textEl) { + continue; + } + + const contextWidth = textEl.parent?.node.w ?? 0; + + if (contextWidth > (this._textContextWidth.get(textId) ?? 0)) { + this._textContextWidth.set(textId, contextWidth); + this._syncTextMeasure(textEl); + } + } + } }; } diff --git a/packages/plugin-flexbox/src/YogaManager.spec.ts b/packages/plugin-flexbox/src/YogaManager.spec.ts index b0b4a548..8ea1327f 100644 --- a/packages/plugin-flexbox/src/YogaManager.spec.ts +++ b/packages/plugin-flexbox/src/YogaManager.spec.ts @@ -33,6 +33,7 @@ const mockConfig = { const mockYogaOptions = { errata: 'none', expandToAutoFlexBasis: false, + fonts: [], processHiddenNodes: false, useWebDefaults: false, useWebWorker: false, diff --git a/packages/plugin-flexbox/src/YogaManager.ts b/packages/plugin-flexbox/src/YogaManager.ts index 794ab3ad..e700e150 100644 --- a/packages/plugin-flexbox/src/YogaManager.ts +++ b/packages/plugin-flexbox/src/YogaManager.ts @@ -3,6 +3,8 @@ import { type Config, loadYoga, type Yoga } from 'yoga-layout/load'; import type { LightningElementStyle, Rect } from '@plextv/react-lightning'; +import { FontMetricsStore } from './text/FontMetricsStore'; +import { layoutText, type TextMeasureProps } from './text/layoutText'; import type { ManagerNode } from './types/ManagerNode'; import type { YogaOptions } from './types/YogaOptions'; import applyReactPropsToYoga, { applyFlexPropToYoga } from './util/applyReactPropsToYoga'; @@ -43,9 +45,14 @@ export class YogaManager { processHiddenNodes: false, useWebWorker: false, expandToAutoFlexBasis: false, + fonts: [], }; private _eventEmitter: EventEmitter = new EventEmitter(); private _dataView: SimpleDataView; + private _fontStore = new FontMetricsStore(); + // Text leaves currently measured by Yoga, so we can re-dirty them when a + // font finishes loading. + private _textNodes: Set = new Set(); public on: EventEmitter['on'] = this._eventEmitter.on.bind(this._eventEmitter); public off: EventEmitter['off'] = this._eventEmitter.off.bind( @@ -89,6 +96,124 @@ export class YogaManager { } this._initialized = true; + + // Load fonts in the background; don't block init. As each arrives, re-dirty + // any text already laid out with it so its measurement updates. + if (this._yogaOptions.fonts) { + for (const font of this._yogaOptions.fonts) { + void this._fontStore.load(font.fontFamily, font.atlasDataUrl).then(() => { + this._remeasureFontFamily(font.fontFamily); + }); + } + } + } + + /** + * Set (or refresh) synchronous text measurement for a node. Installs a Yoga + * measure function the first time so wrapping/sizing happen during layout. + */ + public setTextMeasure(elementId: number, fontFamily: string, props: TextMeasureProps): void { + const yogaNode = this._elementMap.get(elementId); + + if (!yogaNode) { + return; + } + + const isFirst = yogaNode.text === undefined; + yogaNode.text = { fontFamily, props }; + + if (isFirst) { + // A measured leaf can't have children. If any were added before this + // node became text (e.g. the font/family arrived after children + // mounted), detach them — text fragment children aren't layout nodes. + for (const child of yogaNode.children) { + yogaNode.node.removeChild(child.node); + child.parent = undefined; + } + yogaNode.children.length = 0; + + this._textNodes.add(elementId); + } + + // Clear any explicit width/height so the measure func is the sole source of + // this node's size. The renderer measures text asynchronously and pushes + // its texture dimensions back as an explicit `w`/`h` (see the + // `textureLoaded` handler) — if that ran before the node became measured + // text, Yoga sees a DEFINITE width and never calls the measure func, so the + // node keeps the renderer's (often container-clipped) size. Resetting to + // auto makes Yoga measure it. Done every call so a recycled node that + // briefly went through the texture path is corrected too. + yogaNode.node.setWidthAuto(); + yogaNode.node.setHeightAuto(); + + // (Re)install the measure func every time. Re-setting it busts Yoga's + // cached measurement, which `markDirty` alone does not reliably do for a + // recycled node whose available width is unchanged — so changed text never + // re-measured and kept its stale width. + yogaNode.node.setMeasureFunc((width, widthMode) => + this._measureText(elementId, width, widthMode), + ); + + yogaNode.node.markDirty(); + this.queueRender(elementId); + } + + /** Remove text measurement from a node (e.g. it's no longer a text leaf). */ + public clearTextMeasure(elementId: number): void { + const yogaNode = this._elementMap.get(elementId); + + this._textNodes.delete(elementId); + + if (yogaNode?.text !== undefined) { + yogaNode.text = undefined; + yogaNode.node.setMeasureFunc(null); + this.queueRender(elementId); + } + } + + // widthMode is Yoga's MeasureMode: 0 = Undefined (unconstrained), 1 = Exactly, + // 2 = AtMost. Only a bounded width should wrap the text. + private _measureText( + elementId: number, + availableWidth: number, + widthMode: number, + ): { width: number; height: number } { + const text = this._elementMap.get(elementId)?.text; + + if (text === undefined) { + return { width: 0, height: 0 }; + } + + const font = this._fontStore.get(text.fontFamily); + + if (font === undefined) { + // Font not loaded yet — measure empty; _remeasureFontFamily re-dirties + // this node once it arrives. + return { width: 0, height: 0 }; + } + + const maxWidth = + widthMode === 0 || !Number.isFinite(availableWidth) ? Infinity : availableWidth; + + return layoutText(font, text.props, maxWidth); + } + + private _remeasureFontFamily(fontFamily: string): void { + let dirtied = false; + + for (const elementId of this._textNodes) { + const yogaNode = this._elementMap.get(elementId); + + if (yogaNode?.text?.fontFamily === fontFamily) { + yogaNode.node.markDirty(); + dirtied = true; + } + } + + if (dirtied) { + // Force a relayout pass so the newly-measurable text resizes. + this.queueRender(0, true); + } } public addNode(elementId: number): ManagerNode { @@ -105,6 +230,8 @@ export class YogaManager { } public removeNode(elementId: number): void { + this._textNodes.delete(elementId); + const yogaNode = this._elementMap.get(elementId); if (yogaNode) { @@ -131,6 +258,14 @@ export class YogaManager { throw new Error(`Parent or child node not found for IDs ${parentId} and ${childId}.`); } + // A measured text leaf can't have Yoga children (Yoga forbids children on + // a node with a measure func). Text fragment children — e.g. the strings a + // renders to — are folded into the parent's text by the + // renderer, so they're not layout participants here. + if (parentYogaNode.text !== undefined) { + return; + } + index ??= childYogaNode.children.length; parentYogaNode.node.insertChild(childYogaNode.node, index); @@ -224,8 +359,11 @@ export class YogaManager { // `for...in` skips the [key, value] tuple allocation of Object.entries — // this is a hot path on every flushBoth/applyStyles message. for (const elementId in styles) { - // oxlint-disable-next-line typescript/no-non-null-assertion -- key from for..in iteration of own props - this.applyStyle(+elementId, styles[elementId as unknown as number]!, skipRender); + const style = styles[elementId as unknown as number]; + + if (style !== undefined) { + this.applyStyle(+elementId, style, skipRender); + } } } diff --git a/packages/plugin-flexbox/src/YogaManagerWorker.ts b/packages/plugin-flexbox/src/YogaManagerWorker.ts index 6dd36280..301a06b8 100644 --- a/packages/plugin-flexbox/src/YogaManagerWorker.ts +++ b/packages/plugin-flexbox/src/YogaManagerWorker.ts @@ -352,6 +352,20 @@ function wrapWorker(worker: Worker): Workerized { }, addIndependentRoot: (elementId: number) => nodeOperation('addIndependentRoot', elementId), removeIndependentRoot: (elementId: number) => nodeOperation('removeIndependentRoot', elementId), + // Text measurement ops must land after the node's addNode (and any pending + // styles), so flush the buffered pipeline before posting them. + setTextMeasure: (elementId: number, fontFamily: string, props: unknown) => { + flushChildOperations(); + flushSendStyles(); + worker.postMessage({ + method: 'setTextMeasure', + args: [elementId, fontFamily, props], + }); + }, + clearTextMeasure: (elementId: number) => { + flushChildOperations(); + worker.postMessage({ method: 'clearTextMeasure', args: [elementId] }); + }, init: (yogaOptions?: unknown) => _awaitable('init', [yogaOptions]), }; diff --git a/packages/plugin-flexbox/src/measureText.integration.test.ts b/packages/plugin-flexbox/src/measureText.integration.test.ts new file mode 100644 index 00000000..105f563f --- /dev/null +++ b/packages/plugin-flexbox/src/measureText.integration.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import type { AtlasData } from './text/FontMetricsStore'; +import { YogaManager } from './YogaManager'; + +// Same synthetic atlas as layoutText.test.ts: at fontSize 20, "aa" = 40px wide, +// a space = 10px, line height = 20px. +const atlas: AtlasData = { + info: { size: 10, face: 'Test' }, + common: { lineHeight: 12, base: 8 }, + lightningMetrics: { + ascender: 800, + descender: -200, + lineGap: 0, + unitsPerEm: 1000, + }, + chars: [ + { id: 97, xadvance: 10, xoffset: 0, yoffset: 0, width: 8, height: 8 }, + { id: 32, xadvance: 5, xoffset: 0, yoffset: 0, width: 0, height: 0 }, + ], + kernings: [], +}; + +const textProps = { + text: 'aa aa', + fontSize: 20, + letterSpacing: 0, + lineHeight: 1, + maxLines: 0, + maxHeight: 0, + wordBreak: 'break-word' as const, + overflowSuffix: '...', +}; + +type Computed = Map; + +function nextRender(manager: YogaManager): Promise { + return new Promise((resolve) => { + const handler = (buffer: ArrayBuffer) => { + manager.off('render', handler); + + const view = new DataView(buffer); + const out: Computed = new Map(); + + for (let offset = 0; offset + 12 <= buffer.byteLength; offset += 12) { + out.set(view.getUint32(offset, true), { + x: view.getInt16(offset + 4, true), + y: view.getInt16(offset + 6, true), + w: view.getUint16(offset + 8, true), + h: view.getUint16(offset + 10, true), + }); + } + + resolve(out); + }; + + manager.on('render', handler); + manager.queueRender(1, true); + }); +} + +async function setup(rootStyle: Record) { + const manager = new YogaManager(); + await manager.init(); + // Inject the synthetic font synchronously (skip the async URL fetch). + ( + manager as unknown as { + _fontStore: { register: (f: string, d: AtlasData) => void }; + } + )._fontStore.register('Test', atlas); + + manager.addNode(1); + manager.applyStyle(1, rootStyle, true); + manager.addIndependentRoot(1); + + manager.addNode(2); + manager.addChildNode(1, 2); + manager.setTextMeasure(2, 'Test', textProps); + + return manager; +} + +describe('Yoga text measurement (real yoga)', () => { + it('wraps a stretched text child to the container width', async () => { + // 60px-wide column, child stretches to fill width (align-items: stretch) → + // the measure func gets Exactly(60) and wraps "aa aa" to 60px. + const manager = await setup({ + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + w: 60, + h: 200, + }); + + const computed = await nextRender(manager); + const text = computed.get(2); + + expect(text?.w).toBe(60); // stretched to container + expect(text?.h).toBe(40); // "aa" / "aa" → 2 lines × 20px + }); + + it('shrinks an unstretched text child to its content width', async () => { + // align-items flex-start → child sized to measured content, unconstrained. + const manager = await setup({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + w: 300, + h: 200, + }); + + const computed = await nextRender(manager); + const text = computed.get(2); + + expect(text?.w).toBe(90); // "aa aa" single line = 45 design × 2 + expect(text?.h).toBe(20); // 1 line + }); + + it('ignores children of a measured text leaf (stays a leaf)', async () => { + const manager = await setup({ + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + w: 60, + h: 200, + }); + + // A text fragment child must not become a Yoga child (Yoga forbids + // children on a measure-func node) — this must not throw or change size. + manager.addNode(3); + expect(() => manager.addChildNode(2, 3)).not.toThrow(); + + const computed = await nextRender(manager); + expect(computed.get(2)?.h).toBe(40); // still measured as 2 lines + }); +}); diff --git a/packages/plugin-flexbox/src/text/FontMetricsStore.ts b/packages/plugin-flexbox/src/text/FontMetricsStore.ts new file mode 100644 index 00000000..20d3f1bb --- /dev/null +++ b/packages/plugin-flexbox/src/text/FontMetricsStore.ts @@ -0,0 +1,179 @@ +/** + * Synchronous msdf font metrics for the Yoga worker. + * + * Yoga measures text leaves during layout, on whatever thread it runs on + * (here, a web worker). The renderer's own text measurement is async and lives + * on the main thread, so it can't answer "how tall is this text at width W?" + * mid-layout. This store loads the same msdf atlas JSON the renderer uses and + * reproduces its glyph-advance maths so the worker can measure text itself. + * + * Width maths run in atlas *design units* (raw `xadvance`), exactly like + * `@lightningjs/renderer`'s `SdfFontHandler.measureText`, so wrap results match + * what the renderer will paint. Callers convert px↔design units with + * `fontScale = fontSize / designFontSize`. See `layoutText.ts`. + */ + +export interface AtlasChar { + id: number; + xadvance: number; + xoffset: number; + yoffset: number; + width: number; + height: number; +} + +export interface AtlasKerning { + first: number; + second: number; + amount: number; +} + +/** OpenType-style metrics the msdf-generator embeds, in em units. */ +export interface LightningMetrics { + ascender: number; + descender: number; + lineGap: number; + unitsPerEm: number; +} + +export interface AtlasData { + info: { size: number; face?: string }; + common: { lineHeight: number; base: number }; + chars: AtlasChar[]; + kernings?: AtlasKerning[]; + lightningMetrics?: LightningMetrics; +} + +// Mirrors @lightningjs/renderer's TextLayoutEngine default. +const DEFAULT_METRICS: LightningMetrics = { + ascender: 800, + descender: -200, + lineGap: 200, + unitsPerEm: 1000, +}; + +// second glyph id → (first glyph id → kerning amount), matching the renderer's +// buildKerningTable layout for O(1) pair lookup. +type KerningTable = Map>; + +const isZeroWidthSpace = (codepoint: number): boolean => codepoint === 0x200b; + +export class FontMetrics { + public readonly designFontSize: number; + public readonly metrics: LightningMetrics; + + private readonly _glyphs = new Map(); + private readonly _kernings: KerningTable = new Map(); + + public constructor(data: AtlasData) { + this.designFontSize = data.info.size; + this.metrics = data.lightningMetrics ?? DEFAULT_METRICS; + + for (const glyph of data.chars) { + // BMFont `id` is the unicode codepoint; key by it for codepoint lookup. + this._glyphs.set(glyph.id, glyph); + } + + if (data.kernings) { + for (const { first, second, amount } of data.kernings) { + let firsts = this._kernings.get(second); + + if (firsts === undefined) { + firsts = new Map(); + this._kernings.set(second, firsts); + } + + firsts.set(first, amount); + } + } + } + + public getKerning(firstGlyphId: number, secondGlyphId: number): number { + return this._kernings.get(secondGlyphId)?.get(firstGlyphId) ?? 0; + } + + /** + * Width of `text` in atlas design units (port of + * `SdfFontHandler.measureText`). `letterSpacing` is also in design units. + */ + public measureText(text: string, letterSpacing: number): number { + if (text.length === 0) { + return 0; + } + + let width = 0; + let prevGlyphId = 0; + + for (const char of text) { + const codepoint = char.codePointAt(0); + + if (codepoint === undefined || isZeroWidthSpace(codepoint)) { + continue; + } + + const glyph = this._glyphs.get(codepoint); + + if (glyph === undefined) { + continue; + } + + let advance = glyph.xadvance; + + if (prevGlyphId !== 0) { + advance += this.getKerning(prevGlyphId, glyph.id); + } + + width += advance + letterSpacing; + prevGlyphId = glyph.id; + } + + return width; + } +} + +/** Loads and caches one `FontMetrics` per font family from its atlas JSON URL. */ +export class FontMetricsStore { + private readonly _fonts = new Map(); + private readonly _loading = new Map>(); + + public has(fontFamily: string): boolean { + return this._fonts.has(fontFamily); + } + + public get(fontFamily: string): FontMetrics | undefined { + return this._fonts.get(fontFamily); + } + + public register(fontFamily: string, data: AtlasData): void { + this._fonts.set(fontFamily, new FontMetrics(data)); + } + + /** Fetch + register an atlas JSON. Deduplicated per family; never throws. */ + public async load(fontFamily: string, atlasDataUrl: string): Promise { + if (this._fonts.has(fontFamily)) { + return; + } + + let pending = this._loading.get(fontFamily); + + if (pending === undefined) { + pending = (async () => { + try { + const response = await fetch(atlasDataUrl); + const data = (await response.json()) as AtlasData; + this.register(fontFamily, data); + } catch (error) { + // A missing/late font just means text stays unmeasured (single-line + // fallback) until it loads — not a layout-fatal error. + console.warn(`[flexbox] failed to load font metrics for ${fontFamily}`, error); + } finally { + this._loading.delete(fontFamily); + } + })(); + + this._loading.set(fontFamily, pending); + } + + return pending; + } +} diff --git a/packages/plugin-flexbox/src/text/layoutText.test.ts b/packages/plugin-flexbox/src/text/layoutText.test.ts new file mode 100644 index 00000000..c52f0e22 --- /dev/null +++ b/packages/plugin-flexbox/src/text/layoutText.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { type AtlasData, FontMetrics } from './FontMetricsStore'; +import { layoutText, type TextMeasureProps } from './layoutText'; + +// Synthetic atlas with round numbers so expectations are exact: +// designFontSize 10, unitsPerEm 1000, ascender 800, descender -200. +// 'a'/'b' advance 10 design units, space advance 5. +const atlas: AtlasData = { + info: { size: 10, face: 'Test' }, + common: { lineHeight: 12, base: 8 }, + lightningMetrics: { + ascender: 800, + descender: -200, + lineGap: 0, + unitsPerEm: 1000, + }, + chars: [ + { id: 97, xadvance: 10, xoffset: 0, yoffset: 0, width: 8, height: 8 }, // a + { id: 98, xadvance: 10, xoffset: 0, yoffset: 0, width: 8, height: 8 }, // b + { id: 32, xadvance: 5, xoffset: 0, yoffset: 0, width: 0, height: 0 }, // (space) + { id: 46, xadvance: 3, xoffset: 0, yoffset: 0, width: 2, height: 2 }, // . + ], + kernings: [], +}; + +const font = new FontMetrics(atlas); + +const props = (over: Partial = {}): TextMeasureProps => ({ + text: 'aa', + fontSize: 20, // → fontScale 2 + letterSpacing: 0, + lineHeight: 1, + maxLines: 0, + maxHeight: 0, + wordBreak: 'break-word', + overflowSuffix: '...', + ...over, +}); + +// At fontSize 20, em scale 20/1000 = 0.02 → bareLineHeight = (800−(−200))·0.02 = 20. +const LINE_PX = 20; + +describe('FontMetrics.measureText', () => { + it('sums glyph advances in design units', () => { + expect(font.measureText('aa', 0)).toBe(20); + expect(font.measureText('aa aa', 0)).toBe(45); // 10+10+5+10+10 + }); + + it('applies letter spacing per glyph (design units)', () => { + expect(font.measureText('aa', 2)).toBe(24); // (10+2)+(10+2) + }); +}); + +describe('layoutText', () => { + it('measures a single unconstrained line, scaling design→px', () => { + const { width, height } = layoutText(font, props({ text: 'aa aa' }), Infinity); + expect(width).toBe(90); // 45 design × fontScale 2 + expect(height).toBe(LINE_PX); + }); + + it('wraps to the available width and reports the tallest stack', () => { + // maxWidth 60px → 30 design. "aa"(20) fits; +space(5)+"aa"(20)=45 > 30 → wrap. + const { width, height } = layoutText(font, props({ text: 'aa aa' }), 60); + expect(width).toBe(40); // widest line "aa" = 20 design × 2 + expect(height).toBe(2 * LINE_PX); + }); + + it('honours explicit newlines when unconstrained', () => { + const { height } = layoutText(font, props({ text: 'aa\nbb\naa' }), Infinity); + expect(height).toBe(3 * LINE_PX); + }); + + it('caps line count via maxLines', () => { + const { height } = layoutText(font, props({ text: 'aa aa aa', maxLines: 2 }), 60); + expect(height).toBe(2 * LINE_PX); + }); + + it('caps line count via maxHeight', () => { + const { height } = layoutText(font, props({ text: 'aa aa aa', maxHeight: 25 }), 60); + // floor(25 / 20) = 1 line + expect(height).toBe(LINE_PX); + }); + + it('treats a pixel lineHeight (>3) as absolute', () => { + const { height } = layoutText(font, props({ text: 'aa', lineHeight: 40 }), Infinity); + expect(height).toBe(40); + }); +}); diff --git a/packages/plugin-flexbox/src/text/layoutText.ts b/packages/plugin-flexbox/src/text/layoutText.ts new file mode 100644 index 00000000..e3bfe35e --- /dev/null +++ b/packages/plugin-flexbox/src/text/layoutText.ts @@ -0,0 +1,424 @@ +/** + * Text block measurement for the Yoga worker — a measurement-focused port of + * `@lightningjs/renderer`'s `TextLayoutEngine` (Apache-2.0). It reproduces the + * renderer's line-wrapping exactly so the size Yoga lays out matches the size + * the renderer paints; only the parts that affect the measured box (line widths + * and line count) are kept — glyph positions, baselines and x-offsets are not. + * + * Width maths run in atlas design units via `FontMetrics.measureText`; the + * public entry point converts to/from px using `fontScale`. + */ + +import type { FontMetrics, LightningMetrics } from './FontMetricsStore'; + +export interface TextMeasureProps { + text: string; + fontSize: number; + letterSpacing: number; + /** ≤3 → multiplier of the natural line height; otherwise px. Matches renderer. */ + lineHeight: number; + maxLines: number; + /** Hard cap in px (0 = none). */ + maxHeight: number; + wordBreak: 'break-word' | 'break-all' | 'overflow'; + overflowSuffix: string; +} + +export interface MeasuredText { + /** px */ + width: number; + /** px */ + height: number; +} + +// [text, width(design units), truncated] +type Line = [string, number, boolean]; + +const spaceRegex = /[ ​]+/g; + +const measure = (font: FontMetrics, text: string, letterSpacing: number): number => + font.measureText(text, letterSpacing); + +const normalizeFontMetrics = (metrics: LightningMetrics, fontSize: number) => { + const scale = fontSize / metrics.unitsPerEm; + + return { + ascender: metrics.ascender * scale, + descender: metrics.descender * scale, + }; +}; + +/** + * Measure a text block within an available width. + * + * @param availableWidth px width to wrap within; `Infinity`/`<=0` means + * unconstrained (no wrapping, single line per `\n`). + * @returns box size in px. + */ +export function layoutText( + font: FontMetrics, + props: TextMeasureProps, + availableWidth: number, +): MeasuredText { + const { text, fontSize, lineHeight, maxLines, maxHeight, wordBreak, overflowSuffix } = props; + + const fontScale = fontSize / font.designFontSize; + // measureText + maxWidth live in design units; px → design via /fontScale. + const letterSpacing = props.letterSpacing / fontScale; + const maxWidth = + availableWidth === Infinity || availableWidth <= 0 ? 0 : availableWidth / fontScale; + + // Line height in px, from em-scaled metrics (renderer parity). + const { ascender, descender } = normalizeFontMetrics(font.metrics, fontSize); + const bareLineHeight = ascender - descender; + const lineHeightPx = lineHeight <= 3 ? lineHeight * bareLineHeight : lineHeight; + + let effectiveMaxLines = maxLines; + + if (maxHeight > 0 && lineHeightPx > 0) { + const maxFromHeight = Math.max(1, Math.floor(maxHeight / lineHeightPx)); + + if (effectiveMaxLines === 0 || maxFromHeight < effectiveMaxLines) { + effectiveMaxLines = maxFromHeight; + } + } + + const lines = + maxWidth > 0 + ? wrapText(font, text, maxWidth, letterSpacing, overflowSuffix, wordBreak, effectiveMaxLines) + : measureLines(font, text.split('\n'), letterSpacing, effectiveMaxLines); + + let widthDesign = 0; + + for (const line of lines) { + if (line[1] > widthDesign) { + widthDesign = line[1]; + } + } + + return { + width: widthDesign * fontScale, + height: lines.length * lineHeightPx, + }; +} + +function measureLines( + font: FontMetrics, + rawLines: string[], + letterSpacing: number, + maxLines: number, +): Line[] { + const limit = maxLines > 0 ? maxLines : rawLines.length; + const out: Line[] = []; + + for (let i = 0; i < rawLines.length && out.length < limit; i++) { + const raw = rawLines[i] ?? ''; + out.push([raw, measure(font, raw, letterSpacing), false]); + } + + return out; +} + +function wrapText( + font: FontMetrics, + text: string, + maxWidth: number, + letterSpacing: number, + overflowSuffix: string, + wordBreak: TextMeasureProps['wordBreak'], + maxLines: number, +): Line[] { + const sourceLines = text.split('\n'); + const wrappedLines: Line[] = []; + const spaceWidth = measure(font, ' ', letterSpacing); + const overflowWidth = measure(font, overflowSuffix, letterSpacing); + const hasMaxLines = maxLines > 0; + let remainingLines = hasMaxLines ? maxLines : 1000; + + for (let i = 0; i < sourceLines.length; i++) { + const line = sourceLines[i] ?? ''; + + const produced = + line.length > 0 + ? wrapLine( + font, + line, + maxWidth, + letterSpacing, + spaceWidth, + overflowSuffix, + overflowWidth, + wordBreak, + remainingLines, + ) + : ([[['', 0, false]], remainingLines] as [Line[], number]); + + remainingLines = produced[1] - 1; + wrappedLines.push(...produced[0]); + + if (hasMaxLines && remainingLines <= 0) { + break; + } + } + + return wrappedLines; +} + +function wrapLine( + font: FontMetrics, + line: string, + maxWidth: number, + letterSpacing: number, + spaceWidth: number, + overflowSuffix: string, + overflowWidth: number, + wordBreak: TextMeasureProps['wordBreak'], + remainingLinesIn: number, +): [Line[], number] { + const words = line.split(spaceRegex); + const spaces = line.match(spaceRegex) || []; + const wrappedLines: Line[] = []; + let currentLine = ''; + let currentLineWidth = 0; + let remainingLines = remainingLinesIn; + + while (words.length > 0 && remainingLines > 0) { + let word = words.shift() ?? ''; + let wordWidth = measure(font, word, letterSpacing); + + if (currentLineWidth === 0) { + if (wordWidth > maxWidth) { + remainingLines--; + + let remainingWord = ''; + [word, remainingWord, wordWidth] = + remainingLines === 0 + ? truncateWord( + font, + word, + wordWidth, + maxWidth, + letterSpacing, + overflowSuffix, + overflowWidth, + ) + : splitWord(font, word, wordWidth, maxWidth, letterSpacing); + + if (remainingWord.length > 0) { + words.unshift(remainingWord); + } + + wrappedLines.push([word, wordWidth, false]); + } else if (wordWidth + spaceWidth >= maxWidth) { + remainingLines--; + wrappedLines.push([word, wordWidth, false]); + } else { + currentLine = word; + currentLineWidth = wordWidth; + } + + continue; + } + + const space = spaces.shift() || ''; + const effectiveSpaceWidth = space === '​' ? 0 : spaceWidth; + const totalWidth = currentLineWidth + effectiveSpaceWidth + wordWidth; + + if (totalWidth < maxWidth) { + currentLine += effectiveSpaceWidth > 0 ? space + word : word; + currentLineWidth = totalWidth; + continue; + } + + remainingLines--; + + if (totalWidth === maxWidth) { + currentLine += effectiveSpaceWidth > 0 ? space + word : word; + wrappedLines.push([currentLine, totalWidth, false]); + currentLine = ''; + currentLineWidth = 0; + continue; + } + + let remainingWord = ''; + [currentLine, currentLineWidth, remainingWord] = breakOntoNextLine( + font, + word, + wordWidth, + letterSpacing, + wrappedLines, + currentLine, + currentLineWidth, + remainingLines, + maxWidth, + space, + spaceWidth, + overflowSuffix, + overflowWidth, + wordBreak, + ); + + if (remainingWord.length > 0) { + words.unshift(remainingWord); + } + } + + if (currentLineWidth > 0 && remainingLines > 0) { + wrappedLines.push([currentLine, currentLineWidth, false]); + } + + return [wrappedLines, remainingLines]; +} + +function breakOntoNextLine( + font: FontMetrics, + word: string, + wordWidth: number, + letterSpacing: number, + wrappedLines: Line[], + currentLine: string, + currentLineWidth: number, + remainingLines: number, + maxWidth: number, + space: string, + spaceWidth: number, + overflowSuffix: string, + overflowWidth: number, + wordBreak: TextMeasureProps['wordBreak'], +): [string, number, string] { + if (wordBreak === 'overflow') { + currentLine += space + word; + currentLineWidth += spaceWidth + wordWidth; + wrappedLines.push([currentLine, currentLineWidth, true]); + return ['', 0, '']; + } + + if (wordBreak === 'break-all') { + let remainingSpace = maxWidth - currentLineWidth; + + if (currentLineWidth > 0) { + remainingSpace -= spaceWidth; + } + + const truncate = remainingLines === 0; + let remainingWord = ''; + [word, remainingWord, wordWidth] = truncate + ? truncateWord( + font, + word, + wordWidth, + remainingSpace, + letterSpacing, + overflowSuffix, + overflowWidth, + ) + : splitWord(font, word, wordWidth, remainingSpace, letterSpacing); + + wrappedLines.push([ + currentLine + space + word, + currentLineWidth + spaceWidth + wordWidth, + truncate, + ]); + return ['', 0, remainingWord]; + } + + // break-word (default): push the current line, carry the whole word over. + wrappedLines.push([currentLine, currentLineWidth, false]); + return ['', 0, word]; +} + +function splitWord( + font: FontMetrics, + word: string, + wordWidth: number, + maxWidth: number, + letterSpacing: number, +): [string, string, number] { + if (maxWidth <= 0) { + return ['', word, 0]; + } + + const shouldStartFromBack = wordWidth - maxWidth < wordWidth / 2; + + if (!shouldStartFromBack) { + let currentWidth = wordWidth; + + for (let i = word.length - 1; i > 0; i--) { + currentWidth -= measure(font, word.charAt(i), letterSpacing); + + if (currentWidth <= maxWidth) { + return [word.substring(0, i), word.substring(i), currentWidth]; + } + } + + return ['', word, 0]; + } + + let currentWidth = 0; + + for (let i = 0; i < word.length; i++) { + const charWidth = measure(font, word.charAt(i), letterSpacing); + + if (currentWidth + charWidth > maxWidth) { + return [word.substring(0, i), word.substring(i), currentWidth]; + } + + currentWidth += charWidth; + } + + return [word, '', wordWidth]; +} + +function truncateWord( + font: FontMetrics, + word: string, + wordWidth: number, + maxWidth: number, + letterSpacing: number, + overflowSuffix: string, + overflowWidth: number, +): [string, string, number] { + const targetWidth = maxWidth - overflowWidth; + + if (targetWidth <= 0) { + return ['', word, 0]; + } + + const shouldStartFromBack = wordWidth - targetWidth < wordWidth / 2; + + if (!shouldStartFromBack) { + let currentWidth = wordWidth; + + for (let i = word.length - 1; i > 0; i--) { + currentWidth -= measure(font, word.charAt(i), letterSpacing); + + if (currentWidth <= targetWidth) { + return [ + word.substring(0, i) + overflowSuffix, + word.substring(i), + currentWidth + overflowWidth, + ]; + } + } + + return [overflowSuffix, word, overflowWidth]; + } + + let currentWidth = 0; + + for (let i = 0; i < word.length; i++) { + const charWidth = measure(font, word.charAt(i), letterSpacing); + + if (currentWidth + charWidth > targetWidth) { + return [ + word.substring(0, i) + overflowSuffix, + word.substring(i), + currentWidth + overflowWidth, + ]; + } + + currentWidth += charWidth; + } + + return [word + overflowSuffix, '', wordWidth + overflowWidth]; +} diff --git a/packages/plugin-flexbox/src/types/ManagerNode.ts b/packages/plugin-flexbox/src/types/ManagerNode.ts index 3be2f406..adefee5c 100644 --- a/packages/plugin-flexbox/src/types/ManagerNode.ts +++ b/packages/plugin-flexbox/src/types/ManagerNode.ts @@ -1,9 +1,16 @@ import type { Node } from 'yoga-layout'; +import type { TextMeasureProps } from '../text/layoutText'; + export type ManagerNode = { id: number; parent?: ManagerNode; node: Node; children: ManagerNode[]; props: Record; + /** Set when this node is a measured text leaf (has a Yoga measure func). */ + text?: { + fontFamily: string; + props: TextMeasureProps; + }; }; diff --git a/packages/plugin-flexbox/src/types/YogaOptions.ts b/packages/plugin-flexbox/src/types/YogaOptions.ts index 65d29444..ac171cea 100644 --- a/packages/plugin-flexbox/src/types/YogaOptions.ts +++ b/packages/plugin-flexbox/src/types/YogaOptions.ts @@ -1,6 +1,20 @@ +/** A font the worker can measure text with, by family name and atlas JSON URL. */ +export type YogaFont = { + fontFamily: string; + /** URL of the msdf atlas `.json` (same file the renderer loads). */ + atlasDataUrl: string; +}; + export type YogaOptions = { useWebDefaults?: boolean; useWebWorker?: boolean; + /** + * msdf fonts to load for synchronous text measurement. When provided, text + * leaves are measured by Yoga during layout (wrapping/sizing) instead of + * relying on the renderer's async measurement. Loaded in the background; + * text using a not-yet-loaded font measures empty until it arrives. + */ + fonts?: YogaFont[]; /** * Whether to expand flex basis to auto when expanding a flex value. The specs * say it should expand to 0, but this does not match react-native behaviour. diff --git a/packages/react-lightning/src/element/LightningTextElement.ts b/packages/react-lightning/src/element/LightningTextElement.ts index dfc70b30..93edee3c 100644 --- a/packages/react-lightning/src/element/LightningTextElement.ts +++ b/packages/react-lightning/src/element/LightningTextElement.ts @@ -29,12 +29,26 @@ export class LightningTextElement extends LightningViewElement< // derive `node.text` from the children so removing them clears it. private _aggregatesChildText = false; + // Last text we emitted a change for. Tracked separately from `node.text` + // because the base `_doUpdate` writes `node.text` directly (bypassing this + // setter), so comparing against `node.text` would miss real changes. + private _lastEmittedText: string | undefined; + public get text(): string { return this.node.text; } public set text(v) { this.node.text = v; + + // Text content can change without a `setProps`/`propsChanged` cycle — via + // `commitTextUpdate` (recycled nodes) or `recomputeChildText`. Emit a + // dedicated signal so consumers (e.g. the flexbox text-measure) re-measure + // on every value change, not just prop changes. + if (v !== this._lastEmittedText) { + this._lastEmittedText = v; + this.emit('textChanged', this); + } } /** From 840bef7d6b2d246df8cef3774057307eccf37fe3 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 22 Jun 2026 11:44:42 +0200 Subject: [PATCH 03/66] fix(vendor): withhold paint until first layout to avoid async-flex origin flash --- .changeset/lightning-flex-nonzero-sizing.md | 8 ++ .../plugin-flexbox/src/LightningManager.ts | 32 +++++ .../src/element/LightningViewElement.spec.ts | 136 ++++++++++++++++++ .../src/element/LightningViewElement.ts | 83 +++++++++++ 4 files changed, 259 insertions(+) create mode 100644 .changeset/lightning-flex-nonzero-sizing.md create mode 100644 packages/react-lightning/src/element/LightningViewElement.spec.ts diff --git a/.changeset/lightning-flex-nonzero-sizing.md b/.changeset/lightning-flex-nonzero-sizing.md new file mode 100644 index 00000000..2e484f39 --- /dev/null +++ b/.changeset/lightning-flex-nonzero-sizing.md @@ -0,0 +1,8 @@ +--- +"@plextv/react-lightning": minor +"@plextv/react-lightning-plugin-flexbox": patch +--- + +fix(flexbox): withhold paint until first layout to avoid the async-flex origin flash + +Flex layout is computed asynchronously (in a worker), so a definite-sized node mounts and paints at its pre-layout origin (0,0) for a frame or two before the layout result moves it. A node now keeps its rendered alpha at 0 from mount until its first layout resolves, then restores the styled alpha (`withholdPaintUntilLayout` / `releaseWithheldPaint`). Zero-sized and already-invisible nodes are skipped, and subtrees detached from flex layout are released so they can never be stranded invisible. diff --git a/packages/plugin-flexbox/src/LightningManager.ts b/packages/plugin-flexbox/src/LightningManager.ts index bf55b9f3..727a5022 100644 --- a/packages/plugin-flexbox/src/LightningManager.ts +++ b/packages/plugin-flexbox/src/LightningManager.ts @@ -72,6 +72,13 @@ export class LightningManager { } } + // The boundary's descendants no longer get a layout (until a nested flex + // root re-opts them in), so any that were withheld waiting for a first + // layout would never be revealed. Release them now. + for (let i = 0; i < element.children.length; i++) { + this._releaseWithheldSubtree(element.children[i]); + } + // Tree shape changed — re-layout any flex roots that contain it. this._yogaManager.queueRender(element.id); } @@ -107,6 +114,10 @@ export class LightningManager { this._reattachChildren(element); + // A definite-sized root paints at its origin until its first layout + // resolves — withhold paint until then. No-op for 0x0 roots. + element.withholdPaintUntilLayout(); + // First layout pass — without this the root sits at 0,0 until // something else calls applyStyle. this._yogaManager.queueRender(element.id); @@ -217,6 +228,10 @@ export class LightningManager { this._yogaManager.addChildNode(parent.id, child.id, yogaIndex); this._setYogaParent(child.id, parent.id); + // Hide a definite-sized node until its first layout positions it, so it + // doesn't paint at its pre-layout origin while the (async) layout is in + // flight. No-op for 0x0 nodes — the common case. + child.withholdPaintUntilLayout(); yogaIndex++; if (!this._boundaries.has(child.id)) { @@ -225,6 +240,21 @@ export class LightningManager { } } + /** Recursively reveal any withheld nodes in a subtree that has been detached + * from flex layout (and so would never receive the first layout that reveals + * them). Stops at nested flex roots, whose subtrees stay in layout. */ + private _releaseWithheldSubtree(element: LightningElement | undefined): void { + if (!element || this._flexRoots.has(element.id)) { + return; + } + + element.releaseWithheldPaint(); + + for (let i = 0; i < element.children.length; i++) { + this._releaseWithheldSubtree(element.children[i]); + } + } + /** * Push a text element's content + font props to Yoga so it can measure the * text during layout. Only text in a font we have metrics for is measured; @@ -320,6 +350,8 @@ export class LightningManager { // oxlint-disable-next-line typescript/no-non-null-assertion -- Guaranteed to exist. See above this._yogaManager!.addChildNode(element.id, child.id, yogaIndex); this._setYogaParent(child.id, element.id); + // See _reattachChildren — withhold paint until first layout. + child.withholdPaintUntilLayout(); this.applyStyle(element.id, element.style); // React mounts bottom-up: `child`'s descendants were inserted diff --git a/packages/react-lightning/src/element/LightningViewElement.spec.ts b/packages/react-lightning/src/element/LightningViewElement.spec.ts new file mode 100644 index 00000000..ceedf0a4 --- /dev/null +++ b/packages/react-lightning/src/element/LightningViewElement.spec.ts @@ -0,0 +1,136 @@ +import type { RendererMain } from '@lightningjs/renderer'; +import type { Fiber } from 'react-reconciler'; +import { describe, expect, it } from 'vitest'; + +import type { LightningViewElementProps, LightningViewElementStyle } from '../types'; +import { LightningViewElement } from './LightningViewElement'; + +// A minimal stand-in for a renderer CoreNode: just the props the element +// reads/writes plus no-op event/animation hooks. +function createMockNode(props: Record = {}) { + return { + x: 0, + y: 0, + w: 0, + h: 0, + alpha: 1, + color: 0, + shader: { props: {} }, + parent: null, + on() {}, + off() {}, + animate() { + return { once() {}, start() {} }; + }, + destroy() {}, + ...props, + }; +} + +const renderer = { + createNode: (props: Record) => createMockNode(props), + createTextNode: (props: Record) => createMockNode(props), + createShader: () => ({ props: {} }), + createTexture: () => ({}), + destroyNode() {}, +} as unknown as RendererMain; + +function createElement(style: Partial) { + const props = { + style, + } as LightningViewElementProps; + + return new LightningViewElement(props, renderer, [], {} as Fiber); +} + +// setProps stages the update and flushes on a microtask. +const flush = () => Promise.resolve(); + +describe('LightningViewElement paint withholding', () => { + it('hides a definite-sized node until its first layout resolves', () => { + const el = createElement({ w: 100, h: 50, alpha: 1 }); + + el.withholdPaintUntilLayout(); + + expect(el.paintWithheld).toBe(true); + expect(el.node.alpha).toBe(0); + expect(el.visible).toBe(false); + + el.emitLayoutEvent(); + + expect(el.paintWithheld).toBe(false); + expect(el.hasLayout).toBe(true); + expect(el.node.alpha).toBe(1); + expect(el.visible).toBe(true); + }); + + it('restores the originally styled alpha (not 1) on reveal', () => { + const el = createElement({ w: 100, h: 50, alpha: 0.5 }); + + el.withholdPaintUntilLayout(); + expect(el.node.alpha).toBe(0); + + el.emitLayoutEvent(); + expect(el.node.alpha).toBe(0.5); + }); + + it('is a no-op for a zero-sized node (nothing to flash)', () => { + const el = createElement({ alpha: 1 }); + + el.withholdPaintUntilLayout(); + + expect(el.paintWithheld).toBe(false); + expect(el.node.alpha).toBe(1); + }); + + it('is a no-op for an already-invisible node', () => { + const el = createElement({ w: 100, h: 50, alpha: 0 }); + + el.withholdPaintUntilLayout(); + + expect(el.paintWithheld).toBe(false); + expect(el.node.alpha).toBe(0); + }); + + it('is a no-op once a layout has already resolved', () => { + const el = createElement({ w: 100, h: 50, alpha: 1 }); + + el.emitLayoutEvent(); + el.withholdPaintUntilLayout(); + + expect(el.paintWithheld).toBe(false); + expect(el.node.alpha).toBe(1); + }); + + it('keeps the node hidden but records a styled alpha change made while withheld', async () => { + const el = createElement({ w: 100, h: 50, alpha: 1 }); + + el.withholdPaintUntilLayout(); + expect(el.node.alpha).toBe(0); + + // App changes alpha before the first layout arrives — the node must stay + // hidden, but reveal at the new value. + el.setProps({ style: { alpha: 0.25 } }); + await flush(); + + expect(el.node.alpha).toBe(0); + expect(el.paintWithheld).toBe(true); + + el.emitLayoutEvent(); + expect(el.node.alpha).toBe(0.25); + }); + + it('reveals immediately when released before a layout (e.g. detached by a boundary)', () => { + const el = createElement({ w: 100, h: 50, alpha: 1 }); + + el.withholdPaintUntilLayout(); + expect(el.node.alpha).toBe(0); + + el.releaseWithheldPaint(); + + expect(el.paintWithheld).toBe(false); + expect(el.node.alpha).toBe(1); + // Released without a layout — still not laid out. + expect(el.hasLayout).toBe(false); + }); +}); diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index 9d617dcf..dff4a316 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -98,6 +98,8 @@ export class LightningViewElement< private _recycled = false; private _hasStagedUpdates = false; private _hasLayout = false; + private _paintWithheld = false; + private _withheldAlpha = 1; private _eventEmitter = new EventEmitter(); private _deferTarget: LightningElement | null = null; private _deferNodeRemovalHandler: ((destroy: () => void) => void) | null = null; @@ -261,6 +263,59 @@ export class LightningViewElement< return this._hasLayout; } + public get paintWithheld(): boolean { + return this._paintWithheld; + } + + /** + * Hide this node (force rendered alpha to 0) until its first layout resolves, + * then restore the styled alpha. Flex layout is computed asynchronously (in a + * worker), so without this a node with a definite size mounts and paints at + * its pre-layout origin (0,0) for one or more frames before the layout result + * moves it — the "async-flex origin flash". Withholding paint until + * {@link _onLayout} fires removes that flash regardless of how long the + * layout round-trip takes. + * + * No-op once laid out, and a no-op for nodes that can't flash anyway (already + * invisible, or zero-sized — those paint nothing at their origin), so the + * common 0x0 mass-mount path is untouched. + */ + public withholdPaintUntilLayout(): void { + if (this._hasLayout || this._paintWithheld) { + return; + } + + const node = this.node; + + if (node.alpha <= 0 || node.w <= 0 || node.h <= 0) { + return; + } + + this._paintWithheld = true; + this._withheldAlpha = node.alpha; + node.alpha = 0; + this.recalculateVisibility(); + } + + /** + * Restore a withheld node's alpha immediately, without waiting for a layout. + * Used when a node leaves flex layout before its first layout resolves (e.g. + * its subtree is detached by a boundary) and so would otherwise never be + * revealed. + */ + public releaseWithheldPaint(): void { + if (!this._paintWithheld) { + return; + } + + this._paintWithheld = false; + + if (this.node.alpha !== this._withheldAlpha) { + this.node.alpha = this._withheldAlpha; + this.recalculateVisibility(); + } + } + public constructor( initialProps: TProps, renderer: RendererMain, @@ -772,6 +827,14 @@ export class LightningViewElement< delete lngProps.h; } + // While paint is withheld, a styled alpha change must update the alpha we + // restore on first layout, not the node's (which stays 0 so the node keeps + // hiding). See {@link withholdPaintUntilLayout}. + if (this._paintWithheld && lngProps.alpha !== undefined) { + this._withheldAlpha = lngProps.alpha; + delete lngProps.alpha; + } + Object.assign(this.node, lngProps); // oxlint-disable-next-line typescript/no-explicit-any -- Required for accessing AllStyleProps symbol @@ -881,6 +944,14 @@ export class LightningViewElement< continue; } + // While paint is withheld, capture a styled alpha change for restore on + // first layout instead of un-hiding the node. See + // {@link withholdPaintUntilLayout}. + if (key === 'alpha' && this._paintWithheld) { + this._withheldAlpha = value as number; + continue; + } + if (transition?.[typedKey]) { this.animateStyle(typedKey, value as TStyleProps[typeof typedKey]); } else { @@ -927,6 +998,18 @@ export class LightningViewElement< private _onLayout = (dimensions: Rect) => { this._hasLayout = true; + + // First layout resolved — reveal a withheld node at its now-correct + // geometry. See {@link withholdPaintUntilLayout}. + if (this._paintWithheld) { + this._paintWithheld = false; + + if (this.node.alpha !== this._withheldAlpha) { + this.node.alpha = this._withheldAlpha; + this.recalculateVisibility(); + } + } + this.props.onLayout?.(dimensions); }; From e96bf560d9445f9f1593cb1014388d5d4fda2fad Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 22 Jun 2026 11:45:08 +0200 Subject: [PATCH 04/66] feat(vendor): focus-when-ready, arrival-not-mount autoFocus, destinations-on-arrival --- .changeset/lightning-focus-engine.md | 7 + .../src/focus/FocusManager.spec.ts | 135 ++++++++++++++ .../react-lightning/src/focus/FocusManager.ts | 169 ++++++++++++++---- 3 files changed, 278 insertions(+), 33 deletions(-) create mode 100644 .changeset/lightning-focus-engine.md diff --git a/.changeset/lightning-focus-engine.md b/.changeset/lightning-focus-engine.md new file mode 100644 index 00000000..313260c9 --- /dev/null +++ b/.changeset/lightning-focus-engine.md @@ -0,0 +1,7 @@ +--- +"@plextv/react-lightning": minor +--- + +feat(focus): focus-when-ready, arrival-not-mount autoFocus, and destinations-on-arrival + +`FocusManager.focus()` no longer drops a request for an element that isn't registered or focusable yet — it queues it and resolves the moment the element becomes ready, so callers don't have to poll across frames. A later-mounting `autoFocus` child no longer steals live focus on registration (a new `focusCommitted` flag gates the upgrade), matching native `TVFocusGuideView`, which forwards focus on arrival rather than on mount. And `destinations` are now honoured on arrival (first visit without `focusRedirect`, every visit with it), so focus forwards to a declared destination then remembers the last-focused child. diff --git a/packages/react-lightning/src/focus/FocusManager.spec.ts b/packages/react-lightning/src/focus/FocusManager.spec.ts index f27c8b09..2fe49a73 100644 --- a/packages/react-lightning/src/focus/FocusManager.spec.ts +++ b/packages/react-lightning/src/focus/FocusManager.spec.ts @@ -367,6 +367,141 @@ describe('FocusManager', () => { }); }); + describe('focus-when-ready', () => { + it('fulfills a focus() request once the target element registers', () => { + const root = createMockElement(1, 'root'); + const a = createMockElement(2, 'a'); + const target = createMockElement(3, 'target'); + + focusManager.addElement(root, null); + focusManager.addElement(a, root, { autoFocus: true }); + expect(focusManager.focusPath).toEqual([root, a]); + + // Target hasn't mounted/registered yet — the request must not be dropped. + focusManager.focus(target); + expect(target.focused).toBe(false); + + // Target registers a moment later; the queued request now resolves. + focusManager.addElement(target, root); + expect(target.focused).toBe(true); + expect(focusManager.focusPath).toEqual([root, target]); + }); + + it('cancels a pending focus when the target is removed', () => { + const root = createMockElement(1, 'root'); + const a = createMockElement(2, 'a'); + const target = createMockElement(3, 'target'); + + focusManager.addElement(root, null); + focusManager.addElement(a, root, { autoFocus: true }); + + focusManager.focus(target); + focusManager.removeElement(target); + + // Re-registering must not retroactively fulfill the cancelled request. + focusManager.addElement(target, root); + expect(target.focused).toBe(false); + expect(focusManager.focusPath).toEqual([root, a]); + }); + + it('lets a later explicit focus supersede a pending request', () => { + const root = createMockElement(1, 'root'); + const a = createMockElement(2, 'a'); + const target = createMockElement(3, 'target'); + + focusManager.addElement(root, null); + focusManager.addElement(a, root, { autoFocus: true }); + + focusManager.focus(target); // queued (not registered) + focusManager.focus(a); // registered — supersedes the pending request + + expect(a.focused).toBe(true); + + focusManager.addElement(target, root); + expect(target.focused).toBe(false); + expect(focusManager.focusPath).toEqual([root, a]); + }); + }); + + describe('arrival-not-mount autoFocus', () => { + it('does not let a later-mounting autoFocus child steal committed focus', () => { + const root = createMockElement(1, 'root'); + const content = createMockElement(2, 'content'); + const nav = createMockElement(3, 'nav'); + + focusManager.addElement(content, root); + focusManager.addElement(root, null); + + // Focus is explicitly committed to content. + focusManager.focus(content); + expect(focusManager.focusPath).toEqual([root, content]); + + // A nav bar mounts a frame later with autoFocus — native semantics say it + // only forwards focus on arrival, so it must not steal it on mount. + focusManager.addElement(nav, root, { autoFocus: true }); + expect(content.focused).toBe(true); + expect(focusManager.focusPath).toEqual([root, content]); + }); + + it('still lets autoFocus upgrade a mount-default preferred child', () => { + const root = createMockElement(1, 'root'); + const a = createMockElement(2, 'a'); + const b = createMockElement(3, 'b'); + + focusManager.addElement(root, null); + // `a` becomes the preferred child only as a mount default (no explicit + // focus), so a later autoFocus child is still allowed to upgrade it. + focusManager.addElement(a, root); + focusManager.addElement(b, root, { autoFocus: true }); + + expect(focusManager.focusPath).toEqual([root, b]); + expect(b.focused).toBe(true); + }); + }); + + describe('destinations on arrival', () => { + it('forwards to a destination on first arrival, then remembers the child', () => { + const root = createMockElement(1, 'root'); + const group = createMockElement(2, 'group'); + const child1 = createMockElement(3, 'child1'); + const child2 = createMockElement(4, 'child2'); + + focusManager.addElement(root, null); + focusManager.addElement(group, root, { destinations: [child2] }); + focusManager.addElement(child1, group); + focusManager.addElement(child2, group); + + // First arrival forwards to the declared destination (child2), not the + // default first child (child1). + focusManager.focus(group); + expect(focusManager.focusPath).toEqual([root, group, child2]); + + // Move focus to child1, then re-enter the group: it now remembers the + // last-focused child instead of redirecting again. + focusManager.focus(child1); + expect(focusManager.focusPath).toEqual([root, group, child1]); + + focusManager.focus(group); + expect(focusManager.focusPath).toEqual([root, group, child1]); + }); + + it('always redirects with focusRedirect, every visit', () => { + const root = createMockElement(1, 'root'); + const real = createMockElement(2, 'real'); + const guide = createMockElement(3, 'guide'); + + focusManager.addElement(root, null); + focusManager.addElement(real, root); + focusManager.addElement(guide, root, { + focusRedirect: true, + destinations: [real], + }); + + focusManager.focus(guide); + expect(focusManager.focusPath).toEqual([root, real]); + }); + }); + describe('Layer Management (Modal Support)', () => { it('should create a new layer when pushLayer is called', () => { const mainElement = createMockElement(1, 'main'); diff --git a/packages/react-lightning/src/focus/FocusManager.ts b/packages/react-lightning/src/focus/FocusManager.ts index 1502ba1c..3e3fd2b2 100644 --- a/packages/react-lightning/src/focus/FocusManager.ts +++ b/packages/react-lightning/src/focus/FocusManager.ts @@ -9,6 +9,14 @@ type RootNode = { children: FocusNode[]; focusedElement: FocusNode | null; hasFocusableChildren: boolean; + /** + * True once focus has been explicitly committed into this node's subtree via + * `focus()`/spatial navigation (as opposed to a mount-time default). While + * committed, a later-mounting `autoFocus` child must not steal live focus on + * registration — matching native `TVFocusGuideView`, which forwards focus on + * arrival, not on mount. + */ + focusCommitted: boolean; }; export type FocusNode = Omit, 'element'> & { @@ -73,6 +81,13 @@ export class FocusManager< private _childFocusEventHandlers: Map void) | undefined> = new Map(); private _focusStack: FocusLayer[] = []; private _eventEmitter = new EventEmitter>(); + /** + * A focus request whose target was not yet registered (or not yet focusable) + * when `focus()` was called. Fulfilled the moment the element registers or + * becomes focusable, so callers don't have to poll across frames waiting for + * a node to mount/scroll into view. Last request wins. + */ + private _pendingFocus: T | null = null; public get activeLayer(): FocusLayer { if (this._focusStack.length === 0) { @@ -94,6 +109,7 @@ export class FocusManager< children: [], focusedElement: null, hasFocusableChildren: false, + focusCommitted: false, }, elements: new Map(), focusPath: [], @@ -225,15 +241,24 @@ export class FocusManager< this._checkFocusableChildren(parentNode); - if ( - child.focusable && - !hasExternalRedirect(childNode) && - (!parentNode.focusedElement || (!parentNode.focusedElement.autoFocus && autoFocus)) - ) { - parentNode.focusedElement = childNode; + if (child.focusable && !hasExternalRedirect(childNode)) { + if (!parentNode.focusedElement) { + // No preferred child yet — take the slot regardless of autoFocus. + parentNode.focusedElement = childNode; + } else if (autoFocus && !parentNode.focusedElement.autoFocus && !parentNode.focusCommitted) { + // An autoFocus child upgrades a non-autoFocus preferred child only + // while focus hasn't been explicitly committed here. Once committed, + // a later-mounting autoFocus child must not steal live focus (it would + // diverge from native TVFocusGuideView, which forwards on arrival). + parentNode.focusedElement = childNode; + } } this._recalculateFocusPath(); + + // If a focus request was waiting on this element to register, fulfill it + // now that it's in the tree (and possibly focusable). + this._tryFulfillPendingFocus(child); } private _forAllNodes(element: T, callback: (node: FocusNode) => void): void { @@ -249,6 +274,10 @@ export class FocusManager< } public removeElement(element: T): void { + if (this._pendingFocus === element) { + this._pendingFocus = null; + } + this._forAllNodes(element, (node) => { this._removeNode(node, true); }); @@ -328,6 +357,10 @@ export class FocusManager< } public pushLayer(): void { + // A pending focus targets the layer it was requested in; drop it on a + // layer change so it can't fulfill against the wrong layer. + this._pendingFocus = null; + // Store the current layer before creating new one const previousLayer = this.activeLayer; @@ -349,6 +382,7 @@ export class FocusManager< children: [], focusedElement: null, hasFocusableChildren: false, + focusCommitted: false, }, elements: new Map(), focusPath: [], @@ -367,6 +401,10 @@ export class FocusManager< return; } + // A pending focus targets the layer it was requested in; drop it on a + // layer change so it can't fulfill against the wrong layer. + this._pendingFocus = null; + // Get current layer info before popping const currentLayer = this.activeLayer; @@ -411,13 +449,36 @@ export class FocusManager< public focus(element: T): void { const node = this.activeLayer.elements.get(element); - if (!node) { + // Not registered yet, or registered but not focusable yet (e.g. just + // mounted / scrolled into view, dimensions not measured). Queue the + // request instead of dropping it; it resolves once the element is ready. + if (!node || !element.focusable) { + this._pendingFocus = element; + return; } + this._pendingFocus = null; this._focusNode(node); } + /** + * Fulfill a queued {@link focus} request for `element` if it is now + * registered and focusable. No-op otherwise (it stays queued). + */ + private _tryFulfillPendingFocus(element: T): void { + if (this._pendingFocus !== element) { + return; + } + + const node = this.activeLayer.elements.get(element); + + if (node && element.focusable && !hasExternalRedirect(node)) { + this._pendingFocus = null; + this._focusNode(node); + } + } + // Print out the whole focus tree public toString(): string { const printNode = (node: FocusNode | RootNode, depth = 0): string => { @@ -503,6 +564,7 @@ export class FocusManager< traps, hasFocusableChildren: false, allowOffscreen, + focusCommitted: false, }; this.activeLayer.elements.set(element, node); @@ -538,6 +600,12 @@ export class FocusManager< this._checkFocusableChildren(currentNode.parent); this._recalculateFocusPath(); + + // A queued focus request may have been waiting on this element to + // become focusable. + if (isFocusable) { + this._tryFulfillPendingFocus(element); + } }), element.on('focusChanged', (_, isFocused) => { if (isFocused && !element.focused) { @@ -567,47 +635,82 @@ export class FocusManager< } } - private _focusNode(childNode: FocusNode, visitedRedirects?: Set) { - let currParent = childNode.parent; - let currChild: FocusNode | RootNode = childNode; - const elements = this.activeLayer.elements; + /** + * Forward focus to the first focusable destination of `node`, recursing + * through any further redirects. Returns true when focus was redirected (or + * the redirect was aborted on a missing node / cycle) and the caller should + * stop; false when there was no focusable destination and the caller should + * focus `node` normally. + */ + private _redirectToDestination(node: FocusNode, visitedRedirects?: Set): boolean { + // TODO: Probably something smarter here to decide which destination to focus + const destination = node.destinations?.find((child) => child?.focusable); - if (currChild.children.length && !currChild.focusedElement) { - currChild.focusedElement = this._findNextBestFocus(currChild); + if (!destination) { + return false; } - while (currChild && !isRootNode(currChild) && currParent) { - if (currChild.focusRedirect && currChild.destinations) { - // TODO: Probably something smarter here to decide which destination to focus - const destination = currChild.destinations?.find((child) => child?.focusable); + const focusNode = this.activeLayer.elements.get(destination); - if (destination) { - const focusNode = elements.get(destination); + if (!focusNode) { + console.warn('FocusManager: No focus node found for destination', destination); - if (!focusNode) { - console.warn('FocusManager: No focus node found for destination', destination); + return true; + } - return; - } + // Detect redirect cycles + const visited = visitedRedirects ?? new Set(); - // Detect redirect cycles - const visited = visitedRedirects ?? new Set(); + if (visited.has(destination)) { + console.warn('FocusManager: Focus redirect cycle detected, aborting'); - if (visited.has(destination)) { - console.warn('FocusManager: Focus redirect cycle detected, aborting'); + return true; + } - return; - } + visited.add(destination); - visited.add(destination); + this._focusNode(focusNode, visited); - this._focusNode(focusNode, visited); + return true; + } - return; - } + private _focusNode(childNode: FocusNode, visitedRedirects?: Set) { + // On arrival, forward to a declared destination. With focusRedirect this + // happens on every visit (a permanent redirect); without it, only on the + // first visit (no remembered child yet) — matching native + // TVFocusGuideView, which forwards focus on arrival then remembers the + // last-focused child for subsequent visits. + if ( + childNode.destinations && + (childNode.focusRedirect || !childNode.focusCommitted) && + this._redirectToDestination(childNode, visitedRedirects) + ) { + return; + } + + let currParent = childNode.parent; + let currChild: FocusNode | RootNode = childNode; + + if (currChild.children.length && !currChild.focusedElement) { + currChild.focusedElement = this._findNextBestFocus(currChild); + } + + // Focus has now explicitly arrived at this node, so mark its subtree as + // committed: a later-mounting autoFocus sibling must not steal it on + // registration (see addElement / focusCommitted). + childNode.focusCommitted = true; + + while (currChild && !isRootNode(currChild) && currParent) { + if ( + currChild.focusRedirect && + currChild.destinations && + this._redirectToDestination(currChild, visitedRedirects) + ) { + return; } currParent.focusedElement = currChild as FocusNode; + currParent.focusCommitted = true; currChild = currParent; currParent = 'parent' in currChild ? currChild.parent : this.activeLayer.root; } From f8f3bd7247afc054fe20d620dc2a5d23e8133257 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 22 Jun 2026 12:13:53 +0200 Subject: [PATCH 05/66] feat(vendor): getLayout ref API and skipChildFocusScroll opt-out --- .changeset/lightning-virtuallist-parity.md | 7 +++ .../src/components/VirtualList/VirtualList.md | 20 +++--- .../components/VirtualList/VirtualList.tsx | 16 ++++- .../VirtualList/VirtualListTypes.ts | 27 ++++++++ .../VirtualList/computeItemRect.spec.ts | 61 +++++++++++++++++++ .../components/VirtualList/computeItemRect.ts | 23 +++++++ .../src/components/VirtualList/index.ts | 1 + .../src/exports/lists/VirtualList.tsx | 1 + 8 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 .changeset/lightning-virtuallist-parity.md create mode 100644 packages/react-lightning-components/src/components/VirtualList/computeItemRect.spec.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/computeItemRect.ts diff --git a/.changeset/lightning-virtuallist-parity.md b/.changeset/lightning-virtuallist-parity.md new file mode 100644 index 00000000..697390ab --- /dev/null +++ b/.changeset/lightning-virtuallist-parity.md @@ -0,0 +1,7 @@ +--- +"@plextv/react-lightning-components": minor +--- + +feat(virtuallist): getLayout ref API and skipChildFocusScroll opt-out for FlashList parity + +`VirtualListRef` now exposes `getLayout(index)`, returning the scroll-space `{ x, y, width, height }` rectangle of an item (or `undefined` when out of range) — mirroring FlashList's per-item layout query for callers that interpolate row positions against the scroll offset (crossfade/parallax). A new `skipChildFocusScroll` prop opts out of VirtualList's internal focus-follow scroll: a focused child crossing a cell boundary still resolves and persists `focusedIndex`, but the list no longer scrolls the cell into view, letting the app layer own scrolling (e.g. a row that drives `scrollToIndex` from its own authoritative focused index). Both are additive — default behaviour is unchanged. diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.md b/packages/react-lightning-components/src/components/VirtualList/VirtualList.md index 52efd5b2..2ec833be 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.md +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.md @@ -32,11 +32,11 @@ The crucial discipline is that the cross-axis aggregation is **monotonic** (only **Three responsibilities:** -| File | Responsibility | -| - | - | -| `LayoutManager.ts` | Pure layout math. Given data + sizes + cross-axis size, computes per-item offsets in O(n). | +| File | Responsibility | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `LayoutManager.ts` | Pure layout math. Given data + sizes + cross-axis size, computes per-item offsets in O(n). | | `VirtualListCell.tsx` | One `` per visible cell with explicit absolute position and dimensions. Wraps user content in `VLCellKeyContext` + `CellBoundsContext` providers. The renderItem subtree persists across slot recycles — the cell wrapper _and_ its descendants survive userKey changes; nested VLs read the new userKey via `VLCellKeyContext` and run their cellKey-change branch instead of remounting. | -| `VirtualList.tsx` | Viewport derivation, scroll/focus state, recycling, the React glue. | +| `VirtualList.tsx` | Viewport derivation, scroll/focus state, recycling, the React glue. | Supporting modules: `useScrollHandler.ts` (scroll math, animation, focus-driven scroll), `useViewability.ts` (onViewableItemsChanged), `RecyclerPool.ts` (slot reuse by item type), `parseContentStyle.ts` (RN-style padding props), `VirtualListContext.ts` (the three React contexts). @@ -84,6 +84,7 @@ Supporting modules: `useScrollHandler.ts` (scroll math, animation, focus-driven - **`onLoad`** — fires once when first items render (with elapsed ms since mount). - **`onLayout`** — fires when content dimensions change. - **`autoFocus` / `trapFocus{Up,Right,Down,Left}`** — forwarded to the FocusGroup wrapping the list. +- **`skipChildFocusScroll?: boolean`** (default `false`) — opt out of VL's internal focus-follow scroll. When set, a focused child crossing a cell boundary still resolves and persists `focusedIndex`, but VL does not scroll the cell into view; the caller owns scrolling (e.g. drives `scrollToIndex` from its own authoritative focused index). Leaving VL's position-based follow on while the app also follows makes them fight — VL reads a just-recycled cell's not-yet-committed position as ~0 and snaps the row back to the start. ### Imperative — `VirtualListRef` @@ -92,6 +93,7 @@ Supporting modules: `useScrollHandler.ts` (scroll math, animation, focus-driven - `scrollToEnd({ animated? })` - `getScrollOffset()` - `getVisibleRange()` +- `getLayout(index)` — scroll-space `{ x, y, width, height }` of the item at `index` (or `undefined` if out of range). Mirrors FlashList's per-item layout query; for callers that interpolate row positions against the scroll offset (crossfade/parallax). Coordinates are in the content container's space: main axis past the leading padding + header, cross axis past the cross padding. --- @@ -186,7 +188,7 @@ For a list with no explicit cross AND no flex ancestor (pinned mode), no measure ref={cellElementRef} autoFocus={shouldFocus} style={{ - position: 'absolute', + position: "absolute", x, y, // Both axes pinned by VL — cell wrapper has NO flex of its own. @@ -200,13 +202,17 @@ For a list with no explicit cross AND no flex ancestor (pinned mode), no measure store) and cross-axis to onContentCrossLayout (VL maxContentCross). */ - {renderedItem} + + {renderedItem} + ) : ( /* plain content — no flex, no measurement */ - {renderedItem} + + {renderedItem} + )} {/* optional separator, position:absolute */} diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index e9f00d5c..881ba3ae 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -19,6 +19,7 @@ import { } from '@plextv/react-lightning'; import { FlexBoundary, useIsInFlex } from '@plextv/react-lightning-plugin-flexbox'; +import { computeItemRect } from './computeItemRect'; import { LayoutManager } from './LayoutManager'; import { parseContentStyle } from './parseContentStyle'; import { RecyclerPool } from './RecyclerPool'; @@ -85,6 +86,7 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef { if (skipNextFocus) { setSkipNextFocus(false); - handleChildFocused(child); + + if (!skipChildFocusScroll) { + handleChildFocused(child); + } return; } @@ -397,7 +402,9 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef= 0) { setFocusedIndex(resolvedIdx); @@ -461,6 +468,11 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef scrollToEnd(params?.animated), getScrollOffset: () => scrollOffsetRef.current, getVisibleRange: () => visibleRange, + getLayout: (index) => { + const layout = layoutManager.getLayout(index); + + return layout ? computeItemRect(layout, itemAreaOffset, paddingCross, horizontal) : undefined; + }, })); const loadTimeRef = useRef(Date.now()); diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts index 0efc8ac8..70b22c7c 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts @@ -142,6 +142,26 @@ export interface VirtualListProps { trapFocusRight?: boolean; trapFocusDown?: boolean; trapFocusLeft?: boolean; + + /** + * Opt out of VirtualList's internal focus-follow scroll. When a focused + * child crosses a cell boundary VL still resolves and persists the focused + * index, but does NOT scroll the focused cell into view — the caller owns + * scrolling (e.g. a row that drives `scrollToIndex` from its own + * authoritative focused index). Leaving VL's position-based follow on while + * the app also follows makes the two fight: VL reads a just-recycled cell's + * not-yet-committed position as ~0 and snaps the row back to the start. + * Default `false` (VL follows focus itself). + */ + skipChildFocusScroll?: boolean; +} + +/** Scroll-space rectangle of an item, in the list's content coordinate space. */ +export interface ItemLayout { + x: number; + y: number; + width: number; + height: number; } export interface VirtualListRef { @@ -155,6 +175,13 @@ export interface VirtualListRef { scrollToEnd: (params?: { animated?: boolean }) => void; getScrollOffset: () => number; getVisibleRange: () => { startIndex: number; endIndex: number }; + /** + * Scroll-space rectangle of the item at `index`, or `undefined` if the + * index is out of range. Mirrors FlashList's per-item layout query — used + * by callers that interpolate row positions against the scroll offset + * (e.g. crossfade/parallax effects). + */ + getLayout: (index: number) => ItemLayout | undefined; } export interface VirtualListCellProps { diff --git a/packages/react-lightning-components/src/components/VirtualList/computeItemRect.spec.ts b/packages/react-lightning-components/src/components/VirtualList/computeItemRect.spec.ts new file mode 100644 index 00000000..5d7910c2 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/computeItemRect.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { computeItemRect } from './computeItemRect'; +import type { ComputedLayout } from './LayoutManager'; + +const layout = (overrides: Partial = {}): ComputedLayout => ({ + offset: 0, + size: 0, + column: 0, + crossOffset: 0, + crossSize: 0, + ...overrides, +}); + +describe('computeItemRect', () => { + it('maps a vertical item: offset → y, crossOffset → x', () => { + const rect = computeItemRect( + layout({ offset: 300, size: 100, crossOffset: 20, crossSize: 400 }), + 50, // itemAreaOffset (paddingStart + header) + 10, // paddingCross + false, + ); + + expect(rect).toEqual({ x: 30, y: 350, width: 400, height: 100 }); + }); + + it('maps a horizontal item: offset → x, crossOffset → y', () => { + const rect = computeItemRect( + layout({ offset: 300, size: 100, crossOffset: 20, crossSize: 400 }), + 50, + 10, + true, + ); + + expect(rect).toEqual({ x: 350, y: 30, width: 100, height: 400 }); + }); + + it('includes the item area offset and cross padding in the origin', () => { + const rect = computeItemRect(layout({ offset: 0, size: 80, crossSize: 200 }), 120, 16, false); + + expect(rect.x).toBe(16); + expect(rect.y).toBe(120); + }); + + it('places a multi-column cell at its column cross offset', () => { + const rect = computeItemRect( + layout({ + offset: 100, + size: 100, + column: 1, + crossOffset: 200, + crossSize: 200, + }), + 0, + 0, + false, + ); + + expect(rect).toEqual({ x: 200, y: 100, width: 200, height: 100 }); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/computeItemRect.ts b/packages/react-lightning-components/src/components/VirtualList/computeItemRect.ts new file mode 100644 index 00000000..f57b7621 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/computeItemRect.ts @@ -0,0 +1,23 @@ +import type { ComputedLayout } from './LayoutManager'; +import type { ItemLayout } from './VirtualListTypes'; + +/** + * Translates a `LayoutManager` item-space layout into a scroll-space rect in + * the content container's coordinate system — the same mapping the rendered + * cells use (main axis shifted past the leading padding + header via + * `itemAreaOffset`, cross axis past the cross padding). Backs the + * `VirtualListRef.getLayout` imperative API. + */ +export function computeItemRect( + layout: ComputedLayout, + itemAreaOffset: number, + paddingCross: number, + horizontal: boolean | null | undefined, +): ItemLayout { + const main = itemAreaOffset + layout.offset; + const cross = paddingCross + layout.crossOffset; + + return horizontal + ? { x: main, y: cross, width: layout.size, height: layout.crossSize } + : { x: cross, y: main, width: layout.crossSize, height: layout.size }; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/index.ts b/packages/react-lightning-components/src/components/VirtualList/index.ts index cb0e1d29..fa81e59c 100644 --- a/packages/react-lightning-components/src/components/VirtualList/index.ts +++ b/packages/react-lightning-components/src/components/VirtualList/index.ts @@ -1,6 +1,7 @@ export { VirtualList } from './VirtualList'; export type { ContentStyle, + ItemLayout, OverrideItemLayout, OverrideItemLayoutFn, ScrollEvent, diff --git a/packages/react-lightning-components/src/exports/lists/VirtualList.tsx b/packages/react-lightning-components/src/exports/lists/VirtualList.tsx index 6a065460..6cf79bc6 100644 --- a/packages/react-lightning-components/src/exports/lists/VirtualList.tsx +++ b/packages/react-lightning-components/src/exports/lists/VirtualList.tsx @@ -1,5 +1,6 @@ export type { ContentStyle, + ItemLayout, OverrideItemLayout, OverrideItemLayoutFn, ScrollEvent, From 88b908e333dbe86c8a633301a211e5124a5cb0cc Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 22 Jun 2026 12:31:57 +0200 Subject: [PATCH 06/66] fix(vendor): flatten Image array styles and paint/clear border shaders on live nodes --- .changeset/lightning-image-border-paint.md | 12 ++++++ .../src/element/LightningViewElement.spec.ts | 39 +++++++++++++++++++ .../src/element/LightningViewElement.ts | 31 ++++++++++++--- .../src/exports/Image.tsx | 6 ++- 4 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 .changeset/lightning-image-border-paint.md diff --git a/.changeset/lightning-image-border-paint.md b/.changeset/lightning-image-border-paint.md new file mode 100644 index 00000000..9a94f7e8 --- /dev/null +++ b/.changeset/lightning-image-border-paint.md @@ -0,0 +1,12 @@ +--- +"@plextv/react-lightning": minor +"@plextv/react-native-lightning": patch +--- + +fix(image+border): flatten array styles on Image and paint/clear border shaders on live nodes + +The RN `Image` component built its node style with an object spread (`{ ...style, w, h }`), so an RN style array (`style={[a, b]}`) became numeric-keyed garbage and its `width`/`height`/`borderRadius` were silently dropped (the array-flatten polyfill only ran when the style reached `setProps` still an array). `Image` now flattens with `flattenStyles` before spreading. + +Border shaders can now be toggled on an already-mounted node. `border` and `borderColor` were missing from the set of style props that force the shader-creating slow path, so toggling a plain border (e.g. a focus ring) fast-pathed straight onto the node and never created a `Border` shader. A node that already carries a shader now always takes the slow path, and removing the border clears the shader (resetting the node to the stage default) instead of leaving it painting. + +Updating an existing shader's props in place now keys off whether the prop exists, not whether its current value is truthy. Previously a prop whose current value was falsy (e.g. a transparent `border-color` of `0`) was skipped, so toggling a focus-ring border from transparent to a visible color on a mounted node was silently dropped and the ring never appeared. diff --git a/packages/react-lightning/src/element/LightningViewElement.spec.ts b/packages/react-lightning/src/element/LightningViewElement.spec.ts index ceedf0a4..5368c976 100644 --- a/packages/react-lightning/src/element/LightningViewElement.spec.ts +++ b/packages/react-lightning/src/element/LightningViewElement.spec.ts @@ -134,3 +134,42 @@ describe('LightningViewElement paint withholding', () => { expect(el.hasLayout).toBe(false); }); }); + +describe('LightningViewElement border shader', () => { + // A renderer whose createShader returns a tagged shader so we can assert the + // node actually received it. + const borderShader = { props: {}, type: 'Border' }; + const shaderRenderer = { + createNode: (props: Record) => createMockNode(props), + createTextNode: (props: Record) => createMockNode(props), + createShader: () => borderShader, + createTexture: () => ({}), + destroyNode() {}, + } as unknown as RendererMain; + + function createShaderElement(style: Partial) { + const props = { + style, + } as LightningViewElementProps; + + return new LightningViewElement(props, shaderRenderer, [], {} as Fiber); + } + + it('paints a border shader when one is added to an already-mounted node, then clears it on removal', async () => { + // Starts with no border — the focus-ring case toggles it on later. + const el = createShaderElement({ w: 100, h: 50 }); + + // Add a border (e.g. a focus ring). Without `border` forcing the slow path + // this would silently fast-path and never create a shader. + el.setProps({ + style: { w: 100, h: 50, border: { w: 4, color: 0xffffffff } }, + }); + await flush(); + expect(el.node.shader).toBe(borderShader); + + // Remove the border (blur). The shader must be cleared, not left painting. + el.setProps({ style: { w: 100, h: 50 } }); + await flush(); + expect(el.node.shader).toBeNull(); + }); +}); diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index dff4a316..c6e21670 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -163,11 +163,9 @@ export class LightningViewElement< } public set shader(shader: INode['shader'] | null) { - if (shader === null) { - // TODO: Unset shader? - } else { - this.node.shader = shader; - } + // A null shader resets the node to the stage's default shader (CoreNode + // handles the null case), letting callers clear a previously-set shader. + this.node.shader = shader as INode['shader']; } public get parent(): LightningElement | null { @@ -865,6 +863,8 @@ export class LightningViewElement< /** Style properties that may trigger shader creation — must use the slow path. */ private static readonly _shaderStyleProps = new Set([ + 'border', + 'borderColor', 'borderRadius', 'borderTop', 'borderLeft', @@ -882,6 +882,14 @@ export class LightningViewElement< return false; } + // A node that currently has a shader must take the slow path: the update + // may remove the border/radius that produced it, and only the slow path + // recomputes (or clears) the shader. The fast path assigns style keys to + // the node verbatim and would leave a stale shader painting. + if (this._shaderDef) { + return false; + } + for (const key in payload) { if (key !== 'style') { return false; @@ -1171,7 +1179,12 @@ export class LightningViewElement< this.animateShader(this._shaderDef.props); } else if (this._shaderDef.type === oldShader?.type && this.shader.props) { for (const [key, value] of Object.entries(this._shaderDef.props)) { - if (this.shader.props[key]) { + // Gate on key existence, not truthiness: a prop whose current value + // is falsy (e.g. a transparent `border-color` of 0) must still be + // updatable — otherwise toggling a focus-ring border from + // transparent to a visible color on an already-mounted node is + // silently dropped and the ring never appears. + if (key in this.shader.props) { this.shader.props[key] = value; } } @@ -1181,6 +1194,12 @@ export class LightningViewElement< this._shaderDef.props, ); } + } else if (oldShader) { + // The node had a style/explicit shader (e.g. a focus-ring border) and now + // has none — clear it so the previous shader stops painting. Setting the + // node's shader to null resets it to the stage's default shader. Without + // this a removed border would linger on an already-mounted node. + (finalStyle as { shader: INode['shader'] | null }).shader = null; } if (texture && texture !== this._textureDef) { diff --git a/packages/react-native-lightning/src/exports/Image.tsx b/packages/react-native-lightning/src/exports/Image.tsx index 4fa9cfb6..fb6775c6 100644 --- a/packages/react-native-lightning/src/exports/Image.tsx +++ b/packages/react-native-lightning/src/exports/Image.tsx @@ -7,6 +7,7 @@ import type { } from 'react-native'; import type { LightningElementStyle, LightningImageElement } from '@plextv/react-lightning'; +import { flattenStyles } from '@plextv/react-lightning-plugin-css-transform'; import { useImageLoadedHandler } from '../hooks/useImageLoadedHandler'; import { useLayoutHandler } from '../hooks/useLayoutHandler'; @@ -48,7 +49,10 @@ export const Image: ForwardRefExoticComponent = forwardRef< ref={ref} src={finalSource} style={{ - ...(style as LightningElementStyle), + // RN allows array styles, but the lightning Image node takes a single + // plain object: spreading an array here would produce numeric-keyed + // garbage and silently drop width/height/borderRadius. Flatten first. + ...(flattenStyles(style) as LightningElementStyle), w: width, h: height, }} From e1b812a11a75d67b31cd8861d2564323b78984cc Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 22 Jun 2026 12:41:17 +0200 Subject: [PATCH 07/66] fix(vendor): normalize key events and emit held-key auto-repeat --- .changeset/lightning-input-events.md | 9 +++ .../src/input/KeyPressHandler.tsx | 60 +++++++-------- .../src/input/bubbleEvent.spec.ts | 76 +++++++++++++++++++ .../react-lightning/src/input/bubbleEvent.tsx | 2 +- .../src/input/normalizeKeyEvent.spec.ts | 63 +++++++++++++++ .../src/input/normalizeKeyEvent.ts | 47 ++++++++++++ .../react-lightning/src/types/KeyEvent.ts | 1 + 7 files changed, 223 insertions(+), 35 deletions(-) create mode 100644 .changeset/lightning-input-events.md create mode 100644 packages/react-lightning/src/input/bubbleEvent.spec.ts create mode 100644 packages/react-lightning/src/input/normalizeKeyEvent.spec.ts create mode 100644 packages/react-lightning/src/input/normalizeKeyEvent.ts diff --git a/.changeset/lightning-input-events.md b/.changeset/lightning-input-events.md new file mode 100644 index 00000000..aad764ba --- /dev/null +++ b/.changeset/lightning-input-events.md @@ -0,0 +1,9 @@ +--- +"@plextv/react-lightning": minor +--- + +fix(input): normalize key events and stop swallowing held-key auto-repeat + +The key pipeline no longer drops OS auto-repeat events. Holding a directional key now keeps bubbling `onKeyDown` events (with `repeat: true`) through the focus tree, so held keys keep navigating and handlers can implement held-key/long-press behavior without re-deriving repeats from raw DOM listeners. The long-press duration is now measured from the initial press (the press timestamp is no longer reset by each repeat), so a held key still resolves to `onLongPress` on release. + +Key events are also normalized into a consistent shape via a new `normalizeKeyEvent` helper: `keyCode` maps to `remoteKey` (falling back to `Keys.Unknown`), `repeat` is preserved, and `preventDefault` is now bound — previously the raw DOM method was copied unbound, so calling `event.preventDefault()` from a handler threw "Illegal invocation". `currentTarget` is now part of the `KeyEvent` type rather than bolted on during bubbling. diff --git a/packages/react-lightning/src/input/KeyPressHandler.tsx b/packages/react-lightning/src/input/KeyPressHandler.tsx index d5b45cb6..4f635c76 100644 --- a/packages/react-lightning/src/input/KeyPressHandler.tsx +++ b/packages/react-lightning/src/input/KeyPressHandler.tsx @@ -6,6 +6,7 @@ import { bubbleEvent } from './bubbleEvent'; import type { KeyMap } from './KeyMapContext'; import { KeyMapContext } from './KeyMapContext'; import { Keys } from './Keys'; +import { normalizeKeyEvent } from './normalizeKeyEvent'; const LONG_PRESS_THRESHOLD = 500; @@ -16,51 +17,42 @@ export const KeyPressHandler: FC<{ children: ReactNode }> = ({ children }) => { const createKeyHandler = (handler: 'onKeyDown' | 'onKeyUp', keyMap: KeyMap) => { return (event: KeyboardEvent) => { - if (event.repeat) { - return; - } - const element = focusManager.focusPath.at(-1); - if (!element) { + if (!element || !(event instanceof KeyboardEvent)) { return; } - if (event instanceof KeyboardEvent) { - const remoteKey = keyMap[event.keyCode] ?? Keys.Unknown; - - // Build the event object once and reuse for all bubbleEvent calls - const keyEvent = { - keyCode: event.keyCode, - key: event.key, - code: event.code, - remoteKey, - repeat: event.repeat, - target: element, - currentTarget: element, - stopFocusHandling: false, - preventDefault: event.preventDefault, - }; - - if (handler === 'onKeyDown') { + // Build the normalized event once and reuse for all bubbleEvent calls. + const keyEvent = normalizeKeyEvent(event, keyMap, element); + const { remoteKey } = keyEvent; + + if (handler === 'onKeyDown') { + // Stamp the press time only on the initial press — not on the OS + // auto-repeats that follow while a key is held. Otherwise the + // long-press duration measured at key-up would reset to ~0 on every + // repeat and a held key would never register as a long press. The + // repeats still bubble below, so held directional keys keep navigating + // and handlers can read `repeat` to drive held-key behavior. + if (!event.repeat) { keyDownTime.current = event.timeStamp; - } else if (handler === 'onKeyUp') { - const duration = event.timeStamp - keyDownTime.current; + } + } else if (handler === 'onKeyUp') { + const duration = event.timeStamp - keyDownTime.current; - keyDownTime.current = 0; + keyDownTime.current = 0; - bubbleEvent(duration > LONG_PRESS_THRESHOLD ? 'onLongPress' : 'onKeyPress', keyEvent); + bubbleEvent(duration > LONG_PRESS_THRESHOLD ? 'onLongPress' : 'onKeyPress', keyEvent); - // Reset stopFocusHandling for the next bubbleEvent call - keyEvent.stopFocusHandling = false; - } + // Reset stopFocusHandling for the next bubbleEvent call + keyEvent.stopFocusHandling = false; + } - bubbleEvent(handler, keyEvent); + bubbleEvent(handler, keyEvent); - if (remoteKey !== Keys.Unknown) { - event.stopPropagation(); - event.preventDefault(); - } + if (remoteKey !== Keys.Unknown) { + event.stopPropagation(); + event.preventDefault(); } }; }; diff --git a/packages/react-lightning/src/input/bubbleEvent.spec.ts b/packages/react-lightning/src/input/bubbleEvent.spec.ts new file mode 100644 index 00000000..c07dd92a --- /dev/null +++ b/packages/react-lightning/src/input/bubbleEvent.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { KeyEvent, LightningElement } from '../types'; +import { bubbleEvent } from './bubbleEvent'; +import { Keys } from './Keys'; + +type Handlers = Partial< + Record< + 'onKeyDown' | 'onKeyUp' | 'onKeyPress' | 'onLongPress', + (event: KeyEvent) => boolean | undefined + > +>; + +function makeElement(props: Handlers, parent: LightningElement | null = null): LightningElement { + return { props, parent } as unknown as LightningElement; +} + +function keyEvent(target: LightningElement): KeyEvent { + return { + key: 'ArrowRight', + code: 'ArrowRight', + keyCode: 39, + remoteKey: Keys.Right, + repeat: false, + target, + currentTarget: target, + stopFocusHandling: false, + preventDefault: vi.fn(), + }; +} + +describe('bubbleEvent', () => { + it('bubbles from target up through the parent chain', () => { + const order: string[] = []; + const root = makeElement({ onKeyDown: () => void order.push('root') }); + const mid = makeElement({ onKeyDown: () => void order.push('mid') }, root); + const leaf = makeElement({ onKeyDown: () => void order.push('leaf') }, mid); + + bubbleEvent('onKeyDown', keyEvent(leaf)); + + expect(order).toEqual(['leaf', 'mid', 'root']); + }); + + it('stops bubbling when a handler returns false', () => { + const rootHandler = vi.fn(); + const root = makeElement({ onKeyDown: rootHandler }); + const leaf = makeElement({ onKeyDown: () => false }, root); + + bubbleEvent('onKeyDown', keyEvent(leaf)); + + expect(rootHandler).not.toHaveBeenCalled(); + }); + + it('updates currentTarget to the element handling the event', () => { + const seen: Array = []; + const root = makeElement({ + onKeyDown: (e) => void seen.push(e.currentTarget), + }); + const leaf = makeElement({ onKeyDown: (e) => void seen.push(e.currentTarget) }, root); + + bubbleEvent('onKeyDown', keyEvent(leaf)); + + expect(seen).toEqual([leaf, root]); + }); + + it('skips elements without a matching handler and keeps bubbling', () => { + const rootHandler = vi.fn(); + const root = makeElement({ onKeyDown: rootHandler }); + const mid = makeElement({}, root); + const leaf = makeElement({}, mid); + + bubbleEvent('onKeyDown', keyEvent(leaf)); + + expect(rootHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-lightning/src/input/bubbleEvent.tsx b/packages/react-lightning/src/input/bubbleEvent.tsx index 57e4f37c..33526639 100644 --- a/packages/react-lightning/src/input/bubbleEvent.tsx +++ b/packages/react-lightning/src/input/bubbleEvent.tsx @@ -2,7 +2,7 @@ import type { KeyEvent, LightningElement } from '../types'; type BubbleEventFn = ( handler: 'onKeyUp' | 'onKeyDown' | 'onKeyPress' | 'onLongPress', - event: KeyEvent & { currentTarget: LightningElement }, + event: KeyEvent, ) => void; export const bubbleEvent: BubbleEventFn = (handler, event) => { let element: LightningElement | undefined | null = event.target; diff --git a/packages/react-lightning/src/input/normalizeKeyEvent.spec.ts b/packages/react-lightning/src/input/normalizeKeyEvent.spec.ts new file mode 100644 index 00000000..e9176f0a --- /dev/null +++ b/packages/react-lightning/src/input/normalizeKeyEvent.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { LightningElement } from '../types'; +import type { KeyMap } from './KeyMapContext'; +import { Keys } from './Keys'; +import { normalizeKeyEvent, type RawKeyEvent } from './normalizeKeyEvent'; + +const element = { id: 1 } as unknown as LightningElement; + +const keyMap: KeyMap = { + 37: Keys.Left, + 38: Keys.Up, + 39: Keys.Right, + 40: Keys.Down, + 13: Keys.Enter, +}; + +function rawEvent(overrides: Partial = {}): RawKeyEvent { + return { + key: 'ArrowRight', + code: 'ArrowRight', + keyCode: 39, + repeat: false, + preventDefault: vi.fn(), + ...overrides, + }; +} + +describe('normalizeKeyEvent', () => { + it('maps the keyCode to a remoteKey via the key map', () => { + const result = normalizeKeyEvent(rawEvent({ keyCode: 38 }), keyMap, element); + + expect(result.remoteKey).toBe(Keys.Up); + }); + + it('falls back to Keys.Unknown for an unmapped keyCode', () => { + const result = normalizeKeyEvent(rawEvent({ keyCode: 999 }), keyMap, element); + + expect(result.remoteKey).toBe(Keys.Unknown); + }); + + it('preserves the held-key repeat flag', () => { + expect(normalizeKeyEvent(rawEvent({ repeat: true }), keyMap, element).repeat).toBe(true); + expect(normalizeKeyEvent(rawEvent({ repeat: false }), keyMap, element).repeat).toBe(false); + }); + + it('sets target and currentTarget to the focused element and defaults stopFocusHandling', () => { + const result = normalizeKeyEvent(rawEvent(), keyMap, element); + + expect(result.target).toBe(element); + expect(result.currentTarget).toBe(element); + expect(result.stopFocusHandling).toBe(false); + }); + + it('exposes a bound preventDefault that calls through without an illegal-invocation error', () => { + const preventDefault = vi.fn(); + const result = normalizeKeyEvent(rawEvent({ preventDefault }), keyMap, element); + + // A copied (unbound) DOM method would throw "Illegal invocation" here. + expect(() => result.preventDefault()).not.toThrow(); + expect(preventDefault).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-lightning/src/input/normalizeKeyEvent.ts b/packages/react-lightning/src/input/normalizeKeyEvent.ts new file mode 100644 index 00000000..beb7a8a5 --- /dev/null +++ b/packages/react-lightning/src/input/normalizeKeyEvent.ts @@ -0,0 +1,47 @@ +import type { LightningElement } from '../types'; +import type { KeyEvent } from '../types/KeyEvent'; +import type { KeyMap } from './KeyMapContext'; +import { Keys } from './Keys'; + +/** + * The slice of a DOM {@link KeyboardEvent} the key pipeline needs. Accepting a + * structural subset (rather than `KeyboardEvent`) lets synthesized remote events + * flow through the exact same normalization as real keyboard input. + */ +export type RawKeyEvent = Pick & { + preventDefault: () => void; +}; + +/** + * Builds a normalized {@link KeyEvent} from a raw DOM key event. + * + * Centralizes the three things the framework was previously doing + * inconsistently (or wrong) at the call site: + * + * - **keyCode → remoteKey** via the active {@link KeyMap}, falling back to + * {@link Keys.Unknown} so every event carries a defined `remoteKey`. + * - **held-key `repeat`** is preserved verbatim so downstream handlers can tell + * an OS auto-repeat from a fresh press (the basis for long-press / held-key + * navigation) instead of the repeats being dropped on the floor. + * - **a bound `preventDefault`** — the DOM method must run with its event as + * `this`, so copying the reference (`preventDefault: domEvent.preventDefault`) + * throws "Illegal invocation" the moment a handler calls it. Wrapping it in a + * closure keeps the normalized event self-contained and safe to invoke. + */ +export function normalizeKeyEvent( + domEvent: RawKeyEvent, + keyMap: KeyMap, + element: LightningElement, +): KeyEvent { + return { + key: domEvent.key, + code: domEvent.code, + keyCode: domEvent.keyCode, + remoteKey: keyMap[domEvent.keyCode] ?? Keys.Unknown, + repeat: domEvent.repeat, + target: element, + currentTarget: element, + stopFocusHandling: false, + preventDefault: () => domEvent.preventDefault(), + }; +} diff --git a/packages/react-lightning/src/types/KeyEvent.ts b/packages/react-lightning/src/types/KeyEvent.ts index 05179178..8cacf7eb 100644 --- a/packages/react-lightning/src/types/KeyEvent.ts +++ b/packages/react-lightning/src/types/KeyEvent.ts @@ -7,6 +7,7 @@ export type KeyEvent = { keyCode: number; remoteKey: Keys | Keys[]; target: LightningElement; + currentTarget: LightningElement; repeat: boolean; stopFocusHandling: boolean; From 64edb2496db33315c3232b1920609e018d65dd45 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 22 Jun 2026 12:59:46 +0200 Subject: [PATCH 08/66] fix(vendor): parse string aspectRatio values so ratio-sized nodes get a box --- .changeset/lightning-aspect-ratio.md | 7 ++++ .../plugin-flexbox/src/types/FlexStyles.ts | 5 ++- .../src/util/applyReactPropsToYoga.ts | 13 ++++++-- .../src/util/parseAspectRatio.spec.ts | 30 +++++++++++++++++ .../src/util/parseAspectRatio.ts | 33 +++++++++++++++++++ 5 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 .changeset/lightning-aspect-ratio.md create mode 100644 packages/plugin-flexbox/src/util/parseAspectRatio.spec.ts create mode 100644 packages/plugin-flexbox/src/util/parseAspectRatio.ts diff --git a/.changeset/lightning-aspect-ratio.md b/.changeset/lightning-aspect-ratio.md new file mode 100644 index 00000000..da0ac551 --- /dev/null +++ b/.changeset/lightning-aspect-ratio.md @@ -0,0 +1,7 @@ +--- +"@plextv/react-lightning-plugin-flexbox": patch +--- + +fix(flexbox): parse string `aspectRatio` values so ratio-sized nodes get a box + +React Native accepts `aspectRatio` as a number (`1.5`), a ratio string (`'3/2'`), or a numeric string (`'1.5'`), but the value was passed straight to Yoga's `setAspectRatio`, which only takes a number. String forms became `NaN` and the ratio was silently dropped — so a node sized only by `aspectRatio` plus one dimension (e.g. an image with `aspectRatio: '3/2'` and `height: '65%'` but no width) resolved to zero width and never painted. String ratios are now parsed to a number before being applied. diff --git a/packages/plugin-flexbox/src/types/FlexStyles.ts b/packages/plugin-flexbox/src/types/FlexStyles.ts index 3bc5ebbb..cfa20dce 100644 --- a/packages/plugin-flexbox/src/types/FlexStyles.ts +++ b/packages/plugin-flexbox/src/types/FlexStyles.ts @@ -53,7 +53,10 @@ export type FlexLightningBaseElementStyle = { paddingHorizontal?: DimensionValue; paddingVertical?: DimensionValue; - aspectRatio?: number; + // RN accepts a number (`1.5`), a ratio string (`'3/2'`), or a numeric string + // (`'1.5'`); Yoga only takes a number, so the string forms are parsed before + // being applied. + aspectRatio?: number | string; maxHeight?: number; maxWidth?: number; minHeight?: DimensionValue; diff --git a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts index c0f4b4f4..04adb316 100644 --- a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts +++ b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts @@ -16,6 +16,7 @@ import type { AutoDimensionValue, Transform } from '../types/FlexStyles'; import type { ManagerNode } from '../types/ManagerNode'; import type { FlexProps } from './isFlexStyleProp'; import { isFlexStyleProp } from './isFlexStyleProp'; +import { parseAspectRatio } from './parseAspectRatio'; import { parseFlexValue } from './parseFlexValue'; function mapDisplay(yoga: Yoga, value?: 'flex' | 'none'): Display { @@ -219,9 +220,17 @@ export function applyFlexPropToYoga( case 'maxHeight': node.setMaxHeight(formatSizeValue<'maxHeight'>(value)); return true; - case 'aspectRatio': - node.setAspectRatio(value as LightningViewElementStyle['aspectRatio']); + case 'aspectRatio': { + const ratio = parseAspectRatio( + value as NonNullable, + ); + + if (ratio != null) { + node.setAspectRatio(ratio); + } + return true; + } case 'margin': node.setMargin(yoga.EDGE_ALL, value as LightningViewElementStyle['margin']); return true; diff --git a/packages/plugin-flexbox/src/util/parseAspectRatio.spec.ts b/packages/plugin-flexbox/src/util/parseAspectRatio.spec.ts new file mode 100644 index 00000000..81791643 --- /dev/null +++ b/packages/plugin-flexbox/src/util/parseAspectRatio.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { parseAspectRatio } from './parseAspectRatio'; + +describe('parseAspectRatio', () => { + it('passes through a positive finite number', () => { + expect(parseAspectRatio(1.5)).toBe(1.5); + }); + + it('parses a ratio string into a number', () => { + expect(parseAspectRatio('3/2')).toBe(1.5); + expect(parseAspectRatio('16/9')).toBeCloseTo(16 / 9); + }); + + it('parses a plain numeric string', () => { + expect(parseAspectRatio('1.5')).toBe(1.5); + }); + + it('returns undefined for non-positive or non-finite values', () => { + expect(parseAspectRatio(0)).toBeUndefined(); + expect(parseAspectRatio(-2)).toBeUndefined(); + expect(parseAspectRatio(Number.NaN)).toBeUndefined(); + }); + + it('returns undefined for malformed strings', () => { + expect(parseAspectRatio('abc')).toBeUndefined(); + expect(parseAspectRatio('3/0')).toBeUndefined(); + expect(parseAspectRatio('/2')).toBeUndefined(); + }); +}); diff --git a/packages/plugin-flexbox/src/util/parseAspectRatio.ts b/packages/plugin-flexbox/src/util/parseAspectRatio.ts new file mode 100644 index 00000000..435be59b --- /dev/null +++ b/packages/plugin-flexbox/src/util/parseAspectRatio.ts @@ -0,0 +1,33 @@ +/** + * Normalizes a React Native `aspectRatio` value to the plain number Yoga + * expects. RN accepts a number (`1.5`), a ratio string (`'3/2'`), or a numeric + * string (`'1.5'`); Yoga's `setAspectRatio` only takes a number, so passing a + * string straight through yields `NaN` and the ratio is silently dropped — + * leaving e.g. an image sized only by `aspectRatio` + a height with no width, + * so it never paints. + * + * Returns `undefined` for values that don't describe a positive, finite ratio, + * so callers can skip applying it rather than feeding Yoga a bad number. + */ +export function parseAspectRatio(value: number | string): number | undefined { + if (typeof value === 'number') { + return Number.isFinite(value) && value > 0 ? value : undefined; + } + + const slash = value.indexOf('/'); + + if (slash !== -1) { + const width = Number.parseFloat(value.slice(0, slash)); + const height = Number.parseFloat(value.slice(slash + 1)); + + if (Number.isFinite(width) && Number.isFinite(height) && height > 0 && width > 0) { + return width / height; + } + + return undefined; + } + + const ratio = Number.parseFloat(value); + + return Number.isFinite(ratio) && ratio > 0 ? ratio : undefined; +} From 80bb5832e0d2ec93df45416f219172d2a5a0c9ab Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 6 Jul 2026 17:52:26 +0200 Subject: [PATCH 09/66] fix(vendor): accept handler array in useComposedEventHandler --- .../src/exports/useComposedEventHandler.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/plugin-reanimated/src/exports/useComposedEventHandler.ts b/packages/plugin-reanimated/src/exports/useComposedEventHandler.ts index b760eaa0..ef30eb11 100644 --- a/packages/plugin-reanimated/src/exports/useComposedEventHandler.ts +++ b/packages/plugin-reanimated/src/exports/useComposedEventHandler.ts @@ -1,10 +1,15 @@ // oxlint-disable typescript/no-explicit-any -- Valid use of any here type EventHandler = (...args: any[]) => void; -export function useComposedEventHandler(...handlers: EventHandler[]) { +// Mirrors reanimated's public API: a single array of handlers, not rest args. +export function useComposedEventHandler( + handlers: (EventHandler | null | undefined)[], +) { return (...args: any[]): void => { for (const handler of handlers) { - handler(...args); + if (typeof handler === 'function') { + handler(...args); + } } }; } From 08143e26754cedd34066b2c5e8e8d1c92ed3532f Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 6 Jul 2026 18:24:43 +0200 Subject: [PATCH 10/66] fix(vendor): expose focused state to function children --- .../lightning-pressable-focused-state.md | 9 ++++ .../src/exports/Pressable.tsx | 49 +++++++++++++------ 2 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 .changeset/lightning-pressable-focused-state.md diff --git a/.changeset/lightning-pressable-focused-state.md b/.changeset/lightning-pressable-focused-state.md new file mode 100644 index 00000000..76c18b2b --- /dev/null +++ b/.changeset/lightning-pressable-focused-state.md @@ -0,0 +1,9 @@ +--- +'@plextv/react-native-lightning': patch +--- + +fix(pressable): expose `focused` to function children + +`Pressable` tracked only `{ pressed }` in state and passed that to its style/children render functions, so `focused` was always `undefined`. Every focus-driven visual built on RN's `({ focused }) => …` contract — focus rings, focus scale — was dead on Lightning. It also only wired `onFocus`/`onBlur` to the node when the consumer passed those callbacks, so a focusable with no listeners tracked nothing. + +`Pressable` now tracks `focused` in state, updates it on focus/blur regardless of whether the consumer passes `onFocus`/`onBlur` (still forwarding to them), and passes `{ focused, pressed }` to its function children — matching React Native. The `pressed` setters no longer replace the whole state object, so a keypress can't clobber `focused`. diff --git a/packages/react-native-lightning/src/exports/Pressable.tsx b/packages/react-native-lightning/src/exports/Pressable.tsx index 5bcda766..c3ebee94 100644 --- a/packages/react-native-lightning/src/exports/Pressable.tsx +++ b/packages/react-native-lightning/src/exports/Pressable.tsx @@ -1,18 +1,22 @@ import type { ForwardRefExoticComponent, RefAttributes } from 'react'; import { useState } from 'react'; import type { PressableProps as RNPressableProps } from 'react-native'; - import type { KeyEvent } from '@plextv/react-lightning'; -import { focusable, Keys, type LightningViewElement } from '@plextv/react-lightning'; - +import { + Keys, + type LightningViewElement, + focusable, +} from '@plextv/react-lightning'; import { useBlurHandler, useFocusHandler } from '../hooks/useFocusHandler'; import { useLayoutHandler } from '../hooks/useLayoutHandler'; import { createGestureResponderEvent } from '../utils/createGestureResponderEvent'; import { View, type ViewProps } from './View'; -export type PressableProps = RNPressableProps & RefAttributes; +export type PressableProps = RefAttributes & RNPressableProps; -function useEnterKeyHandler(handler: (e: KeyEvent) => void): (e: KeyEvent) => boolean { +function useEnterKeyHandler( + handler: (e: KeyEvent) => void, +): (e: KeyEvent) => boolean { return (e) => { if (e.remoteKey === Keys.Enter) { handler(e); @@ -44,20 +48,37 @@ export const Pressable: ForwardRefExoticComponent = focusable< }, ref, ) { - const [state, setState] = useState({ pressed: false }); + const [state, setState] = useState({ focused: false, pressed: false }); - const handleFocus = useFocusHandler(onFocus); - const handleBlur = useBlurHandler(onBlur); + const forwardFocus = useFocusHandler(onFocus); + const forwardBlur = useBlurHandler(onBlur); const handleLayout = useLayoutHandler(onLayout); + // RN's Pressable exposes `focused` to its function children; mirror that by + // tracking it locally so focus-driven visuals (rings, scale) react. Wire the + // handlers unconditionally — consumer callbacks are optional and forwarded. + const handleFocus = ( + element: Parameters>[0], + ) => { + setState((s) => ({ ...s, focused: true })); + forwardFocus?.(element); + }; + + const handleBlur = ( + element: Parameters>[0], + ) => { + setState((s) => ({ ...s, focused: false })); + forwardBlur?.(element); + }; + const handleKeyDown = useEnterKeyHandler((e) => { onPressIn?.(createGestureResponderEvent(e, ref)); - setState({ pressed: true }); + setState((s) => ({ ...s, pressed: true })); }); const handleKeyUp = useEnterKeyHandler((e) => { onPressOut?.(createGestureResponderEvent(e, ref)); - setState({ pressed: false }); + setState((s) => ({ ...s, pressed: false })); }); const handleKeyPress = useEnterKeyHandler((e) => { @@ -75,13 +96,13 @@ export const Pressable: ForwardRefExoticComponent = focusable< ref={ref} style={finalStyle as ViewProps['style']} {...props} + onBlur={handleBlur} + onFocus={handleFocus} onKeyDown={handleKeyDown} - onKeyUp={handleKeyUp} onKeyPress={handleKeyPress} - onLongPress={handleLongPress} + onKeyUp={handleKeyUp} onLayout={handleLayout} - onFocus={handleFocus} - onBlur={handleBlur} + onLongPress={handleLongPress} > {typeof children === 'function' ? children(state) : children} From 40de3a0b7c4aab1cc7e625afdac4b4081fe7db73 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 7 Jul 2026 22:22:32 +0200 Subject: [PATCH 11/66] fix(vendor): resolve font atlas urls before they reach the inlined worker --- .changeset/lightning-worker-font-url.md | 7 +++ .../plugin-flexbox/src/YogaManagerWorker.ts | 24 +++++++++- .../src/text/resolveAtlasUrl.test.ts | 44 +++++++++++++++++++ .../src/text/resolveAtlasUrl.ts | 26 +++++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 .changeset/lightning-worker-font-url.md create mode 100644 packages/plugin-flexbox/src/text/resolveAtlasUrl.test.ts create mode 100644 packages/plugin-flexbox/src/text/resolveAtlasUrl.ts diff --git a/.changeset/lightning-worker-font-url.md b/.changeset/lightning-worker-font-url.md new file mode 100644 index 00000000..b8b548c6 --- /dev/null +++ b/.changeset/lightning-worker-font-url.md @@ -0,0 +1,7 @@ +--- +'@plextv/react-lightning-plugin-flexbox': patch +--- + +fix(flexbox): resolve font atlas URLs before they cross into the Yoga worker + +The Yoga worker is bundled inline (`?worker&inline`), so in a production build its `self.location` is a `blob:` URL. A root-relative atlas URL like `/fonts/x.msdf.json` (what `import.meta.env.BASE_URL` produces) can't resolve against a blob base, so the worker's `fetch` threw "is not a valid URL" and font metrics never loaded — text fell back to single-line, unmeasured layout. It only reproduced in built apps; the dev server serves the worker as a real module, so root-relative URLs resolved fine. Atlas URLs are now resolved to absolute against the document URL on the main thread, before the options cross `postMessage`. diff --git a/packages/plugin-flexbox/src/YogaManagerWorker.ts b/packages/plugin-flexbox/src/YogaManagerWorker.ts index 301a06b8..8acf4f2f 100644 --- a/packages/plugin-flexbox/src/YogaManagerWorker.ts +++ b/packages/plugin-flexbox/src/YogaManagerWorker.ts @@ -2,7 +2,9 @@ import { EventEmitter } from 'tseep'; import type { LightningElementStyle } from '@plextv/react-lightning'; +import { resolveAtlasUrl } from './text/resolveAtlasUrl'; import { NodeOperations } from './types/NodeOperations'; +import type { YogaOptions } from './types/YogaOptions'; import { isFlexStyleProp } from './util/isFlexStyleProp'; import { SimpleDataView } from './util/SimpleDataView'; import { toSerializableValue } from './util/toSerializableValue'; @@ -366,12 +368,32 @@ function wrapWorker(worker: Worker): Workerized { flushChildOperations(); worker.postMessage({ method: 'clearTextMeasure', args: [elementId] }); }, - init: (yogaOptions?: unknown) => _awaitable('init', [yogaOptions]), + init: (yogaOptions?: unknown) => + _awaitable('init', [resolveFontUrls(yogaOptions as YogaOptions | undefined)]), }; return proxy as unknown as Workerized; } +// The worker is inlined as a blob, so a root-relative atlas URL can't resolve +// against its base once it's over there. Resolve here on the main thread, where +// `location` is the real document URL, before the options cross postMessage. +function resolveFontUrls(yogaOptions?: YogaOptions): YogaOptions | undefined { + if (!yogaOptions?.fonts?.length) { + return yogaOptions; + } + + const baseHref = globalThis.location?.href; + + return { + ...yogaOptions, + fonts: yogaOptions.fonts.map((font) => ({ + ...font, + atlasDataUrl: resolveAtlasUrl(font.atlasDataUrl, baseHref), + })), + }; +} + let count = 0; function getId(): number { return ++count; diff --git a/packages/plugin-flexbox/src/text/resolveAtlasUrl.test.ts b/packages/plugin-flexbox/src/text/resolveAtlasUrl.test.ts new file mode 100644 index 00000000..58b4de12 --- /dev/null +++ b/packages/plugin-flexbox/src/text/resolveAtlasUrl.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveAtlasUrl } from './resolveAtlasUrl'; + +describe('resolveAtlasUrl', () => { + const base = 'https://host.example/app/'; + + it('resolves a root-relative URL against the base origin', () => { + expect(resolveAtlasUrl('/fonts/inter/Regular.msdf.json', base)).toBe( + 'https://host.example/fonts/inter/Regular.msdf.json', + ); + }); + + it('resolves a relative URL against the base path', () => { + expect(resolveAtlasUrl('fonts/Regular.msdf.json', base)).toBe( + 'https://host.example/app/fonts/Regular.msdf.json', + ); + }); + + it('leaves an absolute http URL unchanged', () => { + const abs = 'https://cdn.example/fonts/Regular.msdf.json'; + expect(resolveAtlasUrl(abs, base)).toBe(abs); + }); + + it('leaves a data URL unchanged', () => { + const data = 'data:application/json,{}'; + expect(resolveAtlasUrl(data, base)).toBe(data); + }); + + it('returns the input unchanged when no base is available', () => { + expect(resolveAtlasUrl('/fonts/Regular.msdf.json', undefined)).toBe( + '/fonts/Regular.msdf.json', + ); + }); + + it('does not resolve against a blob base (the worker trap)', () => { + // A blob base can't resolve a root-relative path; the whole point is that + // we resolve on the main thread against the real document URL, never here. + const blob = 'blob:https://host.example/uuid'; + expect(resolveAtlasUrl('/fonts/Regular.msdf.json', blob)).toBe( + '/fonts/Regular.msdf.json', + ); + }); +}); diff --git a/packages/plugin-flexbox/src/text/resolveAtlasUrl.ts b/packages/plugin-flexbox/src/text/resolveAtlasUrl.ts new file mode 100644 index 00000000..f54c97d0 --- /dev/null +++ b/packages/plugin-flexbox/src/text/resolveAtlasUrl.ts @@ -0,0 +1,26 @@ +/** + * Resolve a font atlas URL against a base href on the main thread. + * + * The Yoga worker is bundled inline (`?worker&inline`), so in a production + * build its `self.location` is a `blob:` URL. A root-relative fetch like + * `/fonts/x.json` can't resolve against a blob base and throws. Resolving to an + * absolute URL here, before the URL crosses into the worker, sidesteps that. + * + * `baseHref` should be the main thread's document URL. A blob base can't + * resolve a relative path, so on failure (or no base) we hand back the input + * untouched. + */ +export function resolveAtlasUrl( + url: string, + baseHref: string | undefined, +): string { + if (!baseHref) { + return url; + } + + try { + return new URL(url, baseHref).href; + } catch { + return url; + } +} From f1c881370a3493be7dbc7d9d6ce0b71bc1cf70c9 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 14:27:11 +0200 Subject: [PATCH 12/66] fix(vendor): apply animated transforms and replay resting styles to late-attached nodes --- ...g-animated-transform-and-resting-styles.md | 8 ++++ .../plugin-flexbox/src/YogaManagerWorker.ts | 4 +- .../src/exports/createAnimatedComponent.tsx | 4 ++ .../src/exports/useAnimatedStyle.ts | 42 +++++++++++++++---- .../src/types/AnimatedStyle.ts | 6 +++ 5 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 .changeset/lightning-animated-transform-and-resting-styles.md diff --git a/.changeset/lightning-animated-transform-and-resting-styles.md b/.changeset/lightning-animated-transform-and-resting-styles.md new file mode 100644 index 00000000..ca28a9c0 --- /dev/null +++ b/.changeset/lightning-animated-transform-and-resting-styles.md @@ -0,0 +1,8 @@ +--- +'@plextv/react-lightning-plugin-flexbox': patch +'@plextv/react-lightning-plugin-reanimated': patch +--- + +fix(reanimated): apply animated transforms and replay resting styles to late-attached nodes + +Two gaps stopped a reanimated `transform` (e.g. a scroll-linked `translateY`) from reaching a laid-out node. The flexbox worker proxy filtered every non-flex style key before postMessage, so `transform` was dropped even though the worker-side Yoga already applies it as a top/left offset (and the serializer special-cases transform objects) — let it through. And `useAnimatedStyle` only pushed styles when a shared value changed, so a view that registers after the fact (recycled cell, re-created node) never got the current resting value; `AnimatedStyle` now exposes `applyToView`, which `createAnimatedComponent` calls on registration to replay the last-applied styles. Replay-only on purpose: computing a fresh value at attach time pushed states the normal flow never emitted and broke focus on some nodes. diff --git a/packages/plugin-flexbox/src/YogaManagerWorker.ts b/packages/plugin-flexbox/src/YogaManagerWorker.ts index 8acf4f2f..2b1f4c72 100644 --- a/packages/plugin-flexbox/src/YogaManagerWorker.ts +++ b/packages/plugin-flexbox/src/YogaManagerWorker.ts @@ -114,8 +114,10 @@ function wrapWorker(worker: Worker): Workerized { // `for...in` skips Object.entries' tuple allocation — hot path on // every applyStyle. Filter non-flex keys here so we don't serialize // them, ship them across postMessage, and let the worker re-filter. + // `transform` is not a flex prop but the worker applies it as a + // top/left offset on the laid-out position, so let it through. for (const key in style) { - if (!isFlexStyleProp(key)) { + if (key !== 'transform' && !isFlexStyleProp(key)) { continue; } diff --git a/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx b/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx index bafa3a96..7cdaf920 100644 --- a/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx +++ b/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx @@ -240,6 +240,9 @@ export function createAnimatedComponent( } animatedStyle.viewsRef.add(newRef); + // A fresh node has none of the style's resting values (listeners only + // push on change), so apply the current value now. + animatedStyle.applyToView?.(newRef); } this._ref = newRef; @@ -256,6 +259,7 @@ export function createAnimatedComponent( for (const newAnimatedStyle of newAnimatedStyles) { newAnimatedStyle.viewsRef.add(this._ref); + newAnimatedStyle.applyToView?.(this._ref); } } diff --git a/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts b/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts index 08bf7288..89bbf85e 100644 --- a/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts +++ b/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts @@ -12,21 +12,37 @@ import { toLightningAnimationAndStyles } from '../utils/toLightningAnimationAndS type UseAnimatedStyleFn = (...args: Parameters) => AnimatedStyle; +function setStyles( + view: LightningElement, + transition: ReturnType['transition'], + style: ReturnType['style'], +): void { + view.setProps({ + transition, + // setProps expects lightning props, but we will just pass through the raw + // styles from the useAnimatedStyle and let the transforms take care of + // converting the CSS styles to lightning + style: style as LightningElementStyle, + }); +} + +type AppliedStyles = { + transition: ReturnType['transition']; + style: ReturnType['style']; +} | null; + function computeAndSetStyles( updater: () => AnimatedObject, views: Set, + lastApplied: { current: AppliedStyles }, ): void { const computedStyle = updater(); const { transition, style } = toLightningAnimationAndStyles(computedStyle); + lastApplied.current = { transition, style }; + for (const view of views) { - view.setProps({ - transition, - // setProps expects lightning props, but we will just pass through the raw - // styles from the useAnimatedStyle and let the transforms take care of - // converting the CSS styles to lightning - style: style as LightningElementStyle, - }); + setStyles(view, transition, style); } } @@ -36,6 +52,7 @@ export const useAnimatedStyle: UseAnimatedStyleFn = (updater, dependencies) => { const [views] = useState(() => new Set()); const inputs: DependencyList = dependencies ?? []; const timerRef = useRef(0); + const lastApplied = useRef(null); // Debounce this call so we don't end up calculating the styles multiple times // when updating multiple properties in the same hook @@ -45,7 +62,7 @@ export const useAnimatedStyle: UseAnimatedStyleFn = (updater, dependencies) => { } timerRef.current = window.setTimeout(() => { - computeAndSetStyles(updater, views); + computeAndSetStyles(updater, views, lastApplied); timerRef.current = 0; }, 2); }; @@ -74,5 +91,14 @@ export const useAnimatedStyle: UseAnimatedStyleFn = (updater, dependencies) => { return { viewsRef: views, + // A view registering after styles were already pushed (recycled cells, + // re-created nodes) missed that push and a resting shared value may never + // change again. Replay only what was already applied — never compute a + // fresh value here, that would push states the normal flow never emitted. + applyToView: (view: LightningElement) => { + if (lastApplied.current) { + setStyles(view, lastApplied.current.transition, lastApplied.current.style); + } + }, }; }; diff --git a/packages/plugin-reanimated/src/types/AnimatedStyle.ts b/packages/plugin-reanimated/src/types/AnimatedStyle.ts index c22aa8ef..55587444 100644 --- a/packages/plugin-reanimated/src/types/AnimatedStyle.ts +++ b/packages/plugin-reanimated/src/types/AnimatedStyle.ts @@ -2,4 +2,10 @@ import type { LightningElement } from '@plextv/react-lightning'; export type AnimatedStyle = { viewsRef: Set; + /** + * Apply the style's current computed value to a single view. Called when a + * view registers, so late-attached or re-created nodes don't miss styles + * whose shared values are at rest (listeners only push on change). + */ + applyToView: (view: LightningElement) => void; }; From adfdd7fea0b3796bf3d4678e9de17f6f0d647291 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 14:22:07 +0200 Subject: [PATCH 13/66] fix(vendor): measure virtuallist header/footer size (RNG-469) --- .../components/VirtualList/VirtualList.tsx | 45 ++++++++++++++++--- .../VirtualList/resolveSectionSize.spec.ts | 25 +++++++++++ .../VirtualList/resolveSectionSize.ts | 14 ++++++ 3 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 packages/react-lightning-components/src/components/VirtualList/resolveSectionSize.spec.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/resolveSectionSize.ts diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index 881ba3ae..d51172ab 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -17,12 +17,13 @@ import { type LightningElement, type LightningViewElementStyle, } from '@plextv/react-lightning'; -import { FlexBoundary, useIsInFlex } from '@plextv/react-lightning-plugin-flexbox'; +import { FlexBoundary, FlexRoot, useIsInFlex } from '@plextv/react-lightning-plugin-flexbox'; import { computeItemRect } from './computeItemRect'; import { LayoutManager } from './LayoutManager'; import { parseContentStyle } from './parseContentStyle'; import { RecyclerPool } from './RecyclerPool'; +import { resolveSectionSize } from './resolveSectionSize'; import { useScrollHandler } from './useScrollHandler'; import { useViewability } from './useViewability'; import { VirtualListCell } from './VirtualListCell'; @@ -111,6 +112,10 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef parent cell bounds > self-measured. @@ -279,6 +284,24 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef { + const main = horizontal ? event.w : event.h; + + if (main > 0 && Math.abs(main - measuredHeaderSizeRef.current) >= 1) { + measuredHeaderSizeRef.current = main; + setMeasuredHeaderSize(main); + } + }; + + const handleFooterLayout = (event: { w: number; h: number }) => { + const main = horizontal ? event.w : event.h; + + if (main > 0 && Math.abs(main - measuredFooterSizeRef.current) >= 1) { + measuredFooterSizeRef.current = main; + setMeasuredFooterSize(main); + } + }; + const { contentRef, scrollOffsetRef, @@ -633,7 +656,13 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef - {renderListComponent(ListHeaderComponent)} + {isInFlex ? ( + + {renderListComponent(ListHeaderComponent)} + + ) : ( + renderListComponent(ListHeaderComponent) + )} )} @@ -651,7 +680,13 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef - {renderListComponent(ListFooterComponent)} + {isInFlex ? ( + + {renderListComponent(ListFooterComponent)} + + ) : ( + renderListComponent(ListFooterComponent) + )} )} diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveSectionSize.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveSectionSize.spec.ts new file mode 100644 index 00000000..59941bc6 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveSectionSize.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveSectionSize } from './resolveSectionSize'; + +describe('resolveSectionSize', () => { + it('reserves nothing when there is no section component', () => { + expect(resolveSectionSize(false, 120, 40)).toBe(0); + }); + + it('uses the measured size once the section has laid out', () => { + expect(resolveSectionSize(true, 120, 0)).toBe(120); + }); + + it('prefers the measured size over the caller estimate', () => { + expect(resolveSectionSize(true, 120, 40)).toBe(120); + }); + + it('falls back to the caller estimate before measurement', () => { + expect(resolveSectionSize(true, 0, 40)).toBe(40); + }); + + it('is zero when present but neither measured nor estimated', () => { + expect(resolveSectionSize(true, 0, 0)).toBe(0); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveSectionSize.ts b/packages/react-lightning-components/src/components/VirtualList/resolveSectionSize.ts new file mode 100644 index 00000000..26dce8ff --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveSectionSize.ts @@ -0,0 +1,14 @@ +// Header/footer main-axis size. Measured content wins over the caller's +// estimate (`listHeaderSize`/`listFooterSize`), which now only bridges the +// gap before the section has laid out. No component reserves no space. +export function resolveSectionSize( + hasComponent: boolean, + measuredSize: number, + fallbackSize: number, +): number { + if (!hasComponent) { + return 0; + } + + return measuredSize > 0 ? measuredSize : fallbackSize; +} From 422c9ad2c6bc734b641b1c84a806b72d0a7848a7 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 14:22:10 +0200 Subject: [PATCH 14/66] fix(vendor): render experimental_backgroundImage linear-gradients --- .../src/convertCSSStyleToLightning.ts | 18 ++ packages/plugin-css-transform/src/index.ts | 2 + .../src/utils/parseLinearGradient.spec.ts | 70 ++++++ .../src/utils/parseLinearGradient.ts | 231 ++++++++++++++++++ .../src/element/LightningViewElement.ts | 29 ++- packages/react-lightning/src/types/Styles.ts | 8 + 6 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 packages/plugin-css-transform/src/utils/parseLinearGradient.spec.ts create mode 100644 packages/plugin-css-transform/src/utils/parseLinearGradient.ts diff --git a/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts b/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts index c4f25579..1914b1c6 100644 --- a/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts +++ b/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts @@ -3,6 +3,7 @@ import type { LightningElementStyle, LightningTextElementStyle } from '@plextv/r import type { AllStyleProps } from './types/ReactStyle'; import { flattenStyles } from './utils/flattenStyles'; import { htmlColorToLightningColor } from './utils/htmlColorToLightningColor'; +import { parseLinearGradient } from './utils/parseLinearGradient'; import { parseTransform } from './utils/parseTransform'; export function convertCSSStyleToLightning( @@ -28,6 +29,8 @@ export function convertCSSStyleToLightning( transform, width, height, + backgroundImage, + experimental_backgroundImage, ...otherStyles } = flattenStyles(style); const finalStyle = { @@ -46,6 +49,21 @@ export function convertCSSStyleToLightning( finalStyle.color = color; } + const gradientValue = + typeof backgroundImage === 'string' + ? backgroundImage + : typeof experimental_backgroundImage === 'string' + ? experimental_backgroundImage + : undefined; + + if (gradientValue != null) { + const gradient = parseLinearGradient(gradientValue); + + if (gradient) { + finalStyle.linearGradient = gradient; + } + } + if (shadowColor != null) { (finalStyle as LightningTextElementStyle).shadowColor = htmlColorToLightningColor(shadowColor); } diff --git a/packages/plugin-css-transform/src/index.ts b/packages/plugin-css-transform/src/index.ts index 9d545c20..5c09855c 100644 --- a/packages/plugin-css-transform/src/index.ts +++ b/packages/plugin-css-transform/src/index.ts @@ -12,6 +12,8 @@ export { parseTransform } from './utils/parseTransform'; const CSS_HANDLED_STYLE_PROPS: ReadonlySet = new Set([ 'backgroundColor', + 'backgroundImage', + 'experimental_backgroundImage', 'color', 'border', 'borderWidth', diff --git a/packages/plugin-css-transform/src/utils/parseLinearGradient.spec.ts b/packages/plugin-css-transform/src/utils/parseLinearGradient.spec.ts new file mode 100644 index 00000000..f5e8b086 --- /dev/null +++ b/packages/plugin-css-transform/src/utils/parseLinearGradient.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { parseLinearGradient } from './parseLinearGradient'; + +describe('parseLinearGradient', () => { + it('parses the player controls gradient (to bottom, rgba stops)', () => { + const result = parseLinearGradient( + 'linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.56) 40%, rgba(0,0,0,0.76) 60%, rgba(0,0,0,1) 100%)', + ); + + expect(result).toEqual({ + colors: [0x00000000, 0x0000008f, 0x000000c2, 0x000000ff], + stops: [0, 0.4, 0.6, 1], + angle: 0, + }); + }); + + it('defaults to "to bottom" (angle 0) when no direction is given', () => { + const result = parseLinearGradient('linear-gradient(rgba(0,0,0,0) 0%, rgba(0,0,0,1) 100%)'); + + expect(result?.angle).toBe(0); + }); + + it('maps "to top" to PI', () => { + const result = parseLinearGradient('linear-gradient(to top, #000 0%, #fff 100%)'); + + expect(result?.angle).toBeCloseTo(Math.PI); + }); + + it('maps a degree angle (CSS 90deg / to right)', () => { + const result = parseLinearGradient('linear-gradient(90deg, #000, #fff)'); + + // CSS 90deg -> lightning (90 - 180) deg, normalised to (3/2)PI + expect(result?.angle).toBeCloseTo((3 * Math.PI) / 2); + }); + + it('evenly distributes stops that omit positions', () => { + const result = parseLinearGradient('linear-gradient(to bottom, red, lime, blue)'); + + expect(result?.stops).toEqual([0, 0.5, 1]); + expect(result?.colors).toEqual([0xff0000ff, 0x00ff00ff, 0x0000ffff]); + }); + + it('interpolates a missing interior stop', () => { + const result = parseLinearGradient( + 'linear-gradient(to bottom, #000 0%, #333, #666, #fff 100%)', + ); + + expect(result?.stops).toEqual([0, 1 / 3, 2 / 3, 1]); + }); + + it('handles rgba with spaces after commas', () => { + const result = parseLinearGradient( + 'linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 87.5%)', + ); + + expect(result?.colors).toEqual([0x000000cc, 0x00000000]); + expect(result?.stops).toEqual([0, 0.875]); + }); + + it('returns undefined for non-linear-gradient values', () => { + expect(parseLinearGradient('url(foo.png)')).toBeUndefined(); + expect(parseLinearGradient('radial-gradient(#000, #fff)')).toBeUndefined(); + expect(parseLinearGradient(undefined)).toBeUndefined(); + }); + + it('returns undefined when fewer than two colors resolve', () => { + expect(parseLinearGradient('linear-gradient(#000)')).toBeUndefined(); + }); +}); diff --git a/packages/plugin-css-transform/src/utils/parseLinearGradient.ts b/packages/plugin-css-transform/src/utils/parseLinearGradient.ts new file mode 100644 index 00000000..20975fd6 --- /dev/null +++ b/packages/plugin-css-transform/src/utils/parseLinearGradient.ts @@ -0,0 +1,231 @@ +import { htmlColorToLightningColor } from './htmlColorToLightningColor'; + +export interface LinearGradientShaderProps { + colors: number[]; + stops: number[]; + angle: number; +} + +// CSS keyword directions in CSS degrees (0deg = to top, 90deg = to right, ...). +const KEYWORD_ANGLES: Record = { + top: 0, + right: 90, + bottom: 180, + left: 270, + 'top right': 45, + 'right top': 45, + 'bottom right': 135, + 'right bottom': 135, + 'bottom left': 225, + 'left bottom': 225, + 'top left': 315, + 'left top': 315, +}; + +const ANGLE_UNIT_TO_DEG: Record = { + deg: 1, + grad: 0.9, + rad: 180 / Math.PI, + turn: 360, +}; + +// Lightning's LinearGradient shader points top-to-bottom at angle 0, which is +// CSS "to bottom" (180deg), and rotates the same way. So lightning = css - 180, +// normalised into [0, 2π) so equivalent directions map to one stable value. +function cssDegToLightningRadians(cssDeg: number): number { + const radians = ((cssDeg - 180) * Math.PI) / 180; + const twoPi = Math.PI * 2; + + return ((radians % twoPi) + twoPi) % twoPi; +} + +function parseDirection(token: string): number | undefined { + const angleMatch = /^(-?[\d.]+)(deg|grad|rad|turn)$/.exec(token); + + if (angleMatch) { + const value = Number.parseFloat(angleMatch[1] ?? ''); + const factor = ANGLE_UNIT_TO_DEG[angleMatch[2] ?? '']; + + if (factor == null || Number.isNaN(value)) { + return undefined; + } + + return cssDegToLightningRadians(value * factor); + } + + if (token.startsWith('to ')) { + const sides = token.slice(3).trim().split(/\s+/).join(' '); + const cssDeg = KEYWORD_ANGLES[sides]; + + if (cssDeg != null) { + return cssDegToLightningRadians(cssDeg); + } + } + + return undefined; +} + +// Split on commas that aren't nested inside parens (rgba(...), hsl(...)). +function splitTopLevel(input: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + + for (let i = 0; i < input.length; i++) { + const char = input[i]; + + if (char === '(') { + depth++; + } else if (char === ')') { + depth--; + } else if (char === ',' && depth === 0) { + parts.push(input.slice(start, i)); + start = i + 1; + } + } + + parts.push(input.slice(start)); + + return parts.map((part) => part.trim()).filter(Boolean); +} + +// Fill missing stops the way CSS does: first defaults to 0, last to 1, runs of +// omitted stops are spread evenly between their defined neighbours, and the +// sequence is clamped to be non-decreasing. +function normalizeStops(positions: (number | undefined)[]): number[] { + const out = positions.slice(); + const lastIndex = out.length - 1; + + if (out[0] == null) { + out[0] = 0; + } + + if (out[lastIndex] == null) { + out[lastIndex] = 1; + } + + let i = 0; + + while (i < out.length) { + if (out[i] != null) { + i++; + continue; + } + + const startIndex = i - 1; + const startVal = out[startIndex]; + let end = i; + + while (end < out.length && out[end] == null) { + end++; + } + + const endVal = out[end]; + + if (startVal != null && endVal != null) { + const span = end - startIndex; + + for (let k = i; k < end; k++) { + out[k] = startVal + ((endVal - startVal) * (k - startIndex)) / span; + } + } + + i = end; + } + + let prev = out[0] ?? 0; + + return out.map((value) => { + const resolved = value ?? prev; + const clamped = resolved < prev ? prev : resolved; + + prev = clamped; + + return clamped; + }); +} + +function parseStopPosition(raw: string | undefined): number | undefined { + if (raw == null) { + return undefined; + } + + const match = /^(-?[\d.]+)(%|px)?$/.exec(raw); + const value = match?.[1]; + + if (value == null) { + return undefined; + } + + // px positions need the element size to normalise, which we don't have here. + // Fall back to even distribution for those (rare in practice). + if (match?.[2] === 'px') { + return undefined; + } + + return Number.parseFloat(value) / 100; +} + +/** + * Parse a CSS `linear-gradient(...)` value into the props Lightning's + * LinearGradient shader expects. Returns undefined for anything that isn't a + * linear gradient (url(), radial-gradient, etc.) or that resolves to fewer than + * two colors. + */ +export function parseLinearGradient( + value: string | undefined | null, +): LinearGradientShaderProps | undefined { + if (!value || typeof value !== 'string') { + return undefined; + } + + const match = /^\s*linear-gradient\((.*)\)\s*$/is.exec(value.trim()); + const inner = match?.[1]; + + if (inner == null) { + return undefined; + } + + const segments = splitTopLevel(inner); + const first = segments[0]; + + if (first == null) { + return undefined; + } + + let angle = cssDegToLightningRadians(180); + const direction = parseDirection(first); + + if (direction != null) { + angle = direction; + segments.shift(); + } + + const colors: number[] = []; + const positions: (number | undefined)[] = []; + + for (const segment of segments) { + // Strip a trailing position token, leaving the color (which may itself + // contain spaces, e.g. `rgba(0, 0, 0, 0.8)`). + const posMatch = /\s+(-?[\d.]+(?:%|px)?)\s*$/.exec(segment); + const colorText = posMatch ? segment.slice(0, posMatch.index).trim() : segment; + + try { + colors.push(htmlColorToLightningColor(colorText)); + } catch { + return undefined; + } + + positions.push(parseStopPosition(posMatch?.[1])); + } + + if (colors.length < 2) { + return undefined; + } + + return { + colors, + stops: normalizeStops(positions), + angle, + }; +} diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index c6e21670..c59e4393 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -870,6 +870,7 @@ export class LightningViewElement< 'borderLeft', 'borderRight', 'borderBottom', + 'linearGradient', ]); /** @@ -1043,8 +1044,16 @@ export class LightningViewElement< let type: ShaderDef['type'] | undefined; let hasRounded = false; - const { border, borderColor, borderTop, borderLeft, borderRight, borderBottom, borderRadius } = - style; + const { + border, + borderColor, + borderTop, + borderLeft, + borderRight, + borderBottom, + borderRadius, + linearGradient, + } = style; if (borderRadius) { type = 'Rounded'; @@ -1089,7 +1098,21 @@ export class LightningViewElement< props[hasRounded ? 'border-color' : 'color'] = borderColor; } - return type ? { type, props } : undefined; + if (type) { + if (linearGradient && import.meta.env.DEV) { + console.warn( + `Warning: element ${this.id} sets both a background gradient and a border/radius. A node can only carry one shader, so the border/radius wins and the gradient is dropped.`, + ); + } + + return { type, props }; + } + + if (linearGradient) { + return { type: 'LinearGradient', props: linearGradient }; + } + + return undefined; } public _toLightningNodeProps( diff --git a/packages/react-lightning/src/types/Styles.ts b/packages/react-lightning/src/types/Styles.ts index 92e63714..835fe439 100644 --- a/packages/react-lightning/src/types/Styles.ts +++ b/packages/react-lightning/src/types/Styles.ts @@ -30,6 +30,14 @@ export interface LightningViewElementStyle extends Omit< */ borderRadius?: number | [number, number?, number?, number?]; + /** + * Parsed linear-gradient, applied as a LinearGradient shader. Set by + * plugin-css-transform from a css `background-image` / RN + * `experimental_backgroundImage` value. Colors are in Lightning 0xRRGGBBAA + * format, stops are 0..1, angle is radians. + */ + linearGradient?: { colors: number[]; stops: number[]; angle: number }; + /** Used as the initial dimensions for the element before yoga has calculated * where placement should actually go. This is to estimate where elements are * place on the screen so things like images don't all get loaded immediately From 24842f3606e6a5ff940e251a4f2511dc2c10c67a Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 14:22:17 +0200 Subject: [PATCH 15/66] fix(vendor): round status badge corners on tiles (RNG-530) --- .../src/convertCSSStyleToLightning.spec.ts | 40 +++++++ .../src/convertCSSStyleToLightning.ts | 108 +++++++++++++++++- 2 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts diff --git a/packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts b/packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts new file mode 100644 index 00000000..4c72d8ae --- /dev/null +++ b/packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { convertCSSStyleToLightning } from './convertCSSStyleToLightning'; + +describe('convertCSSStyleToLightning border radius', () => { + it('passes a uniform borderRadius through unchanged', () => { + expect(convertCSSStyleToLightning({ borderRadius: 8 })?.borderRadius).toBe( + 8, + ); + }); + + it('expands a single corner longhand into a [tl, tr, br, bl] array', () => { + expect( + convertCSSStyleToLightning({ borderTopRightRadius: 8 })?.borderRadius, + ).toEqual([0, 8, 0, 0]); + }); + + it('maps logical start/end corners onto physical corners (LTR)', () => { + expect( + convertCSSStyleToLightning({ + borderTopEndRadius: 8, + borderBottomStartRadius: 8, + })?.borderRadius, + ).toEqual([0, 8, 0, 8]); + }); + + it('uses the uniform borderRadius as the base for unspecified corners', () => { + expect( + convertCSSStyleToLightning({ borderRadius: 4, borderTopEndRadius: 8 }) + ?.borderRadius, + ).toEqual([4, 8, 4, 4]); + }); + + it('drops the per-corner longhands from the output', () => { + const result = convertCSSStyleToLightning({ + borderTopEndRadius: 8, + }) as Record; + expect(result.borderTopEndRadius).toBeUndefined(); + }); +}); diff --git a/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts b/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts index 1914b1c6..6a0da24b 100644 --- a/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts +++ b/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts @@ -1,11 +1,75 @@ -import type { LightningElementStyle, LightningTextElementStyle } from '@plextv/react-lightning'; - +import type { + LightningElementStyle, + LightningTextElementStyle, +} from '@plextv/react-lightning'; import type { AllStyleProps } from './types/ReactStyle'; import { flattenStyles } from './utils/flattenStyles'; import { htmlColorToLightningColor } from './utils/htmlColorToLightningColor'; import { parseLinearGradient } from './utils/parseLinearGradient'; import { parseTransform } from './utils/parseTransform'; +// RN exposes per-corner radius longhands; Lightning's Rounded shader wants a single +// borderRadius (number, or [tl, tr, br, bl]). Expand the longhands so they aren't dropped. +// Logical start/end map to physical left/right (LTR only, which is all the app ships). +// Non-numeric values (animated nodes, '50%') aren't supported by the shader, so they're skipped. +interface CornerRadii { + borderRadius?: unknown; + borderTopLeftRadius?: unknown; + borderTopRightRadius?: unknown; + borderBottomLeftRadius?: unknown; + borderBottomRightRadius?: unknown; + borderTopStartRadius?: unknown; + borderTopEndRadius?: unknown; + borderBottomStartRadius?: unknown; + borderBottomEndRadius?: unknown; +} + +function resolveBorderRadius( + radii: CornerRadii, +): number | [number, number, number, number] | undefined { + const { + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + } = radii; + + const num = (value: unknown): number | undefined => + typeof value === 'number' ? value : undefined; + + const topLeft = num(borderTopLeftRadius) ?? num(borderTopStartRadius); + const topRight = num(borderTopRightRadius) ?? num(borderTopEndRadius); + const bottomRight = + num(borderBottomRightRadius) ?? num(borderBottomEndRadius); + const bottomLeft = + num(borderBottomLeftRadius) ?? num(borderBottomStartRadius); + + const base = num(borderRadius); + + if ( + topLeft == null && + topRight == null && + bottomRight == null && + bottomLeft == null + ) { + return base; + } + + const fallback = base ?? 0; + + return [ + topLeft ?? fallback, + topRight ?? fallback, + bottomRight ?? fallback, + bottomLeft ?? fallback, + ]; +} + export function convertCSSStyleToLightning( style: AllStyleProps, ): LightningElementStyle | undefined { @@ -31,6 +95,15 @@ export function convertCSSStyleToLightning( height, backgroundImage, experimental_backgroundImage, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, ...otherStyles } = flattenStyles(style); const finalStyle = { @@ -65,7 +138,8 @@ export function convertCSSStyleToLightning( } if (shadowColor != null) { - (finalStyle as LightningTextElementStyle).shadowColor = htmlColorToLightningColor(shadowColor); + (finalStyle as LightningTextElementStyle).shadowColor = + htmlColorToLightningColor(shadowColor); } if (border != null || borderWidth != null || borderColor != null) { @@ -118,7 +192,9 @@ export function convertCSSStyleToLightning( if (otherStyles.top != null) { finalStyle.y = - typeof otherStyles.top === 'number' ? otherStyles.top : Number.parseInt(otherStyles.top, 10); + typeof otherStyles.top === 'number' + ? otherStyles.top + : Number.parseInt(otherStyles.top, 10); } if (fontWeight != null) { @@ -129,7 +205,8 @@ export function convertCSSStyleToLightning( } if (transform != null) { - const { scaleX, scaleY, rotation, ...translateTransforms } = parseTransform(transform); + const { scaleX, scaleY, rotation, ...translateTransforms } = + parseTransform(transform); if (scaleX != null) { finalStyle.scaleX = scaleX; @@ -147,10 +224,29 @@ export function convertCSSStyleToLightning( } // Disabled for now as some components set overflow to hidden while not having their size correctly calculated - if (overflow === 'hidden' || overflowX === 'hidden' || overflowY === 'hidden') { + if ( + overflow === 'hidden' || + overflowX === 'hidden' || + overflowY === 'hidden' + ) { finalStyle.clipping = true; } + const cornerRadii = resolveBorderRadius({ + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopStartRadius, + borderTopEndRadius, + borderBottomStartRadius, + borderBottomEndRadius, + }); + if (cornerRadii != null) { + finalStyle.borderRadius = cornerRadii; + } + if (width != null) { finalStyle.w = width as number; } From 689922cf0c45b03fe173afb1159566a428626b20 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 14:22:21 +0200 Subject: [PATCH 16/66] fix(vendor): mask the background wash and reuse BackgroundContent --- .../src/exports/Image.tsx | 123 ++++++++++++------ 1 file changed, 85 insertions(+), 38 deletions(-) diff --git a/packages/react-native-lightning/src/exports/Image.tsx b/packages/react-native-lightning/src/exports/Image.tsx index fb6775c6..59e31c3f 100644 --- a/packages/react-native-lightning/src/exports/Image.tsx +++ b/packages/react-native-lightning/src/exports/Image.tsx @@ -5,61 +5,108 @@ import type { Image as RNImage, ImageProps as RNImageProps, } from 'react-native'; - -import type { LightningElementStyle, LightningImageElement } from '@plextv/react-lightning'; +import type { + LightningElementStyle, + LightningImageElement, +} from '@plextv/react-lightning'; import { flattenStyles } from '@plextv/react-lightning-plugin-css-transform'; - import { useImageLoadedHandler } from '../hooks/useImageLoadedHandler'; import { useLayoutHandler } from '../hooks/useLayoutHandler'; export type ImageProps = RNImageProps; -function isImageURISource(source: ImageSourcePropType): source is ImageURISource { +function isImageURISource( + source: ImageSourcePropType, +): source is ImageURISource { return !Array.isArray(source); } -export type Image = RNImage & LightningImageElement; +export type Image = LightningImageElement & RNImage; + +// Map RN `resizeMode` to the texture resizeMode so images keep aspect. Only +// cover/contain have equivalents; others fall back to the default (stretch). +function resolveResizeMode( + resizeMode: RNImageProps['resizeMode'], +): { type: 'contain' } | { type: 'cover' } | undefined { + if (resizeMode === 'cover') { + return { type: 'cover' }; + } + + if (resizeMode === 'contain') { + return { type: 'contain' }; + } + + return undefined; +} export const Image: ForwardRefExoticComponent = forwardRef< LightningImageElement, RNImageProps ->(({ onLoad, onLayout, width, height, src, source, style, ...otherProps }, ref) => { - const handleImageLayout = useLayoutHandler(onLayout); - const handleImageLoaded = useImageLoadedHandler(src as string, onLoad); +>( + ( + { + onLoad, + onLayout, + width, + height, + src, + source, + style, + resizeMode, + ...otherProps + }, + ref, + ) => { + const handleImageLayout = useLayoutHandler(onLayout); + const handleImageLoaded = useImageLoadedHandler(src as string, onLoad); - let finalSource: string | undefined; + let finalSource: string | undefined; - if (typeof source === 'object') { - if (!isImageURISource(source)) { - console.error('[Image] Lightning images only support ImageURISource as a source'); + if (typeof source === 'object') { + if (!isImageURISource(source)) { + console.error( + '[Image] Lightning images only support ImageURISource as a source', + ); + } else { + finalSource = source.uri; + } + } else if (typeof source === 'number') { + console.error('[Image] Lightning images do not support numeric sources'); + } else if (source || src) { + finalSource = source ?? src; } else { - finalSource = source.uri; + return null; } - } else if (typeof source === 'number') { - console.error('[Image] Lightning images do not support numeric sources'); - } else if (source || src) { - finalSource = source ?? src; - } else { - return null; - } - return ( - - ); -}); + const flattenedStyle = flattenStyles(style) as LightningElementStyle; + const resolvedResizeMode = resolveResizeMode(resizeMode); + + return ( + + ); + }, +); Image.displayName = 'Image'; From 177704903002268ebba2b6a06a397f96faf0d895 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 14:22:25 +0200 Subject: [PATCH 17/66] fix(vendor): export findNodeHandle from react-native-lightning --- .changeset/lightning-find-node-handle.md | 5 +++++ .../src/exports/findNodeHandle.ts | 11 +++++++++++ packages/react-native-lightning/src/index.ts | 1 + 3 files changed, 17 insertions(+) create mode 100644 .changeset/lightning-find-node-handle.md create mode 100644 packages/react-native-lightning/src/exports/findNodeHandle.ts diff --git a/.changeset/lightning-find-node-handle.md b/.changeset/lightning-find-node-handle.md new file mode 100644 index 00000000..2b667418 --- /dev/null +++ b/.changeset/lightning-find-node-handle.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-native-lightning': patch +--- + +Add a findNodeHandle export that returns the element ref instead of throwing (react-native-web's re-export throws unconditionally). Lightning focus APIs (setDestinations, focus hints) take element refs directly, so shared RN code that funnels refs through findNodeHandle now works unchanged. diff --git a/packages/react-native-lightning/src/exports/findNodeHandle.ts b/packages/react-native-lightning/src/exports/findNodeHandle.ts new file mode 100644 index 00000000..81024772 --- /dev/null +++ b/packages/react-native-lightning/src/exports/findNodeHandle.ts @@ -0,0 +1,11 @@ +import type { LightningViewElement } from '@plextv/react-lightning'; + +// RN's findNodeHandle returns an opaque numeric node handle; react-native-web's +// throws. On Lightning the focus APIs (FocusGuide.setDestinations, focus hints) +// operate on element refs directly, so return the ref as-is. Keeps shared code +// that funnels refs through findNodeHandle working instead of crashing. +export function findNodeHandle( + componentOrHandle: unknown, +): LightningViewElement | null { + return (componentOrHandle as LightningViewElement | null) ?? null; +} diff --git a/packages/react-native-lightning/src/index.ts b/packages/react-native-lightning/src/index.ts index d454adf1..ff2c5234 100644 --- a/packages/react-native-lightning/src/index.ts +++ b/packages/react-native-lightning/src/index.ts @@ -25,6 +25,7 @@ export { type TouchableWithoutFeedbackProps, } from './exports/TouchableWithoutFeedback'; export { View, type ViewProps } from './exports/View'; +export { findNodeHandle } from './exports/findNodeHandle'; export { VirtualizedList } from './exports/VirtualizedList'; export { useBlurHandler, useFocusHandler } from './hooks/useFocusHandler'; From ac30c91971b5c600ac48851c7478f45543eabd9f Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 14:56:29 +0200 Subject: [PATCH 18/66] fix(lightning): pin virtuallist cell cross axis when the list width is definite --- .changeset/virtuallist-pin-cross-axis.md | 5 ++ .../src/components/VirtualList/VirtualList.md | 10 ++- .../components/VirtualList/VirtualList.tsx | 35 +++------ .../VirtualList/VirtualListCell.tsx | 20 +++-- .../VirtualList/VirtualListTypes.ts | 2 + .../VirtualList/resolveCrossSize.spec.ts | 70 ++++++++++++++++++ .../VirtualList/resolveCrossSize.ts | 73 +++++++++++++++++++ 7 files changed, 182 insertions(+), 33 deletions(-) create mode 100644 .changeset/virtuallist-pin-cross-axis.md create mode 100644 packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts diff --git a/.changeset/virtuallist-pin-cross-axis.md b/.changeset/virtuallist-pin-cross-axis.md new file mode 100644 index 00000000..3de7995b --- /dev/null +++ b/.changeset/virtuallist-pin-cross-axis.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +VirtualList cells now pin their FlexRoot's cross axis to the cell's cross size when the list's cross size is definite (explicit style, parent cell bounds, or the flex-allocated outer size), so flex children can fill the cell width/height like a native list cell. Content-derived cross sizes stay unpinned to avoid the measure feedback loop. diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.md b/packages/react-lightning-components/src/components/VirtualList/VirtualList.md index 2ec833be..d2990d59 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.md +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.md @@ -17,7 +17,7 @@ When the VL is rendered outside any flex parent (`useIsInFlex() === false`), no When the VL is rendered inside a flex parent (`useIsInFlex() === true`), yoga is already laying out the surrounding tree. In this mode each cell wraps its content in a `FlexRoot`, which: - gives the user's `renderItem` real flex layout (children can `flexGrow`, `flexDirection`, etc.), -- is **unpinned on both axes** so yoga shrinks-to-fit content (see [Cell rendering](#cell-rendering) for why), +- is **unpinned on the main axis** so yoga shrinks-to-fit content there; the cross axis is pinned to `cellCrossSize` when the VL's cross size is definite (explicit/parent/outer-allocated) and left unpinned when it's content-derived (see [Cell rendering](#cell-rendering) for why), - emits `onResize` whenever its natural main-axis or cross-axis size changes. The cell forwards the main-axis size to `LayoutManager.reportItemSize(userKey, size)` (drives per-item layout offsets). The cross-axis size is forwarded separately to VL's `maxContentCross` aggregator (a monotonic, reset-on-data-change fallback for `viewportCrossSize` — see [Viewport resolution](#viewport-resolution)). @@ -197,10 +197,12 @@ For a list with no explicit cross AND no flex ancestor (pinned mode), no measure }} > {isInFlex ? ( - /* FlexRoot is unpinned on both axes — yoga shrinks-to-fit content. + /* FlexRoot is unpinned on the main axis — yoga shrinks-to-fit content. + The cross axis is pinned to crossSize when pinCrossAxis (definite + viewport cross), so flex children can fill the cell width/height. handleResize forwards main-axis to onItemSizeChange (LM per-key store) and cross-axis to onContentCrossLayout (VL maxContentCross). */ - + {renderedItem} @@ -233,7 +235,7 @@ When the caller's `renderItem` includes an inner focusable, that inner is added **Why FlexRoot is conditional on `isInFlex`.** When the VL has no flex ancestor, no yoga is running in this subtree. Adding a FlexRoot just for measurement would force yoga to spin up — pure overhead with no benefit, since the user's content isn't using flex either. So we skip it; the cell is silent and pinned. -**Why FlexRoot is unpinned on both axes.** Yoga shrinks the FlexRoot to fit content on both axes. The cell forwards both dimensions: main goes into `LayoutManager`'s per-key measurement store; cross feeds VL's `maxContentCross` fallback (used only when no explicit cross source is available). Pinning cross to `cellCrossSize` would create the prior architecture's feedback loop — cell echoes its own pinned size back to VL, which uses that to compute the pin, etc. Leaving both unpinned lets cell content drive sizing without a loop. Tradeoff: flex-percentage layouts on cross axis (e.g. `width: '100%'` inside a horizontal VL's cell) won't work because the parent has no fixed cross dim — callers needing those should set `style.h` (or `.w`) on the VL, which flips the chain into the explicit branch. +**Why FlexRoot's main axis is unpinned (and the cross axis only conditionally pinned).** Yoga shrinks the FlexRoot to fit content on the main axis; that measurement goes into `LayoutManager`'s per-key store. The cross axis is pinned to `crossSize` when the viewport cross resolved from a definite source (explicit `style`, parent cell bounds, or the flex-allocated outer size — `pinCrossAxis`), so flex children can fill the cell like they would a native list cell. When the cross size is content-derived (a horizontal VL with no explicit `style.h`), pinning would create the prior architecture's feedback loop — cell echoes its own pinned size back to VL, which uses that to compute the pin, etc. — so those cells stay unpinned and `maxContentCross` keeps driving the viewport cross. Tradeoff while unpinned: flex-percentage layouts on the cross axis (e.g. `width: '100%'` inside a horizontal VL's cell) won't work because the parent has no fixed cross dim — callers needing those should set `style.h` (or `.w`) on the VL, which flips the chain into the explicit (pinned) branch. **Why measure via `onResize`?** Lightning's universal `NodeResizeObserver` fires `onResize` whenever a node's size changes. The cell reports the main-axis number to `onItemSizeChange` (LayoutManager's per-key store) and the cross-axis number to `onContentCrossLayout` (VL's `maxContentCross` aggregator). Zero/negative reports are filtered before they reach VL — a transient FlexRoot zero during recycle would otherwise pollute the cache. diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index d51172ab..52e9bff0 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -23,6 +23,7 @@ import { computeItemRect } from './computeItemRect'; import { LayoutManager } from './LayoutManager'; import { parseContentStyle } from './parseContentStyle'; import { RecyclerPool } from './RecyclerPool'; +import { resolveCrossSize } from './resolveCrossSize'; import { resolveSectionSize } from './resolveSectionSize'; import { useScrollHandler } from './useScrollHandler'; import { useViewability } from './useViewability'; @@ -146,30 +147,15 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef 0) { - viewportCrossSize = explicitCross; - } else if (!horizontal && parentCross != null && parentCross > 0) { - viewportCrossSize = parentCross; - } else if (!horizontal && measuredOuterCross > 0) { - viewportCrossSize = measuredOuterCross; - } else if (maxContentCross > 0) { - viewportCrossSize = maxContentCross + crossPadding; - } else if (parentCross != null && parentCross > 0) { - viewportCrossSize = parentCross; - } else if (measuredOuterCross > 0) { - viewportCrossSize = measuredOuterCross; - } else { - viewportCrossSize = estimatedItemSize; - } + const { viewportCrossSize, isDefinite: crossSizeIsDefinite } = resolveCrossSize({ + horizontal, + explicitCross, + parentCross, + measuredOuterCross, + maxContentCross, + crossPadding, + estimatedItemSize, + }); const cellCrossSize = (viewportCrossSize - crossPadding) / numColumns; @@ -625,6 +611,7 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef({ isLastItem, ItemSeparatorComponent, isInFlex, + pinCrossAxis = false, onItemSizeChange, onItemEmpty, onContentCrossLayout, @@ -140,12 +141,20 @@ const VirtualListCellInner = ({ ); - // FlexRoot is unpinned on both axes so yoga shrinks-to-fit content; - // pinning the cross axis would create a cell→VL→cell feedback loop. - // Tradeoff: cross-axis percentages (`width: '100%'` in a vertical VL - // cell) need the caller to set `style.h`/`.w` on the VL. + // When the VL's cross size is definite (external, not content-derived) the + // cell pins the FlexRoot's cross axis so flex children can fill it, like a + // native list cell spanning the list width. A content-derived cross size + // must stay unpinned or it freezes at the estimate before content reports + // its real size (cell→VL→cell feedback loop). While unpinned, cross-axis + // percentages (`width: '100%'` in a vertical VL cell) need the caller to + // set `style.h`/`.w` on the VL. + const flexRootStyle: LightningViewElementStyle | null = pinCrossAxis + ? horizontal + ? { h: crossSize } + : { w: crossSize } + : null; const measuredContent = isInFlex ? ( - + {innerContent} ) : ( @@ -209,6 +218,7 @@ function areCellPropsEqual( prev.isLastItem === next.isLastItem && prev.ItemSeparatorComponent === next.ItemSeparatorComponent && prev.isInFlex === next.isInFlex && + prev.pinCrossAxis === next.pinCrossAxis && prev.pooled === next.pooled ); } diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts index 70b22c7c..973363f4 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts @@ -201,6 +201,8 @@ export interface VirtualListCellProps { ItemSeparatorComponent?: ComponentType | null; /** True when a flex ancestor exists; cells wrap in FlexRoot for layout + measurement. False means pinned/silent. */ isInFlex: boolean; + /** Pin the FlexRoot's cross axis to `crossSize` so flex children can fill it. Only safe when the VL's cross size is definite (not content-derived). */ + pinCrossAxis?: boolean; onItemSizeChange?: (userKey: string, size: number) => void; /** Distinct from `onItemSizeChange(_, 0)` (rejected) — this is the explicit empty-row path. */ onItemEmpty?: (userKey: string) => void; diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts new file mode 100644 index 00000000..e1d9c256 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveCrossSize } from './resolveCrossSize'; + +const base = { + horizontal: false, + explicitCross: undefined as number | undefined, + parentCross: undefined as number | undefined, + measuredOuterCross: 0, + maxContentCross: 0, + crossPadding: 0, + estimatedItemSize: 50, +}; + +describe('resolveCrossSize', () => { + it('prefers an explicit cross size and marks it definite', () => { + const result = resolveCrossSize({ ...base, explicitCross: 400, parentCross: 300 }); + + expect(result).toEqual({ viewportCrossSize: 400, isDefinite: true }); + }); + + it('uses parent cell bounds for a vertical list and marks it definite', () => { + const result = resolveCrossSize({ ...base, parentCross: 320, measuredOuterCross: 280 }); + + expect(result).toEqual({ viewportCrossSize: 320, isDefinite: true }); + }); + + it('uses the measured outer size for a vertical list and marks it definite', () => { + const result = resolveCrossSize({ ...base, measuredOuterCross: 280, maxContentCross: 120 }); + + expect(result).toEqual({ viewportCrossSize: 280, isDefinite: true }); + }); + + it('ignores parent/measured cross for a horizontal list in favor of content', () => { + const result = resolveCrossSize({ + ...base, + horizontal: true, + parentCross: 600, + measuredOuterCross: 600, + maxContentCross: 180, + crossPadding: 10, + }); + + expect(result).toEqual({ viewportCrossSize: 190, isDefinite: false }); + }); + + it('falls back to parent cross for a horizontal list before content measures', () => { + const result = resolveCrossSize({ ...base, horizontal: true, parentCross: 600 }); + + expect(result).toEqual({ viewportCrossSize: 600, isDefinite: false }); + }); + + it('falls back to the measured outer size for a horizontal list before content measures', () => { + const result = resolveCrossSize({ ...base, horizontal: true, measuredOuterCross: 600 }); + + expect(result).toEqual({ viewportCrossSize: 600, isDefinite: false }); + }); + + it('falls back to the estimated item size when nothing has measured', () => { + const result = resolveCrossSize({ ...base }); + + expect(result).toEqual({ viewportCrossSize: 50, isDefinite: false }); + }); + + it('treats a zero explicit cross as unset', () => { + const result = resolveCrossSize({ ...base, explicitCross: 0, parentCross: 320 }); + + expect(result).toEqual({ viewportCrossSize: 320, isDefinite: true }); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts new file mode 100644 index 00000000..f1fd9661 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts @@ -0,0 +1,73 @@ +export interface ResolveCrossSizeInput { + horizontal: boolean | null | undefined; + /** Cross-axis size from the VL's own style (`h` for horizontal, `w` for vertical). */ + explicitCross: number | undefined; + /** Cross-axis size of the parent VirtualList cell, when nested. */ + parentCross: number | undefined; + /** Self-measured cross-axis size of the VL's outer element. */ + measuredOuterCross: number; + /** Largest cross-axis content measurement reported by cells so far. */ + maxContentCross: number; + crossPadding: number; + estimatedItemSize: number; +} + +export interface ResolvedCrossSize { + viewportCrossSize: number; + /** + * True when the size came from an external source (explicit style, parent + * cell bounds, or the flex-allocated outer size) rather than from content + * measurement or the estimate. Cells may safely pin their cross axis to a + * definite size; pinning a content-derived one would freeze it before the + * content gets a chance to report its real size. + */ + isDefinite: boolean; +} + +/** + * Resolves the viewport cross-axis size for a VirtualList. + * + * Cross-axis priority differs by orientation. Vertical: parent/measured + * cross is reliable (parent flex allocates column width; content sits + * behind a FlexBoundary and can't feed back into it). Horizontal: + * parent/measured cross is the OUTER cell's full height (title + this VL + * + siblings) which is bigger than the cards themselves — prefer + * content-driven `maxContentCross` and only fall back when no content + * has measured yet. Without the asymmetry the cells oscillate as the + * outer cell's measured height churns during scroll/focus animations. + */ +export function resolveCrossSize({ + horizontal, + explicitCross, + parentCross, + measuredOuterCross, + maxContentCross, + crossPadding, + estimatedItemSize, +}: ResolveCrossSizeInput): ResolvedCrossSize { + if (explicitCross != null && explicitCross > 0) { + return { viewportCrossSize: explicitCross, isDefinite: true }; + } + + if (!horizontal && parentCross != null && parentCross > 0) { + return { viewportCrossSize: parentCross, isDefinite: true }; + } + + if (!horizontal && measuredOuterCross > 0) { + return { viewportCrossSize: measuredOuterCross, isDefinite: true }; + } + + if (maxContentCross > 0) { + return { viewportCrossSize: maxContentCross + crossPadding, isDefinite: false }; + } + + if (parentCross != null && parentCross > 0) { + return { viewportCrossSize: parentCross, isDefinite: false }; + } + + if (measuredOuterCross > 0) { + return { viewportCrossSize: measuredOuterCross, isDefinite: false }; + } + + return { viewportCrossSize: estimatedItemSize, isDefinite: false }; +} From 28e4e41c40586530b6291e2f25d962d975e7ef56 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 15:02:46 +0200 Subject: [PATCH 19/66] fix(vendor): detach removed nodes from yoga parent so shrink-fit containers shrink (RNG-533) --- .../lightning-flex-shrink-on-child-removal.md | 7 ++++ .../plugin-flexbox/src/YogaManager.spec.ts | 36 +++++++++++++++++++ packages/plugin-flexbox/src/YogaManager.ts | 17 +++++++-- 3 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 .changeset/lightning-flex-shrink-on-child-removal.md diff --git a/.changeset/lightning-flex-shrink-on-child-removal.md b/.changeset/lightning-flex-shrink-on-child-removal.md new file mode 100644 index 00000000..493cf451 --- /dev/null +++ b/.changeset/lightning-flex-shrink-on-child-removal.md @@ -0,0 +1,7 @@ +--- +'@plextv/react-lightning-plugin-flexbox': patch +--- + +fix(flexbox): detach removed nodes from the yoga parent so shrink-fit containers shrink + +`removeNode` freed the child's yoga node and spliced the ManagerNode children array, but never called `parent.node.removeChild(child.node)` on the yoga nodes themselves (unlike `detachChildNode`). The freed child stayed in the parent's yoga child list, so on the next layout the parent kept laying it out and a shrink-to-content container never shrank back. Visible as buttons that grow to fit a label on focus but stay expanded after blur once the label is removed. Now the child is detached from its yoga parent before it's freed. diff --git a/packages/plugin-flexbox/src/YogaManager.spec.ts b/packages/plugin-flexbox/src/YogaManager.spec.ts index 8ea1327f..f3d814dd 100644 --- a/packages/plugin-flexbox/src/YogaManager.spec.ts +++ b/packages/plugin-flexbox/src/YogaManager.spec.ts @@ -200,6 +200,42 @@ describe('YogaManager', () => { expect(mockNode.free).toHaveBeenCalled(); }); + it('should detach the node from its yoga parent before freeing', () => { + const parentId = 1; + const childId = 2; + + yogaManager.addNode(parentId); + yogaManager.addNode(childId); + yogaManager.addChildNode(parentId, childId, 0); + + yogaManager.removeNode(childId); + + // Splicing only the ManagerNode children array leaves the freed child in + // the parent's yoga child list, so a shrink-fit parent keeps laying it + // out and never shrinks back. Detach from the yoga parent too. + expect(mockNode.removeChild).toHaveBeenCalledWith(mockNode); + }); + + it('should not detach from a parent that was already freed', () => { + const parentId = 1; + const childId = 2; + + yogaManager.addNode(parentId); + yogaManager.addNode(childId); + yogaManager.addChildNode(parentId, childId, 0); + + // React tears a subtree down root-first: the parent's yoga node is + // freed (via childRemoved) before the child's removeNode runs. Removing + // the child must not call removeChild on the parent's freed node, which + // is a use-after-free in yoga's wasm heap. + yogaManager.removeNode(parentId); + mockNode.removeChild.mockClear(); + + yogaManager.removeNode(childId); + + expect(mockNode.removeChild).not.toHaveBeenCalled(); + }); + it('should handle removing non-existent node', () => { yogaManager.removeNode(999); expect(mockNode.free).not.toHaveBeenCalled(); diff --git a/packages/plugin-flexbox/src/YogaManager.ts b/packages/plugin-flexbox/src/YogaManager.ts index e700e150..a3930dfd 100644 --- a/packages/plugin-flexbox/src/YogaManager.ts +++ b/packages/plugin-flexbox/src/YogaManager.ts @@ -235,17 +235,28 @@ export class YogaManager { const yogaNode = this._elementMap.get(elementId); if (yogaNode) { - yogaNode.node.free(); - - // Remove the node from its parent's children array + // Detach from the parent's yoga node before freeing. Splicing only the + // ManagerNode children array leaves the freed child in the parent's yoga + // child list, so the parent keeps laying it out and a shrink-fit parent + // never shrinks back. if (yogaNode.parent) { const index = yogaNode.parent.children.indexOf(yogaNode); if (index !== -1) { yogaNode.parent.children.splice(index, 1); } + + // Only detach while the parent is still alive. React tears subtrees + // down root-first, so a descendant's parent can already be freed by + // the time we get here; removeChild on a freed node corrupts yoga's + // heap (surfaces as a re-mounted subtree that never lays out). + if (this._elementMap.has(yogaNode.parent.id)) { + yogaNode.parent.node.removeChild(yogaNode.node); + } } + yogaNode.node.free(); + this._elementMap.delete(elementId); } } From e1549933bdba1ee116dd6cccb45b504d0dac5ad2 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 15:53:52 +0200 Subject: [PATCH 20/66] fix(vendor): feed border width into yoga so borders reserve layout --- .../plugin-flexbox/src/index.border.spec.ts | 40 ++++++++ packages/plugin-flexbox/src/index.ts | 32 +++++++ .../util/applyReactPropsToYoga.border.spec.ts | 92 +++++++++++++++++++ .../src/util/applyReactPropsToYoga.ts | 31 ++++++- .../src/util/isFlexStyleProp.ts | 6 ++ 5 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-flexbox/src/index.border.spec.ts create mode 100644 packages/plugin-flexbox/src/util/applyReactPropsToYoga.border.spec.ts diff --git a/packages/plugin-flexbox/src/index.border.spec.ts b/packages/plugin-flexbox/src/index.border.spec.ts new file mode 100644 index 00000000..200825e2 --- /dev/null +++ b/packages/plugin-flexbox/src/index.border.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { plugin } from './index'; +import { LightningManager } from './LightningManager'; + +// Border must reach both sides of the split: the numeric width goes to Yoga so +// it reserves the box (border-box), and the full border style stays on the +// element so the renderer still paints it. Losing either half is a regression +// (no layout reservation -> tabs jump; stripped from the renderer -> no ring). +describe('flexbox plugin transformProps border routing', () => { + function transform(style: Record) { + const applyStyle = vi + .spyOn(LightningManager.prototype, 'applyStyle') + .mockImplementation(() => {}); + + const p = plugin(); + // oxlint-disable-next-line typescript/no-explicit-any -- minimal fake element/props + const result = p.transformProps?.({ id: 1 } as any, { style } as any) as any; + + const flexStyles = applyStyle.mock.calls[0]?.[1]; + applyStyle.mockRestore(); + + return { flexStyles, remainingStyles: result.style }; + } + + it('sends the numeric border width to yoga and keeps the style for the renderer', () => { + const { flexStyles, remainingStyles } = transform({ + border: { w: 2, color: 0xffffffff }, + }); + + expect(flexStyles).toMatchObject({ border: 2 }); + expect(remainingStyles).toMatchObject({ border: { w: 2, color: 0xffffffff } }); + }); + + it('passes a zero border through so the reservation clears on deselect', () => { + const { flexStyles } = transform({ border: { w: 0, color: 0 } }); + + expect(flexStyles).toMatchObject({ border: 0 }); + }); +}); diff --git a/packages/plugin-flexbox/src/index.ts b/packages/plugin-flexbox/src/index.ts index 4bfa0008..b00f5e69 100644 --- a/packages/plugin-flexbox/src/index.ts +++ b/packages/plugin-flexbox/src/index.ts @@ -5,6 +5,29 @@ import { setFlexboxManager } from './manager'; import type { YogaOptions } from './types/YogaOptions'; import { flexProps, isFlexStyleProp } from './util/isFlexStyleProp'; +const BORDER_PROPS: ReadonlySet = new Set([ + 'border', + 'borderTop', + 'borderRight', + 'borderBottom', + 'borderLeft', +]); + +// Yoga only wants the numeric edge width; the renderer keeps the full +// border style (css-transform hands us `{ w, color }`, a bare number, or a +// per-edge number). +function borderWidth(value: unknown): number { + if (typeof value === 'number') { + return value; + } + + if (value != null && typeof value === 'object' && 'w' in value) { + return (value as { w?: number }).w ?? 0; + } + + return 0; +} + export function plugin(yogaOptions?: YogaOptions): Plugin { const lightningManager = new LightningManager(); @@ -63,6 +86,15 @@ export function plugin(yogaOptions?: YogaOptions): Plugin { // Width and height go to both flex and remaining styles flexStyles[key] = value; remainingStyles[key] = value; + } else if (BORDER_PROPS.has(key)) { + // Border reaches both: the renderer paints it, Yoga reserves its + // box (border-box, like react-native) so a `margin: -border` + // compensation doesn't shift the content when the border toggles. + remainingStyles[key] = value; + + if (value != null) { + flexStyles[key] = borderWidth(value); + } } else if (isFlexStyleProp(key) && value != null) { flexStyles[key] = value; } else { diff --git a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.border.spec.ts b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.border.spec.ts new file mode 100644 index 00000000..ca2b0016 --- /dev/null +++ b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.border.spec.ts @@ -0,0 +1,92 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { Node } from 'yoga-layout'; +import { loadYoga, type Yoga } from 'yoga-layout/load'; + +import type { LightningViewElementStyle } from '@plextv/react-lightning'; + +import type { YogaOptions } from '../types/YogaOptions'; +import { applyFlexPropToYoga } from './applyReactPropsToYoga'; + +// react-native feeds borderWidth into Yoga (border-box), so a border reserves +// layout space and content sits inside it. react-lightning painted the border +// but never told Yoga about it, so any component that adds a border on a state +// change (e.g. a selected tab) and compensates with `margin: -borderWidth` +// ended up shifting by the border width. These specs pin the border-box +// behaviour with real Yoga. + +const options = { expandToAutoFlexBasis: false } as YogaOptions; + +let yoga: Yoga; + +beforeAll(async () => { + yoga = await loadYoga(); +}); + +function apply(node: Node, style: Partial): void { + for (const key in style) { + applyFlexPropToYoga( + yoga, + options, + node, + // oxlint-disable-next-line typescript/no-explicit-any -- test helper + key as any, + style[key as keyof LightningViewElementStyle], + ); + } +} + +describe('applyFlexPropToYoga border', () => { + it('reserves the border width on every edge (object form)', () => { + const node = yoga.Node.create(); + + node.setWidth(100); + node.setHeight(40); + apply(node, { border: { w: 10, color: 0 } }); + node.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + + expect(node.getComputedBorder(yoga.EDGE_LEFT)).toBe(10); + expect(node.getComputedBorder(yoga.EDGE_TOP)).toBe(10); + expect(node.getComputedBorder(yoga.EDGE_RIGHT)).toBe(10); + expect(node.getComputedBorder(yoga.EDGE_BOTTOM)).toBe(10); + }); + + it('reserves the border width on every edge (number form)', () => { + const node = yoga.Node.create(); + + apply(node, { border: 4 }); + node.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + + expect(node.getComputedBorder(yoga.EDGE_LEFT)).toBe(4); + expect(node.getComputedBorder(yoga.EDGE_BOTTOM)).toBe(4); + }); + + // The tab case: an auto-sized box gains a border on select and pulls its + // content back out with `margin: -border`. With border-box that cancels + // exactly, so neither the box width nor the content position moves. + it('does not shift an auto-sized box when a -border margin compensates', () => { + function measure(border: number) { + const outer = yoga.Node.create(); + outer.setPadding(yoga.EDGE_HORIZONTAL, 8); + apply(outer, { border: { w: border, color: 0 } }); + + const child = yoga.Node.create(); + child.setWidth(50); + child.setHeight(20); + child.setMargin(yoga.EDGE_ALL, -border); + outer.insertChild(child, 0); + + outer.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + + return { + width: outer.getComputedWidth(), + childLeft: child.getComputedLeft(), + }; + } + + const plain = measure(0); + const bordered = measure(2); + + expect(bordered.width).toBe(plain.width); + expect(bordered.childLeft).toBe(plain.childLeft); + }); +}); diff --git a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts index 04adb316..fc9ca161 100644 --- a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts +++ b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts @@ -156,6 +156,14 @@ function applyFlex(node: Node, value?: string | number, expandToAutoFlexBasis = } } +function borderWidthOf(value: LightningViewElementStyle['border']): number { + if (value == null) { + return 0; + } + + return typeof value === 'number' ? value : (value.w ?? 0); +} + export default function applyReactPropsToYoga( yoga: Yoga, config: YogaOptions, @@ -196,7 +204,10 @@ export function applyFlexPropToYoga( } try { - const value = styleValue as Exclude; + const value = styleValue as Exclude< + LightningViewElementStyle[K], + Transform | { w: number; color: number } + >; switch (key) { case 'display': @@ -289,6 +300,24 @@ export function applyFlexPropToYoga( case 'paddingBlock': node.setPadding(yoga.EDGE_VERTICAL, value as LightningViewElementStyle['paddingBlock']); return true; + case 'border': + node.setBorder( + yoga.EDGE_ALL, + borderWidthOf(styleValue as LightningViewElementStyle['border']), + ); + return true; + case 'borderTop': + node.setBorder(yoga.EDGE_TOP, (value as number) ?? 0); + return true; + case 'borderRight': + node.setBorder(yoga.EDGE_RIGHT, (value as number) ?? 0); + return true; + case 'borderBottom': + node.setBorder(yoga.EDGE_BOTTOM, (value as number) ?? 0); + return true; + case 'borderLeft': + node.setBorder(yoga.EDGE_LEFT, (value as number) ?? 0); + return true; case 'flex': applyFlex(node, value, config.expandToAutoFlexBasis); return true; diff --git a/packages/plugin-flexbox/src/util/isFlexStyleProp.ts b/packages/plugin-flexbox/src/util/isFlexStyleProp.ts index 7bb83dfb..90129f53 100644 --- a/packages/plugin-flexbox/src/util/isFlexStyleProp.ts +++ b/packages/plugin-flexbox/src/util/isFlexStyleProp.ts @@ -53,6 +53,12 @@ export const flexProps = { left: true, right: true, bottom: true, + + border: true, + borderTop: true, + borderRight: true, + borderBottom: true, + borderLeft: true, } as const; flexProps satisfies Partial>; From 57aaeca83366c08dc329125874419a8323adb355 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 16:08:52 +0200 Subject: [PATCH 21/66] fix(vendor): let modifier key combos through the key press handler --- .../src/input/KeyPressHandler.tsx | 7 ++++++ .../src/input/hasModifierKey.spec.ts | 23 +++++++++++++++++++ .../src/input/hasModifierKey.ts | 13 +++++++++++ 3 files changed, 43 insertions(+) create mode 100644 packages/react-lightning/src/input/hasModifierKey.spec.ts create mode 100644 packages/react-lightning/src/input/hasModifierKey.ts diff --git a/packages/react-lightning/src/input/KeyPressHandler.tsx b/packages/react-lightning/src/input/KeyPressHandler.tsx index 4f635c76..c2eeef5e 100644 --- a/packages/react-lightning/src/input/KeyPressHandler.tsx +++ b/packages/react-lightning/src/input/KeyPressHandler.tsx @@ -3,6 +3,7 @@ import { useContext, useEffect, useRef } from 'react'; import { useFocusManager } from '../focus/useFocusManager'; import { bubbleEvent } from './bubbleEvent'; +import { hasModifierKey } from './hasModifierKey'; import type { KeyMap } from './KeyMapContext'; import { KeyMapContext } from './KeyMapContext'; import { Keys } from './Keys'; @@ -23,6 +24,12 @@ export const KeyPressHandler: FC<{ children: ReactNode }> = ({ children }) => { return; } + // Modifier combos (Cmd+Opt+I, etc.) are host shortcuts, not remote input. + // Let them through so devtools and browser/Storybook shortcuts still work. + if (hasModifierKey(event)) { + return; + } + // Build the normalized event once and reuse for all bubbleEvent calls. const keyEvent = normalizeKeyEvent(event, keyMap, element); const { remoteKey } = keyEvent; diff --git a/packages/react-lightning/src/input/hasModifierKey.spec.ts b/packages/react-lightning/src/input/hasModifierKey.spec.ts new file mode 100644 index 00000000..f9a1032c --- /dev/null +++ b/packages/react-lightning/src/input/hasModifierKey.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { hasModifierKey, type ModifierKeyEvent } from './hasModifierKey'; + +function event(overrides: Partial = {}): ModifierKeyEvent { + return { metaKey: false, ctrlKey: false, altKey: false, ...overrides }; +} + +describe('hasModifierKey', () => { + it('is false when no modifier is held', () => { + expect(hasModifierKey(event())).toBe(false); + }); + + it('is true when meta, ctrl, or alt is held', () => { + expect(hasModifierKey(event({ metaKey: true }))).toBe(true); + expect(hasModifierKey(event({ ctrlKey: true }))).toBe(true); + expect(hasModifierKey(event({ altKey: true }))).toBe(true); + }); + + it('ignores shift so plain keycodes still map (shift does not form a host shortcut here)', () => { + expect(hasModifierKey(event({ shiftKey: true } as Partial))).toBe(false); + }); +}); diff --git a/packages/react-lightning/src/input/hasModifierKey.ts b/packages/react-lightning/src/input/hasModifierKey.ts new file mode 100644 index 00000000..e07b655f --- /dev/null +++ b/packages/react-lightning/src/input/hasModifierKey.ts @@ -0,0 +1,13 @@ +/** The modifier flags of a {@link KeyboardEvent} the key pipeline cares about. */ +export type ModifierKeyEvent = Pick; + +/** + * True when a Cmd/Ctrl/Alt modifier is held. A TV remote never sends modifiers, + * so these events are host shortcuts (devtools, select-all, Storybook keys) that + * the framework should let through rather than swallow. Shift is deliberately + * ignored: it doesn't form a host shortcut here and the keycode map is + * shift-independent. + */ +export function hasModifierKey(event: ModifierKeyEvent): boolean { + return event.metaKey || event.ctrlKey || event.altKey; +} From bf888e038f495c4bd47ec0d9551c12d979e3db6f Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 17:06:11 +0200 Subject: [PATCH 22/66] fix(vendor): key virtuallist focus-follow snap off header/footer size, not padding --- .../resolveFocusScrollTarget.spec.ts | 99 +++++++++++++++++++ .../VirtualList/resolveFocusScrollTarget.ts | 60 +++++++++++ .../VirtualList/useScrollHandler.ts | 39 ++++---- 3 files changed, 176 insertions(+), 22 deletions(-) create mode 100644 packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts new file mode 100644 index 00000000..82b4f8ad --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveFocusScrollTarget } from './resolveFocusScrollTarget'; + +// A start-aligned row centered by large symmetric padding, like the +// "Who's watching?" user picker: item 0 sits half a viewport in, so the +// focused item's target equals its index step and must not snap to an edge. +const centeredRow = { + viewportSize: 1920, + snapToAlignment: 'start' as const, + paddingStart: 848, + paddingEnd: 1696, + headerSize: 0, + footerSize: 0, + maxScroll: 848 + 5 * 233 + 1696 - 1920, +}; + +describe('resolveFocusScrollTarget', () => { + it('start-aligns the focused item by its padding', () => { + expect( + resolveFocusScrollTarget({ + ...centeredRow, + childOffset: 848 + 3 * 233, + childSize: 224, + }), + ).toBe(3 * 233); + }); + + it('does not snap a near-start centered target to 0 (no header to protect)', () => { + // Regression: with the old threshold (paddingStart + headerSize) this + // near-start target fell inside the centering padding and snapped to 0. + expect( + resolveFocusScrollTarget({ + ...centeredRow, + childOffset: 848 + 1 * 233, + childSize: 224, + }), + ).toBe(1 * 233); + }); + + it('does not snap a near-end centered target to maxScroll (no footer to protect)', () => { + const target = resolveFocusScrollTarget({ + ...centeredRow, + childOffset: 848 + 4 * 233, + childSize: 224, + }); + + expect(target).toBe(4 * 233); + expect(target).toBeLessThan(centeredRow.maxScroll); + }); + + it('snaps to 0 to keep a real header fully visible', () => { + expect( + resolveFocusScrollTarget({ + viewportSize: 1920, + snapToAlignment: 'start', + paddingStart: 48, + paddingEnd: 48, + headerSize: 120, + footerSize: 0, + maxScroll: 5000, + childOffset: 48 + 120, + childSize: 256, + }), + ).toBe(0); + }); + + it('snaps to maxScroll to keep a real footer fully visible', () => { + expect( + resolveFocusScrollTarget({ + viewportSize: 1920, + snapToAlignment: 'start', + paddingStart: 48, + paddingEnd: 48, + headerSize: 0, + footerSize: 120, + maxScroll: 5000, + childOffset: 5048, + childSize: 256, + }), + ).toBe(5000); + }); + + it('centers when asked', () => { + expect( + resolveFocusScrollTarget({ + viewportSize: 1920, + snapToAlignment: 'center', + paddingStart: 0, + paddingEnd: 0, + headerSize: 0, + footerSize: 0, + maxScroll: 5000, + childOffset: 1000, + childSize: 200, + }), + ).toBe(1000 + 100 - 960); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts new file mode 100644 index 00000000..dc38887c --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts @@ -0,0 +1,60 @@ +export interface FocusScrollTargetParams { + /** Focused child's main-axis offset within the content container. */ + childOffset: number; + /** Focused child's main-axis size. */ + childSize: number; + viewportSize: number; + snapToAlignment: 'start' | 'center' | 'end'; + /** Main-axis start padding (scroll margin). */ + paddingStart: number; + /** Main-axis end padding (scroll margin). */ + paddingEnd: number; + /** Header main-axis size, excluding padding. */ + headerSize: number; + /** Footer main-axis size, excluding padding. */ + footerSize: number; + maxScroll: number; +} + +// Scroll offset that brings the focused child into the requested alignment. +// +// The edge snap keeps a real header/footer fully visible when the target lands +// inside it. It keys off the header/footer size, NOT the leading/trailing +// padding: a large centering padding (the switch-user row pads by ~half the +// viewport on each side) would otherwise pull every near-start target to 0 and +// every near-end target to maxScroll, so centering only worked in the middle. +export function resolveFocusScrollTarget({ + childOffset, + childSize, + viewportSize, + snapToAlignment, + paddingStart, + paddingEnd, + headerSize, + footerSize, + maxScroll, +}: FocusScrollTargetParams): number { + let target: number; + + switch (snapToAlignment) { + case 'center': + target = childOffset + childSize / 2 - viewportSize / 2; + break; + case 'end': + target = childOffset + childSize - viewportSize + paddingEnd; + break; + default: + target = childOffset - paddingStart; + break; + } + + if (target > 0 && target <= headerSize) { + return 0; + } + + if (target < maxScroll && target >= maxScroll - footerSize) { + return maxScroll; + } + + return target; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts index b8349bc0..a25b0441 100644 --- a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts +++ b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts @@ -5,6 +5,8 @@ import type { LightningElement } from '@plextv/react-lightning'; import type { LayoutManager } from './LayoutManager'; import type { ScrollEvent } from './VirtualListTypes'; +import { resolveFocusScrollTarget } from './resolveFocusScrollTarget'; + export interface UseScrollHandlerOptions { layoutManager: LayoutManager; horizontal: boolean | null; @@ -216,28 +218,21 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan const childOffset = horizontal ? pos.x : pos.y; const childSize = horizontal ? child.node.w : child.node.h; - let target: number; - - switch (snapToAlignment) { - case 'center': - target = childOffset + childSize / 2 - viewportSize / 2; - break; - case 'end': - target = childOffset + childSize - viewportSize + paddingEnd; - break; - default: - target = childOffset - paddingStart; - break; - } - - // Snap to edges to keep header/footer visible when near them - const footerAreaSize = totalContentSize - itemAreaOffset - layoutManager.totalSize; - - if (target > 0 && target <= itemAreaOffset) { - target = 0; - } else if (target < maxScroll && target >= maxScroll - footerAreaSize) { - target = maxScroll; - } + const headerSize = itemAreaOffset - paddingStart; + const footerSize = + totalContentSize - itemAreaOffset - layoutManager.totalSize - paddingEnd; + + const target = resolveFocusScrollTarget({ + childOffset, + childSize, + viewportSize, + snapToAlignment, + paddingStart, + paddingEnd, + headerSize, + footerSize, + maxScroll, + }); scrollToOffset(target, true); } From 64aa64d2a8edb4b291f54396b31b793adea27658 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 17:20:30 +0200 Subject: [PATCH 23/66] fix(vendor): fold border-radius into the linear-gradient shader instead of dropping it --- .../src/element/LightningViewElement.ts | 112 ++++++++++++++---- 1 file changed, 88 insertions(+), 24 deletions(-) diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index c59e4393..9c680ced 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -15,7 +15,10 @@ import type { import type { Fiber } from 'react-reconciler'; import { EventEmitter, type IEventEmitter } from 'tseep'; -import { getNodeResizeObserver, type NodeResizeObserver } from '../observer/NodeResizeObserver'; +import { + getNodeResizeObserver, + type NodeResizeObserver, +} from '../observer/NodeResizeObserver'; import type { Plugin } from '../render/Plugin'; import { type Focusable, @@ -62,7 +65,10 @@ function __checkProps(props: string[]) { } } -function createTexture(renderer: RendererMain, textureDef: TextureDef): Texture { +function createTexture( + renderer: RendererMain, + textureDef: TextureDef, +): Texture { return renderer.createTexture(textureDef.type, textureDef.props); } @@ -70,8 +76,10 @@ let idCounter = 0; export class LightningViewElement< TStyleProps extends LightningViewElementStyle = LightningViewElementStyle, - TProps extends LightningViewElementProps = LightningViewElementProps, -> implements Focusable { + TProps extends + LightningViewElementProps = LightningViewElementProps, +> implements Focusable +{ public static allElements: Record = {}; public readonly id: number; @@ -102,7 +110,8 @@ export class LightningViewElement< private _withheldAlpha = 1; private _eventEmitter = new EventEmitter(); private _deferTarget: LightningElement | null = null; - private _deferNodeRemovalHandler: ((destroy: () => void) => void) | null = null; + private _deferNodeRemovalHandler: ((destroy: () => void) => void) | null = + null; private _resizeObserver: NodeResizeObserver | null = null; private _isObservingResize = false; @@ -173,7 +182,11 @@ export class LightningViewElement< } public set parent(parent) { - if (parent && this._parent === parent && this._parent.node === parent.node) { + if ( + parent && + this._parent === parent && + this._parent.node === parent.node + ) { return; } @@ -348,7 +361,10 @@ export class LightningViewElement< const lngProps = this._toLightningNodeProps(this.props, true); - this._styleProxy = new Proxy(this.props.style ?? {}, this._styleProxyHandler); + this._styleProxy = new Proxy( + this.props.style ?? {}, + this._styleProxyHandler, + ); if (import.meta.env.DEV) { __checkProps(Object.keys(lngProps)); @@ -404,7 +420,9 @@ export class LightningViewElement< this._eventEmitter.emit('destroy'); } - public on = (...args: Parameters['on']>): (() => void) => { + public on = ( + ...args: Parameters['on']> + ): (() => void) => { this._eventEmitter.on(...args); if (args[0] === 'resized') { @@ -463,12 +481,17 @@ export class LightningViewElement< this.recalculateVisibility(); } - public insertChild(child: LightningElement, beforeChild?: LightningElement | null): void { + public insertChild( + child: LightningElement, + beforeChild?: LightningElement | null, + ): void { if (child.parent === this && child.parent.node === this.node) { return; } - const index = beforeChild ? this.children.indexOf(beforeChild) : this.children.length; + const index = beforeChild + ? this.children.indexOf(beforeChild) + : this.children.length; if (beforeChild) { this.children.splice(index, 0, child); @@ -677,7 +700,8 @@ export class LightningViewElement< const prevFocusable = this.focusable; const prevVisible = this._visible; - this._visible = this.node.alpha > 0 && (!this.parent || this.parent.visible); + this._visible = + this.node.alpha > 0 && (!this.parent || this.parent.visible); if (this._visible !== prevVisible) { this._eventEmitter.emit('visibilityChanged', this._visible); @@ -707,7 +731,9 @@ export class LightningViewElement< ).start(); } - public animateShader(props: Partial): IAnimationController { + public animateShader( + props: Partial, + ): IAnimationController { return this._createAnimation( { shaderProps: props, @@ -727,7 +753,8 @@ export class LightningViewElement< }; private _reconcileResizeObserving(): void { - const shouldObserve = this.props.onResize != null || this._eventEmitter.hasListeners('resized'); + const shouldObserve = + this.props.onResize != null || this._eventEmitter.hasListeners('resized'); if (shouldObserve === this._isObservingResize) { return; @@ -747,7 +774,10 @@ export class LightningViewElement< } // Don't pass down the `data` prop to the lightning node. - private _createNode({ data: _data, ...props }: Partial): RendererNode { + private _createNode({ + data: _data, + ...props + }: Partial): RendererNode { const node = this.isTextElement ? this._renderer.createTextNode(props) : this._renderer.createNode(props); @@ -853,7 +883,10 @@ export class LightningViewElement< } if (hasStyleChanges) { - this._eventEmitter.emit('stylesChanged', this.props.style as Partial); + this._eventEmitter.emit( + 'stylesChanged', + this.props.style as Partial, + ); } this._isUpdateQueued = false; @@ -976,7 +1009,10 @@ export class LightningViewElement< this.recalculateVisibility(); } - this._eventEmitter.emit('stylesChanged', this.props.style as Partial); + this._eventEmitter.emit( + 'stylesChanged', + this.props.style as Partial, + ); this._isUpdateQueued = false; @@ -1035,7 +1071,9 @@ export class LightningViewElement< return animation; } - private _getShaderFromStyle(style: TStyleProps | undefined | null): ShaderDef | undefined { + private _getShaderFromStyle( + style: TStyleProps | undefined | null, + ): ShaderDef | undefined { if (!style) { return; } @@ -1061,7 +1099,14 @@ export class LightningViewElement< hasRounded = true; } - if (border || borderColor || borderTop || borderLeft || borderRight || borderBottom) { + if ( + border || + borderColor || + borderTop || + borderLeft || + borderRight || + borderBottom + ) { if (type && type === 'Rounded') { type = 'RoundedWithBorder'; } else { @@ -1099,10 +1144,22 @@ export class LightningViewElement< } if (type) { - if (linearGradient && import.meta.env.DEV) { - console.warn( - `Warning: element ${this.id} sets both a background gradient and a border/radius. A node can only carry one shader, so the border/radius wins and the gradient is dropped.`, - ); + if (linearGradient) { + // Radius-only node: fold the radius into the gradient so it rounds its + // own corners (a node carries one shader, so a separate Rounded shader + // would drop the gradient). A real border still can't combine and wins. + if (type === 'Rounded') { + return { + type: 'LinearGradient', + props: { ...linearGradient, radius: props.radius }, + }; + } + + if (import.meta.env.DEV) { + console.warn( + `Warning: element ${this.id} sets both a background gradient and a border. A node can only carry one shader, so the border wins and the gradient is dropped.`, + ); + } } return { type, props }; @@ -1200,7 +1257,10 @@ export class LightningViewElement< this._shaderDef.props ) { this.animateShader(this._shaderDef.props); - } else if (this._shaderDef.type === oldShader?.type && this.shader.props) { + } else if ( + this._shaderDef.type === oldShader?.type && + this.shader.props + ) { for (const [key, value] of Object.entries(this._shaderDef.props)) { // Gate on key existence, not truthiness: a prop whose current value // is falsy (e.g. a transparent `border-color` of 0) must still be @@ -1232,7 +1292,11 @@ export class LightningViewElement< const finalProps = Object.assign(otherProps, finalStyle); - if (initial === true && this.isImageElement === false && finalProps.color === undefined) { + if ( + initial === true && + this.isImageElement === false && + finalProps.color === undefined + ) { // set default color to 0 for all elements except image elements finalProps.color = 0; } From 6c700e0fe11c5764a2a737521a8c53b3e7eb8fed Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 18:04:07 +0200 Subject: [PATCH 24/66] fix(vendor): skip empty focus groups in spatial nav and autofocus --- .changeset/focus-skip-empty-groups.md | 5 ++ .../src/focus/FocusKeyManager.ts | 10 ++- .../src/focus/FocusManager.spec.ts | 58 ++++++++++++++ .../react-lightning/src/focus/FocusManager.ts | 75 +++++++++++++------ .../src/mocks/createMockElement.ts | 2 + 5 files changed, 128 insertions(+), 22 deletions(-) create mode 100644 .changeset/focus-skip-empty-groups.md diff --git a/.changeset/focus-skip-empty-groups.md b/.changeset/focus-skip-empty-groups.md new file mode 100644 index 00000000..dde8334e --- /dev/null +++ b/.changeset/focus-skip-empty-groups.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +A FocusGroup with no focusable descendant is now skipped by spatial navigation and autoFocus instead of acting as a focus stop. Groups only delegate focus to their children, so a group wrapping non-interactive content (e.g. a list section header) shouldn't be a target; real leaves (Pressable, a `focusable` View) are unaffected. Effective focusability tracks `hasFocusableChildren` and propagates up the ancestor chain, so a group flips back the moment a focusable child mounts (or its last one is removed). diff --git a/packages/react-lightning/src/focus/FocusKeyManager.ts b/packages/react-lightning/src/focus/FocusKeyManager.ts index 429218da..c251894c 100644 --- a/packages/react-lightning/src/focus/FocusKeyManager.ts +++ b/packages/react-lightning/src/focus/FocusKeyManager.ts @@ -8,7 +8,15 @@ import type { FocusManager, FocusNode } from './FocusManager'; function* childElements(children: FocusNode[]): Iterable { for (let i = 0; i < children.length; i++) { // oxlint-disable-next-line typescript/no-non-null-assertion -- bounds-checked loop - yield children[i]!.element; + const child = children[i]!; + + // A focus group with no focusable descendant only wraps non-interactive + // content (a list header); skip it so nav lands on a real target. + if (child.element.isFocusGroup && !child.hasFocusableChildren) { + continue; + } + + yield child.element; } } diff --git a/packages/react-lightning/src/focus/FocusManager.spec.ts b/packages/react-lightning/src/focus/FocusManager.spec.ts index 2fe49a73..63551b2f 100644 --- a/packages/react-lightning/src/focus/FocusManager.spec.ts +++ b/packages/react-lightning/src/focus/FocusManager.spec.ts @@ -758,4 +758,62 @@ describe('FocusManager', () => { expect(focusManager.focusPath).toEqual([modalParent]); }); }); + + describe('non-interactive focus groups', () => { + it('does not focus an empty focus group', () => { + const group = createMockElement(1, 'emptyGroup'); + group.isFocusGroup = true; + + focusManager.addElement(group, null, { autoFocus: true }); + + expect(group.focused).toBe(false); + expect(focusManager.focusPath).toEqual([]); + }); + + it('skips an empty focus group and focuses a real sibling', () => { + const group = createMockElement(1, 'emptyGroup'); + group.isFocusGroup = true; + const leaf = createMockElement(2, 'leaf'); + + focusManager.addElement(group, null, { autoFocus: false }); + focusManager.addElement(leaf, null, { autoFocus: false }); + + expect(focusManager.focusPath).toEqual([leaf]); + }); + + it('focuses a group once it gains a focusable child', () => { + const group = createMockElement(1, 'group'); + group.isFocusGroup = true; + const child = createMockElement(2, 'child'); + + focusManager.addElement(group, null, { autoFocus: false }); + expect(focusManager.focusPath).toEqual([]); + + focusManager.addElement(child, group, { autoFocus: false }); + expect(focusManager.focusPath).toEqual([group, child]); + }); + + it('moves focus off a group when its last focusable child is removed', () => { + const group = createMockElement(1, 'group'); + group.isFocusGroup = true; + const child = createMockElement(2, 'child'); + const sibling = createMockElement(3, 'sibling'); + + focusManager.addElement(group, null, { autoFocus: false }); + focusManager.addElement(child, group, { autoFocus: false }); + focusManager.addElement(sibling, null, { autoFocus: false }); + expect(focusManager.focusPath).toEqual([group, child]); + + focusManager.removeElement(child); + expect(focusManager.focusPath).toEqual([sibling]); + }); + + it('still focuses a normal leaf element that has no children', () => { + const leaf = createMockElement(1, 'leaf'); + + focusManager.addElement(leaf, null, { autoFocus: true }); + + expect(focusManager.focusPath).toEqual([leaf]); + }); + }); }); diff --git a/packages/react-lightning/src/focus/FocusManager.ts b/packages/react-lightning/src/focus/FocusManager.ts index 3e3fd2b2..c04cb470 100644 --- a/packages/react-lightning/src/focus/FocusManager.ts +++ b/packages/react-lightning/src/focus/FocusManager.ts @@ -241,7 +241,7 @@ export class FocusManager< this._checkFocusableChildren(parentNode); - if (child.focusable && !hasExternalRedirect(childNode)) { + if (this._isEffectivelyFocusable(childNode) && !hasExternalRedirect(childNode)) { if (!parentNode.focusedElement) { // No preferred child yet — take the slot regardless of autoFocus. parentNode.focusedElement = childNode; @@ -750,6 +750,12 @@ export class FocusManager< this.activeLayer.elements.delete(node.element); if (isTopMostParentNode) { + // Removing a child can empty a focus-group parent; recompute so its + // effective focusability and the ancestor chain update. + if (!isRootNode(node.parent) && node.parent.element.isFocusGroup) { + this._checkFocusableChildren(node.parent); + } + this._recalculateFocusPath(); } @@ -760,16 +766,21 @@ export class FocusManager< this._removeEventListeners(node); } + // A focus group only delegates, so it's a target only with a focusable + // descendant. Leaves (Pressable, focusable View) always are. + private _isEffectivelyFocusable(node: FocusNode): boolean { + if (!node.element.focusable) { + return false; + } + + return !node.element.isFocusGroup || node.hasFocusableChildren; + } + private _checkFocusableChildren(parentNode: FocusNode | RootNode) { + const previous = parentNode.hasFocusableChildren; const children = parentNode.children; const childrenLength = children.length; - if (childrenLength === 0) { - parentNode.hasFocusableChildren = false; - - return; - } - const leafNodes = new Set(); let hasFocusableChildren = false; @@ -777,7 +788,7 @@ export class FocusManager< // oxlint-disable-next-line typescript/no-non-null-assertion -- Already asserted that child exists const child = children[i]!; - if (child.element.focusable) { + if (this._isEffectivelyFocusable(child)) { hasFocusableChildren = true; } @@ -788,24 +799,45 @@ export class FocusManager< parentNode.hasFocusableChildren = hasFocusableChildren; - // Early return if no leaf nodes to check - if (leafNodes.size === 0) { - return; - } - // Check each child for leaf node ancestry and update focusability - for (let i = 0; i < childrenLength; i++) { - // oxlint-disable-next-line typescript/no-non-null-assertion -- Already asserted that child exists - const child = children[i]!; + if (leafNodes.size > 0) { + for (let i = 0; i < childrenLength; i++) { + // oxlint-disable-next-line typescript/no-non-null-assertion -- Already asserted that child exists + const child = children[i]!; - if (this._hasLeafParent(child.element, leafNodes, parentNode.element)) { - child.element.focusable = false; + if (this._hasLeafParent(child.element, leafNodes, parentNode.element)) { + child.element.focusable = false; - if (parentNode.focusedElement === child) { - parentNode.focusedElement = this._findNextBestFocus(parentNode, child); + if (parentNode.focusedElement === child) { + parentNode.focusedElement = this._findNextBestFocus(parentNode, child); + } } } } + + // A group's effective focusability tracks hasFocusableChildren, so a flip + // here has to refresh the ancestor chain (non-group parents don't). + if ( + previous !== hasFocusableChildren && + !isRootNode(parentNode) && + parentNode.element.isFocusGroup + ) { + this._propagateFocusableChange(parentNode); + } + } + + private _propagateFocusableChange(node: FocusNode) { + const parent = node.parent; + + if (this._isEffectivelyFocusable(node)) { + if (!parent.focusedElement && !hasExternalRedirect(node)) { + parent.focusedElement = node; + } + } else if (parent.focusedElement === node) { + parent.focusedElement = this._findNextBestFocus(parent, node); + } + + this._checkFocusableChildren(parent); } private _hasLeafParent(element: T, leafNodes: Set, parentNode: T | null): boolean { @@ -844,7 +876,8 @@ export class FocusManager< const newChild = parent.children[i]; if ( - newChild?.element.focusable && + newChild && + this._isEffectivelyFocusable(newChild) && !hasExternalRedirect(newChild) && newChild !== relativeNode ) { diff --git a/packages/react-lightning/src/mocks/createMockElement.ts b/packages/react-lightning/src/mocks/createMockElement.ts index ba12cd38..67ad1640 100644 --- a/packages/react-lightning/src/mocks/createMockElement.ts +++ b/packages/react-lightning/src/mocks/createMockElement.ts @@ -5,6 +5,8 @@ export class MockElement implements Focusable, EventNotifier { private _focusable = true; private _focused = false; + public isFocusGroup = false; + public constructor( public id = 0, public name = '', From 2cf683783b766ced8d81399428e9ef61c305e4c3 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 18:04:44 +0200 Subject: [PATCH 25/66] fix(vendor): skip virtuallist focus-follow while a pointer is suppressing it --- .../components/VirtualList/useScrollHandler.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts index a25b0441..5d622859 100644 --- a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts +++ b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts @@ -7,6 +7,17 @@ import type { ScrollEvent } from './VirtualListTypes'; import { resolveFocusScrollTarget } from './resolveFocusScrollTarget'; +// Lightning Magic Remote / mouse support (in the host app) installs this hook +// while a pointer is driving focus. Read it off globalThis so this subtree stays +// free of app imports; undefined (a no-op) on every platform that never loads it. +const isPointerFocusScrollSuppressed = (): boolean => { + const fn = ( + globalThis as { __plexShouldSuppressPointerFocusScroll?: () => boolean } + ).__plexShouldSuppressPointerFocusScroll; + + return typeof fn === 'function' && fn(); +}; + export interface UseScrollHandlerOptions { layoutManager: LayoutManager; horizontal: boolean | null; @@ -208,6 +219,12 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan } function handleChildFocused(child: LightningElement): void { + // Pointer hover moves focus; don't scroll to follow it or the row slides out + // from under a stationary cursor and the next click misses. + if (isPointerFocusScrollSuppressed()) { + return; + } + const el = contentRef.current; if (!el) { From 99d9ff286b9b8d7728081e6d34a7775c58f115c6 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 20:21:20 +0200 Subject: [PATCH 26/66] fix(vendor): map logical start/end position insets to yoga edges --- .changeset/flexbox-logical-position-insets.md | 5 ++ .../plugin-flexbox/src/types/FlexStyles.ts | 4 ++ .../applyReactPropsToYoga.position.spec.ts | 66 +++++++++++++++++++ .../src/util/applyReactPropsToYoga.ts | 6 ++ .../src/util/isFlexStyleProp.ts | 2 + 5 files changed, 83 insertions(+) create mode 100644 .changeset/flexbox-logical-position-insets.md create mode 100644 packages/plugin-flexbox/src/util/applyReactPropsToYoga.position.spec.ts diff --git a/.changeset/flexbox-logical-position-insets.md b/.changeset/flexbox-logical-position-insets.md new file mode 100644 index 00000000..7ae2f754 --- /dev/null +++ b/.changeset/flexbox-logical-position-insets.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-plugin-flexbox': patch +--- + +Logical `start`/`end` position insets now map to yoga's `EDGE_START`/`EDGE_END` (LTR), matching the existing logical margin/padding handling. Previously they were silently dropped, so an absolutely positioned box pinned with `end: 0` fell back to the left edge. diff --git a/packages/plugin-flexbox/src/types/FlexStyles.ts b/packages/plugin-flexbox/src/types/FlexStyles.ts index cfa20dce..8e7838db 100644 --- a/packages/plugin-flexbox/src/types/FlexStyles.ts +++ b/packages/plugin-flexbox/src/types/FlexStyles.ts @@ -75,6 +75,10 @@ export type FlexLightningBaseElementStyle = { right?: DimensionValue; /** Only affects flex layouts */ bottom?: DimensionValue; + /** Logical inline-start inset. LTR: same as `left`. */ + start?: DimensionValue; + /** Logical inline-end inset. LTR: same as `right`. */ + end?: DimensionValue; }; export interface FlexContainer { diff --git a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.position.spec.ts b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.position.spec.ts new file mode 100644 index 00000000..3f26cbfa --- /dev/null +++ b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.position.spec.ts @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { Node } from 'yoga-layout'; +import { loadYoga, type Yoga } from 'yoga-layout/load'; + +import type { LightningViewElementStyle } from '@plextv/react-lightning'; + +import type { YogaOptions } from '../types/YogaOptions'; +import { applyFlexPropToYoga } from './applyReactPropsToYoga'; + +// RN ships logical start/end insets (LTR: start=left, end=right). react-lightning +// mapped logical margins/paddings but never the position insets, so an absolutely +// positioned box pinned with `end: 0` fell back to the left edge. These pin the +// logical-inset positioning with real Yoga. + +const options = { expandToAutoFlexBasis: false } as YogaOptions; + +let yoga: Yoga; + +beforeAll(async () => { + yoga = await loadYoga(); +}); + +function apply(node: Node, style: Partial): void { + for (const key in style) { + applyFlexPropToYoga( + yoga, + options, + node, + // oxlint-disable-next-line typescript/no-explicit-any -- test helper + key as any, + style[key as keyof LightningViewElementStyle], + ); + } +} + +function layoutChild(style: Partial): number { + const parent = yoga.Node.create(); + parent.setWidth(200); + parent.setHeight(100); + + const child = yoga.Node.create(); + child.setWidth(50); + child.setHeight(20); + apply(child, { position: 'absolute', ...style }); + parent.insertChild(child, 0); + + parent.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + + return child.getComputedLeft(); +} + +describe('applyFlexPropToYoga logical position insets', () => { + it('pins `end: 0` to the right edge (LTR)', () => { + // parent 200 - child 50 - end 0 => left 150 + expect(layoutChild({ end: 0 })).toBe(150); + }); + + it('offsets `end` inward by its value', () => { + // parent 200 - child 50 - end 20 => left 130 + expect(layoutChild({ end: 20 })).toBe(130); + }); + + it('pins `start` to the left edge plus its value (LTR)', () => { + expect(layoutChild({ start: 30 })).toBe(30); + }); +}); diff --git a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts index fc9ca161..56880d37 100644 --- a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts +++ b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts @@ -372,6 +372,12 @@ export function applyFlexPropToYoga( case 'top': node.setPosition(yoga.EDGE_TOP, (value as LightningViewElementStyle['top']) ?? 0); return true; + case 'start': + node.setPosition(yoga.EDGE_START, (value as LightningViewElementStyle['left']) ?? 0); + return true; + case 'end': + node.setPosition(yoga.EDGE_END, (value as LightningViewElementStyle['right']) ?? 0); + return true; } } catch (err) { console.error(err); diff --git a/packages/plugin-flexbox/src/util/isFlexStyleProp.ts b/packages/plugin-flexbox/src/util/isFlexStyleProp.ts index 90129f53..36001c8b 100644 --- a/packages/plugin-flexbox/src/util/isFlexStyleProp.ts +++ b/packages/plugin-flexbox/src/util/isFlexStyleProp.ts @@ -53,6 +53,8 @@ export const flexProps = { left: true, right: true, bottom: true, + start: true, + end: true, border: true, borderTop: true, From 0ef7c92ea3d635f87ebb44c988569977cdededeb Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 20:26:00 +0200 Subject: [PATCH 27/66] fix(vendor): pin virtuallist header/footer cross axis when the list width is definite --- .changeset/virtuallist-pin-section-cross-axis.md | 5 +++++ .../src/components/VirtualList/VirtualList.tsx | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 .changeset/virtuallist-pin-section-cross-axis.md diff --git a/.changeset/virtuallist-pin-section-cross-axis.md b/.changeset/virtuallist-pin-section-cross-axis.md new file mode 100644 index 00000000..31d2eb01 --- /dev/null +++ b/.changeset/virtuallist-pin-section-cross-axis.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +VirtualList header and footer now pin their FlexRoot's cross axis under the same definiteness rule as the cells, so flex content (e.g. a stretch Column) fills the list width instead of shrink-fitting to its widest child. diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index 52e9bff0..aabbdff4 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -159,6 +159,16 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef {isInFlex ? ( - + {renderListComponent(ListHeaderComponent)} ) : ( @@ -668,7 +678,7 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef {isInFlex ? ( - + {renderListComponent(ListFooterComponent)} ) : ( From ea0132629f2d75aef37dd975cca549a3b1175a98 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 21:45:51 +0200 Subject: [PATCH 28/66] fix(vendor): keep the rounded shader on partial style updates --- .changeset/lightning-shader-partial-update.md | 12 ++++++++++++ .../src/element/LightningViewElement.ts | 15 ++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 .changeset/lightning-shader-partial-update.md diff --git a/.changeset/lightning-shader-partial-update.md b/.changeset/lightning-shader-partial-update.md new file mode 100644 index 00000000..363f9d9f --- /dev/null +++ b/.changeset/lightning-shader-partial-update.md @@ -0,0 +1,12 @@ +--- +'@plextv/react-lightning': patch +--- + +fix(react-lightning): keep the rounded/border shader on partial style updates + +A partial style update (reanimated pushing just opacity/transform straight to +setProps) recomputed the node's shader from that partial style, found no +borderRadius/border, and cleared the Rounded shader. Any animated rounded node +squared off the moment reanimated touched it. Only rebuild or clear the shader +when the update actually carries a shader-relevant prop (or an explicit shader +override); otherwise keep the existing one. diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index 9c680ced..cf319d88 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -1244,8 +1244,21 @@ export class LightningViewElement< ); } + // Reanimated pushes partial style updates (just opacity/transform) straight + // to setProps. Recomputing the shader from that partial style finds no + // borderRadius/border and clears the Rounded shader, squaring off a node + // mid-animation. Keep the existing shader when this update carries no + // shader-relevant prop and no explicit shader override. + const updateTouchesShaderProp = + style != null && + Object.keys(style).some((key) => + LightningViewElement._shaderStyleProps.has(key), + ); const oldShader = this._shaderDef; - this._shaderDef = shader || styleShader; + this._shaderDef = + shader === undefined && !updateTouchesShaderProp && !styleShader && oldShader + ? oldShader + : shader || styleShader; if (this._shaderDef?.props) { // if the shader is the same as the previous one, we don't need to recreate it From 9628e0a6bdb918029d1b780f01a0656c6fa0bfe6 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 8 Jul 2026 22:13:15 +0200 Subject: [PATCH 29/66] fix(vendor): cap virtuallist viewport at stage edge, honor per-row scrollSnapAlign --- .../components/VirtualList/VirtualList.tsx | 23 ++++++- .../capSelfMeasuredViewport.spec.ts | 23 +++++++ .../VirtualList/capSelfMeasuredViewport.ts | 26 ++++++++ .../resolveChildSnapAlignment.spec.ts | 52 +++++++++++++++ .../VirtualList/resolveChildSnapAlignment.ts | 35 ++++++++++ .../resolveVisibleMainSpan.spec.ts | 66 +++++++++++++++++++ .../VirtualList/resolveVisibleMainSpan.ts | 12 ++++ .../VirtualList/useScrollHandler.ts | 5 +- 8 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 packages/react-lightning-components/src/components/VirtualList/capSelfMeasuredViewport.spec.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/capSelfMeasuredViewport.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.spec.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/resolveVisibleMainSpan.spec.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/resolveVisibleMainSpan.ts diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index aabbdff4..7d029087 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -19,12 +19,14 @@ import { } from '@plextv/react-lightning'; import { FlexBoundary, FlexRoot, useIsInFlex } from '@plextv/react-lightning-plugin-flexbox'; +import { capSelfMeasuredViewport } from './capSelfMeasuredViewport'; import { computeItemRect } from './computeItemRect'; import { LayoutManager } from './LayoutManager'; import { parseContentStyle } from './parseContentStyle'; import { RecyclerPool } from './RecyclerPool'; import { resolveCrossSize } from './resolveCrossSize'; import { resolveSectionSize } from './resolveSectionSize'; +import { resolveVisibleMainSpan } from './resolveVisibleMainSpan'; import { useScrollHandler } from './useScrollHandler'; import { useViewability } from './useViewability'; import { VirtualListCell } from './VirtualListCell'; @@ -110,6 +112,10 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef>(() => new Map()); const [measuredSize, setMeasuredSize] = useState({ w: 0, h: 0 }); + // Visible main-axis span from the list's stage position to the stage edge, + // tracked on resize. Caps the self-measured viewport fallback below. + const outerElementRef = useRef(null); + const [visibleMainSpan, setVisibleMainSpan] = useState(0); const [, setLayoutVersion] = useState(0); const [separatorSize, setSeparatorSize] = useState(0); const separatorSizeRef = useRef(0); @@ -139,7 +145,7 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef 0 ? measuredOuterMain : 0); + explicitMain ?? parentMain ?? capSelfMeasuredViewport(measuredOuterMain, visibleMainSpan); const explicitCross = horizontal ? (style?.h as number | undefined) @@ -478,6 +484,20 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef { setMeasuredSize((prev) => (prev.w === event.w && prev.h === event.h ? prev : event)); + + // A canvas-overflowing (or overflow-margin-inflated) list is flex-sized + // past the screen; capSelfMeasuredViewport needs the visible span to rein + // the viewport back to what is on screen. Horizontal too: the centered + // switch-user row inflates its width via a negative right margin. + const el = outerElementRef.current; + + if (el) { + const root = el.rootElement; + const pos = el.getRelativePosition(root); + const span = resolveVisibleMainSpan(horizontal, root.node.w, root.node.h, pos.x, pos.y); + + setVisibleMainSpan((prev) => (prev === span ? prev : span)); + } }; useImperativeHandle(ref, () => ({ @@ -633,6 +653,7 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef { + it('returns 0 when the outer element has not measured yet', () => { + expect(capSelfMeasuredViewport(0, 440)).toBe(0); + }); + + it('keeps the measured size when it fits within the visible span', () => { + expect(capSelfMeasuredViewport(400, 480)).toBe(400); + }); + + it('caps a content-sized measurement at the visible span', () => { + expect(capSelfMeasuredViewport(2400, 440)).toBe(440); + }); + + it('leaves the measured size uncapped when the visible span is unknown', () => { + // span 0: not measured yet. span < 0: the list starts past the stage edge. + expect(capSelfMeasuredViewport(2400, 0)).toBe(2400); + expect(capSelfMeasuredViewport(2400, -100)).toBe(2400); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/capSelfMeasuredViewport.ts b/packages/react-lightning-components/src/components/VirtualList/capSelfMeasuredViewport.ts new file mode 100644 index 00000000..ca082153 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/capSelfMeasuredViewport.ts @@ -0,0 +1,26 @@ +/** + * Caps a self-measured main-axis viewport at the list's visible span (stage + * edge minus the list's stage position). + * + * A list with no explicit main size and no parent cell bounds is flex-sized, + * and when nothing bounds it (the layout overflows the canvas) flex gives it + * its full content size. Using that as the viewport makes maxScroll 0, so the + * list renders everything and never scrolls to follow focus. Only the + * self-measured fallback is capped — explicit and parent-derived sizes are + * definite and stay trusted. + * + * A non-positive span means it is unknown (unmounted, or the list starts past + * the stage edge); the measurement passes through uncapped rather than + * collapsing the list. + */ +export function capSelfMeasuredViewport(measuredMain: number, visibleMainSpan: number): number { + if (measuredMain <= 0) { + return 0; + } + + if (visibleMainSpan <= 0) { + return measuredMain; + } + + return Math.min(measuredMain, visibleMainSpan); +} diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.spec.ts new file mode 100644 index 00000000..4b86716d --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import type { LightningElement } from '@plextv/react-lightning'; + +import { resolveChildSnapAlignment } from './resolveChildSnapAlignment'; + +function createMockElement( + props: Record, + children: LightningElement[] = [], +): LightningElement { + return { props, children } as unknown as LightningElement; +} + +describe('resolveChildSnapAlignment', () => { + it('returns the alignment carried by the starting element', () => { + const cell = createMockElement({ scrollSnapAlign: 'center' }); + + expect(resolveChildSnapAlignment(cell)).toBe('center'); + }); + + it('descends first children to the row root', () => { + const row = createMockElement({ scrollSnapAlign: 'center' }); + const flexRoot = createMockElement({}, [row]); + const cell = createMockElement({}, [flexRoot]); + + expect(resolveChildSnapAlignment(cell)).toBe('center'); + }); + + it('ignores rows that carry no alignment', () => { + const row = createMockElement({}); + const cell = createMockElement({}, [createMockElement({}, [row])]); + + expect(resolveChildSnapAlignment(cell)).toBeUndefined(); + }); + + it('ignores values that are not valid alignments', () => { + const row = createMockElement({ scrollSnapAlign: 'sideways' }); + const cell = createMockElement({}, [row]); + + expect(resolveChildSnapAlignment(cell)).toBeUndefined(); + }); + + it('stops descending past the depth cap', () => { + let deep = createMockElement({ scrollSnapAlign: 'center' }); + + for (let i = 0; i < 6; i++) { + deep = createMockElement({}, [deep]); + } + + expect(resolveChildSnapAlignment(deep)).toBeUndefined(); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.ts b/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.ts new file mode 100644 index 00000000..df345d5a --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.ts @@ -0,0 +1,35 @@ +import type { LightningElement } from '@plextv/react-lightning'; + +type SnapAlignment = 'start' | 'center' | 'end'; + +const VALID_ALIGNMENTS: ReadonlySet = new Set(['start', 'center', 'end']); + +// The row root is a first-child descent away (cell FocusGroup -> FlexRoot -> +// row); the cap only bounds the walk on rows with deep single-child chains. +const MAX_DEPTH = 5; + +/** + * The focused row's own `scrollSnapAlign`, read from the cell's content. + * + * react-native-tvos lets each list row override the list-level snap alignment + * (`snapToAlignment="item"` defers entirely to the rows). The prop rides on + * the row's Pressable/View and passes through to the Lightning element. + * Focus events hand the list its direct child (the cell wrapper), so the row + * root is found by descending first children; separators render after the + * content, so the first child is always the content side. + */ +export function resolveChildSnapAlignment(cell: LightningElement): SnapAlignment | undefined { + let curr: LightningElement | null = cell; + + for (let depth = 0; curr && depth < MAX_DEPTH; depth++) { + const value = (curr.props as { scrollSnapAlign?: unknown }).scrollSnapAlign; + + if (typeof value === 'string' && VALID_ALIGNMENTS.has(value)) { + return value as SnapAlignment; + } + + curr = curr.children[0] ?? null; + } + + return undefined; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveVisibleMainSpan.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveVisibleMainSpan.spec.ts new file mode 100644 index 00000000..f8d86518 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveVisibleMainSpan.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { capSelfMeasuredViewport } from './capSelfMeasuredViewport'; +import { resolveFocusScrollTarget } from './resolveFocusScrollTarget'; +import { resolveVisibleMainSpan } from './resolveVisibleMainSpan'; + +describe('resolveVisibleMainSpan', () => { + it('measures a horizontal list from its left edge to the stage right edge', () => { + expect(resolveVisibleMainSpan(true, 1920, 1080, 0, 300)).toBe(1920); + }); + + it('measures a vertical list from its top edge to the stage bottom edge', () => { + expect(resolveVisibleMainSpan(false, 1920, 1080, 200, 300)).toBe(780); + }); + + it('grows the span for a full-bleed list that starts off the left edge', () => { + // marginLeft pulls the list start negative; it overflows nothing on screen. + expect(resolveVisibleMainSpan(true, 1920, 1080, -848, 0)).toBe(2768); + }); + + // The centered switch-user row: its -848 overflow margin inflates the + // self-measured width to 2768, but only 1920 is on screen. Capping to the + // visible span is what lets center snap land the focused tile at 960. + it('caps the inflated switch-user viewport and centers the focused tile', () => { + const rawMeasured = 2768; + const span = resolveVisibleMainSpan(true, 1920, 1080, 0, 0); + const viewportSize = capSelfMeasuredViewport(rawMeasured, span); + + expect(viewportSize).toBe(1920); + + const target = resolveFocusScrollTarget({ + childOffset: 1144, + childSize: 224, + viewportSize, + snapToAlignment: 'center', + paddingStart: 848, + paddingEnd: 1696, + headerSize: 0, + footerSize: 0, + maxScroll: 4248 - viewportSize, + }); + + // Centers the tile in the visible 1920: 1144 + 112 - 960. + expect(target).toBe(296); + // On-screen tile center = childOffset - target + childSize / 2 = 960. + expect(1144 - target + 224 / 2).toBe(960); + }); + + it('does not center against the off-screen width when left uncapped', () => { + // Regression guard: with the raw 2768 viewport the center target goes + // negative and clamps to 0, so the tile never leaves its start position. + const target = resolveFocusScrollTarget({ + childOffset: 1144, + childSize: 224, + viewportSize: 2768, + snapToAlignment: 'center', + paddingStart: 848, + paddingEnd: 1696, + headerSize: 0, + footerSize: 0, + maxScroll: 4248 - 2768, + }); + + expect(target).toBeLessThan(0); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveVisibleMainSpan.ts b/packages/react-lightning-components/src/components/VirtualList/resolveVisibleMainSpan.ts new file mode 100644 index 00000000..6029bf89 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveVisibleMainSpan.ts @@ -0,0 +1,12 @@ +// Main-axis distance from the list's start edge to the far stage edge. Caps a +// self-measured viewport to what's on screen (horizontal reads width/x, +// vertical height/y); negative list starts (full-bleed overflow) grow it. +export function resolveVisibleMainSpan( + horizontal: boolean | null | undefined, + rootWidth: number, + rootHeight: number, + listX: number, + listY: number, +): number { + return horizontal ? rootWidth - listX : rootHeight - listY; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts index 5d622859..1126dffc 100644 --- a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts +++ b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts @@ -5,6 +5,7 @@ import type { LightningElement } from '@plextv/react-lightning'; import type { LayoutManager } from './LayoutManager'; import type { ScrollEvent } from './VirtualListTypes'; +import { resolveChildSnapAlignment } from './resolveChildSnapAlignment'; import { resolveFocusScrollTarget } from './resolveFocusScrollTarget'; // Lightning Magic Remote / mouse support (in the host app) installs this hook @@ -243,7 +244,9 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan childOffset, childSize, viewportSize, - snapToAlignment, + // A row's own scrollSnapAlign wins over the list-level alignment, + // matching react-native-tvos (snapToAlignment="item" defers to rows). + snapToAlignment: resolveChildSnapAlignment(child) ?? snapToAlignment, paddingStart, paddingEnd, headerSize, From a618824f59a6d78c88c4f5a3493e07cdc11ca409 Mon Sep 17 00:00:00 2001 From: Ruud Date: Thu, 9 Jul 2026 11:21:04 +0200 Subject: [PATCH 30/66] fix(vendor): honor caller easing in withTiming --- .../src/animation/resolveTimingEasing.test.ts | 27 +++++++++++++++++++ .../src/animation/resolveTimingEasing.ts | 27 +++++++++++++++++++ .../plugin-reanimated/src/animation/timing.ts | 7 +++-- 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts create mode 100644 packages/plugin-reanimated/src/animation/resolveTimingEasing.ts diff --git a/packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts b/packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts new file mode 100644 index 00000000..db953480 --- /dev/null +++ b/packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveTimingEasing } from './resolveTimingEasing'; + +describe('resolveTimingEasing', () => { + it('passes a function easing through unchanged', () => { + const fn = (t: number) => t * t; + + expect(resolveTimingEasing(fn)).toBe(fn); + }); + + it('resolves an Easing.bezier factory object to its function', () => { + const produced = (t: number) => t; + const factoryObj = { factory: () => produced }; + + expect(resolveTimingEasing(factoryObj)).toBe(produced); + }); + + it('falls back to linear when easing is missing', () => { + expect(resolveTimingEasing(undefined)).toBe('linear'); + }); + + it('falls back to linear for an unrecognized easing value', () => { + expect(resolveTimingEasing({ nope: true })).toBe('linear'); + expect(resolveTimingEasing('ease-in')).toBe('linear'); + }); +}); diff --git a/packages/plugin-reanimated/src/animation/resolveTimingEasing.ts b/packages/plugin-reanimated/src/animation/resolveTimingEasing.ts new file mode 100644 index 00000000..c7888d8d --- /dev/null +++ b/packages/plugin-reanimated/src/animation/resolveTimingEasing.ts @@ -0,0 +1,27 @@ +import type { AnimationSettings } from '@lightningjs/renderer'; + +type EasingFactory = { factory: () => AnimationSettings['easing'] }; + +function hasFactory(value: unknown): value is EasingFactory { + return ( + value != null && + typeof value === 'object' && + typeof (value as EasingFactory).factory === 'function' + ); +} + +// reanimated Easing.* are functions; Easing.bezier(...) returns a { factory } +// object. The renderer's CoreAnimation takes a function easing directly and +// resolves a string via getTimingFunction, so pass functions through, unwrap +// the factory, and fall back to linear for anything else. +export function resolveTimingEasing(easing: unknown): AnimationSettings['easing'] { + if (typeof easing === 'function') { + return easing as AnimationSettings['easing']; + } + + if (hasFactory(easing)) { + return easing.factory(); + } + + return 'linear'; +} diff --git a/packages/plugin-reanimated/src/animation/timing.ts b/packages/plugin-reanimated/src/animation/timing.ts index cabfcb19..61b1558b 100644 --- a/packages/plugin-reanimated/src/animation/timing.ts +++ b/packages/plugin-reanimated/src/animation/timing.ts @@ -1,17 +1,16 @@ import type { AnimationSettings } from '@lightningjs/renderer'; import type { WithTimingConfig } from 'react-native-reanimated-original'; -import { ReduceMotion } from 'react-native-reanimated-original'; + +import { resolveTimingEasing } from './resolveTimingEasing'; const DefaultTimingConfig = { duration: 300, - easing: (t: number) => t, - reduceMotion: ReduceMotion.System, }; export function createTimingAnimation(config?: WithTimingConfig): AnimationSettings { return { duration: config?.duration ?? DefaultTimingConfig.duration, - easing: 'linear', + easing: resolveTimingEasing(config?.easing), delay: config?.delay ?? 0, loop: false, repeat: 0, From f17bd8433f56faee99bdf80e1d77a3cb61045cb4 Mon Sep 17 00:00:00 2001 From: Ruud Date: Thu, 9 Jul 2026 11:39:51 +0200 Subject: [PATCH 31/66] fix(vendor): play composed reanimated animations (sequence/repeat/delay) on the node --- .../src/animation/AnimatedValue.ts | 30 ++++++ .../src/animation/animationProgram.test.ts | 99 +++++++++++++++++ .../src/animation/animationProgram.ts | 101 ++++++++++++++++++ .../src/animation/runAnimationProgram.ts | 85 +++++++++++++++ .../src/exports/useAnimatedStyle.ts | 46 ++++++-- .../src/exports/withDelay.tsx | 7 ++ .../src/exports/withRepeat.ts | 8 ++ .../src/exports/withSequence.ts | 29 +++-- .../utils/toLightningAnimationAndStyles.ts | 60 +++++++++-- 9 files changed, 440 insertions(+), 25 deletions(-) create mode 100644 packages/plugin-reanimated/src/animation/animationProgram.test.ts create mode 100644 packages/plugin-reanimated/src/animation/animationProgram.ts create mode 100644 packages/plugin-reanimated/src/animation/runAnimationProgram.ts diff --git a/packages/plugin-reanimated/src/animation/AnimatedValue.ts b/packages/plugin-reanimated/src/animation/AnimatedValue.ts index 1678c992..b3c1bc54 100644 --- a/packages/plugin-reanimated/src/animation/AnimatedValue.ts +++ b/packages/plugin-reanimated/src/animation/AnimatedValue.ts @@ -7,6 +7,12 @@ import type { } from 'react-native-reanimated-original'; import { AnimationType } from '../types/AnimationType'; +import { + type AnimationProgram, + firstLeaf, + leafProgram, + restingValue, +} from './animationProgram'; import { createSpringAnimation } from './spring'; import { createTimingAnimation } from './timing'; @@ -20,6 +26,10 @@ export class AnimatedValue { public value: AnimatableValue; public lngAnimation: AnimationSettings; public callback?: AnimationCallback; + // Set once withSequence/withRepeat(sequence)/withDelay compose steps. A plain + // withTiming/withSpring leaves it undefined and takes the direct transition + // path; a program is played step-by-step against the node instead. + public program?: AnimationProgram; public constructor( type: TType, @@ -33,6 +43,26 @@ export class AnimatedValue { this.callback = callback; } + public static fromProgram(program: AnimationProgram): AnimatedValue { + const value = new AnimatedValue(AnimationType.Timing, restingValue(program) ?? 0); + const first = firstLeaf(program); + + if (first) { + value.lngAnimation = first.lngAnimation; + } + + value.program = program; + + return value; + } + + public toProgram(): AnimationProgram { + return ( + this.program ?? + leafProgram({ toValue: this.value, lngAnimation: this.lngAnimation }) + ); + } + private _getLightningAnimationSettings(config?: AnimationConfigType[TType]): AnimationSettings { switch (this.type) { case AnimationType.Spring: diff --git a/packages/plugin-reanimated/src/animation/animationProgram.test.ts b/packages/plugin-reanimated/src/animation/animationProgram.test.ts new file mode 100644 index 00000000..b5a6f545 --- /dev/null +++ b/packages/plugin-reanimated/src/animation/animationProgram.test.ts @@ -0,0 +1,99 @@ +import type { AnimationSettings } from '@lightningjs/renderer'; +import { describe, expect, it } from 'vitest'; + +import { + type AnimationProgram, + delayProgram, + firstLeaf, + leafProgram, + repeatProgram, + mapProgram, + restingValue, + sequenceProgram, +} from './animationProgram'; + +const settings = (over: Partial = {}): AnimationSettings => ({ + duration: 100, + easing: 'linear', + delay: 0, + loop: false, + repeat: 0, + stopMethod: false, + ...over, +}); + +const leaf = (toValue: number, over?: Partial): AnimationProgram => + leafProgram({ toValue, lngAnimation: settings(over) }); + +describe('animationProgram', () => { + it('wraps a single step as a leaf', () => { + const p = leaf(10); + + expect(p).toEqual({ + kind: 'leaf', + leaf: { toValue: 10, lngAnimation: settings() }, + }); + }); + + it('builds a sequence preserving child order', () => { + const p = sequenceProgram([leaf(1), leaf(2), leaf(3)]); + + expect(p.kind).toBe('sequence'); + expect((p as { children: AnimationProgram[] }).children.map(restingValue)).toEqual([1, 2, 3]); + }); + + it('resting value is the last leaf of a sequence', () => { + expect(restingValue(sequenceProgram([leaf(1), leaf(2), leaf(3)]))).toBe(3); + }); + + it('first leaf is the first leaf of a sequence', () => { + const p = sequenceProgram([leaf(7), leaf(8)]); + + expect(firstLeaf(p)?.toValue).toBe(7); + }); + + it('wraps a child in a repeat with count and reverse', () => { + const seq = sequenceProgram([leaf(1), leaf(2)]); + const p = repeatProgram(seq, -1, false); + + expect(p).toEqual({ kind: 'repeat', child: seq, count: -1, reverse: false }); + }); + + it('resting value of a repeat is its child resting value', () => { + expect(restingValue(repeatProgram(sequenceProgram([leaf(1), leaf(2)]), 3, false))).toBe(2); + }); + + it('delay sets the delay on the first leaf only, without mutating the source', () => { + const inner = settings(); + const p = sequenceProgram([leafProgram({ toValue: 5, lngAnimation: inner }), leaf(6)]); + const delayed = delayProgram(p, 1000); + + expect(firstLeaf(delayed)?.lngAnimation.delay).toBe(1000); + expect(firstLeaf(delayed)?.toValue).toBe(5); + // source untouched + expect(inner.delay).toBe(0); + // later leaves keep their delay + expect(restingValue(delayed)).toBe(6); + }); + + it('nested sequence resolves first/resting through the tree', () => { + const p = sequenceProgram([ + leaf(1), + repeatProgram(sequenceProgram([leaf(2), leaf(3)]), -1, false), + ]); + + expect(firstLeaf(p)?.toValue).toBe(1); + expect(restingValue(p)).toBe(3); + }); + it('mapProgram maps every leaf target and keeps the tree shape', () => { + const p = sequenceProgram([ + leaf(1), + repeatProgram(sequenceProgram([leaf(2), leaf(3)]), -1, false), + ]); + const mapped = mapProgram(p, (v) => (v as number) * 10); + + expect(firstLeaf(mapped)?.toValue).toBe(10); + expect(restingValue(mapped)).toBe(30); + expect(mapped.kind).toBe('sequence'); + }); +}); diff --git a/packages/plugin-reanimated/src/animation/animationProgram.ts b/packages/plugin-reanimated/src/animation/animationProgram.ts new file mode 100644 index 00000000..ddd1baea --- /dev/null +++ b/packages/plugin-reanimated/src/animation/animationProgram.ts @@ -0,0 +1,101 @@ +import type { AnimationSettings } from '@lightningjs/renderer'; +import type { AnimatableValue } from 'react-native-reanimated-original'; + +export type ProgramLeaf = { + toValue: AnimatableValue; + lngAnimation: AnimationSettings; +}; + +// A program is the composition tree for withSequence / withRepeat / withDelay. +// A single withTiming/withSpring stays off this path (see AnimatedValue); the +// tree only exists once steps are chained. delay folds onto the first leaf. +export type AnimationProgram = + | { kind: 'leaf'; leaf: ProgramLeaf } + | { kind: 'sequence'; children: AnimationProgram[] } + | { kind: 'repeat'; child: AnimationProgram; count: number; reverse: boolean }; + +export function leafProgram(leaf: ProgramLeaf): AnimationProgram { + return { kind: 'leaf', leaf }; +} + +export function sequenceProgram(children: AnimationProgram[]): AnimationProgram { + return { kind: 'sequence', children }; +} + +export function repeatProgram( + child: AnimationProgram, + count: number, + reverse: boolean, +): AnimationProgram { + return { kind: 'repeat', child, count, reverse }; +} + +// Prepend a delay by overriding the first leaf's delay (clones so a cached +// lngAnimation, e.g. spring's, is never mutated). +export function delayProgram(program: AnimationProgram, delayMs: number): AnimationProgram { + switch (program.kind) { + case 'leaf': + return leafProgram({ + toValue: program.leaf.toValue, + lngAnimation: { ...program.leaf.lngAnimation, delay: delayMs }, + }); + case 'sequence': { + const [head, ...rest] = program.children; + + if (!head) { + return program; + } + + return sequenceProgram([delayProgram(head, delayMs), ...rest]); + } + case 'repeat': + return repeatProgram(delayProgram(program.child, delayMs), program.count, program.reverse); + } +} + +export function firstLeaf(program: AnimationProgram): ProgramLeaf | undefined { + switch (program.kind) { + case 'leaf': + return program.leaf; + case 'sequence': { + const first = program.children[0]; + + return first ? firstLeaf(first) : undefined; + } + case 'repeat': + return firstLeaf(program.child); + } +} + +export function restingValue(program: AnimationProgram): AnimatableValue | undefined { + switch (program.kind) { + case 'leaf': + return program.leaf.toValue; + case 'sequence': { + const last = program.children[program.children.length - 1]; + + return last ? restingValue(last) : undefined; + } + case 'repeat': + return restingValue(program.child); + } +} + +// Map every leaf target through fn (e.g. translateX px stays as the x value), +// keeping the tree shape and each leaf's animation settings. +export function mapProgram( + program: AnimationProgram, + fn: (value: AnimatableValue) => AnimatableValue, +): AnimationProgram { + switch (program.kind) { + case 'leaf': + return leafProgram({ + toValue: fn(program.leaf.toValue), + lngAnimation: program.leaf.lngAnimation, + }); + case 'sequence': + return sequenceProgram(program.children.map((child) => mapProgram(child, fn))); + case 'repeat': + return repeatProgram(mapProgram(program.child, fn), program.count, program.reverse); + } +} diff --git a/packages/plugin-reanimated/src/animation/runAnimationProgram.ts b/packages/plugin-reanimated/src/animation/runAnimationProgram.ts new file mode 100644 index 00000000..2c19d06c --- /dev/null +++ b/packages/plugin-reanimated/src/animation/runAnimationProgram.ts @@ -0,0 +1,85 @@ +import type { IAnimationController } from '@lightningjs/renderer'; + +import type { LightningElement, LightningElementStyle } from '@plextv/react-lightning'; + +import type { AnimationProgram, ProgramLeaf } from './animationProgram'; + +export type CancelAnimation = () => void; + +// Play a composed program against one node prop: register each step's transition, +// animate to its target, wait for the node to report it stopped, then advance. +// Sequences chain, repeats loop (count < 0 = forever). Reverse isn't needed by +// any current consumer, so it plays forward. +export function runAnimationProgram( + view: LightningElement, + prop: keyof LightningElementStyle, + program: AnimationProgram, +): CancelAnimation { + let cancelled = false; + let current: IAnimationController | undefined; + + const playLeaf = async (leaf: ProgramLeaf): Promise => { + if (cancelled || view.recycled) { + return; + } + + try { + view.setProps({ transition: { [prop]: leaf.lngAnimation } } as never); + + const animateStyle = view.animateStyle as ( + key: keyof LightningElementStyle, + value: unknown, + ) => IAnimationController; + const controller = animateStyle(prop, leaf.toValue); + + current = controller; + + await controller.waitUntilStopped(); + } catch { + // node was destroyed or recycled mid-flight; stop quietly + cancelled = true; + } + }; + + const play = async (node: AnimationProgram): Promise => { + if (cancelled) { + return; + } + + switch (node.kind) { + case 'leaf': + await playLeaf(node.leaf); + break; + case 'sequence': + for (const child of node.children) { + if (cancelled) { + break; + } + + await play(child); + } + break; + case 'repeat': { + const infinite = node.count < 0; + + for (let i = 0; (infinite || i < node.count) && !cancelled; i++) { + await play(node.child); + } + + break; + } + } + }; + + void play(program); + + return () => { + cancelled = true; + + try { + current?.stop(); + } catch { + // ignore + } + }; +} diff --git a/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts b/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts index 89bbf85e..3f5e10e4 100644 --- a/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts +++ b/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts @@ -6,17 +6,33 @@ import type { DefaultStyle } from 'react-native-reanimated/lib/typescript/hook/c import type { LightningElement, LightningElementStyle } from '@plextv/react-lightning'; +import { + type CancelAnimation, + runAnimationProgram, +} from '../animation/runAnimationProgram'; import type { AnimatedObject } from '../types/AnimatedObject'; import type { AnimatedStyle } from '../types/AnimatedStyle'; -import { toLightningAnimationAndStyles } from '../utils/toLightningAnimationAndStyles'; +import { + type ScheduledAnimation, + toLightningAnimationAndStyles, +} from '../utils/toLightningAnimationAndStyles'; type UseAnimatedStyleFn = (...args: Parameters) => AnimatedStyle; +type Runners = WeakMap; + function setStyles( view: LightningElement, transition: ReturnType['transition'], style: ReturnType['style'], + schedules: ScheduledAnimation[], + runners: Runners, ): void { + // Cancel any program still playing on this view before re-applying, so a + // reset (e.g. a shared value set back to a static value) stops the old one. + runners.get(view)?.forEach((cancel) => cancel()); + runners.delete(view); + view.setProps({ transition, // setProps expects lightning props, but we will just pass through the raw @@ -24,25 +40,36 @@ function setStyles( // converting the CSS styles to lightning style: style as LightningElementStyle, }); + + if (schedules.length) { + runners.set( + view, + schedules.map((schedule) => + runAnimationProgram(view, schedule.prop, schedule.program), + ), + ); + } } type AppliedStyles = { transition: ReturnType['transition']; style: ReturnType['style']; + schedules: ScheduledAnimation[]; } | null; function computeAndSetStyles( updater: () => AnimatedObject, views: Set, lastApplied: { current: AppliedStyles }, + runners: Runners, ): void { const computedStyle = updater(); - const { transition, style } = toLightningAnimationAndStyles(computedStyle); + const { transition, style, schedules } = toLightningAnimationAndStyles(computedStyle); - lastApplied.current = { transition, style }; + lastApplied.current = { transition, style, schedules }; for (const view of views) { - setStyles(view, transition, style); + setStyles(view, transition, style, schedules, runners); } } @@ -50,6 +77,7 @@ let idCount = 0; export const useAnimatedStyle: UseAnimatedStyleFn = (updater, dependencies) => { const [views] = useState(() => new Set()); + const [runners] = useState(() => new WeakMap()); const inputs: DependencyList = dependencies ?? []; const timerRef = useRef(0); const lastApplied = useRef(null); @@ -62,7 +90,7 @@ export const useAnimatedStyle: UseAnimatedStyleFn = (updater, dependencies) => { } timerRef.current = window.setTimeout(() => { - computeAndSetStyles(updater, views, lastApplied); + computeAndSetStyles(updater, views, lastApplied, runners); timerRef.current = 0; }, 2); }; @@ -97,7 +125,13 @@ export const useAnimatedStyle: UseAnimatedStyleFn = (updater, dependencies) => { // fresh value here, that would push states the normal flow never emitted. applyToView: (view: LightningElement) => { if (lastApplied.current) { - setStyles(view, lastApplied.current.transition, lastApplied.current.style); + setStyles( + view, + lastApplied.current.transition, + lastApplied.current.style, + lastApplied.current.schedules, + runners, + ); } }, }; diff --git a/packages/plugin-reanimated/src/exports/withDelay.tsx b/packages/plugin-reanimated/src/exports/withDelay.tsx index dfd61375..815d6c3e 100644 --- a/packages/plugin-reanimated/src/exports/withDelay.tsx +++ b/packages/plugin-reanimated/src/exports/withDelay.tsx @@ -1,4 +1,5 @@ import type { AnimatedValue } from '../animation/AnimatedValue'; +import { delayProgram } from '../animation/animationProgram'; export type WithDelayFn = ( delayMs: number, @@ -7,6 +8,12 @@ export type WithDelayFn = ( ) => AnimatedValue; export const withDelay: WithDelayFn = (delayMs, animation) => { + if (animation.program) { + animation.program = delayProgram(animation.program, delayMs); + + return animation; + } + animation.lngAnimation.delay = delayMs; return animation; diff --git a/packages/plugin-reanimated/src/exports/withRepeat.ts b/packages/plugin-reanimated/src/exports/withRepeat.ts index 226d1cf6..e36f4e58 100644 --- a/packages/plugin-reanimated/src/exports/withRepeat.ts +++ b/packages/plugin-reanimated/src/exports/withRepeat.ts @@ -1,4 +1,5 @@ import type { AnimatedValue } from '../animation/AnimatedValue'; +import { repeatProgram } from '../animation/animationProgram'; export type WithRepeatFn = ( animation: AnimatedValue, @@ -11,6 +12,13 @@ export const withRepeat: WithRepeatFn = ( repeatCount = 2, reverse = false, ) => { + if (animation.program) { + animation.program = repeatProgram(animation.program, repeatCount, reverse); + + return animation; + } + + // Single step: let the renderer loop it directly (cheap, GPU-driven). animation.lngAnimation.loop = repeatCount === -1; animation.lngAnimation.repeat = repeatCount; animation.lngAnimation.stopMethod = reverse ? 'reverse' : false; diff --git a/packages/plugin-reanimated/src/exports/withSequence.ts b/packages/plugin-reanimated/src/exports/withSequence.ts index 0712d980..d229bc72 100644 --- a/packages/plugin-reanimated/src/exports/withSequence.ts +++ b/packages/plugin-reanimated/src/exports/withSequence.ts @@ -1,24 +1,31 @@ import type { AnimatableValue, - AnimationObject, + ReduceMotion, withSequence as withSequenceRN, } from 'react-native-reanimated-original'; +import { AnimatedValue } from '../animation/AnimatedValue'; +import { sequenceProgram } from '../animation/animationProgram'; + export function withSequence( - _reduceMotion: string, - ...animations: AnimatableValue[] + reduceMotionOrFirst: ReduceMotion | AnimatableValue, + ...rest: AnimatableValue[] ): ReturnType { - console.error( - '[Reanimated] withSequence is unsupported. Consider building a custom animation in lightning directly instead. Returning just the first animation.', - ); + // reanimated allows an optional ReduceMotion string as the first arg + const animations = + typeof reduceMotionOrFirst === 'string' + ? rest + : [reduceMotionOrFirst, ...rest]; - const returnAnimation = animations[0]; + const values = animations.filter( + (animation) => animation instanceof AnimatedValue, + ) as unknown as AnimatedValue[]; - if (!returnAnimation) { + if (!values.length) { throw new Error('[Reanimated] withSequence requires at least one animation.'); } - return typeof returnAnimation === 'function' - ? (returnAnimation as () => AnimationObject)() - : (returnAnimation as AnimationObject); + return AnimatedValue.fromProgram( + sequenceProgram(values.map((value) => value.toProgram())), + ) as unknown as ReturnType; } diff --git a/packages/plugin-reanimated/src/utils/toLightningAnimationAndStyles.ts b/packages/plugin-reanimated/src/utils/toLightningAnimationAndStyles.ts index 1cc998de..534e6c52 100644 --- a/packages/plugin-reanimated/src/utils/toLightningAnimationAndStyles.ts +++ b/packages/plugin-reanimated/src/utils/toLightningAnimationAndStyles.ts @@ -5,6 +5,7 @@ import { convertCSSTransformToLightning } from '@plextv/react-lightning-plugin-c import type { Transform } from '@plextv/react-lightning-plugin-flexbox'; import { AnimatedValue } from '../animation/AnimatedValue'; +import { type AnimationProgram, mapProgram } from '../animation/animationProgram'; import type { AnimatedObject } from '../types/AnimatedObject'; import { getTransitionProperty } from '../utils/getTransitionProperty'; @@ -16,23 +17,30 @@ type DefaultStyleWithLightningTransform = Omit & { transform?: Transform; }; +export type ScheduledAnimation = { + prop: keyof LightningElementStyle; + program: AnimationProgram; +}; + function applyTransforms( style: DefaultStyleWithLightningTransform, transition: LightningTransition, + schedules: ScheduledAnimation[], animatableTransforms: AnimatableTransform | AnimatableTransform[], ) { if (Array.isArray(animatableTransforms)) { for (const animatableTransform of animatableTransforms) { - applyTransform(style, transition, animatableTransform); + applyTransform(style, transition, schedules, animatableTransform); } } else { - applyTransform(style, transition, animatableTransforms); + applyTransform(style, transition, schedules, animatableTransforms); } } function applyTransform( style: DefaultStyleWithLightningTransform, transition: LightningTransition, + schedules: ScheduledAnimation[], animatableTransform: AnimatableTransform, ) { for (const [key, value] of Object.entries(animatableTransform)) { @@ -42,6 +50,31 @@ function applyTransform( case 'translate': case 'translateX': case 'translateY': + // A composed program drives the axis step-by-step instead of a + // one-shot transition; map each step's px target onto x / y. + if (value instanceof AnimatedValue && value.program) { + const program = value.program; + const toAxis = (axis: 'x' | 'y') => + mapProgram(program, (v) => { + const converted = convertCSSTransformToLightning(key, v) as Record< + string, + number | string + >; + + return converted[axis] ?? v; + }); + + if (key === 'translate' || key === 'translateX') { + schedules.push({ prop: 'x', program: toAxis('x') }); + } + + if (key === 'translate' || key === 'translateY') { + schedules.push({ prop: 'y', program: toAxis('y') }); + } + + break; + } + // Using our lightning style transform instead of RN style.transform = { ...style.transform, @@ -63,18 +96,18 @@ function applyTransform( case 'scaleX': case 'scaleY': if (key === 'scale' || key === 'scaleX') { - applyStyle(style, transition, 'scaleX', value as AnimatedValue); + applyStyle(style, transition, schedules, 'scaleX', value as AnimatedValue); } if (key === 'scale' || key === 'scaleY') { - applyStyle(style, transition, 'scaleY', value as AnimatedValue); + applyStyle(style, transition, schedules, 'scaleY', value as AnimatedValue); } break; case 'rotate': - applyStyle(style, transition, 'rotation', value as AnimatedValue); + applyStyle(style, transition, schedules, 'rotation', value as AnimatedValue); break; default: - applyStyle(style, transition, key as keyof DefaultStyle, value); + applyStyle(style, transition, schedules, key as keyof DefaultStyle, value); break; } } @@ -83,12 +116,20 @@ function applyTransform( function applyStyle( style: DefaultStyleWithLightningTransform, transition: LightningTransition, + schedules: ScheduledAnimation[], prop: K, value: AnimatedObject[K] | (AnimatedObject[K] & string), ) { if (value instanceof AnimatedValue) { const transitionProp = getTransitionProperty(prop as keyof DefaultStyle); + // A program plays step-by-step; a plain value takes the one-shot transition. + if (value.program) { + schedules.push({ prop: transitionProp, program: value.program }); + + return; + } + // oxlint-disable-next-line typescript/no-explicit-any -- Just passing through (style as any)[transitionProp] = value.value as T[K]; transition[transitionProp] = value.lngAnimation; @@ -101,9 +142,11 @@ function applyStyle( export function toLightningAnimationAndStyles(computedStyle: AnimatedObject): { transition: LightningTransition; style: DefaultStyleWithLightningTransform; + schedules: ScheduledAnimation[]; } { const style: DefaultStyleWithLightningTransform = {}; const transition: LightningTransition = {}; + const schedules: ScheduledAnimation[] = []; for (const key in computedStyle) { const prop = key as keyof AnimatedObject; @@ -116,12 +159,13 @@ export function toLightningAnimationAndStyles(computedStyle: AnimatedObject Date: Thu, 9 Jul 2026 16:12:58 +0200 Subject: [PATCH 32/66] fix(vendor): translate the anchored yoga edge so right/bottom-docked nodes don't snap across --- packages/plugin-flexbox/src/YogaManager.ts | 38 +++++++++++-------- .../src/util/resolveTranslateInset.test.ts | 30 +++++++++++++++ .../src/util/resolveTranslateInset.ts | 28 ++++++++++++++ 3 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 packages/plugin-flexbox/src/util/resolveTranslateInset.test.ts create mode 100644 packages/plugin-flexbox/src/util/resolveTranslateInset.ts diff --git a/packages/plugin-flexbox/src/YogaManager.ts b/packages/plugin-flexbox/src/YogaManager.ts index a3930dfd..94f6ed7c 100644 --- a/packages/plugin-flexbox/src/YogaManager.ts +++ b/packages/plugin-flexbox/src/YogaManager.ts @@ -8,6 +8,10 @@ import { layoutText, type TextMeasureProps } from './text/layoutText'; import type { ManagerNode } from './types/ManagerNode'; import type { YogaOptions } from './types/YogaOptions'; import applyReactPropsToYoga, { applyFlexPropToYoga } from './util/applyReactPropsToYoga'; +import { + resolveHorizontalTranslate, + resolveVerticalTranslate, +} from './util/resolveTranslateInset'; import { SimpleDataView } from './util/SimpleDataView'; export type BatchedUpdate = Record>; @@ -407,29 +411,31 @@ export class YogaManager { // Apply transforms after all the styles are applied if (transform) { const { translateX, translateY } = transform; + const yoga = this._yoga; + const node = yogaNode.node; if (translateX != null) { - const left = x ?? 0; - - applyFlexPropToYoga( - this._yoga, - this._yogaOptions, - yogaNode.node, - 'left', - left + translateX, + const right = node.getPosition(yoga.EDGE_RIGHT); + const { edge, value } = resolveHorizontalTranslate( + right.unit === yoga.UNIT_POINT, + x ?? 0, + right.value, + translateX, ); + + applyFlexPropToYoga(yoga, this._yogaOptions, node, edge, value); } if (translateY != null) { - const top = y ?? 0; - - applyFlexPropToYoga( - this._yoga, - this._yogaOptions, - yogaNode.node, - 'top', - top + translateY, + const bottom = node.getPosition(yoga.EDGE_BOTTOM); + const { edge, value } = resolveVerticalTranslate( + bottom.unit === yoga.UNIT_POINT, + y ?? 0, + bottom.value, + translateY, ); + + applyFlexPropToYoga(yoga, this._yogaOptions, node, edge, value); } } } diff --git a/packages/plugin-flexbox/src/util/resolveTranslateInset.test.ts b/packages/plugin-flexbox/src/util/resolveTranslateInset.test.ts new file mode 100644 index 00000000..7d3b78b8 --- /dev/null +++ b/packages/plugin-flexbox/src/util/resolveTranslateInset.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { + resolveHorizontalTranslate, + resolveVerticalTranslate, +} from './resolveTranslateInset'; + +describe('resolveTranslateInset', () => { + it('left-anchored translateX offsets the left edge (base + translate)', () => { + expect(resolveHorizontalTranslate(false, 0, 0, 40)).toEqual({ edge: 'left', value: 40 }); + expect(resolveHorizontalTranslate(false, 10, 0, 40)).toEqual({ edge: 'left', value: 50 }); + }); + + it('right-anchored translateX offsets the right edge (base - translate)', () => { + // docked at right:32; translateX 0 must keep it docked (not snap to left) + expect(resolveHorizontalTranslate(true, 0, 32, 0)).toEqual({ edge: 'right', value: 32 }); + // sliding in from the right (+translate) pushes the right inset negative + expect(resolveHorizontalTranslate(true, 0, 32, 100)).toEqual({ edge: 'right', value: -68 }); + }); + + it('bottom-anchored translateY offsets the bottom edge (base - translate)', () => { + expect(resolveVerticalTranslate(true, 0, 32, 0)).toEqual({ edge: 'bottom', value: 32 }); + expect(resolveVerticalTranslate(true, 0, 32, 100)).toEqual({ edge: 'bottom', value: -68 }); + }); + + it('top-anchored translateY offsets the top edge (base + translate)', () => { + expect(resolveVerticalTranslate(false, 0, 0, 40)).toEqual({ edge: 'top', value: 40 }); + expect(resolveVerticalTranslate(false, 5, 0, 40)).toEqual({ edge: 'top', value: 45 }); + }); +}); diff --git a/packages/plugin-flexbox/src/util/resolveTranslateInset.ts b/packages/plugin-flexbox/src/util/resolveTranslateInset.ts new file mode 100644 index 00000000..31cc8188 --- /dev/null +++ b/packages/plugin-flexbox/src/util/resolveTranslateInset.ts @@ -0,0 +1,28 @@ +export type HorizontalInset = { edge: 'left' | 'right'; value: number }; +export type VerticalInset = { edge: 'top' | 'bottom'; value: number }; + +// translateX/Y shift a node's laid-out position by writing a yoga inset. A node +// anchored via `right`/`bottom` must translate that same edge: writing the +// opposite edge over-constrains yoga (left+width wins over right) and snaps the +// node across the container. +export function resolveHorizontalTranslate( + isRightAnchored: boolean, + baseLeft: number, + baseRight: number, + translateX: number, +): HorizontalInset { + return isRightAnchored + ? { edge: 'right', value: baseRight - translateX } + : { edge: 'left', value: baseLeft + translateX }; +} + +export function resolveVerticalTranslate( + isBottomAnchored: boolean, + baseTop: number, + baseBottom: number, + translateY: number, +): VerticalInset { + return isBottomAnchored + ? { edge: 'bottom', value: baseBottom - translateY } + : { edge: 'top', value: baseTop + translateY }; +} From ccb04c9d746312e36a158d40b38e2afe3b44b6d5 Mon Sep 17 00:00:00 2001 From: Ruud Date: Thu, 9 Jul 2026 23:26:35 +0200 Subject: [PATCH 33/66] fix(vendor): stop horizontal VirtualList cross-size ratcheting off its parent cell --- .../components/VirtualList/resolveCrossSize.spec.ts | 11 +++++++---- .../src/components/VirtualList/resolveCrossSize.ts | 6 ++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts index e1d9c256..df236020 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts @@ -44,16 +44,19 @@ describe('resolveCrossSize', () => { expect(result).toEqual({ viewportCrossSize: 190, isDefinite: false }); }); - it('falls back to parent cross for a horizontal list before content measures', () => { + it('ignores parent cross for a horizontal list and falls back to the estimate', () => { + // parentCross is the outer VL cell height (header + this list + siblings); + // deriving the horizontal cross from it ratchets unbounded. Fall through to + // the estimate and let content report the real size. const result = resolveCrossSize({ ...base, horizontal: true, parentCross: 600 }); - expect(result).toEqual({ viewportCrossSize: 600, isDefinite: false }); + expect(result).toEqual({ viewportCrossSize: 50, isDefinite: false }); }); - it('falls back to the measured outer size for a horizontal list before content measures', () => { + it('ignores the measured outer size for a horizontal list and falls back to the estimate', () => { const result = resolveCrossSize({ ...base, horizontal: true, measuredOuterCross: 600 }); - expect(result).toEqual({ viewportCrossSize: 600, isDefinite: false }); + expect(result).toEqual({ viewportCrossSize: 50, isDefinite: false }); }); it('falls back to the estimated item size when nothing has measured', () => { diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts index f1fd9661..8b6676f3 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts @@ -61,11 +61,13 @@ export function resolveCrossSize({ return { viewportCrossSize: maxContentCross + crossPadding, isDefinite: false }; } - if (parentCross != null && parentCross > 0) { + // Horizontal cross must not come from parent/self measurement: both equal the + // outer VL cell height (header + this list), so it ratchets unbounded. + if (!horizontal && parentCross != null && parentCross > 0) { return { viewportCrossSize: parentCross, isDefinite: false }; } - if (measuredOuterCross > 0) { + if (!horizontal && measuredOuterCross > 0) { return { viewportCrossSize: measuredOuterCross, isDefinite: false }; } From 26a33aa921013d5d6ba8dd0054c7fb33257fa3eb Mon Sep 17 00:00:00 2001 From: Ruud Date: Fri, 10 Jul 2026 16:04:56 +0200 Subject: [PATCH 34/66] chore(vendor): bump @lightningjs/renderer to 3.1.1 --- pnpm-lock.yaml | 28 ++++++++++++++-------------- pnpm-workspace.yaml | 4 ++-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a969580..665444b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: apps: '@lightningjs/renderer': - specifier: 3.0.1 - version: 3.0.1 + specifier: 3.1.1 + version: 3.1.1 react: specifier: 19.2.5 version: 19.2.5 @@ -23,8 +23,8 @@ catalogs: version: 4.3.0 default: '@lightningjs/renderer': - specifier: 3.0.1 - version: 3.0.1 + specifier: 3.1.1 + version: 3.1.1 '@rolldown/plugin-babel': specifier: ^0.2.3 version: 0.2.3 @@ -134,7 +134,7 @@ importers: dependencies: '@lightningjs/renderer': specifier: catalog:apps - version: 3.0.1 + version: 3.1.1 '@plextv/react-lightning': specifier: workspace:* version: link:../../packages/react-lightning @@ -189,7 +189,7 @@ importers: dependencies: '@lightningjs/renderer': specifier: catalog:apps - version: 3.0.1 + version: 3.1.1 '@plextv/react-lightning': specifier: workspace:* version: link:../../packages/react-lightning @@ -268,7 +268,7 @@ importers: dependencies: '@lightningjs/renderer': specifier: catalog:apps - version: 3.0.1 + version: 3.1.1 '@plextv/react-lightning': specifier: workspace:* version: link:../../packages/react-lightning @@ -359,7 +359,7 @@ importers: dependencies: '@lightningjs/renderer': specifier: 'catalog:' - version: 3.0.1 + version: 3.1.1 '@plextv/react-lightning': specifier: workspace:^ version: link:../react-lightning @@ -422,7 +422,7 @@ importers: dependencies: '@lightningjs/renderer': specifier: 'catalog:' - version: 3.0.1 + version: 3.1.1 '@plextv/react-lightning': specifier: workspace:^ version: link:../react-lightning @@ -459,7 +459,7 @@ importers: dependencies: '@lightningjs/renderer': specifier: 'catalog:' - version: 3.0.1 + version: 3.1.1 react: specifier: 'catalog:' version: 19.2.5 @@ -506,7 +506,7 @@ importers: dependencies: '@lightningjs/renderer': specifier: 'catalog:' - version: 3.0.1 + version: 3.1.1 '@plextv/react-lightning': specifier: workspace:^ version: link:../react-lightning @@ -1655,8 +1655,8 @@ packages: resolution: {integrity: sha512-FHIgj5rkOQPd9/wDXaiR0GOoWDEj7BytIzvYq5K8/wAh3z2bbW8gTNN+0J5kc1KXtqPrbyg1i87ksUVLrLEr1g==} engines: {node: '>=18.0.0'} - '@lightningjs/renderer@3.0.1': - resolution: {integrity: sha512-xAn5eVtYdAmpqA8rN5/f4LnF/48cqrC204s1Mv51KL6GylYHCdhzdGqX480Apgiq3ub+DzNDgNs/xhTASzi7hQ==} + '@lightningjs/renderer@3.1.1': + resolution: {integrity: sha512-L1+9ZN13+mH5vz7wQOF9+edghWA8N7ETi1d8cFC2SCp+7evmB7utz/NROfUCZyDhOLXlXREN2GPcELTBXeU0wg==} engines: {node: '>= 18.0.0', npm: '>= 10.0.0', pnpm: '>= 10.17.0'} '@manypkg/find-root@1.1.0': @@ -6788,7 +6788,7 @@ snapshots: msdf-bmfont-xml: 2.8.0 opentype.js: 1.3.4 - '@lightningjs/renderer@3.0.1': {} + '@lightningjs/renderer@3.1.1': {} '@manypkg/find-root@1.1.0': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ecf2aac..76db4407 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,7 +3,7 @@ packages: - packages/* catalog: - '@lightningjs/renderer': 3.0.1 + '@lightningjs/renderer': 3.1.1 '@rolldown/plugin-babel': ^0.2.3 '@types/react': 19.2.14 '@types/react-dom': 19.2.3 @@ -21,7 +21,7 @@ catalog: catalogs: apps: - '@lightningjs/renderer': 3.0.1 + '@lightningjs/renderer': 3.1.1 '@vitejs/plugin-react': 6.0.1 react: 19.2.5 react-dom: 19.2.5 From 398cc2d339eb152907b9eb8a9011fe08cba7ec5e Mon Sep 17 00:00:00 2001 From: Ruud Date: Fri, 10 Jul 2026 20:22:45 +0200 Subject: [PATCH 35/66] fix(vendor): export CanvasRoot for react-dom-free mount --- packages/react-lightning/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-lightning/src/index.ts b/packages/react-lightning/src/index.ts index 4f51de4a..8b9668a3 100644 --- a/packages/react-lightning/src/index.ts +++ b/packages/react-lightning/src/index.ts @@ -17,3 +17,4 @@ export { createRoot, type LightningRoot, LightningRootContext, type RenderOption export type { Plugin } from './render/Plugin'; export * from './types'; export { simpleDiff } from './utils/simpleDiff'; +export { CanvasRoot } from './components/Canvas/CanvasRoot'; From 64b5c5d25b6bc178169a007b1337341f5a8aa227 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 11 Jul 2026 00:18:06 +0200 Subject: [PATCH 36/66] fix(vendor): drop estimatedItemSize from VirtualList, sizes come from overrideItemLayout or measurement --- .../src/pages/VirtualListPage.tsx | 2 - .../src/pages/LibraryTest.tsx | 1 - .../lists/VirtualList.stories.tsx | 16 --- .../VirtualList/LayoutManager.spec.ts | 135 ++++++++---------- .../components/VirtualList/LayoutManager.ts | 33 ++--- .../components/VirtualList/VirtualList.tsx | 6 - .../VirtualList/VirtualListTypes.ts | 2 - .../VirtualList/resolveCrossSize.spec.ts | 16 +-- .../VirtualList/resolveCrossSize.ts | 6 +- 9 files changed, 87 insertions(+), 130 deletions(-) diff --git a/apps/react-lightning-example/src/pages/VirtualListPage.tsx b/apps/react-lightning-example/src/pages/VirtualListPage.tsx index b53e7e21..0cbe18e3 100644 --- a/apps/react-lightning-example/src/pages/VirtualListPage.tsx +++ b/apps/react-lightning-example/src/pages/VirtualListPage.tsx @@ -72,7 +72,6 @@ export const VirtualListPage = () => { { { snapToAlignment="center" drawDistance={100} numColumns={6} - estimatedItemSize={400} ItemSeparatorComponent={() => } contentContainerStyle={{ paddingHorizontal: 25 }} style={{ w: 1670, h: 1080 }} diff --git a/apps/storybook/src/react-lightning-components/lists/VirtualList.stories.tsx b/apps/storybook/src/react-lightning-components/lists/VirtualList.stories.tsx index 74146f36..46b0608b 100644 --- a/apps/storybook/src/react-lightning-components/lists/VirtualList.stories.tsx +++ b/apps/storybook/src/react-lightning-components/lists/VirtualList.stories.tsx @@ -43,7 +43,6 @@ const Label = ({ text, w = 500, h = 30 }: { text: string; w?: number; h?: number export const Vertical = () => ( ( ( ( ( ( ( ( export const EmptyList = () => ( } @@ -170,7 +165,6 @@ export const EmptyList = () => ( export const ContentPadding = () => ( ( export const SnapStart = () => ( ( @@ -218,7 +211,6 @@ export const SnapStart = () => ( export const SnapCenter = () => ( ( @@ -238,7 +230,6 @@ export const SnapCenter = () => ( export const SnapEnd = () => ( ( @@ -264,7 +255,6 @@ export const OverrideItemLayout = () => ( { if (index === 0) { @@ -313,7 +303,6 @@ export const InfiniteScroll = () => { return ( (({ focused, in export const ItemTypes = () => ( String(item.id)} getItemType={(item) => item.type} @@ -405,7 +393,6 @@ export const ItemTypes = () => ( export const InitialScrollIndex = () => ( ( @@ -471,7 +458,6 @@ export const ImperativeScrolling = () => { ( { export const DrawDistance = () => ( ( @@ -523,7 +508,6 @@ export const DrawDistance = () => ( export const SlowAnimation = () => ( ( diff --git a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts index c581c6d4..b0858275 100644 --- a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts +++ b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts @@ -1,32 +1,34 @@ import { describe, expect, it } from 'vitest'; -import { LayoutManager } from './LayoutManager'; +import { DEFAULT_ITEM_SIZE, LayoutManager } from './LayoutManager'; const makeData = (n: number) => Array.from({ length: n }, (_, i) => ({ id: i })); describe('LayoutManager', () => { describe('single column', () => { - it('positions items sequentially using estimatedItemSize', () => { + it('positions unmeasured items sequentially using the default size', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); expect(lm.getLayout(0)).toEqual( - expect.objectContaining({ offset: 0, size: 100, crossSize: 200 }), + expect.objectContaining({ offset: 0, size: DEFAULT_ITEM_SIZE, crossSize: 200 }), ); - expect(lm.getLayout(1)).toEqual(expect.objectContaining({ offset: 100, size: 100 })); - expect(lm.getLayout(2)).toEqual(expect.objectContaining({ offset: 200, size: 100 })); - expect(lm.totalSize).toBe(300); + expect(lm.getLayout(1)).toEqual( + expect.objectContaining({ offset: DEFAULT_ITEM_SIZE, size: DEFAULT_ITEM_SIZE }), + ); + expect(lm.getLayout(2)).toEqual( + expect.objectContaining({ offset: DEFAULT_ITEM_SIZE * 2, size: DEFAULT_ITEM_SIZE }), + ); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3); }); it('uses overrideItemLayout for custom sizes', () => { const sizes = [50, 100, 75]; const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, overrideItemLayout: (layout, _item, index) => { @@ -44,7 +46,6 @@ describe('LayoutManager', () => { it('returns undefined for out-of-range index', () => { const lm = new LayoutManager({ data: makeData(2), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); @@ -56,35 +57,32 @@ describe('LayoutManager', () => { const data: Array<{ id: number } | null> = [{ id: 0 }, null, { id: 2 }]; const lm = new LayoutManager({ data, - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); - expect(lm.getLayout(0)?.size).toBe(100); + expect(lm.getLayout(0)?.size).toBe(DEFAULT_ITEM_SIZE); expect(lm.getLayout(1)?.size).toBe(0); - expect(lm.getLayout(1)?.offset).toBe(100); - expect(lm.getLayout(2)?.offset).toBe(100); - expect(lm.totalSize).toBe(200); + expect(lm.getLayout(1)?.offset).toBe(DEFAULT_ITEM_SIZE); + expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 2); }); it('collapses undefined data entries to size 0', () => { const data: Array<{ id: number } | undefined> = [{ id: 0 }, undefined, { id: 2 }]; const lm = new LayoutManager({ data, - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); expect(lm.getLayout(1)?.size).toBe(0); - expect(lm.getLayout(2)?.offset).toBe(100); + expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE); }); it('honours override.size = 0 to collapse a row', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, overrideItemLayout: (layout, _item, index) => { @@ -95,8 +93,8 @@ describe('LayoutManager', () => { }); expect(lm.getLayout(1)?.size).toBe(0); - expect(lm.getLayout(2)?.offset).toBe(100); - expect(lm.totalSize).toBe(200); + expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 2); }); }); @@ -104,7 +102,6 @@ describe('LayoutManager', () => { it('positions items in a grid using cellCrossSize', () => { const lm = new LayoutManager({ data: makeData(5), - estimatedItemSize: 100, numColumns: 2, cellCrossSize: 100, }); @@ -125,16 +122,21 @@ describe('LayoutManager', () => { crossSize: 100, }), ); - expect(lm.getLayout(2)).toEqual(expect.objectContaining({ offset: 100, column: 0 })); - expect(lm.getLayout(3)).toEqual(expect.objectContaining({ offset: 100, column: 1 })); - expect(lm.getLayout(4)).toEqual(expect.objectContaining({ offset: 200, column: 0 })); - expect(lm.totalSize).toBe(300); + expect(lm.getLayout(2)).toEqual( + expect.objectContaining({ offset: DEFAULT_ITEM_SIZE, column: 0 }), + ); + expect(lm.getLayout(3)).toEqual( + expect.objectContaining({ offset: DEFAULT_ITEM_SIZE, column: 1 }), + ); + expect(lm.getLayout(4)).toEqual( + expect.objectContaining({ offset: DEFAULT_ITEM_SIZE * 2, column: 0 }), + ); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3); }); it('handles span override (crossSize scales with span)', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 3, cellCrossSize: 100, overrideItemLayout: (layout, _item, index) => { @@ -153,7 +155,6 @@ describe('LayoutManager', () => { it('clamps span to available columns', () => { const lm = new LayoutManager({ data: makeData(2), - estimatedItemSize: 100, numColumns: 2, cellCrossSize: 100, overrideItemLayout: (layout) => { @@ -169,22 +170,26 @@ describe('LayoutManager', () => { it('adds separator gap between items in single column', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, separatorSize: 10, }); - expect(lm.getLayout(0)).toEqual(expect.objectContaining({ offset: 0, size: 100 })); - expect(lm.getLayout(1)).toEqual(expect.objectContaining({ offset: 110, size: 100 })); - expect(lm.getLayout(2)).toEqual(expect.objectContaining({ offset: 220, size: 100 })); - expect(lm.totalSize).toBe(320); + expect(lm.getLayout(0)).toEqual( + expect.objectContaining({ offset: 0, size: DEFAULT_ITEM_SIZE }), + ); + expect(lm.getLayout(1)).toEqual( + expect.objectContaining({ offset: DEFAULT_ITEM_SIZE + 10, size: DEFAULT_ITEM_SIZE }), + ); + expect(lm.getLayout(2)).toEqual( + expect.objectContaining({ offset: DEFAULT_ITEM_SIZE * 2 + 20, size: DEFAULT_ITEM_SIZE }), + ); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3 + 20); }); it('does not add separator gap between rows in multi column', () => { const lm = new LayoutManager({ data: makeData(5), - estimatedItemSize: 100, numColumns: 2, cellCrossSize: 100, separatorSize: 20, @@ -192,26 +197,25 @@ describe('LayoutManager', () => { expect(lm.getLayout(0)?.offset).toBe(0); expect(lm.getLayout(1)?.offset).toBe(0); - expect(lm.getLayout(2)?.offset).toBe(100); - expect(lm.getLayout(3)?.offset).toBe(100); - expect(lm.getLayout(4)?.offset).toBe(200); - expect(lm.totalSize).toBe(300); + expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE); + expect(lm.getLayout(3)?.offset).toBe(DEFAULT_ITEM_SIZE); + expect(lm.getLayout(4)?.offset).toBe(DEFAULT_ITEM_SIZE * 2); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3); }); it('does not add separator gap after a zero-size empty row', () => { const data: Array<{ id: number } | null> = [{ id: 0 }, null, { id: 2 }]; const lm = new LayoutManager({ data, - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, separatorSize: 10, }); expect(lm.getLayout(0)?.offset).toBe(0); - expect(lm.getLayout(1)?.offset).toBe(110); + expect(lm.getLayout(1)?.offset).toBe(DEFAULT_ITEM_SIZE + 10); expect(lm.getLayout(1)?.size).toBe(0); - expect(lm.getLayout(2)?.offset).toBe(110); + expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE + 10); }); }); @@ -219,9 +223,11 @@ describe('LayoutManager', () => { it('returns correct range for a window in the middle', () => { const lm = new LayoutManager({ data: makeData(20), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, + overrideItemLayout: (layout) => { + layout.size = 100; + }, }); const range = lm.getVisibleRange(500, 300, 100); @@ -232,7 +238,6 @@ describe('LayoutManager', () => { it('returns empty range for empty data', () => { const lm = new LayoutManager({ data: [], - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); @@ -245,7 +250,6 @@ describe('LayoutManager', () => { it('clamps to data bounds', () => { const lm = new LayoutManager({ data: makeData(5), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); @@ -258,9 +262,11 @@ describe('LayoutManager', () => { it('handles scroll at the very end', () => { const lm = new LayoutManager({ data: makeData(10), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, + overrideItemLayout: (layout) => { + layout.size = 100; + }, }); const range = lm.getVisibleRange(800, 200, 0); @@ -273,9 +279,11 @@ describe('LayoutManager', () => { it('locates the index at a given main-axis offset', () => { const lm = new LayoutManager({ data: makeData(5), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, + overrideItemLayout: (layout) => { + layout.size = 100; + }, }); expect(lm.findIndexAtOffset(0)).toBe(0); @@ -287,7 +295,6 @@ describe('LayoutManager', () => { it('returns -1 for empty data', () => { const lm = new LayoutManager({ data: [], - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); @@ -300,13 +307,12 @@ describe('LayoutManager', () => { it('uses measured size in subsequent layouts', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), }); - expect(lm.getLayout(1)?.offset).toBe(100); + expect(lm.getLayout(1)?.offset).toBe(DEFAULT_ITEM_SIZE); const changed = lm.reportItemSize('0', 150); expect(changed).toBe(true); @@ -322,7 +328,6 @@ describe('LayoutManager', () => { it('measurement wins over override', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), @@ -341,7 +346,6 @@ describe('LayoutManager', () => { it('returns false for zero or negative sizes', () => { const lm = new LayoutManager({ data: makeData(2), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), @@ -349,13 +353,12 @@ describe('LayoutManager', () => { expect(lm.reportItemSize('0', 0)).toBe(false); expect(lm.reportItemSize('0', -5)).toBe(false); - expect(lm.getLayout(0)?.size).toBe(100); + expect(lm.getLayout(0)?.size).toBe(DEFAULT_ITEM_SIZE); }); it('returns false on no-op reports and defers different values via dampening', () => { const lm = new LayoutManager({ data: makeData(2), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), @@ -378,7 +381,6 @@ describe('LayoutManager', () => { const data = makeData(3); const lm = new LayoutManager({ data, - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), @@ -393,7 +395,7 @@ describe('LayoutManager', () => { // The measurement for id=1 follows the userKey across the index // shift. Unmeasured items (id=99 and id=0) fall back to the - // first-measured implicit estimate (150), not `estimatedItemSize`. + // first-measured implicit estimate (150), not the default size. expect(lm.getLayout(0)?.size).toBe(150); expect(lm.getLayout(1)?.size).toBe(150); expect(lm.getLayout(2)?.size).toBe(150); @@ -406,7 +408,6 @@ describe('LayoutManager', () => { // stored but never found, leaving cells stuck at the estimate. const lm = new LayoutManager({ data: makeData(2), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); @@ -419,7 +420,6 @@ describe('LayoutManager', () => { const data = makeData(3); const lm = new LayoutManager({ data, - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); @@ -445,7 +445,6 @@ describe('LayoutManager', () => { const data: Array<{ id: number } | null> = [{ id: 0 }, null]; const lm = new LayoutManager({ data, - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item?.id), @@ -458,14 +457,13 @@ describe('LayoutManager', () => { it('first measurement becomes the implicit estimate for later unmeasured items', () => { const lm = new LayoutManager({ data: makeData(4), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), }); - // Before any measurement: items use the caller's estimatedItemSize. - expect(lm.getLayout(2)?.size).toBe(100); + // Before any measurement: items use the default size. + expect(lm.getLayout(2)?.size).toBe(DEFAULT_ITEM_SIZE); // First measurement comes in. Items 1,2,3 are still unmeasured but // should now use 150 (the first-measured size) as the fallback. @@ -478,7 +476,6 @@ describe('LayoutManager', () => { it('later measurements do NOT update the implicit estimate', () => { const lm = new LayoutManager({ data: makeData(5), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), @@ -499,24 +496,22 @@ describe('LayoutManager', () => { it('reportItemEmpty collapses the row to size 0', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), }); - expect(lm.getLayout(1)?.size).toBe(100); + expect(lm.getLayout(1)?.size).toBe(DEFAULT_ITEM_SIZE); expect(lm.reportItemEmpty('1')).toBe(true); expect(lm.getLayout(1)?.size).toBe(0); - expect(lm.getLayout(2)?.offset).toBe(100); - expect(lm.totalSize).toBe(200); + expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 2); }); it('reportItemEmpty is idempotent', () => { const lm = new LayoutManager({ data: makeData(2), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), @@ -529,7 +524,6 @@ describe('LayoutManager', () => { it('per-item override.size wins over the implicit estimate', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, keyExtractor: (item) => String(item.id), @@ -552,20 +546,18 @@ describe('LayoutManager', () => { it('recomputes layouts after data change', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); - expect(lm.totalSize).toBe(300); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3); expect(lm.updateConfig({ data: makeData(5) })).toBe(true); - expect(lm.totalSize).toBe(500); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 5); }); it('recomputes when cellCrossSize changes', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); @@ -577,12 +569,11 @@ describe('LayoutManager', () => { it('returns false when nothing changed', () => { const lm = new LayoutManager({ data: makeData(3), - estimatedItemSize: 100, numColumns: 1, cellCrossSize: 200, }); - expect(lm.updateConfig({ estimatedItemSize: 100, numColumns: 1 })).toBe(false); + expect(lm.updateConfig({ numColumns: 1 })).toBe(false); }); }); }); diff --git a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts index 767c6b63..fe3100b5 100644 --- a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts +++ b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts @@ -13,9 +13,12 @@ export interface ComputedLayout { crossSize: number; } +// Bootstrap main-axis size for unmeasured items before anything has measured. +// Real sizes come from cell measurements or `overrideItemLayout`. +export const DEFAULT_ITEM_SIZE = 200; + export interface LayoutManagerConfig { data: ReadonlyArray; - estimatedItemSize: number; numColumns: number; overrideItemLayout?: OverrideItemLayoutFn; extraData?: unknown; @@ -28,7 +31,8 @@ export interface LayoutManagerConfig { /** * Computes per-item offsets in O(n). Main-axis size is the per-userKey - * measurement, then `overrideItemLayout`, then `estimatedItemSize`. Cross + * measurement, then `overrideItemLayout`, then the first measured size + * (`DEFAULT_ITEM_SIZE` until anything measures). Cross * is always `cellCrossSize` (× span); never measured or aggregated — * that's the rule that keeps the layout loop-free. */ @@ -39,7 +43,6 @@ export class LayoutManager { private _totalSize = 0; private _dirty = true; private _data: ReadonlyArray; - private _estimatedItemSize: number; private _numColumns: number; private _overrideItemLayout?: OverrideItemLayoutFn; private _extraData?: unknown; @@ -62,15 +65,14 @@ export class LayoutManager { private _onChange?: () => void; private static readonly _STABILITY_MS = 120; /** - * Implicit fallback for unmeasured items once any cell has measured — - * usually a much better predictor than the caller's estimate. Locked on - * first measurement so subsequent cells don't cascade-shift the fallback. + * Implicit fallback for unmeasured items once any cell has measured. + * Locked on first measurement so subsequent cells don't cascade-shift + * the fallback. */ private _firstMeasuredSize = 0; constructor(config: LayoutManagerConfig) { this._data = config.data; - this._estimatedItemSize = config.estimatedItemSize; this._numColumns = Math.max(1, config.numColumns); this._overrideItemLayout = config.overrideItemLayout; this._extraData = config.extraData; @@ -170,14 +172,6 @@ export class LayoutManager { changed = true; } - if ( - config.estimatedItemSize !== undefined && - config.estimatedItemSize !== this._estimatedItemSize - ) { - this._estimatedItemSize = config.estimatedItemSize; - changed = true; - } - if (config.numColumns !== undefined) { const nc = Math.max(1, config.numColumns); @@ -507,11 +501,10 @@ export class LayoutManager { return override.size; } - // Prefer the first-measured size over the caller's estimate once any - // cell has reported. Per-key measurements above still win for cells - // that have actually been seen — this is the fallback for unmeasured - // ones only. - return this._firstMeasuredSize > 0 ? this._firstMeasuredSize : this._estimatedItemSize; + // Prefer the first-measured size once any cell has reported. Per-key + // measurements above still win for cells that have actually been seen — + // this is the fallback for unmeasured ones only. + return this._firstMeasuredSize > 0 ? this._firstMeasuredSize : DEFAULT_ITEM_SIZE; } private _recomputeSingleColumn(count: number): void { diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index 7d029087..fdc2a6e3 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -58,7 +58,6 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef>(() => { const lm = new LayoutManager({ data, - estimatedItemSize, numColumns, overrideItemLayout, extraData, @@ -208,7 +205,6 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef { data: ReadonlyArray; /** Render function for each item. */ renderItem: ((info: VirtualListRenderItemInfo) => ReactElement | null) | null | undefined; - /** Average or median item size. Used before items are measured. Default 200. */ - estimatedItemSize?: number; /** Scroll horizontally instead of vertically. */ horizontal?: boolean | null; /** Number of columns for grid layout. Default 1. */ diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts index df236020..8d81188f 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { DEFAULT_ITEM_SIZE } from './LayoutManager'; import { resolveCrossSize } from './resolveCrossSize'; const base = { @@ -9,7 +10,6 @@ const base = { measuredOuterCross: 0, maxContentCross: 0, crossPadding: 0, - estimatedItemSize: 50, }; describe('resolveCrossSize', () => { @@ -44,25 +44,25 @@ describe('resolveCrossSize', () => { expect(result).toEqual({ viewportCrossSize: 190, isDefinite: false }); }); - it('ignores parent cross for a horizontal list and falls back to the estimate', () => { + it('ignores parent cross for a horizontal list and falls back to the default', () => { // parentCross is the outer VL cell height (header + this list + siblings); // deriving the horizontal cross from it ratchets unbounded. Fall through to - // the estimate and let content report the real size. + // the default and let content report the real size. const result = resolveCrossSize({ ...base, horizontal: true, parentCross: 600 }); - expect(result).toEqual({ viewportCrossSize: 50, isDefinite: false }); + expect(result).toEqual({ viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false }); }); - it('ignores the measured outer size for a horizontal list and falls back to the estimate', () => { + it('ignores the measured outer size for a horizontal list and falls back to the default', () => { const result = resolveCrossSize({ ...base, horizontal: true, measuredOuterCross: 600 }); - expect(result).toEqual({ viewportCrossSize: 50, isDefinite: false }); + expect(result).toEqual({ viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false }); }); - it('falls back to the estimated item size when nothing has measured', () => { + it('falls back to the default item size when nothing has measured', () => { const result = resolveCrossSize({ ...base }); - expect(result).toEqual({ viewportCrossSize: 50, isDefinite: false }); + expect(result).toEqual({ viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false }); }); it('treats a zero explicit cross as unset', () => { diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts index 8b6676f3..e51fd17a 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts @@ -1,3 +1,5 @@ +import { DEFAULT_ITEM_SIZE } from './LayoutManager'; + export interface ResolveCrossSizeInput { horizontal: boolean | null | undefined; /** Cross-axis size from the VL's own style (`h` for horizontal, `w` for vertical). */ @@ -9,7 +11,6 @@ export interface ResolveCrossSizeInput { /** Largest cross-axis content measurement reported by cells so far. */ maxContentCross: number; crossPadding: number; - estimatedItemSize: number; } export interface ResolvedCrossSize { @@ -43,7 +44,6 @@ export function resolveCrossSize({ measuredOuterCross, maxContentCross, crossPadding, - estimatedItemSize, }: ResolveCrossSizeInput): ResolvedCrossSize { if (explicitCross != null && explicitCross > 0) { return { viewportCrossSize: explicitCross, isDefinite: true }; @@ -71,5 +71,5 @@ export function resolveCrossSize({ return { viewportCrossSize: measuredOuterCross, isDefinite: false }; } - return { viewportCrossSize: estimatedItemSize, isDefinite: false }; + return { viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false }; } From 38ee1f276c6ba5138558a2e70745712c82c18871 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 11 Jul 2026 01:02:02 +0200 Subject: [PATCH 37/66] fix(vendor): await font metrics in yoga init so the first layout measures text --- packages/plugin-flexbox/src/YogaManager.ts | 19 ++++++---- .../src/measureText.integration.test.ts | 37 +++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/packages/plugin-flexbox/src/YogaManager.ts b/packages/plugin-flexbox/src/YogaManager.ts index 94f6ed7c..289a8500 100644 --- a/packages/plugin-flexbox/src/YogaManager.ts +++ b/packages/plugin-flexbox/src/YogaManager.ts @@ -101,14 +101,19 @@ export class YogaManager { this._initialized = true; - // Load fonts in the background; don't block init. As each arrives, re-dirty - // any text already laid out with it so its measurement updates. + // Await the font metrics so the first layout measures text for real. An + // unloaded font measures 0x0 and the arrival re-measure reflows the whole + // tree while it is already visible. `load` never rejects (a failed fetch + // warns and leaves the font unmeasured), so this can't hang init. The + // re-dirty stays as a backstop for a font that resolves late anyway. if (this._yogaOptions.fonts) { - for (const font of this._yogaOptions.fonts) { - void this._fontStore.load(font.fontFamily, font.atlasDataUrl).then(() => { - this._remeasureFontFamily(font.fontFamily); - }); - } + await Promise.all( + this._yogaOptions.fonts.map((font) => + this._fontStore.load(font.fontFamily, font.atlasDataUrl).then(() => { + this._remeasureFontFamily(font.fontFamily); + }), + ), + ); } } diff --git a/packages/plugin-flexbox/src/measureText.integration.test.ts b/packages/plugin-flexbox/src/measureText.integration.test.ts index 105f563f..d49bafb7 100644 --- a/packages/plugin-flexbox/src/measureText.integration.test.ts +++ b/packages/plugin-flexbox/src/measureText.integration.test.ts @@ -133,4 +133,41 @@ describe('Yoga text measurement (real yoga)', () => { const computed = await nextRender(manager); expect(computed.get(2)?.h).toBe(40); // still measured as 2 lines }); + + it('awaits font metrics during init so the first layout measures text', async () => { + // If init resolves before the atlas JSON is registered, the first layout + // measures text 0x0 and the font-arrival re-measure reflows the whole + // tree while it is already visible (the boot-time position jump). + const originalFetch = globalThis.fetch; + // Resolve on a macrotask, like a real network fetch — a same-tick stub + // would land before the first layout microtask and mask the race. + globalThis.fetch = (async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + + return { json: async () => atlas }; + }) as unknown as typeof fetch; + + try { + const manager = new YogaManager(); + await manager.init({ + fonts: [{ fontFamily: 'Test', atlasDataUrl: 'test://atlas.json' }], + }); + + manager.addNode(1); + manager.applyStyle(1, { display: 'flex', w: 100, h: 100 }, true); + manager.addIndependentRoot(1); + + manager.addNode(2); + manager.addChildNode(1, 2); + manager.setTextMeasure(2, 'Test', textProps); + + const computed = await nextRender(manager); + + // "aa aa" at fontSize 20 with the synthetic atlas is 90px wide unwrapped. + expect(computed.get(2)?.w).toBeGreaterThan(0); + expect(computed.get(2)?.h).toBeGreaterThan(0); + } finally { + globalThis.fetch = originalFetch; + } + }); }); From a50167dfb7438fa376062390cce8aadc2ee6540e Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 11 Jul 2026 01:02:02 +0200 Subject: [PATCH 38/66] fix(vendor): stub getPosition on the yoga node spec mock --- packages/plugin-flexbox/src/YogaManager.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/plugin-flexbox/src/YogaManager.spec.ts b/packages/plugin-flexbox/src/YogaManager.spec.ts index f3d814dd..a418d33b 100644 --- a/packages/plugin-flexbox/src/YogaManager.spec.ts +++ b/packages/plugin-flexbox/src/YogaManager.spec.ts @@ -20,6 +20,8 @@ const mockNode = { getComputedWidth: vi.fn(), getComputedHeight: vi.fn(), getMaxWidth: vi.fn(), + // Unanchored edge (unit UNDEFINED), matching a node with no right/bottom set. + getPosition: vi.fn(() => ({ unit: 0, value: undefined })), getParent: vi.fn(), markLayoutSeen: vi.fn(), }; From 9d62639e57dd4c0ef4c30e3ab98a51cc00d6e497 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 11 Jul 2026 01:19:33 +0200 Subject: [PATCH 39/66] fix(vendor): re-push cell cross-size after a virtuallist data reset so refreshed rows stop clipping --- .../src/components/VirtualList/VirtualList.tsx | 12 ++++++++++++ .../src/components/VirtualList/VirtualListCell.tsx | 3 ++- .../src/components/VirtualList/VirtualListTypes.ts | 2 ++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index fdc2a6e3..2abd3541 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -126,6 +126,9 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef { maxContentCrossRef.current = 0; setMaxContentCross(0); + setCrossGeneration((g) => g + 1); // oxlint-disable-next-line react-hooks/exhaustive-deps -- intentional reset on data identity change }, [data, extraData]); @@ -635,6 +646,7 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef({ isLastItem, ItemSeparatorComponent, isInFlex, + crossGeneration, pinCrossAxis = false, onItemSizeChange, onItemEmpty, @@ -124,7 +125,7 @@ const VirtualListCellInner = ({ return () => { cancelAnimationFrame(rafId); }; - }, [userKey, isInFlex, isEmpty, horizontal]); + }, [userKey, isInFlex, isEmpty, horizontal, crossGeneration]); const separatorPosition: { x: number } | { y: number } = horizontal ? { x: size } : { y: size }; diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts index 030d7369..2495e291 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts @@ -204,6 +204,8 @@ export interface VirtualListCellProps { onItemSizeChange?: (userKey: string, size: number) => void; /** Distinct from `onItemSizeChange(_, 0)` (rejected) — this is the explicit empty-row path. */ onItemEmpty?: (userKey: string) => void; + /** Bumped by the list to make the cell re-push its cross after a reset. */ + crossGeneration?: number; onContentCrossLayout?: (size: number) => void; onSeparatorLayout?: (size: number) => void; /** Mounted offscreen for state preservation; outer FG is disabled so spatial nav skips it. */ From b56b335030317dbcf9a690162e7c6ac41b116270 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 11 Jul 2026 09:55:53 +0200 Subject: [PATCH 40/66] fix(vendor): skip no-op style animations so they don't hold the scene active --- .../src/element/LightningViewElement.spec.ts | 72 +++++++++++++++++++ .../src/element/LightningViewElement.ts | 51 +++++++++++++ 2 files changed, 123 insertions(+) diff --git a/packages/react-lightning/src/element/LightningViewElement.spec.ts b/packages/react-lightning/src/element/LightningViewElement.spec.ts index 5368c976..fe8e0dc9 100644 --- a/packages/react-lightning/src/element/LightningViewElement.spec.ts +++ b/packages/react-lightning/src/element/LightningViewElement.spec.ts @@ -173,3 +173,75 @@ describe('LightningViewElement border shader', () => { expect(el.node.shader).toBeNull(); }); }); + +describe('LightningViewElement no-op animation skip', () => { + function createSpyElement(style: Partial) { + let animateCalls = 0; + const node = createMockNode({ + animate() { + animateCalls++; + + return { once() {}, start() {} }; + }, + }); + const spyRenderer = { + createNode: () => node, + createTextNode: () => node, + createShader: () => ({ props: {} }), + createTexture: () => ({}), + destroyNode() {}, + } as unknown as RendererMain; + const el = new LightningViewElement( + { style } as LightningViewElementProps, + spyRenderer, + [], + {} as Fiber, + ); + + return { el, node, calls: () => animateCalls }; + } + + it('skips animating a prop to its current value', () => { + const { el, node, calls } = createSpyElement({ alpha: 1 }); + + node.alpha = 1; + el.animateStyle('alpha', 1); + + expect(calls()).toBe(0); + expect(node.alpha).toBe(1); + }); + + it('still animates a real value change', () => { + const { el, node, calls } = createSpyElement({ alpha: 1 }); + + node.alpha = 1; + el.animateStyle('alpha', 0.4); + + expect(calls()).toBe(1); + }); + + it('does not skip when an in-flight animation targets a different value', () => { + const { el, node, calls } = createSpyElement({ alpha: 1 }); + + // In-flight: alpha animating toward 0. + el.animateStyle('alpha', 0); + expect(calls()).toBe(1); + + // Node happens to sit at 0.5 mid-animation; a request for 0.5 must still + // start a new animation (otherwise the old one keeps running to 0). + (node as { alpha: number }).alpha = 0.5; + el.animateStyle('alpha', 0.5); + expect(calls()).toBe(2); + }); + + it('skips a repeat of the same in-flight target once the value arrived', () => { + const { el, node, calls } = createSpyElement({ alpha: 1 }); + + el.animateStyle('alpha', 0); + expect(calls()).toBe(1); + + (node as { alpha: number }).alpha = 0; + el.animateStyle('alpha', 0); + expect(calls()).toBe(1); + }); +}); diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index cf319d88..ee893377 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -74,6 +74,39 @@ function createTexture( let idCounter = 0; +// Returned when a requested animation is a proven no-op; satisfies the +// controller contract without registering anything with the renderer. +const noopAnimationController = { + state: 'stopped', + start() { + return this; + }, + stop() { + return this; + }, + pause() { + return this; + }, + restore() { + return this; + }, + waitUntilStopped() { + return Promise.resolve(); + }, + on() { + return this; + }, + once() { + return this; + }, + off() { + return this; + }, + emit() { + return this; + }, +} as unknown as IAnimationController; + export class LightningViewElement< TStyleProps extends LightningViewElementStyle = LightningViewElementStyle, TProps extends @@ -106,6 +139,8 @@ export class LightningViewElement< private _recycled = false; private _hasStagedUpdates = false; private _hasLayout = false; + /** Last requested animation target per style key, for the no-op-skip guard. */ + private _animTargets = new Map(); private _paintWithheld = false; private _withheldAlpha = 1; private _eventEmitter = new EventEmitter(); @@ -723,6 +758,22 @@ export class LightningViewElement< key: K, value: TStyleProps[K], ): IAnimationController { + // Skip no-op animations (target equals the node's current value, and no + // in-flight animation is heading somewhere else). A no-op still counts as + // an active animation for delay+duration, keeping the scene hot and + // full-redrawing every frame; focus moves fire several (e.g. a popover's + // delayed alpha 1 -> 1) and stall low-end devices for their whole window. + const inFlight = this._animTargets.get(key); + + if ( + (this.node as Record)[key] === value && + (inFlight === undefined || inFlight === value) + ) { + return noopAnimationController; + } + + this._animTargets.set(key, value); + return this._createAnimation( { [key]: value, From 3589df8f2462e0c626f5cfa7eaf1daf909571c80 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 11 Jul 2026 15:45:12 +0200 Subject: [PATCH 41/66] fix(vendor): withhold virtuallist rows until their measured height settles --- .../VirtualList/LayoutManager.spec.ts | 68 +++- .../components/VirtualList/LayoutManager.ts | 86 ++++- .../components/VirtualList/RevealGate.spec.ts | 90 ++++++ .../src/components/VirtualList/RevealGate.ts | 84 +++++ .../components/VirtualList/VirtualList.tsx | 303 ++++++++++++++---- .../VirtualList/VirtualListCell.tsx | 48 ++- .../VirtualList/VirtualListTypes.ts | 29 +- .../VirtualList/resolveCrossSize.spec.ts | 51 ++- .../VirtualList/resolveCrossSize.ts | 5 +- .../VirtualList/resolveRevealBoundary.spec.ts | 64 ++++ .../VirtualList/resolveRevealBoundary.ts | 49 +++ 11 files changed, 757 insertions(+), 120 deletions(-) create mode 100644 packages/react-lightning-components/src/components/VirtualList/RevealGate.spec.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/RevealGate.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/resolveRevealBoundary.spec.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/resolveRevealBoundary.ts diff --git a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts index b0858275..2ffb9481 100644 --- a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts +++ b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_ITEM_SIZE, LayoutManager } from './LayoutManager'; -const makeData = (n: number) => Array.from({ length: n }, (_, i) => ({ id: i })); +const makeData = (n: number) => + Array.from({ length: n }, (_, i) => ({ id: i })); describe('LayoutManager', () => { describe('single column', () => { @@ -14,13 +15,23 @@ describe('LayoutManager', () => { }); expect(lm.getLayout(0)).toEqual( - expect.objectContaining({ offset: 0, size: DEFAULT_ITEM_SIZE, crossSize: 200 }), + expect.objectContaining({ + offset: 0, + size: DEFAULT_ITEM_SIZE, + crossSize: 200, + }), ); expect(lm.getLayout(1)).toEqual( - expect.objectContaining({ offset: DEFAULT_ITEM_SIZE, size: DEFAULT_ITEM_SIZE }), + expect.objectContaining({ + offset: DEFAULT_ITEM_SIZE, + size: DEFAULT_ITEM_SIZE, + }), ); expect(lm.getLayout(2)).toEqual( - expect.objectContaining({ offset: DEFAULT_ITEM_SIZE * 2, size: DEFAULT_ITEM_SIZE }), + expect.objectContaining({ + offset: DEFAULT_ITEM_SIZE * 2, + size: DEFAULT_ITEM_SIZE, + }), ); expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3); }); @@ -69,7 +80,11 @@ describe('LayoutManager', () => { }); it('collapses undefined data entries to size 0', () => { - const data: Array<{ id: number } | undefined> = [{ id: 0 }, undefined, { id: 2 }]; + const data: Array<{ id: number } | undefined> = [ + { id: 0 }, + undefined, + { id: 2 }, + ]; const lm = new LayoutManager({ data, numColumns: 1, @@ -179,10 +194,16 @@ describe('LayoutManager', () => { expect.objectContaining({ offset: 0, size: DEFAULT_ITEM_SIZE }), ); expect(lm.getLayout(1)).toEqual( - expect.objectContaining({ offset: DEFAULT_ITEM_SIZE + 10, size: DEFAULT_ITEM_SIZE }), + expect.objectContaining({ + offset: DEFAULT_ITEM_SIZE + 10, + size: DEFAULT_ITEM_SIZE, + }), ); expect(lm.getLayout(2)).toEqual( - expect.objectContaining({ offset: DEFAULT_ITEM_SIZE * 2 + 20, size: DEFAULT_ITEM_SIZE }), + expect.objectContaining({ + offset: DEFAULT_ITEM_SIZE * 2 + 20, + size: DEFAULT_ITEM_SIZE, + }), ); expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3 + 20); }); @@ -356,6 +377,39 @@ describe('LayoutManager', () => { expect(lm.getLayout(0)?.size).toBe(DEFAULT_ITEM_SIZE); }); + it('isMeasured flips once a real size commits (keyed by userKey)', () => { + const lm = new LayoutManager({ + data: makeData(2), + numColumns: 1, + cellCrossSize: 200, + keyExtractor: (item) => String(item.id), + }); + + expect(lm.isMeasured(0)).toBe(false); + lm.reportItemSize('0', 150); + expect(lm.isMeasured(0)).toBe(true); + expect(lm.isMeasured(1)).toBe(false); + }); + + it('hasOverrideSize reflects whether the caller pins the main-axis size', () => { + const pinned = new LayoutManager({ + data: makeData(2), + numColumns: 1, + cellCrossSize: 200, + overrideItemLayout: (layout) => { + layout.size = 80; + }, + }); + const unpinned = new LayoutManager({ + data: makeData(2), + numColumns: 1, + cellCrossSize: 200, + }); + + expect(pinned.hasOverrideSize(0)).toBe(true); + expect(unpinned.hasOverrideSize(0)).toBe(false); + }); + it('returns false on no-op reports and defers different values via dampening', () => { const lm = new LayoutManager({ data: makeData(2), diff --git a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts index fe3100b5..01857f81 100644 --- a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts +++ b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts @@ -18,7 +18,7 @@ export interface ComputedLayout { export const DEFAULT_ITEM_SIZE = 200; export interface LayoutManagerConfig { - data: ReadonlyArray; + data: readonly T[]; numColumns: number; overrideItemLayout?: OverrideItemLayoutFn; extraData?: unknown; @@ -37,31 +37,37 @@ export interface LayoutManagerConfig { * that's the rule that keeps the layout loop-free. */ export class LayoutManager { - private static _overrideScratch: { size?: number; span?: number } = {}; + private static _overrideScratch: { + size?: number; + span?: number; + } = {}; private _layouts: ComputedLayout[] = []; private _layoutCount = 0; private _totalSize = 0; private _dirty = true; - private _data: ReadonlyArray; + private _data: readonly T[]; private _numColumns: number; private _overrideItemLayout?: OverrideItemLayoutFn; private _extraData?: unknown; private _separatorSize: number; private _cellCrossSize: number; private _keyExtractor?: (item: T, index: number) => string; - private _measuredSizes: Map = new Map(); + private _measuredSizes = new Map(); /** While true, reports accumulate per-userKey and skip dampening. Drained on `setBatching(false)`. */ private _batching = false; - private _batchedSizes: Map = new Map(); + private _batchedSizes = new Map(); /** * Per-userKey stability window. A different incoming value sits pending * until either matched after `_STABILITY_MS` or the backstop timer * fires. Filters multi-frame measurement cascades during scroll/focus * animations and async content settling. */ - private _pendingSizes: Map = new Map(); + private _pendingSizes = new Map< + string, + { size: number; firstSeenAt: number } + >(); /** Backstop timers — required because a cell can push once and go quiet (props stable). */ - private _pendingTimers: Map> = new Map(); + private _pendingTimers = new Map>(); private _onChange?: () => void; private static readonly _STABILITY_MS = 120; /** @@ -189,22 +195,34 @@ export class LayoutManager { changed = true; } - if (config.extraData !== undefined && config.extraData !== this._extraData) { + if ( + config.extraData !== undefined && + config.extraData !== this._extraData + ) { this._extraData = config.extraData; changed = true; } - if (config.separatorSize !== undefined && config.separatorSize !== this._separatorSize) { + if ( + config.separatorSize !== undefined && + config.separatorSize !== this._separatorSize + ) { this._separatorSize = config.separatorSize; changed = true; } - if (config.cellCrossSize !== undefined && config.cellCrossSize !== this._cellCrossSize) { + if ( + config.cellCrossSize !== undefined && + config.cellCrossSize !== this._cellCrossSize + ) { this._cellCrossSize = config.cellCrossSize; changed = true; } - if (config.keyExtractor !== undefined && config.keyExtractor !== this._keyExtractor) { + if ( + config.keyExtractor !== undefined && + config.keyExtractor !== this._keyExtractor + ) { this._keyExtractor = config.keyExtractor; changed = true; } @@ -368,6 +386,28 @@ export class LayoutManager { return this._layouts[index]; } + private _userKeyFor(index: number): string | undefined { + const item = this._data[index]; + + if (item == null) { + return undefined; + } + + return this._keyExtractor ? this._keyExtractor(item, index) : String(index); + } + + /** True once the cell has reported a real (committed) main-axis size. */ + isMeasured(index: number): boolean { + const userKey = this._userKeyFor(index); + + return userKey != null && this._measuredSizes.has(userKey); + } + + /** True when the caller pins the main-axis size via `overrideItemLayout` (no measurement needed). */ + hasOverrideSize(index: number): boolean { + return this._getOverride(index).size != null; + } + /** * Returns the layout index whose [offset, offset+size) range contains the * given offset (in item-space). Used to map a focused descendant's @@ -478,7 +518,11 @@ export class LayoutManager { } } - private _resolveSize(index: number, isEmpty: boolean, override: { size?: number }): number { + private _resolveSize( + index: number, + isEmpty: boolean, + override: { size?: number }, + ): number { if (isEmpty) { return 0; } @@ -489,7 +533,9 @@ export class LayoutManager { // Match VirtualListCell: it reports with String(index) when no // keyExtractor is configured, so per-item lookup must use the same // key. Without this, measurements would be stored but never found. - const userKey = this._keyExtractor ? this._keyExtractor(item, index) : String(index); + const userKey = this._keyExtractor + ? this._keyExtractor(item, index) + : String(index); const measured = this._measuredSizes.get(userKey); if (measured != null) { @@ -504,7 +550,9 @@ export class LayoutManager { // Prefer the first-measured size once any cell has reported. Per-key // measurements above still win for cells that have actually been seen — // this is the fallback for unmeasured ones only. - return this._firstMeasuredSize > 0 ? this._firstMeasuredSize : DEFAULT_ITEM_SIZE; + return this._firstMeasuredSize > 0 + ? this._firstMeasuredSize + : DEFAULT_ITEM_SIZE; } private _recomputeSingleColumn(count: number): void { @@ -560,7 +608,10 @@ export class LayoutManager { const item = this._data[i]; const isEmpty = item === undefined || item === null; const override = this._getOverride(i); - const span = Math.min(override.span ?? 1, this._numColumns - columnsUsed); + const span = Math.min( + override.span ?? 1, + this._numColumns - columnsUsed, + ); const size = this._resolveSize(i, isEmpty, override); layout.offset = offset; @@ -582,7 +633,10 @@ export class LayoutManager { this._totalSize = offset; } - private _getOverride(index: number): { size?: number; span?: number } { + private _getOverride(index: number): { + size?: number; + span?: number; + } { LayoutManager._overrideScratch.size = undefined; LayoutManager._overrideScratch.span = undefined; diff --git a/packages/react-lightning-components/src/components/VirtualList/RevealGate.spec.ts b/packages/react-lightning-components/src/components/VirtualList/RevealGate.spec.ts new file mode 100644 index 00000000..c07bdabb --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/RevealGate.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { RevealGate } from './RevealGate'; + +const QUIET = 120; +const MAX = 1000; + +describe('RevealGate', () => { + it('reports Infinity until a key is noted', () => { + const gate = new RevealGate(); + + expect(gate.timeUntilSettled('a', 0, QUIET, MAX)).toBe(Infinity); + }); + + it('counts down the quiet window from the last size change', () => { + const gate = new RevealGate(); + + gate.note('a', 456, 0); + + expect(gate.timeUntilSettled('a', 0, QUIET, MAX)).toBe(QUIET); + expect(gate.timeUntilSettled('a', 100, QUIET, MAX)).toBe(20); + expect(gate.timeUntilSettled('a', 120, QUIET, MAX)).toBe(0); + expect(gate.timeUntilSettled('a', 500, QUIET, MAX)).toBe(0); + }); + + it('restarts the quiet window when the size changes (grow)', () => { + const gate = new RevealGate(); + + gate.note('a', 120, 0); + // Grows to its real height mid-window; the clock restarts so it can't + // be revealed at the transient smaller size. + gate.note('a', 456, 80); + + expect(gate.timeUntilSettled('a', 120, QUIET, MAX)).toBe(80); + expect(gate.timeUntilSettled('a', 200, QUIET, MAX)).toBe(0); + }); + + it('ignores sub-pixel jitter (does not restart the window)', () => { + const gate = new RevealGate(); + + gate.note('a', 456, 0); + gate.note('a', 456.4, 80); + + expect(gate.timeUntilSettled('a', 120, QUIET, MAX)).toBe(0); + }); + + it('force-settles after the max window even while still changing', () => { + const gate = new RevealGate(); + + gate.note('a', 100, 0); + gate.note('a', 200, 500); + gate.note('a', 300, 1000); + + // Never quiet for QUIET ms, but MAX ms elapsed since first seen. + expect(gate.timeUntilSettled('a', 1000, QUIET, MAX)).toBe(0); + }); + + it('takes the sooner of the quiet and max deadlines', () => { + const gate = new RevealGate(); + + gate.note('a', 100, 0); + gate.note('a', 200, 950); + + // quiet would finish at 950+120=1070; max finishes at 0+1000=1000. + expect(gate.timeUntilSettled('a', 950, QUIET, MAX)).toBe(50); + }); + + it('stays settled once revealed, even when the size changes later', () => { + const gate = new RevealGate(); + + gate.note('a', 456, 0); + gate.markRevealed('a'); + + // A background refresh re-measures the already-visible row to a new + // height; it must not re-gate (hiding it would drop focus). + gate.note('a', 500, 1000); + + expect(gate.timeUntilSettled('a', 1000, QUIET, MAX)).toBe(0); + }); + + it('forgets a key so a recycled slot re-gates from scratch', () => { + const gate = new RevealGate(); + + gate.note('a', 456, 0); + gate.markRevealed('a'); + gate.forget('a'); + + expect(gate.timeUntilSettled('a', 0, QUIET, MAX)).toBe(Infinity); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/RevealGate.ts b/packages/react-lightning-components/src/components/VirtualList/RevealGate.ts new file mode 100644 index 00000000..78df7a83 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/RevealGate.ts @@ -0,0 +1,84 @@ +/** + * Tracks how long each cell's measured main-axis size has held steady. + * + * A row measures bottom-up and async: it reports a small placeholder size + * first, then grows to its real height once its content lays out. Revealing on + * the first report would show that grow (and shift the rows below it). This gate + * lets the list keep a cell hidden until its size has been quiet for a window, + * so it appears once, already at its final height. + */ +export class RevealGate { + private readonly _size = new Map(); + private readonly _stableSince = new Map(); + private readonly _firstSeenAt = new Map(); + private readonly _revealed = new Set(); + + /** Record a measured size for a key. Restarts the quiet window on any real change. */ + note(key: string, size: number, now: number): void { + const prev = this._size.get(key); + + if (prev != null && Math.abs(prev - size) < 1) { + return; + } + + this._size.set(key, size); + this._stableSince.set(key, now); + + if (!this._firstSeenAt.has(key)) { + this._firstSeenAt.set(key, now); + } + } + + /** + * Latch a key as revealed once it has painted. The gate only guards a cell's + * first appearance; a later re-measure (e.g. a background refresh of an + * already-visible row) must NOT hide it again — hiding sets alpha 0, which + * drops focusability and throws spatial nav off the row. + */ + markRevealed(key: string): void { + this._revealed.add(key); + } + + /** + * ms until the key counts as settled: 0 once it has been revealed, otherwise + * the sooner of the quiet window elapsing since the last change and the max + * window since first seen (the backstop for content that never stops + * changing). `Infinity` until the key has been measured at all. + */ + timeUntilSettled( + key: string, + now: number, + quietMs: number, + maxMs: number, + ): number { + if (this._revealed.has(key)) { + return 0; + } + + const stableSince = this._stableSince.get(key); + + if (stableSince == null) { + return Infinity; + } + + const firstSeenAt = this._firstSeenAt.get(key) ?? stableSince; + const quietRemaining = Math.max(0, quietMs - (now - stableSince)); + const forcedRemaining = Math.max(0, maxMs - (now - firstSeenAt)); + + return Math.min(quietRemaining, forcedRemaining); + } + + forget(key: string): void { + this._size.delete(key); + this._stableSince.delete(key); + this._firstSeenAt.delete(key); + this._revealed.delete(key); + } + + clear(): void { + this._size.clear(); + this._stableSince.clear(); + this._firstSeenAt.clear(); + this._revealed.clear(); + } +} diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index 2abd3541..8cef525d 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -1,9 +1,9 @@ import type { ComponentType, Ref } from 'react'; import { type ForwardedRef, + type ReactElement, forwardRef, isValidElement, - type ReactElement, useContext, useEffect, useImperativeHandle, @@ -11,32 +11,45 @@ import { useRef, useState, } from 'react'; - import { FocusGroup, type LightningElement, type LightningViewElementStyle, } from '@plextv/react-lightning'; -import { FlexBoundary, FlexRoot, useIsInFlex } from '@plextv/react-lightning-plugin-flexbox'; - -import { capSelfMeasuredViewport } from './capSelfMeasuredViewport'; -import { computeItemRect } from './computeItemRect'; +import { + FlexBoundary, + FlexRoot, + useIsInFlex, +} from '@plextv/react-lightning-plugin-flexbox'; import { LayoutManager } from './LayoutManager'; -import { parseContentStyle } from './parseContentStyle'; import { RecyclerPool } from './RecyclerPool'; -import { resolveCrossSize } from './resolveCrossSize'; -import { resolveSectionSize } from './resolveSectionSize'; -import { resolveVisibleMainSpan } from './resolveVisibleMainSpan'; -import { useScrollHandler } from './useScrollHandler'; -import { useViewability } from './useViewability'; +import { RevealGate } from './RevealGate'; import { VirtualListCell } from './VirtualListCell'; import { CellBoundsContext, - type VLPersistedState, VLCellKeyContext, + type VLPersistedState, VLStateCacheContext, } from './VirtualListContext'; import type { VirtualListProps, VirtualListRef } from './VirtualListTypes'; +import { capSelfMeasuredViewport } from './capSelfMeasuredViewport'; +import { computeItemRect } from './computeItemRect'; +import { parseContentStyle } from './parseContentStyle'; +import { resolveCrossSize } from './resolveCrossSize'; +import { resolveRevealBoundary } from './resolveRevealBoundary'; +import { resolveSectionSize } from './resolveSectionSize'; +import { resolveVisibleMainSpan } from './resolveVisibleMainSpan'; +import { useScrollHandler } from './useScrollHandler'; +import { useViewability } from './useViewability'; + +// A cell reveals once its size has held steady this long — matches the +// LayoutManager's own stability window, so a size that's been quiet this long +// has no pending change left in flight. +const REVEAL_QUIET_MS = 120; +// Backstop so content that never stops resizing still reveals eventually. +const REVEAL_MAX_MS = 1000; +// Wake a touch after the computed deadline so the quiet window is safely past. +const REVEAL_CHECK_SLOP_MS = 8; function renderListComponent( component: VirtualListProps['ListHeaderComponent'], @@ -54,7 +67,10 @@ function renderListComponent( return ; } -function VirtualListInner(props: VirtualListProps, ref: ForwardedRef) { +function VirtualListInner( + props: VirtualListProps, + ref: ForwardedRef, +) { const { data, renderItem, @@ -99,7 +115,9 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef(props: VirtualListProps, ref: ForwardedRef>(() => new Map()); + const [ownStateCache] = useState>( + () => new Map(), + ); const [measuredSize, setMeasuredSize] = useState({ w: 0, h: 0 }); // Visible main-axis span from the list's stage position to the stage edge, // tracked on resize. Caps the self-measured viewport fallback below. @@ -129,6 +149,16 @@ function VirtualListInner(props: VirtualListProps, ref: ForwardedRef