Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/activity-hide-releases-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning': patch
---

React-hidden trees (Activity mode="hidden", suspended Suspense content) now set display: none instead of only alpha: 0, so they release their layout space like react-dom. The hide is sticky across later style pushes (a full style update used to reset display and pop the space back in while invisible), and unhide restores display and opacity from the current props.
6 changes: 6 additions & 0 deletions .changeset/classname-style-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@plextv/react-native-lightning': patch
'@plextv/react-lightning-plugin-flexbox': patch
---

Keep className-derived styles on style-only updates. Update payloads omit an unchanged className, so its resolved styles vanished from the style object and the flexbox plugin reset those props (a column screen re-laid out as a row after toggling display). The className plugin now remembers what it resolved per instance. Also: a dropped flexDirection resets to column (the node creation default, not the CSS row default), and addChildNode without an index appends at the parent's child count instead of index 0.
5 changes: 5 additions & 0 deletions .changeset/derived-value-withtiming-ticks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-plugin-reanimated': patch
---

Make `useDerivedValue(() => withTiming(...))` expose the live number reanimated does. It stored the raw animation descriptor, so reading it back for arithmetic (or as a style value) got an object, not a number. Now the derived value ticks from its current value toward the target on the JS thread each frame (timing and spring share the path, since a spring bakes its physics into the easing), so reads stay numeric and styles still animate. Composed programs (`withSequence`/`withRepeat`/`withDelay`) snap to their resting target.
5 changes: 5 additions & 0 deletions .changeset/focus-destination-stale-ref-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning': patch
---

Skip focus destinations that are unfocusable or no longer registered instead of aborting the whole focus move. A destination can hold a stale ref (a recycled list cell that unmounted after setDestinations); the redirect now falls through to the next destination or to normal child focus, matching native TVFocusGuideView, which drops invalid node handles.
6 changes: 6 additions & 0 deletions .changeset/focus-events-bubble.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@plextv/react-lightning': patch
'@plextv/react-lightning-components': patch
---

Bubble focus/blur events up the element tree. tvOS and web deliver focus events to ancestor views (a wrapper View's onFocus fires when a focused descendant changes), but the focus path only reaches registered focus nodes, so handlers on plain wrappers never fired. FocusManager now walks the focused leaf's element ancestry on every leaf change and delivers the event to elements that didn't just fire their own focus()/blur(). VirtualList forwards onFocus/onBlur to its outer element so handlers spread onto a list participate like they do on a native FlashList host view.
5 changes: 5 additions & 0 deletions .changeset/grid-focus-cross-axis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-components': patch
---

Resolve the focused VirtualList index with the cross axis. handleVLFocus mapped the focused child back to an item via its main-axis offset alone, which in a multi-column grid identifies only the row: findIndexAtOffset returned the row's first index, the shouldFocus claim then pulled focus to column 0. New LayoutManager.findIndexAt(offset, crossOffset) walks the row's entries and picks the column containing the cross position, so D-pad Down/Up land on the item directly below/above.
5 changes: 5 additions & 0 deletions .changeset/pixelratio-scene-truth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-native-lightning': patch
---

Report PixelRatio 1 instead of window.devicePixelRatio. The Lightning canvas backing store equals the configured scene size, so one layout pixel is one canvas pixel; inheriting react-native-web's devicePixelRatio made image sizing fetch 2x assets on retina displays that the renderer downscales anyway: 4x the texture memory for no visible gain, enough to blow the texture manager's cleanup budget and evict visible tiles.
5 changes: 5 additions & 0 deletions .changeset/text-color-and-refold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning': patch
---

Two text-element fixes. A colorless Text mounted transparent: the mount-time color 0 stamp (meant for views, where the renderer default is white) ran before the text element's white default, so any Text without an explicit style color was invisible. Text elements are now excluded from the stamp. And a text fragment nested more than one level deep (Text > Text > string) only re-folded its direct parent on update; the text setter now propagates the re-fold through every aggregating ancestor.
5 changes: 5 additions & 0 deletions .changeset/virtual-list-viewport-flex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-components': patch
---

Give a vertical VirtualList's outer element a zero flex basis. With the default auto basis, the content plane's explicit height became the flex basis of every ancestor, and one grow-only ancestor (no flexShrink) locked the whole chain at content height (tens of thousands of px). A virtualized list never sizes from its content.
5 changes: 5 additions & 0 deletions .changeset/virtuallist-native-snap-props.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-components': patch
---

VirtualList understands the native snap props: `snapToAlignment`/`scrollSnapAlign` resolve per child, `scrollSnapOffset` shifts the snap point, and `snapToItemPadding` pads the snapped item against the viewport edge.
5 changes: 5 additions & 0 deletions .changeset/virtuallist-no-react-compiler-memo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-components': patch
---

Keep the React Compiler from caching VirtualList's mutable layout reads. Layout state lives in the LayoutManager and every change (data updates, measurements, reveal re-checks) signals through a bare version-bump state that the compiler's dependency sets never see, so the memoized visible range replayed stale and data arriving after mount never mounted any cells (the footer offset had the same staleness). The render-phase reads now live in two `'use no memo'` hooks (`useVisibleRange`, `useLayoutTotalSize`) whose fresh results downstream scopes key on; the rest of the component stays compiled.
5 changes: 5 additions & 0 deletions .changeset/virtuallist-reveal-backstop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning-components': patch
---

Back-stop the VirtualList reveal gate for cells that never measure. A cell that is visible with an estimated size but has not measured yet (async content still pending) returned `Infinity` from the gate, so no re-check timer was scheduled and every cell below it stayed hidden until an unrelated commit woke the list. Such a cell now counts down the same max window a churning cell already uses, so the rows below it can't be stranded invisibly.
5 changes: 5 additions & 0 deletions .changeset/zindex-stacking-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@plextv/react-lightning': patch
---

Contain zIndex to its RN parent under view flattening. A flattened wrapper hoisted zIndex-carrying children to the nearest real ancestor, letting the index compete against unrelated subtrees (a nav bar's zIndex: 2 outranked modal screens mounted after it). A non-zero zIndex/zIndexLocked now materializes the flattened parent so the index only sorts among its real RN siblings, on attach and on every zIndex write path (setNodeProp, batched props, fast-path styles).
93 changes: 93 additions & 0 deletions packages/plugin-flexbox/src/YogaManager.displayToggle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { YogaManager } from './YogaManager';
import { SimpleDataView } from './util/SimpleDataView';

function decode(buffer: ArrayBuffer) {
const view = new SimpleDataView(buffer);
const out: Record<number, { x: number; y: number; w: number; h: number }> =
{};

while (view.offset < buffer.byteLength) {
const id = view.readUint32();
const x = view.readInt32();
const y = view.readInt32();
const w = view.readInt32();
const h = view.readInt32();

out[id] = { x, y, w, h };
}

return out;
}

// Mimics the navigator Screen: a container with no explicit flexDirection
// (yoga node default = column) holding a header and a flex:1 content view.
async function setup() {
const m = new YogaManager();
await m.init();

m.addNode(1);
m.applyStyle(1, { display: 'flex', w: 800, h: 1000 }, true);
m.addIndependentRoot(1);

const screenStyle = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flex: 1,
} as const;

m.addNode(2);
m.applyStyle(2, { ...screenStyle }, true, true);
m.addChildNode(1, 2);

m.addNode(3);
m.applyStyle(3, { h: 112 }, true, true);
m.addChildNode(2, 3);

m.addNode(4);
m.applyStyle(4, { flex: 1 }, true, true);
m.addChildNode(2, 4);

let layout: ReturnType<typeof decode> = {};
m.on('render', (buf) => {
layout = { ...layout, ...decode(buf) };
});

return { m, screenStyle, layout: () => layout };
}

describe('display none -> flex round trip', () => {
it('keeps default column direction after toggling display', async () => {
const { m, screenStyle, layout } = await setup();

m.flushLayout();
expect(layout()[4]).toMatchObject({ x: 0, y: 112 });

m.applyStyle(2, { ...screenStyle, display: 'none' }, true, true);
m.flushLayout();

m.applyStyle(2, { ...screenStyle, display: 'flex' }, true, true);
m.flushLayout();

expect(layout()[4]).toMatchObject({ x: 0, y: 112 });
});

it('resets a dropped flexDirection back to column (RN default)', async () => {
const { m, screenStyle, layout } = await setup();

m.applyStyle(2, { ...screenStyle, flexDirection: 'column' }, true, true);
m.flushLayout();
expect(layout()[4]).toMatchObject({ x: 0, y: 112 });

// Full-style apply without flexDirection: reset should restore the RN
// default (column), not yoga's web default (row).
m.applyStyle(2, { ...screenStyle }, true, true);
m.flushLayout();

expect(layout()[4]).toMatchObject({ x: 0, y: 112 });
});
});
2 changes: 1 addition & 1 deletion packages/plugin-flexbox/src/YogaManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ export class YogaManager {
return;
}

index ??= childYogaNode.children.length;
index ??= parentYogaNode.children.length;

parentYogaNode.node.insertChild(childYogaNode.node, index);
parentYogaNode.children.splice(index, 0, childYogaNode);
Expand Down
4 changes: 3 additions & 1 deletion packages/plugin-flexbox/src/util/applyReactPropsToYoga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,9 @@ function resetFlexPropToDefault(yoga: Yoga, node: Node, prop: FlexProps): void {
node.setDisplay(mapDisplay(yoga));
return;
case 'flexDirection':
node.setFlexDirection(mapDirection(yoga));
// Fresh yoga nodes are RN-default column (useWebDefaults is off), so a
// dropped flexDirection lands back on column, not the CSS default row.
node.setFlexDirection(yoga.FLEX_DIRECTION_COLUMN);
return;
case 'alignItems':
node.setAlignItems(mapAlignItems(yoga));
Expand Down
71 changes: 71 additions & 0 deletions packages/plugin-reanimated/src/animation/sampleAnimation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { AnimationSettings } from '@lightningjs/renderer';
import { describe, expect, it } from 'vitest';

import { sampleAnimation } from './sampleAnimation';

const settings = (
overrides: Partial<AnimationSettings>,
): AnimationSettings => ({
duration: 100,
easing: 'linear',
delay: 0,
loop: false,
repeat: 0,
stopMethod: false,
...overrides,
});

describe('sampleAnimation', () => {
it('holds the start value before the delay elapses', () => {
const r = sampleAnimation(10, 100, 40, settings({ delay: 50 }));

expect(r).toEqual({ value: 10, done: false });
});

it('settles on the target and reports done once the duration passes', () => {
expect(sampleAnimation(0, 100, 100, settings({}))).toEqual({
value: 100,
done: true,
});
expect(sampleAnimation(0, 100, 250, settings({}))).toEqual({
value: 100,
done: true,
});
});

it('interpolates linearly across the duration', () => {
expect(sampleAnimation(0, 100, 50, settings({})).value).toBe(50);
});

it('offsets progress by the delay', () => {
// (150 - 100 delay) / 100 duration = 0.5
expect(sampleAnimation(0, 100, 150, settings({ delay: 100 })).value).toBe(
50,
);
});

it('uses a baked progress function (springs and custom timings)', () => {
const easing = (progress: number) => progress * progress;
// 0.5^2 = 0.25 -> 25
expect(
sampleAnimation(0, 100, 50, settings({ easing })).value,
).toBeCloseTo(25, 5);
});

it('maps the ease-out string the renderer emits for instant springs', () => {
// 1 - (1 - 0.5)^2 = 0.75 -> 75
expect(
sampleAnimation(0, 100, 50, settings({ easing: 'ease-out' })).value,
).toBeCloseTo(75, 5);
});

it('falls back to linear for an unknown easing string', () => {
expect(
sampleAnimation(0, 100, 50, settings({ easing: 'wobble' as never })).value,
).toBe(50);
});

it('animates downward targets too', () => {
expect(sampleAnimation(100, 0, 50, settings({})).value).toBe(50);
});
});
52 changes: 52 additions & 0 deletions packages/plugin-reanimated/src/animation/sampleAnimation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { AnimationSettings } from '@lightningjs/renderer';

function easeInOut(progress: number): number {
return progress < 0.5
? 2 * progress * progress
: 1 - (2 - 2 * progress) ** 2 / 2;
}

// Renderer easings arrive as a progress function (custom timings and springs bake one) or a
// string; map the strings the renderer emits, fall back to linear.
const STRING_EASINGS: Record<string, (progress: number) => number> = {
linear: (progress) => progress,
ease: easeInOut,
'ease-in': (progress) => progress * progress,
'ease-out': (progress) => 1 - (1 - progress) * (1 - progress),
'ease-in-out': easeInOut,
};

function toEasingFn(
easing: AnimationSettings['easing'],
): (progress: number) => number {
if (typeof easing === 'function') {
return easing as (progress: number) => number;
}

return STRING_EASINGS[easing as string] ?? ((progress) => progress);
}

// Sample a leaf timing/spring at elapsedMs (a spring bakes its physics into `easing`, so both
// share this path). Done once the duration elapses so the driver can settle and stop.
export function sampleAnimation(
from: number,
to: number,
elapsedMs: number,
settings: AnimationSettings,
): { value: number; done: boolean } {
const delay = settings.delay ?? 0;
const duration = settings.duration ?? 0;
const elapsed = elapsedMs - delay;

if (elapsed <= 0) {
return { value: from, done: false };
}

if (duration <= 0 || elapsed >= duration) {
return { value: to, done: true };
}

const eased = toEasingFn(settings.easing)(elapsed / duration);

return { value: from + (to - from) * eased, done: false };
}
Loading
Loading