From 8195fd768f7b40d3c11c0fef4ab866ea6a82dcc2 Mon Sep 17 00:00:00 2001 From: Ruud Date: Thu, 16 Jul 2026 22:58:39 +0200 Subject: [PATCH 01/22] fix(vendor): reserve separator gap between grid rows in VirtualList --- .changeset/virtuallist-grid-separator-gap.md | 5 +++ .../VirtualList/LayoutManager.spec.ts | 37 ++++++++++++++++--- .../components/VirtualList/LayoutManager.ts | 6 +++ 3 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 .changeset/virtuallist-grid-separator-gap.md diff --git a/.changeset/virtuallist-grid-separator-gap.md b/.changeset/virtuallist-grid-separator-gap.md new file mode 100644 index 0000000..fdfe840 --- /dev/null +++ b/.changeset/virtuallist-grid-separator-gap.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +VirtualList reserves the separator size between grid rows. The `ItemSeparatorComponent` size was left out of the multi-column row offsets, so rows sat a separator's height too high and the total content size came up short. Separators sit between rows rather than between columns (matching FlashList), and zero-height empty rows are skipped. 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 e528d2c..d36458f 100644 --- a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts +++ b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.spec.ts @@ -208,7 +208,7 @@ describe('LayoutManager', () => { expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3 + 20); }); - it('does not add separator gap between rows in multi column', () => { + it('adds a separator gap between rows in multi column', () => { const lm = new LayoutManager({ data: makeData(5), numColumns: 2, @@ -216,12 +216,38 @@ describe('LayoutManager', () => { separatorSize: 20, }); + // Items in the same row share an offset; each new row adds one gap, + // matching native FlashList (separator between grid rows, not columns). expect(lm.getLayout(0)?.offset).toBe(0); expect(lm.getLayout(1)?.offset).toBe(0); - expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE); - expect(lm.getLayout(3)?.offset).toBe(DEFAULT_ITEM_SIZE); - expect(lm.getLayout(4)?.offset).toBe(DEFAULT_ITEM_SIZE * 2); - expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3); + expect(lm.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE + 20); + expect(lm.getLayout(3)?.offset).toBe(DEFAULT_ITEM_SIZE + 20); + expect(lm.getLayout(4)?.offset).toBe(DEFAULT_ITEM_SIZE * 2 + 40); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3 + 40); + }); + + it('adds no trailing gap after the last row and skips empty rows', () => { + // 2 columns, 3 rows (last row partial) → 2 gaps, none after the last row. + const lm = new LayoutManager({ + data: makeData(5), + numColumns: 2, + cellCrossSize: 100, + separatorSize: 20, + }); + + expect(lm.getLayout(4)?.offset).toBe(DEFAULT_ITEM_SIZE * 2 + 40); + expect(lm.totalSize).toBe(DEFAULT_ITEM_SIZE * 3 + 40); + + // A fully empty (zero-height) row adds no gap after it. + const withEmptyRow = new LayoutManager<{ id: number } | null>({ + data: [{ id: 0 }, { id: 1 }, null, null, { id: 4 }], + numColumns: 2, + cellCrossSize: 100, + separatorSize: 20, + }); + + expect(withEmptyRow.getLayout(2)?.offset).toBe(DEFAULT_ITEM_SIZE + 20); + expect(withEmptyRow.getLayout(4)?.offset).toBe(DEFAULT_ITEM_SIZE + 20); }); it('does not add separator gap after a zero-size empty row', () => { @@ -690,4 +716,3 @@ describe('LayoutManager', () => { }); }); }); - diff --git a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts index 52ac57f..3e4e152 100644 --- a/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts +++ b/packages/react-lightning-components/src/components/VirtualList/LayoutManager.ts @@ -661,6 +661,12 @@ export class LayoutManager { } offset += rowHeight; + + // Separator sits between rows like native FlashList (between rows, not + // columns); skip the last row and any zero-height (empty) row. + if (rowHeight > 0 && i < count) { + offset += this._separatorSize; + } } this._layoutCount = count; From bc1cf393f73f50b0e5ad97faddc9b4e7db29ff34 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Tue, 28 Jul 2026 00:19:19 +0200 Subject: [PATCH 02/22] fix(vendor): let directional focus enter an enclosing sibling container A sibling that fully encloses the source on the movement axis (a screen scene behind a floating header) keeps its focusable content beyond the source, so enter it from the source's far edge instead of rejecting it on its near edge. Down-only, gated on strict vertical enclosure. --- .../directional-focus-enclosing-sibling.md | 5 +++++ .../src/utils/findClosestElement.spec.ts | 16 ++++++++++++++++ .../src/utils/findClosestElement.ts | 9 ++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 .changeset/directional-focus-enclosing-sibling.md diff --git a/.changeset/directional-focus-enclosing-sibling.md b/.changeset/directional-focus-enclosing-sibling.md new file mode 100644 index 0000000..bf930f0 --- /dev/null +++ b/.changeset/directional-focus-enclosing-sibling.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +Directional focus can enter a sibling that fully encloses the source on the movement axis. A screen scene behind a floating header keeps its focusable content below the source, but the distance was measured to the sibling's top edge — above the source — so the move was rejected and focus stayed stuck in the header. Such a container is now entered from the source's trailing edge instead. diff --git a/packages/react-lightning/src/utils/findClosestElement.spec.ts b/packages/react-lightning/src/utils/findClosestElement.spec.ts index 3aa5b63..39d2768 100644 --- a/packages/react-lightning/src/utils/findClosestElement.spec.ts +++ b/packages/react-lightning/src/utils/findClosestElement.spec.ts @@ -313,6 +313,22 @@ suite('findClosestElement', () => { }); suite('getOverlap', () => { + describe('should enter a sibling container that encloses the source', () => { + /** + * A small item (1, e.g. a pivot) sits at the top of a large sibling + * container (2, e.g. a screen scene behind a floating header) that fully + * encloses it and extends below. Down must enter the container — its + * focusable content is beyond the source, not above it. + */ + const elements = createLayout(500, 500, [ + { x: 0, y: 0, w: 200, h: 40 }, + { x: 0, y: 0, w: 500, h: 500 }, + ]); + const tests: TestCases = [[1, Direction.Down, 2]]; + + runTestsOnElements(elements, tests); + }); + it('should return the correct overlap for two elements', () => { const a = { x: 0, y: 0, w: 100, h: 100, centerX: 50, centerY: 50 }; const b = { diff --git a/packages/react-lightning/src/utils/findClosestElement.ts b/packages/react-lightning/src/utils/findClosestElement.ts index 067c6c0..094f068 100644 --- a/packages/react-lightning/src/utils/findClosestElement.ts +++ b/packages/react-lightning/src/utils/findClosestElement.ts @@ -62,7 +62,14 @@ function getDistance(direction: Direction, source: Dimensions, target: Dimension break; case Direction.Down: targetX = clampToSpan(source.centerX, target.x, target.w); - targetY = target.y; + // A sibling that fully encloses the source vertically (a screen scene + // behind a floating header) keeps its focusable content below the source, + // not above it — enter it from the source's bottom edge rather than + // rejecting it on its top edge. + targetY = + target.y <= source.y && target.y + target.h >= source.y + source.h + ? source.y + source.h + : target.y; if (targetY < source.centerY) { return null; From dc1053d15fe552fe951763a9fa8f5bb7d7ad7d8e Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Tue, 28 Jul 2026 00:20:33 +0200 Subject: [PATCH 03/22] fix(vendor): repaint a cleared translate axis and forward destinations every arrival - A transform is a complete snapshot, so an axis it omits returned to identity: reset a previously-written pixel translate instead of leaving the stale offset (the side-nav drawer slides on-screen when opened). - Forward FocusGroup destinations on every arrival, not just the first, so a reopened group returns to its declared destination (the drawer's selected item). RNG-636 --- ...te-clear-and-destinations-every-arrival.md | 8 +++++ .../plugin-flexbox/src/YogaManager.spec.ts | 35 +++++++++++++++++++ packages/plugin-flexbox/src/YogaManager.ts | 18 +++++++--- .../plugin-flexbox/src/types/ManagerNode.ts | 2 ++ .../src/focus/FocusManager.spec.ts | 10 +++--- .../react-lightning/src/focus/FocusManager.ts | 11 +++--- 6 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 .changeset/translate-clear-and-destinations-every-arrival.md diff --git a/.changeset/translate-clear-and-destinations-every-arrival.md b/.changeset/translate-clear-and-destinations-every-arrival.md new file mode 100644 index 0000000..ddb8544 --- /dev/null +++ b/.changeset/translate-clear-and-destinations-every-arrival.md @@ -0,0 +1,8 @@ +--- +'@plextv/react-lightning-plugin-flexbox': patch +'@plextv/react-lightning': patch +--- + +A `transform` is a complete snapshot of the node's transform, so an axis it omits has returned to identity. Yoga now writes translate 0 for that axis instead of leaving the previous pixel inset in place, which a partial style push would otherwise never repaint (a cleared focus offset stayed where it was). + +A focus group with `destinations` also forwards focus on every arrival, not just the first. `destinations` takes precedence over the remembered child, matching native `TVFocusGuideView`, so a reopened nav drawer returns to its selected item. `autoFocus` remains the separate first-then-remember mechanism. diff --git a/packages/plugin-flexbox/src/YogaManager.spec.ts b/packages/plugin-flexbox/src/YogaManager.spec.ts index ab6bcd0..a99a525 100644 --- a/packages/plugin-flexbox/src/YogaManager.spec.ts +++ b/packages/plugin-flexbox/src/YogaManager.spec.ts @@ -550,6 +550,41 @@ describe('YogaManager', () => { ); // y + translateY }); + it('resets a translate inset when a present transform omits the axis', async () => { + const { applyFlexPropToYoga } = await import('./util/applyReactPropsToYoga'); + const elementId = 321; + + yogaManager.addNode(elementId); + + // Closed: translateX shifts the node off-screen to the left. + yogaManager.applyStyle(elementId, { x: 0, transform: { translateX: -452 } }); + expect(applyFlexPropToYoga).toHaveBeenCalledWith(mockYoga, mockYogaOptions, mockNode, 'left', -452); + + vi.mocked(applyFlexPropToYoga).mockClear(); + + // Open: a transform is a complete snapshot, so an empty one (EaseView emits + // `[]` once translateX hits 0) means translate returned to identity. This + // holds even for a partial (resetMissing=false) push — the transform key is + // authoritative for its own axes — so the -452 inset must clear, not stick. + yogaManager.applyStyle(elementId, { x: 0, transform: {} }, false, false); + expect(applyFlexPropToYoga).toHaveBeenCalledWith(mockYoga, mockYogaOptions, mockNode, 'left', 0); + }); + + it('keeps a translate inset when the push omits transform entirely', async () => { + const { applyFlexPropToYoga } = await import('./util/applyReactPropsToYoga'); + const elementId = 654; + + yogaManager.addNode(elementId); + yogaManager.applyStyle(elementId, { x: 0, transform: { translateX: -452 } }); + + vi.mocked(applyFlexPropToYoga).mockClear(); + + // No transform key in this push: the translate is not part of the update + // and must be left alone, not reset to 0. + yogaManager.applyStyle(elementId, { w: 100 }, false, false); + expect(applyFlexPropToYoga).not.toHaveBeenCalledWith(mockYoga, mockYogaOptions, mockNode, 'left', 0); + }); + it('should apply multiple styles', async () => { const styles = { 123: { w: 100, h: 50 }, diff --git a/packages/plugin-flexbox/src/YogaManager.ts b/packages/plugin-flexbox/src/YogaManager.ts index a0b4789..337d1ad 100644 --- a/packages/plugin-flexbox/src/YogaManager.ts +++ b/packages/plugin-flexbox/src/YogaManager.ts @@ -495,6 +495,12 @@ export class YogaManager { yogaNode.translatePercent = undefined; yogaNode.resolvedTranslate = undefined; + // A `transform` is a complete snapshot of the node's transform, so an + // axis it omits has returned to identity. If a previous push wrote a + // pixel inset for that axis, clear it (translate 0) rather than leave the + // stale offset, which a partial push would otherwise never repaint. + const pixelTranslate = (yogaNode.pixelTranslate ??= {}); + if (typeof translateX === 'string') { const pct = Number.parseFloat(translateX); @@ -503,16 +509,18 @@ export class YogaManager { if (!Number.isNaN(pct)) { (yogaNode.translatePercent ??= {}).x = pct; } - } else if (translateX != null) { + pixelTranslate.x = false; + } else if (translateX != null || pixelTranslate.x) { const right = node.getPosition(yoga.EDGE_RIGHT); const { edge, value } = resolveHorizontalTranslate( right.unit === yoga.UNIT_POINT, x ?? 0, right.value, - translateX, + translateX ?? 0, ); applyFlexPropToYoga(yoga, this._yogaOptions, node, edge, value); + pixelTranslate.x = translateX != null; } if (typeof translateY === 'string') { @@ -521,16 +529,18 @@ export class YogaManager { if (!Number.isNaN(pct)) { (yogaNode.translatePercent ??= {}).y = pct; } - } else if (translateY != null) { + pixelTranslate.y = false; + } else if (translateY != null || pixelTranslate.y) { const bottom = node.getPosition(yoga.EDGE_BOTTOM); const { edge, value } = resolveVerticalTranslate( bottom.unit === yoga.UNIT_POINT, y ?? 0, bottom.value, - translateY, + translateY ?? 0, ); applyFlexPropToYoga(yoga, this._yogaOptions, node, edge, value); + pixelTranslate.y = translateY != null; } } } diff --git a/packages/plugin-flexbox/src/types/ManagerNode.ts b/packages/plugin-flexbox/src/types/ManagerNode.ts index 351bf09..86558cf 100644 --- a/packages/plugin-flexbox/src/types/ManagerNode.ts +++ b/packages/plugin-flexbox/src/types/ManagerNode.ts @@ -17,4 +17,6 @@ export type ManagerNode = { translatePercent?: { x?: number; y?: number }; /** Last emitted resolved position for a percent node, to dedupe readback writes. */ resolvedTranslate?: { left: number; top: number }; + /** Axes with a pixel translate inset currently written to yoga, so an omit can clear it. */ + pixelTranslate?: { x?: boolean; y?: boolean }; }; diff --git a/packages/react-lightning/src/focus/FocusManager.spec.ts b/packages/react-lightning/src/focus/FocusManager.spec.ts index cb092b4..83a3dc2 100644 --- a/packages/react-lightning/src/focus/FocusManager.spec.ts +++ b/packages/react-lightning/src/focus/FocusManager.spec.ts @@ -460,7 +460,7 @@ describe('FocusManager', () => { }); describe('destinations on arrival', () => { - it('forwards to a destination on first arrival, then remembers the child', () => { + it('forwards to a destination on every arrival, not just the first', () => { const root = createMockElement(1, 'root'); const group = createMockElement(2, 'group'); const child1 = createMockElement(3, 'child1'); @@ -476,13 +476,15 @@ describe('FocusManager', () => { focusManager.focus(group); expect(focusManager.focusPath).toEqual([root, group, child2]); - // Move focus to child1, then re-enter the group: it now remembers the - // last-focused child instead of redirecting again. + // Move focus to child1, then re-enter the group. `destinations` takes + // precedence over the remembered child on every arrival (matching native + // TVFocusGuideView `destinations`), so focus returns to child2 — this is + // what routes a reopened nav drawer back to its selected item. focusManager.focus(child1); expect(focusManager.focusPath).toEqual([root, group, child1]); focusManager.focus(group); - expect(focusManager.focusPath).toEqual([root, group, child1]); + expect(focusManager.focusPath).toEqual([root, group, child2]); }); it('always redirects with focusRedirect, every visit', () => { diff --git a/packages/react-lightning/src/focus/FocusManager.ts b/packages/react-lightning/src/focus/FocusManager.ts index f841aa2..9760027 100644 --- a/packages/react-lightning/src/focus/FocusManager.ts +++ b/packages/react-lightning/src/focus/FocusManager.ts @@ -681,14 +681,13 @@ export class FocusManager< } private _focusNode(childNode: FocusNode, visitedRedirects?: Set) { - // On arrival, forward to a declared destination. With focusRedirect this - // happens on every visit (a permanent redirect); without it, only on the - // first visit (no remembered child yet) — matching native - // TVFocusGuideView, which forwards focus on arrival then remembers the - // last-focused child for subsequent visits. + // On arrival, forward to a declared destination — on every visit, not just + // the first. `destinations` takes precedence over the remembered child, + // matching native TVFocusGuideView `destinations` (a reopened nav drawer + // returns to its selected item). `autoFocus` is the separate first-then- + // remember mechanism, resolved via the group's `focusedElement` below. if ( childNode.destinations && - (childNode.focusRedirect || !childNode.focusCommitted) && this._redirectToDestination(childNode, visitedRedirects) ) { return; From ec7e44cdfff38b7e67e0f78b14c297414a1b907e Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Tue, 28 Jul 2026 00:24:40 +0200 Subject: [PATCH 04/22] fix(vendor): register a focusable View as a Lightning spatial-focus target RNG-633 --- .changeset/focusable-view-spatial-target.md | 7 +++ .../src/exports/View.tsx | 46 ++++++++++++++++--- .../src/exports/focusableView.spec.ts | 36 +++++++++++++++ .../src/exports/focusableView.ts | 15 ++++++ 4 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 .changeset/focusable-view-spatial-target.md create mode 100644 packages/react-native-lightning/src/exports/focusableView.spec.ts create mode 100644 packages/react-native-lightning/src/exports/focusableView.ts diff --git a/.changeset/focusable-view-spatial-target.md b/.changeset/focusable-view-spatial-target.md new file mode 100644 index 0000000..c3587a2 --- /dev/null +++ b/.changeset/focusable-view-spatial-target.md @@ -0,0 +1,7 @@ +--- +'@plextv/react-native-lightning': patch +--- + +A `View` with `focusable` set is registered as a Lightning spatial-focus target. Native treats any focusable View as a nav target, but react-lightning only knew about `useFocus`/`FocusGroup` elements, so a plain focusable View was unreachable by directional nav and never fired `onFocus`. Only opt-in focusable Views pay for the registration; every other View still renders as a bare node. + +Adds `focusRestorationExcluded` for a directional-only catcher: reachable by a deliberate move, but never by restoration or the mount-time default. diff --git a/packages/react-native-lightning/src/exports/View.tsx b/packages/react-native-lightning/src/exports/View.tsx index eca4c6c..c6c0c5a 100644 --- a/packages/react-native-lightning/src/exports/View.tsx +++ b/packages/react-native-lightning/src/exports/View.tsx @@ -2,22 +2,29 @@ import type { ForwardRefExoticComponent, RefAttributes } from 'react'; import { forwardRef } from 'react'; import type { View as RNView, ViewProps as RNViewProps } from 'react-native'; -import type { - FocusableProps, - LightningElementEventProps, - LightningViewElement, - LightningViewElementProps, +import { + type FocusableProps, + type LightningElementEventProps, + type LightningViewElement, + type LightningViewElementProps, + useCombinedRef, + useFocus, } from '@plextv/react-lightning'; import type { AllStyleProps } from '@plextv/react-lightning-plugin-css-transform'; import { useLayoutHandler } from '../hooks/useLayoutHandler'; import type { NativeLightningViewElement } from '../types/NativeLightningViewElement'; +import { isFocusActive, shouldRegisterFocus } from './focusableView'; type CombinedProps = RNViewProps & LightningViewElementProps & RefAttributes & Omit & - FocusableProps; + FocusableProps & { + // Directional-only focus catcher: reachable by a deliberate move but never + // by restoration or the mount-time default (drawer edge guard). + focusRestorationExcluded?: boolean; + }; export type ViewProps = Omit & { style?: AllStyleProps & RNViewProps['style']; @@ -36,13 +43,38 @@ export const defaultViewStyle = { export type View = RNView & NativeLightningViewElement; +// Native treats any View with `focusable` set as a spatial-nav target, but +// react-lightning only registers useFocus/FocusGroup elements. Register the +// View as a focus leaf so a plain `focusable` View is reachable and fires +// onFocus like it does on tvOS/Android TV. +const FocusableView = forwardRef( + ({ focusRestorationExcluded, ...props }, ref) => { + const { ref: focusRef } = useFocus({ + active: isFocusActive(props), + focusRestorationExcluded, + }); + const combinedRef = useCombinedRef(ref, focusRef); + + return ; + }, +); + +FocusableView.displayName = 'FocusableView'; + export const View: ForwardRefExoticComponent = forwardRef< LightningViewElement, ViewProps >(({ onLayout, ...props }, ref) => { const handleLayout = useLayoutHandler(onLayout); + const viewProps = { ...(props as CombinedProps), onLayout: handleLayout }; + + // Only opt-in focusable Views pay for focus registration; everything else + // renders as a bare node exactly as before. + if (shouldRegisterFocus(viewProps)) { + return ; + } - return ; + return ; }); View.displayName = 'View'; diff --git a/packages/react-native-lightning/src/exports/focusableView.spec.ts b/packages/react-native-lightning/src/exports/focusableView.spec.ts new file mode 100644 index 0000000..75c9ea0 --- /dev/null +++ b/packages/react-native-lightning/src/exports/focusableView.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { isFocusActive, shouldRegisterFocus } from './focusableView'; + +describe('shouldRegisterFocus', () => { + it('registers a View that sets focusable either way', () => { + expect(shouldRegisterFocus({ focusable: true })).toBe(true); + expect(shouldRegisterFocus({ focusable: false })).toBe(true); + }); + + it('registers a directional-only catcher that only excludes restoration', () => { + // The edge guard opts in via focusRestorationExcluded alone, with no + // focusable prop — it must still become a spatial-nav target. + expect(shouldRegisterFocus({ focusRestorationExcluded: true })).toBe(true); + }); + + it('leaves a plain View as a bare node', () => { + expect(shouldRegisterFocus({})).toBe(false); + expect(shouldRegisterFocus({ focusable: null })).toBe(false); + expect(shouldRegisterFocus({ focusRestorationExcluded: false })).toBe( + false, + ); + }); +}); + +describe('isFocusActive', () => { + it('is inactive only when focusable is explicitly false', () => { + expect(isFocusActive({ focusable: false })).toBe(false); + }); + + it('is active for focusable Views and restoration-excluded catchers', () => { + expect(isFocusActive({ focusable: true })).toBe(true); + expect(isFocusActive({ focusRestorationExcluded: true })).toBe(true); + expect(isFocusActive({})).toBe(true); + }); +}); diff --git a/packages/react-native-lightning/src/exports/focusableView.ts b/packages/react-native-lightning/src/exports/focusableView.ts new file mode 100644 index 0000000..1d96ea3 --- /dev/null +++ b/packages/react-native-lightning/src/exports/focusableView.ts @@ -0,0 +1,15 @@ +type FocusRegistrationProps = { + focusable?: boolean | null; + focusRestorationExcluded?: boolean; +}; + +// Native promotes any View with `focusable` set (true or false) to a spatial-nav +// target; a directional-only catcher opts in via focusRestorationExcluded alone. +export function shouldRegisterFocus(props: FocusRegistrationProps): boolean { + return props.focusable != null || Boolean(props.focusRestorationExcluded); +} + +// A registered View is an active focus target unless focusable is explicitly false. +export function isFocusActive(props: FocusRegistrationProps): boolean { + return props.focusable !== false; +} From 791ab062991568ba0590dd5ebb178b9dadc51aad Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Tue, 28 Jul 2026 00:24:40 +0200 Subject: [PATCH 05/22] fix(vendor): exclude directional-only catchers from focus restoration Add a focusRestorationExcluded focus option so a catcher is reachable by a deliberate directional move but never chosen for fallback/mount-default focus, and gate the withheld-paint reveal on a settled translate to kill the open flash. (RNG-634) --- ...storation-excluded-and-translate-reveal.md | 7 ++ .../src/element/LightningViewElement.ts | 16 +++- .../src/element/isTranslateSettled.spec.ts | 43 +++++++++++ .../src/element/isTranslateSettled.ts | 54 +++++++++++++ .../src/focus/FocusManager.spec.ts | 77 +++++++++++++++++++ .../react-lightning/src/focus/FocusManager.ts | 28 ++++++- .../react-lightning/src/focus/useFocus.tsx | 8 ++ 7 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 .changeset/focus-restoration-excluded-and-translate-reveal.md create mode 100644 packages/react-lightning/src/element/isTranslateSettled.spec.ts create mode 100644 packages/react-lightning/src/element/isTranslateSettled.ts diff --git a/.changeset/focus-restoration-excluded-and-translate-reveal.md b/.changeset/focus-restoration-excluded-and-translate-reveal.md new file mode 100644 index 0000000..a390834 --- /dev/null +++ b/.changeset/focus-restoration-excluded-and-translate-reveal.md @@ -0,0 +1,7 @@ +--- +'@plextv/react-lightning': patch +--- + +`focusRestorationExcluded` nodes are skipped by focus restoration. They never become the parent's mount-time default and never inherit focus when the focused sibling unmounts, so a drawer edge guard can't take focus during launch. + +Separately, a node withheld until layout now waits one extra layout pass while a pixel translate hasn't been folded into its position yet — otherwise it painted at its untransformed origin for a frame. Bounded to that single extra pass, so a mis-detected translate can't strand the node invisible. diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index a29b914..eac0c95 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -36,6 +36,7 @@ import { } from '../types'; import { AllStyleProps } from './AllStyleProps'; import { createFlattenedNode } from './FlattenedRendererNode'; +import { isTranslateSettled } from './isTranslateSettled'; const __bannedProps: Record = {}; let __bannedPropsInitialized = false; @@ -1648,11 +1649,20 @@ export class LightningViewElement< }; private _onLayout = (dimensions: Rect) => { + const hadLayout = this._hasLayout; this._hasLayout = true; - // First layout resolved — reveal a withheld node at its now-correct - // geometry. See {@link withholdPaintUntilLayout}. - if (this._paintWithheld) { + // Reveal a withheld node at its now-correct geometry. A pixel translate + // transform is resolved off the base position and lands a layout pass later, + // so hold the reveal past the first (pre-transform) layout — otherwise the + // node paints at its untransformed origin for a frame. Bounded to that one + // extra layout so a mis-detected translate can never strand it invisible. + // See {@link withholdPaintUntilLayout}. + if ( + this._paintWithheld && + (hadLayout || + isTranslateSettled(this.props.style, this.node.x, this.node.y)) + ) { this._paintWithheld = false; if (this.node.alpha !== this._withheldAlpha) { diff --git a/packages/react-lightning/src/element/isTranslateSettled.spec.ts b/packages/react-lightning/src/element/isTranslateSettled.spec.ts new file mode 100644 index 0000000..a6c407e --- /dev/null +++ b/packages/react-lightning/src/element/isTranslateSettled.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { isTranslateSettled } from './isTranslateSettled'; + +describe('isTranslateSettled', () => { + it('settles when there is no transform', () => { + expect(isTranslateSettled(undefined, 0, 0)).toBe(true); + expect(isTranslateSettled({ x: 0 }, 0, 0)).toBe(true); + }); + + it('is unsettled while a pixel translateX is not yet folded into the position', () => { + // Drawer: left 0, translateX -452 → final x -452. First layout is still 0. + const style = { x: 0, transform: { translateX: -452 } }; + expect(isTranslateSettled(style, 0, 0)).toBe(false); + }); + + it('settles once the position reflects the pixel translateX', () => { + const style = { x: 0, transform: { translateX: -452 } }; + expect(isTranslateSettled(style, -452, 0)).toBe(true); + }); + + it('settles for a zero translate', () => { + expect( + isTranslateSettled({ x: 10, transform: { translateX: 0 } }, 10, 0), + ).toBe(true); + }); + + it('does not gate percentage translates (not resolvable from base)', () => { + const style = { x: 0, transform: { translateX: '-100%' as const } }; + expect(isTranslateSettled(style, 0, 0)).toBe(true); + }); + + it('does not gate when the base edge is unknown (e.g. right-anchored)', () => { + const style = { transform: { translateX: -452 } }; + expect(isTranslateSettled(style, 0, 0)).toBe(true); + }); + + it('handles translateY the same way', () => { + const style = { y: 0, transform: { translateY: -100 } }; + expect(isTranslateSettled(style, 0, 0)).toBe(false); + expect(isTranslateSettled(style, 0, -100)).toBe(true); + }); +}); diff --git a/packages/react-lightning/src/element/isTranslateSettled.ts b/packages/react-lightning/src/element/isTranslateSettled.ts new file mode 100644 index 0000000..c7aa9b5 --- /dev/null +++ b/packages/react-lightning/src/element/isTranslateSettled.ts @@ -0,0 +1,54 @@ +type TranslatableStyle = { + x?: number; + y?: number; + transform?: { + translateX?: number | string; + translateY?: number | string; + }; +}; + +/** + * A withheld node reveals on its first layout, but a pixel translate transform + * is resolved off the node's base position and so lands a layout pass later — + * the first layout still holds the pre-transform origin. Returns false while + * such a translate hasn't been folded into the position yet, so the reveal can + * wait for the pass that has (avoids painting the node at its untransformed + * origin for a frame). + * + * Only left/top-anchored pixel translates are detectable here (final position = + * base + delta). Percentage translates and right/bottom anchoring aren't, and + * fall through to the normal first-layout reveal. + */ +export function isTranslateSettled( + style: TranslatableStyle | null | undefined, + nodeX: number, + nodeY: number, +): boolean { + const transform = style?.transform; + + if (!transform) { + return true; + } + + const { translateX, translateY } = transform; + + if ( + typeof translateX === 'number' && + translateX !== 0 && + typeof style?.x === 'number' && + nodeX !== style.x + translateX + ) { + return false; + } + + if ( + typeof translateY === 'number' && + translateY !== 0 && + typeof style?.y === 'number' && + nodeY !== style.y + translateY + ) { + return false; + } + + return true; +} diff --git a/packages/react-lightning/src/focus/FocusManager.spec.ts b/packages/react-lightning/src/focus/FocusManager.spec.ts index 83a3dc2..eef4753 100644 --- a/packages/react-lightning/src/focus/FocusManager.spec.ts +++ b/packages/react-lightning/src/focus/FocusManager.spec.ts @@ -975,4 +975,81 @@ describe('FocusManager', () => { expect(wrapperBubble).not.toHaveBeenCalled(); }); }); + + describe('restoration-excluded focus targets', () => { + it('does not become the parent preferred child on mount', () => { + const parent = createMockElement(1, 'parent'); + const catcher = createMockElement(2, 'catcher'); + + focusManager.addElement(parent, null); + focusManager.addElement(catcher, parent, { + focusRestorationExcluded: true, + }); + + // The catcher is focusable but must never be auto-selected as the + // mount-time default (the drawer-opens-on-launch race). + expect(catcher.focused).toBe(false); + expect(focusManager.focusPath).toEqual([parent]); + }); + + it('is skipped as the fallback when the focused sibling is removed', () => { + const parent = createMockElement(1, 'parent'); + const content = createMockElement(2, 'content'); + const catcher = createMockElement(3, 'catcher'); + + focusManager.addElement(parent, null); + focusManager.addElement(content, parent, { autoFocus: true }); + focusManager.addElement(catcher, parent, { + focusRestorationExcluded: true, + }); + + expect(focusManager.focusPath).toEqual([parent, content]); + + // Content unmounts during launch; the catcher must not inherit focus. + focusManager.removeElement(content); + expect(catcher.focused).toBe(false); + expect(focusManager.focusPath).toEqual([parent]); + }); + + it('prefers a real sibling over the excluded node as fallback', () => { + const parent = createMockElement(1, 'parent'); + const content = createMockElement(2, 'content'); + const catcher = createMockElement(3, 'catcher'); + const other = createMockElement(4, 'other'); + + focusManager.addElement(parent, null); + focusManager.addElement(content, parent, { autoFocus: true }); + focusManager.addElement(catcher, parent, { + focusRestorationExcluded: true, + }); + focusManager.addElement(other, parent); + + expect(focusManager.focusPath).toEqual([parent, content]); + + focusManager.removeElement(content); + // Even though the catcher sits before `other` in the child list, the + // fallback skips it and lands on the real sibling. + expect(focusManager.focusPath).toEqual([parent, other]); + expect(catcher.focused).toBe(false); + }); + + it('still accepts an explicit (directional) focus request', () => { + const parent = createMockElement(1, 'parent'); + const content = createMockElement(2, 'content'); + const catcher = createMockElement(3, 'catcher'); + + focusManager.addElement(parent, null); + focusManager.addElement(content, parent, { autoFocus: true }); + focusManager.addElement(catcher, parent, { + focusRestorationExcluded: true, + }); + + // A deliberate directional move (findClosestElement -> focus) must still + // land on the catcher so pressing Left into the nav keeps working. + focusManager.focus(catcher); + expect(catcher.focused).toBe(true); + expect(focusManager.focusPath).toEqual([parent, catcher]); + }); + }); + }); diff --git a/packages/react-lightning/src/focus/FocusManager.ts b/packages/react-lightning/src/focus/FocusManager.ts index 9760027..21ce4da 100644 --- a/packages/react-lightning/src/focus/FocusManager.ts +++ b/packages/react-lightning/src/focus/FocusManager.ts @@ -31,6 +31,14 @@ export type FocusNode = Omit, 'element'> & { hasFocusableChildren: boolean; /** When true, focus navigation can target non-visible children (e.g. clipped items in a virtualized list). */ allowOffscreen: boolean; + /** + * When true, this node is reachable only by a deliberate directional move, + * never by restoration: it's excluded from fallback selection when a focused + * sibling unmounts and from the mount-time default. Mirrors the intent of + * tvOS `isTVFocusRestorationExcluded` (e.g. an edge-guard focus catcher that + * must not steal focus on launch). + */ + focusRestorationExcluded: boolean; }; type FocusLayer = { @@ -146,12 +154,14 @@ export class FocusManager< destinations?: (T | null)[] | null; traps?: Traps; allowOffscreen?: boolean; + focusRestorationExcluded?: boolean; }, ): void { const autoFocus = options?.autoFocus ?? false; const focusRedirect = options?.focusRedirect ?? false; const destinations = options?.destinations ?? null; const allowOffscreen = options?.allowOffscreen ?? false; + const focusRestorationExcluded = options?.focusRestorationExcluded ?? false; const traps = options?.traps ?? { up: false, right: false, @@ -200,6 +210,7 @@ export class FocusManager< childNode.destinations = destinations; childNode.traps = traps; childNode.allowOffscreen = allowOffscreen; + childNode.focusRestorationExcluded = focusRestorationExcluded; // If the child node already exists, we need to remove it from its current parent if (childNode.parent !== parentNode) { @@ -232,6 +243,7 @@ export class FocusManager< destinations, traps, allowOffscreen, + focusRestorationExcluded, ); } @@ -241,7 +253,11 @@ export class FocusManager< this._checkFocusableChildren(parentNode); - if (this._isEffectivelyFocusable(childNode) && !hasExternalRedirect(childNode)) { + if ( + this._isEffectivelyFocusable(childNode) && + !hasExternalRedirect(childNode) && + !childNode.focusRestorationExcluded + ) { if (!parentNode.focusedElement) { // No preferred child yet — take the slot regardless of autoFocus. parentNode.focusedElement = childNode; @@ -552,6 +568,7 @@ export class FocusManager< destinations: (T | null)[] | null = null, traps: Traps = { up: false, right: false, down: false, left: false }, allowOffscreen = false, + focusRestorationExcluded = false, ) { const node: FocusNode = { element, @@ -564,6 +581,7 @@ export class FocusManager< traps, hasFocusableChildren: false, allowOffscreen, + focusRestorationExcluded, focusCommitted: false, }; @@ -697,7 +715,7 @@ export class FocusManager< let currChild: FocusNode | RootNode = childNode; if (currChild.children.length && !currChild.focusedElement) { - currChild.focusedElement = this._findNextBestFocus(currChild); + currChild.focusedElement = this._findNextBestFocus(currChild, undefined, true); } // Focus has now explicitly arrived at this node, so mark its subtree as @@ -835,7 +853,7 @@ export class FocusManager< const parent = node.parent; if (this._isEffectivelyFocusable(node)) { - if (!parent.focusedElement && !hasExternalRedirect(node)) { + if (!parent.focusedElement && !hasExternalRedirect(node) && !node.focusRestorationExcluded) { parent.focusedElement = node; } } else if (parent.focusedElement === node) { @@ -862,6 +880,9 @@ export class FocusManager< private _findNextBestFocus( parent: FocusNode | RootNode, relativeNode?: FocusNode, + // Restoration-excluded nodes are skipped for fallback/restoration picks, + // but included when focus arrives at a group deliberately (directional). + includeRestorationExcluded = false, ): FocusNode | null { if (parent.children.length === 0) { return null; @@ -884,6 +905,7 @@ export class FocusManager< newChild && this._isEffectivelyFocusable(newChild) && !hasExternalRedirect(newChild) && + (includeRestorationExcluded || !newChild.focusRestorationExcluded) && newChild !== relativeNode ) { if (i >= relativeIndex) { diff --git a/packages/react-lightning/src/focus/useFocus.tsx b/packages/react-lightning/src/focus/useFocus.tsx index 3156674..cd192bd 100644 --- a/packages/react-lightning/src/focus/useFocus.tsx +++ b/packages/react-lightning/src/focus/useFocus.tsx @@ -12,6 +12,12 @@ export type FocusOptions = { onChildFocused?: (child: LightningElement) => void; /** When true, focus navigation can target non-visible children (e.g. clipped items in a virtualized list). */ allowOffscreen?: boolean; + /** + * When true, this element is reachable only by a deliberate directional move, + * never by focus restoration (fallback after a sibling unmounts) or the + * mount-time default. Mirrors tvOS `isTVFocusRestorationExcluded`. + */ + focusRestorationExcluded?: boolean; }; export function useFocus( @@ -22,6 +28,7 @@ export function useFocus( destinations, onChildFocused, allowOffscreen, + focusRestorationExcluded, }: FocusOptions = { active: true, autoFocus: false, @@ -62,6 +69,7 @@ export function useFocus( focusRedirect, destinations, allowOffscreen, + focusRestorationExcluded, }); } From b5d2e597604424ef6d07712917bccf487ad1d962 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Tue, 28 Jul 2026 00:52:36 +0200 Subject: [PATCH 06/22] fix(vendor): scroll a focused descendant into view in ScrollView Plain ScrollView never followed focus (only VirtualList did), so on Lightning the side-nav drawer and other lists stayed pinned while focus moved off-screen. Subscribe to focusPathChanged and reveal the focused descendant with a half-item margin, matching native TV auto-scroll. RNG-641 --- .changeset/scrollview-focus-into-view.md | 6 + packages/react-lightning/src/index.ts | 1 + .../src/exports/ScrollView.tsx | 114 +++++++++++++++++- .../src/exports/scrollFocus.spec.ts | 73 +++++++++++ .../src/exports/scrollFocus.ts | 43 +++++++ 5 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 .changeset/scrollview-focus-into-view.md create mode 100644 packages/react-native-lightning/src/exports/scrollFocus.spec.ts create mode 100644 packages/react-native-lightning/src/exports/scrollFocus.ts diff --git a/.changeset/scrollview-focus-into-view.md b/.changeset/scrollview-focus-into-view.md new file mode 100644 index 0000000..666217e --- /dev/null +++ b/.changeset/scrollview-focus-into-view.md @@ -0,0 +1,6 @@ +--- +'@plextv/react-native-lightning': patch +'@plextv/react-lightning': patch +--- + +`ScrollView` reveals a focused descendant the way native TV scroll views do: scroll the minimum needed to bring it fully into view, and leave already-visible items where they are. `snapToAlignment` only counts as deliberate placement for `center` and `end` — `start` and `item` (paging) aren't real focus targets, and `item` has no alignment math behind it at all, so both fall through to ensure-visible rather than snapping a focused row out of view. Exports `FocusManagerContext` from react-lightning so the compat layer can observe focus changes. diff --git a/packages/react-lightning/src/index.ts b/packages/react-lightning/src/index.ts index e93b60e..8fb9640 100644 --- a/packages/react-lightning/src/index.ts +++ b/packages/react-lightning/src/index.ts @@ -9,6 +9,7 @@ export { PARTIAL_STYLE } from './element/partialStyle'; export { FocusGroup, type FocusGroupProps } from './focus/FocusGroup'; export { FocusGroupContext } from './focus/FocusGroupContext'; export { FocusManager } from './focus/FocusManager'; +export { FocusManagerContext } from './focus/FocusManagerContext'; export { focusable } from './focus/focusable'; export { useFocus } from './focus/useFocus'; export { useFocusManager } from './focus/useFocusManager'; diff --git a/packages/react-native-lightning/src/exports/ScrollView.tsx b/packages/react-native-lightning/src/exports/ScrollView.tsx index fb23ad2..269b2ff 100644 --- a/packages/react-native-lightning/src/exports/ScrollView.tsx +++ b/packages/react-native-lightning/src/exports/ScrollView.tsx @@ -1,4 +1,13 @@ -import { createRef, PureComponent } from 'react'; +import { + createRef, + type ForwardRefExoticComponent, + forwardRef, + PureComponent, + type RefAttributes, + useContext, + useEffect, + useRef, +} from 'react'; import type { NativeScrollEvent, ScrollView as RNScrollView, @@ -7,17 +16,31 @@ import type { import type { JSX } from 'react/jsx-runtime'; import { + FocusManagerContext, type LightningElement, LightningViewElement, type LightningViewElementProps, + useCombinedRef, } from '@plextv/react-lightning'; import type { LightningViewElementStyle } from '../../../react-lightning/src/types'; import { createHandler } from '../hooks/useFocusHandler'; import type { NativeLightningViewElement } from '../types/NativeLightningViewElement'; import { createNativeSyntheticEvent } from '../utils/createNativeSyntheticEvent'; +import { getEnsureVisibleOffset, usesExplicitAlignment } from './scrollFocus'; import { defaultViewStyle, View, type ViewProps } from './View'; +function isDescendantOf(ancestor: LightningElement, node: LightningElement): boolean { + let current: LightningElement | null = node.parent; + while (current) { + if (current === ancestor) { + return true; + } + current = current.parent; + } + return false; +} + export type ScrollViewProps = Omit & Pick & { animated?: boolean; @@ -91,7 +114,7 @@ function getScrollInfo( }; } -export class ScrollView extends PureComponent { +class ScrollViewBase extends PureComponent { private _containerRef = createRef(); private _viewportRef = createRef(); @@ -137,6 +160,63 @@ export class ScrollView extends PureComponent } }; + // Reveal a focused descendant the way native TV scroll views do: scroll the + // minimum needed to bring it fully into view, leaving already-visible items + // where they are. + public scrollFocusIntoView = (el: LightningElement): void => { + const container = this._containerRef.current; + const viewport = this._viewportRef.current; + + if ( + !(el instanceof LightningViewElement) || + !container || + !viewport || + !isDescendantOf(container, el) + ) { + return; + } + + if (usesExplicitAlignment(this.props.snapToAlignment)) { + this.scrollToElement(el); + return; + } + + const child = el.getBoundingClientRect(container); + const containerBounds = container.getBoundingClientRect(viewport); + const viewportBounds = viewport.getBoundingClientRect(); + const { horizontal } = this.props; + const { x, y } = this.state.offset; + + const nextX = horizontal + ? getEnsureVisibleOffset( + viewportBounds.w, + containerBounds.w, + x, + child.x, + child.w, + child.w / 2, + ) + : x; + const nextY = horizontal + ? y + : getEnsureVisibleOffset( + viewportBounds.h, + containerBounds.h, + y, + child.y, + child.h, + child.h / 2, + ); + + this._doScroll({ + contentInset: { top: 0, left: 0, bottom: 0, right: 0 }, + contentOffset: { x: nextX, y: nextY }, + contentSize: { width: containerBounds.w, height: containerBounds.h }, + layoutMeasurement: { width: viewportBounds.w, height: viewportBounds.h }, + zoomScale: 1, + }); + }; + public scrollToEnd: RNScrollView['scrollToEnd'] = () => { const containerBounds = this._containerRef.current?.getBoundingClientRect( this._viewportRef.current, @@ -260,3 +340,33 @@ export class ScrollView extends PureComponent } } } + +// Wrap the class so it follows focus like a native TV scroll view: when spatial +// nav moves focus to a descendant, scroll it into view. The class stays the ref +// target so imperative callers (scrollTo, scrollToEnd, …) are unaffected. +export const ScrollView: ForwardRefExoticComponent< + ScrollViewProps & RefAttributes +> = forwardRef((props, ref) => { + const focusManager = useContext(FocusManagerContext)?.focusManager; + const instanceRef = useRef(null); + const combinedRef = useCombinedRef(ref, instanceRef); + + useEffect(() => { + if (!focusManager) { + return; + } + + return focusManager.on('focusPathChanged', (focusPath: LightningElement[]) => { + const leaf = focusPath[focusPath.length - 1]; + if (leaf) { + instanceRef.current?.scrollFocusIntoView(leaf); + } + }); + }, [focusManager]); + + return ; +}); + +ScrollView.displayName = 'ScrollView'; + +export type ScrollView = ScrollViewBase; diff --git a/packages/react-native-lightning/src/exports/scrollFocus.spec.ts b/packages/react-native-lightning/src/exports/scrollFocus.spec.ts new file mode 100644 index 0000000..d4bf414 --- /dev/null +++ b/packages/react-native-lightning/src/exports/scrollFocus.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { getEnsureVisibleOffset, usesExplicitAlignment } from './scrollFocus'; + +describe('usesExplicitAlignment', () => { + it('honors deliberate center/end placements', () => { + expect(usesExplicitAlignment('center')).toBe(true); + expect(usesExplicitAlignment('end')).toBe(true); + }); + + it('reveals (does not snap) for item, start, and unset', () => { + // 'item' (paging) isn't implemented by the alignment math and 'start' isn't + // a real focus target, so a focused row must ensure-visible instead. + expect(usesExplicitAlignment('item')).toBe(false); + expect(usesExplicitAlignment('start')).toBe(false); + expect(usesExplicitAlignment(undefined)).toBe(false); + expect(usesExplicitAlignment(null)).toBe(false); + }); +}); + +// Offsets follow the ScrollView convention: the content container is translated +// by a non-positive offset, so scrolling down toward the end grows more negative. +describe('getEnsureVisibleOffset', () => { + const viewport = 100; + const container = 400; + + it('leaves the offset unchanged when the child is already fully visible', () => { + // Child spans [10, 40] on screen while resting at the top. + expect(getEnsureVisibleOffset(viewport, container, 0, 10, 30)).toBe(0); + }); + + it('scrolls just enough to reveal a child below the fold', () => { + // Child at [150, 190] within the container; bottom must land on the + // viewport edge (100), so offset = 100 - 190 = -90. + expect(getEnsureVisibleOffset(viewport, container, 0, 150, 40)).toBe(-90); + }); + + it('aligns a child above the current window to the top', () => { + // Already scrolled to -200; child at 50 is above the window. + expect(getEnsureVisibleOffset(viewport, container, -200, 50, 30)).toBe(-50); + }); + + it('never scrolls past the end of the content', () => { + // maxScroll = viewport - container = -300; a child at the very end can't + // pull the offset past that clamp. + expect(getEnsureVisibleOffset(viewport, container, 0, 380, 20)).toBe(-300); + }); + + it('never scrolls before the start of the content', () => { + expect(getEnsureVisibleOffset(viewport, container, -50, 0, 20)).toBe(0); + }); + + it('does not scroll when the content fits inside the viewport', () => { + expect(getEnsureVisibleOffset(200, 120, 0, 80, 30)).toBe(0); + }); + + it('leaves a margin below a child revealed at the bottom', () => { + // Child at [150, 190]; with a 20px margin its bottom lands at 100 - 20 = 80, + // so offset = 80 - 190 = -110. + expect(getEnsureVisibleOffset(viewport, container, 0, 150, 40, 20)).toBe(-110); + }); + + it('leaves a margin above a child revealed at the top', () => { + // Scrolled to -200; child at 50 with a 20px margin should sit at y=20, + // so offset = 20 - 50 = -30. + expect(getEnsureVisibleOffset(viewport, container, -200, 50, 30, 20)).toBe(-30); + }); + + it('collapses the margin at the end of the content via the clamp', () => { + // maxScroll = -300; the margin can't push past it. + expect(getEnsureVisibleOffset(viewport, container, 0, 380, 20, 20)).toBe(-300); + }); +}); diff --git a/packages/react-native-lightning/src/exports/scrollFocus.ts b/packages/react-native-lightning/src/exports/scrollFocus.ts new file mode 100644 index 0000000..c37f7ac --- /dev/null +++ b/packages/react-native-lightning/src/exports/scrollFocus.ts @@ -0,0 +1,43 @@ +/** + * Whether a focus scroll should honor snapToAlignment as a deliberate placement + * rather than just revealing the item. Only 'center'/'end' qualify: 'start' and + * 'item' (paging) aren't real focus targets — and 'item' isn't even implemented + * by the alignment math — so they should fall through to ensure-visible. + */ +export function usesExplicitAlignment(snapToAlignment: string | null | undefined): boolean { + return snapToAlignment === 'center' || snapToAlignment === 'end'; +} + +/** + * Minimal ("nearest") scroll offset that brings a child fully into view, the + * native TV auto-scroll behavior a plain ScrollView lacks. Offsets are + * non-positive: the content container is translated by the returned value, so + * scrolling toward the end grows more negative. A child already in view keeps + * the current offset instead of snapping to an edge. `margin` keeps that much + * breathing room past the child before it counts as revealed, so the focused + * item doesn't sit flush against the viewport edge (naturally collapses at the + * list ends via the clamp). + */ +export function getEnsureVisibleOffset( + viewportSize: number, + containerSize: number, + currentOffset: number, + childOffset: number, + childSize: number, + margin = 0, +): number { + const minOffset = Math.min(0, viewportSize - containerSize); + + const childTop = childOffset + currentOffset; + const childBottom = childOffset + childSize + currentOffset; + + let offset = currentOffset; + if (childTop < margin) { + offset = margin - childOffset; + } else if (childBottom > viewportSize - margin) { + offset = viewportSize - margin - (childOffset + childSize); + } + + // `|| 0` also normalizes -0 to 0. + return Math.max(minOffset, Math.min(0, offset)) || 0; +} From e98f1b6bc9f811c4d92305149639b446a46de700 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Tue, 28 Jul 2026 14:13:50 +0200 Subject: [PATCH 07/22] fix(vendor): anchor EPG directional focus per-airing (RNG-490) Down/Up in the Live TV guide landed on the channel header instead of the airing under the focused column. Two framework gaps: - directional nav beamed from the group's immediate focused child, losing the deep leaf's cross-axis position, so descent fell to the first child (the header). Beam from the deepest focused leaf and descend by geometry into the chosen sibling; a redirect node (the row's airings guide) is handed to the focus manager to forward to its anchored destination. - the airings guide redirects to one of its own cells (an internal redirect). _focusNode's upward walk re-fired that redirect on the way back up, targeting a descendant it had just visited, self-cycling and aborting the move so focus stranded on the guide's first child. The upward walk now only forwards external redirects; internal ones are already satisfied by the downward-arrival redirect. --- .../directional-anchor-internal-redirect.md | 5 + .../src/focus/FocusKeyManager.ts | 46 ++++- .../src/focus/FocusManager.spec.ts | 32 ++++ .../react-lightning/src/focus/FocusManager.ts | 8 +- .../src/utils/findClosestElement.ts | 50 ++++++ .../utils/resolveDirectionalTarget.spec.ts | 159 ++++++++++++++++++ 6 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 .changeset/directional-anchor-internal-redirect.md create mode 100644 packages/react-lightning/src/utils/resolveDirectionalTarget.spec.ts diff --git a/.changeset/directional-anchor-internal-redirect.md b/.changeset/directional-anchor-internal-redirect.md new file mode 100644 index 0000000..4abeb47 --- /dev/null +++ b/.changeset/directional-anchor-internal-redirect.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +Anchor directional focus into a nested group and fix internal-redirect self-cycling. Directional nav now beams from the deepest focused leaf and descends by geometry into the chosen sibling, so a narrow-header-beside-wide-group row lands under the source's cross-axis position instead of on the group's first child; a redirect node is returned for the focus manager to forward to its destination. Separately, `_focusNode`'s upward walk now only hands focus off to external redirects: an internal redirect (destinations within the node's own subtree, e.g. an EPG airings guide pointing at its own cells) is already satisfied by the downward-arrival redirect, and re-firing it while walking back up targeted a descendant just visited and self-cycled, aborting the move and stranding focus on the guide's first child. diff --git a/packages/react-lightning/src/focus/FocusKeyManager.ts b/packages/react-lightning/src/focus/FocusKeyManager.ts index c251894..cd52cd3 100644 --- a/packages/react-lightning/src/focus/FocusKeyManager.ts +++ b/packages/react-lightning/src/focus/FocusKeyManager.ts @@ -1,6 +1,6 @@ import { Keys } from '../input/Keys'; import type { KeyEvent, LightningElement } from '../types'; -import { findClosestElement } from '../utils/findClosestElement'; +import { findClosestElement, resolveDirectionalTarget } from '../utils/findClosestElement'; import { Direction } from './Direction'; import type { FocusManager, FocusNode } from './FocusManager'; @@ -79,6 +79,8 @@ export class FocusKeyManager { return true; } + // Pick the next sibling from the immediate focused child, so the current + // subtree (and its ancestors) are excluded as candidates. const closestElement = findClosestElement( focusNode.focusedElement.element, childElements(focusNode.children), @@ -88,7 +90,29 @@ export class FocusKeyManager { ); if (closestElement) { - this._focusManager.focus(closestElement as T); + // Descend into the chosen sibling from the deepest focused leaf, not the + // group's immediate child, so a nested layout keeps the real cross-axis + // position of what the user is on (an EPG airing, not its full-width row) + // and lands on the overlapping child instead of the group's first child. + // A redirect node (e.g. a row's airings guide) is returned for the focus + // manager to forward to its anchored destination. + let leafNode: FocusNode = focusNode.focusedElement; + + while (leafNode.focusedElement) { + leafNode = leafNode.focusedElement; + } + + const target = resolveDirectionalTarget( + leafNode.element, + closestElement, + focusNode.parent.element, + direction, + (child) => this._focusableChildElements(child), + (child) => this._isRedirect(child), + (child) => this._getAllowOffscreen(child), + ); + + this._focusManager.focus(target); return false; } @@ -108,4 +132,22 @@ export class FocusKeyManager { return true; }; + + private _focusableChildElements = (element: LightningElement): Iterable => { + const node = this._focusManager.getFocusNode(element); + + return node ? childElements(node.children) : []; + }; + + private _getAllowOffscreen = (element: LightningElement): boolean => { + const node = this._focusManager.getFocusNode(element); + + return !!node?.allowOffscreen; + }; + + private _isRedirect = (element: LightningElement): boolean => { + const node = this._focusManager.getFocusNode(element); + + return !!node?.focusRedirect; + }; } diff --git a/packages/react-lightning/src/focus/FocusManager.spec.ts b/packages/react-lightning/src/focus/FocusManager.spec.ts index eef4753..8c6e05e 100644 --- a/packages/react-lightning/src/focus/FocusManager.spec.ts +++ b/packages/react-lightning/src/focus/FocusManager.spec.ts @@ -503,6 +503,38 @@ describe('FocusManager', () => { expect(focusManager.focusPath).toEqual([root, real]); }); + it('lands on an internal-redirect destination without self-cycling', () => { + const root = createMockElement(1, 'root'); + const sibling = createMockElement(2, 'sibling'); + const guide = createMockElement(3, 'guide'); + const cellA = createMockElement(4, 'cellA'); + const cellB = createMockElement(5, 'cellB'); + // The guide's destinations point at its own descendants (an EPG airings + // guide anchoring on one of its cells), so the element parent chain runs + // through the guide. + cellA.parent = guide; + cellB.parent = guide; + + focusManager.addElement(root, null); + focusManager.addElement(sibling, root); + focusManager.addElement(guide, root, { + focusRedirect: true, + destinations: [cellB], + }); + focusManager.addElement(cellA, guide); + focusManager.addElement(cellB, guide); + + focusManager.focus(sibling); + + // Entering the guide forwards to its internal destination and stays + // there. The upward-walk redirect must not re-fire on the way back up + // (it targets a descendant we just came from), or it self-cycles and + // aborts the move, stranding focus on the guide's first child. + focusManager.focus(guide); + expect(focusManager.focusPath).toEqual([root, guide, cellB]); + expect(cellB.focused).toBe(true); + }); + it('falls back to normal child focus when the destination is unregistered', () => { const root = createMockElement(1, 'root'); const sibling = createMockElement(2, 'sibling'); diff --git a/packages/react-lightning/src/focus/FocusManager.ts b/packages/react-lightning/src/focus/FocusManager.ts index 21ce4da..bdff036 100644 --- a/packages/react-lightning/src/focus/FocusManager.ts +++ b/packages/react-lightning/src/focus/FocusManager.ts @@ -724,9 +724,13 @@ export class FocusManager< childNode.focusCommitted = true; while (currChild && !isRootNode(currChild) && currParent) { + // Only hand focus off to an external redirect while walking up. An + // internal redirect (destinations within this node's own subtree, e.g. + // an EPG airings guide pointing at its own cells) is already satisfied by + // the downward-arrival redirect above; re-firing it here would target a + // descendant we just came from and self-cycle, aborting the focus move. if ( - currChild.focusRedirect && - currChild.destinations && + hasExternalRedirect(currChild) && this._redirectToDestination(currChild, visitedRedirects) ) { return; diff --git a/packages/react-lightning/src/utils/findClosestElement.ts b/packages/react-lightning/src/utils/findClosestElement.ts index 094f068..76cfde3 100644 --- a/packages/react-lightning/src/utils/findClosestElement.ts +++ b/packages/react-lightning/src/utils/findClosestElement.ts @@ -326,3 +326,53 @@ function findClosestNodeInTree( return closest; } + +/** + * Resolve the final directional target after a move lands on `target`. + * + * Native focus engines beam from the currently focused view's geometry, so a + * nested layout (a narrow header beside a wide scrollable group) lands under + * the source's cross-axis position, not on the group's first child. From + * `target`, keep picking the child closest to `source` along `direction`. + * `source` must be the deep focused leaf (not an intermediate group) so its + * real cross position drives the descent. + * + * Stop at a redirect node (`isRedirect`) and return it: the focus manager + * forwards it to its destination (e.g. an EPG airings guide anchoring on the + * overlapping airing). Otherwise descend to a plain focusable leaf. Returns + * `target` unchanged when it has no focusable children. + */ +export function resolveDirectionalTarget( + source: LightningElement, + target: LightningElement, + parentElement: LightningElement | null, + direction: Direction, + getFocusableChildren: (element: LightningElement) => Iterable, + isRedirect: (element: LightningElement) => boolean, + getAllowOffscreen: (element: LightningElement) => boolean, +): LightningElement { + let current = target; + + // Bounded by tree depth; guards against a malformed accessor cycle. + for (let depth = 0; depth < 64; depth++) { + if (isRedirect(current)) { + return current; + } + + const next = findClosestElement( + source, + getFocusableChildren(current), + parentElement, + direction, + getAllowOffscreen(current), + ); + + if (!next || next === current) { + return current; + } + + current = next; + } + + return current; +} diff --git a/packages/react-lightning/src/utils/resolveDirectionalTarget.spec.ts b/packages/react-lightning/src/utils/resolveDirectionalTarget.spec.ts new file mode 100644 index 0000000..5b400a0 --- /dev/null +++ b/packages/react-lightning/src/utils/resolveDirectionalTarget.spec.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; + +import { Direction } from '../focus/Direction'; +import type { LightningElement, Rect } from '../types'; +import { resolveDirectionalTarget } from './findClosestElement'; + +type TestElement = LightningElement & { + children: TestElement[]; + redirect: boolean; + allowOffscreen: boolean; +}; + +function el( + id: number, + rect: Rect, + opts: { children?: TestElement[]; redirect?: boolean; allowOffscreen?: boolean } = {}, +): TestElement { + return { + id, + node: { ...rect }, + focusable: true, + focusableIntent: true, + isFocusGroup: (opts.children?.length ?? 0) > 0, + children: opts.children ?? [], + redirect: opts.redirect ?? false, + allowOffscreen: opts.allowOffscreen ?? false, + getRelativePosition(this: LightningElement, relativeElement?: LightningElement) { + return { + x: (relativeElement?.node?.x ?? 0) + this.node.x, + y: (relativeElement?.node?.y ?? 0) + this.node.y, + }; + }, + } as unknown as TestElement; +} + +const getFocusableChildren = (e: LightningElement): Iterable => + (e as TestElement).children; +const isRedirect = (e: LightningElement): boolean => (e as TestElement).redirect; +const getAllowOffscreen = (e: LightningElement): boolean => (e as TestElement).allowOffscreen; + +// EPG-shaped row below the source: a narrow channel header on the left and a +// wide airings guide on the right. The guide is a redirect node (it anchors on +// its own overlapping airing via the focus manager), so the descent should +// stop at it rather than the header or its inner cells. +const ROW_H = 120; +function airingRow(y: number) { + const cellA = el(y + 1, { x: 110, y, w: 300, h: ROW_H }); + const cellB = el(y + 2, { x: 560, y, w: 300, h: ROW_H }); + const guide = el( + y + 3, + { x: 110, y, w: 1390, h: ROW_H }, + { children: [cellA, cellB], redirect: true, allowOffscreen: true }, + ); + const header = el(y + 4, { x: 0, y, w: 104, h: ROW_H }); + const row = el(y + 5, { x: 0, y, w: 1500, h: ROW_H }, { children: [header, guide] }); + return { row, header, guide, cellA, cellB }; +} + +describe('resolveDirectionalTarget', () => { + it('descends to the airings guide (a redirect node), not the row-start header', () => { + const below = airingRow(130); + // Source airing mid-timeline, centerX 660 — over the guide, not the header. + const source = el(999, { x: 560, y: 0, w: 200, h: ROW_H }); + + const target = resolveDirectionalTarget( + source, + below.row, + null, + Direction.Down, + getFocusableChildren, + isRedirect, + getAllowOffscreen, + ); + + expect(target).toBe(below.guide); + expect(target).not.toBe(below.header); + // Stops at the redirect guide; does not descend into its cells. + expect(target).not.toBe(below.cellA); + expect(target).not.toBe(below.cellB); + }); + + it('anchors on Up the same way', () => { + const above = airingRow(0); + const source = el(999, { x: 560, y: 130, w: 200, h: ROW_H }); + + const target = resolveDirectionalTarget( + source, + above.row, + null, + Direction.Up, + getFocusableChildren, + isRedirect, + getAllowOffscreen, + ); + + expect(target).toBe(above.guide); + }); + + it('descends through a non-redirect group to the overlapping leaf', () => { + const leftLeaf = el(1, { x: 0, y: 130, w: 100, h: ROW_H }); + const rightLeaf = el(2, { x: 600, y: 130, w: 300, h: ROW_H }); // 600..900 + const group = el(3, { x: 0, y: 130, w: 900, h: ROW_H }, { + children: [leftLeaf, rightLeaf], + redirect: false, + }); + // Source centerX 700 overlaps the right leaf. + const source = el(999, { x: 600, y: 0, w: 200, h: ROW_H }); + + const target = resolveDirectionalTarget( + source, + group, + null, + Direction.Down, + getFocusableChildren, + isRedirect, + getAllowOffscreen, + ); + + expect(target).toBe(rightLeaf); + }); + + it('returns a redirect node as-is', () => { + const inner = el(2, { x: 560, y: 130, w: 300, h: ROW_H }); + const guide = el(1, { x: 110, y: 130, w: 1390, h: ROW_H }, { + children: [inner], + redirect: true, + }); + const source = el(999, { x: 560, y: 0, w: 200, h: ROW_H }); + + const target = resolveDirectionalTarget( + source, + guide, + null, + Direction.Down, + getFocusableChildren, + isRedirect, + getAllowOffscreen, + ); + + expect(target).toBe(guide); + }); + + it('returns a plain focusable leaf unchanged', () => { + const leaf = el(1, { x: 0, y: 130, w: 300, h: ROW_H }); + const source = el(999, { x: 0, y: 0, w: 300, h: ROW_H }); + + const target = resolveDirectionalTarget( + source, + leaf, + null, + Direction.Down, + getFocusableChildren, + isRedirect, + getAllowOffscreen, + ); + + expect(target).toBe(leaf); + }); +}); From 254de6ba128cb799dfa5b00e11f5d0a2c0778e26 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Tue, 28 Jul 2026 15:02:53 +0200 Subject: [PATCH 08/22] fix(vendor): parse alpha-hex, hsl, and space-form rgb colors; drop opaque color values --- .../color-parsing-alpha-hex-hsl-space-rgb.md | 7 ++ .../utils/htmlColorToLightningColor.test.ts | 55 ++++++-- .../src/utils/htmlColorToLightningColor.ts | 118 +++++++++++++++--- 3 files changed, 154 insertions(+), 26 deletions(-) create mode 100644 .changeset/color-parsing-alpha-hex-hsl-space-rgb.md diff --git a/.changeset/color-parsing-alpha-hex-hsl-space-rgb.md b/.changeset/color-parsing-alpha-hex-hsl-space-rgb.md new file mode 100644 index 0000000..020fa00 --- /dev/null +++ b/.changeset/color-parsing-alpha-hex-hsl-space-rgb.md @@ -0,0 +1,7 @@ +--- +'@plextv/react-lightning-plugin-css-transform': patch +--- + +Color parsing handles 8- and 4-digit alpha hex, `hsl()`/`hsla()`, and the modern space-separated `rgb()` form with a `/` alpha delimiter. These previously fell through unparsed. + +`PlatformColor`/`OpaqueColorValue` objects have no resolvable string form, so they're dropped with a warning (like unresolvable keyword colors) instead of throwing and taking the screen down. diff --git a/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.test.ts b/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.test.ts index a4ebc2c..4e7dbcf 100644 --- a/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.test.ts +++ b/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.test.ts @@ -1,5 +1,5 @@ import type { ColorValue } from 'react-native'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { htmlColorToLightningColor } from './htmlColorToLightningColor'; @@ -52,6 +52,29 @@ describe('htmlColorToLightningColor', () => { expect(actual).toBe(expected); }); + it('should convert space-form rgb string to a number', () => { + expect(htmlColorToLightningColor('rgb(244 164 96)')).toBe(0xf4a460ff); + expect(htmlColorToLightningColor('rgb(244 164 96 / 0.4)')).toBe(0xf4a46066); + }); + + it('should convert an 8-digit alpha hex to a number', () => { + expect(htmlColorToLightningColor('#ff00ff80')).toBe(0xff00ff80); + }); + + it('should convert a 4-digit alpha hex to a number', () => { + expect(htmlColorToLightningColor('#abcd')).toBe(0xaabbccdd); + }); + + it('should convert hsl/hsla strings to a number', () => { + expect(htmlColorToLightningColor('hsl(300, 100%, 50%)')).toBe(0xff00ffff); + expect(htmlColorToLightningColor('hsla(300, 100%, 50%, 0.4)')).toBe( + 0xff00ff66, + ); + expect(htmlColorToLightningColor('hsl(300 100% 50% / 0.4)')).toBe( + 0xff00ff66, + ); + }); + it('should convert html color code to a number', () => { const value = 'sandybrown'; const expected = 0xf4a460ff; @@ -77,16 +100,6 @@ describe('htmlColorToLightningColor', () => { } }); - it('should throw an error if an invalid hex was given', () => { - const value = '#abcd'; - - const run = (): void => { - htmlColorToLightningColor(value); - }; - - expect(run).toThrow('Invalid hex value'); - }); - it('should throw an error if html color code is not a web X11 color', () => { const value = 'sandybrownzzzz'; const run = (): void => { @@ -97,8 +110,26 @@ describe('htmlColorToLightningColor', () => { }); it('should return undefined for unresolvable css keyword colors', () => { - for (const value of ['inherit', 'initial', 'unset', 'revert', 'currentColor', 'CurrentColor']) { + for (const value of [ + 'inherit', + 'initial', + 'unset', + 'revert', + 'currentColor', + 'CurrentColor', + ]) { expect(htmlColorToLightningColor(value)).toBeUndefined(); } }); + + it('should warn and drop opaque/object color values instead of throwing', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Stands in for a PlatformColor / OpaqueColorValue. + const opaque = { semantic: ['label'] } as unknown as ColorValue; + + expect(htmlColorToLightningColor(opaque)).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + + warn.mockRestore(); + }); }); diff --git a/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.ts b/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.ts index ffd8dbf..4877ef8 100644 --- a/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.ts +++ b/packages/plugin-css-transform/src/utils/htmlColorToLightningColor.ts @@ -2,20 +2,55 @@ import type { ColorValue } from 'react-native'; import { htmlColorCodes } from './htmlColorCodes'; +const hexRgbaRegex = /^#?([a-f0-9]{8})$/i; const hexRgbRegex = /^#?([a-f0-9]{6})$/i; +const hexShortRgbaRegex = /^#?([a-f0-9]{4})$/i; const hexShortRgbRegex = /^#?([a-f0-9]{3})$/i; -const rgbRegex = /^rgba?\(([0-9.]+)[, ]+([0-9.]+)[, ]+([0-9.]+)[, ]*([0-9.]+)?\)$/i; +// Accept both legacy comma form and modern space form with a `/` alpha delimiter. +const rgbRegex = + /^rgba?\(([0-9.]+)[,\s]+([0-9.]+)[,\s]+([0-9.]+)[,\s/]*([0-9.]+)?\)$/i; +const hslRegex = + /^hsla?\(([0-9.]+)(?:deg)?[,\s]+([0-9.]+)%[,\s]+([0-9.]+)%[,\s/]*([0-9.]+)?\)$/i; // Keyword colors (inherit, currentColor, …) have no fixed value to resolve, so // they reach the throw below and take the whole screen down. Drop them instead. const cssKeywordColorRegex = /^(inherit|initial|unset|revert|currentcolor)$/i; -function withAlphaOverride(color: number, overrideAlpha?: number | string): number { +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + s /= 100; + l /= 100; + const k = (n: number): number => (n + h / 30) % 12; + const a = s * Math.min(l, 1 - l); + const f = (n: number): number => + l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)); + + return [ + Math.round(f(0) * 255), + Math.round(f(8) * 255), + Math.round(f(4) * 255), + ]; +} + +function packRgba(r: number, g: number, b: number, alpha?: string): number { + return ( + ((r << 24) >>> 0) + + (g << 16) + + (b << 8) + + (alpha != null ? Math.round(Number.parseFloat(alpha) * 255) : 255) + ); +} + +function withAlphaOverride( + color: number, + overrideAlpha?: number | string, +): number { if (overrideAlpha == null) { return color; } const alphaInt = - typeof overrideAlpha === 'string' ? Number.parseInt(overrideAlpha, 16) : overrideAlpha; + typeof overrideAlpha === 'string' + ? Number.parseInt(overrideAlpha, 16) + : overrideAlpha; // Create a bitmask for the alpha value const alphaMask = 0xffffff00 | alphaInt; @@ -36,6 +71,13 @@ export function htmlColorToLightningColor( return withAlphaOverride(color, overrideAlpha); } + // PlatformColor / OpaqueColorValue have no resolvable string form. Drop them + // like unresolvable keywords rather than throwing and killing the screen. + if (typeof color === 'object') { + console.warn('[htmlColorToLightningColor] Unsupported color value:', color); + return undefined; + } + const colorLower = String(color).toLowerCase(); const colorFromCode = htmlColorCodes[colorLower]; @@ -46,28 +88,74 @@ export function htmlColorToLightningColor( const rgbResult = rgbRegex.exec(colorLower); if (rgbResult) { - const parts = rgbResult.slice() as [string, string, string, string, string?]; - - const rgbColor = - ((Number.parseInt(parts[1], 10) << 24) >>> 0) + - (Number.parseInt(parts[2], 10) << 16) + - (Number.parseInt(parts[3], 10) << 8) + - (parts[4] != null ? Math.round(Number.parseFloat(parts[4]) * 255) : 255); + const [, r, g, b, alpha] = rgbResult.slice() as [ + string, + string, + string, + string, + string?, + ]; + const rgbColor = packRgba( + Number.parseInt(r, 10), + Number.parseInt(g, 10), + Number.parseInt(b, 10), + alpha, + ); return withAlphaOverride(rgbColor, overrideAlpha); } + const hslResult = hslRegex.exec(colorLower); + + if (hslResult) { + const [, h, s, l, alpha] = hslResult.slice() as [ + string, + string, + string, + string, + string?, + ]; + const [r, g, b] = hslToRgb( + Number.parseFloat(h), + Number.parseFloat(s), + Number.parseFloat(l), + ); + + return withAlphaOverride(packRgba(r, g, b, alpha), overrideAlpha); + } + + const hexRgbaResult = hexRgbaRegex.exec(colorLower); + + if (hexRgbaResult?.[1]) { + return withAlphaOverride( + Number.parseInt(hexRgbaResult[1], 16), + overrideAlpha, + ); + } + const hexRgbResult = hexRgbRegex.exec(colorLower); if (hexRgbResult?.[1]) { - return withAlphaOverride(Number.parseInt(`${hexRgbResult[1]}ff`, 16), overrideAlpha); + return withAlphaOverride( + Number.parseInt(`${hexRgbResult[1]}ff`, 16), + overrideAlpha, + ); + } + + const hexShortRgbaResult = hexShortRgbaRegex.exec(colorLower); + + if (hexShortRgbaResult?.[1]) { + const short = hexShortRgbaResult[1]; + const rgbaText = [...short].map((c) => `${c}${c}`).join(''); + + return withAlphaOverride(Number.parseInt(rgbaText, 16), overrideAlpha); } const hexShortRgbResult = hexShortRgbRegex.exec(colorLower); if (hexShortRgbResult?.[1]) { - const shortRgbText = hexShortRgbResult[1]; - const rgbText = `${shortRgbText[0]}${shortRgbText[0]}${shortRgbText[1]}${shortRgbText[1]}${shortRgbText[2]}${shortRgbText[2]}ff`; + const short = hexShortRgbResult[1]; + const rgbText = `${[...short].map((c) => `${c}${c}`).join('')}ff`; return withAlphaOverride(Number.parseInt(rgbText, 16), overrideAlpha); } @@ -76,5 +164,7 @@ export function htmlColorToLightningColor( return undefined; } - throw new Error(`Invalid hex value specified for conversion: ${color.toString()}`); + throw new Error( + `Invalid hex value specified for conversion: ${color.toString()}`, + ); } From 521f95288a5bc6f3d2251a9eed154cc4bca66055 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Tue, 28 Jul 2026 15:35:57 +0200 Subject: [PATCH 09/22] fix(vendor): resolve alignSelf auto to parent alignItems and map space-evenly distinctly --- .changeset/alignself-auto-and-space-evenly.md | 5 + .../plugin-flexbox/src/types/FlexStyles.ts | 3 +- .../util/applyReactPropsToYoga.align.spec.ts | 118 +++++++++++++ .../src/util/applyReactPropsToYoga.ts | 166 ++++++++++++++---- 4 files changed, 258 insertions(+), 34 deletions(-) create mode 100644 .changeset/alignself-auto-and-space-evenly.md create mode 100644 packages/plugin-flexbox/src/util/applyReactPropsToYoga.align.spec.ts diff --git a/.changeset/alignself-auto-and-space-evenly.md b/.changeset/alignself-auto-and-space-evenly.md new file mode 100644 index 0000000..5e7b4f7 --- /dev/null +++ b/.changeset/alignself-auto-and-space-evenly.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-plugin-flexbox': patch +--- + +`alignSelf: 'auto'` resolves to the parent's `alignItems` (yoga `ALIGN_AUTO`) instead of being mapped to a fixed alignment, matching RN's type and behavior. `space-evenly` maps to yoga's `ALIGN_SPACE_EVENLY` rather than sharing the `space-between` case. diff --git a/packages/plugin-flexbox/src/types/FlexStyles.ts b/packages/plugin-flexbox/src/types/FlexStyles.ts index b72cb05..d49ba0e 100644 --- a/packages/plugin-flexbox/src/types/FlexStyles.ts +++ b/packages/plugin-flexbox/src/types/FlexStyles.ts @@ -95,7 +95,8 @@ export interface FlexContainer { } export interface FlexItem { - alignSelf?: AlignItems; + // `auto` inherits the parent's alignItems, matching RN's alignSelf type. + alignSelf?: AlignItems | 'auto'; flex?: string | number; flexBasis?: AutoDimensionValue; flexGrow?: number; diff --git a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.align.spec.ts b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.align.spec.ts new file mode 100644 index 0000000..2409f2d --- /dev/null +++ b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.align.spec.ts @@ -0,0 +1,118 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { Node } from 'yoga-layout'; +import { loadYoga, type Yoga } from 'yoga-layout/load'; + +import type { LightningViewElementStyle } from '@plextv/react-lightning'; + +import type { ManagerNode } from '../types/ManagerNode'; +import type { YogaOptions } from '../types/YogaOptions'; +import applyReactPropsToYoga, { applyFlexPropToYoga } from './applyReactPropsToYoga'; + +// Yoga resolves `alignSelf: auto` to the parent's alignItems, and treats +// space-evenly as its own distribution. react-lightning collapsed both, so a +// child under a non-stretch parent stretched and wrapped lines lost their even +// spacing. These pin the mappings against real Yoga. + +const options = { expandToAutoFlexBasis: false } as YogaOptions; + +let yoga: Yoga; + +beforeAll(async () => { + yoga = await loadYoga(); +}); + +function apply(node: Node, style: Partial): void { + for (const key in style) { + applyFlexPropToYoga( + yoga, + options, + node, + // oxlint-disable-next-line typescript/no-explicit-any -- test helper + key as any, + style[key as keyof LightningViewElementStyle], + ); + } +} + +describe('applyFlexPropToYoga alignSelf', () => { + // Column parent → cross axis is horizontal, so alignItems drives child width. + function childWidthUnderFlexStartParent(childStyle: Partial): number { + const parent = yoga.Node.create(); + parent.setWidth(200); + parent.setHeight(100); + parent.setAlignItems(yoga.ALIGN_FLEX_START); + + const child = yoga.Node.create(); + child.setHeight(20); + apply(child, childStyle); + parent.insertChild(child, 0); + + parent.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + + return child.getComputedWidth(); + } + + it("resolves alignSelf 'auto' to the parent's alignItems, not stretch", () => { + // flex-start parent + content-less child => width 0, not stretched to 200. + expect(childWidthUnderFlexStartParent({ alignSelf: 'auto' })).toBe(0); + }); + + it("still honors an explicit alignSelf 'stretch'", () => { + expect(childWidthUnderFlexStartParent({ alignSelf: 'stretch' })).toBe(200); + }); +}); + +describe('applyReactPropsToYoga alignSelf reset', () => { + it('reverts to inherit (auto) when alignSelf is dropped on a later render', () => { + const parent = yoga.Node.create(); + parent.setWidth(200); + parent.setHeight(100); + parent.setAlignItems(yoga.ALIGN_FLEX_START); + + const node = yoga.Node.create(); + parent.insertChild(node, 0); + + const managerNode: ManagerNode = { id: 1, node, children: [], props: {} }; + + // First render stretches the child across the parent's cross axis. + applyReactPropsToYoga(yoga, options, managerNode, { h: 20, alignSelf: 'stretch' }, true); + parent.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + expect(node.getComputedWidth()).toBe(200); + + // Later render omits alignSelf → reset path must restore inherit, not stretch. + applyReactPropsToYoga(yoga, options, managerNode, { h: 20 }, true); + parent.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + expect(node.getComputedWidth()).toBe(0); + }); +}); + +describe('applyFlexPropToYoga alignContent', () => { + // Two wrapped lines in a taller container: space-evenly pads the outer edges, + // space-between does not. + function firstLineTop(alignContent: LightningViewElementStyle['alignContent']): number { + const parent = yoga.Node.create(); + parent.setWidth(100); + parent.setHeight(100); + apply(parent, { flexDirection: 'row', flexWrap: 'wrap', alignContent }); + + for (let i = 0; i < 4; i++) { + const child = yoga.Node.create(); + child.setWidth(50); + child.setHeight(20); + parent.insertChild(child, i); + } + + parent.calculateLayout(undefined, undefined, yoga.DIRECTION_LTR); + + return parent.getChild(0).getComputedTop(); + } + + it("distributes wrapped lines evenly for alignContent 'space-evenly'", () => { + // 2 lines of 20 in 100 tall => 60 free / 3 gaps = 20 top inset. + expect(firstLineTop('space-evenly')).toBe(20); + }); + + it("keeps space-between distinct (no outer inset)", () => { + expect(firstLineTop('space-between')).toBe(0); + }); +}); diff --git a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts index 3673bbc..d20219a 100644 --- a/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts +++ b/packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts @@ -56,12 +56,23 @@ function mapAlignItems(yoga: Yoga, value?: number | string): Align { } } +// alignSelf `auto`/unset inherits the parent's alignItems; only concrete +// values map to a fixed alignment. +function mapAlignSelf(yoga: Yoga, value?: number | string): Align { + if (value == null || value === 'auto') { + return yoga.ALIGN_AUTO; + } + + return mapAlignItems(yoga, value); +} + function mapAlignContent(yoga: Yoga, value?: number | string): Align { switch (value) { case 'space-around': return yoga.ALIGN_SPACE_AROUND; - case 'space-between': case 'space-evenly': + return yoga.ALIGN_SPACE_EVENLY; + case 'space-between': return yoga.ALIGN_SPACE_BETWEEN; case 'center': return yoga.ALIGN_CENTER; @@ -142,7 +153,11 @@ function applyFlexBasis(node: Node, value?: AutoDimensionValue | string) { } } -function applyFlex(node: Node, value?: string | number, expandToAutoFlexBasis = false) { +function applyFlex( + node: Node, + value?: string | number, + expandToAutoFlexBasis = false, +) { if (value == null) { return; } @@ -187,7 +202,7 @@ function resetFlexPropToDefault(yoga: Yoga, node: Node, prop: FlexProps): void { node.setAlignItems(mapAlignItems(yoga)); return; case 'alignSelf': - node.setAlignSelf(mapAlignItems(yoga)); + node.setAlignSelf(mapAlignSelf(yoga)); return; case 'alignContent': node.setAlignContent(mapAlignContent(yoga)); @@ -391,7 +406,9 @@ export function applyFlexPropToYoga( switch (key) { case 'display': - node.setDisplay(mapDisplay(yoga, value as LightningViewElementStyle['display'])); + node.setDisplay( + mapDisplay(yoga, value as LightningViewElementStyle['display']), + ); return true; case 'w': node.setWidth(value as LightningViewElementStyle['w']); @@ -423,62 +440,116 @@ export function applyFlexPropToYoga( return true; } case 'margin': - node.setMargin(yoga.EDGE_ALL, value as LightningViewElementStyle['margin']); + node.setMargin( + yoga.EDGE_ALL, + value as LightningViewElementStyle['margin'], + ); return true; case 'marginBottom': - node.setMargin(yoga.EDGE_BOTTOM, value as LightningViewElementStyle['marginBottom']); + node.setMargin( + yoga.EDGE_BOTTOM, + value as LightningViewElementStyle['marginBottom'], + ); return true; case 'marginEnd': - node.setMargin(yoga.EDGE_END, value as LightningViewElementStyle['marginEnd']); + node.setMargin( + yoga.EDGE_END, + value as LightningViewElementStyle['marginEnd'], + ); return true; case 'marginLeft': - node.setMargin(yoga.EDGE_LEFT, value as LightningViewElementStyle['marginLeft']); + node.setMargin( + yoga.EDGE_LEFT, + value as LightningViewElementStyle['marginLeft'], + ); return true; case 'marginRight': - node.setMargin(yoga.EDGE_RIGHT, value as LightningViewElementStyle['marginRight']); + node.setMargin( + yoga.EDGE_RIGHT, + value as LightningViewElementStyle['marginRight'], + ); return true; case 'marginStart': - node.setMargin(yoga.EDGE_START, value as LightningViewElementStyle['marginStart']); + node.setMargin( + yoga.EDGE_START, + value as LightningViewElementStyle['marginStart'], + ); return true; case 'marginTop': - node.setMargin(yoga.EDGE_TOP, value as LightningViewElementStyle['marginTop']); + node.setMargin( + yoga.EDGE_TOP, + value as LightningViewElementStyle['marginTop'], + ); return true; case 'marginHorizontal': case 'marginInline': - node.setMargin(yoga.EDGE_HORIZONTAL, value as LightningViewElementStyle['marginInline']); + node.setMargin( + yoga.EDGE_HORIZONTAL, + value as LightningViewElementStyle['marginInline'], + ); return true; case 'marginVertical': case 'marginBlock': - node.setMargin(yoga.EDGE_VERTICAL, value as LightningViewElementStyle['marginBlock']); + node.setMargin( + yoga.EDGE_VERTICAL, + value as LightningViewElementStyle['marginBlock'], + ); return true; case 'padding': - node.setPadding(yoga.EDGE_ALL, value as LightningViewElementStyle['padding']); + node.setPadding( + yoga.EDGE_ALL, + value as LightningViewElementStyle['padding'], + ); return true; case 'paddingBottom': - node.setPadding(yoga.EDGE_BOTTOM, value as LightningViewElementStyle['paddingBottom']); + node.setPadding( + yoga.EDGE_BOTTOM, + value as LightningViewElementStyle['paddingBottom'], + ); return true; case 'paddingEnd': - node.setPadding(yoga.EDGE_END, value as LightningViewElementStyle['paddingEnd']); + node.setPadding( + yoga.EDGE_END, + value as LightningViewElementStyle['paddingEnd'], + ); return true; case 'paddingLeft': - node.setPadding(yoga.EDGE_LEFT, value as LightningViewElementStyle['paddingLeft']); + node.setPadding( + yoga.EDGE_LEFT, + value as LightningViewElementStyle['paddingLeft'], + ); return true; case 'paddingRight': - node.setPadding(yoga.EDGE_RIGHT, value as LightningViewElementStyle['paddingRight']); + node.setPadding( + yoga.EDGE_RIGHT, + value as LightningViewElementStyle['paddingRight'], + ); return true; case 'paddingStart': - node.setPadding(yoga.EDGE_START, value as LightningViewElementStyle['paddingStart']); + node.setPadding( + yoga.EDGE_START, + value as LightningViewElementStyle['paddingStart'], + ); return true; case 'paddingTop': - node.setPadding(yoga.EDGE_TOP, value as LightningViewElementStyle['paddingTop']); + node.setPadding( + yoga.EDGE_TOP, + value as LightningViewElementStyle['paddingTop'], + ); return true; case 'paddingHorizontal': case 'paddingInline': - node.setPadding(yoga.EDGE_HORIZONTAL, value as LightningViewElementStyle['paddingInline']); + node.setPadding( + yoga.EDGE_HORIZONTAL, + value as LightningViewElementStyle['paddingInline'], + ); return true; case 'paddingVertical': case 'paddingBlock': - node.setPadding(yoga.EDGE_VERTICAL, value as LightningViewElementStyle['paddingBlock']); + node.setPadding( + yoga.EDGE_VERTICAL, + value as LightningViewElementStyle['paddingBlock'], + ); return true; case 'border': node.setBorder( @@ -511,7 +582,7 @@ export function applyFlexPropToYoga( node.setAlignItems(mapAlignItems(yoga, value)); return true; case 'alignSelf': - node.setAlignSelf(mapAlignItems(yoga, value)); + node.setAlignSelf(mapAlignSelf(yoga, value)); return true; case 'justifyContent': node.setJustifyContent(mapJustify(yoga, value)); @@ -526,37 +597,66 @@ export function applyFlexPropToYoga( node.setFlexGrow((value as LightningViewElementStyle['flexGrow']) ?? 1); return true; case 'flexShrink': - node.setFlexShrink((value as LightningViewElementStyle['flexShrink']) ?? 0); + node.setFlexShrink( + (value as LightningViewElementStyle['flexShrink']) ?? 0, + ); return true; case 'gap': - node.setGap(yoga.GUTTER_ALL, (value as LightningViewElementStyle['gap']) ?? 0); + node.setGap( + yoga.GUTTER_ALL, + (value as LightningViewElementStyle['gap']) ?? 0, + ); return true; case 'columnGap': - node.setGap(yoga.GUTTER_COLUMN, (value as LightningViewElementStyle['columnGap']) ?? 0); + node.setGap( + yoga.GUTTER_COLUMN, + (value as LightningViewElementStyle['columnGap']) ?? 0, + ); return true; case 'rowGap': - node.setGap(yoga.GUTTER_ROW, (value as LightningViewElementStyle['rowGap']) ?? 0); + node.setGap( + yoga.GUTTER_ROW, + (value as LightningViewElementStyle['rowGap']) ?? 0, + ); return true; case 'position': node.setPositionType(mapPosition(yoga, value)); return true; case 'right': - node.setPosition(yoga.EDGE_RIGHT, (value as LightningViewElementStyle['right']) ?? 0); + node.setPosition( + yoga.EDGE_RIGHT, + (value as LightningViewElementStyle['right']) ?? 0, + ); return true; case 'bottom': - node.setPosition(yoga.EDGE_BOTTOM, (value as LightningViewElementStyle['bottom']) ?? 0); + node.setPosition( + yoga.EDGE_BOTTOM, + (value as LightningViewElementStyle['bottom']) ?? 0, + ); return true; case 'left': - node.setPosition(yoga.EDGE_LEFT, (value as LightningViewElementStyle['left']) ?? 0); + node.setPosition( + yoga.EDGE_LEFT, + (value as LightningViewElementStyle['left']) ?? 0, + ); return true; case 'top': - node.setPosition(yoga.EDGE_TOP, (value as LightningViewElementStyle['top']) ?? 0); + node.setPosition( + yoga.EDGE_TOP, + (value as LightningViewElementStyle['top']) ?? 0, + ); return true; case 'start': - node.setPosition(yoga.EDGE_START, (value as LightningViewElementStyle['left']) ?? 0); + node.setPosition( + yoga.EDGE_START, + (value as LightningViewElementStyle['left']) ?? 0, + ); return true; case 'end': - node.setPosition(yoga.EDGE_END, (value as LightningViewElementStyle['right']) ?? 0); + node.setPosition( + yoga.EDGE_END, + (value as LightningViewElementStyle['right']) ?? 0, + ); return true; } } catch (err) { From 74d9005415d8909cf0d920b569a16efa34de5741 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Tue, 28 Jul 2026 15:49:38 +0200 Subject: [PATCH 10/22] fix(vendor): reclamp VirtualList scroll and fire onEndReached on content-size change --- .../virtuallist-reclamp-and-endreached.md | 5 + .../VirtualList/reconcileScrollBounds.spec.ts | 107 ++++++++++++++++++ .../VirtualList/reconcileScrollBounds.ts | 62 ++++++++++ .../VirtualList/useScrollHandler.ts | 56 +++++++-- 4 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 .changeset/virtuallist-reclamp-and-endreached.md create mode 100644 packages/react-lightning-components/src/components/VirtualList/reconcileScrollBounds.spec.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/reconcileScrollBounds.ts diff --git a/.changeset/virtuallist-reclamp-and-endreached.md b/.changeset/virtuallist-reclamp-and-endreached.md new file mode 100644 index 0000000..2fe3eff --- /dev/null +++ b/.changeset/virtuallist-reclamp-and-endreached.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +VirtualList reconciles its scroll offset against the latest content size. A shrink reclamps the offset instead of leaving it past the new end, and a list short enough to fit its viewport primes `onEndReached` on mount. The clamp and threshold decision is now shared by the scroll handler and the content-size effect, so both paths agree. diff --git a/packages/react-lightning-components/src/components/VirtualList/reconcileScrollBounds.spec.ts b/packages/react-lightning-components/src/components/VirtualList/reconcileScrollBounds.spec.ts new file mode 100644 index 0000000..fc596cf --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/reconcileScrollBounds.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import { reconcileScrollBounds } from './reconcileScrollBounds'; + +// A list scrolled near the end, then filtered down so the content is now +// shorter than the current offset — the offset must reclamp to the new end. +const shrunk = { + scrollOffset: 4000, + maxScroll: 1000, + totalContentSize: 1000 + 1920, // maxScroll + viewportSize + viewportSize: 1920, + onEndReachedThreshold: 0.5, + endReached: false, + hasEndReachedListener: true, +}; + +describe('reconcileScrollBounds', () => { + it('reclamps an offset that now sits past maxScroll', () => { + const result = reconcileScrollBounds(shrunk); + + expect(result.clampedOffset).toBe(1000); + expect(result.didClamp).toBe(true); + }); + + it('leaves an in-bounds offset untouched', () => { + const result = reconcileScrollBounds({ + ...shrunk, + scrollOffset: 500, + maxScroll: 5000, + totalContentSize: 5000 + 1920, + }); + + expect(result.clampedOffset).toBe(500); + expect(result.didClamp).toBe(false); + }); + + it('fires onEndReached on mount when a short list is within threshold', () => { + const result = reconcileScrollBounds({ + scrollOffset: 0, + maxScroll: 0, + totalContentSize: 400, // shorter than the viewport + viewportSize: 1920, + onEndReachedThreshold: 0.5, + endReached: false, + hasEndReachedListener: true, + }); + + expect(result.fireEndReached).toBe(true); + expect(result.endReached).toBe(true); + expect(result.distanceFromEnd).toBe(400 - 0 - 1920); + }); + + it('reports distanceFromEnd 0 and fires when the shrink lands us at the end', () => { + const result = reconcileScrollBounds(shrunk); + + expect(result.distanceFromEnd).toBe(0); + expect(result.fireEndReached).toBe(true); + expect(result.endReached).toBe(true); + }); + + it('does not re-fire while already latched at the end', () => { + const result = reconcileScrollBounds({ ...shrunk, endReached: true }); + + expect(result.fireEndReached).toBe(false); + expect(result.endReached).toBe(true); + }); + + it('re-arms the latch once back outside the threshold', () => { + const result = reconcileScrollBounds({ + scrollOffset: 0, + maxScroll: 8000, + totalContentSize: 8000 + 1920, + viewportSize: 1920, + onEndReachedThreshold: 0.5, + endReached: true, + hasEndReachedListener: true, + }); + + expect(result.fireEndReached).toBe(false); + expect(result.endReached).toBe(false); + }); + + it('never fires without a listener', () => { + const result = reconcileScrollBounds({ + ...shrunk, + hasEndReachedListener: false, + }); + + expect(result.fireEndReached).toBe(false); + }); + + it('defaults the threshold to 0.5 of the viewport when null', () => { + // distanceFromEnd 900 sits just inside 0.5 * 1920 = 960. + const result = reconcileScrollBounds({ + scrollOffset: 100, + maxScroll: 1000, + totalContentSize: 1000 + 1920, + viewportSize: 1920, + onEndReachedThreshold: null, + endReached: false, + hasEndReachedListener: true, + }); + + expect(result.distanceFromEnd).toBe(1000 + 1920 - 100 - 1920); + expect(result.fireEndReached).toBe(true); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/reconcileScrollBounds.ts b/packages/react-lightning-components/src/components/VirtualList/reconcileScrollBounds.ts new file mode 100644 index 0000000..81f8b21 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/reconcileScrollBounds.ts @@ -0,0 +1,62 @@ +export interface ScrollBoundsParams { + /** Current scroll offset to reconcile against the latest content size. */ + scrollOffset: number; + maxScroll: number; + totalContentSize: number; + viewportSize: number; + onEndReachedThreshold: number | null; + /** Current onEndReached latch (whether it has already fired at the end). */ + endReached: boolean; + /** Whether an onEndReached callback is wired. */ + hasEndReachedListener: boolean; +} + +export interface ScrollBoundsResult { + clampedOffset: number; + /** True when clampedOffset differs from the input offset. */ + didClamp: boolean; + /** Distance from the end, measured from the clamped offset. */ + distanceFromEnd: number; + /** True when onEndReached should fire this pass (within threshold, not yet latched). */ + fireEndReached: boolean; + /** Next value for the onEndReached latch. */ + endReached: boolean; +} + +// The clamp + onEndReached-threshold decision native ScrollView/FlashList make on +// every layout pass. Shared by the scroll handler and the content-size effect so +// a shrink reclamps the offset and a short list still primes onEndReached on mount. +export function reconcileScrollBounds({ + scrollOffset, + maxScroll, + totalContentSize, + viewportSize, + onEndReachedThreshold, + endReached, + hasEndReachedListener, +}: ScrollBoundsParams): ScrollBoundsResult { + const clampedOffset = Math.max(0, Math.min(scrollOffset, maxScroll)); + const distanceFromEnd = totalContentSize - clampedOffset - viewportSize; + + let fireEndReached = false; + let nextEndReached = endReached; + + if (hasEndReachedListener) { + if (distanceFromEnd <= viewportSize * (onEndReachedThreshold ?? 0.5)) { + if (!endReached) { + fireEndReached = true; + nextEndReached = true; + } + } else { + nextEndReached = false; + } + } + + return { + clampedOffset, + didClamp: clampedOffset !== scrollOffset, + distanceFromEnd, + fireEndReached, + endReached: nextEndReached, + }; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts index 94362f4..0892c55 100644 --- a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts +++ b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts @@ -6,6 +6,7 @@ import type { LayoutManager } from './LayoutManager'; import type { ScrollEvent } from './VirtualListTypes'; import { createCriticalSpring } from './scrollSpring'; +import { reconcileScrollBounds } from './reconcileScrollBounds'; import { resolveChildSnapTarget } from './resolveChildSnapAlignment'; import { resolveFocusScrollTarget } from './resolveFocusScrollTarget'; @@ -288,17 +289,20 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan // render and the parent's cache write. Same-value setState is free. setCommittedScrollOffset(clamped); - if (onEndReached) { - const distanceFromEnd = totalContentSize - clamped - viewportSize; + const bounds = reconcileScrollBounds({ + scrollOffset: clamped, + maxScroll, + totalContentSize, + viewportSize, + onEndReachedThreshold, + endReached: endReachedRef.current, + hasEndReachedListener: !!onEndReached, + }); - if (distanceFromEnd <= viewportSize * (onEndReachedThreshold ?? 0.5)) { - if (!endReachedRef.current) { - endReachedRef.current = true; - onEndReached({ distanceFromEnd }); - } - } else { - endReachedRef.current = false; - } + endReachedRef.current = bounds.endReached; + + if (bounds.fireEndReached) { + onEndReached?.({ distanceFromEnd: bounds.distanceFromEnd }); } // Animated scrolls stream onScroll from the emit loop instead; emitting @@ -402,6 +406,38 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan } } + // Native ScrollView/FlashList reconcile the offset against the content size on + // every layout pass; react-lightning only did it inside the scroll handler. When + // content shrinks below the current offset the list was left scrolled past the + // new end (blank canvas until the next scroll); a short list already within the + // threshold never primed onEndReached on mount. Re-run the decision whenever the + // size axes change. + // oxlint-disable-next-line react-hooks/exhaustive-deps -- keyed on the size axes; maxScroll derives from them + useEffect(() => { + const bounds = reconcileScrollBounds({ + scrollOffset: scrollOffsetRef.current, + maxScroll, + totalContentSize, + viewportSize, + onEndReachedThreshold, + endReached: endReachedRef.current, + hasEndReachedListener: !!onEndReached, + }); + + if (bounds.didClamp) { + scrollOffsetRef.current = bounds.clampedOffset; + setCommittedScrollOffset(bounds.clampedOffset); + applyPosition(bounds.clampedOffset, false); + onScroll?.(makeScrollEvent(bounds.clampedOffset)); + } + + endReachedRef.current = bounds.endReached; + + if (bounds.fireEndReached) { + onEndReached?.({ distanceFromEnd: bounds.distanceFromEnd }); + } + }, [totalContentSize, viewportSize]); + return { contentRef, scrollOffsetRef, From 6194793780ef1a00e4676ea5d88c5fd6a6d2d03f Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Tue, 28 Jul 2026 16:17:54 +0200 Subject: [PATCH 11/22] fix(vendor): default withTiming easing to ease-in-out-quad --- .changeset/withtiming-default-easing.md | 5 +++++ .../src/animation/resolveTimingEasing.test.ts | 5 +++-- .../src/animation/resolveTimingEasing.ts | 15 +++++++++++++-- 3 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 .changeset/withtiming-default-easing.md diff --git a/.changeset/withtiming-default-easing.md b/.changeset/withtiming-default-easing.md new file mode 100644 index 0000000..7e7c44c --- /dev/null +++ b/.changeset/withtiming-default-easing.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-plugin-reanimated': patch +--- + +`withTiming` with no easing defaults to reanimated's `Easing.inOut(Easing.quad)`, expressed as the equivalent cubic-bezier the renderer parses. It previously fell back to linear, so every un-eased timing animation ran flat. diff --git a/packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts b/packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts index db95348..77ad081 100644 --- a/packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts +++ b/packages/plugin-reanimated/src/animation/resolveTimingEasing.test.ts @@ -16,8 +16,9 @@ describe('resolveTimingEasing', () => { expect(resolveTimingEasing(factoryObj)).toBe(produced); }); - it('falls back to linear when easing is missing', () => { - expect(resolveTimingEasing(undefined)).toBe('linear'); + it('defaults an unset easing to ease-in-out-quad', () => { + expect(resolveTimingEasing(undefined)).toBe('cubic-bezier(0.455, 0.03, 0.515, 0.955)'); + expect(resolveTimingEasing(null)).toBe('cubic-bezier(0.455, 0.03, 0.515, 0.955)'); }); it('falls back to linear for an unrecognized easing value', () => { diff --git a/packages/plugin-reanimated/src/animation/resolveTimingEasing.ts b/packages/plugin-reanimated/src/animation/resolveTimingEasing.ts index c7888d8..2e36aea 100644 --- a/packages/plugin-reanimated/src/animation/resolveTimingEasing.ts +++ b/packages/plugin-reanimated/src/animation/resolveTimingEasing.ts @@ -10,11 +10,18 @@ function hasFactory(value: unknown): value is EasingFactory { ); } +// reanimated defaults withTiming to Easing.inOut(Easing.quad); the renderer has +// no named token for it, so use the equivalent cubic-bezier its parser accepts. +const DEFAULT_EASING = 'cubic-bezier(0.455, 0.03, 0.515, 0.955)'; + // reanimated Easing.* are functions; Easing.bezier(...) returns a { factory } // object. The renderer's CoreAnimation takes a function easing directly and // resolves a string via getTimingFunction, so pass functions through, unwrap -// the factory, and fall back to linear for anything else. -export function resolveTimingEasing(easing: unknown): AnimationSettings['easing'] { +// the factory, default the unset case to inOut-quad, and treat anything else as +// linear. +export function resolveTimingEasing( + easing: unknown, +): AnimationSettings['easing'] { if (typeof easing === 'function') { return easing as AnimationSettings['easing']; } @@ -23,5 +30,9 @@ export function resolveTimingEasing(easing: unknown): AnimationSettings['easing' return easing.factory(); } + if (easing == null) { + return DEFAULT_EASING; + } + return 'linear'; } From e1e3e1343b81b602f933c82068bb70e47c961fd4 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Tue, 28 Jul 2026 21:34:05 +0200 Subject: [PATCH 12/22] fix(vendor): notify every ancestor group when focus crosses its children _focusNode only emitted childFocused for the leaf's immediate focus-parent, so a VirtualList whose cells nest their own focus group never learned that focus had moved to a different cell and stopped scrolling to follow focus (row scrollers frozen on Right, Categories grid on Down). Walk the focus chain and emit childFocused for each ancestor whose focusedElement actually changed, matching tvOS onChildFocused semantics. --- .changeset/ancestor-groups-child-focused.md | 5 +++ .../src/focus/FocusManager.spec.ts | 45 ++++++++++++++++++- .../react-lightning/src/focus/FocusManager.ts | 16 ++++++- 3 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 .changeset/ancestor-groups-child-focused.md diff --git a/.changeset/ancestor-groups-child-focused.md b/.changeset/ancestor-groups-child-focused.md new file mode 100644 index 0000000..8848689 --- /dev/null +++ b/.changeset/ancestor-groups-child-focused.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +Every ancestor focus group is notified when its own directly-focused child changes, not only the group directly above the leaf. A VirtualList whose cells nest their own focus group never learned that focus had crossed a cell, so its scroll-to-focus stopped following. Matches tvOS, where every ancestor hears about focus crossing its children. diff --git a/packages/react-lightning/src/focus/FocusManager.spec.ts b/packages/react-lightning/src/focus/FocusManager.spec.ts index 8c6e05e..d4d3deb 100644 --- a/packages/react-lightning/src/focus/FocusManager.spec.ts +++ b/packages/react-lightning/src/focus/FocusManager.spec.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockElement, type MockElement } from '../mocks/createMockElement'; +import { + createMockElement, + type MockElement, +} from '../mocks/createMockElement'; import { FocusManager } from './FocusManager'; describe('FocusManager', () => { @@ -285,7 +288,12 @@ describe('FocusManager', () => { focusManager.addElement(modalGrandChild, modalChild); focusManager.addElement(modalChild, modal); - expect(focusManager.focusPath).toEqual([parent, modal, modalChild, modalGrandChild]); + expect(focusManager.focusPath).toEqual([ + parent, + modal, + modalChild, + modalGrandChild, + ]); }); describe('setFocusedChild', () => { @@ -1084,4 +1092,37 @@ describe('FocusManager', () => { }); }); + describe('onChildFocused across intervening groups', () => { + it('notifies an ancestor group when its directly-focused child changes through a nested group', () => { + // Mirrors a VirtualList (vl) whose cells nest their own focus group: a + // focusable tile is two levels below the list. On tvOS the list still + // hears focus cross its cells, so its scroll-to-focus can run. + const vl = createMockElement(1, 'vl'); + const cellA = createMockElement(2, 'cellA'); + const cellB = createMockElement(3, 'cellB'); + const tileA = createMockElement(4, 'tileA'); + const tileB = createMockElement(5, 'tileB'); + vl.isFocusGroup = true; + cellA.isFocusGroup = true; + cellB.isFocusGroup = true; + + focusManager.addElement(vl, null, { autoFocus: false }); + focusManager.addElement(cellA, vl, { autoFocus: false }); + focusManager.addElement(cellB, vl, { autoFocus: false }); + focusManager.addElement(tileA, cellA, { autoFocus: false }); + focusManager.addElement(tileB, cellB, { autoFocus: false }); + + const onChildFocused = vi.fn(); + focusManager.setOnChildFocused(vl, onChildFocused); + + focusManager.focus(tileA); + onChildFocused.mockClear(); + focusManager.focus(tileB); + + // vl's own focused child moved cellA -> cellB; it must be told, with the + // child on its path (the cell), not left starved because the leaf's + // immediate parent is the cell rather than the list. + expect(onChildFocused).toHaveBeenCalledWith(cellB); + }); + }); }); diff --git a/packages/react-lightning/src/focus/FocusManager.ts b/packages/react-lightning/src/focus/FocusManager.ts index bdff036..1d011da 100644 --- a/packages/react-lightning/src/focus/FocusManager.ts +++ b/packages/react-lightning/src/focus/FocusManager.ts @@ -723,6 +723,13 @@ export class FocusManager< // registration (see addElement / focusCommitted). childNode.focusCommitted = true; + // A group hears onChildFocused whenever its own directly-focused child + // changes — not only the group directly above the leaf. Otherwise a + // VirtualList never learns focus crossed a cell when the cell nests its own + // focus group, and its scroll-to-focus stops following. Matches tvOS, where + // every ancestor is notified as focus crosses its children. + const childFocusChanged: FocusNode[] = []; + while (currChild && !isRootNode(currChild) && currParent) { // Only hand focus off to an external redirect while walking up. An // internal redirect (destinations within this node's own subtree, e.g. @@ -736,6 +743,10 @@ export class FocusManager< return; } + if (currParent.focusedElement !== currChild) { + childFocusChanged.push(currChild as FocusNode); + } + currParent.focusedElement = currChild as FocusNode; currParent.focusCommitted = true; currChild = currParent; @@ -743,7 +754,10 @@ export class FocusManager< } this._recalculateFocusPath(); - this._tryEmitChildFocusedEvent(childNode); + + for (const node of childFocusChanged) { + this._tryEmitChildFocusedEvent(node); + } } private _tryEmitChildFocusedEvent(node: FocusNode) { From 817bd6df084cef807007bca0d2d25365d5faa35b Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Tue, 28 Jul 2026 22:05:29 +0200 Subject: [PATCH 13/22] fix(vendor): implement ScrollView.getNativeScrollRef() --- .../lightning-scrollview-native-scroll-ref.md | 15 +++ .../src/exports/ScrollView.tsx | 112 ++++++++++++------ 2 files changed, 91 insertions(+), 36 deletions(-) create mode 100644 .changeset/lightning-scrollview-native-scroll-ref.md diff --git a/.changeset/lightning-scrollview-native-scroll-ref.md b/.changeset/lightning-scrollview-native-scroll-ref.md new file mode 100644 index 0000000..58b600e --- /dev/null +++ b/.changeset/lightning-scrollview-native-scroll-ref.md @@ -0,0 +1,15 @@ +--- +'@plextv/react-native-lightning': patch +--- + +fix(react-native-lightning): implement `ScrollView.getNativeScrollRef()` + +Shared RN code that scrolls a selected child into view calls +`scrollRef.current.getNativeScrollRef()` and measures the child against the +returned node (`child.measureLayout(scrollHostNode, ...)`), expecting a +content-relative offset independent of the current scroll position. The Lightning +`ScrollView` never implemented `getNativeScrollRef`, so the call threw +`getNativeScrollRef is not a function`. It now returns the inner scrolled content +container (the node that carries the scroll offset as its own x/y), so +`measureLayout` against it yields the same content-relative coordinates as native +React Native. diff --git a/packages/react-native-lightning/src/exports/ScrollView.tsx b/packages/react-native-lightning/src/exports/ScrollView.tsx index 269b2ff..19a76c3 100644 --- a/packages/react-native-lightning/src/exports/ScrollView.tsx +++ b/packages/react-native-lightning/src/exports/ScrollView.tsx @@ -1,20 +1,19 @@ import { - createRef, type ForwardRefExoticComponent, - forwardRef, PureComponent, type RefAttributes, + createRef, + forwardRef, useContext, useEffect, useRef, } from 'react'; +import type { JSX } from 'react/jsx-runtime'; import type { NativeScrollEvent, ScrollView as RNScrollView, ScrollViewProps as RNScrollViewProps, } from 'react-native'; -import type { JSX } from 'react/jsx-runtime'; - import { FocusManagerContext, type LightningElement, @@ -22,22 +21,26 @@ import { type LightningViewElementProps, useCombinedRef, } from '@plextv/react-lightning'; - -import type { LightningViewElementStyle } from '../../../react-lightning/src/types'; +import type { LightningViewElementStyle } from "@plextv/react-lightning/src/types/types"; import { createHandler } from '../hooks/useFocusHandler'; import type { NativeLightningViewElement } from '../types/NativeLightningViewElement'; import { createNativeSyntheticEvent } from '../utils/createNativeSyntheticEvent'; +import { View, type ViewProps, defaultViewStyle } from './View'; import { getEnsureVisibleOffset, usesExplicitAlignment } from './scrollFocus'; -import { defaultViewStyle, View, type ViewProps } from './View'; -function isDescendantOf(ancestor: LightningElement, node: LightningElement): boolean { +function isDescendantOf( + ancestor: LightningElement, + node: LightningElement, +): boolean { let current: LightningElement | null = node.parent; while (current) { if (current === ancestor) { return true; } + current = current.parent; } + return false; } @@ -46,24 +49,24 @@ export type ScrollViewProps = Omit & animated?: boolean; }; -type ScrollViewState = { +interface ScrollViewState { offset: { x: number; y: number }; animated: boolean; -}; +} -type Rect = { +interface Rect { x: number; y: number; w: number; h: number; -}; +} function getAxisOffset( viewportSize: number, containerSize: number, childOffset: number, childSize: number, - snapToAlignment: 'start' | 'center' | 'end', + snapToAlignment: 'center' | 'end' | 'start', ): number { const scrollableSize = containerSize - viewportSize; let offset = 0; @@ -76,11 +79,18 @@ function getAxisOffset( const itemMidPoint = childOffset + childSize / 2; const halfViewportSize = viewportSize / 2; - offset = Math.min(Math.max(itemMidPoint - halfViewportSize, 0), scrollableSize); + offset = Math.min( + Math.max(itemMidPoint - halfViewportSize, 0), + scrollableSize, + ); break; } + case 'end': - offset = Math.max(Math.min(childOffset + childSize - viewportSize, scrollableSize), 0); + offset = Math.max( + Math.min(childOffset + childSize - viewportSize, scrollableSize), + 0, + ); break; } @@ -91,7 +101,7 @@ function getScrollInfo( viewport?: Rect, container?: Rect, child?: Rect | null, - snapToAlignment?: 'start' | 'center' | 'end' | null, + snapToAlignment?: 'center' | 'end' | 'start' | null, horizontal?: boolean | null, ): NativeScrollEvent | null { if (!viewport || !container || !child) { @@ -99,10 +109,22 @@ function getScrollInfo( } const x = horizontal - ? getAxisOffset(viewport.w, container.w, child.x, child.w, snapToAlignment ?? 'start') + ? getAxisOffset( + viewport.w, + container.w, + child.x, + child.w, + snapToAlignment ?? 'start', + ) : container.x; const y = !horizontal - ? getAxisOffset(viewport.h, container.h, child.y, child.h, snapToAlignment ?? 'start') + ? getAxisOffset( + viewport.h, + container.h, + child.y, + child.h, + snapToAlignment ?? 'start', + ) : container.y; return { @@ -252,8 +274,19 @@ class ScrollViewBase extends PureComponent { return this._containerRef.current; } + // RN parity: the host node `measureLayout` measures against. Returns the inner + // scrolled content container so child offsets are content-relative (independent + // of the current scroll offset), matching native ScrollView.getNativeScrollRef. + public getNativeScrollRef(): LightningViewElement< + LightningViewElementStyle, + LightningViewElementProps + > | null { + return this._containerRef.current; + } + public render(): JSX.Element { - const { children, style, contentContainerStyle, horizontal, ...props } = this.props; + const { children, style, contentContainerStyle, horizontal, ...props } = + this.props; const flexDirection = horizontal ? 'row' : 'column'; return ( @@ -265,11 +298,17 @@ class ScrollViewBase extends PureComponent { { overflow: 'hidden', flexDirection, flexGrow: 1, flexShrink: 1 }, ]} {...props} - onFocus={createHandler(this.props.onFocus)} onBlur={createHandler(this.props.onBlur)} + onFocus={createHandler(this.props.onFocus)} > { } : undefined } - style={[ - defaultViewStyle, - { display: 'flex', flexDirection }, - contentContainerStyle, - this.state.offset, - ]} > {children} @@ -291,13 +324,17 @@ class ScrollViewBase extends PureComponent { ); } - private _getChildOffset = (child?: LightningElement | null | Rect) => { + private _getChildOffset = (child?: LightningElement | Rect | null) => { const isElement = child instanceof LightningViewElement; - const rect = isElement ? child.getBoundingClientRect(this._containerRef.current) : child; + const rect = isElement + ? child.getBoundingClientRect(this._containerRef.current) + : child; return getScrollInfo( this._viewportRef.current?.getBoundingClientRect(), - this._containerRef.current?.getBoundingClientRect(this._viewportRef.current), + this._containerRef.current?.getBoundingClientRect( + this._viewportRef.current, + ), rect, // If we're getting offset via a positional value, we make sure we don't // use the snapToAlignment to calculate the offset since the offset should @@ -345,7 +382,7 @@ class ScrollViewBase extends PureComponent { // nav moves focus to a descendant, scroll it into view. The class stays the ref // target so imperative callers (scrollTo, scrollToEnd, …) are unaffected. export const ScrollView: ForwardRefExoticComponent< - ScrollViewProps & RefAttributes + RefAttributes & ScrollViewProps > = forwardRef((props, ref) => { const focusManager = useContext(FocusManagerContext)?.focusManager; const instanceRef = useRef(null); @@ -356,12 +393,15 @@ export const ScrollView: ForwardRefExoticComponent< return; } - return focusManager.on('focusPathChanged', (focusPath: LightningElement[]) => { - const leaf = focusPath[focusPath.length - 1]; - if (leaf) { - instanceRef.current?.scrollFocusIntoView(leaf); - } - }); + return focusManager.on( + 'focusPathChanged', + (focusPath: LightningElement[]) => { + const leaf = focusPath[focusPath.length - 1]; + if (leaf) { + instanceRef.current?.scrollFocusIntoView(leaf); + } + }, + ); }, [focusManager]); return ; From c90ef3133a774899e531b6c56b3792274eb2335d Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Wed, 29 Jul 2026 10:42:50 +0200 Subject: [PATCH 14/22] fix(vendor): preserve full fontWeight scale and map unsupported textAlign Pass the 100-900 numeric/keyword font weights through instead of collapsing to bold/normal, parsing numeric strings the renderer would otherwise treat as 400. Map textAlign auto->left and warn+fall-back justify->left. Drop the inert text-shadow styles (SDF renderer has no shadow) with a dev warning. --- .../fontweight-scale-textalign-textshadow.md | 7 ++ .../src/convertCSSStyleToLightning.spec.ts | 72 ++++++++++++++++++- .../src/convertCSSStyleToLightning.ts | 48 +++++++++++-- 3 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 .changeset/fontweight-scale-textalign-textshadow.md diff --git a/.changeset/fontweight-scale-textalign-textshadow.md b/.changeset/fontweight-scale-textalign-textshadow.md new file mode 100644 index 0000000..49119c6 --- /dev/null +++ b/.changeset/fontweight-scale-textalign-textshadow.md @@ -0,0 +1,7 @@ +--- +'@plextv/react-lightning-plugin-css-transform': patch +--- + +`fontWeight` keeps the full 100-900 scale. Weights were collapsed to bold/normal, and a numeric string like `'600'` then fell through to 400 in the renderer; numeric strings are parsed and numbers and keywords pass through untouched. Unsupported `textAlign` values are mapped rather than forwarded as-is. + +Text shadow styles (`shadowColor`, `textShadow*`) are dropped with a warning instead of converted: the shipping SDF text renderer has no shadow, and RN's `textShadow*` props were never wired to the renderer's keys. diff --git a/packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts b/packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts index 4c72d8a..770b488 100644 --- a/packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts +++ b/packages/plugin-css-transform/src/convertCSSStyleToLightning.spec.ts @@ -1,7 +1,13 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { AllStyleProps } from './types/ReactStyle'; import { convertCSSStyleToLightning } from './convertCSSStyleToLightning'; +// The return type is the view/image/text union; the text-only keys these specs +// assert on aren't on the base, so read results through a loose record. +const convert = (style: AllStyleProps): Record => + convertCSSStyleToLightning(style) as Record; + describe('convertCSSStyleToLightning border radius', () => { it('passes a uniform borderRadius through unchanged', () => { expect(convertCSSStyleToLightning({ borderRadius: 8 })?.borderRadius).toBe( @@ -38,3 +44,67 @@ describe('convertCSSStyleToLightning border radius', () => { expect(result.borderTopEndRadius).toBeUndefined(); }); }); + +describe('convertCSSStyleToLightning fontWeight', () => { + it('passes a numeric weight through unchanged', () => { + expect(convert({ fontWeight: 300 }).fontWeight).toBe(300); + }); + + it('parses a numeric string weight into a number', () => { + expect(convert({ fontWeight: '600' }).fontWeight).toBe(600); + }); + + it('preserves keyword weights', () => { + expect(convert({ fontWeight: 'bold' }).fontWeight).toBe('bold'); + expect(convert({ fontWeight: 'normal' }).fontWeight).toBe('normal'); + expect(convert({ fontWeight: 'lighter' }).fontWeight).toBe('lighter'); + }); +}); + +describe('convertCSSStyleToLightning textAlign', () => { + afterEach(() => vi.restoreAllMocks()); + + it('passes renderer-supported alignments through', () => { + for (const value of ['left', 'center', 'right'] as const) { + expect(convert({ textAlign: value }).textAlign).toBe(value); + } + }); + + it("maps 'auto' to left", () => { + expect(convert({ textAlign: 'auto' }).textAlign).toBe('left'); + }); + + it("warns and falls back to left for 'justify'", () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(convert({ textAlign: 'justify' }).textAlign).toBe('left'); + expect(warn).toHaveBeenCalled(); + }); +}); + +describe('convertCSSStyleToLightning text shadows', () => { + afterEach(() => vi.restoreAllMocks()); + + it('drops the RN text-shadow props and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = convertCSSStyleToLightning({ + textShadowColor: 'red', + textShadowOffset: { width: 1, height: 1 }, + textShadowRadius: 4, + }) as Record; + + expect(result.textShadowColor).toBeUndefined(); + expect(result.textShadowOffset).toBeUndefined(); + expect(result.textShadowRadius).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); + + it('no longer converts shadowColor into a renderer shadow', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = convertCSSStyleToLightning({ + shadowColor: 'red', + }) as Record; + + expect(result.shadowColor).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts b/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts index 6c4673c..f158ace 100644 --- a/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts +++ b/packages/plugin-css-transform/src/convertCSSStyleToLightning.ts @@ -84,6 +84,10 @@ export function convertCSSStyleToLightning( borderWidth, borderColor, shadowColor, + textShadowColor, + textShadowOffset, + textShadowRadius, + textAlign, opacity, overflow, overflowX, @@ -137,9 +141,19 @@ export function convertCSSStyleToLightning( } } - if (shadowColor != null) { - (finalStyle as LightningTextElementStyle).shadowColor = - htmlColorToLightningColor(shadowColor); + // Text shadows are inert: the shipping SDF text renderer has no shadow, and + // RN's textShadow* props were never wired to the Canvas renderer's keys. Drop + // them instead of converting values the renderer ignores. + if ( + shadowColor != null || + textShadowColor != null || + textShadowOffset != null || + textShadowRadius != null + ) { + console.warn( + '[convertCSSStyleToLightning] Text shadows are not supported by the Lightning renderer; dropping shadow styles', + { shadowColor, textShadowColor, textShadowOffset, textShadowRadius }, + ); } if (border != null || borderWidth != null || borderColor != null) { @@ -197,11 +211,33 @@ export function convertCSSStyleToLightning( : Number.parseInt(otherStyles.top, 10); } + // The renderer resolves the full 100-900 scale (and the keyword weights) to + // the nearest font face, but only for numbers — a numeric string like '600' + // falls through to 400. Parse those; pass numbers and keywords through as-is. if (fontWeight != null) { (finalStyle as LightningTextElementStyle).fontWeight = - fontWeight === 'bold' || Number.parseInt(fontWeight.toString(), 10) >= 500 - ? 'bold' - : 'normal'; + typeof fontWeight === 'number' + ? fontWeight + : /^\d+$/.test(fontWeight) + ? Number.parseInt(fontWeight, 10) + : (fontWeight as LightningTextElementStyle['fontWeight']); + } + + if (textAlign != null) { + // The merged style type narrows textAlign to left/center/right, but RN still + // passes auto/justify at runtime, so widen before mapping. + const align = textAlign as string; + + if (align === 'justify') { + console.warn( + '[convertCSSStyleToLightning] textAlign "justify" is not supported; using "left"', + align, + ); + } + + // Renderer only knows left/center/right; auto and justify both resolve left. + (finalStyle as LightningTextElementStyle).textAlign = + align === 'center' || align === 'right' ? align : 'left'; } if (transform != null) { From 65236d631daddbdb1cdeb5125ae57ac14a99df2f Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Wed, 29 Jul 2026 10:42:56 +0200 Subject: [PATCH 15/22] fix(vendor): apply textTransform and honor head/middle ellipsize on Text Case-transform Text children for uppercase/lowercase/capitalize, and fall back head/middle ellipsize to tail since the renderer only truncates at the tail. --- .../text-transform-and-ellipsize-modes.md | 5 +++ .../src/exports/Text.tsx | 27 +++++++----- .../src/utils/applyTextTransform.spec.ts | 30 +++++++++++++ .../src/utils/applyTextTransform.ts | 44 +++++++++++++++++++ .../utils/ellipsizeModeToTextOverflow.spec.ts | 22 ++++++++++ .../src/utils/ellipsizeModeToTextOverflow.ts | 17 +++++++ 6 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 .changeset/text-transform-and-ellipsize-modes.md create mode 100644 packages/react-native-lightning/src/utils/applyTextTransform.spec.ts create mode 100644 packages/react-native-lightning/src/utils/applyTextTransform.ts create mode 100644 packages/react-native-lightning/src/utils/ellipsizeModeToTextOverflow.spec.ts create mode 100644 packages/react-native-lightning/src/utils/ellipsizeModeToTextOverflow.ts diff --git a/.changeset/text-transform-and-ellipsize-modes.md b/.changeset/text-transform-and-ellipsize-modes.md new file mode 100644 index 0000000..6ba2bc7 --- /dev/null +++ b/.changeset/text-transform-and-ellipsize-modes.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-native-lightning': patch +--- + +`Text` applies `textTransform` from its style, and `ellipsizeMode` `head`/`middle` map to a text overflow instead of being dropped — only `clip` and `tail` were handled before. diff --git a/packages/react-native-lightning/src/exports/Text.tsx b/packages/react-native-lightning/src/exports/Text.tsx index e26ce53..d2bb4f8 100644 --- a/packages/react-native-lightning/src/exports/Text.tsx +++ b/packages/react-native-lightning/src/exports/Text.tsx @@ -1,10 +1,14 @@ import { type ForwardRefExoticComponent, forwardRef } from 'react'; import type { Text as RNText, TextProps as RNTextProps } from 'react-native'; - -import type { LightningTextElement, LightningTextElementStyle } from '@plextv/react-lightning'; - +import type { + LightningTextElement, + LightningTextElementStyle, +} from '@plextv/react-lightning'; +import { flattenStyles } from '@plextv/react-lightning-plugin-css-transform'; import { useLayoutHandler } from '../hooks/useLayoutHandler'; import { useTextLayoutHandler } from '../hooks/useTextLayoutHandler'; +import { applyTextTransform } from '../utils/applyTextTransform'; +import { ellipsizeModeToTextOverflow } from '../utils/ellipsizeModeToTextOverflow'; export type TextProps = RNTextProps; @@ -12,7 +16,7 @@ const defaultTextStyle: Partial = { fontWeight: 'normal', }; -export type Text = RNText & LightningTextElement; +export type Text = LightningTextElement & RNText; export const Text: ForwardRefExoticComponent = forwardRef< LightningTextElement, @@ -40,23 +44,24 @@ export const Text: ForwardRefExoticComponent = forwardRef< const overflowStyle: LightningTextElementStyle = { maxLines: numberOfLines, + textOverflow: ellipsizeModeToTextOverflow(ellipsizeMode), }; - if (ellipsizeMode === 'clip') { - overflowStyle.textOverflow = 'clip'; - } else if (ellipsizeMode === 'tail') { - overflowStyle.textOverflow = 'ellipsis'; - } + const { textTransform } = flattenStyles(style); return ( - {children} + {applyTextTransform(children, textTransform)} ); }, diff --git a/packages/react-native-lightning/src/utils/applyTextTransform.spec.ts b/packages/react-native-lightning/src/utils/applyTextTransform.spec.ts new file mode 100644 index 0000000..242ffc5 --- /dev/null +++ b/packages/react-native-lightning/src/utils/applyTextTransform.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { applyTextTransform } from './applyTextTransform'; + +describe('applyTextTransform', () => { + it('uppercases string children', () => { + expect(applyTextTransform('abc', 'uppercase')).toBe('ABC'); + }); + + it('lowercases string children', () => { + expect(applyTextTransform('ABC', 'lowercase')).toBe('abc'); + }); + + it('capitalizes the first letter of each word', () => { + expect(applyTextTransform('hello world', 'capitalize')).toBe('Hello World'); + }); + + it('leaves children unchanged for none/undefined', () => { + expect(applyTextTransform('abc', 'none')).toBe('abc'); + expect(applyTextTransform('abc', undefined)).toBe('abc'); + }); + + it('transforms each string in an array of children', () => { + expect(applyTextTransform(['ab', 'cd'], 'uppercase')).toEqual(['AB', 'CD']); + }); + + it('leaves non-string children (numbers, elements) untouched', () => { + expect(applyTextTransform(5, 'uppercase')).toBe(5); + }); +}); diff --git a/packages/react-native-lightning/src/utils/applyTextTransform.ts b/packages/react-native-lightning/src/utils/applyTextTransform.ts new file mode 100644 index 0000000..b6c5cdf --- /dev/null +++ b/packages/react-native-lightning/src/utils/applyTextTransform.ts @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react'; +import type { TextStyle } from 'react-native'; + +function transformString( + value: string, + transform: TextStyle['textTransform'], +): string { + switch (transform) { + case 'uppercase': + return value.toUpperCase(); + case 'lowercase': + return value.toLowerCase(); + case 'capitalize': + return value.replace( + /(^|\s)(\S)/g, + (_, lead, char) => lead + char.toUpperCase(), + ); + default: + return value; + } +} + +// Only string (or array-of-string) children are transformed; nested element +// children keep their own textTransform, matching the parity baseline. +export function applyTextTransform( + children: ReactNode, + transform: TextStyle['textTransform'], +): ReactNode { + if (transform == null || transform === 'none') { + return children; + } + + if (typeof children === 'string') { + return transformString(children, transform); + } + + if (Array.isArray(children)) { + return children.map((child) => + typeof child === 'string' ? transformString(child, transform) : child, + ); + } + + return children; +} diff --git a/packages/react-native-lightning/src/utils/ellipsizeModeToTextOverflow.spec.ts b/packages/react-native-lightning/src/utils/ellipsizeModeToTextOverflow.spec.ts new file mode 100644 index 0000000..e0b542e --- /dev/null +++ b/packages/react-native-lightning/src/utils/ellipsizeModeToTextOverflow.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { ellipsizeModeToTextOverflow } from './ellipsizeModeToTextOverflow'; + +describe('ellipsizeModeToTextOverflow', () => { + it("maps 'clip' to clip", () => { + expect(ellipsizeModeToTextOverflow('clip')).toBe('clip'); + }); + + it("maps 'tail' to ellipsis", () => { + expect(ellipsizeModeToTextOverflow('tail')).toBe('ellipsis'); + }); + + it("falls back to ellipsis for 'head' and 'middle'", () => { + expect(ellipsizeModeToTextOverflow('head')).toBe('ellipsis'); + expect(ellipsizeModeToTextOverflow('middle')).toBe('ellipsis'); + }); + + it('returns undefined when no mode is given', () => { + expect(ellipsizeModeToTextOverflow(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/react-native-lightning/src/utils/ellipsizeModeToTextOverflow.ts b/packages/react-native-lightning/src/utils/ellipsizeModeToTextOverflow.ts new file mode 100644 index 0000000..b4c6bfb --- /dev/null +++ b/packages/react-native-lightning/src/utils/ellipsizeModeToTextOverflow.ts @@ -0,0 +1,17 @@ +import type { TextProps } from 'react-native'; +import type { LightningTextElementStyle } from '@plextv/react-lightning'; + +export function ellipsizeModeToTextOverflow( + mode: TextProps['ellipsizeMode'], +): LightningTextElementStyle['textOverflow'] { + if (mode === 'clip') { + return 'clip'; + } + + // Renderer has no head/middle truncation, so they fall back to tail ellipsis. + if (mode === 'tail' || mode === 'head' || mode === 'middle') { + return 'ellipsis'; + } + + return undefined; +} From bd754e0ea355b25b7e29ed9c711304c4d5dd2d25 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Wed, 29 Jul 2026 10:56:46 +0200 Subject: [PATCH 16/22] fix(vendor): honor native withRepeat count <= 0 as infinite on both paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-step renderer-loop path only looped for count -1 (so 0 never repeated and other negatives set a nonsensical repeat), and the composed program path treated 0 as zero plays — the opposite of native, where any count <= 0 repeats forever. Unify both to native semantics. --- .changeset/withrepeat-nonpositive-infinite.md | 5 +++ .../src/animation/runAnimationProgram.test.ts | 44 ++++++++++++++++++- .../src/animation/runAnimationProgram.ts | 12 ++--- .../src/exports/withRepeat.test.ts | 37 ++++++++++++++++ .../src/exports/withRepeat.ts | 10 +++-- 5 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 .changeset/withrepeat-nonpositive-infinite.md create mode 100644 packages/plugin-reanimated/src/exports/withRepeat.test.ts diff --git a/.changeset/withrepeat-nonpositive-infinite.md b/.changeset/withrepeat-nonpositive-infinite.md new file mode 100644 index 0000000..a53a5c9 --- /dev/null +++ b/.changeset/withrepeat-nonpositive-infinite.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-plugin-reanimated': patch +--- + +`withRepeat` treats any count <= 0 as infinite on both the renderer-looped and composed paths, matching native reanimated. Only `-1` was infinite before, so a `0` count stopped immediately instead of looping forever. diff --git a/packages/plugin-reanimated/src/animation/runAnimationProgram.test.ts b/packages/plugin-reanimated/src/animation/runAnimationProgram.test.ts index 896d884..d6039f9 100644 --- a/packages/plugin-reanimated/src/animation/runAnimationProgram.test.ts +++ b/packages/plugin-reanimated/src/animation/runAnimationProgram.test.ts @@ -1,7 +1,7 @@ import type { AnimationSettings } from '@lightningjs/renderer'; import { describe, expect, it } from 'vitest'; import type { LightningElement } from '@plextv/react-lightning'; -import { leafProgram, sequenceProgram } from './animationProgram'; +import { leafProgram, repeatProgram, sequenceProgram } from './animationProgram'; import { runAnimationProgram } from './runAnimationProgram'; const settings = ( @@ -95,4 +95,46 @@ describe('runAnimationProgram', () => { // Cancelled before the first leaf resolved, so no further steps run. expect(view.applied.length).toBeLessThanOrEqual(1); }); + + it('repeats a finite positive count that many times', async () => { + const view = makeView(); + + runAnimationProgram( + view as unknown as LightningElement, + 'x', + repeatProgram(leafProgram({ toValue: 1, lngAnimation: settings() }), 3, false), + ); + await flush(); + + expect(view.applied.length).toBe(3); + }); + + it('treats repeat count 0 as infinite, not zero plays (native semantics)', async () => { + const view = makeView(); + + const cancel = runAnimationProgram( + view as unknown as LightningElement, + 'x', + repeatProgram(leafProgram({ toValue: 1, lngAnimation: settings() }), 0, false), + ); + await flush(); + cancel(); + + // count 0 must loop, not play zero times. + expect(view.applied.length).toBeGreaterThan(1); + }); + + it('treats a negative count as infinite', async () => { + const view = makeView(); + + const cancel = runAnimationProgram( + view as unknown as LightningElement, + 'x', + repeatProgram(leafProgram({ toValue: 1, lngAnimation: settings() }), -1, false), + ); + await flush(); + cancel(); + + expect(view.applied.length).toBeGreaterThan(1); + }); }); diff --git a/packages/plugin-reanimated/src/animation/runAnimationProgram.ts b/packages/plugin-reanimated/src/animation/runAnimationProgram.ts index 50b3ff7..c9e3cb1 100644 --- a/packages/plugin-reanimated/src/animation/runAnimationProgram.ts +++ b/packages/plugin-reanimated/src/animation/runAnimationProgram.ts @@ -1,14 +1,15 @@ import type { IAnimationController } from '@lightningjs/renderer'; - -import type { LightningElement, LightningElementStyle } from '@plextv/react-lightning'; - +import type { + LightningElement, + LightningElementStyle, +} from '@plextv/react-lightning'; import type { AnimationProgram, ProgramLeaf } from './animationProgram'; export type CancelAnimation = () => void; // Play a composed program against one node prop: register each step's transition, // animate to its target, wait for the node to report it stopped, then advance. -// Sequences chain, repeats loop (count < 0 = forever). Reverse isn't needed by +// Sequences chain, repeats loop (count <= 0 = forever). Reverse isn't needed by // any current consumer, so it plays forward. export function runAnimationProgram( view: LightningElement, @@ -62,9 +63,10 @@ export function runAnimationProgram( await play(child); } + break; case 'repeat': { - const infinite = node.count < 0; + const infinite = node.count <= 0; for (let i = 0; (infinite || i < node.count) && !cancelled; i++) { await play(node.child); diff --git a/packages/plugin-reanimated/src/exports/withRepeat.test.ts b/packages/plugin-reanimated/src/exports/withRepeat.test.ts new file mode 100644 index 0000000..2dc2a25 --- /dev/null +++ b/packages/plugin-reanimated/src/exports/withRepeat.test.ts @@ -0,0 +1,37 @@ +import type { AnimationSettings } from '@lightningjs/renderer'; +import { describe, expect, it } from 'vitest'; + +import type { AnimatedValue } from '../animation/AnimatedValue'; +import { withRepeat } from './withRepeat'; + +// A plain withTiming value: no composed program, so it takes the single-step +// renderer-loop path. Built as a bare shape to keep the reanimated-original +// alias (pulled in by AnimatedValue's spring import) out of the test env. +const single = () => + ({ program: undefined, lngAnimation: {} as AnimationSettings }) as unknown as AnimatedValue; + +describe('withRepeat (single-step renderer loop)', () => { + it('loops forever for count -1', () => { + expect(withRepeat(single(), -1).lngAnimation.loop).toBe(true); + }); + + it('loops forever for count 0 (native: any count <= 0 is infinite)', () => { + expect(withRepeat(single(), 0).lngAnimation.loop).toBe(true); + }); + + it('loops forever for any negative count', () => { + expect(withRepeat(single(), -5).lngAnimation.loop).toBe(true); + }); + + it('plays a finite positive count without looping', () => { + const a = withRepeat(single(), 3); + + expect(a.lngAnimation.loop).toBe(false); + expect(a.lngAnimation.repeat).toBe(3); + }); + + it('reverse selects the reverse stop method', () => { + expect(withRepeat(single(), -1, true).lngAnimation.stopMethod).toBe('reverse'); + expect(withRepeat(single(), 2, false).lngAnimation.stopMethod).toBe(false); + }); +}); diff --git a/packages/plugin-reanimated/src/exports/withRepeat.ts b/packages/plugin-reanimated/src/exports/withRepeat.ts index e36f4e5..43d2be1 100644 --- a/packages/plugin-reanimated/src/exports/withRepeat.ts +++ b/packages/plugin-reanimated/src/exports/withRepeat.ts @@ -18,9 +18,13 @@ export const withRepeat: WithRepeatFn = ( return animation; } - // Single step: let the renderer loop it directly (cheap, GPU-driven). - animation.lngAnimation.loop = repeatCount === -1; - animation.lngAnimation.repeat = repeatCount; + // Single step: let the renderer loop it directly (cheap, GPU-driven). Native + // repeats forever for any count <= 0. The renderer honors `loop` (infinite); + // finite repeats live on the composed path, so pass 0 when looping. + const infinite = repeatCount <= 0; + + animation.lngAnimation.loop = infinite; + animation.lngAnimation.repeat = infinite ? 0 : repeatCount; animation.lngAnimation.stopMethod = reverse ? 'reverse' : false; return animation; From d02a64f69644d45f9493681540eec861e338ecc2 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Wed, 29 Jul 2026 10:56:53 +0200 Subject: [PATCH 17/22] fix(vendor): restart only the changed keys in useAnimatedStyle setStyles cancelled and restarted every in-flight program on the view on any updater recompute, so a looping pulse reset every time an unrelated shared value (a scroll-linked prop in the same hook) changed. Key the runners by style prop and diff against the new schedule: unchanged keys keep running, only added/removed/changed keys are (re)started. --- .../useanimatedstyle-restart-changed-keys.md | 5 + .../src/animation/animationProgram.test.ts | 53 +++++++ .../src/animation/animationProgram.ts | 98 +++++++++++-- .../src/animation/reconcileRunners.test.ts | 135 ++++++++++++++++++ .../src/animation/reconcileRunners.ts | 49 +++++++ .../src/exports/useAnimatedStyle.ts | 53 ++++--- 6 files changed, 360 insertions(+), 33 deletions(-) create mode 100644 .changeset/useanimatedstyle-restart-changed-keys.md create mode 100644 packages/plugin-reanimated/src/animation/reconcileRunners.test.ts create mode 100644 packages/plugin-reanimated/src/animation/reconcileRunners.ts diff --git a/.changeset/useanimatedstyle-restart-changed-keys.md b/.changeset/useanimatedstyle-restart-changed-keys.md new file mode 100644 index 0000000..3dfcf09 --- /dev/null +++ b/.changeset/useanimatedstyle-restart-changed-keys.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-plugin-reanimated': patch +--- + +`useAnimatedStyle` restarts only the keys whose animation actually changed. Every update previously tore down and restarted all runners, so an unrelated key's animation was restarted mid-flight and visibly jumped. diff --git a/packages/plugin-reanimated/src/animation/animationProgram.test.ts b/packages/plugin-reanimated/src/animation/animationProgram.test.ts index b5a6f54..ffe6d8d 100644 --- a/packages/plugin-reanimated/src/animation/animationProgram.test.ts +++ b/packages/plugin-reanimated/src/animation/animationProgram.test.ts @@ -6,6 +6,7 @@ import { delayProgram, firstLeaf, leafProgram, + programsEqual, repeatProgram, mapProgram, restingValue, @@ -97,3 +98,55 @@ describe('animationProgram', () => { expect(mapped.kind).toBe('sequence'); }); }); + +describe('programsEqual', () => { + it('true for structurally identical trees sharing leaf settings', () => { + const s = settings(); + const build = () => + repeatProgram( + sequenceProgram([ + leafProgram({ toValue: 1, lngAnimation: s }), + leafProgram({ toValue: 0, lngAnimation: s }), + ]), + -1, + true, + ); + + expect(programsEqual(build(), build())).toBe(true); + }); + + it('false when a leaf target differs', () => { + const s = settings(); + + expect( + programsEqual( + leafProgram({ toValue: 1, lngAnimation: s }), + leafProgram({ toValue: 2, lngAnimation: s }), + ), + ).toBe(false); + }); + + it('false when repeat count or reverse differ', () => { + const child = leaf(1); + + expect(programsEqual(repeatProgram(child, -1, false), repeatProgram(child, 2, false))).toBe( + false, + ); + expect(programsEqual(repeatProgram(child, -1, false), repeatProgram(child, -1, true))).toBe( + false, + ); + }); + + it('false for different kinds', () => { + expect(programsEqual(leaf(1), sequenceProgram([leaf(1)]))).toBe(false); + }); + + it('compares leaf settings by identity (a fresh settings object is a change)', () => { + expect( + programsEqual( + leafProgram({ toValue: 1, lngAnimation: settings() }), + leafProgram({ toValue: 1, lngAnimation: settings() }), + ), + ).toBe(false); + }); +}); diff --git a/packages/plugin-reanimated/src/animation/animationProgram.ts b/packages/plugin-reanimated/src/animation/animationProgram.ts index ddd1bae..1cc5036 100644 --- a/packages/plugin-reanimated/src/animation/animationProgram.ts +++ b/packages/plugin-reanimated/src/animation/animationProgram.ts @@ -1,24 +1,29 @@ import type { AnimationSettings } from '@lightningjs/renderer'; import type { AnimatableValue } from 'react-native-reanimated-original'; -export type ProgramLeaf = { +export interface ProgramLeaf { toValue: AnimatableValue; lngAnimation: AnimationSettings; -}; +} // A program is the composition tree for withSequence / withRepeat / withDelay. // A single withTiming/withSpring stays off this path (see AnimatedValue); the // tree only exists once steps are chained. delay folds onto the first leaf. export type AnimationProgram = - | { kind: 'leaf'; leaf: ProgramLeaf } - | { kind: 'sequence'; children: AnimationProgram[] } - | { kind: 'repeat'; child: AnimationProgram; count: number; reverse: boolean }; + { + kind: 'repeat'; + child: AnimationProgram; + count: number; + reverse: boolean; + } | { kind: 'leaf'; leaf: ProgramLeaf } | { kind: 'sequence'; children: AnimationProgram[] }; export function leafProgram(leaf: ProgramLeaf): AnimationProgram { return { kind: 'leaf', leaf }; } -export function sequenceProgram(children: AnimationProgram[]): AnimationProgram { +export function sequenceProgram( + children: AnimationProgram[], +): AnimationProgram { return { kind: 'sequence', children }; } @@ -32,7 +37,10 @@ export function repeatProgram( // Prepend a delay by overriding the first leaf's delay (clones so a cached // lngAnimation, e.g. spring's, is never mutated). -export function delayProgram(program: AnimationProgram, delayMs: number): AnimationProgram { +export function delayProgram( + program: AnimationProgram, + delayMs: number, +): AnimationProgram { switch (program.kind) { case 'leaf': return leafProgram({ @@ -48,8 +56,13 @@ export function delayProgram(program: AnimationProgram, delayMs: number): Animat return sequenceProgram([delayProgram(head, delayMs), ...rest]); } + case 'repeat': - return repeatProgram(delayProgram(program.child, delayMs), program.count, program.reverse); + return repeatProgram( + delayProgram(program.child, delayMs), + program.count, + program.reverse, + ); } } @@ -62,12 +75,15 @@ export function firstLeaf(program: AnimationProgram): ProgramLeaf | undefined { return first ? firstLeaf(first) : undefined; } + case 'repeat': return firstLeaf(program.child); } } -export function restingValue(program: AnimationProgram): AnimatableValue | undefined { +export function restingValue( + program: AnimationProgram, +): AnimatableValue | undefined { switch (program.kind) { case 'leaf': return program.leaf.toValue; @@ -76,11 +92,65 @@ export function restingValue(program: AnimationProgram): AnimatableValue | undef return last ? restingValue(last) : undefined; } + case 'repeat': return restingValue(program.child); } } +// Structural equality of two programs, used to decide whether a running +// animation still matches a freshly computed schedule. Conservative on purpose: +// leaf settings are compared by identity (an unchanged shared value hands back +// the same AnimationSettings ref), so anything uncertain reads as changed and +// gets restarted rather than left stale. +export function programsEqual( + a: AnimationProgram, + b: AnimationProgram, +): boolean { + if (a === b) { + return true; + } + + if (a.kind !== b.kind) { + return false; + } + + switch (a.kind) { + case 'leaf': { + const other = (b as Extract).leaf; + + return ( + Object.is(a.leaf.toValue, other.toValue) && + a.leaf.lngAnimation === other.lngAnimation + ); + } + + case 'sequence': { + const other = (b as Extract) + .children; + + return ( + a.children.length === other.length && + a.children.every((child, i) => { + const otherChild = other[i]; + + return otherChild !== undefined && programsEqual(child, otherChild); + }) + ); + } + + case 'repeat': { + const other = b as Extract; + + return ( + a.count === other.count && + a.reverse === other.reverse && + programsEqual(a.child, other.child) + ); + } + } +} + // Map every leaf target through fn (e.g. translateX px stays as the x value), // keeping the tree shape and each leaf's animation settings. export function mapProgram( @@ -94,8 +164,14 @@ export function mapProgram( lngAnimation: program.leaf.lngAnimation, }); case 'sequence': - return sequenceProgram(program.children.map((child) => mapProgram(child, fn))); + return sequenceProgram( + program.children.map((child) => mapProgram(child, fn)), + ); case 'repeat': - return repeatProgram(mapProgram(program.child, fn), program.count, program.reverse); + return repeatProgram( + mapProgram(program.child, fn), + program.count, + program.reverse, + ); } } diff --git a/packages/plugin-reanimated/src/animation/reconcileRunners.test.ts b/packages/plugin-reanimated/src/animation/reconcileRunners.test.ts new file mode 100644 index 0000000..18c5d0c --- /dev/null +++ b/packages/plugin-reanimated/src/animation/reconcileRunners.test.ts @@ -0,0 +1,135 @@ +import type { AnimationSettings } from '@lightningjs/renderer'; +import { describe, expect, it, vi } from 'vitest'; +import type { LightningElementStyle } from '@plextv/react-lightning'; +import type { ScheduledAnimation } from '../utils/toLightningAnimationAndStyles'; +import { leafProgram, sequenceProgram } from './animationProgram'; +import { type RunningProgram, reconcileRunners } from './reconcileRunners'; + +const settings = (): AnimationSettings => ({ + duration: 100, + easing: 'linear', + delay: 0, + loop: false, + repeat: 0, + stopMethod: false, +}); + +type Prop = keyof LightningElementStyle; + +// A looping pulse held by one shared value: the same program object comes back +// on every recompute. +const pulseSettings = settings(); +const pulse = sequenceProgram([ + leafProgram({ toValue: 1, lngAnimation: pulseSettings }), + leafProgram({ toValue: 0, lngAnimation: pulseSettings }), +]); + +const schedule = ( + prop: Prop, + program: ScheduledAnimation['program'], +): ScheduledAnimation => ({ + prop, + program, +}); + +describe('reconcileRunners', () => { + it('leaves a running program untouched when its key is unchanged', () => { + const pulseCancel = vi.fn(); + const current = new Map([ + ['scaleX', { program: pulse, cancel: pulseCancel }], + ]); + const start = vi.fn(() => vi.fn()); + + const next = reconcileRunners(current, [schedule('scaleX', pulse)], start); + + expect(pulseCancel).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + expect(next.get('scaleX')?.cancel).toBe(pulseCancel); + }); + + it('restarts only the changed key, leaving an unrelated pulse running', () => { + const pulseCancel = vi.fn(); + const scrollCancel = vi.fn(); + const current = new Map([ + ['scaleX', { program: pulse, cancel: pulseCancel }], + [ + 'x', + { + program: leafProgram({ toValue: 0, lngAnimation: settings() }), + cancel: scrollCancel, + }, + ], + ]); + const restartedCancel = vi.fn(); + const start = vi.fn(() => restartedCancel); + + // Same pulse for scaleX, but x moved — mirrors a scroll tick that must not + // reset the pulse. + const next = reconcileRunners( + current, + [ + schedule('scaleX', pulse), + schedule('x', leafProgram({ toValue: 120, lngAnimation: settings() })), + ], + start, + ); + + expect(pulseCancel).not.toHaveBeenCalled(); + expect(scrollCancel).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledTimes(1); + expect(next.get('scaleX')?.cancel).toBe(pulseCancel); + expect(next.get('x')?.cancel).toBe(restartedCancel); + }); + + it('cancels a program whose key disappears from the schedule', () => { + const cancel = vi.fn(); + const current = new Map([ + [ + 'x', + { + program: leafProgram({ toValue: 0, lngAnimation: settings() }), + cancel, + }, + ], + ]); + + const next = reconcileRunners(current, [], vi.fn()); + + expect(cancel).toHaveBeenCalledTimes(1); + expect(next.size).toBe(0); + }); + + it('restarts a program whose leaf settings ref changed (never leaves it stale)', () => { + const oldCancel = vi.fn(); + // Same target and tree shape, but a fresh settings object — must count as + // changed so a real update never keeps playing the stale program. + const before = leafProgram({ toValue: 1, lngAnimation: settings() }); + const after = leafProgram({ toValue: 1, lngAnimation: settings() }); + const current = new Map([ + ['scaleX', { program: before, cancel: oldCancel }], + ]); + const restarted = vi.fn(); + const start = vi.fn(() => restarted); + + const next = reconcileRunners(current, [schedule('scaleX', after)], start); + + expect(oldCancel).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledTimes(1); + expect(next.get('scaleX')?.cancel).toBe(restarted); + }); + + it('starts a program for a newly scheduled key', () => { + const started = vi.fn(); + const start = vi.fn(() => started); + const program = leafProgram({ toValue: 1, lngAnimation: settings() }); + + const next = reconcileRunners( + new Map(), + [schedule('scaleY', program)], + start, + ); + + expect(start).toHaveBeenCalledTimes(1); + expect(next.get('scaleY')?.cancel).toBe(started); + }); +}); diff --git a/packages/plugin-reanimated/src/animation/reconcileRunners.ts b/packages/plugin-reanimated/src/animation/reconcileRunners.ts new file mode 100644 index 0000000..c2e9845 --- /dev/null +++ b/packages/plugin-reanimated/src/animation/reconcileRunners.ts @@ -0,0 +1,49 @@ +import type { LightningElementStyle } from '@plextv/react-lightning'; +import type { ScheduledAnimation } from '../utils/toLightningAnimationAndStyles'; +import { type AnimationProgram, programsEqual } from './animationProgram'; +import type { CancelAnimation } from './runAnimationProgram'; + +type PropKey = keyof LightningElementStyle; + +export interface RunningProgram { + program: AnimationProgram; + cancel: CancelAnimation; +} + +// Reconcile the programs playing on a view against a freshly computed schedule +// set. A key whose program is unchanged keeps its runner; only added, removed, +// or changed keys are (re)started. Restarting every program on any dependency +// change resets unrelated loops (a pulse restarting on every scroll tick). +export function reconcileRunners( + running: Map, + schedules: ScheduledAnimation[], + start: (schedule: ScheduledAnimation) => CancelAnimation, +): Map { + const next = new Map(); + const scheduled = new Set(); + + for (const schedule of schedules) { + scheduled.add(schedule.prop); + + const existing = running.get(schedule.prop); + + if (existing && programsEqual(existing.program, schedule.program)) { + next.set(schedule.prop, existing); + continue; + } + + existing?.cancel(); + next.set(schedule.prop, { + program: schedule.program, + cancel: start(schedule), + }); + } + + for (const [prop, run] of running) { + if (!scheduled.has(prop)) { + run.cancel(); + } + } + + return next; +} diff --git a/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts b/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts index fe0d09c..d40a0d5 100644 --- a/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts +++ b/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts @@ -1,16 +1,18 @@ import type { DependencyList } from 'react'; import { useEffect, useRef, useState } from 'react'; -import type { useAnimatedStyle as useAnimatedStyleRN } from 'react-native-reanimated-original'; import type { Mutable } from 'react-native-reanimated/lib/typescript/commonTypes'; import type { DefaultStyle } from 'react-native-reanimated/lib/typescript/hook/commonTypes'; - +import type { useAnimatedStyle as useAnimatedStyleRN } from 'react-native-reanimated-original'; import { PARTIAL_STYLE } from '@plextv/react-lightning'; -import type { LightningElement, LightningElementStyle } from '@plextv/react-lightning'; - +import type { + LightningElement, + LightningElementStyle, +} from '@plextv/react-lightning'; import { - type CancelAnimation, - runAnimationProgram, -} from '../animation/runAnimationProgram'; + type RunningProgram, + reconcileRunners, +} from '../animation/reconcileRunners'; +import { runAnimationProgram } from '../animation/runAnimationProgram'; import type { AnimatedObject } from '../types/AnimatedObject'; import type { AnimatedStyle } from '../types/AnimatedStyle'; import { @@ -19,9 +21,14 @@ import { } from '../utils/toLightningAnimationAndStyles'; import { useTrackedReaction } from './useTrackedReaction'; -type UseAnimatedStyleFn = (...args: Parameters) => AnimatedStyle; +type UseAnimatedStyleFn = ( + ...args: Parameters +) => AnimatedStyle; -type Runners = WeakMap; +type Runners = WeakMap< + LightningElement, + Map +>; function setStyles( view: LightningElement, @@ -30,11 +37,6 @@ function setStyles( schedules: ScheduledAnimation[], runners: Runners, ): void { - // Cancel any program still playing on this view before re-applying, so a - // reset (e.g. a shared value set back to a static value) stops the old one. - runners.get(view)?.forEach((cancel) => cancel()); - runners.delete(view); - // Animated styles only carry the keys the updater computed; mark them so // the flexbox plugin doesn't reset the element's other flex props. (style as Record)[PARTIAL_STYLE] = true; @@ -47,13 +49,18 @@ function setStyles( style: style as LightningElementStyle, }); - if (schedules.length) { - runners.set( - view, - schedules.map((schedule) => - runAnimationProgram(view, schedule.prop, schedule.program), - ), - ); + // Only (re)start programs whose key changed; unchanged loops keep running and + // a removed key (e.g. a reset to a static value) is cancelled. + const next = reconcileRunners( + runners.get(view) ?? new Map(), + schedules, + (schedule) => runAnimationProgram(view, schedule.prop, schedule.program), + ); + + if (next.size) { + runners.set(view, next); + } else { + runners.delete(view); } } @@ -69,7 +76,8 @@ function applyComputedStyle( lastApplied: { current: AppliedStyles }, runners: Runners, ): void { - const { transition, style, schedules } = toLightningAnimationAndStyles(computedStyle); + const { transition, style, schedules } = + toLightningAnimationAndStyles(computedStyle); lastApplied.current = { transition, style, schedules }; @@ -132,6 +140,7 @@ export const useAnimatedStyle: UseAnimatedStyleFn = (updater, dependencies) => { (dep as Mutable).removeListener(id); } } + applyStyles(); }; }, [autoTrack, inputs, applyStyles]); From a791ee8351f90c012693b1066bd0f199314175a9 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Wed, 29 Jul 2026 11:06:56 +0200 Subject: [PATCH 18/22] fix(vendor): report per-target geometry in ResizeObserver shim Bind a per-target layout handler so each ResizeObserverEntry carries its own target's rect, and deliver only the target that actually changed instead of a batch of every observed target. --- .../resizeobserver-per-target-geometry.md | 5 + .../src/shim/resizeObserverShim.spec.ts | 172 ++++++++++++++++++ .../src/shim/resizeObserverShim.ts | 94 ++++++---- 3 files changed, 236 insertions(+), 35 deletions(-) create mode 100644 .changeset/resizeobserver-per-target-geometry.md create mode 100644 packages/react-lightning/src/shim/resizeObserverShim.spec.ts diff --git a/.changeset/resizeobserver-per-target-geometry.md b/.changeset/resizeobserver-per-target-geometry.md new file mode 100644 index 0000000..87751d1 --- /dev/null +++ b/.changeset/resizeobserver-per-target-geometry.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning': patch +--- + +The ResizeObserver shim reports each entry against its own target's rect. A single shared layout handler meant every observed element was handed the last-laid-out element's geometry, and the handler couldn't be removed per target on `unobserve`/`disconnect`. diff --git a/packages/react-lightning/src/shim/resizeObserverShim.spec.ts b/packages/react-lightning/src/shim/resizeObserverShim.spec.ts new file mode 100644 index 0000000..fd6d460 --- /dev/null +++ b/packages/react-lightning/src/shim/resizeObserverShim.spec.ts @@ -0,0 +1,172 @@ +import { beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; + +import type { Rect } from '../types'; + +// Minimal EventEmitter-backed stand-in so the shim's `instanceof +// LightningViewElement` guard and `on/off/emit('layout')` wiring resolve. +vi.mock('../element/LightningViewElement', () => { + class LightningViewElement { + private _handlers = new Map void>>(); + + public on(event: string, handler: (rect: Rect) => void): () => void { + let set = this._handlers.get(event); + if (!set) { + this._handlers.set(event, (set = new Set())); + } + set.add(handler); + return () => this.off(event, handler); + } + + public off(event: string, handler: (rect: Rect) => void): void { + this._handlers.get(event)?.delete(handler); + } + + public emit(event: string, rect: Rect): void { + this._handlers.get(event)?.forEach((handler) => handler(rect)); + } + + public handlerCount(event: string): number { + return this._handlers.get(event)?.size ?? 0; + } + } + + return { LightningViewElement }; +}); + +type LayoutTarget = { + emit(event: string, rect: Rect): void; + handlerCount(event: string): number; +}; + +let LightningViewElementMock: new () => LayoutTarget; +let ResizeObserverShim: typeof window.ResizeObserver; + +beforeAll(async () => { + class ResizeObserverBase { + public constructor(_callback: ResizeObserverCallback) {} + public observe(): void {} + public unobserve(): void {} + public disconnect(): void {} + } + + class DOMRectReadOnlyStub { + public constructor( + public x: number, + public y: number, + public width: number, + public height: number, + ) {} + } + + vi.stubGlobal('window', { ResizeObserver: ResizeObserverBase }); + vi.stubGlobal('ResizeObserver', ResizeObserverBase); + vi.stubGlobal('DOMRectReadOnly', DOMRectReadOnlyStub); + + await import('./resizeObserverShim'); + ResizeObserverShim = window.ResizeObserver; + + ({ LightningViewElement: LightningViewElementMock } = (await import( + '../element/LightningViewElement' + )) as unknown as { LightningViewElement: new () => LayoutTarget }); +}); + +function makeTarget(): LayoutTarget { + return new LightningViewElementMock(); +} + +describe('LightningResizeObserver', () => { + let callback: Mock; + let observer: ResizeObserver; + + beforeEach(() => { + callback = vi.fn(); + observer = new ResizeObserverShim(callback); + }); + + function entriesFor(call: number): ResizeObserverEntry[] { + const args = callback.mock.calls[call]; + if (!args) { + throw new Error(`callback was not invoked ${call + 1} time(s)`); + } + return args[0]; + } + + function soleEntry(entries: ResizeObserverEntry[]): ResizeObserverEntry { + const [entry] = entries; + if (!entry) { + throw new Error('expected at least one entry'); + } + return entry; + } + + it('delivers only the changed target, with its own rect', () => { + const a = makeTarget(); + const b = makeTarget(); + const c = makeTarget(); + + observer.observe(a as unknown as Element); + observer.observe(b as unknown as Element); + observer.observe(c as unknown as Element); + + b.emit('layout', { x: 1, y: 2, w: 3, h: 4 }); + + expect(callback).toHaveBeenCalledTimes(1); + const entries = entriesFor(0); + expect(entries).toHaveLength(1); + expect(entries.some((e) => e.target === (a as unknown as Element))).toBe(false); + const entry = soleEntry(entries); + expect(entry.target).toBe(b); + expect(entry.contentRect).toMatchObject({ x: 1, y: 2, width: 3, height: 4 }); + expect(entry.borderBoxSize[0]).toMatchObject({ inlineSize: 3, blockSize: 4 }); + expect(entry.contentBoxSize[0]).toMatchObject({ inlineSize: 3, blockSize: 4 }); + }); + + it('reports each target with its own rect, not a stale firing target rect', () => { + const a = makeTarget(); + const b = makeTarget(); + + observer.observe(a as unknown as Element); + observer.observe(b as unknown as Element); + + a.emit('layout', { x: 10, y: 20, w: 100, h: 200 }); + b.emit('layout', { x: 5, y: 6, w: 7, h: 8 }); + + const first = soleEntry(entriesFor(0)); + const second = soleEntry(entriesFor(1)); + + expect(first.target).toBe(a); + expect(first.contentRect).toMatchObject({ x: 10, y: 20, width: 100, height: 200 }); + expect(second.target).toBe(b); + expect(second.contentRect).toMatchObject({ x: 5, y: 6, width: 7, height: 8 }); + }); + + it('stops firing for an unobserved target and removes its layout handler', () => { + const a = makeTarget(); + + observer.observe(a as unknown as Element); + expect(a.handlerCount('layout')).toBe(1); + + observer.unobserve(a as unknown as Element); + expect(a.handlerCount('layout')).toBe(0); + + a.emit('layout', { x: 0, y: 0, w: 1, h: 1 }); + expect(callback).not.toHaveBeenCalled(); + }); + + it('disconnect removes every target layout handler', () => { + const a = makeTarget(); + const b = makeTarget(); + + observer.observe(a as unknown as Element); + observer.observe(b as unknown as Element); + + observer.disconnect(); + + expect(a.handlerCount('layout')).toBe(0); + expect(b.handlerCount('layout')).toBe(0); + + a.emit('layout', { x: 0, y: 0, w: 1, h: 1 }); + b.emit('layout', { x: 0, y: 0, w: 1, h: 1 }); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-lightning/src/shim/resizeObserverShim.ts b/packages/react-lightning/src/shim/resizeObserverShim.ts index c273430..28bc287 100644 --- a/packages/react-lightning/src/shim/resizeObserverShim.ts +++ b/packages/react-lightning/src/shim/resizeObserverShim.ts @@ -3,7 +3,10 @@ import type { Rect } from '../types'; class LightningResizeObserver extends window.ResizeObserver { private _callback: ResizeObserverCallback; - private _targets: Set = new Set(); + // Per-target layout handler so each entry reports its own target's rect and + // the handler ref stays removable on unobserve/disconnect. + private _handlers = + new Map void>(); public constructor(callback: ResizeObserverCallback) { super(callback); @@ -11,10 +14,19 @@ class LightningResizeObserver extends window.ResizeObserver { this._callback = callback; } - public override observe(target: Element, options?: ResizeObserverOptions | undefined): void { + public override observe( + target: Element, + options?: ResizeObserverOptions | undefined, + ): void { if (target instanceof LightningViewElement) { - this._targets.add(target); - target.on('layout', this._fireCallbacks); + if (this._handlers.has(target)) { + return; + } + + const handler = (dimensions: Rect): void => + this._fire(target, dimensions); + this._handlers.set(target, handler); + target.on('layout', handler); return; } @@ -24,8 +36,11 @@ class LightningResizeObserver extends window.ResizeObserver { public override unobserve(target: Element): void { if (target instanceof LightningViewElement) { - this._targets.delete(target); - target.off('layout', this._fireCallbacks); + const handler = this._handlers.get(target); + if (handler) { + target.off('layout', handler); + this._handlers.delete(target); + } return; } @@ -34,39 +49,48 @@ class LightningResizeObserver extends window.ResizeObserver { } public override disconnect(): void { - this._targets.forEach(this.unobserve.bind(this)); + for (const [target, handler] of this._handlers) { + target.off('layout', handler); + } + + this._handlers.clear(); super.disconnect(); } - private _fireCallbacks = (dimensions: Rect): void => { - const entries = Array.from(this._targets).map((target) => { - return { - borderBoxSize: [ - { - blockSize: dimensions.h, - inlineSize: dimensions.w, - }, - ], - contentBoxSize: [ - { - blockSize: dimensions.h, - inlineSize: dimensions.w, - }, - ], - devicePixelContentBoxSize: [ - { - blockSize: dimensions.h, - inlineSize: dimensions.w, - }, - ], - contentRect: new DOMRectReadOnly(dimensions.x, dimensions.y, dimensions.w, dimensions.h), - target: target as unknown as Element, - }; - }); - - this._callback(entries, this); - }; + // Deliver only the target that actually changed, with its own rect — the shim + // has no frame boundary to coalesce multiple targets into one batch. + private _fire(target: LightningViewElement, dimensions: Rect): void { + const entry: ResizeObserverEntry = { + borderBoxSize: [ + { + blockSize: dimensions.h, + inlineSize: dimensions.w, + }, + ], + contentBoxSize: [ + { + blockSize: dimensions.h, + inlineSize: dimensions.w, + }, + ], + devicePixelContentBoxSize: [ + { + blockSize: dimensions.h, + inlineSize: dimensions.w, + }, + ], + contentRect: new DOMRectReadOnly( + dimensions.x, + dimensions.y, + dimensions.w, + dimensions.h, + ), + target: target as unknown as Element, + }; + + this._callback([entry], this); + } } window.ResizeObserver = LightningResizeObserver; From 8dcb06aae872a3c4a565ca8f29361d18a7275b8e Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Wed, 29 Jul 2026 14:34:40 +0200 Subject: [PATCH 19/22] fix(vendor): keep center-aligned focus targets from snapping to the header/footer edge --- .changeset/center-focus-target-header-edge.md | 5 +++ .../resolveFocusScrollTarget.spec.ts | 45 +++++++++++++++++++ .../VirtualList/resolveFocusScrollTarget.ts | 22 +++++++-- 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 .changeset/center-focus-target-header-edge.md diff --git a/.changeset/center-focus-target-header-edge.md b/.changeset/center-focus-target-header-edge.md new file mode 100644 index 0000000..6a00ed5 --- /dev/null +++ b/.changeset/center-focus-target-header-edge.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +A center-aligned focus target stays mid-viewport instead of snapping to the header/footer edge. The edge protection left rows near a header or footer stuck at the top or bottom; center placement now wins (tvOS parity), and the downstream clamp still bounds the resulting target. 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 98bbe93..e65a6ac 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.spec.ts @@ -81,6 +81,51 @@ describe('resolveFocusScrollTarget', () => { ).toBe(5000); }); + it('centers a near-header row instead of snapping it to the header edge', () => { + // Discover/Trending: a tall hero header with center-aligned sections below + // it. tvOS/AndroidTV center the focused section even when that scrolls the + // header partly off-screen. The header edge-snap (which keeps a small real + // header fully visible for start/end alignment) must not override a center + // target, or early sections settle at the top and never re-center. + const viewportSize = 1080; + const childOffset = 720; + const childSize = 500; + + expect( + resolveFocusScrollTarget({ + viewportSize, + snapToAlignment: 'center', + paddingStart: 48, + paddingEnd: 48, + headerSize: 700, + footerSize: 0, + maxScroll: 5000, + childOffset, + childSize, + }), + ).toBe(childOffset + childSize / 2 - viewportSize / 2); + }); + + it('centers a near-footer row instead of snapping it to the footer edge', () => { + const viewportSize = 1080; + const childOffset = 4700; + const childSize = 500; + + expect( + resolveFocusScrollTarget({ + viewportSize, + snapToAlignment: 'center', + paddingStart: 48, + paddingEnd: 48, + headerSize: 0, + footerSize: 700, + maxScroll: 5000, + childOffset, + childSize, + }), + ).toBe(childOffset + childSize / 2 - viewportSize / 2); + }); + it('centers when asked', () => { expect( resolveFocusScrollTarget({ diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts index e1b7794..e0700da 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveFocusScrollTarget.ts @@ -4,7 +4,7 @@ export interface FocusScrollTargetParams { /** Focused child's main-axis size. */ childSize: number; viewportSize: number; - snapToAlignment: 'start' | 'center' | 'end'; + snapToAlignment: 'center' | 'end' | 'start'; /** * Per-item pixel offset (`scrollSnapOffset`): land the child's leading edge * at this viewport coordinate. Wins over `snapToAlignment`. @@ -53,10 +53,18 @@ export function resolveFocusScrollTarget({ } else { switch (snapToAlignment) { case 'center': - target = childOffset + childSize / 2 - viewportSize / 2 + (snapToItemPadding ?? 0) / 2; + target = + childOffset + + childSize / 2 - + viewportSize / 2 + + (snapToItemPadding ?? 0) / 2; break; case 'end': - target = childOffset + childSize - viewportSize + (snapToItemPadding ?? paddingEnd); + target = + childOffset + + childSize - + viewportSize + + (snapToItemPadding ?? paddingEnd); break; default: target = childOffset - (snapToItemPadding ?? paddingStart); @@ -64,6 +72,14 @@ export function resolveFocusScrollTarget({ } } + // Center wins over the edge protection: a centered row is meant to sit + // mid-viewport even when that scrolls a real header/footer partly off (tvOS + // parity). Snapping it to the edge instead leaves near-header/footer rows + // stuck at the top/bottom; downstream clamp still bounds the target. + if (snapOffset === undefined && snapToAlignment === 'center') { + return target; + } + if (target > 0 && target <= headerSize) { return 0; } From ffd0939c62ddaa98f9c210c2ee890b565245762a Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Wed, 29 Jul 2026 15:24:36 +0200 Subject: [PATCH 20/22] fix(vendor): surface momentum scroll callbacks from VirtualList VirtualList now fires onMomentumScrollBegin/onMomentumScrollEnd once around a focus-driven animated scroll (begin on start, end on settle or cancel), threaded through a balance tracker so the pair can never go unbalanced. The reanimated useAnimatedScrollHandler shim routes the onMomentumBegin/onMomentumEnd handler keys by event name instead of only ever calling onScroll. --- .changeset/momentum-scroll-callbacks.md | 6 ++ .../dispatchAnimatedScrollEvent.test.ts | 89 +++++++++++++++++++ .../exports/dispatchAnimatedScrollEvent.ts | 51 +++++++++++ .../src/exports/useAnimatedScrollHandler.tsx | 29 +++--- .../components/VirtualList/VirtualList.tsx | 4 + .../VirtualList/VirtualListTypes.ts | 4 + .../VirtualList/createMomentumTracker.test.ts | 89 +++++++++++++++++++ .../VirtualList/createMomentumTracker.ts | 45 ++++++++++ .../VirtualList/useScrollHandler.ts | 48 +++++++--- 9 files changed, 336 insertions(+), 29 deletions(-) create mode 100644 .changeset/momentum-scroll-callbacks.md create mode 100644 packages/plugin-reanimated/src/exports/dispatchAnimatedScrollEvent.test.ts create mode 100644 packages/plugin-reanimated/src/exports/dispatchAnimatedScrollEvent.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/createMomentumTracker.test.ts create mode 100644 packages/react-lightning-components/src/components/VirtualList/createMomentumTracker.ts diff --git a/.changeset/momentum-scroll-callbacks.md b/.changeset/momentum-scroll-callbacks.md new file mode 100644 index 0000000..6b24519 --- /dev/null +++ b/.changeset/momentum-scroll-callbacks.md @@ -0,0 +1,6 @@ +--- +'@plextv/react-lightning-components': patch +'@plextv/react-lightning-plugin-reanimated': patch +--- + +Surface momentum scroll callbacks. VirtualList now fires `onMomentumScrollBegin`/`onMomentumScrollEnd` once around a focus-driven animated scroll — begin when it starts, end when it settles or is cancelled — mirroring tvOS/AndroidTV. The reanimated `useAnimatedScrollHandler` shim now routes the `onMomentumBegin`/`onMomentumEnd` handler keys by event name instead of only ever calling `onScroll`. Consumers that key off momentum (fast-scroll detection, jump-bar auto-hide) can react to the real end of a scroll instead of an idle timeout. diff --git a/packages/plugin-reanimated/src/exports/dispatchAnimatedScrollEvent.test.ts b/packages/plugin-reanimated/src/exports/dispatchAnimatedScrollEvent.test.ts new file mode 100644 index 0000000..6adc787 --- /dev/null +++ b/packages/plugin-reanimated/src/exports/dispatchAnimatedScrollEvent.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { type ScrollHandlers, dispatchAnimatedScrollEvent } from './dispatchAnimatedScrollEvent'; + +const scrollEvent = (offset: number) => ({ + nativeEvent: { contentOffset: { x: 0, y: offset } }, +}); + +describe('dispatchAnimatedScrollEvent', () => { + it('routes a plain scroll to onScroll', () => { + const handlers: ScrollHandlers = { + onScroll: vi.fn(), + onMomentumBegin: vi.fn(), + onMomentumEnd: vi.fn(), + }; + + dispatchAnimatedScrollEvent(handlers, scrollEvent(10), {}); + + expect(handlers.onScroll).toHaveBeenCalledTimes(1); + expect(handlers.onMomentumBegin).not.toHaveBeenCalled(); + expect(handlers.onMomentumEnd).not.toHaveBeenCalled(); + }); + + it('routes onMomentumScrollBegin to onMomentumBegin, not onScroll', () => { + const handlers: ScrollHandlers = { + onScroll: vi.fn(), + onMomentumBegin: vi.fn(), + onMomentumEnd: vi.fn(), + }; + + dispatchAnimatedScrollEvent( + handlers, + { ...scrollEvent(0), eventName: 'onMomentumScrollBegin' }, + {}, + ); + + expect(handlers.onMomentumBegin).toHaveBeenCalledTimes(1); + expect(handlers.onScroll).not.toHaveBeenCalled(); + expect(handlers.onMomentumEnd).not.toHaveBeenCalled(); + }); + + it('routes onMomentumScrollEnd to onMomentumEnd, not onScroll', () => { + const handlers: ScrollHandlers = { + onScroll: vi.fn(), + onMomentumBegin: vi.fn(), + onMomentumEnd: vi.fn(), + }; + + dispatchAnimatedScrollEvent( + handlers, + { ...scrollEvent(120), eventName: 'onMomentumScrollEnd' }, + {}, + ); + + expect(handlers.onMomentumEnd).toHaveBeenCalledTimes(1); + expect(handlers.onScroll).not.toHaveBeenCalled(); + expect(handlers.onMomentumBegin).not.toHaveBeenCalled(); + }); + + it('tags the dispatched event with its RN event name', () => { + const onMomentumEnd = vi.fn(); + + dispatchAnimatedScrollEvent( + { onMomentumEnd }, + { ...scrollEvent(120), eventName: 'onMomentumScrollEnd' }, + {}, + ); + + expect(onMomentumEnd).toHaveBeenCalledWith( + expect.objectContaining({ + eventName: 'onMomentumScrollEnd', + contentOffset: { x: 0, y: 120 }, + }), + {}, + ); + }); + + it('drops momentum for the function (onScroll-only) form', () => { + const onScroll = vi.fn(); + + dispatchAnimatedScrollEvent( + onScroll, + { ...scrollEvent(0), eventName: 'onMomentumScrollBegin' }, + {}, + ); + + expect(onScroll).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-reanimated/src/exports/dispatchAnimatedScrollEvent.ts b/packages/plugin-reanimated/src/exports/dispatchAnimatedScrollEvent.ts new file mode 100644 index 0000000..d609c1e --- /dev/null +++ b/packages/plugin-reanimated/src/exports/dispatchAnimatedScrollEvent.ts @@ -0,0 +1,51 @@ +// oxlint-disable typescript/no-explicit-any -- mirrors reanimated's loosely-typed scroll handlers + +type ScrollHandlerFn = (event: any, context: any) => void; + +export interface ScrollHandlers { + onScroll?: ScrollHandlerFn; + onBeginDrag?: ScrollHandlerFn; + onEndDrag?: ScrollHandlerFn; + onMomentumBegin?: ScrollHandlerFn; + onMomentumEnd?: ScrollHandlerFn; +} + +export interface WrappedScrollEvent { + nativeEvent: Record; + /** RN ScrollView event name; defaults to a plain scroll. */ + eventName?: string; +} + +// Route a wrapped scroll event to the matching reanimated handler key. Lightning +// only emits scroll + focus-driven momentum (no touch drag on TV), so the begin/ +// end-drag keys are never reached here. +export function dispatchAnimatedScrollEvent( + scrollHandlers: ScrollHandlers | ScrollHandlerFn, + event: WrappedScrollEvent, + context: unknown, +): void { + const eventName = event.eventName ?? 'onScroll'; + const reanimatedEvent = { eventName, ...event.nativeEvent }; + + // Function form is the onScroll-only shorthand in reanimated's API. + if (typeof scrollHandlers === 'function') { + if (eventName === 'onScroll') { + scrollHandlers(reanimatedEvent, context); + } + + return; + } + + if (!scrollHandlers) { + return; + } + + const handler = + eventName === 'onMomentumScrollBegin' + ? scrollHandlers.onMomentumBegin + : eventName === 'onMomentumScrollEnd' + ? scrollHandlers.onMomentumEnd + : scrollHandlers.onScroll; + + handler?.(reanimatedEvent, context); +} diff --git a/packages/plugin-reanimated/src/exports/useAnimatedScrollHandler.tsx b/packages/plugin-reanimated/src/exports/useAnimatedScrollHandler.tsx index 61dd57e..8f75598 100644 --- a/packages/plugin-reanimated/src/exports/useAnimatedScrollHandler.tsx +++ b/packages/plugin-reanimated/src/exports/useAnimatedScrollHandler.tsx @@ -4,6 +4,12 @@ import type { useAnimatedScrollHandler as useAnimatedScrollHandlerRN, } from 'react-native-reanimated-original'; +import { + type ScrollHandlers, + type WrappedScrollEvent, + dispatchAnimatedScrollEvent, +} from './dispatchAnimatedScrollEvent'; + type UseAnimatedScrollHandlerFn = ( ...args: Parameters ) => ScrollHandlerProcessed; @@ -21,21 +27,12 @@ export const useAnimatedScrollHandler: UseAnimatedScrollHandlerFn = ( const contextRef = useRef({}); return useCallback((event) => { - const context = contextRef.current; - // Only allow onScroll event - const reanimatedEvent = { - eventName: 'onScroll', - ...event.nativeEvent, - }; - - if (typeof scrollHandlers === 'function') { - scrollHandlers(reanimatedEvent, context); - - return; - } - - if (scrollHandlers && typeof scrollHandlers.onScroll === 'function') { - scrollHandlers.onScroll(reanimatedEvent, context); - } + // The FlashList.lng mapping tags momentum events with an `eventName`; a + // plain scroll has none and defaults to onScroll. + dispatchAnimatedScrollEvent( + scrollHandlers as ScrollHandlers, + event as unknown as WrappedScrollEvent, + contextRef.current, + ); }, inputs); }; diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index 195b305..363250c 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -134,6 +134,8 @@ function VirtualListInner( onEndReached, onEndReachedThreshold = 0.5, onScroll, + onMomentumScrollBegin, + onMomentumScrollEnd, onViewableItemsChanged, viewabilityConfig, onLoad, @@ -436,6 +438,8 @@ function VirtualListInner( initialScrollOffset, onAnimationStart: handleAnimationStart, onAnimationEnd: handleAnimationEnd, + onMomentumScrollBegin, + onMomentumScrollEnd, }); // Backstop for non-focus scrolls (touch/wheel/imperative scrollToOffset). diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts index 3ecc099..77d0a10 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualListTypes.ts @@ -125,6 +125,10 @@ export interface VirtualListProps { onEndReachedThreshold?: number | null; /** Called on every scroll position change. */ onScroll?: (event: ScrollEvent) => void; + /** Called once when a focus-driven animated scroll begins (tvOS/AndroidTV momentum parity). */ + onMomentumScrollBegin?: (event: ScrollEvent) => void; + /** Called once when the animated scroll settles or is cancelled. */ + onMomentumScrollEnd?: (event: ScrollEvent) => void; /** Called when viewable items change. */ onViewableItemsChanged?: | ((info: { diff --git a/packages/react-lightning-components/src/components/VirtualList/createMomentumTracker.test.ts b/packages/react-lightning-components/src/components/VirtualList/createMomentumTracker.test.ts new file mode 100644 index 0000000..daee455 --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/createMomentumTracker.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { type MomentumCallbacks, createMomentumTracker } from './createMomentumTracker'; +import type { ScrollEvent } from './VirtualListTypes'; + +const evt = (offset: number): ScrollEvent => ({ + contentInset: { top: 0, left: 0, bottom: 0, right: 0 }, + contentOffset: { x: 0, y: offset }, + contentSize: { width: 0, height: 0 }, + layoutMeasurement: { width: 0, height: 0 }, +}); + +const setup = () => { + const onMomentumScrollBegin = vi.fn(); + const onMomentumScrollEnd = vi.fn(); + const callbacks: { current: MomentumCallbacks } = { + current: { onMomentumScrollBegin, onMomentumScrollEnd }, + }; + + return { + tracker: createMomentumTracker(callbacks), + onMomentumScrollBegin, + onMomentumScrollEnd, + }; +}; + +describe('createMomentumTracker', () => { + it('fires begin once on start and end once on settle', () => { + const { tracker, onMomentumScrollBegin, onMomentumScrollEnd } = setup(); + + tracker.start(evt(0)); + tracker.settle(evt(120)); + + expect(onMomentumScrollBegin).toHaveBeenCalledTimes(1); + expect(onMomentumScrollEnd).toHaveBeenCalledTimes(1); + expect(onMomentumScrollEnd).toHaveBeenCalledWith(evt(120)); + }); + + it('fires end once when an in-flight animation is cancelled', () => { + const { tracker, onMomentumScrollBegin, onMomentumScrollEnd } = setup(); + + tracker.start(evt(0)); + tracker.cancel(evt(40)); + + expect(onMomentumScrollBegin).toHaveBeenCalledTimes(1); + expect(onMomentumScrollEnd).toHaveBeenCalledTimes(1); + }); + + it('never emits an unbalanced end (settle/cancel before any start)', () => { + const { tracker, onMomentumScrollEnd } = setup(); + + tracker.settle(evt(0)); + tracker.cancel(evt(0)); + + expect(onMomentumScrollEnd).not.toHaveBeenCalled(); + }); + + it('does not double-fire begin while already animating', () => { + const { tracker, onMomentumScrollBegin } = setup(); + + tracker.start(evt(0)); + tracker.start(evt(10)); + + expect(onMomentumScrollBegin).toHaveBeenCalledTimes(1); + }); + + it('does not double-fire end after it has already settled', () => { + const { tracker, onMomentumScrollEnd } = setup(); + + tracker.start(evt(0)); + tracker.settle(evt(120)); + tracker.settle(evt(120)); + tracker.cancel(evt(120)); + + expect(onMomentumScrollEnd).toHaveBeenCalledTimes(1); + }); + + it('starts a fresh begin/end cycle after settling', () => { + const { tracker, onMomentumScrollBegin, onMomentumScrollEnd } = setup(); + + tracker.start(evt(0)); + tracker.settle(evt(120)); + tracker.start(evt(200)); + tracker.settle(evt(320)); + + expect(onMomentumScrollBegin).toHaveBeenCalledTimes(2); + expect(onMomentumScrollEnd).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/react-lightning-components/src/components/VirtualList/createMomentumTracker.ts b/packages/react-lightning-components/src/components/VirtualList/createMomentumTracker.ts new file mode 100644 index 0000000..26112fc --- /dev/null +++ b/packages/react-lightning-components/src/components/VirtualList/createMomentumTracker.ts @@ -0,0 +1,45 @@ +import type { ScrollEvent } from './VirtualListTypes'; + +export interface MomentumCallbacks { + onMomentumScrollBegin?: (event: ScrollEvent) => void; + onMomentumScrollEnd?: (event: ScrollEvent) => void; +} + +export interface MomentumTracker { + /** Fire onMomentumScrollBegin once when an animated scroll starts. */ + start: (event: ScrollEvent) => void; + /** Fire onMomentumScrollEnd once when the animation settles. */ + settle: (event: ScrollEvent) => void; + /** Fire onMomentumScrollEnd once when an in-flight animation is cancelled. */ + cancel: (event: ScrollEvent) => void; +} + +// Guards the momentum begin/end pair so consumers see a balanced lifecycle: +// begin fires once per animated scroll, end fires exactly once (on settle OR +// cancel), and an end never escapes without a matching begin. A missed or +// unbalanced end is how fast-scroll mode sticks on after the scroll has stopped. +export function createMomentumTracker(callbacks: { current: MomentumCallbacks }): MomentumTracker { + let animating = false; + + const finish = (event: ScrollEvent): void => { + if (!animating) { + return; + } + + animating = false; + callbacks.current.onMomentumScrollEnd?.(event); + }; + + return { + start(event) { + if (animating) { + return; + } + + animating = true; + callbacks.current.onMomentumScrollBegin?.(event); + }, + settle: finish, + cancel: finish, + }; +} diff --git a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts index 0892c55..fdff925 100644 --- a/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts +++ b/packages/react-lightning-components/src/components/VirtualList/useScrollHandler.ts @@ -2,21 +2,20 @@ import { type RefObject, useEffect, useRef, useState } from 'react'; import type { LightningElement } from '@plextv/react-lightning'; +import { type MomentumCallbacks, createMomentumTracker } from './createMomentumTracker'; import type { LayoutManager } from './LayoutManager'; -import type { ScrollEvent } from './VirtualListTypes'; - -import { createCriticalSpring } from './scrollSpring'; import { reconcileScrollBounds } from './reconcileScrollBounds'; import { resolveChildSnapTarget } from './resolveChildSnapAlignment'; import { resolveFocusScrollTarget } from './resolveFocusScrollTarget'; +import { createCriticalSpring } from './scrollSpring'; +import type { ScrollEvent } from './VirtualListTypes'; // Lightning Magic Remote / mouse support (in the host app) installs this hook // while a pointer is driving focus. Read it off globalThis so this subtree stays // free of app imports; undefined (a no-op) on every platform that never loads it. const isPointerFocusScrollSuppressed = (): boolean => { - const fn = ( - globalThis as { __plexShouldSuppressPointerFocusScroll?: () => boolean } - ).__plexShouldSuppressPointerFocusScroll; + const fn = (globalThis as { __plexShouldSuppressPointerFocusScroll?: () => boolean }) + .__plexShouldSuppressPointerFocusScroll; return typeof fn === 'function' && fn(); }; @@ -64,6 +63,10 @@ export interface UseScrollHandlerOptions { onAnimationStart?: () => void; /** Fires once when the in-flight animation ends or `resetScroll` cancels it; VL drains the batch. */ onAnimationEnd?: () => void; + /** Momentum-scroll parity: begin once when a focus-driven scroll animates. */ + onMomentumScrollBegin?: (event: ScrollEvent) => void; + /** Momentum-scroll parity: end once when the animated scroll settles or is cancelled. */ + onMomentumScrollEnd?: (event: ScrollEvent) => void; } export interface UseScrollHandlerResult { @@ -106,8 +109,22 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan initialScrollOffset = 0, onAnimationStart, onAnimationEnd, + onMomentumScrollBegin, + onMomentumScrollEnd, } = options; + // Keep the tracker stable across renders (owns the begin/end balance) while + // reading the latest consumer callbacks off a ref updated post-commit — the + // momentum props fire async from the animation loop, never during render. + const momentumCallbacksRef = useRef({}); + + useEffect(() => { + momentumCallbacksRef.current.onMomentumScrollBegin = onMomentumScrollBegin; + momentumCallbacksRef.current.onMomentumScrollEnd = onMomentumScrollEnd; + }, [onMomentumScrollBegin, onMomentumScrollEnd]); + + const [momentum] = useState(() => createMomentumTracker(momentumCallbacksRef)); + const contentRef = useRef(null); const scrollOffsetRef = useRef(initialScrollOffset); const endReachedRef = useRef(false); @@ -182,7 +199,9 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan if (animated && animationDuration > 0) { const thisAnimId = ++animationIdRef.current; - if (!isAnimatingRef.current) { + const justStarted = !isAnimatingRef.current; + + if (justStarted) { isAnimatingRef.current = true; onAnimationStart?.(); } @@ -198,12 +217,15 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan animTargetRef.current = offset; + if (justStarted) { + momentum.start(makeScrollEvent(clamp(from))); + } + // Every animated scroll runs the tvOS spring. A press pumps in the // normalized initial velocity on top of whatever the in-flight // animation carries (UIKit's additive begin-from-current-state), so // chained moves keep their momentum and glide out on the spring tail. - const v0 = - scrollVelocityRef.current + (SPRING_INITIAL_VELOCITY * distance) / 1000; + const v0 = scrollVelocityRef.current + (SPRING_INITIAL_VELOCITY * distance) / 1000; const spring = createCriticalSpring(-distance, v0, SPRING_OMEGA); lastTickRef.current = { pos: from, time: startTime }; @@ -218,8 +240,7 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan const t = now - startTime; const pos = spring.position(t); const done = - (Math.abs(pos) < SPRING_SETTLE_PX && - Math.abs(spring.velocity(t)) < 0.01) || + (Math.abs(pos) < SPRING_SETTLE_PX && Math.abs(spring.velocity(t)) < 0.01) || t >= SPRING_MAX_DURATION_MS; const current = done ? offset : offset + pos; @@ -247,6 +268,7 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan animTargetRef.current = null; isAnimatingRef.current = false; onAnimationEnd?.(); + momentum.settle(makeScrollEvent(clamp(offset))); } }; @@ -347,8 +369,7 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan const childSize = horizontal ? child.node.w : child.node.h; const headerSize = itemAreaOffset - paddingStart; - const footerSize = - totalContentSize - itemAreaOffset - layoutManager.totalSize - paddingEnd; + 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'). @@ -389,6 +410,7 @@ export function useScrollHandler(options: UseScrollHandlerOptions): UseScrollHan if (isAnimatingRef.current) { isAnimatingRef.current = false; onAnimationEnd?.(); + momentum.cancel(makeScrollEvent(offset)); } // Apply directly to lightning so the next paint reflects the new From bac826c71601493ea924cba09a24bf6c09bb6fe9 Mon Sep 17 00:00:00 2001 From: Ruud Date: Wed, 12 Aug 2026 14:03:16 +0200 Subject: [PATCH 21/22] fix(vendor): tighten compat layer types and drop a react-lightning src deep import --- .../compat-layer-types-and-snap-alignment.md | 7 +++++ .../src/exports/ActivityIndicator.tsx | 4 ++- .../src/exports/Pressable.tsx | 4 +-- .../src/exports/ScrollView.tsx | 7 +++-- .../src/exports/View.tsx | 30 ++++++++++++------- .../src/exports/scrollFocus.ts | 4 ++- 6 files changed, 39 insertions(+), 17 deletions(-) create mode 100644 .changeset/compat-layer-types-and-snap-alignment.md diff --git a/.changeset/compat-layer-types-and-snap-alignment.md b/.changeset/compat-layer-types-and-snap-alignment.md new file mode 100644 index 0000000..cca371c --- /dev/null +++ b/.changeset/compat-layer-types-and-snap-alignment.md @@ -0,0 +1,7 @@ +--- +'@plextv/react-native-lightning': patch +--- + +Typing fixes across the react-native compat layer exports: `View`'s `onFocusCapture` accepts either the RN or the Lightning handler (intersecting them produced a signature nothing could satisfy), `Pressable` tolerates null `onFocus`/`onBlur`, and `ActivityIndicator` handles the asset-id vs URL mismatch on its image source. `LightningViewElementStyle` comes from the package entry instead of a `src/` deep path. + +`usesExplicitAlignment` also narrows its argument, so `snapToAlignment: 'item'` falls back to `'start'` in the scroll-into-view math rather than being passed through to alignment code that doesn't implement it. diff --git a/packages/react-native-lightning/src/exports/ActivityIndicator.tsx b/packages/react-native-lightning/src/exports/ActivityIndicator.tsx index 5be777e..c70a7e4 100644 --- a/packages/react-native-lightning/src/exports/ActivityIndicator.tsx +++ b/packages/react-native-lightning/src/exports/ActivityIndicator.tsx @@ -45,7 +45,9 @@ export const ActivityIndicator: ForwardRefExoticComponent = focusable< ) { const [state, setState] = useState({ focused: false, pressed: false }); - const forwardFocus = useFocusHandler(onFocus); - const forwardBlur = useBlurHandler(onBlur); + const forwardFocus = useFocusHandler(onFocus ?? undefined); + const forwardBlur = useBlurHandler(onBlur ?? undefined); const handleLayout = useLayoutHandler(onLayout); // RN's Pressable exposes `focused` to its function children; mirror that by diff --git a/packages/react-native-lightning/src/exports/ScrollView.tsx b/packages/react-native-lightning/src/exports/ScrollView.tsx index 19a76c3..de5ec0b 100644 --- a/packages/react-native-lightning/src/exports/ScrollView.tsx +++ b/packages/react-native-lightning/src/exports/ScrollView.tsx @@ -19,9 +19,9 @@ import { type LightningElement, LightningViewElement, type LightningViewElementProps, + type LightningViewElementStyle, useCombinedRef, } from '@plextv/react-lightning'; -import type { LightningViewElementStyle } from "@plextv/react-lightning/src/types/types"; import { createHandler } from '../hooks/useFocusHandler'; import type { NativeLightningViewElement } from '../types/NativeLightningViewElement'; import { createNativeSyntheticEvent } from '../utils/createNativeSyntheticEvent'; @@ -339,7 +339,10 @@ class ScrollViewBase extends PureComponent { // If we're getting offset via a positional value, we make sure we don't // use the snapToAlignment to calculate the offset since the offset should // already be taken into account. - isElement ? this.props.snapToAlignment : 'start', + // 'item' (paging) has no alignment math, so it falls back to 'start'. + isElement && usesExplicitAlignment(this.props.snapToAlignment) + ? this.props.snapToAlignment + : 'start', this.props.horizontal, ); }; diff --git a/packages/react-native-lightning/src/exports/View.tsx b/packages/react-native-lightning/src/exports/View.tsx index c6c0c5a..19ddde3 100644 --- a/packages/react-native-lightning/src/exports/View.tsx +++ b/packages/react-native-lightning/src/exports/View.tsx @@ -16,15 +16,23 @@ import { useLayoutHandler } from '../hooks/useLayoutHandler'; import type { NativeLightningViewElement } from '../types/NativeLightningViewElement'; import { isFocusActive, shouldRegisterFocus } from './focusableView'; -type CombinedProps = RNViewProps & - LightningViewElementProps & - RefAttributes & - Omit & - FocusableProps & { - // Directional-only focus catcher: reachable by a deliberate move but never - // by restoration or the mount-time default (drawer edge guard). - focusRestorationExcluded?: boolean; - }; +type CombinedProps = Omit< + RNViewProps & + LightningViewElementProps & + RefAttributes & + Omit & + FocusableProps, + 'onFocusCapture' +> & { + // RN and Lightning both declare onFocusCapture (RN event vs element), and + // intersecting them yields a signature no handler can satisfy. Accept either. + onFocusCapture?: + | FocusableProps['onFocusCapture'] + | RNViewProps['onFocusCapture']; + // Directional-only focus catcher: reachable by a deliberate move but never + // by restoration or the mount-time default (drawer edge guard). + focusRestorationExcluded?: boolean; +}; export type ViewProps = Omit & { style?: AllStyleProps & RNViewProps['style']; @@ -55,7 +63,7 @@ const FocusableView = forwardRef( }); const combinedRef = useCombinedRef(ref, focusRef); - return ; + return ; }, ); @@ -74,7 +82,7 @@ export const View: ForwardRefExoticComponent = forwardRef< return ; } - return ; + return ; }); View.displayName = 'View'; diff --git a/packages/react-native-lightning/src/exports/scrollFocus.ts b/packages/react-native-lightning/src/exports/scrollFocus.ts index c37f7ac..42c94ee 100644 --- a/packages/react-native-lightning/src/exports/scrollFocus.ts +++ b/packages/react-native-lightning/src/exports/scrollFocus.ts @@ -4,7 +4,9 @@ * 'item' (paging) aren't real focus targets — and 'item' isn't even implemented * by the alignment math — so they should fall through to ensure-visible. */ -export function usesExplicitAlignment(snapToAlignment: string | null | undefined): boolean { +export function usesExplicitAlignment( + snapToAlignment: string | null | undefined, +): snapToAlignment is 'center' | 'end' { return snapToAlignment === 'center' || snapToAlignment === 'end'; } From ecfdc2fa32fd52a852041bb95b71344fbdec82f0 Mon Sep 17 00:00:00 2001 From: Ruud Date: Thu, 16 Jul 2026 20:01:16 +0200 Subject: [PATCH 22/22] fix(vendor): virtuallist skips the default-size guess in onlayout (grid remount loop) --- .../virtuallist-no-estimate-onlayout.md | 5 ++++ .../components/VirtualList/VirtualList.tsx | 16 ++++++++-- .../VirtualList/resolveCrossSize.spec.ts | 29 +++++++++++++++---- .../VirtualList/resolveCrossSize.ts | 19 ++++++++---- 4 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 .changeset/virtuallist-no-estimate-onlayout.md diff --git a/.changeset/virtuallist-no-estimate-onlayout.md b/.changeset/virtuallist-no-estimate-onlayout.md new file mode 100644 index 0000000..8105536 --- /dev/null +++ b/.changeset/virtuallist-no-estimate-onlayout.md @@ -0,0 +1,5 @@ +--- +'@plextv/react-lightning-components': patch +--- + +VirtualList no longer reports the DEFAULT_ITEM_SIZE guess through onLayout. Callers that derive layout from that width (Grid computes numColumns and re-keys the list) would remount the list on every guess-measure-guess cycle, an infinite loop when the list mounts without a laid-out size (e.g. inside a hidden Activity). resolveCrossSize now flags the fallback as an estimate and the onLayout effect skips it. diff --git a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx index 363250c..4f6035f 100644 --- a/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx +++ b/packages/react-lightning-components/src/components/VirtualList/VirtualList.tsx @@ -244,8 +244,11 @@ function VirtualListInner( : parentCellBounds?.width; const measuredOuterCross = horizontal ? measuredSize.h : measuredSize.w; - const { viewportCrossSize, isDefinite: crossSizeIsDefinite } = - resolveCrossSize({ + const { + viewportCrossSize, + isDefinite: crossSizeIsDefinite, + isEstimate: crossSizeIsEstimate, + } = resolveCrossSize({ horizontal, explicitCross, parentCross, @@ -765,6 +768,13 @@ function VirtualListInner( return; } + // Never report the default-size guess: callers key layout off this width + // (Grid derives numColumns and re-keys the list), so reporting 200 before + // anything measured remounts the list in a loop. + if (crossSizeIsEstimate) { + return; + } + const contentW = horizontal ? totalContentSize : viewportCrossSize; const contentH = horizontal ? viewportCrossSize : totalContentSize; const prev = prevLayoutRef.current; @@ -773,7 +783,7 @@ function VirtualListInner( prevLayoutRef.current = { w: contentW, h: contentH }; onLayout({ w: contentW, h: contentH }); } - }, [onLayout, horizontal, totalContentSize, viewportCrossSize]); + }, [onLayout, horizontal, totalContentSize, viewportCrossSize, crossSizeIsEstimate]); const outerStyle: LightningViewElementStyle = { ...resolveOuterFlex(horizontal), diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts index 2fbd315..886016e 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.spec.ts @@ -20,7 +20,7 @@ describe('resolveCrossSize', () => { parentCross: 300, }); - expect(result).toEqual({ viewportCrossSize: 400, isDefinite: true }); + expect(result).toEqual({ viewportCrossSize: 400, isDefinite: true, isEstimate: false }); }); it('uses parent cell bounds for a vertical list and marks it definite', () => { @@ -30,7 +30,7 @@ describe('resolveCrossSize', () => { measuredOuterCross: 280, }); - expect(result).toEqual({ viewportCrossSize: 320, isDefinite: true }); + expect(result).toEqual({ viewportCrossSize: 320, isDefinite: true, isEstimate: false }); }); it('uses the measured outer size for a vertical list and marks it definite', () => { @@ -40,7 +40,7 @@ describe('resolveCrossSize', () => { maxContentCross: 120, }); - expect(result).toEqual({ viewportCrossSize: 280, isDefinite: true }); + expect(result).toEqual({ viewportCrossSize: 280, isDefinite: true, isEstimate: false }); }); it('ignores parent/measured cross for a horizontal list in favor of content', () => { @@ -53,7 +53,7 @@ describe('resolveCrossSize', () => { crossPadding: 10, }); - expect(result).toEqual({ viewportCrossSize: 190, isDefinite: false }); + expect(result).toEqual({ viewportCrossSize: 190, isDefinite: false, isEstimate: false }); }); it('ignores parent cross for a horizontal list and falls back to the default', () => { @@ -69,6 +69,7 @@ describe('resolveCrossSize', () => { expect(result).toEqual({ viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false, + isEstimate: true, }); }); @@ -82,6 +83,7 @@ describe('resolveCrossSize', () => { expect(result).toEqual({ viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false, + isEstimate: true, }); }); @@ -91,6 +93,7 @@ describe('resolveCrossSize', () => { expect(result).toEqual({ viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false, + isEstimate: true, }); }); @@ -101,6 +104,22 @@ describe('resolveCrossSize', () => { parentCross: 320, }); - expect(result).toEqual({ viewportCrossSize: 320, isDefinite: true }); + expect(result).toEqual({ viewportCrossSize: 320, isDefinite: true, isEstimate: false }); + }); +}); + +describe('isEstimate', () => { + it('flags the default-size fallback as an estimate', () => { + const result = resolveCrossSize({ ...base }); + + expect(result.viewportCrossSize).toBe(DEFAULT_ITEM_SIZE); + expect(result.isEstimate).toBe(true); + }); + + it('does not flag explicit, parent, measured, or content-derived sizes', () => { + expect(resolveCrossSize({ ...base, explicitCross: 400 }).isEstimate).toBe(false); + expect(resolveCrossSize({ ...base, parentCross: 320 }).isEstimate).toBe(false); + expect(resolveCrossSize({ ...base, measuredOuterCross: 280 }).isEstimate).toBe(false); + expect(resolveCrossSize({ ...base, maxContentCross: 120 }).isEstimate).toBe(false); }); }); diff --git a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts index a540aa8..31f4a81 100644 --- a/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts +++ b/packages/react-lightning-components/src/components/VirtualList/resolveCrossSize.ts @@ -23,6 +23,12 @@ export interface ResolvedCrossSize { * content gets a chance to report its real size. */ isDefinite: boolean; + /** + * True only for the DEFAULT_ITEM_SIZE fallback, when nothing (style, + * parent, measure, or content) has informed the size yet. Callers must not + * report or persist this value; it is a placeholder until a real measure. + */ + isEstimate: boolean; } /** @@ -46,33 +52,34 @@ export function resolveCrossSize({ crossPadding, }: ResolveCrossSizeInput): ResolvedCrossSize { if (explicitCross != null && explicitCross > 0) { - return { viewportCrossSize: explicitCross, isDefinite: true }; + return { viewportCrossSize: explicitCross, isDefinite: true, isEstimate: false }; } if (!horizontal && parentCross != null && parentCross > 0) { - return { viewportCrossSize: parentCross, isDefinite: true }; + return { viewportCrossSize: parentCross, isDefinite: true, isEstimate: false }; } if (!horizontal && measuredOuterCross > 0) { - return { viewportCrossSize: measuredOuterCross, isDefinite: true }; + return { viewportCrossSize: measuredOuterCross, isDefinite: true, isEstimate: false }; } if (maxContentCross > 0) { return { viewportCrossSize: maxContentCross + crossPadding, isDefinite: false, + isEstimate: false, }; } // Horizontal cross must not come from parent/self measurement: both equal the // outer VL cell height (header + this list), so it ratchets unbounded. if (!horizontal && parentCross != null && parentCross > 0) { - return { viewportCrossSize: parentCross, isDefinite: false }; + return { viewportCrossSize: parentCross, isDefinite: false, isEstimate: false }; } if (!horizontal && measuredOuterCross > 0) { - return { viewportCrossSize: measuredOuterCross, isDefinite: false }; + return { viewportCrossSize: measuredOuterCross, isDefinite: false, isEstimate: false }; } - return { viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false }; + return { viewportCrossSize: DEFAULT_ITEM_SIZE, isDefinite: false, isEstimate: true }; }