From f7886653034f766f94e17913f66d4b02185f5825 Mon Sep 17 00:00:00 2001 From: Ruud Date: Mon, 13 Jul 2026 22:23:16 +0200 Subject: [PATCH 01/13] fix(vendor): back-stop the virtuallist reveal gate for cells that never measure (cherry picked from commit abf4cb38d6474621df4fa1edc007c27cb52e344f) --- .changeset/virtuallist-reveal-backstop.md | 5 +++ .../components/VirtualList/RevealGate.spec.ts | 37 +++++++++++++++++++ .../src/components/VirtualList/RevealGate.ts | 23 ++++++++++-- .../components/VirtualList/VirtualList.tsx | 22 ++++++----- 4 files changed, 74 insertions(+), 13 deletions(-) create mode 100644 .changeset/virtuallist-reveal-backstop.md diff --git a/.changeset/virtuallist-reveal-backstop.md b/.changeset/virtuallist-reveal-backstop.md new file mode 100644 index 00000000..50fe53a5 --- /dev/null +++ b/.changeset/virtuallist-reveal-backstop.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +Back-stop the VirtualList reveal gate for cells that never measure. A cell that is visible with an estimated size but has not measured yet (async content still pending) returned `Infinity` from the gate, so no re-check timer was scheduled and every cell below it stayed hidden until an unrelated commit woke the list. Such a cell now counts down the same max window a churning cell already uses, so the rows below it can't be stranded invisibly. diff --git a/packages/react-lightning-components/src/components/VirtualList/RevealGate.spec.ts b/packages/react-lightning-components/src/components/VirtualList/RevealGate.spec.ts index f5af777d..c563fb0a 100644 --- a/packages/react-lightning-components/src/components/VirtualList/RevealGate.spec.ts +++ b/packages/react-lightning-components/src/components/VirtualList/RevealGate.spec.ts @@ -116,4 +116,41 @@ describe('RevealGate', () => { expect(gate.timeUntilSettled('a', 0, QUIET, MAX)).toBe(Infinity); }); + + it('backstops a seen-but-unmeasured key so it never blocks forever', () => { + const gate = new RevealGate(); + + // Visible with an estimated size but never measured (async content pending); it must + // settle by the max window or the rows below stay hidden until an unrelated commit. + gate.markSeen('a', 0); + + expect(gate.timeUntilSettled('a', 0, QUIET, MAX)).toBe(MAX); + expect(gate.timeUntilSettled('a', 400, QUIET, MAX)).toBe(600); + expect(gate.timeUntilSettled('a', 1000, QUIET, MAX)).toBe(0); + }); + + it('markSeen does not restart the backstop clock on repeat', () => { + const gate = new RevealGate(); + + gate.markSeen('a', 0); + gate.markSeen('a', 500); + + expect(gate.timeUntilSettled('a', 500, QUIET, MAX)).toBe(500); + }); + + it('keeps the first-seen backstop clock when a measurement arrives later', () => { + const gate = new RevealGate(); + + gate.markSeen('a', 0); + gate.note('a', 456, 200); + + // quiet from 200 -> 320; backstop from first-seen 0 -> 1000; quiet wins. + expect(gate.timeUntilSettled('a', 200, QUIET, MAX)).toBe(QUIET); + }); + + it('stays Infinity for a key that was never seen or noted', () => { + const gate = new RevealGate(); + + expect(gate.timeUntilSettled('a', 999, 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 index 3bd1377d..34c56934 100644 --- a/packages/react-lightning-components/src/components/VirtualList/RevealGate.ts +++ b/packages/react-lightning-components/src/components/VirtualList/RevealGate.ts @@ -31,6 +31,16 @@ export class RevealGate { } } + /** + * Record that a cell is visible before it has measured, starting the backstop clock so an + * unmeasured cell (async content still pending) can't strand the rows below it forever. + */ + markSeen(key: string, now: number): void { + 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 @@ -60,7 +70,8 @@ export class RevealGate { * 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. + * changing). A key that is only seen (not yet measured) still counts down + * the max window; `Infinity` only until the key has been seen at all. */ timeUntilSettled( key: string, @@ -75,14 +86,18 @@ export class RevealGate { } const stableSince = this._stableSince.get(key); + const firstSeenAt = this._firstSeenAt.get(key); if (stableSince == null) { - return Infinity; + // Seen but never measured: settle by the max window. Never seen: unknown. + return firstSeenAt == null + ? Infinity + : Math.max(0, maxMs - (now - firstSeenAt)); } - const firstSeenAt = this._firstSeenAt.get(key) ?? stableSince; + const seenAt = firstSeenAt ?? stableSince; const quietRemaining = Math.max(0, quietMs - (now - stableSince)); - const forcedRemaining = Math.max(0, maxMs - (now - firstSeenAt)); + const forcedRemaining = Math.max(0, maxMs - (now - seenAt)); return Math.min(quietRemaining, forcedRemaining); } diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index 8488a4a0..e7624d49 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -565,8 +565,14 @@ function VirtualListInner( order.push(index); + const key = getKey(index); + if (layoutManager.isMeasured(index)) { - revealGate.note(getKey(index), layout.size, now); + revealGate.note(key, layout.size, now); + } else { + // Visible but not measured yet: start the backstop so a stuck async + // cell can't hold the rows below it hidden forever. + revealGate.markSeen(key, now); } } @@ -575,14 +581,12 @@ function VirtualListInner( order, (index) => layoutManager.hasOverrideSize(index), (index) => - layoutManager.isMeasured(index) - ? revealGate.timeUntilSettled( - getKey(index), - now, - REVEAL_QUIET_MS, - REVEAL_MAX_MS, - ) - : Infinity, + revealGate.timeUntilSettled( + getKey(index), + now, + REVEAL_QUIET_MS, + REVEAL_MAX_MS, + ), ); // Latch every cell up to the boundary as revealed so a later re-measure From 93b9d7d5aa610054fb948097640385a7dc81264d Mon Sep 17 00:00:00 2001 From: Ruud Date: Mon, 13 Jul 2026 23:14:27 +0200 Subject: [PATCH 02/13] fix(vendor): tick useDerivedValue's withTiming to a live number (cherry picked from commit c9f8de298b2b5ffb5ce669817b8a7828356688a3) --- .changeset/derived-value-withtiming-ticks.md | 5 + .../src/animation/sampleAnimation.test.ts | 71 ++++++++++++++ .../src/animation/sampleAnimation.ts | 52 ++++++++++ .../src/exports/useDerivedValue.ts | 94 ++++++++++++++++++- 4 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 .changeset/derived-value-withtiming-ticks.md create mode 100644 packages/plugin-reanimated/src/animation/sampleAnimation.test.ts create mode 100644 packages/plugin-reanimated/src/animation/sampleAnimation.ts diff --git a/.changeset/derived-value-withtiming-ticks.md b/.changeset/derived-value-withtiming-ticks.md new file mode 100644 index 00000000..6a10142d --- /dev/null +++ b/.changeset/derived-value-withtiming-ticks.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-plugin-reanimated': patch +--- + +Make `useDerivedValue(() => withTiming(...))` expose the live number reanimated does. It stored the raw animation descriptor, so reading it back for arithmetic (or as a style value) got an object, not a number. Now the derived value ticks from its current value toward the target on the JS thread each frame (timing and spring share the path, since a spring bakes its physics into the easing), so reads stay numeric and styles still animate. Composed programs (`withSequence`/`withRepeat`/`withDelay`) snap to their resting target. diff --git a/packages/plugin-reanimated/src/animation/sampleAnimation.test.ts b/packages/plugin-reanimated/src/animation/sampleAnimation.test.ts new file mode 100644 index 00000000..729e5707 --- /dev/null +++ b/packages/plugin-reanimated/src/animation/sampleAnimation.test.ts @@ -0,0 +1,71 @@ +import type { AnimationSettings } from '@lightningjs/renderer'; +import { describe, expect, it } from 'vitest'; + +import { sampleAnimation } from './sampleAnimation'; + +const settings = ( + overrides: Partial, +): AnimationSettings => ({ + duration: 100, + easing: 'linear', + delay: 0, + loop: false, + repeat: 0, + stopMethod: false, + ...overrides, +}); + +describe('sampleAnimation', () => { + it('holds the start value before the delay elapses', () => { + const r = sampleAnimation(10, 100, 40, settings({ delay: 50 })); + + expect(r).toEqual({ value: 10, done: false }); + }); + + it('settles on the target and reports done once the duration passes', () => { + expect(sampleAnimation(0, 100, 100, settings({}))).toEqual({ + value: 100, + done: true, + }); + expect(sampleAnimation(0, 100, 250, settings({}))).toEqual({ + value: 100, + done: true, + }); + }); + + it('interpolates linearly across the duration', () => { + expect(sampleAnimation(0, 100, 50, settings({})).value).toBe(50); + }); + + it('offsets progress by the delay', () => { + // (150 - 100 delay) / 100 duration = 0.5 + expect(sampleAnimation(0, 100, 150, settings({ delay: 100 })).value).toBe( + 50, + ); + }); + + it('uses a baked progress function (springs and custom timings)', () => { + const easing = (progress: number) => progress * progress; + // 0.5^2 = 0.25 -> 25 + expect( + sampleAnimation(0, 100, 50, settings({ easing })).value, + ).toBeCloseTo(25, 5); + }); + + it('maps the ease-out string the renderer emits for instant springs', () => { + // 1 - (1 - 0.5)^2 = 0.75 -> 75 + expect( + sampleAnimation(0, 100, 50, settings({ easing: 'ease-out' })).value, + ).toBeCloseTo(75, 5); + }); + + it('falls back to linear for an unknown easing string', () => { + expect( + sampleAnimation(0, 100, 50, settings({ easing: 'wobble' as never })).value, + ).toBe(50); + }); + + it('animates downward targets too', () => { + expect(sampleAnimation(100, 0, 50, settings({})).value).toBe(50); + }); +}); diff --git a/packages/plugin-reanimated/src/animation/sampleAnimation.ts b/packages/plugin-reanimated/src/animation/sampleAnimation.ts new file mode 100644 index 00000000..73669279 --- /dev/null +++ b/packages/plugin-reanimated/src/animation/sampleAnimation.ts @@ -0,0 +1,52 @@ +import type { AnimationSettings } from '@lightningjs/renderer'; + +function easeInOut(progress: number): number { + return progress < 0.5 + ? 2 * progress * progress + : 1 - (2 - 2 * progress) ** 2 / 2; +} + +// Renderer easings arrive as a progress function (custom timings and springs bake one) or a +// string; map the strings the renderer emits, fall back to linear. +const STRING_EASINGS: Record number> = { + linear: (progress) => progress, + ease: easeInOut, + 'ease-in': (progress) => progress * progress, + 'ease-out': (progress) => 1 - (1 - progress) * (1 - progress), + 'ease-in-out': easeInOut, +}; + +function toEasingFn( + easing: AnimationSettings['easing'], +): (progress: number) => number { + if (typeof easing === 'function') { + return easing as (progress: number) => number; + } + + return STRING_EASINGS[easing as string] ?? ((progress) => progress); +} + +// Sample a leaf timing/spring at elapsedMs (a spring bakes its physics into `easing`, so both +// share this path). Done once the duration elapses so the driver can settle and stop. +export function sampleAnimation( + from: number, + to: number, + elapsedMs: number, + settings: AnimationSettings, +): { value: number; done: boolean } { + const delay = settings.delay ?? 0; + const duration = settings.duration ?? 0; + const elapsed = elapsedMs - delay; + + if (elapsed <= 0) { + return { value: from, done: false }; + } + + if (duration <= 0 || elapsed >= duration) { + return { value: to, done: true }; + } + + const eased = toEasingFn(settings.easing)(elapsed / duration); + + return { value: from + (to - from) * eased, done: false }; +} diff --git a/packages/plugin-reanimated/src/exports/useDerivedValue.ts b/packages/plugin-reanimated/src/exports/useDerivedValue.ts index 4b38c00f..7cdf024c 100644 --- a/packages/plugin-reanimated/src/exports/useDerivedValue.ts +++ b/packages/plugin-reanimated/src/exports/useDerivedValue.ts @@ -1,24 +1,112 @@ import type { DependencyList } from 'react'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { DerivedValue } from 'react-native-reanimated-original'; import { makeMutable } from 'react-native-reanimated-original'; +import { AnimatedValue } from '../animation/AnimatedValue'; +import { restingValue } from '../animation/animationProgram'; +import { sampleAnimation } from '../animation/sampleAnimation'; import { instrumentSharedValue } from '../utils/sharedValueTracking'; import { useTrackedReaction } from './useTrackedReaction'; +type Mutable = { value: unknown }; + +// The plain number reanimated would settle on: a composed program +// (withSequence/withRepeat/withDelay) uses its resting target, a leaf its target. +function restingTarget(av: AnimatedValue): unknown { + if (av.program) { + const resting = restingValue(av.program); + + return typeof resting === 'number' ? resting : av.value; + } + + return av.value; +} + +// A derived value must read as a live number while it animates; storing the raw AnimatedValue +// broke arithmetic and styles reading it. Tick toward the target on the JS thread instead; +// composed programs snap to their resting value. +function animate(mutable: Mutable, av: AnimatedValue): () => void { + const target = restingTarget(av); + const from = mutable.value; + + if ( + av.program || + typeof target !== 'number' || + typeof from !== 'number' || + from === target + ) { + mutable.value = target; + av.callback?.(true); + + return () => {}; + } + + const to = target; + const start = performance.now(); + let frame = 0; + let cancelled = false; + + const tick = () => { + if (cancelled) { + return; + } + + const { value, done } = sampleAnimation( + from, + to, + performance.now() - start, + av.lngAnimation, + ); + + mutable.value = value; + + if (done) { + av.callback?.(true); + + return; + } + + frame = requestAnimationFrame(tick); + }; + + frame = requestAnimationFrame(tick); + + return () => { + cancelled = true; + cancelAnimationFrame(frame); + }; +} + export function useDerivedValue( updater: () => Value, dependencies?: DependencyList, ): DerivedValue { - const [mutable] = useState(() => instrumentSharedValue(makeMutable(updater()))); + const [mutable] = useState(() => { + const initial: unknown = updater(); + const value = + initial instanceof AnimatedValue ? restingTarget(initial) : initial; + + return instrumentSharedValue(makeMutable(value as Value)); + }); + const cancelRef = useRef<(() => void) | undefined>(undefined); useTrackedReaction( updater, (result) => { - mutable.value = result; + cancelRef.current?.(); + cancelRef.current = undefined; + + if (result instanceof AnimatedValue) { + cancelRef.current = animate(mutable as Mutable, result); + } else { + mutable.value = result; + } }, dependencies, ); + useEffect(() => () => cancelRef.current?.(), []); + return mutable as DerivedValue; } From 1d13c408be1250fd2d7a7f1c28c3cfa81faa83dd Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 14 Jul 2026 08:27:19 +0200 Subject: [PATCH 03/13] fix(vendor): report pixel ratio 1 for the fixed-resolution lightning scene (cherry picked from commit c1d16b13e97b3612f80d9c449b7fefbd10e5dcce) --- .changeset/pixelratio-scene-truth.md | 5 +++++ .../src/exports/PixelRatio.ts | 18 ++++++++++++++++++ packages/react-native-lightning/src/index.ts | 1 + 3 files changed, 24 insertions(+) create mode 100644 .changeset/pixelratio-scene-truth.md create mode 100644 packages/react-native-lightning/src/exports/PixelRatio.ts diff --git a/.changeset/pixelratio-scene-truth.md b/.changeset/pixelratio-scene-truth.md new file mode 100644 index 00000000..2e4a2000 --- /dev/null +++ b/.changeset/pixelratio-scene-truth.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-native-lightning': patch +--- + +Report PixelRatio 1 instead of window.devicePixelRatio. The Lightning canvas backing store equals the configured scene size, so one layout pixel is one canvas pixel; inheriting react-native-web's devicePixelRatio made image sizing fetch 2x assets on retina displays that the renderer downscales anyway: 4x the texture memory for no visible gain, enough to blow the texture manager's cleanup budget and evict visible tiles. diff --git a/packages/react-native-lightning/src/exports/PixelRatio.ts b/packages/react-native-lightning/src/exports/PixelRatio.ts new file mode 100644 index 00000000..05cf9921 --- /dev/null +++ b/packages/react-native-lightning/src/exports/PixelRatio.ts @@ -0,0 +1,18 @@ +// The scene renders at a fixed logical resolution, so one layout pixel is one canvas pixel. +// rn-web's devicePixelRatio fetched 2x retina assets: 4x texture memory, evicting visible tiles. + +export function get(): number { + return 1; +} + +export function getFontScale(): number { + return 1; +} + +export function getPixelSizeForLayoutSize(layoutSize: number): number { + return Math.round(layoutSize); +} + +export function roundToNearestPixel(layoutSize: number): number { + return Math.round(layoutSize); +} diff --git a/packages/react-native-lightning/src/index.ts b/packages/react-native-lightning/src/index.ts index ff2c5234..9d34436b 100644 --- a/packages/react-native-lightning/src/index.ts +++ b/packages/react-native-lightning/src/index.ts @@ -14,6 +14,7 @@ export { FocusGroup, type FocusGroupProps } from './exports/FocusGroup'; export { Image, type ImageProps } from './exports/Image'; export { NativeCanvas } from './exports/NativeCanvas'; export * as Platform from './exports/Platform'; +export * as PixelRatio from './exports/PixelRatio'; export { Pressable, type PressableProps } from './exports/Pressable'; export { ScrollView, type ScrollViewProps } from './exports/ScrollView'; export * as StyleSheet from './exports/StyleSheet'; From 2f2abfa3daf47eda7ab72dc3ad0a1e58bfe16809 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 14 Jul 2026 10:12:01 +0200 Subject: [PATCH 04/13] fix(vendor): bubble focus events up the element tree like tvos/web (cherry picked from commit cc3b2ab936c8cafad5fd6a65b7458655b89301d5) --- .changeset/focus-events-bubble.md | 6 ++ .../components/VirtualList/VirtualList.tsx | 4 + .../VirtualList/VirtualListTypes.ts | 9 +- .../src/element/LightningViewElement.ts | 9 ++ .../src/focus/FocusManager.spec.ts | 100 ++++++++++++++++++ .../react-lightning/src/focus/FocusManager.ts | 28 +++++ .../src/mocks/createMockElement.ts | 2 + .../react-lightning/src/types/Focusable.ts | 5 + 8 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 .changeset/focus-events-bubble.md diff --git a/.changeset/focus-events-bubble.md b/.changeset/focus-events-bubble.md new file mode 100644 index 00000000..42e8f313 --- /dev/null +++ b/.changeset/focus-events-bubble.md @@ -0,0 +1,6 @@ +--- +'@plextv/react-lightning': patch +'@plextv/react-lightning-components': patch +--- + +Bubble focus/blur events up the element tree. tvOS and web deliver focus events to ancestor views (a wrapper View's onFocus fires when a focused descendant changes), but the focus path only reaches registered focus nodes, so handlers on plain wrappers never fired. FocusManager now walks the focused leaf's element ancestry on every leaf change and delivers the event to elements that didn't just fire their own focus()/blur(). VirtualList forwards onFocus/onBlur to its outer element so handlers spread onto a list participate like they do on a native FlashList host view. diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index e7624d49..b036878f 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -98,6 +98,8 @@ function VirtualListInner( viewabilityConfig, onLoad, onLayout, + onFocus, + onBlur, snapToAlignment = 'start', animationDuration = 300, autoFocus, @@ -844,7 +846,9 @@ function VirtualListInner( trapFocusLeft={trapFocusLeft} trapFocusRight={trapFocusRight} trapFocusUp={trapFocusUp} + onBlur={onBlur} onChildFocused={handleVLFocus} + onFocus={onFocus} onResize={handleViewportResize} > diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts index 00d9311a..0c943410 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts @@ -1,5 +1,5 @@ import type { ComponentType, ReactElement } from 'react'; -import type { LightningViewElementStyle } from '@plextv/react-lightning'; +import type { LightningElement, LightningViewElementStyle } from '@plextv/react-lightning'; export interface VirtualListRenderItemInfo { item: T; @@ -140,6 +140,13 @@ export interface VirtualListProps { /** Called when the content dimensions change (e.g. items measured, data changed). */ onLayout?: (rect: { w: number; h: number }) => void; + /** + * Focus/blur handlers on the list's outer element. Focus events bubble up + * from the focused cell like they do on a native FlashList's host view. + */ + onFocus?: (element: LightningElement) => void; + onBlur?: (element: LightningElement) => void; + /** Snap scroll alignment when focusing items. Default 'start'. */ snapToAlignment?: 'center' | 'end' | 'start'; /** Duration of scroll animations in ms. Default 300. */ diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index 604fb15e..eb26eac9 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -926,6 +926,15 @@ export class LightningViewElement< } } + /** Deliver a focus/blur event bubbling up from a focused descendant. */ + public bubbleFocusEvent(type: 'focus' | 'blur', target: LightningElement): void { + if (type === 'focus') { + this.props.onFocus?.(target); + } else { + this.props.onBlur?.(target); + } + } + public render(): void { this._scheduleUpdate(); } diff --git a/packages/react-lightning/src/focus/FocusManager.spec.ts b/packages/react-lightning/src/focus/FocusManager.spec.ts index 63551b2f..06384877 100644 --- a/packages/react-lightning/src/focus/FocusManager.spec.ts +++ b/packages/react-lightning/src/focus/FocusManager.spec.ts @@ -816,4 +816,104 @@ describe('FocusManager', () => { expect(focusManager.focusPath).toEqual([leaf]); }); }); + describe('focus event bubbling', () => { + it('bubbles focus to ancestor elements that are not focus nodes', () => { + const group = createMockElement(1, 'group'); + const wrapper = createMockElement(2, 'wrapper'); + const tile = createMockElement(3, 'tile'); + + wrapper.parent = group; + tile.parent = wrapper; + + const groupBubble = (group.bubbleFocusEvent = vi.fn()); + const wrapperBubble = (wrapper.bubbleFocusEvent = vi.fn()); + + // Registers the group node implicitly; group and tile join the path in + // one recalculation. + focusManager.addElement(tile, group, { autoFocus: true }); + + // The wrapper sits between two focus nodes in the element tree; it must + // hear the focus the way a plain View does on tvOS/web. + expect(wrapperBubble).toHaveBeenCalledWith('focus', tile); + // Elements that just joined the path fired their own focus(); no + // duplicate bubbled call. + expect(groupBubble).not.toHaveBeenCalled(); + }); + + it('re-fires on path ancestors when the leaf changes under them', () => { + const group = createMockElement(1, 'group'); + const wrapperA = createMockElement(2, 'wrapperA'); + const wrapperB = createMockElement(3, 'wrapperB'); + const tileA = createMockElement(4, 'tileA'); + const tileB = createMockElement(5, 'tileB'); + + wrapperA.parent = group; + wrapperB.parent = group; + tileA.parent = wrapperA; + tileB.parent = wrapperB; + + const groupBubble = (group.bubbleFocusEvent = vi.fn()); + const wrapperABubble = (wrapperA.bubbleFocusEvent = vi.fn()); + const wrapperBBubble = (wrapperB.bubbleFocusEvent = vi.fn()); + + focusManager.addElement(tileA, group, { autoFocus: true }); + focusManager.addElement(tileB, group); + + groupBubble.mockClear(); + wrapperABubble.mockClear(); + + focusManager.focus(tileB); + + expect(wrapperABubble).toHaveBeenCalledWith('blur', tileA); + expect(wrapperBBubble).toHaveBeenCalledWith('focus', tileB); + // The group stayed on the path, so it hears both, like a wrapper on + // tvOS hears focus/blur per leaf change. + expect(groupBubble).toHaveBeenCalledWith('blur', tileA); + expect(groupBubble).toHaveBeenCalledWith('focus', tileB); + }); + + it('bubbles the old leaf blur before the new leaf focus', () => { + const group = createMockElement(1, 'group'); + const wrapperA = createMockElement(2, 'wrapperA'); + const wrapperB = createMockElement(3, 'wrapperB'); + const tileA = createMockElement(4, 'tileA'); + const tileB = createMockElement(5, 'tileB'); + + wrapperA.parent = group; + wrapperB.parent = group; + tileA.parent = wrapperA; + tileB.parent = wrapperB; + + const order: string[] = []; + + wrapperA.bubbleFocusEvent = (type) => order.push(`a:${type}`); + wrapperB.bubbleFocusEvent = (type) => order.push(`b:${type}`); + + focusManager.addElement(tileA, group, { autoFocus: true }); + focusManager.addElement(tileB, group); + order.length = 0; + + focusManager.focus(tileB); + + expect(order).toEqual(['a:blur', 'b:focus']); + }); + + it('does not bubble when the leaf is unchanged', () => { + const group = createMockElement(1, 'group'); + const wrapper = createMockElement(2, 'wrapper'); + const tile = createMockElement(3, 'tile'); + + wrapper.parent = group; + tile.parent = wrapper; + + const wrapperBubble = (wrapper.bubbleFocusEvent = vi.fn()); + + focusManager.addElement(tile, group, { autoFocus: true }); + wrapperBubble.mockClear(); + + focusManager.focus(tile); + + expect(wrapperBubble).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/react-lightning/src/focus/FocusManager.ts b/packages/react-lightning/src/focus/FocusManager.ts index c04cb470..9de657dc 100644 --- a/packages/react-lightning/src/focus/FocusManager.ts +++ b/packages/react-lightning/src/focus/FocusManager.ts @@ -931,6 +931,10 @@ export class FocusManager< curr = curr!.focusedElement; } + const oldLeaf = oldPath.length > 0 ? (oldPath[oldPath.length - 1] ?? null) : null; + const newLeaf = newLength > 0 ? (newPath[newLength - 1] ?? null) : null; + const leafChanged = oldLeaf !== newLeaf; + // Blur removed elements (leaf-first) for (let i = oldPath.length - 1; i >= divergenceIndex; i--) { const removedFocus = oldPath[i]; @@ -941,6 +945,10 @@ export class FocusManager< } } + if (leafChanged && oldLeaf) { + this._bubbleFocusEvent('blur', oldLeaf, oldPath, divergenceIndex); + } + // Focus newly added elements (root-first) for (let i = divergenceIndex; i < newPath.length; i++) { const addedFocus = newPath[i]; @@ -951,7 +959,27 @@ export class FocusManager< } } + if (leafChanged && newLeaf) { + this._bubbleFocusEvent('focus', newLeaf, newPath, divergenceIndex); + } + layer.focusPath = newPath; this._eventEmitter.emit('focusPathChanged', newPath); } + + /** + * tvOS/web bubble focus through plain wrapper views; the focus path only reaches focus nodes, + * so deliver to the remaining ancestors (skip at/past `divergenceIndex`, they fired their own). + */ + private _bubbleFocusEvent(type: 'focus' | 'blur', target: T, path: T[], divergenceIndex: number): void { + let curr: T | null | undefined = target.parent; + + while (curr) { + if (path.indexOf(curr, divergenceIndex) === -1) { + curr.bubbleFocusEvent?.(type, target); + } + + curr = curr.parent; + } + } } diff --git a/packages/react-lightning/src/mocks/createMockElement.ts b/packages/react-lightning/src/mocks/createMockElement.ts index 67ad1640..50d1a005 100644 --- a/packages/react-lightning/src/mocks/createMockElement.ts +++ b/packages/react-lightning/src/mocks/createMockElement.ts @@ -7,6 +7,8 @@ export class MockElement implements Focusable, EventNotifier { public isFocusGroup = false; + public bubbleFocusEvent?: (type: 'focus' | 'blur', target: MockElement) => void; + public constructor( public id = 0, public name = '', diff --git a/packages/react-lightning/src/types/Focusable.ts b/packages/react-lightning/src/types/Focusable.ts index 3da05c51..45f932fb 100644 --- a/packages/react-lightning/src/types/Focusable.ts +++ b/packages/react-lightning/src/types/Focusable.ts @@ -6,6 +6,11 @@ export interface Focusable extends EventNotifier> { focusable: boolean; focus: () => void; blur: () => void; + /** + * Invoked for focus/blur bubbling up from a focused descendant (tvOS/web parity; the focus + * path alone doesn't reach plain wrapper views). `target` is the originating element. + */ + bubbleFocusEvent?(type: 'focus' | 'blur', target: Focusable): void; } export interface FocusEvents { From 14656a29383b2b2ddf993f05f047df61e842b14c Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 14 Jul 2026 10:50:21 +0200 Subject: [PATCH 05/13] fix(vendor): contain zindex to its rn parent under view flattening (cherry picked from commit bcc049a876694da90e8f3718340f758e95a1c3fa) --- .changeset/zindex-stacking-scope.md | 5 ++ .../LightningViewElement.flatten.spec.ts | 59 +++++++++++++++++++ .../src/element/LightningViewElement.ts | 35 +++++++++++ 3 files changed, 99 insertions(+) create mode 100644 .changeset/zindex-stacking-scope.md diff --git a/.changeset/zindex-stacking-scope.md b/.changeset/zindex-stacking-scope.md new file mode 100644 index 00000000..48d7f09f --- /dev/null +++ b/.changeset/zindex-stacking-scope.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +Contain zIndex to its RN parent under view flattening. A flattened wrapper hoisted zIndex-carrying children to the nearest real ancestor, letting the index compete against unrelated subtrees (a nav bar's zIndex: 2 outranked modal screens mounted after it). A non-zero zIndex/zIndexLocked now materializes the flattened parent so the index only sorts among its real RN siblings, on attach and on every zIndex write path (setNodeProp, batched props, fast-path styles). diff --git a/packages/react-lightning/src/element/LightningViewElement.flatten.spec.ts b/packages/react-lightning/src/element/LightningViewElement.flatten.spec.ts index 64a126d7..9295f221 100644 --- a/packages/react-lightning/src/element/LightningViewElement.flatten.spec.ts +++ b/packages/react-lightning/src/element/LightningViewElement.flatten.spec.ts @@ -360,4 +360,63 @@ describe('flattenLayoutViews', () => { // never runs, and the leaf leaks on the scene. It must be released. expect(destroyNode).toHaveBeenCalledWith(leafNode); }); + describe('zIndex stacking scope', () => { + it('materializes a flattened parent when a zIndex child attaches', () => { + const root = createElement({ w: 1920, h: 1080, color: 0xff }); + const wrapper = createElement({ w: 1920, h: 1080 }); + const nav = createElement({ w: 1920, h: 160, zIndex: 2 }); + + root.insertChild(wrapper); + wrapper.insertChild(nav); + + // zIndex sorts among real siblings, so the parent must own a real node or the child + // competes at the hoist target against unrelated subtrees (nav bar outranking a modal). + expect(wrapper.isFlattened).toBe(false); + expect(nav.node.parent).toBe(wrapper.node); + }); + + it('materializes the flattened parent when zIndex arrives after attach', () => { + const root = createElement({ w: 1920, h: 1080, color: 0xff }); + const wrapper = createElement({ w: 1920, h: 1080 }); + const nav = createElement({ w: 1920, h: 160, color: 0xff000000 }); + + root.insertChild(wrapper); + wrapper.insertChild(nav); + + expect(wrapper.isFlattened).toBe(true); + + nav.setNodeProp('zIndex', 2); + + expect(wrapper.isFlattened).toBe(false); + expect(nav.node.parent).toBe(wrapper.node); + }); + + it('zIndex 0 does not materialize the parent', () => { + const root = createElement({ w: 1920, h: 1080, color: 0xff }); + const wrapper = createElement({ w: 1920, h: 1080 }); + const child = createElement({ w: 100, h: 100, color: 0xff000000 }); + + root.insertChild(wrapper); + wrapper.insertChild(child); + child.setNodeProp('zIndex', 0); + + expect(wrapper.isFlattened).toBe(true); + }); + + it('materializes the flattened parent on a fast-path zIndex style write', async () => { + const root = createElement({ w: 1920, h: 1080, color: 0xff }); + const wrapper = createElement({ w: 1920, h: 1080 }); + const nav = createElement({ w: 1920, h: 160, color: 0xff000000 }); + + root.insertChild(wrapper); + wrapper.insertChild(nav); + + nav.setProps({ style: { zIndex: 2 } } as never); + await flush(); + + expect(wrapper.isFlattened).toBe(false); + expect(nav.node.parent).toBe(wrapper.node); + }); + }); }); + diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index eb26eac9..8b874e0a 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -444,6 +444,20 @@ export class LightningViewElement< this.recalculateVisibility(); } + /** + * zIndex sorts among a node's real siblings, so materialize a flattened parent before a + * stacking index lands or the hoist lets it outrank unrelated subtrees (nav bar over a modal). + */ + private _containStackingIndex(key: string, value: unknown): void { + if ( + (key === 'zIndex' || key === 'zIndexLocked') && + LightningViewElement._needsRealNode(key, value) && + this._parent?.isFlattened + ) { + this._parent._materialize(); + } + } + public set focusable(value: boolean) { if (this._focusable === value) { return; @@ -493,6 +507,13 @@ export class LightningViewElement< this._parent = parent; if (LightningViewElement.flattenLayoutViewsEnabled) { + if (!this._flattened) { + const node = this.node as unknown as Record; + + this._containStackingIndex('zIndex', node.zIndex); + this._containStackingIndex('zIndexLocked', node.zIndexLocked); + } + const host = parent ? parent._hostNode() : null; const offsetX = parent?.isFlattened ? parent._flatOffsetX + parent._layoutX @@ -1064,6 +1085,8 @@ export class LightningViewElement< this._materialize(); } + this._containStackingIndex(key as string, value); + if ( (key === 'x' || key === 'y') && typeof value === 'number' && @@ -1337,6 +1360,11 @@ export class LightningViewElement< this._materialize(); } + const staged = lngProps as Record; + + this._containStackingIndex('zIndex', staged.zIndex); + this._containStackingIndex('zIndexLocked', staged.zIndexLocked); + if (typeof lngProps.x === 'number') { flattenedMoved = this._flattened && this._layoutX !== lngProps.x; this._layoutX = lngProps.x; @@ -1472,6 +1500,13 @@ export class LightningViewElement< } } + if (LightningViewElement.flattenLayoutViewsEnabled) { + const s = style as Record; + + this._containStackingIndex('zIndex', s.zIndex); + this._containStackingIndex('zIndexLocked', s.zIndexLocked); + } + const previousOpacity = this.node.alpha; let changed = false; From c4003ddaade6955451f99017f042f64dcfb785b5 Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 14 Jul 2026 11:09:26 +0200 Subject: [PATCH 06/13] fix(vendor): resolve grid focus index with the cross axis (cherry picked from commit 5499534b6700c4b838812631d936bb787d57d486) --- .changeset/grid-focus-cross-axis.md | 5 +++ .../VirtualList/LayoutManager.spec.ts | 43 +++++++++++++++++++ .../components/VirtualList/LayoutManager.ts | 31 +++++++++++++ .../components/VirtualList/VirtualList.tsx | 4 +- 4 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 .changeset/grid-focus-cross-axis.md diff --git a/.changeset/grid-focus-cross-axis.md b/.changeset/grid-focus-cross-axis.md new file mode 100644 index 00000000..788ad7e9 --- /dev/null +++ b/.changeset/grid-focus-cross-axis.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +Resolve the focused VirtualList index with the cross axis. handleVLFocus mapped the focused child back to an item via its main-axis offset alone, which in a multi-column grid identifies only the row: findIndexAtOffset returned the row's first index, the shouldFocus claim then pulled focus to column 0. New LayoutManager.findIndexAt(offset, crossOffset) walks the row's entries and picks the column containing the cross position, so D-pad Down/Up land on the item directly below/above. 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 2350f44f..e528d2cc 100644 --- a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts +++ b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts @@ -647,4 +647,47 @@ describe('LayoutManager', () => { expect(lm.updateConfig({ numColumns: 1 })).toBe(false); }); }); + describe('findIndexAt', () => { + it('resolves the exact item in a multi-column row from the cross offset', () => { + const lm = new LayoutManager({ + data: makeData(12), + numColumns: 3, + cellCrossSize: 200, + }); + + const rowOffset = lm.getLayout(3)?.offset ?? 0; + + // findIndexAtOffset alone returns the row start (column 0); the cross + // offset must pick the actual column. + expect(lm.findIndexAt(rowOffset, 0)).toBe(3); + expect(lm.findIndexAt(rowOffset, 250)).toBe(4); + expect(lm.findIndexAt(rowOffset, 450)).toBe(5); + }); + + it('clamps a cross offset past the last column to the row end', () => { + const lm = new LayoutManager({ + data: makeData(7), + numColumns: 3, + cellCrossSize: 200, + }); + + const lastRowOffset = lm.getLayout(6)?.offset ?? 0; + + // Last row has a single item; any cross offset resolves to it. + expect(lm.findIndexAt(lastRowOffset, 550)).toBe(6); + }); + + it('matches findIndexAtOffset for single-column lists', () => { + const lm = new LayoutManager({ + data: makeData(4), + numColumns: 1, + cellCrossSize: 200, + }); + + const offset = lm.getLayout(2)?.offset ?? 0; + + expect(lm.findIndexAt(offset, 9999)).toBe(lm.findIndexAtOffset(offset)); + }); + }); }); + diff --git a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts index ec00b67f..52ac57f4 100644 --- a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts +++ b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts @@ -428,6 +428,37 @@ export class LayoutManager { return this._binarySearchStart(offset); } + /** + * Exact item at a main+cross position. `findIndexAtOffset` alone returns the row's column 0 + * in a multi-column grid regardless of the caller's cross position. + */ + findIndexAt(offset: number, crossOffset: number): number { + const start = this.findIndexAtOffset(offset); + + if (start < 0 || this._numColumns === 1) { + return start; + } + + const rowOffset = this._layouts[start]?.offset; + let last = start; + + for (let i = start; i < this._layoutCount; i++) { + const layout = this._layouts[i]; + + if (!layout || layout.offset !== rowOffset) { + break; + } + + last = i; + + if (crossOffset < layout.crossOffset + layout.crossSize) { + return i; + } + } + + return last; + } + getVisibleRange( scrollOffset: number, viewportSize: number, diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index b036878f..0aff7e74 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -495,9 +495,11 @@ function VirtualListInner( if (el) { const pos = child.getRelativePosition(el); const offset = horizontal ? pos.x : pos.y; + const crossPos = horizontal ? pos.y : pos.x; const offsetInItemSpace = Math.max(0, offset - itemAreaOffset); + const crossInItemSpace = Math.max(0, crossPos - paddingCross); - resolvedIdx = layoutManager.findIndexAtOffset(offsetInItemSpace); + resolvedIdx = layoutManager.findIndexAt(offsetInItemSpace, crossInItemSpace); } if (!skipChildFocusScroll) { From 7c06d984f18f54fe9bb33e553cdab4d2181f338e Mon Sep 17 00:00:00 2001 From: Ruud Date: Tue, 14 Jul 2026 11:31:59 +0200 Subject: [PATCH 07/13] fix(vendor): default colorless text to white and re-fold nested text aggregates (cherry picked from commit fd8146b62c101f56912c69a5c0c7d1a4f973bacd) --- .changeset/text-color-and-refold.md | 5 ++ .../src/element/LightningTextElement.spec.ts | 84 +++++++++++++++++++ .../src/element/LightningTextElement.ts | 8 ++ .../src/element/LightningViewElement.ts | 4 +- 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 .changeset/text-color-and-refold.md create mode 100644 packages/react-lightning/src/element/LightningTextElement.spec.ts diff --git a/.changeset/text-color-and-refold.md b/.changeset/text-color-and-refold.md new file mode 100644 index 00000000..2c2da219 --- /dev/null +++ b/.changeset/text-color-and-refold.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +Two text-element fixes. A colorless Text mounted transparent: the mount-time color 0 stamp (meant for views, where the renderer default is white) ran before the text element's white default, so any Text without an explicit style color was invisible. Text elements are now excluded from the stamp. And a text fragment nested more than one level deep (Text > Text > string) only re-folded its direct parent on update; the text setter now propagates the re-fold through every aggregating ancestor. diff --git a/packages/react-lightning/src/element/LightningTextElement.spec.ts b/packages/react-lightning/src/element/LightningTextElement.spec.ts new file mode 100644 index 00000000..d63e19f4 --- /dev/null +++ b/packages/react-lightning/src/element/LightningTextElement.spec.ts @@ -0,0 +1,84 @@ +import type { RendererMain } from '@lightningjs/renderer'; +import type { Fiber } from 'react-reconciler'; +import { describe, expect, it } from 'vitest'; + +import type { LightningTextElementProps } from '../types'; +import { LightningTextElement } from './LightningTextElement'; + +function createMockNode(props: Record = {}) { + return { + x: 0, + y: 0, + w: 0, + h: 0, + alpha: 1, + color: undefined, + text: '', + parent: null, + on() {}, + off() {}, + destroy() {}, + ...props, + }; +} + +const renderer = { + createNode: (props: Record) => createMockNode(props), + createTextNode: (props: Record) => createMockNode(props), + createShader: () => ({ props: {} }), + destroyNode: () => {}, +} as unknown as RendererMain; + +function createTextElement(props: Partial = {}) { + return new LightningTextElement( + props as LightningTextElementProps, + renderer, + [], + {} as Fiber, + ); +} + +describe('LightningTextElement', () => { + it('defaults to a visible color when mounted without one', () => { + // The base transform stamps color 0 on colorless elements at mount (the + // renderer default is white); text must stay readable, not transparent. + const el = createTextElement({ + text: 'hello', + style: { textAlign: 'center' }, + }); + + expect(el.node.color).toBe(0xffffffff); + }); + + it('keeps an explicit style color', () => { + const el = createTextElement({ + text: 'hello', + style: { color: 0xff0000ff }, + }); + + expect(el.node.color).toBe(0xff0000ff); + }); + + it('re-folds ancestor aggregates when a nested fragment updates', () => { + // 4 followers: the outer node renders the combined + // string, so a late fragment update must re-fold every aggregating ancestor. + const outer = createTextElement({}); + const heading = createTextElement({}); + const caption = createTextElement({ text: 'followers' }); + const fragment = createTextElement({ text: '' }); + + heading.insertChild(fragment); + outer.insertChild(heading); + outer.insertChild(caption); + + expect(outer.text).toBe('followers'); + + // commitTextUpdate path: the fragment's text is set directly and the + // host config re-folds the fragment's parent only. + fragment.text = '4 '; + heading.recomputeChildText(); + + expect(heading.text).toBe('4 '); + expect(outer.text).toBe('4 followers'); + }); +}); diff --git a/packages/react-lightning/src/element/LightningTextElement.ts b/packages/react-lightning/src/element/LightningTextElement.ts index 93edee3c..727c473f 100644 --- a/packages/react-lightning/src/element/LightningTextElement.ts +++ b/packages/react-lightning/src/element/LightningTextElement.ts @@ -48,6 +48,14 @@ export class LightningTextElement extends LightningViewElement< if (v !== this._lastEmittedText) { this._lastEmittedText = v; this.emit('textChanged', this); + + // A fragment nested more than one level deep (Text > Text > string) + // must re-fold every aggregating ancestor, not just its direct parent. + const parent = this.parent; + + if (parent?.isTextElement) { + (parent as LightningTextElement).recomputeChildText(); + } } } diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index 8b874e0a..03808b98 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -1902,9 +1902,11 @@ export class LightningViewElement< if ( initial === true && this.isImageElement === false && + this.isTextElement === false && finalProps.color === undefined ) { - // set default color to 0 for all elements except image elements + // Default color 0 for views (renderer default is white). Text and image pick their + // own defaults; stamping 0 here would make a colorless Text mount transparent. finalProps.color = 0; } From fc16fd04dfc3f466646968dac348ab53d028611f Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 15 Jul 2026 09:53:29 +0200 Subject: [PATCH 08/13] fix(vendor): zero flex basis on vertical virtual lists so content height can't inflate the flex chain --- .changeset/virtual-list-viewport-flex.md | 5 ++ .../components/VirtualList/VirtualList.tsx | 4 +- .../VirtualList/resolveOuterFlex.spec.ts | 65 +++++++++++++++++++ .../VirtualList/resolveOuterFlex.ts | 19 ++++++ 4 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 .changeset/virtual-list-viewport-flex.md create mode 100644 packages/react-lightning-components/src/components/VirtualList/resolveOuterFlex.spec.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/resolveOuterFlex.ts diff --git a/.changeset/virtual-list-viewport-flex.md b/.changeset/virtual-list-viewport-flex.md new file mode 100644 index 00000000..14199fcc --- /dev/null +++ b/.changeset/virtual-list-viewport-flex.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +Give a vertical VirtualList's outer element a zero flex basis. With the default auto basis, the content plane's explicit height became the flex basis of every ancestor, and one grow-only ancestor (no flexShrink) locked the whole chain at content height (tens of thousands of px). A virtualized list never sizes from its content. diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index 0aff7e74..fe695daf 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -36,6 +36,7 @@ import { capSelfMeasuredViewport } from './capSelfMeasuredViewport'; import { computeItemRect } from './computeItemRect'; import { parseContentStyle } from './parseContentStyle'; import { resolveCrossSize } from './resolveCrossSize'; +import { resolveOuterFlex } from './resolveOuterFlex'; import { resolveRevealBoundary } from './resolveRevealBoundary'; import { resolveSectionSize } from './resolveSectionSize'; import { resolveVisibleMainSpan } from './resolveVisibleMainSpan'; @@ -738,8 +739,7 @@ function VirtualListInner( }, [onLayout, horizontal, totalContentSize, viewportCrossSize]); const outerStyle: LightningViewElementStyle = { - flexGrow: horizontal ? undefined : 1, - flexShrink: horizontal ? undefined : 1, + ...resolveOuterFlex(horizontal), clipping: true, boundsMargin: horizontal ? [0, drawDistance * 2, 0, drawDistance * 2] diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveOuterFlex.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveOuterFlex.spec.ts new file mode 100644 index 00000000..8a394d4c --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveOuterFlex.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +// Not exported from the plugin's package root; test-only deep imports. +import { SimpleDataView } from '../../../../plugin-flexbox/src/util/SimpleDataView'; +import { YogaManager } from '../../../../plugin-flexbox/src/YogaManager'; +import { resolveOuterFlex } from './resolveOuterFlex'; + +function decode(buffer: ArrayBuffer) { + const view = new SimpleDataView(buffer); + const out: Record = {}; + + while (view.offset < buffer.byteLength) { + const id = view.readUint32(); + const x = view.readInt32(); + const y = view.readInt32(); + const w = view.readInt32(); + const h = view.readInt32(); + + out[id] = { x, y, w, h }; + } + + return out; +} + +// The guide screen's shape: definite root, grow-only ancestor, list outer element, content +// plane with an explicit height. Without flexBasis 0 the whole chain lays out at ~25000px. +describe('resolveOuterFlex', () => { + it('keeps a content-sized vertical list from inflating grow-only ancestors', async () => { + const m = new YogaManager(); + await m.init(); + + m.addNode(1); + m.applyStyle(1, { display: 'flex', flexDirection: 'column', w: 1920, h: 1080 }, true); + m.addIndependentRoot(1); + + // Grow-only ancestor (the app's FocusGuide with style={{ flexGrow: 1 }}). + m.addNode(2); + m.applyStyle(2, { display: 'flex', flexDirection: 'column', flexGrow: 1 }, true); + m.addChildNode(1, 2, 0); + + // The list's outer element. + m.addNode(3); + m.applyStyle(3, { display: 'flex', flexDirection: 'column', ...resolveOuterFlex(false) }, true); + m.addChildNode(2, 3, 0); + + // The list's content plane, explicitly sized to the full content span. + m.addNode(4); + m.applyStyle(4, { w: 1920, h: 25000 }, true); + m.addChildNode(3, 4, 0); + + let layout: ReturnType = {}; + + m.on('render', (buf) => { + layout = { ...layout, ...decode(buf) }; + }); + m.flushLayout(); + + expect(layout[2]?.h).toBe(1080); + expect(layout[3]?.h).toBe(1080); + }); + + it('leaves the main axis of horizontal rows content-driven', () => { + expect(resolveOuterFlex(true)).toEqual({}); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveOuterFlex.ts b/packages/react-lightning-components/src/components/VirtualList/resolveOuterFlex.ts new file mode 100644 index 00000000..c23f8e92 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/resolveOuterFlex.ts @@ -0,0 +1,19 @@ +import type { LightningViewElementStyle } from '@plextv/react-lightning'; + +/** + * Main-axis flex for the list's outer element. flexBasis 0 keeps the content plane's height + * from becoming ancestor flex bases (a grow-only ancestor locks the chain at content height). + */ +export function resolveOuterFlex( + horizontal: boolean | null | undefined, +): LightningViewElementStyle { + if (horizontal) { + return {}; + } + + return { + flexBasis: 0, + flexGrow: 1, + flexShrink: 1, + }; +} From 202cbd1815a8ea858865bb38b1e41610150b9afd Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 15 Jul 2026 22:15:32 +0200 Subject: [PATCH 09/13] feat(vendor): virtuallist native snap props (item, scrollSnapOffset, snapToItemPadding) --- .changeset/virtuallist-native-snap-props.md | 5 +++ .../components/VirtualList/VirtualList.tsx | 2 + .../VirtualList/VirtualListTypes.ts | 12 +++++- .../resolveChildSnapAlignment.spec.ts | 26 +++++++++---- .../VirtualList/resolveChildSnapAlignment.ts | 32 ++++++++++----- .../resolveFocusScrollTarget.spec.ts | 39 +++++++++++++++++++ .../VirtualList/resolveFocusScrollTarget.ts | 36 ++++++++++++----- .../VirtualList/useScrollHandler.ts | 23 ++++++----- 8 files changed, 137 insertions(+), 38 deletions(-) create mode 100644 .changeset/virtuallist-native-snap-props.md diff --git a/.changeset/virtuallist-native-snap-props.md b/.changeset/virtuallist-native-snap-props.md new file mode 100644 index 00000000..2658472b --- /dev/null +++ b/.changeset/virtuallist-native-snap-props.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +VirtualList understands the native snap props: `snapToAlignment`/`scrollSnapAlign` resolve per child, `scrollSnapOffset` shifts the snap point, and `snapToItemPadding` pads the snapped item against the viewport edge. diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index fe695daf..1b3b8faf 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -102,6 +102,7 @@ function VirtualListInner( onFocus, onBlur, snapToAlignment = 'start', + snapToItemPadding, animationDuration = 300, autoFocus, trapFocusUp, @@ -390,6 +391,7 @@ function VirtualListInner( totalCrossSize: finalCross, animationDuration, snapToAlignment, + snapToItemPadding, onScroll, onEndReached, onEndReachedThreshold, diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts index 0c943410..3ecc0990 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts @@ -147,8 +147,16 @@ export interface VirtualListProps { onFocus?: (element: LightningElement) => void; onBlur?: (element: LightningElement) => void; - /** Snap scroll alignment when focusing items. Default 'start'. */ - snapToAlignment?: 'center' | 'end' | 'start'; + /** + * Snap alignment for focus scrolls, default 'start'. 'item' defers to each row's own + * `scrollSnapAlign`/`scrollSnapOffset`; markerless rows use the list value ('start' under 'item'). + */ + snapToAlignment?: 'center' | 'end' | 'item' | 'start'; + /** + * Padding for focus snaps (react-native-tvos `snapToItemPadding`): 'start' lands the row at + * this coordinate, 'center' shifts by half, 'end' insets. Defaults to the main-axis padding. + */ + snapToItemPadding?: number; /** Duration of scroll animations in ms. Default 300. */ animationDuration?: number; diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.spec.ts index 4b86716d..223a4f25 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.spec.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { LightningElement } from '@plextv/react-lightning'; -import { resolveChildSnapAlignment } from './resolveChildSnapAlignment'; +import { resolveChildSnapTarget } from './resolveChildSnapAlignment'; function createMockElement( props: Record, @@ -11,11 +11,23 @@ function createMockElement( return { props, children } as unknown as LightningElement; } -describe('resolveChildSnapAlignment', () => { +describe('resolveChildSnapTarget', () => { it('returns the alignment carried by the starting element', () => { const cell = createMockElement({ scrollSnapAlign: 'center' }); - expect(resolveChildSnapAlignment(cell)).toBe('center'); + expect(resolveChildSnapTarget(cell)).toEqual({ align: 'center' }); + }); + + it('returns a pixel offset and prefers it over an alignment on the same element', () => { + const cell = createMockElement({ scrollSnapAlign: 'center', scrollSnapOffset: 96 }); + + expect(resolveChildSnapTarget(cell)).toEqual({ offset: 96 }); + }); + + it('ignores non-numeric scrollSnapOffset values', () => { + const cell = createMockElement({ scrollSnapOffset: '96' }); + + expect(resolveChildSnapTarget(cell)).toBeUndefined(); }); it('descends first children to the row root', () => { @@ -23,21 +35,21 @@ describe('resolveChildSnapAlignment', () => { const flexRoot = createMockElement({}, [row]); const cell = createMockElement({}, [flexRoot]); - expect(resolveChildSnapAlignment(cell)).toBe('center'); + expect(resolveChildSnapTarget(cell)).toEqual({ align: 'center' }); }); it('ignores rows that carry no alignment', () => { const row = createMockElement({}); const cell = createMockElement({}, [createMockElement({}, [row])]); - expect(resolveChildSnapAlignment(cell)).toBeUndefined(); + expect(resolveChildSnapTarget(cell)).toBeUndefined(); }); it('ignores values that are not valid alignments', () => { const row = createMockElement({ scrollSnapAlign: 'sideways' }); const cell = createMockElement({}, [row]); - expect(resolveChildSnapAlignment(cell)).toBeUndefined(); + expect(resolveChildSnapTarget(cell)).toBeUndefined(); }); it('stops descending past the depth cap', () => { @@ -47,6 +59,6 @@ describe('resolveChildSnapAlignment', () => { deep = createMockElement({}, [deep]); } - expect(resolveChildSnapAlignment(deep)).toBeUndefined(); + expect(resolveChildSnapTarget(deep)).toBeUndefined(); }); }); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.ts b/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.ts index df345d5a..9d43f716 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveChildSnapAlignment.ts @@ -2,6 +2,12 @@ import type { LightningElement } from '@plextv/react-lightning'; type SnapAlignment = 'start' | 'center' | 'end'; +export interface ChildSnapTarget { + align?: SnapAlignment; + /** Per-item pixel offset (`scrollSnapOffset`); wins over `align`. */ + offset?: number; +} + const VALID_ALIGNMENTS: ReadonlySet = new Set(['start', 'center', 'end']); // The row root is a first-child descent away (cell FocusGroup -> FlexRoot -> @@ -9,23 +15,29 @@ const VALID_ALIGNMENTS: ReadonlySet = new Set(['start', 'center', 'end'] const MAX_DEPTH = 5; /** - * The focused row's own `scrollSnapAlign`, read from the cell's content. + * The focused row's own snap marker, 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. + * (`snapToAlignment="item"` defers entirely to the rows) via `scrollSnapAlign` + * or `scrollSnapOffset`; offset wins when both sit on the same view, matching + * the native fork. The props ride on the row's Pressable/View and pass 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 { +export function resolveChildSnapTarget(cell: LightningElement): ChildSnapTarget | undefined { let curr: LightningElement | null = cell; for (let depth = 0; curr && depth < MAX_DEPTH; depth++) { - const value = (curr.props as { scrollSnapAlign?: unknown }).scrollSnapAlign; + const props = curr.props as { scrollSnapAlign?: unknown; scrollSnapOffset?: unknown }; + + if (typeof props.scrollSnapOffset === 'number' && Number.isFinite(props.scrollSnapOffset)) { + return { offset: props.scrollSnapOffset }; + } - if (typeof value === 'string' && VALID_ALIGNMENTS.has(value)) { - return value as SnapAlignment; + if (typeof props.scrollSnapAlign === 'string' && VALID_ALIGNMENTS.has(props.scrollSnapAlign)) { + return { align: props.scrollSnapAlign as SnapAlignment }; } curr = curr.children[0] ?? null; diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts index 82b4f8ad..98bbe933 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts @@ -96,4 +96,43 @@ describe('resolveFocusScrollTarget', () => { }), ).toBe(1000 + 100 - 960); }); + + it('lands the child at the requested snapOffset, ignoring alignment', () => { + expect( + resolveFocusScrollTarget({ + viewportSize: 1920, + snapToAlignment: 'center', + snapOffset: 96, + paddingStart: 48, + paddingEnd: 48, + headerSize: 0, + footerSize: 0, + maxScroll: 5000, + childOffset: 1000, + childSize: 200, + }), + ).toBe(1000 - 96); + }); + + it('uses snapToItemPadding over the padding margins (native formulas)', () => { + const base = { + viewportSize: 1920, + snapToItemPadding: 100, + paddingStart: 48, + paddingEnd: 48, + headerSize: 0, + footerSize: 0, + maxScroll: 5000, + childOffset: 1000, + childSize: 200, + }; + + expect(resolveFocusScrollTarget({ ...base, snapToAlignment: 'start' })).toBe(1000 - 100); + expect(resolveFocusScrollTarget({ ...base, snapToAlignment: 'center' })).toBe( + 1000 + 100 - 960 + 50, + ); + expect( + resolveFocusScrollTarget({ ...base, snapToAlignment: 'end', childOffset: 3000 }), + ).toBe(3000 + 200 - 1920 + 100); + }); }); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts index dc38887c..e1b7794e 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts @@ -5,6 +5,16 @@ export interface FocusScrollTargetParams { childSize: number; viewportSize: number; snapToAlignment: 'start' | 'center' | 'end'; + /** + * Per-item pixel offset (`scrollSnapOffset`): land the child's leading edge + * at this viewport coordinate. Wins over `snapToAlignment`. + */ + snapOffset?: number; + /** + * List-level `snapToItemPadding` (react-native-tvos parity: start subtracts it, center adds + * half, end adds it). Falls back to the main-axis padding margins when absent. + */ + snapToItemPadding?: number; /** Main-axis start padding (scroll margin). */ paddingStart: number; /** Main-axis end padding (scroll margin). */ @@ -28,6 +38,8 @@ export function resolveFocusScrollTarget({ childSize, viewportSize, snapToAlignment, + snapOffset, + snapToItemPadding, paddingStart, paddingEnd, headerSize, @@ -36,16 +48,20 @@ export function resolveFocusScrollTarget({ }: 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 (snapOffset !== undefined) { + target = childOffset - snapOffset; + } else { + switch (snapToAlignment) { + case 'center': + target = childOffset + childSize / 2 - viewportSize / 2 + (snapToItemPadding ?? 0) / 2; + break; + case 'end': + target = childOffset + childSize - viewportSize + (snapToItemPadding ?? paddingEnd); + break; + default: + target = childOffset - (snapToItemPadding ?? paddingStart); + break; + } } if (target > 0 && target <= headerSize) { diff --git a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts index d23c4a9f..94362f4f 100644 --- a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts +++ b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts @@ -6,7 +6,7 @@ import type { LayoutManager } from './LayoutManager'; import type { ScrollEvent } from './VirtualListTypes'; import { createCriticalSpring } from './scrollSpring'; -import { resolveChildSnapAlignment } from './resolveChildSnapAlignment'; +import { resolveChildSnapTarget } from './resolveChildSnapAlignment'; import { resolveFocusScrollTarget } from './resolveFocusScrollTarget'; // Lightning Magic Remote / mouse support (in the host app) installs this hook @@ -48,7 +48,9 @@ export interface UseScrollHandlerOptions { /** Cross-axis content size. */ totalCrossSize: number; animationDuration: number; - snapToAlignment: 'start' | 'center' | 'end'; + snapToAlignment: 'start' | 'center' | 'end' | 'item'; + /** List-level snap padding for focus snaps (react-native-tvos parity). */ + snapToItemPadding?: number; onScroll?: (event: ScrollEvent) => void; onEndReached?: ((info: { distanceFromEnd: number }) => void) | null; onEndReachedThreshold: number | null; @@ -94,6 +96,7 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan totalCrossSize, animationDuration, snapToAlignment, + snapToItemPadding, onScroll, onEndReached, onEndReachedThreshold, @@ -267,10 +270,8 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan function scrollToOffset(offset: number, animated = true): void { const clamped = clamp(offset); - // A focus move fires two scroll requests on Lightning (VirtualList's own - // focus-follow and the app's ScrollIntoViewHelper). Ignore the duplicate - // so it doesn't restart the spring and re-inject velocity, which shows up - // as a small bounce at the end of the settle. + // A focus move can fire duplicate scroll requests for the same target; restarting the + // spring re-injects velocity and shows as a small bounce at the end of the settle. if ( animated && isAnimatingRef.current && @@ -345,13 +346,17 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan const footerSize = totalContentSize - itemAreaOffset - layoutManager.totalSize - paddingEnd; + // A row's own scrollSnapAlign/scrollSnapOffset wins over the list-level alignment, + // matching react-native-tvos ("item" defers to rows, markerless rows get 'start'). + const childTarget = resolveChildSnapTarget(child); const target = resolveFocusScrollTarget({ childOffset, childSize, viewportSize, - // A row's own scrollSnapAlign wins over the list-level alignment, - // matching react-native-tvos (snapToAlignment="item" defers to rows). - snapToAlignment: resolveChildSnapAlignment(child) ?? snapToAlignment, + snapToAlignment: + childTarget?.align ?? (snapToAlignment === 'item' ? 'start' : snapToAlignment), + snapOffset: childTarget?.offset, + snapToItemPadding, paddingStart, paddingEnd, headerSize, From 1d4327f0caa16454af0217a61e900b6bb2221d28 Mon Sep 17 00:00:00 2001 From: Ruud Date: Thu, 16 Jul 2026 00:43:11 +0200 Subject: [PATCH 10/13] fix(vendor): keep classname styles on updates, column reset default, child append index --- .changeset/classname-style-updates.md | 6 ++ .../src/YogaManager.displayToggle.test.ts | 93 +++++++++++++++++++ packages/plugin-flexbox/src/YogaManager.ts | 2 +- .../src/util/applyReactPropsToYoga.ts | 4 +- .../cssClassNameTransformPlugin.test.ts | 89 ++++++++++++++++++ .../plugins/cssClassNameTransformPlugin.ts | 17 +++- 6 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 .changeset/classname-style-updates.md create mode 100644 packages/plugin-flexbox/src/YogaManager.displayToggle.test.ts create mode 100644 packages/react-native-lightning/src/plugins/cssClassNameTransformPlugin.test.ts diff --git a/.changeset/classname-style-updates.md b/.changeset/classname-style-updates.md new file mode 100644 index 00000000..fadf1a1f --- /dev/null +++ b/.changeset/classname-style-updates.md @@ -0,0 +1,6 @@ +--- +'@plextv/react-native-lightning': patch +'@plextv/react-lightning-plugin-flexbox': patch +--- + +Keep className-derived styles on style-only updates. Update payloads omit an unchanged className, so its resolved styles vanished from the style object and the flexbox plugin reset those props (a column screen re-laid out as a row after toggling display). The className plugin now remembers what it resolved per instance. Also: a dropped flexDirection resets to column (the node creation default, not the CSS row default), and addChildNode without an index appends at the parent's child count instead of index 0. diff --git a/packages/plugin-flexbox/src/YogaManager.displayToggle.test.ts b/packages/plugin-flexbox/src/YogaManager.displayToggle.test.ts new file mode 100644 index 00000000..294de042 --- /dev/null +++ b/packages/plugin-flexbox/src/YogaManager.displayToggle.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { YogaManager } from './YogaManager'; +import { SimpleDataView } from './util/SimpleDataView'; + +function decode(buffer: ArrayBuffer) { + const view = new SimpleDataView(buffer); + const out: Record = + {}; + + while (view.offset < buffer.byteLength) { + const id = view.readUint32(); + const x = view.readInt32(); + const y = view.readInt32(); + const w = view.readInt32(); + const h = view.readInt32(); + + out[id] = { x, y, w, h }; + } + + return out; +} + +// Mimics the navigator Screen: a container with no explicit flexDirection +// (yoga node default = column) holding a header and a flex:1 content view. +async function setup() { + const m = new YogaManager(); + await m.init(); + + m.addNode(1); + m.applyStyle(1, { display: 'flex', w: 800, h: 1000 }, true); + m.addIndependentRoot(1); + + const screenStyle = { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + display: 'flex', + flex: 1, + } as const; + + m.addNode(2); + m.applyStyle(2, { ...screenStyle }, true, true); + m.addChildNode(1, 2); + + m.addNode(3); + m.applyStyle(3, { h: 112 }, true, true); + m.addChildNode(2, 3); + + m.addNode(4); + m.applyStyle(4, { flex: 1 }, true, true); + m.addChildNode(2, 4); + + let layout: ReturnType = {}; + m.on('render', (buf) => { + layout = { ...layout, ...decode(buf) }; + }); + + return { m, screenStyle, layout: () => layout }; +} + +describe('display none -> flex round trip', () => { + it('keeps default column direction after toggling display', async () => { + const { m, screenStyle, layout } = await setup(); + + m.flushLayout(); + expect(layout()[4]).toMatchObject({ x: 0, y: 112 }); + + m.applyStyle(2, { ...screenStyle, display: 'none' }, true, true); + m.flushLayout(); + + m.applyStyle(2, { ...screenStyle, display: 'flex' }, true, true); + m.flushLayout(); + + expect(layout()[4]).toMatchObject({ x: 0, y: 112 }); + }); + + it('resets a dropped flexDirection back to column (RN default)', async () => { + const { m, screenStyle, layout } = await setup(); + + m.applyStyle(2, { ...screenStyle, flexDirection: 'column' }, true, true); + m.flushLayout(); + expect(layout()[4]).toMatchObject({ x: 0, y: 112 }); + + // Full-style apply without flexDirection: reset should restore the RN + // default (column), not yoga's web default (row). + m.applyStyle(2, { ...screenStyle }, true, true); + m.flushLayout(); + + expect(layout()[4]).toMatchObject({ x: 0, y: 112 }); + }); +}); diff --git a/packages/plugin-flexbox/src/YogaManager.ts b/packages/plugin-flexbox/src/YogaManager.ts index fa6dcb78..a0b47897 100644 --- a/packages/plugin-flexbox/src/YogaManager.ts +++ b/packages/plugin-flexbox/src/YogaManager.ts @@ -294,7 +294,7 @@ export class YogaManager { return; } - index ??= childYogaNode.children.length; + index ??= parentYogaNode.children.length; parentYogaNode.node.insertChild(childYogaNode.node, index); parentYogaNode.children.splice(index, 0, childYogaNode); diff --git a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts index 1c8fcd47..3673bbcc 100644 --- a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts +++ b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts @@ -179,7 +179,9 @@ function resetFlexPropToDefault(yoga: Yoga, node: Node, prop: FlexProps): void { node.setDisplay(mapDisplay(yoga)); return; case 'flexDirection': - node.setFlexDirection(mapDirection(yoga)); + // Fresh yoga nodes are RN-default column (useWebDefaults is off), so a + // dropped flexDirection lands back on column, not the CSS default row. + node.setFlexDirection(yoga.FLEX_DIRECTION_COLUMN); return; case 'alignItems': node.setAlignItems(mapAlignItems(yoga)); diff --git a/packages/react-native-lightning/src/plugins/cssClassNameTransformPlugin.test.ts b/packages/react-native-lightning/src/plugins/cssClassNameTransformPlugin.test.ts new file mode 100644 index 00000000..4b56f6ca --- /dev/null +++ b/packages/react-native-lightning/src/plugins/cssClassNameTransformPlugin.test.ts @@ -0,0 +1,89 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { cssClassNameTransformPlugin } from './cssClassNameTransformPlugin'; + +const fakeDocument = { + styleSheets: [ + { + ownerNode: { id: 'react-native-stylesheet' }, + cssRules: [ + { + selectorText: '.r-col', + style: { cssText: 'flex-direction: column; align-items: stretch' }, + }, + { + selectorText: '.r-row', + style: { cssText: 'flex-direction: row' }, + }, + ], + }, + ], +}; + +beforeAll(() => { + vi.stubGlobal('document', fakeDocument); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +describe('cssClassNameTransformPlugin', () => { + it('resolves className rules into the style object', () => { + const plugin = cssClassNameTransformPlugin(); + const instance = {} as never; + + const out = plugin.transformProps?.(instance, { + className: 'r-col', + style: { display: 'flex' }, + }); + + expect(out?.style).toMatchObject({ + flexDirection: 'column', + alignItems: 'stretch', + display: 'flex', + }); + }); + + it('keeps class-derived styles on updates that omit className', () => { + const plugin = cssClassNameTransformPlugin(); + const instance = {} as never; + + plugin.transformProps?.(instance, { + className: 'r-col', + style: { display: 'flex' }, + }); + + // className is unchanged so the update payload omits it; the resolved class + // styles must still ride along or downstream consumers see them as removed. + const out = plugin.transformProps?.(instance, { + style: { display: 'none' }, + }); + + expect(out?.style).toMatchObject({ + flexDirection: 'column', + alignItems: 'stretch', + display: 'none', + }); + }); + + it('re-resolves when className changes', () => { + const plugin = cssClassNameTransformPlugin(); + const instance = {} as never; + + plugin.transformProps?.(instance, { className: 'r-col', style: {} }); + const out = plugin.transformProps?.(instance, { className: 'r-row', style: {} }); + + expect(out?.style).toMatchObject({ flexDirection: 'row' }); + }); + + it('passes through instances that never had a className', () => { + const plugin = cssClassNameTransformPlugin(); + const instance = {} as never; + + const props = { style: { display: 'none' } }; + const out = plugin.transformProps?.(instance, props); + + expect(out).toBe(props); + }); +}); diff --git a/packages/react-native-lightning/src/plugins/cssClassNameTransformPlugin.ts b/packages/react-native-lightning/src/plugins/cssClassNameTransformPlugin.ts index e035726d..521249b2 100644 --- a/packages/react-native-lightning/src/plugins/cssClassNameTransformPlugin.ts +++ b/packages/react-native-lightning/src/plugins/cssClassNameTransformPlugin.ts @@ -13,6 +13,10 @@ export const cssClassNameTransformPlugin = (): Plugin => { const cache: Record> = {}; + // Update payloads only carry changed props, so a style-only update arrives without the + // (unchanged) className. Remember the resolved styles per instance or they read as removed. + const resolvedClassStyles = new WeakMap>(); + function parseStyle(cssText: string) { if (!cache[cssText]) { const styleObject: Record = {}; @@ -32,8 +36,17 @@ export const cssClassNameTransformPlugin = (): Plugin => { } return { - transformProps(_instance, props) { + transformProps(instance, props) { if (!('className' in props)) { + const remembered = resolvedClassStyles.get(instance); + + if (remembered && props.style) { + return { + ...props, + style: { ...remembered, ...props.style }, + }; + } + return props; } @@ -60,6 +73,8 @@ export const cssClassNameTransformPlugin = (): Plugin => { } } + resolvedClassStyles.set(instance, finalStyle as Record); + return { ...otherProps, className: className, From e2f7487d8614607552e377b71cda3b13c1d8a9e9 Mon Sep 17 00:00:00 2001 From: Ruud Date: Thu, 16 Jul 2026 17:41:55 +0200 Subject: [PATCH 11/13] fix(vendor): keep react compiler from caching virtuallist layout reads --- .../virtuallist-no-react-compiler-memo.md | 5 ++ .../components/VirtualList/VirtualList.tsx | 61 ++++++++++++++----- 2 files changed, 51 insertions(+), 15 deletions(-) create mode 100644 .changeset/virtuallist-no-react-compiler-memo.md diff --git a/.changeset/virtuallist-no-react-compiler-memo.md b/.changeset/virtuallist-no-react-compiler-memo.md new file mode 100644 index 00000000..283fb2e1 --- /dev/null +++ b/.changeset/virtuallist-no-react-compiler-memo.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +Keep the React Compiler from caching VirtualList's mutable layout reads. Layout state lives in the LayoutManager and every change (data updates, measurements, reveal re-checks) signals through a bare version-bump state that the compiler's dependency sets never see, so the memoized visible range replayed stale and data arriving after mount never mounted any cells (the footer offset had the same staleness). The render-phase reads now live in two `'use no memo'` hooks (`useVisibleRange`, `useLayoutTotalSize`) whose fresh results downstream scopes key on; the rest of the component stays compiled. diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index 1b3b8faf..195b305e 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -52,6 +52,45 @@ 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; +// The LayoutManager mutates between renders and signals through bare +// setLayoutVersion bumps, which invalidate none of the compiler's dep sets. +// Reads of it during render live in these opted-out hooks so they re-run +// every render; their results are reactive values downstream scopes key on. +function useLayoutTotalSize(layoutManager: LayoutManager): number { + 'use no memo'; + + return layoutManager.totalSize; +} + +function useVisibleRange( + layoutManager: LayoutManager, + scrollInItemSpace: number, + viewportSize: number, + drawDistance: number, + dataLength: number, +): { + visibleRange: { startIndex: number; endIndex: number }; + visibleIndices: number[]; + totalSize: number; +} { + 'use no memo'; + + const visibleRange = layoutManager.getVisibleRange( + scrollInItemSpace, + viewportSize, + drawDistance, + ); + const visibleIndices: number[] = []; + + if (dataLength > 0 && visibleRange.endIndex >= visibleRange.startIndex) { + for (let i = visibleRange.startIndex; i <= visibleRange.endIndex; i++) { + visibleIndices.push(i); + } + } + + return { visibleRange, visibleIndices, totalSize: layoutManager.totalSize }; +} + function renderListComponent( component: VirtualListProps['ListHeaderComponent'], ): ReactElement | null { @@ -285,12 +324,9 @@ function VirtualListInner( const getData = (i: number) => data[i]; const getLayout = (i: number) => layoutManager.getLayout(i); + const measuredTotalSize = useLayoutTotalSize(layoutManager); const totalContentSize = - paddingStart + - headerSize + - layoutManager.totalSize + - footerSize + - paddingEnd; + paddingStart + headerSize + measuredTotalSize + footerSize + paddingEnd; const finalCross = viewportCrossSize > 0 ? viewportCrossSize @@ -523,18 +559,13 @@ function VirtualListInner( }; const scrollInItemSpace = Math.max(0, committedScrollOffset - itemAreaOffset); - const visibleRange = layoutManager.getVisibleRange( + const { visibleRange, visibleIndices, totalSize } = useVisibleRange( + layoutManager, scrollInItemSpace, viewportSize, drawDistance, + data.length, ); - const visibleIndices: number[] = []; - - if (data.length > 0 && visibleRange.endIndex >= visibleRange.startIndex) { - for (let i = visibleRange.startIndex; i <= visibleRange.endIndex; i++) { - visibleIndices.push(i); - } - } useViewability({ viewabilityConfig, @@ -885,11 +916,11 @@ function VirtualListInner( style={{ position: 'absolute', x: horizontal - ? paddingStart + headerSize + layoutManager.totalSize + ? paddingStart + headerSize + totalSize : paddingCross, y: horizontal ? paddingCross - : paddingStart + headerSize + layoutManager.totalSize, + : paddingStart + headerSize + totalSize, }} > {isInFlex ? ( From b328b19d3575356eeb54c82d54f1b2e0e0f92df8 Mon Sep 17 00:00:00 2001 From: Ruud Date: Thu, 16 Jul 2026 18:44:51 +0200 Subject: [PATCH 12/13] fix(vendor): skip stale focus destinations instead of aborting the focus move --- .../focus-destination-stale-ref-fallback.md | 5 ++ .../src/focus/FocusManager.spec.ts | 57 +++++++++++++++++++ .../react-lightning/src/focus/FocusManager.ts | 52 +++++++++-------- 3 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 .changeset/focus-destination-stale-ref-fallback.md diff --git a/.changeset/focus-destination-stale-ref-fallback.md b/.changeset/focus-destination-stale-ref-fallback.md new file mode 100644 index 00000000..2a1823e2 --- /dev/null +++ b/.changeset/focus-destination-stale-ref-fallback.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +Skip focus destinations that are unfocusable or no longer registered instead of aborting the whole focus move. A destination can hold a stale ref (a recycled list cell that unmounted after setDestinations); the redirect now falls through to the next destination or to normal child focus, matching native TVFocusGuideView, which drops invalid node handles. diff --git a/packages/react-lightning/src/focus/FocusManager.spec.ts b/packages/react-lightning/src/focus/FocusManager.spec.ts index 06384877..cb092b45 100644 --- a/packages/react-lightning/src/focus/FocusManager.spec.ts +++ b/packages/react-lightning/src/focus/FocusManager.spec.ts @@ -500,6 +500,63 @@ describe('FocusManager', () => { focusManager.focus(guide); expect(focusManager.focusPath).toEqual([root, real]); }); + + it('falls back to normal child focus when the destination is unregistered', () => { + const root = createMockElement(1, 'root'); + const sibling = createMockElement(2, 'sibling'); + const group = createMockElement(3, 'group'); + const child1 = createMockElement(4, 'child1'); + const child2 = createMockElement(5, 'child2'); + + focusManager.addElement(root, null); + focusManager.addElement(sibling, root); + focusManager.addElement(group, root, { destinations: [child2] }); + focusManager.addElement(child1, group); + focusManager.addElement(child2, group); + + focusManager.focus(sibling); + + // A recycled list cell: unmounted (removed from the focus tree) but the + // stale ref is still focusable and still listed as a destination. + focusManager.removeElement(child2); + + focusManager.focus(group); + expect(focusManager.focusPath).toEqual([root, group, child1]); + expect(child1.focused).toBe(true); + }); + + it('skips an unregistered destination and forwards to the next one', () => { + const root = createMockElement(1, 'root'); + const group = createMockElement(2, 'group'); + const child1 = createMockElement(3, 'child1'); + const child2 = createMockElement(4, 'child2'); + const stale = createMockElement(5, 'stale'); + + focusManager.addElement(root, null); + focusManager.addElement(group, root, { destinations: [stale, child2] }); + focusManager.addElement(child1, group); + focusManager.addElement(child2, group); + + focusManager.focus(group); + expect(focusManager.focusPath).toEqual([root, group, child2]); + }); + + it('falls back on a focusRedirect guide whose destination is unregistered', () => { + const root = createMockElement(1, 'root'); + const guide = createMockElement(2, 'guide'); + const child1 = createMockElement(3, 'child1'); + const stale = createMockElement(4, 'stale'); + + focusManager.addElement(root, null); + focusManager.addElement(guide, root, { + focusRedirect: true, + destinations: [stale], + }); + focusManager.addElement(child1, guide); + + focusManager.focus(guide); + expect(focusManager.focusPath).toEqual([root, guide, child1]); + }); }); describe('Layer Management (Modal Support)', () => { diff --git a/packages/react-lightning/src/focus/FocusManager.ts b/packages/react-lightning/src/focus/FocusManager.ts index 9de657dc..f841aa2c 100644 --- a/packages/react-lightning/src/focus/FocusManager.ts +++ b/packages/react-lightning/src/focus/FocusManager.ts @@ -636,42 +636,48 @@ export class FocusManager< } /** - * 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. + * Forward focus to the first resolvable destination of `node`, recursing + * through any further redirects. Destinations that are unfocusable or no + * longer registered (stale refs) are skipped. Returns true when focus was + * redirected (or aborted on a cycle) and the caller should stop; false when + * nothing resolved 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 (!destination) { + if (!node.destinations) { return false; } - const focusNode = this.activeLayer.elements.get(destination); + for (const destination of node.destinations) { + // A destination can hold a stale ref (e.g. a recycled list cell that + // unmounted after setDestinations); skip it like native TVFocusGuideView + // drops invalid node handles, so focus falls back to the normal child. + if (!destination?.focusable) { + continue; + } - if (!focusNode) { - console.warn('FocusManager: No focus node found for destination', destination); + const focusNode = this.activeLayer.elements.get(destination); - return true; - } + if (!focusNode) { + continue; + } - // 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 true; + } - visited.add(destination); + visited.add(destination); - this._focusNode(focusNode, visited); + this._focusNode(focusNode, visited); - return true; + return true; + } + + return false; } private _focusNode(childNode: FocusNode, visitedRedirects?: Set) { From aab632409534f45ea8743d4e007add85e87bde57 Mon Sep 17 00:00:00 2001 From: Ruud Date: Thu, 16 Jul 2026 19:22:17 +0200 Subject: [PATCH 13/13] fix(vendor): react-hidden trees release layout space (display none, not just alpha) --- .changeset/activity-hide-releases-layout.md | 5 + .../src/element/LightningViewElement.ts | 34 +++++++ .../src/render/createHostConfig.ts | 12 +-- .../src/render/hideInstance.test.ts | 96 +++++++++++++++++++ 4 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 .changeset/activity-hide-releases-layout.md create mode 100644 packages/react-lightning/src/render/hideInstance.test.ts diff --git a/.changeset/activity-hide-releases-layout.md b/.changeset/activity-hide-releases-layout.md new file mode 100644 index 00000000..aae6a4bf --- /dev/null +++ b/.changeset/activity-hide-releases-layout.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +React-hidden trees (Activity mode="hidden", suspended Suspense content) now set display: none instead of only alpha: 0, so they release their layout space like react-dom. The hide is sticky across later style pushes (a full style update used to reset display and pop the space back in while invisible), and unhide restores display and opacity from the current props. diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index 03808b98..a29b914c 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -144,6 +144,7 @@ export class LightningViewElement< /** Last requested animation target per style key, for the no-op-skip guard. */ private _animTargets = new Map(); private _paintWithheld = false; + private _reactHidden = false; private _withheldAlpha = 1; private _eventEmitter = new EventEmitter(); private _deferTarget: LightningElement | null = null; @@ -1035,6 +1036,29 @@ export class LightningViewElement< * Updates existing props with the payload, keeping other unspecified props * unchanged. */ + /** + * React reconciler hide/unhide (Activity, Suspense). A hidden tree must + * release its layout space, not just stop painting. + */ + public setReactHidden(hidden: boolean, props?: Partial): void { + if (this._reactHidden === hidden) { + return; + } + + this._reactHidden = hidden; + + const propStyle = (props?.style ?? {}) as Record; + const style: Record = hidden + ? { display: 'none', alpha: 0 } + : { + display: propStyle.display ?? 'flex', + alpha: propStyle.alpha ?? propStyle.opacity ?? 1, + }; + + style[PARTIAL_STYLE] = true; + this.setProps({ style } as unknown as Partial); + } + public setProps(payload: Partial): void { const { style, transition, ...otherProps } = payload; @@ -1291,6 +1315,16 @@ export class LightningViewElement< this._stagedUpdates = {}; this._hasStagedUpdates = false; + // A React-hidden tree must stay out of layout: a full style push while + // hidden would reset display and pop the space back in while invisible. + if (this._reactHidden && payload.style) { + payload.style = { + ...payload.style, + display: 'none', + alpha: 0, + } as typeof payload.style; + } + // Fast path: style-only updates where no plugin handles the changed properties if (this._canFastPathStyle(payload)) { return this._applyStyleFastPath(payload); diff --git a/packages/react-lightning/src/render/createHostConfig.ts b/packages/react-lightning/src/render/createHostConfig.ts index 253bc478..6c852cbf 100644 --- a/packages/react-lightning/src/render/createHostConfig.ts +++ b/packages/react-lightning/src/render/createHostConfig.ts @@ -250,21 +250,19 @@ export function createHostConfig(options?: LightningHostConfigOptions): Lightnin }, hideInstance(instance) { - instance.style.alpha = 0; + instance.setReactHidden(true); }, hideTextInstance(textInstance) { - textInstance.style.alpha = 0; + textInstance.setReactHidden(true); }, - unhideInstance(instance) { - // Probably need to make this a different property so that we don't - // override user-specified alpha values - instance.style.alpha = 1; + unhideInstance(instance, props) { + instance.setReactHidden(false, props); }, unhideTextInstance(textInstance): void { - textInstance.style.alpha = 1; + textInstance.setReactHidden(false); }, }; } diff --git a/packages/react-lightning/src/render/hideInstance.test.ts b/packages/react-lightning/src/render/hideInstance.test.ts new file mode 100644 index 00000000..5236a9d8 --- /dev/null +++ b/packages/react-lightning/src/render/hideInstance.test.ts @@ -0,0 +1,96 @@ +import type { RendererMain } from '@lightningjs/renderer'; +import type { Fiber } from 'react-reconciler'; +import { describe, expect, it } from 'vitest'; + +import { LightningViewElement } from '../element/LightningViewElement'; +import type { LightningElement, LightningElementProps } from '../types'; +import { createHostConfig } from './createHostConfig'; +import type { Plugin } from './Plugin'; + +// Element updates flush in a microtask; two ticks covers the nested schedule. +const flush = () => + new Promise((resolve) => queueMicrotask(() => queueMicrotask(() => resolve()))); + +function createElement(styleLog: Array>) { + const makeNode = (props: Record) => ({ + ...props, + on: () => {}, + off: () => {}, + emit: () => {}, + }); + const renderer = { + createNode: makeNode, + createTextNode: makeNode, + } as unknown as RendererMain; + + // Mimics the flexbox plugin: handles layout props so updates take the + // slow path, and records every style payload it is handed. + const plugin: Plugin = { + handledStyleProps: new Set(['display', 'alpha', 'w']), + transformProps(_instance, props) { + const style = (props as { style?: Record }).style; + + if (style) { + styleLog.push({ ...style }); + } + + return props; + }, + }; + + return new LightningViewElement({ style: {} } as LightningElementProps, renderer, [plugin], { + _debugInfo: {}, + } as unknown as Fiber); +} + +describe('hideInstance / unhideInstance', () => { + it('removes a hidden instance from layout, not just from paint', async () => { + const styleLog: Array> = []; + const element = createElement(styleLog); + const hostConfig = createHostConfig(); + + styleLog.length = 0; + hostConfig.hideInstance?.(element); + await flush(); + + const style = styleLog.at(-1); + expect(style).toMatchObject({ display: 'none', alpha: 0 }); + }); + + it('keeps a hidden instance hidden across later style updates', async () => { + const styleLog: Array> = []; + const element = createElement(styleLog); + const hostConfig = createHostConfig(); + + hostConfig.hideInstance?.(element); + await flush(); + + // A React commit inside the hidden tree pushes a full style; the + // flexbox plugin resets keys missing from it, so display must be + // re-stamped or the tree pops back into layout. + styleLog.length = 0; + element.setProps({ style: { w: 100 } } as Partial); + await flush(); + + const style = styleLog.at(-1); + expect(style).toMatchObject({ display: 'none', alpha: 0, w: 100 }); + }); + + it('restores display and opacity from props on unhide', async () => { + const styleLog: Array> = []; + const element = createElement(styleLog); + const hostConfig = createHostConfig(); + + hostConfig.hideInstance?.(element); + await flush(); + + styleLog.length = 0; + hostConfig.unhideInstance?.(element, { + style: { opacity: 0.5 }, + } as LightningElementProps); + await flush(); + + const style = styleLog.at(-1); + expect(style).toMatchObject({ display: 'flex', alpha: 0.5 }); + }); +});